import Combine import Foundation import GhosttyTerminal import TXCore import TXTransport import TsnetBridge #if canImport(UIKit) import UIKit #endif /// 出口计划(connect 时按 host 解析,贯穿 startSession/launchMosh/唤醒脉冲)。 enum EgressPlan { case direct /// 经指定 Tailscale 连接的 tsnet 节点。authKey 仅首次注册需要(已存 state 用 nil)。 case tsnet(connID: UUID, stateDirName: String, hostname: String, authKey: String?) /// host key pin 的命名空间(按出口隔离,架构决策 §2.6)。 var egressKey: String { switch self { case .direct: "direct" case .tsnet(let id, _, _, _): "tsnet-\(id.uuidString)" } } } /// 经 tsnet 节点 dial,带退避重试(tsnet 刚 Up 后到 peer 的路径预热期间首次 dial 可能超时)。 private func dialFDWithRetry(_ node: TsnetbridgeNode, host: String, port: Int) throws -> Int32 { var lastError: Error? for attempt in 0 ..< 6 { do { var fd: Int64 = -1 try node.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 } /// 线程安全地持有 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() } } } /// raw 输出的接收端(`OutputGate` 或它的旁路 `OutputTap`)。 /// 抽这个协议只为让 `TmuxRouter` 能同时接受两者,别在这上面加职责。 protocol RawOutputSink: Sendable { func deliver(_ data: Data) } /// 输出闸门:surface 未 attach 时 receive 会丢弃写入,故未就绪先缓冲、就绪后按序 flush(修白屏)。 final class OutputGate: RawOutputSink, @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) } } } /// 输出旁路:把 raw 字节**同时**送给下游(终端渲染)和一个观察闭包(会话探测扫描)。 /// /// 为什么需要:连接后的 tmux 会话探测要读 shell 的输出,而那条路原本直通 `OutputGate`。 /// 旁路而非改 gate:gate 的缓冲/flush 语义(修白屏的关键)不能动。 final class OutputTap: RawOutputSink, @unchecked Sendable { private let downstream: OutputGate private let observe: @Sendable (Data) -> Void init(downstream: OutputGate, observe: @escaping @Sendable (Data) -> Void) { self.downstream = downstream self.observe = observe } func deliver(_ data: Data) { downstream.deliver(data) observe(data) } func markReady() { downstream.markReady() } } /// 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 /// 格子数**变化**时回调(去重)。网格并非「首次布局后稳定」——侧边栏开合就会 resize, /// 标题胶囊的格子数要跟着走;TerminalSession 用它转发 objectWillChange。 var onGridChange: (@Sendable () -> Void)? 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() let changed = self.cols != max(1, cols) || self.rows != max(1, rows) 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() if changed { onGridChange?() } } } /// 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 } /// 链路状态(设计稿右上状态胶囊「只染出问题的那层」用)。 /// /// 首版**不含 RTT 数字**:mosh 侧只有 `lastHeardMs()`(时刻,非 SRTT),要真实 RTT 需改 /// vendor/mosh 的 iosclient 导出 SRTT 再桥到 Swift —— 属传输层改动,二期做。 enum LinkState: Equatable { case connecting /// 直连(无 tsnet 出口) case direct /// 经 tsnet(首版不细分 DERP relay / 直连 WG) case relay case reconnecting(attempt: Int) case offline } /// 连接后待用户选择的 tmux 会话(选择器 sheet 的数据源)。 struct TmuxSessionChoice: Equatable { let version: String let sessions: [TmuxSessionProbe.Session] /// 「新建会话」的默认名(取主机配置里的 tmux 会话名,默认 main)。 let defaultNewName: String } /// tmux 概览(侧边栏分组标题、会话卡副行)。真实会话从 `TmuxController` 派生,假会话直接注入。 struct TmuxSummary: Equatable { /// tmux 会话名。首版拿不到(需 `list-sessions`,二期做)→ 空串时 UI 只显示「tmux」。 var name: String = "" var windows: Int = 0 var panes: Int = 0 /// 当前 window title。 var currentTitle: String = "" /// 当前 window 的工作目录(`#{pane_current_path}`)。标题胶囊显示「title · cwd」。 var cwd: String = "" } /// 一条会话 = 一个 TerminalSession 实例:SSHSession(libssh2) ⇄ GhosttyKit in-memory 终端, /// 由 TXCore.SessionMachine 编排生命周期(断线指数退避自动重连、后台冻结/前台恢复)。 /// /// **多会话并存**由 `SessionManager` 持有多个实例实现;本类内无全局单例依赖 /// (tsnet 经 `TsnetRegistry.shared` 按 connID 操作,天然多节点)。 @MainActor final class TerminalSession: ObservableObject, Identifiable { /// 会话身份(路由 `.terminal(id)`、⌘1…9 索引、首页卡与轨道头像都用它)。 let id = UUID() let state: TerminalViewState let session: InMemoryTerminalSession private let holder: TransportHolder private let gate: OutputGate /// 归属主机(从主机列表连接时非 nil;无头直连为 nil,标题回退 `connectionTitle`)。 @Published var host: SavedHost? /// 会话已建立(重连/后台期间保持 true)。**不再驱动导航** —— 路由归 AppRouter。 @Published var isLive = false /// 最小化中:会话继续跑,只是不在前台。首页卡片据此置顶并标「最小化中」。 @Published var isMinimized = false /// 最后一次成为焦点的时刻(首页「上次活跃」+ 会话卡排序)。 @Published var lastActiveAt = Date() /// 键盘焦点脉冲:进终端 / 侧边栏切到本会话时 +1,终端视图据此 becomeFirstResponder /// —— 选中即可直接输入,不必再点一下屏幕(用户裁决;onAppear 只在常驻挂载时来一次,不够)。 @Published var focusTick = 0 /// 链路状态(状态胶囊/状态点)。由 `syncUI()` 从 phase + 出口计划推导。 @Published var link: LinkState = .offline /// 无头 UI 验证的假会话:不建连接、**不参与生命周期广播**(否则 scenePhase 广播会 /// 经 syncUI 把注入的状态覆盖回 idle)。Release 构建恒为 false。 private(set) var isSyntheticFixture = false /// 首页会话缩略卡的文本镜像(viewport 尾部若干行,剔掉尾部空行)。 /// 不渲染第二个 surface(一 surface 一 IOSurfaceLayer,离屏渲染是已修过的泄漏雷区)。 @Published private(set) var snapshotLines: [String] = [] /// 假会话注入的 tmux 概览(真实会话走 `tmuxSummary` 的派生分支)。 private var injectedTmuxSummary: TmuxSummary? /// tmux 概览:假会话用注入值,真实会话从 controller 现算。 var tmuxSummary: TmuxSummary? { if let injectedTmuxSummary { return injectedTmuxSummary } guard let t = tmuxController else { return nil } let active = t.windows.first { $0.id == t.activeWindowID } ?? t.windows.first return TmuxSummary( name: t.currentSessionName, windows: t.windows.count, panes: t.windows.reduce(0) { $0 + $1.panes.count }, currentTitle: active?.title ?? "", cwd: active?.cwd ?? "") } /// 抓一次终端内容快照。tmux 模式取活动 pane 的 surface,否则取自己的。 /// surface 未 attach 时 `readViewportText()` 返回 nil → 保留上一次快照(不清空,避免卡片闪空)。 func captureSnapshot(maxLines: Int = 32) { guard let text = snapshotSource?.readViewportText() else { return } var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) while let last = lines.last, last.trimmingCharacters(in: .whitespaces).isEmpty { lines.removeLast() } let tail = Array(lines.suffix(maxLines)) if tail != snapshotLines { snapshotLines = tail } } private var snapshotSource: InMemoryTerminalSession? { if let t = tmuxController { let win = t.windows.first { $0.id == t.activeWindowID } ?? t.windows.first if let win, let paneID = win.activePane ?? win.panes.keys.first, let surface = win.panes[paneID] { return surface.session } } return session } @Published var banner: String = "" @Published var phaseText: String = "空闲" /// 非 nil 时进入 tmux control-mode(原生 tab)。 @Published var tmuxController: TmuxController? /// 把 controller 的变化**转发**成本对象的变化。 /// /// 必须有:`tmuxSummary` 等是读 controller 的 computed property,而轨道/胶囊/侧边栏只 /// `@ObservedObject` 订阅了 session —— 嵌套 ObservableObject 不冒泡(见 CLAUDE.md 坑), /// 没有这条转发,window 列表/会话名/cwd 解析对了 UI 也不刷新(真机上表现为轨道空、第三格只有「tmux」)。 private var tmuxObserver: AnyCancellable? /// M4:host key 与 pin 不符 → 非 nil 触发 SwiftUI alert(信任新密钥 / 取消)。 @Published var hostKeyMismatch: HostKeyMismatchInfo? /// 终端顶部状态行:当前连接标题(主机名/地址)。 @Published var connectionTitle: String = "" /// mosh 是否已接管(传输徽章 SSH/mosh)。 @Published var moshEngaged: Bool = false /// 连接后探测到的 tmux 会话(非 nil = 等用户选「attach 哪个 / 新建 / 跳过」)。 /// 设计:点主机连接后先让用户挑,而不是替他决定 attach 哪个会话。 @Published var pendingSessionChoice: TmuxSessionChoice? /// 用户在选择器里选了「原生终端」→ 本次会话不经 tmux,也不显示安装引导横幅(他是主动选的)。 @Published private(set) var tmuxSkipped = false /// 正在探测服务器的 tmux 能力/会话。 /// /// **必须在 `.connected` 的那一刻就置位**(不能等 2.5s 后的 bootstrap):否则连接建立与探测开始 /// 之间有个空窗,`SessionManager.evaluateEnter` 会看到 `isLive == true` 就把用户送进终端页, /// 于是选择器弹在终端页上而不是首页 —— 那正是这次要改掉的错误时序。 @Published private(set) var isProbingTmux = false /// 已发出 `tmux -CC` 但迟迟没进 control mode → 服务器很可能没装 tmux。 /// 终端顶部据此显示可关闭的安装引导横幅(设计稿 2c),**不弹窗、不强推**。 @Published var tmuxUnavailable = false let knownHosts = KeychainKnownHostsStore() private var machine = SessionMachine() private var config: SSHConfig? private var reconnectTask: Task? private var autoCommand: String? /// 出口计划(direct / 经某 Tailscale 连接)。connect 时按 host 解析。 private var egressPlan: EgressPlan = .direct /// 当前会话的 Tailscale 连接 id(tsnet 出口时非 nil):mosh relay / 唤醒脉冲 / 会话计数按此 connID 操作。 private var activeConnID: UUID? /// 解析 host 认证/出口所需的两个 Store(由 ContentView 注入)。 private weak var credentialStore: (any CredentialStore)? private weak var tailscaleStore: TailscaleStore? /// tmux 模式:登录后自动 `tmux -CC new -A -s ` 进 control mode(默认开)。 private var tmuxMode = true private var tmuxSessionName = "main" private var tmuxBootstrapSent = false private var tmuxProbeTask: Task? /// 连接后的会话探测(普通 shell 阶段,进 control mode 之前)。 private var sessionProbe: TmuxSessionProbe? private var sessionProbeSent = false /// 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) // 格子数变化 → 主线程转发发布(标题胶囊等读 screenGrid 的视图才会刷新)。 screenGrid.onGridChange = { [weak self] in Task { @MainActor in self?.objectWillChange.send() } } // 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/软件签名器) 认证而非密码(launch-arg 无头验证路径)。 private var pubkeySigner: SigningKeyProvider? /// 无头验证合成的临时 Tailscale 连接 id(固定,复用 "tsnet-main" 模拟器身份)。 private static let headlessTsnetConnID = UUID(uuidString: "00000000-0000-0000-0000-0000000000FF")! /// 注入解析所需的 Store(ContentView.onAppear 调用)。 func bind(credentials: any CredentialStore, tailscale: TailscaleStore) { credentialStore = credentials tailscaleStore = tailscale } /// 会话显示名(首页卡、标题胶囊、关闭确认文案)。 var displayName: String { if let h = host, !h.name.isEmpty { return h.name } return connectionTitle.isEmpty ? "终端" : connectionTitle } /// 两字缩写(轨道 / 侧边栏 / 会话卡的主机头像)。 /// 规则同设计稿:首段首字母 + 末段(末段以数字结尾取该数字,否则取首字母)—— /// `mac-studio → ms`、`ubuntu-cn2 → u2`、`nas-home → nh`。 var initials: String { let name = displayName // 纯 IP(只有数字和点):取首段前两位,"10.255.255.1" → "10"(按字母规则会得到无意义的 "11")。 if name.allSatisfy({ $0.isNumber || $0 == "." }), let head = name.split(separator: ".").first { return String(head.prefix(2)) } let parts = name.split(whereSeparator: { !$0.isLetter && !$0.isNumber }) guard let first = parts.first, let last = parts.last, parts.count >= 2 else { return String(name.prefix(2)).lowercased() } let tail = last.last.map { $0.isNumber ? String($0) : String(last.prefix(1)) } ?? "" return (String(first.prefix(1)) + tail).lowercased() } /// 是否 tmux 会话(决定关闭确认走「仅断开 / 结束会话」还是「进程随之结束」文案)。 var isTmuxSession: Bool { tmuxController != nil } /// 从主机列表连接:解析凭据 + 出口,再驱动标准连接流程。 func connect(to h: SavedHost) { moshMode = h.useMosh tmuxMode = h.useTmux if let name = h.tmuxSession, !name.isEmpty { tmuxSessionName = name } host = h connectionTitle = h.name // 1) 解析认证 let username: String let auth: SSHConfig.Authentication switch h.auth { case .inlinePassword(let user, let pw): username = user; auth = .password(pw) case .credential(let id): guard let store = credentialStore, let cred = store.list().first(where: { $0.id == id }), let secret = store.secret(for: id), let resolved = CredentialAuth.authentication(for: cred, secret: secret) else { phaseText = "失败:凭据不可用"; banner = "凭据已删除或需签名构建(Face ID 密钥)" return } username = cred.username; auth = resolved } // 2) 解析出口 if let tid = h.tailscaleID { guard let conn = tailscaleStore?.connection(id: tid) else { phaseText = "失败:Tailscale 连接不存在"; return } if conn.needsAuthKey { phaseText = "失败:Tailscale 未认证"; banner = "请先在 Tailscale 页完成认证"; return } egressPlan = .tsnet(connID: conn.id, stateDirName: conn.stateDirName, hostname: conn.hostname, authKey: nil) } else { egressPlan = .direct } startConnect(host: h.host, port: h.port, username: username, authentication: auth) } /// 密码/签名器直连(无头验证 / 简单入口)。 func connect(host: String, port: Int, username: String, password: String) { let auth: SSHConfig.Authentication = pubkeySigner.map { .publicKeyCallback($0) } ?? .password(password) startConnect(host: host, port: port, username: username, authentication: auth) } /// 统一起连:构造 config(含按出口命名空间的 host key 校验)并驱动状态机。 private func startConnect(host: String, port: Int, username: String, authentication: SSHConfig.Authentication) { if connectionTitle.isEmpty { connectionTitle = host } moshEngaged = false config = SSHConfig(host: host, port: port, username: username, authentication: authentication, hostKeyVerifier: makeHostKeyVerifier(egress: egressPlan.egressKey, 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 } /// 失败 / 已关闭后重试:有 config 就走状态机重连;否则(凭据解析失败等)重跑一次 host 解析。 func retry() { if config != nil { run(machine.reduce(.retryRequested)) } else if let h = host { connect(to: h) } } /// 切换主题时推送新终端配色到当前 raw surface 与所有 tmux pane(新建面板另经 TerminalTheme.txDefault 读取)。 func applyTerminalTheme(_ t: TerminalTheme) { _ = state.controller.setTheme(t) tmuxController?.applyTerminalTheme(t) } func close() { if let id = activeConnID { TsnetRegistry.shared.markSessionActive(id, false) } activeConnID = nil 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)) } } } #if DEBUG /// 无头 UI 验证:把示意输出灌进 in-memory 终端。**不建传输连接、不驱动状态机**, /// 只为让首页会话卡/轨道头像/状态徽标有内容可截图对拍(见 UIPreviewFixture)。 /// 走 `gate` 而非 `session.receive`,故 surface 未 attach 时会先缓冲、就绪后 flush。 func injectFixture(host: SavedHost, lines: [String], link: LinkState, mosh: Bool, tmux: TmuxSummary? = nil) { isSyntheticFixture = true injectedTmuxSummary = tmux self.host = host connectionTitle = host.name isLive = true self.link = link moshEngaged = mosh phaseText = mosh ? "mosh 已连接" : "已连接" gate.deliver(Data(lines.joined(separator: "\r\n").utf8)) } #endif /// 无头验证:从启动参数自动连接(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 { // 无头验证:合成临时 Tailscale 出口(stateDir 沿用 "tsnet-main" 复用既有模拟器身份)。 egressPlan = .tsnet(connID: Self.headlessTsnetConnID, stateDirName: "tsnet-main", hostname: "terminalx-ipad", authKey: key) } // tmux 自动进入:默认开;`-txTmux 0` 关(回归 raw 终端路径),`-txTmuxSession` 指定会话名。 if d.object(forKey: "txTmux") != nil { tmuxMode = d.bool(forKey: "txTmux") } if let n = d.string(forKey: "txTmuxSession"), !n.isEmpty { tmuxSessionName = n } 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) { knownHosts.pin(HostTriple(egress: egressPlan.egressKey, 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 } switch egressPlan { case .direct: wireAndStart(SSHSession(config: config)) case .tsnet(let connID, let dir, let hostname, let key): // tsnet 模式:后台经注册表 up 该连接的节点 + dial fd,再用外部 fd 建 SSHSession。 Task.detached { do { let node = try TsnetRegistry.shared.node( connID: connID, stateDirName: dir, hostname: hostname, authKey: key, timeoutMs: 45000) let fd = try dialFDWithRetry(node, host: config.host, port: config.port) await MainActor.run { self.activeConnID = connID TsnetRegistry.shared.markSessionActive(connID, true) self.wireAndStart(SSHSession(config: config, preconnectedFD: fd)) } } catch { await MainActor.run { self.handleTransportState(.failed("tsnet: \(error.localizedDescription)")) } } } } } 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() } /// raw 阶段的输出旁路:既喂终端渲染,也喂会话探测扫描器。 private lazy var probeTapGate = OutputTap(downstream: gate) { [weak self] data in Task { @MainActor in guard let self, self.sessionProbe != nil else { return } self.feedSessionProbe(String(decoding: data, as: UTF8.self)) } } private lazy var tmuxRouter = TmuxRouter( rawGate: probeTapGate, // 经有序通道传主线程(enter 先于后续 bytes,单消费者保序)。 enterGateway: { [tmuxChannel] after in tmuxChannel.enter(after) }, gatewayBytes: { [tmuxChannel] bytes in tmuxChannel.bytes(bytes) } ) private func enterTmux(initialBytes: [UInt8]) { tmuxProbeTask?.cancel(); tmuxProbeTask = nil tmuxUnavailable = false 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 tmuxObserver = controller.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() } if !initialBytes.isEmpty { controller.feed(Data(initialBytes)) } } private func exitTmux() { tmuxObserver = nil if tmuxController != nil { tmuxController = nil } tmuxRouter.reset() tmuxProbeTask?.cancel(); tmuxProbeTask = nil tmuxBootstrapSent = false // 重连后需要重新 -CC attach sessionProbeSent = false sessionProbe = nil pendingSessionChoice = nil tmuxSkipped = false isProbingTmux = false } // MARK: - tmux 自动进入 /// 登录后自动进入 tmux control mode。 /// /// `tmux -CC new -A -s `:`-A` = 同名会话存在就 attach(断线原样接回的关键)、否则新建; /// `-CC` = control mode(我们的 `TmuxRouter` 检测其 DCS 前导后接管字节流)。 /// 服务器没装 tmux → shell 回 command not found,探测超时后降级为原生窗口 + 安装引导横幅。 /// 连接建立后**先探测**服务器的 tmux 会话,等用户选择再 attach。 /// /// 为什么不直接 `tmux -CC new -A -s main`:那是替用户决定了 attach 哪个会话。真实运维里 /// 服务器上常有多个会话(各跑不同任务),必须让用户挑;没装 tmux 则**不打扰**,直接原生终端。 private func scheduleTmuxBootstrap() { guard tmuxMode, !sessionProbeSent, tmuxController == nil else { return } sessionProbeSent = true banner = "检测 tmux 会话…" Task { [weak self] in // 等 shell 提示符就绪(登录横幅 / motd 可能还在刷)。2.5s 沿用 mosh 引导已验证的值—— // 太短会把命令喂给还没起来的 shell,字符被丢。 try? await Task.sleep(nanoseconds: 2_500_000_000) guard let self else { return } self.sessionProbe = TmuxSessionProbe() NSLog("TMUXDBG probing tmux sessions") self.sendToTerminal(TmuxSessionProbe.command) self.startProbeTimeout() } } /// 探测输出的消费入口(raw 阶段的字节都会流经这里)。 private func feedSessionProbe(_ text: String) { guard var probe = sessionProbe else { return } guard let result = probe.feed(text) else { sessionProbe = probe // struct:写回累积状态 return } sessionProbe = nil tmuxProbeTask?.cancel(); tmuxProbeTask = nil isProbingTmux = false banner = "" switch result { case .unavailable: // 没装 tmux → **不弹框**,直接用原生终端 + 行内安装引导横幅(用户明确要求)。 NSLog("TMUXDBG probe: tmux 不可用 → 原生终端") tmuxUnavailable = true case .available(let version, let sessions): NSLog("TMUXDBG probe: \(version) sessions=\(sessions.map(\.name))") pendingSessionChoice = TmuxSessionChoice( version: version, sessions: sessions, defaultNewName: tmuxSessionName) } } /// 探测超时兜底(服务器不回、shell 卡住等):按无 tmux 处理,不把用户永远挂在等待里。 private func startProbeTimeout() { tmuxProbeTask?.cancel() tmuxProbeTask = Task { [weak self] in try? await Task.sleep(nanoseconds: 6_000_000_000) guard !Task.isCancelled, let self, self.sessionProbe != nil else { return } self.sessionProbe = nil self.isProbingTmux = false self.banner = "" self.tmuxUnavailable = true NSLog("TMUXDBG probe timeout → 按无 tmux 处理") } } // MARK: - 用户的会话选择 /// attach 已有会话。 func attachTmuxSession(_ name: String) { pendingSessionChoice = nil isProbingTmux = false guard tmuxController == nil else { return } tmuxSessionName = name tmuxBootstrapSent = true banner = "接回 tmux 会话 \(name)…" NSLog("TMUXDBG attach -t \(name)") sendToTerminal("tmux -CC attach -t \(name)\n") startTmuxProbe() } /// 新建会话(`-A` 兜底:同名已存在就接回,避免报错)。 func createTmuxSession(_ name: String) { pendingSessionChoice = nil isProbingTmux = false guard tmuxController == nil else { return } tmuxSessionName = name tmuxBootstrapSent = true banner = "新建 tmux 会话 \(name)…" NSLog("TMUXDBG new -A -s \(name)") sendToTerminal("tmux -CC new -A -s \(name)\n") startTmuxProbe() } /// 选择原生终端:不经 tmux(本次会话不再提示,也不显示安装横幅——用户是主动选的)。 func useNativeTerminal() { pendingSessionChoice = nil isProbingTmux = false tmuxUnavailable = false tmuxSkipped = true banner = "" NSLog("TMUXDBG user chose native terminal") } /// 探测:给足时间进 control mode,超时则判定不可用(服务器无 tmux / 命令失败)。 private func startTmuxProbe() { tmuxProbeTask?.cancel() tmuxProbeTask = Task { [weak self] in try? await Task.sleep(nanoseconds: 4_000_000_000) guard !Task.isCancelled, let self, self.tmuxController == nil else { return } self.tmuxUnavailable = true self.banner = "" NSLog("TMUXDBG probe timeout → 判定无 tmux(服务器未安装或命令失败)") } } /// 用户在横幅里点「安装并启用 tmux」后:装完再试一次。 func retryTmux() { guard tmuxController == nil else { return } tmuxUnavailable = false tmuxBootstrapSent = false scheduleTmuxBootstrap() } // (dismissTmuxBanner / runInTerminal 已随「未检测到 tmux」横幅与安装引导一起删除; // tmuxUnavailable 仍保留 —— 底部状态行据它显示「无 tmux · 客户端窗口」。) /// 往当前承载通道写入(mosh 接管后走 mosh,否则走 SSH)——与终端输入同一条路径。 private func sendToTerminal(_ text: String) { let data = Data(text.utf8) if let m = moshSession { m.send([UInt8](data)) } else { holder.transport?.send(data) } } // 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 let connID = activeConnID { let host = config.host Task.detached { do { NSLog("MOSHDBG startMoshRelay host=\(host) moshPort=\(info.port)") let relay = try TsnetRegistry.shared.startMoshRelay( connID: connID, 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") scheduleTmuxBootstrap() // mosh 已承载会话 → 在 mosh 里进 tmux // 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 let connID = activeConnID { Task.detached { TsnetRegistry.shared.wakeUp(connID: connID) } } 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: // 先置探测中,再驱动状态机 —— 顺序反了就会漏进上面注释说的空窗。 if tmuxMode, tmuxController == nil, !sessionProbeSent { isProbingTmux = true } run(machine.reduce(.established)) if moshMode { scheduleMoshBootstrap() } else { scheduleTmuxBootstrap() } // mosh 模式等 mosh 接管后再进 tmux 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: isLive = true case .idle, .connecting, .authenticating, .closed, .failed: isLive = false } link = Self.linkState(machine.phase, egress: egressPlan) phaseText = Self.describe(machine.phase) } /// 链路状态推导:出口决定 direct/relay,重连/失败覆盖之。 private static func linkState(_ phase: SessionMachine.Phase, egress: EgressPlan) -> LinkState { let up: LinkState = { if case .tsnet = egress { return .relay } else { return .direct } }() switch phase { case .connecting, .authenticating: return .connecting // 挂起(后台)时链路并未断,仍报上行状态;恢复中归入重连语义。 case .connected, .moshActive, .backgroundParked, .moshParked: return up case .waitingToReconnect(let n), .reconnecting(let n), .moshResuming(let n): return .reconnecting(attempt: n) case .idle, .closed, .failed: return .offline } } 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) 次)" } } }