初始提交: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,291 @@
//
// InMemoryTerminalSession.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/16.
//
import Foundation
import GhosttyKit
public final class InMemoryTerminalSession: @unchecked Sendable {
private static let slowSurfaceWriteThreshold: TimeInterval = 0.5
private let resizeLock = NSLock()
private let surfaceAccess: InMemoryTerminalSurfaceAccess
private var lastResize: InMemoryTerminalViewport?
private let writeHandler: @Sendable (Data) -> Void
private let resizeHandler: @Sendable (InMemoryTerminalViewport) -> Void
public init(
write: @escaping @Sendable (Data) -> Void,
resize: @escaping @Sendable (InMemoryTerminalViewport) -> Void
) {
writeHandler = write
resizeHandler = resize
surfaceAccess = InMemoryTerminalSurfaceAccess(
write: Self.writeToSurface,
processExit: Self.reportProcessExit
)
}
init(
write: @escaping @Sendable (Data) -> Void,
resize: @escaping @Sendable (InMemoryTerminalViewport) -> Void,
surfaceWrite: @escaping InMemoryTerminalSurfaceAccess.Write,
processExit: @escaping InMemoryTerminalSurfaceAccess.ProcessExit =
InMemoryTerminalSession.reportProcessExit
) {
writeHandler = write
resizeHandler = resize
surfaceAccess = InMemoryTerminalSurfaceAccess(
write: surfaceWrite,
processExit: processExit
)
}
// MARK: - Surface Lifecycle
func setSurface(_ surface: ghostty_surface_t?) {
surfaceAccess.setSurface(surface)
TerminalDebugLog.log(
.lifecycle,
"in-memory session surface=\(surface == nil ? "nil" : "set")"
)
}
func clearSurface(ifMatches expectedSurface: ghostty_surface_t?) {
guard surfaceAccess.clearSurface(ifMatches: expectedSurface) else {
TerminalDebugLog.log(
.lifecycle,
"in-memory session clear skipped expected=\(expectedSurface == nil ? "nil" : "set") current=\(surfaceAccess.currentSurface == nil ? "nil" : "set")"
)
return
}
TerminalDebugLog.log(.lifecycle, "in-memory session surface=nil matched")
}
var currentSurface: ghostty_surface_t? {
surfaceAccess.currentSurface
}
// MARK: - Viewport Read
/// Returns the active viewport as a UTF-8 string, or `nil` if no surface
/// is attached. Lines are separated by `\n`. The `ghostty_text_s`
/// lifecycle (allocate via `ghostty_surface_read_text`, free via
/// `ghostty_surface_free_text`) is fully encapsulated callers never
/// touch the C buffer.
///
/// Selection grammar: `(VIEWPORT, TOP_LEFT)` to `(VIEWPORT, BOTTOM_RIGHT)`
/// with `rectangle: false` (linear flow). This reads exactly the visible
/// rows and ignores scrollback. Empty viewports return an empty string.
///
/// Thread-safe: keeps the surface alive for the duration of the read,
/// preventing access against a surface mid-replacement.
public func readViewportText() -> String? {
surfaceAccess.withCurrentSurface { surface in
let topLeft = ghostty_point_s(
tag: GHOSTTY_POINT_VIEWPORT,
coord: GHOSTTY_POINT_COORD_TOP_LEFT,
x: 0,
y: 0
)
let bottomRight = ghostty_point_s(
tag: GHOSTTY_POINT_VIEWPORT,
coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
x: 0,
y: 0
)
let selection = ghostty_selection_s(
top_left: topLeft,
bottom_right: bottomRight,
rectangle: false
)
var out = ghostty_text_s()
guard ghostty_surface_read_text(surface, selection, &out) else {
return nil
}
defer { ghostty_surface_free_text(surface, &out) }
guard let textPtr = out.text, out.text_len > 0 else {
return ""
}
let bytes = UnsafeBufferPointer(start: textPtr, count: Int(out.text_len))
.map { UInt8(bitPattern: $0) }
return String(decoding: bytes, as: UTF8.self)
} ?? nil
}
func updateViewport(_ size: TerminalGridMetrics) {
TerminalDebugLog.log(.metrics, "in-memory viewport update \(size.debugSummary)")
dispatchResize(InMemoryTerminalViewport(
columns: size.columns,
rows: size.rows,
widthPixels: size.widthPixels,
heightPixels: size.heightPixels,
cellWidthPixels: size.cellWidthPixels,
cellHeightPixels: size.cellHeightPixels
))
}
// MARK: - Receiving Data
/// Enqueue data for the terminal from the host backend.
///
/// Writes are processed in order on a per-session serial queue so parsing
/// cannot block the caller or the main thread.
public func receive(_ data: Data) {
guard surfaceAccess.enqueueWrite(data) else {
TerminalDebugLog.log(
.output,
"terminal <- host dropped \(TerminalDebugLog.describe(data))"
)
return
}
TerminalDebugLog.log(
.output,
"terminal <- host \(TerminalDebugLog.describe(data))"
)
}
/// Feed a UTF-8 string into the terminal from the host backend.
public func receive(_ string: String) {
guard let data = string.data(using: .utf8) else { return }
receive(data)
}
/// Inject input bytes directly into the host-side consumer.
///
/// This bypasses `ghostty_surface_key` translation and is intended for
/// control sequences that the in-memory backend must interpret itself.
public func sendInput(_ data: Data) {
TerminalDebugLog.log(
.input,
"host <- direct input \(TerminalDebugLog.describe(data))"
)
writeHandler(data)
}
// MARK: - Process Exit
/// Enqueue a host-managed process exit after all previously received data.
public func finish(exitCode: UInt32, runtimeMilliseconds: UInt64) {
guard surfaceAccess.enqueueProcessExit(
exitCode: exitCode,
runtimeMilliseconds: runtimeMilliseconds
) else {
TerminalDebugLog.log(
.lifecycle,
"process exit ignored: missing surface exitCode=\(exitCode) runtimeMs=\(runtimeMilliseconds)"
)
return
}
TerminalDebugLog.log(
.lifecycle,
"process exit exitCode=\(exitCode) runtimeMs=\(runtimeMilliseconds)"
)
}
// MARK: - C Callbacks
static let receiveBufferCallback: ghostty_surface_receive_buffer_cb = { userdata, ptr, len in
guard let userdata, let ptr else { return }
let session = Unmanaged<InMemoryTerminalSession>
.fromOpaque(userdata)
.takeUnretainedValue()
let data = Data(bytes: ptr, count: len)
TerminalDebugLog.log(
.input,
"host <- terminal \(TerminalDebugLog.describe(data))"
)
session.writeHandler(data)
}
static let receiveResizeCallback: ghostty_surface_receive_resize_cb = { userdata, cols, rows, widthPx, heightPx in
guard let userdata else { return }
let session = Unmanaged<InMemoryTerminalSession>
.fromOpaque(userdata)
.takeUnretainedValue()
TerminalDebugLog.log(
.metrics,
"receive resize cols=\(cols) rows=\(rows) pixels=\(widthPx)x\(heightPx)"
)
session.dispatchResize(InMemoryTerminalViewport(
columns: cols,
rows: rows,
widthPixels: widthPx,
heightPixels: heightPx
))
}
private func dispatchResize(_ resize: InMemoryTerminalViewport) {
resizeLock.lock()
let mergedResize = mergedResize(resize)
guard mergedResize != lastResize else {
resizeLock.unlock()
TerminalDebugLog.log(
.metrics,
"resize unchanged cols=\(mergedResize.columns) rows=\(mergedResize.rows) pixels=\(mergedResize.widthPixels)x\(mergedResize.heightPixels) cell=\(mergedResize.cellWidthPixels)x\(mergedResize.cellHeightPixels)"
)
return
}
lastResize = mergedResize
resizeLock.unlock()
TerminalDebugLog.log(
.metrics,
"resize dispatched cols=\(mergedResize.columns) rows=\(mergedResize.rows) pixels=\(mergedResize.widthPixels)x\(mergedResize.heightPixels) cell=\(mergedResize.cellWidthPixels)x\(mergedResize.cellHeightPixels)"
)
resizeHandler(mergedResize)
}
private func mergedResize(_ resize: InMemoryTerminalViewport) -> InMemoryTerminalViewport {
guard let lastResize else { return resize }
return InMemoryTerminalViewport(
columns: resize.columns,
rows: resize.rows,
widthPixels: resize.widthPixels == 0 ? lastResize.widthPixels : resize.widthPixels,
heightPixels: resize.heightPixels == 0 ? lastResize.heightPixels : resize.heightPixels,
cellWidthPixels: resize.cellWidthPixels == 0 ? lastResize.cellWidthPixels : resize.cellWidthPixels,
cellHeightPixels: resize.cellHeightPixels == 0 ? lastResize.cellHeightPixels : resize.cellHeightPixels
)
}
func waitForPendingOutput() {
surfaceAccess.waitForPendingOutput()
}
private static func writeToSurface(_ surface: ghostty_surface_t, _ data: Data) {
let start = ProcessInfo.processInfo.systemUptime
defer {
let duration = ProcessInfo.processInfo.systemUptime - start
if duration >= slowSurfaceWriteThreshold {
TerminalDebugLog.log(
.output,
"surface write slow bytes=\(data.count) duration=\(String(format: "%.3f", duration))s"
)
}
}
data.withUnsafeBytes { buffer in
guard let ptr = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
return
}
ghostty_surface_write_buffer(surface, ptr, UInt(buffer.count))
}
}
private static func reportProcessExit(
_ surface: ghostty_surface_t,
_ exitCode: UInt32,
_ runtimeMilliseconds: UInt64
) {
ghostty_surface_process_exit(surface, exitCode, runtimeMilliseconds)
}
}

