- 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>
474 lines
19 KiB
Bash
Executable File
474 lines
19 KiB
Bash
Executable File
#!/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"
|