按 Claude Design 定稿(归档在 docs/design/)重做 UI,并把 tmux 从 「从不自动进」修成「连接前探测 → 让用户选会话」。 设计令牌与导航地基 - Theme 拆三层:TXAccent(恒定 blurple) / TXChrome(中性阶×4 表) / TXFlavor(Catppuccin 终端) - vendor JetBrains Mono 四权重(附 OFL 许可) - AppRouter + SessionManager:多会话并存,路由与会话生命周期解耦 - SessionCanvas 常驻挂载会话 surface(摘除即丢内容,也是缩回动画的前提) 按设计稿落地的屏 - 沉浸轨道页:56pt 轨道 + 浮起标题/状态胶囊 + 侧边栏三态(遮罩不 resize、Pin 各一次) - 首页:活动会话卡(readViewportText 文本镜像)+ 主机网格 + 筛选 chips - 关闭二次确认(tmux 仅断开 / 原生窗口两套文案)、分屏菜单、pane 拖拽条 - 空状态:首次运行 / 无会话 / 搜索无命中 / 连接中·失败·已关闭 tmux 真实链路(真机查出并修掉 4 个 bug) - 全代码库从来没人发起 attach → 连接前探测 + 会话选择器(接回 / 新建 / 原生终端) - format 分隔符 tab 经 PTY 变成下划线 → 改用 |:| - controller 变化不冒泡到 session → Combine 转发(否则数据解析对了 UI 不刷新) - 当前会话名不能靠 session_attached 反推 → 改用 display-message 传输层:SSH connect 加超时(原来阻塞到系统 TCP 超时 75s+) 无头验证设施:假会话 fixture · terminalx://ui/* 驱动 · 横屏截图脚本 · 对拍走查法 TXCore 45 tests 绿(新增 5 个会话探测单测) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1098 lines
49 KiB
Swift
1098 lines
49 KiB
Swift
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<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
|
||
}
|
||
|
||
/// 链路状态(设计稿右上状态胶囊「只染出问题的那层」用)。
|
||
///
|
||
/// 首版**不含 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()
|
||
/// 链路状态(状态胶囊/状态点)。由 `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 会话列表(分屏菜单的 detached 行;真实会话走 controller.sessions)。
|
||
private(set) var injectedTmuxSessions: [TmuxSessionInfo] = []
|
||
|
||
/// 分屏菜单的候选会话:真实会话用 controller 的 list-sessions 结果,假会话用注入值。
|
||
var tmuxSessionList: [TmuxSessionInfo] {
|
||
if let c = tmuxController, !c.sessions.isEmpty { return c.sessions }
|
||
return injectedTmuxSessions
|
||
}
|
||
|
||
/// 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<Void, Never>?
|
||
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 <name>` 进 control mode(默认开)。
|
||
private var tmuxMode = true
|
||
private var tmuxSessionName = "main"
|
||
private var tmuxBootstrapSent = false
|
||
private var tmuxProbeTask: Task<Void, Never>?
|
||
/// 连接后的会话探测(普通 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<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))
|
||
_ = state.controller.setTheme(.txDefault) // Catppuccin Mocha 终端配色
|
||
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/软件签名器) 认证而非密码(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, tmuxSessions: [TmuxSessionInfo] = []) {
|
||
isSyntheticFixture = true
|
||
injectedTmuxSummary = tmux
|
||
injectedTmuxSessions = tmuxSessions
|
||
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 <name>`:`-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()
|
||
}
|
||
|
||
/// 用户关掉横幅(这台主机本次会话不再提示)。
|
||
func dismissTmuxBanner() { tmuxUnavailable = false }
|
||
|
||
/// 把一条命令塞进当前终端执行(安装引导用)。执行后给足时间装包,再自动重试进 tmux。
|
||
func runInTerminal(_ command: String) {
|
||
sendToTerminal(command + "\n")
|
||
tmuxUnavailable = false
|
||
Task { [weak self] in
|
||
try? await Task.sleep(nanoseconds: 8_000_000_000)
|
||
guard let self, self.tmuxController == nil else { return }
|
||
self.retryTmux()
|
||
}
|
||
}
|
||
|
||
/// 往当前承载通道写入(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) 次)"
|
||
}
|
||
}
|
||
}
|