From f4abeb10328178d4685cc9788c296516e6b186a6 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 11:24:26 -0700 Subject: [PATCH] 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 --- Cargo.lock | 1 + crates/snowbound/Cargo.toml | 2 + crates/snowbound/linux/snowbound.desktop | 1 + crates/snowbound/src/desktop_linux.rs | 79 +++++++++++++++++++++++- crates/snowbound/src/instance.rs | 41 ++++++++---- crates/snowbound/src/linux.rs | 14 +++-- crates/snowbound/src/main.rs | 6 ++ 7 files changed, 125 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7ca8611708bd432e3cf116aad793573aa8a14cc9..ec52665c0e0f5c05d5032e37b76e11f99607b4d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3711,6 +3711,7 @@ dependencies = [ "wgpu", "windows-sys 0.61.2", "winit", + "x11rb", "zbus", "zeroize", ] diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index 2b5dbbdf259d33d846c15142addafe2f2ecade1f..82d50e047cca0724080bf6217b9f6aefc13231c8 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -118,6 +118,8 @@ sctk-adwaita = { version = "0.10.1", default-features = false } wayland-backend = { version = "0.3.17", features = ["client_system"] } wayland-client = "0.31.15" wayland-protocols = { version = "0.32.13", features = ["client", "staging"] } +# Brings the window forward on X11, on a connection of its own. +x11rb = "0.13.2" [dev-dependencies] # Draws the pictures of the video recording the tests store. diff --git a/crates/snowbound/linux/snowbound.desktop b/crates/snowbound/linux/snowbound.desktop index 8be4cfc5eb9a11497b987fcc77ed60c333c50e86..bdcd5af39dce4cfbccefb73e0aa6b2590df0a16c 100644 --- a/crates/snowbound/linux/snowbound.desktop +++ b/crates/snowbound/linux/snowbound.desktop @@ -8,4 +8,5 @@ Icon=net.paperclover.snowbound Terminal=false Categories=Office; MimeType=application/onenote; +StartupNotify=true StartupWMClass=net.paperclover.snowbound diff --git a/crates/snowbound/src/desktop_linux.rs b/crates/snowbound/src/desktop_linux.rs index 02c8cf727a0c560bde387de8fafe0b6e0fa65d85..074a26effd6e809e1e51c916076c009f76f9b9cc 100644 --- a/crates/snowbound/src/desktop_linux.rs +++ b/crates/snowbound/src/desktop_linux.rs @@ -17,9 +17,10 @@ use wayland_client::{ Connection, Dispatch, EventQueue, Proxy, backend::{Backend, ObjectId}, globals::{GlobalList, GlobalListContents, registry_queue_init}, - protocol::{wl_buffer, wl_registry, wl_shm, wl_shm_pool}, + protocol::{wl_buffer, wl_registry, wl_shm, wl_shm_pool, wl_surface::WlSurface}, }; use wayland_protocols::xdg::{ + activation::v1::client::xdg_activation_v1::XdgActivationV1, shell::client::xdg_toplevel::XdgToplevel, toplevel_icon::v1::client::{ xdg_toplevel_icon_manager_v1::XdgToplevelIconManagerV1, @@ -29,7 +30,7 @@ use wayland_protocols::xdg::{ use winit::{ event_loop::EventLoop, platform::wayland::{EventLoopExtWayland, WindowExtWayland}, - raw_window_handle::{HasDisplayHandle, RawDisplayHandle}, + raw_window_handle::{HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle}, window::{Icon, Window}, }; @@ -248,6 +249,7 @@ wayland_client::delegate_noop!(Wayland: wl_shm_pool::WlShmPool); wayland_client::delegate_noop!(Wayland: ignore wl_buffer::WlBuffer); wayland_client::delegate_noop!(Wayland: ignore XdgToplevelIconManagerV1); wayland_client::delegate_noop!(Wayland: XdgToplevelIconV1); +wayland_client::delegate_noop!(Wayland: XdgActivationV1); fn display(handle: &impl HasDisplayHandle) -> Option> { match handle.display_handle().ok()?.as_raw() { @@ -349,6 +351,79 @@ pub fn set_toplevel_icon(window: &Window) -> Option<()> { Some(()) } +/// The token the launcher gave this launch for its window to take the focus with: an +/// xdg-activation token on Wayland, a startup notification ID on X11. +pub fn activation_token() -> Option { + std::env::var("XDG_ACTIVATION_TOKEN") + .or_else(|_| std::env::var("DESKTOP_STARTUP_ID")) + .ok() + .filter(|token| !token.is_empty()) +} + +/// Brings the window forward with the token a later launch handed over. +pub fn activate(window: &Window, token: &str) { + match window.window_handle().map(|handle| handle.as_raw()) { + Ok(RawWindowHandle::Wayland(handle)) => { + activate_wayland(window, handle.surface, token); + } + Ok(RawWindowHandle::Xlib(handle)) => { + if let Err(error) = activate_x11(handle.window as u32, token) { + eprintln!("Cannot activate the window: {error}"); + } + } + _ => {} + } +} + +fn activate_wayland(window: &Window, surface: NonNull, token: &str) -> Option<()> { + let (connection, globals, mut queue) = connect(display(window)?)?; + let activation: XdgActivationV1 = globals.bind(&queue.handle(), 1..=1, ()).ok()?; + // Safety: winit's surface lives as long as the window. + let id = unsafe { ObjectId::from_ptr(WlSurface::interface(), surface.as_ptr().cast()) }; + let surface = WlSurface::from_id(&connection, id.ok()?).ok()?; + activation.activate(token.into(), &surface); + activation.destroy(); + queue.roundtrip(&mut Wayland).ok()?; + Some(()) +} + +/// Asks for the focus at the launch's time, which the window manager weighs against the +/// user's latest input, then ends the launch's startup notification. +fn activate_x11(window: u32, token: &str) -> Result<(), Box> { + use x11rb::{ + connection::Connection as _, + protocol::xproto::{ClientMessageEvent, ConnectionExt as _, EventMask}, + }; + let (connection, screen) = x11rb::connect(None)?; + let root = connection.setup().roots[screen].root; + let atom = |name: &str| -> Result> { + Ok(connection + .intern_atom(false, name.as_bytes())? + .reply()? + .atom) + }; + let time = token + .rsplit_once("_TIME") + .and_then(|(_, time)| time.parse().ok()) + .unwrap_or(x11rb::CURRENT_TIME); + let active = + ClientMessageEvent::new(32, window, atom("_NET_ACTIVE_WINDOW")?, [1, time, 0, 0, 0]); + let mask = EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY; + connection.send_event(false, root, mask, active)?; + let quoted = token.replace('\\', "\\\\").replace('"', "\\\""); + let mut kind = atom("_NET_STARTUP_INFO_BEGIN")?; + let more = atom("_NET_STARTUP_INFO")?; + for chunk in format!("remove: ID=\"{quoted}\"\0").as_bytes().chunks(20) { + let mut data = [0; 20]; + data[..chunk.len()].copy_from_slice(chunk); + let message = ClientMessageEvent::new(8, window, kind, data); + connection.send_event(false, root, EventMask::PROPERTY_CHANGE, message)?; + kind = more; + } + connection.flush()?; + Ok(()) +} + /// Copies this executable to `~/.local/bin` unless it runs from there, adds Snowbound to the /// app menu and to the apps opening OneNote's files, and says where Uninstall is. pub fn install() { diff --git a/crates/snowbound/src/instance.rs b/crates/snowbound/src/instance.rs index e475fbfbecfb75c75883356597cb780918582114..2d3e38368be3b11d44f00fe5dd582c536e8a15d5 100644 --- a/crates/snowbound/src/instance.rs +++ b/crates/snowbound/src/instance.rs @@ -6,9 +6,11 @@ use std::path::{Path, PathBuf}; use winit::event_loop::EventLoopProxy; -/// The files a launch asks to open, separated by NULs, which no path holds. -fn encode(paths: &[PathBuf]) -> Vec { - let mut bytes = Vec::new(); +/// The launcher's activation token, which may be empty, then the files a launch asks to open, +/// each ended by a NUL, which neither holds. +fn encode(token: Option<&str>, paths: &[PathBuf]) -> Vec { + let mut bytes = token.unwrap_or_default().as_bytes().to_vec(); + bytes.push(0); for path in paths { bytes.extend(path_bytes(path)); bytes.push(0); @@ -16,12 +18,17 @@ fn encode(paths: &[PathBuf]) -> Vec { bytes } -fn decode(bytes: &[u8]) -> Vec { - bytes - .split(|&byte| byte == 0) +fn decode(bytes: &[u8]) -> (Option, Vec) { + let mut fields = bytes.split(|&byte| byte == 0); + let token = fields + .next() + .filter(|token| !token.is_empty()) + .map(|token| String::from_utf8_lossy(token).into_owned()); + let paths = fields .filter(|path| !path.is_empty()) .map(bytes_path) - .collect() + .collect(); + (token, paths) } /// Opens what each later launch hands over, on a thread of its own. @@ -33,7 +40,13 @@ fn serve( while let Some(mut connection) = accept() { let mut bytes = Vec::new(); if connection.read_to_end(&mut bytes).is_ok() { - let _ = proxy.send_event(crate::UserEvent::Open(decode(&bytes))); + #[cfg_attr(not(target_os = "linux"), allow(unused_variables))] + let (token, paths) = decode(&bytes); + let _ = proxy.send_event(crate::UserEvent::Open(paths)); + #[cfg(target_os = "linux")] + if let Some(token) = token { + let _ = proxy.send_event(crate::UserEvent::Activate(token)); + } } } }); @@ -81,7 +94,8 @@ mod linux { for _ in 0..3 { match UnixStream::connect(&socket) { Ok(mut stream) => { - if stream.write_all(&encode(paths)).is_ok() { + let token = crate::desktop::activation_token(); + if stream.write_all(&encode(token.as_deref(), paths)).is_ok() { return None; } } @@ -192,7 +206,7 @@ mod windows { // The running app may then bring its window to the front. unsafe { AllowSetForegroundWindow(ASFW_ANY) }; let mut pipe = unsafe { File::from_raw_handle(handle) }; - if pipe.write_all(&encode(paths)).is_ok() { + if pipe.write_all(&encode(None, paths)).is_ok() { return None; } } else if unsafe { GetLastError() } == ERROR_PIPE_BUSY { @@ -236,12 +250,13 @@ mod tests { use super::*; #[test] - fn handed_paths_read_back() { + fn handed_token_and_paths_read_back() { let paths = [ PathBuf::from("/notes/Personal/Garden.one"), PathBuf::from("/notes/a b/Open Notebook.onetoc2"), ]; - assert_eq!(decode(&encode(&paths)), paths); - assert!(decode(&encode(&[])).is_empty()); + assert_eq!(decode(&encode(None, &paths)), (None, paths.to_vec())); + let token = Some("gnome-shell/Snowbound/1234-0-host_TIME5678".to_owned()); + assert_eq!(decode(&encode(token.as_deref(), &[])), (token, vec![])); } } diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index 0d411b87f3bc381f123c740e75e87346cb17d8ac..e50c02e9a61e2975ddc8964dc5e851bb750068b4 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -23,10 +23,11 @@ use ui::{Flags, Spec, Ui, px}; use winit::{ event_loop::{EventLoop, EventLoopProxy}, platform::{ + startup_notify::WindowAttributesExtStartupNotify, wayland::{EventLoopExtWayland, WindowAttributesExtWayland}, x11::EventLoopBuilderExtX11, }, - window::{ResizeDirection, Theme, Window, WindowAttributes}, + window::{ActivationToken, ResizeDirection, Theme, Window, WindowAttributes}, }; use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; @@ -191,13 +192,18 @@ fn breeze_radius(connection: &zbus::blocking::Connection) -> Option { /// Decorated by the window manager, or on Wayland by the compositor where it offers /// xdg-decoration and otherwise by winit's Adwaita frame; named so the desktop entry supplies -/// the icon on Wayland, while X11 takes it from the window. +/// the icon on Wayland, while X11 takes it from the window. It opens in front with the +/// launcher's activation token, which also ends the launch's startup notification. pub fn window_attributes() -> WindowAttributes { use crate::desktop::APP_ID; - Window::default_attributes() + let attributes = Window::default_attributes() .with_name(APP_ID, APP_ID) .with_window_icon(crate::desktop::window_icon()) - .with_transparent(cuts_corners()) + .with_transparent(cuts_corners()); + match crate::desktop::activation_token() { + Some(token) => attributes.with_activation_token(ActivationToken::from_raw(token)), + None => attributes, + } } /// The window manager or the frame draws the title bar; the icon is the window's own where diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 769853cc4bf9b254a1441c15056cf406c5320a41..653f4da33a08618cf2912148679a52063101220a 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -291,6 +291,10 @@ enum UserEvent { /// The system or a later launch asked to open notebook folders, tables of contents or /// sections, as the Finder does with a double-clicked file; none brings the window forward. Open(Vec), + /// The activation token a later launch's launcher gave it, which lets the window come + /// forward despite the desktop's focus-stealing prevention. + #[cfg(target_os = "linux")] + Activate(String), } /// Asks the event loop for a frame from any thread. @@ -5662,6 +5666,8 @@ impl State { self.window.focus_window(); self.window.request_redraw(); } + #[cfg(target_os = "linux")] + UserEvent::Activate(token) => desktop::activate(&self.window, &token), UserEvent::Update => { self.updated(); self.window.request_redraw(); -- 2.54.0