feat: tmux 多 pane 分屏(pane-per-surface + refresh-client -C 尺寸协调)

- TmuxController 重构为 pane-per-surface:TmuxPaneSurface(session+state+gate/pane)
  + TmuxWindow(panes 字典/visibleLayout 渲染/fullLayout diff) + surfaceByPane O(1) 路由
- 尺寸走路线 b:attach 发 refresh-client -C WxH 让 tmux 布局,pane surface 尺寸取 layout
  rect(%output 按 tmux pane 宽高排版,一致才不换行/清屏错乱);surface resize 只断言不反向驱动
- applyLayout reconcile(增删留、复用不重建);新 pane capture-pane %end 前丢弃 %output
- 输入绑 pane→send-keys -t %self;tap→select-pane;ContentView 按 rect 绝对定位分屏 + 焦点边框
- 前置修复:tmux 网关字节改 TmuxByteChannel(AsyncStream 单消费者)保序,防多 pane 打碎协议
- 验证(192.168.9.199 直连 tmux -CC attach):左右 pane 各渲染 LEFT/RIGHT 输出、实时、无错乱

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kid
2026-07-24 17:30:17 +08:00
parent 866df1a323
commit 9a27338222
5 changed files with 239 additions and 82 deletions

View File

@@ -77,6 +77,8 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具
- **Swift6 `SendNonSendable` pass 可能崩溃**(`pthread_create`+`Unmanaged` 上下文+C 入口的组合):改用 `Foundation.Thread`+线程内 `pthread_self()` 规避(见 `MoshSession`)。 - **Swift6 `SendNonSendable` pass 可能崩溃**(`pthread_create`+`Unmanaged` 上下文+C 入口的组合):改用 `Foundation.Thread`+线程内 `pthread_self()` 规避(见 `MoshSession`)。
- GhosttyKit 的 `InMemoryTerminalSession.receive` 在 surface 未 attach 时**丢弃**写入 → 用 `OutputGate` 缓冲到 `surfaceSize != nil` 再 flush。 - GhosttyKit 的 `InMemoryTerminalSession.receive` 在 surface 未 attach 时**丢弃**写入 → 用 `OutputGate` 缓冲到 `surfaceSize != nil` 再 flush。
- tmux `-CC attach` **不推 %window-add** → 必须主动 `list-windows` 枚举。 - tmux `-CC attach` **不推 %window-add** → 必须主动 `list-windows` 枚举。
- **tmux 多 pane 尺寸走"路线 b"**:app 按全屏格子发 `refresh-client -C WxH` 让 tmux 布局,pane surface 尺寸严格取 layout rect(%output 按 tmux pane 宽高排版,二者不一致必换行/清屏错乱);pane surface 的 resize 回调**只断言不反向驱动 tmux**(防反馈环)。diff 用 full layout、渲染用 visible layout(zoom 免费);SwiftUI identity 只用 paneID(布局变不重建 surface)。新 pane 在 `capture-pane` %end 前丢弃其 %output(tmux 单线程串行保证快照不缺不重)。
- **tmux 网关字节必须单消费者保序**:多个 `Task{@MainActor}` 到主线程顺序无保证,多 pane 高吞吐会打碎 % 协议 → 用 `TmuxByteChannel`(AsyncStream) 单消费者。
- SourceKit "No such module" 是索引噪声,**以 `swift build`/`xcodebuild` 为准**。 - SourceKit "No such module" 是索引噪声,**以 `swift build`/`xcodebuild` 为准**。
- 密钥/密码**绝不写进仓库**;测试主机凭据由用户即时提供。 - 密钥/密码**绝不写进仓库**;测试主机凭据由用户即时提供。
- 提交/推送 git 只在用户明确要求时(当前已 `git init`,尚无 commit)。 - 提交/推送 git 只在用户明确要求时(当前已 `git init`,尚无 commit)。

View File

@@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import GhosttyTerminal import GhosttyTerminal
import TXCore
struct ContentView: View { struct ContentView: View {
@StateObject private var model = SSHTerminalModel() @StateObject private var model = SSHTerminalModel()
@@ -162,19 +163,75 @@ struct TmuxTabChip: View {
} }
} }
/// 一个 window:按 tmux 可见布局树把多 pane 绝对定位为分屏(每 pane 一 surface)。
struct TmuxWindowView: View { struct TmuxWindowView: View {
@ObservedObject var window: TmuxWindow @ObservedObject var window: TmuxWindow
let controller: TmuxController let controller: TmuxController
var body: some View {
GeometryReader { geo in
if let layout = window.visibleLayout {
let root = layout.root.rect
let cw = geo.size.width / CGFloat(max(1, root.width))
let ch = geo.size.height / CGFloat(max(1, root.height))
ZStack(alignment: .topLeading) {
ForEach(leaves(layout.root), id: \.pane.raw) { item in
if let surface = window.panes[item.pane] {
TmuxPaneView(
surface: surface,
isActive: window.activePane == item.pane,
onTap: { controller.selectPane(item.pane, in: window.id) },
onReady: { controller.markPaneReady(item.pane) }
)
.frame(width: CGFloat(item.rect.width) * cw,
height: CGFloat(item.rect.height) * ch)
.position(x: (CGFloat(item.rect.x) + CGFloat(item.rect.width) / 2) * cw,
y: (CGFloat(item.rect.y) + CGFloat(item.rect.height) / 2) * ch)
}
}
}
} else {
Color.clear
}
}
}
/// 收集布局树叶子(pane + 绝对 rect),identity 只用 paneID → 布局变化不重建 surface view。
private func leaves(_ node: TmuxLayout.Node) -> [(pane: TmuxPaneID, rect: TmuxLayout.Rect)] {
switch node {
case .leaf(let p, let r): return [(p, r)]
case .horizontal(let cs, _), .vertical(let cs, _): return cs.flatMap(leaves)
}
}
}
/// 单个 pane 的终端面 + 焦点边框 + 点击选中。
struct TmuxPaneView: View {
@ObservedObject var surface: TmuxPaneSurface
let isActive: Bool
let onTap: () -> Void
let onReady: () -> Void
@FocusState private var focused: Bool @FocusState private var focused: Bool
var body: some View { var body: some View {
TerminalSurfaceView(context: window.state) TerminalSurfaceView(context: surface.state)
.terminalFocusOnAppear($focused) .terminalFocusOnAppear($focused)
.overlay(
Rectangle().stroke(isActive ? Color.accentColor : Color.gray.opacity(0.35),
lineWidth: isActive ? 2 : 0.5)
)
// 非活动 pane 叠透明 tap-catcher(TerminalSurfaceView 会吞触摸);活动 pane 直通终端。
.overlay {
if !isActive {
Color.black.opacity(0.04).contentShape(Rectangle()).onTapGesture { onTap() }
}
}
.onChange(of: isActive) { _, active in focused = active }
.task { .task {
for _ in 0 ..< 300 where window.state.surfaceSize == nil { for _ in 0 ..< 300 where surface.state.surfaceSize == nil {
try? await Task.sleep(nanoseconds: 30_000_000) try? await Task.sleep(nanoseconds: 30_000_000)
} }
controller.markWindowReady(window.id) onReady()
} }
} }
} }

View File

@@ -111,6 +111,27 @@ final class OutputGate: @unchecked Sendable {
} }
} }
/// tmux 网关字节的有序事件(enter 携初始字节 / bytes 后续)。经单一 AsyncStream 传到主线程,
/// 杜绝"每 chunk 起独立 Task"导致的乱序(多 pane 高吞吐会打碎 % 协议解析)。
enum TmuxByteEvent: Sendable { case enter([UInt8]); case bytes([UInt8]) }
/// 线程安全承载 tmux 字节通道的 continuation(传输后台线程 yield,主线程单消费者取用)。
final class TmuxByteChannel: @unchecked Sendable {
let stream: AsyncStream<TmuxByteEvent>
private let cont: AsyncStream<TmuxByteEvent>.Continuation
init() { (stream, cont) = AsyncStream.makeStream() }
func enter(_ b: [UInt8]) { cont.yield(.enter(b)) }
func bytes(_ b: [UInt8]) { cont.yield(.bytes(b)) }
}
/// 线程安全记录全屏终端格子数(tmux attach 时据此 `refresh-client -C`)。
final class GridBox: @unchecked Sendable {
private let lock = NSLock()
private var v: (cols: Int, rows: Int) = (80, 24)
var value: (cols: Int, rows: Int) { lock.lock(); defer { lock.unlock() }; return v }
func set(cols: Int, rows: Int) { lock.lock(); v = (max(1, cols), max(1, rows)); lock.unlock() }
}
/// M0 SSH 终端会话模型:SSHSession(libssh2) ⇄ GhosttyKit in-memory 终端, /// M0 SSH 终端会话模型:SSHSession(libssh2) ⇄ GhosttyKit in-memory 终端,
/// 由 TXCore.SessionMachine 编排生命周期(断线指数退避自动重连、后台冻结/前台恢复)。 /// 由 TXCore.SessionMachine 编排生命周期(断线指数退避自动重连、后台冻结/前台恢复)。
@MainActor @MainActor
@@ -152,9 +173,14 @@ final class SSHTerminalModel: ObservableObject {
private var bgTask: UIBackgroundTaskIdentifier = .invalid private var bgTask: UIBackgroundTaskIdentifier = .invalid
#endif #endif
private let tmuxChannel = TmuxByteChannel()
/// 全屏终端格子数(raw 模式 surface resize 时记录;tmux attach 用于 refresh-client -C)。
let screenGrid = GridBox()
init() { init() {
let holder = TransportHolder() let holder = TransportHolder()
let moshHolder = MoshHolder() let moshHolder = MoshHolder()
let grid = screenGrid
let session = InMemoryTerminalSession( let session = InMemoryTerminalSession(
// 输入/resize:mosh 激活时路由到 mosh,否则走 SSH transport。 // 输入/resize:mosh 激活时路由到 mosh,否则走 SSH transport。
write: { data in write: { data in
@@ -162,6 +188,7 @@ final class SSHTerminalModel: ObservableObject {
else { holder.transport?.send(data) } else { holder.transport?.send(data) }
}, },
resize: { vp in resize: { vp in
grid.set(cols: Int(vp.columns), rows: Int(vp.rows)) // 记录全屏格子供 tmux
if let m = moshHolder.session { m.resize(cols: Int(vp.columns), rows: Int(vp.rows)) } if let m = moshHolder.session { m.resize(cols: Int(vp.columns), rows: Int(vp.rows)) }
else { holder.transport?.resize(cols: vp.columns, rows: vp.rows) } else { holder.transport?.resize(cols: vp.columns, rows: vp.rows) }
} }
@@ -173,6 +200,18 @@ final class SSHTerminalModel: ObservableObject {
self.session = session self.session = session
self.state = state self.state = state
self.gate = OutputGate(session: session) self.gate = OutputGate(session: session)
// tmux 网关字节:单消费者按序处理(enter 建 controller+喂初始,bytes 续喂)。
let ch = tmuxChannel
Task { @MainActor [weak self] in
for await ev in ch.stream {
guard let self else { continue }
switch ev {
case .enter(let initial): self.enterTmux(initialBytes: initial)
case .bytes(let b): self.tmuxController?.feed(Data(b))
}
}
}
} }
// MARK: - 公开入口 // MARK: - 公开入口
@@ -285,18 +324,18 @@ final class SSHTerminalModel: ObservableObject {
private lazy var tmuxRouter = TmuxRouter( private lazy var tmuxRouter = TmuxRouter(
rawGate: gate, rawGate: gate,
enterGateway: { [weak self] after in // 经有序通道传主线程(enter 先于后续 bytes,单消费者保序)。
Task { @MainActor in self?.enterTmux(initialBytes: after) } enterGateway: { [tmuxChannel] after in tmuxChannel.enter(after) },
}, gatewayBytes: { [tmuxChannel] bytes in tmuxChannel.bytes(bytes) }
gatewayBytes: { [weak self] bytes in
Task { @MainActor in self?.tmuxController?.feed(Data(bytes)) }
}
) )
private func enterTmux(initialBytes: [UInt8]) { private func enterTmux(initialBytes: [UInt8]) {
let holder = self.holder let holder = self.holder
let controller = TmuxController(sendRaw: { data in holder.transport?.send(data) }) let controller = TmuxController(sendRaw: { data in holder.transport?.send(data) })
controller.onExit = { [weak self] in Task { @MainActor in self?.exitTmux() } } controller.onExit = { [weak self] in Task { @MainActor in self?.exitTmux() } }
// 告知全屏格子数 → attach 时 refresh-client -C,让 tmux 按此尺寸布局。
let g = screenGrid.value
controller.setClientSize(cols: g.cols, rows: g.rows)
tmuxController = controller tmuxController = controller
if !initialBytes.isEmpty { controller.feed(Data(initialBytes)) } if !initialBytes.isEmpty { controller.feed(Data(initialBytes)) }
} }

View File

@@ -2,22 +2,24 @@ import Foundation
import GhosttyTerminal import GhosttyTerminal
import TXCore import TXCore
/// 一个 tmux window(MVP:显示其活动 pane)。每 window 独立终端会话 + surface。 /// 一个 pane 的独立终端面:session + surface state + 输出闸门。
@MainActor @MainActor
final class TmuxWindow: ObservableObject, Identifiable { final class TmuxPaneSurface: ObservableObject, Identifiable {
let id: TmuxWindowID let id: TmuxPaneID
@Published var title: String
let state: TerminalViewState let state: TerminalViewState
let session: InMemoryTerminalSession let session: InMemoryTerminalSession
let gate: OutputGate let gate: OutputGate
var activePane: TmuxPaneID? /// 来自 layout rect 的字符格子(cols×rows),供渲染 frame 与断言参考。
var grid: (cols: Int, rows: Int)
/// 初始 `capture-pane` 应答到达前丢弃该 pane 的 %output(避免与快照重复);tmux 单线程串行保证正确。
var awaitingCapture = true
init(id: TmuxWindowID, title: String, onInput: @escaping @Sendable (Data) -> Void) { init(id: TmuxPaneID, cols: Int, rows: Int, onInput: @escaping @Sendable (Data) -> Void) {
self.id = id self.id = id
self.title = title self.grid = (cols, rows)
let session = InMemoryTerminalSession( let session = InMemoryTerminalSession(
write: { data in onInput(data) }, // 用户输入 → send-keys(由 controller 处理) write: { data in onInput(data) }, // 输入直接绑到本 pane → send-keys -t %self
resize: { _ in } resize: { _ in } // tmux 模式:surface resize 不反向驱动 tmux(防反馈环)
) )
let state = TerminalViewState() let state = TerminalViewState()
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session)) state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
@@ -27,9 +29,24 @@ final class TmuxWindow: ObservableObject, Identifiable {
} }
} }
/// tmux control-mode 网关:消费 `%`-协议流,把 tmux windows 映射为原生 tab。 /// 一个 tmux window:持有多 pane surface + 布局树(渲染用可见树 / diff 用完整树)。
/// MVP:每 window 显示活动 pane;输入经 `send-keys -H`;切 tab 经 `select-window`。 @MainActor
/// 已简化:多 pane 分屏只显示活动 pane;历史依赖 attach 重绘;命令不做严格应答匹配。 final class TmuxWindow: ObservableObject, Identifiable {
let id: TmuxWindowID
@Published var title: String
@Published var panes: [TmuxPaneID: TmuxPaneSurface] = [:]
/// 渲染用:zoom 时只含被 zoom 的 pane。
@Published var visibleLayout: TmuxLayout?
/// pane 集合 diff 用:完整树。
var fullLayout: TmuxLayout?
@Published var activePane: TmuxPaneID?
init(id: TmuxWindowID, title: String) { self.id = id; self.title = title }
}
/// tmux control-mode 网关:消费 `%`-协议流,把 window 映射为原生 tab、window 内多 pane 映射为分屏。
/// 尺寸走"路线 b":app 按全屏格子发 `refresh-client -C WxH` 让 tmux 布局,pane surface 尺寸严格取
/// layout rect(%output 按 tmux pane 宽高排版,二者一致才不换行/清屏错乱)。
@MainActor @MainActor
final class TmuxController: ObservableObject { final class TmuxController: ObservableObject {
@Published private(set) var windows: [TmuxWindow] = [] @Published private(set) var windows: [TmuxWindow] = []
@@ -37,19 +54,25 @@ final class TmuxController: ObservableObject {
private let parser = TmuxControlParser() private let parser = TmuxControlParser()
private var windowByID: [TmuxWindowID: TmuxWindow] = [:] private var windowByID: [TmuxWindowID: TmuxWindow] = [:]
private var surfaceByPane: [TmuxPaneID: TmuxPaneSurface] = [:] // %output O(1) 按 pane 路由
private var paneToWindow: [TmuxPaneID: TmuxWindowID] = [:] private var paneToWindow: [TmuxPaneID: TmuxWindowID] = [:]
private let sendRaw: @Sendable (Data) -> Void private let sendRaw: @Sendable (Data) -> Void
var onExit: (() -> Void)? var onExit: (() -> Void)?
// control-mode 命令应答 FIFO 匹配(attach 场景需主动 list-windows 枚举)。 private var clientCols = 80, clientRows = 24
// control-mode 命令应答 FIFO 匹配。
private var attachAcked = false private var attachAcked = false
private enum PendingKind { case ignore, listWindows, listPanes(TmuxWindowID), capturePane(TmuxWindowID) } private enum PendingKind { case ignore, listWindows, capturePane(TmuxPaneID) }
private var pending: [PendingKind] = [] private var pending: [PendingKind] = []
init(sendRaw: @escaping @Sendable (Data) -> Void) { init(sendRaw: @escaping @Sendable (Data) -> Void) {
self.sendRaw = sendRaw self.sendRaw = sendRaw
} }
/// 由 model 在 attach 前告知全屏格子(tmux 据此 refresh-client -C 布局)。
func setClientSize(cols: Int, rows: Int) { clientCols = max(1, cols); clientRows = max(1, rows) }
var activeWindow: TmuxWindow? { var activeWindow: TmuxWindow? {
guard let id = activeWindowID else { return windows.first } guard let id = activeWindowID else { return windows.first }
return windowByID[id] return windowByID[id]
@@ -65,29 +88,20 @@ final class TmuxController: ObservableObject {
private func apply(_ event: TmuxEvent) { private func apply(_ event: TmuxEvent) {
switch event { switch event {
case .windowAdd(let w): case .windowAdd(let w):
_ = ensureWindow(w) _ = ensureWindow(w) // 布局稍后经 %layout-change 到来
case .windowClose(let w), .unlinkedWindowClose(let w): case .windowClose(let w), .unlinkedWindowClose(let w):
removeWindow(w) removeWindow(w)
case .windowRenamed(let w, let name): case .windowRenamed(let w, let name):
windowByID[w]?.title = name windowByID[w]?.title = name
case .layoutChange(let w, let layout, _): case .layoutChange(let w, let layout, let visible):
let win = ensureWindow(w) applyLayout(window: w, full: layout, visible: visible ?? layout)
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): case .windowPaneChanged(let w, let pane):
paneToWindow[pane] = w paneToWindow[pane] = w
windowByID[w]?.activePane = pane windowByID[w]?.activePane = pane // 幂等:仅记录,不反向发 select-pane
case .output(let pane, let data): case .output(let pane, let data):
if let wid = paneToWindow[pane] { routeOutput(pane, data)
windowByID[wid]?.gate.deliver(data)
}
case .extendedOutput(let pane, _, let data): case .extendedOutput(let pane, _, let data):
if let wid = paneToWindow[pane] { routeOutput(pane, data)
windowByID[wid]?.gate.deliver(data)
}
case .sessionWindowChanged(_, let w): case .sessionWindowChanged(_, let w):
if windowByID[w] != nil { activeWindowID = w } if windowByID[w] != nil { activeWindowID = w }
case .commandResponse(let response): case .commandResponse(let response):
@@ -99,13 +113,67 @@ final class TmuxController: ObservableObject {
} }
} }
/// control-mode 命令应答。第一个应答是 attach 的隐式块(无对应命令)→ 触发 list-windows 枚举; private func routeOutput(_ pane: TmuxPaneID, _ data: Data) {
/// 之后按 FIFO 匹配我方发出的命令。 guard let s = surfaceByPane[pane] else { return }
if s.awaitingCapture { return } // capture 快照前的输出丢弃(快照已含)
s.gate.deliver(data)
}
/// 解析 layout → reconcile pane surface(增/删/留),设置渲染用可见树。
private func applyLayout(window w: TmuxWindowID, full: String, visible: String) {
let win = ensureWindow(w)
guard let fullTree = try? TmuxLayout.parse(full) else { return }
let visTree = (try? TmuxLayout.parse(visible)) ?? fullTree
let newPanes = Set(fullTree.paneIDs)
// 删除:不在新完整树里的 pane。
for p in Array(win.panes.keys) where !newPanes.contains(p) {
win.panes[p] = nil; surfaceByPane[p] = nil; paneToWindow[p] = nil
}
// 新增:为完整树里的所有 pane 建 surface(保内容跨 zoom);grid 取可见树 rect(渲染尺寸)。
walkLeaves(fullTree.root) { pane, rect in
ensurePane(pane, window: w, cols: rect.width, rows: rect.height)
}
walkLeaves(visTree.root) { pane, rect in
surfaceByPane[pane]?.grid = (rect.width, rect.height)
}
win.fullLayout = fullTree
win.visibleLayout = visTree // 单次发布 → SwiftUI 单帧更新,无闪烁
if win.activePane == nil || !newPanes.contains(win.activePane!) {
win.activePane = fullTree.paneIDs.first
}
}
private func walkLeaves(_ node: TmuxLayout.Node, _ visit: (TmuxPaneID, TmuxLayout.Rect) -> Void) {
switch node {
case .leaf(let p, let r): visit(p, r)
case .horizontal(let cs, _), .vertical(let cs, _): cs.forEach { walkLeaves($0, visit) }
}
}
private func ensurePane(_ p: TmuxPaneID, window w: TmuxWindowID, cols: Int, rows: Int) {
paneToWindow[p] = w
guard let win = windowByID[w] else { return }
if let existing = surfaceByPane[p] { existing.grid = (cols, rows); return } // 复用,不重建
let surface = TmuxPaneSurface(id: p, cols: cols, rows: rows) { [weak self] data in
self?.sendKeys(pane: p, data: data)
}
surfaceByPane[p] = surface
win.panes[p] = surface
// 抓初始屏(capture %end 后放行 %output)。
pending.append(.capturePane(p))
sendCommand("capture-pane -t %\(p.raw) -p -e -J")
}
/// control-mode 命令应答。第一个应答是 attach 隐式块 → 设尺寸 + 枚举 windows;之后按 FIFO 匹配。
private func handleCommandResponse(_ r: TmuxCommandResponse) { private func handleCommandResponse(_ r: TmuxCommandResponse) {
if !attachAcked { if !attachAcked {
attachAcked = true attachAcked = true
// 先设客户端尺寸(tmux 据此布局),再枚举——枚举到的 layout 已是我方尺寸,避免先渲一帧再重排的闪跳。
sendCommand("refresh-client -C \(clientCols)x\(clientRows)")
pending.append(.ignore)
sendCommand("list-windows -F \"#{window_id}\t#{window_name}\t#{window_active}\t#{window_layout}\t#{window_visible_layout}\"")
pending.append(.listWindows) pending.append(.listWindows)
sendCommand("list-windows -F \"#{window_id} #{window_name}\"")
return return
} }
guard !pending.isEmpty else { return } guard !pending.isEmpty else { return }
@@ -114,34 +182,21 @@ final class TmuxController: ObservableObject {
break break
case .listWindows: case .listWindows:
for line in r.lines { for line in r.lines {
let parts = line.split(separator: " ", maxSplits: 1) let parts = line.split(separator: "\t", omittingEmptySubsequences: false)
guard let first = parts.first, let w = TmuxWindowID.parse(first) else { continue } guard parts.count >= 5, let w = TmuxWindowID.parse(parts[0]) else { continue }
let win = ensureWindow(w) let win = ensureWindow(w)
if parts.count > 1 { win.title = String(parts[1]) } win.title = String(parts[1])
pending.append(.listPanes(w)) if parts[2] == "1" { activeWindowID = w }
sendCommand("list-panes -t @\(w.raw) -F \"#{pane_id} #{pane_active}\"") applyLayout(window: w, full: String(parts[3]), visible: String(parts[4]))
} }
sendCommand("refresh-client") // 强制重绘 → 当前屏幕以 %output 下发 case .capturePane(let p):
pending.append(.ignore) let snapshot = r.lines.joined(separator: "\r\n")
case .listPanes(let w): if let s = surfaceByPane[p] {
for line in r.lines { if !snapshot.isEmpty {
let parts = line.split(separator: " ") // 归位后铺快照(不尾随换行,避免滚屏)。
guard let first = parts.first, let p = TmuxPaneID.parse(first) else { continue } s.gate.deliver(Data(("\u{1b}[H" + snapshot).utf8))
paneToWindow[p] = w
let active = parts.count > 1 && parts[1] == "1"
if active || windowByID[w]?.activePane == nil {
windowByID[w]?.activePane = p
} }
} s.awaitingCapture = false // 放行后续 %output
// 抓当前屏幕内容作初始渲染(初始 %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))
} }
} }
} }
@@ -149,9 +204,7 @@ final class TmuxController: ObservableObject {
@discardableResult @discardableResult
private func ensureWindow(_ w: TmuxWindowID) -> TmuxWindow { private func ensureWindow(_ w: TmuxWindowID) -> TmuxWindow {
if let existing = windowByID[w] { return existing } if let existing = windowByID[w] { return existing }
let win = TmuxWindow(id: w, title: "窗口 \(w.raw)") { [weak self] data in let win = TmuxWindow(id: w, title: "窗口 \(w.raw)")
self?.enqueueInput(window: w, data: data)
}
windowByID[w] = win windowByID[w] = win
windows.append(win) windows.append(win)
windows.sort { $0.id.raw < $1.id.raw } windows.sort { $0.id.raw < $1.id.raw }
@@ -160,6 +213,9 @@ final class TmuxController: ObservableObject {
} }
private func removeWindow(_ w: TmuxWindowID) { private func removeWindow(_ w: TmuxWindowID) {
if let win = windowByID[w] {
for p in win.panes.keys { surfaceByPane[p] = nil }
}
windowByID[w] = nil windowByID[w] = nil
windows.removeAll { $0.id == w } windows.removeAll { $0.id == w }
paneToWindow = paneToWindow.filter { $0.value != w } paneToWindow = paneToWindow.filter { $0.value != w }
@@ -172,13 +228,16 @@ final class TmuxController: ObservableObject {
sendRaw(Data((command + "\n").utf8)) sendRaw(Data((command + "\n").utf8))
} }
/// 用户在某 window 输入 → send-keys -H 到其活动 pane(非主线程安全:只在主线程调)。 /// 某 pane 的 surface 收到用户输入 → send-keys -H 到该 pane(可能在非主线程被调,hop 到主线程)。
private nonisolated func enqueueInput(window w: TmuxWindowID, data: Data) { private nonisolated func sendKeys(pane p: TmuxPaneID, data: Data) {
Task { @MainActor in let hex = data.map { String(format: "%02x", $0) }.joined(separator: " ")
guard let pane = self.windowByID[w]?.activePane else { return } Task { @MainActor in self.sendCommand("send-keys -t %\(p.raw) -H \(hex)") }
let hex = data.map { String(format: "%02x", $0) }.joined(separator: " ") }
self.sendCommand("send-keys -t %\(pane.raw) -H \(hex)")
} /// 点击 pane → 本地立即置活动 + 通知 tmux(%window-pane-changed 回来幂等应用)。
func selectPane(_ p: TmuxPaneID, in w: TmuxWindowID) {
windowByID[w]?.activePane = p
sendCommand("select-pane -t %\(p.raw)")
} }
func selectWindow(_ w: TmuxWindowID) { func selectWindow(_ w: TmuxWindowID) {
@@ -189,8 +248,8 @@ final class TmuxController: ObservableObject {
func newWindow() { sendCommand("new-window") } func newWindow() { sendCommand("new-window") }
func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") } func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") }
/// 某 window 的 surface 就绪 → 放行其缓冲输出。 /// 某 pane 的 surface 就绪 → 放行其缓冲输出。
func markWindowReady(_ w: TmuxWindowID) { windowByID[w]?.gate.markReady() } func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() }
} }
/// 传输字节分流器:检测 `tmux -CC` 进入 DCS 前 → 原始终端;进入后 → tmux 网关。 /// 传输字节分流器:检测 `tmux -CC` 进入 DCS 前 → 原始终端;进入后 → tmux 网关。

View File

@@ -6,7 +6,7 @@
--- ---
## 0. 一句话现状 ## 0. 一句话现状
一个**原生 iPad SSH 终端**已可用并真机验证:libghostty 渲染 + 键盘输入 + SSH 直连 + **SSH-over-tsnet(Tailscale 用户态)** + **tmux -CC 原生 tab** + **mosh(LAN + over-tsnet 均已端到端验证)** + 断线自动重连 + **mosh 挂起→恢复<3s(M3)**。四大核心需求(libghostty/tailscale/tmux/**mosh 均已验证**)。仅在 iOS 模拟器验证(未上真机/未签名)。 一个**原生 iPad SSH 终端**已可用并真机验证:libghostty 渲染 + 键盘输入 + SSH 直连 + **SSH-over-tsnet(Tailscale 用户态)** + **tmux -CC 原生 tab + 多 pane 分屏** + **mosh(LAN + over-tsnet 均已端到端验证)** + 断线自动重连 + **mosh 挂起→恢复<3s(M3)**。四大核心需求(libghostty/tailscale/tmux/**mosh 均已验证**)。仅在 iOS 模拟器验证(未上真机/未签名)。
> **mosh 状态(2026-07-24 全部验证通过)**:mosh(C++,blinksh/ios)+protobuf-lite 交叉编译成 `MoshCore.xcframework`(CommonCrypto 后端,含 arm64 模拟器 slice);tsnet 桥加 UDP relay(`StartMoshRelay`);`MoshSession`(pipe↔FILE*/pthread/SIGWINCH)+`MoshConnectScanner`(TXCore,39 单测)+`SSHTerminalModel` 编排(SSH 引导 mosh-server→解析 MOSH CONNECT→接管)。 > **mosh 状态(2026-07-24 全部验证通过)**:mosh(C++,blinksh/ios)+protobuf-lite 交叉编译成 `MoshCore.xcframework`(CommonCrypto 后端,含 arm64 模拟器 slice);tsnet 桥加 UDP relay(`StartMoshRelay`);`MoshSession`(pipe↔FILE*/pthread/SIGWINCH)+`MoshConnectScanner`(TXCore,39 单测)+`SSHTerminalModel` 编排(SSH 引导 mosh-server→解析 MOSH CONNECT→接管)。
> - **LAN 直连端到端验证**(192.168.9.199):mosh-server 存活 104s≫60s 无客户端超时+子 shell+心跳=真连。 > - **LAN 直连端到端验证**(192.168.9.199):mosh-server 存活 104s≫60s 无客户端超时+子 shell+心跳=真连。
> - **mosh over tsnet 端到端验证**(vohive-vm 100.69.201.101):app 日志 `parsed MOSH CONNECT port=60002`→`relay up localPort=63542`→`activateMosh started`;屏幕渲染远端 shell 且无 mosh 断连 overlay=relay UDP 双向流通。**R1(tsnet UDP 数据报语义) 证伪**。 > - **mosh over tsnet 端到端验证**(vohive-vm 100.69.201.101):app 日志 `parsed MOSH CONNECT port=60002`→`relay up localPort=63542`→`activateMosh started`;屏幕渲染远端 shell 且无 mosh 断连 overlay=relay UDP 双向流通。**R1(tsnet UDP 数据报语义) 证伪**。
@@ -59,7 +59,7 @@
- `apps/TerminalX/iOS/` - `apps/TerminalX/iOS/`
- `SSHTerminalModel.swift` —— 核心编排:`OutputGate`(surface-ready 缓冲)、`TransportHolder`、`TsnetManager`(tsnet 节点 up/dial+重试)、`TsnetError`、`TmuxRouter`(raw/gateway 字节分流)、状态机驱动 startSession(直连/tsnet 分支)、`autoConnectIfConfigured`(启动参数)。 - `SSHTerminalModel.swift` —— 核心编排:`OutputGate`(surface-ready 缓冲)、`TransportHolder`、`TsnetManager`(tsnet 节点 up/dial+重试)、`TsnetError`、`TmuxRouter`(raw/gateway 字节分流)、状态机驱动 startSession(直连/tsnet 分支)、`autoConnectIfConfigured`(启动参数)。
- `ContentView.swift` —— 分支:`tmuxController != nil`→`TmuxTabbedView`(tab 条+活动窗口);否则 `RawTerminalView`;`TsnetProbeView`(纯自检);`ConnectionForm`。 - `ContentView.swift` —— 分支:`tmuxController != nil`→`TmuxTabbedView`(tab 条+活动窗口);否则 `RawTerminalView`;`TsnetProbeView`(纯自检);`ConnectionForm`。
- `TmuxController.swift` —— tmux 网关:消费解析事件、window↔`TmuxWindow`(各自终端会话+OutputGate)、`list-windows`/`list-panes`/`capture-pane` 握手、`send-keys`/`select-window`/`new-window`。 - `TmuxController.swift` —— tmux 网关:消费解析事件;**pane-per-surface**(`TmuxPaneSurface`=session+state+gate/pane);`TmuxWindow` 持 panes 字典 + `visibleLayout`(渲染)/`fullLayout`(diff);`surfaceByPane` O(1) 路由 %output;`applyLayout` reconcile(增删留);attach: `refresh-client -C WxH`→`list-windows`(带 layout)→每 pane `capture-pane`(`awaitingCapture` 丢弃快照前 %output);输入绑 pane→`send-keys -t %self`;tap→`select-pane`。字节经 `SSHTerminalModel` 的 `TmuxByteChannel`(AsyncStream 单消费者)保序入网关。
- `TsnetProbe.swift` —— M1 自检视图(加入 tailnet + 对端列表)。 - `TsnetProbe.swift` —— M1 自检视图(加入 tailnet + 对端列表)。
- `TerminalXApp.swift` —— @main。 - `TerminalXApp.swift` —— @main。
- **数据流(直连)**:transport.onBytes → `TmuxRouter.feed` → (raw) `OutputGate.deliver`→`session.receive`→libghostty;(检测到 DCS) → `TmuxController.feed`→解析→各 window。输入:libghostty→session write 闭包→transport.send(tmux 模式→send-keys)。 - **数据流(直连)**:transport.onBytes → `TmuxRouter.feed` → (raw) `OutputGate.deliver`→`session.receive`→libghostty;(检测到 DCS) → `TmuxController.feed`→解析→各 window。输入:libghostty→session write 闭包→transport.send(tmux 模式→send-keys)。
@@ -67,7 +67,7 @@
## 6. 已知问题 / TODO(继续工作的入口) ## 6. 已知问题 / TODO(继续工作的入口)
- **M4 安全**:`SSHSession.connect()` 有 `TODO(M4)`:不校验 host key(接受任意)。需接 known_hosts 三元组 pin(egress,host,port) + Secure Enclave 私钥(`libssh2_userauth_publickey_frommemory`+`SecKeyCreateSignature`)。 - **M4 安全**:`SSHSession.connect()` 有 `TODO(M4)`:不校验 host key(接受任意)。需接 known_hosts 三元组 pin(egress,host,port) + Secure Enclave 私钥(`libssh2_userauth_publickey_frommemory`+`SecKeyCreateSignature`)。
- **tmux MVP 简化**:每 window 只显示活动 pane(**多 pane 分屏未做**);历史靠 `capture-pane` 抓当前屏(非完整 scrollback);命令应答 FIFO 简化匹配;resize 未回传(默认 80x24,应发 `refresh-client -C`)。 - **tmux 多 pane 分屏**:✅ 已做(pane-per-surface + layout rect 绝对定位渲染 + 按 pane %output 路由 + tap 切焦点/select-pane + attach 时 `refresh-client -C` 设尺寸让 tmux 布局)。**剩余**:动态 resize(旋转/改字号→重发 refresh-client -C,fable 方案第 5 步,未做,当前 attach 时定一次);完整 scrollback(仍靠 capture-pane 抓当前屏);pause 流控(`%pause/%continue`,防单 pane 刷屏);tmux<3.1 降级路径未做。
- **tsnet 首连**:已加 dial 重试;更优是等 peer online 再 dial(读 PeersJSON online 状态)。 - **tsnet 首连**:已加 dial 重试;更优是等 peer online 再 dial(读 PeersJSON online 状态)。
- **签名/真机**:`CODE_SIGNING_ALLOWED=NO`,仅模拟器;上真机/TestFlight 需配置签名 + entitlements(tsnet 用户态**无需** NE entitlement,利好审核)。 - **签名/真机**:`CODE_SIGNING_ALLOWED=NO`,仅模拟器;上真机/TestFlight 需配置签名 + entitlements(tsnet 用户态**无需** NE entitlement,利好审核)。
- **git**:已 `git init`,**尚无 commit**;`artifacts/*.xcframework` 已 gitignore(需 `make`/脚本重建或从 Release 拉)。 - **git**:已 `git init`,**尚无 commit**;`artifacts/*.xcframework` 已 gitignore(需 `make`/脚本重建或从 Release 拉)。
@@ -76,7 +76,7 @@
## 7. 下一步建议(按价值) ## 7. 下一步建议(按价值)
1. **M2 mosh 打磨**(功能已通,打磨项):`activateMosh` 写死 80x24 初值(靠首次 resize 纠正);SSH 会话在 mosh 接管后保持 idle 未关(占一条连接);`MoshSession.close()` 后 mosh 线程可能滞留(阻塞主循环,见 TODO);moshiosbridge.cc 顶部 `fwrite("Hello from the Bridge!")` 调试行可清理;`SSHTerminalModel` 里 `NSLog("MOSHDBG …")` 诊断日志可按需保留/删除;mosh-server 引导后远端会累积 detached 会话(`mosh-server new` 每次新建),可考虑复用或清理。**挂起→恢复<3s 门(M3)待专门测**。 1. **M2 mosh 打磨**(功能已通,打磨项):`activateMosh` 写死 80x24 初值(靠首次 resize 纠正);SSH 会话在 mosh 接管后保持 idle 未关(占一条连接);`MoshSession.close()` 后 mosh 线程可能滞留(阻塞主循环,见 TODO);moshiosbridge.cc 顶部 `fwrite("Hello from the Bridge!")` 调试行可清理;`SSHTerminalModel` 里 `NSLog("MOSHDBG …")` 诊断日志可按需保留/删除;mosh-server 引导后远端会累积 detached 会话(`mosh-server new` 每次新建),可考虑复用或清理。**挂起→恢复<3s 门(M3)待专门测**。
3. **M3 真机深挂起补测**:模拟器不复现 socket defunct、且看门狗会杀掉被 `kill -STOP` 冻结过久的 app(>~30s 概率被杀),故 WG 密钥过期(>180s)/DERP 死链/socket defunct 路径需**真机锁屏数分钟**用例补测(`MoshRelay.Rebind`/`hop_port` 的正确性靠代码 + 真机)。 3. **M3 真机深挂起补测**:模拟器不复现 socket defunct、且看门狗会杀掉被 `kill -STOP` 冻结过久的 app(>~30s 概率被杀),故 WG 密钥过期(>180s)/DERP 死链/socket defunct 路径需**真机锁屏数分钟**用例补测(`MoshRelay.Rebind`/`hop_port` 的正确性靠代码 + 真机)。
4. **tmux 多 pane 分屏**:`TmuxLayout` 已能解析布局树 → 把 window 内多 pane 渲染成 SwiftUI 分屏(每 pane 一 surface)。 4. **tmux 多 pane 打磨**:动态 resize(旋转/改字号→debounce 重发 `refresh-client -C`,处理回来的 %layout-change,防抖动/反馈环);pause 流控;split/kill pane 的 UI 手势;外接键盘 `Cmd+Opt+方向` 导航 pane。
5. **M4 安全 + 冷启动 mosh 重连**:known_hosts 固定 + SE 密钥;mosh 进程死后重连原 detached 会话需持久化 MOSH_KEY+port 且改 mosh 序列化 SSP 序号/终端状态(Blink 式,大活)。 5. **M4 安全 + 冷启动 mosh 重连**:known_hosts 固定 + SE 密钥;mosh 进程死后重连原 detached 会话需持久化 MOSH_KEY+port 且改 mosh 序列化 SSP 序号/终端状态(Blink 式,大活)。
5. **产品化**:主机列表持久化(Keychain)、多标签(非 tmux)、软键盘运维工具栏(GhosttyKit 内建,接线即可)。 5. **产品化**:主机列表持久化(Keychain)、多标签(非 tmux)、软键盘运维工具栏(GhosttyKit 内建,接线即可)。