srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--crates/core/src/window.rs111
-rw-r--r--crates/platform/src/appmenu_registrar.rs142
-rw-r--r--crates/wayland/src/appmenu.rs124
-rw-r--r--crates/wayland/src/gtk_shell.rs33
-rw-r--r--crates/x11/src/platform/connect.rs1
-rw-r--r--crates/x11/src/platform/global_menu.rs90
-rw-r--r--crates/x11/src/platform/mod.rs30
-rw-r--r--crates/x11/src/platform/trait_impl.rs15
8 files changed, 533 insertions, 13 deletions
diff --git a/crates/core/src/window.rs b/crates/core/src/window.rs
index 850b936..7df7b5c 100644
--- a/crates/core/src/window.rs
+++ b/crates/core/src/window.rs
@@ -45,16 +45,78 @@ pub struct GlobalMenu {
/// `app.xxx`/`win.xxx`; a consumer must insert two action groups, under
/// prefixes `"app"` and `"win"`, from [`GlobalMenu::app_path`]/
/// [`GlobalMenu::window_path`] respectively.
-/// - [`MenuSource::Unity`]: the older Ubuntu Unity-era export
-/// (`_UNITY_OBJECT_PATH`, still relevant for some Qt platform-theme
-/// builds). Items reference actions as `unity.xxx`, all against one
-/// group at the menu's own path - a consumer inserts a single group
-/// under prefix `"unity"` instead.
+/// - [`MenuSource::Unity`]: still a `GMenuModel` underneath - same
+/// `org.gtk.Menus`/`Gio.DBusMenuModel`-compatible wire content as
+/// [`MenuSource::Gtk`] - but from `appmenu-gtk-module`'s Unity-
+/// compatibility shim (a plain `Gtk.Window` with no `GtkApplication`),
+/// which serves it under one `unity.xxx`-prefixed action group at the
+/// menu's own path instead of separate `app.xxx`/`win.xxx` groups.
+/// Confirmed live by an AGS peer session reading the actual bus content
+/// for this exact case: `_GTK_MENUBAR_OBJECT_PATH` and
+/// `_UNITY_OBJECT_PATH` set to the *same* `org.gtk.Menus` object, real
+/// content, `unity.File`/`unity.Edit` actions - not a different wire
+/// protocol, just a different action-group prefix. A consumer that
+/// already speaks `Gio.DBusMenuModel` for [`Self::Gtk`] needs nothing
+/// more than reading this variant to also handle this one.
+/// - [`MenuSource::DbusMenu`]: a genuinely different wire protocol,
+/// `com.canonical.dbusmenu` - what a client sets `_UNITY_OBJECT_PATH`
+/// for *without* any `_GTK_*` atom alongside it (the original pre-GTK3.4
+/// Ubuntu Unity export this atom was created for, and what `appmenu-
+/// qt5`'s classic Unity-registrar model still uses), or what the
+/// Wayland-native `org_kde_kwin_appmenu` protocol always carries.
+/// `Gio.DBusMenuModel` cannot read this at all - pointed at a
+/// `com.canonical.dbusmenu` object it silently returns an empty model,
+/// the same class of silent failure as every other menu-source bug this
+/// session - a consumer needs an actual dbusmenu client for this one,
+/// not a differently-prefixed `GMenuModel` read.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum MenuSource {
#[default]
Gtk,
Unity,
+ DbusMenu,
+}
+
+/// The decision every X11-property-reading global-menu source needs --
+/// pulled out as a pure function, shared by the Wayland backend's
+/// `xwayland.rs::read_global_menu` and the native X11 backend, so it's
+/// unit-testable without a real X connection and can't drift into two
+/// differently-behaving copies.
+///
+/// `gtk_menu_path` present (real `GtkApplication` or not) always means
+/// `org.gtk.Menus` - that's the only thing `_GTK_MENUBAR_OBJECT_PATH`/
+/// `_GTK_APP_MENU_OBJECT_PATH` ever address, GTK-module shim included (see
+/// [`MenuSource::Unity`]'s own doc comment for the live evidence). Only a
+/// bare `unity_path`, with no GTK atom at all, gets [`MenuSource::DbusMenu`]:
+/// that's `_UNITY_OBJECT_PATH` doing the job it was actually created for --
+/// the original pre-GTK3.4 Unity/`libdbusmenu` export, and what a non-GTK
+/// client (`appmenu-qt5`'s classic model) still uses it for - rather than
+/// `appmenu-gtk-module` setting it as a compatibility alias alongside a GTK
+/// atom that already answers the question on its own.
+///
+/// `is_real_gtk_application` overrides "a GTK path exists" rather than the
+/// reverse: a real `GMenuModel` export (`app.`/`win.`-prefixed actions)
+/// only ever comes from a `GtkApplication`, which always also sets
+/// `_GTK_APPLICATION_OBJECT_PATH`/`_GTK_WINDOW_OBJECT_PATH` (or their
+/// `gtk_shell1` equivalents) - if both are absent despite a GTK menubar
+/// path existing, `appmenu-gtk-module` is exporting a plain window's menu
+/// through its Unity-compatibility shim instead: real content, at this
+/// same path, but under `unity.`-prefixed actions. Getting this wrong means
+/// every menu item renders permanently insensitive against action groups
+/// the app never inserted - a silent failure that reads exactly like a
+/// broken app, not a wiring bug. Confirmed live by an AGS peer session
+/// reading the actual exported menu content off the bus for exactly this
+/// case (`_GTK_MENUBAR_OBJECT_PATH` set, `app_path`/`window_path` both
+/// empty, every action `unity.*`).
+pub fn classify_menu_source(gtk_menu_path: Option<String>, is_real_gtk_application: bool, unity_path: Option<String>) -> (Option<String>, MenuSource) {
+ match gtk_menu_path {
+ Some(path) if !is_real_gtk_application => (Some(path), MenuSource::Unity),
+ Some(path) => (Some(path), MenuSource::Gtk),
+ None => match unity_path {
+ Some(path) => (Some(path), MenuSource::DbusMenu),
+ None => (None, MenuSource::Gtk),
+ },
+ }
}
/// State of a single managed window. This is platform-independent: backends
@@ -90,6 +152,11 @@ pub struct Window {
pub always_on_top: bool,
pub border_color: (u8, u8, u8),
pub border_width: u32,
+ /// Titlebar/border-strip corner radius, in logical pixels. Copied from
+ /// `ThemeConfig::default_corner_radius` at creation (see `WindowManager
+ /// ::add_window`), same as `border_color`/`border_width`; a rule's own
+ /// `corner_radius` action still wins afterward.
+ pub corner_radius: u32,
/// This window's own content opacity, `0.0`..=`1.0`. Only the content
/// (the client's own surface tree) is affected - srdwm's own
/// decoration (titlebar/border/shadow) always renders fully opaque
@@ -147,6 +214,7 @@ impl Window {
always_on_top: false,
border_color: (136, 192, 208), // Nord accent, matches legacy theme default
border_width: 2,
+ corner_radius: 6,
opacity: 1.0,
resize_margin: None,
workspace: 0,
@@ -396,6 +464,39 @@ mod tests {
}
#[test]
+ fn appmenu_gtk_module_shim_is_classified_as_unity_not_gtk() {
+ // The exact live case that motivated this: a GTK menubar path
+ // present, but no application/window object path - confirmed by
+ // an AGS peer session reading the actual exported menu content off
+ // the bus and finding `unity.`-prefixed actions despite the GTK
+ // atom being what resolved the path.
+ let (path, source) = classify_menu_source(Some("/org/appmenu/gtk/window/0".to_string()), false, None);
+ assert_eq!(path.as_deref(), Some("/org/appmenu/gtk/window/0"), "the path itself is still correct - only the label was wrong");
+ assert_eq!(source, MenuSource::Unity);
+ }
+
+ #[test]
+ fn real_gtk_application_export_is_still_classified_as_gtk() {
+ let (path, source) = classify_menu_source(Some("/org/gtk/menus/window/1".to_string()), true, None);
+ assert_eq!(path.as_deref(), Some("/org/gtk/menus/window/1"));
+ assert_eq!(source, MenuSource::Gtk);
+ }
+
+ #[test]
+ fn plain_unity_object_path_with_no_gtk_atom_is_real_dbusmenu() {
+ let (path, source) = classify_menu_source(None, false, Some("/com/canonical/menu/1".to_string()));
+ assert_eq!(path.as_deref(), Some("/com/canonical/menu/1"));
+ assert_eq!(source, MenuSource::DbusMenu);
+ }
+
+ #[test]
+ fn neither_path_present_is_none() {
+ let (path, source) = classify_menu_source(None, false, None);
+ assert_eq!(path, None);
+ assert_eq!(source, MenuSource::Gtk);
+ }
+
+ #[test]
fn close_button_is_top_right_corner_of_titlebar() {
let f = frame();
let hit = ResizeEdge::hit_test(f, f.right() - 5, f.y + 5, true, 0, RESIZE_MARGIN);
diff --git a/crates/platform/src/appmenu_registrar.rs b/crates/platform/src/appmenu_registrar.rs
new file mode 100644
index 0000000..60511e6
--- /dev/null
+++ b/crates/platform/src/appmenu_registrar.rs
@@ -0,0 +1,142 @@
+//! `com.canonical.AppMenu.Registrar`: the classic Unity/`appmenu-qt5`
+//! global-menu registration service. Unlike every other menu source srdwm
+//! reads (the Wayland backend's `xwayland.rs`'s `_GTK_*`/
+//! `_UNITY_OBJECT_PATH` X11 properties and `gtk_shell.rs`'s native-Wayland
+//! `gtk_surface1.set_dbus_properties`), a classic Qt app using
+//! `appmenu-qt5`'s original model never puts its menu's D-Bus address on an
+//! X11 property at all - it calls `RegisterWindow(windowId, menuObjectPath)`
+//! on this well-known D-Bus name instead, with the *sender* of that call
+//! being the only way to learn which bus name the menu actually lives on.
+//! Reading properties can't discover this; something has to actually own
+//! this name on the session bus and receive the call, which is what this
+//! module does.
+//!
+//! Lives in `srdwm-platform`, not `srdwm-wayland`, even though `windowId`
+//! is always a raw X11 XID (this model predates Wayland entirely): both the
+//! Wayland backend's XWayland integration *and* the native X11 backend
+//! (`srdwm-x11`) need the exact same service against the exact same kind of
+//! id, and neither depends on the other - `srdwm-platform` is the one
+//! crate both already depend on. Each backend constructs its own
+//! `AppmenuRegistrarState` (only ever one should actually be running at a
+//! time, since only one backend runs per session) and resolves each
+//! `RegistrarEvent`'s `window_id` back to its own `WindowId` however it
+//! already maps X11 ids - the Wayland backend's `xwayland.rs::apply_
+//! registrar_events` matches `X11Surface::window_id()` against `id_to_
+//! window`, the X11 backend's `xid_to_core` map directly.
+//!
+//! This compositor has no async runtime anywhere else (see `srdwm/src/
+//! main.rs`'s plain synchronous loop), and zbus's `blocking` API exists
+//! specifically so this doesn't need one: `zbus::blocking::Connection`
+//! still runs its socket-reading/dispatch on a background thread
+//! internally (via `async-io`'s own global executor, which spawns that
+//! thread the first time any work is submitted to it - nothing here has
+//! to drive that explicitly), so holding the built `Connection` alive is
+//! the only thing `AppmenuRegistrarState` needs to do to keep the service
+//! running. The interface impl below only ever sends a plain event over a
+//! `std::sync::mpsc` channel - draining it is a non-blocking, synchronous
+//! `try_iter()` call from the compositor's own event-loop tick, the same
+//! "background thread feeds a channel, the main loop drains it" shape
+//! `IpcServer` already uses for the control socket.
+
+use std::sync::mpsc::{Receiver, Sender};
+
+use zbus::message::Header;
+use zbus::zvariant::OwnedObjectPath;
+
+/// One `RegisterWindow`/`UnregisterWindow` call, captured off the D-Bus
+/// message and handed to a backend's own event-loop tick to resolve
+/// against a real `WindowId` and update `Window.global_menu`.
+#[derive(Debug)]
+pub enum RegistrarEvent {
+ Registered { window_id: u32, bus_name: String, menu_path: String },
+ Unregistered { window_id: u32 },
+}
+
+/// The D-Bus interface itself - deliberately does nothing but forward
+/// each call onto `tx`. Resolving `window_id` against `WindowManager` has
+/// to happen back on the compositor's own thread (everything `WindowManager`
+/// touches is `Rc`/`RefCell`, not `Send`), so there is nothing more useful
+/// this could do while actually running on zbus's background thread.
+struct RegistrarIface {
+ tx: Sender<RegistrarEvent>,
+}
+
+#[zbus::interface(name = "com.canonical.AppMenu.Registrar")]
+impl RegistrarIface {
+ /// The registrar's own defined method: a window announcing (or
+ /// re-announcing - a client is free to call this again if its menu
+ /// path changes) its `com.canonical.dbusmenu` object. `#[zbus(header)]`
+ /// is what makes the caller's bus name visible at all - it's the
+ /// message envelope, not a request argument, so there is no other way
+ /// to learn it.
+ fn register_window(&mut self, window_id: u32, menu_object_path: OwnedObjectPath, #[zbus(header)] header: Header<'_>) {
+ let Some(bus_name) = header.sender() else { return };
+ let _ = self.tx.send(RegistrarEvent::Registered { window_id, bus_name: bus_name.to_string(), menu_path: menu_object_path.to_string() });
+ }
+
+ /// The protocol's own way of saying "no menu after all" - a window
+ /// closing its menu, or closing entirely, without necessarily also
+ /// destroying the D-Bus connection that registered it. Matches
+ /// `xwayland.rs`/`gtk_shell.rs`'s own handling of the equivalent case:
+ /// clear `Window.global_menu` rather than leaving it stale.
+ fn unregister_window(&mut self, window_id: u32) {
+ let _ = self.tx.send(RegistrarEvent::Unregistered { window_id });
+ }
+}
+
+/// Owns the registrar's D-Bus connection for as long as an X11-capable
+/// backend (XWayland under the Wayland backend, or the native X11 backend)
+/// is running. `None` if starting the service failed (another process
+/// already owns the name, or no session bus is reachable at all) - a
+/// classic Qt app's global menu simply won't be seen in that case, same
+/// "degrade, don't crash the compositor over a cosmetic feature" handling
+/// as every other optional protocol here.
+pub struct AppmenuRegistrarState {
+ // Never read again after construction - its only job is to outlive
+ // the compositor so the background-thread service it owns keeps
+ // running. `Option` because starting it can fail (see above).
+ _connection: Option<zbus::blocking::Connection>,
+ rx: Receiver<RegistrarEvent>,
+}
+
+impl AppmenuRegistrarState {
+ pub fn new() -> Self {
+ let (tx, rx) = std::sync::mpsc::channel();
+ let iface = RegistrarIface { tx };
+ // `replace_existing_names`: a previous srdwm process that crashed
+ // or was killed (rather than exiting cleanly) can leave this name
+ // owned until the bus itself notices the connection is gone, which
+ // is not guaranteed to have happened yet by the time a fresh
+ // process starts - same reasoning as `IpcServer::bind_in` removing
+ // a stale control-socket file before binding its own fresh one, so
+ // a restart doesn't leave the *new* process's registrar silently
+ // unreachable behind a name the old one still holds.
+ let connection = zbus::blocking::connection::Builder::session()
+ .and_then(|b| b.serve_at("/com/canonical/AppMenu/Registrar", iface))
+ .and_then(|b| b.name("com.canonical.AppMenu.Registrar"))
+ .map(|b| b.replace_existing_names(true))
+ .and_then(|b| b.build());
+ let _connection = match connection {
+ Ok(c) => Some(c),
+ Err(e) => {
+ log::warn!("appmenu_registrar: couldn't start com.canonical.AppMenu.Registrar ({e}); classic Qt/appmenu-qt5 global menus won't be seen");
+ None
+ }
+ };
+ Self { _connection, rx }
+ }
+
+ /// Every registration/unregistration that arrived since the last call,
+ /// non-blockingly - called once per event-loop tick from `poll_events`,
+ /// the same drain-a-channel shape `IpcServer::poll` already uses for
+ /// the control socket.
+ pub fn drain_events(&self) -> Vec<RegistrarEvent> {
+ self.rx.try_iter().collect()
+ }
+}
+
+impl Default for AppmenuRegistrarState {
+ fn default() -> Self {
+ Self::new()
+ }
+}
diff --git a/crates/wayland/src/appmenu.rs b/crates/wayland/src/appmenu.rs
new file mode 100644
index 0000000..9abe826
--- /dev/null
+++ b/crates/wayland/src/appmenu.rs
@@ -0,0 +1,124 @@
+//! `org_kde_kwin_appmenu`: the Wayland-native equivalent of the `_GTK_*`/
+//! `_UNITY_OBJECT_PATH` X11 properties `xwayland.rs`'s `read_global_menu`
+//! reads - lets a client link a `wl_surface` straight to a
+//! `com.canonical.dbusmenu` D-Bus address, no XWayland/X11 property
+//! round-trip involved at all.
+//!
+//! This is the only real gap X11 property reading structurally can't
+//! close: `xwayland.rs::read_global_menu` only ever runs for a window that
+//! `x11_surface()` resolves (an XWayland client), so a genuinely
+//! Wayland-native GTK4/Qt6 window - no XWayland involved - could never
+//! have exported a menu srdwm would see, regardless of how correct the X11
+//! side is. There is no Wayland-native equivalent of `_GTK_MENUBAR_OBJECT_
+//! PATH`/GMenuModel (GTK's own menu export is X11-property-only, by
+//! GTK's own design, XWayland or not), but Qt/KDE's side of this - the
+//! same `com.canonical.dbusmenu` content `appmenu-qt5`'s Unity-registrar
+//! path exports over X11 - does have a real Wayland-native protocol for
+//! it, and it's what this file implements. Closes the other open half of
+//! the Qt global-menu gap found this session: `read_global_menu`'s
+//! `_UNITY_OBJECT_PATH` handling only ever reaches a Qt app that still
+//! goes through XWayland, which a `QT_QPA_PLATFORM=wayland` app (the
+//! default this session's `env.conf` port now sets) does not.
+//!
+//! Always `MenuSource::DbusMenu`, never `MenuSource::Unity`: this
+//! protocol's own description says exactly what it addresses - "a
+//! `com.canonical.dbusmenu` interface" - and despite the name, `Unity`
+//! does *not* mean dbusmenu content in this codebase (see that variant's
+//! own doc comment, corrected after an AGS peer session caught this exact
+//! mislabeling live: `appmenu-gtk-module`'s `_UNITY_OBJECT_PATH` points at
+//! an `org.gtk.Menus` object, not a dbusmenu one). `DbusMenu` is the one
+//! that means what this protocol carries. No classification heuristic
+//! needed the way `xwayland.rs::classify_menu_source` needs one for the
+//! X11 side, though: this protocol only ever carries the one kind of
+//! content.
+//!
+//! No smithay helper exists for this protocol (same as `gamma_control.rs`/
+//! `output_power.rs`/`screencopy.rs`), so the `GlobalDispatch`/`Dispatch`
+//! plumbing below is hand-written against the raw `wayland-protocols-
+//! plasma` server bindings.
+
+use smithay::reexports::wayland_server::backend::{ClientId, GlobalId};
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New};
+use wayland_protocols_plasma::appmenu::server::org_kde_kwin_appmenu::{self, OrgKdeKwinAppmenu};
+use wayland_protocols_plasma::appmenu::server::org_kde_kwin_appmenu_manager::{self, OrgKdeKwinAppmenuManager};
+
+use crate::state::CompState;
+
+/// The manager global. Held by `CompState` purely to keep the global alive
+/// for the compositor's lifetime - same reasoning as `GammaControlManagerState`.
+pub struct AppmenuManagerState {
+ _global: GlobalId,
+}
+
+impl AppmenuManagerState {
+ pub fn new<D>(dh: &DisplayHandle) -> Self
+ where
+ D: GlobalDispatch<OrgKdeKwinAppmenuManager, ()> + 'static,
+ {
+ Self { _global: dh.create_global::<D, OrgKdeKwinAppmenuManager, _>(2, ()) }
+ }
+}
+
+/// Which `wl_surface` an `org_kde_kwin_appmenu` object addresses, resolved
+/// once at creation - same reasoning as `GammaControlData`'s output.
+pub struct AppmenuData {
+ surface: WlSurface,
+}
+
+impl GlobalDispatch<OrgKdeKwinAppmenuManager, ()> for CompState {
+ fn bind(_state: &mut Self, _dh: &DisplayHandle, _client: &Client, manager: New<OrgKdeKwinAppmenuManager>, _data: &(), data_init: &mut DataInit<'_, Self>) {
+ data_init.init(manager, ());
+ }
+}
+
+impl Dispatch<OrgKdeKwinAppmenuManager, ()> for CompState {
+ fn request(
+ _state: &mut Self,
+ _client: &Client,
+ _manager: &OrgKdeKwinAppmenuManager,
+ request: org_kde_kwin_appmenu_manager::Request,
+ _data: &(),
+ _dh: &DisplayHandle,
+ data_init: &mut DataInit<'_, Self>,
+ ) {
+ let org_kde_kwin_appmenu_manager::Request::Create { id, surface } = request else { return };
+ data_init.init(id, AppmenuData { surface });
+ }
+}
+
+impl Dispatch<OrgKdeKwinAppmenu, AppmenuData> for CompState {
+ fn request(
+ state: &mut Self,
+ _client: &Client,
+ _resource: &OrgKdeKwinAppmenu,
+ request: org_kde_kwin_appmenu::Request,
+ data: &AppmenuData,
+ _dh: &DisplayHandle,
+ _data_init: &mut DataInit<'_, Self>,
+ ) {
+ let org_kde_kwin_appmenu::Request::SetAddress { service_name, object_path } = request else { return };
+ let Some(id) = state.surface_to_id.get(&data.surface).copied() else { return };
+ if let Some(w) = state.wm.borrow_mut().window_mut(id) {
+ w.global_menu = Some(srdwm_core::GlobalMenu {
+ bus_name: service_name,
+ menu_path: Some(object_path),
+ app_path: None,
+ window_path: None,
+ source: srdwm_core::MenuSource::DbusMenu,
+ });
+ }
+ }
+
+ /// The protocol's own doc comment: "If not applicable, clients should
+ /// remove this object" - releasing (or disconnecting) is a real,
+ /// expected way for a client to say "no menu after all", not just
+ /// cleanup, so the address needs actually clearing here rather than
+ /// left stale for whichever window this surface maps to.
+ fn destroyed(state: &mut Self, _client: ClientId, _resource: &OrgKdeKwinAppmenu, data: &AppmenuData) {
+ let Some(id) = state.surface_to_id.get(&data.surface).copied() else { return };
+ if let Some(w) = state.wm.borrow_mut().window_mut(id) {
+ w.global_menu = None;
+ }
+ }
+}
diff --git a/crates/wayland/src/gtk_shell.rs b/crates/wayland/src/gtk_shell.rs
index f4ae030..1b0e57b 100644
--- a/crates/wayland/src/gtk_shell.rs
+++ b/crates/wayland/src/gtk_shell.rs
@@ -86,16 +86,37 @@ impl Dispatch<GtkSurface1, GtkSurfaceData> for CompState {
// (or never had one) - matches `xwayland.rs`'s `read_global_menu`
// returning `None` for the same case, so a panel sees the same
// shape regardless of which backend a window came from.
- // Always `MenuSource::Gtk`: this protocol has no Unity-style
- // equivalent to carry, unlike XWayland's `_UNITY_OBJECT_PATH` --
- // see `xwayland.rs`'s `read_global_menu` for the case that needs
- // the other variant.
+ //
+ // `source` used to be hardcoded `MenuSource::Gtk` unconditionally
+ // here, on the reasoning that this protocol "has no Unity-style
+ // equivalent to carry" - true of the *wire message*, but not of
+ // what's actually behind it: `appmenu-gtk-module` is the same
+ // module regardless of whether it signals its address via X11
+ // atoms (`xwayland.rs`) or `gtk_surface1.set_dbus_properties`
+ // (here), and exports a plain `Gtk.Window`'s menu (no
+ // `GtkApplication`) through its Unity-compatibility shim --
+ // `unity.`-prefixed actions and all - on *either* path. That's
+ // exactly the misclassification `classify_menu_source` was written
+ // to fix for the X11 side (see its own doc comment and
+ // `xwayland.rs::read_global_menu`); this call site just never
+ // adopted it, so a native-Wayland GTK window hitting the same
+ // shim case stayed permanently mislabeled `Gtk` while its XWayland
+ // counterpart got the fix. Confirmed live: Nemo (native Wayland,
+ // not XWayland - only Spotify was in `_NET_CLIENT_LIST` at the
+ // time) reported `source: "gtk"` here, but its actual exported
+ // menu content, read directly off the bus, used `unity.`-prefixed
+ // actions throughout (File/Edit/View/Go/Bookmarks/Help) - the
+ // exact "every item renders, none of them are ever clickable"
+ // failure `classify_menu_source`'s own tests exist to catch.
+ let is_real_gtk_application = application_object_path.as_deref().is_some_and(|s| !s.is_empty()) || window_object_path.as_deref().is_some_and(|s| !s.is_empty());
+ let gtk_menu_path = menubar_path.filter(|s| !s.is_empty()).or_else(|| app_menu_path.filter(|s| !s.is_empty()));
+ let (menu_path, source) = srdwm_core::classify_menu_source(gtk_menu_path, is_real_gtk_application, None);
let menu = unique_bus_name.filter(|s| !s.is_empty()).map(|bus_name| srdwm_core::GlobalMenu {
bus_name,
- menu_path: menubar_path.filter(|s| !s.is_empty()).or_else(|| app_menu_path.filter(|s| !s.is_empty())),
+ menu_path,
app_path: application_object_path.filter(|s| !s.is_empty()),
window_path: window_object_path.filter(|s| !s.is_empty()),
- source: srdwm_core::MenuSource::Gtk,
+ source,
});
if let Some(w) = state.wm.borrow_mut().window_mut(id) {
w.global_menu = menu;
diff --git a/crates/x11/src/platform/connect.rs b/crates/x11/src/platform/connect.rs
index d5c3a59..2d2f726 100644
--- a/crates/x11/src/platform/connect.rs
+++ b/crates/x11/src/platform/connect.rs
@@ -94,6 +94,7 @@ impl X11Platform {
keyboard_mapping,
numlock_mask,
ipc,
+ appmenu_registrar: Some(srdwm_platform::AppmenuRegistrarState::new()),
})
}
diff --git a/crates/x11/src/platform/global_menu.rs b/crates/x11/src/platform/global_menu.rs
new file mode 100644
index 0000000..161fe80
--- /dev/null
+++ b/crates/x11/src/platform/global_menu.rs
@@ -0,0 +1,90 @@
+//! Global-menu support for the native X11 backend - parity with the
+//! Wayland backend's XWayland integration (`crates/wayland/src/xwayland.rs`
+//! and `srdwm_platform::appmenu_registrar`), reading the identical X11
+//! properties and running the identical `com.canonical.AppMenu.Registrar`
+//! D-Bus service, since a client-side toolkit (GTK/Qt) exports its menu the
+//! same way regardless of which X server it's actually talking to.
+
+use super::*;
+
+impl X11Platform {
+ /// 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. Exact mirror of `crates/wayland/src/
+ /// xwayland.rs::EwmhState::read_global_menu`; see that method's doc
+ /// comment for the full reasoning (menubar wins over app-menu, the
+ /// `appmenu-gtk-module` Unity-shim case `classify_menu_source` exists
+ /// for). No bus name means no menu at all, so this returns `None`
+ /// rather than a `GlobalMenu` with an empty `bus_name`.
+ pub(super) fn read_global_menu(&self, xid: XWindow) -> Option<srdwm_core::GlobalMenu> {
+ let read_string = |atom: u32| -> Option<String> {
+ let reply = self.conn.get_property(false, xid, atom, x11rb::protocol::xproto::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())
+ };
+
+ // Checked before anything GTK-atom-related - see `xwayland.rs`'s
+ // identical check in its own `read_global_menu` for why: these two
+ // are already a complete address on their own, and a Qt app under
+ // a KDE Plasma session never sets `_GTK_UNIQUE_BUS_NAME` at all.
+ if let (Some(bus_name), Some(menu_path)) = (read_string(self.atoms._KDE_NET_WM_APPMENU_SERVICE_NAME), read_string(self.atoms._KDE_NET_WM_APPMENU_OBJECT_PATH)) {
+ return Some(srdwm_core::GlobalMenu { bus_name, menu_path: Some(menu_path), app_path: None, window_path: None, source: srdwm_core::MenuSource::DbusMenu });
+ }
+
+ let bus_name = read_string(self.atoms._GTK_UNIQUE_BUS_NAME)?;
+ let app_path = read_string(self.atoms._GTK_APPLICATION_OBJECT_PATH);
+ let window_path = read_string(self.atoms._GTK_WINDOW_OBJECT_PATH);
+ let is_real_gtk_application = app_path.is_some() || window_path.is_some();
+ let gtk_menu_path = read_string(self.atoms._GTK_MENUBAR_OBJECT_PATH).or_else(|| read_string(self.atoms._GTK_APP_MENU_OBJECT_PATH));
+ let unity_path = read_string(self.atoms._UNITY_OBJECT_PATH);
+ let (menu_path, source) = srdwm_core::classify_menu_source(gtk_menu_path, is_real_gtk_application, unity_path);
+ Some(srdwm_core::GlobalMenu { bus_name, menu_path, app_path, window_path, source })
+ }
+
+ /// Refreshes the focused window's `global_menu` from its own X11
+ /// properties - call on every real focus change (`Platform::focus`,
+ /// the single chokepoint every focus path already goes through, same
+ /// role `update_net_active_window` plays for the Wayland backend).
+ /// 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 - reading only at map time would miss a client that finished
+ /// registering a moment later, so this re-reads on every focus instead.
+ /// `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 left over from whichever window was focused before.
+ pub(super) fn refresh_focused_global_menu(&mut self, id: WindowId, client: XWindow) {
+ let menu = self.read_global_menu(client);
+ if let Some(w) = self.wm.borrow_mut().window_mut(id) {
+ w.global_menu = menu;
+ }
+ }
+
+ /// Drains `AppmenuRegistrarState`'s channel and applies every event to
+ /// the matching `Window.global_menu` - call once per `poll_events`
+ /// tick, same as the Wayland backend's `xwayland.rs::apply_registrar_
+ /// events`. `xid_to_core` already maps XID straight to `WindowId` here
+ /// (unlike the Wayland backend, which has to scan for the matching
+ /// `X11Surface`), so this needs no extra lookup structure of its own.
+ pub(super) fn apply_registrar_events(&mut self) {
+ let Some(registrar) = &self.appmenu_registrar else { return };
+ let events = registrar.drain_events();
+ if events.is_empty() {
+ return;
+ }
+ for event in events {
+ let (window_id, menu) = match event {
+ srdwm_platform::RegistrarEvent::Registered { window_id, bus_name, menu_path } => (
+ window_id,
+ Some(srdwm_core::GlobalMenu { bus_name, menu_path: Some(menu_path), app_path: None, window_path: None, source: srdwm_core::MenuSource::DbusMenu }),
+ ),
+ srdwm_platform::RegistrarEvent::Unregistered { window_id } => (window_id, None),
+ };
+ let Some(&id) = self.xid_to_core.get(&window_id) else { continue };
+ if let Some(w) = self.wm.borrow_mut().window_mut(id) {
+ w.global_menu = menu;
+ }
+ }
+ }
+}
diff --git a/crates/x11/src/platform/mod.rs b/crates/x11/src/platform/mod.rs
index eea936f..04c918c 100644
--- a/crates/x11/src/platform/mod.rs
+++ b/crates/x11/src/platform/mod.rs
@@ -55,6 +55,26 @@ x11rb::atom_manager! {
_NET_CLIENT_LIST,
_NET_ACTIVE_WINDOW,
UTF8_STRING,
+ // Global-menu properties - see `read_global_menu`'s doc comment.
+ // A native X11 client is exactly the same GTK/Qt app the Wayland
+ // backend's `xwayland.rs::read_global_menu` already reads these
+ // from (XWayland is just another X server as far as a toolkit is
+ // concerned), so this is the identical atom set for the identical
+ // reason.
+ _GTK_UNIQUE_BUS_NAME,
+ _GTK_APPLICATION_OBJECT_PATH,
+ _GTK_WINDOW_OBJECT_PATH,
+ _GTK_MENUBAR_OBJECT_PATH,
+ _GTK_APP_MENU_OBJECT_PATH,
+ _UNITY_OBJECT_PATH,
+ // KWin's own global-menu property pair - what `libdbusmenu-qt`'s
+ // KDE integration sets. Already a complete, unambiguous
+ // `com.canonical.dbusmenu` address on its own (no classification
+ // needed the way the GTK/Unity atoms above need), and checked
+ // first in `read_global_menu` for exactly that reason - see that
+ // method's doc comment.
+ _KDE_NET_WM_APPMENU_SERVICE_NAME,
+ _KDE_NET_WM_APPMENU_OBJECT_PATH,
}
}
@@ -121,6 +141,15 @@ pub struct X11Platform {
/// itself still starts either way, matching how the Wayland backends
/// already treat this as non-fatal.
ipc: Option<srdwm_platform::IpcServer>,
+ /// `com.canonical.AppMenu.Registrar` - the classic Qt/`appmenu-qt5`
+ /// global-menu source, see `srdwm_platform::appmenu_registrar`'s module
+ /// doc comment. Unlike the Wayland backend (where this is `None` until
+ /// XWayland finishes starting up), a native X11 session always has a
+ /// real X server the moment this struct exists, so it's started
+ /// unconditionally in `connect` - still `Option` because starting the
+ /// D-Bus service itself can independently fail (see that module's own
+ /// `None` handling).
+ appmenu_registrar: Option<srdwm_platform::AppmenuRegistrarState>,
}
@@ -139,6 +168,7 @@ impl ClonedForRender for Option<&CoreWindow> {
mod actions;
mod connect;
mod events;
+mod global_menu;
mod trait_impl;
mod window;
diff --git a/crates/x11/src/platform/trait_impl.rs b/crates/x11/src/platform/trait_impl.rs
index 0a082be..1ff7845 100644
--- a/crates/x11/src/platform/trait_impl.rs
+++ b/crates/x11/src/platform/trait_impl.rs
@@ -34,6 +34,7 @@ impl Platform for X11Platform {
out.push(e);
}
}
+ self.apply_registrar_events();
if let Some(ipc) = self.ipc.as_mut() {
if ipc.poll(&self.wm) {
out.push(Event::WorkspaceChanged);
@@ -106,9 +107,11 @@ impl Platform for X11Platform {
fn focus(&mut self, window: WindowId) -> PlatformResult<()> {
if let Some(frame) = self.frames.get(&window) {
- self.conn.set_input_focus(x11rb::protocol::xproto::InputFocus::POINTER_ROOT, frame.client, x11rb::CURRENT_TIME).map_err(err)?;
- self.conn.change_property32(x11rb::protocol::xproto::PropMode::REPLACE, self.root, self.atoms._NET_ACTIVE_WINDOW, x11rb::protocol::xproto::AtomEnum::WINDOW, &[frame.client]).map_err(err)?;
+ let client = frame.client;
+ self.conn.set_input_focus(x11rb::protocol::xproto::InputFocus::POINTER_ROOT, client, x11rb::CURRENT_TIME).map_err(err)?;
+ self.conn.change_property32(x11rb::protocol::xproto::PropMode::REPLACE, self.root, self.atoms._NET_ACTIVE_WINDOW, x11rb::protocol::xproto::AtomEnum::WINDOW, &[client]).map_err(err)?;
self.conn.flush().map_err(err)?;
+ self.refresh_focused_global_menu(window, client);
}
Ok(())
}
@@ -217,4 +220,12 @@ impl Platform for X11Platform {
self.conn.ungrab_key(0, self.root, ModMask::ANY).map_err(err)?;
Ok(())
}
+
+ fn keyboard_layout(&mut self) -> PlatformResult<String> {
+ Err(PlatformError::Unsupported("keyboard_layout"))
+ }
+
+ fn cycle_keyboard_layout(&mut self) -> PlatformResult<String> {
+ Err(PlatformError::Unsupported("cycle_keyboard_layout"))
+ }
}