初始提交: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,61 @@
#!/bin/zsh
set -euo pipefail
SOURCE_DIR=${1:-}
if [ -z "$SOURCE_DIR" ]; then
echo "[-] missing source_dir"
exit 1
fi
BUILD_ZIG="$SOURCE_DIR/build.zig"
MARKER="libghostty static install for Darwin"
if [ ! -f "$BUILD_ZIG" ]; then
echo "[-] build.zig not found: $BUILD_ZIG"
exit 1
fi
if grep -Fq "$MARKER" "$BUILD_ZIG"; then
echo "[+] patch already applied: 0001-darwin-libghostty-install"
exit 0
fi
if grep -Fq "const lib_shared = try buildpkg.GhosttyLib.initShared" "$BUILD_ZIG"; then
sed -i '' \
'/We shouldn'\''t have this guard but we don'\''t currently/,/^ }$/c\
// libghostty static install for Darwin:\
// upstream only wires this for non-Darwin today, but we need the\
// static archive for our own XCFramework assembly pipeline.\
lib_shared.installHeader();\
if (config.target.result.os.tag.isDarwin()) {\
lib_static.install("libghostty.a");\
} else if (config.target.result.os.tag == .windows) {\
lib_shared.install("ghostty-internal.dll");\
lib_static.install("ghostty-internal-static.lib");\
} else {\
lib_shared.install("ghostty-internal.so");\
lib_static.install("ghostty-internal.a");\
}' \
"$BUILD_ZIG"
else
sed -i '' \
'/We shouldn'\''t have this guard but we don'\''t currently/,/^ }$/c\
// libghostty static install for Darwin:\
// upstream only wires this for non-Darwin today, but we need the\
// static archive for our own XCFramework assembly pipeline.\
libghostty_shared.installHeader(); // Only need one header\
if (!config.target.result.os.tag.isDarwin()) {\
libghostty_shared.install("libghostty.so");\
}\
libghostty_static.install("libghostty.a");' \
"$BUILD_ZIG"
fi
if ! grep -Fq "$MARKER" "$BUILD_ZIG"; then
echo "[-] failed to apply patch: 0001-darwin-libghostty-install"
exit 1
fi
echo "[+] applied patch: 0001-darwin-libghostty-install"

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),
}
}

View File

@@ -0,0 +1,518 @@
diff --git a/include/ghostty.h b/include/ghostty.h
index 40ff55c..4c4f01b 100644
--- a/include/ghostty.h
+++ b/include/ghostty.h
@@ -437,10 +437,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;
@@ -1080,6 +1096,8 @@ ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t, gho
void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t);
bool ghostty_surface_needs_confirm_quit(ghostty_surface_t);
bool ghostty_surface_process_exited(ghostty_surface_t);
+void ghostty_surface_write_buffer(ghostty_surface_t, const uint8_t*, uintptr_t);
+void ghostty_surface_process_exit(ghostty_surface_t, uint32_t, uint64_t);
void ghostty_surface_refresh(ghostty_surface_t);
void ghostty_surface_draw(ghostty_surface_t);
void ghostty_surface_set_content_scale(ghostty_surface_t, double, double);
diff --git a/src/Surface.zig b/src/Surface.zig
index 4d66622..145d113 100644
--- a/src/Surface.zig
+++ b/src/Surface.zig
@@ -620,34 +620,42 @@ 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);
+ 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();
+
+ env.remove("GHOSTTY_LOG");
+
+ 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(),
+ ),
+ },
};
- errdefer env.deinit();
-
- // don't leak GHOSTTY_LOG to any subprocesses
- env.remove("GHOSTTY_LOG");
-
- // 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();
- // Initialize our IO mailbox
var io_mailbox = try termio.Mailbox.initSPSC(alloc);
errdefer io_mailbox.deinit(alloc);
@@ -655,7 +663,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,
@@ -1289,9 +1297,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 0d5a4f8..b44d4d3 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");
@@ -409,6 +410,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,
@@ -419,6 +422,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
@@ -429,6 +443,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,
@@ -467,6 +495,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),
@@ -632,6 +666,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;
}
@@ -1598,6 +1645,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 b168851..6931fcb 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 0000000..0d18213
--- /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 ae0e200..f1d35d2 100644
--- a/src/termio/backend.zig
+++ b/src/termio/backend.zig
@@ -10,28 +10,34 @@ const termio = @import("../termio.zig");
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),
}
}
@@ -43,12 +49,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),
}
}
@@ -59,6 +67,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),
}
}
@@ -69,6 +78,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),
}
}
@@ -81,6 +91,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),
}
}
@@ -98,6 +109,12 @@ pub const Backend = union(Kind) {
exit_code,
runtime_ms,
),
+ .host_managed => |*host_managed| try host_managed.childExitedAbnormally(
+ gpa,
+ t,
+ exit_code,
+ runtime_ms,
+ ),
}
}
};
@@ -105,10 +122,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),
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
#!/bin/bash
set -euo pipefail
SOURCE_DIR="${1:?Usage: $0 <ghostty-source-dir>}"
# Patch 1: cf_release_thread — ignore loop.run errors on iOS
# The kqueue-based event loop panics on iOS simulator due to mach port issues
CF_RELEASE="${SOURCE_DIR}/src/os/cf_release_thread.zig"
if [ -f "$CF_RELEASE" ]; then
if grep -q 'try self.loop.run(.until_done);' "$CF_RELEASE"; then
sed -i '' 's/try self\.loop\.run(\.until_done);/self.loop.run(.until_done) catch |err| { log.warn("cf release loop failed err={}", .{err}); return; };/' "$CF_RELEASE"
echo "[+] patched cf_release_thread to ignore loop errors"
else
echo "[+] cf_release_thread already patched"
fi
fi
# Patch 2: Disable private window blur API (App Store compliance)
EMBEDDED="${SOURCE_DIR}/src/apprt/embedded.zig"
if [ -f "$EMBEDDED" ]; then
if grep -q 'CGSSetWindowBackgroundBlurRadius' "$EMBEDDED"; then
python3 - "$EMBEDDED" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
text = path.read_text()
old_fn = """ export fn ghostty_set_window_background_blur(
app: *App,
window: *anyopaque,
) void {
// This is only supported on macOS
if (comptime builtin.target.os.tag != .macos) return;
const config = &app.config;
// Do nothing if we don't have background transparency enabled
if (config.@"background-opacity" >= 1.0) return;
const nswindow = objc.Object.fromId(window);
_ = CGSSetWindowBackgroundBlurRadius(
CGSDefaultConnectionForThread(),
nswindow.msgSend(usize, objc.sel("windowNumber"), .{}),
@intCast(config.@"background-blur".cval()),
);
}
/// See ghostty_set_window_background_blur
extern "c" fn CGSSetWindowBackgroundBlurRadius(*anyopaque, usize, c_int) i32;
extern "c" fn CGSDefaultConnectionForThread() *anyopaque;"""
new_fn = """ export fn ghostty_set_window_background_blur(
app: *App,
window: *anyopaque,
) void {
_ = app;
_ = window;
return;
}"""
if old_fn not in text:
print("[+] blur patch already applied or source changed")
else:
path.write_text(text.replace(old_fn, new_fn))
print("[+] patched: disabled private blur API")
PY
else
echo "[+] blur patch already applied"
fi
fi
# Patch 3: Link Metal frameworks
BUILD_ZIG="${SOURCE_DIR}/pkg/macos/build.zig"
if [ -f "$BUILD_ZIG" ]; then
if ! grep -q 'lib.linkFramework("Metal")' "$BUILD_ZIG"; then
perl -0pi -e 's/lib\.linkFramework\("IOSurface"\);/lib.linkFramework("IOSurface");\n lib.linkFramework("Metal");\n lib.linkFramework("MetalKit");/g' "$BUILD_ZIG"
perl -0pi -e 's/module\.linkFramework\("IOSurface", \.\{\}\);/module.linkFramework("IOSurface", .{});\n module.linkFramework("Metal", .{});\n module.linkFramework("MetalKit", .{});/g' "$BUILD_ZIG"
echo "[+] patched: linked Metal frameworks"
else
echo "[+] Metal frameworks already linked"
fi
fi
# Patch 4: Lower iOS deployment target to 15.0
CONFIG_ZIG="${SOURCE_DIR}/src/build/Config.zig"
if [ -f "$CONFIG_ZIG" ]; then
if grep -q '\.ios => \.{ \.semver = \.{' "$CONFIG_ZIG"; then
perl -0pi -e 's/\.ios => \.{ \.semver = \.{\n\s*\.major = \d+,\n\s*\.minor = \d+,\n\s*\.patch = \d+,/.ios => .{ .semver = .{\n .major = 15,\n .minor = 0,\n .patch = 0,/s' "$CONFIG_ZIG"
echo "[+] patched: iOS deployment target -> 15.0"
else
echo "[+] iOS deployment target already patched"
fi
fi
echo "[+] all ios-fixes patches applied"

