Harden input ordering, portal activation and portable Linux builds

This commit is contained in:
Peterino2 2026-09-18 14:12:08 -07:00
parent 5623376284
commit c1c2de8c4b
9 changed files with 218 additions and 36 deletions

View File

@ -323,10 +323,6 @@ pub fn forget(w: *native.Window) void {
} }
} }
} }
pub fn focusLost() void {
if (s().selection) |offer_| unref(offer_);
s().selection = null;
}
pub fn releaseSeat() void { pub fn releaseSeat() void {
while (s().transfers) |t| finish(t, abi.CANCELLED); while (s().transfers) |t| finish(t, abi.CANCELLED);
s().selection = null; s().selection = null;

View File

@ -9,6 +9,15 @@ const Keyboard = @import("Keyboard.zig");
const Data = @import("Data.zig"); const Data = @import("Data.zig");
const Text = @import("Text.zig"); const Text = @import("Text.zig");
const Key = struct {
code: u32,
down: bool,
repeated: bool,
sym: u32,
modifiers: u32,
text: [256]u8 = undefined,
len: usize = 0,
};
pub const State = struct { pub const State = struct {
seat: ?*c.struct_wl_seat = null, seat: ?*c.struct_wl_seat = null,
seat_name: u32 = 0, seat_name: u32 = 0,
@ -29,6 +38,8 @@ pub const State = struct {
compose: ?*c.struct_xkb_compose_state = null, compose: ?*c.struct_xkb_compose_state = null,
modifiers: u32 = 0, modifiers: u32 = 0,
active: bool = false, active: bool = false,
pending_key: ?Key = null,
key_sync: ?*c.struct_wl_callback = null,
serial: u32 = 0, serial: u32 = 0,
serial_time: u64 = 0, serial_time: u64 = 0,
pointer_serial: u32 = 0, pointer_serial: u32 = 0,
@ -137,6 +148,7 @@ fn seatCapabilities(_: ?*anyopaque, seat: ?*c.struct_wl_seat, caps: u32) callcon
fn seatName(_: ?*anyopaque, _: ?*c.struct_wl_seat, _: [*c]const u8) callconv(.c) void {} 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 }; 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 { fn keymap(_: ?*anyopaque, _: ?*c.struct_wl_keyboard, format: u32, fd: i32, size: u32) callconv(.c) void {
flushKey();
defer _ = c.close(fd); defer _ = c.close(fd);
const s = state(); const s = state();
s.repeat_key = null; s.repeat_key = null;
@ -182,31 +194,66 @@ fn modifiers(_: ?*anyopaque, _: ?*c.struct_wl_keyboard, _: u32, depressed: u32,
const s = state(); const s = state();
if (s.state != null) _ = c.z_xkb_state_update_mask.?(s.state, depressed, latched, locked, 0, 0, group); if (s.state != null) _ = c.z_xkb_state_update_mask.?(s.state, depressed, latched, locked, 0, 0, group);
updateModifiers(); updateModifiers();
flushKey();
} }
fn key(_: ?*anyopaque, _: ?*c.struct_wl_keyboard, serial: u32, _: u32, code: u32, pressed: u32) callconv(.c) void { fn key(_: ?*anyopaque, _: ?*c.struct_wl_keyboard, serial: u32, _: u32, code: u32, pressed: u32) callconv(.c) void {
const s = state(); const s = state();
flushKey();
if (code > 0xffff) return; if (code > 0xffff) return;
provenance(serial); provenance(serial);
if (code < s.held.len) s.held[code] = pressed != 0; if (code < s.held.len) s.held[code] = pressed != 0;
// The compositor supplies the authoritative modifier/group mask. Running // The compositor supplies the authoritative modifier/group mask. Running
// XKB key actions locally as well would apply latch/lock actions twice. // XKB key actions locally as well would apply latch/lock actions twice.
deliverKey(code, pressed != 0, false); s.pending_key = prepareKey(code, pressed != 0, false);
// wl_keyboard has no frame event. A sync boundary lets the following
// authoritative modifiers event supply the public post-event flags even
// when the key and modifier messages arrive in separate socket reads.
if (s.key_sync == null) {
s.key_sync = c.wl_display_sync(b().display);
if (s.key_sync != null) _ = c.wl_callback_add_listener(s.key_sync, &key_sync_listener, null) else flushKey();
}
if (pressed != 0 and s.repeat_rate > 0 and s.keymap != null and c.z_xkb_keymap_key_repeats.?(s.keymap, code + 8) != 0) { 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_key = code;
s.repeat_at = runtime.now(b().app.io) + @as(u64, s.repeat_delay) * std.time.ns_per_ms; 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; } else if (s.repeat_key == code) s.repeat_key = null;
} }
fn prepareKey(code: u32, down: bool, repeated: bool) Key {
const s = state();
var value: Key = .{ .code = code, .down = down, .repeated = repeated, .sym = if (s.state != null) c.z_xkb_state_key_get_one_sym.?(s.state, code + 8) else 0, .modifiers = s.modifiers };
if (down and s.state != null) {
const length = c.z_xkb_state_key_get_utf8.?(s.state, code + 8, &value.text, value.text.len);
if (length > 0 and length < value.text.len) value.len = @intCast(length);
}
return value;
}
fn deliverKey(code: u32, down: bool, repeated: bool) void { fn deliverKey(code: u32, down: bool, repeated: bool) void {
emitKey(prepareKey(code, down, repeated));
}
fn emitKey(value: Key) void {
const s = state(); const s = state();
const w = s.key_focus orelse return; 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); var event = std.mem.zeroes(n.z_event);
event.type = n.UHDK_EVENT_KEY; event.type = n.UHDK_EVENT_KEY;
event.input_id = id(); 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) }; event.data.key = .{ .physical_key = Keyboard.physical(value.code), .logical_key = if (s.state != null) Keyboard.logical(value.sym) else 0, .modifiers = s.modifiers, .pressed = @intFromBool(value.down), .repeat = @intFromBool(value.repeated) };
send(w, &event); send(w, &event);
if (down and w.text.enabled) Text.key(w, code, sym, event.data.key.logical_key, event.input_id); // Resolve symbols/text before consuming a latched modifier, while reporting
// the compositor's post-event flags in the observational raw event.
if (value.down and w.text.enabled) Text.key(w, value.sym, event.data.key.logical_key, event.input_id, value.modifiers, value.text[0..value.len]);
} }
pub fn flushKey() void {
if (state().pending_key) |value| {
state().pending_key = null;
emitKey(value);
}
}
fn keySynced(_: ?*anyopaque, callback: ?*c.struct_wl_callback, _: u32) callconv(.c) void {
c.wl_callback_destroy(callback);
state().key_sync = null;
flushKey();
}
const key_sync_listener: c.struct_wl_callback_listener = .{ .done = keySynced };
fn repeatInfo(_: ?*anyopaque, _: ?*c.struct_wl_keyboard, rate: i32, delay: i32) callconv(.c) void { 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_rate = @intCast(std.math.clamp(rate, 0, 1000));
state().repeat_delay = @intCast(@max(0, delay)); state().repeat_delay = @intCast(@max(0, delay));
@ -216,7 +263,6 @@ const keyboard_listener: c.struct_wl_keyboard_listener = .{ .keymap = keymap, .e
pub fn tick() i32 { pub fn tick() i32 {
const s = state(); const s = state();
const active = s.key_focus != null; const active = s.key_focus != null;
if (!active) Data.focusLost();
if (active != s.active) { if (active != s.active) {
s.active = active; s.active = active;
var event = std.mem.zeroes(n.z_event); var event = std.mem.zeroes(n.z_event);
@ -224,6 +270,7 @@ pub fn tick() i32 {
event.data.active = @intFromBool(active); event.data.active = @intFromBool(active);
n.z_emit(runtime.toAppHandle(b().app), &event); n.z_emit(runtime.toAppHandle(b().app), &event);
} }
if (s.pending_key != null) return -1;
if (s.repeat_key) |code| { if (s.repeat_key) |code| {
const now = runtime.now(b().app.io); const now = runtime.now(b().app.io);
if (now >= s.repeat_at) { if (now >= s.repeat_at) {
@ -235,7 +282,9 @@ pub fn tick() i32 {
return -1; return -1;
} }
fn leaveKeyboard() void { fn leaveKeyboard() void {
flushKey();
const s = state(); const s = state();
s.modifiers = 0;
s.repeat_key = null; s.repeat_key = null;
if (s.key_focus) |w| { if (s.key_focus) |w| {
for (&s.held, 0..) |*held, code| if (held.*) { for (&s.held, 0..) |*held, code| if (held.*) {
@ -402,6 +451,8 @@ pub fn remove(name: u32) void {
fn releaseSeat() void { fn releaseSeat() void {
Data.releaseSeat(); Data.releaseSeat();
const s = state(); const s = state();
if (s.key_sync != null) c.wl_callback_destroy(s.key_sync);
s.key_sync = null;
leaveKeyboard(); leaveKeyboard();
leavePointer(); leavePointer();
if (s.text_focus) |w| Text.forget(w); if (s.text_focus) |w| Text.forget(w);

View File

@ -6,6 +6,7 @@ const n = @import("native");
const native = @import("native.zig"); const native = @import("native.zig");
const Bus = @import("Bus.zig"); const Bus = @import("Bus.zig");
const Files = @import("Files.zig"); const Files = @import("Files.zig");
const Input = @import("Input.zig");
const runtime = @import("../runtime.zig"); const runtime = @import("../runtime.zig");
const abi = @import("../abi.zig"); const abi = @import("../abi.zig");
const desktop = "org.freedesktop.portal.Desktop"; const desktop = "org.freedesktop.portal.Desktop";
@ -24,6 +25,7 @@ const Pending = struct {
handle: [:0]u8, handle: [:0]u8,
token: [:0]u8, token: [:0]u8,
exported: ?*c.struct_zxdg_exported_v2 = null, exported: ?*c.struct_zxdg_exported_v2 = null,
activation: ?*c.struct_xdg_activation_token_v1 = null,
deadline: u64, deadline: u64,
}; };
pub const State = struct { pub const State = struct {
@ -309,7 +311,18 @@ pub fn request(cmd: *const n.z_command, id: u64) u32 {
return abi.OOM; return abi.OOM;
}; };
_ = c.zxdg_exported_v2_add_listener(p.exported, &export_listener, p); _ = c.zxdg_exported_v2_add_listener(p.exported, &export_listener, p);
} else submit(p, "") catch { } else if (cmd.op == n.Z_URL and b().desktop.activation != null and Input.state().key_focus != null and
Input.state().serial != 0 and runtime.now(b().app.io) -| Input.state().serial_time <= 5 * std.time.ns_per_s)
{
p.activation = c.xdg_activation_v1_get_activation_token(b().desktop.activation) orelse {
destroy(p);
return abi.OOM;
};
_ = c.xdg_activation_token_v1_add_listener(p.activation, &activation_listener, p);
c.xdg_activation_token_v1_set_serial(p.activation, Input.state().serial, Input.state().seat);
c.xdg_activation_token_v1_set_surface(p.activation, Input.state().key_focus.?.surface);
c.xdg_activation_token_v1_commit(p.activation);
} else submit(p, "", null) catch {
destroy(p); destroy(p);
return abi.OOM; return abi.OOM;
}; };
@ -336,13 +349,21 @@ fn exported(raw: ?*anyopaque, _: ?*c.struct_zxdg_exported_v2, handle: [*c]const
return; return;
}; };
defer b().app.gpa.free(parent); defer b().app.gpa.free(parent);
submit(p, parent) catch { submit(p, parent, null) catch {
complete(p, abi.OOM, null); complete(p, abi.OOM, null);
destroy(p); destroy(p);
}; };
} }
const export_listener: c.struct_zxdg_exported_v2_listener = .{ .handle = exported }; const export_listener: c.struct_zxdg_exported_v2_listener = .{ .handle = exported };
fn submit(p: *Pending, parent: [*:0]const u8) !void { fn activated(raw: ?*anyopaque, _: ?*c.struct_xdg_activation_token_v1, token: [*c]const u8) callconv(.c) void {
const p: *Pending = @ptrCast(@alignCast(raw.?));
submit(p, "", token) catch {
complete(p, abi.OOM, null);
destroy(p);
};
}
const activation_listener: c.struct_xdg_activation_token_v1_listener = .{ .done = activated };
fn submit(p: *Pending, parent: [*:0]const u8, activation_token: ?[*:0]const u8) !void {
const cmd = p.command.?; const cmd = p.command.?;
const dialog = cmd.op == n.Z_DIALOG; 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"); 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");
@ -356,6 +377,7 @@ fn submit(p: *Pending, parent: [*:0]const u8) !void {
var options = try Bus.open(&args, c.DBUS_TYPE_ARRAY, "{sv}"); var options = try Bus.open(&args, c.DBUS_TYPE_ARRAY, "{sv}");
errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&args, &options); errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&args, &options);
try Bus.option(&options, "handle_token", "s", p.token.ptr); try Bus.option(&options, "handle_token", "s", p.token.ptr);
if (activation_token) |token| try Bus.option(&options, "activation_token", "s", token);
if (dialog) { if (dialog) {
try Bus.option(&options, "modal", "b", @as(u32, @intFromBool(cmd.dialog.parent != null))); try Bus.option(&options, "modal", "b", @as(u32, @intFromBool(cmd.dialog.parent != null)));
if (cmd.dialog.kind != n.UHDK_DIALOG_SAVE_FILE) { if (cmd.dialog.kind != n.UHDK_DIALOG_SAVE_FILE) {
@ -485,6 +507,7 @@ fn destroy(p: *Pending) void {
while (link.* != p) link = &link.*.?.next; while (link.* != p) link = &link.*.?.next;
link.* = p.next; link.* = p.next;
if (p.exported != null) c.zxdg_exported_v2_destroy(p.exported); if (p.exported != null) c.zxdg_exported_v2_destroy(p.exported);
if (p.activation != null) c.xdg_activation_token_v1_destroy(p.activation);
b().app.gpa.free(p.handle); b().app.gpa.free(p.handle);
b().app.gpa.free(p.token); b().app.gpa.free(p.token);
b().app.gpa.destroy(p); b().app.gpa.destroy(p);

View File

@ -114,7 +114,10 @@ NixOS with Plasma 6.5.6 and Mesa 25.2.6; other systems are not runtime-qualified
- One active seat owns keyboard, pointer, and clipboard state. The keyboard uses - 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, 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. and locale-aware compose tables. Raw events use the post-event modifier mask;
text lookup retains the pre-event state for latched modifiers. A Wayland sync
boundary flushes keys without modifier changes. Focus loss releases tracked
keys and stops repeat.
- Text input uses text-input-v3 when available and local XKB composition for - 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 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 offsets until `done`. Surrounding text is cropped to the protocol's 4000-byte
@ -138,7 +141,8 @@ NixOS with Plasma 6.5.6 and Mesa 25.2.6; other systems are not runtime-qualified
A private D-Bus connection subscribes before submission, correlates early A private D-Bus connection subscribes before submission, correlates early
responses, closes cancelled requests, and handles portal-owner changes. responses, closes cancelled requests, and handles portal-owner changes.
Portal discovery is asynchronous, so its capability can appear after `READY`. Portal discovery is asynchronous, so its capability can appear after `READY`.
Connection/method setup is bounded; human interaction has no timeout. Connection/method setup is bounded; human interaction has no timeout. URL
handoff includes an activation token when recent focused input permits it.
- Portal Settings supplies appearance, contrast and reduced-motion preferences - Portal Settings supplies appearance, contrast and reduced-motion preferences
and live changes. Unknown/unavailable values retain neutral defaults. See the 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) [Settings contract](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Settings.html)
@ -178,9 +182,9 @@ does not stop or modify the desktop compositor.
The service fixtures own their compositor socket and D-Bus daemon. They exercise The service fixtures own their compositor socket and D-Bus daemon. They exercise
keys/repeat/dead-key composition, IME revisions/deletion/preedit, scrolling, keys/repeat/dead-key composition, IME revisions/deletion/preedit, scrolling,
clipboard size limits and early reader exit, URI drops, activation, portal clipboard size limits/cancellation/invalid UTF-8/early reader exit, URI drops,
filters/results, response-before-reply, cancellation with a late alternate activation, portal open/save options, live settings, response-before-reply,
handle, parent destruction and portal restart. They never open a real chooser 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 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 portal discovery, icon submission and atomic-save watch behavior without touching
the user's clipboard. These fixtures supplement live desktop testing; they do the user's clipboard. These fixtures supplement live desktop testing; they do

View File

@ -80,6 +80,7 @@ fn deleted(_: ?*anyopaque, _: ?*c.struct_zwp_text_input_v3, before: u32, after:
w.text.after = after; w.text.after = after;
} }
fn done(_: ?*anyopaque, _: ?*c.struct_zwp_text_input_v3, serial: u32) callconv(.c) void { fn done(_: ?*anyopaque, _: ?*c.struct_zwp_text_input_v3, serial: u32) callconv(.c) void {
Input.flushKey();
const w = Input.state().text_focus orelse return; const w = Input.state().text_focus orelse return;
const t = &w.text; const t = &w.text;
defer clearPending(w); defer clearPending(w);
@ -276,25 +277,26 @@ pub fn deinit(w: *native.Window) void {
forget(w); forget(w);
w.owner.gpa.free(w.text.surrounding); w.owner.gpa.free(w.text.surrounding);
} }
pub fn key(w: *native.Window, code: u32, sym: u32, logical: u32, input_id: u64) void { pub fn key(w: *native.Window, sym: u32, logical: u32, input_id: u64, modifiers: u32, plain: []const u8) void {
const s = Input.state(); const s = Input.state();
const t = &w.text; const t = &w.text;
if (t.ime_composing or s.state == null) return; if (t.ime_composing or s.state == null) return;
const edit = Keyboard.edit(logical, s.modifiers); const edit = Keyboard.edit(logical, modifiers);
if (edit != 0) { if (edit != 0) {
if (s.compose != null) c.z_xkb_compose_state_reset.?(s.compose); if (s.compose != null) c.z_xkb_compose_state_reset.?(s.compose);
endAt(w, input_id); endAt(w, input_id);
var e = std.mem.zeroes(n.z_event); var e = std.mem.zeroes(n.z_event);
e.type = n.UHDK_EVENT_EDIT_COMMAND; e.type = n.UHDK_EVENT_EDIT_COMMAND;
e.input_id = input_id; e.input_id = input_id;
e.data.edit = .{ .command = edit, .extend_selection = @intFromBool(s.modifiers & n.UHDK_MOD_SHIFT != 0) }; e.data.edit = .{ .command = edit, .extend_selection = @intFromBool(modifiers & n.UHDK_MOD_SHIFT != 0) };
t.last_edit = input_id; t.last_edit = input_id;
Input.send(w, &e); Input.send(w, &e);
return; return;
} }
if (s.modifiers & (n.UHDK_MOD_CONTROL | n.UHDK_MOD_ALT | n.UHDK_MOD_SUPER) != 0) return; if (modifiers & (n.UHDK_MOD_CONTROL | n.UHDK_MOD_ALT | n.UHDK_MOD_SUPER) != 0) return;
var buffer: [256]u8 = undefined; var buffer: [256]u8 = undefined;
var length: c_int = 0; var length: c_int = @intCast(plain.len);
@memcpy(buffer[0..plain.len], plain);
if (s.compose != null) { if (s.compose != null) {
_ = c.z_xkb_compose_state_feed.?(s.compose, sym); _ = c.z_xkb_compose_state_feed.?(s.compose, sym);
switch (c.z_xkb_compose_state_get_status.?(s.compose)) { switch (c.z_xkb_compose_state_get_status.?(s.compose)) {
@ -312,9 +314,9 @@ pub fn key(w: *native.Window, code: u32, sym: u32, logical: u32, input_id: u64)
c.z_xkb_compose_state_reset.?(s.compose); c.z_xkb_compose_state_reset.?(s.compose);
endAt(w, input_id); endAt(w, input_id);
}, },
else => length = c.z_xkb_state_key_get_utf8.?(s.state, code + 8, &buffer, buffer.len), else => {},
}
} }
} 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; if (length <= 0 or length >= buffer.len or buffer[0] < 32 or buffer[0] == 127) return;
var e = std.mem.zeroes(n.z_event); var e = std.mem.zeroes(n.z_event);
e.type = n.UHDK_EVENT_TEXT_COMMIT; e.type = n.UHDK_EVENT_TEXT_COMMIT;

View File

@ -8,7 +8,6 @@
#include <sys/syscall.h> #include <sys/syscall.h>
#include <unistd.h> #include <unistd.h>
#include <sys/mman.h> #include <sys/mman.h>
#include <fcntl.h>
#include <stdlib.h> #include <stdlib.h>
#include "wayland-client-core.h" #include "wayland-client-core.h"
#include "wayland-egl-core.h" #include "wayland-egl-core.h"

View File

@ -37,7 +37,10 @@ async def main(binary):
async def delayed_reply(message, handle, delay, is_url=False): async def delayed_reply(message, handle, delay, is_url=False):
await asyncio.sleep(delay) await asyncio.sleep(delay)
bus.send(Message.new_method_return(message, 'o', [handle])) bus.send(Message.new_method_return(message, 'o', [handle]))
if is_url: response(handle) if is_url:
for key, value in [('color-scheme', 2), ('contrast', 0), ('reduced-motion', 0)]:
bus.send(Message.new_signal(PATH, 'org.freedesktop.portal.Settings', 'SettingChanged', 'ssv', ['org.freedesktop.appearance', key, Variant('u', value)]))
response(handle)
async def restart(): async def restart():
await bus.release_name(DESKTOP) await bus.release_name(DESKTOP)
@ -65,11 +68,13 @@ async def main(binary):
handle_path = PATH + '/request/' + message.sender[1:].replace('.', '_') + '/' + token handle_path = PATH + '/request/' + message.sender[1:].replace('.', '_') + '/' + token
if message.member == 'OpenURI': if message.member == 'OpenURI':
assert title == 'https://example.org/test' assert title == 'https://example.org/test'
assert options['activation_token'].value == 'fixture-token'
schedule(delayed_reply(message, handle_path, .35, True)) schedule(delayed_reply(message, handle_path, .35, True))
seen.add('url') seen.add('url')
return True return True
seen.add(title) seen.add(title)
assert message.signature == 'ssa{sv}' assert message.signature == 'ssa{sv}'
if message.member == 'OpenFile':
assert options['multiple'].value and not options['directory'].value assert options['multiple'].value and not options['directory'].value
assert options['filters'].value == [['Text', [[0, '*.txt'], [0, '*.md']]]] assert options['filters'].value == [['Text', [[0, '*.txt'], [0, '*.md']]]]
if title == 'success': if title == 'success':
@ -79,6 +84,13 @@ async def main(binary):
# must already be subscribed using its chosen handle token. # 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'])}) 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])) bus.send(Message.new_method_return(message, 'o', [handle_path]))
elif title == 'save':
assert message.member == 'SaveFile'
assert options['current_name'].value == 'book.txt'
assert options['accept_label'].value == 'Store'
assert bytes(options['current_folder'].value) == b'/tmp\0'
bus.send(Message.new_method_return(message, 'o', [handle_path]))
response(handle_path, results={'uris': Variant('as', ['file:///tmp/save.txt'])})
elif title == 'cancel': elif title == 'cancel':
# Return a different handle after local cancellation; both # Return a different handle after local cancellation; both
# the predicted and returned paths need Close calls. # the predicted and returned paths need Close calls.
@ -102,7 +114,7 @@ async def main(binary):
bus.add_message_handler(handle) bus.add_message_handler(handle)
await asyncio.to_thread(run, binary, 'portal', dict(os.environ, DBUS_SESSION_BUS_ADDRESS=address)) await asyncio.to_thread(run, binary, 'portal', dict(os.environ, DBUS_SESSION_BUS_ADDRESS=address))
await asyncio.sleep(.1) await asyncio.sleep(.1)
assert {'success', 'cancel', 'url', 'restart', 'dismiss'} <= seen, seen assert {'success', 'save', 'cancel', 'url', 'restart', 'dismiss'} <= seen, seen
assert any(p.endswith('_late') for p in closed), closed assert any(p.endswith('_late') for p in closed), closed
assert len(closed) >= 2, closed assert len(closed) >= 2, closed
print('private portal protocol/cancellation/restart fixture: PASS') print('private portal protocol/cancellation/restart fixture: PASS')

View File

@ -162,6 +162,28 @@ static void portals(uhdk_app *app) {
files->ptr[0], UHDK_FILE_NATIVE_PATH, bytes, sizeof(bytes), &len files->ptr[0], UHDK_FILE_NATIVE_PATH, bytes, sizeof(bytes), &len
)); ));
assert(len == 14 && memcmp(bytes, "/tmp/a b%\xff.txt", 14) == 0); assert(len == 14 && memcmp(bytes, "/tmp/a b%\xff.txt", 14) == 0);
uhdk_preferences prefs = {0};
prefs.struct_size = sizeof(prefs);
OK(uhdk_app_get_preferences(app, &prefs));
assert(prefs.appearance == UHDK_APPEARANCE_DARK && prefs.reduced_motion && prefs.high_contrast);
uhdk_file *directory;
OK(
uhdk_file_create_from_native_path(app, (uhdk_bytes){(const uint8_t *)"/tmp", 4}, &directory)
);
desc.title = text("save");
desc.kind = UHDK_DIALOG_SAVE_FILE;
desc.suggested_name = text("book.txt");
desc.accept_label = text("Store");
desc.initial_directory = directory;
OK(uhdk_file_dialog_show_async(app, &desc, &id));
r = wait_result(app, id);
OK(r.status);
assert(((const uhdk_file_list *)r.data)->len == 1);
uhdk_file_release(directory);
desc.kind = UHDK_DIALOG_OPEN_FILES;
desc.initial_directory = NULL;
desc.suggested_name = text("");
desc.accept_label = text("");
desc.title = text("cancel"); desc.title = text("cancel");
OK(uhdk_file_dialog_show_async(app, &desc, &id)); OK(uhdk_file_dialog_show_async(app, &desc, &id));
// Allow method submission, then cancel while the mock withholds its reply. // Allow method submission, then cancel while the mock withholds its reply.
@ -169,12 +191,17 @@ static void portals(uhdk_app *app) {
OK(uhdk_app_poll_events(app, 100000000, &events)); OK(uhdk_app_poll_events(app, 100000000, &events));
OK(uhdk_request_cancel(app, id)); OK(uhdk_request_cancel(app, id));
assert(wait_result(app, id).status == UHDK_STATUS_CANCELLED); 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);
OK(uhdk_app_get_preferences(app, &prefs));
assert(
prefs.appearance == UHDK_APPEARANCE_LIGHT && !prefs.reduced_motion && !prefs.high_contrast
);
desc.title = text("parent"); desc.title = text("parent");
OK(uhdk_file_dialog_show_async(app, &desc, &id)); OK(uhdk_file_dialog_show_async(app, &desc, &id));
uhdk_window_destroy(w); uhdk_window_destroy(w);
assert(wait_result(app, id).status == UHDK_STATUS_CANCELLED); 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.parent = NULL;
desc.title = text("restart"); desc.title = text("restart");
OK(uhdk_file_dialog_show_async(app, &desc, &id)); OK(uhdk_file_dialog_show_async(app, &desc, &id));
@ -200,19 +227,28 @@ static void input(uhdk_app *app) {
state.selection_len = 1; state.selection_len = 1;
OK(uhdk_window_set_text_state(w, &state)); OK(uhdk_window_set_text_state(w, &state));
unsigned key = 0, repeat = 0, commit = 0, preedit = 0, pointer = 0, scroll = 0, dropped = 0, unsigned key = 0, repeat = 0, commit = 0, preedit = 0, pointer = 0, scroll = 0, dropped = 0,
copy = 0, compose = 0; copy = 0, compose = 0, captured = 0, released = 0;
uint64_t acknowledged = 0, capture_request = 0;
int published = 0;
uint64_t end = deadline(app); uint64_t end = deadline(app);
while (uhdk_app_get_time_ns(app) < end && !(key && repeat && commit >= 2 && preedit && while (uhdk_app_get_time_ns(app) < end &&
pointer && scroll && dropped && copy && compose)) { !(key && repeat && commit >= 2 && preedit && pointer && scroll && dropped && copy &&
compose && captured && released)) {
uhdk_event_list events; uhdk_event_list events;
OK(uhdk_app_poll_events(app, 50000000, &events)); OK(uhdk_app_poll_events(app, 50000000, &events));
for (uint64_t i = 0; i < events.len; ++i) { for (uint64_t i = 0; i < events.len; ++i) {
const uhdk_event *e = &events.ptr[i]; const uhdk_event *e = &events.ptr[i];
if (e->input_id > acknowledged)
acknowledged = e->input_id;
switch (e->type) { switch (e->type) {
case UHDK_EVENT_KEY: { case UHDK_EVENT_KEY: {
const uhdk_key_event *k = e->data; const uhdk_key_event *k = e->data;
if (k->physical_key == 4 && k->logical_key == 'a' && k->pressed) if (k->physical_key == 4 && k->logical_key == 'a' && k->pressed)
key++; key++;
if (k->physical_key == 224)
assert(!!(k->modifiers & UHDK_MOD_CONTROL) == !!k->pressed);
if (k->logical_key == 'A' && k->pressed)
assert(!(k->modifiers & UHDK_MOD_SHIFT));
if (k->repeat) if (k->repeat)
repeat++; repeat++;
break; break;
@ -241,6 +277,22 @@ static void input(uhdk_app *app) {
preedit++; preedit++;
break; break;
} }
case UHDK_EVENT_POINTER_BUTTON:
if (((const uhdk_pointer_event *)e->data)->pressed)
OK(uhdk_window_set_pointer_mode_async(
w, UHDK_POINTER_CAPTURE, &capture_request
));
break;
case UHDK_EVENT_POINTER_MODE_CHANGED:
if (((const uhdk_pointer_mode_event *)e->data)->mode == UHDK_POINTER_CAPTURE)
captured++;
else
released++;
break;
case UHDK_EVENT_REQUEST_DONE:
if (((const uhdk_request_result *)e->data)->id == capture_request)
OK(((const uhdk_request_result *)e->data)->status);
break;
case UHDK_EVENT_POINTER_MOVE: { case UHDK_EVENT_POINTER_MOVE: {
const uhdk_pointer_event *p = e->data; const uhdk_pointer_event *p = e->data;
if (p->position.x == 12 && p->position.y == 18) if (p->position.x == 12 && p->position.y == 18)
@ -263,6 +315,13 @@ static void input(uhdk_app *app) {
break; break;
} }
} }
if (repeat && !published) {
state.surrounding_text = text("abcd");
state.revision = 78;
state.acknowledged_input_id = acknowledged;
OK(uhdk_window_set_text_state(w, &state));
published = 1;
}
} }
fprintf( fprintf(
stderr, stderr,
@ -286,6 +345,17 @@ static void input(uhdk_app *app) {
OK(r.status); OK(r.status);
const uhdk_text *t = r.data; const uhdk_text *t = r.data;
assert(t->len == 11 && memcmp(t->ptr, "hello café", 11) == 0); assert(t->len == 11 && memcmp(t->ptr, "hello café", 11) == 0);
OK(uhdk_clipboard_read_text_async(app, 100, &id));
uhdk_event_list pause;
OK(uhdk_app_poll_events(app, 100000000, &pause));
OK(uhdk_request_cancel(app, id));
assert(wait_result(app, id).status == UHDK_STATUS_CANCELLED);
OK(uhdk_clipboard_read_text_async(app, 100, &id));
assert(wait_result(app, id).status == UHDK_STATUS_INVALID_ARGUMENT);
OK(uhdk_clipboard_read_text_async(app, 0, &id));
r = wait_result(app, id);
OK(r.status);
assert(((const uhdk_text *)r.data)->len == 0);
OK(uhdk_clipboard_write_text_async(app, text("local copy"), &id)); OK(uhdk_clipboard_write_text_async(app, text("local copy"), &id));
OK(wait_result(app, id).status); OK(wait_result(app, id).status);
OK(uhdk_window_request_focus_async(w, &id)); OK(uhdk_window_request_focus_async(w, &id));

View File

@ -73,6 +73,8 @@ class Compositor:
self.tokens = False self.tokens = False
self.finished_drop = False self.finished_drop = False
self.outgoing = None self.outgoing = None
self.receives = 0
self.held = []
def event(self, obj, name, *values): def event(self, obj, name, *values):
interface = INTERFACES[self.objects[obj]] interface = INTERFACES[self.objects[obj]]
@ -176,7 +178,15 @@ class Compositor:
elif iface == 'wl_data_offer' and name == 'receive': elif iface == 'wl_data_offer' and name == 'receive':
mime, fd = args mime, fd = args
assert not fcntl.fcntl(fd, fcntl.F_GETFL) & os.O_NONBLOCK 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()) if mime == 'text/uri-list':
payload = b'file:///tmp/drop%20file\r\n'
else:
self.receives += 1
if self.receives == 3:
self.held.append(fd)
return
payload = b'\xff' if self.receives == 4 else b'' if self.receives == 5 else 'hello café'.encode()
os.write(fd, payload)
os.close(fd) os.close(fd)
elif iface == 'wl_data_offer' and name == 'finish': elif iface == 'wl_data_offer' and name == 'finish':
self.finished_drop = True self.finished_drop = True
@ -207,6 +217,7 @@ class Compositor:
self.event(keyboard, 'key', 203, 3, 48, 1) self.event(keyboard, 'key', 203, 3, 48, 1)
self.event(pointer, 'enter', 204, surface, 10 * 256, 10 * 256) self.event(pointer, 'enter', 204, surface, 10 * 256, 10 * 256)
self.event(pointer, 'motion', 4, 12 * 256, 18 * 256) self.event(pointer, 'motion', 4, 12 * 256, 18 * 256)
self.event(pointer, 'button', 204, 4, 0x110, 1)
self.event(pointer, 'axis_source', 0) self.event(pointer, 'axis_source', 0)
self.event(pointer, 'axis', 5, 0, 5 * 256) self.event(pointer, 'axis', 5, 0, 5 * 256)
self.event(pointer, 'axis_value120', 0, 60) self.event(pointer, 'axis_value120', 0, 60)
@ -225,15 +236,29 @@ class Compositor:
text = self.ids.get('zwp_text_input_v3') text = self.ids.get('zwp_text_input_v3')
if text and self.surfaces and not getattr(self, 'text_entered', False): if text and self.surfaces and not getattr(self, 'text_entered', False):
self.text_entered = True self.text_entered = True
self.event(text, 'enter', next(iter(self.surfaces))) surface = next(iter(self.surfaces))
self.event(text, 'enter', surface)
if not self.inject:
keyboard = self.ids['wl_keyboard']
self.event(keyboard, 'enter', 190, surface, b'')
self.event(keyboard, 'key', 191, 1, 30, 1)
self.event(keyboard, 'key', 192, 2, 30, 0)
if self.later and time.monotonic() >= self.later[0]: if self.later and time.monotonic() >= self.later[0]:
_, text, serial = self.later; self.later = None _, text, serial = self.later; self.later = None
assert self.text_serial > serial, (self.text_serial, serial)
self.event(self.ids['wl_pointer'], 'button', 206, 6, 0x110, 0)
keyboard = self.ids['wl_keyboard'] keyboard = self.ids['wl_keyboard']
self.event(keyboard, 'key', 206, 6, 48, 0) self.event(keyboard, 'key', 206, 6, 48, 0)
self.event(keyboard, 'key', 207, 7, 29, 1)
self.event(keyboard, 'modifiers', 207, 4, 0, 0, 0) self.event(keyboard, 'modifiers', 207, 4, 0, 0, 0)
self.event(keyboard, 'key', 208, 7, 46, 1) self.event(keyboard, 'key', 208, 7, 46, 1)
self.event(keyboard, 'key', 209, 8, 46, 0) self.event(keyboard, 'key', 209, 8, 46, 0)
self.event(keyboard, 'key', 210, 9, 29, 0)
self.event(keyboard, 'modifiers', 210, 0, 0, 0, 0) self.event(keyboard, 'modifiers', 210, 0, 0, 0, 0)
self.event(keyboard, 'modifiers', 210, 0, 1, 0, 0)
self.event(keyboard, 'key', 210, 9, 30, 1)
self.event(keyboard, 'modifiers', 210, 0, 0, 0, 0)
self.event(keyboard, 'key', 210, 9, 30, 0)
self.event(keyboard, 'key', 211, 9, 40, 1) self.event(keyboard, 'key', 211, 9, 40, 1)
self.event(keyboard, 'key', 212, 10, 40, 0) self.event(keyboard, 'key', 212, 10, 40, 0)
self.event(keyboard, 'key', 213, 11, 18, 1) self.event(keyboard, 'key', 213, 11, 18, 1)
@ -266,7 +291,7 @@ def run(binary, mode, env=None):
finally: finally:
if process.poll() is None: process.kill(); process.wait() if process.poll() is None: process.kill(); process.wait()
server.close() server.close()
for fd in compositor.fds: os.close(fd) for fd in compositor.fds + compositor.held: os.close(fd)
if compositor.outgoing is not None: os.close(compositor.outgoing) if compositor.outgoing is not None: os.close(compositor.outgoing)