From 180efb0ba990c1bf8ec209f40ccb5e7e1bdc8ecf Mon Sep 17 00:00:00 2001 From: bbb651 Date: Mon, 3 Mar 2025 00:18:27 +0200 Subject: [PATCH] Support XDG Activation Test XDG Activation --- Cargo.toml | 2 +- src/clientside/mod.rs | 4 + src/clientside/xdg_activation.rs | 42 ++++++++ src/server/dispatch.rs | 14 ++- src/server/event.rs | 38 +++++-- src/server/mod.rs | 107 ++++++++++++++++++-- src/server/tests.rs | 5 +- src/xstate/mod.rs | 31 +++++- tests/integration.rs | 33 +++++++ testwl/src/lib.rs | 164 +++++++++++++++++++++++++++++-- 10 files changed, 413 insertions(+), 27 deletions(-) create mode 100644 src/clientside/xdg_activation.rs diff --git a/Cargo.toml b/Cargo.toml index a0d0efa..59f959e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ wayland-client.workspace = true wayland-protocols = { workspace = true, features = ["client", "server", "staging", "unstable"] } wayland-scanner.workspace = true wayland-server.workspace = true -xcb = { version = "1.3.0", features = ["composite", "randr"] } +xcb = { version = "1.3.0", features = ["composite", "randr", "res"] } wl_drm = { path = "wl_drm" } libc = "0.2.153" log = "0.4.21" diff --git a/src/clientside/mod.rs b/src/clientside/mod.rs index bcbef55..207db4b 100644 --- a/src/clientside/mod.rs +++ b/src/clientside/mod.rs @@ -1,4 +1,5 @@ mod data_device; +pub mod xdg_activation; use crate::server::{ObjectEvent, ObjectKey}; use std::os::unix::net::UnixStream; @@ -46,6 +47,7 @@ use wayland_protocols::{ viewporter::client::{wp_viewport::WpViewport, wp_viewporter::WpViewporter}, }, xdg::{ + activation::v1::client::xdg_activation_v1::XdgActivationV1, shell::client::{ xdg_popup::XdgPopup, xdg_positioner::XdgPositioner, xdg_surface::XdgSurface, xdg_toplevel::XdgToplevel, xdg_wm_base::XdgWmBase, @@ -69,6 +71,7 @@ pub struct Globals { smithay_client_toolkit::data_device_manager::WritePipe, )>, pub cancelled: bool, + pub pending_activations: Vec<(xcb::x::Window, String)>, } pub type ClientQueueHandle = QueueHandle; @@ -136,6 +139,7 @@ delegate_noop!(Globals: WpViewport); delegate_noop!(Globals: ZxdgOutputManagerV1); delegate_noop!(Globals: ZwpPointerConstraintsV1); delegate_noop!(Globals: ZwpTabletManagerV2); +delegate_noop!(Globals: XdgActivationV1); impl Dispatch for Globals { fn event( diff --git a/src/clientside/xdg_activation.rs b/src/clientside/xdg_activation.rs new file mode 100644 index 0000000..afebe1e --- /dev/null +++ b/src/clientside/xdg_activation.rs @@ -0,0 +1,42 @@ +use smithay_client_toolkit::{ + activation::{ActivationHandler, RequestData, RequestDataExt}, + delegate_activation, +}; +use xcb::x; + +use crate::clientside::Globals; + +delegate_activation!(Globals, ActivationData); + +pub struct ActivationData { + window: x::Window, + data: RequestData, +} + +impl ActivationData { + pub fn new(window: x::Window, data: RequestData) -> Self { + Self { window, data } + } +} + +impl RequestDataExt for ActivationData { + fn app_id(&self) -> Option<&str> { + self.data.app_id() + } + + fn seat_and_serial(&self) -> Option<(&wayland_client::protocol::wl_seat::WlSeat, u32)> { + self.data.seat_and_serial() + } + + fn surface(&self) -> Option<&wayland_client::protocol::wl_surface::WlSurface> { + self.data.surface() + } +} + +impl ActivationHandler for Globals { + type RequestData = ActivationData; + + fn new_token(&mut self, token: String, data: &Self::RequestData) { + self.pending_activations.push((data.window, token)); + } +} diff --git a/src/server/dispatch.rs b/src/server/dispatch.rs index 423008e..f3d4ea2 100644 --- a/src/server/dispatch.rs +++ b/src/server/dispatch.rs @@ -445,10 +445,18 @@ impl Dispatch for ServerState { state .objects .insert_from_other_objects([*key], |[seat_obj], key| { - let Seat { client, .. }: &Seat = seat_obj.try_into().unwrap(); - let client = client.get_keyboard(&state.qh, key); + let Seat { + client: client_seat, + .. + }: &Seat = seat_obj.try_into().unwrap(); + let client = client_seat.get_keyboard(&state.qh, key); let server = data_init.init(id, key); - Keyboard { client, server }.into() + Keyboard { + client, + server, + seat: client_seat.clone(), + } + .into() }); } Request::::GetTouch { id } => { diff --git a/src/server/event.rs b/src/server/event.rs index 0ebfeb3..8c9b861 100644 --- a/src/server/event.rs +++ b/src/server/event.rs @@ -540,7 +540,12 @@ impl HandleEvent for Pointer { } } -pub type Keyboard = GenericObject; +pub struct Keyboard { + pub server: WlKeyboard, + pub client: client::wl_keyboard::WlKeyboard, + pub seat: client::wl_seat::WlSeat, +} + impl HandleEvent for Keyboard { type Event = client::wl_keyboard::Event; @@ -557,7 +562,14 @@ impl HandleEvent for Keyboard { .get(key) .map(<_ as AsRef>::as_ref) }) { - state.last_kb_serial = Some(serial); + state.last_kb_serial = Some(( + state + .last_kb_serial + .take() + .and_then(|(seat, _)| (seat == self.seat).then_some(seat)) + .unwrap_or_else(|| self.seat.clone()), + serial, + )); let output_name = data.get_output_name(state); state.to_focus = Some(FocusData { window: data.window.unwrap(), @@ -584,6 +596,22 @@ impl HandleEvent for Keyboard { self.server.leave(serial, &data.server); } } + client::wl_keyboard::Event::Key { + serial, + time, + key, + state: key_state, + } => { + state.last_kb_serial = Some(( + state + .last_kb_serial + .take() + .and_then(|(seat, _)| (seat == self.seat).then_some(seat)) + .unwrap_or_else(|| self.seat.clone()), + serial, + )); + self.server.key(serial, time, key, convert_wenum(key_state)); + } _ => simple_event_shunt! { self.server, event: client::wl_keyboard::Event => [ Keymap { @@ -591,12 +619,6 @@ impl HandleEvent for Keyboard { |fd| fd.as_fd(), size }, - Key { - serial, - time, - key, - |state| convert_wenum(state) - }, Modifiers { serial, mods_depressed, diff --git a/src/server/mod.rs b/src/server/mod.rs index c17263c..80fba51 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -11,11 +11,12 @@ use crate::{X11Selection, XConnection}; use log::{debug, warn}; use rustix::event::{poll, PollFd, PollFlags}; use slotmap::{new_key_type, HopSlotMap, SparseSecondaryMap}; +use smithay_client_toolkit::activation::ActivationState; use smithay_client_toolkit::data_device_manager::{ data_device::DataDevice, data_offer::SelectionOffer, data_source::CopyPasteSource, DataDeviceManagerState, }; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::io::Read; use std::os::fd::{AsFd, BorrowedFd}; use std::os::unix::net::UnixStream; @@ -43,10 +44,11 @@ use wayland_protocols::{ xwayland_shell_v1::XwaylandShellV1, xwayland_surface_v1::XwaylandSurfaceV1, }, }; +use wayland_server::protocol::wl_seat::WlSeat; use wayland_server::{ protocol::{ - wl_callback::WlCallback, wl_compositor::WlCompositor, wl_output::WlOutput, wl_seat::WlSeat, - wl_shm::WlShm, wl_surface::WlSurface, + wl_callback::WlCallback, wl_compositor::WlCompositor, wl_output::WlOutput, wl_shm::WlShm, + wl_surface::WlSurface, }, Client, DisplayHandle, Resource, WEnum, }; @@ -102,6 +104,7 @@ struct WindowData { attrs: WindowAttributes, output_offset: WindowOutputOffset, output_key: Option, + activation_token: Option, } impl WindowData { @@ -110,6 +113,7 @@ impl WindowData { override_redirect: bool, dims: WindowDims, parent: Option, + activation_token: Option, ) -> Self { Self { window, @@ -124,6 +128,7 @@ impl WindowData { }, output_offset: WindowOutputOffset::default(), output_key: None, + activation_token, } } @@ -488,6 +493,7 @@ pub struct ServerState { associated_windows: SparseSecondaryMap, output_keys: SparseSecondaryMap, windows: HashMap, + pids: HashSet, qh: ClientQueueHandle, client: Option, @@ -499,7 +505,8 @@ pub struct ServerState { xdg_wm_base: XdgWmBase, clipboard_data: Option>, - last_kb_serial: Option, + last_kb_serial: Option<(client::wl_seat::WlSeat, u32)>, + activation_state: Option, global_output_offset: GlobalOutputOffset, global_offset_updated: bool, } @@ -529,6 +536,12 @@ impl ServerState { source: None::>, }); + let activation_state = ActivationState::bind(&clientside.global_list, &qh) + .inspect_err(|e| { + warn!("Could not bind xdg activation ({e:?}). Windows might not recive focus depending on compositor focus stealing policy.") + }) + .ok(); + dh.create_global::(1, ()); clientside .global_list @@ -537,6 +550,7 @@ impl ServerState { Self { windows: HashMap::new(), + pids: HashSet::new(), clientside, client: None, qh, @@ -552,6 +566,7 @@ impl ServerState { xdg_wm_base, clipboard_data, last_kb_serial: None, + activation_state, global_output_offset: GlobalOutputOffset { x: GlobalOutputOffsetDimension { owner: None, @@ -593,10 +608,23 @@ impl ServerState { override_redirect: bool, dims: WindowDims, parent: Option, + pid: Option, ) { + let activation_token = pid + .filter(|pid| self.pids.insert(*pid)) + .and_then(|pid| std::fs::read(format!("/proc/{pid}/environ")).ok()) + .and_then(|environ| { + environ + .split(|byte| *byte == 0) + .find_map(|line| line.strip_prefix(b"XDG_ACTIVATION_TOKEN=")) + .and_then(|token| String::from_utf8(token.to_vec()).ok()) + }); + if activation_token.is_none() { + self.activate_window(window); + } self.windows.insert( window, - WindowData::new(window, override_redirect, dims, parent), + WindowData::new(window, override_redirect, dims, parent, activation_token), ); } @@ -823,6 +851,41 @@ impl ServerState { } } + pub fn activate_window(&mut self, window: x::Window) { + let Some(activation_state) = self.activation_state.as_ref() else { + return; + }; + + let Some(last_focused_toplevel) = self.last_focused_toplevel else { + warn!("No last focused toplevel, cannot focus window {window:?}"); + return; + }; + let Some(win) = self.windows.get(&last_focused_toplevel) else { + warn!("Unknown last focused toplevel, cannot focus window {window:?}"); + return; + }; + let Some(key) = win.surface_key else { + warn!("Last focused toplevel has no surface, cannot focus window {window:?}"); + return; + }; + let Some(object) = self.objects.get_mut(key) else { + warn!("Last focused toplevel has stale reference, cannot focus window {window:?}"); + return; + }; + let surface: &mut SurfaceData = object.as_mut(); + activation_state.request_token_with_data( + &self.qh, + xdg_activation::ActivationData::new( + window, + smithay_client_toolkit::activation::RequestData { + app_id: win.attrs.class.clone(), + seat_and_serial: self.last_kb_serial.clone(), + surface: Some(surface.client.clone()), + }, + ), + ); + } + pub fn destroy_window(&mut self, window: x::Window) { let _ = self.windows.remove(&window); } @@ -839,7 +902,12 @@ impl ServerState { let CopyPasteData::X11 { inner, .. } = d.source.insert(data) else { unreachable!(); }; - if let Some(serial) = self.last_kb_serial.as_ref().copied() { + if let Some(serial) = self + .last_kb_serial + .as_ref() + .map(|(_seat, serial)| serial) + .copied() + { inner.set_selection(d.device.as_ref().unwrap(), serial); } } @@ -920,6 +988,7 @@ impl ServerState { } self.handle_clipboard_events(); + self.handle_activations(); self.clientside .queue .flush() @@ -967,6 +1036,24 @@ impl ServerState { } } + fn handle_activations(&mut self) { + let Some(activation_state) = self.activation_state.as_ref() else { + return; + }; + let globals = &mut self.clientside.globals; + + globals.pending_activations.retain(|(window, token)| { + if let Some(window) = self.windows.get(window) { + if let Some(key) = window.surface_key { + let surface: &SurfaceData = self.objects[key].as_ref(); + activation_state.activate::(&surface.client, token.clone()); + return false; + } + } + true + }); + } + fn calc_global_output_offset(&mut self) { for (key, _) in &self.output_keys { let Some(object) = &self.objects.get(key) else { @@ -1134,6 +1221,14 @@ impl ServerState { toplevel.set_fullscreen(None); } + let surface: &SurfaceData = self.objects[surface_key].as_ref(); + if let (Some(activation_state), Some(token)) = ( + self.activation_state.as_ref(), + window.activation_token.clone(), + ) { + activation_state.activate::(&surface.client, token); + } + ToplevelData { xdg: XdgSurfaceData { surface: xdg, diff --git a/src/server/tests.rs b/src/server/tests.rs index e0eaccc..74667d3 100644 --- a/src/server/tests.rs +++ b/src/server/tests.rs @@ -515,7 +515,7 @@ impl TestFixture { let dims = data.dims; self.register_window(window, data); self.satellite - .new_window(window, override_redirect, dims, parent); + .new_window(window, override_redirect, dims, parent, None); } fn map_window( @@ -1118,6 +1118,7 @@ fn window_group_properties() { ..Default::default() }, None, + None, ); f.satellite .set_win_title(prop_win, WmName::WmName("window".into())); @@ -1137,7 +1138,7 @@ fn window_group_properties() { let (_, surface) = comp.create_surface(); let dims = data.dims; f.register_window(win, data); - f.satellite.new_window(win, false, dims, None); + f.satellite.new_window(win, false, dims, None, None); f.satellite.set_win_hints( win, super::WmHints { diff --git a/src/xstate/mod.rs b/src/xstate/mod.rs index e2df1cc..13143d1 100644 --- a/src/xstate/mod.rs +++ b/src/xstate/mod.rs @@ -121,6 +121,7 @@ impl XState { xcb::Extension::Composite, xcb::Extension::RandR, xcb::Extension::XFixes, + xcb::Extension::Res, ], &[], ) @@ -301,7 +302,13 @@ impl XState { } else { Some(parent) }; - server_state.new_window(e.window(), e.override_redirect(), (&e).into(), parent); + server_state.new_window( + e.window(), + e.override_redirect(), + (&e).into(), + parent, + self.get_pid(e.window()), + ); } xcb::Event::X(x::Event::ReparentNotify(e)) => { debug!("reparent event: {e:?}"); @@ -313,6 +320,7 @@ impl XState { attrs.override_redirect, attrs.dims, None, + self.get_pid(e.window()), ); self.handle_window_attributes(server_state, e.window(), attrs); } else { @@ -447,6 +455,9 @@ impl XState { } } } + x if x == self.atoms.active_win => { + server_state.activate_window(e.window()); + } t => warn!("unrecognized message: {t:?}"), }, xcb::Event::X(x::Event::MappingNotify(_)) => {} @@ -649,6 +660,24 @@ impl XState { } } + fn get_pid(&self, window: x::Window) -> Option { + let Some(pid) = self + .connection + .wait_for_reply(self.connection.send_request(&xcb::res::QueryClientIds { + specs: &[xcb::res::ClientIdSpec { + client: window.resource_id(), + mask: xcb::res::ClientIdMask::LOCAL_CLIENT_PID, + }], + })) + .ok() + .and_then(|reply| Some(*reply.ids().next()?.value().first()?)) + else { + warn!("Failed to get pid of window: {window:?}"); + return None; + }; + Some(pid) + } + fn handle_property_change( &mut self, event: x::PropertyNotifyEvent, diff --git a/tests/integration.rs b/tests/integration.rs index c1df0cd..81a0e22 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -546,6 +546,17 @@ impl Connection { }) .unwrap(); } + + #[track_caller] + fn send_client_message(&self, request: &x::ClientMessageEvent) { + self.send_and_check_request(&x::SendEvent { + propagate: false, + destination: x::SendEventDest::Window(self.root), + event_mask: x::EventMask::SUBSTRUCTURE_NOTIFY | x::EventMask::SUBSTRUCTURE_REDIRECT, + event: request, + }) + .unwrap(); + } } #[test] @@ -781,6 +792,28 @@ fn input_focus() { f.wm_delete_window(&mut connection, win1, surface1); } +#[test] +fn activation_x11_to_x11() { + let mut f = Fixture::new(); + let mut connection = Connection::new(&f.display); + + let window1 = connection.new_window(connection.root, 0, 0, 20, 20, false); + let surface1 = f.map_as_toplevel(&mut connection, window1); + let window2 = connection.new_window(connection.root, 0, 0, 20, 20, false); + let surface2 = f.map_as_toplevel(&mut connection, window2); + + f.testwl.focus_toplevel(surface2); + std::thread::sleep(Duration::from_millis(1)); + connection.send_client_message(&x::ClientMessageEvent::new( + window1, + connection.atoms.net_active_window, + x::ClientMessageData::Data32([2, x::CURRENT_TIME, 0, 0, 0]), + )); + f.wait_and_dispatch(); + + assert_eq!(f.testwl.get_focused(), Some(surface1)); +} + #[test] fn quick_delete() { let mut f = Fixture::new(); diff --git a/testwl/src/lib.rs b/testwl/src/lib.rs index 7feed28..4957073 100644 --- a/testwl/src/lib.rs +++ b/testwl/src/lib.rs @@ -23,6 +23,10 @@ use wayland_protocols::{ viewporter::server::wp_viewporter::WpViewporter, }, xdg::{ + activation::v1::server::{ + xdg_activation_token_v1::{self, XdgActivationTokenV1}, + xdg_activation_v1::{self, XdgActivationV1}, + }, shell::server::{ xdg_popup::{self, XdgPopup}, xdg_positioner::{self, XdgPositioner}, @@ -176,6 +180,14 @@ struct KeyboardState { current_focus: Option, } +#[derive(Default)] +struct ActivationTokenData { + serial: Option<(u32, WlSeat)>, + app_id: Option, + surface: Option, + constructed: bool, +} + struct State { surfaces: HashMap, outputs: HashMap, @@ -185,12 +197,16 @@ struct State { last_surface_id: Option, last_output: Option, callbacks: Vec, + seat: Option, pointer: Option, keyboard: Option, configure_serial: u32, selection: Option, data_device_man: Option, data_device: Option, + xdg_activation: Option, + valid_tokens: HashSet, + token_counter: u32, } impl Default for State { @@ -204,12 +220,16 @@ impl Default for State { last_surface_id: None, last_output: None, callbacks: Vec::new(), + seat: None, pointer: None, keyboard: None, configure_serial: 0, selection: None, data_device_man: None, data_device: None, + xdg_activation: None, + valid_tokens: HashSet::new(), + token_counter: 0, } } } @@ -246,11 +266,9 @@ impl State { keyboard.leave(self.configure_serial, &self.surfaces[id].surface); } - keyboard.enter( - self.configure_serial, - &self.surfaces[&surface_id].surface, - Vec::default(), - ); + let surface = self.surfaces.get_mut(&surface_id).unwrap(); + keyboard.enter(self.configure_serial, &surface.surface, Vec::default()); + surface.last_enter_serial = Some(self.configure_serial); *current_focus = Some(surface_id); } @@ -267,6 +285,10 @@ impl State { } } + fn get_focused(&self) -> Option { + self.keyboard.as_ref()?.current_focus + } + #[track_caller] fn configure_popup(&mut self, surface_id: SurfaceId) { let surface = self.surfaces.get_mut(&surface_id).unwrap(); @@ -360,6 +382,7 @@ impl Server { dh.create_global::(5, ()); dh.create_global::(3, ()); dh.create_global::(1, ()); + dh.create_global::(1, ()); global_noop!(ZwpLinuxDmabufV1); global_noop!(ZwpRelativePointerManagerV1); global_noop!(WpViewporter); @@ -495,6 +518,11 @@ impl Server { self.display.flush_clients().unwrap(); } + #[track_caller] + pub fn get_focused(&self) -> Option { + self.state.get_focused() + } + #[track_caller] pub fn configure_popup(&mut self, surface_id: SurfaceId) { self.state.configure_popup(surface_id); @@ -971,7 +999,7 @@ impl Dispatch for State { impl GlobalDispatch for State { fn bind( - _: &mut Self, + state: &mut Self, _: &DisplayHandle, _: &Client, resource: wayland_server::New, @@ -980,6 +1008,7 @@ impl GlobalDispatch for State { ) { let seat = data_init.init(resource, ()); seat.capabilities(wl_seat::Capability::Pointer | wl_seat::Capability::Keyboard); + state.seat = Some(seat); } } @@ -1524,3 +1553,126 @@ impl Dispatch for State { unreachable!() } } + +impl GlobalDispatch for State { + fn bind( + state: &mut Self, + _: &DisplayHandle, + _: &Client, + resource: wayland_server::New, + _: &(), + data_init: &mut wayland_server::DataInit<'_, Self>, + ) { + state.xdg_activation = Some(data_init.init(resource, ())); + } +} + +impl Dispatch for State { + fn request( + state: &mut Self, + _: &Client, + _: &XdgActivationV1, + request: ::Request, + _: &(), + _: &DisplayHandle, + data_init: &mut wayland_server::DataInit<'_, Self>, + ) { + match request { + xdg_activation_v1::Request::Destroy => {} + xdg_activation_v1::Request::GetActivationToken { id } => { + data_init.init(id, Mutex::new(ActivationTokenData::default())); + } + xdg_activation_v1::Request::Activate { token, surface } => { + if state.valid_tokens.remove(&token) { + let surface_id = SurfaceId(surface.id().protocol_id()); + state.focus_toplevel(surface_id); + } + } + _ => unreachable!(), + } + } +} + +impl Dispatch> for State { + fn request( + state: &mut Self, + _: &Client, + token: &XdgActivationTokenV1, + request: ::Request, + data: &Mutex, + _: &DisplayHandle, + _: &mut wayland_server::DataInit<'_, Self>, + ) { + let mut data = data.lock().unwrap(); + match request { + xdg_activation_token_v1::Request::SetSerial { serial, seat } => { + if data.constructed { + token.post_error( + xdg_activation_token_v1::Error::AlreadyUsed, + "The activation token has already been constructed", + ); + return; + } + + data.serial = Some((serial, seat)); + } + xdg_activation_token_v1::Request::SetAppId { app_id } => { + if data.constructed { + token.post_error( + xdg_activation_token_v1::Error::AlreadyUsed, + "The activation token has already been constructed", + ); + return; + } + + data.app_id = Some(app_id); + } + xdg_activation_token_v1::Request::SetSurface { surface } => { + if data.constructed { + token.post_error( + xdg_activation_token_v1::Error::AlreadyUsed, + "The activation token has already been constructed", + ); + return; + } + + data.surface = Some(surface); + } + xdg_activation_token_v1::Request::Commit => { + if data.constructed { + token.post_error( + xdg_activation_token_v1::Error::AlreadyUsed, + "The activation token has already been constructed", + ); + return; + } + data.constructed = true; + + // Require a valid serial, otherwise ignore the activation. + // This matches niri's behavior: https://github.com/YaLTeR/niri/blob/5e549e13238a853f8860e29621ab6b31ee1b9ee4/src/handlers/mod.rs#L712-L723 + let valid = if let (Some((serial, seat)), Some(surface_data)) = ( + data.serial.take(), + data.surface.take().and_then(|surface| { + state.surfaces.get(&SurfaceId(surface.id().protocol_id())) + }), + ) { + state.seat == Some(seat) + && surface_data + .last_enter_serial + .is_some_and(|last_enter| serial >= last_enter) + } else { + false + }; + + let activation_token = state.token_counter.to_string(); + state.token_counter += 1; + if valid { + state.valid_tokens.insert(activation_token.clone()); + } + token.done(activation_token); + } + xdg_activation_token_v1::Request::Destroy => {} + _ => unreachable!(), + } + } +}