View File

@@ -0,0 +1,473 @@
#!/bin/bash
set -euo pipefail
SOURCE_DIR="${1:?Usage: $0 <ghostty-source-dir>}"
# =============================================================================
# Patch 1: IOSurfaceLayer — iOS rendering compatibility
#
# Problem: On iOS, IOSurface dimensions may differ by ±1 pixel from the
# CALayer bounds due to rounding differences between UIKit point-to-pixel
# conversion and Metal's integer pixel sizes. The upstream code does an
# exact match and silently discards the surface, causing a blank screen.
#
# Additionally, iOS doesn't have a native CALayer subclass optimized for
# IOSurface content display. Using the private CAIOSurfaceLayer class
# (available since iOS 11) provides hardware-accelerated compositing.
#
# Fix:
# - Allow ±1px tolerance on iOS when comparing surface vs layer dimensions
# - Dynamically adjust contentsScale when dimensions don't match exactly
# - Use CAIOSurfaceLayer as base class on iOS for native IOSurface compositing
# - Mark layer as opaque since terminal content fills the entire bounds
# =============================================================================
IOSURFACE_LAYER="${SOURCE_DIR}/src/renderer/metal/IOSurfaceLayer.zig"
if [ -f "$IOSURFACE_LAYER" ]; then
if grep -q 'const log = std.log.scoped(.IOSurfaceLayer);' "$IOSURFACE_LAYER"; then
python3 - "$IOSURFACE_LAYER" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
src = path.read_text()
# Need builtin for comptime os.tag checks
src = src.replace(
'const std = @import("std");\nconst Allocator = std.mem.Allocator;',
'const std = @import("std");\nconst builtin = @import("builtin");\nconst Allocator = std.mem.Allocator;'
)
# The scoped log is only used in the size check we're replacing; drop it
src = src.replace('\nconst log = std.log.scoped(.IOSurfaceLayer);\n', '\n')
# Terminal surface is always fully opaque — tell the compositor
src = src.replace(
'layer.setProperty("contentsGravity", macos.animation.kCAGravityTopLeft);\n\n layer.setInstanceVariable',
'layer.setProperty("contentsGravity", macos.animation.kCAGravityTopLeft);\n layer.setProperty("opaque", true);\n\n layer.setInstanceVariable'
)
# Replace the strict size equality check with a platform-aware version.
# On iOS, UIKit's point→pixel rounding can produce a 1px discrepancy.
# Rather than dropping the frame entirely (→ blank screen), we accept it
# and recalculate contentsScale so CoreAnimation stretches correctly.
old_block = """ if (width != surface.getWidth() or height != surface.getHeight()) {
log.debug(
"setSurfaceCallback(): surface is wrong size for layer, discarding. surface = {d}x{d}, layer = {d}x{d}",
.{ surface.getWidth(), surface.getHeight(), width, height },
);
return;
}"""
new_block = """ const sw = surface.getWidth();
const sh = surface.getHeight();
const dw: usize = if (width > sw) width - sw else sw - width;
const dh: usize = if (height > sh) height - sh else sh - height;
// iOS UIKit rounding can produce ±1px discrepancy; macOS must match exactly
const max_drift: usize = if (comptime builtin.os.tag == .ios) 1 else 0;
if (dw > max_drift or dh > max_drift) {
if (comptime builtin.os.tag == .ios) {
// Recalculate contentsScale so CA maps surface pixels to layer points
const pw = bounds.size.width;
const ph = bounds.size.height;
if (pw > 0 and ph > 0) {
const cs_x: f64 = @as(f64, @floatFromInt(sw)) / pw;
const cs_y: f64 = @as(f64, @floatFromInt(sh)) / ph;
const cs: f64 = @max(cs_x, cs_y);
if (@abs(cs - scale) > 0.01) {
layer.setProperty("contentsScale", cs);
}
}
} else {
return;
}
}"""
if old_block not in src:
print("[!] IOSurfaceLayer size check block not found — source may have changed")
sys.exit(1)
src = src.replace(old_block, new_block)
# Use the system-provided CAIOSurfaceLayer on iOS; it handles
# IOSurface display natively with zero-copy compositing.
old_cls = """ const CALayer =
objc.getClass("CALayer") orelse return error.ObjCFailed;
var subclass =
objc.allocateClassPair(CALayer, "IOSurfaceLayer") orelse return error.ObjCFailed;"""
new_cls = """ const parent_cls = if (comptime builtin.os.tag == .ios)
// CAIOSurfaceLayer provides native zero-copy IOSurface compositing
objc.getClass("CAIOSurfaceLayer") orelse
objc.getClass("CALayer") orelse return error.ObjCFailed
else
objc.getClass("CALayer") orelse return error.ObjCFailed;
var subclass =
objc.allocateClassPair(parent_cls, "IOSurfaceLayer") orelse return error.ObjCFailed;"""
src = src.replace(old_cls, new_cls)
path.write_text(src)
print("[+] patched IOSurfaceLayer: iOS size tolerance + CAIOSurfaceLayer")
PY
else
echo "[+] IOSurfaceLayer already patched"
fi
fi
# =============================================================================
# Patch 2: Metal.zig — iOS first-frame display + synchronous present
#
# Problem 1: On iOS the IOSurfaceLayer is added as a sublayer of the UIView's
# backing layer. By the time the renderer registers its display callback the
# sublayer already has its bounds set, so no "display" message is generated.
# The first frame never renders.
#
# Problem 2: The async present path dispatches to the main thread via GCD.
# On iOS the render loop already runs on the main thread, so the async
# dispatch adds an unnecessary runloop turn of latency and can cause ordering
# issues with UIKit layout.
#
# Fix:
# - Call setNeedsDisplay after registering the display callback on iOS
# - On iOS, always use the synchronous present path (setSurface checks
# isMainThread internally and runs inline when true)
# =============================================================================
METAL_ZIG="${SOURCE_DIR}/src/renderer/Metal.zig"
if [ -f "$METAL_ZIG" ]; then
if ! grep -q 'setNeedsDisplay' "$METAL_ZIG"; then
python3 - "$METAL_ZIG" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
src = path.read_text()
# Kick the first display cycle after callback registration on iOS
old_cb = """ @ptrCast(&displayCallback),
@ptrCast(renderer),
);
}"""
new_cb = """ @ptrCast(&displayCallback),
@ptrCast(renderer),
);
// iOS: sublayer bounds are already set before the callback is wired up,
// so no display message fires automatically. Kick the first frame.
if (comptime builtin.os.tag == .ios) {
self.layer.layer.msgSend(void, objc.sel("setNeedsDisplay"), .{});
}
}"""
if old_cb not in src:
print("[!] Metal loopEnter callback not found")
sys.exit(1)
src = src.replace(old_cb, new_cb)
# iOS render loop is main-thread; skip the async dispatch path entirely.
old_present = """pub inline fn present(self: *Metal, target: Target, sync: bool) !void {
if (sync) {
self.layer.setSurfaceSync(target.surface);
} else {
try self.layer.setSurface(target.surface);
}
}"""
new_present = """pub inline fn present(self: *Metal, target: Target, sync: bool) !void {
// iOS: always present synchronously — the render loop already runs on
// the main thread, so the async GCD hop is unnecessary overhead.
if (comptime builtin.os.tag == .ios) {
try self.layer.setSurface(target.surface);
return;
}
if (sync) {
self.layer.setSurfaceSync(target.surface);
} else {
try self.layer.setSurface(target.surface);
}
}"""
if old_present not in src:
print("[!] Metal present function not found")
sys.exit(1)
src = src.replace(old_present, new_present)
path.write_text(src)
print("[+] patched Metal.zig: iOS first-frame trigger + sync present")
PY
else
echo "[+] Metal.zig already patched"
fi
fi
# =============================================================================
# Patch 3: coretext.zig — Skip CF release thread on iOS
#
# Problem: The CoreText font shaper spawns a background thread that uses
# libxev's kqueue event loop to asynchronously release CoreFoundation
# objects. On iOS, kqueue's Mach port allocation fails (sandbox restrictions
# + simulator incompatibility), crashing the thread and potentially stalling
# font shaping operations.
#
# Fix: Make the CF release thread optional. On iOS, skip thread creation
# entirely and release CF objects synchronously in endFrame(). This is
# acceptable because iOS devices have fast enough CF release performance
# and the terminal doesn't produce the same volume of shaped text as a
# desktop compositor.
# =============================================================================
CORETEXT="${SOURCE_DIR}/src/font/shaper/coretext.zig"
if [ -f "$CORETEXT" ]; then
if grep -q 'cf_release_thread: \*CFReleaseThread,' "$CORETEXT"; then
python3 - "$CORETEXT" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
src = path.read_text()
# Make the struct fields optional so nil can represent "no thread"
src = src.replace(
'cf_release_thread: *CFReleaseThread,\n cf_release_thr: std.Thread,',
'cf_release_thread: ?*CFReleaseThread,\n cf_release_thr: ?std.Thread,'
)
# Guard thread creation behind a comptime platform check
old_create = """ // Create the CF release thread.
var cf_release_thread = try alloc.create(CFReleaseThread);
errdefer alloc.destroy(cf_release_thread);
cf_release_thread.* = try .init(alloc);
errdefer cf_release_thread.deinit();
// Start the CF release thread.
var cf_release_thr = try std.Thread.spawn(
.{},
CFReleaseThread.threadMain,
.{cf_release_thread},
);
cf_release_thr.setName("cf_release") catch {};
return .{"""
new_create = """ // On iOS the kqueue-based event loop used by the release thread
// crashes due to Mach port sandbox restrictions. Skip it entirely
// and fall through to synchronous release in endFrame.
var cf_release_thread: ?*CFReleaseThread = null;
var cf_release_thr: ?std.Thread = null;
if (comptime builtin.os.tag != .ios) {
const thr_obj = try alloc.create(CFReleaseThread);
errdefer alloc.destroy(thr_obj);
thr_obj.* = try .init(alloc);
errdefer thr_obj.deinit();
const thr = try std.Thread.spawn(.{}, CFReleaseThread.threadMain, .{thr_obj});
thr.setName("cf_release") catch {};
cf_release_thread = thr_obj;
cf_release_thr = thr;
}
return .{"""
if old_create not in src:
print("[!] coretext CF release thread creation block not found")
sys.exit(1)
src = src.replace(old_create, new_create)
# Deinit: only join/stop the thread if it was created
old_deinit = """ // Stop the CF release thread
{
self.cf_release_thread.stop.notify() catch |err|
log.err("error notifying cf release thread to stop, may stall err={}", .{err});
self.cf_release_thr.join();
}
self.cf_release_thread.deinit();
self.alloc.destroy(self.cf_release_thread);"""
new_deinit = """ // Stop the CF release thread (nil on iOS)
if (self.cf_release_thread) |thr_obj| {
thr_obj.stop.notify() catch |err|
log.err("error notifying cf release thread to stop, may stall err={}", .{err});
self.cf_release_thr.?.join();
thr_obj.deinit();
self.alloc.destroy(thr_obj);
}"""
if old_deinit not in src:
print("[!] coretext CF release thread deinit block not found")
sys.exit(1)
src = src.replace(old_deinit, new_deinit)
# endFrame: guard the mailbox push behind an optional check.
# When nil (iOS), fall through to the synchronous release below.
old_end = """ // Send the items. If the send succeeds then we wake up the
// thread to process the items. If the send fails then do a manual
// cleanup.
if (self.cf_release_thread.mailbox.push(.{ .release = .{
.refs = items,
.alloc = self.alloc,
} }, .{ .forever = {} }) != 0) {
self.cf_release_thread.wakeup.notify() catch |err| {
log.warn(
"error notifying cf release thread to wake up, may stall err={}",
.{err},
);
};
return;
}
for (items) |ref| macos.foundation.CFRelease(ref);"""
new_end = """ // Offload to the background release thread when available.
// On iOS cf_release_thread is nil, so we fall through to sync release.
if (self.cf_release_thread) |thr_obj| {
if (thr_obj.mailbox.push(.{ .release = .{
.refs = items,
.alloc = self.alloc,
} }, .{ .forever = {} }) != 0) {
thr_obj.wakeup.notify() catch |err| {
log.warn(
"error notifying cf release thread to wake up, may stall err={}",
.{err},
);
};
return;
}
}
for (items) |ref| macos.foundation.CFRelease(ref);"""
if old_end not in src:
print("[!] coretext endFrame mailbox block not found")
sys.exit(1)
src = src.replace(old_end, new_end)
path.write_text(src)
print("[+] patched coretext.zig: CF release thread disabled on iOS")
PY
else
echo "[+] coretext.zig already patched"
fi
fi
# =============================================================================
# Patch 4: iosurface.zig — Explicit row byte alignment
#
# Problem: When creating an IOSurface without specifying bytesPerRow, the
# system picks whatever alignment it wants. Metal textures created from
# these surfaces may have mismatched row strides, causing corrupted or
# shifted glyph rendering (especially visible on font atlas textures).
#
# Fix: Calculate 64-byte-aligned row bytes and pass kIOSurfaceBytesPerRow
# when creating the IOSurface. Also suppress unused return value warnings
# from IOSurfaceLock/Unlock.
# =============================================================================
IOSURFACE="${SOURCE_DIR}/pkg/macos/iosurface/iosurface.zig"
if [ -f "$IOSURFACE" ]; then
if ! grep -q 'kIOSurfaceBytesPerRow' "$IOSURFACE"; then
python3 - "$IOSURFACE" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
src = path.read_text()
# Compute aligned row stride before creating the Number objects
old_start = """ pub fn init(properties: Properties) Allocator.Error!*IOSurface {
var w = try foundation.Number.create(.int, &properties.width);"""
new_start = """ pub fn init(properties: Properties) Allocator.Error!*IOSurface {
// Ensure row stride is 64-byte aligned for Metal texture compatibility.
const aligned_stride: c_int = @intCast(
(properties.width * properties.bytes_per_element + 63) & ~@as(c_int, 63),
);
var w = try foundation.Number.create(.int, &properties.width);"""
if old_start not in src:
print("[!] iosurface init start not found")
sys.exit(1)
src = src.replace(old_start, new_start)
# Create a Number for the stride and include it in the dictionary
old_dict_setup = """ var bpe = try foundation.Number.create(.int, &properties.bytes_per_element);
defer bpe.release();
var properties_dict = try foundation.Dictionary.create("""
new_dict_setup = """ var bpe = try foundation.Number.create(.int, &properties.bytes_per_element);
defer bpe.release();
var stride_num = try foundation.Number.create(.int, &aligned_stride);
defer stride_num.release();
var properties_dict = try foundation.Dictionary.create("""
if old_dict_setup not in src:
print("[!] iosurface bpe block not found")
sys.exit(1)
src = src.replace(old_dict_setup, new_dict_setup)
# Extend the dictionary keys/values arrays
old_dict = """ &[_]?*const anyopaque{
c.kIOSurfaceWidth,
c.kIOSurfaceHeight,
c.kIOSurfacePixelFormat,
c.kIOSurfaceBytesPerElement,
},
&[_]?*const anyopaque{ w, h, pf, bpe },"""
new_dict = """ &[_]?*const anyopaque{
c.kIOSurfaceWidth,
c.kIOSurfaceHeight,
c.kIOSurfacePixelFormat,
c.kIOSurfaceBytesPerElement,
c.kIOSurfaceBytesPerRow,
},
&[_]?*const anyopaque{ w, h, pf, bpe, stride_num },"""
if old_dict not in src:
print("[!] iosurface dictionary keys not found")
sys.exit(1)
src = src.replace(old_dict, new_dict)
# Silence unused return value from IOSurfaceLock/Unlock
src = src.replace(
' c.IOSurfaceLock(\n @ptrCast(self),\n 0,\n null,\n );',
' _ = c.IOSurfaceLock(\n @ptrCast(self),\n 0,\n null,\n );'
)
src = src.replace(
' c.IOSurfaceUnlock(\n @ptrCast(self),\n 0,\n null,\n );',
' _ = c.IOSurfaceUnlock(\n @ptrCast(self),\n 0,\n null,\n );'
)
path.write_text(src)
print("[+] patched iosurface.zig: 64-byte stride alignment for Metal")
PY
else
echo "[+] iosurface.zig already patched"
fi
fi
# =============================================================================
# Patch 5: build.zig.zon — Update libxev to fix iOS kqueue mach port panic
#
# Problem: The bundled libxev uses mach ports for async wakeup on Darwin.
# Its kqueue backend checks `os.tag != .macos` and returns null for mach port
# kevents on non-macOS Darwin (iOS). The caller then unwraps null with `.?`
# causing a panic. A newer libxev version fixes this by properly supporting
# iOS as a Darwin target.
#
# Fix: Update the libxev dependency URL and hash to a version that handles
# iOS mach ports correctly.
# =============================================================================
BUILD_ZON="${SOURCE_DIR}/build.zig.zon"
if [ -f "$BUILD_ZON" ]; then
if ! grep -q '7e7d2f2ab4700544657f8ec268715c8ef320d839' "$BUILD_ZON"; then
sed -i '' 's|"https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz"|"https://github.com/mitchellh/libxev/archive/7e7d2f2ab4700544657f8ec268715c8ef320d839.tar.gz"|' "$BUILD_ZON"
sed -i '' 's|"libxev-0.0.0-86vtc4IcEwCqEYxEYoN_3KXmc6A9VLcm22aVImfvecYs"|"libxev-0.0.0-86vtcwE9EwB942iWRnaNMXHv3n0BeLAs_tVhrs5cT8cQ"|' "$BUILD_ZON"
echo "[+] patched build.zig.zon: updated libxev for iOS mach port fix"
else
echo "[+] libxev already updated"
fi
fi
echo "[+] all ios metal rendering patches applied"

