Windowing — Linux DBus

Overview

WIP — works on Wayland-on-Mutter and X11-on-GNOME 3+ when the compositor exposes the GTK menu globals. KDE / non-GNOME compositors fall back to the in-window CSD menu bar. The dbus/ and gnome_menu/ subtrees implement the GTK org.gtk.Menus / org.gtk.Actions DBus protocol so Azul windows can publish their menu bar to GNOME Shell's panel („global menu“). Azul loads libdbus-1.so.3 at runtime — there is no compile-time dependency on libdbus-dev, so cross-compiling from macOS still produces a Linux binary that picks up DBus when the target system has it installed.

Two layers

shell2/linux/
├── dbus/                      ← Generic libdbus-1 dlopen layer
│   ├── mod.rs                 (re-exports DBusLib, DBusConnection, …)
│   └── dlopen.rs              (DynamicLibrary loader for libdbus-1)
└── gnome_menu/                ← Application of dbus/ to GNOME's menu protocol
    ├── mod.rs                 (should_use_gnome_menus() entry point)
    ├── shared_dbus.rs         (OnceLock<Arc<DBusLib>>)
    ├── manager.rs             (GnomeMenuManager — owns the connection)
    ├── menu_conversion.rs     (azul Menu → DbusMenuGroup)
    ├── menu_protocol.rs       (DbusMenuItem / DbusMenuGroup types)
    ├── actions_protocol.rs    (PendingMenuCallback queue)
    ├── protocol_impl.rs       (object-path message handlers)
    └── x11_properties.rs      (sets _GTK_* atoms on the X11 window)

DBusLib — the dlopen layer

DBusLib:

pub struct DBusLib {
    _lib: Library,
    pub dbus_bus_get: unsafe extern "C" fn(c_int, *mut DBusError) -> *mut DBusConnection,
    pub dbus_connection_unref: unsafe extern "C" fn(*mut DBusConnection),
    pub dbus_connection_read_write_dispatch:
        unsafe extern "C" fn(*mut DBusConnection, c_int) -> c_int,
    pub dbus_connection_flush: unsafe extern "C" fn(*mut DBusConnection),
    pub dbus_bus_request_name:
        unsafe extern "C" fn(*mut DBusConnection, *const c_char, c_uint, *mut DBusError) -> c_int,
    pub dbus_connection_register_object_path: unsafe extern "C" fn(
        *mut DBusConnection, *const c_char, *const DBusObjectPathVTable, *mut c_void,
    ) -> c_int,
    pub dbus_message_new_method_return: unsafe extern "C" fn(*mut DBusMessage) -> *mut DBusMessage,
    pub dbus_message_iter_init:        unsafe extern "C" fn(*mut DBusMessage, *mut DBusMessageIter) -> c_int,
    pub dbus_message_iter_get_arg_type: unsafe extern "C" fn(*mut DBusMessageIter) -> c_int,
    // … ~30 more function pointers, one per libdbus-1 entry point we use
}

DBusLib::new calls Library::load("libdbus-1.so.3") then load_first_available::<Library>(&["libdbus-1.so.3", "libdbus-1.so"]) under the hood; each function pointer is populated by load_symbol!.

Constants exposed from the dbus module:

  • DBUS_BUS_SESSION. Connect to the user's session bus rather than the system bus.
  • DBUS_NAME_FLAG_DO_NOT_QUEUE. Refuse to queue if the requested name is already taken.
  • DBUS_HANDLER_RESULT_HANDLED / _NOT_YET_HANDLED / _NEED_MEMORY. Return values from object-path message handlers.
  • DBUS_TYPE_STRING / _UINT32 / _ARRAY / _VARIANT. Argument-type tags used during message marshalling.

Many other type constants (DBUS_TYPE_BOOLEAN, DBUS_TYPE_INT32, DBUS_TYPE_DOUBLE, etc.) are defined but not currently re-exported; the autoreview report flags them as [MEDIUM] Dead Code — they're kept for future protocol expansion.

DBusError is the C-ABI error struct. The dummy* and pad* field names mirror the upstream /usr/include/dbus-1.0/dbus/dbus-types.h — these are not stub fields, they are the layout libdbus expects.

should_use_gnome_menus

The gate:

