Implement Plasma app menu export, activation and local accelerators

This commit is contained in:
Peterino2 2026-09-18 14:33:40 -07:00
parent fdbc99a274
commit b0da270a67
15 changed files with 1067 additions and 11 deletions

View File

@ -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).

View File

@ -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;
}

View File

@ -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);

504
src/linux/KdeAppMenu.zig Normal file
View File

@ -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);
}

View File

@ -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);

View File

@ -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

View File

@ -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);

72
src/linux/dbusmenu.xml Normal file
View File

@ -0,0 +1,72 @@
<!-- UHDK D-BusMenu exporter introspection. -->
<node>
<interface name="com.canonical.dbusmenu">
<property name="Version" type="u" access="read" />
<property name="TextDirection" type="s" access="read" />
<property name="Status" type="s" access="read" />
<property name="IconThemePath" type="as" access="read" />
<method name="GetLayout">
<arg type="i" direction="in" />
<arg type="i" direction="in" />
<arg type="as" direction="in" />
<arg type="u" direction="out" />
<arg type="(ia{sv}av)" direction="out" />
</method>
<method name="GetGroupProperties">
<arg type="ai" direction="in" />
<arg type="as" direction="in" />
<arg type="a(ia{sv})" direction="out" />
</method>
<method name="GetProperty">
<arg type="i" direction="in" />
<arg type="s" direction="in" />
<arg type="v" direction="out" />
</method>
<method name="Event">
<arg type="i" direction="in" />
<arg type="s" direction="in" />
<arg type="v" direction="in" />
<arg type="u" direction="in" />
</method>
<method name="EventGroup">
<arg type="a(isvu)" direction="in" />
<arg type="ai" direction="out" />
</method>
<method name="AboutToShow">
<arg type="i" direction="in" />
<arg type="b" direction="out" />
</method>
<method name="AboutToShowGroup">
<arg type="ai" direction="in" />
<arg type="ai" direction="out" />
<arg type="ai" direction="out" />
</method>
<signal name="ItemsPropertiesUpdated">
<arg type="a(ia{sv})" />
<arg type="a(ias)" />
</signal>
<signal name="LayoutUpdated">
<arg type="u" />
<arg type="i" />
</signal>
</interface>
<interface name="org.freedesktop.DBus.Introspectable">
<method name="Introspect">
<arg type="s" direction="out" />
</method>
</interface>
<interface name="org.freedesktop.DBus.Peer">
<method name="Ping" />
</interface>
<interface name="org.freedesktop.DBus.Properties">
<method name="Get">
<arg type="s" direction="in" />
<arg type="s" direction="in" />
<arg type="v" direction="out" />
</method>
<method name="GetAll">
<arg type="s" direction="in" />
<arg type="a{sv}" direction="out" />
</method>
</interface>
</node>

View File

@ -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);

View File

@ -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('<busconfig><type>session</type><listen>unix:tmpdir=/tmp</listen><auth>EXTERNAL</auth><policy context="default"><allow send_destination="*"/><allow receive_sender="*"/><allow own="*"/></policy></busconfig>')
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())))

View File

@ -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]))

159
src/linux/tests/menus.c Normal file
View File

@ -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");
}

View File

@ -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));

View File

@ -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)

View File

@ -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: