import Foundation import GhosttyTerminal import TXCore import TXTransport import TsnetBridge 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) } } /// 线程安全地持有 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) } } } /// 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? 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 let moshScanner = MoshConnectScanner() private var moshBootstrapSent = false init() { let holder = TransportHolder() let moshHolder = MoshHolder() 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 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)) self.holder = holder self.moshHolder = moshHolder self.session = session self.state = state self.gate = OutputGate(session: session) } // MARK: - 公开入口 func connect(host: String, port: Int, username: String, password: String) { config = SSHConfig(host: host, port: port, username: username, authentication: .password(password)) run(machine.reduce(.connectRequested)) } func close() { run(machine.reduce(.closeRequested)) } func enterBackground() { run(machine.reduce(.enteredBackground)) } func enterForeground() { 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 } connect(host: host, port: port == 0 ? 22 : port, 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 } } 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, enterGateway: { [weak self] after in Task { @MainActor in self?.enterTmux(initialBytes: after) } }, gatewayBytes: { [weak self] bytes in Task { @MainActor in self?.tmuxController?.feed(Data(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() } } 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 self?.banner = "mosh 已结束(rc=\(rc))" } } moshRelay = relay moshSession = mosh moshHolder.session = mosh // 输入/resize 从此路由到 mosh mosh.start() NSLog("MOSHDBG activateMosh ip=\(ip) port=\(port) started") banner = "mosh 已连接" } 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: 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: "已关闭" } } }