// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The primary and the hand-off to it, through the session bus. //! //! The primary owns `dev.jcollie.harrier` and serves //! `org.freedesktop.Application` at `/dev/jcollie/harrier`, which is what a //! launcher calls for an application whose desktop file says //! `DBusActivatable=true`. Every later launch hands off to it and exits: it //! calls `ActivateAction("new-window", …)` with its request — what the //! window should run, where, and with which environment — and the primary //! opens the window. With nothing owning the name, the bus starts the //! service (`harrier --service`) to take the call; without even that, the //! launch keeps its request and becomes the primary itself. //! //! `/dev/jcollie/harrier/SearchProvider` serves GNOME Shell's search, //! `org.gnome.Shell.SearchProvider2`, which finds open panes by title and //! directory. The main loop publishes what panes there are; the search reads //! that, and an activated result goes back through the queue. //! //! The same object serves `org.freedesktop.Terminal1`, the Terminal Intent: //! what a launcher calls to run a terminal program — one whose desktop file //! says `Terminal=true` — in the terminal the user chose, rather than //! guessing at which terminal is installed and how its command line works. //! //! `/dev/jcollie/harrier/LauncherEntry` says, through Unity's //! `com.canonical.Unity.LauncherEntry`, how far along the programs in the //! terminals are, for a dock or task manager to show on harrier's icon. //! //! The service runs on a thread of its own, as zig-dbus-service's `Service` //! does. A call's handler only copies what was asked for into a queue and //! wakes the main loop, which opens the window: everything to do with //! windows stays on the main thread. const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; const dbus = @import("dbus_service"); const mdbus = @import("mdbus"); const Application = @import("dbus_interfaces").Application; const Terminal1 = @import("dbus_interfaces").Terminal1; const SearchProvider2 = @import("dbus_interfaces").SearchProvider2; const LauncherEntry = @import("dbus_interfaces").LauncherEntry; const Wake = @import("os/Wake.zig"); const req = @import("request.zig"); const desktop_entry = @import("desktop_entry.zig"); const search = @import("shell_search.zig"); const Portal = @import("Portal.zig"); const log = std.log.scoped(.dbus); pub const app_id = req.app_id; pub const object_path = "/dev/jcollie/harrier"; pub const search_path = object_path ++ "/SearchProvider"; pub const launcher_path = object_path ++ "/LauncherEntry"; /// How the launcher API names the application: by its desktop file. const app_uri = "application://" ++ app_id ++ ".desktop"; pub const Launcher = req.Launcher; pub const Request = req.Request; const dupeStrings = req.dupeStrings; /// The session bus's address: `DBUS_SESSION_BUS_ADDRESS`, or the socket /// systemd's user manager puts in the runtime directory. 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; } // --------------------------------------------------------------------------- // The hand-off to the primary, which owns the name // --------------------------------------------------------------------------- /// Hands `request` off to the primary on the bus — started by the bus if it /// has to be. True if it took it; false if there is no primary to ask, and /// the caller should become the primary and open the window itself. pub fn handOff(gpa: Allocator, io: Io, env: *const std.process.Environ.Map, request: Request) bool { var addr_buf: [std.fs.max_path_bytes + 16]u8 = undefined; const address = sessionAddress(&addr_buf, env) orelse return false; var connection = mdbus.Connection.connectAddress(io, gpa, address) catch |err| { log.debug("no session bus: {t}", .{err}); return false; }; defer connection.deinit(); var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); // The window, as one a{sv} in the action's parameter. var entries: std.ArrayList(dbus.Vardict.Entry) = .empty; const command = request.command orelse &[_][]const u8{}; if (request.command != null) entries.append(arena, .{ .key = "command", .value = .of("as", &command) }) catch return false; if (request.cwd) |*c| entries.append(arena, .{ .key = "cwd", .value = .of("s", c) }) catch return false; if (request.environ) |*e| entries.append(arena, .{ .key = "environ", .value = .of("as", e) }) catch return false; if (request.serial_path) |*p| entries.append(arena, .{ .key = "serial-path", .value = .of("s", p) }) catch return false; if (request.baud) |*b| entries.append(arena, .{ .key = "baud", .value = .of("u", b) }) catch return false; if (request.profile) |*e| entries.append(arena, .{ .key = "profile", .value = .of("s", e) }) catch return false; if (request.focus_pane) |*id| entries.append(arena, .{ .key = "focus-pane", .value = .of("t", id) }) catch return false; if (request.focus_primary) |*pid| entries.append(arena, .{ .key = "focus-primary", .value = .of("u", pid) }) catch return false; const window: dbus.Vardict = .{ .entries = entries.items }; var platform: std.ArrayList(dbus.Vardict.Entry) = .empty; if (request.activation_token) |*t| { platform.append(arena, .{ .key = "activation-token", .value = .of("s", t) }) catch return false; platform.append(arena, .{ .key = "desktop-startup-id", .value = .of("s", t) }) catch return false; } var client: Application.Client = .{ .connection = &connection, .destination = app_id, .path = object_path, // Long enough for the bus to start the service, which scans the // fonts before it can answer. .timeout_ms = 30_000, }; _ = client.call(Application.ActivateAction, arena, .{ .action_name = if (request.new_tab) "new-tab" else "new-window", .parameter = &.{.of("a{sv}", &window)}, .platform_data = .{ .entries = platform.items }, }) catch |err| { if (client.failure) |f| { log.debug("handing the window off: {s}: {s}", .{ f.name, f.message }); } else log.debug("handing the window off: {t}", .{err}); return false; }; return true; } /// `l` as the launcher API's properties, pointing into `l`. fn launcherProperties(l: *const Launcher, buf: *[3]dbus.Vardict.Entry) dbus.Vardict { buf.* = .{ .{ .key = "progress", .value = .of("d", &l.progress) }, .{ .key = "progress-visible", .value = .of("b", &l.visible) }, .{ .key = "urgent", .value = .of("b", &l.urgent) }, }; return .{ .entries = buf }; } // --------------------------------------------------------------------------- // Owning the name // --------------------------------------------------------------------------- pub const Server = struct { gpa: Allocator, io: Io, connection: mdbus.Connection, service: dbus.Service, interfaces: [2]dbus.Object.Implementation, search_interfaces: [1]dbus.Object.Implementation, launcher_interfaces: [1]dbus.Object.Implementation, objects: [3]dbus.Object, thread: ?std.Thread = null, /// The service has stopped: the bus went away, and nothing more can be /// asked of this harrier. gone: std.atomic.Value(bool) = .init(false), /// `$HOME`, where a terminal launched with nowhere else to go starts. /// Owned. home: ?[]const u8, mutex: Io.Mutex = .init, /// The open panes, as the main loop last published them; under `mutex`, /// with the strings in `panes_arena`. panes: []const search.Pane = &.{}, panes_arena: std.heap.ArenaAllocator, /// The configuration's profiles, likewise, as the main loop last /// published them. profiles: []const search.Profile = &.{}, profiles_arena: std.heap.ArenaAllocator, /// What the launcher entry last said; under `mutex`. launcher: Launcher = .{}, pending: std.ArrayList(Request) = .empty, /// Wakes the main loop when a request arrives. Poll `wake.handle`. wake: Wake, pub const StartError = error{ NoBus, NameTaken } || Allocator.Error || error{SystemResources}; /// Owns the name and starts serving it. On the heap: the service's /// objects point back into it. pub fn start(gpa: Allocator, io: Io, env: *const std.process.Environ.Map) StartError!*Server { var addr_buf: [std.fs.max_path_bytes + 16]u8 = undefined; const address = sessionAddress(&addr_buf, env) orelse return error.NoBus; const s = try gpa.create(Server); errdefer gpa.destroy(s); s.* = .{ .gpa = gpa, .io = io, .connection = mdbus.Connection.connectAddress(io, gpa, address) catch return error.NoBus, .service = undefined, .interfaces = undefined, .objects = undefined, .home = if (env.get("HOME")) |h| try gpa.dupe(u8, h) else null, .search_interfaces = undefined, .launcher_interfaces = undefined, .panes_arena = .init(gpa), .profiles_arena = .init(gpa), .wake = try .init(), }; errdefer s.connection.deinit(); errdefer s.wake.deinit(); errdefer if (s.home) |h| gpa.free(h); s.interfaces = .{ Application.Server(Server).implementation(s), Terminal1.Server(Server).implementation(s), }; s.search_interfaces = .{SearchProvider2.Server(Server).implementation(s)}; s.launcher_interfaces = .{LauncherEntry.Server(Server).implementation(s)}; s.objects = .{ .{ .path = object_path, .interfaces = &s.interfaces }, .{ .path = search_path, .interfaces = &s.search_interfaces }, .{ .path = launcher_path, .interfaces = &s.launcher_interfaces }, }; s.service = dbus.Service.init(gpa, io, &s.connection, &s.objects) catch return error.SystemResources; errdefer s.service.deinit(); s.service.requestName(app_id) catch |err| return switch (err) { error.NameTaken => error.NameTaken, error.OutOfMemory => error.OutOfMemory, else => error.NoBus, }; s.thread = std.Thread.spawn(.{}, serve, .{s}) catch return error.SystemResources; log.info("serving {s} on the session bus", .{app_id}); return s; } fn serve(s: *Server) void { s.service.run() catch |err| log.info("the session bus service stopped: {t}", .{err}); s.gone.store(true, .release); s.wake.signal(); } pub fn destroy(s: *Server) void { s.service.stop(); if (s.thread) |t| t.join(); s.service.deinit(); s.connection.deinit(); for (s.pending.items) |*r| r.deinit(s.gpa); s.pending.deinit(s.gpa); s.wake.deinit(); if (s.home) |h| s.gpa.free(h); s.panes_arena.deinit(); s.profiles_arena.deinit(); s.gpa.destroy(s); } /// The next window asked for, if any. The caller owns it. pub fn take(s: *Server) ?Request { _ = s.wake.drain(); s.mutex.lockUncancelable(s.io); defer s.mutex.unlock(s.io); if (s.pending.items.len == 0) return null; const r = s.pending.orderedRemove(0); // More waiting: make sure the loop comes back for them. if (s.pending.items.len > 0) s.wake.signal(); return r; } fn queue(s: *Server, request: Request) !void { try s.queueAll(&.{request}); } /// Several windows, queued together or not at all. fn queueAll(s: *Server, requests: []const Request) !void { { s.mutex.lockUncancelable(s.io); defer s.mutex.unlock(s.io); try s.pending.appendSlice(s.gpa, requests); } s.wake.signal(); } /// Says `l` on harrier's icon, if it is not what was said last. pub fn publishLauncher(s: *Server, l: Launcher) void { { s.mutex.lockUncancelable(s.io); defer s.mutex.unlock(s.io); if (std.meta.eql(s.launcher, l)) return; s.launcher = l; } var buf: [3]dbus.Vardict.Entry = undefined; LauncherEntry.emitUpdate(&s.service, launcher_path, .{ .app_uri = app_uri, .properties = launcherProperties(&l, &buf), }) catch |err| log.warn("the launcher entry: {t}", .{err}); } pub fn query(s: *Server, call: *dbus.Call, _: LauncherEntry.Query.In) !LauncherEntry.Query.Out { s.mutex.lockUncancelable(s.io); const l = s.launcher; s.mutex.unlock(s.io); const copy = try call.arena.create(Launcher); copy.* = l; const buf = try call.arena.create([3]dbus.Vardict.Entry); return .{ .properties = launcherProperties(copy, buf) }; } /// Replaces what the search knows of the open panes with `panes`, copied. pub fn publishPanes(s: *Server, panes: []const search.Pane) Allocator.Error!void { var arena: std.heap.ArenaAllocator = .init(s.gpa); errdefer arena.deinit(); const a = arena.allocator(); const copy = try a.alloc(search.Pane, panes.len); for (panes, copy) |p, *c| c.* = .{ .id = p.id, .title = try a.dupe(u8, p.title), .location = try a.dupe(u8, p.location), }; s.mutex.lockUncancelable(s.io); defer s.mutex.unlock(s.io); s.panes_arena.deinit(); s.panes_arena = arena; s.panes = copy; } /// Replaces what the search knows of the profiles with `profiles`, /// copied. pub fn publishProfiles(s: *Server, profiles: []const search.Profile) Allocator.Error!void { var arena: std.heap.ArenaAllocator = .init(s.gpa); errdefer arena.deinit(); const a = arena.allocator(); const copy = try a.alloc(search.Profile, profiles.len); for (profiles, copy) |p, *c| c.* = .{ .name = try a.dupe(u8, p.name), .runs = try a.dupe(u8, p.runs), .description = try a.dupe(u8, p.description), .tab = p.tab, }; s.mutex.lockUncancelable(s.io); defer s.mutex.unlock(s.io); s.profiles_arena.deinit(); s.profiles_arena = arena; s.profiles = copy; } /// The activation token a launcher passes, owned. fn token(s: *Server, platform_data: dbus.Vardict, arena: Allocator) !?[]const u8 { const t = (try platform_data.get("s", []const u8, "activation-token", arena)) orelse (try platform_data.get("s", []const u8, "desktop-startup-id", arena)) orelse return null; return try s.gpa.dupe(u8, t); } // -- org.freedesktop.Application ------------------------------------ /// What a launcher calls when the desktop file is opened: a window. pub fn activate(s: *Server, call: *dbus.Call, in: Application.Activate.In) !void { try s.queue(.{ .activation_token = try s.token(in.platform_data, call.arena) }); } /// Files and directories: a window in each directory, or in the /// directory a file is in. pub fn open(s: *Server, call: *dbus.Call, in: Application.Open.In) !void { for (in.uris) |uri| { const prefix = "file://"; if (!std.mem.startsWith(u8, uri, prefix)) continue; const path = try call.arena.dupe(u8, uri[prefix.len..]); const decoded = std.Uri.percentDecodeInPlace(path); const dir = blk: { var d = Io.Dir.cwd().openDir(s.io, decoded, .{}) catch break :blk std.fs.path.dirname(decoded) orelse "/"; d.close(s.io); break :blk decoded; }; try s.queue(.{ .cwd = try s.gpa.dupe(u8, dir), .activation_token = try s.token(in.platform_data, call.arena), }); } } /// `new-window` or `new-tab`, with what it runs as an a{sv}: `command` /// (as), `cwd` (s), `environ` (as), `serial-path` (s) and `baud` (u), /// all optional. A new tab goes in the window most recently opened or /// focused. /// /// Or `focus-pane`, with a pane's ID (t): what clicking one of /// harrier's notifications does, when the desktop activates the /// application rather than telling the portal. pub fn activateAction(s: *Server, call: *dbus.Call, in: Application.ActivateAction.In) !void { if (std.mem.eql(u8, in.action_name, Portal.focus_action)) { if (in.parameter.len == 0) return error.InvalidArgs; const id = (try in.parameter[0].get("t", u64, call.arena)) orelse return error.InvalidArgs; try s.queue(.{ .focus_pane = id, .activation_token = try s.token(in.platform_data, call.arena) }); return; } const new_tab = std.mem.eql(u8, in.action_name, "new-tab"); if (!new_tab and !std.mem.eql(u8, in.action_name, "new-window")) return error.NotSupported; const arena = call.arena; const window: dbus.Vardict = if (in.parameter.len > 0) (try in.parameter[0].get("a{sv}", dbus.Vardict, arena)) orelse return error.InvalidArgs else .{}; var r: Request = .{ .new_tab = new_tab }; errdefer r.deinit(s.gpa); if (try window.get("as", []const []const u8, "command", arena)) |c| if (c.len > 0) { r.command = try dupeStrings(s.gpa, c); }; if (try window.get("s", []const u8, "cwd", arena)) |c| r.cwd = try s.gpa.dupe(u8, c); if (try window.get("as", []const []const u8, "environ", arena)) |e| r.environ = try dupeStrings(s.gpa, e); if (try window.get("s", []const u8, "serial-path", arena)) |p| r.serial_path = try s.gpa.dupe(u8, p); r.baud = try window.get("u", u32, "baud", arena); if (try window.get("s", []const u8, "profile", arena)) |e| r.profile = try s.gpa.dupe(u8, e); r.focus_pane = try window.get("t", u64, "focus-pane", arena); r.focus_primary = try window.get("u", u32, "focus-primary", arena); r.activation_token = try s.token(in.platform_data, arena); try s.queue(r); } // -- org.freedesktop.Terminal1 -------------------------------------- /// A window with a tab for each command — or the shell, for an empty /// one or none at all — with each command's `exec`, `env` and /// `working_directory`: the grouping the specification suggests. /// /// A desktop entry names the windows and gives them its `Path` when a /// command has no directory of its own; one that cannot be read refuses /// the call. A command that cannot be started still gets a window, /// saying so, and the rest are launched regardless. pub fn launchCommand(s: *Server, call: *dbus.Call, in: Terminal1.LaunchCommand.In) !void { const arena = call.arena; var entry: desktop_entry.Entry = .{}; const entry_path = bytestring(in.desktop_entry); if (entry_path.len > 0) { entry = s.readDesktopEntry(arena, entry_path) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return call.fail(dbus.errors.invalid_args, "desktop_entry is not the path of a desktop entry"), }; } const keep_open = (try in.options.get("b", bool, "keep-terminal-open", arena)) orelse false; const token_ = try s.token(in.platform_data, arena); defer if (token_) |t| s.gpa.free(t); // No commands is one shell. const empty = [_]dbus.Vardict{.{}}; const commands = if (in.commands.len == 0) &empty else in.commands; var requests: std.ArrayList(Request) = .empty; defer requests.deinit(s.gpa); errdefer for (requests.items) |*r| r.deinit(s.gpa); for (commands, 0..) |command, i| { // The first opens the window the rest are tabs in. var r: Request = .{ .hold = if (keep_open) .always else .failure, .new_tab = i > 0 }; errdefer r.deinit(s.gpa); if (try command.get("aay", []const []const u8, "exec", arena)) |argv| if (argv.len > 0) { r.command = try dupeStrings(s.gpa, try bytestrings(arena, argv)); }; if (try command.get("aay", []const []const u8, "env", arena)) |env| if (env.len > 0) { r.environ_set = try dupeStrings(s.gpa, try bytestrings(arena, env)); }; const wd = bytestring((try command.get("ay", []const u8, "working_directory", arena)) orelse ""); const cwd: ?[]const u8 = if (wd.len > 0) wd else entry.path orelse s.home; if (cwd) |c| r.cwd = try s.gpa.dupe(u8, c); if (entry.name) |n| r.title = try s.gpa.dupe(u8, n); // A token makes a window active once; the first window takes it. if (i == 0) if (token_) |t| { r.activation_token = try s.gpa.dupe(u8, t); }; try requests.append(s.gpa, r); } try s.queueAll(requests.items); requests.clearRetainingCapacity(); } /// An `ay` as GLib writes one — `g_variant_new_bytestring`, or `b'…'` /// to gdbus — ends in a NUL that is not part of the string. A NUL can be /// in no path, argument or environment entry, so one at the end is /// always that terminator. fn bytestring(b: []const u8) []const u8 { return if (b.len > 0 and b[b.len - 1] == 0) b[0 .. b.len - 1] else b; } fn bytestrings(arena: Allocator, list: []const []const u8) Allocator.Error![]const []const u8 { const out = try arena.alloc([]const u8, list.len); for (list, out) |b, *o| o.* = bytestring(b); return out; } // -- org.gnome.Shell.SearchProvider2 -------------------------------- pub fn getInitialResultSet(s: *Server, call: *dbus.Call, in: SearchProvider2.GetInitialResultSet.In) !SearchProvider2.GetInitialResultSet.Out { return .{ .results = try s.find(call.arena, in.terms) }; } /// Made afresh, as `search.find` says, rather than among /// `previous_results`. pub fn getSubsearchResultSet(s: *Server, call: *dbus.Call, in: SearchProvider2.GetSubsearchResultSet.In) !SearchProvider2.GetSubsearchResultSet.Out { return .{ .results = try s.find(call.arena, in.terms) }; } /// What `terms` find, as `search.find` orders it. fn find(s: *Server, arena: Allocator, terms: []const []const u8) ![]const []const u8 { s.mutex.lockUncancelable(s.io); defer s.mutex.unlock(s.io); return search.find(arena, s.panes, s.profiles, terms); } /// For each result still open: its title as the name, where it is as /// the description, and harrier's icon. pub fn getResultMetas(s: *Server, call: *dbus.Call, in: SearchProvider2.GetResultMetas.In) !SearchProvider2.GetResultMetas.Out { const arena = call.arena; var metas: std.ArrayList(dbus.Vardict) = .empty; s.mutex.lockUncancelable(s.io); defer s.mutex.unlock(s.io); for (in.identifiers) |id_str| { // A variant points at its value until it is sent: every value // lives in the call's arena. const strings = try arena.alloc([]const u8, 4); strings[0] = try arena.dupe(u8, id_str); switch (search.Result.parse(id_str) orelse continue) { .pane => |id| { const p = for (s.panes) |p| { if (p.id == id) break p; } else continue; strings[1] = try arena.dupe(u8, if (p.title.len > 0) p.title else "harrier"); strings[2] = try arena.dupe(u8, p.location); }, .profile => |name| { const p = for (s.profiles) |p| { if (std.mem.eql(u8, p.name, name)) break p; } else continue; strings[1] = try arena.dupe(u8, p.name); strings[2] = try arena.dupe(u8, p.description); }, } strings[3] = app_id; const entries = try arena.alloc(dbus.Vardict.Entry, 4); entries[0] = .{ .key = "id", .value = .of("s", &strings[0]) }; entries[1] = .{ .key = "name", .value = .of("s", &strings[1]) }; entries[2] = .{ .key = "description", .value = .of("s", &strings[2]) }; // The icon by its name in the theme, which is what a GIcon's // string form is for a themed icon. entries[3] = .{ .key = "gicon", .value = .of("s", &strings[3]) }; try metas.append(arena, .{ .entries = entries }); } return .{ .metas = metas.items }; } pub fn activateResult(s: *Server, _: *dbus.Call, in: SearchProvider2.ActivateResult.In) !void { try s.openResult(search.Result.parse(in.identifier) orelse return error.InvalidArgs); } /// The Shell's "show me everything" — a click on harrier's icon beside /// its results. harrier has no list of its own to show them in: it /// shows the first pane found, or else starts the first profile found, /// or opens a window when nothing is. pub fn launchSearch(s: *Server, call: *dbus.Call, in: SearchProvider2.LaunchSearch.In) !void { const found = try s.find(call.arena, in.terms); if (found.len > 0) { try s.openResult(search.Result.parse(found[0]).?); } else try s.queue(.{}); } /// A result picked: a pane shown, or a profile started as it says, in /// a window or in a tab of the last window, which is brought forward. fn openResult(s: *Server, r: search.Result) !void { switch (r) { .pane => |id| try s.queue(.{ .focus_pane = id }), .profile => |name| { const tab = tab: { s.mutex.lockUncancelable(s.io); defer s.mutex.unlock(s.io); for (s.profiles) |p| if (std.mem.eql(u8, p.name, name)) break :tab p.tab; return; }; const copy = try s.gpa.dupe(u8, name); errdefer s.gpa.free(copy); try s.queue(.{ .profile = copy, .new_tab = tab, .raise = true }); }, } } fn readDesktopEntry(s: *Server, arena: Allocator, path: []const u8) !desktop_entry.Entry { if (!std.mem.endsWith(u8, path, ".desktop")) return error.NotDesktopEntry; const text = try Io.Dir.cwd().readFileAlloc(s.io, path, arena, .limited(1024 * 1024)); return desktop_entry.parse(arena, text); } }; // --------------------------------------------------------------------------- const testing = std.testing; test { _ = desktop_entry; _ = search; } test "a GLib bytestring's terminator is dropped, and nothing else" { try testing.expectEqualStrings("/usr/bin/vim", Server.bytestring("/usr/bin/vim\x00")); try testing.expectEqualStrings("/usr/bin/vim", Server.bytestring("/usr/bin/vim")); try testing.expectEqualStrings("", Server.bytestring("\x00")); try testing.expectEqualStrings("", Server.bytestring("")); } test "a request copies and frees what it holds" { var r: Request = .{ .command = try dupeStrings(testing.allocator, &.{ "vim", "README.md" }), .cwd = try testing.allocator.dupe(u8, "/tmp"), .environ = try dupeStrings(testing.allocator, &.{"TERM=dumb"}), }; r.deinit(testing.allocator); }