View File

@@ -0,0 +1,197 @@
#!/bin/bash
set -euo pipefail
SOURCE_DIR="${1:?Usage: $0 <ghostty-source-dir>}"
MARKER="LIBGHOSTTY_SPM_TRIM_PATCH"
# Skip if already applied
if grep -q "$MARKER" "$SOURCE_DIR/src/build/Config.zig" 2>/dev/null; then
echo "[+] trim patch already applied"
exit 0
fi
python3 - "$SOURCE_DIR" "$MARKER" <<'PYEOF'
import sys
from pathlib import Path
source_dir = Path(sys.argv[1])
marker = sys.argv[2]
# ──────────────────────────────────────────────────────────────────────
# 1. Config.zig — add custom_shaders feature flag
# ──────────────────────────────────────────────────────────────────────
config_path = source_dir / "src/build/Config.zig"
text = config_path.read_text()
# Add field
text = text.replace(
"sentry: bool = true,",
f"sentry: bool = true,\ncustom_shaders: bool = true, // {marker}",
)
# Add option parsing after sentry block
sentry_end = """ ) orelse sentry: {
switch (target.result.os.tag) {
.macos, .ios => break :sentry true,
// Note its false for linux because the crash reports on Linux
// don't have much useful information.
else => break :sentry false,
}
};"""
new_options = sentry_end + """
config.custom_shaders = b.option(
bool,
"custom-shaders",
"Build with custom shader (glslang/spirv-cross) support.",
) orelse true;"""
text = text.replace(sentry_end, new_options)
# Add to addOptions
text = text.replace(
'step.addOption(bool, "sentry", self.sentry);',
'step.addOption(bool, "sentry", self.sentry);\n'
' step.addOption(bool, "custom_shaders", self.custom_shaders);',
)
config_path.write_text(text)
print("[+] patched Config.zig")
# ──────────────────────────────────────────────────────────────────────
# 2. SharedDeps.zig — gate glslang + spirv-cross on custom_shaders
# ──────────────────────────────────────────────────────────────────────
shared_path = source_dir / "src/build/SharedDeps.zig"
text = shared_path.read_text()
# Gate glslang — wrap with custom_shaders check
text = text.replace(
' // Glslang\n if (b.lazyDependency("glslang", .{',
' // Glslang — only needed for custom shaders\n if (self.config.custom_shaders) if (b.lazyDependency("glslang", .{',
)
# Close the extra if at end of glslang block
text = text.replace(
""" step.linkLibrary(glslang_dep.artifact("glslang"));
try static_libs.append(
b.allocator,
glslang_dep.artifact("glslang").getEmittedBin(),
);
}
}
// Spirv-cross""",
""" step.linkLibrary(glslang_dep.artifact("glslang"));
try static_libs.append(
b.allocator,
glslang_dep.artifact("glslang").getEmittedBin(),
);
}
};
// Spirv-cross""",
)
# Gate spirv-cross — wrap with custom_shaders check
text = text.replace(
' // Spirv-cross\n if (b.lazyDependency("spirv_cross", .{',
' // Spirv-cross — only needed for custom shaders\n if (self.config.custom_shaders) if (b.lazyDependency("spirv_cross", .{',
)
text = text.replace(
""" step.linkLibrary(spirv_cross_dep.artifact("spirv_cross"));
try static_libs.append(
b.allocator,
spirv_cross_dep.artifact("spirv_cross").getEmittedBin(),
);
}
}
// Sentry""",
""" step.linkLibrary(spirv_cross_dep.artifact("spirv_cross"));
try static_libs.append(
b.allocator,
spirv_cross_dep.artifact("spirv_cross").getEmittedBin(),
);
}
};
// Sentry""",
)
shared_path.write_text(text)
print("[+] patched SharedDeps.zig")
# ──────────────────────────────────────────────────────────────────────
# 3. build_config.zig — re-export custom_shaders flag
# ──────────────────────────────────────────────────────────────────────
bc_path = source_dir / "src/build_config.zig"
text = bc_path.read_text()
if "custom_shaders" not in text:
text = text.replace(
'const options = @import("build_options");',
'const options = @import("build_options");\npub const custom_shaders = options.custom_shaders;',
)
bc_path.write_text(text)
print("[+] patched build_config.zig")
# ──────────────────────────────────────────────────────────────────────
# 4. global.zig — conditional glslang init
# (global.zig already imports build_config)
# ──────────────────────────────────────────────────────────────────────
global_path = source_dir / "src/global.zig"
text = global_path.read_text()
text = text.replace(
'const glslang = @import("glslang");',
'const glslang = if (build_config.custom_shaders) @import("glslang") else struct {\n'
' pub fn init() !void {}\n'
'};',
)
global_path.write_text(text)
print("[+] patched global.zig")
# ──────────────────────────────────────────────────────────────────────
# 5. renderer/shadertoy.zig — gate shader imports and loadFromFile
# When custom_shaders is disabled, loadFromFile is unreachable
# so glslang/spirv_cross are never semantically analyzed
# ──────────────────────────────────────────────────────────────────────
shader_path = source_dir / "src/renderer/shadertoy.zig"
text = shader_path.read_text()
# Make imports conditional — these won't be analyzed if never reached
text = text.replace(
'const glslang = @import("glslang");',
'const build_config = @import("../build_config.zig");\n'
'const glslang = @import("glslang");',
)
# Add early return in loadFromFiles when custom_shaders is disabled
# This prevents loadFromFile (and thus spirvFromGlsl etc) from being analyzed
text = text.replace(
"""pub fn loadFromFiles(
alloc_gpa: Allocator,
paths: configpkg.RepeatablePath,
target: Target,
) ![]const [:0]const u8 {
var list: std.ArrayList([:0]const u8) = .empty;""",
"""pub fn loadFromFiles(
alloc_gpa: Allocator,
paths: configpkg.RepeatablePath,
target: Target,
) ![]const [:0]const u8 {
if (comptime !build_config.custom_shaders) return &.{};
var list: std.ArrayList([:0]const u8) = .empty;""",
)
shader_path.write_text(text)
print("[+] patched renderer/shadertoy.zig")
print(f"[+] all trim patches complete ({marker})")
PYEOF
echo "[+] trim patch applied"

