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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// 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"); }}