// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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)); }