View File

@@ -0,0 +1,408 @@
#!/bin/bash
set -euo pipefail
SOURCE_DIR="${1:?Usage: $0 <ghostty-source-dir>}"
MARKER="LIBGHOSTTY_SPM_INSPECTOR_DISABLE"
if grep -q "$MARKER" "$SOURCE_DIR/src/build/Config.zig" 2>/dev/null; then
echo "[+] inspector disable patch already applied"
exit 0
fi
python3 - "$SOURCE_DIR" "$MARKER" <<'PYEOF'
import sys
from pathlib import Path
source_dir = Path(sys.argv[1])
marker = sys.argv[2]
def patch_file(rel_path, replacements):
path = source_dir / rel_path
text = path.read_text()
for old, new in replacements:
if old not in text:
print(f"[-] pattern not found in {rel_path}:")
print(f" {old[:80]}...")
sys.exit(1)
count = text.count(old)
if count > 1:
print(f"[-] pattern matched {count} times in {rel_path} (expected 1):")
print(f" {old[:80]}...")
sys.exit(1)
text = text.replace(old, new, 1)
path.write_text(text)
print(f"[+] patched {rel_path}")
# ──────────────────────────────────────────────────────────────────────
# 1. Config.zig — add inspector feature flag
# (runs AFTER 0006, so custom_shaders line already exists)
# ──────────────────────────────────────────────────────────────────────
patch_file("src/build/Config.zig", [
# Add field after custom_shaders (added by 0006)
(
f"custom_shaders: bool = true, // LIBGHOSTTY_SPM_TRIM_PATCH",
f"custom_shaders: bool = true, // LIBGHOSTTY_SPM_TRIM_PATCH\n"
f"inspector: bool = true, // {marker}",
),
# Add option parsing after the custom_shaders option block (added by 0006)
(
' "custom-shaders",\n'
' "Build with custom shader (glslang/spirv-cross) support.",\n'
' ) orelse true;',
' "custom-shaders",\n'
' "Build with custom shader (glslang/spirv-cross) support.",\n'
' ) orelse true;\n'
'\n'
' config.inspector = b.option(\n'
' bool,\n'
' "inspector",\n'
' "Build with terminal inspector (dcimgui) support.",\n'
' ) orelse true;',
),
# Add to addOptions after custom_shaders (added by 0006)
(
' step.addOption(bool, "custom_shaders", self.custom_shaders);',
' step.addOption(bool, "custom_shaders", self.custom_shaders);\n'
' step.addOption(bool, "inspector", self.inspector);',
),
])
# ──────────────────────────────────────────────────────────────────────
# 2. SharedDeps.zig — gate dcimgui linking on inspector flag
# ──────────────────────────────────────────────────────────────────────
patch_file("src/build/SharedDeps.zig", [
(
' // cimgui\n'
' if (b.lazyDependency("dcimgui", .{',
' // cimgui — only needed for inspector\n'
' if (self.config.inspector) if (b.lazyDependency("dcimgui", .{',
),
# Close the extra if — find the end of the dcimgui block
(
' );\n'
' }\n'
'\n'
' // Fonts',
' );\n'
' };\n'
'\n'
' // Fonts',
),
])
# ──────────────────────────────────────────────────────────────────────
# 3. build_config.zig — re-export inspector flag
# (runs AFTER 0006, so custom_shaders line already exists)
# ──────────────────────────────────────────────────────────────────────
patch_file("src/build_config.zig", [
(
'pub const custom_shaders = options.custom_shaders;',
'pub const custom_shaders = options.custom_shaders;\n'
'pub const inspector = options.inspector;',
),
])
# ──────────────────────────────────────────────────────────────────────
# 4. inspector/main.zig — COMPLETE FILE REPLACEMENT with stub module
#
# This is the key stability improvement. Instead of pattern-matching
# downstream files, we replace the single chokepoint module with
# stubs that satisfy all downstream type/method requirements.
# When inspector=true, everything re-exports as before.
# When inspector=false, stub types with no-op methods are provided
# so Surface.zig, renderer, termio all compile without modification.
# ──────────────────────────────────────────────────────────────────────
inspector_main = source_dir / "src/inspector/main.zig"
inspector_main.write_text('''\
const build_config = @import("../build_config.zig");
const std = @import("std");
const terminal = @import("../terminal/main.zig");
const input = @import("../input.zig");
const renderer = @import("../renderer.zig");
pub const widgets = if (build_config.inspector) @import("widgets.zig") else struct {
pub const key = struct {
pub const Event = StubKeyEvent;
};
pub const renderer = struct {
pub const Info = StubRendererInfo;
};
pub const surface = struct {
pub const Mouse = StubMouse;
};
};
pub const Inspector = if (build_config.inspector) @import("Inspector.zig") else StubInspector;
pub const KeyEvent = widgets.key.Event;
/// Stub types — these satisfy all downstream method/field accesses
/// when inspector is disabled, so no other files need patching.
const StubMouse = struct {
last_xpos: f64 = 0,
last_ypos: f64 = 0,
last_point: ?terminal.Pin = null,
};
const StubRendererInfo = struct {
pub const empty: @This() = .{};
pub fn deinit(_: *@This(), _: std.mem.Allocator) void {}
pub fn overlayFeatures(
_: *const @This(),
_: std.mem.Allocator,
) std.mem.Allocator.Error![]renderer.Overlay.Feature {
return &.{};
}
};
const StubKeyEvent = struct {
event: input.KeyEvent = undefined,
binding: []const input.Binding.Action = &.{},
pty: []const u8 = "",
pub fn deinit(_: *const @This(), _: std.mem.Allocator) void {}
};
const StubInspector = struct {
mouse: StubMouse = .{},
pub fn setup() void {}
pub fn init(_: std.mem.Allocator) !@This() { return .{}; }
pub fn deinit(_: *@This(), _: std.mem.Allocator) void {}
pub fn render(_: *@This(), _: anytype) void {}
pub fn rendererInfo(_: *@This()) *StubRendererInfo {
return &stub_renderer_info;
}
pub fn recordKeyEvent(
_: *@This(),
_: std.mem.Allocator,
_: StubKeyEvent,
) std.mem.Allocator.Error!void {}
pub fn recordPtyRead(
_: *@This(),
_: std.mem.Allocator,
_: *terminal.Terminal,
_: []const u8,
) !void {}
var stub_renderer_info: StubRendererInfo = .{};
};
test {
if (build_config.inspector) {
@import("std").testing.refAllDecls(@This());
}
}
''')
print("[+] replaced src/inspector/main.zig with stub module")
# ──────────────────────────────────────────────────────────────────────
# 5. input/key.zig — gate dcimgui import + imguiKey method
# ──────────────────────────────────────────────────────────────────────
patch_file("src/input/key.zig", [
(
'const cimgui = @import("dcimgui");',
'const _build_config = @import("../build_config.zig");\n'
'const cimgui = if (_build_config.inspector) @import("dcimgui") else struct {};',
),
(
' pub fn imguiKey(self: Key) ?c_int {',
' pub fn imguiKey(self: Key) ?c_int {\n'
' if (comptime !_build_config.inspector) return null;',
),
])
# ──────────────────────────────────────────────────────────────────────
# 6. apprt/embedded.zig — gate Inspector struct + CoreInspector import
# ──────────────────────────────────────────────────────────────────────
embedded_path = source_dir / "src/apprt/embedded.zig"
text = embedded_path.read_text()
# 6a. Gate CoreInspector import
old = 'const CoreInspector = @import("../inspector/main.zig").Inspector;'
new = 'const CoreInspector = if (@import("../build_config.zig").inspector) @import("../inspector/main.zig").Inspector else struct {};'
assert old in text, f"pattern not found: {old}"
text = text.replace(old, new, 1)
# 6b. Gate the Inspector struct definition using brace-depth counting
# Find the struct opening
struct_marker = 'pub const Inspector = struct {\n const cimgui = @import("dcimgui");'
assert struct_marker in text, f"Inspector struct marker not found"
struct_start_idx = text.index('pub const Inspector = struct {\n const cimgui = @import("dcimgui");')
# Find the opening brace
brace_start = text.index('{', struct_start_idx)
# Count braces to find the matching close
depth = 0
i = brace_start
while i < len(text):
if text[i] == '{':
depth += 1
elif text[i] == '}':
depth -= 1
if depth == 0:
break
i += 1
# i now points to the closing }
# The struct ends with `};` — we want to capture up to and including `}`
# but NOT the `;` since the semicolon will come after the else branch
struct_end_brace = i + 1 # position after closing }
# Check for trailing semicolon to know where to resume after replacement
struct_end = struct_end_brace
if struct_end < len(text) and text[struct_end] == ';':
struct_end += 1 # skip the original ; in the replacement range
# Extract the original struct content (up to } but not ;)
original_struct = text[struct_start_idx:struct_end_brace]
# Wrap: replace opening with comptime conditional
new_struct = original_struct.replace(
'pub const Inspector = struct {',
'pub const Inspector = if (@import("../build_config.zig").inspector) struct {',
1,
)
# Add else branch after closing };
stub = """ else struct {
surface: *Surface = undefined,
pub fn init(_: *Surface) !@This() { return .{}; }
pub fn deinit(_: *@This()) void {}
};"""
new_struct = new_struct + stub
text = text[:struct_start_idx] + new_struct + text[struct_end:]
# 6c. Gate initInspector body
old_init = """ pub fn initInspector(self: *Surface) !*Inspector {
if (self.inspector) |v| return v;
const alloc = self.app.core_app.alloc;
const inspector = try alloc.create(Inspector);
errdefer alloc.destroy(inspector);
inspector.* = try .init(self);
self.inspector = inspector;
return inspector;
}"""
new_init = """ pub fn initInspector(self: *Surface) !*Inspector {
if (comptime !@import("../build_config.zig").inspector) return error.InspectorUnavailable;
if (self.inspector) |v| return v;
const alloc = self.app.core_app.alloc;
const inspector = try alloc.create(Inspector);
errdefer alloc.destroy(inspector);
inspector.* = try .init(self);
self.inspector = inspector;
return inspector;
}"""
assert old_init in text, "initInspector pattern not found"
text = text.replace(old_init, new_init, 1)
# 6d. Gate freeInspector body
old_free = """ pub fn freeInspector(self: *Surface) void {
if (self.inspector) |v| {
v.deinit();
self.app.core_app.alloc.destroy(v);
self.inspector = null;
}
}"""
new_free = """ pub fn freeInspector(self: *Surface) void {
if (comptime !@import("../build_config.zig").inspector) return;
if (self.inspector) |v| {
v.deinit();
self.app.core_app.alloc.destroy(v);
self.inspector = null;
}
}"""
assert old_free in text, "freeInspector pattern not found"
text = text.replace(old_free, new_free, 1)
# 6e. Gate CAPI inspector functions that call methods on Inspector
# These functions take *Inspector and call methods that don't exist on the stub struct.
# We add comptime early-returns so the method calls are never analyzed.
capi_guards = [
(' export fn ghostty_inspector_set_size(ptr: *Inspector, w: u32, h: u32) void {\n'
' ptr.updateSize(w, h);',
' export fn ghostty_inspector_set_size(ptr: *Inspector, w: u32, h: u32) void {\n'
' if (comptime !@import("../build_config.zig").inspector) return;\n'
' ptr.updateSize(w, h);'),
(' export fn ghostty_inspector_set_content_scale(ptr: *Inspector, x: f64, y: f64) void {\n'
' ptr.updateContentScale(x, y);',
' export fn ghostty_inspector_set_content_scale(ptr: *Inspector, x: f64, y: f64) void {\n'
' if (comptime !@import("../build_config.zig").inspector) return;\n'
' ptr.updateContentScale(x, y);'),
(' ptr.mouseButtonCallback(\n'
' action,\n'
' button,',
' if (comptime !@import("../build_config.zig").inspector) return;\n'
' ptr.mouseButtonCallback(\n'
' action,\n'
' button,'),
(' export fn ghostty_inspector_mouse_pos(ptr: *Inspector, x: f64, y: f64) void {\n'
' ptr.cursorPosCallback(x, y);',
' export fn ghostty_inspector_mouse_pos(ptr: *Inspector, x: f64, y: f64) void {\n'
' if (comptime !@import("../build_config.zig").inspector) return;\n'
' ptr.cursorPosCallback(x, y);'),
(' ptr.scrollCallback(\n'
' x,\n'
' y,',
' if (comptime !@import("../build_config.zig").inspector) return;\n'
' ptr.scrollCallback(\n'
' x,\n'
' y,'),
(' ptr.keyCallback(\n'
' action,\n'
' key,',
' if (comptime !@import("../build_config.zig").inspector) return;\n'
' ptr.keyCallback(\n'
' action,\n'
' key,'),
(' export fn ghostty_inspector_text(\n'
' ptr: *Inspector,\n'
' str: [*:0]const u8,\n'
' ) void {\n'
' ptr.textCallback(std.mem.sliceTo(str, 0));',
' export fn ghostty_inspector_text(\n'
' ptr: *Inspector,\n'
' str: [*:0]const u8,\n'
' ) void {\n'
' if (comptime !@import("../build_config.zig").inspector) return;\n'
' ptr.textCallback(std.mem.sliceTo(str, 0));'),
(' export fn ghostty_inspector_set_focus(ptr: *Inspector, focused: bool) void {\n'
' ptr.focusCallback(focused);',
' export fn ghostty_inspector_set_focus(ptr: *Inspector, focused: bool) void {\n'
' if (comptime !@import("../build_config.zig").inspector) return;\n'
' ptr.focusCallback(focused);'),
(' export fn ghostty_inspector_metal_init(ptr: *Inspector, device: objc.c.id) bool {\n'
' return ptr.initMetal(.fromId(device));',
' export fn ghostty_inspector_metal_init(ptr: *Inspector, device: objc.c.id) bool {\n'
' if (comptime !@import("../build_config.zig").inspector) return false;\n'
' return ptr.initMetal(.fromId(device));'),
(' return ptr.renderMetal(\n'
' .fromId(command_buffer),',
' if (comptime !@import("../build_config.zig").inspector) return;\n'
' return ptr.renderMetal(\n'
' .fromId(command_buffer),'),
(' export fn ghostty_inspector_metal_shutdown(ptr: *Inspector) void {\n'
' if (ptr.backend) |v| {',
' export fn ghostty_inspector_metal_shutdown(ptr: *Inspector) void {\n'
' if (comptime !@import("../build_config.zig").inspector) return;\n'
' if (ptr.backend) |v| {'),
]
for old_capi, new_capi in capi_guards:
assert old_capi in text, f"CAPI pattern not found: {old_capi[:60]}..."
text = text.replace(old_capi, new_capi, 1)
embedded_path.write_text(text)
print("[+] patched apprt/embedded.zig")
print(f"[+] all inspector patches complete ({marker})")
PYEOF
echo "[+] inspector disable patch applied"