View File

@@ -0,0 +1,141 @@
import Foundation
import GhosttyKit
/// Serializes host output while keeping the raw surface alive for each C call.
final class InMemoryTerminalSurfaceAccess: @unchecked Sendable {
typealias Write = @Sendable (ghostty_surface_t, Data) -> Void
typealias ProcessExit = @Sendable (ghostty_surface_t, UInt32, UInt64) -> Void
private let condition = NSCondition()
private let outputQueue = DispatchQueue(
label: "com.lakr233.libghostty-spm.in-memory-output",
qos: .userInitiated
)
private let write: Write
private let processExit: ProcessExit
private var surface: ghostty_surface_t?
/// Invalidates work that was enqueued for a surface that has been replaced.
private var generation: UInt64 = 0
/// Prevents the caller from freeing a surface while a C operation uses it.
private var activeOperations = 0
init(
write: @escaping Write,
processExit: @escaping ProcessExit
) {
self.write = write
self.processExit = processExit
}
func setSurface(_ surface: ghostty_surface_t?) {
condition.lock()
generation &+= 1
self.surface = nil
waitForActiveOperations()
self.surface = surface
condition.unlock()
}
@discardableResult
func clearSurface(ifMatches expectedSurface: ghostty_surface_t?) -> Bool {
condition.lock()
guard surface == expectedSurface else {
condition.unlock()
return false
}
generation &+= 1
surface = nil
waitForActiveOperations()
condition.unlock()
return true
}
var currentSurface: ghostty_surface_t? {
condition.lock()
defer { condition.unlock() }
return surface
}
@discardableResult
func enqueueWrite(_ data: Data) -> Bool {
guard let generation = currentGeneration else { return false }
outputQueue.async { [self] in
withSurface(generation: generation) { surface in
write(surface, data)
}
}
return true
}
@discardableResult
func enqueueProcessExit(
exitCode: UInt32,
runtimeMilliseconds: UInt64
) -> Bool {
guard let generation = currentGeneration else { return false }
outputQueue.async { [self] in
withSurface(generation: generation) { surface in
processExit(surface, exitCode, runtimeMilliseconds)
}
}
return true
}
func withCurrentSurface<Result>(
_ operation: (ghostty_surface_t) -> Result
) -> Result? {
condition.lock()
guard let surface else {
condition.unlock()
return nil
}
activeOperations += 1
condition.unlock()
defer { finishOperation() }
return operation(surface)
}
func waitForPendingOutput() {
outputQueue.sync {}
}
private var currentGeneration: UInt64? {
condition.lock()
defer { condition.unlock() }
return surface == nil ? nil : generation
}
private func withSurface(
generation expectedGeneration: UInt64,
_ operation: (ghostty_surface_t) -> Void
) {
condition.lock()
guard generation == expectedGeneration, let surface else {
condition.unlock()
return
}
activeOperations += 1
condition.unlock()
defer { finishOperation() }
operation(surface)
}
private func finishOperation() {
condition.lock()
activeOperations -= 1
if activeOperations == 0 {
condition.broadcast()
}
condition.unlock()
}
private func waitForActiveOperations() {
while activeOperations > 0 {
condition.wait()
}
}
}

