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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The primary and the hand-off to it, on Windows: what `Activation.zig`//! does over the session bus, over a named pipe.//!//! The primary owns `\\.\pipe\dev.jcollie.harrier-<SID>`, named for the//! user and open only to them. Every later launch hands off to it: it//! connects, sends its request — the command, where and with which//! environment, a serial port — and exits, and the primary opens the//! window. With no one listening, the launch keeps its request and becomes//! the primary itself.//!//! A pipe's name is anyone's to take, so a launch checks before it sends//! anything that the process at the other end is the same user's: a request//! carries the user's environment.//!//! An elevated primary has a pipe of its own, `…-<SID>-admin`, so that an//! elevated launch never hands off to a primary that is not elevated, nor//! the other way: its windows would run with the wrong rights (see//! `docs/adr/0001-one-primary-per-elevation.md`). A request to it runs as//! administrator, so it takes one only from an elevated process, by the//! pipe's integrity label and by asking; and an elevated launch hands off//! only to an elevated primary.//!//! The server runs on a thread of its own, as the session bus's does. It//! only reads requests into a queue and wakes the main loop, which opens//! the windows.
const std = @import("std");const Allocator = std.mem.Allocator;const Io = std.Io;const win32 = @import("win32").everything;
const Wake = @import("os/Wake.zig");const req = @import("request.zig");const elevation = @import("windows/elevation.zig");const search = @import("shell_search.zig");
const log = std.log.scoped(.instance);
pub const app_id = req.app_id;pub const Launcher = req.Launcher;pub const Request = req.Request;
/// A request bigger than this is refused: an environment is a few/// kilobytes.const max_request = 1024 * 1024;
const L = std.unicode.utf8ToUtf16LeStringLiteral;
// ---------------------------------------------------------------------------// The hand-off to the primary, which owns the pipe// ---------------------------------------------------------------------------
/// Hands `request` off to the primary. True if it took it; false if there/// is none 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 { _ = io; _ = env; var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator();
const me = userSid(arena) catch return false; const admin = elevation.elevated(); const name = pipeName(arena, me.string, admin) catch return false; const pipe = win32.CreateFileW(name, .{ .FILE_READ_DATA = 1, .FILE_WRITE_DATA = 1 }, .{}, null, win32.OPEN_EXISTING, .{}, null); if (pipe == win32.INVALID_HANDLE_VALUE) return false; defer _ = win32.CloseHandle(pipe);
// Whoever is at the other end must be this user, and as elevated as // this launch. var server_pid: u32 = 0; if (win32.GetNamedPipeServerProcessId(pipe, &server_pid) == 0) return false; const theirs = processSid(arena, server_pid) catch return false; if (win32.EqualSid(@ptrCast(me.sid), @ptrCast(theirs)) == 0) { log.warn("not handing the window off: the pipe belongs to another user", .{}); return false; } if (admin and !elevation.pidElevated(server_pid)) { log.warn("not handing the window off: the administrator's pipe belongs to a harrier that is not elevated", .{}); return false; }
var mode: win32.NAMED_PIPE_MODE = win32.PIPE_READMODE_MESSAGE; _ = win32.SetNamedPipeHandleState(pipe, &mode, null, null); const message = encode(arena, request) catch return false; // The primary may bring its new window forward, which this launch, the // one the user just started, is allowed to let it do. _ = win32.AllowSetForegroundWindow(server_pid); var written: u32 = 0; if (win32.WriteFile(pipe, message.ptr, @intCast(message.len), &written, null) == 0 or written != message.len) return false; // One byte back says the request was taken. var ack: [1]u8 = undefined; var read: u32 = 0; return win32.ReadFile(pipe, &ack, 1, &read, null) != 0 and read == 1 and ack[0] == 1;}
// ---------------------------------------------------------------------------// Owning the pipe// ---------------------------------------------------------------------------
pub const Server = struct { gpa: Allocator, /// Wakes the main loop when a request arrives. Wait on `wake.handle`. wake: Wake, /// The server thread has stopped, for good. gone: std.atomic.Value(bool) = .init(false), stopping: std.atomic.Value(bool) = .init(false), name: [:0]const u16, security: ?*anyopaque, /// The pipe the thread is listening on. pipe: win32.HANDLE, /// This harrier is elevated: it takes requests only from processes /// that are. admin: bool, thread: std.Thread = undefined, lock: win32.RTL_SRWLOCK = .{ .Ptr = null }, pending: std.ArrayList(Request) = .empty,
pub const StartError = error{ /// Another harrier has the pipe. Taken, SystemResources, OutOfMemory, };
pub fn start(gpa: Allocator, io: Io, env: *const std.process.Environ.Map) StartError!*Server { _ = io; _ = env; const s = try gpa.create(Server); errdefer gpa.destroy(s);
var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); const me = userSid(arena) catch return error.SystemResources; const admin = elevation.elevated(); const name = try gpa.dupeSentinel(u16, pipeName(arena, me.string, admin) catch return error.OutOfMemory, 0); errdefer gpa.free(name);
// Only this user, and nothing from another machine; for an // elevated harrier, nothing below high integrity may write to it, // which a connection must. const label = if (admin) "S:(ML;;NW;;;HI)" else ""; const sddl = std.unicode.utf8ToUtf16LeAllocZ(arena, std.fmt.allocPrint(arena, "D:P(A;;GA;;;{s}){s}", .{ me.string, label }) catch return error.OutOfMemory) catch return error.OutOfMemory; var security: ?*anyopaque = null; if (win32.ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, win32.SDDL_REVISION_1, @ptrCast(&security), null) == 0) return error.SystemResources; errdefer _ = win32.LocalFree(@bitCast(@intFromPtr(security)));
s.* = .{ .gpa = gpa, .wake = Wake.init() catch return error.SystemResources, .name = name, .security = security, .pipe = undefined, .admin = admin, }; errdefer s.wake.deinit(); s.pipe = try s.listen(); errdefer _ = win32.CloseHandle(s.pipe); s.thread = std.Thread.spawn(.{}, run, .{s}) catch return error.SystemResources; log.info("serving {s} on a named pipe", .{app_id}); return s; }
/// The pipe, to wait for launches on, one at a time: the first instance /// there is, or another harrier has it. fn listen(s: *Server) StartError!win32.HANDLE { var attributes: win32.SECURITY_ATTRIBUTES = .{ .nLength = @sizeOf(win32.SECURITY_ATTRIBUTES), .lpSecurityDescriptor = s.security, .bInheritHandle = win32.FALSE, }; // zigwin32 names these bits for what they mean on a file, so they // are put together by their values. const duplex: u32 = @bitCast(win32.PIPE_ACCESS_DUPLEX); const first_instance: u32 = @bitCast(win32.FILE_FLAG_FIRST_PIPE_INSTANCE); const open_mode: win32.FILE_FLAGS_AND_ATTRIBUTES = @bitCast(duplex | first_instance); var pipe_mode: win32.NAMED_PIPE_MODE = win32.PIPE_TYPE_MESSAGE; pipe_mode.READMODE_MESSAGE = 1; pipe_mode.REJECT_REMOTE_CLIENTS = 1; const pipe = win32.CreateNamedPipeW(s.name, open_mode, pipe_mode, win32.PIPE_UNLIMITED_INSTANCES, 4096, 64 * 1024, 0, &attributes); if (pipe == win32.INVALID_HANDLE_VALUE) return switch (win32.GetLastError()) { .ERROR_ACCESS_DENIED => error.Taken, else => error.SystemResources, }; return pipe; }
/// Takes a launch at a time, until stopped. fn run(s: *Server) void { defer s.gone.store(true, .release); var buf: std.ArrayList(u8) = .empty; defer buf.deinit(s.gpa); while (!s.stopping.load(.acquire)) { const connected = win32.ConnectNamedPipe(s.pipe, null) != 0 or win32.GetLastError() == .ERROR_PIPE_CONNECTED; if (s.stopping.load(.acquire)) break; if (connected) s.serve(&buf); _ = win32.DisconnectNamedPipe(s.pipe); } _ = win32.CloseHandle(s.pipe); }
/// Reads one request from the launch connected, queues it, and says so. fn serve(s: *Server, buf: *std.ArrayList(u8)) void { buf.clearRetainingCapacity(); if (s.admin) { var client: u32 = 0; if (win32.GetNamedPipeClientProcessId(s.pipe, &client) == 0 or !elevation.pidElevated(client)) { log.warn("a request on the administrator's pipe from a process that is not elevated, refused", .{}); return; } } var chunk: [16 * 1024]u8 = undefined; while (true) { var n: u32 = 0; const ok = win32.ReadFile(s.pipe, &chunk, chunk.len, &n, null) != 0; buf.appendSlice(s.gpa, chunk[0..n]) catch return; if (buf.items.len > max_request) return; if (ok) break; if (win32.GetLastError() != .ERROR_MORE_DATA) return; } const request = decode(s.gpa, buf.items) catch |err| { log.warn("a request on the pipe: {t}", .{err}); return; }; { win32.AcquireSRWLockExclusive(&s.lock); defer win32.ReleaseSRWLockExclusive(&s.lock); s.pending.append(s.gpa, request) catch { var r = request; r.deinit(s.gpa); return; }; } s.wake.signal(); var written: u32 = 0; _ = win32.WriteFile(s.pipe, &[_]u8{1}, 1, &written, null); _ = win32.FlushFileBuffers(s.pipe); }
pub fn destroy(s: *Server) void { s.stopping.store(true, .release); // A connection of its own gets the thread out of waiting for one. const poke = win32.CreateFileW(s.name, .{ .FILE_READ_DATA = 1, .FILE_WRITE_DATA = 1 }, .{}, null, win32.OPEN_EXISTING, .{}, null); if (poke != win32.INVALID_HANDLE_VALUE) _ = win32.CloseHandle(poke); s.thread.join(); for (s.pending.items) |*r| r.deinit(s.gpa); s.pending.deinit(s.gpa); _ = win32.LocalFree(@bitCast(@intFromPtr(s.security))); s.gpa.free(s.name); s.wake.deinit(); s.gpa.destroy(s); }
/// The next window asked for, owned by the caller. pub fn take(s: *Server) ?Request { win32.AcquireSRWLockExclusive(&s.lock); defer win32.ReleaseSRWLockExclusive(&s.lock); if (s.pending.items.len == 0) return null; return s.pending.orderedRemove(0); }
/// The taskbar's progress is the platform's, on each window's button. pub fn publishLauncher(s: *Server, l: Launcher) void { _ = s; _ = l; }
/// Windows has no shell search to publish panes to. pub fn publishPanes(s: *Server, panes: []const search.Pane) Allocator.Error!void { _ = s; _ = panes; }
/// Nor profiles. pub fn publishProfiles(s: *Server, profiles: []const search.Profile) Allocator.Error!void { _ = s; _ = profiles; }};
// ---------------------------------------------------------------------------// Who is asking// ---------------------------------------------------------------------------
const Sid = struct { sid: *anyopaque, /// `S-1-5-21-…`. string: []const u8,};
/// This process's user.fn userSid(arena: Allocator) !Sid { const sid = try tokenSid(arena, win32.GetCurrentProcess()); var wide: ?[*:0]u16 = null; if (win32.ConvertSidToStringSidW(@ptrCast(sid), &wide) == 0) return error.Unexpected; defer _ = win32.LocalFree(@bitCast(@intFromPtr(wide))); return .{ .sid = sid, .string = try std.unicode.utf16LeToUtf8Alloc(arena, std.mem.sliceTo(wide.?, 0)) };}
/// The user process `pid` runs as.fn processSid(arena: Allocator, pid: u32) !*anyopaque { const process = win32.OpenProcess(win32.PROCESS_QUERY_LIMITED_INFORMATION, win32.FALSE, pid) orelse return error.AccessDenied; defer _ = win32.CloseHandle(process); return tokenSid(arena, process);}
fn tokenSid(arena: Allocator, process: ?win32.HANDLE) !*anyopaque { var token: ?win32.HANDLE = null; if (win32.OpenProcessToken(process, win32.TOKEN_QUERY, &token) == 0) return error.AccessDenied; defer _ = win32.CloseHandle(token); var size: u32 = 0; _ = win32.GetTokenInformation(token, win32.TokenUser, null, 0, &size); if (size == 0) return error.Unexpected; const buf = try arena.alignedAlloc(u8, .of(win32.TOKEN_USER), size); if (win32.GetTokenInformation(token, win32.TokenUser, buf.ptr, size, &size) == 0) return error.Unexpected; const user: *const win32.TOKEN_USER = @ptrCast(buf.ptr); return user.User.Sid orelse error.Unexpected;}
fn pipeName(arena: Allocator, sid: []const u8, admin: bool) ![:0]const u16 { const suffix = if (admin) "-admin" else ""; return std.unicode.utf8ToUtf16LeAllocZ(arena, try std.fmt.allocPrint(arena, "\\\\.\\pipe\\{s}-{s}{s}", .{ app_id, sid, suffix }));}
// ---------------------------------------------------------------------------// The request, as it goes down the pipe// ---------------------------------------------------------------------------
/// What a request starts with, and its version.const magic = "harrier-request-1\n";
/// `request` as bytes: a line saying what it is, then each field a byte/// saying which, and its value — a string as its length and its bytes, a/// list of strings as their count and each, a number as itself — all little/// endian.fn encode(arena: Allocator, request: Request) Allocator.Error![]u8 { var out: std.Io.Writer.Allocating = .init(arena); const w = &out.writer; encodeTo(w, request) catch return error.OutOfMemory; return out.written();}
const Field = enum(u8) { command = 1, cwd, environ, serial_path, baud, new_tab, profile, focus_pane, focus_primary };
fn encodeTo(w: *std.Io.Writer, r: Request) std.Io.Writer.Error!void { try w.writeAll(magic); if (r.command) |c| { try w.writeByte(@backingInt(Field.command)); try writeList(w, c); } if (r.cwd) |c| { try w.writeByte(@backingInt(Field.cwd)); try writeString(w, c); } if (r.environ) |e| { try w.writeByte(@backingInt(Field.environ)); try writeList(w, e); } if (r.serial_path) |p| { try w.writeByte(@backingInt(Field.serial_path)); try writeString(w, p); } if (r.baud) |b| { try w.writeByte(@backingInt(Field.baud)); try w.writeInt(u32, b, .little); } if (r.new_tab) try w.writeByte(@backingInt(Field.new_tab)); if (r.profile) |e| { try w.writeByte(@backingInt(Field.profile)); try writeString(w, e); } if (r.focus_pane) |id| { try w.writeByte(@backingInt(Field.focus_pane)); try w.writeInt(u64, id, .little); } if (r.focus_primary) |pid| { try w.writeByte(@backingInt(Field.focus_primary)); try w.writeInt(u32, pid, .little); }}
fn writeString(w: *std.Io.Writer, s: []const u8) std.Io.Writer.Error!void { try w.writeInt(u32, @intCast(s.len), .little); try w.writeAll(s);}
fn writeList(w: *std.Io.Writer, list: []const []const u8) std.Io.Writer.Error!void { try w.writeInt(u32, @intCast(list.len), .little); for (list) |s| try writeString(w, s);}
const DecodeError = error{ Malformed, OutOfMemory };
/// The request `bytes` hold, every field owned.fn decode(gpa: Allocator, bytes: []const u8) DecodeError!Request { if (!std.mem.startsWith(u8, bytes, magic)) return error.Malformed; var r: std.Io.Reader = .fixed(bytes[magic.len..]); var request: Request = .{}; errdefer request.deinit(gpa); while (r.takeByte()) |tag| { const field = std.enums.fromInt(Field, tag) orelse return error.Malformed; switch (field) { .command => request.command = try readList(gpa, &r), .cwd => request.cwd = try readString(gpa, &r), .environ => request.environ = try readList(gpa, &r), .serial_path => request.serial_path = try readString(gpa, &r), .baud => request.baud = r.takeInt(u32, .little) catch return error.Malformed, .new_tab => request.new_tab = true, .profile => request.profile = try readString(gpa, &r), .focus_pane => request.focus_pane = r.takeInt(u64, .little) catch return error.Malformed, .focus_primary => request.focus_primary = r.takeInt(u32, .little) catch return error.Malformed, } } else |_| {} return request;}
fn readString(gpa: Allocator, r: *std.Io.Reader) DecodeError![]const u8 { const len = r.takeInt(u32, .little) catch return error.Malformed; const bytes = r.take(len) catch return error.Malformed; return gpa.dupe(u8, bytes);}
fn readList(gpa: Allocator, r: *std.Io.Reader) DecodeError![]const []const u8 { const n = r.takeInt(u32, .little) catch return error.Malformed; if (n > max_request / 4) return error.Malformed; const list = try gpa.alloc([]const u8, n); var done: usize = 0; errdefer { for (list[0..done]) |s| gpa.free(s); gpa.free(list); } for (list) |*s| { s.* = try readString(gpa, r); done += 1; } return list;}
test "a request goes down the pipe and comes back the same" { const testing = std.testing; var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const sent: Request = .{ .command = &.{ "pwsh", "-NoLogo" }, .cwd = "C:\\Users\\me", .environ = &.{ "PATH=C:\\Windows", "TERM=xterm-256color" }, .serial_path = "COM3", .baud = 115200, .new_tab = true, .profile = "admin shell", .focus_pane = 7, .focus_primary = 4242, }; const bytes = try encode(arena_state.allocator(), sent); var got = try decode(testing.allocator, bytes); defer got.deinit(testing.allocator); try testing.expectEqual(@as(usize, 2), got.command.?.len); try testing.expectEqualStrings("-NoLogo", got.command.?[1]); try testing.expectEqualStrings("C:\\Users\\me", got.cwd.?); try testing.expectEqualStrings("TERM=xterm-256color", got.environ.?[1]); try testing.expectEqualStrings("COM3", got.serial_path.?); try testing.expectEqual(@as(?u32, 115200), got.baud); try testing.expect(got.new_tab); try testing.expectEqualStrings("admin shell", got.profile.?); try testing.expectEqual(@as(?u64, 7), got.focus_pane); try testing.expectEqual(@as(?u32, 4242), got.focus_primary);
var empty = try decode(testing.allocator, try encode(arena_state.allocator(), .{})); defer empty.deinit(testing.allocator); try testing.expect(empty.command == null and !empty.new_tab);
try testing.expectError(error.Malformed, decode(testing.allocator, "nonsense")); try testing.expectError(error.Malformed, decode(testing.allocator, magic ++ "\x01\xff\xff\xff\xff"));}
test "a launch hands off to the primary serving the pipe" { const testing = std.testing; var env: std.process.Environ.Map = .init(testing.allocator); defer env.deinit(); // A primary of the user's own has the pipe. const server = Server.start(testing.allocator, testing.io, &env) catch |err| switch (err) { error.Taken => return error.SkipZigTest, else => return err, }; defer server.destroy();
try testing.expect(handOff(testing.allocator, testing.io, &env, .{ .command = &.{"cmd.exe"}, .new_tab = true })); try testing.expectEqual(win32.WAIT_OBJECT_0, win32.WaitForSingleObject(server.wake.handle, 5000)); var got = server.take().?; defer got.deinit(testing.allocator); try testing.expectEqualStrings("cmd.exe", got.command.?[0]); try testing.expect(got.new_tab); try testing.expect(server.take() == null);
// A second server finds the pipe taken. try testing.expectError(error.Taken, Server.start(testing.allocator, testing.io, &env));}