- 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>
305 lines
7.8 KiB
Swift
305 lines
7.8 KiB
Swift
import Foundation
|
|
import GhosttyKit
|
|
|
|
public struct TerminalDebugCategory: OptionSet, Sendable {
|
|
public let rawValue: UInt16
|
|
|
|
public init(rawValue: UInt16) {
|
|
self.rawValue = rawValue
|
|
}
|
|
|
|
public static let lifecycle = Self(rawValue: 1 << 0)
|
|
public static let metrics = Self(rawValue: 1 << 1)
|
|
public static let input = Self(rawValue: 1 << 2)
|
|
public static let output = Self(rawValue: 1 << 3)
|
|
public static let ime = Self(rawValue: 1 << 4)
|
|
public static let actions = Self(rawValue: 1 << 5)
|
|
public static let render = Self(rawValue: 1 << 6)
|
|
|
|
public static let standard: Self = [
|
|
.lifecycle,
|
|
.metrics,
|
|
.input,
|
|
.output,
|
|
.ime,
|
|
.actions,
|
|
]
|
|
|
|
public static let all: Self = [
|
|
.standard,
|
|
.render,
|
|
]
|
|
}
|
|
|
|
public enum TerminalDebugLog {
|
|
public typealias Sink = @Sendable (String) -> Void
|
|
|
|
private struct Snapshot {
|
|
let isEnabled: Bool
|
|
let categories: TerminalDebugCategory
|
|
let sink: Sink
|
|
}
|
|
|
|
private final class Store: @unchecked Sendable {
|
|
let lock = NSLock()
|
|
var isEnabled = false
|
|
var categories: TerminalDebugCategory = .standard
|
|
var sink: Sink = { message in
|
|
Swift.print(message)
|
|
}
|
|
}
|
|
|
|
private static let store = Store()
|
|
|
|
public static var isEnabled: Bool {
|
|
get {
|
|
withSnapshot { $0.isEnabled }
|
|
}
|
|
set {
|
|
updateStore { $0.isEnabled = newValue }
|
|
}
|
|
}
|
|
|
|
public static var categories: TerminalDebugCategory {
|
|
get {
|
|
withSnapshot { $0.categories }
|
|
}
|
|
set {
|
|
updateStore { $0.categories = newValue }
|
|
}
|
|
}
|
|
|
|
public static var sink: Sink {
|
|
get {
|
|
withSnapshot { $0.sink }
|
|
}
|
|
set {
|
|
updateStore { $0.sink = newValue }
|
|
}
|
|
}
|
|
|
|
public static func enable(_ categories: TerminalDebugCategory = .standard) {
|
|
updateStore {
|
|
$0.isEnabled = true
|
|
$0.categories = categories
|
|
}
|
|
}
|
|
|
|
public static func disable() {
|
|
updateStore { $0.isEnabled = false }
|
|
}
|
|
|
|
static func log(
|
|
_ category: TerminalDebugCategory,
|
|
_ message: @autoclosure () -> String
|
|
) {
|
|
let snapshot = snapshot()
|
|
guard snapshot.isEnabled else { return }
|
|
guard snapshot.categories.contains(category) else { return }
|
|
snapshot.sink(
|
|
"[GhosttyTerminal][\(timestamp())][\(label(for: category))] \(message())"
|
|
)
|
|
}
|
|
|
|
static func describe(_ string: String?, limit: Int = 96) -> String {
|
|
guard let string else { return "nil" }
|
|
return "\"\(escaped(string, limit: limit))\""
|
|
}
|
|
|
|
static func describe(_ data: Data, limit: Int = 48) -> String {
|
|
let preview = data.prefix(limit)
|
|
let text = String(decoding: preview, as: UTF8.self)
|
|
let hex = preview.map { String(format: "%02X", $0) }.joined(separator: " ")
|
|
let suffix = data.count > limit ? "..." : ""
|
|
return "bytes=\(data.count) utf8=\"\(escaped(text, limit: limit))\(suffix)\" hex=\(hex)\(suffix)"
|
|
}
|
|
|
|
static func describe(_ range: NSRange) -> String {
|
|
"{location=\(range.location), length=\(range.length)}"
|
|
}
|
|
|
|
static func describe(_ action: ghostty_input_action_e) -> String {
|
|
switch action {
|
|
case GHOSTTY_ACTION_PRESS:
|
|
"press"
|
|
case GHOSTTY_ACTION_RELEASE:
|
|
"release"
|
|
case GHOSTTY_ACTION_REPEAT:
|
|
"repeat"
|
|
default:
|
|
"unknown(\(action.rawValue))"
|
|
}
|
|
}
|
|
|
|
static func describe(_ state: ghostty_input_mouse_state_e) -> String {
|
|
switch state {
|
|
case GHOSTTY_MOUSE_PRESS:
|
|
"press"
|
|
case GHOSTTY_MOUSE_RELEASE:
|
|
"release"
|
|
default:
|
|
"unknown(\(state.rawValue))"
|
|
}
|
|
}
|
|
|
|
static func describe(_ tag: ghostty_action_tag_e) -> String {
|
|
switch tag {
|
|
case GHOSTTY_ACTION_CELL_SIZE:
|
|
"cell_size"
|
|
case GHOSTTY_ACTION_SET_TITLE:
|
|
"set_title"
|
|
case GHOSTTY_ACTION_SET_TAB_TITLE:
|
|
"set_tab_title"
|
|
case GHOSTTY_ACTION_RING_BELL:
|
|
"ring_bell"
|
|
case GHOSTTY_ACTION_RENDER:
|
|
"render"
|
|
case GHOSTTY_ACTION_CONFIG_CHANGE:
|
|
"config_change"
|
|
case GHOSTTY_ACTION_RELOAD_CONFIG:
|
|
"reload_config"
|
|
default:
|
|
"tag(\(tag.rawValue))"
|
|
}
|
|
}
|
|
|
|
private static func withSnapshot<T>(
|
|
_ body: (Snapshot) -> T
|
|
) -> T {
|
|
body(snapshot())
|
|
}
|
|
|
|
private static func snapshot() -> Snapshot {
|
|
store.lock.lock()
|
|
defer { store.lock.unlock() }
|
|
return Snapshot(
|
|
isEnabled: store.isEnabled,
|
|
categories: store.categories,
|
|
sink: store.sink
|
|
)
|
|
}
|
|
|
|
private static func updateStore(
|
|
_ body: (Store) -> Void
|
|
) {
|
|
store.lock.lock()
|
|
defer { store.lock.unlock() }
|
|
body(store)
|
|
}
|
|
|
|
private static func label(
|
|
for category: TerminalDebugCategory
|
|
) -> String {
|
|
switch category {
|
|
case .lifecycle:
|
|
"lifecycle"
|
|
case .metrics:
|
|
"metrics"
|
|
case .input:
|
|
"input"
|
|
case .output:
|
|
"output"
|
|
case .ime:
|
|
"ime"
|
|
case .actions:
|
|
"actions"
|
|
case .render:
|
|
"render"
|
|
default:
|
|
"debug"
|
|
}
|
|
}
|
|
|
|
private static func timestamp() -> String {
|
|
String(format: "%.3f", Date().timeIntervalSince1970)
|
|
}
|
|
|
|
private static func escaped(
|
|
_ string: String,
|
|
limit: Int
|
|
) -> String {
|
|
var result = ""
|
|
var emitted = 0
|
|
for scalar in string.unicodeScalars {
|
|
guard emitted < limit else {
|
|
result.append("...")
|
|
break
|
|
}
|
|
|
|
switch scalar.value {
|
|
case 0x09:
|
|
result.append("\\t")
|
|
case 0x0A:
|
|
result.append("\\n")
|
|
case 0x0D:
|
|
result.append("\\r")
|
|
case 0x1B:
|
|
result.append("\\e")
|
|
case 0x20 ..< 0x7F:
|
|
result.append(Character(scalar))
|
|
default:
|
|
if scalar.value < 0x20 || scalar.value == 0x7F {
|
|
result.append(String(format: "\\u{%02X}", Int(scalar.value)))
|
|
} else {
|
|
result.append(Character(scalar))
|
|
}
|
|
}
|
|
|
|
emitted += 1
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
extension TerminalGridMetrics {
|
|
var debugSummary: String {
|
|
"cols=\(columns) rows=\(rows) pixels=\(widthPixels)x\(heightPixels) cell=\(cellWidthPixels)x\(cellHeightPixels)"
|
|
}
|
|
}
|
|
|
|
extension TerminalViewportMetrics {
|
|
var debugSummary: String {
|
|
"\(surfaceSize.debugSummary) scale=\(String(format: "%.2f", scale))"
|
|
}
|
|
}
|
|
|
|
extension TerminalSessionBackend {
|
|
var debugSummary: String {
|
|
switch self {
|
|
case .exec:
|
|
"exec"
|
|
case .inMemory:
|
|
"in-memory"
|
|
}
|
|
}
|
|
}
|
|
|
|
extension TerminalSurfaceOptions {
|
|
var debugSummary: String {
|
|
let fontSizeDescription = fontSize.map { String($0) } ?? "nil"
|
|
return "backend=\(backend.debugSummary) fontSize=\(fontSizeDescription) workingDirectory=\(workingDirectory ?? "nil") context=\(context.debugSummary)"
|
|
}
|
|
}
|
|
|
|
extension TerminalSurfaceContext {
|
|
var debugSummary: String {
|
|
switch self {
|
|
case .window:
|
|
"window"
|
|
case .split:
|
|
"split"
|
|
}
|
|
}
|
|
}
|
|
|
|
extension TerminalHardwareKeyDelivery {
|
|
var debugSummary: String {
|
|
switch self {
|
|
case let .ghostty(key):
|
|
"ghostty(\(key.rawValue))"
|
|
case let .data(data):
|
|
"data(\(TerminalDebugLog.describe(data)))"
|
|
}
|
|
}
|
|
}
|