初始提交:terminalX 可运行态(M0/M1/M1.5 已真机验证)

- M0: libghostty SSH 终端(渲染/输入/连接)+ 白屏修复(OutputGate) + 会话状态机/自动重连
- M1: tsnet 用户态组网 + SSH-over-tsnet(fd 桥),shell 级真机验证;R5(Go+gvisor+C+++Swift 同进程) retire
- M1.5: tmux -CC 原生 tab(MVP)
- 结构: packages/(TXCore·TXTransport), apps/TerminalX, vendor/(libghostty-spm/libssh2/mbedtls/tsnet-bridge), artifacts/
- 文档: CLAUDE.md + docs/HANDOFF.md(新会话入口)
- 环境: 认证代理→依赖 vendor 本地化;Go 在 ~/.local/go;仅模拟器/未签名
- 待续: M2 mosh, tmux 多 pane, M4 安全(host key/SE)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kid
2026-07-24 10:20:46 +08:00
commit aa92d0e676
2761 changed files with 803505 additions and 0 deletions

View File

@@ -0,0 +1,536 @@
diff --git a/include/ghostty.h b/include/ghostty.h
index 72bbb57a8..d9e399220 100644
--- a/include/ghostty.h
+++ b/include/ghostty.h
@@ -464,10 +464,26 @@ typedef enum {
GHOSTTY_SURFACE_CONTEXT_SPLIT = 2,
} ghostty_surface_context_e;
+typedef enum {
+ GHOSTTY_SURFACE_IO_BACKEND_EXEC = 0,
+ GHOSTTY_SURFACE_IO_BACKEND_HOST_MANAGED = 1,
+} ghostty_surface_io_backend_e;
+
+typedef void (*ghostty_surface_receive_buffer_cb)(void*, const uint8_t*, size_t);
+typedef void (*ghostty_surface_receive_resize_cb)(void*,
+ uint16_t,
+ uint16_t,
+ uint32_t,
+ uint32_t);
+
typedef struct {
ghostty_platform_e platform_tag;
ghostty_platform_u platform;
void* userdata;
+ ghostty_surface_io_backend_e backend;
+ void* receive_userdata;
+ ghostty_surface_receive_buffer_cb receive_buffer;
+ ghostty_surface_receive_resize_cb receive_resize;
double scale_factor;
float font_size;
const char* working_directory;
@@ -1109,6 +1125,8 @@ GHOSTTY_API ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_su
GHOSTTY_API void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t);
GHOSTTY_API bool ghostty_surface_needs_confirm_quit(ghostty_surface_t);
GHOSTTY_API bool ghostty_surface_process_exited(ghostty_surface_t);
+GHOSTTY_API void ghostty_surface_write_buffer(ghostty_surface_t, const uint8_t*, uintptr_t);
+GHOSTTY_API void ghostty_surface_process_exit(ghostty_surface_t, uint32_t, uint64_t);
GHOSTTY_API void ghostty_surface_refresh(ghostty_surface_t);
GHOSTTY_API void ghostty_surface_draw(ghostty_surface_t);
GHOSTTY_API void ghostty_surface_set_content_scale(ghostty_surface_t, double, double);
diff --git a/src/Surface.zig b/src/Surface.zig
index 2c4edc497..b35c4465e 100644
--- a/src/Surface.zig
+++ b/src/Surface.zig
@@ -628,40 +628,48 @@ pub fn init(
// This separate block ({}) is important because our errdefers must
// be scoped here to be valid.
{
- var env = rt_surface.defaultTermioEnv() catch |err| env: {
- // If an error occurs, we don't want to block surface startup.
- log.warn("error getting env map for surface err={}", .{err});
- break :env internal_os.getEnvMap(alloc) catch
- std.process.EnvMap.init(alloc);
- };
- errdefer env.deinit();
+ const backend: termio.Backend = switch (rt_surface.termioBackend()) {
+ .exec => backend: {
+ var env = rt_surface.defaultTermioEnv() catch |err| env: {
+ log.warn("error getting env map for surface err={}", .{err});
+ break :env internal_os.getEnvMap(alloc) catch
+ std.process.EnvMap.init(alloc);
+ };
+ errdefer env.deinit();
- // don't leak GHOSTTY_LOG to any subprocesses
- env.remove("GHOSTTY_LOG");
+ env.remove("GHOSTTY_LOG");
- var buf: [18]u8 = undefined;
- try env.put(
- "GHOSTTY_SURFACE_ID",
- std.fmt.bufPrint(&buf, "0x{x:0>16}", .{self.id}) catch unreachable,
- );
+ var buf: [18]u8 = undefined;
+ try env.put(
+ "GHOSTTY_SURFACE_ID",
+ std.fmt.bufPrint(&buf, "0x{x:0>16}", .{self.id}) catch unreachable,
+ );
- // Initialize our IO backend
- var io_exec = try termio.Exec.init(alloc, .{
- .command = command,
- .env = env,
- .env_override = config.env,
- .shell_integration = config.@"shell-integration",
- .shell_integration_features = config.@"shell-integration-features",
- .cursor_blink = config.@"cursor-style-blink",
- .working_directory = if (config.@"working-directory") |wd| wd.value() else null,
- .resources_dir = global_state.resources_dir.host(),
- .term = config.term,
- .rt_pre_exec_info = .init(config),
- .rt_post_fork_info = .init(config),
- });
- errdefer io_exec.deinit();
+ var io_exec = try termio.Exec.init(alloc, .{
+ .command = command,
+ .env = env,
+ .env_override = config.env,
+ .shell_integration = config.@"shell-integration",
+ .shell_integration_features = config.@"shell-integration-features",
+ .cursor_blink = config.@"cursor-style-blink",
+ .working_directory = if (config.@"working-directory") |wd| wd.value() else null,
+ .resources_dir = global_state.resources_dir.host(),
+ .term = config.term,
+ .rt_pre_exec_info = .init(config),
+ .rt_post_fork_info = .init(config),
+ });
+ errdefer io_exec.deinit();
+
+ break :backend .{ .exec = io_exec };
+ },
+
+ .host_managed => .{
+ .host_managed = termio.HostManaged.init(
+ try rt_surface.hostManagedConfig(),
+ ),
+ },
+ };
- // Initialize our IO mailbox
var io_mailbox = try termio.Mailbox.initSPSC(alloc);
errdefer io_mailbox.deinit(alloc);
@@ -669,7 +677,7 @@ pub fn init(
.size = size,
.full_config = config,
.config = try termio.Termio.DerivedConfig.init(alloc, config),
- .backend = .{ .exec = io_exec },
+ .backend = backend,
.mailbox = io_mailbox,
.renderer_state = &self.renderer_state,
.renderer_wakeup = render_thread.wakeup,
@@ -1311,9 +1319,11 @@ fn childExitedAbnormally(
const alloc = arena.allocator();
// Build up our command for the error message
- const command = try std.mem.join(alloc, " ", switch (self.io.backend) {
- .exec => |*exec| exec.subprocess.args,
- });
+ const command = switch (self.io.backend) {
+ .exec => |*exec| try std.mem.join(alloc, " ", exec.subprocess.args),
+ .host_managed => try alloc.dupe(u8, "host-managed session"),
+ };
+ defer alloc.free(command);
const runtime_str = try std.fmt.allocPrint(alloc, "{d} ms", .{info.runtime_ms});
self.renderer_state.mutex.lock();
diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig
index 7310159cc..0180884fc 100644
--- a/src/apprt/embedded.zig
+++ b/src/apprt/embedded.zig
@@ -15,6 +15,7 @@ const input = @import("../input.zig");
const internal_os = @import("../os/main.zig");
const renderer = @import("../renderer.zig");
const terminal = @import("../terminal/main.zig");
+const termio = @import("../termio.zig");
const CoreApp = @import("../App.zig");
const CoreInspector = @import("../inspector/main.zig").Inspector;
const CoreSurface = @import("../Surface.zig");
@@ -411,6 +412,8 @@ pub const Surface = struct {
app: *App,
platform: Platform,
userdata: ?*anyopaque = null,
+ termio_backend: TermioBackend = .exec,
+ host_io: HostIO = .{},
core_surface: CoreSurface,
content_scale: apprt.ContentScale,
size: apprt.SurfaceSize,
@@ -421,6 +424,17 @@ pub const Surface = struct {
/// that getTitle works without the implementer needing to save it.
title: ?[:0]const u8 = null,
+ pub const TermioBackend = enum(c_int) {
+ exec = 0,
+ host_managed = 1,
+ };
+
+ pub const HostIO = struct {
+ userdata: ?*anyopaque = null,
+ receive_buffer: ?termio.HostManaged.Config.Write = null,
+ receive_resize: ?termio.HostManaged.Config.Resize = null,
+ };
+
/// Surface initialization options.
pub const Options = extern struct {
/// The platform that this surface is being initialized for and
@@ -431,6 +445,20 @@ pub const Surface = struct {
/// Userdata passed to some of the callbacks.
userdata: ?*anyopaque = null,
+ /// The backend used to satisfy terminal IO for this surface.
+ backend: TermioBackend = .exec,
+
+ /// Userdata passed to host-managed IO callbacks.
+ receive_userdata: ?*anyopaque = null,
+
+ /// Called when Ghostty emits bytes that should be sent to the host
+ /// transport.
+ receive_buffer: ?termio.HostManaged.Config.Write = null,
+
+ /// Called when the terminal size changes and the host transport
+ /// should be updated.
+ receive_resize: ?termio.HostManaged.Config.Resize = null,
+
/// The scale factor of the screen.
scale_factor: f64 = 1,
@@ -469,6 +497,12 @@ pub const Surface = struct {
.app = app,
.platform = try .init(opts.platform_tag, opts.platform),
.userdata = opts.userdata,
+ .termio_backend = opts.backend,
+ .host_io = .{
+ .userdata = opts.receive_userdata orelse opts.userdata,
+ .receive_buffer = opts.receive_buffer,
+ .receive_resize = opts.receive_resize,
+ },
.core_surface = undefined,
.content_scale = .{
.x = @floatCast(opts.scale_factor),
@@ -634,6 +668,19 @@ pub const Surface = struct {
return &self.core_surface;
}
+ pub fn termioBackend(self: *const Surface) TermioBackend {
+ return self.termio_backend;
+ }
+
+ pub fn hostManagedConfig(self: *const Surface) !termio.HostManaged.Config {
+ const write = self.host_io.receive_buffer orelse return error.HostManagedWriteRequired;
+ return .{
+ .userdata = self.host_io.userdata,
+ .write = write,
+ .resize = self.host_io.receive_resize,
+ };
+ }
+
pub fn rtApp(self: *const Surface) *App {
return self.app;
}
@@ -1600,6 +1647,28 @@ pub const CAPI = struct {
return surface.core_surface.child_exited;
}
+ export fn ghostty_surface_write_buffer(
+ surface: *Surface,
+ ptr: [*]const u8,
+ len: usize,
+ ) void {
+ if (len == 0) return;
+ surface.core_surface.io.processOutput(ptr[0..len]);
+ }
+
+ export fn ghostty_surface_process_exit(
+ surface: *Surface,
+ exit_code: u32,
+ runtime_ms: u64,
+ ) void {
+ _ = surface.core_surface.io.surface_mailbox.push(.{
+ .child_exited = .{
+ .exit_code = exit_code,
+ .runtime_ms = runtime_ms,
+ },
+ }, .{ .forever = {} });
+ }
+
/// Returns true if the surface has a selection.
export fn ghostty_surface_has_selection(surface: *Surface) bool {
return surface.core_surface.hasSelection();
diff --git a/src/termio.zig b/src/termio.zig
index b16885109..6931fcb58 100644
--- a/src/termio.zig
+++ b/src/termio.zig
@@ -23,6 +23,7 @@ const message = @import("termio/message.zig");
pub const backend = @import("termio/backend.zig");
pub const mailbox = @import("termio/mailbox.zig");
pub const Exec = @import("termio/Exec.zig");
+pub const HostManaged = @import("termio/HostManaged.zig");
pub const Options = @import("termio/Options.zig");
pub const Termio = @import("termio/Termio.zig");
pub const Thread = @import("termio/Thread.zig");
diff --git a/src/termio/HostManaged.zig b/src/termio/HostManaged.zig
new file mode 100644
index 000000000..0d1821350
--- /dev/null
+++ b/src/termio/HostManaged.zig
@@ -0,0 +1,136 @@
+const HostManaged = @This();
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const renderer = @import("../renderer.zig");
+const terminal = @import("../terminal/main.zig");
+const termio = @import("../termio.zig");
+
+userdata: ?*anyopaque = null,
+write: Config.Write,
+resize_cb: ?Config.Resize = null,
+grid_size: renderer.GridSize = .{
+ .columns = 0,
+ .rows = 0,
+},
+screen_size: renderer.ScreenSize = .{
+ .width = 0,
+ .height = 0,
+},
+
+pub const Config = struct {
+ pub const Write = *const fn (?*anyopaque, [*]const u8, usize) callconv(.c) void;
+ pub const Resize = *const fn (?*anyopaque, u16, u16, u32, u32) callconv(.c) void;
+
+ userdata: ?*anyopaque = null,
+ write: Write,
+ resize: ?Resize = null,
+};
+
+pub fn init(cfg: Config) HostManaged {
+ return .{
+ .userdata = cfg.userdata,
+ .write = cfg.write,
+ .resize_cb = cfg.resize,
+ };
+}
+
+pub fn deinit(self: *HostManaged) void {
+ _ = self;
+}
+
+pub fn initTerminal(self: *HostManaged, term: *terminal.Terminal) void {
+ self.grid_size = .{
+ .columns = term.cols,
+ .rows = term.rows,
+ };
+ self.screen_size = .{
+ .width = term.width_px,
+ .height = term.height_px,
+ };
+}
+
+pub fn threadEnter(
+ self: *HostManaged,
+ alloc: Allocator,
+ io: *termio.Termio,
+ td: *termio.Termio.ThreadData,
+) !void {
+ _ = alloc;
+ _ = io;
+
+ td.backend = .{ .host_managed = .{} };
+ self.notifyResize();
+}
+
+pub fn threadExit(self: *HostManaged, td: *termio.Termio.ThreadData) void {
+ _ = self;
+ _ = td;
+}
+
+pub fn focusGained(
+ self: *HostManaged,
+ td: *termio.Termio.ThreadData,
+ focused: bool,
+) !void {
+ _ = self;
+ _ = td;
+ _ = focused;
+}
+
+pub fn resize(
+ self: *HostManaged,
+ grid_size: renderer.GridSize,
+ screen_size: renderer.ScreenSize,
+) !void {
+ self.grid_size = grid_size;
+ self.screen_size = screen_size;
+ self.notifyResize();
+}
+
+pub fn queueWrite(
+ self: *HostManaged,
+ alloc: Allocator,
+ td: *termio.Termio.ThreadData,
+ data: []const u8,
+ linefeed: bool,
+) !void {
+ _ = alloc;
+ _ = td;
+ _ = linefeed;
+
+ if (data.len == 0) return;
+ self.write(self.userdata, data.ptr, data.len);
+}
+
+pub fn childExitedAbnormally(
+ self: *HostManaged,
+ gpa: Allocator,
+ t: *terminal.Terminal,
+ exit_code: u32,
+ runtime_ms: u64,
+) !void {
+ _ = self;
+ _ = gpa;
+ _ = t;
+ _ = exit_code;
+ _ = runtime_ms;
+}
+
+fn notifyResize(self: *HostManaged) void {
+ const resize_cb = self.resize_cb orelse return;
+ resize_cb(
+ self.userdata,
+ self.grid_size.columns,
+ self.grid_size.rows,
+ self.screen_size.width,
+ self.screen_size.height,
+ );
+}
+
+pub const ThreadData = struct {
+ pub fn deinit(self: *ThreadData, alloc: Allocator) void {
+ _ = self;
+ _ = alloc;
+ }
+};
diff --git a/src/termio/backend.zig b/src/termio/backend.zig
index c29009acb..9857cbd7a 100644
--- a/src/termio/backend.zig
+++ b/src/termio/backend.zig
@@ -11,28 +11,34 @@ const ProcessInfo = @import("../pty.zig").ProcessInfo;
const WRITE_REQ_PREALLOC = std.math.pow(usize, 2, 5);
/// The kinds of backends.
-pub const Kind = enum { exec };
+pub const Kind = enum { exec, host_managed };
/// Configuration for the various backend types.
pub const Config = union(Kind) {
/// Exec uses posix exec to run a command with a pty.
exec: termio.Exec.Config,
+
+ /// Host managed forwards terminal IO through embedder callbacks.
+ host_managed: termio.HostManaged.Config,
};
/// Backend implementations. A backend is responsible for owning the pty
/// behavior and providing read/write capabilities.
pub const Backend = union(Kind) {
exec: termio.Exec,
+ host_managed: termio.HostManaged,
pub fn deinit(self: *Backend) void {
switch (self.*) {
.exec => |*exec| exec.deinit(),
+ .host_managed => |*host_managed| host_managed.deinit(),
}
}
pub fn initTerminal(self: *Backend, t: *terminal.Terminal) void {
switch (self.*) {
.exec => |*exec| exec.initTerminal(t),
+ .host_managed => |*host_managed| host_managed.initTerminal(t),
}
}
@@ -44,12 +50,14 @@ pub const Backend = union(Kind) {
) !void {
switch (self.*) {
.exec => |*exec| try exec.threadEnter(alloc, io, td),
+ .host_managed => |*host_managed| try host_managed.threadEnter(alloc, io, td),
}
}
pub fn threadExit(self: *Backend, td: *termio.Termio.ThreadData) void {
switch (self.*) {
.exec => |*exec| exec.threadExit(td),
+ .host_managed => |*host_managed| host_managed.threadExit(td),
}
}
@@ -60,6 +68,7 @@ pub const Backend = union(Kind) {
) !void {
switch (self.*) {
.exec => |*exec| try exec.focusGained(td, focused),
+ .host_managed => |*host_managed| try host_managed.focusGained(td, focused),
}
}
@@ -70,6 +79,7 @@ pub const Backend = union(Kind) {
) !void {
switch (self.*) {
.exec => |*exec| try exec.resize(grid_size, screen_size),
+ .host_managed => |*host_managed| try host_managed.resize(grid_size, screen_size),
}
}
@@ -82,6 +92,7 @@ pub const Backend = union(Kind) {
) !void {
switch (self.*) {
.exec => |*exec| try exec.queueWrite(alloc, td, data, linefeed),
+ .host_managed => |*host_managed| try host_managed.queueWrite(alloc, td, data, linefeed),
}
}
@@ -99,6 +110,12 @@ pub const Backend = union(Kind) {
exit_code,
runtime_ms,
),
+ .host_managed => |*host_managed| try host_managed.childExitedAbnormally(
+ gpa,
+ t,
+ exit_code,
+ runtime_ms,
+ ),
}
}
@@ -108,6 +125,7 @@ pub const Backend = union(Kind) {
pub fn getProcessInfo(self: *Backend, comptime info: ProcessInfo) ?ProcessInfo.Type(info) {
return switch (self.*) {
.exec => |*exec| exec.getProcessInfo(info),
+ .host_managed => null,
};
}
};
@@ -115,10 +133,12 @@ pub const Backend = union(Kind) {
/// Termio thread data. See termio.ThreadData for docs.
pub const ThreadData = union(Kind) {
exec: termio.Exec.ThreadData,
+ host_managed: termio.HostManaged.ThreadData,
pub fn deinit(self: *ThreadData, alloc: Allocator) void {
switch (self.*) {
.exec => |*exec| exec.deinit(alloc),
+ .host_managed => |*host_managed| host_managed.deinit(alloc),
}
}