Files
terminalX/apps/TerminalX/iOS/TerminalSession.swift
kid 5707d70dff feat: 侧边栏两态重构(统一视图身份)+ 第四轮触控化走查
侧边栏(真机验证):
- 砍掉悬浮态,只留收起⇄固定展开;TerminalSidebar 单一视图身份,
  内容恒按 288pt 排版、收起=左侧裁切(clipped 宽度动画),
  图标列两态同尺寸同位置,明细淡入淡出,终端随宽度盖上/让开
- 开合入口收敛:Logo 位(收起态=»展开键/展开态=Logo)+ 头部«;
  删底部开合键与 Pin;LeadingHitShape 修 clipped 不裁触摸
- 修 GridBox 从不通知 UI:onGridChange→objectWillChange,
  标题胶囊格子数随开合实时刷新

触控化走查(上一轮,真机部署验证):
- 窗口按钮 13pt 色点→22pt 圆角方块常显字形;动作条改可点按钮
- TXTech 拼写/配色规范(SSH/mosh/tmux 官方写法+品牌色映射)
- 首页会话卡固定 3 列 4:3;去掉未实现的 ⌘K 提示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-26 00:55:53 +08:00

1108 lines
50 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()
/// / `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 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?
/// 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, 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
// 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()
}
///
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 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) 次)"
}
}
}