View File

@@ -0,0 +1,111 @@
#!/bin/bash
set -euo pipefail
SOURCE_DIR="${1:?Usage: $0 <ghostty-source-dir>}"
METAL_ZIG="$SOURCE_DIR/src/renderer/Metal.zig"
if [ ! -f "$METAL_ZIG" ]; then
echo "[-] Metal.zig not found"
exit 1
fi
if grep -q 'LIBGHOSTTY_SPM_TEXTURE_STORAGE_PATCH' "$METAL_ZIG"; then
echo "[+] Metal texture storage already patched"
exit 0
fi
python3 - "$METAL_ZIG" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
src = path.read_text()
replacements = [
(
"""default_storage_mode: mtl.MTLResourceOptions.StorageMode,
/// The maximum 2D texture width and height supported by the device.
""",
"""default_storage_mode: mtl.MTLResourceOptions.StorageMode,
/// The default storage mode to use for MTLTexture resources.
default_texture_storage_mode: mtl.MTLResourceOptions.StorageMode,
/// The maximum 2D texture width and height supported by the device.
""",
),
(
""" const max_texture_size = queryMaxTextureSize(device);
log.debug(
"device properties default_storage_mode={} max_texture_size={}",
.{ default_storage_mode, max_texture_size },
);
""",
""" // LIBGHOSTTY_SPM_TEXTURE_STORAGE_PATCH
// MTLStorageModeShared is valid for textures on Apple GPUs, while Intel
// and AMD macOS GPUs require managed textures even when hasUnifiedMemory is
// true. Keep buffer storage unchanged, but choose texture storage from the
// Metal GPU family as Apple recommends for CPU-updated textures.
const default_texture_storage_mode: mtl.MTLResourceOptions.StorageMode = switch (comptime builtin.os.tag) {
.ios => .shared,
.macos => if (device.msgSend(
bool,
objc.sel("supportsFamily:"),
.{mtl.MTLGPUFamily.apple1},
)) .shared else .managed,
else => default_storage_mode,
};
const max_texture_size = queryMaxTextureSize(device);
log.debug(
"device properties default_storage_mode={} default_texture_storage_mode={} max_texture_size={}",
.{ default_storage_mode, default_texture_storage_mode, max_texture_size },
);
""",
),
(
""" .default_storage_mode = default_storage_mode,
.max_texture_size = max_texture_size,
""",
""" .default_storage_mode = default_storage_mode,
.default_texture_storage_mode = default_texture_storage_mode,
.max_texture_size = max_texture_size,
""",
),
]
for old, new in replacements:
if old not in src:
print("[-] Metal.zig structure block not found")
sys.exit(1)
src = src.replace(old, new, 1)
old_count = src.count(".storage_mode = self.default_storage_mode")
if old_count < 4:
print("[-] expected texture storage call sites not found")
sys.exit(1)
src = src.replace(
".storage_mode = self.default_storage_mode",
".storage_mode = self.default_texture_storage_mode",
)
buffer_marker = """pub inline fn bufferOptions(self: Metal) bufferpkg.Options {"""
buffer_start = src.find(buffer_marker)
if buffer_start == -1:
print("[-] bufferOptions not found")
sys.exit(1)
src = (
src[:buffer_start]
+ src[buffer_start:].replace(
".storage_mode = self.default_texture_storage_mode",
".storage_mode = self.default_storage_mode",
1,
)
)
path.write_text(src)
print("[+] patched Metal.zig: split buffer and texture storage modes")
PY

