Integrate with herdr cli.
This commit is contained in:
1 parent
baa2907391
commit
b5af181a4d
11 files changed
+1623
-75
No files matched your search
@@ -480,6 +480,24 @@ deliberate one — there is nothing left worth offering to put back, and a banne
|
||||
at the next launch offering the session before that would be answering a question
|
||||
nobody asked.
|
||||
|
||||
### Shells that outlive the window
|
||||
|
||||
Settings → Quitting → *Keep shells running after the window closes* changes the
|
||||
last paragraph's answer for terminals. With it on, a new shell does not run in a
|
||||
pty of playpen's own but in [herdr-core](https://github.com/herdrdev/herdr-core)
|
||||
— the runtime behind `hc` — and the pane attaches to it. Quitting, a crash, or
|
||||
systemd killing the app's scope leaves every shell and whatever is running in
|
||||
it alone, and the next launch restores the session **without asking**, in place
|
||||
of the startup tabs: each pane reattaches to its own shell, scrollback and all.
|
||||
Closing a pane or a tab still ends what is in it; only the app going away does
|
||||
not. It needs `hc` on `PATH` or in `~/.local/bin`, and falls back to a local
|
||||
shell whenever the core cannot be reached.
|
||||
|
||||
The snapshot records which core terminal each pane was showing (`"hc": "t7"` on
|
||||
a terminal leaf). A terminal that has since exited reopens as a fresh shell in
|
||||
the recorded directory, as it would without herdr-core. Shells in the core can
|
||||
also be reached from anywhere else with `hc ls` and `hc attach`.
|
||||
|
||||
The file is `session.json`, and it lives in the **state** directory rather than
|
||||
beside `layouts.json` and `settings.json`:
|
||||
|
||||
|
||||
@@ -180,6 +180,18 @@ pub fn build(b: *std.Build) void {
|
||||
palette_tests.root_module.addImport("ghostty-vt", ghostty.module("ghostty-vt"));
|
||||
test_step.dependOn(&b.addRunArtifact(palette_tests).step);
|
||||
|
||||
// herdr-core's frame format is its own root: the bytes of the wire
|
||||
// protocol, testable with no core to talk to. The connection on top of it
|
||||
// wants a socket and a main loop, and is checked by running it.
|
||||
const hc_frame_tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/hc/frame.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
test_step.dependOn(&b.addRunArtifact(hc_frame_tests).step);
|
||||
|
||||
// And a root for the settings file itself. `Settings.zig` reaches GLib for
|
||||
// where the file lives, but reading and writing its *contents* is pure
|
||||
// enough to test — which is what these cover, since the palette overrides
|
||||
|
||||
@@ -94,6 +94,14 @@ pub const Pane = struct {
|
||||
|
||||
/// Page to load, for web panes.
|
||||
url: []const u8 = "",
|
||||
|
||||
/// The herdr-core terminal a terminal pane was showing (`t7`).
|
||||
///
|
||||
/// Only ever written into the session snapshot, never into a saved layout:
|
||||
/// a layout is a recipe for new shells, and a snapshot is a photograph of
|
||||
/// running ones — this is what lets restoring it reattach to them rather
|
||||
/// than start over. See `Session.Options.hc`.
|
||||
hc: []const u8 = "",
|
||||
};
|
||||
|
||||
pub const Node = union(enum) {
|
||||
@@ -304,6 +312,7 @@ pub fn parseNode(alloc: std.mem.Allocator, raw: std.json.Value) ParseError!*Node
|
||||
.cwd = try optionalString(alloc, obj.get("cwd")),
|
||||
.command = try optionalString(alloc, obj.get("command")),
|
||||
.url = try optionalString(alloc, obj.get("url")),
|
||||
.hc = try optionalString(alloc, obj.get("hc")),
|
||||
} };
|
||||
return node;
|
||||
}
|
||||
@@ -469,6 +478,10 @@ pub fn writeNode(json: *std.json.Stringify, node: *const Node) SaveError!void {
|
||||
try json.objectField("url");
|
||||
try json.write(p.url);
|
||||
}
|
||||
if (p.hc.len > 0) {
|
||||
try json.objectField("hc");
|
||||
try json.write(p.hc);
|
||||
}
|
||||
},
|
||||
}
|
||||
try json.endObject();
|
||||
@@ -498,6 +511,7 @@ pub const Builder = struct {
|
||||
.cwd = try self.dupe(spec.cwd),
|
||||
.command = try self.dupe(spec.command),
|
||||
.url = try self.dupe(spec.url),
|
||||
.hc = try self.dupe(spec.hc),
|
||||
} });
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -241,7 +241,7 @@ fn buildEnv(
|
||||
|
||||
/// Whether an inherited `NAME=value` entry names a variable the caller is
|
||||
/// about to define.
|
||||
fn shadowedBy(entry: []const u8, extra: []const []const u8) bool {
|
||||
pub fn shadowedBy(entry: []const u8, extra: []const []const u8) bool {
|
||||
const eq = std.mem.indexOfScalar(u8, entry, '=') orelse return false;
|
||||
for (extra) |candidate| {
|
||||
const candidate_eq = std.mem.indexOfScalar(u8, candidate, '=') orelse continue;
|
||||
@@ -263,8 +263,14 @@ fn freeEnv(alloc: std.mem.Allocator, envp: [:null]?[*:0]const u8) void {
|
||||
/// are rather than where you began. Read straight out of procfs: there is no
|
||||
/// portable API for it, and this app is Linux-only regardless.
|
||||
pub fn cwd(self: Pty, buf: []u8) ?[]const u8 {
|
||||
return cwdOf(self.pid, buf);
|
||||
}
|
||||
|
||||
/// `cwd` for any process, not only one this module started — a herdr-core
|
||||
/// terminal's program, which runs on this machine but is nobody's child here.
|
||||
pub fn cwdOf(pid: pid_t, buf: []u8) ?[]const u8 {
|
||||
var link_buf: [64]u8 = undefined;
|
||||
const link = std.mem.printSentinel(&link_buf, "/proc/{d}/cwd", .{self.pid}, 0) catch return null;
|
||||
const link = std.mem.printSentinel(&link_buf, "/proc/{d}/cwd", .{pid}, 0) catch return null;
|
||||
|
||||
const n = c.readlink(link.ptr, buf.ptr, buf.len);
|
||||
if (n <= 0) return null;
|
||||
|
||||
+423
-22
@@ -12,6 +12,8 @@ const glibunix = @import("glibunix");
|
||||
const vt = @import("ghostty-vt");
|
||||
|
||||
const Pty = @import("Pty.zig");
|
||||
const Settings = @import("Settings.zig");
|
||||
const hc = @import("hc.zig");
|
||||
const scope = @import("scope.zig");
|
||||
const theme = @import("theme.zig");
|
||||
|
||||
@@ -31,11 +33,17 @@ term: vt.Terminal,
|
||||
/// read boundaries, so this must outlive individual reads.
|
||||
stream: vt.TerminalStream,
|
||||
|
||||
pty: Pty,
|
||||
/// Where the program runs: a pty of our own, or a terminal in herdr-core.
|
||||
backend: Backend,
|
||||
|
||||
/// GLib source ID for the PTY read watch, so we can cancel it on teardown.
|
||||
/// Local backend only; herdr-core output arrives through `hc`.
|
||||
watch: c_uint = 0,
|
||||
|
||||
/// Fallback for typing the startup command into a herdr-core terminal — see
|
||||
/// `onHcData`.
|
||||
flush_timer: c_uint = 0,
|
||||
|
||||
/// True once the child process has exited and the PTY hung up.
|
||||
exited: bool = false,
|
||||
|
||||
@@ -91,6 +99,57 @@ pub const Status = enum {
|
||||
failed,
|
||||
};
|
||||
|
||||
/// Where a session's program runs.
|
||||
///
|
||||
/// Local is the original arrangement: a pty this process opened, and a child
|
||||
/// that dies with it. Herdr-core moves the program into a terminal the core
|
||||
/// holds, so that it outlives the app — see `hc.zig`. Everything above the
|
||||
/// byte stream (the emulator, the renderer, input encoding) is the same for
|
||||
/// both; only where bytes go and come from differs.
|
||||
const Backend = union(enum) {
|
||||
local: Pty,
|
||||
hc: Remote,
|
||||
};
|
||||
|
||||
/// A terminal in herdr-core, as seen from the pane showing it.
|
||||
const Remote = struct {
|
||||
/// Its address in the core, `t7`. Owned.
|
||||
address: []u8,
|
||||
|
||||
/// The stream it is attached on, or null while it is not — after the
|
||||
/// connection dropped and before a reattach has succeeded.
|
||||
channel: ?u32 = null,
|
||||
|
||||
/// Its program's pid, for reading the working directory out of procfs. The
|
||||
/// core runs on this machine, so the pid means the same thing here.
|
||||
pid: i32 = 0,
|
||||
|
||||
/// Output frames to let pass before the shell counts as started. A fresh
|
||||
/// terminal's stream opens with the core's paint of its screen, which comes
|
||||
/// at once and says nothing about whether the shell is ready to read.
|
||||
paints_to_skip: u8 = 0,
|
||||
|
||||
/// Pending check on the terminal after its stream ended.
|
||||
timer: c_uint = 0,
|
||||
|
||||
/// Reattach attempts since the stream was last up, for the backoff.
|
||||
attempts: u32 = 0,
|
||||
|
||||
/// The size the program was last told, and the pending telling of a new
|
||||
/// one — see `scheduleRemoteResize`.
|
||||
sent_cols: u16 = 0,
|
||||
sent_rows: u16 = 0,
|
||||
resize_timer: c_uint = 0,
|
||||
};
|
||||
|
||||
/// How long a pane's size has to hold before a herdr-core program hears of it.
|
||||
const remote_resize_settle_ms = 60;
|
||||
|
||||
/// How long to wait before typing the startup command into a herdr-core shell
|
||||
/// that has said nothing since the first paint — one whose prompt was already
|
||||
/// on screen when it was attached.
|
||||
const flush_fallback_ms = 1500;
|
||||
|
||||
pub const Callbacks = struct {
|
||||
on_damage: *const fn (ctx: ?*anyopaque) void,
|
||||
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
|
||||
@@ -117,6 +176,13 @@ pub const Options = struct {
|
||||
/// no chance of picking up another tab's review. Empty leaves the variable
|
||||
/// unset, which is what an agent sees when the server never started.
|
||||
review_url: []const u8 = "",
|
||||
|
||||
/// A herdr-core terminal to attach to instead of starting a shell — one
|
||||
/// this pane was showing when the session snapshot was taken. Attached to
|
||||
/// whether or not the setting is on, since it is already running: what the
|
||||
/// setting decides is where *new* shells start. If it has gone, the pane
|
||||
/// starts a new shell in `cwd` as it would have without it.
|
||||
hc: []const u8 = "",
|
||||
};
|
||||
|
||||
pub fn create(
|
||||
@@ -138,7 +204,7 @@ pub fn create(
|
||||
.max_scrollback_bytes = 8 * 1024 * 1024,
|
||||
}),
|
||||
.stream = undefined,
|
||||
.pty = undefined,
|
||||
.backend = undefined,
|
||||
.on_damage = cbs.on_damage,
|
||||
.on_title = cbs.on_title,
|
||||
.on_exit = cbs.on_exit,
|
||||
@@ -149,7 +215,7 @@ pub fn create(
|
||||
|
||||
// Before the child is spawned, so the first thing it prints is already in
|
||||
// the right palette.
|
||||
self.refreshPalette();
|
||||
self.applyPalette();
|
||||
|
||||
self.stream = self.term.vtStream();
|
||||
errdefer self.stream.deinit();
|
||||
@@ -203,34 +269,48 @@ pub fn create(
|
||||
env_extra = &env_buf;
|
||||
}
|
||||
|
||||
self.pty = try .create(alloc, shell, &.{argv0}, cwd_z, env_extra, .{
|
||||
.ws_row = rows,
|
||||
.ws_col = cols,
|
||||
});
|
||||
errdefer self.pty.deinit();
|
||||
if (self.startRemote(opts, cols, rows, shell, cwd_z, env_extra)) |remote| {
|
||||
self.backend = .{ .hc = remote };
|
||||
} else |err| {
|
||||
if (err != error.Disabled) {
|
||||
std.log.warn("herdr-core: starting a local shell instead ({s})", .{@errorName(err)});
|
||||
}
|
||||
// The grid's size rather than `cols` and `rows`: a reattach that got as
|
||||
// far as taking its terminal's size before failing has already moved it.
|
||||
self.backend = .{ .local = try .create(alloc, shell, &.{argv0}, cwd_z, env_extra, .{
|
||||
.ws_row = self.term.rows,
|
||||
.ws_col = self.term.cols,
|
||||
}) };
|
||||
|
||||
// Out of the app's own cgroup, so that the kernel killing something this
|
||||
// shell runs is not also systemd's cue to kill the app. See `scope`.
|
||||
scope.adopt(self.pty.pid);
|
||||
|
||||
if (opts.command.len > 0) {
|
||||
self.startup_command = try alloc.dupe(u8, opts.command);
|
||||
}
|
||||
// A herdr-core terminal needs none of this: the core already runs its
|
||||
// programs in services of their own, outside the app's scope.
|
||||
scope.adopt(self.backend.local.pid);
|
||||
|
||||
self.watch = glibunix.fdAdd(
|
||||
self.pty.master,
|
||||
self.backend.local.master,
|
||||
.{ .in = true, .hup = true, .err = true },
|
||||
&onReadable,
|
||||
self,
|
||||
);
|
||||
}
|
||||
errdefer self.stopBackend();
|
||||
|
||||
if (opts.command.len > 0) {
|
||||
self.startup_command = try alloc.dupe(u8, opts.command);
|
||||
if (self.backend == .hc) {
|
||||
self.flush_timer = glib.timeoutAddOnce(flush_fallback_ms, &onFlushTimer, self);
|
||||
}
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn destroy(self: *Session) void {
|
||||
if (self.startup_command) |cmd| self.alloc.free(cmd);
|
||||
if (self.watch != 0) _ = glib.Source.remove(self.watch);
|
||||
self.pty.deinit();
|
||||
if (self.flush_timer != 0) _ = glib.Source.remove(self.flush_timer);
|
||||
self.stopBackend();
|
||||
self.stream.deinit();
|
||||
self.term.deinit(self.alloc);
|
||||
self.alloc.destroy(self);
|
||||
@@ -268,7 +348,7 @@ fn onReadable(
|
||||
|
||||
if (condition.in) {
|
||||
var buf: [read_buf_size]u8 = undefined;
|
||||
if (self.pty.read(&buf)) |n| {
|
||||
if (self.backend.local.read(&buf)) |n| {
|
||||
self.stream.nextSlice(buf[0..n]);
|
||||
self.flushStartupCommand();
|
||||
self.on_damage(self.ctx);
|
||||
@@ -300,14 +380,14 @@ fn flushStartupCommand(self: *Session) void {
|
||||
self.startup_command = null;
|
||||
defer self.alloc.free(command);
|
||||
|
||||
self.pty.writeAll(command);
|
||||
self.pty.writeAll("\n");
|
||||
self.write(command);
|
||||
self.write("\n");
|
||||
}
|
||||
|
||||
/// Effect callback: the terminal wants to send bytes back to the child.
|
||||
fn effectWritePty(handler: *vt.TerminalStream.Handler, data: []const u8) void {
|
||||
const self = fromHandler(handler);
|
||||
self.pty.writeAll(data);
|
||||
self.write(data);
|
||||
}
|
||||
|
||||
/// Effect callback: OSC 0/2 changed the window title.
|
||||
@@ -399,6 +479,11 @@ fn fromHandler(handler: *vt.TerminalStream.Handler) *Session {
|
||||
/// terminal owns. If that copy can't be made, fall back to the built-in
|
||||
/// palette, which never allocates, rather than keep the previous scheme's.
|
||||
pub fn refreshPalette(self: *Session) void {
|
||||
self.applyPalette();
|
||||
self.sendFacts();
|
||||
}
|
||||
|
||||
fn applyPalette(self: *Session) void {
|
||||
const palette = &self.term.colors.palette;
|
||||
palette.changeDefault(self.alloc, theme.ansiPalette()) catch |err| {
|
||||
std.log.err("could not change the palette: {s}", .{@errorName(err)});
|
||||
@@ -408,14 +493,36 @@ pub fn refreshPalette(self: *Session) void {
|
||||
|
||||
/// Send user input to the child.
|
||||
pub fn write(self: *Session, bytes: []const u8) void {
|
||||
self.pty.writeAll(bytes);
|
||||
switch (self.backend) {
|
||||
.local => |pty| pty.writeAll(bytes),
|
||||
.hc => |r| if (r.channel) |ch| hc.input(ch, bytes),
|
||||
}
|
||||
}
|
||||
|
||||
/// The program's current working directory, copied into `buf`, or null if it
|
||||
/// can't be read. See `Pty.cwd`.
|
||||
pub fn cwd(self: *Session, buf: []u8) ?[]const u8 {
|
||||
return switch (self.backend) {
|
||||
.local => |pty| pty.cwd(buf),
|
||||
.hc => |r| if (r.pid > 0) Pty.cwdOf(r.pid, buf) else null,
|
||||
};
|
||||
}
|
||||
|
||||
/// The herdr-core terminal this session shows, or null for a local one.
|
||||
pub fn hcAddress(self: *const Session) ?[]const u8 {
|
||||
return switch (self.backend) {
|
||||
.local => null,
|
||||
.hc => |r| r.address,
|
||||
};
|
||||
}
|
||||
|
||||
/// Resize the terminal grid and tell the child about it.
|
||||
pub fn resize(self: *Session, cols: u16, rows: u16, cell_w: u32, cell_h: u32) !void {
|
||||
// Recorded before the early return: the grid can keep its dimensions
|
||||
// across a font change that moves every pixel measurement.
|
||||
const cell_changed = self.cell_px.w != cell_w or self.cell_px.h != cell_h;
|
||||
self.cell_px = .{ .w = cell_w, .h = cell_h };
|
||||
if (cell_changed) self.sendFacts();
|
||||
|
||||
if (cols == self.term.cols and rows == self.term.rows) return;
|
||||
|
||||
@@ -425,10 +532,304 @@ pub fn resize(self: *Session, cols: u16, rows: u16, cell_w: u32, cell_h: u32) !v
|
||||
.cell_size_px = .{ .width = cell_w, .height = cell_h },
|
||||
});
|
||||
|
||||
self.pty.setSize(.{
|
||||
switch (self.backend) {
|
||||
.local => |pty| pty.setSize(.{
|
||||
.ws_row = rows,
|
||||
.ws_col = cols,
|
||||
.ws_xpixel = @intCast(cell_w * cols),
|
||||
.ws_ypixel = @intCast(cell_h * rows),
|
||||
}),
|
||||
.hc => self.scheduleRemoteResize(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell a herdr-core program its new size once the size has settled.
|
||||
///
|
||||
/// A local shell is told every step, and that has never mattered: a pane goes
|
||||
/// through a few sizes on its way to its place in a layout — full width, then
|
||||
/// its share of a split — but a new shell has printed nothing by then. A
|
||||
/// restored one has a screen full of output, and a shell answers each resize
|
||||
/// by redrawing its prompt in place; a prompt that wraps at one width and not
|
||||
/// at the next is redrawn over the lines above it. One resize, to where the
|
||||
/// pane actually ends up, leaves them alone. Dragging a divider gets the same
|
||||
/// treatment, which spares the program a redraw per pixel.
|
||||
fn scheduleRemoteResize(self: *Session) void {
|
||||
const r = &self.backend.hc;
|
||||
if (r.resize_timer != 0) _ = glib.Source.remove(r.resize_timer);
|
||||
r.resize_timer = glib.timeoutAddOnce(remote_resize_settle_ms, &onRemoteResize, self);
|
||||
}
|
||||
|
||||
fn onRemoteResize(data: ?*anyopaque) callconv(.c) void {
|
||||
const self: *Session = @ptrCast(@alignCast(data.?));
|
||||
const r = &self.backend.hc;
|
||||
r.resize_timer = 0;
|
||||
const ch = r.channel orelse return;
|
||||
if (r.sent_cols == self.term.cols and r.sent_rows == self.term.rows) return;
|
||||
hc.resize(ch, self.term.cols, self.term.rows);
|
||||
r.sent_cols = self.term.cols;
|
||||
r.sent_rows = self.term.rows;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// herdr-core
|
||||
|
||||
/// Attach to the terminal `opts` names, or start a new one, or say why not.
|
||||
/// `error.Disabled` means the setting is off and there was nothing to attach
|
||||
/// to: the ordinary way a local shell gets made, not a failure.
|
||||
fn startRemote(
|
||||
self: *Session,
|
||||
opts: Options,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
shell: [:0]const u8,
|
||||
cwd_z: ?[:0]const u8,
|
||||
env_extra: []const []const u8,
|
||||
) !Remote {
|
||||
if (opts.hc.len > 0) {
|
||||
if (self.reattach(opts.hc)) |remote| {
|
||||
return remote;
|
||||
} else |err| switch (err) {
|
||||
// Gone while the app was away: start afresh, as the pane would
|
||||
// have without the snapshot remembering it.
|
||||
error.NotFound, error.Finished => {},
|
||||
else => std.log.warn("herdr-core: could not reattach {s}: {s}", .{ opts.hc, @errorName(err) }),
|
||||
}
|
||||
}
|
||||
|
||||
if (!Settings.get().herdr_core) return error.Disabled;
|
||||
|
||||
var env: std.ArrayListUnmanaged([]const u8) = .empty;
|
||||
defer env.deinit(self.alloc);
|
||||
try remoteEnvironment(self.alloc, &env, env_extra);
|
||||
|
||||
// `-l` rather than the local pty's `-zsh` argv[0]: the core takes a
|
||||
// program to find on PATH, not a path and a separate argv[0], and every
|
||||
// shell worth starting reads its profile for `-l` all the same.
|
||||
const argv = [_][]const u8{ shell, "-l" };
|
||||
|
||||
// The app's own directory stands in for none, as it does for a local
|
||||
// shell, which simply inherits it. Left out, the core would use its own.
|
||||
var here_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const here: ?[]const u8 = if (std.c.getcwd(&here_buf, here_buf.len)) |p|
|
||||
std.mem.span(@as([*:0]u8, @ptrCast(p)))
|
||||
else
|
||||
null;
|
||||
|
||||
const started = hc.run(self.alloc, .{
|
||||
.argv = &argv,
|
||||
.cwd = if (cwd_z) |z| z else here,
|
||||
.env = env.items,
|
||||
}) catch |err| switch (err) {
|
||||
// A directory that no longer exists. The local pty starts in the app's
|
||||
// own directory in that case; so does this.
|
||||
error.SpawnFailed => if (cwd_z != null) try hc.run(self.alloc, .{
|
||||
.argv = &argv,
|
||||
.cwd = here,
|
||||
.env = env.items,
|
||||
}) else return err,
|
||||
else => return err,
|
||||
};
|
||||
errdefer {
|
||||
hc.kill(started.address);
|
||||
self.alloc.free(started.address);
|
||||
}
|
||||
|
||||
const att = try hc.attach(started.address, cols, rows, 0, self.display(), self.hcStream());
|
||||
return .{
|
||||
.address = started.address,
|
||||
.channel = att.channel,
|
||||
.pid = if (att.pid > 0) att.pid else started.pid,
|
||||
.paints_to_skip = 1,
|
||||
.sent_cols = cols,
|
||||
.sent_rows = rows,
|
||||
};
|
||||
}
|
||||
|
||||
/// Attach to a terminal that was running before this pane existed.
|
||||
///
|
||||
/// At the terminal's own size rather than the placeholder this session was
|
||||
/// made at: the scrollback arrives laid out for whatever size the attach asks
|
||||
/// for, so asking for 80 columns would hard-wrap every longer line in it. At
|
||||
/// its own size, the lines arrive as they were drawn, and when the widget then
|
||||
/// gives the grid its real size they reflow like any other resize.
|
||||
fn reattach(self: *Session, address: []const u8) !Remote {
|
||||
const facts = try hc.get(address);
|
||||
switch (facts.state) {
|
||||
.live => {},
|
||||
.finished => return error.Finished,
|
||||
.gone => return error.NotFound,
|
||||
}
|
||||
if (facts.cols > 0 and facts.rows > 0) {
|
||||
try self.stream.handler.resize(.{
|
||||
.cols = facts.cols,
|
||||
.rows = facts.rows,
|
||||
.cell_size_px = .{ .width = self.cell_px.w, .height = self.cell_px.h },
|
||||
});
|
||||
}
|
||||
|
||||
const att = try hc.attach(address, self.term.cols, self.term.rows, hc.restore_history, self.display(), self.hcStream());
|
||||
return .{
|
||||
.address = try self.alloc.dupe(u8, address),
|
||||
.channel = att.channel,
|
||||
.pid = att.pid,
|
||||
.sent_cols = self.term.cols,
|
||||
.sent_rows = self.term.rows,
|
||||
};
|
||||
}
|
||||
|
||||
/// The environment a herdr-core shell is started with: this process's, as the
|
||||
/// local pty would hand on, on top of the core's own.
|
||||
///
|
||||
/// Passed whole rather than left to the core, whose environment is whatever
|
||||
/// the session that first started it had — not necessarily the one this app
|
||||
/// is running in. `HC_*` is dropped, since those describe a terminal of the
|
||||
/// core's and would be wrong if this app was itself started from one.
|
||||
fn remoteEnvironment(
|
||||
alloc: std.mem.Allocator,
|
||||
out: *std.ArrayListUnmanaged([]const u8),
|
||||
extra: []const []const u8,
|
||||
) !void {
|
||||
var i: usize = 0;
|
||||
while (std.c.environ[i]) |entry| : (i += 1) {
|
||||
const span = std.mem.span(entry);
|
||||
if (std.mem.startsWith(u8, span, "TERM=")) continue;
|
||||
if (std.mem.startsWith(u8, span, "COLORTERM=")) continue;
|
||||
if (std.mem.startsWith(u8, span, "HC_")) continue;
|
||||
if (Pty.shadowedBy(span, extra)) continue;
|
||||
try out.append(alloc, span);
|
||||
}
|
||||
try out.append(alloc, "TERM=xterm-256color");
|
||||
try out.append(alloc, "COLORTERM=truecolor");
|
||||
try out.appendSlice(alloc, extra);
|
||||
}
|
||||
|
||||
fn stopBackend(self: *Session) void {
|
||||
switch (self.backend) {
|
||||
.local => |*pty| {
|
||||
if (self.watch != 0) _ = glib.Source.remove(self.watch);
|
||||
self.watch = 0;
|
||||
pty.deinit();
|
||||
},
|
||||
.hc => |*r| {
|
||||
if (r.timer != 0) _ = glib.Source.remove(r.timer);
|
||||
r.timer = 0;
|
||||
if (r.resize_timer != 0) _ = glib.Source.remove(r.resize_timer);
|
||||
r.resize_timer = 0;
|
||||
if (r.channel) |ch| hc.detach(ch);
|
||||
r.channel = null;
|
||||
|
||||
// The one decision that makes this backend worth having. Closing a
|
||||
// pane is asking for its program to go; the app quitting, or
|
||||
// crashing, is not — see `hc.detaching`.
|
||||
if (!hc.detaching and !self.exited) hc.kill(r.address);
|
||||
self.alloc.free(r.address);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn hcStream(self: *Session) hc.Stream {
|
||||
return .{ .on_data = &onHcData, .on_end = &onHcEnd, .ctx = self };
|
||||
}
|
||||
|
||||
/// What the core answers the program's display queries with: what this pane
|
||||
/// actually looks like.
|
||||
fn display(self: *const Session) hc.Display {
|
||||
const rgb = struct {
|
||||
fn of(c: theme.Rgb) hc.Rgb {
|
||||
return .{ .r = c.r, .g = c.g, .b = c.b };
|
||||
}
|
||||
}.of;
|
||||
return .{
|
||||
.fg = rgb(theme.fg()),
|
||||
.bg = rgb(theme.bg()),
|
||||
.cursor = rgb(theme.cursor()),
|
||||
.cell = .{ self.cell_px.w, self.cell_px.h },
|
||||
};
|
||||
}
|
||||
|
||||
fn sendFacts(self: *Session) void {
|
||||
switch (self.backend) {
|
||||
.local => {},
|
||||
.hc => |r| if (r.channel) |ch| hc.facts(ch, self.display()),
|
||||
}
|
||||
}
|
||||
|
||||
fn onHcData(ctx: *anyopaque, bytes: []const u8) void {
|
||||
const self: *Session = @ptrCast(@alignCast(ctx));
|
||||
self.stream.nextSlice(bytes);
|
||||
|
||||
const r = &self.backend.hc;
|
||||
if (r.paints_to_skip > 0) {
|
||||
r.paints_to_skip -= 1;
|
||||
} else {
|
||||
self.flushStartupCommand();
|
||||
}
|
||||
self.on_damage(self.ctx);
|
||||
}
|
||||
|
||||
fn onFlushTimer(data: ?*anyopaque) callconv(.c) void {
|
||||
const self: *Session = @ptrCast(@alignCast(data.?));
|
||||
self.flush_timer = 0;
|
||||
self.flushStartupCommand();
|
||||
}
|
||||
|
||||
/// The stream ended. Whether that is the program exiting is a question for the
|
||||
/// core, asked from a timer: this runs inside the connection's delivery loop,
|
||||
/// which must not make calls of its own.
|
||||
fn onHcEnd(ctx: *anyopaque, why: hc.End) void {
|
||||
const self: *Session = @ptrCast(@alignCast(ctx));
|
||||
const r = &self.backend.hc;
|
||||
r.channel = null;
|
||||
|
||||
if (why == .lost and r.attempts == 0) {
|
||||
// Said once, in the pane, so a terminal that has stopped answering
|
||||
// does not look like a hung program. The reattach repaints over it.
|
||||
self.stream.nextSlice("\r\n\x1b[2m[playpen: lost herdr-core, reconnecting\xe2\x80\xa6]\x1b[0m\r\n");
|
||||
self.on_damage(self.ctx);
|
||||
}
|
||||
self.scheduleRecheck();
|
||||
}
|
||||
|
||||
fn scheduleRecheck(self: *Session) void {
|
||||
const r = &self.backend.hc;
|
||||
if (r.timer != 0) return;
|
||||
// Quick the first time, which is the common case of a program exiting,
|
||||
// then backing off to every few seconds for a core that is down.
|
||||
const shift: u5 = @intCast(@min(r.attempts, 5));
|
||||
const delay: c_uint = @min(@as(c_uint, 50) << shift, 5000);
|
||||
r.timer = glib.timeoutAddOnce(delay, &onRecheck, self);
|
||||
}
|
||||
|
||||
fn onRecheck(data: ?*anyopaque) callconv(.c) void {
|
||||
const self: *Session = @ptrCast(@alignCast(data.?));
|
||||
const r = &self.backend.hc;
|
||||
r.timer = 0;
|
||||
|
||||
const facts = hc.get(r.address) catch {
|
||||
r.attempts += 1;
|
||||
self.scheduleRecheck();
|
||||
return;
|
||||
};
|
||||
|
||||
switch (facts.state) {
|
||||
// Still running: the stream ended for some other reason — the
|
||||
// connection dropped, or a kept terminal's shell was replaced. The
|
||||
// emulator already holds the scrollback, so only the screen is asked for.
|
||||
.live => {
|
||||
const att = hc.attach(r.address, self.term.cols, self.term.rows, 0, self.display(), self.hcStream()) catch {
|
||||
r.attempts += 1;
|
||||
self.scheduleRecheck();
|
||||
return;
|
||||
};
|
||||
r.channel = att.channel;
|
||||
if (att.pid > 0) r.pid = att.pid;
|
||||
r.attempts = 0;
|
||||
r.sent_cols = self.term.cols;
|
||||
r.sent_rows = self.term.rows;
|
||||
},
|
||||
.finished, .gone => {
|
||||
self.exited = true;
|
||||
self.on_exit(self.ctx);
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,16 @@ theme: Theme = .system,
|
||||
/// a mistyped chord and every shell in every tab exiting.
|
||||
confirm_quit: bool = true,
|
||||
|
||||
/// Whether new terminals run in herdr-core (`hc`) rather than in a pty of the
|
||||
/// app's own.
|
||||
///
|
||||
/// Off by default: it needs `hc` installed, and it changes what quitting
|
||||
/// means. With it on, a shell's program belongs to the core, so closing the
|
||||
/// window — or the app crashing — leaves every shell and agent running, and
|
||||
/// the next launch restores the session without asking, attaching to them
|
||||
/// again. Closing a pane or a tab still ends what is in it. See `hc.zig`.
|
||||
herdr_core: bool = false,
|
||||
|
||||
/// Whether a pane finishing posts a desktop notification.
|
||||
///
|
||||
/// On by default. The whole shape of this app is "set something going in a tab
|
||||
@@ -268,6 +278,10 @@ fn parse(self: *Settings, text: []const u8) ParseError!void {
|
||||
if (value == .bool) self.confirm_quit = value.bool;
|
||||
}
|
||||
|
||||
if (root.get("herdr_core")) |value| {
|
||||
if (value == .bool) self.herdr_core = value.bool;
|
||||
}
|
||||
|
||||
if (root.get("notifications")) |value| {
|
||||
if (value == .bool) self.notifications = value.bool;
|
||||
}
|
||||
@@ -623,6 +637,8 @@ fn serialize(self: *Settings) SaveError![]u8 {
|
||||
try json.write(@tagName(self.theme));
|
||||
try json.objectField("confirm_quit");
|
||||
try json.write(self.confirm_quit);
|
||||
try json.objectField("herdr_core");
|
||||
try json.write(self.herdr_core);
|
||||
try json.objectField("notifications");
|
||||
try json.write(self.notifications);
|
||||
|
||||
|
||||
+79
-26
@@ -36,6 +36,7 @@ const PaletteEditor = @import("PaletteEditor.zig");
|
||||
const PathEntry = @import("PathEntry.zig");
|
||||
const Settings = @import("Settings.zig");
|
||||
const appearance = @import("appearance.zig");
|
||||
const hc = @import("hc.zig");
|
||||
|
||||
const SettingsDialog = @This();
|
||||
|
||||
@@ -112,6 +113,9 @@ confirm_quit: *gtk.Switch,
|
||||
/// The notifications toggle, held for the same reason.
|
||||
notifications: *gtk.Switch,
|
||||
|
||||
/// The run-terminals-in-herdr-core toggle, held for the same reason.
|
||||
herdr_core: *gtk.Switch,
|
||||
|
||||
/// Container the startup rows live in, so rows can be added and removed after
|
||||
/// the page is already on screen.
|
||||
startup_box: *gtk.Box,
|
||||
@@ -149,6 +153,7 @@ pub fn present(alloc: std.mem.Allocator, parent: *gtk.Window, opts: Options) !vo
|
||||
.colors = undefined,
|
||||
.confirm_quit = gtk.Switch.new(),
|
||||
.notifications = gtk.Switch.new(),
|
||||
.herdr_core = gtk.Switch.new(),
|
||||
.startup_box = gtk.Box.new(.vertical, 6),
|
||||
.error_label = gtk.Label.new(null),
|
||||
};
|
||||
@@ -407,7 +412,8 @@ fn onNotificationsChanged(
|
||||
self.showError(null);
|
||||
}
|
||||
|
||||
/// The Quitting section: whether closing the window asks first.
|
||||
/// The Quitting section: whether closing the window asks first, and whether
|
||||
/// closing it ends the shells at all.
|
||||
///
|
||||
/// Above the startup list rather than below it. That list grows with the number
|
||||
/// of tabs someone opens at launch, and a single-row group underneath it would
|
||||
@@ -421,30 +427,7 @@ fn buildQuitting(self: *SettingsDialog) *gtk.Widget {
|
||||
title.as(gtk.Widget).addCssClass("playpen-settings-title");
|
||||
group.append(title.as(gtk.Widget));
|
||||
|
||||
const row = gtk.Box.new(.horizontal, 12);
|
||||
|
||||
const labels = gtk.Box.new(.vertical, 2);
|
||||
labels.as(gtk.Widget).setHexpand(1);
|
||||
labels.as(gtk.Widget).setValign(.center);
|
||||
|
||||
const name = gtk.Label.new("Confirm before closing the window");
|
||||
name.setXalign(0);
|
||||
name.as(gtk.Widget).addCssClass("playpen-dialog-label");
|
||||
labels.append(name.as(gtk.Widget));
|
||||
|
||||
const hint_label = gtk.Label.new(
|
||||
"Closing the window exits every shell in every tab. Off, the window " ++
|
||||
"goes the moment the window manager says so.",
|
||||
);
|
||||
hint_label.setXalign(0);
|
||||
hint_label.setWrap(1);
|
||||
hint_label.as(gtk.Widget).addCssClass("playpen-dialog-sublabel");
|
||||
labels.append(hint_label.as(gtk.Widget));
|
||||
|
||||
row.append(labels.as(gtk.Widget));
|
||||
|
||||
self.confirm_quit.setActive(@intFromBool(Settings.get().confirm_quit));
|
||||
self.confirm_quit.as(gtk.Widget).setValign(.center);
|
||||
_ = gobject.Object.signals.notify.connect(
|
||||
self.confirm_quit,
|
||||
*SettingsDialog,
|
||||
@@ -452,13 +435,83 @@ fn buildQuitting(self: *SettingsDialog) *gtk.Widget {
|
||||
self,
|
||||
.{ .detail = "active" },
|
||||
);
|
||||
row.append(self.confirm_quit.as(gtk.Widget));
|
||||
group.append(switchRow(
|
||||
"Confirm before closing the window",
|
||||
"Closing the window exits every shell in every tab. Off, the window " ++
|
||||
"goes the moment the window manager says so.",
|
||||
self.confirm_quit,
|
||||
));
|
||||
|
||||
group.append(row.as(gtk.Widget));
|
||||
// Said up front when `hc` is missing, rather than letting the switch turn
|
||||
// on and every new shell quietly come up local anyway.
|
||||
const installed = hc.installed();
|
||||
self.herdr_core.setActive(@intFromBool(Settings.get().herdr_core));
|
||||
_ = gobject.Object.signals.notify.connect(
|
||||
self.herdr_core,
|
||||
*SettingsDialog,
|
||||
&onHerdrCoreChanged,
|
||||
self,
|
||||
.{ .detail = "active" },
|
||||
);
|
||||
group.append(switchRow(
|
||||
"Keep shells running after the window closes",
|
||||
if (installed)
|
||||
"New shells run in herdr-core, so quitting or a crash leaves them " ++
|
||||
"running, and the next launch reattaches to them in place of the " ++
|
||||
"startup tabs. Closing a pane or tab still ends it."
|
||||
else
|
||||
"Needs herdr-core: hc was not found on PATH or in ~/.local/bin. " ++
|
||||
"Until it is, new shells run here as usual.",
|
||||
self.herdr_core,
|
||||
));
|
||||
|
||||
return group.as(gtk.Widget);
|
||||
}
|
||||
|
||||
/// A setting that is one switch: its name and a line of explanation on the
|
||||
/// left, the switch on the right.
|
||||
fn switchRow(name_text: [:0]const u8, hint_text: [:0]const u8, toggle: *gtk.Switch) *gtk.Widget {
|
||||
const row = gtk.Box.new(.horizontal, 12);
|
||||
|
||||
const labels = gtk.Box.new(.vertical, 2);
|
||||
labels.as(gtk.Widget).setHexpand(1);
|
||||
labels.as(gtk.Widget).setValign(.center);
|
||||
|
||||
const name = gtk.Label.new(name_text);
|
||||
name.setXalign(0);
|
||||
name.as(gtk.Widget).addCssClass("playpen-dialog-label");
|
||||
labels.append(name.as(gtk.Widget));
|
||||
|
||||
const hint_label = gtk.Label.new(hint_text);
|
||||
hint_label.setXalign(0);
|
||||
hint_label.setWrap(1);
|
||||
hint_label.as(gtk.Widget).addCssClass("playpen-dialog-sublabel");
|
||||
labels.append(hint_label.as(gtk.Widget));
|
||||
|
||||
row.append(labels.as(gtk.Widget));
|
||||
|
||||
toggle.as(gtk.Widget).setValign(.center);
|
||||
row.append(toggle.as(gtk.Widget));
|
||||
|
||||
return row.as(gtk.Widget);
|
||||
}
|
||||
|
||||
/// Saved straight away like the others. Only shells opened from now on are
|
||||
/// affected: the ones already open stay where they are running.
|
||||
fn onHerdrCoreChanged(
|
||||
toggle: *gtk.Switch,
|
||||
_: *gobject.ParamSpec,
|
||||
self: *SettingsDialog,
|
||||
) callconv(.c) void {
|
||||
Settings.get().herdr_core = toggle.getActive() != 0;
|
||||
|
||||
Settings.get().save() catch {
|
||||
self.showError("Could not write the settings file.");
|
||||
return;
|
||||
};
|
||||
self.showError(null);
|
||||
}
|
||||
|
||||
/// Straight to the file, as the theme picker does, rather than through
|
||||
/// `persist`: this is one flag, and `commit` would re-read every startup row to
|
||||
/// save it.
|
||||
|
||||
+17
-7
@@ -562,7 +562,11 @@ fn paneSpec(
|
||||
const cwd = try Layouts.expandPath(self.alloc, p.cwd, bindings);
|
||||
errdefer self.alloc.free(cwd);
|
||||
const command = try Layouts.expand(self.alloc, p.command, bindings);
|
||||
break :blk .{ .terminal = .{ .cwd = cwd, .command = command } };
|
||||
errdefer self.alloc.free(command);
|
||||
// Taken literally: an address is not something a parameter can
|
||||
// stand in for, and only a snapshot ever has one.
|
||||
const hc = try self.alloc.dupe(u8, p.hc);
|
||||
break :blk .{ .terminal = .{ .cwd = cwd, .command = command, .hc = hc } };
|
||||
},
|
||||
.web => .{ .web = .{
|
||||
.url = try Layouts.expand(self.alloc, p.url, bindings),
|
||||
@@ -580,6 +584,7 @@ fn freePaneSpec(alloc: std.mem.Allocator, spec: Pane.Spec) void {
|
||||
.terminal => |o| {
|
||||
alloc.free(o.cwd);
|
||||
alloc.free(o.command);
|
||||
alloc.free(o.hc);
|
||||
},
|
||||
.web => |o| alloc.free(o.url),
|
||||
// Borrowed from the window, which owns the strings for as long as the
|
||||
@@ -594,19 +599,24 @@ fn freePaneSpec(alloc: std.mem.Allocator, spec: Pane.Spec) void {
|
||||
/// they are on screen. What each pane should *run* can't be known from a live
|
||||
/// pane, so terminals come back with their current directory and an empty
|
||||
/// command for the user to fill in; web panes bring their current URL.
|
||||
pub fn capture(self: *View, builder: Layouts.Builder) !?*Layouts.Node {
|
||||
///
|
||||
/// `running` is for the session snapshot, which also records the herdr-core
|
||||
/// terminal each terminal pane is attached to so a restore can reattach to it.
|
||||
/// A saved layout leaves that out: it is a recipe for new shells.
|
||||
pub fn capture(self: *View, builder: Layouts.Builder, running: bool) !?*Layouts.Node {
|
||||
const root = self.layout.root orelse return null;
|
||||
return try self.captureNode(root, builder);
|
||||
return try self.captureNode(root, builder, running);
|
||||
}
|
||||
|
||||
fn captureNode(self: *View, node: *Layout.Node, builder: Layouts.Builder) !*Layouts.Node {
|
||||
fn captureNode(self: *View, node: *Layout.Node, builder: Layouts.Builder, running: bool) !*Layouts.Node {
|
||||
return switch (node.kind) {
|
||||
.leaf => |pane| switch (pane.content) {
|
||||
.terminal => |t| blk: {
|
||||
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
break :blk try builder.pane(.{
|
||||
.kind = .terminal,
|
||||
.cwd = t.session.pty.cwd(&buf) orelse "",
|
||||
.cwd = t.session.cwd(&buf) orelse "",
|
||||
.hc = if (running) t.session.hcAddress() orelse "" else "",
|
||||
});
|
||||
},
|
||||
.web => |b| try builder.pane(.{
|
||||
@@ -628,8 +638,8 @@ fn captureNode(self: *View, node: *Layout.Node, builder: Layouts.Builder) !*Layo
|
||||
else => .horizontal,
|
||||
},
|
||||
s.ratio,
|
||||
try self.captureNode(s.a, builder),
|
||||
try self.captureNode(s.b, builder),
|
||||
try self.captureNode(s.a, builder, running),
|
||||
try self.captureNode(s.b, builder, running),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
+66
-18
@@ -29,6 +29,7 @@ const Terminal = @import("Terminal.zig");
|
||||
const View = @import("View.zig");
|
||||
const appearance = @import("appearance.zig");
|
||||
const emoji = @import("emoji.zig");
|
||||
const hc = @import("hc.zig");
|
||||
const key = @import("key.zig");
|
||||
const notify = @import("notify.zig");
|
||||
const review = @import("review.zig");
|
||||
@@ -499,12 +500,18 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
|
||||
|
||||
appearance.onChanged(&onAppearanceChanged, self);
|
||||
|
||||
try self.openStartupTabs();
|
||||
// With shells in herdr-core, the last session is not a photograph to be
|
||||
// offered but the shells themselves, still running — so it simply comes
|
||||
// back, in place of the startup tabs rather than after them. With nothing
|
||||
// to restore, or nothing that would build, it is an ordinary launch.
|
||||
const herdr = Settings.get().herdr_core;
|
||||
if (!(herdr and self.restoreAtLaunch())) try self.openStartupTabs();
|
||||
|
||||
// After the startup tabs, not before: the offer is withdrawn by anything
|
||||
// that changes the tab set, and the window opening its own tabs would
|
||||
// otherwise withdraw it before it was ever made.
|
||||
self.offerRestore();
|
||||
// otherwise withdraw it before it was ever made. Never with herdr-core on:
|
||||
// whatever there was to restore has already been restored.
|
||||
if (!herdr) self.offerRestore();
|
||||
|
||||
self.autosave = glib.timeoutAddSeconds(autosave_interval_s, &onAutosave, self);
|
||||
for (terminate_signals, &self.signal_sources) |signum, *source| {
|
||||
@@ -1133,7 +1140,7 @@ fn onSaveLayoutClicked(_: *gtk.Button, self: *Window) callconv(.c) void {
|
||||
fn saveCurrentTabAsLayout(self: *Window) void {
|
||||
const tab = self.activeTab() orelse return;
|
||||
|
||||
const root = tab.view.capture(self.layouts.builder()) catch |err| {
|
||||
const root = tab.view.capture(self.layouts.builder(), false) catch |err| {
|
||||
std.log.err("failed to capture layout: {s}", .{@errorName(err)});
|
||||
return;
|
||||
} orelse return;
|
||||
@@ -1549,6 +1556,34 @@ fn restoreSession(self: *Window) void {
|
||||
};
|
||||
defer self.alloc.free(previous);
|
||||
|
||||
// Nothing was built, so nothing is closed and the window is exactly as it
|
||||
// was — only the banner has gone.
|
||||
const restored = self.buildSnapshotTabs() orelse return;
|
||||
|
||||
self.select(restored);
|
||||
for (previous) |tab| self.discardTab(tab);
|
||||
}
|
||||
|
||||
/// The launch-time restore herdr-core gets instead of the banner: the snapshot
|
||||
/// read and built straight into an empty window. False when there was nothing
|
||||
/// to restore or none of it would build, leaving the window empty for the
|
||||
/// startup tabs.
|
||||
///
|
||||
/// Built into an empty window rather than over the startup tabs the way the
|
||||
/// banner's restore is, since there is no question to wait on. Opening the
|
||||
/// startup tabs first would start a herdr-core shell for each of them only to
|
||||
/// kill it a moment later.
|
||||
fn restoreAtLaunch(self: *Window) bool {
|
||||
self.snapshot.load();
|
||||
if (!self.snapshot.any()) return false;
|
||||
|
||||
const restored = self.buildSnapshotTabs() orelse return false;
|
||||
self.select(restored);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Build every tab in the snapshot, returning the first, or null if none built.
|
||||
fn buildSnapshotTabs(self: *Window) ?*Tab {
|
||||
var first: ?*Tab = null;
|
||||
for (self.snapshot.tabs.items) |entry| {
|
||||
const tab = self.buildSnapshotTab(entry) catch |err| {
|
||||
@@ -1563,16 +1598,10 @@ fn restoreSession(self: *Window) void {
|
||||
if (first == null) first = tab;
|
||||
}
|
||||
|
||||
const restored = first orelse {
|
||||
// Nothing was built, so nothing is closed and the window is exactly as
|
||||
// it was — only the banner has gone. Reaching here takes an allocation
|
||||
// failure per tab, at which point there is nothing better to offer.
|
||||
std.log.err("nothing in the session snapshot could be reopened", .{});
|
||||
return;
|
||||
};
|
||||
|
||||
self.select(restored);
|
||||
for (previous) |tab| self.discardTab(tab);
|
||||
// Reaching here with nothing takes an allocation failure per tab, at which
|
||||
// point there is nothing better to offer.
|
||||
if (first == null) std.log.err("nothing in the session snapshot could be reopened", .{});
|
||||
return first;
|
||||
}
|
||||
|
||||
/// One tab out of the snapshot.
|
||||
@@ -1632,7 +1661,7 @@ fn saveSnapshot(self: *Window) void {
|
||||
defer values.deinit(self.alloc);
|
||||
|
||||
for (self.tabs.items) |tab| {
|
||||
const root = tab.view.capture(snapshot.builder()) catch |err| {
|
||||
const root = tab.view.capture(snapshot.builder(), true) catch |err| {
|
||||
std.log.warn("could not capture a tab for the snapshot: {s}", .{@errorName(err)});
|
||||
continue;
|
||||
} orelse continue;
|
||||
@@ -2351,8 +2380,22 @@ fn askBeforeQuitting(self: *Window) void {
|
||||
// it is also the quickest way to notice you are about to close the wrong
|
||||
// window. The buffer is sized so the format cannot fail, but the fallback
|
||||
// says the same thing without the number rather than nothing at all.
|
||||
//
|
||||
// With shells in herdr-core, the second half would be untrue — and untrue
|
||||
// in the direction that makes quitting look costlier than it is.
|
||||
var buf: [160]u8 = undefined;
|
||||
const body: [:0]const u8 = std.mem.printSentinel(
|
||||
const body: [:0]const u8 = if (Settings.get().herdr_core)
|
||||
std.mem.printSentinel(
|
||||
&buf,
|
||||
"{d} tab{s} will close. Shells in herdr-core keep running and come back on the next launch.",
|
||||
.{
|
||||
self.tabs.items.len,
|
||||
if (self.tabs.items.len == 1) "" else "s",
|
||||
},
|
||||
0,
|
||||
) catch "Every tab will close. Shells in herdr-core keep running."
|
||||
else
|
||||
std.mem.printSentinel(
|
||||
&buf,
|
||||
"{d} tab{s} will close, and every shell in {s} will exit.",
|
||||
.{
|
||||
@@ -2415,6 +2458,11 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void {
|
||||
if (source != 0) _ = glib.Source.remove(source);
|
||||
}
|
||||
|
||||
// From here on the app is going away, not closing panes: herdr-core
|
||||
// terminals are detached from, not killed, so they are still there for the
|
||||
// snapshot below to be restored into. See `hc.detaching`.
|
||||
hc.detaching = true;
|
||||
|
||||
// First, while every tab is still whole and every shell still running: the
|
||||
// directories in the snapshot are read out of live processes. Written even
|
||||
// if the last autosave took the same picture, since the exit is the one
|
||||
@@ -2640,12 +2688,12 @@ fn tabDirectory(self: *Window, tab: *Tab, buf: []u8) []const u8 {
|
||||
|
||||
if (tab.view.focusedPane()) |focused| {
|
||||
if (focused.terminal()) |terminal| {
|
||||
if (terminal.session.pty.cwd(buf)) |dir| return dir;
|
||||
if (terminal.session.cwd(buf)) |dir| return dir;
|
||||
}
|
||||
}
|
||||
for (tab.view.panes.items) |pane| {
|
||||
const terminal = pane.terminal() orelse continue;
|
||||
if (terminal.session.pty.cwd(buf)) |dir| return dir;
|
||||
if (terminal.session.cwd(buf)) |dir| return dir;
|
||||
}
|
||||
|
||||
// Playpen's own directory, which at least gives the server something to
|
||||
|
||||
+827
@@ -0,0 +1,827 @@
|
||||
//! A connection to herdr-core, the terminal runtime behind `hc`, so that a
|
||||
//! terminal pane's program can live in the core instead of in this process.
|
||||
//!
|
||||
//! What that buys is the one thing a pty owned by the app cannot give: the
|
||||
//! programs outlive the window. The core holds each terminal (its process, its
|
||||
//! screen, its scrollback) in a service of its own, and a client attaches to
|
||||
//! one to see it and type into it. Quit playpen, or have it crash, or have
|
||||
//! systemd kill its scope, and every shell and agent keeps going. The session
|
||||
//! snapshot remembers which terminal each pane was attached to, and restoring
|
||||
//! it attaches again.
|
||||
//!
|
||||
//! The connection is `hc connect`: the core's JSON-RPC API over the child's
|
||||
//! stdin and stdout, switched to framed mode by `hello` (see `hc/frame.zig`).
|
||||
//! Channel 0 carries the RPC; each attached terminal gets a channel of its own
|
||||
//! for its raw byte stream. One connection serves every pane in the app.
|
||||
//!
|
||||
//! Everything runs on the GLib main loop, the same as the local pty, so the
|
||||
//! terminal state stays single-threaded. RPCs are synchronous — a pane being
|
||||
//! created needs its terminal's address before it can do anything — and wait
|
||||
//! on the socket with a deadline rather than spinning the main loop. Frames
|
||||
//! for other channels that arrive during the wait are left in the buffer and
|
||||
//! delivered from an idle callback afterwards, never from inside the call,
|
||||
//! so no stream callback can run while some other code is halfway through
|
||||
//! creating a pane.
|
||||
//!
|
||||
//! The child's stdin and stdout are one end of a socketpair rather than two
|
||||
//! pipes, so writes can pass `MSG_NOSIGNAL`. A pipe whose reader has died
|
||||
//! raises SIGPIPE, which would kill the app; ignoring the signal process-wide
|
||||
//! instead would leak into every shell the local pty starts, since an ignored
|
||||
//! signal stays ignored across exec.
|
||||
|
||||
const std = @import("std");
|
||||
const glib = @import("glib");
|
||||
const glibunix = @import("glibunix");
|
||||
|
||||
const frame = @import("hc/frame.zig");
|
||||
|
||||
/// Everything here lives as long as the connection, which is not tied to any
|
||||
/// one pane, so it takes libc's allocator rather than borrowing a caller's.
|
||||
const gpa = std.heap.c_allocator;
|
||||
|
||||
const c = struct {
|
||||
extern "c" fn socketpair(domain: c_int, kind: c_int, protocol: c_int, fds: *[2]c_int) c_int;
|
||||
extern "c" fn send(fd: c_int, buf: [*]const u8, len: usize, flags: c_int) isize;
|
||||
extern "c" fn recv(fd: c_int, buf: [*]u8, len: usize, flags: c_int) isize;
|
||||
extern "c" fn close(fd: c_int) c_int;
|
||||
extern "c" fn poll(fds: [*]PollFd, n: c_ulong, timeout: c_int) c_int;
|
||||
extern "c" fn access(path: [*:0]const u8, mode: c_int) c_int;
|
||||
extern "c" fn __errno_location() *c_int;
|
||||
};
|
||||
|
||||
const PollFd = extern struct { fd: c_int, events: c_short, revents: c_short = 0 };
|
||||
|
||||
const AF_UNIX = 1;
|
||||
const SOCK_STREAM = 1;
|
||||
const SOCK_CLOEXEC = 0o2000000;
|
||||
const MSG_DONTWAIT = 0x40;
|
||||
const MSG_NOSIGNAL = 0x4000;
|
||||
const POLLIN = 0x1;
|
||||
const POLLOUT = 0x4;
|
||||
const EINTR = 4;
|
||||
const EAGAIN = 11;
|
||||
const X_OK = 1;
|
||||
|
||||
/// The protocol generation this client speaks.
|
||||
const generation = 1;
|
||||
|
||||
/// How long an RPC may take before the app stops waiting for it. Long enough
|
||||
/// for a core that is starting up; short enough that a wedged one freezes the
|
||||
/// window for a moment rather than for good.
|
||||
const call_timeout_ms = 5000;
|
||||
|
||||
/// How long to wait before trying to connect again after failing to. Without
|
||||
/// it, a broken core would cost every new pane the whole of `call_timeout_ms`.
|
||||
const retry_after_failure_us = 5 * std.time.us_per_s;
|
||||
|
||||
/// Rows of scrollback asked for when attaching to a terminal this app has not
|
||||
/// seen before — one being restored from the snapshot. Matches herdr's own app.
|
||||
pub const restore_history = 2000;
|
||||
|
||||
/// Set by the window as it tears down. While it is set, a terminal pane being
|
||||
/// destroyed detaches from its program rather than killing it: the app is
|
||||
/// going away, and the programs are meant to outlive it. At any other time,
|
||||
/// destroying a pane is closing it, and its program goes with it.
|
||||
pub var detaching = false;
|
||||
|
||||
/// What an attached terminal's owner hears back.
|
||||
pub const Stream = struct {
|
||||
/// Output from the program, to be fed to the emulator.
|
||||
on_data: *const fn (ctx: *anyopaque, bytes: []const u8) void,
|
||||
|
||||
/// The stream is over. The owner decides what that means by asking the
|
||||
/// core — see `End`.
|
||||
on_end: *const fn (ctx: *anyopaque, why: End) void,
|
||||
|
||||
ctx: *anyopaque,
|
||||
};
|
||||
|
||||
pub const End = enum {
|
||||
/// The core ended this stream. Usually the program exited, but a kept
|
||||
/// terminal whose program was lost restarts with a fresh shell and ends
|
||||
/// its streams too, so this is a cue to ask, not an answer.
|
||||
closed,
|
||||
|
||||
/// The connection to the core dropped. The program is most likely still
|
||||
/// running — a core restart keeps them — so the owner should reattach.
|
||||
lost,
|
||||
};
|
||||
|
||||
pub const Error = error{
|
||||
/// No core: `hc` is not installed, or the core would not answer.
|
||||
Unavailable,
|
||||
/// The connection dropped mid-call.
|
||||
Lost,
|
||||
Timeout,
|
||||
/// The terminal does not exist (any more).
|
||||
NotFound,
|
||||
/// The terminal exists but its program has ended.
|
||||
Finished,
|
||||
/// The core could not start the program.
|
||||
SpawnFailed,
|
||||
/// Any other refusal, or a reply we could not read.
|
||||
Refused,
|
||||
OutOfMemory,
|
||||
};
|
||||
|
||||
pub const Rgb = struct { r: u8, g: u8, b: u8 };
|
||||
|
||||
/// What the viewer looks like. The core answers the program's display
|
||||
/// queries (colors, light or dark, cell size) from this rather than passing
|
||||
/// them through, so it has to be told.
|
||||
pub const Display = struct {
|
||||
fg: Rgb,
|
||||
bg: Rgb,
|
||||
cursor: Rgb,
|
||||
cell: [2]u32,
|
||||
};
|
||||
|
||||
/// A terminal just started in the core.
|
||||
pub const Started = struct {
|
||||
/// Its address, `t7`. Owned by the caller's allocator.
|
||||
address: []u8,
|
||||
pid: i32,
|
||||
};
|
||||
|
||||
/// A terminal just attached to.
|
||||
pub const Attached = struct {
|
||||
channel: u32,
|
||||
pid: i32,
|
||||
};
|
||||
|
||||
pub const RunOptions = struct {
|
||||
argv: []const []const u8,
|
||||
cwd: ?[]const u8 = null,
|
||||
/// `NAME=value`, set on top of the core's own environment.
|
||||
env: []const []const u8 = &.{},
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The connection
|
||||
|
||||
const Core = struct {
|
||||
/// Our end of the socketpair the `hc connect` child talks on.
|
||||
fd: c_int,
|
||||
|
||||
watch: c_uint = 0,
|
||||
|
||||
/// Pending delivery of frames read during a synchronous call.
|
||||
idle: c_uint = 0,
|
||||
|
||||
/// Bytes read and not yet consumed, from `head` on.
|
||||
buf: std.ArrayListUnmanaged(u8) = .empty,
|
||||
head: usize = 0,
|
||||
|
||||
next_id: u64 = 1,
|
||||
|
||||
streams: std.AutoHashMapUnmanaged(u32, Stream) = .empty,
|
||||
|
||||
/// Set while `dispatch` runs, which must not nest.
|
||||
dispatching: bool = false,
|
||||
};
|
||||
|
||||
var core: ?*Core = null;
|
||||
|
||||
/// When a failed connect may next be retried, in GLib monotonic microseconds.
|
||||
var retry_at: i64 = 0;
|
||||
|
||||
/// Scratch for one frame's payload while it is delivered. A stream callback
|
||||
/// can write, and a write can read more into `buf` and move it, so the payload
|
||||
/// cannot be handed out as a slice of `buf`.
|
||||
var scratch: std.ArrayListUnmanaged(u8) = .empty;
|
||||
|
||||
/// The connection, made if there is none. Null when there is no core to be
|
||||
/// had, which callers take as "use a local pty".
|
||||
fn connection() ?*Core {
|
||||
if (core) |k| return k;
|
||||
const now = glib.getMonotonicTime();
|
||||
if (now < retry_at) return null;
|
||||
core = connect() catch |err| {
|
||||
retry_at = now + retry_after_failure_us;
|
||||
std.log.warn("herdr-core: could not connect ({s}); terminals run locally", .{@errorName(err)});
|
||||
return null;
|
||||
};
|
||||
return core;
|
||||
}
|
||||
|
||||
/// Whether `hc` is installed where this app would look for it. Says nothing
|
||||
/// about whether its core will answer.
|
||||
pub fn installed() bool {
|
||||
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
return findBinary(&buf) != null;
|
||||
}
|
||||
|
||||
/// Find `hc`. On PATH first, then where its installer puts it — a desktop
|
||||
/// launcher's PATH often has no `~/.local/bin` on it.
|
||||
fn findBinary(buf: []u8) ?[:0]const u8 {
|
||||
for ([_][*:0]const u8{ "hc", "herdr-core" }) |name| {
|
||||
const found = glib.findProgramInPath(name) orelse continue;
|
||||
defer glib.free(found);
|
||||
return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(found)}, 0) catch null;
|
||||
}
|
||||
const home = std.mem.span(glib.getHomeDir());
|
||||
for ([_][]const u8{ "hc", "herdr-core" }) |name| {
|
||||
const path = std.fmt.bufPrintSentinel(buf, "{s}/.local/bin/{s}", .{ home, name }, 0) catch continue;
|
||||
if (c.access(path.ptr, X_OK) == 0) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn connect() !*Core {
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const bin = findBinary(&path_buf) orelse return error.NotInstalled;
|
||||
|
||||
var fds: [2]c_int = undefined;
|
||||
if (c.socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, &fds) != 0) return error.SocketFailed;
|
||||
errdefer _ = c.close(fds[0]);
|
||||
|
||||
const arg0 = try gpa.dupeSentinel(u8, bin, 0);
|
||||
defer gpa.free(arg0);
|
||||
var arg1 = "connect".*;
|
||||
var argv = [_:null]?[*:0]u8{ arg0.ptr, &arg1 };
|
||||
|
||||
// The child's end becomes its stdin and stdout. Its stderr is ours, so
|
||||
// anything the core has to say lands in the app's log. Reaped by GLib,
|
||||
// since nothing here needs its exit status: the socket closing is how a
|
||||
// dead child makes itself known.
|
||||
var spawn_err: ?*glib.Error = null;
|
||||
const spawned = glib.spawnAsyncWithFds(null, &argv, null, .{}, null, null, null, fds[1], fds[1], -1, &spawn_err);
|
||||
_ = c.close(fds[1]);
|
||||
if (spawned == 0) {
|
||||
if (spawn_err) |e| {
|
||||
std.log.warn("herdr-core: {s}", .{e.f_message orelse "spawn failed"});
|
||||
e.free();
|
||||
}
|
||||
return error.SpawnFailed;
|
||||
}
|
||||
|
||||
const self = try gpa.create(Core);
|
||||
self.* = .{ .fd = fds[0] };
|
||||
errdefer {
|
||||
self.buf.deinit(gpa);
|
||||
gpa.destroy(self);
|
||||
}
|
||||
|
||||
try hello(self);
|
||||
|
||||
self.watch = glibunix.fdAdd(self.fd, .{ .in = true, .hup = true, .err = true }, &onReadable, self);
|
||||
if (self.buf.items.len > self.head) scheduleDispatch(self);
|
||||
return self;
|
||||
}
|
||||
|
||||
/// The handshake, in line mode: one JSON line out, one back. Everything after
|
||||
/// the reply's newline is already framed.
|
||||
fn hello(self: *Core) !void {
|
||||
try sendAll(self, "{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"hello\",\"params\":{\"framed\":true,\"generation\":1}}\n");
|
||||
|
||||
const deadline = glib.getMonotonicTime() + call_timeout_ms * std.time.us_per_ms;
|
||||
const line_end = while (true) {
|
||||
if (std.mem.indexOfScalar(u8, self.buf.items, '\n')) |i| break i;
|
||||
if (!try fill(self, deadline)) return error.Timeout;
|
||||
};
|
||||
|
||||
const parsed = std.json.parseFromSlice(std.json.Value, gpa, self.buf.items[0..line_end], .{}) catch
|
||||
return error.BadHello;
|
||||
defer parsed.deinit();
|
||||
const obj = switch (parsed.value) {
|
||||
.object => |o| o,
|
||||
else => return error.BadHello,
|
||||
};
|
||||
if (obj.get("error")) |e| {
|
||||
_ = e;
|
||||
return error.HelloRefused;
|
||||
}
|
||||
const res = obj.get("result") orelse return error.BadHello;
|
||||
if (res != .object) return error.BadHello;
|
||||
if (res.object.get("framed")) |f| {
|
||||
if (f != .bool or !f.bool) return error.NotFramed;
|
||||
}
|
||||
if (res.object.get("generation")) |g| {
|
||||
if (g != .integer or g.integer != generation) return error.WrongGeneration;
|
||||
}
|
||||
|
||||
self.head = line_end + 1;
|
||||
}
|
||||
|
||||
/// Read whatever is available into `buf`, waiting until `deadline` for
|
||||
/// something to arrive. False on timeout; error.Lost when the core has gone.
|
||||
fn fill(self: *Core, deadline: i64) Error!bool {
|
||||
while (true) {
|
||||
const remaining_us = deadline - glib.getMonotonicTime();
|
||||
if (remaining_us <= 0) return false;
|
||||
var pfd = [_]PollFd{.{ .fd = self.fd, .events = POLLIN }};
|
||||
const ms: c_int = @intCast(@max(1, @divTrunc(remaining_us, std.time.us_per_ms)));
|
||||
const ready = c.poll(&pfd, 1, ms);
|
||||
if (ready < 0) {
|
||||
if (c.__errno_location().* == EINTR) continue;
|
||||
return error.Lost;
|
||||
}
|
||||
if (ready == 0) return false;
|
||||
return switch (try readSome(self)) {
|
||||
.some, .would_block => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const ReadResult = enum { some, would_block };
|
||||
|
||||
/// One non-blocking read into `buf`.
|
||||
fn readSome(self: *Core) Error!ReadResult {
|
||||
try self.buf.ensureUnusedCapacity(gpa, 64 * 1024);
|
||||
const spare = self.buf.unusedCapacitySlice();
|
||||
const n = c.recv(self.fd, spare.ptr, spare.len, MSG_DONTWAIT);
|
||||
if (n > 0) {
|
||||
self.buf.items.len += @intCast(n);
|
||||
return .some;
|
||||
}
|
||||
if (n < 0) {
|
||||
const errno = c.__errno_location().*;
|
||||
if (errno == EAGAIN or errno == EINTR) return .would_block;
|
||||
}
|
||||
return error.Lost;
|
||||
}
|
||||
|
||||
/// Write all of `bytes`. When the socket is full, whatever the core has sent
|
||||
/// meanwhile is read into `buf` while we wait: a core blocked writing to us
|
||||
/// while we are blocked writing to it would otherwise be a deadlock.
|
||||
fn sendAll(self: *Core, bytes: []const u8) Error!void {
|
||||
var off: usize = 0;
|
||||
while (off < bytes.len) {
|
||||
const n = c.send(self.fd, bytes.ptr + off, bytes.len - off, MSG_NOSIGNAL | MSG_DONTWAIT);
|
||||
if (n > 0) {
|
||||
off += @intCast(n);
|
||||
continue;
|
||||
}
|
||||
const errno = c.__errno_location().*;
|
||||
if (n < 0 and errno == EINTR) continue;
|
||||
if (n < 0 and errno != EAGAIN) return error.Lost;
|
||||
|
||||
var pfd = [_]PollFd{.{ .fd = self.fd, .events = POLLIN | POLLOUT }};
|
||||
if (c.poll(&pfd, 1, call_timeout_ms) <= 0) return error.Lost;
|
||||
if (pfd[0].revents & POLLIN != 0) {
|
||||
_ = try readSome(self);
|
||||
scheduleDispatch(self);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sendFrame(self: *Core, channel: u32, kind: frame.Kind, payload: []const u8) Error!void {
|
||||
const head = frame.header(channel, kind, payload.len);
|
||||
try sendAll(self, &head);
|
||||
try sendAll(self, payload);
|
||||
}
|
||||
|
||||
fn scheduleDispatch(self: *Core) void {
|
||||
if (self.idle == 0) self.idle = glib.idleAddOnce(&onIdle, self);
|
||||
}
|
||||
|
||||
fn onIdle(data: ?*anyopaque) callconv(.c) void {
|
||||
const self: *Core = @ptrCast(@alignCast(data.?));
|
||||
self.idle = 0;
|
||||
dispatch(self);
|
||||
}
|
||||
|
||||
fn onReadable(_: c_int, condition: glib.IOCondition, data: ?*anyopaque) callconv(.c) c_int {
|
||||
const self: *Core = @ptrCast(@alignCast(data.?));
|
||||
|
||||
var gone = !condition.in;
|
||||
if (condition.in) {
|
||||
while (true) {
|
||||
const r = readSome(self) catch {
|
||||
gone = true;
|
||||
break;
|
||||
};
|
||||
if (r == .would_block) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Deliver what did arrive before giving up on the rest: the last thing a
|
||||
// dying core sends can be the close of a stream.
|
||||
dispatch(self);
|
||||
if (core != self) return 0;
|
||||
|
||||
if (gone) {
|
||||
self.watch = 0;
|
||||
lose(self);
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/// Deliver every complete frame in `buf` to its stream.
|
||||
fn dispatch(self: *Core) void {
|
||||
if (self.dispatching) return;
|
||||
self.dispatching = true;
|
||||
|
||||
while (true) {
|
||||
const f = (frame.parse(self.buf.items[self.head..]) catch {
|
||||
std.log.err("herdr-core: a frame too large to be real; dropping the connection", .{});
|
||||
self.dispatching = false;
|
||||
lose(self);
|
||||
return;
|
||||
}) orelse break;
|
||||
self.head += f.len();
|
||||
|
||||
// Replies to calls nobody is waiting for: kills, mostly.
|
||||
if (f.channel == 0) continue;
|
||||
|
||||
const stream = self.streams.get(f.channel) orelse continue;
|
||||
switch (f.kind) {
|
||||
.data => {
|
||||
scratch.clearRetainingCapacity();
|
||||
scratch.appendSlice(gpa, f.payload) catch continue;
|
||||
// Granted back before it is handled, so a slow frame does not
|
||||
// also stall the next one in the core.
|
||||
sendFrame(self, f.channel, .credit, &frame.creditPayload(scratch.items.len)) catch {};
|
||||
stream.on_data(stream.ctx, scratch.items);
|
||||
},
|
||||
.close => {
|
||||
_ = self.streams.remove(f.channel);
|
||||
stream.on_end(stream.ctx, .closed);
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
|
||||
// A callback can lose the connection, which frees it.
|
||||
if (core != self) return;
|
||||
}
|
||||
|
||||
self.dispatching = false;
|
||||
compact(self);
|
||||
}
|
||||
|
||||
/// Drop consumed bytes from the front of `buf`.
|
||||
fn compact(self: *Core) void {
|
||||
if (self.head == 0) return;
|
||||
const rest = self.buf.items.len - self.head;
|
||||
std.mem.copyForwards(u8, self.buf.items[0..rest], self.buf.items[self.head..]);
|
||||
self.buf.items.len = rest;
|
||||
self.head = 0;
|
||||
}
|
||||
|
||||
/// The connection is gone: tell every stream, and forget it. The next call
|
||||
/// connects afresh.
|
||||
fn lose(self: *Core) void {
|
||||
if (core == self) core = null;
|
||||
if (self.watch != 0) _ = glib.Source.remove(self.watch);
|
||||
if (self.idle != 0) _ = glib.Source.remove(self.idle);
|
||||
_ = c.close(self.fd);
|
||||
|
||||
var streams = self.streams;
|
||||
self.buf.deinit(gpa);
|
||||
gpa.destroy(self);
|
||||
|
||||
std.log.warn("herdr-core: lost the connection", .{});
|
||||
var it = streams.valueIterator();
|
||||
while (it.next()) |stream| stream.on_end(stream.ctx, .lost);
|
||||
streams.deinit(gpa);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Calls
|
||||
|
||||
/// A reply, owned by the caller.
|
||||
const Reply = std.json.Parsed(std.json.Value);
|
||||
|
||||
/// Make a call and wait for its reply. Returns the reply's `result`, inside
|
||||
/// `Reply`, or the error the core gave.
|
||||
fn call(self: *Core, method: []const u8, params: []const u8) Error!Reply {
|
||||
const id = self.next_id;
|
||||
self.next_id += 1;
|
||||
|
||||
const request = std.fmt.allocPrint(gpa, "{{\"jsonrpc\":\"2.0\",\"id\":{d},\"method\":\"{s}\",\"params\":{s}}}", .{ id, method, params }) catch
|
||||
return error.OutOfMemory;
|
||||
defer gpa.free(request);
|
||||
sendFrame(self, 0, .data, request) catch |err| {
|
||||
lose(self);
|
||||
return err;
|
||||
};
|
||||
|
||||
const deadline = glib.getMonotonicTime() + call_timeout_ms * std.time.us_per_ms;
|
||||
var scan = self.head;
|
||||
while (true) {
|
||||
while (frame.parse(self.buf.items[scan..]) catch {
|
||||
lose(self);
|
||||
return error.Lost;
|
||||
}) |f| {
|
||||
if (f.channel == 0 and f.kind == .data) {
|
||||
if (try matchReply(f.payload, id)) |reply| {
|
||||
// Taken out of the buffer; everything around it stays for
|
||||
// `dispatch`, in order.
|
||||
self.buf.replaceRangeAssumeCapacity(scan, f.len(), &.{});
|
||||
if (self.buf.items.len > self.head) scheduleDispatch(self);
|
||||
return reply;
|
||||
}
|
||||
}
|
||||
scan += f.len();
|
||||
}
|
||||
|
||||
const got = fill(self, deadline) catch |err| {
|
||||
lose(self);
|
||||
return err;
|
||||
};
|
||||
if (!got) {
|
||||
if (self.buf.items.len > self.head) scheduleDispatch(self);
|
||||
std.log.warn("herdr-core: {s} timed out", .{method});
|
||||
return error.Timeout;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The reply in `payload` if it answers call `id`, with an error reply turned
|
||||
/// into the matching error.
|
||||
fn matchReply(payload: []const u8, id: u64) Error!?Reply {
|
||||
const reply = std.json.parseFromSlice(std.json.Value, gpa, payload, .{}) catch return null;
|
||||
const obj = switch (reply.value) {
|
||||
.object => |o| o,
|
||||
else => {
|
||||
reply.deinit();
|
||||
return null;
|
||||
},
|
||||
};
|
||||
const got = obj.get("id") orelse {
|
||||
reply.deinit();
|
||||
return null;
|
||||
};
|
||||
if (got != .integer or got.integer != id) {
|
||||
reply.deinit();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (obj.get("error")) |e| {
|
||||
defer reply.deinit();
|
||||
const kind = if (e == .object) blk: {
|
||||
const d = e.object.get("data") orelse break :blk "";
|
||||
if (d != .object) break :blk "";
|
||||
const k = d.object.get("kind") orelse break :blk "";
|
||||
break :blk if (k == .string) k.string else "";
|
||||
} else "";
|
||||
if (std.mem.eql(u8, kind, "not_found")) return error.NotFound;
|
||||
if (std.mem.eql(u8, kind, "finished")) return error.Finished;
|
||||
if (std.mem.eql(u8, kind, "spawn_failed")) return error.SpawnFailed;
|
||||
std.log.warn("herdr-core refused a call: {s}", .{if (kind.len > 0) kind else "unknown"});
|
||||
return error.Refused;
|
||||
}
|
||||
if (obj.get("result") == null) {
|
||||
reply.deinit();
|
||||
return error.Refused;
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
|
||||
/// Send a call and don't wait for its reply, which `dispatch` will drop.
|
||||
fn cast(self: *Core, method: []const u8, params: []const u8) void {
|
||||
const id = self.next_id;
|
||||
self.next_id += 1;
|
||||
const request = std.fmt.allocPrint(gpa, "{{\"jsonrpc\":\"2.0\",\"id\":{d},\"method\":\"{s}\",\"params\":{s}}}", .{ id, method, params }) catch return;
|
||||
defer gpa.free(request);
|
||||
sendFrame(self, 0, .data, request) catch {};
|
||||
}
|
||||
|
||||
fn result(reply: Reply) std.json.ObjectMap {
|
||||
const r = reply.value.object.get("result").?;
|
||||
return if (r == .object) r.object else .empty;
|
||||
}
|
||||
|
||||
fn intField(obj: std.json.ObjectMap, name: []const u8) ?i64 {
|
||||
const v = obj.get(name) orelse return null;
|
||||
return if (v == .integer) v.integer else null;
|
||||
}
|
||||
|
||||
fn strField(obj: std.json.ObjectMap, name: []const u8) ?[]const u8 {
|
||||
const v = obj.get(name) orelse return null;
|
||||
return if (v == .string) v.string else null;
|
||||
}
|
||||
|
||||
const Json = struct {
|
||||
out: std.Io.Writer.Allocating,
|
||||
j: std.json.Stringify,
|
||||
|
||||
fn init(self: *Json) void {
|
||||
self.out = .init(gpa);
|
||||
self.j = .{ .writer = &self.out.writer };
|
||||
}
|
||||
|
||||
fn deinit(self: *Json) void {
|
||||
self.out.deinit();
|
||||
}
|
||||
|
||||
fn written(self: *Json) []const u8 {
|
||||
return self.out.written();
|
||||
}
|
||||
};
|
||||
|
||||
fn writeRgb(j: *std.json.Stringify, rgb: Rgb) !void {
|
||||
var buf: [7]u8 = undefined;
|
||||
try j.write(std.fmt.bufPrint(&buf, "#{x:0>2}{x:0>2}{x:0>2}", .{ rgb.r, rgb.g, rgb.b }) catch unreachable);
|
||||
}
|
||||
|
||||
fn writeDisplay(j: *std.json.Stringify, d: Display) !void {
|
||||
try j.beginObject();
|
||||
try j.objectField("fg");
|
||||
try writeRgb(j, d.fg);
|
||||
try j.objectField("bg");
|
||||
try writeRgb(j, d.bg);
|
||||
try j.objectField("cursor");
|
||||
try writeRgb(j, d.cursor);
|
||||
try j.objectField("cell");
|
||||
try j.beginArray();
|
||||
try j.write(d.cell[0]);
|
||||
try j.write(d.cell[1]);
|
||||
try j.endArray();
|
||||
try j.endObject();
|
||||
}
|
||||
|
||||
fn writeAddress(address: []const u8) Error![]u8 {
|
||||
var p: Json = undefined;
|
||||
p.init();
|
||||
errdefer p.deinit();
|
||||
p.j.beginObject() catch return error.OutOfMemory;
|
||||
p.j.objectField("terminal") catch return error.OutOfMemory;
|
||||
p.j.write(address) catch return error.OutOfMemory;
|
||||
p.j.endObject() catch return error.OutOfMemory;
|
||||
return p.out.toOwnedSlice() catch error.OutOfMemory;
|
||||
}
|
||||
|
||||
/// Start a program in a new terminal.
|
||||
///
|
||||
/// Every terminal is started with `keep`, so a program the core loses — its
|
||||
/// holder dying, or the program killed by a signal — is replaced with a shell
|
||||
/// in place rather than leaving a dead pane; and with `rm`, so one that exits
|
||||
/// is forgotten straight away instead of piling up in `hc ls --all`. It has
|
||||
/// no parent: a pane is nobody's child, and with none the core types no notes
|
||||
/// about it into anything.
|
||||
pub fn run(alloc: std.mem.Allocator, opts: RunOptions) Error!Started {
|
||||
const self = connection() orelse return error.Unavailable;
|
||||
|
||||
var p: Json = undefined;
|
||||
p.init();
|
||||
defer p.deinit();
|
||||
writeRunParams(&p.j, opts) catch return error.OutOfMemory;
|
||||
|
||||
const reply = try call(self, "terminal.run", p.written());
|
||||
defer reply.deinit();
|
||||
const r = result(reply);
|
||||
const address = strField(r, "address") orelse return error.Refused;
|
||||
return .{
|
||||
.address = try alloc.dupe(u8, address),
|
||||
.pid = @intCast(intField(r, "pid") orelse 0),
|
||||
};
|
||||
}
|
||||
|
||||
fn writeRunParams(j: *std.json.Stringify, opts: RunOptions) !void {
|
||||
try j.beginObject();
|
||||
try j.objectField("argv");
|
||||
try j.beginArray();
|
||||
for (opts.argv) |a| try j.write(a);
|
||||
try j.endArray();
|
||||
if (opts.cwd) |dir| {
|
||||
try j.objectField("cwd");
|
||||
try j.write(dir);
|
||||
}
|
||||
try j.objectField("env");
|
||||
try j.beginObject();
|
||||
for (opts.env) |entry| {
|
||||
const eq = std.mem.indexOfScalar(u8, entry, '=') orelse continue;
|
||||
try j.objectField(entry[0..eq]);
|
||||
try j.write(entry[eq + 1 ..]);
|
||||
}
|
||||
try j.endObject();
|
||||
try j.objectField("labels");
|
||||
try j.beginObject();
|
||||
try j.objectField("playpen");
|
||||
try j.write("1");
|
||||
try j.endObject();
|
||||
try j.objectField("keep");
|
||||
try j.write(true);
|
||||
try j.objectField("rm");
|
||||
try j.write(true);
|
||||
try j.objectField("no_parent");
|
||||
try j.write(true);
|
||||
try j.objectField("no_notify");
|
||||
try j.write(true);
|
||||
try j.endObject();
|
||||
}
|
||||
|
||||
/// Attach to a live terminal and start receiving its output on `stream`.
|
||||
///
|
||||
/// The core opens the stream with a paint of the whole screen, and `history`
|
||||
/// rows of scrollback before it — wanted when the emulator on our side is
|
||||
/// empty, and not when it already holds that scrollback from before.
|
||||
pub fn attach(
|
||||
address: []const u8,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
history: u32,
|
||||
display: Display,
|
||||
stream: Stream,
|
||||
) Error!Attached {
|
||||
const self = connection() orelse return error.Unavailable;
|
||||
|
||||
var p: Json = undefined;
|
||||
p.init();
|
||||
defer p.deinit();
|
||||
(struct {
|
||||
fn write(j: *std.json.Stringify, a: []const u8, w: u16, h: u16, hist: u32, d: Display) !void {
|
||||
try j.beginObject();
|
||||
try j.objectField("terminal");
|
||||
try j.write(a);
|
||||
try j.objectField("cols");
|
||||
try j.write(w);
|
||||
try j.objectField("rows");
|
||||
try j.write(h);
|
||||
try j.objectField("history");
|
||||
try j.write(hist);
|
||||
try j.objectField("display");
|
||||
try writeDisplay(j, d);
|
||||
try j.endObject();
|
||||
}
|
||||
}).write(&p.j, address, cols, rows, history, display) catch return error.OutOfMemory;
|
||||
|
||||
const reply = try call(self, "terminal.attach", p.written());
|
||||
defer reply.deinit();
|
||||
const r = result(reply);
|
||||
const channel = intField(r, "channel") orelse return error.Refused;
|
||||
const ch: u32 = @intCast(channel);
|
||||
|
||||
// Registered before anything else can be read, so the first paint — which
|
||||
// may already be sitting in the buffer behind the reply — has somewhere to go.
|
||||
self.streams.put(gpa, ch, stream) catch return error.OutOfMemory;
|
||||
|
||||
const terminal = if (r.get("terminal")) |t| (if (t == .object) t.object else std.json.ObjectMap.empty) else std.json.ObjectMap.empty;
|
||||
return .{
|
||||
.channel = ch,
|
||||
.pid = @intCast(intField(terminal, "pid") orelse 0),
|
||||
};
|
||||
}
|
||||
|
||||
pub const State = enum { live, finished, gone };
|
||||
|
||||
/// What `get` reports about a terminal.
|
||||
pub const Facts = struct {
|
||||
state: State,
|
||||
/// Its current grid size, which is the size its program is drawing for.
|
||||
cols: u16 = 0,
|
||||
rows: u16 = 0,
|
||||
};
|
||||
|
||||
/// Whether a terminal is still running, and at what size.
|
||||
pub fn get(address: []const u8) Error!Facts {
|
||||
const self = connection() orelse return error.Unavailable;
|
||||
const params = try writeAddress(address);
|
||||
defer gpa.free(params);
|
||||
|
||||
const reply = call(self, "terminal.get", params) catch |err| switch (err) {
|
||||
error.NotFound => return .{ .state = .gone },
|
||||
else => return err,
|
||||
};
|
||||
defer reply.deinit();
|
||||
const r = result(reply);
|
||||
const s = strField(r, "state") orelse return .{ .state = .gone };
|
||||
return .{
|
||||
.state = if (std.mem.eql(u8, s, "live")) .live else .finished,
|
||||
.cols = std.math.cast(u16, intField(r, "cols") orelse 0) orelse 0,
|
||||
.rows = std.math.cast(u16, intField(r, "rows") orelse 0) orelse 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// End a terminal's program, without waiting to hear how it went.
|
||||
pub fn kill(address: []const u8) void {
|
||||
const self = connection() orelse return;
|
||||
const params = writeAddress(address) catch return;
|
||||
defer gpa.free(params);
|
||||
cast(self, "terminal.kill", params);
|
||||
}
|
||||
|
||||
/// Keystrokes and replies for the program.
|
||||
pub fn input(channel: u32, bytes: []const u8) void {
|
||||
const self = core orelse return;
|
||||
if (bytes.len == 0) return;
|
||||
sendFrame(self, channel, .data, bytes) catch {};
|
||||
}
|
||||
|
||||
pub fn resize(channel: u32, cols: u16, rows: u16) void {
|
||||
const self = core orelse return;
|
||||
sendFrame(self, channel, .resize, &frame.resizePayload(cols, rows)) catch {};
|
||||
}
|
||||
|
||||
/// The viewer's colors or cell size changed.
|
||||
pub fn facts(channel: u32, display: Display) void {
|
||||
const self = core orelse return;
|
||||
var p: Json = undefined;
|
||||
p.init();
|
||||
defer p.deinit();
|
||||
writeDisplay(&p.j, display) catch return;
|
||||
sendFrame(self, channel, .facts, p.written()) catch {};
|
||||
}
|
||||
|
||||
/// Stop receiving a terminal's output. The program carries on.
|
||||
pub fn detach(channel: u32) void {
|
||||
const self = core orelse return;
|
||||
_ = self.streams.remove(channel);
|
||||
sendFrame(self, channel, .close, &.{}) catch {};
|
||||
}
|
||||
|
||||
test {
|
||||
_ = frame;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! The framed wire format herdr-core speaks once `hello` has asked for it.
|
||||
//!
|
||||
//! Every message is a nine-byte header and a payload:
|
||||
//!
|
||||
//! channel u32 LE 0 is JSON-RPC; an attached terminal gets its own
|
||||
//! kind u8 what the payload is (see `Kind`)
|
||||
//! length u32 LE payload bytes that follow
|
||||
//!
|
||||
//! Nothing here touches a socket. It is the part of the protocol that is pure
|
||||
//! bytes, kept apart so it can be tested without a core to talk to — the same
|
||||
//! reason the layout and snapshot grammars have their own test roots.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub const header_len = 9;
|
||||
|
||||
/// Bigger than anything the core sends — a terminal stream arrives in pieces
|
||||
/// far smaller than this — and small enough that a corrupt length cannot have
|
||||
/// us waiting forever for a payload that will never come.
|
||||
pub const max_payload = 16 * 1024 * 1024;
|
||||
|
||||
pub const Kind = enum(u8) {
|
||||
/// Bytes: JSON on channel 0, terminal output and input on the others.
|
||||
data = 0,
|
||||
/// We have consumed this many bytes; the core may send that many more.
|
||||
credit = 1,
|
||||
/// The stream is over. From the core, a JSON payload saying why.
|
||||
close = 2,
|
||||
/// Our grid size: cols u16 LE, rows u16 LE.
|
||||
resize = 3,
|
||||
/// Focus in (1) or out (0).
|
||||
focus = 4,
|
||||
/// Our display facts changed (JSON, as `terminal.attach`'s `display`).
|
||||
facts = 5,
|
||||
_,
|
||||
};
|
||||
|
||||
pub const Frame = struct {
|
||||
channel: u32,
|
||||
kind: Kind,
|
||||
payload: []const u8,
|
||||
|
||||
/// Bytes of input this frame occupied, header included.
|
||||
pub fn len(self: Frame) usize {
|
||||
return header_len + self.payload.len;
|
||||
}
|
||||
};
|
||||
|
||||
pub const ParseError = error{TooLarge};
|
||||
|
||||
/// The frame at the start of `bytes`, or null if it has not all arrived yet.
|
||||
/// The payload is a slice of `bytes`.
|
||||
pub fn parse(bytes: []const u8) ParseError!?Frame {
|
||||
if (bytes.len < header_len) return null;
|
||||
const length = std.mem.readInt(u32, bytes[5..9], .little);
|
||||
if (length > max_payload) return error.TooLarge;
|
||||
if (bytes.len < header_len + length) return null;
|
||||
return .{
|
||||
.channel = std.mem.readInt(u32, bytes[0..4], .little),
|
||||
.kind = @enumFromInt(bytes[4]),
|
||||
.payload = bytes[header_len..][0..length],
|
||||
};
|
||||
}
|
||||
|
||||
/// The header for a frame carrying `payload_len` bytes.
|
||||
pub fn header(channel: u32, kind: Kind, payload_len: usize) [header_len]u8 {
|
||||
var out: [header_len]u8 = undefined;
|
||||
std.mem.writeInt(u32, out[0..4], channel, .little);
|
||||
out[4] = @intFromEnum(kind);
|
||||
std.mem.writeInt(u32, out[5..9], @intCast(payload_len), .little);
|
||||
return out;
|
||||
}
|
||||
|
||||
/// The payload of a resize frame.
|
||||
pub fn resizePayload(cols: u16, rows: u16) [4]u8 {
|
||||
var out: [4]u8 = undefined;
|
||||
std.mem.writeInt(u16, out[0..2], cols, .little);
|
||||
std.mem.writeInt(u16, out[2..4], rows, .little);
|
||||
return out;
|
||||
}
|
||||
|
||||
/// The payload of a credit frame.
|
||||
pub fn creditPayload(n: usize) [4]u8 {
|
||||
var out: [4]u8 = undefined;
|
||||
std.mem.writeInt(u32, &out, @intCast(@min(n, std.math.maxInt(u32))), .little);
|
||||
return out;
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "a frame round-trips through its header" {
|
||||
const payload = "hello";
|
||||
var buf: [header_len + payload.len]u8 = undefined;
|
||||
buf[0..header_len].* = header(7, .data, payload.len);
|
||||
@memcpy(buf[header_len..], payload);
|
||||
|
||||
const f = (try parse(&buf)).?;
|
||||
try testing.expectEqual(@as(u32, 7), f.channel);
|
||||
try testing.expectEqual(Kind.data, f.kind);
|
||||
try testing.expectEqualStrings(payload, f.payload);
|
||||
try testing.expectEqual(buf.len, f.len());
|
||||
}
|
||||
|
||||
test "a frame split across reads is not parsed until it has all arrived" {
|
||||
var buf: [header_len + 3]u8 = undefined;
|
||||
buf[0..header_len].* = header(1, .close, 3);
|
||||
@memcpy(buf[header_len..], "abc");
|
||||
|
||||
try testing.expect(try parse(buf[0..4]) == null);
|
||||
try testing.expect(try parse(buf[0 .. header_len + 2]) == null);
|
||||
try testing.expect(try parse(&buf) != null);
|
||||
}
|
||||
|
||||
test "two frames back to back are read one at a time" {
|
||||
var buf: [2 * header_len + 2]u8 = undefined;
|
||||
buf[0..header_len].* = header(1, .data, 1);
|
||||
buf[header_len] = 'a';
|
||||
buf[header_len + 1 ..][0..header_len].* = header(2, .data, 1);
|
||||
buf[buf.len - 1] = 'b';
|
||||
|
||||
const first = (try parse(&buf)).?;
|
||||
try testing.expectEqualStrings("a", first.payload);
|
||||
const second = (try parse(buf[first.len()..])).?;
|
||||
try testing.expectEqual(@as(u32, 2), second.channel);
|
||||
try testing.expectEqualStrings("b", second.payload);
|
||||
}
|
||||
|
||||
test "a length no core would send is refused rather than waited for" {
|
||||
var buf: [header_len]u8 = header(1, .data, 0);
|
||||
std.mem.writeInt(u32, buf[5..9], max_payload + 1, .little);
|
||||
try testing.expectError(error.TooLarge, parse(&buf));
|
||||
}
|
||||
|
||||
test "a kind this build does not know survives parsing" {
|
||||
var buf: [header_len]u8 = header(1, .data, 0);
|
||||
buf[4] = 42;
|
||||
const f = (try parse(&buf)).?;
|
||||
try testing.expectEqual(@as(u8, 42), @intFromEnum(f.kind));
|
||||
}
|
||||
|
||||
test "resize is cols then rows, little-endian" {
|
||||
try testing.expectEqualSlices(u8, &.{ 80, 0, 24, 0 }, &resizePayload(80, 24));
|
||||
}
|
||||
Reference in new issue
Block a user