| author | |
| committer | |
| log | f4abeb10328178d4685cc9788c296516e6b186a6 |
| tree | 41b6c6684263f8989d4cdcd9b3e87f669aa6d423 |
| parent | 282b96db3198a95fdb61aa5d1977a7b2c9e05828 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.57 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 } |
| 118 | wayland-backend = { version = "0.3.17", features = ["client_system"] } | 118 | wayland-backend = { version = "0.3.17", features = ["client_system"] } |
| 119 | wayland-client = "0.31.15" | 119 | wayland-client = "0.31.15" |
| 120 | wayland-protocols = { version = "0.32.13", features = ["client", "staging"] } | 120 | wayland-protocols = { version = "0.32.13", features = ["client", "staging"] } |
| 121 | # Brings the window forward on X11, on a connection of its own. | ||
| 122 | x11rb = "0.13.2" | ||
| 121 | 123 | ||
| 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 |
| 8 | Terminal=false | 8 | Terminal=false |
| 9 | Categories=Office; | 9 | Categories=Office; |
| 10 | MimeType=application/onenote; | 10 | MimeType=application/onenote; |
| 11 | StartupNotify=true | ||
| 11 | StartupWMClass=net.paperclover.snowbound | 12 | StartupWMClass=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 | }; |
| 22 | use wayland_protocols::xdg::{ | 22 | use 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::{ |
| 29 | use winit::{ | 30 | use 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 | }; |
| 35 | 36 | ||
| ... | @@ -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); |
| 248 | wayland_client::delegate_noop!(Wayland: ignore wl_buffer::WlBuffer); | 249 | wayland_client::delegate_noop!(Wayland: ignore wl_buffer::WlBuffer); |
| 249 | wayland_client::delegate_noop!(Wayland: ignore XdgToplevelIconManagerV1); | 250 | wayland_client::delegate_noop!(Wayland: ignore XdgToplevelIconManagerV1); |
| 250 | wayland_client::delegate_noop!(Wayland: XdgToplevelIconV1); | 251 | wayland_client::delegate_noop!(Wayland: XdgToplevelIconV1); |
| 252 | wayland_client::delegate_noop!(Wayland: XdgActivationV1); | ||
| 251 | 253 | ||
| 252 | fn display(handle: &impl HasDisplayHandle) -> Option<NonNull<c_void>> { | 254 | fn 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 | } |
| 351 | 353 | ||
| 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. | ||
| 356 | pub 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. | ||
| 364 | pub 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 | |||
| 378 | fn 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. | ||
| 392 | fn 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 the | 427 | /// 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. |
| 354 | pub fn install() { | 429 | pub fn install() { |
crates/snowbound/src/instance.rs+28-13| ... | @@ -6,9 +6,11 @@ | ... | @@ -6,9 +6,11 @@ |
| 6 | use std::path::{Path, PathBuf}; | 6 | use std::path::{Path, PathBuf}; |
| 7 | use winit::event_loop::EventLoopProxy; | 7 | use winit::event_loop::EventLoopProxy; |
| 8 | 8 | ||
| 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, |
| 10 | fn encode(paths: &[PathBuf]) -> Vec<u8> { | 10 | /// each ended by a NUL, which neither holds. |
| 11 | let mut bytes = Vec::new(); | 11 | fn 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 | bytes | 18 | bytes |
| 17 | } | 19 | } |
| 18 | 20 | ||
| 19 | fn decode(bytes: &[u8]) -> Vec<PathBuf> { | 21 | fn decode(bytes: &[u8]) -> (Option<String>, Vec<PathBuf>) { |
| 20 | bytes | 22 | 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 | } |
| 26 | 33 | ||
| 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::*; |
| 237 | 251 | ||
| 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}; |
| 23 | use winit::{ | 23 | use 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 | }; |
| 31 | use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; | 32 | use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; |
| 32 | 33 | ||
| ... | @@ -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> { |
| 191 | 192 | ||
| 192 | /// Decorated by the window manager, or on Wayland by the compositor where it offers | 193 | /// 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 supplies | 194 | /// 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. | ||
| 195 | pub fn window_attributes() -> WindowAttributes { | 197 | pub 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 | } |
| 202 | 208 | ||
| 203 | /// The window manager or the frame draws the title bar; the icon is the window's own where | 209 | /// 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 or | 291 | /// 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 | } |
| 295 | 299 | ||
| 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(); |