Implement Wayland input and Linux desktop services with isolated fixtures

This commit is contained in:
Peterino2 2026-09-18 14:01:02 -07:00
parent fa3959e221
commit 5623376284
23 changed files with 3307 additions and 32 deletions

View File

@ -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 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. Windows 10 version 1703 or later and an OpenGL 3.3-capable driver.
The Linux Wayland preview implements windows and EGL/OpenGL, initially tested The Linux Wayland preview implements windows, EGL/OpenGL, keyboard/pointer and
on KDE Plasma. Input and desktop services are still pending. It vendors only ABI 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 headers and protocol bindings and loads system libraries at runtime. Normal
builds need Zig alone; the optional `shell.nix` supplies maintainer tools and 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). NixOS runtime library paths. See [Linux support and limitations](src/linux/README.md).

View File

@ -3,7 +3,8 @@
}: }:
pkgs.mkShell { pkgs.mkShell {
packages = [ packages = [
pkgs.python3 (pkgs.python3.withPackages (python: [ python.dbus-next ]))
pkgs.dbus
pkgs.wayland-scanner pkgs.wayland-scanner
pkgs.clang-tools pkgs.clang-tools
]; ];

88
src/linux/Bus.zig Normal file
View File

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

349
src/linux/Data.zig Normal file
View File

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

188
src/linux/Desktop.zig Normal file
View File

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

View File

@ -4,7 +4,7 @@ const n = @import("native");
const abi = @import("../abi.zig"); const abi = @import("../abi.zig");
const runtime = @import("../runtime.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; 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); var result: std.Io.Writer.Allocating = .init(allocator);
errdefer result.deinit(); errdefer result.deinit();

430
src/linux/Input.zig Normal file
View File

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

83
src/linux/Keyboard.zig Normal file
View File

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

512
src/linux/Portal.zig Normal file
View File

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

View File

@ -4,12 +4,14 @@ The first implementation targets KDE Plasma Wayland using standard protocols.
It implements window creation/destruction, titles, parent relationships, size It implements window creation/destruction, titles, parent relationships, size
constraints, maximize/fullscreen/minimize requests, show/hide, close requests, constraints, maximize/fullscreen/minimize requests, show/hide, close requests,
logical/pixel metrics, EGL desktop OpenGL, and local POSIX file references. 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, Native menus, a cursor-theme fallback, client-side window decorations, and X11
portals, preferences, file watches, native menus, icons and activation requests remain unsupported. Protocol tests cover IME batching; a live Fcitx/IBus CJK
are not implemented yet. Their capabilities are false and requests return session and other desktops still need qualification. Missing optional protocols
`UNSUPPORTED`. X11 is also unsupported. This is a window/graphics foundation, or libraries disable their features without a desktop-name whitelist.
not yet a complete interactive desktop backend.
## Build and run ## 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 At runtime, UHDK opens `libwayland-client.so.0`. OpenGL also needs
`libwayland-egl.so.1`, `libEGL.so.1` and a working system graphics driver. `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. 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 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 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 NixOS does not normally expose every desktop library through the conventional
runtime linker search path. The optional package-owned shell supplies the pinned 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 ```sh
nix-shell 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. application's show/hide intent, not proof that the compositor displays it.
- Size/state commands acknowledge submission. Confirmed geometry and focus arrive - Size/state commands acknowledge submission. Confirmed geometry and focus arrive
through metrics. Wayland cannot report minimized state, force focus, or directly 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 - Fractional scale uses fractional-scale-v1 plus viewporter; otherwise preferred
integer buffer scale is used when wl_surface v6 is available. Older compositors integer buffer scale is used when wl_surface v6 is available. Older compositors
without those mechanisms currently use scale 1. Fractional/mixed-DPI behavior 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; context. Window framebuffer dimensions describe the presentation target;
applications continue to own their viewport and rendering resources. 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 ## Validation
From the package directory: From the package directory:
@ -115,6 +162,10 @@ zig build test-linux-native # opens windows in the active session
zig build linux-test-bin zig build linux-test-bin
python3 src/linux/tests/startup.py zig-out/bin/linux-native-tests python3 src/linux/tests/startup.py zig-out/bin/linux-native-tests
python3 src/linux/tests/disconnect.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 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 uses isolated sockets for absent, disconnected and unresponsive servers. The
runtime-disconnect test proxies and closes only its own client connection; it runtime-disconnect test proxies and closes only its own client connection; it
does not stop or modify the desktop compositor. 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.

337
src/linux/Text.zig Normal file
View File

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

130
src/linux/Watch.zig Normal file
View File

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

View File

@ -4,8 +4,12 @@
#include <errno.h> #include <errno.h>
#include <poll.h> #include <poll.h>
#include <sys/eventfd.h> #include <sys/eventfd.h>
#include <sys/inotify.h>
#include <sys/syscall.h> #include <sys/syscall.h>
#include <unistd.h> #include <unistd.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <stdlib.h>
#include "wayland-client-core.h" #include "wayland-client-core.h"
#include "wayland-egl-core.h" #include "wayland-egl-core.h"
#define EGL_NO_X11 #define EGL_NO_X11
@ -16,6 +20,11 @@
/* Runtime imports are checked against upstream declarations in libraries.c. /* Runtime imports are checked against upstream declarations in libraries.c.
* typeof(function) in translated Zig debug info can create unwanted link-time * typeof(function) in translated Zig debug info can create unwanted link-time
* references, so the translated header uses explicit function-pointer types. */ * references, so the translated header uses explicit function-pointer types. */
#include <xkbcommon/xkbcommon.h>
#include <xkbcommon/xkbcommon-compose.h>
#include "xkb-imports.h"
#include <dbus/dbus.h>
#include "dbus-imports.h"
#include "function-pointers.h" #include "function-pointers.h"
#include "redirects.h" #include "redirects.h"
#include "wayland-client-protocol.h" #include "wayland-client-protocol.h"
@ -23,12 +32,23 @@
#include "xdg-decoration-unstable-v1.h" #include "xdg-decoration-unstable-v1.h"
#include "viewporter.h" #include "viewporter.h"
#include "fractional-scale-v1.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_libraries_open(void);
int z_linux_egl_available(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); void z_linux_libraries_close(void);
int z_linux_errno(void); int z_linux_errno(void);
int z_linux_poll(struct pollfd *, nfds_t, int); int z_linux_poll(struct pollfd *, nfds_t, int);
ssize_t z_linux_read(int, void *, size_t); ssize_t z_linux_read(int, void *, size_t);
ssize_t z_linux_write(int, const 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); int z_linux_is_main_thread(void);
#endif #endif

View File

@ -6,13 +6,13 @@ pub fn configure(b: *std.Build, module: *std.Build.Module, target: std.Build.Res
.target = target, .target = target,
.optimize = optimize, .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)); bindings.addIncludePath(b.path(path));
module.addIncludePath(b.path(path)); module.addIncludePath(b.path(path));
} }
module.addImport("linux_c", bindings.createModule()); module.addImport("linux_c", bindings.createModule());
module.addCSourceFile(.{ .file = b.path("src/linux/libraries.c"), .flags = &.{ "-std=gnu11", "-Wall", "-Wextra", "-Werror", "-fvisibility=hidden" } }); 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(.{ module.addCSourceFile(.{
.file = b.path(b.fmt("src/linux/protocols/generated/{s}.c", .{name})), .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) }, .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); check.dependOn(&exe.step);
b.step("test-linux-native", "Exercise Wayland/EGL lifecycle in the active desktop session").dependOn(&b.addRunArtifact(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, .{}); 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);
} }

