diff --git a/apps/TerminalX/iOS/ContentView.swift b/apps/TerminalX/iOS/ContentView.swift index 44db32a..c9916db 100644 --- a/apps/TerminalX/iOS/ContentView.swift +++ b/apps/TerminalX/iOS/ContentView.swift @@ -167,31 +167,39 @@ 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 - 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) + Group { + 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 } - } 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) } } } diff --git a/apps/TerminalX/iOS/SSHTerminalModel.swift b/apps/TerminalX/iOS/SSHTerminalModel.swift index c8f0da1..5fd7642 100644 --- a/apps/TerminalX/iOS/SSHTerminalModel.swift +++ b/apps/TerminalX/iOS/SSHTerminalModel.swift @@ -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)) } } diff --git a/apps/TerminalX/iOS/TmuxController.swift b/apps/TerminalX/iOS/TmuxController.swift index fd00b87..ed55e8b 100644 --- a/apps/TerminalX/iOS/TmuxController.swift +++ b/apps/TerminalX/iOS/TmuxController.swift @@ -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? // 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)。 diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index c12c1c7..111f74b 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -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 内建,接线即可)。