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();