69
src/linux/dbus-imports.h Normal file
View File

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

View File

@ -30,3 +30,54 @@ Z_FUNCTION(eglSwapBuffers)
Z_FUNCTION(eglSwapInterval) Z_FUNCTION(eglSwapInterval)
Z_FUNCTION(eglGetProcAddress) Z_FUNCTION(eglGetProcAddress)
Z_FUNCTION(eglReleaseThread) 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)

View File

@ -2,22 +2,31 @@
#include <dlfcn.h> #include <dlfcn.h>
#include <errno.h> #include <errno.h>
#include <poll.h> #include <poll.h>
#include <signal.h>
#include <pthread.h>
#include <fcntl.h>
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
#include <sys/syscall.h> #include <sys/syscall.h>
#include <sys/stat.h>
#include <unistd.h> #include <unistd.h>
#include "wayland-client-core.h" #include "wayland-client-core.h"
#include "wayland-egl-core.h" #include "wayland-egl-core.h"
#define EGL_NO_X11 #define EGL_NO_X11
#include "EGL/egl.h" #include "EGL/egl.h"
#include "EGL/eglext.h" #include "EGL/eglext.h"
#include <xkbcommon/xkbcommon.h>
#include <xkbcommon/xkbcommon-compose.h>
#include "xkb-imports.h"
#include <dbus/dbus.h>
#include "dbus-imports.h"
#include "function-pointers.h" #include "function-pointers.h"
#define Z_FUNCTION(name) __typeof__(name) *z_##name; #define Z_FUNCTION(name) __typeof__(name) *z_##name;
#include "functions.inc" #include "functions.inc"
#undef Z_FUNCTION #undef Z_FUNCTION
static void *wayland, *wayland_egl, *egl; static void *wayland, *wayland_egl, *egl, *xkb, *dbus;
static int have_egl; static int have_egl, have_xkb, have_dbus;
static void *open_library(const char *name) { static void *open_library(const char *name) {
void *handle = dlopen(name, RTLD_NOW | RTLD_LOCAL); 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) { 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) if (egl)
dlclose(egl); dlclose(egl);
if (wayland_egl) if (wayland_egl)
@ -56,19 +71,34 @@ int z_linux_libraries_open(void) {
wayland_egl = open_library("libwayland-egl.so.1"); wayland_egl = open_library("libwayland-egl.so.1");
egl = open_library("libEGL.so.1"); egl = open_library("libEGL.so.1");
have_egl = wayland_egl && egl; 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) \ #define Z_FUNCTION(name) \
do { \ do { \
const char *symbol = #name; \ 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; \ 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) \ if (library) \
z_##name = (__typeof__(z_##name))dlsym(library, symbol); \ z_##name = (__typeof__(z_##name))dlsym(library, symbol); \
if (!z_##name) { \ 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(); \ z_linux_libraries_close(); \
return 0; \ return 0; \
} \ } \
have_egl = 0; \
} \ } \
} while (0); } while (0);
#include "functions.inc" #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) { ssize_t z_linux_write(int fd, const void *buffer, size_t count) {
return write(fd, buffer, 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");

View File

@ -7,6 +7,12 @@ const abi = @import("../abi.zig");
const runtime = @import("../runtime.zig"); const runtime = @import("../runtime.zig");
const Application = @import("../Application.zig"); const Application = @import("../Application.zig");
const Egl = @import("Egl.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 { pub const Window = struct {
next: ?*Window = null, next: ?*Window = null,
@ -37,6 +43,9 @@ pub const Window = struct {
focused: bool = false, focused: bool = false,
state: u32 = n.UHDK_WINDOW_NORMAL, state: u32 = n.UHDK_WINDOW_NORMAL,
flags: u32, 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), metrics: n.uhdk_window_metrics = std.mem.zeroes(n.uhdk_window_metrics),
}; };
@ -52,6 +61,12 @@ pub const Backend = struct {
windows: ?*Window = null, windows: ?*Window = null,
egl_display: c.EGLDisplay = null, egl_display: c.EGLDisplay = null,
egl_config: c.EGLConfig = 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, running: bool = true,
failed: std.atomic.Value(bool) = .init(false), 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; if (handle == null) return 0;
const b = instance orelse 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; 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 { 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 }; b.* = .{ .app = app };
instance = b; instance = b;
Input.initialize();
const fd = c.eventfd(0, c.EFD_CLOEXEC | c.EFD_NONBLOCK); const fd = c.eventfd(0, c.EFD_CLOEXEC | c.EFD_NONBLOCK);
if (fd < 0) { if (fd < 0) {
z_native_finish(); z_native_finish();
@ -106,7 +127,15 @@ export fn z_native_start(handle: ?*n.uhdk_app, _: n.uhdk_text) u32 {
z_native_finish(); z_native_finish();
return n.UHDK_STATUS_UNSUPPORTED; 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); Egl.initialize(b);
setCapability(n.UHDK_CAP_OPENGL, b.egl_display != null);
return abi.OK; return abi.OK;
} }
// Startup also uses a bounded read/flush/poll handshake: an unresponsive // 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 { export fn z_native_finish() void {
const b = instance orelse return; const b = instance orelse return;
std.debug.assert(b.windows == null); std.debug.assert(b.windows == null);
while (b.watches) |w| Watch.destroy(w);
Desktop.deinit();
Portal.deinit();
Data.deinit();
Input.deinit();
Egl.finish(b); Egl.finish(b);
if (b.fractional != null) c.wp_fractional_scale_manager_v1_destroy(b.fractional); if (b.fractional != null) c.wp_fractional_scale_manager_v1_destroy(b.fractional);
if (b.viewporter != null) c.wp_viewporter_destroy(b.viewporter); 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 { export fn z_native_run() void {
const b = instance.?; const b = instance.?;
var fds: std.ArrayList(c.struct_pollfd) = .empty;
defer fds.deinit(b.app.gpa);
while (b.running) { while (b.running) {
if (!b.failed.load(.acquire) and c.z_wl_display_dispatch_pending.?(b.display) < 0) fail(b); if (!b.failed.load(.acquire) and c.z_wl_display_dispatch_pending.?(b.display) < 0) fail(b);
n.z_tick(runtime.toAppHandle(b.app)); n.z_tick(runtime.toAppHandle(b.app));
var cursor = b.windows; 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| { while (cursor) |w| {
cursor = w.next; cursor = w.next;
if (w.request != 0) { if (w.request != 0) {
timeout = 50; timeout = if (timeout < 0) 50 else @min(timeout, 50);
if (runtime.now(b.app.io) >= w.deadline) { if (runtime.now(b.app.io) >= w.deadline) {
completeCreation(w, abi.UNAVAILABLE); completeCreation(w, abi.UNAVAILABLE);
continue; 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 = 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 }, .{ .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 (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); if (c.z_wl_display_read_events.?(b.display) < 0) fail(b);
} else c.z_wl_display_cancel_read.?(b.display); } else c.z_wl_display_cancel_read.?(b.display);
} }
if (ready < 0 and c.z_linux_errno() != c.EINTR) fail(b); 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.items[0].revents & (c.POLLERR | c.POLLHUP | c.POLLNVAL) != 0) fail(b);
if (fds[1].revents & c.POLLIN != 0) { if (fds.items[1].revents & c.POLLIN != 0) {
var value: u64 = 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 { 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 b: *Backend = @ptrCast(@alignCast(data.?));
const str = std.mem.span(interface); 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) { 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))); 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")) { } 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)); 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 }; 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 { fn ping(_: ?*anyopaque, shell: ?*c.struct_xdg_wm_base, serial: u32) callconv(.c) void {
c.xdg_wm_base_pong(shell, serial); 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; 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_title(w.top, title);
c.xdg_toplevel_set_app_id(w.top, app_id); c.xdg_toplevel_set_app_id(w.top, app_id);
Desktop.applyIcon(w);
if (desc.parent) |parent| { if (desc.parent) |parent| {
const p: *Window = @ptrCast(@alignCast(n.z_object_native(parent) orelse return abi.UNAVAILABLE)); const p: *Window = @ptrCast(@alignCast(n.z_object_native(parent) orelse return abi.UNAVAILABLE));
c.xdg_toplevel_set_parent(w.top, p.top); c.xdg_toplevel_set_parent(w.top, p.top);
@ -445,6 +515,10 @@ fn setLimits(w: *Window) void {
fn destroyWindow(w: *Window) void { fn destroyWindow(w: *Window) void {
const b = instance.?; const b = instance.?;
std.debug.assert(w.egl == null); std.debug.assert(w.egl == null);
Desktop.forget(w);
Data.forget(w);
Input.forget(w);
Text.deinit(w);
var link = &b.windows; var link = &b.windows;
while (link.*) |item| { while (link.*) |item| {
if (item == w) { 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 (b.failed.load(.acquire)) return abi.UNAVAILABLE;
if (cmd.op == n.Z_WINDOW) return createWindow(b, cmd, id); 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 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; const w: *Window = if (ptr) |p| @ptrCast(@alignCast(p)) else return n.UHDK_STATUS_UNSUPPORTED;
switch (cmd.op) { 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); if (w.want_visible and !w.configured) c.wl_surface_commit(w.surface);
w.dirty = true; 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_REDRAW => emit(w, n.UHDK_EVENT_WINDOW_REDRAW),
n.Z_GL => return Egl.create(b, w, cmd.other, result), n.Z_GL => return Egl.create(b, w, cmd.other, result),
else => return n.UHDK_STATUS_UNSUPPORTED, 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 { export fn z_native_cancel(id: u64) void {
const b = instance orelse return; const b = instance orelse return;
if (Data.cancel(id) or Portal.cancel(id) or Desktop.cancel(id)) return;
var cursor = b.windows; var cursor = b.windows;
while (cursor) |w| : (cursor = w.next) { while (cursor) |w| : (cursor = w.next) {
if (w.request == id) { if (w.request == id) {
@ -539,6 +625,7 @@ export fn z_native_destroy(kind: u32, ptr: ?*anyopaque) void {
const w: *Window = @ptrCast(@alignCast(p)); const w: *Window = @ptrCast(@alignCast(p));
if (w.request != 0) completeCreation(w, abi.CANCELLED) else destroyWindow(w); 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))), n.Z_KIND_GL => Egl.destroy(@ptrCast(@alignCast(p))),
else => {}, else => {},
} }

View File

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

316
src/linux/tests/services.c Normal file
View File

@ -0,0 +1,316 @@
#define _GNU_SOURCE
#include "uhdk.h"
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#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;
}

View File

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

57
src/linux/xkb-imports.h Normal file
View File

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

View File

@ -19,7 +19,13 @@ test {
std.testing.refAllDecls(@import("FileOperation.zig")); std.testing.refAllDecls(@import("FileOperation.zig"));
_ = @import("core_tests.zig"); _ = @import("core_tests.zig");
_ = @import("arenas.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 { fn checkExports(comptime module: type) void {