Files
terminalX/apps/TerminalX/iOS/SSHTerminalModel.swift
kid 32520f09e0 feat: 完成 M2 mosh(LAN + tsnet 端到端验证)
- mosh(blinksh/ios C++)+protobuf-lite 交叉编译为 MoshCore.xcframework
  (CommonCrypto 后端、含 arm64 模拟器 slice;绕 autotools 用 CMake 直编 + 手写 config.h)
- tsnet 桥新增 UDP relay(StartMoshRelay:loopback↔tsnet.Dial 逐包搬运)
- MoshSession(pipe↔FILE*/pthread/SIGWINCH)+ MoshConnectScanner(TXCore,+4 单测=39)
- SSHTerminalModel 编排:SSH 引导 mosh-server→解析 MOSH CONNECT→relay→mosh 接管
- LAN 直连 + mosh-over-tsnet 均端到端验证通过,R1(tsnet UDP 数据报语义) 证伪

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-24 16:16:15 +08:00

404 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 relayloopback 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<Void, Never>?
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(
// /resizemosh 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: "已关闭"
}
}
}