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,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 -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ final class TmuxPaneSurface: ObservableObject, Identifiable {
|
|||||||
/// 初始 `capture-pane` 应答到达前丢弃该 pane 的 %output(避免与快照重复);tmux 单线程串行保证正确。
|
/// 初始 `capture-pane` 应答到达前丢弃该 pane 的 %output(避免与快照重复);tmux 单线程串行保证正确。
|
||||||
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)。
|
||||||
|
|||||||
@@ -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 -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 状态)。
|
- **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 打磨**:动态 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 内建,接线即可)。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user