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:
kid
2026-07-24 17:42:45 +08:00
parent 9a27338222
commit 7abe61a71f
4 changed files with 85 additions and 32 deletions

View File

@@ -167,31 +167,39 @@ struct TmuxTabChip: View {
struct TmuxWindowView: View { struct TmuxWindowView: View {
@ObservedObject var window: TmuxWindow @ObservedObject var window: TmuxWindow
let controller: TmuxController let controller: TmuxController
@Environment(\.displayScale) private var displayScale
var body: some View { var body: some View {
GeometryReader { geo in GeometryReader { geo in
if let layout = window.visibleLayout { Group {
let root = layout.root.rect if let layout = window.visibleLayout {
let cw = geo.size.width / CGFloat(max(1, root.width)) let root = layout.root.rect
let ch = geo.size.height / CGFloat(max(1, root.height)) let cw = geo.size.width / CGFloat(max(1, root.width))
ZStack(alignment: .topLeading) { let ch = geo.size.height / CGFloat(max(1, root.height))
ForEach(leaves(layout.root), id: \.pane.raw) { item in ZStack(alignment: .topLeading) {
if let surface = window.panes[item.pane] { ForEach(leaves(layout.root), id: \.pane.raw) { item in
TmuxPaneView( if let surface = window.panes[item.pane] {
surface: surface, TmuxPaneView(
isActive: window.activePane == item.pane, surface: surface,
onTap: { controller.selectPane(item.pane, in: window.id) }, isActive: window.activePane == item.pane,
onReady: { controller.markPaneReady(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) .frame(width: CGFloat(item.rect.width) * cw,
.position(x: (CGFloat(item.rect.x) + CGFloat(item.rect.width) / 2) * cw, height: CGFloat(item.rect.height) * ch)
y: (CGFloat(item.rect.y) + CGFloat(item.rect.height) / 2) * 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
} }
} else { }
Color.clear // iPadOS refresh-client -Ccontroller 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)
} }
} }
} }

View File

@@ -124,12 +124,20 @@ final class TmuxByteChannel: @unchecked Sendable {
func bytes(_ b: [UInt8]) { cont.yield(.bytes(b)) } 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 { final class GridBox: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private var v: (cols: Int, rows: Int) = (80, 24) private var cols = 80, rows = 24, cellW = 0, cellH = 0
var value: (cols: Int, rows: Int) { lock.lock(); defer { lock.unlock() }; return v } var value: (cols: Int, rows: Int, cellW: Int, cellH: Int) {
func set(cols: Int, rows: Int) { lock.lock(); v = (max(1, cols), max(1, rows)); lock.unlock() } 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 /// M0 SSH SSHSession(libssh2) GhosttyKit in-memory
@@ -188,7 +196,9 @@ 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 // + 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)) } 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) }
} }
@@ -333,9 +343,10 @@ final class SSHTerminalModel: ObservableObject {
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 // + cell attach refresh-client -C resize
let g = screenGrid.value let g = screenGrid.value
controller.setClientSize(cols: g.cols, rows: g.rows) controller.setClientSize(cols: g.cols, rows: g.rows)
controller.setCellPixels(w: g.cellW, h: g.cellH)
tmuxController = controller tmuxController = controller
if !initialBytes.isEmpty { controller.feed(Data(initialBytes)) } if !initialBytes.isEmpty { controller.feed(Data(initialBytes)) }
} }

View File

@@ -14,12 +14,15 @@ final class TmuxPaneSurface: ObservableObject, Identifiable {
/// `capture-pane` pane %outputtmux 线 /// `capture-pane` pane %outputtmux 线
var awaitingCapture = true 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.id = id
self.grid = (cols, rows) self.grid = (cols, rows)
let session = InMemoryTerminalSession( let session = InMemoryTerminalSession(
write: { data in onInput(data) }, // pane send-keys -t %self 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() let state = TerminalViewState()
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session)) state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
@@ -60,6 +63,9 @@ final class TmuxController: ObservableObject {
var onExit: (() -> Void)? var onExit: (() -> Void)?
private var clientCols = 80, clientRows = 24 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 // control-mode FIFO
private var attachAcked = false private var attachAcked = false
@@ -73,6 +79,30 @@ final class TmuxController: ObservableObject {
/// model attach tmux refresh-client -C /// model attach tmux refresh-client -C
func setClientSize(cols: Int, rows: Int) { clientCols = max(1, cols); clientRows = max(1, rows) } 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? { 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]
@@ -139,6 +169,8 @@ final class TmuxController: ObservableObject {
} }
win.fullLayout = fullTree win.fullLayout = fullTree
win.visibleLayout = visTree // SwiftUI 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!) { if win.activePane == nil || !newPanes.contains(win.activePane!) {
win.activePane = fullTree.paneIDs.first win.activePane = fullTree.paneIDs.first
} }
@@ -155,9 +187,11 @@ final class TmuxController: ObservableObject {
paneToWindow[p] = w paneToWindow[p] = w
guard let win = windowByID[w] else { return } guard let win = windowByID[w] else { return }
if let existing = surfaceByPane[p] { existing.grid = (cols, rows); return } // if let existing = surfaceByPane[p] { existing.grid = (cols, rows); return } //
let surface = TmuxPaneSurface(id: p, cols: cols, rows: rows) { [weak self] data in let surface = TmuxPaneSurface(
self?.sendKeys(pane: p, data: data) 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 surfaceByPane[p] = surface
win.panes[p] = surface win.panes[p] = surface
// capture %end %output // capture %end %output

View File

@@ -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 多 pane 分屏**:✅ 已做pane-per-surface + layout rect 绝对定位渲染 + 按 pane %output 路由 + tap 切焦点/select-pane + attach `refresh-client -C` 设尺寸让 tmux 布局)。**剩余**:动态 resize旋转/改字号→重发 refresh-client -Cfable 方案第 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→129x73tmux 精确采纳)。**剩余**完整 scrollback仍靠 capture-pane 抓当前屏pause 流控(`%pause/%continue`,防单 pane 刷屏);split/kill pane 手势;tmux<3.1 降级路径未做
- **tsnet 首连**已加 dial 重试更优是等 peer online dial PeersJSON online 状态)。 - **tsnet 首连**已加 dial 重试更优是等 peer online dial PeersJSON online 状态)。
- **签名/真机**`CODE_SIGNING_ALLOWED=NO`仅模拟器上真机/TestFlight 需配置签名 + entitlementstsnet 用户态**无需** NE entitlement利好审核)。 - **签名/真机**`CODE_SIGNING_ALLOWED=NO`仅模拟器上真机/TestFlight 需配置签名 + entitlementstsnet 用户态**无需** 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 线程可能滞留阻塞主循环 TODOmoshiosbridge.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 线程可能滞留阻塞主循环 TODOmoshiosbridge.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 打磨**动态 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. **M4 安全 + 冷启动 mosh 重连**known_hosts 固定 + SE 密钥mosh 进程死后重连原 detached 会话需持久化 MOSH_KEY+port 且改 mosh 序列化 SSP 序号/终端状态Blink 式,大活)。
5. **产品化**:主机列表持久化(Keychain)、多标签(非 tmux)、软键盘运维工具栏(GhosttyKit 内建,接线即可)。 5. **产品化**:主机列表持久化(Keychain)、多标签(非 tmux)、软键盘运维工具栏(GhosttyKit 内建,接线即可)。