import Foundation import GhosttyTerminal import TXCore import TXTransport import TsnetBridge #if canImport(UIKit) import UIKit #endif enum TsnetError: Error { case newNodeFailed, notUp } /// 主 tsnet 节点管理器(线程安全,供 SSHTerminalModel 在后台线程 up/dial)。 /// up 后节点常驻,重连时复用(只重新 dial fd,不重新加入 tailnet)。 final class TsnetManager: @unchecked Sendable { private let lock = NSLock() private var node: TsnetbridgeNode? private let stateDir: String init() { let base = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0] stateDir = base + "/tsnet-main" try? FileManager.default.createDirectory(atPath: stateDir, withIntermediateDirectories: true) } /// 确保节点已 up(幂等)。阻塞,需后台线程调用。 func ensureUp(authKey: String, timeoutMs: Int) throws { lock.lock(); let existing = node; lock.unlock() if existing != nil { return } guard let n = TsnetbridgeNewNode(stateDir, "terminalx-ipad") else { throw TsnetError.newNodeFailed } try n.up(withAuthKey: authKey, timeoutMs: timeoutMs) lock.lock(); node = n; lock.unlock() } /// 经 tsnet dial tailnet 内 host:port,返回已连接 fd。阻塞,需后台线程调用。 /// 带重试:tsnet 刚 Up 后到 peer 的路径(DERP/直连打洞)预热期间首次 dial 可能 i/o timeout, /// 短退避重试若干次直到路径就绪。 func dialFD(host: String, port: Int, timeoutMs: Int) throws -> Int32 { lock.lock(); let n = node; lock.unlock() guard let n else { throw TsnetError.notUp } var lastError: Error? for attempt in 0 ..< 6 { do { var fd: Int64 = -1 try n.dialTCPFD(host, port: port, timeoutMs: 8000, ret0_: &fd) return Int32(fd) } catch { lastError = error if attempt < 5 { Thread.sleep(forTimeInterval: 2.0) } // 后台线程,路径预热 } } throw lastError ?? TsnetError.notUp } /// 为 mosh 建立 UDP relay:loopback ↔ tsnet(host:moshPort)。返回句柄(含 localPort)。 /// 阻塞(tsnet UDP Dial),需后台线程调用。 func startMoshRelay(host: String, moshPort: Int, timeoutMs: Int) throws -> TsnetbridgeMoshRelay { lock.lock(); let n = node; lock.unlock() guard let n else { throw TsnetError.notUp } return try n.startMoshRelay(host, moshPort: moshPort, timeoutMs: timeoutMs) } /// M3:前台恢复时唤醒 tsnet 网络层(注入链路事件 + Rebind/重新 STUN)。可能阻塞,后台线程调用。 func wakeUp() { lock.lock(); let n = node; lock.unlock() n?.wakeUp() } } /// 线程安全地持有 transport 引用,供 libghostty 的输入/resize 回调(非主线程)安全取用。 final class TransportHolder: @unchecked Sendable { private let lock = NSLock() private var value: Transport? var transport: Transport? { get { lock.lock(); defer { lock.unlock() }; return value } set { lock.lock(); value = newValue; lock.unlock() } } } /// 线程安全地持有 mosh 会话引用;非 nil 时终端输入/resize 路由到 mosh 而非 SSH transport。 final class MoshHolder: @unchecked Sendable { private let lock = NSLock() private var value: MoshSession? var session: MoshSession? { get { lock.lock(); defer { lock.unlock() }; return value } set { lock.lock(); value = newValue; lock.unlock() } } } /// 输出闸门:surface 未 attach 时 receive 会丢弃写入,故未就绪先缓冲、就绪后按序 flush(修白屏)。 final class OutputGate: @unchecked Sendable { private let lock = NSLock() private let session: InMemoryTerminalSession private var ready = false private var pending = Data() init(session: InMemoryTerminalSession) { self.session = session } func deliver(_ data: Data) { lock.lock() if ready { lock.unlock(); session.receive(data) } else { pending.append(data); lock.unlock() } } func markReady() { lock.lock() if ready { lock.unlock(); return } ready = true let buffered = pending; pending = Data() lock.unlock() if !buffered.isEmpty { session.receive(buffered) } } } /// 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 private let cont: AsyncStream.Continuation init() { (stream, cont) = AsyncStream.makeStream() } func enter(_ b: [UInt8]) { cont.yield(.enter(b)) } func bytes(_ b: [UInt8]) { cont.yield(.bytes(b)) } } /// 线程安全记录全屏终端格子数 + cell 像素尺寸(tmux attach/resize 时据此换算 `refresh-client -C`)。 final class GridBox: @unchecked Sendable { private let lock = NSLock() 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() } } /// M4 host key 不符详情(供 SwiftUI alert 并排显示旧/新指纹 + 用户抉择)。 struct HostKeyMismatchInfo: Identifiable { let id = UUID() let egress: String, host: String, port: Int let keyType: Int32 let presentedBlob: Data let presentedFp: String let storedFp: String } /// M0 SSH 终端会话模型:SSHSession(libssh2) ⇄ GhosttyKit in-memory 终端, /// 由 TXCore.SessionMachine 编排生命周期(断线指数退避自动重连、后台冻结/前台恢复)。 @MainActor final class SSHTerminalModel: ObservableObject { let state: TerminalViewState let session: InMemoryTerminalSession private let holder: TransportHolder private let gate: OutputGate /// 是否展示终端页(连接后保持,重连/后台期间不退回表单)。 @Published var showsTerminal = false @Published var banner: String = "" @Published var phaseText: String = "空闲" /// 非 nil 时进入 tmux control-mode(原生 tab)。 @Published var tmuxController: TmuxController? /// M4:host key 与 pin 不符 → 非 nil 触发 SwiftUI alert(信任新密钥 / 取消)。 @Published var hostKeyMismatch: HostKeyMismatchInfo? /// 终端顶部状态行:当前连接标题(主机名/地址)。 @Published var connectionTitle: String = "" /// mosh 是否已接管(传输徽章 SSH/mosh)。 @Published var moshEngaged: Bool = false private let knownHosts = KeychainKnownHostsStore() private var machine = SessionMachine() private var config: SSHConfig? private var reconnectTask: Task? private var autoCommand: String? /// tsnet 模式:非 nil 时经 tsnet fd 桥连接(而非系统直连),重连复用同一节点。 private var tsnetAuthKey: String? private let tsnetMgr = TsnetManager() /// M2 mosh 模式:SSH 登录后跑 mosh-server,解析 MOSH CONNECT,改由 mosh(UDP over tsnet relay) 承载会话。 private var moshMode = false private var moshServerCmd = "mosh-server new -s -c 256 -l LANG=en_US.UTF-8 -l LC_ALL=en_US.UTF-8" private let moshHolder: MoshHolder private var moshSession: MoshSession? private var moshRelay: TsnetbridgeMoshRelay? private var moshScanner = MoshConnectScanner() private var moshBootstrapSent = false // M3 前台恢复:基线时刻 + healthy 轮询 + 恢复看门狗 + 后台任务。 private var moshResumeBaseMs: UInt64 = 0 private var moshHealthyPoll: Task? private var resumeWatchdog: Task? #if canImport(UIKit) private var bgTask: UIBackgroundTaskIdentifier = .invalid #endif private let tmuxChannel = TmuxByteChannel() /// 全屏终端格子数(raw 模式 surface resize 时记录;tmux attach 用于 refresh-client -C)。 let screenGrid = GridBox() init() { let holder = TransportHolder() let moshHolder = MoshHolder() let grid = screenGrid let session = InMemoryTerminalSession( // 输入/resize:mosh 激活时路由到 mosh,否则走 SSH transport。 write: { data in if let m = moshHolder.session { m.send([UInt8](data)) } else { holder.transport?.send(data) } }, resize: { vp in // 记录全屏格子 + 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) } } ) let state = TerminalViewState() state.configuration = TerminalSurfaceOptions(backend: .inMemory(session)) _ = state.controller.setTheme(.txDefault) // Catppuccin Mocha 终端配色 self.holder = holder self.moshHolder = moshHolder self.session = session self.state = state 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: - 公开入口 /// M4b:非 nil 时用 publickey(SE/软件签名器) 认证而非密码。 private var pubkeySigner: SigningKeyProvider? /// 从主机列表连接(设置 mosh 模式后走标准连接)。 func connect(to h: SavedHost) { moshMode = h.useMosh connect(host: h.host, port: h.port, username: h.username, password: h.password) connectionTitle = h.name } func connect(host: String, port: Int, username: String, password: String) { connectionTitle = host moshEngaged = false let egress = tsnetAuthKey != nil ? "tsnet" : "direct" let auth: SSHConfig.Authentication = pubkeySigner.map { .publicKeyCallback($0) } ?? .password(password) config = SSHConfig(host: host, port: port, username: username, authentication: auth, hostKeyVerifier: makeHostKeyVerifier(egress: egress, host: host, port: port)) run(machine.reduce(.connectRequested)) } /// M4:构造 host key 校验器(TOFU)。在 ssh 串行队列同步调用;firstUse 自动 pin + 横幅, /// mismatch 冒泡到主线程触发 alert 并返回 false(SSHSession 会断开重试)。 private func makeHostKeyVerifier(egress: String, host: String, port: Int) -> @Sendable (Data, Int32) -> Bool { let store = knownHosts let triple = HostTriple(egress: egress, host: host, port: port) return { [weak self] blob, keyType in switch HostKey.evaluate(stored: store.lookup(triple)?.blob, presented: blob) { case .firstUse: store.pin(triple, record: HostKeyRecord(blob: blob, keyType: keyType)) let fp = HostKey.opensshFingerprint(blob) Task { @MainActor in self?.banner = "已记住 host key \(fp)" } return true case .trusted: return true case .mismatch(let stored): let info = HostKeyMismatchInfo( egress: egress, host: host, port: port, keyType: keyType, presentedBlob: blob, presentedFp: HostKey.opensshFingerprint(blob), storedFp: HostKey.opensshFingerprint(stored)) Task { @MainActor in self?.hostKeyMismatch = info } return false } } } /// 用户确认信任新 host key → 覆盖 pin 并重连。 func trustNewHostKey() { guard let m = hostKeyMismatch else { return } knownHosts.pin(HostTriple(egress: m.egress, host: m.host, port: m.port), record: HostKeyRecord(blob: m.presentedBlob, keyType: m.keyType)) hostKeyMismatch = nil run(machine.reduce(.retryRequested)) // failed → connecting(verifier 此时将判定 trusted) } /// 用户取消 → 保持断开。 func dismissHostKeyMismatch() { hostKeyMismatch = nil } /// 切换主题时推送新终端配色到当前 raw surface 与所有 tmux pane(新建面板另经 TerminalTheme.txDefault 读取)。 func applyTerminalTheme(_ t: TerminalTheme) { _ = state.controller.setTheme(t) tmuxController?.applyTerminalTheme(t) } func close() { run(machine.reduce(.closeRequested)) } func enterBackground() { if moshMode { beginBgTask() } // 争取 ~30s 后台执行,mosh 每 3s ack 保活 NAT/WG/DERP run(machine.reduce(.enteredBackground)) } func enterForeground() { endBgTask() run(machine.reduce(.enteredForeground)) } /// TerminalScreen 在 surface 首次布局后调用:放行缓冲输出(+ 无头自动命令)。 func markSurfaceReady() { gate.markReady() if let cmd = autoCommand { autoCommand = nil let holder = self.holder Task { try? await Task.sleep(nanoseconds: 2_500_000_000) holder.transport?.send(Data("\n".utf8)) try? await Task.sleep(nanoseconds: 900_000_000) holder.transport?.send(Data((cmd + "\n").utf8)) } } } /// 无头验证:从启动参数自动连接(simctl launch --args -txHost … -txAutoCommand …)。 func autoConnectIfConfigured() { let d = UserDefaults.standard guard let host = d.string(forKey: "txHost"), !host.isEmpty, let user = d.string(forKey: "txUser") else { return } let port = d.integer(forKey: "txPort") autoCommand = d.string(forKey: "txAutoCommand") if let key = d.string(forKey: "txTsnetKey"), !key.isEmpty { tsnetAuthKey = key } if d.bool(forKey: "txMosh") { moshMode = true if let cmd = d.string(forKey: "txMoshServerCmd"), !cmd.isEmpty { moshServerCmd = cmd } autoCommand = nil // mosh 模式用自己的引导流程,不用 txAutoCommand } let realPort = port == 0 ? 22 : port // M4b:publickey 认证(SE/软件签名器)。日志打出 authorized_keys 行供加入服务器。 if d.bool(forKey: "txPubkeyAuth") { pubkeySigner = SigningKeyProvider.loadOrCreate() if let s = pubkeySigner { NSLog("M4DBG authorized_keys hw=\(s.isHardwareBacked): \(s.authorizedKeysLine)") } } // 无头验证:播种一个假 pin → 真实 host key 将 mismatch,触发 alert 路径。 if let b64 = d.string(forKey: "txHostKeyPinOverrideBase64"), let fake = Data(base64Encoded: b64) { let egress = tsnetAuthKey != nil ? "tsnet" : "direct" knownHosts.pin(HostTriple(egress: egress, host: host, port: realPort), record: HostKeyRecord(blob: fake, keyType: 3)) } connect(host: host, port: realPort, username: user, password: d.string(forKey: "txPass") ?? "") } // MARK: - 状态机副作用执行 private func run(_ effects: [SessionMachine.Effect]) { for effect in effects { switch effect { case .startConnect: startSession() case .scheduleReconnect(_, let delayMS): scheduleReconnect(delayMS) case .cancelReconnectTimer: reconnectTask?.cancel(); reconnectTask = nil case .teardownTransport: holder.transport?.stop() case .notify(let message): banner = message case .nudgeResume: performWakePulse(); startHealthyPollIfNeeded() case .scheduleResumeWatchdog(let ms): scheduleResumeWatchdog(ms) case .cancelResumeWatchdog: cancelResumeWatchdog() case .teardownMosh: teardownMosh() } } syncUI() } private func startSession() { guard let config else { return } if let key = tsnetAuthKey { // tsnet 模式:后台 up 节点 + dial fd,再用外部 fd 建 SSHSession。 let mgr = tsnetMgr Task.detached { do { try mgr.ensureUp(authKey: key, timeoutMs: 45000) let fd = try mgr.dialFD(host: config.host, port: config.port, timeoutMs: 15000) await MainActor.run { self.wireAndStart(SSHSession(config: config, preconnectedFD: fd)) } } catch { await MainActor.run { self.handleTransportState(.failed("tsnet: \(error.localizedDescription)")) } } } } else { wireAndStart(SSHSession(config: config)) } } private func wireAndStart(_ ssh: SSHSession) { exitTmux() // 新会话回原始模式(tmux 会话在服务端,重连后需重新 -CC attach) let router = tmuxRouter if moshMode { // mosh 模式:SSH 字节喂给引导流程(既显示进度、又扫描 MOSH CONNECT)。 ssh.onBytes = { [weak self] data in Task { @MainActor in self?.moshBootstrapFeed(data) } } } else { ssh.onBytes = { data in router.feed(data) } // 经路由器分流 raw / tmux 网关 } ssh.onState = { [weak self] newState in Task { @MainActor in self?.handleTransportState(newState) } } holder.transport = ssh ssh.start() } private lazy var tmuxRouter = TmuxRouter( rawGate: gate, // 经有序通道传主线程(enter 先于后续 bytes,单消费者保序)。 enterGateway: { [tmuxChannel] after in tmuxChannel.enter(after) }, gatewayBytes: { [tmuxChannel] bytes in tmuxChannel.bytes(bytes) } ) private func enterTmux(initialBytes: [UInt8]) { let holder = self.holder let controller = TmuxController(sendRaw: { data in holder.transport?.send(data) }) controller.onExit = { [weak self] in Task { @MainActor in self?.exitTmux() } } // 告知全屏格子数 + 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)) } } private func exitTmux() { if tmuxController != nil { tmuxController = nil } tmuxRouter.reset() } // MARK: - M2 mosh 编排 /// SSH 已连接后引导 mosh-server(延迟等 shell 提示符,只发一次)。 private func scheduleMoshBootstrap() { guard moshMode, !moshBootstrapSent, moshSession == nil else { return } moshBootstrapSent = true let holder = self.holder let cmd = moshServerCmd banner = "启动 mosh-server…" Task { try? await Task.sleep(nanoseconds: 2_500_000_000) // 等 shell 提示符就绪 holder.transport?.send(Data("\n".utf8)) try? await Task.sleep(nanoseconds: 800_000_000) NSLog("MOSHDBG sent mosh-server cmd: \(cmd)") holder.transport?.send(Data((cmd + "\n").utf8)) } } /// mosh 引导期消费 SSH 字节:显示进度 + 扫描 MOSH CONNECT,命中即切换到 mosh。 /// mosh 接管后忽略 SSH 残余字节,避免污染 mosh 的屏幕状态。 private func moshBootstrapFeed(_ data: Data) { guard moshSession == nil else { return } gate.deliver(data) // 让用户看到 mosh-server 引导输出 let text = String(decoding: data, as: UTF8.self) if let info = moshScanner.feed(text) { NSLog("MOSHDBG parsed MOSH CONNECT port=\(info.port) keyLen=\(info.key.count)") launchMosh(info) } } /// 拿到 MOSH CONNECT 后:tsnet 起 UDP relay(loopback:port) → 建 MoshSession → 接管会话。 private func launchMosh(_ info: MoshConnectInfo) { guard moshSession == nil, let config else { return } banner = "mosh 连接中(server port \(info.port))…" if tsnetAuthKey != nil { let mgr = tsnetMgr let host = config.host Task.detached { do { NSLog("MOSHDBG startMoshRelay host=\(host) moshPort=\(info.port)") let relay = try mgr.startMoshRelay(host: host, moshPort: info.port, timeoutMs: 15000) let localPort = relay.localPort() NSLog("MOSHDBG relay up localPort=\(localPort)") await MainActor.run { self.activateMosh(ip: "127.0.0.1", port: localPort, key: info.key, relay: relay) } } catch { NSLog("MOSHDBG relay FAILED: \(error.localizedDescription)") await MainActor.run { self.banner = "mosh relay 失败:\(error.localizedDescription)" } } } } else { // 直连模式:iOS 允许出站 UDP,直接连 host:port。 activateMosh(ip: config.host, port: info.port, key: info.key, relay: nil) } } private func activateMosh(ip: String, port: Int, key: String, relay: TsnetbridgeMoshRelay?) { guard moshSession == nil else { return } let mosh = MoshSession(ip: ip, port: port, key: key, cols: 80, rows: 24) mosh.onBytes = { [weak self] bytes in guard let self else { return } self.gate.deliver(Data(bytes)) } mosh.onClosed = { [weak self] rc in Task { @MainActor in guard let self else { return } self.run(self.machine.reduce(.moshExited(rc: Int(rc)))) } } moshRelay = relay moshSession = mosh moshHolder.session = mosh // 输入/resize 从此路由到 mosh mosh.start() moshEngaged = true NSLog("MOSHDBG activateMosh ip=\(ip) port=\(port) started") // mosh 接管成功 → 状态机拆掉底层 idle SSH(moshActive 之后忽略其 transportClosed)。 run(machine.reduce(.moshEstablished)) } // MARK: - M3 前台快速恢复 /// 唤醒脉冲:tsnet WakeUp(后台线程,可能阻塞)+ relay 同端口 Rebind + mosh SIGCONT。 private func performWakePulse() { guard let mosh = moshSession else { return } if moshResumeBaseMs == 0 { moshResumeBaseMs = mosh.lastHeardMs() } if tsnetAuthKey != nil { let mgr = tsnetMgr Task.detached { mgr.wakeUp() } } do { try moshRelay?.rebind() } catch { NSLog("MOSHDBG relay rebind failed: \(error.localizedDescription)") } mosh.nudge() NSLog("MOSHDBG wake pulse base=\(moshResumeBaseMs)") } /// 轮询 mosh 探针:收到基线之后的新服务器状态即判定 SSP 已续(healthy)。 private func startHealthyPollIfNeeded() { guard moshHealthyPoll == nil, let mosh = moshSession else { return } let base = moshResumeBaseMs moshHealthyPoll = Task { [weak self] in for _ in 0 ..< 100 { // ~10s 安全上限(看门狗更早兜底) try? await Task.sleep(nanoseconds: 100_000_000) if Task.isCancelled { return } if mosh.lastHeardMs() > base { guard let self else { return } NSLog("TXM3 recovered base=\(base) now=\(mosh.lastHeardMs())") self.moshHealthyPoll = nil self.run(self.machine.reduce(.moshHealthy)) return } } } } private func scheduleResumeWatchdog(_ ms: Int) { resumeWatchdog?.cancel() resumeWatchdog = Task { [weak self] in try? await Task.sleep(nanoseconds: UInt64(ms) * 1_000_000) guard !Task.isCancelled, let self else { return } self.run(self.machine.reduce(.resumeWatchdogFired)) } } private func cancelResumeWatchdog() { resumeWatchdog?.cancel(); resumeWatchdog = nil moshHealthyPoll?.cancel(); moshHealthyPoll = nil moshResumeBaseMs = 0 } /// 拆除 mosh 会话(兜底全量重建 / 关闭时)。重置引导状态,允许重新引导 mosh-server。 private func teardownMosh() { cancelResumeWatchdog() moshEngaged = false moshHolder.session = nil moshSession?.close(); moshSession = nil try? moshRelay?.close(); moshRelay = nil moshBootstrapSent = false moshScanner = MoshConnectScanner() } private func beginBgTask() { #if canImport(UIKit) guard bgTask == .invalid else { return } bgTask = UIApplication.shared.beginBackgroundTask(withName: "mosh-keepalive") { [weak self] in self?.endBgTask() } #endif } private func endBgTask() { #if canImport(UIKit) if bgTask != .invalid { UIApplication.shared.endBackgroundTask(bgTask); bgTask = .invalid } #endif } private func handleTransportState(_ st: TransportState) { switch st { case .authenticating: run(machine.reduce(.authenticating)) case .connected: run(machine.reduce(.established)) if moshMode { scheduleMoshBootstrap() } case .disconnected(let reason): run(machine.reduce(.transportClosed(reason: reason))) case .failed(let message): run(machine.reduce(.authFailed(reason: message))) case .idle, .connecting: break } } private func scheduleReconnect(_ delayMS: Int) { reconnectTask?.cancel() reconnectTask = Task { [weak self] in try? await Task.sleep(nanoseconds: UInt64(delayMS) * 1_000_000) guard !Task.isCancelled else { return } await MainActor.run { guard let self else { return } self.run(self.machine.reduce(.reconnectTimerFired)) } } } private func syncUI() { switch machine.phase { case .connected, .reconnecting, .waitingToReconnect, .backgroundParked, .moshActive, .moshParked, .moshResuming: showsTerminal = true case .idle, .connecting, .authenticating, .closed, .failed: showsTerminal = false } phaseText = Self.describe(machine.phase) } private static func describe(_ phase: SessionMachine.Phase) -> String { switch phase { case .idle: "空闲" case .connecting: "连接中…" case .authenticating: "认证中…" case .connected: "已连接" case .backgroundParked: "已挂起(后台)" case .waitingToReconnect(let n): "等待重连(第 \(n) 次)" case .reconnecting(let n): "重连中(第 \(n) 次)" case .failed(let r): "失败:\(r)" case .closed: "已关闭" case .moshActive: "mosh 已连接" case .moshParked: "已挂起(后台)" case .moshResuming(let n): "mosh 恢复中(第 \(n) 次)" } } }