pub fn should_use_gnome_menus() -> bool {
    if env::var("AZ_DISABLE_GNOME_MENUS").unwrap_or_default() == "1" { return false; }
    let desktop = env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
    if !desktop.to_lowercase().contains("gnome") { return false; }
    if env::var("DBUS_SESSION_BUS_ADDRESS").is_err() { return false; }
    true
}

Both X11 and Wayland windows call this at startup. When false, no DBus connection is opened and the windowing backend renders its menu bar in the title-bar area as part of the client-side decorations.

AZ_GNOME_MENU_DEBUG=1 enables verbose logging via debug_log — useful when GNOME Shell silently ignores a published menu.

GnomeMenuManager

pub struct GnomeMenuManager {
    app_name: String,
    bus_name: String,                                   // "org.gtk.{app_name}"
    object_path: String,                                // "/org/gtk/{app}"
    dbus_lib: Arc<DBusLib>,
    connection: *mut DBusConnection,
    menu_groups: Arc<Mutex<HashMap<u32, DbusMenuGroup>>>,
    actions: Arc<Mutex<HashMap<String, DbusAction>>>,
}

GnomeMenuManager::new(app_name, dbus_lib):

  1. Sanitises app_name ('.' / ' ' / '-' → '_') so it's a valid DBus name component.
  2. Computes bus_name = format!("org.gtk.{}", sanitized) and object_path = format!("/org/gtk/{}", sanitized.replace('_','/')).
  3. dbus_bus_get(DBUS_BUS_SESSION, &mut error) to open the session bus connection.
  4. dbus_bus_request_name(connection, bus_name, DBUS_NAME_FLAG_DO_NOT_QUEUE, &mut error) to claim the name.
  5. register_menus_interface(...) and register_actions_interface(...) to install the message handlers on the object path.

The dbus_lib is loaded once via shared_dbus::get_shared_dbus_lib() — a OnceLock<Option<Arc<DBusLib>>> that ensures a single dlopen per process even with many windows.

The *mut DBusConnection is not wrapped in ArcDrop for GnomeMenuManager calls dbus_connection_unref when the window is closed.

org.gtk.Menus interface

pub struct DbusMenuItem {
    pub label: String,
    pub action: Option<String>,           // "app.{action_name}"
    pub target: Option<String>,           // optional argument
    pub submenu: Option<(u32, u32)>,      // (group_id, menu_id)
    pub section: Option<(u32, u32)>,      // for separators
    pub enabled: bool,
}

pub struct DbusMenuGroup {
    pub group_id: u32,
    pub menu_id: u32,
    pub items: Vec<DbusMenuItem>,
}

MenuConversion::convert_menu walks the recursive azul_core::menu::Menu tree and flattens it: each level of submenus becomes its own DbusMenuGroup with a fresh group_id. The submenu: Some((id, 0)) field on a parent item references the child group by id — that is how GTK reconstructs the hierarchy on the panel side.

extract_actions is the second pass over the same tree: every menu item with a callback becomes a DbusAction named app.{label} (lower-cased + sanitised). The callback itself is kept on DbusAction.menu_callback: Option<CoreMenuCallback> so the event loop can dispatch it later.

The autoreview report flags that DbusMenuItem.enabled may not be serialised into the DBus variant dict — verify against protocol_impl.rs::menus_message_handler before relying on it.

org.gtk.Actions interface

actions_protocol.rs documents the four DBus methods Azul implements on its org.gtk.Actions object:

  • List. Signature () → as. Returns the action-name array.
  • Describe. Signature (s) → (bsav). Returns (enabled, param_type, state) for one action.
  • DescribeAll. Signature () → a{s(bsav)}. Returns all actions with descriptions.
  • Activate. Signature (s, av, a{sv}). Invokes a callback.

Activation runs on the libdbus dispatch thread, not the window's event-loop thread. Calling the Azul callback there is unsafe — it would race the layout pass.

The fix:

pub struct PendingMenuCallback {
    pub action_name: String,
    pub menu_callback: CoreMenuCallback,    // RefAny + fn-ptr
}

static PENDING_MENU_CALLBACKS: LazyLock<Mutex<Vec<PendingMenuCallback>>> =
    LazyLock::new(|| Mutex::new(Vec::new()));

pub fn queue_menu_callback(callback: PendingMenuCallback) { /* push */ }
pub fn drain_pending_menu_callbacks() -> Vec<PendingMenuCallback> { /* take */ }

