diff --git a/README.md b/README.md index 56478a9..385a2f5 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,10 @@ macOS builds require Xcode Command Line Tools. The Windows Win32/WGL backend cross-compiles for `x86_64-windows-gnu`; runtime verification on Windows is pending. It requires Windows 10 version 1703 or later and an OpenGL 3.3-capable driver. -The Linux Wayland preview implements windows and EGL/OpenGL, initially tested -on KDE Plasma. Input and desktop services are still pending. It vendors only ABI +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 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/shell.nix b/shell.nix index b6cd478..61b5538 100644 --- a/shell.nix +++ b/shell.nix @@ -3,7 +3,8 @@ }: pkgs.mkShell { packages = [ - pkgs.python3 + (pkgs.python3.withPackages (python: [ python.dbus-next ])) + pkgs.dbus pkgs.wayland-scanner pkgs.clang-tools ]; diff --git a/src/linux/Bus.zig b/src/linux/Bus.zig new file mode 100644 index 0000000..9d40ee5 --- /dev/null +++ b/src/linux/Bus.zig @@ -0,0 +1,88 @@ +//! Small typed D-Bus message builder/parser. No GLib, process spawning, or +//! synchronous method calls. libdbus owns message storage and the transport. +const std = @import("std"); +const c = @import("linux_c"); +pub const Error = error{OutOfMemory}; +pub const Iter = c.DBusMessageIter; +pub const Message = c.DBusMessage; +pub fn method(destination: [*:0]const u8, path: [*:0]const u8, interface: [*:0]const u8, member: [*:0]const u8) Error!*Message { + return c.z_dbus_message_new_method_call.?(destination, path, interface, member) orelse error.OutOfMemory; +} +pub fn append(message: *Message) Iter { + var i: Iter = undefined; + c.z_dbus_message_iter_init_append.?(message, &i); + return i; +} +pub fn parse(message: *Message) Iter { + var i: Iter = std.mem.zeroes(Iter); + _ = c.z_dbus_message_iter_init.?(message, &i); + return i; +} +pub fn basic(i: *Iter, type_: c_int, value: anytype) Error!void { + var v = value; + if (c.z_dbus_message_iter_append_basic.?(i, type_, @ptrCast(&v)) == 0) return error.OutOfMemory; +} +pub fn string(i: *Iter, value: [*:0]const u8) Error!void { + try basic(i, c.DBUS_TYPE_STRING, value); +} +pub fn open(i: *Iter, type_: c_int, signature: ?[*:0]const u8) Error!Iter { + var nested: Iter = std.mem.zeroes(Iter); + if (c.z_dbus_message_iter_open_container.?(i, type_, signature, &nested) == 0) return error.OutOfMemory; + return nested; +} +pub fn close(i: *Iter, nested: *Iter) Error!void { + if (c.z_dbus_message_iter_close_container.?(i, nested) == 0) return error.OutOfMemory; +} +pub fn option(i: *Iter, key: [*:0]const u8, comptime signature: [:0]const u8, value: anytype) Error!void { + var pair = try open(i, c.DBUS_TYPE_DICT_ENTRY, null); + errdefer c.z_dbus_message_iter_abandon_container_if_open.?(i, &pair); + try string(&pair, key); + var payload = try open(&pair, c.DBUS_TYPE_VARIANT, signature); + errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&pair, &payload); + try basic(&payload, signature[0], value); + try close(&pair, &payload); + try close(i, &pair); +} +pub fn kind(i: *Iter) c_int { + return c.z_dbus_message_iter_get_arg_type.?(i); +} +pub fn next(i: *Iter) bool { + return c.z_dbus_message_iter_next.?(i) != 0; +} +pub fn child(i: *Iter) Iter { + var result: Iter = std.mem.zeroes(Iter); + c.z_dbus_message_iter_recurse.?(i, &result); + return result; +} +pub fn str(i: *Iter) ?[]const u8 { + if (kind(i) != c.DBUS_TYPE_STRING and kind(i) != c.DBUS_TYPE_OBJECT_PATH) return null; + var value: [*c]const u8 = null; + c.z_dbus_message_iter_get_basic.?(i, @ptrCast(&value)); + return if (value == null) null else std.mem.span(value); +} +pub fn uint(i: *Iter) ?u32 { + if (kind(i) != c.DBUS_TYPE_UINT32) return null; + var value: u32 = 0; + c.z_dbus_message_iter_get_basic.?(i, @ptrCast(&value)); + return value; +} +pub fn variant(i: *Iter) ?Iter { + return if (kind(i) == c.DBUS_TYPE_VARIANT) child(i) else null; +} +pub fn entry(i: *Iter, key: []const u8) ?Iter { + if (kind(i) != c.DBUS_TYPE_ARRAY) return null; + var entries = child(i); + while (kind(&entries) == c.DBUS_TYPE_DICT_ENTRY) { + var pair = child(&entries); + const name = str(&pair) orelse return null; + if (!next(&pair)) return null; + if (std.mem.eql(u8, name, key)) return pair; + if (!next(&entries)) break; + } + return null; +} +comptime { + std.debug.assert(@sizeOf(c.dbus_uint32_t) == 4); + std.debug.assert(@sizeOf(c.dbus_uint64_t) == 8); + std.debug.assert(@sizeOf(Iter) == if (@sizeOf(usize) == 8) 72 else 56); +} diff --git a/src/linux/Data.zig b/src/linux/Data.zig new file mode 100644 index 0000000..486831b --- /dev/null +++ b/src/linux/Data.zig @@ -0,0 +1,349 @@ +//! Core Wayland selection and copy-only URI drops. Transfers keep their offers +//! and sources alive until the nonblocking pipe closes, independent of focus. +const std = @import("std"); +const c = @import("linux_c"); +const n = @import("native"); +const native = @import("native.zig"); +const Input = @import("Input.zig"); +const Files = @import("Files.zig"); +const runtime = @import("../runtime.zig"); +const abi = @import("../abi.zig"); +const Offer = struct { + next: ?*Offer = null, + proxy: ?*c.struct_wl_data_offer, + refs: usize = 1, + text: ?[*:0]const u8 = null, + uris: bool = false, + actions: u32 = 0, + action: u32 = 0, +}; +const Source = struct { next: ?*Source = null, proxy: ?*c.struct_wl_data_source, bytes: []u8, refs: usize = 1 }; +const Transfer = struct { + next: ?*Transfer = null, + fd: c_int, + request: u64 = 0, + offer: ?*Offer = null, + source: ?*Source = null, + window: ?*native.Window = null, + position: n.uhdk_point = .{ .x = 0, .y = 0 }, + bytes: std.ArrayList(u8) = .empty, + offset: usize = 0, + limit: u64 = 0, + deadline: u64, +}; +pub const State = struct { + manager: ?*c.struct_wl_data_device_manager = null, + device: ?*c.struct_wl_data_device = null, + offers: ?*Offer = null, + sources: ?*Source = null, + selection: ?*Offer = null, + drag: ?*Offer = null, + drag_window: ?*native.Window = null, + drag_serial: u32 = 0, + position: n.uhdk_point = .{ .x = 0, .y = 0 }, + transfers: ?*Transfer = null, +}; +fn b() *native.Backend { + return native.instance.?; +} +fn s() *State { + return &b().data; +} +fn data(comptime T: type, raw: ?*anyopaque) *T { + return @ptrCast(@alignCast(raw.?)); +} +pub fn global(registry: ?*c.struct_wl_registry, name: u32, interface: []const u8, version: u32) void { + if (std.mem.eql(u8, interface, "wl_data_device_manager") and version >= 3) + s().manager = @ptrCast(c.wl_registry_bind(registry, name, &c.z_uhdk_wl_data_device_manager_interface, 3)); +} +pub fn setup() void { + if (s().manager != null and Input.state().seat != null and s().device == null) { + s().device = c.wl_data_device_manager_get_data_device(s().manager, Input.state().seat); + if (s().device != null) _ = c.wl_data_device_add_listener(s().device, &device_listener, null); + } + native.setCapability(n.UHDK_CAP_CLIPBOARD, s().device != null); + native.setCapability(n.UHDK_CAP_DROP_FILES, s().device != null); +} +fn unref(o: *Offer) void { + o.refs -= 1; + if (o.refs != 0) return; + var link = &s().offers; + while (link.* != o) link = &link.*.?.next; + link.* = o.next; + c.wl_data_offer_destroy(o.proxy); + b().app.gpa.destroy(o); +} +fn offer(_: ?*anyopaque, _: ?*c.struct_wl_data_device, proxy: ?*c.struct_wl_data_offer) callconv(.c) void { + const o = b().app.gpa.create(Offer) catch { + c.wl_data_offer_destroy(proxy); + return; + }; + o.* = .{ .next = s().offers, .proxy = proxy }; + s().offers = o; + _ = c.wl_data_offer_add_listener(proxy, &offer_listener, o); +} +fn mime(raw: ?*anyopaque, _: ?*c.struct_wl_data_offer, name: [*c]const u8) callconv(.c) void { + const o = data(Offer, raw); + const str = std.mem.span(name); + if (std.mem.eql(u8, str, "text/plain;charset=utf-8")) o.text = "text/plain;charset=utf-8" else if (std.mem.eql(u8, str, "text/plain") and o.text == null) o.text = "text/plain"; + if (std.mem.eql(u8, str, "text/uri-list")) o.uris = true; +} +fn actions(raw: ?*anyopaque, _: ?*c.struct_wl_data_offer, value: u32) callconv(.c) void { + data(Offer, raw).actions = value; +} +fn action(raw: ?*anyopaque, _: ?*c.struct_wl_data_offer, value: u32) callconv(.c) void { + data(Offer, raw).action = value; +} +const offer_listener: c.struct_wl_data_offer_listener = .{ .offer = mime, .source_actions = actions, .action = action }; +fn lookup(proxy: ?*c.struct_wl_data_offer) ?*Offer { + var o = s().offers; + while (o) |item| : (o = item.next) if (item.proxy == proxy) return item; + return null; +} +fn selection(_: ?*anyopaque, _: ?*c.struct_wl_data_device, proxy: ?*c.struct_wl_data_offer) callconv(.c) void { + if (s().selection) |old| unref(old); + s().selection = lookup(proxy); // Takes the initial offer reference. +} +fn enter(_: ?*anyopaque, _: ?*c.struct_wl_data_device, serial: u32, surface: ?*c.struct_wl_surface, x: i32, y: i32, proxy: ?*c.struct_wl_data_offer) callconv(.c) void { + leave(null, null); + s().drag = lookup(proxy); + s().drag_window = Input.find(surface); + s().drag_serial = serial; + s().position = .{ .x = @as(f32, @floatFromInt(x)) / 256, .y = @as(f32, @floatFromInt(y)) / 256 }; + negotiate(); +} +fn negotiate() void { + const o = s().drag orelse return; + const accepted = o.uris and s().drag_window != null and s().drag_window.?.drop_enabled; + c.wl_data_offer_accept(o.proxy, s().drag_serial, if (accepted) "text/uri-list" else null); + c.wl_data_offer_set_actions(o.proxy, if (accepted) c.WL_DATA_DEVICE_MANAGER_DND_ACTION_COPY else 0, if (accepted) c.WL_DATA_DEVICE_MANAGER_DND_ACTION_COPY else 0); +} +fn leave(_: ?*anyopaque, _: ?*c.struct_wl_data_device) callconv(.c) void { + if (s().drag) |o| unref(o); + s().drag = null; + s().drag_window = null; +} +fn motion(_: ?*anyopaque, _: ?*c.struct_wl_data_device, _: u32, x: i32, y: i32) callconv(.c) void { + s().position = .{ .x = @as(f32, @floatFromInt(x)) / 256, .y = @as(f32, @floatFromInt(y)) / 256 }; +} +fn drop(_: ?*anyopaque, _: ?*c.struct_wl_data_device) callconv(.c) void { + const o = s().drag orelse return; + const w = s().drag_window orelse return; + if (!w.drop_enabled or !o.uris or o.action != c.WL_DATA_DEVICE_MANAGER_DND_ACTION_COPY) return; + _ = receive(o, "text/uri-list", 0, 16 * 1024 * 1024, w); + // drop ends the drag session; no leave is required after a successful drop. + leave(null, null); +} +const device_listener: c.struct_wl_data_device_listener = .{ .data_offer = offer, .enter = enter, .leave = leave, .motion = motion, .drop = drop, .selection = selection }; +pub fn enable(w: *native.Window, enabled: bool) u32 { + if (s().device == null) return n.UHDK_STATUS_UNSUPPORTED; + w.drop_enabled = enabled; + if (s().drag_window == w) negotiate(); + return abi.OK; +} +fn sourceUnref(source: *Source) void { + source.refs -= 1; + if (source.refs != 0) return; + var link = &s().sources; + while (link.* != source) link = &link.*.?.next; + link.* = source.next; + b().app.gpa.free(source.bytes); + b().app.gpa.destroy(source); +} +fn target(_: ?*anyopaque, _: ?*c.struct_wl_data_source, _: [*c]const u8) callconv(.c) void {} +fn send(raw: ?*anyopaque, _: ?*c.struct_wl_data_source, name: [*c]const u8, fd: i32) callconv(.c) void { + const source = data(Source, raw); + const str = std.mem.span(name); + if ((!std.mem.eql(u8, str, "text/plain;charset=utf-8") and !std.mem.eql(u8, str, "text/plain")) or c.z_linux_nonblock(fd) != 0) { + _ = c.close(fd); + return; + } + const transfer = b().app.gpa.create(Transfer) catch { + _ = c.close(fd); + return; + }; + source.refs += 1; + transfer.* = .{ .next = s().transfers, .fd = fd, .source = source, .deadline = runtime.now(b().app.io) + 10 * std.time.ns_per_s }; + s().transfers = transfer; +} +fn cancelled(raw: ?*anyopaque, proxy: ?*c.struct_wl_data_source) callconv(.c) void { + const source = data(Source, raw); + c.wl_data_source_destroy(proxy); + source.proxy = null; + sourceUnref(source); +} +fn unused(_: ?*anyopaque, _: ?*c.struct_wl_data_source) callconv(.c) void {} +fn unusedAction(_: ?*anyopaque, _: ?*c.struct_wl_data_source, _: u32) callconv(.c) void {} +const source_listener: c.struct_wl_data_source_listener = .{ .target = target, .send = send, .cancelled = cancelled, .dnd_drop_performed = unused, .dnd_finished = unused, .action = unusedAction }; +pub fn write(text: n.uhdk_text) u32 { + if (s().device == null) return n.UHDK_STATUS_UNSUPPORTED; + const input = Input.state(); + // Selection changes require an actual input serial. Never queue a write to + // hijack an unrelated future keystroke when the app has no input focus. + if (input.key_focus == null or input.serial == 0) return abi.UNAVAILABLE; + const source = b().app.gpa.create(Source) catch return abi.OOM; + const bytes = b().app.gpa.dupe(u8, abi.textBytes(text)) catch { + b().app.gpa.destroy(source); + return abi.OOM; + }; + const proxy = c.wl_data_device_manager_create_data_source(s().manager) orelse { + b().app.gpa.free(bytes); + b().app.gpa.destroy(source); + return abi.OOM; + }; + source.* = .{ .next = s().sources, .proxy = proxy, .bytes = bytes }; + s().sources = source; + _ = c.wl_data_source_add_listener(proxy, &source_listener, source); + c.wl_data_source_offer(proxy, "text/plain;charset=utf-8"); + c.wl_data_source_offer(proxy, "text/plain"); + c.wl_data_device_set_selection(s().device, proxy, input.serial); + return abi.OK; +} +pub fn read(id: u64, limit: u64) u32 { + if (s().device == null) return n.UHDK_STATUS_UNSUPPORTED; + if (Input.state().key_focus == null) return abi.UNAVAILABLE; + const o = s().selection orelse return n.UHDK_STATUS_NOT_FOUND; + const format = o.text orelse return n.UHDK_STATUS_NOT_FOUND; + return receive(o, format, id, limit, null); +} +fn receive(o: *Offer, format: [*:0]const u8, id: u64, limit: u64, w: ?*native.Window) u32 { + var fds: [2]c_int = undefined; + if (c.z_linux_pipe(&fds) != 0) return n.UHDK_STATUS_OS_ERROR; + const transfer = b().app.gpa.create(Transfer) catch { + _ = c.close(fds[0]); + _ = c.close(fds[1]); + return abi.OOM; + }; + o.refs += 1; + transfer.* = .{ .next = s().transfers, .fd = fds[0], .request = id, .offer = o, .window = w, .position = s().position, .limit = limit, .deadline = runtime.now(b().app.io) + 10 * std.time.ns_per_s }; + s().transfers = transfer; + c.wl_data_offer_receive(o.proxy, format, fds[1]); + _ = c.close(fds[1]); + return n.Z_DEFERRED; +} +pub fn poll(fds: *std.ArrayList(c.struct_pollfd)) !void { + var t = s().transfers; + while (t) |item| : (t = item.next) try fds.append(b().app.gpa, .{ .fd = item.fd, .events = if (item.source != null) c.POLLOUT else c.POLLIN, .revents = 0 }); +} +pub fn tick() i32 { + var t = s().transfers; + while (t) |item| { + t = item.next; + if (runtime.now(b().app.io) >= item.deadline) { + finish(item, abi.UNAVAILABLE); + continue; + } + if (item.source) |source| { + const bytes = source.bytes[item.offset..]; + if (bytes.len == 0) { + finish(item, abi.OK); + continue; + } + const amount = c.z_linux_pipe_write(item.fd, bytes.ptr, @min(bytes.len, 65536)); + if (amount > 0) item.offset += @intCast(amount) else if (amount < 0 and c.z_linux_errno() != c.EAGAIN and c.z_linux_errno() != c.EINTR) finish(item, abi.UNAVAILABLE); + } else { + var buffer: [65536]u8 = undefined; + const amount = c.z_linux_read(item.fd, &buffer, buffer.len); + if (amount == 0) finish(item, abi.OK) else if (amount > 0) { + const length: usize = @intCast(amount); + if (length > item.limit -| item.bytes.items.len) { + finish(item, n.UHDK_STATUS_BUFFER_TOO_SMALL); + continue; + } + item.bytes.appendSlice(b().app.gpa, buffer[0..length]) catch { + finish(item, abi.OOM); + continue; + }; + } else if (c.z_linux_errno() != c.EAGAIN and c.z_linux_errno() != c.EINTR) finish(item, abi.UNAVAILABLE); + } + } + return if (s().transfers != null) 100 else -1; +} +fn finish(t: *Transfer, status: u32) void { + if (t.request != 0) { + var result = std.mem.zeroes(n.z_request_result); + result.value.text = abi.textSpan(t.bytes.items); + n.z_complete(runtime.toAppHandle(b().app), t.request, if (status == abi.OK and !std.unicode.utf8ValidateSlice(t.bytes.items)) abi.INVALID else status, &result); + } else if (t.window) |w| { + if (status == abi.OK and w.drop_enabled and emitDrop(w, t.bytes.items, t.position)) c.wl_data_offer_finish(t.offer.?.proxy); + } + var link = &s().transfers; + while (link.* != t) link = &link.*.?.next; + link.* = t.next; + _ = c.close(t.fd); + if (t.offer) |o| unref(o); + if (t.source) |source| sourceUnref(source); + t.bytes.deinit(b().app.gpa); + b().app.gpa.destroy(t); +} +fn emitDrop(w: *native.Window, bytes: []const u8, position: n.uhdk_point) bool { + var files: std.ArrayList(?*n.uhdk_file) = .empty; + defer { + for (files.items) |file| n.uhdk_file_release(file); + files.deinit(b().app.gpa); + } + var lines = std.mem.splitScalar(u8, bytes, '\n'); + while (lines.next()) |line| { + const uri = std.mem.trim(u8, line, "\r"); + if (uri.len == 0 or uri[0] == '#') continue; + const path = Files.decode(b().app.gpa, uri) catch return false; + defer b().app.gpa.free(path); + const file = n.z_file_from_url(runtime.toAppHandle(b().app), .{ .ptr = path.ptr, .len = path.len }, null) orelse return false; + files.append(b().app.gpa, file) catch { + n.uhdk_file_release(file); + return false; + }; + } + if (files.items.len == 0) return false; + var event = std.mem.zeroes(n.z_event); + event.type = n.UHDK_EVENT_DROP_FILES; + event.data.drop_files = .{ .files = .{ .ptr = files.items.ptr, .len = files.items.len }, .position = position }; + Input.send(w, &event); + return true; +} +pub fn cancel(id: u64) bool { + var t = s().transfers; + while (t) |item| : (t = item.next) if (item.request == id) { + finish(item, abi.CANCELLED); + return true; + }; + return false; +} +pub fn forget(w: *native.Window) void { + if (s().drag_window == w) { + s().drag_window = null; + negotiate(); + } + var t = s().transfers; + while (t) |item| { + t = item.next; + if (item.window == w) { + item.window = null; + finish(item, abi.CANCELLED); + } + } +} +pub fn focusLost() void { + if (s().selection) |offer_| unref(offer_); + s().selection = null; +} +pub fn releaseSeat() void { + while (s().transfers) |t| finish(t, abi.CANCELLED); + s().selection = null; + s().drag = null; + s().drag_window = null; + while (s().offers) |o| unref(o); + while (s().sources) |source| { + if (source.proxy != null) c.wl_data_source_destroy(source.proxy); + sourceUnref(source); + } + if (s().device != null) c.wl_data_device_release(s().device); + s().device = null; + native.setCapability(n.UHDK_CAP_CLIPBOARD, false); + native.setCapability(n.UHDK_CAP_DROP_FILES, false); +} +pub fn deinit() void { + releaseSeat(); + if (s().manager != null) c.wl_data_device_manager_destroy(s().manager); + s().manager = null; +} diff --git a/src/linux/Desktop.zig b/src/linux/Desktop.zig new file mode 100644 index 0000000..03cdfb6 --- /dev/null +++ b/src/linux/Desktop.zig @@ -0,0 +1,188 @@ +//! Optional Wayland activation and application icons. Activation is always a +//! compositor request, never a promise to bypass focus-stealing policy. +const std = @import("std"); +const c = @import("linux_c"); +const n = @import("native"); +const native = @import("native.zig"); +const Input = @import("Input.zig"); +const runtime = @import("../runtime.zig"); +const abi = @import("../abi.zig"); +const Activation = struct { next: ?*Activation = null, id: u64, window: *native.Window, token: ?*c.struct_xdg_activation_token_v1, deadline: u64 }; +const Icon = struct { proxy: ?*c.struct_xdg_toplevel_icon_v1, buffer: ?*c.struct_wl_buffer, mapping: *anyopaque, size: usize }; +pub const State = struct { + activation: ?*c.struct_xdg_activation_v1 = null, + manager: ?*c.struct_xdg_toplevel_icon_manager_v1 = null, + shm: ?*c.struct_wl_shm = null, + icon: ?Icon = null, + pending: ?*Activation = null, + launch: ?[:0]u8 = null, +}; +fn b() *native.Backend { + return native.instance.?; +} +fn s() *State { + return &b().desktop; +} +pub fn global(registry: ?*c.struct_wl_registry, name: u32, interface: []const u8, _: u32) void { + if (std.mem.eql(u8, interface, "xdg_activation_v1")) s().activation = @ptrCast(c.wl_registry_bind(registry, name, &c.z_uhdk_xdg_activation_v1_interface, 1)) else if (std.mem.eql(u8, interface, "xdg_toplevel_icon_manager_v1")) { + s().manager = @ptrCast(c.wl_registry_bind(registry, name, &c.z_uhdk_xdg_toplevel_icon_manager_v1_interface, 1)); + if (s().manager != null) _ = c.xdg_toplevel_icon_manager_v1_add_listener(s().manager, &icon_listener, null); + } else if (std.mem.eql(u8, interface, "wl_shm")) { + s().shm = @ptrCast(c.wl_registry_bind(registry, name, &c.z_uhdk_wl_shm_interface, 1)); + if (s().shm != null) _ = c.wl_shm_add_listener(s().shm, &shm_listener, null); + } + native.setCapability(n.UHDK_CAP_APP_ICON, s().manager != null and s().shm != null); +} +fn iconSize(_: ?*anyopaque, _: ?*c.struct_xdg_toplevel_icon_manager_v1, _: i32) callconv(.c) void {} +fn iconDone(_: ?*anyopaque, _: ?*c.struct_xdg_toplevel_icon_manager_v1) callconv(.c) void {} +const icon_listener: c.struct_xdg_toplevel_icon_manager_v1_listener = .{ .icon_size = iconSize, .done = iconDone }; +fn format(_: ?*anyopaque, _: ?*c.struct_wl_shm, _: u32) callconv(.c) void {} +const shm_listener: c.struct_wl_shm_listener = .{ .format = format }; +pub fn initialize() void { + const token = c.getenv("XDG_ACTIVATION_TOKEN"); + if (token != null and token[0] != 0) { + s().launch = b().app.gpa.dupeZ(u8, std.mem.span(token)) catch null; + _ = c.unsetenv("XDG_ACTIVATION_TOKEN"); + } +} +pub fn focus(w: *native.Window, id: u64) u32 { + if (s().activation == null) return n.UHDK_STATUS_UNSUPPORTED; + if (s().launch) |token| { + c.xdg_activation_v1_activate(s().activation, token, w.surface); + b().app.gpa.free(token); + s().launch = null; + return abi.OK; + } + const input = Input.state(); + if (input.serial == 0 or input.key_focus == null or runtime.now(b().app.io) -| input.serial_time > 5 * std.time.ns_per_s) return abi.UNAVAILABLE; + const p = b().app.gpa.create(Activation) catch return abi.OOM; + const token = c.xdg_activation_v1_get_activation_token(s().activation) orelse { + b().app.gpa.destroy(p); + return abi.OOM; + }; + p.* = .{ .next = s().pending, .id = id, .window = w, .token = token, .deadline = runtime.now(b().app.io) + 5 * std.time.ns_per_s }; + s().pending = p; + _ = c.xdg_activation_token_v1_add_listener(token, &activation_listener, p); + c.xdg_activation_token_v1_set_serial(token, input.serial, input.seat); + c.xdg_activation_token_v1_set_surface(token, input.key_focus.?.surface); + const app_id = b().app.gpa.dupeZ(u8, b().app.application_id) catch { + finish(p, abi.OOM); + return n.Z_DEFERRED; + }; + defer b().app.gpa.free(app_id); + c.xdg_activation_token_v1_set_app_id(token, app_id); + c.xdg_activation_token_v1_commit(token); + return n.Z_DEFERRED; +} +fn activated(raw: ?*anyopaque, _: ?*c.struct_xdg_activation_token_v1, token: [*c]const u8) callconv(.c) void { + const p: *Activation = @ptrCast(@alignCast(raw.?)); + c.xdg_activation_v1_activate(s().activation, token, p.window.surface); + finish(p, abi.OK); +} +const activation_listener: c.struct_xdg_activation_token_v1_listener = .{ .done = activated }; +fn finish(p: *Activation, status: u32) void { + var link = &s().pending; + while (link.* != p) link = &link.*.?.next; + link.* = p.next; + c.xdg_activation_token_v1_destroy(p.token); + n.z_complete(runtime.toAppHandle(b().app), p.id, status, null); + b().app.gpa.destroy(p); +} +pub fn tick() i32 { + var cursor = s().pending; + while (cursor) |p| { + cursor = p.next; + if (runtime.now(b().app.io) >= p.deadline) finish(p, abi.UNAVAILABLE); + } + return if (s().pending != null) 100 else -1; +} +pub fn cancel(id: u64) bool { + var cursor = s().pending; + while (cursor) |p| : (cursor = p.next) if (p.id == id) { + finish(p, abi.CANCELLED); + return true; + }; + return false; +} +pub fn forget(w: *native.Window) void { + var cursor = s().pending; + while (cursor) |p| { + cursor = p.next; + if (p.window == w) finish(p, abi.CANCELLED); + } +} +pub fn applyIcon(w: *native.Window) void { + if (s().icon) |icon| c.xdg_toplevel_icon_manager_v1_set_icon(s().manager, w.top, icon.proxy); +} +/// Public pixels are straight RGBA; wl_shm ARGB8888 uses native-endian, +/// premultiplied channels. Pad rectangular images to a transparent square. +pub fn pixels(destination: []u32, side: usize, image: n.uhdk_image) void { + @memset(destination, 0); + const source = abi.bytes(image.pixels); + const x0 = (side - image.width) / 2; + const y0 = (side - image.height) / 2; + for (0..image.height) |y| for (0..image.width) |x| { + const offset = y * @as(usize, @intCast(image.stride_bytes)) + x * 4; + const a: u32 = source[offset + 3]; + const r = (@as(u32, source[offset]) * a + 127) / 255; + const g = (@as(u32, source[offset + 1]) * a + 127) / 255; + const blue = (@as(u32, source[offset + 2]) * a + 127) / 255; + destination[(y + y0) * side + x + x0] = a << 24 | r << 16 | g << 8 | blue; + }; +} +pub fn setIcon(image: n.uhdk_image) u32 { + if (s().manager == null or s().shm == null) return n.UHDK_STATUS_UNSUPPORTED; + const side = @max(image.width, image.height); + const size: usize = @as(usize, side) * side * 4; + const fd = c.memfd_create("uhdk-icon", c.MFD_CLOEXEC); + if (fd < 0) return n.UHDK_STATUS_OS_ERROR; + defer _ = c.close(fd); + if (c.ftruncate(fd, @intCast(size)) != 0) return n.UHDK_STATUS_OS_ERROR; + const mapping = c.mmap(null, size, c.PROT_READ | c.PROT_WRITE, c.MAP_SHARED, fd, 0); + if (mapping == c.MAP_FAILED) return n.UHDK_STATUS_OS_ERROR; + var retained = false; + defer if (!retained) { + _ = c.munmap(mapping, size); + }; + const words: [*]u32 = @ptrCast(@alignCast(mapping.?)); + pixels(words[0 .. size / 4], side, image); + const pool = c.wl_shm_create_pool(s().shm, fd, @intCast(size)) orelse return abi.OOM; + defer c.wl_shm_pool_destroy(pool); + const buffer = c.wl_shm_pool_create_buffer(pool, 0, @intCast(side), @intCast(side), @intCast(side * 4), c.WL_SHM_FORMAT_ARGB8888) orelse return abi.OOM; + const proxy = c.xdg_toplevel_icon_manager_v1_create_icon(s().manager) orelse { + c.wl_buffer_destroy(buffer); + return abi.OOM; + }; + c.xdg_toplevel_icon_v1_add_buffer(proxy, buffer, 1); + const old = s().icon; + s().icon = .{ .proxy = proxy, .buffer = buffer, .mapping = mapping.?, .size = size }; + retained = true; + var cursor = b().windows; + while (cursor) |w| : (cursor = w.next) applyIcon(w); + if (old) |icon| destroyIcon(icon); + return abi.OK; +} +fn destroyIcon(icon: Icon) void { + c.xdg_toplevel_icon_v1_destroy(icon.proxy); + c.wl_buffer_destroy(icon.buffer); + _ = c.munmap(icon.mapping, icon.size); +} +pub fn deinit() void { + while (s().pending) |p| finish(p, abi.CANCELLED); + if (s().icon) |icon| destroyIcon(icon); + if (s().launch) |token| b().app.gpa.free(token); + if (s().manager != null) c.xdg_toplevel_icon_manager_v1_destroy(s().manager); + if (s().shm != null) c.wl_shm_destroy(s().shm); + if (s().activation != null) c.xdg_activation_v1_destroy(s().activation); +} +test "icons premultiply RGBA and pad rectangular images" { + const rgba = [_]u8{ 255, 128, 0, 128, 0, 255, 0, 255 }; + var image = std.mem.zeroes(n.uhdk_image); + image.width = 2; + image.height = 1; + image.stride_bytes = 8; + image.pixels = .{ .ptr = &rgba, .len = rgba.len }; + var result: [4]u32 = undefined; + pixels(&result, 2, image); + try std.testing.expectEqualSlices(u32, &.{ 0x80804000, 0xff00ff00, 0, 0 }, &result); +} diff --git a/src/linux/Files.zig b/src/linux/Files.zig index 42fb59c..6852b14 100644 --- a/src/linux/Files.zig +++ b/src/linux/Files.zig @@ -4,7 +4,7 @@ const n = @import("native"); const abi = @import("../abi.zig"); const runtime = @import("../runtime.zig"); -fn decode(allocator: std.mem.Allocator, uri: []const u8) ![]u8 { +pub fn decode(allocator: std.mem.Allocator, uri: []const u8) ![]u8 { const encoded = if (std.mem.startsWith(u8, uri, "file:///")) uri[7..] else if (std.mem.startsWith(u8, uri, "file://localhost/")) uri[16..] else return error.Invalid; var result: std.Io.Writer.Allocating = .init(allocator); errdefer result.deinit(); diff --git a/src/linux/Input.zig b/src/linux/Input.zig new file mode 100644 index 0000000..80f12f5 --- /dev/null +++ b/src/linux/Input.zig @@ -0,0 +1,430 @@ +//! One active Wayland seat. All mutable input state belongs to 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 abi = @import("../abi.zig"); +const Keyboard = @import("Keyboard.zig"); +const Data = @import("Data.zig"); +const Text = @import("Text.zig"); + +pub const State = struct { + seat: ?*c.struct_wl_seat = null, + seat_name: u32 = 0, + keyboard: ?*c.struct_wl_keyboard = null, + pointer: ?*c.struct_wl_pointer = null, + cursor_manager: ?*c.struct_wp_cursor_shape_manager_v1 = null, + cursor_device: ?*c.struct_wp_cursor_shape_device_v1 = null, + text_manager: ?*c.struct_zwp_text_input_manager_v3 = null, + text_input: ?*c.struct_zwp_text_input_v3 = null, + text_focus: ?*native.Window = null, + text_serial: u32 = 0, + key_focus: ?*native.Window = null, + pointer_focus: ?*native.Window = null, + context: ?*c.struct_xkb_context = null, + keymap: ?*c.struct_xkb_keymap = null, + state: ?*c.struct_xkb_state = null, + compose_table: ?*c.struct_xkb_compose_table = null, + compose: ?*c.struct_xkb_compose_state = null, + modifiers: u32 = 0, + active: bool = false, + serial: u32 = 0, + serial_time: u64 = 0, + pointer_serial: u32 = 0, + next_id: u64 = 0, + position: n.uhdk_point = .{ .x = 0, .y = 0 }, + buttons: u32 = 0, + captured: bool = false, + last_click_time: u64 = 0, + last_click_button: u32 = 0, + last_click_position: n.uhdk_point = .{ .x = 0, .y = 0 }, + click_count: u32 = 0, + repeat_key: ?u32 = null, + repeat_rate: u32 = 0, + repeat_delay: u32 = 600, + repeat_at: u64 = 0, + held: [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, + axis_stop: bool = false, + scrolling: bool = false, +}; +fn b() *native.Backend { + return native.instance.?; +} +pub fn state() *State { + return &b().input; +} +pub fn find(surface: ?*c.struct_wl_surface) ?*native.Window { + var item = b().windows; + while (item) |w| : (item = w.next) if (w.surface == surface) return w; + return null; +} +pub fn send(w: *native.Window, event: *n.z_event) void { + event.window = w.handle; + n.z_emit(runtime.toAppHandle(w.owner), event); +} +pub fn id() u64 { + state().next_id += 1; + return state().next_id; +} +pub fn provenance(serial: u32) void { + state().serial = serial; + state().serial_time = runtime.now(b().app.io); +} +pub fn initialize() void { + const s = state(); + if (c.z_linux_xkb_available() != 0) { + s.context = c.z_xkb_context_new.?(c.XKB_CONTEXT_NO_FLAGS); + if (s.context != null) { + var locale: [*c]const u8 = c.getenv("LC_ALL"); + if (locale == null or locale[0] == 0) locale = c.getenv("LC_CTYPE"); + if (locale == null or locale[0] == 0) locale = c.getenv("LANG"); + if (locale == null or locale[0] == 0) locale = "C.UTF-8"; + s.compose_table = c.z_xkb_compose_table_new_from_locale.?(s.context, locale, c.XKB_COMPOSE_COMPILE_NO_FLAGS); + if (s.compose_table != null) s.compose = c.z_xkb_compose_state_new.?(s.compose_table, c.XKB_COMPOSE_STATE_NO_FLAGS); + } + } +} +pub fn global(registry: ?*c.struct_wl_registry, name: u32, interface: []const u8, version: u32) void { + const s = state(); + if (std.mem.eql(u8, interface, "wl_seat") and s.seat == null and version >= 5) { + s.seat = @ptrCast(c.wl_registry_bind(registry, name, &c.z_uhdk_wl_seat_interface, @min(version, 8))); + s.seat_name = name; + if (s.seat != null) _ = c.wl_seat_add_listener(s.seat, &seat_listener, null); + } else if (std.mem.eql(u8, interface, "wp_cursor_shape_manager_v1")) { + s.cursor_manager = @ptrCast(c.wl_registry_bind(registry, name, &c.z_uhdk_wp_cursor_shape_manager_v1_interface, 1)); + } else if (std.mem.eql(u8, interface, "zwp_text_input_manager_v3")) { + s.text_manager = @ptrCast(c.wl_registry_bind(registry, name, &c.z_uhdk_zwp_text_input_manager_v3_interface, 1)); + } +} +pub fn setup() void { + const s = state(); + if (s.pointer != null and s.cursor_manager != null and s.cursor_device == null) + s.cursor_device = c.wp_cursor_shape_manager_v1_get_pointer(s.cursor_manager, s.pointer); + if (s.seat != null and s.text_manager != null and s.text_input == null) { + s.text_input = c.zwp_text_input_manager_v3_get_text_input(s.text_manager, s.seat); + if (s.text_input != null) Text.listen(s.text_input); + } + Data.setup(); + native.setCapability(n.UHDK_CAP_POINTER_CAPTURE, s.pointer != null); + native.setCapability(n.UHDK_CAP_TEXT_INPUT, s.context != null and s.keyboard != null); +} +fn seatCapabilities(_: ?*anyopaque, seat: ?*c.struct_wl_seat, caps: u32) callconv(.c) void { + const s = state(); + if (caps & c.WL_SEAT_CAPABILITY_KEYBOARD != 0 and s.keyboard == null) { + s.keyboard = c.wl_seat_get_keyboard(seat); + if (s.keyboard != null) _ = c.wl_keyboard_add_listener(s.keyboard, &keyboard_listener, null); + } else if (caps & c.WL_SEAT_CAPABILITY_KEYBOARD == 0 and s.keyboard != null) { + leaveKeyboard(); + c.wl_keyboard_release(s.keyboard); + s.keyboard = null; + } + if (caps & c.WL_SEAT_CAPABILITY_POINTER != 0 and s.pointer == null) { + s.pointer = c.wl_seat_get_pointer(seat); + if (s.pointer != null) _ = c.wl_pointer_add_listener(s.pointer, &pointer_listener, null); + } else if (caps & c.WL_SEAT_CAPABILITY_POINTER == 0 and s.pointer != null) { + leavePointer(); + if (s.cursor_device != null) c.wp_cursor_shape_device_v1_destroy(s.cursor_device); + s.cursor_device = null; + c.wl_pointer_release(s.pointer); + s.pointer = null; + } + setup(); +} +fn seatName(_: ?*anyopaque, _: ?*c.struct_wl_seat, _: [*c]const u8) callconv(.c) void {} +const seat_listener: c.struct_wl_seat_listener = .{ .capabilities = seatCapabilities, .name = seatName }; +fn keymap(_: ?*anyopaque, _: ?*c.struct_wl_keyboard, format: u32, fd: i32, size: u32) callconv(.c) void { + defer _ = c.close(fd); + const s = state(); + s.repeat_key = null; + if (s.compose != null) c.z_xkb_compose_state_reset.?(s.compose); + if (s.key_focus) |w| Text.cancel(w); + if (s.state != null) c.z_xkb_state_unref.?(s.state); + if (s.keymap != null) c.z_xkb_keymap_unref.?(s.keymap); + s.state = null; + s.keymap = null; + if (s.context == null or format != c.WL_KEYBOARD_KEYMAP_FORMAT_XKB_V1 or size == 0 or size > 16 * 1024 * 1024) return; + const mapped = c.mmap(null, size, c.PROT_READ, c.MAP_PRIVATE, fd, 0); + if (mapped == c.MAP_FAILED) return; + defer _ = c.munmap(mapped, size); + const bytes: [*]const u8 = @ptrCast(mapped.?); + if (bytes[size - 1] != 0) return; + s.keymap = c.z_xkb_keymap_new_from_string.?(s.context, @ptrCast(mapped), c.XKB_KEYMAP_FORMAT_TEXT_V1, c.XKB_KEYMAP_COMPILE_NO_FLAGS); + if (s.keymap != null) s.state = c.z_xkb_state_new.?(s.keymap); +} +fn keyEnter(_: ?*anyopaque, _: ?*c.struct_wl_keyboard, _: u32, surface: ?*c.struct_wl_surface, keys: [*c]c.struct_wl_array) callconv(.c) void { + leaveKeyboard(); + const s = state(); + s.key_focus = find(surface); + if (s.state != null and keys.*.size != 0) { + const held: [*]const u32 = @ptrCast(@alignCast(keys.*.data)); + for (held[0 .. keys.*.size / 4]) |code| { + if (code < s.held.len) s.held[code] = true; + } + } +} +fn keyLeave(_: ?*anyopaque, _: ?*c.struct_wl_keyboard, _: u32, _: ?*c.struct_wl_surface) callconv(.c) void { + leaveKeyboard(); +} +fn updateModifiers() void { + const s = state(); + s.modifiers = 0; + if (s.state == null) return; + const names = [_][*:0]const u8{ "Shift", "Control", "Mod1", "Mod4", "Lock", "Mod2" }; + for (names, 0..) |name, index| if (c.z_xkb_state_mod_name_is_active.?(s.state, name, c.XKB_STATE_MODS_EFFECTIVE) > 0) { + s.modifiers |= @as(u32, 1) << @intCast(index); + }; +} +fn modifiers(_: ?*anyopaque, _: ?*c.struct_wl_keyboard, _: u32, depressed: u32, latched: u32, locked: u32, group: u32) callconv(.c) void { + const s = state(); + if (s.state != null) _ = c.z_xkb_state_update_mask.?(s.state, depressed, latched, locked, 0, 0, group); + updateModifiers(); +} +fn key(_: ?*anyopaque, _: ?*c.struct_wl_keyboard, serial: u32, _: u32, code: u32, pressed: u32) callconv(.c) void { + const s = state(); + if (code > 0xffff) return; + provenance(serial); + if (code < s.held.len) s.held[code] = pressed != 0; + // The compositor supplies the authoritative modifier/group mask. Running + // XKB key actions locally as well would apply latch/lock actions twice. + deliverKey(code, pressed != 0, false); + if (pressed != 0 and s.repeat_rate > 0 and s.keymap != null and c.z_xkb_keymap_key_repeats.?(s.keymap, code + 8) != 0) { + s.repeat_key = code; + s.repeat_at = runtime.now(b().app.io) + @as(u64, s.repeat_delay) * std.time.ns_per_ms; + } else if (s.repeat_key == code) s.repeat_key = null; +} +fn deliverKey(code: u32, down: bool, repeated: bool) void { + const s = state(); + const w = s.key_focus orelse return; + const sym = if (s.state != null) c.z_xkb_state_key_get_one_sym.?(s.state, code + 8) else 0; + var event = std.mem.zeroes(n.z_event); + event.type = n.UHDK_EVENT_KEY; + event.input_id = id(); + event.data.key = .{ .physical_key = Keyboard.physical(code), .logical_key = if (s.state != null) Keyboard.logical(sym) else 0, .modifiers = s.modifiers, .pressed = @intFromBool(down), .repeat = @intFromBool(repeated) }; + send(w, &event); + if (down and w.text.enabled) Text.key(w, code, sym, event.data.key.logical_key, event.input_id); +} +fn repeatInfo(_: ?*anyopaque, _: ?*c.struct_wl_keyboard, rate: i32, delay: i32) callconv(.c) void { + state().repeat_rate = @intCast(std.math.clamp(rate, 0, 1000)); + state().repeat_delay = @intCast(@max(0, delay)); + if (rate <= 0) state().repeat_key = null; +} +const keyboard_listener: c.struct_wl_keyboard_listener = .{ .keymap = keymap, .enter = keyEnter, .leave = keyLeave, .key = key, .modifiers = modifiers, .repeat_info = repeatInfo }; +pub fn tick() i32 { + const s = state(); + const active = s.key_focus != null; + if (!active) Data.focusLost(); + if (active != s.active) { + s.active = active; + var event = std.mem.zeroes(n.z_event); + event.type = n.UHDK_EVENT_APP_ACTIVATION; + event.data.active = @intFromBool(active); + n.z_emit(runtime.toAppHandle(b().app), &event); + } + if (s.repeat_key) |code| { + const now = runtime.now(b().app.io); + if (now >= s.repeat_at) { + deliverKey(code, true, true); + s.repeat_at = now + std.time.ns_per_s / @max(1, s.repeat_rate); + } + return @intCast(@min(1000, (s.repeat_at -| now) / std.time.ns_per_ms + 1)); + } + return -1; +} +fn leaveKeyboard() void { + const s = state(); + s.repeat_key = null; + if (s.key_focus) |w| { + for (&s.held, 0..) |*held, code| if (held.*) { + held.* = false; + deliverKey(@intCast(code), false, false); + }; + Text.cancel(w); + } + s.key_focus = null; + s.modifiers = 0; + s.serial = 0; + if (s.state != null) _ = c.z_xkb_state_update_mask.?(s.state, 0, 0, 0, 0, 0, 0); + normal(); +} +fn pointerEvent(kind: u32, button_: u32, pressed: bool, delta: n.uhdk_point) void { + const s = state(); + const w = s.pointer_focus orelse return; + var e = std.mem.zeroes(n.z_event); + e.type = kind; + e.input_id = id(); + e.data.pointer = .{ .position = s.position, .delta = delta, .button = button_, .pressed = @intFromBool(pressed), .modifiers = s.modifiers, .click_count = if (pressed) s.click_count else 0 }; + send(w, &e); +} +fn pointerEnter(_: ?*anyopaque, _: ?*c.struct_wl_pointer, serial: u32, surface: ?*c.struct_wl_surface, x: i32, y: i32) callconv(.c) void { + leavePointer(); + const s = state(); + s.pointer_focus = find(surface); + s.pointer_serial = serial; + s.position = .{ .x = @as(f32, @floatFromInt(x)) / 256, .y = @as(f32, @floatFromInt(y)) / 256 }; + pointerEvent(n.UHDK_EVENT_POINTER_ENTER, 0, false, .{ .x = 0, .y = 0 }); + if (s.pointer_focus) |w| _ = cursor(w, w.cursor); +} +fn pointerLeave(_: ?*anyopaque, _: ?*c.struct_wl_pointer, _: u32, _: ?*c.struct_wl_surface) callconv(.c) void { + leavePointer(); +} +fn leavePointer() void { + const s = state(); + normal(); + pointerEvent(n.UHDK_EVENT_POINTER_LEAVE, 0, false, .{ .x = 0, .y = 0 }); + s.pointer_focus = null; + s.buttons = 0; + s.pointer_serial = 0; + s.scrolling = false; + s.axis = .{ .x = 0, .y = 0 }; + s.wheel = .{ .x = 0, .y = 0 }; + s.axis_stop = false; + s.last_click_button = 0; +} +fn motion(_: ?*anyopaque, _: ?*c.struct_wl_pointer, _: u32, x: i32, y: i32) callconv(.c) void { + const s = state(); + const previous = s.position; + s.position = .{ .x = @as(f32, @floatFromInt(x)) / 256, .y = @as(f32, @floatFromInt(y)) / 256 }; + pointerEvent(n.UHDK_EVENT_POINTER_MOVE, 0, false, .{ .x = s.position.x - previous.x, .y = s.position.y - previous.y }); +} +fn button(_: ?*anyopaque, _: ?*c.struct_wl_pointer, serial: u32, _: u32, code: u32, pressed: u32) callconv(.c) void { + const s = state(); + provenance(serial); + const mapped: u32 = switch (code) { + 0x110 => n.UHDK_BUTTON_PRIMARY, + 0x111 => n.UHDK_BUTTON_SECONDARY, + 0x112 => n.UHDK_BUTTON_MIDDLE, + 0x113, 0x116 => n.UHDK_BUTTON_BACK, + 0x114, 0x115 => n.UHDK_BUTTON_FORWARD, + else => 0, + }; + if (mapped == 0) return; + const mask = @as(u32, 1) << @intCast(mapped); + if (pressed != 0) { + s.buttons |= mask; + const now = runtime.now(b().app.io); + const dx = s.position.x - s.last_click_position.x; + const dy = s.position.y - s.last_click_position.y; + s.click_count = if (s.last_click_button == mapped and now -| s.last_click_time <= 500 * std.time.ns_per_ms and dx * dx + dy * dy <= 25) @min(3, s.click_count + 1) else 1; + s.last_click_time = now; + s.last_click_button = mapped; + s.last_click_position = s.position; + } else s.buttons &= ~mask; + pointerEvent(n.UHDK_EVENT_POINTER_BUTTON, mapped, pressed != 0, .{ .x = 0, .y = 0 }); + if (s.buttons == 0) normal(); +} +fn axis(_: ?*anyopaque, _: ?*c.struct_wl_pointer, _: u32, which: u32, value: i32) callconv(.c) void { + const v: f32 = @as(f32, @floatFromInt(value)) / 256; + if (which == c.WL_POINTER_AXIS_VERTICAL_SCROLL) state().axis.y += v else state().axis.x += v; +} +fn axisSource(_: ?*anyopaque, _: ?*c.struct_wl_pointer, source: u32) callconv(.c) void { + state().axis_source = source; +} +fn axisStop(_: ?*anyopaque, _: ?*c.struct_wl_pointer, _: u32, _: u32) callconv(.c) void { + state().axis_stop = true; +} +fn axisDiscrete(_: ?*anyopaque, _: ?*c.struct_wl_pointer, which: u32, value: i32) callconv(.c) void { + if (c.wl_pointer_get_version(state().pointer) >= 8) return; + if (which == c.WL_POINTER_AXIS_VERTICAL_SCROLL) state().wheel.y += @floatFromInt(value) else state().wheel.x += @floatFromInt(value); +} +fn axis120(_: ?*anyopaque, _: ?*c.struct_wl_pointer, which: u32, value: i32) callconv(.c) void { + if (which == c.WL_POINTER_AXIS_VERTICAL_SCROLL) state().wheel.y += @as(f32, @floatFromInt(value)) / 120 else state().wheel.x += @as(f32, @floatFromInt(value)) / 120; +} +fn frame(_: ?*anyopaque, _: ?*c.struct_wl_pointer) callconv(.c) void { + const s = state(); + defer { + s.axis = .{ .x = 0, .y = 0 }; + s.wheel = .{ .x = 0, .y = 0 }; + s.axis_stop = false; + } + const w = s.pointer_focus orelse return; + const discrete = s.axis_source == c.WL_POINTER_AXIS_SOURCE_WHEEL or s.axis_source == c.WL_POINTER_AXIS_SOURCE_WHEEL_TILT; + const has_wheel = s.wheel.x != 0 or s.wheel.y != 0; + if (s.axis.x == 0 and s.axis.y == 0 and !has_wheel and !s.axis_stop) return; + var e = std.mem.zeroes(n.z_event); + e.type = n.UHDK_EVENT_SCROLL; + e.input_id = id(); + e.data.scroll = .{ .position = s.position, .delta = if (has_wheel) s.wheel else s.axis, .unit = if (has_wheel) n.UHDK_SCROLL_LINES else n.UHDK_SCROLL_LOGICAL_PIXELS, .phase = if (discrete) n.UHDK_SCROLL_DISCRETE else if (s.axis_stop) n.UHDK_SCROLL_END else if (!s.scrolling) n.UHDK_SCROLL_BEGIN else n.UHDK_SCROLL_UPDATE, .modifiers = s.modifiers, .momentum = 0 }; + s.scrolling = !discrete and !s.axis_stop; + send(w, &e); +} +const pointer_listener: c.struct_wl_pointer_listener = .{ .enter = pointerEnter, .leave = pointerLeave, .motion = motion, .button = button, .axis = axis, .frame = frame, .axis_source = axisSource, .axis_stop = axisStop, .axis_discrete = axisDiscrete, .axis_value120 = axis120, .axis_relative_direction = null }; +pub fn cursor(w: *native.Window, shape: u32) u32 { + w.cursor = shape; + const s = state(); + if (shape != n.UHDK_CURSOR_HIDDEN and s.cursor_device == null) return n.UHDK_STATUS_UNSUPPORTED; + if (s.pointer_focus != w) return abi.OK; + if (shape == n.UHDK_CURSOR_HIDDEN) { + c.wl_pointer_set_cursor(s.pointer, s.pointer_serial, null, 0, 0); + return abi.OK; + } + if (s.cursor_device == null) return n.UHDK_STATUS_UNSUPPORTED; + const shapes = [_]u32{ c.WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_DEFAULT, c.WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_TEXT, c.WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_CROSSHAIR, c.WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_POINTER, c.WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_EW_RESIZE, c.WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_NS_RESIZE, c.WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_NWSE_RESIZE, c.WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_NESW_RESIZE, c.WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_MOVE, c.WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_NOT_ALLOWED, c.WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_WAIT }; + c.wp_cursor_shape_device_v1_set_shape(s.cursor_device, s.pointer_serial, shapes[shape]); + return abi.OK; +} +pub fn capture(w: *native.Window, enabled: bool) u32 { + const s = state(); + if (enabled and (s.pointer_focus != w or s.buttons == 0)) return abi.UNAVAILABLE; + if (!enabled) { + normal(); + return abi.OK; + } + s.captured = true; + var e = std.mem.zeroes(n.z_event); + e.type = n.UHDK_EVENT_POINTER_MODE_CHANGED; + e.data.pointer_mode = n.UHDK_POINTER_CAPTURE; + send(w, &e); + return abi.OK; +} +fn normal() void { + const s = state(); + if (!s.captured) return; + s.captured = false; + if (s.pointer_focus) |w| { + var e = std.mem.zeroes(n.z_event); + e.type = n.UHDK_EVENT_POINTER_MODE_CHANGED; + e.data.pointer_mode = n.UHDK_POINTER_NORMAL; + send(w, &e); + } +} +pub fn forget(w: *native.Window) void { + if (state().key_focus == w) leaveKeyboard(); + if (state().pointer_focus == w) leavePointer(); + Text.forget(w); +} +pub fn remove(name: u32) void { + if (name == state().seat_name) releaseSeat(); +} +fn releaseSeat() void { + Data.releaseSeat(); + const s = state(); + leaveKeyboard(); + leavePointer(); + if (s.text_focus) |w| Text.forget(w); + if (s.text_input != null) c.zwp_text_input_v3_destroy(s.text_input); + if (s.cursor_device != null) c.wp_cursor_shape_device_v1_destroy(s.cursor_device); + if (s.keyboard != null) c.wl_keyboard_release(s.keyboard); + if (s.pointer != null) c.wl_pointer_release(s.pointer); + if (s.seat != null) c.wl_seat_release(s.seat); + s.text_input = null; + s.cursor_device = null; + s.keyboard = null; + s.pointer = null; + s.seat = null; + setup(); +} +pub fn deinit() void { + const s = state(); + releaseSeat(); + if (s.text_manager != null) c.zwp_text_input_manager_v3_destroy(s.text_manager); + if (s.cursor_manager != null) c.wp_cursor_shape_manager_v1_destroy(s.cursor_manager); + if (s.compose != null) c.z_xkb_compose_state_unref.?(s.compose); + if (s.compose_table != null) c.z_xkb_compose_table_unref.?(s.compose_table); + if (s.state != null) c.z_xkb_state_unref.?(s.state); + if (s.keymap != null) c.z_xkb_keymap_unref.?(s.keymap); + if (s.context != null) c.z_xkb_context_unref.?(s.context); +} diff --git a/src/linux/Keyboard.zig b/src/linux/Keyboard.zig new file mode 100644 index 0000000..57e2e45 --- /dev/null +++ b/src/linux/Keyboard.zig @@ -0,0 +1,83 @@ +const std = @import("std"); +const c = @import("linux_c"); +const n = @import("native"); + +/// evdev keyboard codes to USB HID keyboard-page usages. Values describe +/// physical positions, independently of the compositor-supplied XKB layout. +pub fn physical(code: u32) u32 { + const map = comptime blk: { + var t = [_]u16{0} ** 256; + const pairs = [_][2]u16{ + .{ 1, 41 }, .{ 12, 45 }, .{ 13, 46 }, .{ 14, 42 }, .{ 15, 43 }, .{ 26, 47 }, .{ 27, 48 }, .{ 28, 40 }, .{ 29, 224 }, + .{ 39, 51 }, .{ 40, 52 }, .{ 41, 53 }, .{ 42, 225 }, .{ 43, 49 }, .{ 51, 54 }, .{ 52, 55 }, .{ 53, 56 }, .{ 54, 229 }, + .{ 55, 85 }, .{ 56, 226 }, .{ 57, 44 }, .{ 58, 57 }, .{ 69, 83 }, .{ 70, 71 }, .{ 71, 95 }, .{ 72, 96 }, .{ 73, 97 }, + .{ 74, 86 }, .{ 75, 92 }, .{ 76, 93 }, .{ 77, 94 }, .{ 78, 87 }, .{ 79, 89 }, .{ 80, 90 }, .{ 81, 91 }, .{ 82, 98 }, + .{ 83, 99 }, .{ 86, 100 }, .{ 87, 68 }, .{ 88, 69 }, .{ 96, 88 }, .{ 97, 228 }, .{ 98, 84 }, .{ 99, 70 }, .{ 100, 230 }, + .{ 102, 74 }, .{ 103, 82 }, .{ 104, 75 }, .{ 105, 80 }, .{ 106, 79 }, .{ 107, 77 }, .{ 108, 81 }, .{ 109, 78 }, .{ 110, 73 }, + .{ 111, 76 }, .{ 119, 72 }, .{ 125, 227 }, .{ 126, 231 }, .{ 127, 101 }, + }; + for (pairs) |p| t[p[0]] = p[1]; + for (0..10) |i| t[2 + i] = 30 + i; + for (0..10) |i| t[59 + i] = 58 + i; + for (0..12) |i| t[183 + i] = 104 + i; + for ("qwertyuiop", 16..) |letter, code_| t[code_] = letter - 'a' + 4; + for ("asdfghjkl", 30..) |letter, code_| t[code_] = letter - 'a' + 4; + for ("zxcvbnm", 44..) |letter, code_| t[code_] = letter - 'a' + 4; + break :blk t; + }; + return if (code < map.len) map[code] else 0; +} +pub fn logical(sym: u32) u32 { + return switch (sym) { + c.XKB_KEY_Escape => n.UHDK_KEY_ESCAPE, + c.XKB_KEY_Return, c.XKB_KEY_KP_Enter => n.UHDK_KEY_ENTER, + c.XKB_KEY_Tab, c.XKB_KEY_ISO_Left_Tab => n.UHDK_KEY_TAB, + c.XKB_KEY_BackSpace => n.UHDK_KEY_BACKSPACE, + c.XKB_KEY_Delete, c.XKB_KEY_KP_Delete => n.UHDK_KEY_DELETE, + c.XKB_KEY_Insert, c.XKB_KEY_KP_Insert => n.UHDK_KEY_INSERT, + c.XKB_KEY_Left, c.XKB_KEY_KP_Left => n.UHDK_KEY_LEFT, + c.XKB_KEY_Right, c.XKB_KEY_KP_Right => n.UHDK_KEY_RIGHT, + c.XKB_KEY_Up, c.XKB_KEY_KP_Up => n.UHDK_KEY_UP, + c.XKB_KEY_Down, c.XKB_KEY_KP_Down => n.UHDK_KEY_DOWN, + c.XKB_KEY_Home, c.XKB_KEY_KP_Home => n.UHDK_KEY_HOME, + c.XKB_KEY_End, c.XKB_KEY_KP_End => n.UHDK_KEY_END, + c.XKB_KEY_Page_Up, c.XKB_KEY_KP_Page_Up => n.UHDK_KEY_PAGE_UP, + c.XKB_KEY_Page_Down, c.XKB_KEY_KP_Page_Down => n.UHDK_KEY_PAGE_DOWN, + c.XKB_KEY_F1...c.XKB_KEY_F24 => @as(u32, n.UHDK_KEY_F1) + sym - c.XKB_KEY_F1, + else => c.z_xkb_keysym_to_utf32.?(sym), + }; +} +pub fn edit(key: u32, mods: u32) u32 { + const ctrl = mods & n.UHDK_MOD_CONTROL != 0; + if (mods & (n.UHDK_MOD_ALT | n.UHDK_MOD_SUPER) != 0) return 0; + if (ctrl) switch (key) { + 'c', 'C' => return n.UHDK_EDIT_COPY, + 'x', 'X' => return n.UHDK_EDIT_CUT, + 'v', 'V' => return n.UHDK_EDIT_PASTE, + 'a', 'A' => return n.UHDK_EDIT_SELECT_ALL, + 'z', 'Z' => return if (mods & n.UHDK_MOD_SHIFT != 0) n.UHDK_EDIT_REDO else n.UHDK_EDIT_UNDO, + 'y', 'Y' => return n.UHDK_EDIT_REDO, + else => {}, + }; + return switch (key) { + n.UHDK_KEY_BACKSPACE => n.UHDK_EDIT_DELETE_BACKWARD, + n.UHDK_KEY_DELETE => n.UHDK_EDIT_DELETE_FORWARD, + n.UHDK_KEY_LEFT => if (ctrl) n.UHDK_EDIT_WORD_LEFT else n.UHDK_EDIT_MOVE_LEFT, + n.UHDK_KEY_RIGHT => if (ctrl) n.UHDK_EDIT_WORD_RIGHT else n.UHDK_EDIT_MOVE_RIGHT, + n.UHDK_KEY_UP => n.UHDK_EDIT_MOVE_UP, + n.UHDK_KEY_DOWN => n.UHDK_EDIT_MOVE_DOWN, + n.UHDK_KEY_HOME => if (ctrl) n.UHDK_EDIT_DOCUMENT_START else n.UHDK_EDIT_LINE_START, + n.UHDK_KEY_END => if (ctrl) n.UHDK_EDIT_DOCUMENT_END else n.UHDK_EDIT_LINE_END, + n.UHDK_KEY_ENTER => if (ctrl) 0 else n.UHDK_EDIT_NEWLINE, + n.UHDK_KEY_TAB => if (ctrl) 0 else n.UHDK_EDIT_TAB, + else => 0, + }; +} +test "physical positions and editor shortcuts" { + try std.testing.expectEqual(@as(u32, 4), physical(30)); + try std.testing.expectEqual(@as(u32, 29), physical(44)); + try std.testing.expectEqual(@as(u32, 0), physical(500)); + try std.testing.expectEqual(@as(u32, n.UHDK_EDIT_COPY), edit('c', n.UHDK_MOD_CONTROL)); + try std.testing.expectEqual(@as(u32, 0), edit('c', n.UHDK_MOD_CONTROL | n.UHDK_MOD_ALT)); + try std.testing.expectEqual(@as(u32, n.UHDK_EDIT_DOCUMENT_START), edit(n.UHDK_KEY_HOME, n.UHDK_MOD_CONTROL)); +} diff --git a/src/linux/Portal.zig b/src/linux/Portal.zig new file mode 100644 index 0000000..59e1838 --- /dev/null +++ b/src/linux/Portal.zig @@ -0,0 +1,512 @@ +//! Freedesktop portal frontend over a private, asynchronously pumped D-Bus +//! connection. The session chooses its backend (including KDE's native dialogs). +const std = @import("std"); +const c = @import("linux_c"); +const n = @import("native"); +const native = @import("native.zig"); +const Bus = @import("Bus.zig"); +const Files = @import("Files.zig"); +const runtime = @import("../runtime.zig"); +const abi = @import("../abi.zig"); +const desktop = "org.freedesktop.portal.Desktop"; +const path = "/org/freedesktop/portal/desktop"; +const chooser = "org.freedesktop.portal.FileChooser"; +const settings = "org.freedesktop.portal.Settings"; +const dbus = "org.freedesktop.DBus"; +const dbus_path = "/org/freedesktop/DBus"; +const Pending = struct { + next: ?*Pending = null, + id: u64, + command: ?*const n.z_command, + serial: u32 = 0, + replied: bool = false, + cancelled: bool = false, + handle: [:0]u8, + token: [:0]u8, + exported: ?*c.struct_zxdg_exported_v2 = null, + deadline: u64, +}; +pub const State = struct { + connection: ?*c.DBusConnection = null, + unique: ?[:0]u8 = null, + owner: ?[:0]u8 = null, + hello: u32 = 0, + matches: [3]u32 = @splat(0), + ready: bool = false, + version_serial: u32 = 0, + settings_serial: u32 = 0, + chooser_version: u32 = 0, + startup_deadline: u64 = 0, + pending: ?*Pending = null, + exporter: ?*c.struct_zxdg_exporter_v2 = null, + preferences: n.uhdk_preferences = .{ .struct_size = @sizeOf(n.uhdk_preferences), .appearance = n.UHDK_APPEARANCE_UNKNOWN, .reduced_motion = 0, .high_contrast = 0 }, +}; +fn b() *native.Backend { + return native.instance.?; +} +fn s() *State { + return &b().portal; +} +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; +} +pub fn global(registry: ?*c.struct_wl_registry, name: u32, interface: []const u8, _: u32) void { + if (std.mem.eql(u8, interface, "zxdg_exporter_v2")) s().exporter = @ptrCast(c.wl_registry_bind(registry, name, &c.z_uhdk_zxdg_exporter_v2_interface, 1)); +} +pub fn initialize() void { + if (c.z_linux_dbus_available() == 0 or c.z_dbus_threads_init_default.?() == 0) return; + // Avoid libdbus autolaunch/process spawning. Desktop sessions provide this + // address; absence disables portals without affecting windows or input. + const address = c.getenv("DBUS_SESSION_BUS_ADDRESS"); + if (address == null or !std.mem.startsWith(u8, std.mem.span(address), "unix:")) return; + s().connection = c.z_dbus_connection_open_private.?(address, null) orelse return; + c.z_dbus_connection_set_exit_on_disconnect.?(s().connection, 0); + s().startup_deadline = runtime.now(b().app.io) + 5 * std.time.ns_per_s; + const message = Bus.method(dbus, dbus_path, dbus, "Hello") catch { + disconnect(); + return; + }; + defer c.z_dbus_message_unref.?(message); + s().hello = send(message) catch { + disconnect(); + return; + }; +} +fn addMatches() !void { + const rules = [_][*:0]const u8{ + "type='signal',sender='org.freedesktop.portal.Desktop',interface='org.freedesktop.portal.Request',member='Response'", + "type='signal',sender='org.freedesktop.portal.Desktop',interface='org.freedesktop.portal.Settings',member='SettingChanged',path='/org/freedesktop/portal/desktop'", + "type='signal',sender='org.freedesktop.DBus',interface='org.freedesktop.DBus',member='NameOwnerChanged',arg0='org.freedesktop.portal.Desktop'", + }; + for (rules, 0..) |rule, i| { + const message = try Bus.method(dbus, dbus_path, dbus, "AddMatch"); + defer c.z_dbus_message_unref.?(message); + var args = Bus.append(message); + try Bus.string(&args, rule); + s().matches[i] = try send(message); + } +} +fn discover() !void { + const version = try Bus.method(desktop, path, "org.freedesktop.DBus.Properties", "Get"); + defer c.z_dbus_message_unref.?(version); + var args = Bus.append(version); + try Bus.string(&args, chooser); + try Bus.string(&args, "version"); + s().version_serial = try send(version); + const read = try Bus.method(desktop, path, settings, "ReadAll"); + defer c.z_dbus_message_unref.?(read); + args = Bus.append(read); + var namespaces = try Bus.open(&args, c.DBUS_TYPE_ARRAY, "s"); + errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&args, &namespaces); + try Bus.string(&namespaces, "org.freedesktop.appearance"); + try Bus.close(&args, &namespaces); + s().settings_serial = try send(read); +} +pub fn poll(fds: *std.ArrayList(c.struct_pollfd)) !void { + const connection = s().connection orelse return; + var fd: c_int = -1; + if (c.z_dbus_connection_get_unix_fd.?(connection, &fd) != 0) + try fds.append(b().app.gpa, .{ .fd = fd, .events = @as(c_short, c.POLLIN) | @as(c_short, if (c.z_dbus_connection_has_messages_to_send.?(connection) != 0) c.POLLOUT else 0), .revents = 0 }); +} +pub fn tick() i32 { + const connection = s().connection orelse return -1; + if (c.z_dbus_connection_read_write.?(connection, 0) == 0) { + disconnect(); + return -1; + } + // Bound dispatch work so an active bus cannot starve Wayland or app commands. + for (0..64) |_| { + const message = c.z_dbus_connection_pop_message.?(connection) orelse break; + defer c.z_dbus_message_unref.?(message); + process(message) catch { + disconnect(); + return -1; + }; + if (s().connection == null) return -1; + } else return 0; + const now = runtime.now(b().app.io); + if (!s().ready and now >= s().startup_deadline) { + disconnect(); + return -1; + } + var cursor = s().pending; + while (cursor) |pending| { + cursor = pending.next; + if (pending.deadline != 0 and now >= pending.deadline) { + closeRequest(pending.handle); + complete(pending, abi.UNAVAILABLE, null); + destroy(pending); + } + } + return if (s().pending != null or !s().ready) 100 else -1; +} +fn setOwner(message: *Bus.Message) !void { + const sender = c.z_dbus_message_get_sender.?(message); + if (sender == null) return; + if (s().owner) |old| b().app.gpa.free(old); + s().owner = try b().app.gpa.dupeZ(u8, std.mem.span(sender)); +} +fn process(message: *Bus.Message) !void { + 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); + if (serial != 0 and serial == s().hello) { + s().hello = 0; + if (failed or c.z_dbus_message_has_signature.?(message, "s") == 0) { + disconnect(); + return; + } + s().unique = try b().app.gpa.dupeZ(u8, Bus.str(&args).?); + try addMatches(); + return; + } + for (&s().matches) |*match| if (serial != 0 and serial == match.*) { + if (failed) { + disconnect(); + return; + } + match.* = 0; + if (std.mem.allEqual(u32, &s().matches, 0)) { + s().ready = true; + try discover(); + } + return; + }; + if (serial != 0 and serial == s().version_serial) { + s().version_serial = 0; + if (!failed) { + var value = Bus.variant(&args) orelse return; + s().chooser_version = Bus.uint(&value) orelse 0; + try setOwner(message); + native.setCapability(n.UHDK_CAP_FILE_DIALOGS, s().chooser_version >= 3); + } + return; + } + if (serial != 0 and serial == s().settings_serial) { + s().settings_serial = 0; + if (!failed and c.z_dbus_message_has_signature.?(message, "a{sa{sv}}") != 0) { + try setOwner(message); + var appearance = Bus.entry(&args, "org.freedesktop.appearance") orelse return; + inline for (.{ "color-scheme", "contrast", "reduced-motion" }) |key| { + if (Bus.entry(&appearance, key)) |raw| { + var v = raw; + if (Bus.variant(&v)) |inner| { + var value = inner; + preference(key, Bus.uint(&value) orelse 0); + } + } + } + } + return; + } + if (c.z_dbus_message_is_signal.?(message, dbus, "NameOwnerChanged") != 0 and c.z_dbus_message_has_signature.?(message, "sss") != 0) { + const sender = c.z_dbus_message_get_sender.?(message); + if (sender == null or !std.mem.eql(u8, std.mem.span(sender), dbus)) return; + if (!std.mem.eql(u8, Bus.str(&args).?, desktop)) return; + _ = Bus.next(&args); + const old = Bus.str(&args).?; + _ = Bus.next(&args); + const new = Bus.str(&args).?; + if (old.len != 0) { + while (s().pending) |p| { + complete(p, abi.UNAVAILABLE, null); + destroy(p); + } + s().chooser_version = 0; + native.setCapability(n.UHDK_CAP_FILE_DIALOGS, false); + if (s().owner) |owner| b().app.gpa.free(owner); + s().owner = null; + preference("color-scheme", 0); + preference("contrast", 0); + preference("reduced-motion", 0); + } + if (new.len != 0) { + if (s().owner) |owner| b().app.gpa.free(owner); + s().owner = try b().app.gpa.dupeZ(u8, new); + try discover(); + } + return; + } + var cursor = s().pending; + while (cursor) |pending| : (cursor = pending.next) { + if (serial != 0 and serial == pending.serial) { + pending.replied = true; + if (failed) { + complete(pending, errorStatus(message), null); + destroy(pending); + return; + } + if (c.z_dbus_message_has_signature.?(message, "o") == 0) { + complete(pending, n.UHDK_STATUS_OS_ERROR, null); + destroy(pending); + return; + } + const handle = try b().app.gpa.dupeZ(u8, Bus.str(&args).?); + b().app.gpa.free(pending.handle); + pending.handle = handle; + if (pending.id == 0) { + if (pending.cancelled) closeRequest(pending.handle); + destroy(pending); + } else pending.deadline = 0; // Human interaction has no artificial timeout. + return; + } + } + // Match rules are installed on the bus, and sender validation also protects + // against unrelated unicast signals sent directly to our private connection. + const sender = c.z_dbus_message_get_sender.?(message); + if (sender == null or s().owner == null or !std.mem.eql(u8, std.mem.span(sender), s().owner.?)) return; + if (c.z_dbus_message_is_signal.?(message, settings, "SettingChanged") != 0 and c.z_dbus_message_has_signature.?(message, "ssv") != 0) { + if (!std.mem.eql(u8, Bus.str(&args).?, "org.freedesktop.appearance")) return; + _ = Bus.next(&args); + const key = Bus.str(&args).?; + _ = Bus.next(&args); + var value = Bus.variant(&args).?; + preference(key, Bus.uint(&value) orelse 0); + } else if (c.z_dbus_message_is_signal.?(message, "org.freedesktop.portal.Request", "Response") != 0 and c.z_dbus_message_has_signature.?(message, "ua{sv}") != 0) { + const response_path = c.z_dbus_message_get_path.?(message); + if (response_path == null) return; + cursor = s().pending; + while (cursor) |pending| : (cursor = pending.next) if (std.mem.eql(u8, pending.handle, std.mem.span(response_path))) { + const code = Bus.uint(&args).?; + _ = Bus.next(&args); + if (pending.id != 0) { + if (code != 0) complete(pending, if (code == 1) abi.CANCELLED else n.UHDK_STATUS_OS_ERROR, null) else if (pending.command.?.op == n.Z_URL) complete(pending, abi.OK, null) else responseFiles(pending, &args); + } + if (pending.replied) destroy(pending); + return; + }; + } +} +fn errorStatus(message: *Bus.Message) u32 { + const name = c.z_dbus_message_get_error_name.?(message); + if (name != null and (std.mem.endsWith(u8, std.mem.span(name), ".NotFound") or std.mem.endsWith(u8, std.mem.span(name), ".ServiceUnknown") or std.mem.endsWith(u8, std.mem.span(name), ".UnknownMethod"))) return n.UHDK_STATUS_UNSUPPORTED; + return n.UHDK_STATUS_OS_ERROR; +} +fn preference(key: []const u8, value: u32) void { + const old = s().preferences; + if (std.mem.eql(u8, key, "color-scheme")) s().preferences.appearance = switch (value) { + 1 => n.UHDK_APPEARANCE_DARK, + 2 => n.UHDK_APPEARANCE_LIGHT, + else => n.UHDK_APPEARANCE_UNKNOWN, + } else if (std.mem.eql(u8, key, "contrast")) s().preferences.high_contrast = @intFromBool(value == 1) else if (std.mem.eql(u8, key, "reduced-motion")) s().preferences.reduced_motion = @intFromBool(value == 1); + if (!std.meta.eql(old, s().preferences)) n.z_preferences(runtime.toAppHandle(b().app), &s().preferences); +} +pub fn request(cmd: *const n.z_command, id: u64) u32 { + if (!s().ready or s().connection == null) return abi.UNAVAILABLE; + if (cmd.op == n.Z_DIALOG and s().chooser_version < 3) return n.UHDK_STATUS_UNSUPPORTED; + const p = allocate(cmd, id) catch return abi.OOM; + p.next = s().pending; + s().pending = p; + if (cmd.op == n.Z_DIALOG and cmd.dialog.parent != null and s().exporter != null) { + const w: *native.Window = @ptrCast(@alignCast(n.z_object_native(cmd.dialog.parent) orelse { + destroy(p); + return abi.UNAVAILABLE; + })); + p.exported = c.zxdg_exporter_v2_export_toplevel(s().exporter, w.surface) orelse { + destroy(p); + return abi.OOM; + }; + _ = c.zxdg_exported_v2_add_listener(p.exported, &export_listener, p); + } else submit(p, "") catch { + destroy(p); + return abi.OOM; + }; + return n.Z_DEFERRED; +} +fn allocate(cmd: *const n.z_command, id: u64) !*Pending { + const gpa = b().app.gpa; + const p = try gpa.create(Pending); + errdefer gpa.destroy(p); + const token = try std.fmt.allocPrintSentinel(gpa, "uhdk_{x}", .{id}, 0); + errdefer gpa.free(token); + const unique = s().unique.?; + const name = try gpa.dupe(u8, unique[1..]); + defer gpa.free(name); + std.mem.replaceScalar(u8, name, '.', '_'); + p.* = .{ .id = id, .command = cmd, .token = token, .handle = try std.fmt.allocPrintSentinel(gpa, path ++ "/request/{s}/{s}", .{ name, token }, 0), .deadline = runtime.now(b().app.io) + 30 * std.time.ns_per_s }; + return p; +} +fn exported(raw: ?*anyopaque, _: ?*c.struct_zxdg_exported_v2, handle: [*c]const u8) callconv(.c) void { + const p: *Pending = @ptrCast(@alignCast(raw.?)); + const parent = std.fmt.allocPrintSentinel(b().app.gpa, "wayland:{s}", .{std.mem.span(handle)}, 0) catch { + complete(p, abi.OOM, null); + destroy(p); + return; + }; + defer b().app.gpa.free(parent); + submit(p, parent) catch { + complete(p, abi.OOM, null); + destroy(p); + }; +} +const export_listener: c.struct_zxdg_exported_v2_listener = .{ .handle = exported }; +fn submit(p: *Pending, parent: [*:0]const u8) !void { + const cmd = p.command.?; + const dialog = cmd.op == n.Z_DIALOG; + const message = try Bus.method(desktop, path, if (dialog) chooser else "org.freedesktop.portal.OpenURI", if (!dialog) "OpenURI" else if (cmd.dialog.kind == n.UHDK_DIALOG_SAVE_FILE) "SaveFile" else "OpenFile"); + defer c.z_dbus_message_unref.?(message); + var arena: std.heap.ArenaAllocator = .init(b().app.gpa); + defer arena.deinit(); + const gpa = arena.allocator(); + var args = Bus.append(message); + try Bus.string(&args, parent); + try Bus.string(&args, try gpa.dupeZ(u8, abi.textBytes(if (dialog) cmd.dialog.title else cmd.text))); + var options = try Bus.open(&args, c.DBUS_TYPE_ARRAY, "{sv}"); + errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&args, &options); + try Bus.option(&options, "handle_token", "s", p.token.ptr); + if (dialog) { + try Bus.option(&options, "modal", "b", @as(u32, @intFromBool(cmd.dialog.parent != null))); + if (cmd.dialog.kind != n.UHDK_DIALOG_SAVE_FILE) { + try Bus.option(&options, "multiple", "b", @as(u32, @intFromBool(cmd.dialog.kind == n.UHDK_DIALOG_OPEN_FILES))); + try Bus.option(&options, "directory", "b", @as(u32, @intFromBool(cmd.dialog.kind == n.UHDK_DIALOG_OPEN_DIRECTORY))); + } + if (cmd.dialog.accept_label.len != 0) try Bus.option(&options, "accept_label", "s", (try gpa.dupeZ(u8, abi.textBytes(cmd.dialog.accept_label))).ptr); + if (cmd.dialog.suggested_name.len != 0 and cmd.dialog.kind == n.UHDK_DIALOG_SAVE_FILE) try Bus.option(&options, "current_name", "s", (try gpa.dupeZ(u8, abi.textBytes(cmd.dialog.suggested_name))).ptr); + if (cmd.dialog.initial_directory != null) { + const directory = abi.bytes(n.z_file_path(cmd.dialog.initial_directory)); + var entry = try Bus.open(&options, c.DBUS_TYPE_DICT_ENTRY, null); + errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&options, &entry); + try Bus.string(&entry, "current_folder"); + var variant = try Bus.open(&entry, c.DBUS_TYPE_VARIANT, "ay"); + errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&entry, &variant); + var bytes = try Bus.open(&variant, c.DBUS_TYPE_ARRAY, "y"); + errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&variant, &bytes); + for (directory) |byte| try Bus.basic(&bytes, c.DBUS_TYPE_BYTE, byte); + try Bus.basic(&bytes, c.DBUS_TYPE_BYTE, @as(u8, 0)); + try Bus.close(&variant, &bytes); + try Bus.close(&entry, &variant); + try Bus.close(&options, &entry); + } + if (cmd.dialog.filters_len != 0) try filters(&options, cmd.dialog, gpa); + } + try Bus.close(&args, &options); + p.serial = try send(message); +} +fn filters(options: *Bus.Iter, desc: n.uhdk_file_dialog_desc, gpa: std.mem.Allocator) !void { + var entry = try Bus.open(options, c.DBUS_TYPE_DICT_ENTRY, null); + errdefer c.z_dbus_message_iter_abandon_container_if_open.?(options, &entry); + try Bus.string(&entry, "filters"); + var variant = try Bus.open(&entry, c.DBUS_TYPE_VARIANT, "a(sa(us))"); + errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&entry, &variant); + var list = try Bus.open(&variant, c.DBUS_TYPE_ARRAY, "(sa(us))"); + errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&variant, &list); + for (desc.filters[0..@intCast(desc.filters_len)]) |filter| { + var pair = try Bus.open(&list, c.DBUS_TYPE_STRUCT, null); + errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&list, &pair); + try Bus.string(&pair, try gpa.dupeZ(u8, abi.textBytes(filter.label))); + var patterns = try Bus.open(&pair, c.DBUS_TYPE_ARRAY, "(us)"); + errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&pair, &patterns); + for (0..@max(1, filter.extensions.len)) |index| { + const pattern: [:0]const u8 = if (filter.extensions.len == 0) "*" else try std.fmt.allocPrintSentinel(gpa, "*.{s}", .{abi.textBytes(filter.extensions.ptr[index])}, 0); + var rule = try Bus.open(&patterns, c.DBUS_TYPE_STRUCT, null); + errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&patterns, &rule); + try Bus.basic(&rule, c.DBUS_TYPE_UINT32, @as(u32, 0)); + try Bus.string(&rule, pattern); + try Bus.close(&patterns, &rule); + } + try Bus.close(&pair, &patterns); + try Bus.close(&list, &pair); + } + try Bus.close(&variant, &list); + try Bus.close(&entry, &variant); + try Bus.close(options, &entry); +} +fn responseFiles(p: *Pending, args: *Bus.Iter) void { + var raw = Bus.entry(args, "uris") orelse { + complete(p, n.UHDK_STATUS_OS_ERROR, null); + return; + }; + var value = Bus.variant(&raw) orelse { + complete(p, n.UHDK_STATUS_OS_ERROR, null); + return; + }; + if (Bus.kind(&value) != c.DBUS_TYPE_ARRAY) { + complete(p, n.UHDK_STATUS_OS_ERROR, null); + return; + } + var strings = Bus.child(&value); + var files: std.ArrayList(?*n.uhdk_file) = .empty; + defer { + for (files.items) |file| n.uhdk_file_release(file); + files.deinit(b().app.gpa); + } + while (Bus.kind(&strings) != c.DBUS_TYPE_INVALID) { + const uri = Bus.str(&strings) orelse { + complete(p, n.UHDK_STATUS_OS_ERROR, null); + return; + }; + const bytes = Files.decode(b().app.gpa, uri) catch |err| { + complete(p, if (err == error.OutOfMemory) abi.OOM else n.UHDK_STATUS_UNSUPPORTED, null); + return; + }; + defer b().app.gpa.free(bytes); + const file = n.z_file_from_url(runtime.toAppHandle(b().app), .{ .ptr = bytes.ptr, .len = bytes.len }, null) orelse { + complete(p, abi.OOM, null); + return; + }; + files.append(b().app.gpa, file) catch { + n.uhdk_file_release(file); + complete(p, abi.OOM, null); + return; + }; + if (!Bus.next(&strings)) break; + } + var result = std.mem.zeroes(n.z_request_result); + result.value.files = .{ .ptr = files.items.ptr, .len = files.items.len }; + complete(p, if (files.items.len == 0) abi.CANCELLED else abi.OK, &result); +} +fn complete(p: *Pending, status: u32, result: ?*n.z_request_result) void { + if (p.id != 0) n.z_complete(runtime.toAppHandle(b().app), p.id, status, result); + p.id = 0; + p.command = null; +} +fn closeRequest(handle: [*:0]const u8) void { + if (s().connection == null) return; + const message = Bus.method(desktop, handle, "org.freedesktop.portal.Request", "Close") catch return; + defer c.z_dbus_message_unref.?(message); + c.z_dbus_message_set_no_reply.?(message, 1); + _ = send(message) catch {}; +} +pub fn cancel(id: u64) bool { + var cursor = s().pending; + while (cursor) |p| : (cursor = p.next) if (p.id == id) { + if (p.serial != 0) closeRequest(p.handle); + p.cancelled = true; + complete(p, abi.CANCELLED, null); + if (p.serial == 0 or p.replied) destroy(p); + return true; + }; + return false; +} +fn destroy(p: *Pending) void { + var link = &s().pending; + while (link.* != p) link = &link.*.?.next; + link.* = p.next; + if (p.exported != null) c.zxdg_exported_v2_destroy(p.exported); + b().app.gpa.free(p.handle); + b().app.gpa.free(p.token); + b().app.gpa.destroy(p); +} +fn disconnect() void { + while (s().pending) |p| { + complete(p, abi.UNAVAILABLE, null); + destroy(p); + } + if (s().connection) |connection| { + c.z_dbus_connection_close.?(connection); + c.z_dbus_connection_unref.?(connection); + } + s().connection = null; + s().ready = false; + native.setCapability(n.UHDK_CAP_FILE_DIALOGS, false); +} +pub fn deinit() void { + var cursor = s().pending; + while (cursor) |p| : (cursor = p.next) closeRequest(p.handle); + disconnect(); + if (s().unique) |unique| b().app.gpa.free(unique); + if (s().owner) |owner| b().app.gpa.free(owner); + if (s().exporter != null) c.zxdg_exporter_v2_destroy(s().exporter); +} diff --git a/src/linux/README.md b/src/linux/README.md index 7b6a16c..b26a106 100644 --- a/src/linux/README.md +++ b/src/linux/README.md @@ -4,12 +4,14 @@ The first implementation targets KDE Plasma Wayland using standard protocols. It implements window creation/destruction, titles, parent relationships, size 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. -Keyboard/pointer input, cursors, text input/IME, clipboard, drag and drop, -portals, preferences, file watches, native menus, icons and activation requests -are not implemented yet. Their capabilities are false and requests return -`UNSUPPORTED`. X11 is also unsupported. This is a window/graphics foundation, -not yet a complete interactive desktop backend. +Native 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. ## Build and run @@ -28,6 +30,8 @@ C files and translates the private headers at build time. At runtime, UHDK opens `libwayland-client.so.0`. OpenGL also needs `libwayland-egl.so.1`, `libEGL.so.1` and a working system graphics driver. +Keyboard layout/text processing needs `libxkbcommon.so.0`. Portal services need +`libdbus-1.so.3`, a session bus, and the desktop portal frontend/backend. Missing EGL disables the OpenGL capability without preventing window creation. No desktop libraries are linked or bundled. EGL 1.5 and desktop GL 3.3 core are required for graphics; there is no OpenGL ES fallback. A non-null capability @@ -43,7 +47,8 @@ failure requests shutdown and cancels pending work; it never switches backends. NixOS does not normally expose every desktop library through the conventional runtime linker search path. The optional package-owned shell supplies the pinned -scanner, Python, clang-format and runtime library paths: +scanner, Python (including dbus-next for fixtures), dbus-daemon, clang-format +and runtime library paths: ```sh nix-shell @@ -87,7 +92,8 @@ NixOS with Plasma 6.5.6 and Mesa 25.2.6; other systems are not runtime-qualified application's show/hide intent, not proof that the compositor displays it. - Size/state commands acknowledge submission. Confirmed geometry and focus arrive through metrics. Wayland cannot report minimized state, force focus, or directly - undo minimization. Activation tokens will be added separately. + undo minimization. Activation requests use a launch token or a recent focused + input serial; the compositor can decline to focus the window. - Fractional scale uses fractional-scale-v1 plus viewporter; otherwise preferred integer buffer scale is used when wl_surface v6 is available. Older compositors without those mechanisms currently use scale 1. Fractional/mixed-DPI behavior @@ -104,6 +110,47 @@ NixOS with Plasma 6.5.6 and Mesa 25.2.6; other systems are not runtime-qualified context. Window framebuffer dimensions describe the presentation target; applications continue to own their viewport and rendering resources. +## Input and desktop services + +- One active seat owns keyboard, pointer, and clipboard state. The keyboard uses + the compositor's XKB layout/masks, USB HID physical usages, client-side repeat, + and locale-aware compose tables. Focus loss releases tracked keys and stops repeat. +- Text input uses text-input-v3 when available and local XKB composition for + forwarded keys. IME batches retain their surrounding-text revision and UTF-8 + offsets until `done`. Surrounding text is cropped to the protocol's 4000-byte + limit; selections too large to fit disable surrounding-text edits for that + snapshot. Ctrl editing shortcuts produce native edit events. +- Pointer motion, buttons, wheel/touchpad frames and cursor-shape-v1 are supported. + Capture acknowledges Wayland's implicit button grab during a selection drag; + requesting it without a held button/focused pointer returns `UNAVAILABLE`. + The last button release or focus loss restores normal mode. There is no pointer + lock, relative motion, touch, tablet, or themed-cursor fallback yet. +- Clipboard writes require keyboard focus and a triggering input serial. They + never wait for an unrelated future key. Reads negotiate UTF-8/plain text, + enforce the caller's byte limit without truncation, and reject invalid UTF-8. + Transfers use nonblocking reads/writes with ten-second deadlines and preserve + source/offer ownership across cancellation and selection changes. Incoming + drops accept local `text/uri-list` files using copy-only negotiation, bounded + to 16 MiB. Remote/KIO URIs are not local file references. +- File dialogs and URL opening use `org.freedesktop.portal.Desktop`, never KDE's + backend service directly. Dialogs export their parent through xdg-foreign-v2, + support filters, initial directories, suggested names and multiple selection. + A private D-Bus connection subscribes before submission, correlates early + responses, closes cancelled requests, and handles portal-owner changes. + Portal discovery is asynchronous, so its capability can appear after `READY`. + Connection/method setup is bounded; human interaction has no timeout. +- Portal Settings supplies appearance, contrast and reduced-motion preferences + and live changes. Unknown/unavailable values retain neutral defaults. See the + [Settings contract](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Settings.html) + and [request lifecycle](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html). +- 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. +- 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 + watch owns an inotify descriptor and is subject to the host's instance limits. + ## Validation From the package directory: @@ -115,6 +162,10 @@ zig build test-linux-native # opens windows in the active session zig build linux-test-bin python3 src/linux/tests/startup.py zig-out/bin/linux-native-tests 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 +zig-out/bin/linux-service-tests desktop # live portal discovery/icons/file watches python3 src/linux/tools/protocols.py --check ``` @@ -124,3 +175,13 @@ actual GL version, pixel readback and byte-preserving file URIs. The startup tes uses isolated sockets for absent, disconnected and unresponsive servers. The runtime-disconnect test proxies and closes only its own client connection; it does not stop or modify the desktop compositor. + +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 and early reader exit, URI drops, activation, portal +filters/results, response-before-reply, cancellation with a late alternate +handle, parent destruction and portal restart. 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 +not establish compatibility with every compositor or input-method engine. diff --git a/src/linux/Text.zig b/src/linux/Text.zig new file mode 100644 index 0000000..289c600 --- /dev/null +++ b/src/linux/Text.zig @@ -0,0 +1,337 @@ +//! IME batches are applied at done, using the snapshot associated with its +//! commit serial. Forwarded wl_keyboard keys use local XKB composition. +const std = @import("std"); +const c = @import("linux_c"); +const n = @import("native"); +const abi = @import("../abi.zig"); +const native = @import("native.zig"); +const Input = @import("Input.zig"); +const Keyboard = @import("Keyboard.zig"); + +const Sent = struct { + next: ?*Sent = null, + serial: u32, + revision: u64, + bytes: [:0]u8, + offset: usize, + start: usize, + len: usize, +}; +pub const State = struct { + enabled: bool = false, + composing: bool = false, + ime_composing: bool = false, + snapshot: n.uhdk_text_state = std.mem.zeroes(n.uhdk_text_state), + surrounding: []u8 = &.{}, + last_edit: u64 = 0, + sent: ?*Sent = null, + sent_count: usize = 0, + dirty: bool = true, + pending_preedit: ?[]u8 = null, + pending_commit: ?[]u8 = null, + cursor_begin: i32 = 0, + cursor_end: i32 = 0, + before: u32 = 0, + after: u32 = 0, +}; +pub fn listen(input: ?*c.struct_zwp_text_input_v3) void { + _ = c.zwp_text_input_v3_add_listener(input, &listener, null); +} +fn commit() u32 { + const s = Input.state(); + s.text_serial +%= 1; + c.zwp_text_input_v3_commit(s.text_input); + return s.text_serial; +} +fn enter(_: ?*anyopaque, _: ?*c.struct_zwp_text_input_v3, surface: ?*c.struct_wl_surface) callconv(.c) void { + const s = Input.state(); + s.text_focus = Input.find(surface); + if (s.text_focus) |w| if (w.text.enabled) { + c.zwp_text_input_v3_enable(s.text_input); + _ = publish(w); + }; +} +fn leave(_: ?*anyopaque, _: ?*c.struct_zwp_text_input_v3, _: ?*c.struct_wl_surface) callconv(.c) void { + if (Input.state().text_focus) |w| { + end(w); + clearPending(w); + clearSent(w); + } + Input.state().text_focus = null; +} +fn own(w: *native.Window, destination: *?[]u8, text: [*c]const u8) void { + if (destination.*) |old| w.owner.gpa.free(old); + const bytes = if (text == null) "" else std.mem.span(text); + destination.* = w.owner.gpa.dupe(u8, bytes) catch null; +} +fn preedit(_: ?*anyopaque, _: ?*c.struct_zwp_text_input_v3, text: [*c]const u8, first: i32, last: i32) callconv(.c) void { + const w = Input.state().text_focus orelse return; + own(w, &w.text.pending_preedit, text); + w.text.cursor_begin = first; + w.text.cursor_end = last; +} +fn committed(_: ?*anyopaque, _: ?*c.struct_zwp_text_input_v3, text: [*c]const u8) callconv(.c) void { + const w = Input.state().text_focus orelse return; + own(w, &w.text.pending_commit, text); +} +fn deleted(_: ?*anyopaque, _: ?*c.struct_zwp_text_input_v3, before: u32, after: u32) callconv(.c) void { + const w = Input.state().text_focus orelse return; + w.text.before = before; + w.text.after = after; +} +fn done(_: ?*anyopaque, _: ?*c.struct_zwp_text_input_v3, serial: u32) callconv(.c) void { + const w = Input.state().text_focus orelse return; + const t = &w.text; + defer clearPending(w); + if (!t.enabled) return; + var sent = t.sent; + while (sent) |value| { + if (value.serial == serial) break; + sent = value.next; + } + // A cleared serial belongs to a cancelled/disabled input session. + const basis = sent orelse return; + if (t.pending_commit) |bytes| if (!std.unicode.utf8ValidateSlice(bytes)) return; + end(w); + if (t.pending_commit != null or t.before != 0 or t.after != 0) { + var event = std.mem.zeroes(n.z_event); + event.type = n.UHDK_EVENT_TEXT_COMMIT; + event.input_id = Input.id(); + event.data.text.text = abi.textSpan(t.pending_commit orelse ""); + event.data.text.revision = basis.revision; + if (t.before != 0 or t.after != 0) { + if (t.before > basis.start or t.after > basis.bytes.len - basis.start - basis.len) return; + const start = basis.start - t.before; + const length = t.before + basis.len + t.after; + if (!abi.boundary(basis.bytes, start) or !abi.boundary(basis.bytes, start + length)) return; + event.data.text.has_replacement = 1; + event.data.text.replacement_start = basis.offset + start; + event.data.text.replacement_len = length; + } + t.last_edit = event.input_id; + Input.send(w, &event); + } + if (t.pending_preedit) |bytes| { + if (bytes.len != 0 and std.unicode.utf8ValidateSlice(bytes)) { + var event = std.mem.zeroes(n.z_event); + event.type = n.UHDK_EVENT_TEXT_PREEDIT; + event.input_id = Input.id(); + event.data.text.text = abi.textSpan(bytes); + event.data.text.revision = basis.revision; + const lo: usize = @intCast(@max(0, @min(t.cursor_begin, t.cursor_end))); + const hi: usize = @intCast(@max(0, @max(t.cursor_begin, t.cursor_end))); + if (lo <= hi and hi <= bytes.len and abi.boundary(bytes, lo) and abi.boundary(bytes, hi)) { + event.data.text.selection_start = lo; + event.data.text.selection_len = hi - lo; + } + t.composing = true; + t.ime_composing = true; + t.last_edit = event.input_id; + Input.send(w, &event); + } + } + // Keep the acknowledged basis: more than one done can share its serial. + while (t.sent != basis) { + const old = t.sent.?; + t.sent = old.next; + w.owner.gpa.free(old.bytes); + w.owner.gpa.destroy(old); + t.sent_count -= 1; + } + // Never send pending state in response to an older serial. A later matching + // done or an acknowledged application basis will release the throttle. + if (serial == Input.state().text_serial and t.dirty and t.snapshot.acknowledged_input_id >= t.last_edit) _ = publish(w); +} +const listener: c.struct_zwp_text_input_v3_listener = .{ .enter = enter, .leave = leave, .preedit_string = preedit, .commit_string = committed, .delete_surrounding_text = deleted, .done = done }; + +pub fn crop(bytes: []const u8, start: usize, length: usize) ?struct { start: usize, end: usize } { + if (length > 4000 or std.mem.findScalar(u8, bytes, 0) != null) return null; + var lo = start -| ((4000 - length) / 2); + while (lo < start and !abi.boundary(bytes, lo)) lo += 1; + var hi = @min(bytes.len, lo + 4000); + while (hi > start + length and !abi.boundary(bytes, hi)) hi -= 1; + if (hi < start + length) return null; + return .{ .start = lo, .end = hi }; +} +fn publish(w: *native.Window) u32 { + const s = Input.state(); + const t = &w.text; + if (s.text_input == null or s.text_focus != w or !t.enabled) return abi.OK; + // Bound unacknowledged snapshots without discarding snapshots a late IME + // batch may still refer to. Keep the newest application state for later. + if (t.sent_count >= 32) { + t.dirty = true; + return abi.OK; + } + const range = crop(t.surrounding, @intCast(t.snapshot.selection_start), @intCast(t.snapshot.selection_len)); + const sent = w.owner.gpa.create(Sent) catch return abi.OOM; + const bytes = w.owner.gpa.dupeZ(u8, if (range) |r| t.surrounding[r.start..r.end] else "") catch { + w.owner.gpa.destroy(sent); + return abi.OOM; + }; + sent.* = .{ .serial = s.text_serial +% 1, .revision = t.snapshot.revision, .bytes = bytes, .offset = if (range) |r| r.start else 0, .start = if (range) |r| @intCast(t.snapshot.selection_start - r.start) else 0, .len = if (range != null) @intCast(t.snapshot.selection_len) else 0 }; + c.zwp_text_input_v3_set_surrounding_text(s.text_input, bytes, @intCast(sent.start + sent.len), @intCast(sent.start)); + const caret = t.snapshot.caret; + c.zwp_text_input_v3_set_cursor_rectangle(s.text_input, @intFromFloat(std.math.clamp(caret.x, -1000000, 1000000)), @intFromFloat(std.math.clamp(caret.y, -1000000, 1000000)), @intFromFloat(@min(caret.width, 1000000)), @intFromFloat(@min(caret.height, 1000000))); + c.zwp_text_input_v3_set_content_type(s.text_input, c.ZWP_TEXT_INPUT_V3_CONTENT_HINT_MULTILINE, c.ZWP_TEXT_INPUT_V3_CONTENT_PURPOSE_NORMAL); + c.zwp_text_input_v3_set_text_change_cause(s.text_input, if (t.snapshot.acknowledged_input_id == t.last_edit) c.ZWP_TEXT_INPUT_V3_CHANGE_CAUSE_INPUT_METHOD else c.ZWP_TEXT_INPUT_V3_CHANGE_CAUSE_OTHER); + _ = commit(); + var link = &t.sent; + while (link.*) |item| link = &item.next; + link.* = sent; + t.sent_count += 1; + t.dirty = false; + return abi.OK; +} +pub fn snapshot(w: *native.Window, value: n.uhdk_text_state) u32 { + const t = &w.text; + if (value.acknowledged_input_id < t.last_edit) return abi.OK; + const bytes = abi.textBytes(value.surrounding_text); + const same = t.snapshot.revision == value.revision and t.snapshot.selection_start == value.selection_start and t.snapshot.selection_len == value.selection_len and + std.meta.eql(t.snapshot.caret, value.caret) and std.mem.eql(u8, t.surrounding, bytes); + const copy = w.owner.gpa.dupe(u8, bytes) catch return abi.OOM; + w.owner.gpa.free(t.surrounding); + t.surrounding = copy; + t.snapshot = value; + t.snapshot.surrounding_text = abi.textSpan(copy); + if (same and !t.dirty) return abi.OK; + t.dirty = true; + return publish(w); +} +pub fn enable(w: *native.Window, enabled: bool) u32 { + const t = &w.text; + if (enabled and Input.state().context == null) return n.UHDK_STATUS_UNSUPPORTED; + if (t.enabled == enabled) return abi.OK; + end(w); + clearPending(w); + clearSent(w); + if (Input.state().compose != null) c.z_xkb_compose_state_reset.?(Input.state().compose); + t.enabled = enabled; + const s = Input.state(); + if (s.text_input != null and s.text_focus == w) { + if (enabled) { + c.zwp_text_input_v3_enable(s.text_input); + return publish(w); + } + c.zwp_text_input_v3_disable(s.text_input); + _ = commit(); + } + return abi.OK; +} +fn end(w: *native.Window) void { + if (w.text.composing) endAt(w, Input.id()); +} +fn endAt(w: *native.Window, input_id: u64) void { + const t = &w.text; + if (!t.composing) return; + var e = std.mem.zeroes(n.z_event); + e.type = n.UHDK_EVENT_TEXT_COMPOSITION_END; + e.input_id = input_id; + t.last_edit = input_id; + Input.send(w, &e); + t.composing = false; + t.ime_composing = false; +} +fn clearPending(w: *native.Window) void { + const t = &w.text; + if (t.pending_preedit) |p| w.owner.gpa.free(p); + if (t.pending_commit) |p| w.owner.gpa.free(p); + t.pending_preedit = null; + t.pending_commit = null; + t.before = 0; + t.after = 0; +} +fn clearSent(w: *native.Window) void { + while (w.text.sent) |item| { + w.text.sent = item.next; + w.owner.gpa.free(item.bytes); + w.owner.gpa.destroy(item); + } + w.text.sent_count = 0; + w.text.dirty = true; +} +pub fn cancel(w: *native.Window) void { + end(w); + clearPending(w); + clearSent(w); + const s = Input.state(); + if (s.compose != null) c.z_xkb_compose_state_reset.?(s.compose); + if (s.text_input != null and s.text_focus == w and w.text.enabled) { + c.zwp_text_input_v3_disable(s.text_input); + _ = commit(); + c.zwp_text_input_v3_enable(s.text_input); + _ = publish(w); + } +} +pub fn forget(w: *native.Window) void { + if (Input.state().text_focus == w) { + _ = enable(w, false); + Input.state().text_focus = null; + } + end(w); + clearPending(w); + clearSent(w); +} +pub fn deinit(w: *native.Window) void { + forget(w); + w.owner.gpa.free(w.text.surrounding); +} +pub fn key(w: *native.Window, code: u32, sym: u32, logical: u32, input_id: u64) void { + const s = Input.state(); + const t = &w.text; + if (t.ime_composing or s.state == null) return; + const edit = Keyboard.edit(logical, s.modifiers); + if (edit != 0) { + if (s.compose != null) c.z_xkb_compose_state_reset.?(s.compose); + endAt(w, input_id); + var e = std.mem.zeroes(n.z_event); + e.type = n.UHDK_EVENT_EDIT_COMMAND; + e.input_id = input_id; + e.data.edit = .{ .command = edit, .extend_selection = @intFromBool(s.modifiers & n.UHDK_MOD_SHIFT != 0) }; + t.last_edit = input_id; + Input.send(w, &e); + return; + } + if (s.modifiers & (n.UHDK_MOD_CONTROL | n.UHDK_MOD_ALT | n.UHDK_MOD_SUPER) != 0) return; + var buffer: [256]u8 = undefined; + var length: c_int = 0; + if (s.compose != null) { + _ = c.z_xkb_compose_state_feed.?(s.compose, sym); + switch (c.z_xkb_compose_state_get_status.?(s.compose)) { + c.XKB_COMPOSE_COMPOSING => { + t.composing = true; + return; + }, + c.XKB_COMPOSE_CANCELLED => { + c.z_xkb_compose_state_reset.?(s.compose); + endAt(w, input_id); + return; + }, + c.XKB_COMPOSE_COMPOSED => { + length = c.z_xkb_compose_state_get_utf8.?(s.compose, &buffer, buffer.len); + c.z_xkb_compose_state_reset.?(s.compose); + endAt(w, input_id); + }, + else => length = c.z_xkb_state_key_get_utf8.?(s.state, code + 8, &buffer, buffer.len), + } + } else length = c.z_xkb_state_key_get_utf8.?(s.state, code + 8, &buffer, buffer.len); + if (length <= 0 or length >= buffer.len or buffer[0] < 32 or buffer[0] == 127) return; + var e = std.mem.zeroes(n.z_event); + e.type = n.UHDK_EVENT_TEXT_COMMIT; + e.input_id = input_id; + e.data.text.text = abi.textSpan(buffer[0..@intCast(length)]); + e.data.text.revision = t.snapshot.revision; + t.last_edit = input_id; + Input.send(w, &e); +} +test "surrounding crop preserves UTF-8 and complete selections" { + var bytes: [8000]u8 = undefined; + for (0..4000) |i| { + bytes[i * 2] = 0xc3; + bytes[i * 2 + 1] = 0xa9; + } + const range = crop(&bytes, 3998, 4).?; + try std.testing.expect(range.start % 2 == 0 and range.end % 2 == 0 and range.end - range.start <= 4000); + try std.testing.expect(range.start <= 3998 and range.end >= 4002); + try std.testing.expect(crop(&bytes, 0, 4002) == null); +} diff --git a/src/linux/Watch.zig b/src/linux/Watch.zig new file mode 100644 index 0000000..596f071 --- /dev/null +++ b/src/linux/Watch.zig @@ -0,0 +1,130 @@ +//! inotify watches observe a document's parent so atomic replacement keeps the +//! watch attached to the name. Directory watches cover immediate children only. +const std = @import("std"); +const c = @import("linux_c"); +const n = @import("native"); +const native = @import("native.zig"); +const runtime = @import("../runtime.zig"); +const abi = @import("../abi.zig"); +pub const Watch = struct { + next: ?*Watch = null, + handle: ?*n.uhdk_watch, + root: ?*n.uhdk_file, + fd: c_int, + directory: []u8, + name: []u8, +}; +fn b() *native.Backend { + return native.instance.?; +} +pub fn create(cmd: *const n.z_command, result: *n.z_request_result) u32 { + const root: ?*n.uhdk_file = @ptrCast(cmd.target); + const path = b().app.gpa.dupeZ(u8, abi.bytes(n.z_file_path(root))) catch return abi.OOM; + defer b().app.gpa.free(path); + const kind = c.z_linux_is_directory(path); + if (kind < 0 and c.z_linux_errno() != c.ENOENT) return n.UHDK_STATUS_OS_ERROR; + const directory = b().app.gpa.dupeZ(u8, if (kind == 1) path else std.fs.path.dirname(path) orelse "/") catch return abi.OOM; + defer b().app.gpa.free(directory); + const fd = c.inotify_init1(c.IN_NONBLOCK | c.IN_CLOEXEC); + if (fd < 0) return n.UHDK_STATUS_OS_ERROR; + const mask = c.IN_CREATE | c.IN_DELETE | c.IN_MOVED_FROM | c.IN_MOVED_TO | c.IN_MODIFY | c.IN_ATTRIB | c.IN_DELETE_SELF | c.IN_MOVE_SELF | c.IN_ONLYDIR; + if (c.inotify_add_watch(fd, directory, mask) < 0) { + _ = c.close(fd); + return n.UHDK_STATUS_OS_ERROR; + } + const w = allocate(fd, root, @ptrCast(cmd.other), directory, if (kind == 1) "" else std.fs.path.basename(path)) catch { + _ = c.close(fd); + return abi.OOM; + }; + w.next = b().watches; + b().watches = w; + n.z_object_set_native(cmd.other, w); + result.value.watch = w.handle; + return abi.OK; +} +fn allocate(fd: c_int, root: ?*n.uhdk_file, handle: ?*n.uhdk_watch, directory: []const u8, name: []const u8) !*Watch { + const gpa = b().app.gpa; + const w = try gpa.create(Watch); + errdefer gpa.destroy(w); + const dir = try gpa.dupe(u8, directory); + errdefer gpa.free(dir); + w.* = .{ .fd = fd, .root = root, .handle = handle, .directory = dir, .name = try gpa.dupe(u8, name) }; + return w; +} +pub fn poll(fds: *std.ArrayList(c.struct_pollfd)) !void { + var w = b().watches; + while (w) |item| : (w = item.next) if (item.fd >= 0) try fds.append(b().app.gpa, .{ .fd = item.fd, .events = c.POLLIN, .revents = 0 }); +} +pub fn flags(mask: u32) u32 { + var value: u32 = 0; + if (mask & (c.IN_CREATE | c.IN_MOVED_TO) != 0) value |= n.UHDK_CHANGE_CREATED; + if (mask & (c.IN_DELETE | c.IN_MOVED_FROM) != 0) value |= n.UHDK_CHANGE_REMOVED; + if (mask & c.IN_MODIFY != 0) value |= n.UHDK_CHANGE_CONTENT; + if (mask & c.IN_ATTRIB != 0) value |= n.UHDK_CHANGE_METADATA; + if (mask & (c.IN_MOVED_TO | c.IN_MOVED_FROM) != 0) value |= n.UHDK_CHANGE_RENAMED; + if (mask & c.IN_Q_OVERFLOW != 0) value |= n.UHDK_CHANGE_RESCAN_REQUIRED; + if (mask & (c.IN_DELETE_SELF | c.IN_MOVE_SELF | c.IN_IGNORED | c.IN_UNMOUNT) != 0) value |= n.UHDK_CHANGE_ROOT_INVALIDATED; + return value; +} +fn emit(w: *Watch, changes: u32, name: []const u8, status: u32) void { + var file = w.root; + if (w.name.len == 0 and name.len != 0) { + const path = std.fs.path.join(b().app.gpa, &.{ w.directory, name }) catch null; + if (path) |p| { + defer b().app.gpa.free(p); + file = n.z_file_from_url(runtime.toAppHandle(b().app), .{ .ptr = p.ptr, .len = p.len }, null); + } else file = null; + } + defer if (file != null and file != w.root) n.uhdk_file_release(file); + var event = std.mem.zeroes(n.z_event); + event.type = n.UHDK_EVENT_WATCH; + event.data.watch = .{ .watch = w.handle, .flags = changes, .status = status, .file = file }; + n.z_emit(runtime.toAppHandle(b().app), &event); +} +pub fn tick() void { + var cursor = b().watches; + while (cursor) |w| : (cursor = w.next) { + if (w.fd < 0) continue; + var buffer: [65536]u8 align(@alignOf(c.struct_inotify_event)) = undefined; + const count = c.z_linux_read(w.fd, &buffer, buffer.len); + if (count < 0) { + if (c.z_linux_errno() != c.EAGAIN and c.z_linux_errno() != c.EINTR) { + emit(w, n.UHDK_CHANGE_ROOT_INVALIDATED, "", n.UHDK_STATUS_OS_ERROR); + _ = c.close(w.fd); + w.fd = -1; + } + continue; + } + var offset: usize = 0; + while (offset + @sizeOf(c.struct_inotify_event) <= @as(usize, @intCast(count))) { + const event: *const c.struct_inotify_event = @ptrCast(@alignCast(&buffer[offset])); + const start = offset + @sizeOf(c.struct_inotify_event); + offset = start + event.len; + if (offset > @as(usize, @intCast(count))) break; + const bytes = buffer[start..offset]; + const name = bytes[0 .. std.mem.findScalar(u8, bytes, 0) orelse bytes.len]; + const changes = flags(event.mask); + const root_event = changes & (n.UHDK_CHANGE_ROOT_INVALIDATED | n.UHDK_CHANGE_RESCAN_REQUIRED) != 0; + if (changes != 0 and (root_event or w.name.len == 0 or std.mem.eql(u8, name, w.name))) emit(w, changes, if (root_event) "" else name, abi.OK); + if (changes & n.UHDK_CHANGE_ROOT_INVALIDATED != 0) { + _ = c.close(w.fd); + w.fd = -1; + break; + } + } + } +} +pub fn destroy(w: *Watch) void { + var link = &b().watches; + while (link.* != w) link = &link.*.?.next; + link.* = w.next; + if (w.fd >= 0) _ = c.close(w.fd); + b().app.gpa.free(w.directory); + b().app.gpa.free(w.name); + b().app.gpa.destroy(w); +} +test "watch replacement is remove/create, overflow and invalidation stay distinct" { + try std.testing.expectEqual(@as(u32, n.UHDK_CHANGE_CREATED | n.UHDK_CHANGE_RENAMED), flags(c.IN_MOVED_TO)); + try std.testing.expectEqual(@as(u32, n.UHDK_CHANGE_RESCAN_REQUIRED), flags(c.IN_Q_OVERFLOW)); + try std.testing.expectEqual(@as(u32, n.UHDK_CHANGE_ROOT_INVALIDATED), flags(c.IN_IGNORED)); +} diff --git a/src/linux/bindings.h b/src/linux/bindings.h index 1c58fb1..bc57576 100644 --- a/src/linux/bindings.h +++ b/src/linux/bindings.h @@ -4,8 +4,12 @@ #include #include #include +#include #include #include +#include +#include +#include #include "wayland-client-core.h" #include "wayland-egl-core.h" #define EGL_NO_X11 @@ -16,6 +20,11 @@ /* Runtime imports are checked against upstream declarations in libraries.c. * typeof(function) in translated Zig debug info can create unwanted link-time * references, so the translated header uses explicit function-pointer types. */ +#include +#include +#include "xkb-imports.h" +#include +#include "dbus-imports.h" #include "function-pointers.h" #include "redirects.h" #include "wayland-client-protocol.h" @@ -23,12 +32,23 @@ #include "xdg-decoration-unstable-v1.h" #include "viewporter.h" #include "fractional-scale-v1.h" +#include "cursor-shape-v1.h" +#include "text-input-unstable-v3.h" +#include "xdg-activation-v1.h" +#include "xdg-foreign-unstable-v2.h" +#include "xdg-toplevel-icon-v1.h" int z_linux_libraries_open(void); int z_linux_egl_available(void); +int z_linux_xkb_available(void); +int z_linux_dbus_available(void); +int z_linux_is_directory(const char *); +int z_linux_nonblock(int); +int z_linux_pipe(int[2]); void z_linux_libraries_close(void); int z_linux_errno(void); int z_linux_poll(struct pollfd *, nfds_t, int); ssize_t z_linux_read(int, void *, size_t); ssize_t z_linux_write(int, const void *, size_t); +ssize_t z_linux_pipe_write(int, const void *, size_t); int z_linux_is_main_thread(void); #endif diff --git a/src/linux/build.zig b/src/linux/build.zig index 639b930..0cc90f4 100644 --- a/src/linux/build.zig +++ b/src/linux/build.zig @@ -6,13 +6,13 @@ pub fn configure(b: *std.Build, module: *std.Build.Module, target: std.Build.Res .target = target, .optimize = optimize, }); - for ([_][]const u8{ "src/linux", "src/linux/vendor/wayland", "src/linux/vendor/khronos", "src/linux/protocols/generated" }) |path| { + for ([_][]const u8{ "src/linux", "src/linux/vendor/wayland", "src/linux/vendor", "src/linux/vendor/dbus", "src/linux/vendor/khronos", "src/linux/protocols/generated" }) |path| { bindings.addIncludePath(b.path(path)); module.addIncludePath(b.path(path)); } module.addImport("linux_c", bindings.createModule()); module.addCSourceFile(.{ .file = b.path("src/linux/libraries.c"), .flags = &.{ "-std=gnu11", "-Wall", "-Wextra", "-Werror", "-fvisibility=hidden" } }); - for ([_][]const u8{ "wayland", "xdg-shell", "xdg-decoration-unstable-v1", "viewporter", "fractional-scale-v1" }) |name| { + for ([_][]const u8{ "wayland", "xdg-shell", "xdg-decoration-unstable-v1", "viewporter", "fractional-scale-v1", "cursor-shape-v1", "tablet-v2", "text-input-unstable-v3", "xdg-activation-v1", "xdg-foreign-unstable-v2", "xdg-toplevel-icon-v1" }) |name| { module.addCSourceFile(.{ .file = b.path(b.fmt("src/linux/protocols/generated/{s}.c", .{name})), .flags = &.{ "-std=c99", "-include", b.path("src/linux/protocols/generated/private-symbols.h").getPath(b) }, @@ -30,5 +30,18 @@ pub fn addTests(b: *std.Build, lib: *std.Build.Step.Compile, target: std.Build.R check.dependOn(&exe.step); b.step("test-linux-native", "Exercise Wayland/EGL lifecycle in the active desktop session").dependOn(&b.addRunArtifact(exe).step); const install = b.addInstallArtifact(exe, .{}); - b.step("linux-test-bin", "Install the Linux native test harness").dependOn(&install.step); + b.step("linux-test-bin", "Install the Linux native test harnesses").dependOn(&install.step); + addServiceTests(b, lib, target, optimize, check); +} + +// Kept separate from automatic native smoke: fixture tests supply an isolated +// compositor/session bus, and the live mode explicitly checks installed portals. +pub fn addServiceTests(b: *std.Build, lib: *std.Build.Step.Compile, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, check: *std.Build.Step) void { + const module = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true }); + module.addIncludePath(b.path("include")); + module.addCSourceFile(.{ .file = b.path("src/linux/tests/services.c"), .flags = &.{ "-std=c99", "-Wall", "-Wextra", "-Werror" } }); + module.linkLibrary(lib); + const exe = b.addExecutable(.{ .name = "linux-service-tests", .root_module = module }); + check.dependOn(&exe.step); + b.top_level_steps.get("linux-test-bin").?.step.dependOn(&b.addInstallArtifact(exe, .{}).step); } diff --git a/src/linux/dbus-imports.h b/src/linux/dbus-imports.h new file mode 100644 index 0000000..f522228 --- /dev/null +++ b/src/linux/dbus-imports.h @@ -0,0 +1,69 @@ +/* Runtime ABI pointers, type-checked against upstream in libraries.c. */ +#ifndef UHDK_DBUS_IMPORTS_H +#define UHDK_DBUS_IMPORTS_H +extern DBusConnection *(*z_dbus_connection_open_private)(const char *address, DBusError *error); +extern void (*z_dbus_connection_set_exit_on_disconnect)( + DBusConnection *connection, + dbus_bool_t exit_on_disconnect +); +extern void (*z_dbus_connection_close)(DBusConnection *connection); +extern void (*z_dbus_connection_unref)(DBusConnection *connection); +extern dbus_bool_t (*z_dbus_connection_get_is_connected)(DBusConnection *connection); +extern dbus_bool_t (*z_dbus_connection_get_unix_fd)(DBusConnection *connection, int *fd); +extern dbus_bool_t (*z_dbus_connection_has_messages_to_send)(DBusConnection *connection); +extern dbus_bool_t (*z_dbus_connection_read_write)( + DBusConnection *connection, + int timeout_milliseconds +); +extern DBusMessage *(*z_dbus_connection_pop_message)(DBusConnection *connection); +extern dbus_bool_t (*z_dbus_connection_send)( + DBusConnection *connection, + DBusMessage *message, + dbus_uint32_t *client_serial +); +extern DBusMessage *(*z_dbus_message_new_method_call)( + const char *bus_name, + const char *path, + const char *iface, + const char *method +); +extern void (*z_dbus_message_unref)(DBusMessage *message); +extern int (*z_dbus_message_get_type)(DBusMessage *message); +extern dbus_uint32_t (*z_dbus_message_get_reply_serial)(DBusMessage *message); +extern const char *(*z_dbus_message_get_path)(DBusMessage *message); +extern const char *(*z_dbus_message_get_sender)(DBusMessage *message); +extern const char *(*z_dbus_message_get_error_name)(DBusMessage *message); +extern dbus_bool_t (*z_dbus_message_is_signal)( + DBusMessage *message, + const char *iface, + const char *signal_name +); +extern dbus_bool_t (*z_dbus_message_has_signature)(DBusMessage *message, const char *signature); +extern void (*z_dbus_message_set_no_reply)(DBusMessage *message, dbus_bool_t no_reply); +extern dbus_bool_t (*z_dbus_message_iter_init)(DBusMessage *message, DBusMessageIter *iter); +extern void (*z_dbus_message_iter_init_append)(DBusMessage *message, DBusMessageIter *iter); +extern int (*z_dbus_message_iter_get_arg_type)(DBusMessageIter *iter); +extern dbus_bool_t (*z_dbus_message_iter_next)(DBusMessageIter *iter); +extern void (*z_dbus_message_iter_recurse)(DBusMessageIter *iter, DBusMessageIter *sub); +extern void (*z_dbus_message_iter_get_basic)(DBusMessageIter *iter, void *value); +extern dbus_bool_t (*z_dbus_message_iter_append_basic)( + DBusMessageIter *iter, + int type, + const void *value +); +extern dbus_bool_t (*z_dbus_message_iter_open_container)( + DBusMessageIter *iter, + int type, + const char *contained_signature, + DBusMessageIter *sub +); +extern dbus_bool_t (*z_dbus_message_iter_close_container)( + DBusMessageIter *iter, + DBusMessageIter *sub +); +extern void (*z_dbus_message_iter_abandon_container_if_open)( + DBusMessageIter *iter, + DBusMessageIter *sub +); +extern dbus_bool_t (*z_dbus_threads_init_default)(void); +#endif diff --git a/src/linux/functions.inc b/src/linux/functions.inc index 34ef01f..45759cc 100644 --- a/src/linux/functions.inc +++ b/src/linux/functions.inc @@ -30,3 +30,54 @@ Z_FUNCTION(eglSwapBuffers) Z_FUNCTION(eglSwapInterval) Z_FUNCTION(eglGetProcAddress) Z_FUNCTION(eglReleaseThread) +Z_FUNCTION(xkb_context_new) +Z_FUNCTION(xkb_context_unref) +Z_FUNCTION(xkb_keymap_new_from_string) +Z_FUNCTION(xkb_keymap_unref) +Z_FUNCTION(xkb_keymap_key_repeats) +Z_FUNCTION(xkb_state_new) +Z_FUNCTION(xkb_state_unref) +Z_FUNCTION(xkb_state_update_mask) +Z_FUNCTION(xkb_state_key_get_one_sym) +Z_FUNCTION(xkb_state_key_get_utf8) +Z_FUNCTION(xkb_state_mod_name_is_active) +Z_FUNCTION(xkb_keysym_to_utf32) +Z_FUNCTION(xkb_compose_table_new_from_locale) +Z_FUNCTION(xkb_compose_table_unref) +Z_FUNCTION(xkb_compose_state_new) +Z_FUNCTION(xkb_compose_state_unref) +Z_FUNCTION(xkb_compose_state_feed) +Z_FUNCTION(xkb_compose_state_reset) +Z_FUNCTION(xkb_compose_state_get_status) +Z_FUNCTION(xkb_compose_state_get_utf8) +Z_FUNCTION(dbus_connection_open_private) +Z_FUNCTION(dbus_connection_set_exit_on_disconnect) +Z_FUNCTION(dbus_connection_close) +Z_FUNCTION(dbus_connection_unref) +Z_FUNCTION(dbus_connection_get_is_connected) +Z_FUNCTION(dbus_connection_get_unix_fd) +Z_FUNCTION(dbus_connection_has_messages_to_send) +Z_FUNCTION(dbus_connection_read_write) +Z_FUNCTION(dbus_connection_pop_message) +Z_FUNCTION(dbus_connection_send) +Z_FUNCTION(dbus_message_new_method_call) +Z_FUNCTION(dbus_message_unref) +Z_FUNCTION(dbus_message_get_type) +Z_FUNCTION(dbus_message_get_reply_serial) +Z_FUNCTION(dbus_message_get_path) +Z_FUNCTION(dbus_message_get_sender) +Z_FUNCTION(dbus_message_get_error_name) +Z_FUNCTION(dbus_message_is_signal) +Z_FUNCTION(dbus_message_has_signature) +Z_FUNCTION(dbus_message_set_no_reply) +Z_FUNCTION(dbus_message_iter_init) +Z_FUNCTION(dbus_message_iter_init_append) +Z_FUNCTION(dbus_message_iter_get_arg_type) +Z_FUNCTION(dbus_message_iter_next) +Z_FUNCTION(dbus_message_iter_recurse) +Z_FUNCTION(dbus_message_iter_get_basic) +Z_FUNCTION(dbus_message_iter_append_basic) +Z_FUNCTION(dbus_message_iter_open_container) +Z_FUNCTION(dbus_message_iter_close_container) +Z_FUNCTION(dbus_message_iter_abandon_container_if_open) +Z_FUNCTION(dbus_threads_init_default) diff --git a/src/linux/libraries.c b/src/linux/libraries.c index cd289c4..ddf5785 100644 --- a/src/linux/libraries.c +++ b/src/linux/libraries.c @@ -2,22 +2,31 @@ #include #include #include +#include +#include +#include #include #include #include +#include #include #include "wayland-client-core.h" #include "wayland-egl-core.h" #define EGL_NO_X11 #include "EGL/egl.h" #include "EGL/eglext.h" +#include +#include +#include "xkb-imports.h" +#include +#include "dbus-imports.h" #include "function-pointers.h" #define Z_FUNCTION(name) __typeof__(name) *z_##name; #include "functions.inc" #undef Z_FUNCTION -static void *wayland, *wayland_egl, *egl; -static int have_egl; +static void *wayland, *wayland_egl, *egl, *xkb, *dbus; +static int have_egl, have_xkb, have_dbus; static void *open_library(const char *name) { void *handle = dlopen(name, RTLD_NOW | RTLD_LOCAL); @@ -36,7 +45,13 @@ static void *open_library(const char *name) { } void z_linux_libraries_close(void) { - have_egl = 0; + have_egl = have_xkb = have_dbus = 0; + if (dbus) + dlclose(dbus); + dbus = NULL; + if (xkb) + dlclose(xkb); + xkb = NULL; if (egl) dlclose(egl); if (wayland_egl) @@ -56,19 +71,34 @@ int z_linux_libraries_open(void) { wayland_egl = open_library("libwayland-egl.so.1"); egl = open_library("libEGL.so.1"); have_egl = wayland_egl && egl; + xkb = open_library("libxkbcommon.so.0"); + have_xkb = xkb != NULL; + dbus = open_library("libdbus-1.so.3"); + have_dbus = dbus != NULL; #define Z_FUNCTION(name) \ do { \ const char *symbol = #name; \ + int bus = strncmp(symbol, "dbus_", 5) == 0; \ + int keyboard = strncmp(symbol, "xkb_", 4) == 0; \ int graphics = strncmp(symbol, "egl", 3) == 0 || strncmp(symbol, "wl_egl_", 7) == 0; \ - void *library = strncmp(symbol, "egl", 3) == 0 ? egl : (graphics ? wayland_egl : wayland); \ + void *library = \ + bus ? dbus \ + : keyboard \ + ? xkb \ + : (strncmp(symbol, "egl", 3) == 0 ? egl : (graphics ? wayland_egl : wayland)); \ if (library) \ z_##name = (__typeof__(z_##name))dlsym(library, symbol); \ if (!z_##name) { \ - if (!graphics) { \ + if (bus) \ + have_dbus = 0; \ + else if (keyboard) \ + have_xkb = 0; \ + else if (graphics) \ + have_egl = 0; \ + else { \ z_linux_libraries_close(); \ return 0; \ } \ - have_egl = 0; \ } \ } while (0); #include "functions.inc" @@ -96,3 +126,57 @@ ssize_t z_linux_read(int fd, void *buffer, size_t count) { ssize_t z_linux_write(int fd, const void *buffer, size_t count) { return write(fd, buffer, count); } + +int z_linux_xkb_available(void) { + return have_xkb; +} +int z_linux_nonblock(int fd) { + int flags = fcntl(fd, F_GETFL); + return flags < 0 ? -1 : fcntl(fd, F_SETFL, flags | O_NONBLOCK); +} +int z_linux_pipe(int fds[2]) { + if (pipe2(fds, O_CLOEXEC) < 0) + return -1; + if (z_linux_nonblock(fds[0]) == 0) + return 0; + int saved = errno; + close(fds[0]); + close(fds[1]); + errno = saved; + return -1; +} + +/* A clipboard reader may close early. Suppress only the SIGPIPE generated by + * this write, preserving the embedding application's signal disposition. */ +ssize_t z_linux_pipe_write(int fd, const void *buffer, size_t count) { + sigset_t set, old, pending; + sigemptyset(&set); + sigaddset(&set, SIGPIPE); + if (pthread_sigmask(SIG_BLOCK, &set, &old) != 0) { + errno = EIO; + return -1; + } + sigpending(&pending); + int was_pending = sigismember(&pending, SIGPIPE); + ssize_t result = write(fd, buffer, count); + int saved = errno; + if (result < 0 && saved == EPIPE && !was_pending) { + struct timespec zero = {0, 0}; + while (sigtimedwait(&set, NULL, &zero) < 0 && errno == EINTR) { + } + } + pthread_sigmask(SIG_SETMASK, &old, NULL); + errno = saved; + return result; +} + +int z_linux_is_directory(const char *path) { + struct stat info; + return stat(path, &info) < 0 ? -1 : S_ISDIR(info.st_mode); +} + +int z_linux_dbus_available(void) { + return have_dbus; +} +_Static_assert(sizeof(dbus_uint32_t) == 4 && sizeof(dbus_uint64_t) == 8, "D-Bus integer ABI"); +_Static_assert(sizeof(DBusMessageIter) == (sizeof(void *) == 8 ? 72 : 56), "D-Bus iterator ABI"); diff --git a/src/linux/native.zig b/src/linux/native.zig index 0e60cbc..07bff60 100644 --- a/src/linux/native.zig +++ b/src/linux/native.zig @@ -7,6 +7,12 @@ const abi = @import("../abi.zig"); const runtime = @import("../runtime.zig"); const Application = @import("../Application.zig"); const Egl = @import("Egl.zig"); +const Input = @import("Input.zig"); +const Desktop = @import("Desktop.zig"); +const Portal = @import("Portal.zig"); +const Watch = @import("Watch.zig"); +const Data = @import("Data.zig"); +const Text = @import("Text.zig"); pub const Window = struct { next: ?*Window = null, @@ -37,6 +43,9 @@ pub const Window = struct { focused: bool = false, state: u32 = n.UHDK_WINDOW_NORMAL, flags: u32, + cursor: u32 = n.UHDK_CURSOR_ARROW, + text: Text.State = .{}, + drop_enabled: bool = false, metrics: n.uhdk_window_metrics = std.mem.zeroes(n.uhdk_window_metrics), }; @@ -52,6 +61,12 @@ pub const Backend = struct { windows: ?*Window = null, egl_display: c.EGLDisplay = null, egl_config: c.EGLConfig = null, + input: Input.State = .{}, + data: Data.State = .{}, + watches: ?*Watch.Watch = null, + portal: Portal.State = .{}, + desktop: Desktop.State = .{}, + capabilities: std.atomic.Value(u32) = .init(0), running: bool = true, failed: std.atomic.Value(bool) = .init(false), }; @@ -67,7 +82,12 @@ pub fn capability(handle: ?*n.uhdk_app, cap: u32) n.uhdk_bool { if (handle == null) return 0; const b = instance orelse return 0; if (b.app != runtime.fromAppHandle(handle) or !b.app.running or b.failed.load(.acquire)) return 0; - return @intFromBool(cap == n.UHDK_CAP_OPENGL and b.egl_display != null); + return @intFromBool(cap < 32 and b.capabilities.load(.acquire) & (@as(u32, 1) << @intCast(cap)) != 0); +} + +pub fn setCapability(cap: u32, available: bool) void { + const bits = @as(u32, 1) << @intCast(cap); + if (available) _ = instance.?.capabilities.fetchOr(bits, .release) else _ = instance.?.capabilities.fetchAnd(~bits, .release); } export fn z_native_is_main_thread() c_int { @@ -82,6 +102,7 @@ export fn z_native_start(handle: ?*n.uhdk_app, _: n.uhdk_text) u32 { }; b.* = .{ .app = app }; instance = b; + Input.initialize(); const fd = c.eventfd(0, c.EFD_CLOEXEC | c.EFD_NONBLOCK); if (fd < 0) { z_native_finish(); @@ -106,7 +127,15 @@ export fn z_native_start(handle: ?*n.uhdk_app, _: n.uhdk_text) u32 { z_native_finish(); return n.UHDK_STATUS_UNSUPPORTED; } + Input.setup(); + if (!initialRegistry(b)) { + z_native_finish(); + return n.UHDK_STATUS_OS_ERROR; + } + Desktop.initialize(); + Portal.initialize(); Egl.initialize(b); + setCapability(n.UHDK_CAP_OPENGL, b.egl_display != null); return abi.OK; } // Startup also uses a bounded read/flush/poll handshake: an unresponsive @@ -148,6 +177,11 @@ fn registryDone(data: ?*anyopaque, _: ?*c.struct_wl_callback, _: u32) callconv(. export fn z_native_finish() void { const b = instance orelse return; std.debug.assert(b.windows == null); + while (b.watches) |w| Watch.destroy(w); + Desktop.deinit(); + Portal.deinit(); + Data.deinit(); + Input.deinit(); Egl.finish(b); if (b.fractional != null) c.wp_fractional_scale_manager_v1_destroy(b.fractional); if (b.viewporter != null) c.wp_viewporter_destroy(b.viewporter); @@ -191,15 +225,24 @@ fn fail(b: *Backend) void { } export fn z_native_run() void { const b = instance.?; + var fds: std.ArrayList(c.struct_pollfd) = .empty; + defer fds.deinit(b.app.gpa); while (b.running) { if (!b.failed.load(.acquire) and c.z_wl_display_dispatch_pending.?(b.display) < 0) fail(b); n.z_tick(runtime.toAppHandle(b.app)); var cursor = b.windows; - var timeout: c_int = -1; + var timeout: c_int = Input.tick(); + Watch.tick(); + const desktop_timeout = Desktop.tick(); + if (desktop_timeout >= 0) timeout = if (timeout < 0) desktop_timeout else @min(timeout, desktop_timeout); + const portal_timeout = Portal.tick(); + if (portal_timeout >= 0) timeout = if (timeout < 0) portal_timeout else @min(timeout, portal_timeout); + const data_timeout = Data.tick(); + if (data_timeout >= 0) timeout = if (timeout < 0) data_timeout else @min(timeout, data_timeout); while (cursor) |w| { cursor = w.next; if (w.request != 0) { - timeout = 50; + timeout = if (timeout < 0) 50 else @min(timeout, 50); if (runtime.now(b.app.io) >= w.deadline) { completeCreation(w, abi.UNAVAILABLE); continue; @@ -221,21 +264,41 @@ export fn z_native_run() void { } } } - var fds = [_]c.struct_pollfd{ + fds.clearRetainingCapacity(); + fds.appendSlice(b.app.gpa, &.{ .{ .fd = if (reading) c.z_wl_display_get_fd.?(b.display) else -1, .events = display_events, .revents = 0 }, .{ .fd = wake_fd.load(.acquire), .events = c.POLLIN, .revents = 0 }, + }) catch { + if (reading) c.z_wl_display_cancel_read.?(b.display); + fail(b); + continue; }; - const ready = c.z_linux_poll(&fds, fds.len, timeout); + Data.poll(&fds) catch { + if (reading) c.z_wl_display_cancel_read.?(b.display); + fail(b); + continue; + }; + Watch.poll(&fds) catch { + if (reading) c.z_wl_display_cancel_read.?(b.display); + fail(b); + continue; + }; + Portal.poll(&fds) catch { + if (reading) c.z_wl_display_cancel_read.?(b.display); + fail(b); + continue; + }; + const ready = c.z_linux_poll(fds.items.ptr, fds.items.len, timeout); if (reading) { - if (ready > 0 and fds[0].revents & c.POLLIN != 0) { + if (ready > 0 and fds.items[0].revents & c.POLLIN != 0) { if (c.z_wl_display_read_events.?(b.display) < 0) fail(b); } else c.z_wl_display_cancel_read.?(b.display); } if (ready < 0 and c.z_linux_errno() != c.EINTR) fail(b); - if (fds[0].revents & (c.POLLERR | c.POLLHUP | c.POLLNVAL) != 0) fail(b); - if (fds[1].revents & c.POLLIN != 0) { + if (fds.items[0].revents & (c.POLLERR | c.POLLHUP | c.POLLNVAL) != 0) fail(b); + if (fds.items[1].revents & c.POLLIN != 0) { var value: u64 = 0; - _ = c.z_linux_read(fds[1].fd, &value, @sizeOf(u64)); + _ = c.z_linux_read(fds.items[1].fd, &value, @sizeOf(u64)); } } } @@ -243,6 +306,10 @@ export fn z_native_run() void { fn registryGlobal(data: ?*anyopaque, registry: ?*c.struct_wl_registry, name: u32, interface: [*c]const u8, version: u32) callconv(.c) void { const b: *Backend = @ptrCast(@alignCast(data.?)); const str = std.mem.span(interface); + Input.global(registry, name, str, version); + Data.global(registry, name, str, version); + Portal.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))); } else if (std.mem.eql(u8, str, "xdg_wm_base")) { @@ -256,7 +323,9 @@ fn registryGlobal(data: ?*anyopaque, registry: ?*c.struct_wl_registry, name: u32 b.fractional = @ptrCast(c.wl_registry_bind(registry, name, &c.z_uhdk_wp_fractional_scale_manager_v1_interface, 1)); } } -fn registryRemove(_: ?*anyopaque, _: ?*c.struct_wl_registry, _: u32) callconv(.c) void {} +fn registryRemove(_: ?*anyopaque, _: ?*c.struct_wl_registry, name: u32) callconv(.c) void { + Input.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 { c.xdg_wm_base_pong(shell, serial); @@ -411,6 +480,7 @@ fn setupWindow(b: *Backend, w: *Window, desc: n.uhdk_window_desc) u32 { if (c.xdg_toplevel_add_listener(w.top, &top_listener, w) != 0) return abi.OOM; c.xdg_toplevel_set_title(w.top, title); c.xdg_toplevel_set_app_id(w.top, app_id); + Desktop.applyIcon(w); 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); @@ -445,6 +515,10 @@ fn setLimits(w: *Window) void { fn destroyWindow(w: *Window) void { const b = instance.?; std.debug.assert(w.egl == null); + Desktop.forget(w); + Data.forget(w); + Input.forget(w); + Text.deinit(w); var link = &b.windows; while (link.*) |item| { if (item == w) { @@ -473,6 +547,11 @@ 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_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.*); + if (cmd.op == n.Z_CLIP_WRITE) return Data.write(cmd.text); + if (cmd.op == n.Z_CLIP_READ) return Data.read(id, cmd.limit); 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) { @@ -515,7 +594,13 @@ export fn z_native_execute(_: ?*n.uhdk_app, raw: [*c]const n.z_command, id: u64, if (w.want_visible and !w.configured) c.wl_surface_commit(w.surface); w.dirty = true; }, - n.Z_FOCUS => return n.UHDK_STATUS_UNSUPPORTED, + n.Z_DROP => return Data.enable(w, cmd.number != 0), + n.Z_CURSOR => return Input.cursor(w, @intCast(cmd.number)), + n.Z_POINTER => return Input.capture(w, cmd.number != 0), + n.Z_TEXT_ENABLE => return Text.enable(w, cmd.number != 0), + n.Z_TEXT_STATE => return Text.snapshot(w, cmd.text_state), + n.Z_TEXT_CANCEL => Text.cancel(w), + n.Z_FOCUS => return Desktop.focus(w, id), n.Z_REDRAW => emit(w, n.UHDK_EVENT_WINDOW_REDRAW), n.Z_GL => return Egl.create(b, w, cmd.other, result), else => return n.UHDK_STATUS_UNSUPPORTED, @@ -524,6 +609,7 @@ export fn z_native_execute(_: ?*n.uhdk_app, raw: [*c]const n.z_command, id: u64, } export fn z_native_cancel(id: u64) void { const b = instance orelse return; + if (Data.cancel(id) or Portal.cancel(id) or Desktop.cancel(id)) return; var cursor = b.windows; while (cursor) |w| : (cursor = w.next) { if (w.request == id) { @@ -539,6 +625,7 @@ export fn z_native_destroy(kind: u32, ptr: ?*anyopaque) void { const w: *Window = @ptrCast(@alignCast(p)); if (w.request != 0) completeCreation(w, abi.CANCELLED) else destroyWindow(w); }, + n.Z_KIND_WATCH => Watch.destroy(@ptrCast(@alignCast(p))), n.Z_KIND_GL => Egl.destroy(@ptrCast(@alignCast(p))), else => {}, } diff --git a/src/linux/tests/portal_fixture.py b/src/linux/tests/portal_fixture.py new file mode 100644 index 0000000..c4fd5e4 --- /dev/null +++ b/src/linux/tests/portal_fixture.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Exercise portal races on a private session bus and private Wayland socket. + +Needs dbus-daemon and Python dbus-next (provided by the optional shell.nix). +Never calls the user's portal, opens a real dialog, or launches a URL handler. +""" +import asyncio +import os +from pathlib import Path +import subprocess +import sys + +from dbus_next import Message, MessageType, Variant +from dbus_next.aio import MessageBus +from wayland_fixture import run + +DESKTOP = 'org.freedesktop.portal.Desktop' +PATH = '/org/freedesktop/portal/desktop' +REQUEST = 'org.freedesktop.portal.Request' + + +async def main(binary): + daemon = subprocess.Popen(['dbus-daemon', '--session', '--nofork', '--print-address=1'], stdout=subprocess.PIPE, text=True) + bus = None + tasks = set() + closed = [] + seen = set() + try: + address = daemon.stdout.readline().strip() + assert address + bus = await MessageBus(bus_address=address).connect() + await bus.request_name(DESKTOP) + + def response(handle, code=0, results=None): + bus.send(Message.new_signal(handle, REQUEST, 'Response', 'ua{sv}', [code, results or {}])) + + async def delayed_reply(message, handle, delay, is_url=False): + await asyncio.sleep(delay) + bus.send(Message.new_method_return(message, 'o', [handle])) + if is_url: response(handle) + + async def restart(): + await bus.release_name(DESKTOP) + await asyncio.sleep(.1) + await bus.request_name(DESKTOP) + + def schedule(coro): + task = asyncio.create_task(coro) + tasks.add(task) + task.add_done_callback(tasks.discard) + + def handle(message): + if message.message_type != MessageType.METHOD_CALL: + return False + if message.interface == 'org.freedesktop.DBus.Properties' and message.member == 'Get': + assert message.body == ['org.freedesktop.portal.FileChooser', 'version'] + bus.send(Message.new_method_return(message, 'v', [Variant('u', 4)])) + elif message.interface == 'org.freedesktop.portal.Settings' and message.member == 'ReadAll': + assert message.body == [['org.freedesktop.appearance']] + bus.send(Message.new_method_return(message, 'a{sa{sv}}', [{'org.freedesktop.appearance': { + 'color-scheme': Variant('u', 1), 'contrast': Variant('u', 1), 'reduced-motion': Variant('u', 1)}}])) + elif message.interface in ('org.freedesktop.portal.FileChooser', 'org.freedesktop.portal.OpenURI'): + parent, title, options = message.body + token = options['handle_token'].value + handle_path = PATH + '/request/' + message.sender[1:].replace('.', '_') + '/' + token + if message.member == 'OpenURI': + assert title == 'https://example.org/test' + schedule(delayed_reply(message, handle_path, .35, True)) + seen.add('url') + return True + seen.add(title) + assert message.signature == 'ssa{sv}' + assert options['multiple'].value and not options['directory'].value + assert options['filters'].value == [['Text', [[0, '*.txt'], [0, '*.md']]]] + if title == 'success': + assert parent == 'wayland:fixture-parent' + assert options['modal'].value + # A fast response can beat the method return. The frontend + # must already be subscribed using its chosen handle token. + response(handle_path, results={'uris': Variant('as', ['file:///tmp/a%20b%25%FF.txt', 'file:///tmp/second'])}) + bus.send(Message.new_method_return(message, 'o', [handle_path])) + elif title == 'cancel': + # Return a different handle after local cancellation; both + # the predicted and returned paths need Close calls. + schedule(delayed_reply(message, handle_path + '_late', .2)) + elif title == 'parent': + bus.send(Message.new_method_return(message, 'o', [handle_path])) + elif title == 'restart': + schedule(restart()) + elif title == 'dismiss': + bus.send(Message.new_method_return(message, 'o', [handle_path])) + response(handle_path, 1) + else: + raise AssertionError(title) + elif message.interface == REQUEST and message.member == 'Close': + closed.append(message.path) + bus.send(Message.new_method_return(message)) + else: + return False + return True + + bus.add_message_handler(handle) + await asyncio.to_thread(run, binary, 'portal', dict(os.environ, DBUS_SESSION_BUS_ADDRESS=address)) + await asyncio.sleep(.1) + assert {'success', 'cancel', 'url', 'restart', 'dismiss'} <= seen, seen + assert any(p.endswith('_late') for p in closed), closed + assert len(closed) >= 2, closed + print('private portal protocol/cancellation/restart fixture: PASS') + finally: + for task in tasks: task.cancel() + if bus: bus.disconnect() + daemon.terminate() + daemon.wait(timeout=5) + + +if __name__ == '__main__': + asyncio.run(main(str(Path(sys.argv[1]).resolve()))) diff --git a/src/linux/tests/services.c b/src/linux/tests/services.c new file mode 100644 index 0000000..b42f68f --- /dev/null +++ b/src/linux/tests/services.c @@ -0,0 +1,316 @@ +#define _GNU_SOURCE +#include "uhdk.h" +#include +#include +#include +#include +#include + +#define OK(expr) \ + do { \ + uhdk_status status_ = (expr); \ + if (status_ != UHDK_STATUS_OK) { \ + fprintf(stderr, "%d: %s: status %u\n", __LINE__, #expr, status_); \ + abort(); \ + } \ + } while (0) +static uhdk_text text(const char *s) { + return (uhdk_text){s, strlen(s)}; +} +static uint64_t deadline(uhdk_app *app) { + return uhdk_app_get_time_ns(app) + 8000000000ull; +} +static uhdk_request_result wait_result(uhdk_app *app, uint64_t id) { + uint64_t end = deadline(app); + while (uhdk_app_get_time_ns(app) < end) { + uhdk_event_list events; + OK(uhdk_app_poll_events(app, 50000000, &events)); + for (uint64_t i = 0; i < events.len; ++i) + if (events.ptr[i].type == UHDK_EVENT_REQUEST_DONE) { + const uhdk_request_result *r = events.ptr[i].data; + if (r->id == id) + return *r; + } + } + fprintf(stderr, "request %lu timed out\n", (unsigned long)id); + abort(); +} +static uhdk_window *window(uhdk_app *app) { + uhdk_window_desc desc = {0}; + desc.struct_size = sizeof(desc); + desc.title = text("UHDK service tests"); + desc.content_size = (uhdk_size){320, 240}; + desc.flags = UHDK_WINDOW_DECORATED | UHDK_WINDOW_RESIZABLE; + uint64_t id; + OK(uhdk_window_create_async(app, &desc, &id)); + uhdk_request_result r = wait_result(app, id); + OK(r.status); + return ((const uhdk_window_result *)r.data)->window; +} +static void wait_capability(uhdk_app *app, uint32_t cap) { + uint64_t end = deadline(app); + while (!uhdk_app_has_capability(app, cap) && uhdk_app_get_time_ns(app) < end) { + uhdk_event_list events; + OK(uhdk_app_poll_events(app, 50000000, &events)); + } + assert(uhdk_app_has_capability(app, cap)); +} +static void write_file(const char *path, const char *bytes) { + FILE *f = fopen(path, "wb"); + assert(f); + assert(fwrite(bytes, 1, strlen(bytes), f) == strlen(bytes)); + assert(fclose(f) == 0); +} +static void wait_change(uhdk_app *app, uhdk_watch *watch, uint32_t flag) { + uint64_t end = deadline(app); + while (uhdk_app_get_time_ns(app) < end) { + uhdk_event_list events; + OK(uhdk_app_poll_events(app, 50000000, &events)); + for (uint64_t i = 0; i < events.len; ++i) + if (events.ptr[i].type == UHDK_EVENT_WATCH) { + const uhdk_watch_event *e = events.ptr[i].data; + if (e->watch == watch && (e->flags & flag)) { + OK(e->status); + return; + } + } + } + fprintf(stderr, "watch flag %x timed out\n", flag); + abort(); +} +static void watches(uhdk_app *app) { + char directory[] = "/tmp/uhdk-watch-XXXXXX"; + assert(mkdtemp(directory)); + char path[256], replacement[256]; + snprintf(path, sizeof(path), "%s/document", directory); + snprintf(replacement, sizeof(replacement), "%s/replacement", directory); + write_file(path, "old"); + uhdk_file *file; + OK(uhdk_file_create_from_native_path( + app, (uhdk_bytes){(const uint8_t *)path, strlen(path)}, &file + )); + uint64_t id; + OK(uhdk_watch_create_async(file, &id)); + uhdk_request_result result = wait_result(app, id); + OK(result.status); + uhdk_watch *watch = ((const uhdk_watch_result *)result.data)->watch; + write_file(replacement, "new"); + assert(rename(replacement, path) == 0); + wait_change(app, watch, UHDK_CHANGE_CREATED); + write_file(path, "after replacement"); + wait_change(app, watch, UHDK_CHANGE_CONTENT); + assert(unlink(path) == 0); + wait_change(app, watch, UHDK_CHANGE_REMOVED); + write_file(path, "recreated"); + wait_change(app, watch, UHDK_CHANGE_CREATED); + assert(unlink(path) == 0); + assert(rmdir(directory) == 0); + wait_change(app, watch, UHDK_CHANGE_ROOT_INVALIDATED); + uhdk_watch_destroy(watch); + uhdk_file_release(file); + puts("watch replacement/removal/recreation/invalidation: PASS"); +} +static void real_desktop(uhdk_app *app) { + wait_capability(app, UHDK_CAP_FILE_DIALOGS); + uhdk_preferences prefs = {0}; + prefs.struct_size = sizeof(prefs); + OK(uhdk_app_get_preferences(app, &prefs)); + printf( + "Portal available; appearance=%u, reduced_motion=%d, high_contrast=%d\n", + prefs.appearance, + prefs.reduced_motion, + prefs.high_contrast + ); + watches(app); + uhdk_window *w = window(app); + uint8_t rgba[] = {255, 0, 0, 128, 0, 255, 0, 255}; + uhdk_image image = {0}; + image.struct_size = sizeof(image); + image.width = 2; + image.height = 1; + image.stride_bytes = 8; + image.pixels = (uhdk_bytes){rgba, sizeof(rgba)}; + uint64_t id; + OK(uhdk_app_set_icon_async(app, &image, &id)); + OK(wait_result(app, id).status); + // A hidden, unfocused window must not commandeer a later user's keystroke. + OK(uhdk_clipboard_write_text_async(app, text("must not replace clipboard"), &id)); + assert(wait_result(app, id).status == UHDK_STATUS_UNAVAILABLE); + uhdk_window_destroy(w); +} +static void portals(uhdk_app *app) { + wait_capability(app, UHDK_CAP_FILE_DIALOGS); + uhdk_window *w = window(app); + uhdk_file_dialog_desc desc = {0}; + desc.struct_size = sizeof(desc); + desc.parent = w; + desc.title = text("success"); + desc.kind = UHDK_DIALOG_OPEN_FILES; + uhdk_text extensions[] = {text("txt"), text("md")}; + uhdk_file_filter filter = {text("Text"), {extensions, 2}}; + desc.filters = &filter; + desc.filters_len = 1; + uint64_t id; + OK(uhdk_file_dialog_show_async(app, &desc, &id)); + uhdk_request_result r = wait_result(app, id); + OK(r.status); + const uhdk_file_list *files = r.data; + assert(files->len == 2); + uint8_t bytes[256]; + uint64_t len; + OK(uhdk_file_copy_representation( + files->ptr[0], UHDK_FILE_NATIVE_PATH, bytes, sizeof(bytes), &len + )); + assert(len == 14 && memcmp(bytes, "/tmp/a b%\xff.txt", 14) == 0); + desc.title = text("cancel"); + OK(uhdk_file_dialog_show_async(app, &desc, &id)); + // Allow method submission, then cancel while the mock withholds its reply. + uhdk_event_list events; + OK(uhdk_app_poll_events(app, 100000000, &events)); + OK(uhdk_request_cancel(app, id)); + assert(wait_result(app, id).status == UHDK_STATUS_CANCELLED); + desc.title = text("parent"); + OK(uhdk_file_dialog_show_async(app, &desc, &id)); + uhdk_window_destroy(w); + assert(wait_result(app, id).status == UHDK_STATUS_CANCELLED); + OK(uhdk_shell_open_url_async(app, text("https://example.org/test"), &id)); + OK(wait_result(app, id).status); + desc.parent = NULL; + desc.title = text("restart"); + OK(uhdk_file_dialog_show_async(app, &desc, &id)); + assert(wait_result(app, id).status == UHDK_STATUS_UNAVAILABLE); + wait_capability(app, UHDK_CAP_FILE_DIALOGS); + desc.title = text("dismiss"); + OK(uhdk_file_dialog_show_async(app, &desc, &id)); + assert(wait_result(app, id).status == UHDK_STATUS_CANCELLED); + puts("portal results/cancellation/parent/restart: PASS"); +} +static void input(uhdk_app *app) { + uhdk_window *w = window(app); + uint64_t id; + OK(uhdk_window_set_text_input_async(w, 1, &id)); + OK(wait_result(app, id).status); + OK(uhdk_window_set_drop_enabled_async(w, 1, &id)); + OK(wait_result(app, id).status); + uhdk_text_state state = {0}; + state.struct_size = sizeof(state); + state.surrounding_text = text("abc"); + state.revision = 77; + state.selection_start = 1; + state.selection_len = 1; + OK(uhdk_window_set_text_state(w, &state)); + unsigned key = 0, repeat = 0, commit = 0, preedit = 0, pointer = 0, scroll = 0, dropped = 0, + copy = 0, compose = 0; + uint64_t end = deadline(app); + while (uhdk_app_get_time_ns(app) < end && !(key && repeat && commit >= 2 && preedit && + pointer && scroll && dropped && copy && compose)) { + uhdk_event_list events; + OK(uhdk_app_poll_events(app, 50000000, &events)); + for (uint64_t i = 0; i < events.len; ++i) { + const uhdk_event *e = &events.ptr[i]; + switch (e->type) { + case UHDK_EVENT_KEY: { + const uhdk_key_event *k = e->data; + if (k->physical_key == 4 && k->logical_key == 'a' && k->pressed) + key++; + if (k->repeat) + repeat++; + break; + } + case UHDK_EVENT_EDIT_COMMAND: + if (((const uhdk_edit_event *)e->data)->command == UHDK_EDIT_COPY) + copy++; + break; + case UHDK_EVENT_TEXT_COMMIT: { + const uhdk_text_event *t = e->data; + if (t->text.len == 1 && t->text.ptr[0] == 'a') + commit++; + if (t->text.len == 2 && memcmp(t->text.ptr, "é", 2) == 0) + compose++; + if (t->has_replacement) { + assert( + t->revision == 77 && t->replacement_start == 0 && t->replacement_len == 3 + ); + commit++; + } + break; + } + case UHDK_EVENT_TEXT_PREEDIT: { + const uhdk_text_event *t = e->data; + assert(t->text.len == 2 && memcmp(t->text.ptr, "é", 2) == 0); + preedit++; + break; + } + case UHDK_EVENT_POINTER_MOVE: { + const uhdk_pointer_event *p = e->data; + if (p->position.x == 12 && p->position.y == 18) + pointer++; + break; + } + case UHDK_EVENT_SCROLL: { + const uhdk_scroll_event *s = e->data; + assert(s->unit == UHDK_SCROLL_LINES && s->delta.y == 0.5f); + scroll++; + break; + } + case UHDK_EVENT_DROP_FILES: { + const uhdk_drop_files_event *d = e->data; + assert(d->files.len == 1 && d->position.x == 12); + dropped++; + break; + } + default: + break; + } + } + } + fprintf( + stderr, + "input: key=%u repeat=%u commit=%u preedit=%u pointer=%u scroll=%u drop=%u copy=%u\n", + key, + repeat, + commit, + preedit, + pointer, + scroll, + dropped, + copy + ); + assert( + key && repeat && commit >= 2 && preedit && pointer && scroll && dropped && copy && compose + ); + OK(uhdk_clipboard_read_text_async(app, 3, &id)); + assert(wait_result(app, id).status == UHDK_STATUS_BUFFER_TOO_SMALL); + OK(uhdk_clipboard_read_text_async(app, 100, &id)); + uhdk_request_result r = wait_result(app, id); + OK(r.status); + const uhdk_text *t = r.data; + assert(t->len == 11 && memcmp(t->ptr, "hello café", 11) == 0); + OK(uhdk_clipboard_write_text_async(app, text("local copy"), &id)); + OK(wait_result(app, id).status); + OK(uhdk_window_request_focus_async(w, &id)); + OK(wait_result(app, id).status); + uhdk_window_destroy(w); + puts("input/IME/clipboard/drop/activation: PASS"); +} +static void run(uhdk_app *app, void *mode) { + if (strcmp(mode, "input") == 0) + input(app); + else if (strcmp(mode, "portal") == 0) + portals(app); + else + real_desktop(app); +} +int main(int argc, char **argv) { + assert(argc == 2); + uhdk_app_desc desc = {0}; + desc.struct_size = sizeof(desc); + desc.abi_version = UHDK_ABI_VERSION; + desc.display_name = text("UHDK service tests"); + desc.application_id = text("org.uhdk.ServiceTests"); + uhdk_app *app; + OK(uhdk_app_create(&desc, &app)); + OK(uhdk_app_run(app, run, argv[1])); + uhdk_app_destroy(app); + return 0; +} diff --git a/src/linux/tests/wayland_fixture.py b/src/linux/tests/wayland_fixture.py new file mode 100644 index 0000000..c0dcc10 --- /dev/null +++ b/src/linux/tests/wayland_fixture.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +"""Minimal isolated Wayland peer for protocol tests, driven by the pinned XML. + +This never attaches to the real compositor or injects input into other clients. +It deliberately sends delayed IME batches, selection pipes and copy-only drops. +""" +import array +import ctypes +import ctypes.util +import fcntl +import os +from pathlib import Path +import select +import socket +import struct +import subprocess +import sys +import tempfile +import time +import xml.etree.ElementTree as ET + +XML = Path(__file__).resolve().parents[1] / 'protocols/xml' +INTERFACES = {} +for filename in XML.glob('*.xml'): + for interface in ET.parse(filename).getroot().findall('interface'): + INTERFACES[interface.get('name')] = interface + + +def keymap(): + lib = ctypes.CDLL(ctypes.util.find_library('xkbcommon') or 'libxkbcommon.so.0') + lib.xkb_context_new.restype = ctypes.c_void_p + lib.xkb_keymap_new_from_names.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int] + lib.xkb_keymap_new_from_names.restype = ctypes.c_void_p + lib.xkb_keymap_get_as_string.argtypes = [ctypes.c_void_p, ctypes.c_int] + lib.xkb_keymap_get_as_string.restype = ctypes.c_void_p + lib.xkb_context_unref.argtypes = [ctypes.c_void_p] + lib.xkb_keymap_unref.argtypes = [ctypes.c_void_p] + names = (ctypes.c_char_p * 5)(None, None, b'us', b'intl', None) + context = lib.xkb_context_new(0) + mapping = lib.xkb_keymap_new_from_names(context, names, 0) + assert mapping + pointer = lib.xkb_keymap_get_as_string(mapping, 1) + result = ctypes.string_at(pointer) + b'\0' + libc = ctypes.CDLL(None) + libc.free.argtypes = [ctypes.c_void_p] + libc.free(pointer) + lib.xkb_keymap_unref(mapping) + lib.xkb_context_unref(context) + return result + + +class Compositor: + globals = [('wl_compositor', 6), ('xdg_wm_base', 6), + ('zxdg_decoration_manager_v1', 1), ('wl_seat', 8), + ('wp_cursor_shape_manager_v1', 1), ('zwp_text_input_manager_v3', 1), + ('wl_data_device_manager', 3), ('zxdg_exporter_v2', 1), + ('xdg_activation_v1', 1)] + + def __init__(self, sock, inject=False): + self.sock = sock + self.inject = inject + self.buffer = b'' + self.fds = [] + self.objects = {1: 'wl_display'} + self.ids = {} + self.server_id = 0xfeffffff + self.surfaces = {} + self.serial = 100 + self.text_serial = 0 + self.surrounding = '' + self.injected = False + self.later = None + self.tokens = False + self.finished_drop = False + self.outgoing = None + + def event(self, obj, name, *values): + interface = INTERFACES[self.objects[obj]] + events = interface.findall('event') + description = next(e for e in events if e.get('name') == name) + body, fds = b'', [] + for arg, value in zip(description.findall('arg'), values, strict=True): + kind = arg.get('type') + if kind in ('uint', 'object', 'new_id'): + body += struct.pack('=I', value or 0) + elif kind in ('int', 'fixed'): + body += struct.pack('=i', value) + elif kind in ('string', 'array'): + data = (value.encode() + b'\0') if kind == 'string' and value is not None else (value or b'') + body += struct.pack('=I', len(data)) + data + b'\0' * (-len(data) % 4) + elif kind == 'fd': + fds.append(value) + else: + raise AssertionError(kind) + wire = struct.pack('=II', obj, ((len(body) + 8) << 16) | events.index(description)) + body + self.sock.sendmsg([wire], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array('i', fds))] if fds else []) + + def new_offer(self, mime): + self.server_id += 1 + obj = self.server_id + self.objects[obj] = 'wl_data_offer' + self.event(self.ids['wl_data_device'], 'data_offer', obj) + self.event(obj, 'offer', mime) + return obj + + def dispatch(self): + try: + data, ancillary, _, _ = self.sock.recvmsg(65536, socket.CMSG_SPACE(64 * 4)) + except ConnectionResetError: + return False + if not data: + return False + self.buffer += data + for level, kind, payload in ancillary: + if level == socket.SOL_SOCKET and kind == socket.SCM_RIGHTS: + values = array.array('i'); values.frombytes(payload[:len(payload) // 4 * 4]); self.fds.extend(values) + while len(self.buffer) >= 8: + obj, header = struct.unpack_from('=II', self.buffer) + length, opcode = header >> 16, header & 65535 + if len(self.buffer) < length: + break + payload, self.buffer = self.buffer[8:length], self.buffer[length:] + iface = self.objects[obj] + request = INTERFACES[iface].findall('request')[opcode] + args, offset = [], 0 + for arg in request.findall('arg'): + kind = arg.get('type') + if kind == 'new_id' and arg.get('interface') is None: + size = struct.unpack_from('=I', payload, offset)[0]; offset += 4 + dynamic = payload[offset:offset + size - 1].decode(); offset += (size + 3) & ~3 + version, value = struct.unpack_from('=II', payload, offset); offset += 8 + self.objects[value] = dynamic; self.ids[dynamic] = value + args.extend([dynamic, version, value]) + continue + if kind == 'fd': + value = self.fds.pop(0) + elif kind in ('string', 'array'): + size = struct.unpack_from('=I', payload, offset)[0]; offset += 4 + value = payload[offset:offset + size]; offset += (size + 3) & ~3 + if kind == 'string': value = value[:-1].decode() if size else None + else: + value = struct.unpack_from('=i' if kind in ('int', 'fixed') else '=I', payload, offset)[0]; offset += 4 + if kind == 'new_id': + self.objects[value] = arg.get('interface'); self.ids[arg.get('interface')] = value + args.append(value) + self.request(obj, iface, request.get('name'), args) + if request.get('type') == 'destructor': + if obj < 0xff000000: + self.event(1, 'delete_id', obj) + del self.objects[obj] + return True + + def request(self, obj, iface, name, args): + if iface == 'wl_display' and name == 'get_registry': + for index, (interface, version) in enumerate(self.globals, 1): self.event(args[0], 'global', index, interface, version) + elif iface == 'wl_display' and name == 'sync': + self.event(args[0], 'done', self.serial) + self.event(1, 'delete_id', args[0]); del self.objects[args[0]] + elif iface == 'wl_registry' and name == 'bind' and args[1] == 'wl_seat': + self.event(args[-1], 'capabilities', 3); self.event(args[-1], 'name', 'fixture') + elif iface == 'wl_seat' and name == 'get_keyboard': + contents = keymap(); fd = os.memfd_create('uhdk-test-keymap'); os.write(fd, contents) + self.event(args[0], 'keymap', 1, fd, len(contents)); os.close(fd) + self.event(args[0], 'repeat_info', 40, 20) + elif iface == 'xdg_wm_base' and name == 'get_xdg_surface': + self.surfaces[args[1]] = args[0] + elif iface == 'wl_surface' and name == 'commit' and obj in self.surfaces: + self.serial += 1; self.event(self.surfaces[obj], 'configure', self.serial) + elif iface == 'zxdg_decoration_manager_v1' and name == 'get_toplevel_decoration': + self.event(args[0], 'configure', 2) + elif iface == 'zwp_text_input_v3' and name == 'set_surrounding_text': + self.surrounding = args[0] + elif iface == 'zwp_text_input_v3' and name == 'commit': + self.text_serial += 1 + if self.inject and not self.injected and self.surrounding == 'abc': self.start_input(obj) + elif iface == 'wl_data_offer' and name == 'receive': + mime, fd = args + assert not fcntl.fcntl(fd, fcntl.F_GETFL) & os.O_NONBLOCK + os.write(fd, b'file:///tmp/drop%20file\r\n' if mime == 'text/uri-list' else 'hello café'.encode()) + os.close(fd) + elif iface == 'wl_data_offer' and name == 'finish': + self.finished_drop = True + elif iface == 'wl_data_device' and name == 'set_selection': + source = args[0] + closed_read, closed_write = os.pipe() + os.close(closed_read) + self.event(source, 'send', 'text/plain;charset=utf-8', closed_write) + os.close(closed_write) + read, write = os.pipe() + self.event(source, 'send', 'text/plain;charset=utf-8', write) + os.close(write); self.outgoing = read + elif iface == 'xdg_activation_token_v1' and name == 'commit': + self.event(obj, 'done', 'fixture-token') + elif iface == 'xdg_activation_v1' and name == 'activate': + assert args[0] == 'fixture-token'; self.tokens = True + elif iface == 'zxdg_exporter_v2' and name == 'export_toplevel': + self.event(args[0], 'handle', 'fixture-parent') + + def start_input(self, text_input): + self.injected = True + surface = next(iter(self.surfaces)) + keyboard, pointer = self.ids['wl_keyboard'], self.ids['wl_pointer'] + self.event(keyboard, 'enter', 200, surface, b'') + self.event(keyboard, 'modifiers', 200, 0, 0, 0, 0) + self.event(keyboard, 'key', 201, 1, 30, 1) + self.event(keyboard, 'key', 202, 2, 30, 0) + self.event(keyboard, 'key', 203, 3, 48, 1) + self.event(pointer, 'enter', 204, surface, 10 * 256, 10 * 256) + self.event(pointer, 'motion', 4, 12 * 256, 18 * 256) + self.event(pointer, 'axis_source', 0) + self.event(pointer, 'axis', 5, 0, 5 * 256) + self.event(pointer, 'axis_value120', 0, 60) + self.event(pointer, 'frame') + selection = self.new_offer('text/plain;charset=utf-8') + self.event(self.ids['wl_data_device'], 'selection', selection) + drop = self.new_offer('text/uri-list') + self.event(drop, 'source_actions', 1) + self.event(self.ids['wl_data_device'], 'enter', 205, surface, 12 * 256, 18 * 256, drop) + self.event(drop, 'action', 1) + self.event(self.ids['wl_data_device'], 'drop') + self.later = (time.monotonic() + .15, text_input, self.text_serial) + + def timers(self): + # Text input enter precedes the application's first surrounding snapshot. + text = self.ids.get('zwp_text_input_v3') + if text and self.surfaces and not getattr(self, 'text_entered', False): + self.text_entered = True + self.event(text, 'enter', next(iter(self.surfaces))) + if self.later and time.monotonic() >= self.later[0]: + _, text, serial = self.later; self.later = None + keyboard = self.ids['wl_keyboard'] + self.event(keyboard, 'key', 206, 6, 48, 0) + self.event(keyboard, 'modifiers', 207, 4, 0, 0, 0) + self.event(keyboard, 'key', 208, 7, 46, 1) + self.event(keyboard, 'key', 209, 8, 46, 0) + self.event(keyboard, 'modifiers', 210, 0, 0, 0, 0) + self.event(keyboard, 'key', 211, 9, 40, 1) + self.event(keyboard, 'key', 212, 10, 40, 0) + self.event(keyboard, 'key', 213, 11, 18, 1) + self.event(keyboard, 'key', 214, 12, 18, 0) + self.event(text, 'preedit_string', 'é', 0, 2) + self.event(text, 'done', serial) + self.event(text, 'preedit_string', '', 0, 0) + self.event(text, 'delete_surrounding_text', 1, 1) + self.event(text, 'commit_string', 'λ') + self.event(text, 'done', serial) + + +def run(binary, mode, env=None): + 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') + end = time.monotonic() + 20 + try: + while process.poll() is None and time.monotonic() < end: + if select.select([server], [], [], .01)[0] and not compositor.dispatch(): break + compositor.timers() + assert process.wait(timeout=2) == 0, process.returncode + if mode == 'input': + assert compositor.finished_drop and compositor.tokens + assert compositor.outgoing is not None + assert os.read(compositor.outgoing, 1024) == b'local copy' + finally: + if process.poll() is None: process.kill(); process.wait() + server.close() + for fd in compositor.fds: os.close(fd) + if compositor.outgoing is not None: os.close(compositor.outgoing) + + +if __name__ == '__main__': + run(sys.argv[1], 'input') diff --git a/src/linux/xkb-imports.h b/src/linux/xkb-imports.h new file mode 100644 index 0000000..a250468 --- /dev/null +++ b/src/linux/xkb-imports.h @@ -0,0 +1,57 @@ +/* Pinned xkbcommon ABI; libraries.c checks these against upstream typeof. */ +extern struct xkb_context *(*z_xkb_context_new)(enum xkb_context_flags flags); +extern void (*z_xkb_context_unref)(struct xkb_context *context); +extern struct xkb_keymap *(*z_xkb_keymap_new_from_string)( + struct xkb_context *context, + const char *string, + enum xkb_keymap_format format, + enum xkb_keymap_compile_flags flags +); +extern void (*z_xkb_keymap_unref)(struct xkb_keymap *keymap); +extern int (*z_xkb_keymap_key_repeats)(struct xkb_keymap *keymap, xkb_keycode_t key); +extern struct xkb_state *(*z_xkb_state_new)(struct xkb_keymap *keymap); +extern void (*z_xkb_state_unref)(struct xkb_state *state); +extern enum xkb_state_component (*z_xkb_state_update_mask)( + struct xkb_state *state, + xkb_mod_mask_t depressed_mods, + xkb_mod_mask_t latched_mods, + xkb_mod_mask_t locked_mods, + xkb_layout_index_t depressed_layout, + xkb_layout_index_t latched_layout, + xkb_layout_index_t locked_layout +); +extern xkb_keysym_t (*z_xkb_state_key_get_one_sym)(struct xkb_state *state, xkb_keycode_t key); +extern int (*z_xkb_state_key_get_utf8)( + struct xkb_state *state, + xkb_keycode_t key, + char *buffer, + size_t size +); +extern int (*z_xkb_state_mod_name_is_active)( + struct xkb_state *state, + const char *name, + enum xkb_state_component type +); +extern uint32_t (*z_xkb_keysym_to_utf32)(xkb_keysym_t keysym); +extern struct xkb_compose_table *(*z_xkb_compose_table_new_from_locale)( + struct xkb_context *context, + const char *locale, + enum xkb_compose_compile_flags flags +); +extern void (*z_xkb_compose_table_unref)(struct xkb_compose_table *table); +extern struct xkb_compose_state *(*z_xkb_compose_state_new)( + struct xkb_compose_table *table, + enum xkb_compose_state_flags flags +); +extern void (*z_xkb_compose_state_unref)(struct xkb_compose_state *state); +extern enum xkb_compose_feed_result (*z_xkb_compose_state_feed)( + struct xkb_compose_state *state, + xkb_keysym_t keysym +); +extern void (*z_xkb_compose_state_reset)(struct xkb_compose_state *state); +extern enum xkb_compose_status (*z_xkb_compose_state_get_status)(struct xkb_compose_state *state); +extern int (*z_xkb_compose_state_get_utf8)( + struct xkb_compose_state *state, + char *buffer, + size_t size +); diff --git a/src/root.zig b/src/root.zig index 61d3726..05473d9 100644 --- a/src/root.zig +++ b/src/root.zig @@ -19,7 +19,13 @@ test { std.testing.refAllDecls(@import("FileOperation.zig")); _ = @import("core_tests.zig"); _ = @import("arenas.zig"); - if (@import("builtin").os.tag == .linux) _ = @import("linux/Files.zig"); + if (@import("builtin").os.tag == .linux) { + _ = @import("linux/Files.zig"); + _ = @import("linux/Keyboard.zig"); + _ = @import("linux/Text.zig"); + _ = @import("linux/Watch.zig"); + _ = @import("linux/Desktop.zig"); + } } fn checkExports(comptime module: type) void {