View File

@@ -0,0 +1,95 @@
#!/bin/bash
set -euo pipefail
SOURCE_DIR="${1:?Usage: $0 <ghostty-source-dir>}"
# Zig's bundled libc++ headers ship with Apple vendor availability annotations
# disabled, so C++ code references runtime symbols that the Apple system
# libc++.1.dylib may not export at our deployment floors (e.g.
# std::__1::__libcpp_verbose_abort, exported only since iOS 16.3 /
# macOS 13.3 / tvOS 16.3). Forcing the annotations on makes libc++ headers
# degrade gracefully below those floors (exactly like Apple SDK clang) and
# turns any hard dependency on a too-new symbol into a compile error instead
# of a dyld crash at app launch.
#
# -Wno-macro-redefined: zig predefines the macro to 0 on its own command
# line; our -D override redefines it.
# Patch 1: highway flags (hwy/abort.cc et al reference __libcpp_verbose_abort
# through the -fno-exceptions throw helpers)
HIGHWAY_BUILD="${SOURCE_DIR}/pkg/highway/build.zig"
if [ ! -f "$HIGHWAY_BUILD" ]; then
echo "[-] missing: $HIGHWAY_BUILD; upstream changed, update this patch"
exit 1
fi
if ! grep -q '_LIBCPP_HAS_VENDOR_AVAILABILITY_ANNOTATIONS' "$HIGHWAY_BUILD"; then
perl -0pi -e 's/try flags\.appendSlice\(b\.allocator, &\.\{\n/try flags.appendSlice(b.allocator, &.{\n "-D_LIBCPP_HAS_VENDOR_AVAILABILITY_ANNOTATIONS=1",\n "-Wno-macro-redefined",\n/' "$HIGHWAY_BUILD"
grep -q '_LIBCPP_HAS_VENDOR_AVAILABILITY_ANNOTATIONS' "$HIGHWAY_BUILD" || {
echo "[-] highway flags block not found; upstream changed, update this patch"
exit 1
}
echo "[+] patched: highway libc++ availability annotations"
else
echo "[+] highway libc++ availability already patched"
fi
# Patch 2: simdutf flags
SIMDUTF_BUILD="${SOURCE_DIR}/pkg/simdutf/build.zig"
if [ ! -f "$SIMDUTF_BUILD" ]; then
echo "[-] missing: $SIMDUTF_BUILD; upstream changed, update this patch"
exit 1
fi
if ! grep -q '_LIBCPP_HAS_VENDOR_AVAILABILITY_ANNOTATIONS' "$SIMDUTF_BUILD"; then
perl -0pi -e 's/try flags\.appendSlice\(b\.allocator, &\.\{\n/try flags.appendSlice(b.allocator, &.{\n "-D_LIBCPP_HAS_VENDOR_AVAILABILITY_ANNOTATIONS=1",\n "-Wno-macro-redefined",\n/' "$SIMDUTF_BUILD"
grep -q '_LIBCPP_HAS_VENDOR_AVAILABILITY_ANNOTATIONS' "$SIMDUTF_BUILD" || {
echo "[-] simdutf flags block not found; upstream changed, update this patch"
exit 1
}
echo "[+] patched: simdutf libc++ availability annotations"
else
echo "[+] simdutf libc++ availability already patched"
fi
# Patch 3: ghostty's own C++ SIMD sources (src/simd/*.cpp)
SHARED_DEPS="${SOURCE_DIR}/src/build/SharedDeps.zig"
if [ ! -f "$SHARED_DEPS" ]; then
echo "[-] missing: $SHARED_DEPS; upstream changed, update this patch"
exit 1
fi
if grep -q 'HWY_NO_LIBCXX' "$SHARED_DEPS" &&
grep -q 'SIMDUTF_NO_LIBCXX' "$SHARED_DEPS"; then
echo "[+] src/simd already builds without libc++"
elif ! grep -q '_LIBCPP_HAS_VENDOR_AVAILABILITY_ANNOTATIONS' "$SHARED_DEPS"; then
python3 - "$SHARED_DEPS" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
text = path.read_text()
old_flags = """ .flags = if (target.result.cpu.arch == .x86_64) &.{
b.fmt("-DHWY_DISABLED_TARGETS={}", .{HWY_DISABLED_TARGETS}),
} else &.{},"""
new_flags = """ .flags = if (target.result.cpu.arch == .x86_64) &.{
"-D_LIBCPP_HAS_VENDOR_AVAILABILITY_ANNOTATIONS=1",
"-Wno-macro-redefined",
b.fmt("-DHWY_DISABLED_TARGETS={}", .{HWY_DISABLED_TARGETS}),
} else &.{
"-D_LIBCPP_HAS_VENDOR_AVAILABILITY_ANNOTATIONS=1",
"-Wno-macro-redefined",
},"""
if old_flags not in text:
print("[-] src/simd flags block not found; upstream changed, update this patch")
sys.exit(1)
path.write_text(text.replace(old_flags, new_flags))
print("[+] patched: src/simd libc++ availability annotations")
PY
else
echo "[+] src/simd libc++ availability already patched"
fi
echo "[+] all libcxx-apple-availability patches applied"

