diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:00:03 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:00:03 +0200 |
| commit | 2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8 (patch) | |
| tree | 7cd6274303ba1da1f8bd57371aec336dd687fb58 /vendor/libghostty-vt/src/os | |
| download | slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.gz slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.xz slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.zip | |
sl0ptty: M0 — libghostty-vt vendored, one pane, compositor, diff renderer
Design contract in DESIGN.md. libghostty-vt vendored fresh from ghostty
main@bedb6f2 (zig 0.16), built to a 14MB static lib.
M0: pty spawn (no libutil), a lib-vt terminal per pane, a screen cell buffer,
a diff emitter that repaints only what changed, raw mode, SIGWINCH, and a
~120Hz frame cap that coalesces pty reads.
Also the seed of the headless harness: --headless dumps the composited screen
without a tty, so composition is testable in milliseconds.
tests/live_m0.py covers what headless cannot: raw mode, the diff emitter,
resize propagation, and the quit path. 6/6 green.
Diffstat (limited to 'vendor/libghostty-vt/src/os')
34 files changed, 4646 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/os/TempDir.zig b/vendor/libghostty-vt/src/os/TempDir.zig new file mode 100644 index 0000000..ca23426 --- /dev/null +++ b/vendor/libghostty-vt/src/os/TempDir.zig @@ -0,0 +1,155 @@ +//! Creates a temporary directory at runtime that can be safely used to +//! store temporary data and is destroyed on deinit. +const TempDir = @This(); + +const builtin = @import("builtin"); +const std = @import("std"); +const Dir = std.Io.Dir; +const file = @import("file.zig"); +const global = @import("../global.zig"); + +const log = std.log.scoped(.tempdir); + +/// Dir is the directory handle +dir: Dir, + +/// Parent directory +parent: Dir, + +/// Name buffer that name points into. Generally do not use. To get the +/// name call the name() function. +name_buf: [file.random_basename_len:0]u8, + +/// Create the temporary directory. +pub fn init() !TempDir { + // Note: the tmp_path_buf sentinel is important because it ensures + // we actually always have random_basename_len+1 bytes of available + // space. We need that so we can set the sentinel in the case we use + // all the possible length. + var tmp_path_buf: [file.random_basename_len:0]u8 = undefined; + + const dir = dir: { + const cwd = std.Io.Dir.cwd(); + const tmp_dir = try file.allocTmpDir(std.heap.page_allocator, global.environ()); + defer file.freeTmpDir(std.heap.page_allocator, tmp_dir); + break :dir try cwd.openDir(global.io(), tmp_dir, .{}); + }; + + // We now loop forever until we can find a directory that we can create. + while (true) { + const tmp_path = try file.randomBasename(&tmp_path_buf); + tmp_path_buf[tmp_path.len] = 0; + + dir.createDir(global.io(), tmp_path, .default_dir) catch |err| switch (err) { + error.PathAlreadyExists => continue, + else => |e| return e, + }; + + return TempDir{ + .dir = try dir.openDir(global.io(), tmp_path, .{}), + .parent = dir, + .name_buf = tmp_path_buf, + }; + } +} + +/// Name returns the name of the directory. This is just the basename +/// and is not the full absolute path. +pub fn name(self: *TempDir) []const u8 { + return std.mem.sliceTo(&self.name_buf, 0); +} + +/// Finish with the temporary directory. This deletes all contents in the +/// directory. +pub fn deinit(self: *TempDir) void { + self.close(.delete); +} + +pub const CloseMode = enum { delete, retain }; + +/// Close the directory handles, optionally retaining the temporary directory +/// and its contents on disk. +pub fn close(self: *TempDir, mode: CloseMode) void { + self.dir.close(global.io()); + switch (mode) { + .delete => self.parent.deleteTree(global.io(), self.name()) catch |err| + log.err("error deleting temp dir err={}", .{err}), + .retain => {}, + } + self.parent.close(global.io()); +} + +test { + const testing = std.testing; + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + var path_len: usize = undefined; + var dir_handle: Dir.Handle = undefined; + var parent_handle: Dir.Handle = undefined; + { + var td = try init(); + errdefer td.deinit(); + + const nameval = td.name(); + try testing.expect(nameval.len > 0); + + // Can open a new handle to it proves it exists. + var dir = try td.parent.openDir(testing.io, nameval, .{}); + dir.close(testing.io); + + path_len = try td.dir.realPath(testing.io, &path_buf); + dir_handle = td.dir.handle; + parent_handle = td.parent.handle; + + // Should be deleted after we deinit. + td.deinit(); + } + + switch (builtin.os.tag) { + .freebsd, .ios, .linux, .macos => { + for ([_]Dir.Handle{ dir_handle, parent_handle }) |handle| { + const result = std.posix.system.fcntl(handle, std.posix.F.GETFD); + try testing.expectEqual(std.posix.E.BADF, std.posix.errno(result)); + } + }, + + else => {}, + } + try testing.expectError( + error.FileNotFound, + Dir.openDirAbsolute(testing.io, path_buf[0..path_len], .{}), + ); +} + +test "close retains temporary directory" { + const testing = std.testing; + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + var path_len: usize = undefined; + var dir_handle: Dir.Handle = undefined; + var parent_handle: Dir.Handle = undefined; + { + var td = try init(); + errdefer td.deinit(); + + path_len = try td.dir.realPath(testing.io, &path_buf); + dir_handle = td.dir.handle; + parent_handle = td.parent.handle; + + td.close(.retain); + } + defer Dir.deleteDirAbsolute(testing.io, path_buf[0..path_len]) catch {}; + + switch (builtin.os.tag) { + .freebsd, .ios, .linux, .macos => { + for ([_]Dir.Handle{ dir_handle, parent_handle }) |handle| { + const result = std.posix.system.fcntl(handle, std.posix.F.GETFD); + try testing.expectEqual(std.posix.E.BADF, std.posix.errno(result)); + } + }, + + else => {}, + } + var dir = try Dir.openDirAbsolute(testing.io, path_buf[0..path_len], .{}); + dir.close(testing.io); +} diff --git a/vendor/libghostty-vt/src/os/cf_release_thread.zig b/vendor/libghostty-vt/src/os/cf_release_thread.zig new file mode 100644 index 0000000..e93120b --- /dev/null +++ b/vendor/libghostty-vt/src/os/cf_release_thread.zig @@ -0,0 +1,192 @@ +//! Represents the CFRelease thread. Pools of CFTypeRefs are sent to +//! this thread to be released, so that their release callback logic +//! doesn't block the execution of a high throughput thread like the +//! renderer thread. +pub const Thread = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const macos = @import("macos"); + +const internal_os = @import("../os/main.zig"); +const global = @import("../global.zig"); +const xev = global.xev; +const BlockingQueue = @import("../datastruct/main.zig").BlockingQueue; + +const Allocator = std.mem.Allocator; +const log = std.log.scoped(.cf_release_thread); + +pub const Message = union(enum) { + /// Release a slice of CFTypeRefs. Uses alloc to free the slice after + /// releasing all the refs. + release: struct { + refs: []*anyopaque, + alloc: Allocator, + }, +}; + +/// The type used for sending messages to the thread. For now this is +/// hardcoded with a capacity. We can make this a comptime parameter in +/// the future if we want it configurable. +pub const Mailbox = BlockingQueue(Message, 64); + +/// Allocator used for some state +alloc: std.mem.Allocator, + +/// The main event loop for the thread. The user data of this loop +/// is always the allocator used to create the loop. This is a convenience +/// so that users of the loop always have an allocator. +loop: xev.Loop, + +/// This can be used to wake up the thread. +wakeup: xev.Async, +wakeup_c: xev.Completion = .{}, + +/// This can be used to stop the thread on the next loop iteration. +stop: xev.Async, +stop_c: xev.Completion = .{}, + +/// The mailbox that can be used to send this thread messages. Note +/// this is a blocking queue so if it is full you will get errors (or block). +mailbox: *Mailbox, + +flags: packed struct { + /// This is set to true only when an abnormal exit is detected. It + /// tells our mailbox system to drain and ignore all messages. + drain: bool = false, +} = .{}, + +/// Initialize the thread. This does not START the thread. This only sets +/// up all the internal state necessary prior to starting the thread. It +/// is up to the caller to start the thread with the threadMain entrypoint. +pub fn init( + alloc: Allocator, +) !Thread { + // Create our event loop. + var loop = try xev.Loop.init(.{}); + errdefer loop.deinit(); + + // This async handle is used to "wake up" the thread to collect objects. + var wakeup_h = try xev.Async.init(); + errdefer wakeup_h.deinit(); + + // This async handle is used to stop the loop and force the thread to end. + var stop_h = try xev.Async.init(); + errdefer stop_h.deinit(); + + // The mailbox for messaging this thread + var mailbox = try Mailbox.create(alloc); + errdefer mailbox.destroy(alloc); + + return Thread{ + .alloc = alloc, + .loop = loop, + .wakeup = wakeup_h, + .stop = stop_h, + .mailbox = mailbox, + }; +} + +/// Clean up the thread. This is only safe to call once the thread +/// completes executing; the caller must join prior to this. +pub fn deinit(self: *Thread) void { + self.stop.deinit(); + self.wakeup.deinit(); + self.loop.deinit(); + + // Nothing can possibly access the mailbox anymore, destroy it. + self.mailbox.destroy(self.alloc); +} + +/// The main entrypoint for the thread. +pub fn threadMain(self: *Thread) void { + // Call child function so we can use errors... + self.threadMain_() catch |err| { + log.warn("error in cf release thread err={}", .{err}); + }; + + // If our loop is not stopped, then we need to keep running so that + // messages are drained and we can wait for the surface to send a stop + // message. + if (!self.loop.stopped()) { + log.warn("abrupt cf release thread exit detected, starting xev to drain mailbox", .{}); + defer log.debug("cf release thread fully exiting after abnormal failure", .{}); + self.flags.drain = true; + self.loop.run(.until_done) catch |err| { + log.err("failed to start xev loop for draining err={}", .{err}); + }; + } +} + +fn threadMain_(self: *Thread) !void { + defer log.debug("cf release thread exited", .{}); + + // Right now, on Darwin, `std.Thread.setName` can only name the current + // thread, and we have no way to get the current thread from within it, + // so instead we use this code to name the thread instead. + if (builtin.os.tag.isDarwin()) { + internal_os.macos.pthread_setname_np(&"cf_release".*); + } + + // Start the async handlers. We start these first so that they're + // registered even if anything below fails so we can drain the mailbox. + self.wakeup.wait(&self.loop, &self.wakeup_c, Thread, self, wakeupCallback); + self.stop.wait(&self.loop, &self.stop_c, Thread, self, stopCallback); + + // Run + log.debug("starting cf release thread", .{}); + defer log.debug("starting cf release thread shutdown", .{}); + try self.loop.run(.until_done); +} + +/// Drain the mailbox, handling all the messages in our terminal implementation. +fn drainMailbox(self: *Thread) !void { + // If we're draining, we just drain the mailbox and return. + if (self.flags.drain) { + while (self.mailbox.pop(global.io())) |_| {} + return; + } + + while (self.mailbox.pop(global.io())) |message| { + // log.debug("mailbox message={}", .{message}); + switch (message) { + .release => |msg| { + for (msg.refs) |ref| macos.foundation.CFRelease(ref); + // log.debug("Released {} CFTypeRefs.", .{ msg.refs.len }); + msg.alloc.free(msg.refs); + }, + } + } +} + +fn wakeupCallback( + self_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Async.WaitError!void, +) xev.CallbackAction { + _ = r catch |err| { + log.err("error in wakeup err={}", .{err}); + return .rearm; + }; + + const t = self_.?; + + // When we wake up, we check the mailbox. Mailbox producers should + // wake up our thread after publishing. + t.drainMailbox() catch |err| + log.err("error draining mailbox err={}", .{err}); + + return .rearm; +} + +fn stopCallback( + self_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Async.WaitError!void, +) xev.CallbackAction { + _ = r catch unreachable; + self_.?.loop.stop(); + return .disarm; +} diff --git a/vendor/libghostty-vt/src/os/cgroup.zig b/vendor/libghostty-vt/src/os/cgroup.zig new file mode 100644 index 0000000..f5eacc6 --- /dev/null +++ b/vendor/libghostty-vt/src/os/cgroup.zig @@ -0,0 +1,27 @@ +const std = @import("std"); +const global = @import("../global.zig"); + +const log = std.log.scoped(.@"linux-cgroup"); + +/// Returns the path to the cgroup for the given pid. +pub fn current(buf: []u8, pid: u32) ?[]const u8 { + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + + // Read our cgroup by opening /proc/<pid>/cgroup and reading the first + // line. The first line will look something like this: + // 0::/user.slice/user-1000.slice/session-1.scope + // The cgroup path is the third field. + const path = std.fmt.bufPrint(&path_buf, "/proc/{}/cgroup", .{pid}) catch return null; + const file = std.Io.Dir.openFileAbsolute(global.io(), path, .{}) catch return null; + defer file.close(global.io()); + + var read_buf: [64]u8 = undefined; + var file_reader = file.reader(global.io(), &read_buf); + const reader = &file_reader.interface; + const len = reader.readSliceShort(buf) catch return null; + const contents = buf[0..len]; + + // Find the last ':' + const idx = std.mem.lastIndexOfScalar(u8, contents, ':') orelse return null; + return std.mem.trimEnd(u8, contents[idx + 1 ..], " \r\n"); +} diff --git a/vendor/libghostty-vt/src/os/dbus.zig b/vendor/libghostty-vt/src/os/dbus.zig new file mode 100644 index 0000000..99824db --- /dev/null +++ b/vendor/libghostty-vt/src/os/dbus.zig @@ -0,0 +1,21 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +/// Returns true if the program was launched by D-Bus activation. +/// +/// On Linux GTK, this returns true if the program was launched using D-Bus +/// activation. It will return false if Ghostty was launched any other way. +/// +/// For other platforms and app runtimes, this returns false. +pub fn launchedByDbusActivation() bool { + return switch (builtin.os.tag) { + // On Linux, D-Bus activation sets `DBUS_STARTER_ADDRESS` and + // `DBUS_STARTER_BUS_TYPE`. If these environment variables are present + // (no matter the value) we were launched by D-Bus activation. + .linux => std.posix.getenv("DBUS_STARTER_ADDRESS") != null and + std.posix.getenv("DBUS_STARTER_BUS_TYPE") != null, + + // No other system supports D-Bus so always return false. + else => false, + }; +} diff --git a/vendor/libghostty-vt/src/os/desktop.zig b/vendor/libghostty-vt/src/os/desktop.zig new file mode 100644 index 0000000..60ec51a --- /dev/null +++ b/vendor/libghostty-vt/src/os/desktop.zig @@ -0,0 +1,169 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const build_config = @import("../build_config.zig"); +const global = @import("../global.zig"); + +const c = @cImport({ + @cInclude("unistd.h"); +}); + +/// Returns true if the program was launched from a desktop environment. +/// +/// On macOS, this returns true if the program was launched from Finder. +/// +/// On Linux GTK, this returns true if the program was launched using the +/// desktop file. This also includes when `gtk-launch` is used because I +/// can't find a way to distinguish the two scenarios. +/// +/// For other platforms and app runtimes, this returns false. +pub fn launchedFromDesktop() bool { + return switch (builtin.os.tag) { + // macOS apps launched from finder or `open` always have the init + // process as their parent. + .macos => macos: { + // This special case is so that if we launch the app via the + // app bundle (i.e. via open) then we still treat it as if it + // was launched from the desktop. + if (build_config.artifact == .lib) lib: { + const env = "GHOSTTY_MAC_LAUNCH_SOURCE"; + const source = global.environ().getPosix(env) orelse break :lib; + + // Source can be "app", "cli", or "zig_run". We assume + // its the desktop only if its "app". We may want to do + // "zig_run" but at the moment there's no reason. + if (std.mem.eql(u8, source, "app")) break :macos true; + } + + break :macos c.getppid() == 1; + }, + + // On Linux and BSD, GTK sets GIO_LAUNCHED_DESKTOP_FILE and + // GIO_LAUNCHED_DESKTOP_FILE_PID. We only check the latter to see if + // we match the PID and assume that if we do, we were launched from + // the desktop file. Pid comparing catches the scenario where + // another terminal was launched from a desktop file and then launches + // Ghostty and Ghostty inherits the env. + .linux, .freebsd => ul: { + const gio_pid_str = global.environ().getPosix("GIO_LAUNCHED_DESKTOP_FILE_PID") orelse + break :ul false; + + const pid = c.getpid(); + const gio_pid = std.fmt.parseInt( + @TypeOf(pid), + gio_pid_str, + 10, + ) catch break :ul false; + + break :ul gio_pid == pid; + }, + + // TODO: This should have some logic to detect this. Perhaps std.builtin.subsystem + .windows => false, + + // iPhone/iPad is always launched from the "desktop" + .ios => true, + + else => @compileError("unsupported platform"), + }; +} + +/// The list of desktop environments that we detect. New Linux desktop +/// environments should only be added to this list if there's a specific reason +/// to differentiate between `gnome` and `other`. +pub const DesktopEnvironment = enum { + gnome, + macos, + other, + windows, +}; + +/// Detect what desktop environment we are running under. This is mainly used +/// on Linux and BSD to enable or disable certain features but there may be more uses in +/// the future. +pub fn desktopEnvironment(environ_map: *const std.process.Environ.Map) DesktopEnvironment { + return switch (comptime builtin.os.tag) { + .macos => .macos, + .windows => .windows, + .linux, .freebsd => de: { + if (@inComptime()) @compileError("Checking for the desktop environment on Linux/BSD must be done at runtime."); + + // Use $XDG_SESSION_DESKTOP to determine what DE we are using on Linux + // https://www.freedesktop.org/software/systemd/man/latest/pam_systemd.html#desktop= + if (environ_map.get("XDG_SESSION_DESKTOP")) |sd| { + if (std.ascii.eqlIgnoreCase("gnome", sd)) break :de .gnome; + if (std.ascii.eqlIgnoreCase("gnome-xorg", sd)) break :de .gnome; + } + + // If $XDG_SESSION_DESKTOP is not set, or doesn't match any known + // DE, check $XDG_CURRENT_DESKTOP. $XDG_CURRENT_DESKTOP is a + // colon-separated list of up to three desktop names, although we + // only look at the first. + // https://specifications.freedesktop.org/desktop-entry-spec/latest/recognized-keys.html + if (environ_map.get("XDG_CURRENT_DESKTOP")) |cd| { + var cd_it = std.mem.splitScalar(u8, cd, ':'); + const cd_first = cd_it.first(); + if (std.ascii.eqlIgnoreCase(cd_first, "gnome")) break :de .gnome; + } + + break :de .other; + }, + else => .other, + }; +} + +test "desktop environment" { + const testing = std.testing; + + // Always run these tests with a blank evironment map so that we don't get any + // failures from values in the "real" evironment leaking in. + switch (builtin.os.tag) { + inline .macos, .windows => |tag| { + var environ_map: std.process.Environ.Map = .init(testing.allocator); + defer environ_map.deinit(); + try testing.expectEqual(@tagName(tag), @tagName(desktopEnvironment(&environ_map))); + }, + .linux, .freebsd => { + { + var environ_map: std.process.Environ.Map = .init(testing.allocator); + defer environ_map.deinit(); + try environ_map.put("XDG_SESSION_DESKTOP", "gnome"); + try testing.expectEqual(.gnome, desktopEnvironment(&environ_map)); + } + + { + var environ_map: std.process.Environ.Map = .init(testing.allocator); + defer environ_map.deinit(); + try environ_map.put("XDG_SESSION_DESKTOP", "gnome-xorg"); + try testing.expectEqual(.gnome, desktopEnvironment(&environ_map)); + } + + { + var environ_map: std.process.Environ.Map = .init(testing.allocator); + defer environ_map.deinit(); + try environ_map.put("XDG_SESSION_DESKTOP", "foobar"); + try testing.expectEqual(.other, desktopEnvironment(&environ_map)); + } + + { + var environ_map: std.process.Environ.Map = .init(testing.allocator); + defer environ_map.deinit(); + try environ_map.put("XDG_CURRENT_DESKTOP", "GNOME"); + try testing.expectEqual(.gnome, desktopEnvironment(&environ_map)); + } + + { + var environ_map: std.process.Environ.Map = .init(testing.allocator); + defer environ_map.deinit(); + try environ_map.put("XDG_CURRENT_DESKTOP", "FOOBAR"); + try testing.expectEqual(.other, desktopEnvironment(&environ_map)); + } + + { + var environ_map: std.process.Environ.Map = .init(testing.allocator); + defer environ_map.deinit(); + try testing.expectEqual(.other, desktopEnvironment(&environ_map)); + } + }, + else => try testing.expectEqual(.other, DesktopEnvironment()), + } +} diff --git a/vendor/libghostty-vt/src/os/edit.zig b/vendor/libghostty-vt/src/os/edit.zig new file mode 100644 index 0000000..3e14bca --- /dev/null +++ b/vendor/libghostty-vt/src/os/edit.zig @@ -0,0 +1,69 @@ +const std = @import("std"); + +const global = @import("../global.zig"); +const ShellEscapeWriter = @import("shell.zig").ShellEscapeWriter; + +const log = std.log.scoped(.os_edit); + +const Options = struct { + default_editor: enum { + /// Return an error if $EDITOR or $VISUAL is not defined. + failure, + /// Default to "vi" if $EDITOR or $VISUAL is not defined. + vi, + /// Default to "nano" if $EDITOR or $VISUAL is not defined. + nano, + } = .vi, + + pub const default: Options = .{}; +}; + +pub const Error = error{NoEditorConfigured}; + +pub fn getConfigEditCommand(alloc: std.mem.Allocator, path: []const u8, options: Options) (Error || std.Io.Writer.Error || std.mem.Allocator.Error)![:0]const u8 { + const editor: []const u8 = editor: { + // VISUAL vs. EDITOR: https://unix.stackexchange.com/questions/4859/visual-vs-editor-what-s-the-difference + v: { + if (global.environ().containsUnempty(alloc, "VISUAL") catch break :v) { + break :editor global.environ().getAlloc(alloc, "VISUAL") catch break :v; + } + } + + e: { + if (global.environ().containsUnempty(alloc, "EDITOR") catch break :e) { + break :editor global.environ().getAlloc(alloc, "EDITOR") catch break :e; + } + } + + switch (options.default_editor) { + .failure => { + log.warn("$EDITOR or $VISUAL must be set to open config in a new window", .{}); + return error.NoEditorConfigured; + }, + .vi => { + log.warn("$EDITOR or $VISUAL not set, falling back to vi", .{}); + break :editor try alloc.dupe(u8, "vi"); + }, + .nano => { + log.warn("$EDITOR or $VISUAL not set, falling back to nano", .{}); + break :editor try alloc.dupe(u8, "nano"); + }, + } + }; + defer alloc.free(editor); + + var buffer: std.Io.Writer.Allocating = .init(alloc); + defer buffer.deinit(); + const writer = &buffer.writer; + + try writer.writeAll(editor); + try writer.writeByte(' '); + { + var sh: ShellEscapeWriter = .init(writer); + try sh.writer.writeAll(path); + try sh.writer.flush(); + } + try writer.flush(); + + return try buffer.toOwnedSliceSentinel(0); +} diff --git a/vendor/libghostty-vt/src/os/file.zig b/vendor/libghostty-vt/src/os/file.zig new file mode 100644 index 0000000..3c170b4 --- /dev/null +++ b/vendor/libghostty-vt/src/os/file.zig @@ -0,0 +1,147 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const global = @import("../global.zig"); +const posix = std.posix; +const windows = @import("windows.zig"); + +const log = std.log.scoped(.os); + +pub const rlimit = if (@hasDecl(posix.system, "rlimit")) posix.rlimit else struct {}; + +/// This maximizes the number of file descriptors we can have open. We +/// need to do this because each window consumes at least a handful of fds. +/// This is extracted from the Zig compiler source code. +pub fn fixMaxFiles() ?rlimit { + if (!@hasDecl(posix.system, "rlimit") or + posix.system.rlimit == void) return null; + + const old = posix.getrlimit(.NOFILE) catch { + log.warn("failed to query file handle limit, may limit max windows", .{}); + return null; // Oh well; we tried. + }; + + // If we're already at the max, we're done. + if (old.cur >= old.max) { + log.debug("file handle limit already maximized value={}", .{old.cur}); + return old; + } + + // Do a binary search for the limit. + var lim = old; + var min: posix.rlim_t = lim.cur; + var max: posix.rlim_t = 1 << 20; + // But if there's a defined upper bound, don't search, just set it. + if (lim.max != posix.RLIM.INFINITY) { + min = lim.max; + max = lim.max; + } + + while (true) { + lim.cur = min + @divTrunc(max - min, 2); // on freebsd rlim_t is signed + if (posix.setrlimit(.NOFILE, lim)) |_| { + min = lim.cur; + } else |_| { + max = lim.cur; + } + if (min + 1 >= max) break; + } + + log.debug("file handle limit raised value={}", .{lim.cur}); + return old; +} + +pub fn restoreMaxFiles(lim: rlimit) void { + if (!@hasDecl(posix.system, "rlimit")) return; + posix.setrlimit(.NOFILE, lim) catch {}; +} + +/// Return the recommended path for temporary files. Any trailing +/// path separator is stripped so callers can safely join with their +/// own separator (e.g. `"{tmp}/{name}"`). +/// +/// On Windows this calls `GetTempPathW` and allocates a UTF-8 copy +/// (or duplicates a hard-fallback string if the syscall fails). On +/// POSIX this returns `$TMPDIR`/`$TMP` (or `"/tmp"` as a fallback) +/// without allocating. Always pair with `freeTmpDir` to release any +/// allocation. +pub fn allocTmpDir(allocator: std.mem.Allocator, environ: std.process.Environ) std.mem.Allocator.Error![]const u8 { + if (builtin.os.tag == .windows) { + // GetTempPathW guarantees the result fits in MAX_PATH+1. + var buf: [windows.MAX_PATH + 1:0]u16 = undefined; + const len = windows.exp.kernel32.GetTempPathW(buf.len, &buf); + if (len > 0) { + // Trim the UTF-16 string before encoding as UT8-8 so that the + // returned slice's length matches its underlying allocation. + const trimmed = std.mem.trimEnd(u16, buf[0..len], &.{std.fs.path.sep}); + if (std.unicode.utf16LeToUtf8Alloc(allocator, trimmed)) |utf8| { + return utf8; + } else |e| switch (e) { + error.OutOfMemory => return error.OutOfMemory, + else => log.warn("failed to convert temp dir path from windows string: {}", .{e}), + } + } + return allocator.dupe(u8, "C:\\Windows\\Temp"); + } + const tmpdir = environ.getPosix("TMPDIR") orelse environ.getPosix("TMP") orelse return "/tmp"; + return std.mem.trimEnd(u8, tmpdir, &.{std.fs.path.sep}); +} + +/// Free a path returned by `allocTmpDir` if it allocated memory. +/// This is a no-op on POSIX. +pub fn freeTmpDir(allocator: std.mem.Allocator, dir: []const u8) void { + if (builtin.os.tag != .windows) return; + allocator.free(dir); +} + +const random_basename_bytes = 16; +const b64_encoder = std.base64.url_safe_no_pad.Encoder; + +pub const RandomBasenameError = error{BufferTooSmall}; + +/// Length of the basename produced by `randomBasename`. +pub const random_basename_len = b64_encoder.calcSize(random_basename_bytes); + +/// Write a random filesystem-safe base64 basename of length +/// `random_basename_len` into `buf` and return a slice over the +/// written bytes. Returns `error.BufferTooSmall` if `buf` is too +/// short. +pub fn randomBasename(buf: []u8) RandomBasenameError![]const u8 { + if (buf.len < random_basename_len) return error.BufferTooSmall; + var rand_buf: [random_basename_bytes]u8 = undefined; + global.io().random(&rand_buf); + return b64_encoder.encode(buf[0..random_basename_len], &rand_buf); +} + +/// Return a freshly-allocated path of the form `{TMPDIR}/{prefix}{random}`. +/// The caller owns the returned slice and must free it with `allocator`. +/// +/// Nothing is created on disk; this only builds the path string. Useful +/// for one-shot temporary file/socket paths where a full `TempDir` is +/// overkill. +pub fn randomTmpPath( + allocator: std.mem.Allocator, + prefix: []const u8, +) std.mem.Allocator.Error![]u8 { + var name_buf: [random_basename_len]u8 = undefined; + const basename = randomBasename(&name_buf) catch unreachable; + return std.fmt.allocPrint( + allocator, + "{s}{c}{s}{s}", + .{ global.tmpDirPath(), std.fs.path.sep, prefix, basename }, + ); +} + +test randomBasename { + const testing = std.testing; + + var buf: [random_basename_len]u8 = undefined; + const name = try randomBasename(&buf); + try testing.expectEqual(random_basename_len, name.len); + for (name) |c| { + const ok = std.ascii.isAlphanumeric(c) or c == '-' or c == '_'; + try testing.expect(ok); + } + + var small: [random_basename_len - 1]u8 = undefined; + try testing.expectError(error.BufferTooSmall, randomBasename(&small)); +} diff --git a/vendor/libghostty-vt/src/os/flatpak.zig b/vendor/libghostty-vt/src/os/flatpak.zig new file mode 100644 index 0000000..15c619d --- /dev/null +++ b/vendor/libghostty-vt/src/os/flatpak.zig @@ -0,0 +1,521 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const builtin = @import("builtin"); +const posix = std.posix; +const global = @import("../global.zig"); +const xev = global.xev; + +const log = std.log.scoped(.flatpak); + +/// Returns true if we're running in a Flatpak environment. +pub fn isFlatpak() bool { + // If we're not on Linux then we'll make this comptime false. + if (comptime builtin.os.tag != .linux) return false; + return if (std.Io.Dir.accessAbsolute(global.io(), "/.flatpak-info", .{})) + true + else |_| + false; +} + +/// A struct to help execute commands on the host via the +/// org.freedesktop.Flatpak.Development DBus module. This uses GIO/GLib +/// under the hood. +/// +/// This always spawns its own thread and maintains its own GLib event loop. +/// This makes it easy for the command to behave synchronously similar to +/// std.process.Child. +/// +/// There are lots of chances for low-hanging improvements here (automatic +/// pipes, /dev/null, etc.) but this was purpose built for my needs so +/// it doesn't have all of those. +/// +/// Requires GIO, GLib to be available and linked. +pub const FlatpakHostCommand = struct { + const fd_t = posix.fd_t; + const EnvMap = std.process.Environ.Map; + const gio_c = @import("gio_c"); + /// Flags for HostCommand method + /// + /// Ref: https://docs.flatpak.org/en/latest/libflatpak-api-reference.html#gdbus-method-org-freedesktop-Flatpak-Development.HostCommand + const Flags = packed struct(c_uint) { + /// Clear the environment + clear_env: bool = false, + /// Kill the sandbox when the caller disappears from the session bus + watch_bus: bool = false, + _reserved: std.meta.Int(.unsigned, @bitSizeOf(c_uint) - 2) = 0, + }; + + /// Argv are the arguments to call on the host with argv[0] being + /// the command to execute. + argv: []const []const u8, + + /// The cwd for the new process. If this is not set then it will use + /// the current cwd of the calling process. + cwd: ?[:0]const u8 = null, + + /// Environment variables for the child process. If this is null, this + /// does not send any environment variables. + env: ?*const EnvMap = null, + + /// File descriptors to send to the child process. It is up to the + /// caller to create the file descriptors and set them up. + stdin: fd_t, + stdout: fd_t, + stderr: fd_t, + + /// State of the process. This is updated by the dedicated thread it + /// runs in and is protected by the given lock and condition variable. + state: State = .{ .init = {} }, + state_mutex: std.Io.Mutex = .init, + state_cv: std.Io.Condition = .init, + + /// State the process is in. This can't be inspected directly, you + /// must use getters on the struct to get access. + const State = union(enum) { + /// Initial state + init: void, + + /// Error starting. The error message is only available via logs. + /// (This isn't a fundamental limitation, just didn't need the + /// error message yet) + err: void, + + /// Process started with the given pid on the host. + started: struct { + pid: u32, + loop_xev: ?*xev.Loop, + completion: ?*Completion, + subscription: gio_c.guint, + loop: *gio_c.GMainLoop, + }, + + /// Process exited + exited: struct { + pid: u32, + status: u8, + }, + }; + + pub const Completion = struct { + callback: *const fn (ud: ?*anyopaque, l: *xev.Loop, c: *Completion, r: WaitError!u8) void = noopCallback, + c_xev: xev.Completion = .{}, + userdata: ?*anyopaque = null, + timer: ?xev.Timer = null, + result: ?WaitError!u8 = null, + }; + + /// Errors that are possible from us. + pub const Error = error{ + FlatpakMustBeStarted, + FlatpakSpawnFail, + FlatpakSetupFail, + FlatpakRPCFail, + }; + + pub const WaitError = xev.Timer.RunError || Error; + + /// Spawn the command. This will start the host command. On return, + /// the pid will be available. This must only be called with the + /// state in "init". + /// + /// Precondition: The self pointer MUST be stable. + pub fn spawn(self: *FlatpakHostCommand, alloc: Allocator) !u32 { + const thread = try std.Thread.spawn(.{}, threadMain, .{ self, alloc }); + thread.setName(global.io(), "flatpak-host-command") catch {}; + // We don't track this thread, it will terminate on its own on command exit + thread.detach(); + + // Wait for the process to start or error. + self.state_mutex.lockUncancelable(global.io()); + defer self.state_mutex.unlock(global.io()); + while (self.state == .init) self.state_cv.waitUncancelable(global.io(), &self.state_mutex); + + return switch (self.state) { + .init => unreachable, + .err => Error.FlatpakSpawnFail, + .started => |v| v.pid, + .exited => |v| v.pid, + }; + } + + /// Wait for the process to end and return the exit status. This + /// can only be called ONCE. Once this returns, the state is reset. + pub fn wait(self: *FlatpakHostCommand) !u8 { + self.state_mutex.lockUncancelable(global.io()); + defer self.state_mutex.unlock(global.io()); + + while (true) { + switch (self.state) { + .init => return Error.FlatpakMustBeStarted, + .err => return Error.FlatpakSpawnFail, + .started => {}, + .exited => |v| { + self.state = .{ .init = {} }; + self.state_cv.broadcast(global.io()); + return v.status; + }, + } + + self.state_cv.waitUncancelable(global.io(), &self.state_mutex); + } + } + + /// Wait for the process to end asynchronously via libxev. This + /// can only be called ONCE. + pub fn waitXev( + self: *FlatpakHostCommand, + loop: *xev.Loop, + completion: *Completion, + comptime Userdata: type, + userdata: ?*Userdata, + comptime cb: *const fn ( + ud: ?*Userdata, + l: *xev.Loop, + c: *Completion, + r: WaitError!u8, + ) void, + ) void { + self.state_mutex.lockUncancelable(global.io()); + defer self.state_mutex.unlock(global.io()); + + completion.* = .{ + .callback = (struct { + fn callback( + ud_: ?*anyopaque, + l_inner: *xev.Loop, + c_inner: *Completion, + r: WaitError!u8, + ) void { + const ud = @as(?*Userdata, if (Userdata == void) null else @ptrCast(@alignCast(ud_))); + @call(.always_inline, cb, .{ ud, l_inner, c_inner, r }); + } + }).callback, + .userdata = userdata, + .timer = xev.Timer.init() catch unreachable, // not great, but xev timer can't fail atm + }; + + switch (self.state) { + .init => completion.result = Error.FlatpakMustBeStarted, + .err => completion.result = Error.FlatpakSpawnFail, + .started => |*v| { + v.loop_xev = loop; + v.completion = completion; + return; + }, + .exited => |v| { + completion.result = v.status; + }, + } + + completion.timer.?.run( + loop, + &completion.c_xev, + 0, + anyopaque, + completion.userdata, + (struct { + fn callback( + ud: ?*anyopaque, + l_inner: *xev.Loop, + c_inner: *xev.Completion, + r: xev.Timer.RunError!void, + ) xev.CallbackAction { + const c_outer: *Completion = @fieldParentPtr("c_xev", c_inner); + defer if (c_outer.timer) |*t| t.deinit(); + + const result = if (r) |_| c_outer.result.? else |err| err; + c_outer.callback(ud, l_inner, c_outer, result); + return .disarm; + } + }).callback, + ); + } + + /// Send a signal to the started command. This does nothing if the + /// command is not in the started state. + pub fn signal(self: *FlatpakHostCommand, sig: u8, pg: bool) !void { + const pid = pid: { + self.state_mutex.lockUncancelable(global.io()); + defer self.state_mutex.unlock(global.io()); + switch (self.state) { + .started => |v| break :pid v.pid, + else => return, + } + }; + + // Get our bus connection. + var g_err: ?*gio_c.GError = null; + defer if (g_err) |ptr| gio_c.g_error_free(ptr); + const bus = gio_c.g_bus_get_sync(gio_c.G_BUS_TYPE_SESSION, null, &g_err) orelse { + log.warn("signal error getting bus: {s}", .{g_err.?.*.message}); + return Error.FlatpakSetupFail; + }; + defer gio_c.g_object_unref(bus); + + const reply = gio_c.g_dbus_connection_call_sync( + bus, + "org.freedesktop.Flatpak", + "/org/freedesktop/Flatpak/Development", + "org.freedesktop.Flatpak.Development", + "HostCommandSignal", + gio_c.g_variant_new( + "(uub)", + pid, + sig, + @as(c_int, @intCast(@intFromBool(pg))), + ), + gio_c.G_VARIANT_TYPE("()"), + gio_c.G_DBUS_CALL_FLAGS_NONE, + gio_c.G_MAXINT, + null, + &g_err, + ); + if (g_err != null) { + log.warn("signal send error: {s}", .{g_err.?.*.message}); + return; + } + defer gio_c.g_variant_unref(reply); + } + + fn threadMain(self: *FlatpakHostCommand, alloc: Allocator) void { + // Create a new thread-local context so that all our sources go + // to this context and we can run our loop correctly. + const ctx = gio_c.g_main_context_new(); + defer gio_c.g_main_context_unref(ctx); + gio_c.g_main_context_push_thread_default(ctx); + defer gio_c.g_main_context_pop_thread_default(ctx); + + // Get our loop for the current thread + const loop = gio_c.g_main_loop_new(ctx, 1).?; + defer gio_c.g_main_loop_unref(loop); + + // Get our bus connection. This has to remain active until we exit + // the thread otherwise our signals won't be called. + var g_err: ?*gio_c.GError = null; + defer if (g_err) |ptr| gio_c.g_error_free(ptr); + const bus = gio_c.g_bus_get_sync(gio_c.G_BUS_TYPE_SESSION, null, &g_err) orelse { + log.warn("spawn error getting bus: {s}", .{g_err.?.*.message}); + self.updateState(.{ .err = {} }); + return; + }; + defer gio_c.g_object_unref(bus); + + // Spawn the command first. This will setup all our IO. + self.start(alloc, bus, loop) catch |err| { + log.warn("error starting host command: {}", .{err}); + self.updateState(.{ .err = {} }); + return; + }; + + // Run the event loop. It quits in the exit callback. + gio_c.g_main_loop_run(loop); + } + + /// Start the command. This will start the host command and set the + /// pid field on success. This will not wait for completion. + /// + /// Once this is called, the self pointer MUST remain stable. This + /// requirement is due to using GLib under the covers with callbacks. + fn start( + self: *FlatpakHostCommand, + alloc: Allocator, + bus: *gio_c.GDBusConnection, + loop: *gio_c.GMainLoop, + ) !void { + var err: ?*gio_c.GError = null; + defer if (err) |ptr| gio_c.g_error_free(ptr); + var arena_allocator = std.heap.ArenaAllocator.init(alloc); + defer arena_allocator.deinit(); + const arena = arena_allocator.allocator(); + + // Our list of file descriptors that we need to send to the process. + const fd_list = gio_c.g_unix_fd_list_new(); + defer gio_c.g_object_unref(fd_list); + if (gio_c.g_unix_fd_list_append(fd_list, self.stdin, &err) < 0) { + log.warn("error adding fd: {s}", .{err.?.*.message}); + return Error.FlatpakSetupFail; + } + if (gio_c.g_unix_fd_list_append(fd_list, self.stdout, &err) < 0) { + log.warn("error adding fd: {s}", .{err.?.*.message}); + return Error.FlatpakSetupFail; + } + if (gio_c.g_unix_fd_list_append(fd_list, self.stderr, &err) < 0) { + log.warn("error adding fd: {s}", .{err.?.*.message}); + return Error.FlatpakSetupFail; + } + + // Build our arguments for the file descriptors. + const fd_builder = gio_c.g_variant_builder_new(gio_c.G_VARIANT_TYPE("a{uh}")); + defer gio_c.g_variant_builder_unref(fd_builder); + gio_c.g_variant_builder_add(fd_builder, "{uh}", @as(c_int, 0), self.stdin); + gio_c.g_variant_builder_add(fd_builder, "{uh}", @as(c_int, 1), self.stdout); + gio_c.g_variant_builder_add(fd_builder, "{uh}", @as(c_int, 2), self.stderr); + + // Build our env vars + const env_builder = gio_c.g_variant_builder_new(gio_c.G_VARIANT_TYPE("a{ss}")); + defer gio_c.g_variant_builder_unref(env_builder); + if (self.env) |env| { + var it = env.iterator(); + while (it.next()) |pair| { + const key = try arena.dupeZ(u8, pair.key_ptr.*); + const value = try arena.dupeZ(u8, pair.value_ptr.*); + gio_c.g_variant_builder_add(env_builder, "{ss}", key.ptr, value.ptr); + } + } + + // Build our args + const args = try arena.alloc(?[*:0]u8, self.argv.len + 1); + for (0.., self.argv) |i, arg| { + const argZ = try arena.dupeZ(u8, arg); + args[i] = argZ.ptr; + } + args[args.len - 1] = null; + + // Get the cwd in case we don't have ours set. A small optimization + // would be to do this only if we need it but this isn't a + // common code path. + const g_cwd = gio_c.g_get_current_dir(); + defer gio_c.g_free(g_cwd); + + // Terminate session if Ghostty drops off the bus (e.g. due to crashes) + const flags: Flags = .{ .watch_bus = true }; + + // The params for our RPC call + const params = gio_c.g_variant_new( + "(^ay^aay@a{uh}@a{ss}u)", + @as(*const anyopaque, if (self.cwd) |*cwd| cwd.ptr else g_cwd), + args.ptr, + gio_c.g_variant_builder_end(fd_builder), + gio_c.g_variant_builder_end(env_builder), + @as(c_uint, @bitCast(flags)), + ); + _ = gio_c.g_variant_ref_sink(params); // take ownership + defer gio_c.g_variant_unref(params); + + // Subscribe to exit notifications + const subscription_id = gio_c.g_dbus_connection_signal_subscribe( + bus, + "org.freedesktop.Flatpak", + "org.freedesktop.Flatpak.Development", + "HostCommandExited", + "/org/freedesktop/Flatpak/Development", + null, + 0, + onExit, + self, + null, + ); + errdefer gio_c.g_dbus_connection_signal_unsubscribe(bus, subscription_id); + + // Go! + const reply = gio_c.g_dbus_connection_call_with_unix_fd_list_sync( + bus, + "org.freedesktop.Flatpak", + "/org/freedesktop/Flatpak/Development", + "org.freedesktop.Flatpak.Development", + "HostCommand", + params, + gio_c.G_VARIANT_TYPE("(u)"), + gio_c.G_DBUS_CALL_FLAGS_NONE, + gio_c.G_MAXINT, + fd_list, + null, + null, + &err, + ) orelse { + log.warn("Flatpak.HostCommand failed: {s}", .{err.?.*.message}); + return Error.FlatpakRPCFail; + }; + defer gio_c.g_variant_unref(reply); + + var pid: u32 = 0; + gio_c.g_variant_get(reply, "(u)", &pid); + log.debug("HostCommand started pid={} subscription={}", .{ + pid, + subscription_id, + }); + + self.updateState(.{ + .started = .{ + .pid = pid, + .subscription = subscription_id, + .loop = loop, + .completion = null, + .loop_xev = null, + }, + }); + } + + /// Helper to update the state and notify waiters via the cv. + fn updateState(self: *FlatpakHostCommand, state: State) void { + self.state_mutex.lockUncancelable(global.io()); + defer self.state_mutex.unlock(global.io()); + defer self.state_cv.broadcast(global.io()); + self.state = state; + } + + fn onExit( + bus: ?*gio_c.GDBusConnection, + _: [*c]const u8, + _: [*c]const u8, + _: [*c]const u8, + _: [*c]const u8, + params: ?*gio_c.GVariant, + ud: ?*anyopaque, + ) callconv(.c) void { + const self = @as(*FlatpakHostCommand, @ptrCast(@alignCast(ud))); + const state = state: { + self.state_mutex.lockUncancelable(global.io()); + defer self.state_mutex.unlock(global.io()); + break :state self.state.started; + }; + + var pid: u32 = 0; + var exit_status_raw: u32 = 0; + gio_c.g_variant_get(params.?, "(uu)", &pid, &exit_status_raw); + if (state.pid != pid) return; + + const exit_status = posix.W.EXITSTATUS(exit_status_raw); + // Update our state + self.updateState(.{ + .exited = .{ + .pid = pid, + .status = exit_status, + }, + }); + if (state.completion) |completion| { + completion.result = exit_status; + completion.timer.?.run( + state.loop_xev.?, + &completion.c_xev, + 0, + anyopaque, + completion.userdata, + (struct { + fn callback( + ud_inner: ?*anyopaque, + l_inner: *xev.Loop, + c_inner: *xev.Completion, + r: xev.Timer.RunError!void, + ) xev.CallbackAction { + const c_outer: *Completion = @fieldParentPtr("c_xev", c_inner); + defer if (c_outer.timer) |*t| t.deinit(); + + const result = if (r) |_| c_outer.result.? else |err| err; + c_outer.callback(ud_inner, l_inner, c_outer, result); + return .disarm; + } + }).callback, + ); + } + log.debug("HostCommand exited pid={} status={}", .{ pid, exit_status }); + + // We're done now, so we can unsubscribe + gio_c.g_dbus_connection_signal_unsubscribe(bus.?, state.subscription); + + // We are also done with our loop so we can exit. + gio_c.g_main_loop_quit(state.loop); + } + + fn noopCallback(_: ?*anyopaque, _: *xev.Loop, _: *Completion, _: WaitError!u8) void {} +}; diff --git a/vendor/libghostty-vt/src/os/homedir.zig b/vendor/libghostty-vt/src/os/homedir.zig new file mode 100644 index 0000000..8da9640 --- /dev/null +++ b/vendor/libghostty-vt/src/os/homedir.zig @@ -0,0 +1,185 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const passwd = @import("passwd.zig"); +const objc = @import("objc"); + +const Error = error{ + /// The buffer used for output is not large enough to store the value. + BufferTooSmall, +}; + +/// Determine the home directory for the currently executing user. This +/// is generally an expensive process so the value should be cached. +pub inline fn home(io: std.Io, environ_map: *const std.process.Environ.Map, buf: []u8) !?[]const u8 { + return switch (builtin.os.tag) { + .linux, .freebsd, .macos => try homeUnix(io, environ_map, buf), + .windows => homeWindows(environ_map, buf) catch return error.BufferTooSmall, + + // iOS doesn't have a user-writable home directory + .ios => null, + + else => @compileError("unimplemented"), + }; +} + +fn homeUnix(io: std.Io, environ_map: *const std.process.Environ.Map, buf: []u8) !?[]const u8 { + // First: if we have a HOME env var, then we use that. + if (environ_map.get("HOME")) |result| { + if (buf.len < result.len) return Error.BufferTooSmall; + @memcpy(buf[0..result.len], result); + return buf[0..result.len]; + } + + // On macOS: [NSFileManager defaultManager].homeDirectoryForCurrentUser.path + if (builtin.os.tag == .macos) { + const NSFileManager = objc.getClass("NSFileManager").?; + const manager = NSFileManager.msgSend(objc.Object, objc.sel("defaultManager"), .{}); + const homeURL = manager.getProperty(objc.Object, "homeDirectoryForCurrentUser"); + const homePath = homeURL.getProperty(objc.Object, "path"); + + const c_str = homePath.getProperty([*:0]const u8, "UTF8String"); + const result = std.mem.sliceTo(c_str, 0); + + if (buf.len < result.len) return Error.BufferTooSmall; + @memcpy(buf[0..result.len], result); + return buf[0..result.len]; + } + + // Everything below here will require some allocation + var tempBuf: [1024]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&tempBuf); + + // We try passwd. This doesn't work on multi-user mac but we try it anyways. + const pw = try passwd.get(fba.allocator()); + if (pw.home) |result| { + if (buf.len < result.len) return Error.BufferTooSmall; + @memcpy(buf[0..result.len], result); + return buf[0..result.len]; + } + + // If all else fails, have the shell tell us... + fba.reset(); + const run = try std.process.run(fba.allocator(), io, .{ + .argv = &[_][]const u8{ "/bin/sh", "-c", "cd && pwd" }, + .environ_map = environ_map, + .stdout_limit = .limited(fba.buffer.len / 2), + .stderr_limit = .limited(fba.buffer.len / 2), + }); + + if (run.term == .exited and run.term.exited == 0) { + const result = trimSpace(run.stdout); + if (buf.len < result.len) return Error.BufferTooSmall; + @memcpy(buf[0..result.len], result); + return buf[0..result.len]; + } + + return null; +} + +fn homeWindows(environ_map: *const std.process.Environ.Map, buf: []u8) !?[]const u8 { + var writer: std.Io.Writer = .fixed(buf); + _ = try writer.write(environ_map.get("HOMEDRIVE") orelse return null); + _ = try writer.write(environ_map.get("HOMEPATH") orelse return null); + return writer.buffered(); +} + +fn trimSpace(input: []const u8) []const u8 { + return std.mem.trim(u8, input, " \n\t"); +} + +pub const ExpandError = error{ + HomeDetectionFailed, + BufferTooSmall, +}; + +/// Expands a path that starts with a tilde (~) to the home directory of +/// the current user. +/// +/// Errors if `home` fails or if the size of the expanded path is larger +/// than `buf.len`. +pub fn expandHome( + io: std.Io, + environ_map: *const std.process.Environ.Map, + path: []const u8, + buf: []u8, +) ExpandError![]const u8 { + return switch (builtin.os.tag) { + .linux, .freebsd, .macos => try expandHomeUnix(io, environ_map, path, buf), + + // `~/` is not an idiom generally used on Windows + .windows => return path, + + // iOS doesn't have a user-writable home directory + .ios => return path, + + else => @compileError("unimplemented"), + }; +} + +fn expandHomeUnix( + io: std.Io, + environ_map: *const std.process.Environ.Map, + path: []const u8, + buf: []u8, +) ExpandError![]const u8 { + if (!std.mem.startsWith(u8, path, "~/")) return path; + const home_dir: []const u8 = if (home(io, environ_map, buf)) |home_| + home_ orelse return error.HomeDetectionFailed + else |_| + return error.HomeDetectionFailed; + const rest = path[1..]; // Skip the ~ + const expanded_len = home_dir.len + rest.len; + + if (expanded_len > buf.len) return Error.BufferTooSmall; + @memcpy(buf[home_dir.len..expanded_len], rest); + + return buf[0..expanded_len]; +} + +test "expandHomeUnix" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + const testing = std.testing; + const io = testing.io; + const allocator = testing.allocator; + var environ_map = try testing.environ.createMap(testing.allocator); + defer environ_map.deinit(); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const home_dir = try expandHomeUnix(io, &environ_map, "~/", &buf); + // Joining the home directory `~` with the path `/` + // the result should end with a separator here. (e.g. `/home/user/`) + try testing.expect(home_dir[home_dir.len - 1] == std.fs.path.sep); + + const downloads = try expandHomeUnix(io, &environ_map, "~/Downloads/shader.glsl", &buf); + const expected_downloads = try std.mem.concat(allocator, u8, &[_][]const u8{ home_dir, "Downloads/shader.glsl" }); + defer allocator.free(expected_downloads); + try testing.expectEqualStrings(expected_downloads, downloads); + + try testing.expectEqualStrings("~", try expandHomeUnix(io, &environ_map, "~", &buf)); + try testing.expectEqualStrings("~abc/", try expandHomeUnix(io, &environ_map, "~abc/", &buf)); + try testing.expectEqualStrings("/home/user", try expandHomeUnix(io, &environ_map, "/home/user", &buf)); + try testing.expectEqualStrings("", try expandHomeUnix(io, &environ_map, "", &buf)); + + // Expect an error if the buffer is large enough to hold the home directory, + // but not the expanded path + var small_buf = try allocator.alloc(u8, home_dir.len); + defer allocator.free(small_buf); + try testing.expectError(error.BufferTooSmall, expandHomeUnix( + io, + &environ_map, + "~/Downloads", + small_buf[0..], + )); +} + +test { + const testing = std.testing; + const io = testing.io; + var environ_map = try testing.environ.createMap(testing.allocator); + defer environ_map.deinit(); + + var buf: [1024]u8 = undefined; + const result = try home(io, &environ_map, &buf); + try testing.expect(result != null); + try testing.expect(result.?.len > 0); +} diff --git a/vendor/libghostty-vt/src/os/hostname.zig b/vendor/libghostty-vt/src/os/hostname.zig new file mode 100644 index 0000000..969b4ba --- /dev/null +++ b/vendor/libghostty-vt/src/os/hostname.zig @@ -0,0 +1,63 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const posix = std.posix; + +pub const LocalHostnameValidationError = error{ + PermissionDenied, + Unexpected, +}; + +/// Checks if a hostname is local to the current machine. This matches +/// both "localhost" and the current hostname of the machine (as returned +/// by `gethostname`). +pub fn isLocal(hostname: []const u8) LocalHostnameValidationError!bool { + // A 'localhost' hostname is always considered local. + if (std.mem.eql(u8, "localhost", hostname)) return true; + + // If hostname is not "localhost" it must match our hostname. + switch (builtin.os.tag) { + .windows => { + const windows = @import("windows.zig"); + var buf: [256:0]u8 = undefined; + var nSize: windows.DWORD = buf.len; + if (windows.exp.kernel32.GetComputerNameA(&buf, &nSize) == windows.FALSE) return false; + const ourHostname = buf[0..nSize]; + return std.mem.eql(u8, hostname, ourHostname); + }, + else => { + var buf: [posix.HOST_NAME_MAX]u8 = undefined; + const ourHostname = try posix.gethostname(&buf); + return std.mem.eql(u8, hostname, ourHostname); + }, + } +} + +test "isLocal returns true when provided hostname is localhost" { + try std.testing.expect(try isLocal("localhost")); +} + +test "isLocal returns true when hostname is local" { + switch (builtin.os.tag) { + .windows => { + const windows = @import("windows.zig"); + var buf: [256:0]u8 = undefined; + var nSize: windows.DWORD = buf.len; + if (windows.exp.kernel32.GetComputerNameA(&buf, &nSize) == windows.FALSE) + return error.GetComputerNameFailed; + const localHostname = buf[0..nSize]; + try std.testing.expect(try isLocal(localHostname)); + }, + else => { + var buf: [posix.HOST_NAME_MAX]u8 = undefined; + const localHostname = try posix.gethostname(&buf); + try std.testing.expect(try isLocal(localHostname)); + }, + } +} + +test "isLocal returns false when hostname is not local" { + try std.testing.expectEqual( + false, + try isLocal("not-the-local-hostname"), + ); +} diff --git a/vendor/libghostty-vt/src/os/i18n.zig b/vendor/libghostty-vt/src/os/i18n.zig new file mode 100644 index 0000000..20d3c1b --- /dev/null +++ b/vendor/libghostty-vt/src/os/i18n.zig @@ -0,0 +1,210 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const build_config = @import("../build_config.zig"); +const locales = @import("i18n_locales.zig"); + +const log = std.log.scoped(.i18n); + +/// Set for faster membership lookup of locales. +pub const locales_map = map: { + var kvs: [locales.len]struct { []const u8 } = undefined; + for (locales, 0..) |locale, i| kvs[i] = .{locale}; + break :map std.StaticStringMap(void).initComptime(kvs); +}; + +pub const InitError = error{ + InvalidResourcesDir, + OutOfMemory, +}; + +/// Initialize i18n support for the application. This should be +/// called automatically by the global state initialization +/// in global.zig. +/// +/// This calls `bindtextdomain` for gettext with the proper directory +/// of translations. This does NOT call `textdomain` as we don't +/// want to set the domain for the entire application since this is also +/// used by libghostty. +pub fn init(resources_dir: []const u8) InitError!void { + if (comptime !build_config.i18n) return; + + switch (builtin.os.tag) { + // i18n is unsupported on Windows + .windows => return, + + else => { + // Our resources dir is always nested below the share dir that + // is standard for translations. + const share_dir = std.fs.path.dirname(resources_dir) orelse + return error.InvalidResourcesDir; + + // Build our locale path + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = std.fmt.bufPrintZ(&buf, "{s}/locale", .{share_dir}) catch + return error.OutOfMemory; + + // Bind our bundle ID to the given locale path + log.debug("binding domain={s} path={s}", .{ build_config.bundle_id, path }); + _ = bindtextdomain(build_config.bundle_id, path.ptr) orelse + return error.OutOfMemory; + }, + } +} + +/// Set the global gettext domain to our bundle ID, allowing unqualified +/// `gettext` (`_`) calls to look up translations for our application. +/// +/// This should only be called for apprts that are fully owning the +/// Ghostty application. This should not be called for libghostty users. +pub fn initGlobalDomain() error{OutOfMemory}!void { + if (comptime !build_config.i18n) return; + _ = textdomain(build_config.bundle_id) orelse return error.OutOfMemory; +} + +/// Translate a message for the Ghostty domain. +/// +/// If this is called at comptime, the direct msgid is returned without +/// translation. This still allows the string to be marked for translation +/// while remaining usable in comptime contexts. +pub fn _(msgid: [*:0]const u8) [*:0]const u8 { + if (comptime !build_config.i18n) return msgid; + if (@inComptime()) return msgid; + return dgettext(build_config.bundle_id, msgid); +} + +/// Mark a string for translation without translating it immediately. +pub fn N_(msgid: [:0]const u8) [:0]const u8 { + return msgid; +} + +/// Canonicalize a locale name from a platform-specific value to +/// a POSIX-compliant value. This is a thin layer over the unexported +/// gnulib-lib function in gettext that does this already. +/// +/// The gnulib-lib function modifies the buffer in place but has +/// zero bounds checking, so we do a bit extra to ensure we don't +/// overflow the buffer. This is likely slightly more expensive but +/// this isn't a hot path so it should be fine. +/// +/// The buffer must be at least 16 bytes long. This ensures we can +/// fit the longest possible hardcoded locale name. Additionally, +/// it should be at least as long as locale in case the locale +/// is unchanged. +/// +/// Here is the logic for macOS, but other platforms also have +/// their own canonicalization logic: +/// +/// https://github.com/coreutils/gnulib/blob/5b92dd0a45c8d27f13a21076b57095ea5e220870/lib/localename.c#L1171 +pub fn canonicalizeLocale( + buf: []u8, + locale: []const u8, +) error{NoSpaceLeft}![:0]const u8 { + if (comptime !build_config.i18n) { + if (buf.len < locale.len + 1) return error.NoSpaceLeft; + @memcpy(buf[0..locale.len], locale); + buf[locale.len] = 0; + return buf[0..locale.len :0]; + } + + // Fix zh locales for macOS + if (fixZhLocale(locale)) |fixed| { + if (buf.len < fixed.len + 1) return error.NoSpaceLeft; + @memcpy(buf[0..fixed.len], fixed); + buf[fixed.len] = 0; + return buf[0..fixed.len :0]; + } + + // Buffer must be 16 or at least as long as the locale and null term + if (buf.len < @max(16, locale.len + 1)) return error.NoSpaceLeft; + + // Copy our locale into the buffer since it modifies in place. + // This must be null-terminated. + @memcpy(buf[0..locale.len], locale); + buf[locale.len] = 0; + + _libintl_locale_name_canonicalize(buf[0..locale.len :0]); + + // Convert the null-terminated result buffer into a slice. We + // need to search for the null terminator and slice it back. + // We have to use `buf` since `slice` len will exclude the + // null. + const slice = std.mem.sliceTo(buf, 0); + return buf[0..slice.len :0]; +} + +/// Handles some zh locales canonicalization because internal libintl +/// canonicalization function doesn't handle correctly in these cases. +fn fixZhLocale(locale: []const u8) ?[:0]const u8 { + var it = std.mem.splitScalar(u8, locale, '-'); + const name = it.next() orelse return null; + if (!std.mem.eql(u8, name, "zh")) return null; + + const script = it.next() orelse return null; + const region = it.next() orelse return null; + + if (std.mem.eql(u8, script, "Hans")) { + if (std.mem.eql(u8, region, "SG")) return "zh_SG"; + return "zh_CN"; + } + + if (std.mem.eql(u8, script, "Hant")) { + if (std.mem.eql(u8, region, "MO")) return "zh_MO"; + if (std.mem.eql(u8, region, "HK")) return "zh_HK"; + return "zh_TW"; + } + + return null; +} + +/// This can be called at any point a compile-time-known locale is +/// available. This will use comptime to verify the locale is supported. +pub fn staticLocale(comptime v: [*:0]const u8) [*:0]const u8 { + comptime { + for (locales) |locale| { + if (std.mem.eql(u8, locale, v)) { + return locale; + } + } + + @compileError("unsupported locale"); + } +} + +// Manually include function definitions for the gettext functions +// as libintl.h isn't always easily available (e.g. in musl) +extern fn bindtextdomain(domainname: [*:0]const u8, dirname: [*:0]const u8) ?[*:0]const u8; +extern fn textdomain(domainname: [*:0]const u8) ?[*:0]const u8; +extern fn dgettext(domainname: [*:0]const u8, msgid: [*:0]const u8) [*:0]const u8; + +// This is only available if we're building libintl from source +// since its otherwise not exported. We only need it on macOS +// currently but probably will on Windows as well. +extern fn _libintl_locale_name_canonicalize(name: [*:0]u8) void; + +test "canonicalizeLocale darwin" { + if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest; + + const testing = std.testing; + var buf: [256]u8 = undefined; + try testing.expectEqualStrings("en_US", try canonicalizeLocale(&buf, "en_US")); + try testing.expectEqualStrings("zh_CN", try canonicalizeLocale(&buf, "zh-Hans")); + try testing.expectEqualStrings("zh_TW", try canonicalizeLocale(&buf, "zh-Hant")); + + try testing.expectEqualStrings("zh_CN", try canonicalizeLocale(&buf, "zh-Hans-CN")); + try testing.expectEqualStrings("zh_SG", try canonicalizeLocale(&buf, "zh-Hans-SG")); + try testing.expectEqualStrings("zh_TW", try canonicalizeLocale(&buf, "zh-Hant-TW")); + try testing.expectEqualStrings("zh_HK", try canonicalizeLocale(&buf, "zh-Hant-HK")); + try testing.expectEqualStrings("zh_MO", try canonicalizeLocale(&buf, "zh-Hant-MO")); + + // This is just an edge case I want to make sure we're aware of: + // canonicalizeLocale does not handle encodings and will turn them into + // underscores. We should parse them out before calling this function. + try testing.expectEqualStrings("en_US.UTF_8", try canonicalizeLocale(&buf, "en_US.UTF-8")); +} + +test "_ returns msgid at comptime" { + const testing = std.testing; + + const msgid = comptime @"_"("Ghostty"); + try testing.expectEqualStrings("Ghostty", std.mem.span(msgid)); +} diff --git a/vendor/libghostty-vt/src/os/i18n_locales.zig b/vendor/libghostty-vt/src/os/i18n_locales.zig new file mode 100644 index 0000000..54fa2c9 --- /dev/null +++ b/vendor/libghostty-vt/src/os/i18n_locales.zig @@ -0,0 +1,63 @@ +// NOTE: This is in a separate file because our build depends on it and +// we want to minimize the transitive dependencies of the build binary +// itself. + +/// Supported locales for the application. This must be kept up to date +/// with the translations available in the `po/` directory; this is used +/// by our build process as well as runtime libghostty APIs. +/// +/// The order also matters. For incomplete locale information (i.e. only +/// a language code available), the first match is used. For example, if +/// we know the user requested `zh` but has no script code, then we'd pick +/// the first locale that matches `zh`. +/// +/// For ordering, we prefer: +/// +/// 1. The most common locales first, since there are places in the code +/// where we do linear searches for a locale and we want to minimize +/// the number of iterations for the common case. +/// +/// 2. Alphabetical for otherwise equally common locales. +/// +/// 3. Most preferred locale for a language without a country code. +/// +/// Note for "most common" locales, this is subjective and based on +/// the perceived userbase of Ghostty, which may not be representative +/// of general populations or global language distribution. Also note +/// that ordering may be weird when we first merge a new locale since +/// we don't have a good way to determine this. We can always reorder +/// with some data. +pub const locales = [_][:0]const u8{ + "zh_CN", + "de", + "fr", + "ja", + "nl", + "nb", + "ru", + "uk", + "pl", + "ko_KR", + "mk", + "tr", + "id", + "es_BO", + "es_AR", + "es_ES", + "pt_BR", + "ca", + "it", + "bg", + "ga", + "hu", + "he", + "zh_TW", + "hr", + "lt", + "lv", + "vi", + "kk", + "be", + "eu", + "da", +}; diff --git a/vendor/libghostty-vt/src/os/kernel_info.zig b/vendor/libghostty-vt/src/os/kernel_info.zig new file mode 100644 index 0000000..60e6b49 --- /dev/null +++ b/vendor/libghostty-vt/src/os/kernel_info.zig @@ -0,0 +1,32 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const global = @import("../global.zig"); + +pub fn getKernelInfo(alloc: std.mem.Allocator) ?[]const u8 { + if (comptime builtin.os.tag != .linux) return null; + const path = "/proc/sys/kernel/osrelease"; + var file = std.Io.Dir.openFileAbsolute(global.io(), path, .{}) catch return null; + defer file.close(global.io()); + + // 128 bytes should be enough to hold the kernel information + var kernel_info_buf: [128]u8 = undefined; + const kernel_info = kernel_info_buf[0 .. file.readPositionalAll( + global.io(), + &kernel_info_buf, + 0, + ) catch return null]; + return alloc.dupe(u8, std.mem.trim(u8, kernel_info, &std.ascii.whitespace)) catch return null; +} + +test "read /proc/sys/kernel/osrelease" { + if (comptime builtin.os.tag != .linux) return null; + const allocator = std.testing.allocator; + + const kernel_info = getKernelInfo(allocator).?; + defer allocator.free(kernel_info); + + // Since we can't hardcode the info in tests, just check + // if something was read from the file + try std.testing.expect(kernel_info.len > 0); + try std.testing.expect(!std.mem.eql(u8, kernel_info, "")); +} diff --git a/vendor/libghostty-vt/src/os/locale.c b/vendor/libghostty-vt/src/os/locale.c new file mode 100644 index 0000000..a889a57 --- /dev/null +++ b/vendor/libghostty-vt/src/os/locale.c @@ -0,0 +1 @@ +#include <locale.h> diff --git a/vendor/libghostty-vt/src/os/locale.zig b/vendor/libghostty-vt/src/os/locale.zig new file mode 100644 index 0000000..ceb3c88 --- /dev/null +++ b/vendor/libghostty-vt/src/os/locale.zig @@ -0,0 +1,232 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = @import("../quirks.zig").inlineAssert; +const macos = @import("macos"); +const objc = @import("objc"); +const internal_os = @import("main.zig"); +const i18n = internal_os.i18n; + +const log = std.log.scoped(.os_locale); + +/// Ensure that the locale is set. +pub fn ensureLocale() !void { + // This does a lot of in-process mutation of the environment and can't be + // run in a test as a result. + assert(!builtin.is_test); + + // On macOS, pre-populate the LANG env var with system preferences. + // When launching the .app, LANG is not set so we must query it from the + // OS. When launching from the CLI, LANG is usually set by the parent + // process. + if (comptime builtin.target.os.tag.isDarwin()) { + // Set the lang if it is not set or if its empty. + const lang = std.posix.system.getenv("LANG"); + if (lang == null or lang.?[0] == 0) { + setLangFromCocoa(); + } + } + + // Set the locale to whatever is set in env vars. + if (setlocale(LC_ALL, "")) |v| { + log.info("setlocale from env result={s}", .{v}); + return; + } + + if (builtin.os.tag == .windows) { + // Exit early for Windows. + // + // NOTE: There currently is no official Windows version of Ghostty, + // either by way of an official Windows port or through use of + // libghostty-internal. As such this function is likely unused on those + // platforms, so this serves as a TODO stub to support more robust + // locale support on Windows. setlocale on Windows sets an + // "implementation-defined native environment" with an empty string, so + // we are currently just doing best-effort for now. + log.info("TODO: setlocale failed on Windows, implement better fallbacks", .{}); + return; + } + + // setlocale failed. This is probably because the LANG env var is + // invalid. Try to set it without the LANG var set to use the system + // default. + if (std.posix.system.getenv("LANG")) |lang| { + if (lang[0] != 0) { + // We don't need to do both of these things but we do them + // both to be sure that lang is either empty or unset completely. + _ = setenv("LANG", "", 1); + _ = unsetenv("LANG"); + + if (setlocale(LC_ALL, "")) |v| { + log.info("setlocale after unset lang result={s}", .{v}); + + // If we try to setlocale to an unsupported locale it'll return "C" + // as the POSIX/C fallback, if that's the case we want to not use + // it and move to our fallback of en_US.UTF-8 + if (!std.mem.eql(u8, std.mem.sliceTo(v, 0), "C")) return; + } + } + } + + // Failure again... fallback to en_US.UTF-8 + log.warn("setlocale failed with LANG and system default. Falling back to en_US.UTF-8", .{}); + if (setlocale(LC_ALL, "en_US.UTF-8")) |v| { + _ = setenv("LANG", "en_US.UTF-8", 1); + log.info("setlocale default result={s}", .{v}); + return; + } else log.warn("setlocale failed even with the fallback, uncertain results", .{}); +} + +/// This sets the LANG environment variable based on the macOS system +/// preferences selected locale settings. +fn setLangFromCocoa() void { + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + // The classes we're going to need. + const NSLocale = objc.getClass("NSLocale") orelse { + log.warn("NSLocale class not found. Locale may be incorrect.", .{}); + return; + }; + + // Get our current locale and extract the language code ("en") and + // country code ("US") + const locale = NSLocale.msgSend(objc.Object, objc.sel("currentLocale"), .{}); + const lang = locale.getProperty(objc.Object, "languageCode"); + const country = locale.getProperty(objc.Object, "countryCode"); + + if (lang.value == null or country.value == null) { + log.warn("languageCode or countryCode not found. Locale may be incorrect.", .{}); + return; + } + + // Get our UTF8 string values + const c_lang = lang.getProperty([*:0]const u8, "UTF8String"); + const c_country = country.getProperty([*:0]const u8, "UTF8String"); + + // Convert them to Zig slices + const z_lang = std.mem.sliceTo(c_lang, 0); + const z_country = std.mem.sliceTo(c_country, 0); + + // Format our locale as "<lang>_<country>.UTF-8" and set it as LANG. + { + var buf: [128]u8 = undefined; + const env_value = std.fmt.bufPrintZ(&buf, "{s}_{s}.UTF-8", .{ z_lang, z_country }) catch |err| { + log.warn("error setting locale from system. err={}", .{err}); + return; + }; + log.info("detected system locale={s}", .{env_value}); + + // Set it onto our environment + if (setenv("LANG", @ptrCast(env_value), 1) < 0) { + log.warn("error setting locale env var", .{}); + return; + } + } + + // Get our preferred languages and set that to the LANGUAGE + // env var in case our language differs from our locale. + language: { + var buf: [1024]u8 = undefined; + const pref_ = preferredLanguageFromCocoa( + &buf, + NSLocale, + ) catch |err| { + log.warn("error getting preferred languages. err={}", .{err}); + break :language; + }; + + const pref = pref_ orelse break :language; + log.debug( + "setting LANGUAGE from preferred languages value={s}", + .{pref}, + ); + _ = setenv("LANGUAGE", @ptrCast(pref), 1); + } +} + +/// Sets the LANGUAGE environment variable based on the preferred languages +/// as reported by NSLocale. +/// +/// macOS has a concept of preferred languages separate from the system +/// locale. The set of preferred languages is a list in priority order +/// of what translations the user prefers. A user can have, for example, +/// "fr_FR" as their locale but "en" as their preferred language. This would +/// mean that they want to use French units, date formats, etc. but they +/// prefer English translations. +/// +/// gettext uses the LANGUAGE environment variable to override only +/// translations and a priority order can be specified by separating +/// the languages with colons. For example, "en:fr" would mean that +/// English translations are preferred but if they are not available +/// then French translations should be used. +/// +/// To further complicate things, Apple reports the languages in BCP-47 +/// format which is not compatible with gettext's POSIX locale format so +/// we have to canonicalize them. +fn preferredLanguageFromCocoa( + buf: []u8, + NSLocale: objc.Class, +) error{NoSpaceLeft}!?[:0]const u8 { + var writer: std.Io.Writer = .fixed(buf); + + // We need to get our app's preferred languages. These may not + // match the system locale (NSLocale.currentLocale). + const preferred: *macos.foundation.Array = array: { + const ns = NSLocale.msgSend( + objc.Object, + objc.sel("preferredLanguages"), + .{}, + ); + break :array @ptrCast(ns.value); + }; + for (0..preferred.getCount()) |i| { + var str_buf: [255:0]u8 = undefined; + const str = preferred.getValueAtIndex(macos.foundation.String, i); + const c_str = str.cstring(&str_buf, .utf8) orelse { + // I don't think this can happen but if it does then I want + // to know about it if a user has translation issues. + log.warn("failed to convert a preferred language to UTF-8", .{}); + continue; + }; + + // Append our separator if we have any previous languages + if (writer.end > 0) { + _ = writer.writeByte(':') catch + return error.NoSpaceLeft; + } + + // Apple languages are in BCP-47 format, and we need to + // canonicalize them to the POSIX format. + const canon = try i18n.canonicalizeLocale( + writer.buffer[writer.end..], + c_str, + ); + writer.end += canon.len; + + // The canonicalized locale never contains the encoding and + // all of our translations require UTF-8 so we add that. + _ = writer.writeAll(".UTF-8") catch return error.NoSpaceLeft; + } + + // If we had no preferred languages then we return nothing. + if (writer.end == 0) return null; + + // Null terminate it + _ = writer.writeByte(0) catch return error.NoSpaceLeft; + + // Get our slice, this won't be null terminated so we have to + // reslice it with the null terminator. + const slice = writer.buffered(); + return slice[0 .. slice.len - 1 :0]; +} + +const c = @import("locale-c"); +const LC_ALL: c_int = c.LC_ALL; +const LC_ALL_MASK: c_int = c.LC_ALL_MASK; +const locale_t = c.locale_t; +const setlocale = c.setlocale; +const newlocale = c.newlocale; +const freelocale = c.freelocale; + +extern "c" fn setenv(name: ?[*]const u8, value: ?[*]const u8, overwrite: c_int) c_int; +extern "c" fn unsetenv(name: ?[*]const u8) c_int; diff --git a/vendor/libghostty-vt/src/os/mach.zig b/vendor/libghostty-vt/src/os/mach.zig new file mode 100644 index 0000000..5f5a3ed --- /dev/null +++ b/vendor/libghostty-vt/src/os/mach.zig @@ -0,0 +1,153 @@ +const std = @import("std"); +const assert = @import("../quirks.zig").inlineAssert; +const mem = std.mem; +const Allocator = std.mem.Allocator; + +/// macOS virtual memory tags for use with mach_vm_map/mach_vm_allocate. +/// These identify memory regions in tools like vmmap and Instruments. +pub const VMTag = enum(u8) { + application_specific_1 = 240, + application_specific_2 = 241, + application_specific_3 = 242, + application_specific_4 = 243, + application_specific_5 = 244, + application_specific_6 = 245, + application_specific_7 = 246, + application_specific_8 = 247, + application_specific_9 = 248, + application_specific_10 = 249, + application_specific_11 = 250, + application_specific_12 = 251, + application_specific_13 = 252, + application_specific_14 = 253, + application_specific_15 = 254, + application_specific_16 = 255, + + // We ignore the rest because we never realistic set them. + _, + + /// Converts the tag to the format expected by mach_vm_map/mach_vm_allocate. + /// Equivalent to C macro: VM_MAKE_TAG(tag) + pub fn make(self: VMTag) i32 { + return @bitCast(@as(u32, @intFromEnum(self)) << 24); + } +}; + +/// Creates a page allocator that tags all allocated memory with the given +/// VMTag. +pub fn taggedPageAllocator(tag: VMTag) Allocator { + return .{ + // We smuggle the tag in as the context pointer. + .ptr = @ptrFromInt(@as(usize, @intFromEnum(tag))), + .vtable = &TaggedPageAllocator.vtable, + }; +} + +/// This is based heavily on the Zig 0.15.2 PageAllocator implementation, +/// with only the posix implementation. Zig 0.15.2 is MIT licensed. +const TaggedPageAllocator = struct { + // Static local next_mmap_addr_hint since this has been removed from stdlib now + var next_mmap_addr_hint: ?[*]align(std.heap.page_size_min) u8 = null; + + pub const vtable: Allocator.VTable = .{ + .alloc = alloc, + .resize = resize, + .remap = remap, + .free = free, + }; + + fn alloc(context: *anyopaque, n: usize, alignment: mem.Alignment, ra: usize) ?[*]u8 { + _ = ra; + assert(n > 0); + const tag: VMTag = @enumFromInt(@as(u8, @truncate(@intFromPtr(context)))); + return map(n, alignment, tag); + } + + fn resize(context: *anyopaque, memory: []u8, alignment: mem.Alignment, new_len: usize, return_address: usize) bool { + _ = context; + _ = alignment; + _ = return_address; + return realloc(memory, new_len, false) != null; + } + + fn remap(context: *anyopaque, memory: []u8, alignment: mem.Alignment, new_len: usize, return_address: usize) ?[*]u8 { + _ = context; + _ = alignment; + _ = return_address; + return realloc(memory, new_len, true); + } + + fn free(context: *anyopaque, memory: []u8, alignment: mem.Alignment, return_address: usize) void { + _ = context; + _ = alignment; + _ = return_address; + return unmap(@alignCast(memory)); + } + + pub fn map(n: usize, alignment: mem.Alignment, tag: VMTag) ?[*]u8 { + const page_size = std.heap.pageSize(); + if (n >= std.math.maxInt(usize) - page_size) return null; + const alignment_bytes = alignment.toByteUnits(); + + const aligned_len = mem.alignForward(usize, n, page_size); + const max_drop_len = alignment_bytes - @min(alignment_bytes, page_size); + const overalloc_len = if (max_drop_len <= aligned_len - n) + aligned_len + else + mem.alignForward(usize, aligned_len + max_drop_len, page_size); + const hint = @atomicLoad(@TypeOf(next_mmap_addr_hint), &next_mmap_addr_hint, .unordered); + const slice = std.posix.mmap( + hint, + overalloc_len, + .{ .READ = true, .WRITE = true }, + .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, + tag.make(), + 0, + ) catch return null; + const result_ptr = mem.alignPointer(slice.ptr, alignment_bytes) orelse return null; + // Unmap the extra bytes that were only requested in order to guarantee + // that the range of memory we were provided had a proper alignment in it + // somewhere. The extra bytes could be at the beginning, or end, or both. + const drop_len = result_ptr - slice.ptr; + if (drop_len != 0) std.posix.munmap(slice[0..drop_len]); + const remaining_len = overalloc_len - drop_len; + if (remaining_len > aligned_len) std.posix.munmap(@alignCast(result_ptr[aligned_len..remaining_len])); + const new_hint: [*]align(std.heap.page_size_min) u8 = @alignCast(result_ptr + aligned_len); + _ = @cmpxchgStrong(@TypeOf(next_mmap_addr_hint), &next_mmap_addr_hint, hint, new_hint, .monotonic, .monotonic); + return result_ptr; + } + + pub fn unmap(memory: []align(std.heap.page_size_min) u8) void { + const page_aligned_len = mem.alignForward(usize, memory.len, std.heap.pageSize()); + std.posix.munmap(memory.ptr[0..page_aligned_len]); + } + + pub fn realloc(uncasted_memory: []u8, new_len: usize, may_move: bool) ?[*]u8 { + const memory: []align(std.heap.page_size_min) u8 = @alignCast(uncasted_memory); + const page_size = std.heap.pageSize(); + const new_size_aligned = mem.alignForward(usize, new_len, page_size); + + const page_aligned_len = mem.alignForward(usize, memory.len, page_size); + if (new_size_aligned == page_aligned_len) + return memory.ptr; + + if (std.posix.MREMAP != void) { + // TODO: if the next_mmap_addr_hint is within the remapped range, update it + const new_memory = std.posix.mremap(memory.ptr, memory.len, new_len, .{ .MAYMOVE = may_move }, null) catch return null; + return new_memory.ptr; + } + + if (new_size_aligned < page_aligned_len) { + const ptr = memory.ptr + new_size_aligned; + // TODO: if the next_mmap_addr_hint is within the unmapped range, update it + std.posix.munmap(@alignCast(ptr[0 .. page_aligned_len - new_size_aligned])); + return memory.ptr; + } + + return null; + } +}; + +test "VMTag.make" { + try std.testing.expectEqual(@as(i32, @bitCast(@as(u32, 240) << 24)), VMTag.application_specific_1.make()); +} diff --git a/vendor/libghostty-vt/src/os/macos.zig b/vendor/libghostty-vt/src/os/macos.zig new file mode 100644 index 0000000..fcd1c3e --- /dev/null +++ b/vendor/libghostty-vt/src/os/macos.zig @@ -0,0 +1,177 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const build_config = @import("../build_config.zig"); +const assert = @import("../quirks.zig").inlineAssert; +const objc = @import("objc"); +const Allocator = std.mem.Allocator; + +/// Verifies that the running macOS system version is at least the given version. +pub fn isAtLeastVersion(major: i64, minor: i64, patch: i64) bool { + comptime assert(builtin.target.os.tag.isDarwin()); + + const NSProcessInfo = objc.getClass("NSProcessInfo").?; + const info = NSProcessInfo.msgSend(objc.Object, objc.sel("processInfo"), .{}); + return info.msgSend(bool, objc.sel("isOperatingSystemAtLeastVersion:"), .{ + NSOperatingSystemVersion{ .major = major, .minor = minor, .patch = patch }, + }); +} + +pub const AppSupportDirError = Allocator.Error || error{AppleAPIFailed}; + +/// Return the path to the application support directory for Ghostty +/// with the given sub path joined. This allocates the result using the +/// given allocator. +pub fn appSupportDir( + alloc: Allocator, + sub_path: []const u8, +) AppSupportDirError![]const u8 { + return try commonDir( + alloc, + .NSApplicationSupportDirectory, + &.{ build_config.bundle_id, sub_path }, + ); +} + +pub const CacheDirError = Allocator.Error || error{AppleAPIFailed}; + +/// Return the path to the system cache directory with the given sub path joined. +/// This allocates the result using the given allocator. +pub fn cacheDir( + alloc: Allocator, + sub_path: []const u8, +) CacheDirError![]const u8 { + return try commonDir( + alloc, + .NSCachesDirectory, + &.{ build_config.bundle_id, sub_path }, + ); +} + +pub const SetQosClassError = error{ + // The thread can't have its QoS class changed usually because + // a different pthread API was called that makes it an invalid + // target. + ThreadIncompatible, +}; + +/// Set the QoS class of the running thread. +/// +/// https://developer.apple.com/documentation/apple-silicon/tuning-your-code-s-performance-for-apple-silicon?preferredLanguage=occ +pub fn setQosClass(class: QosClass) !void { + return switch (std.posix.errno(pthread_set_qos_class_self_np( + class, + 0, + ))) { + .SUCCESS => {}, + .PERM => error.ThreadIncompatible, + + // EPERM is the only known error that can happen based on + // the man pages for pthread_set_qos_class_self_np. I haven't + // checked the XNU source code to see if there are other + // possible errors. + else => @panic("unexpected pthread_set_qos_class_self_np error"), + }; +} + +/// https://developer.apple.com/library/archive/documentation/Performance/Conceptual/power_efficiency_guidelines_osx/PrioritizeWorkAtTheTaskLevel.html#//apple_ref/doc/uid/TP40013929-CH35-SW1 +pub const QosClass = enum(c_uint) { + user_interactive = 0x21, + user_initiated = 0x19, + default = 0x15, + utility = 0x11, + background = 0x09, + unspecified = 0x00, +}; + +extern "c" fn pthread_set_qos_class_self_np( + qos_class: QosClass, + relative_priority: c_int, +) c_int; + +pub extern "c" fn pthread_setname_np( + name: [*:0]const u8, +) void; + +pub const NSOperatingSystemVersion = extern struct { + major: i64, + minor: i64, + patch: i64, +}; + +pub const NSSearchPathDirectory = enum(c_ulong) { + NSCachesDirectory = 13, + NSApplicationSupportDirectory = 14, +}; + +pub const NSSearchPathDomainMask = enum(c_ulong) { + NSUserDomainMask = 1, +}; + +fn commonDir( + alloc: Allocator, + directory: NSSearchPathDirectory, + sub_paths: []const []const u8, +) (error{AppleAPIFailed} || Allocator.Error)![]const u8 { + comptime assert(builtin.target.os.tag.isDarwin()); + + const NSFileManager = objc.getClass("NSFileManager").?; + const manager = NSFileManager.msgSend( + objc.Object, + objc.sel("defaultManager"), + .{}, + ); + + const url = manager.msgSend( + objc.Object, + objc.sel("URLForDirectory:inDomain:appropriateForURL:create:error:"), + .{ + directory, + NSSearchPathDomainMask.NSUserDomainMask, + @as(?*anyopaque, null), + true, + @as(?*anyopaque, null), + }, + ); + + if (url.value == null) return error.AppleAPIFailed; + + const path = url.getProperty(objc.Object, "path"); + const c_str = path.getProperty(?[*:0]const u8, "UTF8String") orelse + return error.AppleAPIFailed; + const base_dir = std.mem.sliceTo(c_str, 0); + + // Create a new array with base_dir as the first element + var paths = try alloc.alloc([]const u8, sub_paths.len + 1); + paths[0] = base_dir; + @memcpy(paths[1..], sub_paths); + defer alloc.free(paths); + + return try std.fs.path.join(alloc, paths); +} + +test "cacheDir paths" { + if (!builtin.target.os.tag.isDarwin()) return; + + const testing = std.testing; + const alloc = testing.allocator; + + // Test base path + { + const cache_path = try cacheDir(alloc, ""); + defer alloc.free(cache_path); + try testing.expect(std.mem.indexOf(u8, cache_path, "Caches") != null); + try testing.expect(std.mem.indexOf(u8, cache_path, build_config.bundle_id) != null); + } + + // Test with subdir + { + const cache_path = try cacheDir(alloc, "test"); + defer alloc.free(cache_path); + try testing.expect(std.mem.indexOf(u8, cache_path, "Caches") != null); + try testing.expect(std.mem.indexOf(u8, cache_path, build_config.bundle_id) != null); + + const bundle_path = try std.fmt.allocPrint(alloc, "{s}/test", .{build_config.bundle_id}); + defer alloc.free(bundle_path); + try testing.expect(std.mem.indexOf(u8, cache_path, bundle_path) != null); + } +} diff --git a/vendor/libghostty-vt/src/os/main.zig b/vendor/libghostty-vt/src/os/main.zig new file mode 100644 index 0000000..05f2a45 --- /dev/null +++ b/vendor/libghostty-vt/src/os/main.zig @@ -0,0 +1,76 @@ +//! The "os" package contains utilities for interfacing with the operating +//! system. These aren't restricted to syscalls or low-level operations, butos/main.zig +//! also OS-specific features and conventions. + +const builtin = @import("builtin"); + +const dbus = @import("dbus.zig"); +const desktop = @import("desktop.zig"); +const file = @import("file.zig"); +const flatpak = @import("flatpak.zig"); +const homedir = @import("homedir.zig"); +const locale = @import("locale.zig"); +const mouse = @import("mouse.zig"); +const openpkg = @import("open.zig"); +const pipepkg = @import("pipe.zig"); +const resourcesdir = @import("resourcesdir.zig"); +const systemd = @import("systemd.zig"); +const kernel_info = @import("kernel_info.zig"); + +// Namespaces +pub const cgroup = @import("cgroup.zig"); +pub const edit = @import("edit.zig"); +pub const hostname = @import("hostname.zig"); +pub const i18n = @import("i18n.zig"); +pub const mach = @import("mach.zig"); +pub const path = @import("path.zig"); +pub const passwd = @import("passwd.zig"); +pub const xdg = @import("xdg.zig"); +pub const windows = @import("windows.zig"); +pub const macos = @import("macos.zig"); +pub const shell = @import("shell.zig"); +pub const stderr = @import("stderr.zig"); +pub const uri = @import("uri.zig"); + +// Functions and types +pub const CFReleaseThread = @import("cf_release_thread.zig"); +pub const TempDir = @import("TempDir.zig"); +pub const launchedFromDesktop = desktop.launchedFromDesktop; +pub const launchedByDbusActivation = dbus.launchedByDbusActivation; +pub const launchedBySystemd = systemd.launchedBySystemd; +pub const desktopEnvironment = desktop.desktopEnvironment; +pub const rlimit = file.rlimit; +pub const fixMaxFiles = file.fixMaxFiles; +pub const restoreMaxFiles = file.restoreMaxFiles; +pub const randomTmpPath = file.randomTmpPath; +pub const isFlatpak = flatpak.isFlatpak; +pub const FlatpakHostCommand = flatpak.FlatpakHostCommand; +pub const home = homedir.home; +pub const expandHome = homedir.expandHome; +pub const ensureLocale = locale.ensureLocale; +pub const clickInterval = mouse.clickInterval; +pub const open = openpkg.open; +pub const OpenType = openpkg.Type; +pub const pipe = pipepkg.pipe; +pub const resourcesDir = resourcesdir.resourcesDir; +pub const ResourcesDir = resourcesdir.ResourcesDir; +pub const ShellEscapeWriter = shell.ShellEscapeWriter; +pub const getKernelInfo = kernel_info.getKernelInfo; +pub const getConfigEditCommand = edit.getConfigEditCommand; + +test { + _ = file; + _ = stderr; + _ = edit; + _ = i18n; + _ = path; + _ = uri; + _ = shell; + + if (comptime builtin.os.tag == .linux) { + _ = kernel_info; + } else if (comptime builtin.os.tag.isDarwin()) { + _ = mach; + _ = macos; + } +} diff --git a/vendor/libghostty-vt/src/os/mouse.zig b/vendor/libghostty-vt/src/os/mouse.zig new file mode 100644 index 0000000..d68bb22 --- /dev/null +++ b/vendor/libghostty-vt/src/os/mouse.zig @@ -0,0 +1,25 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const objc = @import("objc"); + +const log = std.log.scoped(.os); + +/// The system-configured double-click interval if its available. +pub fn clickInterval() ?u32 { + return switch (builtin.os.tag) { + // On macOS, we can ask the system. + .macos => macos: { + const NSEvent = objc.getClass("NSEvent") orelse { + log.err("NSEvent class not found. Can't get click interval.", .{}); + return null; + }; + + // Get the interval and convert to ms + const interval = NSEvent.msgSend(f64, objc.sel("doubleClickInterval"), .{}); + const ms = @as(u32, @intFromFloat(@ceil(interval * 1000))); + break :macos ms; + }, + + else => null, + }; +} diff --git a/vendor/libghostty-vt/src/os/open.zig b/vendor/libghostty-vt/src/os/open.zig new file mode 100644 index 0000000..bc09518 --- /dev/null +++ b/vendor/libghostty-vt/src/os/open.zig @@ -0,0 +1,101 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; +const build_config = @import("../build_config.zig"); +const apprt = @import("../apprt.zig"); +const global = @import("../global.zig"); + +const log = std.log.scoped(.@"os-open"); + +/// Open a URL in the default handling application. +/// +/// Any output on stderr is logged as a warning in the application logs. +/// Output on stdout is ignored. +/// +/// This function is purposely simple for the sake of providing some portable +/// way to open URLs. If you are implementing an apprt for Ghostty, you should +/// consider doing something special-cased for your platform. +pub fn open( + kind: apprt.action.OpenUrl.Kind, + url: []const u8, +) !void { + // On macOS, the apprt handles OSC 8 targets before this fallback. Ghostty's + // native apprt applies its allowlist, confirmation, and file safety policy. + // If a macOS embedder declines the action, fail closed rather than bypassing + // that policy by handing producer-controlled terminal output to `open`. + if (comptime builtin.os.tag == .macos) { + if (kind == .osc8) return error.UnsafeOSC8Link; + } + + var spawn_opts: std.process.SpawnOptions = switch (builtin.os.tag) { + .linux, .freebsd => .{ .argv = &.{ "xdg-open", url } }, + .windows => .{ .argv = &.{ "rundll32", "url.dll,FileProtocolHandler", url } }, + .macos => switch (kind) { + .text => .{ .argv = &.{ "open", "-t", url } }, + .html, .unknown => .{ .argv = &.{ "open", url } }, + .osc8 => unreachable, + }, + .ios => return error.Unimplemented, + else => @compileError("unsupported OS"), + }; + // Ignore anything from stdout. This must be set before spawning the + // process. + spawn_opts.stdout = .ignore; + // Pipe stderr so we can log the stderr from the command. This must be set + // before spawning the process. + spawn_opts.stderr = .pipe; + + const exe = if (comptime build_config.snap) local_env: { + // In the snap on Linux the launcher exports LD_LIBRARY_PATH + // pointing at the snap's bundled libraries. Leaking this into + // child process can can be problematic, so let's drop it from the + // env. + // + // Note that `spawn` copies the passed in `Environ.Map` into a + // fresh `Environ` block, so this is safe to release immediately + // after spawn. + var environ_map = try global.environMap(); + defer environ_map.deinit(); + _ = environ_map.orderedRemove("LD_LIBRARY_PATH"); + spawn_opts.environ_map = &environ_map; + break :local_env try std.process.spawn(global.io(), spawn_opts); + } else + // Non-snap releases don't need to alter the env. + try std.process.spawn(global.io(), spawn_opts); + + const thread = try std.Thread.spawn(.{}, openThread, .{ global.io(), exe }); + thread.detach(); +} + +test "macOS OSC 8 links have no generic opener fallback" { + if (builtin.os.tag != .macos) return error.SkipZigTest; + + try std.testing.expectError( + error.UnsafeOSC8Link, + open(.osc8, "file:///tmp/payload.command"), + ); +} + +fn openThread(io: std.Io, exe_: std.process.Child) void { + // Copy the exe so it is non-const. This is necessary because wait() + // requires a mutable reference and we can't have one as a thread + // param. + var exe = exe_; + if (exe.stderr) |stderr| { + var buffer: [256]u8 = undefined; + var stream = stderr.readerStreaming(io, &buffer); + const reader = &stream.interface; + while (true) { + const line = reader.takeDelimiterExclusive('\n') catch |outer| switch (outer) { + error.EndOfStream => break, + error.ReadFailed => break, + error.StreamTooLong => reader.take(buffer.len) catch |inner| switch (inner) { + error.ReadFailed => break, + error.EndOfStream => break, + }, + }; + log.warn("open stderr={s}", .{line}); + } + } + _ = exe.wait(io) catch {}; +} diff --git a/vendor/libghostty-vt/src/os/passwd.zig b/vendor/libghostty-vt/src/os/passwd.zig new file mode 100644 index 0000000..7c16e54 --- /dev/null +++ b/vendor/libghostty-vt/src/os/passwd.zig @@ -0,0 +1,158 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const internal_os = @import("main.zig"); +const build_config = @import("../build_config.zig"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const posix = std.posix; +const compat_fd = @import("../lib/compat/fd.zig"); + +const log = std.log.scoped(.passwd); + +// We want to be extra sure since this will force bad symbols into our import table +comptime { + if (builtin.target.cpu.arch.isWasm()) { + @compileError("passwd is not available for wasm"); + } +} + +/// Used to determine the default shell and directory on Unixes. +const c = if (builtin.os.tag != .windows) @cImport({ + @cInclude("sys/types.h"); + @cInclude("unistd.h"); + @cInclude("pwd.h"); +}) else {}; + +// Entry that is retrieved from the passwd API. This only contains the fields +// we care about. +pub const Entry = struct { + shell: ?[:0]const u8 = null, + home: ?[:0]const u8 = null, + name: ?[:0]const u8 = null, +}; + +/// Get the passwd entry for the currently executing user. +pub fn get(alloc: Allocator) !Entry { + if (builtin.os.tag == .windows) @compileError("passwd is not available on windows"); + + var buf: [1024]u8 = undefined; + var pw: c.struct_passwd = undefined; + var pw_ptr: ?*c.struct_passwd = null; + const res = c.getpwuid_r(c.getuid(), &pw, &buf, buf.len, &pw_ptr); + if (res != 0) { + log.warn("error retrieving pw entry code={d}", .{res}); + return Entry{}; + } + + if (pw_ptr == null) { + // Future: let's check if a better shell is available like zsh + log.warn("no pw entry to detect default shell, will default to 'sh'", .{}); + return Entry{}; + } + + var result: Entry = .{}; + + // If we're in flatpak then our entry is always empty so we grab it + // by shelling out to the host. note that we do HAVE an entry in the + // sandbox but only the username is correct. + if (internal_os.isFlatpak()) flatpak: { + if (comptime !build_config.flatpak) { + log.warn("flatpak detected, but this build doesn't contain flatpak support", .{}); + break :flatpak; + } + + log.info("flatpak detected, will use host command to get our entry", .{}); + + // Note: we wrap our getent call in a /bin/sh login shell because + // some operating systems (NixOS tested) don't set the PATH for various + // utilities properly until we get a login shell. + const Pty = @import("../pty.zig").Pty; + var pty = try Pty.open(.{}); + defer pty.deinit(); + var cmd: internal_os.FlatpakHostCommand = .{ + .argv = &[_][]const u8{ + "/bin/sh", + "-l", + "-c", + try std.fmt.allocPrint( + alloc, + "getent passwd {s}", + .{std.mem.sliceTo(pw.pw_name, 0)}, + ), + }, + .stdin = pty.slave, + .stdout = pty.slave, + .stderr = pty.slave, + }; + _ = try cmd.spawn(alloc); + _ = try cmd.wait(); + + // Once started, we can close the child side. We do this after + // wait right now but that is fine too. This lets us read the + // parent and detect EOF. + _ = compat_fd.close(pty.slave); + + // Read all of our output + const output = output: { + var output: std.ArrayListUnmanaged(u8) = .empty; + while (true) { + const n = posix.read(pty.master, &buf) catch |err| { + switch (err) { + // EIO is triggered at the end since we closed our + // child side. This is just EOF for this. I'm not sure + // if I'm doing this wrong. + error.InputOutput => break, + else => return err, + } + }; + + try output.appendSlice(alloc, buf[0..n]); + + // Max total size is buf.len. We can do better here by trimming + // the front and continuing reading but we choose to just exit. + if (output.items.len > buf.len) break; + } + + break :output try output.toOwnedSlice(alloc); + }; + + // Shell and home are the last two entries + var it = std.mem.splitBackwardsScalar(u8, std.mem.trimEnd(u8, output, " \r\n"), ':'); + result.shell = if (it.next()) |v| try alloc.dupeZ(u8, v) else null; + result.home = if (it.next()) |v| try alloc.dupeZ(u8, v) else null; + return result; + } + + if (pw.pw_shell) |ptr| { + const source = std.mem.sliceTo(ptr, 0); + const value = try alloc.dupeZ(u8, source); + result.shell = value; + } + + if (pw.pw_dir) |ptr| { + const source = std.mem.sliceTo(ptr, 0); + const value = try alloc.dupeZ(u8, source); + result.home = value; + } + + if (pw.pw_name) |ptr| { + const source = std.mem.sliceTo(ptr, 0); + const value = try alloc.dupeZ(u8, source); + result.name = value; + } + + return result; +} + +test { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // We should be able to get an entry + const entry = try get(alloc); + try testing.expect(entry.shell != null); + try testing.expect(entry.home != null); +} diff --git a/vendor/libghostty-vt/src/os/path.zig b/vendor/libghostty-vt/src/os/path.zig new file mode 100644 index 0000000..ba1a63f --- /dev/null +++ b/vendor/libghostty-vt/src/os/path.zig @@ -0,0 +1,100 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; +const testing = std.testing; + +/// Search for "cmd" in the PATH and return the absolute path. This will +/// always allocate if there is a non-null result. The caller must free the +/// resulting value. +pub fn expand( + io: std.Io, + alloc: Allocator, + environ_map: *const std.process.Environ.Map, + cmd: []const u8, +) !?[]u8 { + // If the command already contains a slash, then we return it as-is + // because it is assumed to be absolute or relative. + if (std.mem.indexOfScalar(u8, cmd, '/') != null) { + return try alloc.dupe(u8, cmd); + } + + const PATH = environ_map.get("PATH") orelse return null; + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + var it = std.mem.tokenizeScalar(u8, PATH, std.fs.path.delimiter); + var seen_eacces = false; + while (it.next()) |search_path| { + // We need enough space in our path buffer to store this + const path_len = search_path.len + cmd.len + 1; + if (path_buf.len < path_len) return error.PathTooLong; + + // Copy in the full path + @memcpy(path_buf[0..search_path.len], search_path); + path_buf[search_path.len] = std.fs.path.sep; + @memcpy(path_buf[search_path.len + 1 ..][0..cmd.len], cmd); + path_buf[path_len] = 0; + const full_path = path_buf[0..path_len :0]; + + // Stat it + const f = std.Io.Dir.cwd().openFile( + io, + full_path, + .{}, + ) catch |err| switch (err) { + error.FileNotFound => continue, + error.AccessDenied => { + // Accumulate this and return it later so we can try other + // paths that we have access to. + seen_eacces = true; + continue; + }, + else => return err, + }; + defer f.close(io); + const stat = try f.stat(io); + if (stat.kind != .directory and isExecutable(stat.permissions)) { + return try alloc.dupe(u8, full_path); + } + } + + if (seen_eacces) return error.AccessDenied; + + return null; +} + +fn isExecutable(perms: std.Io.File.Permissions) bool { + return switch (builtin.os.tag) { + .windows => true, + else => posix: { + break :posix switch (std.posix.mode_t) { + u0 => true, + else => perms.toMode() & 0o0111 != 0, + }; + }, + }; +} + +// `uname -n` is the *nix equivalent of `hostname.exe` on Windows +test "expand: hostname" { + var environ_map = try testing.environ.createMap(testing.allocator); + defer environ_map.deinit(); + const executable = if (builtin.os.tag == .windows) "hostname.exe" else "uname"; + const path = (try expand(testing.io, testing.allocator, &environ_map, executable)).?; + defer testing.allocator.free(path); + try testing.expect(path.len > executable.len); +} + +test "expand: does not exist" { + var environ_map = try testing.environ.createMap(testing.allocator); + defer environ_map.deinit(); + const path = try expand(testing.io, testing.allocator, &environ_map, "thisreallyprobablydoesntexist123"); + try testing.expect(path == null); +} + +test "expand: slash" { + var environ_map = try testing.environ.createMap(testing.allocator); + defer environ_map.deinit(); + const path = (try expand(testing.io, testing.allocator, &environ_map, "foo/env")).?; + defer testing.allocator.free(path); + try testing.expect(path.len == 7); +} diff --git a/vendor/libghostty-vt/src/os/pipe.zig b/vendor/libghostty-vt/src/os/pipe.zig new file mode 100644 index 0000000..0310fb3 --- /dev/null +++ b/vendor/libghostty-vt/src/os/pipe.zig @@ -0,0 +1,22 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const windows = @import("windows.zig"); +const posix = std.posix; +const compat_fd = @import("../lib/compat/fd.zig"); + +/// pipe() that works on Windows and POSIX. For POSIX systems, this sets +/// CLOEXEC on the file descriptors. +pub fn pipe() ![2]posix.fd_t { + switch (builtin.os.tag) { + else => return compat_fd.pipe2(.{ .CLOEXEC = true }), + .windows => { + var read: windows.HANDLE = undefined; + var write: windows.HANDLE = undefined; + if (windows.exp.kernel32.CreatePipe(&read, &write, null, 0) == windows.FALSE) { + return windows.unexpectedError(windows.GetLastError()); + } + + return .{ read, write }; + }, + } +} diff --git a/vendor/libghostty-vt/src/os/resourcesdir.zig b/vendor/libghostty-vt/src/os/resourcesdir.zig new file mode 100644 index 0000000..ef8b5a2 --- /dev/null +++ b/vendor/libghostty-vt/src/os/resourcesdir.zig @@ -0,0 +1,148 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; +const global = @import("../global.zig"); + +pub const ResourcesDir = struct { + /// Avoid accessing these directly, use the app() and host() methods instead. + app_path: ?[]const u8 = null, + host_path: ?[]const u8 = null, + + /// Free resources held. Requires the same allocator as when resourcesDir() + /// is called. + pub fn deinit(self: *ResourcesDir, alloc: Allocator) void { + if (self.app_path) |p| alloc.free(p); + if (self.host_path) |p| alloc.free(p); + } + + /// Get the directory to the bundled resources directory accessible + /// by the application. + pub fn app(self: *const ResourcesDir) ?[]const u8 { + return self.app_path; + } + + /// Get the directory to the bundled resources directory accessible + /// by the host environment (i.e. for sandboxed applications). The + /// returned directory might not be accessible from the application + /// itself. + /// + /// In non-sandboxed environment, this should be the same as app(). + pub fn host(self: *const ResourcesDir) ?[]const u8 { + return self.host_path orelse self.app_path; + } +}; + +/// Gets the directory to the bundled resources directory, if it +/// exists (not all platforms or packages have it). The output is +/// owned by the caller. +/// +/// This is highly Ghostty-specific and can likely be generalized at +/// some point but we can cross that bridge if we ever need to. +pub fn resourcesDir(alloc: Allocator) !ResourcesDir { + // Use the GHOSTTY_RESOURCES_DIR environment variable in release builds. + // + // In debug builds we try using terminfo detection first instead, since + // if debug Ghostty is launched by an older version of Ghostty, it + // would inherit the old, stale resources of older Ghostty instead of the + // freshly built ones under zig-out/share/ghostty. + // + // Note: we ALWAYS want to allocate here because the result is always + // freed, do not try to use internal_os.getenv or posix getenv. + if (comptime builtin.mode != .Debug) env: { + const dir = global.environ().getAlloc(alloc, "GHOSTTY_RESOURCES_DIR") catch |err| switch (err) { + error.EnvironmentVariableMissing => break :env, + else => return err, + }; + + if (dir.len > 0) return .{ .app_path = dir }; + } + + // This is the sentinel value we look for in the path to know + // we've found the resources directory. + const sentinels = switch (comptime builtin.target.os.tag) { + .windows => .{"terminfo/ghostty.terminfo"}, + .macos => .{"terminfo/78/xterm-ghostty"}, + .freebsd => .{ "site-terminfo/g/ghostty", "site-terminfo/x/xterm-ghostty" }, + else => .{ "terminfo/g/ghostty", "terminfo/x/xterm-ghostty" }, + }; + + // Get the path to our running binary + var exe_buf: [std.fs.max_path_bytes]u8 = undefined; + var exe: []const u8 = exe_buf[0 .. std.process.executablePath( + global.io(), + &exe_buf, + ) catch return .{}]; + + // We have an exe path! Climb the tree looking for the terminfo + // bundle as we expect it. + var dir_buf: [std.fs.max_path_bytes]u8 = undefined; + while (std.fs.path.dirname(exe)) |dir| { + exe = dir; + + // On MacOS, we look for the app bundle path. + if (comptime builtin.target.os.tag.isDarwin()) { + inline for (sentinels) |sentinel| { + if (try maybeDir( + &dir_buf, + dir, + "Contents/Resources", + sentinel, + )) |v| { + return .{ .app_path = try std.fs.path.join(alloc, &.{ v, "ghostty" }) }; + } + } + } + + // On all platforms (except BSD), we look for a /usr/share style path. This + // is valid even on Mac since there is nothing that requires + // Ghostty to be in an app bundle. + inline for (sentinels) |sentinel| { + if (try maybeDir( + &dir_buf, + dir, + if (builtin.target.os.tag == .freebsd) "local/share" else "share", + sentinel, + )) |v| { + return .{ .app_path = try std.fs.path.join(alloc, &.{ v, "ghostty" }) }; + } + } + } + + // If terminfo detection failed in debug builds (somehow), + // fallback and use the provided resources dir. + if (comptime builtin.mode == .Debug) { + if (global.environ().getAlloc(alloc, "GHOSTTY_RESOURCES_DIR")) |dir| { + if (dir.len > 0) return .{ .app_path = dir }; + } else |err| switch (err) { + error.InvalidWtf8, error.EnvironmentVariableMissing => {}, + else => return err, + } + } + + return .{}; +} + +/// Little helper to check if the "base/sub/suffix" directory exists and +/// if so return true. The "suffix" is just used as a way to verify a directory +/// seems roughly right. +/// +/// "buf" must be large enough to fit base + sub + suffix. This is generally +/// max_path_bytes so its not a big deal. +pub fn maybeDir( + buf: []u8, + base: []const u8, + sub: []const u8, + suffix: []const u8, +) !?[]const u8 { + const path = try std.fmt.bufPrint(buf, "{s}/{s}/{s}", .{ base, sub, suffix }); + + if (std.Io.Dir.accessAbsolute(global.io(), path, .{})) { + const len = path.len - suffix.len - 1; + return buf[0..len]; + } else |_| { + // Folder doesn't exist. If a different error happens its okay + // we just ignore it and move on. + } + + return null; +} diff --git a/vendor/libghostty-vt/src/os/shell.zig b/vendor/libghostty-vt/src/os/shell.zig new file mode 100644 index 0000000..fe8f1b2 --- /dev/null +++ b/vendor/libghostty-vt/src/os/shell.zig @@ -0,0 +1,196 @@ +const std = @import("std"); +const testing = std.testing; +const Allocator = std.mem.Allocator; +const Writer = std.Io.Writer; + +/// Builder for constructing space-separated shell command strings. +/// Uses a caller-provided allocator (typically with stackFallback). +pub const ShellCommandBuilder = struct { + buffer: std.Io.Writer.Allocating, + + pub fn init(allocator: Allocator) ShellCommandBuilder { + return .{ .buffer = .init(allocator) }; + } + + pub fn deinit(self: *ShellCommandBuilder) void { + self.buffer.deinit(); + } + + /// Append an argument to the command with automatic space separation. + pub fn appendArg(self: *ShellCommandBuilder, arg: []const u8) (Allocator.Error || Writer.Error)!void { + if (arg.len == 0) return; + if (self.buffer.written().len > 0) { + try self.buffer.writer.writeByte(' '); + } + try self.buffer.writer.writeAll(arg); + } + + /// Get the final null-terminated command string, transferring ownership to caller. + /// Calling deinit() after this is safe but unnecessary. + pub fn toOwnedSlice(self: *ShellCommandBuilder) Allocator.Error![:0]const u8 { + return try self.buffer.toOwnedSliceSentinel(0); + } +}; + +test ShellCommandBuilder { + // Empty command + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try testing.expectEqualStrings("", cmd.buffer.written()); + } + + // Single arg + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try cmd.appendArg("bash"); + try testing.expectEqualStrings("bash", cmd.buffer.written()); + } + + // Multiple args + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try cmd.appendArg("bash"); + try cmd.appendArg("--posix"); + try cmd.appendArg("-l"); + try testing.expectEqualStrings("bash --posix -l", cmd.buffer.written()); + } + + // Empty arg + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try cmd.appendArg("bash"); + try cmd.appendArg(""); + try testing.expectEqualStrings("bash", cmd.buffer.written()); + } + + // toOwnedSlice + { + var cmd = ShellCommandBuilder.init(testing.allocator); + try cmd.appendArg("bash"); + try cmd.appendArg("--posix"); + const result = try cmd.toOwnedSlice(); + defer testing.allocator.free(result); + try testing.expectEqualStrings("bash --posix", result); + try testing.expectEqual(@as(u8, 0), result[result.len]); + } +} + +/// Writer that escapes characters that shells treat specially to reduce the +/// risk of injection attacks or other such weirdness. Specifically excludes +/// linefeeds so that they can be used to delineate lists of file paths. +pub const ShellEscapeWriter = struct { + writer: Writer, + child: *Writer, + + pub fn init(child: *Writer) ShellEscapeWriter { + return .{ + .writer = .{ + // TODO: Actually use a buffer here + .buffer = &.{}, + .vtable = &.{ .drain = ShellEscapeWriter.drain }, + }, + .child = child, + }; + } + + fn drain(w: *Writer, data: []const []const u8, splat: usize) Writer.Error!usize { + const self: *ShellEscapeWriter = @fieldParentPtr("writer", w); + + // TODO: This is a very naive implementation and does not really make + // full use of the post-Writergate API. However, since we know that + // this is going into an Allocating writer anyways, we can be a bit + // less strict here. + + var count: usize = 0; + for (data[0 .. data.len - 1]) |chunk| try self.writeEscaped(chunk, &count); + + for (0..splat) |_| try self.writeEscaped(data[data.len - 1], &count); + return count; + } + + fn writeEscaped( + self: *ShellEscapeWriter, + s: []const u8, + count: *usize, + ) Writer.Error!void { + for (s) |byte| { + const buf = switch (byte) { + '\\', + '"', + '\'', + '$', + '`', + '*', + '?', + ' ', + '|', + '(', + ')', + => &[_]u8{ '\\', byte }, + else => &[_]u8{byte}, + }; + try self.child.writeAll(buf); + count.* += 1; + } + } +}; + +test "shell escape 1" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + var shell: ShellEscapeWriter = .init(&writer); + try shell.writer.writeAll("abc"); + try testing.expectEqualStrings("abc", writer.buffered()); +} + +test "shell escape 2" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + var shell: ShellEscapeWriter = .init(&writer); + try shell.writer.writeAll("a c"); + try testing.expectEqualStrings("a\\ c", writer.buffered()); +} + +test "shell escape 3" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + var shell: ShellEscapeWriter = .init(&writer); + try shell.writer.writeAll("a?c"); + try testing.expectEqualStrings("a\\?c", writer.buffered()); +} + +test "shell escape 4" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + var shell: ShellEscapeWriter = .init(&writer); + try shell.writer.writeAll("a\\c"); + try testing.expectEqualStrings("a\\\\c", writer.buffered()); +} + +test "shell escape 5" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + var shell: ShellEscapeWriter = .init(&writer); + try shell.writer.writeAll("a|c"); + try testing.expectEqualStrings("a\\|c", writer.buffered()); +} + +test "shell escape 6" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + var shell: ShellEscapeWriter = .init(&writer); + try shell.writer.writeAll("a\"c"); + try testing.expectEqualStrings("a\\\"c", writer.buffered()); +} + +test "shell escape 7" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + var shell: ShellEscapeWriter = .init(&writer); + try shell.writer.writeAll("a(1)"); + try testing.expectEqualStrings("a\\(1\\)", writer.buffered()); +} diff --git a/vendor/libghostty-vt/src/os/stderr.zig b/vendor/libghostty-vt/src/os/stderr.zig new file mode 100644 index 0000000..201bc43 --- /dev/null +++ b/vendor/libghostty-vt/src/os/stderr.zig @@ -0,0 +1,60 @@ +//! Raw, best-effort stderr writing that bypasses `std.Io` entirely. +//! +//! `std.debug.lockStderr` (and everything layered on top of it) routes +//! through `std.Options.debug_io`, whose default implementation is +//! `std.Io.Threaded`. This keeps ~70KB of unreachable code in binaries. + +const std = @import("std"); +const builtin = @import("builtin"); + +/// Write bytes to stderr using the most primitive mechanism available +/// for the target. This is best-effort: errors are ignored, since this +/// is only used for diagnostics (logging and panic messages). +/// +/// Freestanding targets (e.g. wasm) have no stderr, so this is a no-op +/// there. +pub fn write(bytes: []const u8) void { + switch (builtin.os.tag) { + .freestanding, .other => {}, + + .windows => { + const windows = std.os.windows; + const handle = windows.peb().ProcessParameters.hStdError; + var iosb: windows.IO_STATUS_BLOCK = undefined; + _ = windows.ntdll.NtWriteFile( + handle, + null, // event + null, // APC routine + null, // APC context + &iosb, + bytes.ptr, + @intCast(bytes.len), + null, // byte offset + null, // key + ); + }, + + else => { + const posix = std.posix; + var i: usize = 0; + while (i < bytes.len) { + const rc = posix.system.write( + posix.STDERR_FILENO, + bytes[i..].ptr, + bytes.len - i, + ); + switch (posix.errno(rc)) { + .SUCCESS => i += @as(usize, @intCast(rc)), + .INTR => continue, + else => return, + } + } + }, + } +} + +test write { + // Smoke test: must not crash. We can't assert on the output without + // capturing stderr, which isn't worth the complexity here. + write(""); +} diff --git a/vendor/libghostty-vt/src/os/string_encoding.zig b/vendor/libghostty-vt/src/os/string_encoding.zig new file mode 100644 index 0000000..875a5bc --- /dev/null +++ b/vendor/libghostty-vt/src/os/string_encoding.zig @@ -0,0 +1,307 @@ +const std = @import("std"); + +/// Decode date from the buffer that has been encoded in the same way that +/// `bash`'s `printf %q` encodes a string and write it to the writer. If an +/// error is returned garbage may have been written to the buffer. +pub fn printfQDecode(writer: *std.Io.Writer, buf: []const u8) (std.Io.Writer.Error || error{DecodeError})!void { + const data: []const u8 = data: { + // Strip off `$''` quoting. + if (std.mem.startsWith(u8, buf, "$'")) { + if (buf.len < 3 or !std.mem.endsWith(u8, buf, "'")) return error.DecodeError; + break :data buf[2 .. buf.len - 1]; + } + // Strip off `''` quoting. + if (std.mem.startsWith(u8, buf, "'")) { + if (buf.len < 2 or !std.mem.endsWith(u8, buf, "'")) return error.DecodeError; + break :data buf[1 .. buf.len - 1]; + } + break :data buf; + }; + + var src: usize = 0; + + while (src < data.len) { + switch (data[src]) { + else => { + try writer.writeByte(data[src]); + src += 1; + }, + '\\' => { + if (src + 1 >= data.len) return error.DecodeError; + switch (data[src + 1]) { + ' ', + '\\', + '"', + '\'', + '$', + => |c| { + try writer.writeByte(c); + src += 2; + }, + 'e' => { + try writer.writeByte(std.ascii.control_code.esc); + src += 2; + }, + 'n' => { + try writer.writeByte(std.ascii.control_code.lf); + src += 2; + }, + 'r' => { + try writer.writeByte(std.ascii.control_code.cr); + src += 2; + }, + 't' => { + try writer.writeByte(std.ascii.control_code.ht); + src += 2; + }, + 'v' => { + try writer.writeByte(std.ascii.control_code.vt); + src += 2; + }, + else => return error.DecodeError, + } + }, + } + } +} + +test "printf_q 1" { + var w: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer w.deinit(); + + const s: []const u8 = "bobr\\ kurwa"; + + try printfQDecode(&w.writer, s); + try std.testing.expectEqualStrings("bobr kurwa", w.written()); +} + +test "printf_q 2" { + var w: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer w.deinit(); + + const s: [:0]const u8 = "bobr\\nkurwa"; + + try printfQDecode(&w.writer, s); + try std.testing.expectEqualStrings("bobr\nkurwa", w.written()); +} + +test "printf_q 3" { + var w: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer w.deinit(); + + const s: [:0]const u8 = "bobr\\dkurwa"; + + try std.testing.expectError(error.DecodeError, printfQDecode(&w.writer, s)); +} + +test "printf_q 4" { + var w: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer w.deinit(); + + const s: [:0]const u8 = "bobr kurwa\\"; + + try std.testing.expectError(error.DecodeError, printfQDecode(&w.writer, s)); +} + +test "printf_q 5" { + var w: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer w.deinit(); + + const s: [:0]const u8 = "$'bobr kurwa'"; + + try printfQDecode(&w.writer, s); + try std.testing.expectEqualStrings("bobr kurwa", w.written()); +} + +test "printf_q 6" { + var w: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer w.deinit(); + + const s: [:0]const u8 = "'bobr kurwa'"; + + try printfQDecode(&w.writer, s); + try std.testing.expectEqualStrings("bobr kurwa", w.written()); +} + +test "printf_q 7" { + var w: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer w.deinit(); + + const s: [:0]const u8 = "$'bobr kurwa"; + + try std.testing.expectError(error.DecodeError, printfQDecode(&w.writer, s)); +} + +test "printf_q 8" { + var w: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer w.deinit(); + const s: [:0]const u8 = "$'"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&w.writer, s)); +} + +test "printf_q 9" { + var w: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer w.deinit(); + const s: [:0]const u8 = "'bobr kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&w.writer, s)); +} + +test "printf_q 10" { + var w: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer w.deinit(); + + const s: [:0]const u8 = "'"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&w.writer, s)); +} + +/// Decode data from the buffer that has been URL percent encoded and write +/// it to the given buffer. If an error is returned the garbage may have been +/// written to the writer. +pub fn urlPercentDecode(writer: *std.Io.Writer, buf: []const u8) (std.Io.Writer.Error || error{DecodeError})!void { + var src: usize = 0; + while (src < buf.len) { + switch (buf[src]) { + else => { + try writer.writeByte(buf[src]); + src += 1; + }, + '%' => { + if (src + 2 >= buf.len) return error.DecodeError; + switch (buf[src + 1]) { + '0'...'9', 'a'...'f', 'A'...'F' => { + switch (buf[src + 2]) { + '0'...'9', 'a'...'f', 'A'...'F' => { + try writer.writeByte(std.math.shl(u8, hex(buf[src + 1]), 4) | hex(buf[src + 2])); + src += 3; + }, + else => return error.DecodeError, + } + }, + else => return error.DecodeError, + } + }, + } + } +} + +inline fn hex(c: u8) u4 { + switch (c) { + '0'...'9' => return @truncate(c - '0'), + 'a'...'f' => return @truncate(c - 'a' + 10), + 'A'...'F' => return @truncate(c - 'A' + 10), + else => unreachable, + } +} + +test "singles percent" { + for (0..255) |c| { + var w: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer w.deinit(); + + var buf_: [4]u8 = undefined; + const buf = try std.fmt.bufPrintZ(&buf_, "%{x:0>2}", .{c}); + + try urlPercentDecode(&w.writer, buf); + const decoded = w.written(); + + try std.testing.expectEqual(1, decoded.len); + try std.testing.expectEqual(c, decoded[0]); + } + for (0..255) |c| { + var w: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer w.deinit(); + + var buf_: [4]u8 = undefined; + const buf = try std.fmt.bufPrintZ(&buf_, "%{X:0>2}", .{c}); + + try urlPercentDecode(&w.writer, buf); + const decoded = w.written(); + + try std.testing.expectEqual(1, decoded.len); + try std.testing.expectEqual(c, decoded[0]); + } +} + +test "percent 1" { + var w: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer w.deinit(); + + const s: []const u8 = "bobr%20kurwa"; + + try urlPercentDecode(&w.writer, s); + try std.testing.expectEqualStrings("bobr kurwa", w.written()); +} + +test "percent 2" { + var w: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer w.deinit(); + + const s: []const u8 = "bobr%2kurwa"; + + try std.testing.expectError(error.DecodeError, urlPercentDecode(&w.writer, s)); +} + +test "percent 3" { + var w: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer w.deinit(); + + const s: []const u8 = "bobr%kurwa"; + + try std.testing.expectError(error.DecodeError, urlPercentDecode(&w.writer, s)); +} + +test "percent 4" { + var w: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer w.deinit(); + + const s: []const u8 = "bobr%%kurwa"; + + try std.testing.expectError(error.DecodeError, urlPercentDecode(&w.writer, s)); +} + +test "percent 5" { + var w: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer w.deinit(); + + const s: []const u8 = "bobr%20kurwa%20"; + + try urlPercentDecode(&w.writer, s); + try std.testing.expectEqualStrings("bobr kurwa ", w.written()); +} + +test "percent 6" { + var w: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer w.deinit(); + + const s: []const u8 = "bobr%20kurwa%2"; + + try std.testing.expectError(error.DecodeError, urlPercentDecode(&w.writer, s)); +} + +test "percent 7" { + var w: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer w.deinit(); + + const s: []const u8 = "bobr%20kurwa%"; + + try std.testing.expectError(error.DecodeError, urlPercentDecode(&w.writer, s)); +} + +/// Is the given character valid in URI percent encoding? +fn isValidChar(c: u8) bool { + return switch (c) { + ' ', ';', '=' => false, + else => return std.ascii.isPrint(c), + }; +} + +/// Write data to the writer after URI percent encoding. +pub fn urlPercentEncode(writer: *std.Io.Writer, data: []const u8) std.Io.Writer.Error!void { + try std.Uri.Component.percentEncode(writer, data, isValidChar); +} diff --git a/vendor/libghostty-vt/src/os/systemd.zig b/vendor/libghostty-vt/src/os/systemd.zig new file mode 100644 index 0000000..5815648 --- /dev/null +++ b/vendor/libghostty-vt/src/os/systemd.zig @@ -0,0 +1,200 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const global = @import("../global.zig"); + +const log = std.log.scoped(.systemd); + +/// Returns true if the program was launched as a systemd service. +/// +/// On Linux, this returns true if the program was launched as a systemd +/// service. It will return false if Ghostty was launched any other way. +/// +/// For other platforms and app runtimes, this returns false. +pub fn launchedBySystemd() bool { + return switch (builtin.os.tag) { + .linux => linux: { + // On Linux, systemd sets the `INVOCATION_ID` (v232+) and the + // `JOURNAL_STREAM` (v231+) environment variables. If these + // environment variables are not present we were not launched by + // systemd. + if (global.environ().getPosix("INVOCATION_ID") == null) break :linux false; + if (global.environ().getPosix("JOURNAL_STREAM") == null) break :linux false; + + // If `INVOCATION_ID` and `JOURNAL_STREAM` are present, check to make sure + // that our parent process is actually `systemd`, not some other terminal + // emulator that doesn't clean up those environment variables. + const ppid = std.os.linux.getppid(); + if (ppid == 1) break :linux true; + + // If the parent PID is not 1 we need to check to see if we were launched by + // a user systemd daemon. Do that by checking the `/proc/<ppid>/comm` + // to see if it ends with `systemd`. + var comm_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const comm_path = std.fmt.bufPrint(&comm_path_buf, "/proc/{d}/comm", .{ppid}) catch { + log.err("unable to format comm path for pid {d}", .{ppid}); + break :linux false; + }; + const comm_file = std.Io.Dir.openFileAbsolute(global.io(), comm_path, .{ .mode = .read_only }) catch { + log.err("unable to open '{s}' for reading", .{comm_path}); + break :linux false; + }; + defer comm_file.close(); + + // The maximum length of the command name is defined by + // `TASK_COMM_LEN` in the Linux kernel. This is usually 16 + // bytes at the time of writing (Jun 2025) so its set to that. + // Also, since we only care to compare to "systemd", anything + // longer can be assumed to not be systemd. + const TASK_COMM_LEN = 16; + var comm_data_buf: [TASK_COMM_LEN]u8 = undefined; + const comm_size = comm_file.readAll(&comm_data_buf) catch { + log.err("problems reading from '{s}'", .{comm_path}); + break :linux false; + }; + const comm_data = comm_data_buf[0..comm_size]; + + break :linux std.mem.eql( + u8, + std.mem.trimRight(u8, comm_data, "\n"), + "systemd", + ); + }, + + // No other system supports systemd so always return false. + else => false, + }; +} + +/// systemd notifications. Used by Ghostty to inform systemd of the state of the +/// process. Currently only used to notify systemd that we are ready and that +/// configuration reloading has started. +/// +/// See: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html +/// +/// These functions were re-implemented in Zig instead of using the `libsystemd` +/// library to avoid the complexity of another external dependency, as well as +/// to take advantage of Zig features like `comptime` to ensure minimal impact +/// on non-Linux systems (like FreeBSD) that will never support `systemd`. +/// +/// Linux systems that do not use `systemd` should not be impacted as they +/// should never start Ghostty with the `NOTIFY_SOCKET` environment variable set +/// and these functions essentially become a no-op. +/// +/// See `systemd`'s [Interface Portability and Stability Promise](https://systemd.io/PORTABILITY_AND_STABILITY/) +/// for assurances that the interfaces used here will be supported and stable for +/// the long term. +pub const notify = struct { + /// Send the given message to the UNIX socket specified in the NOTIFY_SOCKET + /// environment variable. If there NOTIFY_SOCKET environment variable does + /// not exist then no message is sent. + fn send(message: []const u8) void { + // systemd is Linux-only so this is a no-op anywhere else + if (comptime builtin.os.tag != .linux) return; + + // Get the socket address that should receive notifications. + const socket_path = global.environ().getPosix("NOTIFY_SOCKET") orelse return; + + // If the socket address is an empty string return. + if (socket_path.len == 0) return; + + // The socket address must be a path or an abstract socket. + if (socket_path[0] != '/' and socket_path[0] != '@') { + log.warn("only AF_UNIX sockets with path or abstract namespace addresses are supported!", .{}); + return; + } + + var socket_address: std.os.linux.sockaddr.un = undefined; + + // Error out if the supplied socket path is too long. + if (socket_address.path.len < socket_path.len) { + log.warn("NOTIFY_SOCKET path is too long!", .{}); + return; + } + + socket_address.family = std.os.linux.AF.UNIX; + + @memcpy(socket_address.path[0..socket_path.len], socket_path); + socket_address.path[socket_path.len] = 0; + + const socket: std.os.linux.socket_t = socket: { + const rc = std.os.linux.socket( + std.os.linux.AF.UNIX, + std.os.linux.SOCK.DGRAM | std.os.linux.SOCK.CLOEXEC, + 0, + ); + switch (linuxErrnoFromSyscall(rc)) { + .SUCCESS => break :socket @intCast(rc), + else => |e| { + log.warn("creating socket failed: {s}", .{@tagName(e)}); + return; + }, + } + }; + + defer _ = std.os.linux.close(socket); + + connect: { + const rc = std.os.linux.connect( + socket, + &socket_address, + @offsetOf(std.os.linux.sockaddr.un, "path") + socket_address.path.len, + ); + switch (linuxErrnoFromSyscall(rc)) { + .SUCCESS => break :connect, + else => |e| { + log.warn("unable to connect to notify socket: {s}", .{@tagName(e)}); + return; + }, + } + } + + write: { + const rc = std.os.linux.write(socket, message.ptr, message.len); + switch (linuxErrnoFromSyscall(rc)) { + .SUCCESS => { + const written = rc; + if (written < message.len) { + log.warn("short write to notify socket: {d} < {d}", .{ rc, message.len }); + return; + } + break :write; + }, + else => |e| { + log.warn("unable to write to notify socket: {s}", .{@tagName(e)}); + return; + }, + } + } + } + + /// Tell systemd that we are ready or that we are finished reloading. + /// See: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html#READY=1 + pub fn ready() void { + if (comptime builtin.os.tag != .linux) return; + + send("READY=1"); + } + + /// Tell systemd that we have started reloading our configuration. + /// See: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html#RELOADING=1 + /// and: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html#MONOTONIC_USEC=%E2%80%A6 + pub fn reloading() void { + if (comptime builtin.os.tag != .linux) return; + + const now = std.Io.Timestamp.now(global.io(), .awake).toMicroseconds(); + + var buffer: [64]u8 = undefined; + const message = std.fmt.bufPrint(&buffer, "RELOADING=1\nMONOTONIC_USEC={d}", .{now}) catch |err| { + log.err("unable to format reloading message: {}", .{err}); + return; + }; + + send(message); + } +}; + +fn linuxErrnoFromSyscall(r: usize) std.os.linux.E { + const signed_r: isize = @bitCast(r); + const int = if (signed_r > -4096 and signed_r < 0) -signed_r else 0; + return @enumFromInt(int); +} diff --git a/vendor/libghostty-vt/src/os/uri.zig b/vendor/libghostty-vt/src/os/uri.zig new file mode 100644 index 0000000..3d67487 --- /dev/null +++ b/vendor/libghostty-vt/src/os/uri.zig @@ -0,0 +1,204 @@ +const std = @import("std"); + +pub const ParseOptions = struct { + /// Parse MAC addresses in the host component. + /// + /// This is useful when the "Private Wi-Fi address" is enabled on macOS, + /// which sets the hostname to a rotating MAC address (12:34:56:ab:cd:ef). + mac_address: bool = false, + + /// Return the full, raw, unencoded path string. Any query and fragment + /// values will be return as part of the path instead of as distinct + /// fields. + raw_path: bool = false, +}; + +pub const ParseError = std.Uri.ParseError || error{InvalidMacAddress}; + +/// Parses a URI from the given string. +/// +/// This extends std.Uri.parse with some additional ParseOptions. +pub fn parse(text: []const u8, options: ParseOptions) ParseError!std.Uri { + var uri = std.Uri.parse(text) catch |err| uri: { + // We can attempt to re-parse the text as a URI that has a MAC address + // in its host field (which tripped up std.Uri.parse's port parsing): + // + // file://12:34:56:78:90:aa/path/to/file + // ^^ InvalidPort + // + if (err != error.InvalidPort or !options.mac_address) return err; + + // We can assume that the initial Uri.parse already validated the + // scheme, so we only need to find its bounds within the string. + const scheme_end = std.mem.indexOf(u8, text, "://") orelse { + return error.InvalidFormat; + }; + const scheme = text[0..scheme_end]; + + // We similarly find the bounds of the host component by looking + // for the first slash (/) after the scheme. This is all we need + // for this case because the resulting slice can be unambiguously + // determined to be a MAC address (or not). + const host_start = scheme_end + "://".len; + const host_end = std.mem.indexOfScalarPos(u8, text, host_start, '/') orelse text.len; + const mac_address = text[host_start..host_end]; + if (!isValidMacAddress(mac_address)) return error.InvalidMacAddress; + + // Parse the rest of the text (starting with the path component) as a + // partial URI and then add our MAC address as its host component. + var uri = try std.Uri.parseAfterScheme(scheme, text[host_end..]); + uri.host = .{ .percent_encoded = mac_address }; + break :uri uri; + }; + + // When MAC address parsing is enabled, we need to handle the case where + // std.Uri.parse parsed the address's last octet as a numeric port number. + // We use a few heuristics to identify this case (14 characters, 4 colons) + // and then "repair" the result by reassign the .host component to the full + // MAC address and clearing the .port component. + // + // 12:34:56:78:90:99 -> [12:34:56:78:90, 99] -> 12:34:56:78:90:99 + // (original host) (parsed host + port) (restored host) + // + if (options.mac_address and uri.host != null) mac: { + const host = uri.host.?.percent_encoded; + if (host.len != 14 or std.mem.count(u8, host, ":") != 4) break :mac; + + const port = uri.port orelse break :mac; + if (port > 99) break :mac; + + // std.Uri.parse returns slices pointing into the original text string. + const host_start = @intFromPtr(host.ptr) - @intFromPtr(text.ptr); + const path_start = @intFromPtr(uri.path.percent_encoded.ptr) - @intFromPtr(text.ptr); + const mac_address = text[host_start..path_start]; + if (!isValidMacAddress(mac_address)) return error.InvalidMacAddress; + + uri.host = .{ .percent_encoded = mac_address }; + uri.port = null; + } + + // When the raw_path option is active, return everything after the authority + // (host) in the .path component, including any query and fragment values. + if (options.raw_path) { + // std.Uri.parse returns slices pointing into the original text string. + const path_start = @intFromPtr(uri.path.percent_encoded.ptr) - @intFromPtr(text.ptr); + uri.path = .{ .raw = text[path_start..] }; + uri.query = null; + uri.fragment = null; + } + + return uri; +} + +test "parse: mac_address" { + const testing = @import("std").testing; + + // Numeric MAC address without a port + const uri1 = try parse("file://00:12:34:56:78:90/path", .{ .mac_address = true }); + try testing.expectEqualStrings("file", uri1.scheme); + try testing.expectEqualStrings("00:12:34:56:78:90", uri1.host.?.percent_encoded); + try testing.expectEqualStrings("/path", uri1.path.percent_encoded); + try testing.expectEqual(null, uri1.port); + + // Numeric MAC address with a port + const uri2 = try parse("file://00:12:34:56:78:90:999/path", .{ .mac_address = true }); + try testing.expectEqualStrings("file", uri2.scheme); + try testing.expectEqualStrings("00:12:34:56:78:90", uri2.host.?.percent_encoded); + try testing.expectEqualStrings("/path", uri2.path.percent_encoded); + try testing.expectEqual(999, uri2.port); + + // Alphabetic MAC address without a port + const uri3 = try parse("file://ab:cd:ef:ab:cd:ef/path", .{ .mac_address = true }); + try testing.expectEqualStrings("file", uri3.scheme); + try testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri3.host.?.percent_encoded); + try testing.expectEqualStrings("/path", uri3.path.percent_encoded); + try testing.expectEqual(null, uri3.port); + + // Alphabetic MAC address with a port + const uri4 = try parse("file://ab:cd:ef:ab:cd:ef:999/path", .{ .mac_address = true }); + try testing.expectEqualStrings("file", uri4.scheme); + try testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri4.host.?.percent_encoded); + try testing.expectEqualStrings("/path", uri4.path.percent_encoded); + try testing.expectEqual(999, uri4.port); + + // Numeric MAC address without a path component + const uri5 = try parse("file://00:12:34:56:78:90", .{ .mac_address = true }); + try testing.expectEqualStrings("file", uri5.scheme); + try testing.expectEqualStrings("00:12:34:56:78:90", uri5.host.?.percent_encoded); + try testing.expect(uri5.path.isEmpty()); + + // Alphabetic MAC address without a path component + const uri6 = try parse("file://ab:cd:ef:ab:cd:ef", .{ .mac_address = true }); + try testing.expectEqualStrings("file", uri6.scheme); + try testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri6.host.?.percent_encoded); + try testing.expect(uri6.path.isEmpty()); + + // Invalid MAC addresses + try testing.expectError(error.InvalidMacAddress, parse( + "file://zz:zz:zz:zz:zz:00/path", + .{ .mac_address = true }, + )); + try testing.expectError(error.InvalidMacAddress, parse( + "file://zz:zz:zz:zz:zz:zz/path", + .{ .mac_address = true }, + )); +} + +test "parse: raw_path" { + const testing = @import("std").testing; + + const text = "file://localhost/path??#fragment"; + var buf: [256]u8 = undefined; + + const uri1 = try parse(text, .{ .raw_path = false }); + try testing.expectEqualStrings("file", uri1.scheme); + try testing.expectEqualStrings("localhost", uri1.host.?.percent_encoded); + try testing.expectEqualStrings("/path", try uri1.path.toRaw(&buf)); + try testing.expectEqualStrings("?", uri1.query.?.percent_encoded); + try testing.expectEqualStrings("fragment", uri1.fragment.?.percent_encoded); + + const uri2 = try parse(text, .{ .raw_path = true }); + try testing.expectEqualStrings("file", uri2.scheme); + try testing.expectEqualStrings("localhost", uri2.host.?.percent_encoded); + try testing.expectEqualStrings("/path??#fragment", try uri2.path.toRaw(&buf)); + try testing.expectEqual(null, uri2.query); + try testing.expectEqual(null, uri2.fragment); + + const uri3 = try parse("file://localhost", .{ .raw_path = true }); + try testing.expectEqualStrings("file", uri3.scheme); + try testing.expectEqualStrings("localhost", uri3.host.?.percent_encoded); + try testing.expect(uri3.path.isEmpty()); + try testing.expectEqual(null, uri3.query); + try testing.expectEqual(null, uri3.fragment); +} + +/// Checks if a string represents a valid MAC address, e.g. 12:34:56:ab:cd:ef. +fn isValidMacAddress(s: []const u8) bool { + if (s.len != 17) return false; + + for (s, 0..) |c, i| { + if (i % 3 == 2) { + if (c != ':') return false; + } else { + switch (c) { + '0'...'9', 'A'...'F', 'a'...'f' => {}, + else => return false, + } + } + } + + return true; +} + +test isValidMacAddress { + const testing = @import("std").testing; + + try testing.expect(isValidMacAddress("01:23:45:67:89:Aa")); + try testing.expect(isValidMacAddress("Aa:Bb:Cc:Dd:Ee:Ff")); + + try testing.expect(!isValidMacAddress("")); + try testing.expect(!isValidMacAddress("00:23:45")); + try testing.expect(!isValidMacAddress("00:23:45:Xx:Yy:Zz")); + try testing.expect(!isValidMacAddress("01-23-45-67-89-Aa")); + try testing.expect(!isValidMacAddress("01:23:45:67:89:Aa:Bb")); +} diff --git a/vendor/libghostty-vt/src/os/wasm.zig b/vendor/libghostty-vt/src/os/wasm.zig new file mode 100644 index 0000000..3d0b90e --- /dev/null +++ b/vendor/libghostty-vt/src/os/wasm.zig @@ -0,0 +1,25 @@ +//! This file contains helpers for wasm compilation. +const std = @import("std"); +const builtin = @import("builtin"); +const options = @import("build_options"); + +comptime { + if (!builtin.target.cpu.arch.isWasm()) { + @compileError("wasm.zig should only be analyzed for wasm32 builds"); + } +} + +/// True if we're in shared memory mode. If true, then the memory buffer +/// in JS will be backed by a SharedArrayBuffer and some behaviors change. +pub const shared_mem = options.wasm_shared; + +/// The allocator to use in wasm environments. +/// +/// The return values of this should NOT be sent to the host environment +/// unless toHostOwned is called on them. In this case, the caller is expected +/// to call free. If a pointer is NOT host-owned, then the wasm module is +/// expected to call the normal alloc.free/destroy functions. +pub const alloc = if (builtin.is_test) + std.testing.allocator +else + std.heap.wasm_allocator; diff --git a/vendor/libghostty-vt/src/os/wasm/log.zig b/vendor/libghostty-vt/src/os/wasm/log.zig new file mode 100644 index 0000000..faa885c --- /dev/null +++ b/vendor/libghostty-vt/src/os/wasm/log.zig @@ -0,0 +1,45 @@ +const std = @import("std"); +const wasm = @import("../wasm.zig"); + +// Use the correct implementation +pub const log = Freestanding.log; + +/// Freestanding implementation calls an extern "log" function. +pub const Freestanding = struct { + // The function std.log will call. + pub fn log( + comptime level: std.log.Level, + comptime scope: @TypeOf(.EnumLiteral), + comptime format: []const u8, + args: anytype, + ) void { + // The buffer for putting our log message. We try to use a stack-allocated + // buffer first because we want to avoid allocation. If we are logging + // an error DUE to an OOM, allocating will of course fail and we'd like + // to see the error message so we prefer to use this. + var buf: [2048]u8 = undefined; + + // Build the string + const level_txt = comptime level.asText(); + const prefix = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): "; + const txt = level_txt ++ prefix ++ format; + + // Format. We attempt to use a stack-allocated string first and if that + // fails we'll try to allocate. + var allocated: bool = false; + const str = nosuspend std.fmt.bufPrint(&buf, txt, args) catch str: { + allocated = true; + break :str std.fmt.allocPrint(wasm.alloc, txt, args) catch return; + }; + defer if (allocated) wasm.alloc.free(str); + + // Send it over to the JS side + JS.log(str.ptr, str.len); + } + + // We wrap our externs in this namespace so we can reuse symbols, otherwise + // "log" would collide. + const JS = struct { + extern "env" fn log(ptr: [*]const u8, len: usize) void; + }; +}; diff --git a/vendor/libghostty-vt/src/os/wasm/target.zig b/vendor/libghostty-vt/src/os/wasm/target.zig new file mode 100644 index 0000000..a6a29e2 --- /dev/null +++ b/vendor/libghostty-vt/src/os/wasm/target.zig @@ -0,0 +1,20 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const options = @import("build_options"); + +/// The wasm target platform. This is used to toggle certain features +/// on and off since the standard triple target is often not specific +/// enough (i.e. we can't tell wasm32-freestanding is for browser or not). +pub const Target = enum { + browser, +}; + +/// Our specific target platform. +pub const target: ?Target = if (!builtin.target.cpu.arch.isWasm()) null else target: { + const result = @as(Target, @enumFromInt(@intFromEnum(options.wasm_target))); + // This maybe isn't necessary but I don't know if enums without a specific + // tag type and value are guaranteed to be the same between build.zig + // compilation and our own source compilation so I have this just in case. + std.debug.assert(std.mem.eql(u8, @tagName(result), @tagName(options.wasm_target))); + break :target result; +}; diff --git a/vendor/libghostty-vt/src/os/windows.zig b/vendor/libghostty-vt/src/os/windows.zig new file mode 100644 index 0000000..feef8ce --- /dev/null +++ b/vendor/libghostty-vt/src/os/windows.zig @@ -0,0 +1,273 @@ +const std = @import("std"); +const windows = std.os.windows; + +// NOTE: The Windows part of the Zig stdlib is currently in the process of +// having most of its features removed, with the ultimate goal of switching to +// serve as a support for higher-level functionality offered in places like +// `std.Io` only. As such this file serves as a "bridge" between type +// information (mostly coming from stdlib) and manually-defined constants and +// external functions. + +// Utility functions +pub const GetCurrentProcessId = windows.GetCurrentProcessId; +pub const GetLastError = windows.GetLastError; +pub const unexpectedError = windows.unexpectedError; +pub const unexpectedStatus = windows.unexpectedStatus; + +// Primitive types +pub const BOOL = windows.BOOL; +pub const COORD = windows.COORD; +pub const DWORD = windows.DWORD; +pub const DWORD_PTR = windows.DWORD_PTR; +pub const HANDLE = windows.HANDLE; +pub const HINSTANCE = windows.HINSTANCE; +pub const HPCON = windows.LPVOID; +pub const HRESULT = c_long; +pub const LARGE_INTEGER = windows.LARGE_INTEGER; +pub const LPCWSTR = windows.LPCWSTR; +pub const LPSTR = windows.LPSTR; +pub const LPVOID = windows.LPVOID; +pub const LPWSTR = windows.LPWSTR; +pub const PVOID = windows.PVOID; +pub const SIZE_T = windows.SIZE_T; +pub const UINT = windows.UINT; +pub const ULONG = windows.ULONG; +pub const ULONG_PTR = windows.ULONG_PTR; +pub const UNICODE_STRING = windows.UNICODE_STRING; + +// Structs and opaque types +pub const LPPROC_THREAD_ATTRIBUTE_LIST = ?*anyopaque; +pub const SECURITY_ATTRIBUTES = windows.SECURITY_ATTRIBUTES; +pub const STARTF_USESTDHANDLES = windows.STARTF_USESTDHANDLES; +pub const STARTUPINFOW = windows.STARTUPINFOW; + +pub const OVERLAPPED = extern struct { + Internal: ULONG_PTR, + InternalHigh: ULONG_PTR, + DUMMYUNIONNAME: extern union { + DUMMYSTRUCTNAME: extern struct { + Offset: DWORD, + OffsetHigh: DWORD, + }, + Pointer: ?PVOID, + }, + hEvent: ?HANDLE, +}; +pub const PROCESS_INFORMATION = extern struct { + hProcess: HANDLE, + hThread: HANDLE, + dwProcessId: DWORD, + dwThreadId: DWORD, +}; +pub const STARTUPINFOEX = extern struct { + StartupInfo: windows.STARTUPINFOW, + lpAttributeList: LPPROC_THREAD_ATTRIBUTE_LIST, +}; + +// Well-known constant values +pub const INFINITE = 4294967295; +pub const INVALID_HANDLE_VALUE = windows.INVALID_HANDLE_VALUE; +pub const MAX_PATH = windows.MAX_PATH; +pub const FALSE: windows.BOOL = .fromBool(false); +pub const TRUE: windows.BOOL = .fromBool(true); + +// Bit-field and enum constant values +pub const CREATE_UNICODE_ENVIRONMENT = 0x00000400; +pub const EXTENDED_STARTUPINFO_PRESENT = 0x00080000; +pub const FILE_ATTRIBUTE_NORMAL = 0x80; +pub const FILE_FLAG_FIRST_PIPE_INSTANCE = 0x00080000; +pub const FILE_FLAG_OVERLAPPED = 0x40000000; +pub const FILE_NON_DIRECTORY_FILE = 0x00000040; +pub const FILE_SHARE_READ = 0x00000001; +pub const GENERIC_READ = 0x80000000; +pub const HANDLE_FLAG_INHERIT = 0x00000001; +pub const MEM_COMMIT = 0x1000; +pub const MEM_RELEASE = 0x8000; +pub const MEM_RESERVE = 0x2000; +pub const OPEN_EXISTING = 3; // Known as FILE_OPEN in Windows docs +pub const PAGE_READWRITE = 0x04; +pub const PIPE_ACCESS_OUTBOUND = 0x00000002; +pub const PIPE_TYPE_BYTE = 0x00000000; +pub const PROC_THREAD_ATTRIBUTE_ADDITIVE = 0x00040000; +pub const PROC_THREAD_ATTRIBUTE_INPUT = 0x00020000; +pub const PROC_THREAD_ATTRIBUTE_NUMBER = 0x0000FFFF; +pub const PROC_THREAD_ATTRIBUTE_THREAD = 0x00010000; +pub const S_OK = 0; +pub const WAIT_FAILED = 0xFFFFFFFF; + +pub const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = ProcThreadAttributeValue( + .ProcThreadAttributePseudoConsole, + false, + true, + false, +); + +// Types needed for ntdll calls +pub const ACCESS_MASK = windows.ACCESS_MASK; +pub const IO_STATUS_BLOCK = windows.IO_STATUS_BLOCK; +pub const NTSTATUS = windows.NTSTATUS; +pub const OBJECT_ATTRIBUTES = windows.OBJECT.ATTRIBUTES; + +// Exported functions by library +pub const exp = struct { + pub const kernel32 = struct { + pub extern "kernel32" fn CreatePipe( + hReadPipe: *HANDLE, + hWritePipe: *HANDLE, + lpPipeAttributes: ?*const SECURITY_ATTRIBUTES, + nSize: DWORD, + ) callconv(.winapi) BOOL; + pub extern "kernel32" fn CreatePseudoConsole( + size: COORD, + hInput: HANDLE, + hOutput: HANDLE, + dwFlags: DWORD, + phPC: *HPCON, + ) callconv(.winapi) HRESULT; + pub extern "kernel32" fn ResizePseudoConsole( + hPC: HPCON, + size: COORD, + ) callconv(.winapi) HRESULT; + pub extern "kernel32" fn ClosePseudoConsole(hPC: HPCON) callconv(.winapi) void; + pub extern "kernel32" fn InitializeProcThreadAttributeList( + lpAttributeList: LPPROC_THREAD_ATTRIBUTE_LIST, + dwAttributeCount: DWORD, + dwFlags: DWORD, + lpSize: *SIZE_T, + ) callconv(.winapi) BOOL; + pub extern "kernel32" fn UpdateProcThreadAttribute( + lpAttributeList: LPPROC_THREAD_ATTRIBUTE_LIST, + dwFlags: DWORD, + Attribute: DWORD_PTR, + lpValue: PVOID, + cbSize: SIZE_T, + lpPreviousValue: ?PVOID, + lpReturnSize: ?*SIZE_T, + ) callconv(.winapi) BOOL; + pub extern "kernel32" fn PeekNamedPipe( + hNamedPipe: HANDLE, + lpBuffer: ?LPVOID, + nBufferSize: DWORD, + lpBytesRead: ?*DWORD, + lpTotalBytesAvail: ?*DWORD, + lpBytesLeftThisMessage: ?*DWORD, + ) callconv(.winapi) BOOL; + // Duplicated here because lpCommandLine is not marked optional in zig std + pub extern "kernel32" fn CreateProcessW( + lpApplicationName: ?LPWSTR, + lpCommandLine: ?LPWSTR, + lpProcessAttributes: ?*SECURITY_ATTRIBUTES, + lpThreadAttributes: ?*SECURITY_ATTRIBUTES, + bInheritHandles: BOOL, + dwCreationFlags: DWORD, + lpEnvironment: ?*anyopaque, + lpCurrentDirectory: ?LPWSTR, + lpStartupInfo: *STARTUPINFOW, + lpProcessInformation: *PROCESS_INFORMATION, + ) callconv(.winapi) BOOL; + /// https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-getcomputernamea + pub extern "kernel32" fn GetComputerNameA( + lpBuffer: LPSTR, + nSize: *DWORD, + ) callconv(.winapi) BOOL; + /// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-gettemppathw + pub extern "kernel32" fn GetTempPathW( + nBufferLength: DWORD, + lpBuffer: LPWSTR, + ) callconv(.winapi) DWORD; + pub extern "kernel32" fn SetHandleInformation( + hObject: HANDLE, + dwMask: DWORD, + dwFlags: DWORD, + ) callconv(.winapi) BOOL; + pub extern "kernel32" fn CreateFileW( + lpFileName: LPCWSTR, + dwDesiredAccess: DWORD, + dwShareMode: DWORD, + lpSecurityAttributes: ?*SECURITY_ATTRIBUTES, + dwCreationDisposition: DWORD, + dwFlagsAndAttributes: DWORD, + hTemplateFile: ?HANDLE, + ) callconv(.winapi) HANDLE; + pub extern "kernel32" fn CreateNamedPipeW( + lpName: LPCWSTR, + dwOpenMode: DWORD, + dwPipeMode: DWORD, + nMaxInstances: DWORD, + nOutBufferSize: DWORD, + nInBufferSize: DWORD, + nDefaultTimeOut: DWORD, + lpSecurityAttributes: ?*const SECURITY_ATTRIBUTES, + ) callconv(.winapi) HANDLE; + pub extern "kernel32" fn CloseHandle( + hObject: HANDLE, + ) callconv(.winapi) BOOL; + pub extern "kernel32" fn VirtualAlloc( + lpAddress: ?LPVOID, + dwSize: SIZE_T, + flAllocationType: DWORD, + flProtect: DWORD, + ) callconv(.winapi) ?LPVOID; + pub extern "kernel32" fn VirtualFree( + lpAddress: ?LPVOID, + dwSize: SIZE_T, + dwFreeType: DWORD, + ) callconv(.winapi) BOOL; + pub extern "kernel32" fn WaitForSingleObject( + hHandle: HANDLE, + dwMilliseconds: DWORD, + ) callconv(.winapi) DWORD; + pub extern "kernel32" fn GetExitCodeProcess( + hProcess: HANDLE, + lpExitCode: *DWORD, + ) callconv(.winapi) BOOL; + pub extern "kernel32" fn TerminateProcess( + hProcess: HANDLE, + uExitCode: UINT, + ) callconv(.winapi) BOOL; + pub extern "kernel32" fn CancelIoEx( + hFile: HANDLE, + lpOverlapped: ?*OVERLAPPED, + ) callconv(.winapi) BOOL; + pub extern "kernel32" fn ReadFile( + hFile: HANDLE, + lpBuffer: LPVOID, + nNumberOfBytesToRead: DWORD, + lpNumberOfBytesRead: ?*DWORD, + lpOverlapped: ?*OVERLAPPED, + ) callconv(.winapi) BOOL; + }; + pub const ntdll = struct { + pub extern "ntdll" fn NtCreateFile( + FileHandle: *HANDLE, + DesiredAccess: ACCESS_MASK, + ObjectAttributes: *OBJECT_ATTRIBUTES, + IoStatusBlock: *IO_STATUS_BLOCK, + AllocationSize: ?*LARGE_INTEGER, + FileAttributes: ULONG, + ShareAccess: ULONG, + CreateDisposition: ULONG, + CreateOptions: ULONG, + EaBuffer: ?*anyopaque, + EaLength: ULONG, + ) callconv(.winapi) NTSTATUS; + }; +}; + +pub const ProcThreadAttributeNumber = enum(DWORD) { + ProcThreadAttributePseudoConsole = 22, + _, +}; + +/// Corresponds to the ProcThreadAttributeValue define in WinBase.h +pub fn ProcThreadAttributeValue( + comptime attribute: ProcThreadAttributeNumber, + comptime thread: bool, + comptime input: bool, + comptime additive: bool, +) DWORD { + return (@intFromEnum(attribute) & PROC_THREAD_ATTRIBUTE_NUMBER) | + (if (thread) PROC_THREAD_ATTRIBUTE_THREAD else 0) | + (if (input) PROC_THREAD_ATTRIBUTE_INPUT else 0) | + (if (additive) PROC_THREAD_ATTRIBUTE_ADDITIVE else 0); +} diff --git a/vendor/libghostty-vt/src/os/xdg.zig b/vendor/libghostty-vt/src/os/xdg.zig new file mode 100644 index 0000000..9202958 --- /dev/null +++ b/vendor/libghostty-vt/src/os/xdg.zig @@ -0,0 +1,269 @@ +//! Implementation of the XDG Base Directory specification +//! (https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) + +const std = @import("std"); +const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; +const posix = std.posix; +const homedir = @import("homedir.zig"); + +pub const Options = struct { + /// Subdirectories to join to the base. This avoids extra allocations + /// when building up the directory. This is commonly the application. + subdir: ?[]const u8 = null, + + /// The home directory for the user. If this is not set, we will attempt + /// to look it up which is an expensive process. By setting this, you can + /// avoid lookups. + home: ?[]const u8 = null, +}; + +/// Get the XDG user config directory. The returned value is allocated. +pub fn config(io: std.Io, alloc: Allocator, environ_map: *const std.process.Environ.Map, opts: Options) ![]u8 { + return try dir(io, alloc, environ_map, opts, .{ + .env = "XDG_CONFIG_HOME", + .windows_env = "LOCALAPPDATA", + .default_subdir = ".config", + }); +} + +/// Get the XDG cache directory. The returned value is allocated. +pub fn cache(io: std.Io, alloc: Allocator, environ_map: *const std.process.Environ.Map, opts: Options) ![]u8 { + return try dir(io, alloc, environ_map, opts, .{ + .env = "XDG_CACHE_HOME", + .windows_env = "LOCALAPPDATA", + .default_subdir = ".cache", + }); +} + +/// Get the XDG state directory. The returned value is allocated. +pub fn state(io: std.Io, alloc: Allocator, environ_map: *const std.process.Environ.Map, opts: Options) ![]u8 { + return try dir(io, alloc, environ_map, opts, .{ + .env = "XDG_STATE_HOME", + .windows_env = "LOCALAPPDATA", + .default_subdir = ".local/state", + }); +} + +const InternalOptions = struct { + env: []const u8, + windows_env: []const u8, + default_subdir: []const u8, +}; + +/// Unified helper to get XDG directories that follow a common pattern. +fn dir( + io: std.Io, + alloc: Allocator, + environ_map: *const std.process.Environ.Map, + opts: Options, + internal_opts: InternalOptions, +) ![]u8 { + // If we have a cached home dir, use that. + if (opts.home) |home| { + return try std.fs.path.join(alloc, &[_][]const u8{ + home, + internal_opts.default_subdir, + opts.subdir orelse "", + }); + } + + // First check the env var. On Windows we treat `LOCALAPPDATA` as a + // fallback for `XDG_CONFIG_HOME` + const env = switch (builtin.os.tag) { + .windows => environ_map.get(internal_opts.env) orelse environ_map.get(internal_opts.windows_env) orelse "", + else => environ_map.get(internal_opts.env) orelse "", + }; + + if (env.len > 0) { + // If we have a subdir, then we use the env as-is to avoid a copy. + if (opts.subdir) |subdir| { + return try std.fs.path.join(alloc, &[_][]const u8{ + env, + subdir, + }); + } + + return try alloc.dupe(u8, env); + } + + // Get our home dir + var buf: [1024]u8 = undefined; + if (try homedir.home(io, environ_map, &buf)) |home| { + return try std.fs.path.join(alloc, &[_][]const u8{ + home, + internal_opts.default_subdir, + opts.subdir orelse "", + }); + } + + return error.NoHomeDir; +} + +/// Parses the xdg-terminal-exec specification. This expects argv[0] to +/// be "xdg-terminal-exec". +pub fn parseTerminalExec(argv: []const [*:0]const u8) ?[]const [*:0]const u8 { + if (!std.mem.eql( + u8, + std.fs.path.basename(std.mem.sliceTo(argv[0], 0)), + "xdg-terminal-exec", + )) return null; + + // We expect at least one argument + if (argv.len < 2) return &.{}; + + // If the first argument is "-e" we skip it. + const start: usize = if (std.mem.eql(u8, std.mem.sliceTo(argv[1], 0), "-e")) 2 else 1; + return argv[start..]; +} + +test { + const testing = std.testing; + const io = testing.io; + const alloc = testing.allocator; + var environ_map = try testing.environ.createMap(alloc); + defer environ_map.deinit(); + + { + const value = try config(io, alloc, &environ_map, .{}); + defer alloc.free(value); + try testing.expect(value.len > 0); + } +} + +test "cache directory paths" { + const testing = std.testing; + const io = testing.io; + const alloc = testing.allocator; + const mock_home = if (builtin.os.tag == .windows) "C:\\Users\\test" else "/Users/test"; + var environ_map = try testing.environ.createMap(alloc); + defer environ_map.deinit(); + + // Test when XDG_CACHE_HOME is not set + { + // Test base path + { + const cache_path = try cache(io, alloc, &environ_map, .{ .home = mock_home }); + defer alloc.free(cache_path); + const expected = try std.fs.path.join(alloc, &.{ mock_home, ".cache" }); + defer alloc.free(expected); + try testing.expectEqualStrings(expected, cache_path); + } + + // Test with subdir + { + const cache_path = try cache(io, alloc, &environ_map, .{ + .home = mock_home, + .subdir = "ghostty", + }); + defer alloc.free(cache_path); + const expected = try std.fs.path.join(alloc, &.{ mock_home, ".cache", "ghostty" }); + defer alloc.free(expected); + try testing.expectEqualStrings(expected, cache_path); + } + } +} + +test "fallback when xdg env empty" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + const io = std.testing.io; + const alloc = std.testing.allocator; + + const DirCase = struct { + name: [:0]const u8, + func: fn (std.Io, Allocator, *std.process.Environ.Map, Options) anyerror![]u8, + default_subdir: []const u8, + }; + + const cases = [_]DirCase{ + .{ .name = "XDG_CONFIG_HOME", .func = config, .default_subdir = ".config" }, + .{ .name = "XDG_CACHE_HOME", .func = cache, .default_subdir = ".cache" }, + .{ .name = "XDG_STATE_HOME", .func = state, .default_subdir = ".local/state" }, + }; + + inline for (cases) |case| { + var environ_map = try std.testing.environ.createMap(alloc); + defer environ_map.deinit(); + const temp_home = "/tmp/ghostty-test-home"; + try environ_map.put("HOME", temp_home); + + const expected = try std.fs.path.join(alloc, &[_][]const u8{ + temp_home, + case.default_subdir, + }); + defer alloc.free(expected); + + // Test with empty string - should fallback to home + try environ_map.put(case.name, ""); + const actual = try case.func(io, alloc, &environ_map, .{}); + defer alloc.free(actual); + + try std.testing.expectEqualStrings(expected, actual); + } +} + +test "fallback when xdg env empty and subdir" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + const io = std.testing.io; + const alloc = std.testing.allocator; + + const DirCase = struct { + name: [:0]const u8, + func: fn (std.Io, Allocator, *const std.process.Environ.Map, Options) anyerror![]u8, + default_subdir: []const u8, + }; + + const cases = [_]DirCase{ + .{ .name = "XDG_CONFIG_HOME", .func = config, .default_subdir = ".config" }, + .{ .name = "XDG_CACHE_HOME", .func = cache, .default_subdir = ".cache" }, + .{ .name = "XDG_STATE_HOME", .func = state, .default_subdir = ".local/state" }, + }; + + inline for (cases) |case| { + var environ_map = try std.testing.environ.createMap(alloc); + defer environ_map.deinit(); + const temp_home = "/tmp/ghostty-test-home"; + try environ_map.put("HOME", temp_home); + + const expected = try std.fs.path.join(alloc, &[_][]const u8{ + temp_home, + case.default_subdir, + "ghostty", + }); + defer alloc.free(expected); + + // Test with empty string - should fallback to home + try environ_map.put(case.name, ""); + const actual = try case.func(io, alloc, &environ_map, .{ .subdir = "ghostty" }); + defer alloc.free(actual); + + try std.testing.expectEqualStrings(expected, actual); + } +} + +test parseTerminalExec { + const testing = std.testing; + + { + const actual = parseTerminalExec(&.{ "a", "b", "c" }); + try testing.expect(actual == null); + } + { + const actual = parseTerminalExec(&.{"xdg-terminal-exec"}).?; + try testing.expectEqualSlices([*:0]const u8, actual, &.{}); + } + { + const actual = parseTerminalExec(&.{ "xdg-terminal-exec", "a", "b", "c" }).?; + try testing.expectEqualSlices([*:0]const u8, actual, &.{ "a", "b", "c" }); + } + { + const actual = parseTerminalExec(&.{ "xdg-terminal-exec", "-e", "a", "b", "c" }).?; + try testing.expectEqualSlices([*:0]const u8, actual, &.{ "a", "b", "c" }); + } + { + const actual = parseTerminalExec(&.{ "xdg-terminal-exec", "a", "-e", "b", "c" }).?; + try testing.expectEqualSlices([*:0]const u8, actual, &.{ "a", "-e", "b", "c" }); + } +} |
