// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Desktop notifications, through the notification server on the session //! bus (the Desktop Notifications Specification's //! `org.freedesktop.Notifications`), called with the client zig-dbus-service //! generates from `dbus/org.freedesktop.Notifications.xml`. //! //! A notification is only ever a report: nothing here fails the work being //! reported on. A session with no bus, or no notification server, gets its //! messages on stderr instead. const Notifier = @This(); const std = @import("std"); const Io = std.Io; const Allocator = std.mem.Allocator; const mdbus = @import("mdbus"); const dbus = @import("dbus_service"); const Notifications = @import("dbus_interfaces").Notifications; pub const app_name = "Paperless"; pub const icon = "document-send"; gpa: Allocator, connection: ?mdbus.Connection, pub const Urgency = enum(u8) { low = 0, normal = 1, critical = 2 }; /// Connects to the session bus at `address` (`DBUS_SESSION_BUS_ADDRESS`). /// A null address, or one that cannot be reached, leaves the notifier /// writing to stderr. pub fn init(io: Io, gpa: Allocator, address: ?[]const u8) Notifier { const connection: ?mdbus.Connection = if (address) |a| mdbus.Connection.connectAddress(io, gpa, a) catch |err| blk: { std.log.warn("cannot reach the session bus, notifications go to stderr: {t}", .{err}); break :blk null; } else null; return .{ .gpa = gpa, .connection = connection }; } pub fn deinit(self: *Notifier) void { if (self.connection) |*c| c.deinit(); self.* = undefined; } /// The session bus's address, as `connectSession` would find it: the /// environment's `DBUS_SESSION_BUS_ADDRESS`, else the socket in /// `$XDG_RUNTIME_DIR`. Allocated in `arena`. pub fn sessionBusAddress(arena: Allocator, bus_address: ?[]const u8, runtime_dir: ?[]const u8) Allocator.Error!?[]const u8 { if (bus_address) |a| if (a.len > 0) return a; if (runtime_dir) |d| if (d.len > 0) return try std.fmt.allocPrint(arena, "unix:path={s}/bus", .{d}); return null; } /// Shows a notification, or replaces the one `replaces` names, and answers /// with its identifier, to replace it in turn. Zero means there is none, /// and is always a safe thing to pass back. pub fn send(self: *Notifier, replaces: u32, urgency: Urgency, summary: []const u8, body: []const u8) u32 { if (self.connection) |*connection| { if (self.notify(connection, replaces, urgency, summary, body)) |id| return id else |err| { std.log.warn("notification not shown: {t}", .{err}); } } std.log.info("{s}: {s}", .{ summary, body }); return 0; } fn notify(self: *Notifier, connection: *mdbus.Connection, replaces: u32, urgency: Urgency, summary: []const u8, body: []const u8) !u32 { var arena_state: std.heap.ArenaAllocator = .init(self.gpa); defer arena_state.deinit(); var client: Notifications.Client = .{ .connection = connection, .destination = "org.freedesktop.Notifications", .path = "/org/freedesktop/Notifications", .timeout_ms = 5_000, }; const level: u8 = @backingInt(urgency); const hints = [_]dbus.Vardict.Entry{ .{ .key = "urgency", .value = .of("y", &level) }, }; const out = try client.call(Notifications.Notify, arena_state.allocator(), .{ .app_name = app_name, .replaces_id = replaces, .app_icon = icon, .summary = summary, .body = body, .actions = &.{}, .hints = .{ .entries = &hints }, // The server's own default. .expire_timeout = -1, }); return out.id; } const testing = std.testing; test sessionBusAddress { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); try testing.expectEqualStrings("unix:path=/x", (try sessionBusAddress(a, "unix:path=/x", "/run/user/1")).?); try testing.expectEqualStrings("unix:path=/run/user/1/bus", (try sessionBusAddress(a, null, "/run/user/1")).?); try testing.expectEqualStrings("unix:path=/run/user/1/bus", (try sessionBusAddress(a, "", "/run/user/1")).?); try testing.expectEqual(null, try sessionBusAddress(a, null, null)); } test "a notifier with no bus does not fail" { var notifier: Notifier = .init(testing.io, testing.allocator, null); defer notifier.deinit(); try testing.expectEqual(0, notifier.send(0, .normal, "summary", "body")); }