Files
terminalX/vendor/libghostty-spm/Patches/ghostty/0005-ios-metal-rendering.sh
kid aa92d0e676 初始提交: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>
2026-07-24 10:20:46 +08:00

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"