View File

@@ -0,0 +1,31 @@
//
// InMemoryTerminalViewport.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/16.
//
public struct InMemoryTerminalViewport: Sendable, Equatable {
public var columns: UInt16
public var rows: UInt16
public var widthPixels: UInt32
public var heightPixels: UInt32
public var cellWidthPixels: UInt32
public var cellHeightPixels: UInt32
public init(
columns: UInt16,
rows: UInt16,
widthPixels: UInt32 = 0,
heightPixels: UInt32 = 0,
cellWidthPixels: UInt32 = 0,
cellHeightPixels: UInt32 = 0
) {
self.columns = columns
self.rows = rows
self.widthPixels = widthPixels
self.heightPixels = heightPixels
self.cellWidthPixels = cellWidthPixels
self.cellHeightPixels = cellHeightPixels
}
}

View File

@@ -0,0 +1,176 @@
//
// TerminalCallbackBridge.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/16.
//
import Foundation
import GhosttyKit
/// Dispatches C runtime callbacks to a ``TerminalSurfaceViewDelegate``.
///
/// An instance of this class is passed as the `userdata` pointer in the
/// surface config so that Ghostty callbacks can route actions back to
/// the owning view.
@MainActor
final class TerminalCallbackBridge {
weak var delegate: (any TerminalSurfaceViewDelegate)?
/// Raw surface pointer for use in C callbacks (e.g. clipboard).
nonisolated(unsafe) var rawSurface: ghostty_surface_t?
var onCellSizeChange: ((UInt32, UInt32) -> Void)?
var onRenderRequest: (() -> Void)?
init(delegate: (any TerminalSurfaceViewDelegate)? = nil) {
self.delegate = delegate
}
func handleAction(_ action: ghostty_action_s) {
switch action.tag {
case GHOSTTY_ACTION_SET_TITLE:
if let cStr = action.action.set_title.title {
let title = String(cString: cStr)
TerminalDebugLog.log(
.actions,
"callback action=set_title title=\(TerminalDebugLog.describe(title))"
)
(delegate as? any TerminalSurfaceTitleDelegate)?
.terminalDidChangeTitle(title)
}
case GHOSTTY_ACTION_CELL_SIZE:
let cellSize = action.action.cell_size
TerminalDebugLog.log(
.actions,
"callback action=cell_size width=\(cellSize.width) height=\(cellSize.height)"
)
onCellSizeChange?(cellSize.width, cellSize.height)
case GHOSTTY_ACTION_RING_BELL:
TerminalDebugLog.log(.actions, "callback action=ring_bell")
(delegate as? any TerminalSurfaceBellDelegate)?
.terminalDidRingBell()
case GHOSTTY_ACTION_RENDER:
TerminalDebugLog.log(.render, "callback action=render")
onRenderRequest?()
case GHOSTTY_ACTION_CONFIG_CHANGE:
// Colors/theme may have changed (e.g. on system appearance
// toggle). Ghostty applies the new config internally but won't
// repaint until the next frame request one so the refreshed
// theme is visible without waiting for input or layout.
TerminalDebugLog.log(.actions, "callback action=config_change")
onRenderRequest?()
case GHOSTTY_ACTION_PROGRESS_REPORT:
let report = action.action.progress_report
let state = TerminalProgressState(report.state) ?? .set
// int8_t -1 signals "no progress provided" surface as nil.
let percent: Int? = report.progress < 0 ? nil : Int(report.progress)
TerminalDebugLog.log(
.actions,
"callback action=progress_report state=\(state) percent=\(percent.map { "\($0)" } ?? "nil")"
)
(delegate as? any TerminalSurfaceProgressReportDelegate)?
.terminalDidReportProgress(state: state, percent: percent)
case GHOSTTY_ACTION_COMMAND_FINISHED:
let finished = action.action.command_finished
// int16_t -1 signals unknown exit code.
let exit: Int? = finished.exit_code < 0 ? nil : Int(finished.exit_code)
TerminalDebugLog.log(
.actions,
"callback action=command_finished exit=\(exit.map { "\($0)" } ?? "nil") duration_ns=\(finished.duration)"
)
(delegate as? any TerminalSurfaceCommandFinishedDelegate)?
.terminalDidFinishCommand(
exitCode: exit,
durationNanos: finished.duration
)
case GHOSTTY_ACTION_DESKTOP_NOTIFICATION:
let payload = action.action.desktop_notification
let title = payload.title.map { String(cString: $0) } ?? ""
let body = payload.body.map { String(cString: $0) } ?? ""
TerminalDebugLog.log(
.actions,
"callback action=desktop_notification title=\(TerminalDebugLog.describe(title)) body=\(TerminalDebugLog.describe(body))"
)
(delegate as? any TerminalSurfaceDesktopNotificationDelegate)?
.terminalDidRequestDesktopNotification(title: title, body: body)
case GHOSTTY_ACTION_OPEN_URL:
let payload = action.action.open_url
let kind = TerminalOpenURLKind(payload.kind)
let url: String = payload.url.map { ptr in
// Ghostty provides a length-prefixed string; respect the
// documented length rather than trusting a NUL terminator.
let buf = UnsafeBufferPointer(start: ptr, count: Int(payload.len))
return String(decoding: buf.map(UInt8.init), as: UTF8.self)
} ?? ""
TerminalDebugLog.log(
.actions,
"callback action=open_url kind=\(kind) url=\(TerminalDebugLog.describe(url))"
)
(delegate as? any TerminalSurfaceOpenURLDelegate)?
.terminalDidRequestOpenURL(url, kind: kind)
case GHOSTTY_ACTION_MOUSE_OVER_LINK:
let payload = action.action.mouse_over_link
let url: String? = {
guard let ptr = payload.url, payload.len > 0 else { return nil }
let buf = UnsafeBufferPointer(start: ptr, count: Int(payload.len))
return String(decoding: buf.map(UInt8.init), as: UTF8.self)
}()
TerminalDebugLog.log(
.actions,
"callback action=mouse_over_link url=\(url.map { TerminalDebugLog.describe($0) } ?? "nil")"
)
(delegate as? any TerminalSurfaceHoverLinkDelegate)?
.terminalDidUpdateHoverLink(url)
case GHOSTTY_ACTION_PWD:
let payload = action.action.pwd
if let cStr = payload.pwd {
let pwd = String(cString: cStr)
TerminalDebugLog.log(
.actions,
"callback action=pwd pwd=\(TerminalDebugLog.describe(pwd))"
)
(delegate as? any TerminalSurfacePwdDelegate)?
.terminalDidChangeWorkingDirectory(pwd)
}
case GHOSTTY_ACTION_SCROLLBAR:
let payload = action.action.scrollbar
TerminalDebugLog.log(
.actions,
"callback action=scrollbar total=\(payload.total) offset=\(payload.offset) len=\(payload.len)"
)
(delegate as? any TerminalSurfaceScrollbarDelegate)?
.terminalDidUpdateScrollbar(
TerminalScrollbar(
total: payload.total,
offset: payload.offset,
len: payload.len
)
)
default:
TerminalDebugLog.log(
.actions,
"callback action=\(TerminalDebugLog.describe(action.tag))"
)
}
}
func handleClose(processAlive: Bool) {
TerminalDebugLog.log(
.lifecycle,
"callback close processAlive=\(processAlive)"
)
(delegate as? any TerminalSurfaceCloseDelegate)?
.terminalDidClose(processAlive: processAlive)
}
}

View File

@@ -0,0 +1,22 @@
//
// TerminalSessionBackend.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/16.
//
public enum TerminalSessionBackend: Sendable {
case exec
case inMemory(InMemoryTerminalSession)
func isEquivalent(to other: TerminalSessionBackend) -> Bool {
switch (self, other) {
case (.exec, .exec):
true
case let (.inMemory(lhs), .inMemory(rhs)):
lhs === rhs
default:
false
}
}
}