View File

@@ -0,0 +1,22 @@
diff --git a/src/Surface.zig b/src/Surface.zig
index 4d66622..c0fe19e 100644
--- a/src/Surface.zig
+++ b/src/Surface.zig
@@ -3430,7 +3430,7 @@ pub fn scrollCallback(
}
// We scroll by the number of rows in the offset and save the remainder
- const amount = poff / cell_size;
+ const amount = @trunc(poff / cell_size);
assert(@abs(amount) >= 1);
self.mouse.pending_scroll_y = poff - (amount * cell_size);
@@ -3455,7 +3455,7 @@ pub fn scrollCallback(
break :x .{};
}
- const amount = poff / cell_size;
+ const amount = @trunc(poff / cell_size);
assert(@abs(amount) >= 1);
self.mouse.pending_scroll_x = poff - (amount * cell_size);
const delta: isize = @intFromFloat(@trunc(amount));

View File

@@ -0,0 +1,25 @@
# Ghostty Patches
This directory is the single place for local upstream Ghostty patches used by
the `libghostty-spm` build pipeline.
## Rules
- Keep patches numbered so they apply in a stable order.
- Prefer standard unified diff files (`.patch`) when the upstream context is
stable.
- Use executable patch scripts (`.sh`) only when upstream context is too
unstable for a reliable diff.
- Keep version-specific variants beside the original patch and select them in
`Script/apply-patches.sh` using an upstream API marker.
- Preserve newer Ghostty's renamed internal-library outputs when extending its
Darwin static-library build path.
- Every patch in this directory must be safe to re-run.
- Patches here are applied automatically by `Script/build-ghostty.sh`, so they
affect macOS, iOS, and Mac Catalyst builds equally.
## Current goal
This patch workflow exists so we can carry host-managed IO work required for
sandboxed iOS, macOS, and Mac Catalyst integration without hiding upstream
modifications inside ad-hoc build script edits.