The DBus handler queues; the X11/Wayland event loop drains and dispatches the callbacks at a safe point (between process_window_events and regenerate_layout).

Object-path registration

register_menus_interface and register_actions_interface install message handlers via dbus_connection_register_object_path:

let vtable = DBusObjectPathVTable {
    unregister_function: Some(menus_unregister_handler),
    message_function:    Some(menus_message_handler),
    ..
};
let state = Box::new(HandlerState { dbus_lib, menu_groups, actions });
dbus_connection_register_object_path(
    connection,
    object_path_cstr.as_ptr(),
    &vtable as *const _,
    Box::into_raw(state) as *mut c_void,
);

The state box is leaked — it must outlive every async DBus message; libdbus passes it back as user_data to the message function. The unregister_function is the cleanup hook, fired by libdbus when the connection drops.

menus_message_handler decodes the incoming DBusMessage, switches on dbus_message_get_member ("Start", "End", "List", etc.), and marshals the response with dbus_message_iter_open_container / dbus_message_iter_append_basic.

X11 window properties

GNOME Shell needs to know which DBus name and object path serves a given window. On X11, this is published through window properties:

  • Atom _GTK_APPLICATION_ID carries the app name string.
  • Atom _GTK_UNIQUE_BUS_NAME carries org.gtk.{app_name}.
  • Atom _GTK_APPLICATION_OBJECT_PATH carries /org/gtk/{app_name_with_slashes}.
  • Atom _GTK_APP_MENU_OBJECT_PATH carries {object_path}/menus/AppMenu.
  • Atom _GTK_MENUBAR_OBJECT_PATH carries {object_path}/menus/MenuBar.

All five atoms are interned via XInternAtom, and values are written with XChangeProperty(format=8, type=UTF8_STRING). GNOME Shell polls these properties when the window maps; once set, the menu bar appears in the panel.

The Wayland equivalent uses the same protocol but a different discovery path (Mutter introspects the org.gtk.* services via org.freedesktop.DBus.ListNames instead of window properties) — the manager publishes identically; the X11-properties step is simply skipped on Wayland.

End-to-end flow

Azul Menu (azul_core::menu::Menu)
   │
   │ MenuConversion::convert_menu
   ▼
Vec<DbusMenuGroup>  +  Vec<DbusAction>
   │                       │
   │  menu_groups Arc      │  actions Arc
   ▼                       ▼
GnomeMenuManager
   │   register_menus_interface (org.gtk.Menus)
   │   register_actions_interface (org.gtk.Actions)
   │   X11Properties::set_properties (X11 only)
   ▼
GNOME Shell ─── panel renders menus
   │
   │  user clicks → DBus org.gtk.Actions.Activate("app.foo", ...)
   ▼
actions_protocol::queue_menu_callback (DBus thread)
   │
   ▼
event loop ── drain_pending_menu_callbacks ── invoke CoreMenuCallback
                                              with full CallbackInfo

Testing without DBus

The handful of unit tests in this subtree all #[cfg_attr(miri, ignore)] because Miri can't dlopen, and they gracefully degrade when libdbus-1.so is not installed (shared_dbus::test_dbus_library_loading reports either „loaded successfully“ or „not available“).

For integration testing on a CI runner without GNOME, set AZ_DISABLE_GNOME_MENUS=1 to force the in-window menu bar path and avoid every code path on this page.

Where to add new menu features

  • Map a new MenuItem variant (e.g., toggleable check). Handle it in the recursive walk in menu_conversion.rs.
  • Add a new DBus method on org.gtk.Actions. Update the interface comment and the message handler in actions_protocol.rs and protocol_impl.rs::actions_message_handler.
  • Support a new desktop-environment-specific atom. Add the atom write in x11_properties.rs::set_properties.
  • Support a non-GNOME menu protocol (KDE Plasma, ...). Add a new sibling module under linux/ and reuse dbus/dlopen.rs.

Coming Up Next

  • Linux Wayland — Linux Wayland shell - wl_surface, xdg-shell, libinput
  • Linux X11 — Linux X11 shell - Xlib, GLX, XInput2
  • Accessibility — Per-platform a11y back-ends - UIA, AT-SPI, NSAccessibility
  • Windowing Overview — Per-window aggregate, headless variant, and the platform shell layer

Back to guide index