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 } } /// 线程安全地持有 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() } } } /// 输出闸门: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() init() { let holder = TransportHolder() let session = InMemoryTerminalSession( write: { data in holder.transport?.send(data) }, resize: { vp in holder.transport?.resize(cols: vp.columns, rows: vp.rows) } ) let state = TerminalViewState() state.configuration = TerminalSurfaceOptions(backend: .inMemory(session)) self.holder = holder 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 } 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 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() } private func handleTransportState(_ st: TransportState) { switch st { case .authenticating: run(machine.reduce(.authenticating)) case .connected: run(machine.reduce(.established)) 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: "已关闭" } } }