- 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>
243 lines
9.4 KiB
Swift
243 lines
9.4 KiB
Swift
import Foundation
|
||
import GhosttyTerminal
|
||
import TXCore
|
||
|
||
/// 一个 tmux window(MVP:显示其活动 pane)。每 window 独立终端会话 + surface。
|
||
@MainActor
|
||
final class TmuxWindow: ObservableObject, Identifiable {
|
||
let id: TmuxWindowID
|
||
@Published var title: String
|
||
let state: TerminalViewState
|
||
let session: InMemoryTerminalSession
|
||
let gate: OutputGate
|
||
var activePane: TmuxPaneID?
|
||
|
||
init(id: TmuxWindowID, title: String, onInput: @escaping @Sendable (Data) -> Void) {
|
||
self.id = id
|
||
self.title = title
|
||
let session = InMemoryTerminalSession(
|
||
write: { data in onInput(data) }, // 用户输入 → send-keys(由 controller 处理)
|
||
resize: { _ in }
|
||
)
|
||
let state = TerminalViewState()
|
||
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
|
||
self.session = session
|
||
self.state = state
|
||
self.gate = OutputGate(session: session)
|
||
}
|
||
}
|
||
|
||
/// tmux control-mode 网关:消费 `%`-协议流,把 tmux windows 映射为原生 tab。
|
||
/// MVP:每 window 显示活动 pane;输入经 `send-keys -H`;切 tab 经 `select-window`。
|
||
/// 已简化:多 pane 分屏只显示活动 pane;历史依赖 attach 重绘;命令不做严格应答匹配。
|
||
@MainActor
|
||
final class TmuxController: ObservableObject {
|
||
@Published private(set) var windows: [TmuxWindow] = []
|
||
@Published var activeWindowID: TmuxWindowID?
|
||
|
||
private let parser = TmuxControlParser()
|
||
private var windowByID: [TmuxWindowID: TmuxWindow] = [:]
|
||
private var paneToWindow: [TmuxPaneID: TmuxWindowID] = [:]
|
||
private let sendRaw: @Sendable (Data) -> Void
|
||
var onExit: (() -> Void)?
|
||
|
||
// control-mode 命令应答 FIFO 匹配(attach 场景需主动 list-windows 枚举)。
|
||
private var attachAcked = false
|
||
private enum PendingKind { case ignore, listWindows, listPanes(TmuxWindowID), capturePane(TmuxWindowID) }
|
||
private var pending: [PendingKind] = []
|
||
|
||
init(sendRaw: @escaping @Sendable (Data) -> Void) {
|
||
self.sendRaw = sendRaw
|
||
}
|
||
|
||
var activeWindow: TmuxWindow? {
|
||
guard let id = activeWindowID else { return windows.first }
|
||
return windowByID[id]
|
||
}
|
||
|
||
/// 送入 control-mode 字节流。
|
||
func feed(_ data: Data) {
|
||
for event in parser.feed(data) { apply(event) }
|
||
}
|
||
|
||
// MARK: - 事件处理
|
||
|
||
private func apply(_ event: TmuxEvent) {
|
||
switch event {
|
||
case .windowAdd(let w):
|
||
_ = ensureWindow(w)
|
||
case .windowClose(let w), .unlinkedWindowClose(let w):
|
||
removeWindow(w)
|
||
case .windowRenamed(let w, let name):
|
||
windowByID[w]?.title = name
|
||
case .layoutChange(let w, let layout, _):
|
||
let win = ensureWindow(w)
|
||
if let parsed = try? TmuxLayout.parse(layout) {
|
||
let panes = parsed.paneIDs
|
||
for p in panes { paneToWindow[p] = w }
|
||
if win.activePane == nil { win.activePane = panes.first }
|
||
}
|
||
case .windowPaneChanged(let w, let pane):
|
||
paneToWindow[pane] = w
|
||
windowByID[w]?.activePane = pane
|
||
case .output(let pane, let data):
|
||
if let wid = paneToWindow[pane] {
|
||
windowByID[wid]?.gate.deliver(data)
|
||
}
|
||
case .extendedOutput(let pane, _, let data):
|
||
if let wid = paneToWindow[pane] {
|
||
windowByID[wid]?.gate.deliver(data)
|
||
}
|
||
case .sessionWindowChanged(_, let w):
|
||
if windowByID[w] != nil { activeWindowID = w }
|
||
case .commandResponse(let response):
|
||
handleCommandResponse(response)
|
||
case .exit:
|
||
onExit?()
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
/// control-mode 命令应答。第一个应答是 attach 的隐式块(无对应命令)→ 触发 list-windows 枚举;
|
||
/// 之后按 FIFO 匹配我方发出的命令。
|
||
private func handleCommandResponse(_ r: TmuxCommandResponse) {
|
||
if !attachAcked {
|
||
attachAcked = true
|
||
pending.append(.listWindows)
|
||
sendCommand("list-windows -F \"#{window_id} #{window_name}\"")
|
||
return
|
||
}
|
||
guard !pending.isEmpty else { return }
|
||
switch pending.removeFirst() {
|
||
case .ignore:
|
||
break
|
||
case .listWindows:
|
||
for line in r.lines {
|
||
let parts = line.split(separator: " ", maxSplits: 1)
|
||
guard let first = parts.first, let w = TmuxWindowID.parse(first) else { continue }
|
||
let win = ensureWindow(w)
|
||
if parts.count > 1 { win.title = String(parts[1]) }
|
||
pending.append(.listPanes(w))
|
||
sendCommand("list-panes -t @\(w.raw) -F \"#{pane_id} #{pane_active}\"")
|
||
}
|
||
sendCommand("refresh-client") // 强制重绘 → 当前屏幕以 %output 下发
|
||
pending.append(.ignore)
|
||
case .listPanes(let w):
|
||
for line in r.lines {
|
||
let parts = line.split(separator: " ")
|
||
guard let first = parts.first, let p = TmuxPaneID.parse(first) else { continue }
|
||
paneToWindow[p] = w
|
||
let active = parts.count > 1 && parts[1] == "1"
|
||
if active || windowByID[w]?.activePane == nil {
|
||
windowByID[w]?.activePane = p
|
||
}
|
||
}
|
||
// 抓当前屏幕内容作初始渲染(初始 %output 可能在映射建立前已到达被丢)。
|
||
if let pane = windowByID[w]?.activePane {
|
||
pending.append(.capturePane(w))
|
||
sendCommand("capture-pane -t %\(pane.raw) -p -e -J")
|
||
}
|
||
case .capturePane(let w):
|
||
let screen = r.lines.joined(separator: "\r\n")
|
||
if !screen.isEmpty {
|
||
windowByID[w]?.gate.deliver(Data((screen + "\r\n").utf8))
|
||
}
|
||
}
|
||
}
|
||
|
||
@discardableResult
|
||
private func ensureWindow(_ w: TmuxWindowID) -> TmuxWindow {
|
||
if let existing = windowByID[w] { return existing }
|
||
let win = TmuxWindow(id: w, title: "窗口 \(w.raw)") { [weak self] data in
|
||
self?.enqueueInput(window: w, data: data)
|
||
}
|
||
windowByID[w] = win
|
||
windows.append(win)
|
||
windows.sort { $0.id.raw < $1.id.raw }
|
||
if activeWindowID == nil { activeWindowID = w }
|
||
return win
|
||
}
|
||
|
||
private func removeWindow(_ w: TmuxWindowID) {
|
||
windowByID[w] = nil
|
||
windows.removeAll { $0.id == w }
|
||
paneToWindow = paneToWindow.filter { $0.value != w }
|
||
if activeWindowID == w { activeWindowID = windows.first?.id }
|
||
}
|
||
|
||
// MARK: - 命令 / 输入
|
||
|
||
func sendCommand(_ command: String) {
|
||
sendRaw(Data((command + "\n").utf8))
|
||
}
|
||
|
||
/// 用户在某 window 输入 → send-keys -H 到其活动 pane(非主线程安全:只在主线程调)。
|
||
private nonisolated func enqueueInput(window w: TmuxWindowID, data: Data) {
|
||
Task { @MainActor in
|
||
guard let pane = self.windowByID[w]?.activePane else { return }
|
||
let hex = data.map { String(format: "%02x", $0) }.joined(separator: " ")
|
||
self.sendCommand("send-keys -t %\(pane.raw) -H \(hex)")
|
||
}
|
||
}
|
||
|
||
func selectWindow(_ w: TmuxWindowID) {
|
||
activeWindowID = w
|
||
sendCommand("select-window -t @\(w.raw)")
|
||
}
|
||
|
||
func newWindow() { sendCommand("new-window") }
|
||
func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") }
|
||
|
||
/// 某 window 的 surface 就绪 → 放行其缓冲输出。
|
||
func markWindowReady(_ w: TmuxWindowID) { windowByID[w]?.gate.markReady() }
|
||
}
|
||
|
||
/// 传输字节分流器:检测 `tmux -CC` 进入 DCS 前 → 原始终端;进入后 → tmux 网关。
|
||
/// 在传输后台线程调用;原始字节走线程安全的 rawGate,网关字节经闭包 hop 到主线程。
|
||
final class TmuxRouter: @unchecked Sendable {
|
||
private let lock = NSLock()
|
||
private let rawGate: OutputGate
|
||
private let enterGateway: @Sendable ([UInt8]) -> Void
|
||
private let gatewayBytes: @Sendable ([UInt8]) -> Void
|
||
private var carry: [UInt8] = []
|
||
private var inGateway = false
|
||
|
||
init(rawGate: OutputGate,
|
||
enterGateway: @escaping @Sendable ([UInt8]) -> Void,
|
||
gatewayBytes: @escaping @Sendable ([UInt8]) -> Void) {
|
||
self.rawGate = rawGate
|
||
self.enterGateway = enterGateway
|
||
self.gatewayBytes = gatewayBytes
|
||
}
|
||
|
||
func feed(_ data: Data) {
|
||
lock.lock(); defer { lock.unlock() }
|
||
if inGateway {
|
||
gatewayBytes(Array(data))
|
||
return
|
||
}
|
||
let buf = carry + Array(data)
|
||
if let idx = TmuxControlSequence.find(TmuxControlSequence.enter, in: buf) {
|
||
let before = Array(buf[0 ..< idx])
|
||
let after = Array(buf[(idx + TmuxControlSequence.enter.count)...])
|
||
if !before.isEmpty { rawGate.deliver(Data(before)) }
|
||
inGateway = true
|
||
carry = []
|
||
enterGateway(after)
|
||
} else {
|
||
// 仅当结尾是哨兵部分前缀时滞留,避免延迟正常输出。
|
||
let k = TmuxControlSequence.partialTrailingMatchLength(TmuxControlSequence.enter, in: buf)
|
||
if buf.count > k {
|
||
rawGate.deliver(Data(buf[0 ..< (buf.count - k)]))
|
||
}
|
||
carry = k > 0 ? Array(buf.suffix(k)) : []
|
||
}
|
||
}
|
||
|
||
/// tmux 退出 → 回原始模式。
|
||
func reset() {
|
||
lock.lock(); inGateway = false; carry = []; lock.unlock()
|
||
}
|
||
}
|