Files
terminalX/vendor/libghostty-spm/Sources/GhosttyTerminal/InMemory/InMemoryTerminalSession.swift
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

292 lines
10 KiB
Swift

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