// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! harrier: a terminal emulator on libghostty-vt. const std = @import("std"); const builtin = @import("builtin"); const build_options = @import("build_options"); const App = @import("App.zig"); const Config = @import("config/Config.zig"); const load = @import("config/load.zig"); const validate = @import("config/validate.zig"); const desktop = @import("desktop.zig"); const Activation = desktop.Activation; const req = @import("request.zig"); const Portal = desktop.Portal; const startup = @import("startup.zig"); const images = @import("renderer/images.zig"); const settings_mod = @import("settings.zig"); const usage = \\usage: harrier [options] [-e command [args...]] \\ harrier validate-config [--hostname NAME] [--color-scheme SCHEME] \\ [--foreground NAME[:UID]]... FILE \\ \\ -e, --command PROG ARGS... run PROG in the window instead of the shell; \\ everything after it is its arguments \\ --serial PATH open a serial port instead of running a program \\ --baud RATE the serial port's rate (default 115200) \\ --config PATH the configuration file to read; also \\ keeps the window in this process \\ --no-config read no configuration at all, not even \\ the default file: the built-in defaults; \\ also keeps the window in this process \\ --profile NAME open the profile NAME, as the profile \\ selector would \\ --working-directory DIR start the program in DIR \\ --focus-pane PRIMARY:ID show pane ID of the harrier whose process \\ ID is PRIMARY, as its jump list does on \\ Windows; its last window, or a new one, \\ when that harrier has gone \\ --tab open a tab in the harrier window most \\ recently used, rather than a window \\ --elevate on Windows, run as administrator: in a \\ harrier of its own, after Windows asks \\ --standalone open the window in this process, rather \\ than handing it to a harrier already running \\ --service run as the session bus service, with no \\ window until one is asked for \\ --validation turn on Vulkan's validation layer \\ --version say which version this is \\ -h, --help this \\ \\validate-config evaluates FILE and checks its colors, key bindings and \\font features, reporting every problem it finds, and exits 1 if there \\were any. It opens no window. --hostname sets what the file sees as \\ctx.hostname, for checking a file meant for another machine. The file \\is checked under each color scheme it could see as \\ctx.appearance.color_scheme -- no_preference, dark and light -- or \\only the one --color-scheme names. A file that reads ctx.foreground is \\checked as a pane would see it too, with each foreground program \\--foreground names (UID defaults to yours; it has no user name), or \\without one, as sh run by you and by root; and what such a pane says \\of anything but its colors, bindings, bell, cursor, clipboard, links, \\notifications, drag and drop and images, which it cannot change, is \\reported. \\ ; /// Where this launch was started, for its window to start there too: not /// Windows' system directory, where the shell starts a harrier from a /// shortcut with no directory of its own (see `windows/directories.zig`). pub fn launchDir(io: std.Io, arena: std.mem.Allocator) ?[]const u8 { const cwd = std.process.currentPathAlloc(io, arena) catch return null; if (builtin.os.tag == .windows and @import("windows/directories.zig").isSystem(cwd)) return null; return cwd; } pub fn main(init: std.process.Init) !u8 { const gpa = init.gpa; const arena = init.arena.allocator(); const args = try init.minimal.args.toSlice(arena); var stderr_buf: [1024]u8 = undefined; var stderr_file = std.Io.File.stderr().writer(init.io, &stderr_buf); const stderr = &stderr_file.interface; defer stderr.flush() catch {}; startup.init(init.environ_map); // Only an elevated harrier can name itself as the Application log's // source of harrier's events. if (builtin.os.tag == .windows and @import("windows/elevation.zig").elevated()) @import("windows/eventlog.zig").register(); // Before any window, which takes the process's ID when it is made. if (builtin.os.tag == .windows) @import("windows/app_user_model.zig").set(); images.decodePngs(); startup.mark("main"); if (args.len >= 2 and std.mem.eql(u8, args[1], "validate-config")) return validateConfig(init, args[2..], stderr); var options: App.Options = .{}; var config_path: ?[]const u8 = null; var no_config = false; var serial_path: ?[]const u8 = null; var baud: ?u32 = null; var command: ?[]const []const u8 = null; var standalone = false; var new_tab = false; var profile: ?[]const u8 = null; var working_directory: ?[]const u8 = null; var elevate = false; var focus: ?req.PaneRef = null; var i: usize = 1; while (i < args.len) : (i += 1) { const arg = args[i]; if (std.mem.eql(u8, arg, "-e") or std.mem.eql(u8, arg, "--command")) { if (i + 1 >= args.len) return fail(stderr, "{s} wants a program to run", .{arg}); command = args[i + 1 ..]; break; } else if (std.mem.eql(u8, arg, "--serial")) { serial_path = try value(args, &i, stderr) orelse return 2; } else if (std.mem.eql(u8, arg, "--baud")) { const v = try value(args, &i, stderr) orelse return 2; baud = std.fmt.parseInt(u32, v, 10) catch return fail(stderr, "--baud: \"{s}\" is not a rate", .{v}); } else if (std.mem.eql(u8, arg, "--config")) { config_path = try value(args, &i, stderr) orelse return 2; } else if (std.mem.eql(u8, arg, "--no-config")) { no_config = true; } else if (std.mem.eql(u8, arg, "--tab")) { new_tab = true; } else if (std.mem.eql(u8, arg, "--profile")) { profile = try value(args, &i, stderr) orelse return 2; } else if (std.mem.eql(u8, arg, "--focus-pane")) { const v = try value(args, &i, stderr) orelse return 2; focus = req.PaneRef.parse(v) orelse return fail(stderr, "--focus-pane wants PRIMARY:ID, two numbers: \"{s}\"", .{v}); } else if (std.mem.eql(u8, arg, "--working-directory")) { const dir = try value(args, &i, stderr) orelse return 2; // Explorer's "Open in harrier" passes `"%V\."`, since `"C:\"`, // a drive's root, would reach here as `C:"`; made plain again. working_directory = if (builtin.os.tag == .windows) try std.fs.path.resolveAllocWindows(arena, &.{dir}) else dir; } else if (std.mem.eql(u8, arg, "--elevate")) { elevate = true; } else if (std.mem.eql(u8, arg, "--standalone")) { standalone = true; } else if (std.mem.eql(u8, arg, "--service")) { options.service = true; } else if (std.mem.eql(u8, arg, "--validation")) { options.validation = true; } else if (std.mem.eql(u8, arg, "--version")) { try stderr.print("harrier {s}\n", .{build_options.version}); return 0; } else if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) { try stderr.writeAll(usage); return 0; } else { try stderr.print("harrier: unknown option \"{s}\"\n\n{s}", .{ arg, usage }); return 2; } } if (no_config and config_path != null) return fail(stderr, "--no-config and --config ask for opposite things; give one", .{}); if (baud != null and serial_path == null) return fail(stderr, "--baud is for a serial port; give one with --serial", .{}); if (profile != null and (command != null or serial_path != null)) return fail(stderr, "--profile says what to run; give it without -e or --serial", .{}); if (focus != null and (command != null or serial_path != null or profile != null or new_tab)) return fail(stderr, "--focus-pane shows a pane already open; give it alone", .{}); // Run as administrator: harrier again, elevated, after Windows asks, with // everything else this launch was given; one already elevated just // goes on. if (elevate) { if (builtin.os.tag != .windows) return fail(stderr, "--elevate is for Windows; use sudo in the terminal", .{}); const elevation = @import("windows/elevation.zig"); if (!elevation.elevated()) { var forward = try withoutElevate(arena, args[1..]); // An elevated program starts in the system's directory, not // this one's. if (working_directory == null) if (launchDir(init.io, arena)) |cwd| { try forward.insertSlice(arena, 0, &.{ "--working-directory", cwd }); }; elevation.relaunch(gpa, forward.items) catch |err| return switch (err) { error.Cancelled => 1, else => fail(stderr, "running as administrator: {t}", .{err}), }; return 0; } } // What this launch asks for, as a request it can hand off to the // primary: the command, and where and with what environment to run it. var request: req.Request = .{}; if (!options.service) { request.command = command; request.profile = profile; // A profile starts where it says, unless told otherwise. request.cwd = working_directory orelse if (profile != null) null else launchDir(init.io, arena); request.serial_path = serial_path; request.baud = baud; request.new_tab = new_tab; if (focus) |f| { request.focus_pane = f.id; request.focus_primary = f.primary; } request.activation_token = init.environ_map.get("XDG_ACTIVATION_TOKEN") orelse init.environ_map.get("DESKTOP_STARTUP_ID"); var environ: std.ArrayList([]const u8) = .empty; var it = init.environ_map.iterator(); while (it.next()) |e| try environ.append(arena, try std.fmt.allocPrint(arena, "{s}={s}", .{ e.key_ptr.*, e.value_ptr.* })); request.environ = environ.items; // A configuration file or a validation layer of its own needs a // process of its own; anything else is handed off to the primary. if (!standalone and config_path == null and !no_config and !options.validation and Activation.handOff(gpa, init.io, init.environ_map, request)) return 0; } // The desktop's settings, which the configuration can read, before the // configuration is read; watched from here on. The portal is also what // shows notifications, so --no-config, which has no configuration to // read the settings, starts it all the same. startup.mark("arguments; no primary to hand off to"); const portal = Portal.start(gpa, init.io, init.environ_map); startup.mark("desktop portal"); var settings: settings_mod.Settings = .init(gpa); var generation: u64 = 0; if (portal) |p| if (p.takeIfNewer(gpa, 0) catch null) |fresh| { settings.deinit(); settings = fresh.settings; generation = fresh.generation; }; var problem: load.Problem = .{}; const loaded = load.load(gpa, init.io, init.environ_map, config_path, .{ .settings = &settings, .no_file = no_config }, &problem) catch |err| { if (portal) |p| p.destroy(); settings.deinit(); return switch (err) { error.OutOfMemory => err, else => fail(stderr, "{f}", .{&problem}), }; }; startup.mark("configuration"); options.config_path = config_path; options.no_config = no_config; options.portal = portal; options.settings = settings; options.settings_generation = generation; options.environ = init.minimal.environ; const app = App.init(gpa, init.io, init.environ_map, loaded, options) catch |err| return switch (err) { error.IncompatibleDriver => fail(stderr, "no GPU driver with Vulkan 1.2 was found to draw with ({t})", .{err}), else => fail(stderr, "{t}", .{err}), }; defer app.deinit(); app.serve() catch |err| return fail(stderr, "serving {s} on the session bus: {t}", .{ req.app_id, err }); startup.mark("session bus name"); if (!options.service) app.open(&request) catch |err| return fail(stderr, "opening a window: {t}", .{err}); startup.mark("window"); try app.run(); return 0; } /// `harrier validate-config [--hostname NAME] [--color-scheme SCHEME] /// [--foreground NAME[:UID]]... FILE`. fn validateConfig(init: std.process.Init, args: []const [:0]const u8, stderr: *std.Io.Writer) !u8 { var hostname: ?[]const u8 = null; var path: ?[]const u8 = null; var only: ?validate.ColorScheme = null; var foregrounds: std.ArrayList(Config.Foreground) = .empty; defer foregrounds.deinit(init.gpa); const own_uid: u32 = if (builtin.os.tag == .linux) std.os.linux.geteuid() else 1000; var i: usize = 0; while (i < args.len) : (i += 1) { const arg = args[i]; if (std.mem.eql(u8, arg, "--hostname")) { hostname = try value(args, &i, stderr) orelse return 2; } else if (std.mem.eql(u8, arg, "--color-scheme")) { const v = try value(args, &i, stderr) orelse return 2; only = std.meta.stringToEnum(validate.ColorScheme, v) orelse { try stderr.print("harrier: --color-scheme: \"{s}\" is not no_preference, dark or light\n", .{v}); return 2; }; } else if (std.mem.eql(u8, arg, "--foreground")) { const v = try value(args, &i, stderr) orelse return 2; const colon = std.mem.findScalar(u8, v, ':'); const name = v[0 .. colon orelse v.len]; const uid = if (colon) |c| std.fmt.parseInt(u32, v[c + 1 ..], 10) catch { try stderr.print("harrier: --foreground: \"{s}\" is not a UID\n", .{v[c + 1 ..]}); return 2; } else own_uid; if (name.len == 0) { try stderr.writeAll("harrier: --foreground wants a program's name\n"); return 2; } try foregrounds.append(init.gpa, .{ .name = name, .uid = uid }); } else if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) { try stderr.writeAll(usage); return 0; } else if (path == null and !std.mem.startsWith(u8, arg, "-")) { path = arg; } else { try stderr.print("harrier: validate-config: unexpected \"{s}\"\n\n{s}", .{ arg, usage }); return 2; } } const file = path orelse { try stderr.print("harrier: validate-config wants the file to check\n\n{s}", .{usage}); return 2; }; const every = [_]validate.ColorScheme{ .no_preference, .dark, .light }; const schemes: []const validate.ColorScheme = if (only) |o| &.{o} else &every; // The file evaluated once for each scheme: what a mistake in only one // branch of `if (ctx.appearance.color_scheme == .dark)` needs. var findings: validate.Findings = .init(init.gpa); defer findings.deinit(); for (schemes) |scheme| { var problem: load.Problem = .{}; var loaded = load.load(init.gpa, init.io, init.environ_map, file, .{ .hostname = hostname, .appearance = .{ .color_scheme = scheme }, }, &problem) catch |err| switch (err) { error.OutOfMemory => return err, else => { var buf: [4096]u8 = undefined; try findings.add(scheme, std.fmt.bufPrint(&buf, "{f}", .{&problem}) catch "the file does not evaluate"); continue; }, }; defer loaded.deinit(); // Each problem is a line, prefixed with the file as the // evaluator's own diagnostics are. var report: std.Io.Writer.Allocating = .init(init.gpa); defer report.deinit(); _ = try validate.check(loaded.config(), &report.writer); try addLines(&findings, scheme, file, "", report.written()); // As a pane sees it, for each foreground program. if (!loaded.readsForeground()) continue; const defaults = [_]Config.Foreground{ .{ .name = "sh", .uid = own_uid, .user = init.environ_map.get("USER") }, .{ .name = "sh", .uid = 0, .user = "root" }, }; const each: []const Config.Foreground = if (foregrounds.items.len > 0) foregrounds.items else &defaults; for (each) |fg| { const as = try std.fmt.allocPrint(init.gpa, "as {s} with UID {d}: ", .{ fg.name, fg.uid }); defer init.gpa.free(as); var pane_problem: load.Problem = .{}; var pane = loaded.evalFor(init.gpa, fg, &pane_problem.diag) catch |err| switch (err) { error.OutOfMemory => return err, else => { const line = try std.fmt.allocPrint(init.gpa, "{f} ({s})", .{ &pane_problem, as[0 .. as.len - 2] }); defer init.gpa.free(line); try findings.add(scheme, line); continue; }, }; defer pane.deinit(); report.clearRetainingCapacity(); _ = try validate.check(&pane.value, &report.writer); var differing: [64][]const u8 = undefined; for (Config.appFieldsDiffering(loaded.config(), &pane.value, &differing)) |name| try report.writer.print("{s} depends on ctx.foreground, which only a pane's colors, bindings, bell, cursor, clipboard, links, notifications, drag and drop and images can; ignored\n", .{name}); try addLines(&findings, scheme, file, as, report.written()); } } try findings.write(schemes, stderr); return if (findings.empty()) 0 else 1; } /// Each line of `report` as a finding, after the file's name and `as`. fn addLines(findings: *validate.Findings, scheme: validate.ColorScheme, file: []const u8, as: []const u8, report: []const u8) !void { var lines = std.mem.splitScalar(u8, report, '\n'); while (lines.next()) |line| if (line.len > 0) { const full = try std.fmt.allocPrint(findings.gpa, "{s}: {s}{s}", .{ file, as, line }); defer findings.gpa.free(full); try findings.add(scheme, full); }; } /// The arguments without `--elevate`: every one before `-e`, but an /// option's value, and everything after it, are kept as they are. fn withoutElevate(arena: std.mem.Allocator, args: []const [:0]const u8) !std.ArrayList([]const u8) { const with_value = [_][]const u8{ "--serial", "--baud", "--config", "--profile", "--working-directory", "--focus-pane" }; var out: std.ArrayList([]const u8) = .empty; var i: usize = 0; while (i < args.len) : (i += 1) { const arg: []const u8 = args[i]; if (std.mem.eql(u8, arg, "-e") or std.mem.eql(u8, arg, "--command")) { for (args[i..]) |a| try out.append(arena, a); break; } if (std.mem.eql(u8, arg, "--elevate")) continue; try out.append(arena, arg); for (with_value) |o| if (std.mem.eql(u8, arg, o) and i + 1 < args.len) { i += 1; try out.append(arena, args[i]); }; } return out; } test withoutElevate { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const out = try withoutElevate(arena.allocator(), &.{ "--elevate", "--profile", "--elevate", "--tab", "-e", "pwsh", "--elevate" }); try std.testing.expectEqual(@as(usize, 6), out.items.len); try std.testing.expectEqualStrings("--profile", out.items[0]); try std.testing.expectEqualStrings("--elevate", out.items[1]); try std.testing.expectEqualStrings("--tab", out.items[2]); try std.testing.expectEqualStrings("--elevate", out.items[5]); } fn value(args: []const [:0]const u8, i: *usize, stderr: *std.Io.Writer) !?[]const u8 { if (i.* + 1 >= args.len) { try stderr.print("harrier: {s} wants a value\n", .{args[i.*]}); return null; } i.* += 1; return args[i.*]; } fn fail(stderr: *std.Io.Writer, comptime fmt: []const u8, args: anytype) u8 { stderr.print("harrier: " ++ fmt ++ "\n", args) catch {}; // A GUI program's standard error goes nowhere unless redirected: in // the Application log, and a message box when there is no other way. if (builtin.os.tag == .windows and !builtin.is_test) { var buf: [2048]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); w.print(fmt, args) catch {}; @import("windows/eventlog.zig").fatal(w.buffered()); } return 1; } pub const std_options: std.Options = .{ .logFn = logFn }; /// The usual log, and on Windows its warnings and errors in the /// Application log too, where they are kept whatever started harrier. fn logFn(comptime level: std.log.Level, comptime scope: @EnumLiteral(), comptime format: []const u8, args: anytype) void { std.log.defaultLog(level, scope, format, args); if (builtin.os.tag != .windows or builtin.is_test) return; if (@backingInt(level) > @backingInt(std.log.Level.warn)) return; var buf: [2048]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const prefix = if (scope == .default) "" else "(" ++ @tagName(scope) ++ ") "; w.print(prefix ++ format, args) catch {}; @import("windows/eventlog.zig").report(level, w.buffered()); } test { _ = @import("os/Wake.zig"); _ = @import("backend/Backend.zig"); _ = @import("termio/Session.zig"); _ = @import("config/Config.zig"); _ = @import("config/load.zig"); _ = @import("PaneConfigs.zig"); _ = @import("os/foreground.zig"); _ = @import("BellSounds.zig"); _ = @import("config/validate.zig"); _ = @import("font/shape.zig"); _ = @import("font/cell_glyphs.zig"); _ = @import("font/sprite/Sprite.zig"); _ = @import("input/keys.zig"); _ = @import("input/bindings.zig"); _ = @import("renderer/frame.zig"); _ = @import("renderer/images.zig"); _ = @import("decor/TitleBar.zig"); _ = @import("decor/decor.zig"); _ = @import("decor/TabBar.zig"); _ = @import("decor/StatusButton.zig"); _ = @import("StatusWindow.zig"); _ = @import("status.zig"); _ = @import("decor/Scrollbar.zig"); _ = @import("Tab.zig"); _ = @import("Pane.zig"); _ = @import("shell_search.zig"); _ = @import("match.zig"); _ = @import("settings.zig"); _ = @import("request.zig"); _ = @import("links.zig"); _ = @import("location.zig"); _ = @import("jump_list.zig"); _ = @import("startup.zig"); _ = @import("l10n.zig"); _ = @import("Search.zig"); _ = @import("ProfileSelector.zig"); _ = @import("Confirmation.zig"); _ = @import("Toast.zig"); _ = desktop.Activation; // Nothing a test runs reaches these, so every declaration is referenced // to have it compiled at all. inline for (.{ @import("renderer/frame.zig").Builder, App, @import("Window.zig"), @import("Tab.zig"), @import("Pane.zig"), desktop.Portal, }) |T| std.testing.refAllDecls(T); if (@import("builtin").os.tag == .linux) { _ = @import("Dnd.zig"); _ = @import("Scopes.zig"); std.testing.refAllDecls(@import("Dnd.zig")); } if (@import("builtin").os.tag == .windows) { _ = @import("windows/eventlog.zig"); _ = @import("windows/elevation.zig"); _ = @import("windows/app_user_model.zig"); _ = @import("windows/directories.zig"); } }