Something went wrong. Try again.
A GPU terminal emulator for Wayland and Windows on libghostty-vt, drawing with Vulkan, running a program on a pty or a serial port.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The XDG desktop portal: the desktop's settings, and word when they//! change; and desktop notifications, and word when one is clicked.//!//! `org.freedesktop.portal.Settings` on `org.freedesktop.portal.Desktop`//! is read once, whole, when harrier starts, and then watched: its//! `SettingChanged` signal updates the snapshot here, on a thread of its//! own with a connection of its own, and wakes the main loop, which//! evaluates the configuration again against the new settings. With no//! portal — a bare compositor, a test machine — there are no settings, and//! the configuration sees the defaults.//!//! A portal that is there but slow to answer — being started at login, on//! a slow machine — is not the same as none. harrier does not wait for it://! it starts with the defaults, and the thread goes on asking until the//! portal answers. The settings then arrive as a change does, and what the//! main thread queued meanwhile — the global shortcuts, above all — is sent//! only then, so that it is not lost to a portal that could not yet hear it.//!//! Notifications go through `org.freedesktop.portal.Notification`, on a//! second connection, opened for the first one. The harrier that owns//! `dev.jcollie.harrier` says so on it with//! `org.freedesktop.host.portal.Registry`, as GTK does — it is not in a//! sandbox the portal could tell it by, and registering has to come before//! anything else on a connection, which is why this one is opened only//! once it is known. The desktop then shows the notifications as//! harrier's, and a click activates the application: `focus-pane`, through//! `Activation`. Any other harrier stays anonymous, so that a click on its//! notification is not sent to the one that owns the name, and comes back//! instead as `ActionInvoked` on the connection that sent it.//!//! The main thread queues what to send, and the thread sends it, so that//! one thread alone uses the connections; a click it hears of is queued for//! the main loop.//!//! A notification is named for the pane it came from, so a pane has one at//! a time, the newest; clicking it shows that pane.//!//! Global shortcuts, where the compositor has no hotkey protocol of its//! own, go through `org.freedesktop.portal.GlobalShortcuts` on that//! connection too: a session is made, its shortcuts bound in it — each//! answered by `Response` on the request object the call returned — and//! each fires as `Activated`, with the shortcut's ID and a token to raise//! a window with. The desktop may let the user choose the keys; harrier's//! are only what it would prefer. Binding them again ends the session and//! makes another.//!//! Opening a link goes on the same connection, through//! `org.freedesktop.portal.OpenURI`, so that the desktop asks on harrier's//! behalf: `OpenURI` for a URI, and `OpenFile`, with the file itself open,//! for a local file, which `OpenURI` refuses.
const std = @import("std");const Allocator = std.mem.Allocator;const Io = std.Io;const linux = std.os.linux;const dbus = @import("dbus_service");const mdbus = @import("mdbus");const interfaces = @import("dbus_interfaces");const PortalSettings = interfaces.Settings;const PortalNotification = interfaces.Notification;const Registry = interfaces.Registry;const OpenURI = interfaces.OpenURI;const GlobalShortcuts = interfaces.GlobalShortcuts;const PortalRequest = interfaces.Request;const PortalSession = interfaces.Session;
const Wake = @import("os/Wake.zig");const settings = @import("settings.zig");
const Portal = @This();
const log = std.log.scoped(.portal);
pub const bus_name = "org.freedesktop.portal.Desktop";pub const object_path = "/org/freedesktop/portal/desktop";const interface = "org.freedesktop.portal.Settings";pub const app_id = "dev.jcollie.harrier";/// The application action a click on a notification activates, with the/// pane's ID as its target; see `Activation.Server.activateAction`.pub const focus_action = "focus-pane";
gpa: Allocator,io: Io,connection: mdbus.Connection,/// The session bus's address, for opening `app_connection`.address: []u8,/// The connection notifications go through; the thread's alone.app_connection: ?mdbus.Connection = null,/// `app_connection` is registered as harrier's; the thread's alone.registered: bool = false,/// This harrier owns `dev.jcollie.harrier`; see `setOwnsName`.owns_name: std.atomic.Value(bool) = .init(false),thread: ?std.Thread = null,/// The portal has answered `ReadAll`, or said it never will; until then/// the thread asks again, and sends nothing. The thread's alone once it/// has started.answered: bool = true,stopping: std.atomic.Value(bool) = .init(false),/// Stops the thread's wait.stop_wake: Wake,/// Wakes the main loop when a setting changes. Poll `wake.handle`.wake: Wake,
mutex: Io.Mutex = .init,/// Under `mutex`.current: settings.Settings,/// Counts changes, under `mutex`, so that the main loop can tell it has/// seen the latest.generation: u64 = 0,/// Notifications to send and withdraw, for the thread, under `mutex`.outbox: std.ArrayList(Outgoing) = .empty,/// Notifications clicked, for the main loop, under `mutex`.clicked: std.ArrayList(Click) = .empty,/// Global shortcuts fired, for the main loop, under `mutex`.fired: std.ArrayList(Fired) = .empty,
/// The global shortcuts session, and the shortcuts to bind in it; the/// thread's alone.shortcut_session: ?[]u8 = null,shortcuts_wanted: []Shortcut = &.{},/// Calls the portal will answer with `Response`, by the request object/// each returned; the thread's alone.requests: std.StringHashMapUnmanaged(RequestKind) = .empty,/// Tells handle tokens apart.next_token: u32 = 0,
const RequestKind = enum { create_session, bind_shortcuts };
/// A global shortcut to bind: its ID, what it does, and the trigger/// harrier would like, as the XDG shortcuts specification writes them.pub const Shortcut = struct { id: []const u8, description: []const u8, trigger: []const u8,
fn dupe(s: Shortcut, gpa: Allocator) Allocator.Error!Shortcut { const id = try gpa.dupe(u8, s.id); errdefer gpa.free(id); const d = try gpa.dupe(u8, s.description); errdefer gpa.free(d); return .{ .id = id, .description = d, .trigger = try gpa.dupe(u8, s.trigger) }; }
fn free(s: Shortcut, gpa: Allocator) void { gpa.free(s.id); gpa.free(s.description); gpa.free(s.trigger); }};
/// A global shortcut that fired, by its ID, and the activation token to/// raise a window with, when the portal gave one. Both owned.pub const Fired = struct { id: []u8, activation_token: ?[]u8,
pub fn deinit(f: Fired, gpa: Allocator) void { gpa.free(f.id); if (f.activation_token) |t| gpa.free(t); }};
/// Something for the thread to say to the portal.const Outgoing = union(enum) { add: struct { pane: u64, title: []u8, body: []u8 }, remove: u64, open_uri: struct { uri: []u8, token: ?[]u8 }, /// `fd` is the file, open; `name` is for saying which it was. open_file: struct { fd: linux.fd_t, name: []u8, token: ?[]u8 }, /// Every global shortcut, replacing those bound before. bind_shortcuts: []Shortcut,
fn deinit(o: Outgoing, gpa: Allocator) void { switch (o) { .add => |a| { gpa.free(a.title); gpa.free(a.body); }, .remove => {}, .open_uri => |u| { gpa.free(u.uri); if (u.token) |t| gpa.free(t); }, .open_file => |f| { _ = linux.close(f.fd); gpa.free(f.name); if (f.token) |t| gpa.free(t); }, .bind_shortcuts => |list| { for (list) |s| s.free(gpa); gpa.free(list); }, } }};
/// A pane whose notification was clicked, and the activation token to/// raise its window with, when the portal gave one. The token is owned.pub const Click = struct { pane: u64, activation_token: ?[]u8,};
/// Reads the settings and starts watching them. Null when there is no/// session bus or no portal on it.pub fn start(gpa: Allocator, io: Io, env: *const std.process.Environ.Map) ?*Portal { return startOrFail(gpa, io, env) catch |err| { switch (err) { // Nothing wrong: a bare compositor, a test machine. error.NoBus, error.NoPortal => log.info("no desktop portal; the configuration sees the default appearance", .{}), else => log.warn("reading the desktop's settings: {t}", .{err}), } return null; };}
fn startOrFail(gpa: Allocator, io: Io, env: *const std.process.Environ.Map) !*Portal { var addr_buf: [std.fs.max_path_bytes + 16]u8 = undefined; const address = sessionAddress(&addr_buf, env) orelse return error.NoBus;
const p = try gpa.create(Portal); errdefer gpa.destroy(p); p.* = .{ .gpa = gpa, .io = io, .connection = mdbus.Connection.connectAddress(io, gpa, address) catch return error.NoBus, .address = try gpa.dupe(u8, address), .stop_wake = try .init(), .wake = undefined, .current = .init(gpa), }; errdefer p.connection.deinit(); errdefer gpa.free(p.address); errdefer p.stop_wake.deinit(); errdefer p.current.deinit(); p.wake = try .init(); errdefer p.wake.deinit();
// Watch before reading, so that nothing changes unseen between the two. try p.connection.addMatch("type='signal',interface='" ++ interface ++ "',member='SettingChanged',path='" ++ object_path ++ "'"); p.readAll() catch |err| switch (err) { error.Timeout => { log.info("the desktop portal is slow to answer; the configuration sees the default appearance until it does", .{}); p.answered = false; }, else => return err, }; p.thread = try std.Thread.spawn(.{}, run, .{p}); return p;}
pub fn destroy(p: *Portal) void { p.stopping.store(true, .release); p.stop_wake.signal(); if (p.thread) |t| t.join(); p.connection.deinit(); if (p.app_connection) |*n| n.deinit(); p.gpa.free(p.address); p.current.deinit(); for (p.outbox.items) |o| o.deinit(p.gpa); p.outbox.deinit(p.gpa); for (p.clicked.items) |c| if (c.activation_token) |t| p.gpa.free(t); p.clicked.deinit(p.gpa); for (p.fired.items) |f| f.deinit(p.gpa); p.fired.deinit(p.gpa); if (p.shortcut_session) |s| p.gpa.free(s); for (p.shortcuts_wanted) |s| s.free(p.gpa); p.gpa.free(p.shortcuts_wanted); var keys = p.requests.keyIterator(); while (keys.next()) |k| p.gpa.free(k.*); p.requests.deinit(p.gpa); p.stop_wake.deinit(); p.wake.deinit(); p.gpa.destroy(p);}
/// A copy of the settings, and which change it is as of, if that is newer/// than `seen`.pub fn takeIfNewer(p: *Portal, gpa: Allocator, seen: u64) Allocator.Error!?struct { settings: settings.Settings, generation: u64 } { _ = p.wake.drain(); p.mutex.lockUncancelable(p.io); defer p.mutex.unlock(p.io); if (p.generation == seen) return null; return .{ .settings = try p.current.clone(gpa), .generation = p.generation };}
/// Says whether this harrier owns `dev.jcollie.harrier`, which decides how/// its notifications are shown; see the top of the file. Before the first/// notification.pub fn setOwnsName(p: *Portal, owns: bool) void { p.owns_name.store(owns, .release);}
/// Shows a notification for pane `pane`, replacing the one it last had.pub fn notify(p: *Portal, pane: u64, title: []const u8, body: []const u8) void { p.queueOut(add: { const t = p.gpa.dupe(u8, title) catch return; const b = p.gpa.dupe(u8, body) catch { p.gpa.free(t); return; }; break :add .{ .add = .{ .pane = pane, .title = t, .body = b } }; });}
/// Opens `uri` with what the desktop opens it with. `token` is an/// xdg-activation token for the application that opens it to take the/// focus with.pub fn openUri(p: *Portal, uri: []const u8, token: ?[]const u8) void { const u = p.gpa.dupe(u8, uri) catch return; const t = if (token) |x| p.gpa.dupe(u8, x) catch { p.gpa.free(u); return; } else null; p.queueOut(.{ .open_uri = .{ .uri = u, .token = t } });}
/// Opens the file `fd` is open on — `name`, for the log — with what the/// desktop opens it with. Takes `fd`, also on failure.pub fn openFile(p: *Portal, fd: linux.fd_t, name: []const u8, token: ?[]const u8) void { const n = p.gpa.dupe(u8, name) catch { _ = linux.close(fd); return; }; const t = if (token) |x| p.gpa.dupe(u8, x) catch { _ = linux.close(fd); p.gpa.free(n); return; } else null; p.queueOut(.{ .open_file = .{ .fd = fd, .name = n, .token = t } });}
/// Binds `shortcuts` as harrier's global shortcuts, in place of any bound/// before; none unbinds them all. Each firing comes from `takeFired`.pub fn bindShortcuts(p: *Portal, shortcuts: []const Shortcut) void { const list = p.gpa.alloc(Shortcut, shortcuts.len) catch return; var n: usize = 0; for (shortcuts) |s| { list[n] = s.dupe(p.gpa) catch { for (list[0..n]) |x| x.free(p.gpa); p.gpa.free(list); return; }; n += 1; } p.queueOut(.{ .bind_shortcuts = list });}
/// The next global shortcut fired, if one was. The caller frees it.pub fn takeFired(p: *Portal) ?Fired { p.mutex.lockUncancelable(p.io); defer p.mutex.unlock(p.io); if (p.fired.items.len == 0) return null; return p.fired.orderedRemove(0);}
/// Withdraws pane `pane`'s notification, if it has one showing.pub fn withdraw(p: *Portal, pane: u64) void { p.queueOut(.{ .remove = pane });}
fn queueOut(p: *Portal, o: Outgoing) void { { p.mutex.lockUncancelable(p.io); defer p.mutex.unlock(p.io); p.outbox.append(p.gpa, o) catch { o.deinit(p.gpa); return; }; } p.stop_wake.signal();}
/// The next notification clicked, if one was. The caller frees its token.pub fn takeClick(p: *Portal) ?Click { p.mutex.lockUncancelable(p.io); defer p.mutex.unlock(p.io); if (p.clicked.items.len == 0) return null; return p.clicked.orderedRemove(0);}
/// The portal's name for pane `pane`'s notification, from process `pid`./// Pane IDs are this process's own, and a click can be told of to every/// harrier watching, so the process is in it too.fn notificationId(buf: []u8, pid: linux.pid_t, pane: u64) []const u8 { return std.fmt.bufPrint(buf, "harrier-{d}-pane-{d}", .{ pid, pane }) catch unreachable;}
/// The pane a notification ID names, if it is one of process `pid`'s.fn paneOf(id: []const u8, pid: linux.pid_t) ?u64 { var buf: [32]u8 = undefined; const prefix = std.fmt.bufPrint(&buf, "harrier-{d}-pane-", .{pid}) catch unreachable; if (!std.mem.startsWith(u8, id, prefix)) return null; return std.fmt.parseInt(u64, id[prefix.len..], 10) catch null;}
/// The connection notifications go through, opened the first time: as/// harrier's own when it owns the name, and anonymous otherwise.fn appConnection(p: *Portal) !*mdbus.Connection { if (p.app_connection) |*n| return n; var c = try mdbus.Connection.connectAddress(p.io, p.gpa, p.address); errdefer c.deinit(); p.registered = p.owns_name.load(.acquire) and register(p.gpa, &c); if (!p.registered) log.info("notifications are shown anonymously, and clicks come back through the portal", .{}); try c.addMatch("type='signal',interface='" ++ PortalNotification.name ++ "',member='ActionInvoked',path='" ++ object_path ++ "'"); try c.addMatch("type='signal',interface='" ++ GlobalShortcuts.name ++ "',member='Activated',path='" ++ object_path ++ "'"); // Every answer to a request, told apart by the object each call // returned: the connection's own name, which the object's path is // made from, is not to be had. try c.addMatch("type='signal',interface='" ++ PortalRequest.name ++ "',member='Response',path_namespace='" ++ object_path ++ "/request'"); p.app_connection = c; return &p.app_connection.?;}
/// Tells the portal which application this is. An older portal has no/// registry, and then guesses, from the systemd unit harrier runs in.fn register(gpa: Allocator, connection: *mdbus.Connection) bool { var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); var client: Registry.Client = .{ .connection = connection, .destination = bus_name, .path = object_path, .timeout_ms = 5_000, }; _ = client.call(Registry.Register, arena_state.allocator(), .{ .app_id = app_id, .options = .{} }) catch |err| { const why = if (client.failure) |f| f.name else @errorName(err); log.info("the portal has no registry ({s}); it will name harrier as it can", .{why}); return false; }; log.info("notifications are shown as {s}'s", .{app_id}); return true;}
/// Sends what the main thread queued. On the thread.fn sendQueued(p: *Portal) void { while (true) { const o = blk: { p.mutex.lockUncancelable(p.io); defer p.mutex.unlock(p.io); if (p.outbox.items.len == 0) return; break :blk p.outbox.orderedRemove(0); }; defer o.deinit(p.gpa); p.send(o) catch |err| log.warn("a notification: {t}", .{err}); }}
fn send(p: *Portal, o: Outgoing) !void { var arena_state: std.heap.ArenaAllocator = .init(p.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var client: PortalNotification.Client = .{ .connection = try p.appConnection(), .destination = bus_name, .path = object_path, .timeout_ms = 5_000, }; var id_buf: [64]u8 = undefined; switch (o) { .add => |a| { const id = notificationId(&id_buf, linux.getpid(), a.pane); // An application action for the harrier the desktop can activate // by name. For any other, a plain action, which the portal // answers with `ActionInvoked`: the GTK backend tries to // activate even an anonymous application for an `app.` one, and // fails. const action: []const u8 = if (p.registered) "app." ++ focus_action else focus_action; const priority: []const u8 = "normal"; const target: u64 = a.pane; const title: []const u8 = a.title; const body: []const u8 = a.body; const entries = [_]dbus.Vardict.Entry{ .{ .key = "title", .value = .of("s", &title) }, .{ .key = "body", .value = .of("s", &body) }, .{ .key = "priority", .value = .of("s", &priority) }, .{ .key = "default-action", .value = .of("s", &action) }, .{ .key = "default-action-target", .value = .of("t", &target) }, }; _ = client.call(PortalNotification.AddNotification, arena, .{ .id = id, .notification = .{ .entries = &entries } }) catch |err| { if (client.failure) |f| log.warn("showing a notification: {s}: {s}", .{ f.name, f.message }); return err; }; }, .open_uri => |u| { var opener: OpenURI.Client = .{ .connection = try p.appConnection(), .destination = bus_name, .path = object_path, .timeout_ms = 5_000 }; const token: []const u8 = u.token orelse ""; const entries = [_]dbus.Vardict.Entry{.{ .key = "activation_token", .value = .of("s", &token) }}; const options: dbus.Vardict = .{ .entries = if (u.token != null) &entries else &.{} }; _ = opener.call(OpenURI.OpenURI, arena, .{ .parent_window = "", .uri = u.uri, .options = options }) catch |err| { if (opener.failure) |f| log.warn("opening {s}: {s}: {s}", .{ u.uri, f.name, f.message }); return err; }; log.info("opening {s}", .{u.uri}); }, .open_file => |f| { // The client's `call` sends no descriptors, so this is the // same call, by hand: the descriptor goes with the message, and // the argument is its index among them. const conn = try p.appConnection(); const token: []const u8 = f.token orelse ""; const entries = [_]dbus.Vardict.Entry{.{ .key = "activation_token", .value = .of("s", &token) }}; const options: dbus.Vardict = .{ .entries = if (f.token != null) &entries else &.{} }; var body: mdbus.wire.Encoder = .init(arena); try dbus.codec.encodeArgs(OpenURI.OpenFile.in_signature, OpenURI.OpenFile.In, .{ .parent_window = "", .fd = .{ .index = 0 }, .options = options }, &body); var reply = try conn.call(.{ .destination = bus_name, .path = object_path, .interface = OpenURI.name, .member = OpenURI.OpenFile.name, }, OpenURI.OpenFile.in_signature, body.bytes(), &.{f.fd}, 5_000); defer reply.deinit(); if (reply.messageType() != .method_return) { log.warn("opening {s}: {s}", .{ f.name, reply.header.error_name orelse "failed" }); return error.RemoteError; } log.info("opening {s}", .{f.name}); }, .bind_shortcuts => |list| try p.bindShortcutsNow(list), .remove => |pane| { _ = client.call(PortalNotification.RemoveNotification, arena, .{ .id = notificationId(&id_buf, linux.getpid(), pane) }) catch |err| { if (client.failure) |f| log.info("withdrawing a notification: {s}: {s}", .{ f.name, f.message }); return err; }; }, }}
/// Ends the session there is, and starts one for `list`, whose shortcuts/// are bound when the portal answers. On the thread.fn bindShortcutsNow(p: *Portal, list: []const Shortcut) !void { for (p.shortcuts_wanted) |s| s.free(p.gpa); p.gpa.free(p.shortcuts_wanted); p.shortcuts_wanted = &.{}; const wanted = try p.gpa.alloc(Shortcut, list.len); var n: usize = 0; errdefer { for (wanted[0..n]) |s| s.free(p.gpa); p.gpa.free(wanted); } for (list) |s| { wanted[n] = try s.dupe(p.gpa); n += 1; } p.shortcuts_wanted = wanted;
const conn = try p.appConnection(); if (p.shortcut_session) |old| { defer p.gpa.free(old); p.shortcut_session = null; var reply = conn.call(.{ .destination = bus_name, .path = old, .interface = PortalSession.name, .member = "Close" }, "", "", &.{}, 5_000) catch null; if (reply) |*r| r.deinit(); } if (wanted.len == 0) return;
var arena_state: std.heap.ArenaAllocator = .init(p.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var client: GlobalShortcuts.Client = .{ .connection = conn, .destination = bus_name, .path = object_path, .timeout_ms = 5_000 }; const token = try p.handleToken(arena); const entries = [_]dbus.Vardict.Entry{ .{ .key = "handle_token", .value = .of("s", &token) }, .{ .key = "session_handle_token", .value = .of("s", &token) }, }; const out = client.call(GlobalShortcuts.CreateSession, arena, .{ .options = .{ .entries = &entries } }) catch |err| { if (client.failure) |f| log.warn("global shortcuts: {s}: {s}", .{ f.name, f.message }); return err; }; try p.expect(out.handle.path, .create_session);}
/// A token for a request's or a session's object path: `harrier_N`.fn handleToken(p: *Portal, arena: Allocator) ![]const u8 { p.next_token += 1; return std.fmt.allocPrint(arena, "harrier_{d}_{d}", .{ linux.getpid(), p.next_token });}
fn expect(p: *Portal, request: []const u8, kind: RequestKind) !void { const key = try p.gpa.dupe(u8, request); errdefer p.gpa.free(key); try p.requests.put(p.gpa, key, kind);}
/// The portal answered a request: the session made, whose shortcuts are/// then bound; or the shortcuts bound. On the thread.fn response(p: *Portal, m: *const mdbus.wire.Message) !void { if (!std.mem.eql(u8, m.bodySignature(), PortalRequest.Response.signature)) return; const kv = p.requests.fetchRemove(m.header.path orelse return) orelse return; defer p.gpa.free(kv.key); var arena_state: std.heap.ArenaAllocator = .init(p.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var dec = m.bodyDecoder(); const args = try dbus.codec.decodeArgs(PortalRequest.Response.signature, PortalRequest.Response.Args, &dec, arena); switch (kv.value) { .create_session => { if (args.response != 0) return log.warn("global shortcuts: the portal made no session ({d})", .{args.response}); const session = (try args.results.get("s", []const u8, "session_handle", arena)) orelse ((try args.results.get("o", dbus.ObjectPath, "session_handle", arena)) orelse return).path; p.shortcut_session = try p.gpa.dupe(u8, session); const list = try arena.alloc(struct { []const u8, dbus.Vardict }, p.shortcuts_wanted.len); for (p.shortcuts_wanted, list) |*s, *l| { const e = try arena.alloc(dbus.Vardict.Entry, 2); e[0] = .{ .key = "description", .value = .of("s", &s.description) }; e[1] = .{ .key = "preferred_trigger", .value = .of("s", &s.trigger) }; l.* = .{ s.id, .{ .entries = e } }; } const token = try p.handleToken(arena); const entries = [_]dbus.Vardict.Entry{.{ .key = "handle_token", .value = .of("s", &token) }}; log.info("global shortcuts: asking the desktop to bind {d}", .{list.len}); var client: GlobalShortcuts.Client = .{ .connection = try p.appConnection(), .destination = bus_name, .path = object_path, .timeout_ms = 5_000 }; const out = client.call(GlobalShortcuts.BindShortcuts, arena, .{ .session_handle = .{ .path = session }, .shortcuts = list, .parent_window = "", .options = .{ .entries = &entries }, }) catch |err| { if (client.failure) |f| log.warn("global shortcuts: {s}: {s}", .{ f.name, f.message }); return err; }; try p.expect(out.request_handle.path, .bind_shortcuts); }, .bind_shortcuts => { if (args.response != 0) return log.warn("global shortcuts: not bound ({s})", .{if (args.response == 1) "the user said no" else "the portal failed"}); const bound = (try args.results.get("a(sa{sv})", []const struct { []const u8, dbus.Vardict }, "shortcuts", arena)) orelse &.{}; for (bound) |b| { const how = (try b[1].get("s", []const u8, "trigger_description", arena)) orelse "no trigger"; log.info("global shortcut {s}: {s}", .{ b[0], how }); } }, }}
/// A global shortcut fired, in the session there is now. On the thread.fn activated(p: *Portal, m: *const mdbus.wire.Message) !void { if (!std.mem.eql(u8, m.bodySignature(), GlobalShortcuts.Activated.signature)) return; var arena_state: std.heap.ArenaAllocator = .init(p.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var dec = m.bodyDecoder(); const args = try dbus.codec.decodeArgs(GlobalShortcuts.Activated.signature, GlobalShortcuts.Activated.Args, &dec, arena); const session = p.shortcut_session orelse return; if (!std.mem.eql(u8, args.session_handle.path, session)) return; const id = try p.gpa.dupe(u8, args.shortcut_id); errdefer p.gpa.free(id); var token: ?[]u8 = null; if (try args.options.get("s", []const u8, "activation_token", arena)) |t| token = try p.gpa.dupe(u8, t); errdefer if (token) |t| p.gpa.free(t); { p.mutex.lockUncancelable(p.io); defer p.mutex.unlock(p.io); try p.fired.append(p.gpa, .{ .id = id, .activation_token = token }); } p.wake.signal();}
fn readAll(p: *Portal) !void { var arena_state: std.heap.ArenaAllocator = .init(p.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var client: PortalSettings.Client = .{ .connection = &p.connection, .destination = bus_name, .path = object_path, // The bus may have to start the portal. .timeout_ms = 5_000, }; const out = client.call(PortalSettings.ReadAll, arena, .{ .namespaces = &.{} }) catch |err| { if (client.failure) |f| { // No portal installed, or none that will start. if (std.mem.eql(u8, f.name, "org.freedesktop.DBus.Error.ServiceUnknown") or std.mem.eql(u8, f.name, "org.freedesktop.DBus.Error.NameHasNoOwner")) return error.NoPortal; log.warn("reading the desktop's settings: {s}: {s}", .{ f.name, f.message }); } return err; }; var count: usize = 0; { p.mutex.lockUncancelable(p.io); defer p.mutex.unlock(p.io); for (out.value) |ns| for (ns.value.entries) |e| { const v = (try valueOf(arena, e.value)) orelse continue; _ = try p.current.put(ns.key, e.key, v); count += 1; }; p.generation += 1; } log.info("{d} desktop settings from the portal", .{count});}
/// Asks a portal that was slow to answer for the settings again, and says/// whether it has answered, or said it never will. On the thread; each/// try waits as long as `readAll` does, so stopping waits for at most one.fn readAllAgain(p: *Portal) bool { p.readAll() catch |err| switch (err) { error.Timeout => return false, error.NoPortal => { log.info("no desktop portal after all; the configuration sees the default appearance", .{}); return true; }, else => { log.warn("reading the desktop's settings: {t}", .{err}); return true; }, }; p.wake.signal(); return true;}
fn run(p: *Portal) void { p.loop() catch |err| log.info("no longer watching the desktop settings: {t}", .{err});}
fn loop(p: *Portal) !void { while (!p.stopping.load(.acquire)) { if (!p.answered) { p.answered = p.readAllAgain(); continue; } _ = p.stop_wake.drain(); p.sendQueued(); while (try p.connection.nextMessage()) |message| { var m = message; defer m.deinit(); p.handle(&m) catch |err| log.warn("a setting that changed: {t}", .{err}); } if (p.app_connection) |*n| while (try n.nextMessage()) |message| { var m = message; defer m.deinit(); p.handle(&m) catch |err| log.warn("a notification clicked: {t}", .{err}); }; var fds = [_]linux.pollfd{ .{ .fd = p.connection.getFd(), .events = linux.POLL.IN, .revents = 0 }, .{ .fd = p.stop_wake.handle, .events = linux.POLL.IN, .revents = 0 }, .{ .fd = if (p.app_connection) |n| n.getFd() else -1, .events = linux.POLL.IN, .revents = 0 }, }; const rc = linux.poll(&fds, fds.len, -1); if (linux.errno(rc) == .INTR) continue; if (fds[0].revents & (linux.POLL.HUP | linux.POLL.ERR) != 0 and fds[0].revents & linux.POLL.IN == 0) return error.ConnectionClosed; }}
fn handle(p: *Portal, m: *const mdbus.wire.Message) !void { if (m.messageType() != .signal) return; const h = m.header; if (std.mem.eql(u8, h.interface orelse "", PortalNotification.name) and std.mem.eql(u8, h.member orelse "", "ActionInvoked")) { return p.actionInvoked(m); } if (std.mem.eql(u8, h.interface orelse "", GlobalShortcuts.name) and std.mem.eql(u8, h.member orelse "", "Activated")) { return p.activated(m); } if (std.mem.eql(u8, h.interface orelse "", PortalRequest.name) and std.mem.eql(u8, h.member orelse "", "Response")) { return p.response(m); } if (!std.mem.eql(u8, h.interface orelse "", interface) or !std.mem.eql(u8, h.member orelse "", "SettingChanged")) return; if (!std.mem.eql(u8, m.bodySignature(), "ssv")) return; var arena_state: std.heap.ArenaAllocator = .init(p.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var dec = m.bodyDecoder(); const args = try dbus.codec.decodeArgs("ssv", PortalSettings.SettingChanged.Args, &dec, arena); const v = (try valueOf(arena, args.value)) orelse return; const changed = blk: { p.mutex.lockUncancelable(p.io); defer p.mutex.unlock(p.io); const changed = try p.current.put(args.namespace, args.key, v); if (changed) p.generation += 1; break :blk changed; }; if (changed) { log.info("desktop setting {s} {s} changed", .{ args.namespace, args.key }); p.wake.signal(); }}
/// A notification was clicked: whatever the action, the pane it came from/// is what to show. Since version 2 the parameter's second value is the/// platform's data, with an activation token for raising the window.fn actionInvoked(p: *Portal, m: *const mdbus.wire.Message) !void { if (!std.mem.eql(u8, m.bodySignature(), PortalNotification.ActionInvoked.signature)) return; var arena_state: std.heap.ArenaAllocator = .init(p.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var dec = m.bodyDecoder(); const args = try dbus.codec.decodeArgs(PortalNotification.ActionInvoked.signature, PortalNotification.ActionInvoked.Args, &dec, arena); const pane = paneOf(args.id, linux.getpid()) orelse return; var token: ?[]u8 = null; if (args.parameter.len >= 2) if (try args.parameter[1].get("a{sv}", dbus.Vardict, arena)) |data| { if (try data.get("s", []const u8, "activation-token", arena)) |t| token = try p.gpa.dupe(u8, t); }; errdefer if (token) |t| p.gpa.free(t); { p.mutex.lockUncancelable(p.io); defer p.mutex.unlock(p.io); try p.clicked.append(p.gpa, .{ .pane = pane, .activation_token = token }); } log.info("pane {d}'s notification was clicked", .{pane}); p.wake.signal();}
const valueOf = @import("aerie").desktop.Watcher.valueOf;
fn sessionAddress(buf: []u8, env: *const std.process.Environ.Map) ?[]const u8 { if (env.get("DBUS_SESSION_BUS_ADDRESS")) |a| return a; const runtime = env.get("XDG_RUNTIME_DIR") orelse return null; return std.fmt.bufPrint(buf, "unix:path={s}/bus", .{runtime}) catch null;}
test "a notification is named for its pane and its process" { var buf: [64]u8 = undefined; const id = notificationId(&buf, 1234, 42); try std.testing.expectEqualStrings("harrier-1234-pane-42", id); try std.testing.expectEqual(@as(?u64, 42), paneOf(id, 1234)); // Another harrier's pane 42 is not this one's. try std.testing.expectEqual(@as(?u64, null), paneOf(id, 123)); try std.testing.expectEqual(@as(?u64, null), paneOf("harrier-12345-pane-42", 1234)); try std.testing.expectEqual(@as(?u64, null), paneOf("someone-else", 1234)); try std.testing.expectEqual(@as(?u64, null), paneOf("harrier-1234-pane-x", 1234));}