diff options
Diffstat (limited to 'crates/wayland/src/xwayland.rs')
| -rw-r--r-- | crates/wayland/src/xwayland.rs | 380 |
1 files changed, 378 insertions, 2 deletions
diff --git a/crates/wayland/src/xwayland.rs b/crates/wayland/src/xwayland.rs index ac71492..f18a485 100644 --- a/crates/wayland/src/xwayland.rs +++ b/crates/wayland/src/xwayland.rs @@ -75,7 +75,11 @@ pub(crate) fn spawn(handle: &LoopHandle<'static, CompState>, display_handle: &sm XWaylandEvent::Ready { x11_socket, display_number } => { log::info!("XWayland ready on display :{display_number}"); match X11Wm::start_wm(handle_for_ready.clone(), x11_socket, client.clone()) { - Ok(wm) => data.xwm = Some(wm), + Ok(wm) => { + data.xwm = Some(wm); + fix_wm_name(display_number); + data.ewmh = EwmhState::connect(display_number); + } Err(e) => log::error!("failed to start X11 window manager for XWayland: {e}"), } } @@ -85,6 +89,289 @@ pub(crate) fn spawn(handle: &LoopHandle<'static, CompState>, display_handle: &sm Ok(()) } +/// Overwrites `_NET_WM_NAME` on XWayland's WM-check window from "Smithay X +/// WM" to "srdwm". +/// +/// `X11Wm::start_wm` hardcodes that string with no override hook exposed -- +/// no public method on `X11Wm`, and `wm_window`/its connection are private +/// fields, so it can't be reached through smithay's API at all. Every X11 +/// client that asks "who is the window manager" (`xprop`, `wmctrl`, +/// `xdotool`, fetch tools, app-compat shims that branch on WM identity) +/// gets told the name of the *library*, not the compositor - which is +/// actively misleading, not just cosmetic: it broke a shell function that +/// resolved the WM's process name from this exact property to kill it on +/// logout, `pkill`ing "Smithay" and matching nothing. +/// +/// Worked around by opening a second, independent X11 connection of our +/// own to the same XWayland display - exactly what `xprop` itself would +/// do - and rewriting the property directly. `_NET_SUPPORTING_WM_CHECK` +/// (which `start_wm` does set correctly) is how a plain client is meant to +/// find the WM-check window in the first place, so reading it back off the +/// root window rather than assuming a window ID keeps this from silently +/// going stale if smithay ever changes how it allocates that window. +fn fix_wm_name(display_number: u32) { + use smithay::reexports::x11rb::connection::Connection; + use smithay::reexports::x11rb::protocol::xproto::{AtomEnum, ConnectionExt as _, PropMode}; + use smithay::reexports::x11rb::rust_connection::RustConnection; + use smithay::reexports::x11rb::wrapper::ConnectionExt as _; + + let display = format!(":{display_number}"); + let (conn, screen_num) = match RustConnection::connect(Some(&display)) { + Ok(c) => c, + Err(e) => { + log::warn!("xwayland: couldn't open a second connection to fix _NET_WM_NAME: {e}"); + return; + } + }; + let root = conn.setup().roots[screen_num].root; + + let intern = |name: &str| -> Option<u32> { conn.intern_atom(false, name.as_bytes()).ok()?.reply().ok().map(|r| r.atom) }; + let (Some(supporting_wm_check), Some(net_wm_name), Some(utf8_string)) = + (intern("_NET_SUPPORTING_WM_CHECK"), intern("_NET_WM_NAME"), intern("UTF8_STRING")) + else { + log::warn!("xwayland: couldn't intern EWMH atoms to fix _NET_WM_NAME"); + return; + }; + + let reply = conn.get_property(false, root, supporting_wm_check, AtomEnum::WINDOW, 0, 1).ok().and_then(|c| c.reply().ok()); + let wm_window = reply.as_ref().and_then(|r| r.value32()).and_then(|mut it| it.next()); + let Some(wm_window) = wm_window else { + log::warn!("xwayland: _NET_SUPPORTING_WM_CHECK unset on the XWayland root; can't fix _NET_WM_NAME"); + return; + }; + + if let Err(e) = conn.change_property8(PropMode::REPLACE, wm_window, net_wm_name, utf8_string, b"srdwm") { + log::warn!("xwayland: failed to set _NET_WM_NAME: {e}"); + return; + } + let _ = conn.flush(); +} + +/// Keeps `_NET_ACTIVE_WINDOW`/`_NET_CLIENT_LIST`/`_NET_CLIENT_LIST_STACKING` +/// on the XWayland root window up to date. +/// +/// srdwm declares all three in `_NET_SUPPORTED` (smithay's `X11Wm` sets that +/// part up on its own), but never actually wrote them: `_NET_ACTIVE_WINDOW` +/// stayed `0x0` and `_NET_CLIENT_LIST` stayed empty regardless of what was +/// focused or mapped. Confirmed this is not something `X11Wm` does for us +/// automatically - it only updates `_NET_ACTIVE_WINDOW` in response to a +/// real X11 `FocusIn`/`FocusOut` event on the window, which requires an +/// actual `SetInputFocus` request to have been issued in the first place, +/// and nothing in this codebase ever issues one (Wayland keyboard focus and +/// X11 input focus are separate things; only the former was ever set). The +/// practical effect: any X11-aware client trying to answer "what's the +/// focused window" or "what windows exist" - `xdotool`, `wmctrl`, and +/// (per a downstream report) an AGS global-menu widget resolving the +/// focused window to query its DBusMenu registrar - got nothing. +/// +/// Rather than depend on `X11Wm`'s `FocusIn`-triggered path (which would +/// also need us to issue real `SetInputFocus` requests, itself a bigger +/// change with its own risk of fighting Wayland focus), this writes both +/// properties directly, from srdwm's own already-authoritative focus and +/// window-list state - exactly the "no new protocol needed" shape a +/// real EWMH-maintaining WM uses. Same reasoning as `fix_wm_name` for using +/// a second, independent connection rather than reaching into `X11Wm`'s +/// private one: there is no public accessor for it. +pub(crate) struct EwmhState { + conn: smithay::reexports::x11rb::rust_connection::RustConnection, + root: u32, + net_active_window: u32, + net_client_list: u32, + net_client_list_stacking: u32, + /// Global-menu atoms - see `read_global_menu`. Individually optional + /// (unlike the EWMH atoms above): a server old enough, or configured + /// oddly enough, to not know these names is still a fully functional + /// X server for everything else this module does, so a failure to + /// intern any one of them just means that field never resolves rather + /// than aborting `connect` entirely. + gtk_unique_bus_name: Option<u32>, + gtk_application_object_path: Option<u32>, + gtk_window_object_path: Option<u32>, + gtk_menubar_object_path: Option<u32>, + gtk_app_menu_object_path: Option<u32>, + unity_object_path: Option<u32>, +} + +impl EwmhState { + fn connect(display_number: u32) -> Option<Self> { + use smithay::reexports::x11rb::connection::Connection; + use smithay::reexports::x11rb::protocol::xproto::ConnectionExt as _; + use smithay::reexports::x11rb::rust_connection::RustConnection; + + let display = format!(":{display_number}"); + let (conn, screen_num) = match RustConnection::connect(Some(&display)) { + Ok(c) => c, + Err(e) => { + log::warn!("xwayland: couldn't open a connection for EWMH property updates: {e}"); + return None; + } + }; + let root = conn.setup().roots[screen_num].root; + let intern = |name: &str| -> Option<u32> { conn.intern_atom(false, name.as_bytes()).ok()?.reply().ok().map(|r| r.atom) }; + let (Some(net_active_window), Some(net_client_list), Some(net_client_list_stacking)) = + (intern("_NET_ACTIVE_WINDOW"), intern("_NET_CLIENT_LIST"), intern("_NET_CLIENT_LIST_STACKING")) + else { + log::warn!("xwayland: couldn't intern EWMH atoms; _NET_ACTIVE_WINDOW/_NET_CLIENT_LIST won't be maintained"); + return None; + }; + let gtk_unique_bus_name = intern("_GTK_UNIQUE_BUS_NAME"); + let gtk_application_object_path = intern("_GTK_APPLICATION_OBJECT_PATH"); + let gtk_window_object_path = intern("_GTK_WINDOW_OBJECT_PATH"); + let gtk_menubar_object_path = intern("_GTK_MENUBAR_OBJECT_PATH"); + let gtk_app_menu_object_path = intern("_GTK_APP_MENU_OBJECT_PATH"); + let unity_object_path = intern("_UNITY_OBJECT_PATH"); + let state = Self { + conn, + root, + net_active_window, + net_client_list, + net_client_list_stacking, + gtk_unique_bus_name, + gtk_application_object_path, + gtk_window_object_path, + gtk_menubar_object_path, + gtk_app_menu_object_path, + unity_object_path, + }; + // `_NET_CLIENT_LIST`/`_STACKING` are properties on the X root window, + // which XWayland recreates fresh on every launch - but nothing + // guarantees a *client* reading them does so only after this + // compositor's own first `update_net_client_list()` call, and until + // that first real add/remove there is no guarantee the property even + // has a defined initial value. Clearing it here, before any window + // has ever mapped, means a freshly connected client can never read a + // leftover or undefined list - it always starts empty and correct. + state.set_client_list(&[]); + Some(state) + } + + /// Reads `xid`'s global-menu D-Bus address straight off its own X11 + /// properties - `_GTK_UNIQUE_BUS_NAME` plus whichever menu-path atom + /// the client actually set. `_GTK_MENUBAR_OBJECT_PATH` (a real menu + /// bar) wins over `_GTK_APP_MENU_OBJECT_PATH` (the single-item + /// fallback simpler/older clients export) if a client somehow sets + /// both; `_UNITY_OBJECT_PATH` is the pre-`_GTK_*` name some + /// still-relevant toolkits (older Qt builds with the appmenu-qt5 + /// platform theme) use instead, tried last. No bus name means no menu + /// at all - the paths are meaningless without it - so this returns + /// `None` rather than a `GlobalMenu` with an empty `bus_name`. + /// + /// Which atom actually won is recorded as `source` - a consumer needs + /// it to pick the right D-Bus action-group prefix (`app`/`win` for a + /// real `GMenuModel`, `unity` for the older export), and + /// `appmenu-gtk-module` is known to set the `_GTK_*` atoms *and* + /// `_UNITY_OBJECT_PATH` simultaneously in some configurations - a + /// consumer with only the resolved path string, and no record of + /// which one it came from, can't tell the two cases apart even though + /// picking the wrong prefix means every menu item renders permanently + /// insensitive (a silent failure that reads exactly like a broken + /// app, not a wiring bug). Reported by the AGS peer session building + /// the consumer, from hitting exactly this live. + fn read_global_menu(&self, xid: u32) -> Option<srdwm_core::GlobalMenu> { + use smithay::reexports::x11rb::protocol::xproto::{AtomEnum, ConnectionExt as _}; + + let read_string = |atom: Option<u32>| -> Option<String> { + let atom = atom?; + let reply = self.conn.get_property(false, xid, atom, AtomEnum::ANY, 0, u32::MAX).ok()?.reply().ok()?; + if reply.value.is_empty() { + return None; + } + String::from_utf8(reply.value).ok().filter(|s| !s.is_empty()) + }; + + let bus_name = read_string(self.gtk_unique_bus_name)?; + let gtk_menu_path = read_string(self.gtk_menubar_object_path).or_else(|| read_string(self.gtk_app_menu_object_path)); + let (menu_path, source) = match gtk_menu_path { + Some(path) => (Some(path), srdwm_core::MenuSource::Gtk), + None => match read_string(self.unity_object_path) { + Some(path) => (Some(path), srdwm_core::MenuSource::Unity), + None => (None, srdwm_core::MenuSource::Gtk), + }, + }; + let app_path = read_string(self.gtk_application_object_path); + let window_path = read_string(self.gtk_window_object_path); + Some(srdwm_core::GlobalMenu { bus_name, menu_path, app_path, window_path, source }) + } + + /// `xid` is `None` when focus is on a native Wayland window (or + /// nothing) rather than an X11 one - `_NET_ACTIVE_WINDOW`'s value is + /// only meaningful for X11 clients, so this writes `0` (the documented + /// "no active window" sentinel) rather than leaving the last X11 + /// window's id stale and misleading. + fn set_active_window(&self, xid: Option<u32>) { + use smithay::reexports::x11rb::connection::Connection; + use smithay::reexports::x11rb::protocol::xproto::{AtomEnum, PropMode}; + use smithay::reexports::x11rb::wrapper::ConnectionExt as _; + if let Err(e) = self.conn.change_property32(PropMode::REPLACE, self.root, self.net_active_window, AtomEnum::WINDOW, &[xid.unwrap_or(0)]) { + log::warn!("xwayland: failed to set _NET_ACTIVE_WINDOW: {e}"); + return; + } + let _ = self.conn.flush(); + } + + fn set_client_list(&self, xids: &[u32]) { + use smithay::reexports::x11rb::connection::Connection; + use smithay::reexports::x11rb::protocol::xproto::{AtomEnum, PropMode}; + use smithay::reexports::x11rb::wrapper::ConnectionExt as _; + // Same order for both: EWMH only defines a strict order for the + // `_STACKING` variant (bottom-to-top), and `stacking_order` is + // already srdwm's one authoritative ordering of its windows - a + // second, differently-ordered list for plain `_NET_CLIENT_LIST` + // would need tracking mapping order separately for no real benefit. + for (atom, name) in [(self.net_client_list, "_NET_CLIENT_LIST"), (self.net_client_list_stacking, "_NET_CLIENT_LIST_STACKING")] { + if let Err(e) = self.conn.change_property32(PropMode::REPLACE, self.root, atom, AtomEnum::WINDOW, xids) { + log::warn!("xwayland: failed to set {name}: {e}"); + return; + } + } + let _ = self.conn.flush(); + } +} + +impl CompState { + /// Call on every focus change (from `set_keyboard_focus`, the single + /// chokepoint every focus path already goes through). `surface` is + /// whatever just gained keyboard focus; resolves to an X11 window id + /// only if that surface's window is XWayland-backed. + pub(crate) fn update_net_active_window(&self, surface: Option<&smithay::reexports::wayland_server::protocol::wl_surface::WlSurface>) { + let Some(ewmh) = &self.ewmh else { return }; + let id = surface.and_then(|s| self.surface_to_id.get(s)).copied(); + let xid = id.and_then(|id| self.id_to_window.get(&id)).and_then(|w| w.x11_surface()).map(|x| x.window_id()); + ewmh.set_active_window(xid); + // Global-menu properties are usually set once, shortly after a + // client registers on the session bus - which can race a window's + // own initial map, so reading them only at map time would miss a + // client that finished that registration a moment later. Refreshed + // here instead: every real focus change is a natural, already- + // existing hook, and a menu only actually needs to be current for + // whichever window is focused right now anyway. `read_global_menu` + // returning `None` (the common case for anything non-GTK, or a + // GTK app with no menu to export) correctly clears a stale value + // from a previous window that used to occupy this `id`. + if let (Some(id), Some(xid)) = (id, xid) { + let menu = ewmh.read_global_menu(xid); + if let Some(w) = self.wm.borrow_mut().window_mut(id) { + w.global_menu = menu; + } + } + } + + /// Call whenever the set of mapped windows changes (X11 window map, + /// unmap, or destroy - see the `XwmHandler` methods below). + pub(crate) fn update_net_client_list(&self) { + let Some(ewmh) = &self.ewmh else { return }; + let xids: Vec<u32> = self + .wm + .borrow() + .stacking_order() + .filter_map(|w| self.id_to_window.get(&w.id)) + .filter_map(|w| w.x11_surface()) + .map(|x| x.window_id()) + .collect(); + ewmh.set_client_list(&xids); + } +} + /// Writes a small shell script named `Xwayland` to a private directory and /// prepends that directory to this process's own `PATH` - the next /// `Command::new("Xwayland")` (namely `XWayland::spawn`'s, which copies @@ -195,10 +482,23 @@ impl CompState { let _ = surface.configure(Rectangle::new((geom.x, geom.y + TITLEBAR_HEIGHT as i32).into(), (geom.width as i32, (geom.height - TITLEBAR_HEIGHT) as i32).into())); self.space.map_element(dwindow.clone(), (geom.x, geom.y + TITLEBAR_HEIGHT as i32), true); - self.surface_to_id.insert(wl_surface, id); + self.surface_to_id.insert(wl_surface.clone(), id); self.id_to_window.insert(id, dwindow); self.redraw_decoration_buffer(id); + // `WindowManager::add_window` already made this the focused window + // in srdwm's own bookkeeping (it unconditionally does, for every + // new window), but that's purely internal state - without this, a + // freshly-opened XWayland app never receives a single keystroke + // until it's clicked, and (found investigating a downstream EWMH + // report) `_NET_ACTIVE_WINDOW` never updates either, since this is + // `set_keyboard_focus`'s only caller for X11 windows and that's the + // sole place `_NET_ACTIVE_WINDOW` gets written. The xdg-shell path + // (`new_managed_window` in state.rs) already does this; this is the + // equivalent X11 creation path, which never got the same fix. + self.set_keyboard_focus(Some(wl_surface)); self.pending.borrow_mut().push(CoreEvent::WindowCreated(id)); + self.update_net_client_list(); + crate::foreign_toplevel::window_created(self, id); } fn remove_x11_window(&mut self, xid: X11Window) { @@ -207,8 +507,20 @@ impl CompState { self.space.unmap_elem(&w); } self.decorations.remove(&id); + // Same reason as `state.rs`'s native `remove_window`: don't leave + // the context menu open against a window that's about to stop + // existing. + if self.context_menu.as_ref().is_some_and(|m| m.window == id) { + self.close_context_menu(); + } self.wm.borrow_mut().remove_window(id); self.pending.borrow_mut().push(CoreEvent::WindowDestroyed(id)); + crate::foreign_toplevel::window_closed(self, id); + // Same reason as the equivalent call in `state.rs`'s native + // `remove_window`: core may have already moved focus to whatever's + // now on top, and the Wayland/X11 side needs to be told to follow. + crate::input::sync_keyboard_focus(self); + self.update_net_client_list(); } } @@ -320,6 +632,70 @@ impl XwmHandler for CompState { } } + /// The same six requests found missing for native Wayland windows + /// (`XdgShellHandler`'s `maximize_request`/`unmaximize_request`/ + /// `fullscreen_request`/`unfullscreen_request`/`minimize_request`, + /// see `protocols.rs`) exist here too, under EWMH/ICCCM naming -- + /// `_NET_WM_STATE_MAXIMIZED_VERT`/`_HORZ`, `_NET_WM_STATE_FULLSCREEN`, + /// `_NET_WM_STATE_HIDDEN` toggled via a client message - and were + /// equally unimplemented, silently doing nothing for any XWayland + /// app's own window-menu maximize/minimize/fullscreen action. `move_ + /// request`/`resize_request` right below were already implemented, + /// which is what made this omission easy to miss; the drag/resize + /// half of this class of gap already had parity, only the state- + /// toggle half didn't. `unminimize_request` has no native-Wayland + /// equivalent to mirror - xdg-shell has no client-initiated "restore + /// from minimized" request at all, only EWMH does. + fn maximize_request(&mut self, _xwm: XwmId, window: X11Surface) { + let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return }; + if !self.wm.borrow().window(id).is_some_and(|w| w.maximized) { + self.wm.borrow_mut().toggle_maximize(id); + self.sync_geometry(id); + crate::foreign_toplevel::send_state(self, id); + } + } + + fn unmaximize_request(&mut self, _xwm: XwmId, window: X11Surface) { + let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return }; + if self.wm.borrow().window(id).is_some_and(|w| w.maximized) { + self.wm.borrow_mut().toggle_maximize(id); + self.sync_geometry(id); + crate::foreign_toplevel::send_state(self, id); + } + } + + fn fullscreen_request(&mut self, _xwm: XwmId, window: X11Surface) { + let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return }; + if !self.wm.borrow().is_fullscreen(id) { + self.wm.borrow_mut().toggle_fullscreen(id); + self.redraw_decoration_buffer(id); + self.sync_geometry(id); + crate::foreign_toplevel::send_state(self, id); + } + } + + fn unfullscreen_request(&mut self, _xwm: XwmId, window: X11Surface) { + let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return }; + if self.wm.borrow().is_fullscreen(id) { + self.wm.borrow_mut().toggle_fullscreen(id); + self.redraw_decoration_buffer(id); + self.sync_geometry(id); + crate::foreign_toplevel::send_state(self, id); + } + } + + fn minimize_request(&mut self, _xwm: XwmId, window: X11Surface) { + let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return }; + self.wm.borrow_mut().minimize_window(id); + crate::foreign_toplevel::send_state(self, id); + } + + fn unminimize_request(&mut self, _xwm: XwmId, window: X11Surface) { + let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return }; + self.wm.borrow_mut().restore_window(id); + crate::foreign_toplevel::send_state(self, id); + } + fn resize_request(&mut self, _xwm: XwmId, window: X11Surface, _button: u32, resize_edge: X11ResizeEdge) { let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return }; let pos = self.seat.get_pointer().map(|p| p.current_location()).unwrap_or_default(); |