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 = [...@@ -3711,6 +3711,7 @@ dependencies = [
3711 "wgpu",3711 "wgpu",
3712 "windows-sys 0.61.2",3712 "windows-sys 0.61.2",
3713 "winit",3713 "winit",
3714 "x11rb",
3714 "zbus",3715 "zbus",
3715 "zeroize",3716 "zeroize",
3716]3717]
crates/snowbound/Cargo.toml+2
...@@ -118,6 +118,8 @@ sctk-adwaita = { version = "0.10.1", default-features = false }...@@ -118,6 +118,8 @@ sctk-adwaita = { version = "0.10.1", default-features = false }
118wayland-backend = { version = "0.3.17", features = ["client_system"] }118wayland-backend = { version = "0.3.17", features = ["client_system"] }
119wayland-client = "0.31.15"119wayland-client = "0.31.15"
120wayland-protocols = { version = "0.32.13", features = ["client", "staging"] }120wayland-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
122[dev-dependencies]124[dev-dependencies]
123# Draws the pictures of the video recording the tests store.125# Draws the pictures of the video recording the tests store.
crates/snowbound/linux/snowbound.desktop+1
...@@ -8,4 +8,5 @@ Icon=net.paperclover.snowbound...@@ -8,4 +8,5 @@ Icon=net.paperclover.snowbound
8Terminal=false8Terminal=false
9Categories=Office;9Categories=Office;
10MimeType=application/onenote;10MimeType=application/onenote;
11StartupNotify=true
11StartupWMClass=net.paperclover.snowbound12StartupWMClass=net.paperclover.snowbound
crates/snowbound/src/desktop_linux.rs+77-2
...@@ -17,9 +17,10 @@ use wayland_client::{...@@ -17,9 +17,10 @@ use wayland_client::{
17 Connection, Dispatch, EventQueue, Proxy,17 Connection, Dispatch, EventQueue, Proxy,
18 backend::{Backend, ObjectId},18 backend::{Backend, ObjectId},
19 globals::{GlobalList, GlobalListContents, registry_queue_init},19 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},
21};21};
22use wayland_protocols::xdg::{22use wayland_protocols::xdg::{
23 activation::v1::client::xdg_activation_v1::XdgActivationV1,
23 shell::client::xdg_toplevel::XdgToplevel,24 shell::client::xdg_toplevel::XdgToplevel,
24 toplevel_icon::v1::client::{25 toplevel_icon::v1::client::{
25 xdg_toplevel_icon_manager_v1::XdgToplevelIconManagerV1,26 xdg_toplevel_icon_manager_v1::XdgToplevelIconManagerV1,
...@@ -29,7 +30,7 @@ use wayland_protocols::xdg::{...@@ -29,7 +30,7 @@ use wayland_protocols::xdg::{
29use winit::{30use winit::{
30 event_loop::EventLoop,31 event_loop::EventLoop,
31 platform::wayland::{EventLoopExtWayland, WindowExtWayland},32 platform::wayland::{EventLoopExtWayland, WindowExtWayland},
32 raw_window_handle::{HasDisplayHandle, RawDisplayHandle},33 raw_window_handle::{HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle},
33 window::{Icon, Window},34 window::{Icon, Window},
34};35};
3536
...@@ -248,6 +249,7 @@ wayland_client::delegate_noop!(Wayland: wl_shm_pool::WlShmPool);...@@ -248,6 +249,7 @@ wayland_client::delegate_noop!(Wayland: wl_shm_pool::WlShmPool);
248wayland_client::delegate_noop!(Wayland: ignore wl_buffer::WlBuffer);249wayland_client::delegate_noop!(Wayland: ignore wl_buffer::WlBuffer);
249wayland_client::delegate_noop!(Wayland: ignore XdgToplevelIconManagerV1);250wayland_client::delegate_noop!(Wayland: ignore XdgToplevelIconManagerV1);
250wayland_client::delegate_noop!(Wayland: XdgToplevelIconV1);251wayland_client::delegate_noop!(Wayland: XdgToplevelIconV1);
252wayland_client::delegate_noop!(Wayland: XdgActivationV1);
251253
252fn display(handle: &impl HasDisplayHandle) -> Option<NonNull<c_void>> {254fn display(handle: &impl HasDisplayHandle) -> Option<NonNull<c_void>> {
253 match handle.display_handle().ok()?.as_raw() {255 match handle.display_handle().ok()?.as_raw() {
...@@ -349,6 +351,79 @@ pub fn set_toplevel_icon(window: &Window) -> Option<()> {...@@ -349,6 +351,79 @@ pub fn set_toplevel_icon(window: &Window) -> Option<()> {
349 Some(())351 Some(())
350}352}
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
352/// Copies this executable to `~/.local/bin` unless it runs from there, adds Snowbound to the427/// Copies this executable to `~/.local/bin` unless it runs from there, adds Snowbound to the
353/// app menu and to the apps opening OneNote's files, and says where Uninstall is.428/// app menu and to the apps opening OneNote's files, and says where Uninstall is.
354pub fn install() {429pub fn install() {
crates/snowbound/src/instance.rs+28-13
...@@ -6,9 +6,11 @@...@@ -6,9 +6,11 @@
6use std::path::{Path, PathBuf};6use std::path::{Path, PathBuf};
7use winit::event_loop::EventLoopProxy;7use winit::event_loop::EventLoopProxy;
88
9/// The files a launch asks to open, separated by NULs, which no path holds.9/// The launcher's activation token, which may be empty, then the files a launch asks to open,
10fn encode(paths: &[PathBuf]) -> Vec<u8> {10/// each ended by a NUL, which neither holds.
11 let mut bytes = Vec::new();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);
12 for path in paths {14 for path in paths {
13 bytes.extend(path_bytes(path));15 bytes.extend(path_bytes(path));
14 bytes.push(0);16 bytes.push(0);
...@@ -16,12 +18,17 @@ fn encode(paths: &[PathBuf]) -> Vec<u8> {...@@ -16,12 +18,17 @@ fn encode(paths: &[PathBuf]) -> Vec<u8> {
16 bytes18 bytes
17}19}
1820
19fn decode(bytes: &[u8]) -> Vec<PathBuf> {21fn decode(bytes: &[u8]) -> (Option<String>, Vec<PathBuf>) {
20 bytes22 let mut fields = bytes.split(|&byte| byte == 0);
21 .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
22 .filter(|path| !path.is_empty())28 .filter(|path| !path.is_empty())
23 .map(bytes_path)29 .map(bytes_path)
24 .collect()30 .collect();
31 (token, paths)
25}32}
2633
27/// Opens what each later launch hands over, on a thread of its own.34/// 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>(...@@ -33,7 +40,13 @@ fn serve<C: std::io::Read + Send + 'static>(
33 while let Some(mut connection) = accept() {40 while let Some(mut connection) = accept() {
34 let mut bytes = Vec::new();41 let mut bytes = Vec::new();
35 if connection.read_to_end(&mut bytes).is_ok() {42 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 }
37 }50 }
38 }51 }
39 });52 });
...@@ -81,7 +94,8 @@ mod linux {...@@ -81,7 +94,8 @@ mod linux {
81 for _ in 0..3 {94 for _ in 0..3 {
82 match UnixStream::connect(&socket) {95 match UnixStream::connect(&socket) {
83 Ok(mut stream) => {96 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() {
85 return None;99 return None;
86 }100 }
87 }101 }
...@@ -192,7 +206,7 @@ mod windows {...@@ -192,7 +206,7 @@ mod windows {
192 // The running app may then bring its window to the front.206 // The running app may then bring its window to the front.
193 unsafe { AllowSetForegroundWindow(ASFW_ANY) };207 unsafe { AllowSetForegroundWindow(ASFW_ANY) };
194 let mut pipe = unsafe { File::from_raw_handle(handle) };208 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() {
196 return None;210 return None;
197 }211 }
198 } else if unsafe { GetLastError() } == ERROR_PIPE_BUSY {212 } else if unsafe { GetLastError() } == ERROR_PIPE_BUSY {
...@@ -236,12 +250,13 @@ mod tests {...@@ -236,12 +250,13 @@ mod tests {
236 use super::*;250 use super::*;
237251
238 #[test]252 #[test]
239 fn handed_paths_read_back() {253 fn handed_token_and_paths_read_back() {
240 let paths = [254 let paths = [
241 PathBuf::from("/notes/Personal/Garden.one"),255 PathBuf::from("/notes/Personal/Garden.one"),
242 PathBuf::from("/notes/a b/Open Notebook.onetoc2"),256 PathBuf::from("/notes/a b/Open Notebook.onetoc2"),
243 ];257 ];
244 assert_eq!(decode(&encode(&paths)), paths);258 assert_eq!(decode(&encode(None, &paths)), (None, paths.to_vec()));
245 assert!(decode(&encode(&[])).is_empty());259 let token = Some("gnome-shell/Snowbound/1234-0-host_TIME5678".to_owned());
260 assert_eq!(decode(&encode(token.as_deref(), &[])), (token, vec![]));
246 }261 }
247}262}
crates/snowbound/src/linux.rs+10-4
...@@ -23,10 +23,11 @@ use ui::{Flags, Spec, Ui, px};...@@ -23,10 +23,11 @@ use ui::{Flags, Spec, Ui, px};
23use winit::{23use winit::{
24 event_loop::{EventLoop, EventLoopProxy},24 event_loop::{EventLoop, EventLoopProxy},
25 platform::{25 platform::{
26 startup_notify::WindowAttributesExtStartupNotify,
26 wayland::{EventLoopExtWayland, WindowAttributesExtWayland},27 wayland::{EventLoopExtWayland, WindowAttributesExtWayland},
27 x11::EventLoopBuilderExtX11,28 x11::EventLoopBuilderExtX11,
28 },29 },
29 window::{ResizeDirection, Theme, Window, WindowAttributes},30 window::{ActivationToken, ResizeDirection, Theme, Window, WindowAttributes},
30};31};
31use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};32use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
3233
...@@ -191,13 +192,18 @@ fn breeze_radius(connection: &zbus::blocking::Connection) -> Option<f32> {...@@ -191,13 +192,18 @@ fn breeze_radius(connection: &zbus::blocking::Connection) -> Option<f32> {
191192
192/// Decorated by the window manager, or on Wayland by the compositor where it offers193/// Decorated by the window manager, or on Wayland by the compositor where it offers
193/// xdg-decoration and otherwise by winit's Adwaita frame; named so the desktop entry supplies194/// 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.
195pub fn window_attributes() -> WindowAttributes {197pub fn window_attributes() -> WindowAttributes {
196 use crate::desktop::APP_ID;198 use crate::desktop::APP_ID;
197 Window::default_attributes()199 let attributes = Window::default_attributes()
198 .with_name(APP_ID, APP_ID)200 .with_name(APP_ID, APP_ID)
199 .with_window_icon(crate::desktop::window_icon())201 .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 }
201}207}
202208
203/// The window manager or the frame draws the title bar; the icon is the window's own where209/// 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 {...@@ -291,6 +291,10 @@ enum UserEvent {
291 /// The system or a later launch asked to open notebook folders, tables of contents or291 /// The system or a later launch asked to open notebook folders, tables of contents or
292 /// sections, as the Finder does with a double-clicked file; none brings the window forward.292 /// sections, as the Finder does with a double-clicked file; none brings the window forward.
293 Open(Vec<PathBuf>),293 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),
294}298}
295299
296/// Asks the event loop for a frame from any thread.300/// Asks the event loop for a frame from any thread.
...@@ -5662,6 +5666,8 @@ impl State {...@@ -5662,6 +5666,8 @@ impl State {
5662 self.window.focus_window();5666 self.window.focus_window();
5663 self.window.request_redraw();5667 self.window.request_redraw();
5664 }5668 }
5669 #[cfg(target_os = "linux")]
5670 UserEvent::Activate(token) => desktop::activate(&self.window, &token),
5665 UserEvent::Update => {5671 UserEvent::Update => {
5666 self.updated();5672 self.updated();
5667 self.window.request_redraw();5673 self.window.request_redraw();