diff --git a/README.md b/README.md
index 385a2f5..4e44e0d 100644
--- a/README.md
+++ b/README.md
@@ -11,8 +11,8 @@ Windows 10 version 1703 or later and an OpenGL 3.3-capable driver.
The Linux Wayland preview implements windows, EGL/OpenGL, keyboard/pointer and
text input, clipboard/file drops, portal dialogs/preferences, activation, icons,
-and file watches, initially tested on KDE Plasma. Native menus and X11 remain
-unsupported. It vendors only ABI
+file watches, and Plasma app/window menu export, initially tested on KDE Plasma.
+Native context menus and X11 remain unsupported. It vendors only ABI
headers and protocol bindings and loads system libraries at runtime. Normal
builds need Zig alone; the optional `shell.nix` supplies maintainer tools and
NixOS runtime library paths. See [Linux support and limitations](src/linux/README.md).
diff --git a/src/linux/Bus.zig b/src/linux/Bus.zig
index 9d40ee5..c1acc43 100644
--- a/src/linux/Bus.zig
+++ b/src/linux/Bus.zig
@@ -86,3 +86,10 @@ comptime {
std.debug.assert(@sizeOf(c.dbus_uint64_t) == 8);
std.debug.assert(@sizeOf(Iter) == if (@sizeOf(usize) == 8) 72 else 56);
}
+
+pub fn int(i: *Iter) ?i32 {
+ if (kind(i) != c.DBUS_TYPE_INT32) return null;
+ var value: i32 = 0;
+ c.z_dbus_message_iter_get_basic.?(i, @ptrCast(&value));
+ return value;
+}
diff --git a/src/linux/Input.zig b/src/linux/Input.zig
index 0132457..cad7ba2 100644
--- a/src/linux/Input.zig
+++ b/src/linux/Input.zig
@@ -5,6 +5,7 @@ const n = @import("native");
const native = @import("native.zig");
const runtime = @import("../runtime.zig");
const abi = @import("../abi.zig");
+const KdeAppMenu = @import("KdeAppMenu.zig");
const Keyboard = @import("Keyboard.zig");
const Data = @import("Data.zig");
const Text = @import("Text.zig");
@@ -56,6 +57,7 @@ pub const State = struct {
repeat_delay: u32 = 600,
repeat_at: u64 = 0,
held: [256]bool = @splat(false),
+ menu_keys: [256]bool = @splat(false),
axis: n.uhdk_point = .{ .x = 0, .y = 0 },
wheel: n.uhdk_point = .{ .x = 0, .y = 0 },
axis_source: u32 = c.WL_POINTER_AXIS_SOURCE_WHEEL,
@@ -236,6 +238,16 @@ fn emitKey(value: Key) void {
event.type = n.UHDK_EVENT_KEY;
event.input_id = id();
event.data.key = .{ .physical_key = Keyboard.physical(value.code), .logical_key = if (s.state != null) Keyboard.logical(value.sym) else 0, .modifiers = s.modifiers, .pressed = @intFromBool(value.down), .repeat = @intFromBool(value.repeated) };
+ if (value.code < s.menu_keys.len) {
+ if (s.menu_keys[value.code]) {
+ if (!value.down) s.menu_keys[value.code] = false;
+ return; // Consume the release/repeats even after flags or focus change.
+ }
+ if (value.down and !value.repeated and KdeAppMenu.shortcut(w, event.data.key.logical_key, value.modifiers, event.input_id)) {
+ s.menu_keys[value.code] = true;
+ return;
+ }
+ }
send(w, &event);
// Resolve symbols/text before consuming a latched modifier, while reporting
// the compositor's post-event flags in the observational raw event.
@@ -294,6 +306,7 @@ fn leaveKeyboard() void {
Text.cancel(w);
}
s.key_focus = null;
+ s.menu_keys = @splat(false);
s.modifiers = 0;
s.serial = 0;
if (s.state != null) _ = c.z_xkb_state_update_mask.?(s.state, 0, 0, 0, 0, 0, 0);
diff --git a/src/linux/KdeAppMenu.zig b/src/linux/KdeAppMenu.zig
new file mode 100644
index 0000000..ad6147f
--- /dev/null
+++ b/src/linux/KdeAppMenu.zig
@@ -0,0 +1,504 @@
+//! Optional Plasma appmenu adapter. The session bus transport is shared with
+//! portals; no toolkit, registrar, X11 window ID, or consumer process is required.
+//! All exports and menu references are owned by the native thread.
+const std = @import("std");
+const c = @import("linux_c");
+const n = @import("native");
+const native = @import("native.zig");
+const runtime = @import("../runtime.zig");
+const Resource = @import("../Resource.zig");
+const abi = @import("../abi.zig");
+const Bus = @import("Bus.zig");
+const Portal = @import("Portal.zig");
+const Input = @import("Input.zig");
+const iface = "com.canonical.dbusmenu";
+const prefix = "/org/uhdk/menu/";
+const properties = [_][:0]const u8{ "type", "label", "enabled", "visible", "children-display", "toggle-type", "toggle-state", "shortcut" };
+const Node = struct {
+ item: ?*const n.uhdk_menu_item = null,
+ parent: usize = 0,
+ first: usize = 0,
+ next: usize = 0,
+ label: [:0]const u8 = "",
+ flags: u32 = 0,
+};
+pub const Export = struct {
+ next: ?*Export = null, // Temporary transaction list, never an object registry.
+ window: *native.Window,
+ root: *Resource,
+ arena: std.heap.ArenaAllocator,
+ nodes: std.ArrayList(Node) = .empty,
+ path: [:0]const u8,
+ proxy: ?*c.struct_org_kde_kwin_appmenu = null,
+};
+pub const State = struct {
+ manager: ?*c.struct_org_kde_kwin_appmenu_manager = null,
+ global_name: u32 = 0,
+ sequence: u64 = 0,
+ app_menu: ?*Resource = null,
+};
+fn b() *native.Backend {
+ return native.instance.?;
+}
+fn s() *State {
+ return &b().menus;
+}
+fn same(a: []const u8, z: []const u8) bool {
+ return std.mem.eql(u8, a, z);
+}
+
+pub fn global(registry: ?*c.struct_wl_registry, name: u32, interface: []const u8, version: u32) void {
+ if (!same(interface, "org_kde_kwin_appmenu_manager") or s().manager != null) return;
+ s().manager = @ptrCast(c.wl_registry_bind(registry, name, &c.z_uhdk_org_kde_kwin_appmenu_manager_interface, @min(version, 2)));
+ s().global_name = name;
+ ready();
+}
+pub fn remove(name: u32) void {
+ if (name != s().global_name) return;
+ disconnected();
+ destroyManager();
+}
+fn destroyManager() void {
+ if (s().manager) |manager| {
+ if (c.org_kde_kwin_appmenu_manager_get_version(manager) >= 2) c.org_kde_kwin_appmenu_manager_release(manager) else c.org_kde_kwin_appmenu_manager_destroy(manager);
+ }
+ s().manager = null;
+ s().global_name = 0;
+}
+pub fn ready() void {
+ native.setCapability(n.UHDK_CAP_APP_MENU, s().manager != null and b().portal.ready);
+ var cursor = b().windows;
+ while (cursor) |w| : (cursor = w.next) if (w.menu_export) |e| associate(e);
+}
+fn associate(e: *Export) void {
+ if (e.proxy != null or s().manager == null or !b().portal.ready) return;
+ // The export is already reachable by process() before advertising it.
+ e.proxy = c.org_kde_kwin_appmenu_manager_create(s().manager, e.window.surface) orelse return;
+ c.org_kde_kwin_appmenu_set_address(e.proxy, b().portal.unique.?.ptr, e.path.ptr);
+}
+pub fn disconnected() void {
+ native.setCapability(n.UHDK_CAP_APP_MENU, false);
+ var cursor = b().windows;
+ while (cursor) |w| : (cursor = w.next) if (w.menu_export) |e| unassociate(e);
+}
+fn unassociate(e: *Export) void {
+ if (e.proxy != null) c.org_kde_kwin_appmenu_release(e.proxy);
+ e.proxy = null;
+}
+fn destroy(e: *Export) void {
+ unassociate(e);
+ runtime.release(e.root);
+ e.arena.deinit();
+ b().app.gpa.destroy(e);
+}
+fn add(e: *Export, menu: *Resource, parent: usize) !void {
+ var previous: usize = 0;
+ for (menu.payload.menu.items) |*item| {
+ // Bound work and D-Bus message size even for repeatedly shared subtrees.
+ if (e.nodes.items.len >= 65536) return error.OutOfMemory;
+ const index = e.nodes.items.len;
+ const allocator = e.arena.allocator();
+ // D-BusMenu uses '_' as a mnemonic marker; UHDK labels are literal text.
+ var label: std.ArrayList(u8) = .empty;
+ for (abi.textBytes(item.label)) |byte| {
+ try label.append(allocator, byte);
+ if (byte == '_') try label.append(allocator, '_');
+ }
+ try label.append(allocator, 0);
+ try e.nodes.append(allocator, .{ .item = item, .parent = parent, .label = label.items[0 .. label.items.len - 1 :0], .flags = item.flags });
+ if (previous == 0) e.nodes.items[parent].first = index else e.nodes.items[previous].next = index;
+ previous = index;
+ if (item.submenu) |child| try add(e, @ptrCast(@alignCast(child)), index);
+ }
+}
+fn create(w: *native.Window, root: *Resource) !*Export {
+ const e = try b().app.gpa.create(Export);
+ errdefer b().app.gpa.destroy(e);
+ s().sequence += 1;
+ e.* = .{ .window = w, .root = root, .arena = .init(b().app.gpa), .path = "" };
+ errdefer e.arena.deinit();
+ e.path = try std.fmt.allocPrintSentinel(e.arena.allocator(), prefix ++ "{d}", .{s().sequence}, 0);
+ try e.nodes.append(e.arena.allocator(), .{});
+ try add(e, root, 0);
+ runtime.retain(root);
+ return e;
+}
+fn replace(w: *native.Window, e: ?*Export) void {
+ const old = w.menu_export;
+ w.menu_export = e;
+ if (old) |value| destroy(value);
+ if (e) |value| associate(value);
+}
+pub fn inherit(w: *native.Window) !void {
+ if (s().app_menu) |root| replace(w, try create(w, root));
+}
+pub fn attach(w: ?*native.Window, menu: ?*anyopaque) u32 {
+ const root: ?*Resource = if (menu) |p| @ptrCast(@alignCast(p)) else null;
+ if (root != null) {
+ if (s().manager == null) return n.UHDK_STATUS_UNSUPPORTED;
+ if (b().portal.connection == null) return abi.UNAVAILABLE;
+ }
+ if (w) |window| {
+ const effective = root orelse s().app_menu;
+ const e = if (effective) |r| create(window, r) catch return abi.OOM else null;
+ if (root) |r| runtime.retain(r);
+ if (window.menu_override) |r| runtime.release(r);
+ window.menu_override = root;
+ replace(window, e);
+ } else {
+ // Prepare every replacement before committing; OOM leaves attachments intact.
+ var pending: ?*Export = null;
+ defer while (pending) |e| {
+ pending = e.next;
+ destroy(e);
+ };
+ if (root) |r| {
+ var cursor = b().windows;
+ while (cursor) |window| : (cursor = window.next) if (window.menu_override == null) {
+ const e = create(window, r) catch return abi.OOM;
+ e.next = pending;
+ pending = e;
+ };
+ runtime.retain(r);
+ }
+ if (s().app_menu) |r| runtime.release(r);
+ s().app_menu = root;
+ if (root == null) {
+ var cursor = b().windows;
+ while (cursor) |window| : (cursor = window.next) if (window.menu_override == null) {
+ replace(window, null);
+ };
+ }
+ while (pending) |e| {
+ pending = e.next;
+ e.next = null;
+ replace(e.window, e);
+ }
+ }
+ return abi.OK;
+}
+pub fn forget(w: *native.Window) void {
+ replace(w, null);
+ if (w.menu_override) |root| runtime.release(root);
+ w.menu_override = null;
+}
+pub fn deinit() void {
+ if (s().app_menu) |root| runtime.release(root);
+ s().app_menu = null;
+ destroyManager();
+}
+fn enabled(e: *Export, index: usize) bool {
+ var i = index;
+ while (i != 0) : (i = e.nodes.items[i].parent) if (e.nodes.items[i].item.?.flags & n.UHDK_MENU_DISABLED != 0) return false;
+ return true;
+}
+fn activate(e: *Export, index: usize, input_id: u64) void {
+ const item = e.nodes.items[index].item orelse return;
+ if (!enabled(e, index) or item.flags & n.UHDK_MENU_SEPARATOR != 0 or item.submenu != null) return;
+ var event = std.mem.zeroes(n.z_event);
+ if (item.role == n.UHDK_MENU_ROLE_QUIT) {
+ event.type = n.UHDK_EVENT_QUIT_REQUESTED;
+ n.z_emit(runtime.toAppHandle(b().app), &event);
+ } else if (item.role >= n.UHDK_MENU_ROLE_COPY and item.role <= n.UHDK_MENU_ROLE_REDO) {
+ event.type = n.UHDK_EVENT_EDIT_COMMAND;
+ event.input_id = input_id;
+ event.data.edit.command = @as(u32, n.UHDK_EDIT_COPY) + item.role - n.UHDK_MENU_ROLE_COPY;
+ e.window.text.last_edit = input_id;
+ Input.send(e.window, &event);
+ } else {
+ event.type = n.UHDK_EVENT_MENU_COMMAND;
+ event.data.menu.command_id = item.command_id;
+ Input.send(e.window, &event);
+ }
+}
+fn folded(key: u32) u32 {
+ return if (key >= 'A' and key <= 'Z') key + ('a' - 'A') else key;
+}
+pub fn shortcut(w: *native.Window, key: u32, mods: u32, input_id: u64) bool {
+ const e = w.menu_export orelse return false;
+ if (key == 0) return false;
+ const mask: u32 = n.UHDK_MOD_SHIFT | n.UHDK_MOD_CONTROL | n.UHDK_MOD_ALT | n.UHDK_MOD_SUPER;
+ for (e.nodes.items[1..], 1..) |node, i| {
+ const item = node.item.?;
+ if (item.submenu != null or item.flags & n.UHDK_MENU_SEPARATOR != 0 or !enabled(e, i)) continue;
+ if (folded(item.shortcut_key) == folded(key) and item.shortcut_modifiers & mask == mods & mask) {
+ activate(e, i, input_id);
+ return true;
+ }
+ }
+ return false;
+}
+fn nodeId(e: *Export, value: i32) ?usize {
+ return if (value >= 0 and value < e.nodes.items.len) @intCast(value) else null;
+}
+fn variant(i: *Bus.Iter, comptime sig: [:0]const u8, value: anytype) !void {
+ var v = try Bus.open(i, c.DBUS_TYPE_VARIANT, sig);
+ errdefer c.z_dbus_message_iter_abandon_container_if_open.?(i, &v);
+ try Bus.basic(&v, sig[0], value);
+ try Bus.close(i, &v);
+}
+fn keyName(key: u32, buffer: *[32]u8) ?[:0]const u8 {
+ if (key >= n.UHDK_KEY_F1 and key < n.UHDK_KEY_F1 + 24) return std.fmt.bufPrintZ(buffer, "F{d}", .{key - n.UHDK_KEY_F1 + 1}) catch unreachable;
+ switch (key) {
+ n.UHDK_KEY_ESCAPE => return "Escape",
+ n.UHDK_KEY_ENTER => return "Return",
+ n.UHDK_KEY_TAB => return "Tab",
+ n.UHDK_KEY_BACKSPACE => return "BackSpace",
+ n.UHDK_KEY_DELETE => return "Delete",
+ n.UHDK_KEY_INSERT => return "Insert",
+ n.UHDK_KEY_LEFT => return "Left",
+ n.UHDK_KEY_RIGHT => return "Right",
+ n.UHDK_KEY_UP => return "Up",
+ n.UHDK_KEY_DOWN => return "Down",
+ n.UHDK_KEY_HOME => return "Home",
+ n.UHDK_KEY_END => return "End",
+ n.UHDK_KEY_PAGE_UP => return "Page_Up",
+ n.UHDK_KEY_PAGE_DOWN => return "Page_Down",
+ ' ' => return "space",
+ else => {},
+ }
+ if (key < 0x21 or key > 0x10ffff) return null;
+ const k = if (key >= 'a' and key <= 'z') key - ('a' - 'A') else key;
+ const len = std.unicode.utf8Encode(@intCast(k), buffer) catch return null;
+ buffer[len] = 0;
+ return buffer[0..len :0];
+}
+fn property(e: *Export, index: usize, name: []const u8, out: *Bus.Iter) !bool {
+ const node = e.nodes.items[index];
+ const item = node.item;
+ const flags = if (item) |v| v.flags else 0;
+ if (same(name, "type")) try variant(out, "s", @as([*:0]const u8, if (flags & n.UHDK_MENU_SEPARATOR != 0) "separator" else "standard")) else if (same(name, "label")) try variant(out, "s", node.label.ptr) else if (same(name, "enabled")) try variant(out, "b", @as(u32, @intFromBool(enabled(e, index)))) else if (same(name, "visible")) try variant(out, "b", @as(u32, 1)) else if (same(name, "children-display")) try variant(out, "s", @as([*:0]const u8, if (index == 0 or (item != null and item.?.submenu != null)) "submenu" else "")) else if (same(name, "toggle-type")) try variant(out, "s", @as([*:0]const u8, if (flags & n.UHDK_MENU_CHECKED != 0) "checkmark" else "")) else if (same(name, "toggle-state")) try variant(out, "i", @as(i32, if (flags & n.UHDK_MENU_CHECKED != 0) 1 else 0)) else if (same(name, "shortcut")) {
+ var v = try Bus.open(out, c.DBUS_TYPE_VARIANT, "aas");
+ errdefer c.z_dbus_message_iter_abandon_container_if_open.?(out, &v);
+ var arrays = try Bus.open(&v, c.DBUS_TYPE_ARRAY, "as");
+ errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&v, &arrays);
+ var buffer: [32]u8 = undefined;
+ if (item) |value| if (keyName(value.shortcut_key, &buffer)) |key_name| {
+ var keys = try Bus.open(&arrays, c.DBUS_TYPE_ARRAY, "s");
+ errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&arrays, &keys);
+ inline for (.{ .{ n.UHDK_MOD_CONTROL, "Control" }, .{ n.UHDK_MOD_ALT, "Alt" }, .{ n.UHDK_MOD_SHIFT, "Shift" }, .{ n.UHDK_MOD_SUPER, "Super" } }) |mod| {
+ if (value.shortcut_modifiers & mod[0] != 0) try Bus.string(&keys, mod[1]);
+ }
+ try Bus.string(&keys, key_name.ptr);
+ try Bus.close(&arrays, &keys);
+ };
+ try Bus.close(&v, &arrays);
+ try Bus.close(out, &v);
+ } else return false;
+ return true;
+}
+fn wanted(filter: ?Bus.Iter, name: []const u8) bool {
+ var iter = filter orelse return true;
+ if (Bus.kind(&iter) == c.DBUS_TYPE_INVALID) return true;
+ while (Bus.str(&iter)) |value| {
+ if (same(value, name)) return true;
+ if (!Bus.next(&iter)) break;
+ }
+ return false;
+}
+fn props(e: *Export, index: usize, filter: ?Bus.Iter, out: *Bus.Iter) !void {
+ var dict = try Bus.open(out, c.DBUS_TYPE_ARRAY, "{sv}");
+ errdefer c.z_dbus_message_iter_abandon_container_if_open.?(out, &dict);
+ for (properties) |name| if (wanted(filter, name)) {
+ var pair = try Bus.open(&dict, c.DBUS_TYPE_DICT_ENTRY, null);
+ errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&dict, &pair);
+ try Bus.string(&pair, name.ptr);
+ _ = try property(e, index, name, &pair);
+ try Bus.close(&dict, &pair);
+ };
+ try Bus.close(out, &dict);
+}
+fn layout(e: *Export, index: usize, depth: i32, filter: Bus.Iter, out: *Bus.Iter) Bus.Error!void {
+ var tuple = try Bus.open(out, c.DBUS_TYPE_STRUCT, null);
+ errdefer c.z_dbus_message_iter_abandon_container_if_open.?(out, &tuple);
+ try Bus.basic(&tuple, c.DBUS_TYPE_INT32, @as(i32, @intCast(index)));
+ try props(e, index, filter, &tuple);
+ var children = try Bus.open(&tuple, c.DBUS_TYPE_ARRAY, "v");
+ errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&tuple, &children);
+ if (depth != 0) {
+ var cursor = e.nodes.items[index].first;
+ while (cursor != 0) : (cursor = e.nodes.items[cursor].next) {
+ var v = try Bus.open(&children, c.DBUS_TYPE_VARIANT, "(ia{sv}av)");
+ errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&children, &v);
+ try layout(e, cursor, if (depth < 0) -1 else depth - 1, filter, &v);
+ try Bus.close(&children, &v);
+ }
+ }
+ try Bus.close(&tuple, &children);
+ try Bus.close(out, &tuple);
+}
+fn groupItem(e: *Export, index: usize, filter: ?Bus.Iter, out: *Bus.Iter) !void {
+ var tuple = try Bus.open(out, c.DBUS_TYPE_STRUCT, null);
+ errdefer c.z_dbus_message_iter_abandon_container_if_open.?(out, &tuple);
+ try Bus.basic(&tuple, c.DBUS_TYPE_INT32, @as(i32, @intCast(index)));
+ try props(e, index, filter, &tuple);
+ try Bus.close(out, &tuple);
+}
+pub fn updateFlags() u32 {
+ var cursor = b().windows;
+ while (cursor) |w| : (cursor = w.next) if (w.menu_export) |e| {
+ changed(e) catch return abi.OOM;
+ };
+ return abi.OK;
+}
+fn changed(e: *Export) !void {
+ var dirty = false;
+ for (e.nodes.items[1..]) |node| if (node.flags != node.item.?.flags) {
+ dirty = true;
+ break;
+ };
+ if (!dirty) return;
+ if (b().portal.ready and e.proxy != null) {
+ const message = c.z_dbus_message_new_signal.?(e.path.ptr, iface, "ItemsPropertiesUpdated") orelse return error.OutOfMemory;
+ defer c.z_dbus_message_unref.?(message);
+ var out = Bus.append(message);
+ var updates = try Bus.open(&out, c.DBUS_TYPE_ARRAY, "(ia{sv})");
+ errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&out, &updates);
+ // Include descendants: a disabled ancestor also disables their activation.
+ for (e.nodes.items[1..], 1..) |_, i| try groupItem(e, i, null, &updates);
+ try Bus.close(&out, &updates);
+ var removed = try Bus.open(&out, c.DBUS_TYPE_ARRAY, "(ias)");
+ try Bus.close(&out, &removed);
+ _ = try Portal.send(message);
+ }
+ for (e.nodes.items[1..]) |*node| node.flags = node.item.?.flags;
+}
+fn fail(message: *Bus.Message, name: [*:0]const u8) !void {
+ if (c.z_dbus_message_get_no_reply.?(message) != 0) return;
+ const reply = c.z_dbus_message_new_error.?(message, name, "Menu request could not be handled") orelse return error.OutOfMemory;
+ defer c.z_dbus_message_unref.?(reply);
+ _ = try Portal.send(reply);
+}
+const invalid = "org.freedesktop.DBus.Error.InvalidArgs";
+const missing = "com.canonical.dbusmenu.Error.InvalidMenuItem";
+fn callIs(message: *Bus.Message, interface: []const u8, member: []const u8) bool {
+ const a = c.z_dbus_message_get_interface.?(message);
+ const z = c.z_dbus_message_get_member.?(message);
+ return a != null and z != null and same(std.mem.span(a), interface) and same(std.mem.span(z), member);
+}
+fn signature(message: *Bus.Message, sig: [*:0]const u8) bool {
+ return c.z_dbus_message_has_signature.?(message, sig) != 0;
+}
+fn globalProperty(name: []const u8, out: *Bus.Iter) !bool {
+ if (same(name, "Version")) try variant(out, "u", @as(u32, 3)) else if (same(name, "TextDirection")) try variant(out, "s", @as([*:0]const u8, "ltr")) else if (same(name, "Status")) try variant(out, "s", @as([*:0]const u8, "normal")) else if (same(name, "IconThemePath")) {
+ var v = try Bus.open(out, c.DBUS_TYPE_VARIANT, "as");
+ errdefer c.z_dbus_message_iter_abandon_container_if_open.?(out, &v);
+ var paths = try Bus.open(&v, c.DBUS_TYPE_ARRAY, "s");
+ try Bus.close(&v, &paths);
+ try Bus.close(out, &v);
+ } else return false;
+ return true;
+}
+pub fn process(message: *Bus.Message) !bool {
+ if (c.z_dbus_message_get_type.?(message) != c.DBUS_MESSAGE_TYPE_METHOD_CALL) return false;
+ const path = c.z_dbus_message_get_path.?(message);
+ if (path == null or !std.mem.startsWith(u8, std.mem.span(path), prefix)) return false;
+ var found: ?*Export = null;
+ var cursor = b().windows;
+ while (cursor) |w| : (cursor = w.next) if (w.menu_export) |e| {
+ if (same(e.path, std.mem.span(path))) {
+ found = e;
+ break;
+ }
+ };
+ if (found) |e| try dispatch(e, message) else try fail(message, "org.freedesktop.DBus.Error.UnknownObject");
+ return true;
+}
+fn dispatch(e: *Export, message: *Bus.Message) !void {
+ const reply = c.z_dbus_message_new_method_return.?(message) orelse return error.OutOfMemory;
+ defer c.z_dbus_message_unref.?(reply);
+ var out = Bus.append(reply);
+ var args = Bus.parse(message);
+ if (callIs(message, "org.freedesktop.DBus.Introspectable", "Introspect")) {
+ if (!signature(message, "")) return fail(message, invalid);
+ try Bus.string(&out, @embedFile("dbusmenu.xml"));
+ } else if (callIs(message, "org.freedesktop.DBus.Peer", "Ping")) {
+ if (!signature(message, "")) return fail(message, invalid);
+ } else if (callIs(message, "org.freedesktop.DBus.Properties", "Get") or callIs(message, "org.freedesktop.DBus.Properties", "GetAll")) {
+ const single = callIs(message, "org.freedesktop.DBus.Properties", "Get");
+ if (!signature(message, if (single) "ss" else "s")) return fail(message, invalid);
+ if (!same(Bus.str(&args).?, iface)) return fail(message, "org.freedesktop.DBus.Error.UnknownInterface");
+ if (single) {
+ _ = Bus.next(&args);
+ if (!try globalProperty(Bus.str(&args).?, &out)) return fail(message, "org.freedesktop.DBus.Error.UnknownProperty");
+ } else {
+ var dict = try Bus.open(&out, c.DBUS_TYPE_ARRAY, "{sv}");
+ errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&out, &dict);
+ for ([_][:0]const u8{ "Version", "TextDirection", "Status", "IconThemePath" }) |name| {
+ var pair = try Bus.open(&dict, c.DBUS_TYPE_DICT_ENTRY, null);
+ errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&dict, &pair);
+ try Bus.string(&pair, name.ptr);
+ _ = try globalProperty(name, &pair);
+ try Bus.close(&dict, &pair);
+ }
+ try Bus.close(&out, &dict);
+ }
+ } else if (callIs(message, iface, "GetLayout")) {
+ if (!signature(message, "iias")) return fail(message, invalid);
+ const index = nodeId(e, Bus.int(&args).?) orelse return fail(message, missing);
+ _ = Bus.next(&args);
+ const depth = Bus.int(&args).?;
+ if (depth < -1) return fail(message, invalid);
+ _ = Bus.next(&args);
+ try Bus.basic(&out, c.DBUS_TYPE_UINT32, @as(u32, 1));
+ try layout(e, index, depth, Bus.child(&args), &out);
+ } else if (callIs(message, iface, "GetGroupProperties")) {
+ if (!signature(message, "aias")) return fail(message, invalid);
+ var ids = Bus.child(&args);
+ _ = Bus.next(&args);
+ const filter = Bus.child(&args);
+ var items = try Bus.open(&out, c.DBUS_TYPE_ARRAY, "(ia{sv})");
+ errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&out, &items);
+ if (Bus.kind(&ids) == c.DBUS_TYPE_INVALID) {
+ for (e.nodes.items, 0..) |_, i| try groupItem(e, i, filter, &items);
+ } else while (Bus.int(&ids)) |value| {
+ if (nodeId(e, value)) |i| try groupItem(e, i, filter, &items);
+ if (!Bus.next(&ids)) break;
+ }
+ try Bus.close(&out, &items);
+ } else if (callIs(message, iface, "GetProperty")) {
+ if (!signature(message, "is")) return fail(message, invalid);
+ const index = nodeId(e, Bus.int(&args).?) orelse return fail(message, missing);
+ _ = Bus.next(&args);
+ if (!try property(e, index, Bus.str(&args).?, &out)) return fail(message, "org.freedesktop.DBus.Error.UnknownProperty");
+ } else if (callIs(message, iface, "Event")) {
+ if (!signature(message, "isvu")) return fail(message, invalid);
+ const index = nodeId(e, Bus.int(&args).?) orelse return fail(message, missing);
+ _ = Bus.next(&args);
+ if (same(Bus.str(&args).?, "clicked")) activate(e, index, Input.id());
+ } else if (callIs(message, iface, "AboutToShow")) {
+ if (!signature(message, "i")) return fail(message, invalid);
+ _ = nodeId(e, Bus.int(&args).?) orelse return fail(message, missing);
+ try Bus.basic(&out, c.DBUS_TYPE_BOOLEAN, @as(u32, 0));
+ } else if (callIs(message, iface, "EventGroup") or callIs(message, iface, "AboutToShowGroup")) {
+ const events = callIs(message, iface, "EventGroup");
+ if (!signature(message, if (events) "a(isvu)" else "ai")) return fail(message, invalid);
+ var ids = Bus.child(&args);
+ if (!events) {
+ var empty = try Bus.open(&out, c.DBUS_TYPE_ARRAY, "i");
+ try Bus.close(&out, &empty);
+ }
+ var errors = try Bus.open(&out, c.DBUS_TYPE_ARRAY, "i");
+ errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&out, &errors);
+ var valid_count: usize = 0;
+ var error_count: usize = 0;
+ while (Bus.kind(&ids) != c.DBUS_TYPE_INVALID) {
+ var value = if (events) Bus.child(&ids) else ids;
+ const number = Bus.int(&value).?;
+ if (nodeId(e, number)) |index| {
+ valid_count += 1;
+ if (events) {
+ _ = Bus.next(&value);
+ if (same(Bus.str(&value).?, "clicked")) activate(e, index, Input.id());
+ }
+ } else {
+ error_count += 1;
+ try Bus.basic(&errors, c.DBUS_TYPE_INT32, number);
+ }
+ if (!Bus.next(&ids)) break;
+ }
+ try Bus.close(&out, &errors);
+ if (valid_count == 0 and error_count != 0) return fail(message, missing);
+ } else return fail(message, "org.freedesktop.DBus.Error.UnknownMethod");
+ if (c.z_dbus_message_get_no_reply.?(message) == 0) _ = try Portal.send(reply);
+}
diff --git a/src/linux/Portal.zig b/src/linux/Portal.zig
index a1ceffa..dece15e 100644
--- a/src/linux/Portal.zig
+++ b/src/linux/Portal.zig
@@ -4,6 +4,7 @@ const std = @import("std");
const c = @import("linux_c");
const n = @import("native");
const native = @import("native.zig");
+const KdeAppMenu = @import("KdeAppMenu.zig");
const Bus = @import("Bus.zig");
const Files = @import("Files.zig");
const Input = @import("Input.zig");
@@ -49,7 +50,7 @@ fn b() *native.Backend {
fn s() *State {
return &b().portal;
}
-fn send(message: *Bus.Message) !u32 {
+pub fn send(message: *Bus.Message) !u32 {
var serial: u32 = 0;
if (c.z_dbus_connection_send.?(s().connection, message, &serial) == 0) return error.OutOfMemory;
return serial;
@@ -151,6 +152,7 @@ fn setOwner(message: *Bus.Message) !void {
s().owner = try b().app.gpa.dupeZ(u8, std.mem.span(sender));
}
fn process(message: *Bus.Message) !void {
+ if (try KdeAppMenu.process(message)) return;
const serial = c.z_dbus_message_get_reply_serial.?(message);
const failed = c.z_dbus_message_get_type.?(message) == c.DBUS_MESSAGE_TYPE_ERROR;
var args = Bus.parse(message);
@@ -172,6 +174,7 @@ fn process(message: *Bus.Message) !void {
match.* = 0;
if (std.mem.allEqual(u32, &s().matches, 0)) {
s().ready = true;
+ KdeAppMenu.ready();
try discover();
}
return;
@@ -513,6 +516,7 @@ fn destroy(p: *Pending) void {
b().app.gpa.destroy(p);
}
fn disconnect() void {
+ KdeAppMenu.disconnected();
while (s().pending) |p| {
complete(p, abi.UNAVAILABLE, null);
destroy(p);
diff --git a/src/linux/README.md b/src/linux/README.md
index 0bc54c5..7eccbf7 100644
--- a/src/linux/README.md
+++ b/src/linux/README.md
@@ -6,9 +6,10 @@ constraints, maximize/fullscreen/minimize requests, show/hide, close requests,
logical/pixel metrics, EGL desktop OpenGL, and local POSIX file references.
Keyboard/pointer input, cursor shapes, XKB composition, text-input-v3 IME,
clipboard, incoming file drops, portal dialogs/URLs/preferences, activation,
-application icons, and inotify file watches are also implemented.
+application icons, inotify file watches, and Plasma app/window menu export are
+also implemented.
-Native menus, a cursor-theme fallback, client-side window decorations, and X11
+Native context menus, a cursor-theme fallback, client-side window decorations, and X11
remain unsupported. Protocol tests cover IME batching; a live Fcitx/IBus CJK
session and other desktops still need qualification. Missing optional protocols
or libraries disable their features without a desktop-name whitelist.
@@ -150,6 +151,29 @@ NixOS with Plasma 6.5.6 and Mesa 25.2.6; other systems are not runtime-qualified
- Icons use xdg-toplevel-icon with square, premultiplied ARGB shared-memory
buffers; rectangular public images receive transparent padding. App IDs and
installed desktop entries still govern normal desktop application identity.
+- Plasma app/window menus use `com.canonical.dbusmenu` on the session bus and
+ KDE's `org_kde_kwin_appmenu_manager` surface association. `APP_MENU` appears
+ after bus discovery when the compositor offers that protocol; it does not
+ promise a visible panel widget. Configure Plasma's Global Menu widget or an
+ application-menu titlebar button to display the export. No Qt or libdbusmenu
+ dependency is required. Other desktops can use this adapter if they implement
+ the same protocols; otherwise the menu capability remains false.
+- An app menu supplies each window's default. A window menu overrides it;
+ clearing the override restores the default. New windows inherit the app menu.
+ Attachments retain the menu tree after callers release their references.
+ Each replacement has a new object path; stale calls cannot activate a new
+ menu. Enabled/checked changes are signalled to consumers, and disabled parents
+ block their descendants. Labels are literal UTF-8, with mnemonic markers
+ escaped. The current API has no separate checkable/radio or RTL setting.
+- Menu accelerators use logical keys and Shift/Control/Alt/Super, ignoring lock
+ flags and ASCII letter case. They dispatch menu/edit/quit events before raw
+ keys or text; matching key releases and repeats are consumed. They remain
+ active without a menu consumer. COPY through REDO roles emit edit commands for
+ the associated window; QUIT requests application quit. Ordinary commands keep
+ their full 64-bit IDs. Menu export is bounded to 65,535 item occurrences per
+ window. Session-bus loss removes exports/capability but keeps local shortcuts;
+ reconnecting to a replaced session bus requires restarting the application.
+ Restarting just the menu consumer does not require reattachment.
- File watches use inotify. A document watch follows its named path through
atomic replacement by observing its parent. Directory watches observe immediate
children. Queue overflow requests a rescan; lost roots are invalidated. Each
@@ -169,6 +193,9 @@ python3 src/linux/tests/disconnect.py zig-out/bin/linux-native-tests
# In the optional nix-shell, or with dbus-next/dbus-daemon installed:
python3 src/linux/tests/wayland_fixture.py zig-out/bin/linux-service-tests
python3 src/linux/tests/portal_fixture.py zig-out/bin/linux-service-tests
+python3 src/linux/tests/menu_fixture.py zig-out/bin/linux-service-tests
+# Opens a supplied app that exports a Quit or Quit … item, then activates that item:
+python3 src/linux/tests/menu_live.py /path/to/menu-enabled-app
zig-out/bin/linux-service-tests desktop # live portal discovery/icons/file watches
python3 src/linux/tools/protocols.py --check
```
@@ -184,7 +211,11 @@ The service fixtures own their compositor socket and D-Bus daemon. They exercise
keys/repeat/dead-key composition, IME revisions/deletion/preedit, scrolling,
clipboard size limits/cancellation/invalid UTF-8/early reader exit, URI drops,
activation, portal open/save options, live settings, response-before-reply,
-cancellation with a late alternate handle, parent destruction and portal restart. They never open a real chooser
+cancellation with a late alternate handle, parent destruction and portal restart.
+The menu fixture covers D-BusMenu layout/properties/errors, state signals, window
+overrides/defaults, retained trees, stale paths, consumer restarts, 64-bit command
+IDs, role dispatch, shortcut consumption, missing protocol/bus and bus loss. The live menu check verifies KWin
+association and D-Bus round trips, not visual panel/titlebar presentation. They never open a real chooser
or browser or inject input into the user's desktop. The live service mode checks
portal discovery, icon submission and atomic-save watch behavior without touching
the user's clipboard. These fixtures supplement live desktop testing; they do
diff --git a/src/linux/dbus-imports.h b/src/linux/dbus-imports.h
index 240d412..fe8277a 100644
--- a/src/linux/dbus-imports.h
+++ b/src/linux/dbus-imports.h
@@ -67,8 +67,10 @@ extern void (*z_dbus_message_iter_abandon_container_if_open)(
);
extern dbus_bool_t (*z_dbus_threads_init_default)(void);
extern DBusMessage *(*z_dbus_message_new_method_return)(DBusMessage *message);
-extern DBusMessage *(*z_dbus_message_new_error)(DBusMessage *message, const char *name, const char *text);
-extern DBusMessage *(*z_dbus_message_new_signal)(const char *path, const char *iface, const char *name);
+extern DBusMessage
+ *(*z_dbus_message_new_error)(DBusMessage *message, const char *name, const char *text);
+extern DBusMessage
+ *(*z_dbus_message_new_signal)(const char *path, const char *iface, const char *name);
extern const char *(*z_dbus_message_get_interface)(DBusMessage *message);
extern const char *(*z_dbus_message_get_member)(DBusMessage *message);
extern dbus_bool_t (*z_dbus_message_get_no_reply)(DBusMessage *message);
diff --git a/src/linux/dbusmenu.xml b/src/linux/dbusmenu.xml
new file mode 100644
index 0000000..2ecb0da
--- /dev/null
+++ b/src/linux/dbusmenu.xml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/linux/native.zig b/src/linux/native.zig
index 07bff60..f3389f3 100644
--- a/src/linux/native.zig
+++ b/src/linux/native.zig
@@ -12,6 +12,7 @@ const Desktop = @import("Desktop.zig");
const Portal = @import("Portal.zig");
const Watch = @import("Watch.zig");
const Data = @import("Data.zig");
+const KdeAppMenu = @import("KdeAppMenu.zig");
const Text = @import("Text.zig");
pub const Window = struct {
@@ -45,6 +46,8 @@ pub const Window = struct {
flags: u32,
cursor: u32 = n.UHDK_CURSOR_ARROW,
text: Text.State = .{},
+ menu_override: ?*@import("../Resource.zig") = null,
+ menu_export: ?*KdeAppMenu.Export = null,
drop_enabled: bool = false,
metrics: n.uhdk_window_metrics = std.mem.zeroes(n.uhdk_window_metrics),
};
@@ -66,6 +69,7 @@ pub const Backend = struct {
watches: ?*Watch.Watch = null,
portal: Portal.State = .{},
desktop: Desktop.State = .{},
+ menus: KdeAppMenu.State = .{},
capabilities: std.atomic.Value(u32) = .init(0),
running: bool = true,
failed: std.atomic.Value(bool) = .init(false),
@@ -179,6 +183,7 @@ export fn z_native_finish() void {
std.debug.assert(b.windows == null);
while (b.watches) |w| Watch.destroy(w);
Desktop.deinit();
+ KdeAppMenu.deinit();
Portal.deinit();
Data.deinit();
Input.deinit();
@@ -309,6 +314,7 @@ fn registryGlobal(data: ?*anyopaque, registry: ?*c.struct_wl_registry, name: u32
Input.global(registry, name, str, version);
Data.global(registry, name, str, version);
Portal.global(registry, name, str, version);
+ KdeAppMenu.global(registry, name, str, version);
Desktop.global(registry, name, str, version);
if (std.mem.eql(u8, str, "wl_compositor") and version >= 4) {
b.compositor = @ptrCast(c.wl_registry_bind(registry, name, &c.z_uhdk_wl_compositor_interface, @min(version, 6)));
@@ -325,6 +331,7 @@ fn registryGlobal(data: ?*anyopaque, registry: ?*c.struct_wl_registry, name: u32
}
fn registryRemove(_: ?*anyopaque, _: ?*c.struct_wl_registry, name: u32) callconv(.c) void {
Input.remove(name);
+ KdeAppMenu.remove(name);
}
const registry_listener: c.struct_wl_registry_listener = .{ .global = registryGlobal, .global_remove = registryRemove };
fn ping(_: ?*anyopaque, shell: ?*c.struct_xdg_wm_base, serial: u32) callconv(.c) void {
@@ -481,6 +488,7 @@ fn setupWindow(b: *Backend, w: *Window, desc: n.uhdk_window_desc) u32 {
c.xdg_toplevel_set_title(w.top, title);
c.xdg_toplevel_set_app_id(w.top, app_id);
Desktop.applyIcon(w);
+ KdeAppMenu.inherit(w) catch return abi.OOM;
if (desc.parent) |parent| {
const p: *Window = @ptrCast(@alignCast(n.z_object_native(parent) orelse return abi.UNAVAILABLE));
c.xdg_toplevel_set_parent(w.top, p.top);
@@ -515,6 +523,7 @@ fn setLimits(w: *Window) void {
fn destroyWindow(w: *Window) void {
const b = instance.?;
std.debug.assert(w.egl == null);
+ KdeAppMenu.forget(w);
Desktop.forget(w);
Data.forget(w);
Input.forget(w);
@@ -547,6 +556,8 @@ export fn z_native_execute(_: ?*n.uhdk_app, raw: [*c]const n.z_command, id: u64,
}
if (b.failed.load(.acquire)) return abi.UNAVAILABLE;
if (cmd.op == n.Z_WINDOW) return createWindow(b, cmd, id);
+ if (cmd.op == n.Z_APP_MENU) return KdeAppMenu.attach(null, cmd.other);
+ if (cmd.op == n.Z_MENU_FLAGS) return KdeAppMenu.updateFlags();
if (cmd.op == n.Z_ICON) return Desktop.setIcon(cmd.image);
if (cmd.op == n.Z_DIALOG or cmd.op == n.Z_URL) return Portal.request(cmd, id);
if (cmd.op == n.Z_WATCH) return Watch.create(cmd, &result.*);
@@ -555,6 +566,7 @@ export fn z_native_execute(_: ?*n.uhdk_app, raw: [*c]const n.z_command, id: u64,
const ptr = if (cmd.target != null) n.z_object_native(cmd.target) else null;
const w: *Window = if (ptr) |p| @ptrCast(@alignCast(p)) else return n.UHDK_STATUS_UNSUPPORTED;
switch (cmd.op) {
+ n.Z_WINDOW_MENU => return KdeAppMenu.attach(w, cmd.other),
n.Z_TITLE => {
const title = b.app.gpa.dupeZ(u8, abi.textBytes(cmd.text)) catch return abi.OOM;
defer b.app.gpa.free(title);
diff --git a/src/linux/tests/menu_fixture.py b/src/linux/tests/menu_fixture.py
new file mode 100644
index 0000000..919dcaa
--- /dev/null
+++ b/src/linux/tests/menu_fixture.py
@@ -0,0 +1,193 @@
+#!/usr/bin/env python3
+"""Plasma surface association and D-BusMenu on a private bus/compositor.
+
+The consumer is replaced mid-test; menu exports must not depend on its lifetime.
+No input or menu actions are sent to the user's desktop.
+"""
+import asyncio
+import os
+from pathlib import Path
+import queue
+import subprocess
+import sys
+import tempfile
+
+from dbus_next import Message, MessageType, Variant
+from dbus_next.aio import MessageBus
+from wayland_fixture import Compositor, run
+
+IFACE = 'com.canonical.dbusmenu'
+
+
+async def main(binary):
+ config = tempfile.NamedTemporaryFile(mode='w', suffix='.conf')
+ config.write('sessionunix:tmpdir=/tmpEXTERNAL')
+ config.flush()
+ daemon = subprocess.Popen(['dbus-daemon', '--config-file=' + config.name, '--nofork', '--print-address=1'], stdout=subprocess.PIPE, text=True)
+ bus = None
+ runner = None
+ loop = asyncio.get_running_loop()
+ notifications = asyncio.Queue()
+ commands = queue.Queue()
+ addresses = {}
+ surfaces = {}
+ signals = []
+
+ class MenuCompositor(Compositor):
+ globals = Compositor.globals + [('org_kde_kwin_appmenu_manager', 2)]
+
+ def request(self, obj, iface, name, args):
+ super().request(obj, iface, name, args)
+ if iface == 'org_kde_kwin_appmenu_manager' and name == 'create':
+ surfaces[args[0]] = args[1]
+ elif iface == 'org_kde_kwin_appmenu' and name == 'set_address':
+ loop.call_soon_threadsafe(notifications.put_nowait, ('address', surfaces[obj], tuple(args)))
+ elif iface == 'xdg_toplevel' and name == 'set_title' and args[0].endswith('-ready'):
+ loop.call_soon_threadsafe(notifications.put_nowait, ('phase', args[0]))
+
+ def timers(self):
+ # Only inject explicitly requested keys into this client's private socket.
+ while not commands.empty():
+ surface, code, press, enter, future = commands.get_nowait()
+ keyboard = self.ids['wl_keyboard']
+ if enter:
+ self.event(keyboard, 'enter', 200, surface, b'')
+ self.event(keyboard, 'modifiers', 201, 4, 0, 0, 0)
+ self.event(keyboard, 'key', 202, 1, code, int(press))
+ loop.call_soon_threadsafe(future.set_result, None)
+
+ async def phase(name):
+ while True:
+ item = await asyncio.wait_for(notifications.get(), 5)
+ if item[0] == 'address': addresses[item[1]] = item[2]
+ else:
+ assert item == ('phase', name), item
+ return
+
+ async def key(surface, code, press, enter=False):
+ future = loop.create_future()
+ commands.put((surface, code, press, enter, future))
+ await future
+ await asyncio.sleep(.06)
+
+ async def call(address, member, signature='', body=None, interface=IFACE, error=None):
+ reply = await asyncio.wait_for(bus.call(Message(destination=address[0], path=address[1], interface=interface, member=member, signature=signature, body=body or [])), 3)
+ if error:
+ assert reply.message_type == MessageType.ERROR and reply.error_name.endswith(error), reply
+ else:
+ assert reply.message_type == MessageType.METHOD_RETURN, (reply.error_name, reply.body)
+ return reply.body
+
+ async def layout(address, depth=-1, props=None):
+ return (await call(address, 'GetLayout', 'iias', [0, depth, props or []]))[1]
+
+ async def click(address, item, kind='clicked'):
+ await call(address, 'Event', 'isvu', [item, kind, Variant('i', 0), 0])
+ await asyncio.sleep(.03)
+
+ def flatten(node):
+ return [node] + [item for child in node[2] for item in flatten(child.value)]
+
+ async def connect(address):
+ consumer = await MessageBus(bus_address=address).connect()
+ consumer.add_message_handler(lambda m: signals.append(m) if m.message_type == MessageType.SIGNAL and m.interface == IFACE else None)
+ reply = await consumer.call(Message(destination='org.freedesktop.DBus', path='/org/freedesktop/DBus', interface='org.freedesktop.DBus', member='AddMatch', signature='s', body=["type='signal',interface='com.canonical.dbusmenu'"]))
+ assert reply.message_type == MessageType.METHOD_RETURN
+ return consumer
+
+ try:
+ address = daemon.stdout.readline().strip()
+ bus = await connect(address)
+ no_bus_env = dict(os.environ)
+ no_bus_env.pop('DBUS_SESSION_BUS_ADDRESS', None)
+ await asyncio.to_thread(run, binary, 'menu-noprotocol', no_bus_env)
+ await asyncio.to_thread(run, binary, 'menu-nobus', no_bus_env, MenuCompositor)
+ runner = asyncio.create_task(asyncio.to_thread(run, binary, 'menu', dict(os.environ, DBUS_SESSION_BUS_ADDRESS=address), MenuCompositor))
+ await phase('menus-ready')
+ assert len(addresses) == 2
+ a, b = list(addresses)
+ first, override = addresses[a], addresses[b]
+ tree = await layout(first)
+ nodes = flatten(tree)
+ by_label = {v[1]['label'].value: v[0] for v in nodes}
+ assert by_label.keys() >= {'File', 'Open__file', 'Checked', 'Disabled', 'Copy', 'Quit', 'Next'}
+ open_id, toggle, disabled, copy, quit_id, step = [by_label[k] for k in ['Open__file', 'Checked', 'Disabled', 'Copy', 'Quit', 'Next']]
+ assert (await call(first, 'GetProperty', 'is', [open_id, 'shortcut']))[0].value == [['Control', 'O']]
+ assert (await call(first, 'GetProperty', 'is', [toggle, 'toggle-state']))[0].value == 1
+ assert len((await layout(first, 0))[2]) == 0
+ shallow = await layout(first, 1, ['label'])
+ assert all(set(v.value[1]) == {'label'} and not v.value[2] for v in shallow[2])
+ groups = (await call(first, 'GetGroupProperties', 'aias', [[], ['label']]))[0]
+ assert len(groups) == len(nodes)
+ assert all(set(p) == {'label'} for _, p in groups)
+ assert (await call(first, 'GetGroupProperties', 'aias', [[open_id, -1], ['enabled']]))[0] == [[open_id, {'enabled': Variant('b', True)}]]
+ props = (await call(first, 'GetAll', 's', [IFACE], 'org.freedesktop.DBus.Properties'))[0]
+ assert props['Version'].value == 3 and props['IconThemePath'].value == []
+ xml = (await call(first, 'Introspect', interface='org.freedesktop.DBus.Introspectable'))[0]
+ assert 'EventGroup' in xml
+ await call(first, 'Ping', interface='org.freedesktop.DBus.Peer')
+ await call(first, 'GetLayout', 'i', [0], error='InvalidArgs')
+ await call(first, 'GetLayout', 'iias', [0, -2, []], error='InvalidArgs')
+ await call(first, 'GetLayout', 'iias', [-1, -1, []], error='InvalidMenuItem')
+ await call(first, 'GetProperty', 'is', [0, 'invented'], error='UnknownProperty')
+ await call(first, 'MissingMethod', error='UnknownMethod')
+ assert await call(first, 'AboutToShow', 'i', [0]) == [False]
+ assert await call(first, 'AboutToShowGroup', 'ai', [[0, -1]]) == [[], [-1]]
+ await call(first, 'AboutToShowGroup', 'ai', [[-1]], error='InvalidMenuItem')
+ assert await call(first, 'EventGroup', 'a(isvu)', [[[0, 'opened', Variant('s', ''), 0], [-1, 'clicked', Variant('i', 0), 0]]]) == [[-1]]
+ await call(first, 'EventGroup', 'a(isvu)', [[[-1, 'clicked', Variant('i', 0), 0]]], error='InvalidMenuItem')
+ for i in [0, by_label['File'], disabled] + [v[0] for v in nodes if v[1]['type'].value == 'separator']:
+ await click(first, i)
+ await click(first, open_id, 'hovered')
+ await click(first, open_id)
+ await click(first, copy)
+ await click(first, quit_id)
+ await click(override, 1)
+ await click(first, step)
+ await phase('flags-ready')
+ assert any(m.member == 'ItemsPropertiesUpdated' for m in signals)
+ assert not (await call(first, 'GetProperty', 'is', [open_id, 'enabled']))[0].value
+ assert (await call(first, 'GetProperty', 'is', [toggle, 'toggle-type']))[0].value == ''
+ await click(first, open_id) # Disabled ancestor blocks activation.
+ await key(a, 24, True, True)
+ await key(a, 24, False) # Disabled shortcut must reach raw key handling.
+ await click(first, step)
+ await phase('restored-ready')
+ inherited = addresses[b]
+ assert inherited != override
+ await call(override, 'GetLayout', 'iias', [0, -1, []], error='UnknownObject')
+ # A newly started consumer can obtain complete state without registration.
+ bus.disconnect()
+ bus = await connect(address)
+ assert (await layout(inherited))[1]['children-display'].value == 'submenu'
+ await key(a, 24, True, True)
+ await asyncio.sleep(.1) # Repeated accelerator downs remain consumed.
+ await key(a, 24, False)
+ await key(a, 46, True)
+ await key(a, 46, False)
+ await key(b, 24, True, True)
+ await key(b, 24, False)
+ await click(first, step)
+ await phase('detached-ready')
+ await call(first, 'GetLayout', 'iias', [0, -1, []], error='UnknownObject')
+ await call(inherited, 'GetLayout', 'iias', [0, -1, []], error='UnknownObject')
+ bus.disconnect()
+ bus = None
+ daemon.terminate()
+ daemon.wait(timeout=5)
+ await asyncio.sleep(.1)
+ # Bus loss removes the capability/association, but local shortcuts survive.
+ await key(b, 49, True, True) # Ctrl+N invokes the retained window override.
+ await asyncio.wait_for(runner, 5)
+ print('D-BusMenu layout/errors/state/consumer restart and KDE association: PASS')
+ finally:
+ if bus: bus.disconnect()
+ daemon.terminate()
+ daemon.wait(timeout=5)
+ config.close()
+ if runner:
+ await runner
+
+
+if __name__ == '__main__':
+ asyncio.run(main(str(Path(sys.argv[1]).resolve())))
diff --git a/src/linux/tests/menu_live.py b/src/linux/tests/menu_live.py
new file mode 100644
index 0000000..2ce12fe
--- /dev/null
+++ b/src/linux/tests/menu_live.py
@@ -0,0 +1,50 @@
+#!/usr/bin/env python3
+"""Open a supplied menu-enabled app on Plasma, query its export, then choose Quit.
+
+The app must export a literal 'Quit' or 'Quit …' item. This checks real KWin association and
+session-bus transport; it does not assert panel or titlebar menu rendering.
+"""
+import asyncio
+import os
+import re
+import sys
+
+from dbus_next import Message, MessageType, Variant
+from dbus_next.aio import MessageBus
+
+
+async def main(binary):
+ process = await asyncio.create_subprocess_exec(binary, env=dict(os.environ, WAYLAND_DEBUG='client'), stderr=asyncio.subprocess.PIPE)
+ bus = None
+ try:
+ async def address():
+ while line := await process.stderr.readline():
+ match = re.search(rb'set_address\("([^"]+)", "([^"]+)"\)', line)
+ if match: return [part.decode() for part in match.groups()]
+ raise AssertionError('Application exited without a KDE menu association')
+ service, path = await asyncio.wait_for(address(), 10)
+ # Keep draining Wayland diagnostics so a full pipe cannot stall the app.
+ drain = asyncio.create_task(process.stderr.read())
+ bus = await MessageBus().connect()
+ reply = await asyncio.wait_for(bus.call(Message(destination=service, path=path, interface='com.canonical.dbusmenu', member='GetLayout', signature='iias', body=[0, -1, []])), 5)
+ assert reply.message_type == MessageType.METHOD_RETURN, reply.body
+ def flatten(node):
+ return [node] + [item for child in node[2] for item in flatten(child.value)]
+ nodes = flatten(reply.body[1])
+ labels = [node[1]['label'].value for node in nodes]
+ quit_id = next(node[0] for node in nodes if node[1]['label'].value == 'Quit' or node[1]['label'].value.startswith('Quit '))
+ print('Live KWin association and menu layout:', labels)
+ reply = await bus.call(Message(destination=service, path=path, interface='com.canonical.dbusmenu', member='Event', signature='isvu', body=[quit_id, 'clicked', Variant('i', 0), 0]))
+ assert reply.message_type == MessageType.METHOD_RETURN
+ assert await asyncio.wait_for(process.wait(), 5) == 0
+ await drain
+ print('Live Plasma menu export / Quit activation: PASS')
+ finally:
+ if bus: bus.disconnect()
+ if process.returncode is None:
+ process.kill()
+ await process.wait()
+
+
+if __name__ == '__main__':
+ asyncio.run(main(sys.argv[1]))
diff --git a/src/linux/tests/menus.c b/src/linux/tests/menus.c
new file mode 100644
index 0000000..ad1afcf
--- /dev/null
+++ b/src/linux/tests/menus.c
@@ -0,0 +1,159 @@
+/* Included by the service harness: all desktop traffic uses private fixtures. */
+#define OPEN_ID UINT64_C(0x1234567800000065)
+static void menu_phase(uhdk_app *app, uhdk_window *w, const char *phase) {
+ uint64_t id;
+ OK(uhdk_window_set_title_async(w, text(phase), &id));
+ OK(wait_result(app, id).status);
+}
+static void menus(uhdk_app *app) {
+ wait_capability(app, UHDK_CAP_APP_MENU);
+ assert(!uhdk_app_has_capability(app, UHDK_CAP_CONTEXT_MENU));
+ uhdk_menu_item children[] = {
+ {.command_id = OPEN_ID,
+ .label = {"Open_file", 9},
+ .shortcut_key = 'o',
+ .shortcut_modifiers = UHDK_MOD_CONTROL},
+ {.command_id = 102, .label = {"Checked", 7}, .flags = UHDK_MENU_CHECKED},
+ {.command_id = 103, .label = {"Disabled", 8}, .flags = UHDK_MENU_DISABLED},
+ {.role = UHDK_MENU_ROLE_COPY,
+ .label = {"Copy", 4},
+ .shortcut_key = 'c',
+ .shortcut_modifiers = UHDK_MOD_CONTROL},
+ {.role = UHDK_MENU_ROLE_QUIT, .label = {"Quit", 4}},
+ {.flags = UHDK_MENU_SEPARATOR},
+ };
+ uhdk_menu *child, *root;
+ OK(uhdk_menu_create(app, children, sizeof(children) / sizeof(*children), &child));
+ uhdk_menu_item roots[] = {
+ {.command_id = 10, .label = {"File", 4}, .submenu = child},
+ {.command_id = 900, .label = {"Next", 4}},
+ };
+ OK(uhdk_menu_create(app, roots, 2, &root));
+ uhdk_menu_release(child);
+ uint64_t id;
+ OK(uhdk_app_set_menu_async(app, root, &id));
+ OK(wait_result(app, id).status);
+ uhdk_window *a = window(app), *b = window(app);
+ uhdk_menu_item overrides[] = {
+ {.command_id = 200, .label = {"Window action", 13}},
+ {.command_id = 900,
+ .label = {"Next", 4},
+ .shortcut_key = 'n',
+ .shortcut_modifiers = UHDK_MOD_CONTROL},
+ };
+ uhdk_menu *override;
+ OK(uhdk_menu_create(app, overrides, 2, &override));
+ OK(uhdk_window_set_menu_async(b, override, &id));
+ OK(wait_result(app, id).status);
+ uhdk_menu_release(override); // The attachment must keep its model alive.
+ OK(uhdk_window_set_text_input_async(a, 1, &id));
+ OK(wait_result(app, id).status);
+ OK(uhdk_menu_popup_async(root, a, (uhdk_point){0, 0}, &id));
+ assert(wait_result(app, id).status == UHDK_STATUS_UNSUPPORTED);
+ menu_phase(app, a, "menus-ready");
+ unsigned stage = 0, opens_a = 0, opens_b = 0, copies = 0, quits = 0, overrides_seen = 0,
+ raw_o = 0;
+ uint64_t end = deadline(app);
+ while (stage < 4 && uhdk_app_get_time_ns(app) < end) {
+ uhdk_event_list events;
+ OK(uhdk_app_poll_events(app, 50000000, &events));
+ int next = 0;
+ for (uint64_t i = 0; i < events.len; ++i) {
+ const uhdk_event *e = &events.ptr[i];
+ if (e->type == UHDK_EVENT_MENU_COMMAND) {
+ uint64_t command = ((const uhdk_menu_event *)e->data)->command_id;
+ if (command == OPEN_ID) {
+ assert(stage == 0 || stage == 2);
+ if (e->window == a)
+ ++opens_a;
+ else {
+ assert(e->window == b);
+ ++opens_b;
+ }
+ } else if (command == 200) {
+ assert(e->window == b);
+ ++overrides_seen;
+ } else {
+ assert(command == 900);
+ next = 1;
+ }
+ } else if (e->type == UHDK_EVENT_EDIT_COMMAND) {
+ const uhdk_edit_event *edit = e->data;
+ assert(edit->command == UHDK_EDIT_COPY && e->window == a && e->input_id != 0);
+ ++copies;
+ } else if (e->type == UHDK_EVENT_QUIT_REQUESTED)
+ ++quits;
+ else if (e->type == UHDK_EVENT_TEXT_COMMIT)
+ assert(!"accelerator leaked text");
+ else if (e->type == UHDK_EVENT_KEY) {
+ const uhdk_key_event *key = e->data;
+ if (key->logical_key == 'o' || key->logical_key == 'O') {
+ assert(stage == 1);
+ ++raw_o;
+ }
+ assert(key->logical_key != 'c' && key->logical_key != 'C');
+ }
+ }
+ if (!next)
+ continue;
+ ++stage;
+ if (stage == 1) {
+ assert(
+ opens_a == 1 && opens_b == 0 && copies == 1 && quits == 1 && overrides_seen == 1
+ );
+ OK(uhdk_menu_set_item_flags_async(root, 10, UHDK_MENU_DISABLED, &id));
+ OK(wait_result(app, id).status);
+ OK(uhdk_menu_set_item_flags_async(root, 102, 0, &id));
+ OK(wait_result(app, id).status);
+ menu_phase(app, a, "flags-ready");
+ } else if (stage == 2) {
+ assert(raw_o >= 2);
+ OK(uhdk_menu_set_item_flags_async(root, 10, 0, &id));
+ OK(wait_result(app, id).status);
+ OK(uhdk_window_set_menu_async(b, NULL, &id));
+ OK(wait_result(app, id).status);
+ menu_phase(app, a, "restored-ready");
+ } else if (stage == 3) {
+ assert(opens_a == 2 && opens_b == 1 && copies == 2);
+ // Override survives clearing the app default, then the first window dies.
+ OK(uhdk_menu_create(app, overrides, 2, &override));
+ OK(uhdk_window_set_menu_async(b, override, &id));
+ OK(wait_result(app, id).status);
+ uhdk_menu_release(override);
+ OK(uhdk_app_set_menu_async(app, NULL, &id));
+ OK(wait_result(app, id).status);
+ uhdk_menu_release(root);
+ uhdk_window_destroy(a);
+ menu_phase(app, b, "detached-ready");
+ }
+ }
+ assert(stage == 4);
+ assert(!uhdk_app_has_capability(app, UHDK_CAP_APP_MENU));
+ uhdk_window_destroy(b);
+ puts("Plasma menu routing/state/lifetime/shortcut fixture: PASS");
+}
+#undef OPEN_ID
+
+static void menus_unavailable(uhdk_app *app, int no_bus) {
+ uhdk_window *w = window(app);
+ uhdk_menu_item item = {.command_id = 1, .label = {"Unavailable", 11}};
+ uhdk_menu *menu;
+ OK(uhdk_menu_create(app, &item, 1, &menu));
+ uint64_t id;
+ OK(uhdk_app_set_menu_async(app, menu, &id));
+ assert(
+ wait_result(app, id).status == (no_bus ? UHDK_STATUS_UNAVAILABLE : UHDK_STATUS_UNSUPPORTED)
+ );
+ OK(uhdk_window_set_menu_async(w, menu, &id));
+ assert(
+ wait_result(app, id).status == (no_bus ? UHDK_STATUS_UNAVAILABLE : UHDK_STATUS_UNSUPPORTED)
+ );
+ assert(!uhdk_app_has_capability(app, UHDK_CAP_APP_MENU));
+ OK(uhdk_menu_set_item_flags_async(menu, 1, UHDK_MENU_CHECKED, &id));
+ OK(wait_result(app, id).status);
+ OK(uhdk_app_set_menu_async(app, NULL, &id));
+ OK(wait_result(app, id).status);
+ uhdk_menu_release(menu);
+ uhdk_window_destroy(w);
+ puts("Unavailable menu protocol/session bus: PASS");
+}
diff --git a/src/linux/tests/native.c b/src/linux/tests/native.c
index 23dcf58..adad69b 100644
--- a/src/linux/tests/native.c
+++ b/src/linux/tests/native.c
@@ -79,7 +79,6 @@ static uhdk_window *create_window(uhdk_app *app) {
static void test_main(uhdk_app *app, void *user) {
++entered;
assert(uhdk_app_get_backend(app) == UHDK_BACKEND_WAYLAND);
- assert(!uhdk_app_has_capability(app, UHDK_CAP_APP_MENU));
assert(!uhdk_app_has_capability(app, UHDK_CAP_FILE_DIALOGS));
uhdk_event_list ready;
OK(uhdk_app_poll_events(app, 0, &ready));
diff --git a/src/linux/tests/services.c b/src/linux/tests/services.c
index 2ccc0de..89a81b2 100644
--- a/src/linux/tests/services.c
+++ b/src/linux/tests/services.c
@@ -363,7 +363,17 @@ static void input(uhdk_app *app) {
uhdk_window_destroy(w);
puts("input/IME/clipboard/drop/activation: PASS");
}
+#include "menus.c"
+
static void run(uhdk_app *app, void *mode) {
+ if (strcmp(mode, "menu-noprotocol") == 0 || strcmp(mode, "menu-nobus") == 0) {
+ menus_unavailable(app, strcmp(mode, "menu-nobus") == 0);
+ return;
+ }
+ if (strcmp(mode, "menu") == 0) {
+ menus(app);
+ return;
+ }
if (strcmp(mode, "input") == 0)
input(app);
else if (strcmp(mode, "portal") == 0)
diff --git a/src/linux/tests/wayland_fixture.py b/src/linux/tests/wayland_fixture.py
index 0815317..efab365 100644
--- a/src/linux/tests/wayland_fixture.py
+++ b/src/linux/tests/wayland_fixture.py
@@ -271,13 +271,13 @@ class Compositor:
self.event(text, 'done', serial)
-def run(binary, mode, env=None):
+def run(binary, mode, env=None, compositor_type=Compositor):
client, server = socket.socketpair()
environment = dict(os.environ if env is None else env, WAYLAND_SOCKET=str(client.fileno()))
if mode == 'input': environment.pop('DBUS_SESSION_BUS_ADDRESS', None)
process = subprocess.Popen([str(Path(binary).resolve()), mode], env=environment, pass_fds=(client.fileno(),))
client.close()
- compositor = Compositor(server, mode == 'input')
+ compositor = compositor_type(server, mode == 'input')
end = time.monotonic() + 20
try:
while process.poll() is None and time.monotonic() < end: