- TXTransport: SSHSigner 协议 + Authentication.publicKeyCallback;SSHSession 用 @convention(c) sshSignCallback(abstract 桥 SignerBox + malloc 出参)接 libssh2_userauth_publickey - app SigningKeyProvider: SE 优先(kSecAttrTokenIDSecureEnclave),未签名/无 SE 回退软件 P-256 (导出落文件持久化),同一 SSHSigner 路径;SecKeyCreateSignature(ecdsa)→ECDSAConv.derToSSHSignature - 公钥 blob=ecdsa-sha2-nistp256||nistp256||X963;-txPubkeyAuth 日志打 authorized_keys 行 - 验证(192.168.9.199):不给密码,公钥加 authorized_keys 后免密登录成功(whoami=kid); 模拟器 SE 生成 hw=true 可达,真机 SE 硬件签名+keychain 持久化待补 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
634 lines
27 KiB
Swift
634 lines
27 KiB
Swift
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<TmuxByteEvent>
|
||
private let cont: AsyncStream<TmuxByteEvent>.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?
|
||
|
||
private let knownHosts = KeychainKnownHostsStore()
|
||
|
||
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 var moshScanner = MoshConnectScanner()
|
||
private var moshBootstrapSent = false
|
||
// M3 前台恢复:基线时刻 + healthy 轮询 + 恢复看门狗 + 后台任务。
|
||
private var moshResumeBaseMs: UInt64 = 0
|
||
private var moshHealthyPoll: Task<Void, Never>?
|
||
private var resumeWatchdog: Task<Void, Never>?
|
||
#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))
|
||
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?
|
||
|
||
func connect(host: String, port: Int, username: String, password: String) {
|
||
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 }
|
||
|
||
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()
|
||
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()
|
||
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) 次)"
|
||
}
|
||
}
|
||
}
|