Files
terminalX/apps/TerminalX/iOS/TerminalSession.swift
kid 4b5292107b feat: 业务逻辑对齐 + 首页/弹框七项减法 + 终端页四项体验修复
业务逻辑(用户定稿):
- 点主机总是新建会话(会话:主机=多:1);activate 选中断线会话自动重连
- 「直接输入」凭据保存时自动落 Keychain(去重复用),主机只存引用
- 主机编辑页可就地新建 Tailscale 连接;删无人引用的旧 HostsListView

首页/弹框减法:
- 删侧边栏「连接新主机」、无 tmux 横幅与安装引导、页头「新建连接」
- 搜索移到「全部主机」行;添加主机虚线卡与主机卡等高(骨架复刻)
- 会话卡加 ✕ + 复用终端页同款二次确认
- 会话选择器 sheet → 项目风格居中弹框(去手柄/Header/最近)

终端页修复:
- 侧边栏会话列表改创建顺序,切换不再重排抖动
- 焦点高亮上移到 pane 容器边框(四边完整,去负 padding)
- 软键盘双重让位修复:画布忽略键盘 + TXKeyboardObserver 显式让位一次
- 自动聚焦:focusTick 脉冲强制翻转 FocusState + surface 就绪补聚焦;
  vendor 补 iPadOS 触摸板 indirectPointer 的 becomeFirstResponder

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 17:49:58 +08:00

1091 lines
49 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 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`
/// gategate /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
/// **** resize
/// TerminalSession objectWillChange
var onGridChange: (@Sendable () -> Void)?
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()
let changed = self.cols != max(1, cols) || self.rows != max(1, rows)
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()
if changed { onGridChange?() }
}
}
/// 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)`19
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()
/// / +1 becomeFirstResponder
/// onAppear
@Published var focusTick = 0
/// / `syncUI()` phase +
@Published var link: LinkState = .offline
/// UI **广** scenePhase 广
/// syncUI idleRelease false
private(set) var isSyntheticFixture = false
/// viewport
/// surface surface IOSurfaceLayer
@Published private(set) var snapshotLines: [String] = []
/// tmux `tmuxSummary`
private var injectedTmuxSummary: TmuxSummary?
/// 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?
/// M4host 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 idtsnet nilmosh 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(
// /resizemosh 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)
// 线 screenGrid
screenGrid.onGridChange = { [weak self] in
Task { @MainActor in self?.objectWillChange.send() }
}
// 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")!
/// StoreContentView.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 falseSSHSession
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 connectingverifier 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) {
isSyntheticFixture = true
injectedTmuxSummary = tmux
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
// M4bpublickey 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()
}
// dismissTmuxBanner / runInTerminal tmux
// tmuxUnavailable tmux ·
/// 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 SSHmoshActive 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) 次)"
}
}
}