Files
terminalX/apps/TerminalX/iOS/TmuxController.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

243 lines
9.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import GhosttyTerminal
import TXCore
/// tmux windowMVP 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()
}
}