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: