feat: tmux 动态 resize(iPadOS 拖拽窗口/旋转自适应)
- ContentView 观测 onChange(geo.size)/onAppear → controller.containerResized - containerResized: 点尺寸 × displayScale ÷ cell 像素 = 目标 cols×rows,去重 + debounce(200ms) → refresh-client -C WxH → tmux 重排回 %layout-change → 更新 pane frame - cell 像素来自 raw 终端/pane surface metrics(InMemoryTerminalViewport.cellWidthPixels); surface resize 回调只 noteCellPixels 不驱动 tmux(防反馈环,单向数据流) - 验证:容器 1032x1280@2x cell16x35→129x73,tmux 根 rect 精确采纳,2 pane 复用不重建 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -167,9 +167,11 @@ struct TmuxTabChip: View {
|
||||
struct TmuxWindowView: View {
|
||||
@ObservedObject var window: TmuxWindow
|
||||
let controller: TmuxController
|
||||
@Environment(\.displayScale) private var displayScale
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
Group {
|
||||
if let layout = window.visibleLayout {
|
||||
let root = layout.root.rect
|
||||
let cw = geo.size.width / CGFloat(max(1, root.width))
|
||||
@@ -194,6 +196,12 @@ struct TmuxWindowView: View {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
// iPadOS 可自由拖拽窗口尺寸 → 容器变化即重算总格子并 refresh-client -C(controller 内 debounce)。
|
||||
.onAppear { controller.containerResized(widthPt: geo.size.width, heightPt: geo.size.height, scale: displayScale) }
|
||||
.onChange(of: geo.size) { _, s in
|
||||
controller.containerResized(widthPt: s.width, heightPt: s.height, scale: displayScale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 收集布局树叶子(pane + 绝对 rect),identity 只用 paneID → 布局变化不重建 surface view。
|
||||
|
||||
@@ -124,12 +124,20 @@ final class TmuxByteChannel: @unchecked Sendable {
|
||||
func bytes(_ b: [UInt8]) { cont.yield(.bytes(b)) }
|
||||
}
|
||||
|
||||
/// 线程安全记录全屏终端格子数(tmux attach 时据此 `refresh-client -C`)。
|
||||
/// 线程安全记录全屏终端格子数 + cell 像素尺寸(tmux attach/resize 时据此换算 `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() }
|
||||
private var cols = 80, rows = 24, cellW = 0, cellH = 0
|
||||
var value: (cols: Int, rows: Int, cellW: Int, cellH: Int) {
|
||||
lock.lock(); defer { lock.unlock() }; return (cols, rows, cellW, cellH)
|
||||
}
|
||||
func set(cols: Int, rows: Int, cellW: Int, cellH: Int) {
|
||||
lock.lock()
|
||||
self.cols = max(1, cols); self.rows = max(1, rows)
|
||||
if cellW > 0 { self.cellW = cellW } // cell 尺寸稳定,保留最后有效值
|
||||
if cellH > 0 { self.cellH = cellH }
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
/// M0 SSH 终端会话模型:SSHSession(libssh2) ⇄ GhosttyKit in-memory 终端,
|
||||
@@ -188,7 +196,9 @@ final class SSHTerminalModel: ObservableObject {
|
||||
else { holder.transport?.send(data) }
|
||||
},
|
||||
resize: { vp in
|
||||
grid.set(cols: Int(vp.columns), rows: Int(vp.rows)) // 记录全屏格子供 tmux
|
||||
// 记录全屏格子 + cell 像素(tmux resize 换算用)。
|
||||
grid.set(cols: Int(vp.columns), rows: Int(vp.rows),
|
||||
cellW: Int(vp.cellWidthPixels), cellH: Int(vp.cellHeightPixels))
|
||||
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) }
|
||||
}
|
||||
@@ -333,9 +343,10 @@ final class SSHTerminalModel: ObservableObject {
|
||||
let holder = self.holder
|
||||
let controller = TmuxController(sendRaw: { data in holder.transport?.send(data) })
|
||||
controller.onExit = { [weak self] in Task { @MainActor in self?.exitTmux() } }
|
||||
// 告知全屏格子数 → attach 时 refresh-client -C,让 tmux 按此尺寸布局。
|
||||
// 告知全屏格子数 + cell 像素 → attach 时 refresh-client -C、动态 resize 换算。
|
||||
let g = screenGrid.value
|
||||
controller.setClientSize(cols: g.cols, rows: g.rows)
|
||||
controller.setCellPixels(w: g.cellW, h: g.cellH)
|
||||
tmuxController = controller
|
||||
if !initialBytes.isEmpty { controller.feed(Data(initialBytes)) }
|
||||
}
|
||||
|
||||
@@ -14,12 +14,15 @@ final class TmuxPaneSurface: ObservableObject, Identifiable {
|
||||
/// 初始 `capture-pane` 应答到达前丢弃该 pane 的 %output(避免与快照重复);tmux 单线程串行保证正确。
|
||||
var awaitingCapture = true
|
||||
|
||||
init(id: TmuxPaneID, cols: Int, rows: Int, onInput: @escaping @Sendable (Data) -> Void) {
|
||||
init(id: TmuxPaneID, cols: Int, rows: Int,
|
||||
onInput: @escaping @Sendable (Data) -> Void,
|
||||
onMetrics: @escaping @Sendable (Int, Int) -> Void) {
|
||||
self.id = id
|
||||
self.grid = (cols, rows)
|
||||
let session = InMemoryTerminalSession(
|
||||
write: { data in onInput(data) }, // 输入直接绑到本 pane → send-keys -t %self
|
||||
resize: { _ in } // tmux 模式:surface resize 不反向驱动 tmux(防反馈环)
|
||||
// tmux 模式:surface resize 只回报 cell 像素供换算,绝不反向驱动 tmux(防反馈环)。
|
||||
resize: { vp in onMetrics(Int(vp.cellWidthPixels), Int(vp.cellHeightPixels)) }
|
||||
)
|
||||
let state = TerminalViewState()
|
||||
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
|
||||
@@ -60,6 +63,9 @@ final class TmuxController: ObservableObject {
|
||||
var onExit: (() -> Void)?
|
||||
|
||||
private var clientCols = 80, clientRows = 24
|
||||
private var cellWpx = 0, cellHpx = 0 // cell 设备像素(换算容器点尺寸→格子)
|
||||
private var lastSentCols = 0, lastSentRows = 0
|
||||
private var resizeDebounce: Task<Void, Never>?
|
||||
|
||||
// control-mode 命令应答 FIFO 匹配。
|
||||
private var attachAcked = false
|
||||
@@ -73,6 +79,30 @@ final class TmuxController: ObservableObject {
|
||||
/// 由 model 在 attach 前告知全屏格子(tmux 据此 refresh-client -C 布局)。
|
||||
func setClientSize(cols: Int, rows: Int) { clientCols = max(1, cols); clientRows = max(1, rows) }
|
||||
|
||||
/// cell 设备像素(来自 raw 终端或 pane surface metrics)。
|
||||
func setCellPixels(w: Int, h: Int) { if w > 0 { cellWpx = w }; if h > 0 { cellHpx = h } }
|
||||
/// pane surface metrics 回报(仅刷新 cell 缓存,绝不反向驱动 tmux)。
|
||||
func noteCellPixels(w: Int, h: Int) { setCellPixels(w: w, h: h) }
|
||||
|
||||
/// 容器尺寸变化(iPadOS 拖拽窗口/旋转/分屏):换算总格子 → 去重 + debounce → refresh-client -C。
|
||||
/// 单向数据流:尺寸只来自"容器点尺寸 × displayScale ÷ cell 像素",绝不来自 surface resize 回调(防反馈环)。
|
||||
func containerResized(widthPt: CGFloat, heightPt: CGFloat, scale: CGFloat) {
|
||||
guard cellWpx > 0, cellHpx > 0, widthPt > 0, heightPt > 0, scale > 0 else { return }
|
||||
let cols = max(1, Int(widthPt * scale / CGFloat(cellWpx)))
|
||||
let rows = max(1, Int(heightPt * scale / CGFloat(cellHpx)))
|
||||
guard cols != lastSentCols || rows != lastSentRows else { return }
|
||||
lastSentCols = cols; lastSentRows = rows
|
||||
clientCols = cols; clientRows = rows
|
||||
NSLog("TMUXDBG containerResized pt=\(Int(widthPt))x\(Int(heightPt)) scale=\(scale) cell=\(cellWpx)x\(cellHpx) -> \(cols)x\(rows)")
|
||||
resizeDebounce?.cancel()
|
||||
resizeDebounce = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 200_000_000) // 合并拖拽期间的连续变化
|
||||
guard !Task.isCancelled, let self, self.attachAcked else { return }
|
||||
NSLog("TMUXDBG refresh-client -C \(cols)x\(rows)")
|
||||
self.sendCommand("refresh-client -C \(cols)x\(rows)") // tmux 重排 → 回 %layout-change → 更新 frame
|
||||
}
|
||||
}
|
||||
|
||||
var activeWindow: TmuxWindow? {
|
||||
guard let id = activeWindowID else { return windows.first }
|
||||
return windowByID[id]
|
||||
@@ -139,6 +169,8 @@ final class TmuxController: ObservableObject {
|
||||
}
|
||||
win.fullLayout = fullTree
|
||||
win.visibleLayout = visTree // 单次发布 → SwiftUI 单帧更新,无闪烁
|
||||
let rr = fullTree.root.rect
|
||||
NSLog("TMUXDBG layout win=@\(w.raw) root=\(rr.width)x\(rr.height) panes=\(fullTree.paneIDs.count)")
|
||||
if win.activePane == nil || !newPanes.contains(win.activePane!) {
|
||||
win.activePane = fullTree.paneIDs.first
|
||||
}
|
||||
@@ -155,9 +187,11 @@ final class TmuxController: ObservableObject {
|
||||
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)
|
||||
}
|
||||
let surface = TmuxPaneSurface(
|
||||
id: p, cols: cols, rows: rows,
|
||||
onInput: { [weak self] data in self?.sendKeys(pane: p, data: data) },
|
||||
onMetrics: { [weak self] w, h in Task { @MainActor in self?.noteCellPixels(w: w, h: h) } }
|
||||
)
|
||||
surfaceByPane[p] = surface
|
||||
win.panes[p] = surface
|
||||
// 抓初始屏(capture %end 后放行 %output)。
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
## 6. 已知问题 / TODO(继续工作的入口)
|
||||
- **M4 安全**:`SSHSession.connect()` 有 `TODO(M4)`:不校验 host key(接受任意)。需接 known_hosts 三元组 pin(egress,host,port) + Secure Enclave 私钥(`libssh2_userauth_publickey_frommemory`+`SecKeyCreateSignature`)。
|
||||
- **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 降级路径未做。
|
||||
- **tmux 多 pane 分屏 + 动态 resize**:✅ 已做(pane-per-surface + layout rect 绝对定位渲染 + 按 pane %output 路由 + tap 切焦点/select-pane + attach `refresh-client -C` 设尺寸 + **iPadOS 拖拽窗口/旋转 → `onChange(geo.size)`→`containerResized`(点×displayScale÷cell像素)→debounce→refresh-client -C→%layout-change→更新 frame**,验证:容器 1032x1280@2x cell16x35→129x73,tmux 精确采纳)。**剩余**:完整 scrollback(仍靠 capture-pane 抓当前屏);pause 流控(`%pause/%continue`,防单 pane 刷屏);split/kill pane 手势;tmux<3.1 降级路径未做。
|
||||
- **tsnet 首连**:已加 dial 重试;更优是等 peer online 再 dial(读 PeersJSON online 状态)。
|
||||
- **签名/真机**:`CODE_SIGNING_ALLOWED=NO`,仅模拟器;上真机/TestFlight 需配置签名 + entitlements(tsnet 用户态**无需** NE entitlement,利好审核)。
|
||||
- **git**:已 `git init`,**尚无 commit**;`artifacts/*.xcframework` 已 gitignore(需 `make`/脚本重建或从 Release 拉)。
|
||||
@@ -76,7 +76,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)待专门测**。
|
||||
3. **M3 真机深挂起补测**:模拟器不复现 socket defunct、且看门狗会杀掉被 `kill -STOP` 冻结过久的 app(>~30s 概率被杀),故 WG 密钥过期(>180s)/DERP 死链/socket defunct 路径需**真机锁屏数分钟**用例补测(`MoshRelay.Rebind`/`hop_port` 的正确性靠代码 + 真机)。
|
||||
4. **tmux 多 pane 打磨**:动态 resize(旋转/改字号→debounce 重发 `refresh-client -C`,处理回来的 %layout-change,防抖动/反馈环);pause 流控;split/kill pane 的 UI 手势;外接键盘 `Cmd+Opt+方向` 导航 pane。
|
||||
4. **tmux 多 pane 打磨**:pause 流控(`%pause/%continue` 防单 pane 刷屏);split/kill pane 的 UI 手势;外接键盘 `Cmd+Opt+方向` 导航 pane;完整 scrollback。(多 pane + 动态 resize 已做并验证。)
|
||||
5. **M4 安全 + 冷启动 mosh 重连**:known_hosts 固定 + SE 密钥;mosh 进程死后重连原 detached 会话需持久化 MOSH_KEY+port 且改 mosh 序列化 SSP 序号/终端状态(Blink 式,大活)。
|
||||
5. **产品化**:主机列表持久化(Keychain)、多标签(非 tmux)、软键盘运维工具栏(GhosttyKit 内建,接线即可)。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user