初始提交:terminalX 可运行态(M0/M1/M1.5 已真机验证)
- M0: libghostty SSH 终端(渲染/输入/连接)+ 白屏修复(OutputGate) + 会话状态机/自动重连 - M1: tsnet 用户态组网 + SSH-over-tsnet(fd 桥),shell 级真机验证;R5(Go+gvisor+C+++Swift 同进程) retire - M1.5: tmux -CC 原生 tab(MVP) - 结构: packages/(TXCore·TXTransport), apps/TerminalX, vendor/(libghostty-spm/libssh2/mbedtls/tsnet-bridge), artifacts/ - 文档: CLAUDE.md + docs/HANDOFF.md(新会话入口) - 环境: 认证代理→依赖 vendor 本地化;Go 在 ~/.local/go;仅模拟器/未签名 - 待续: M2 mosh, tmux 多 pane, M4 安全(host key/SE) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
280
apps/TerminalX/iOS/SSHTerminalModel.swift
Normal file
280
apps/TerminalX/iOS/SSHTerminalModel.swift
Normal file
@@ -0,0 +1,280 @@
|
||||
import Foundation
|
||||
import GhosttyTerminal
|
||||
import TXCore
|
||||
import TXTransport
|
||||
import TsnetBridge
|
||||
|
||||
enum TsnetError: Error { case newNodeFailed, notUp }
|
||||
|
||||
/// 主 tsnet 节点管理器(线程安全,供 SSHTerminalModel 在后台线程 up/dial)。
|
||||
/// up 后节点常驻,重连时复用(只重新 dial fd,不重新加入 tailnet)。
|
||||
final class TsnetManager: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var node: TsnetbridgeNode?
|
||||
private let stateDir: String
|
||||
|
||||
init() {
|
||||
let base = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0]
|
||||
stateDir = base + "/tsnet-main"
|
||||
try? FileManager.default.createDirectory(atPath: stateDir, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
/// 确保节点已 up(幂等)。阻塞,需后台线程调用。
|
||||
func ensureUp(authKey: String, timeoutMs: Int) throws {
|
||||
lock.lock(); let existing = node; lock.unlock()
|
||||
if existing != nil { return }
|
||||
guard let n = TsnetbridgeNewNode(stateDir, "terminalx-ipad") else { throw TsnetError.newNodeFailed }
|
||||
try n.up(withAuthKey: authKey, timeoutMs: timeoutMs)
|
||||
lock.lock(); node = n; lock.unlock()
|
||||
}
|
||||
|
||||
/// 经 tsnet dial tailnet 内 host:port,返回已连接 fd。阻塞,需后台线程调用。
|
||||
/// 带重试:tsnet 刚 Up 后到 peer 的路径(DERP/直连打洞)预热期间首次 dial 可能 i/o timeout,
|
||||
/// 短退避重试若干次直到路径就绪。
|
||||
func dialFD(host: String, port: Int, timeoutMs: Int) throws -> Int32 {
|
||||
lock.lock(); let n = node; lock.unlock()
|
||||
guard let n else { throw TsnetError.notUp }
|
||||
var lastError: Error?
|
||||
for attempt in 0 ..< 6 {
|
||||
do {
|
||||
var fd: Int64 = -1
|
||||
try n.dialTCPFD(host, port: port, timeoutMs: 8000, ret0_: &fd)
|
||||
return Int32(fd)
|
||||
} catch {
|
||||
lastError = error
|
||||
if attempt < 5 { Thread.sleep(forTimeInterval: 2.0) } // 后台线程,路径预热
|
||||
}
|
||||
}
|
||||
throw lastError ?? TsnetError.notUp
|
||||
}
|
||||
}
|
||||
|
||||
/// 线程安全地持有 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() }
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出闸门:surface 未 attach 时 receive 会丢弃写入,故未就绪先缓冲、就绪后按序 flush(修白屏)。
|
||||
final class OutputGate: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let session: InMemoryTerminalSession
|
||||
private var ready = false
|
||||
private var pending = Data()
|
||||
|
||||
init(session: InMemoryTerminalSession) { self.session = session }
|
||||
|
||||
func deliver(_ data: Data) {
|
||||
lock.lock()
|
||||
if ready { lock.unlock(); session.receive(data) }
|
||||
else { pending.append(data); lock.unlock() }
|
||||
}
|
||||
|
||||
func markReady() {
|
||||
lock.lock()
|
||||
if ready { lock.unlock(); return }
|
||||
ready = true
|
||||
let buffered = pending; pending = Data()
|
||||
lock.unlock()
|
||||
if !buffered.isEmpty { session.receive(buffered) }
|
||||
}
|
||||
}
|
||||
|
||||
/// M0 SSH 终端会话模型:SSHSession(libssh2) ⇄ GhosttyKit in-memory 终端,
|
||||
/// 由 TXCore.SessionMachine 编排生命周期(断线指数退避自动重连、后台冻结/前台恢复)。
|
||||
@MainActor
|
||||
final class SSHTerminalModel: ObservableObject {
|
||||
let state: TerminalViewState
|
||||
let session: InMemoryTerminalSession
|
||||
private let holder: TransportHolder
|
||||
private let gate: OutputGate
|
||||
|
||||
/// 是否展示终端页(连接后保持,重连/后台期间不退回表单)。
|
||||
@Published var showsTerminal = false
|
||||
@Published var banner: String = ""
|
||||
@Published var phaseText: String = "空闲"
|
||||
/// 非 nil 时进入 tmux control-mode(原生 tab)。
|
||||
@Published var tmuxController: TmuxController?
|
||||
|
||||
private var machine = SessionMachine()
|
||||
private var config: SSHConfig?
|
||||
private var reconnectTask: Task<Void, Never>?
|
||||
private var autoCommand: String?
|
||||
|
||||
/// tsnet 模式:非 nil 时经 tsnet fd 桥连接(而非系统直连),重连复用同一节点。
|
||||
private var tsnetAuthKey: String?
|
||||
private let tsnetMgr = TsnetManager()
|
||||
|
||||
init() {
|
||||
let holder = TransportHolder()
|
||||
let session = InMemoryTerminalSession(
|
||||
write: { data in holder.transport?.send(data) },
|
||||
resize: { vp in holder.transport?.resize(cols: vp.columns, rows: vp.rows) }
|
||||
)
|
||||
let state = TerminalViewState()
|
||||
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
|
||||
self.holder = holder
|
||||
self.session = session
|
||||
self.state = state
|
||||
self.gate = OutputGate(session: session)
|
||||
}
|
||||
|
||||
// MARK: - 公开入口
|
||||
|
||||
func connect(host: String, port: Int, username: String, password: String) {
|
||||
config = SSHConfig(host: host, port: port, username: username,
|
||||
authentication: .password(password))
|
||||
run(machine.reduce(.connectRequested))
|
||||
}
|
||||
|
||||
func close() { run(machine.reduce(.closeRequested)) }
|
||||
func enterBackground() { run(machine.reduce(.enteredBackground)) }
|
||||
func enterForeground() { run(machine.reduce(.enteredForeground)) }
|
||||
|
||||
/// TerminalScreen 在 surface 首次布局后调用:放行缓冲输出(+ 无头自动命令)。
|
||||
func markSurfaceReady() {
|
||||
gate.markReady()
|
||||
if let cmd = autoCommand {
|
||||
autoCommand = nil
|
||||
let holder = self.holder
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 2_500_000_000)
|
||||
holder.transport?.send(Data("\n".utf8))
|
||||
try? await Task.sleep(nanoseconds: 900_000_000)
|
||||
holder.transport?.send(Data((cmd + "\n").utf8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 无头验证:从启动参数自动连接(simctl launch --args -txHost … -txAutoCommand …)。
|
||||
func autoConnectIfConfigured() {
|
||||
let d = UserDefaults.standard
|
||||
guard let host = d.string(forKey: "txHost"), !host.isEmpty,
|
||||
let user = d.string(forKey: "txUser") else { return }
|
||||
let port = d.integer(forKey: "txPort")
|
||||
autoCommand = d.string(forKey: "txAutoCommand")
|
||||
if let key = d.string(forKey: "txTsnetKey"), !key.isEmpty { tsnetAuthKey = key }
|
||||
connect(host: host, port: port == 0 ? 22 : port,
|
||||
username: user, password: d.string(forKey: "txPass") ?? "")
|
||||
}
|
||||
|
||||
// MARK: - 状态机副作用执行
|
||||
|
||||
private func run(_ effects: [SessionMachine.Effect]) {
|
||||
for effect in effects {
|
||||
switch effect {
|
||||
case .startConnect: startSession()
|
||||
case .scheduleReconnect(_, let delayMS): scheduleReconnect(delayMS)
|
||||
case .cancelReconnectTimer: reconnectTask?.cancel(); reconnectTask = nil
|
||||
case .teardownTransport: holder.transport?.stop()
|
||||
case .notify(let message): banner = message
|
||||
}
|
||||
}
|
||||
syncUI()
|
||||
}
|
||||
|
||||
private func startSession() {
|
||||
guard let config else { return }
|
||||
if let key = tsnetAuthKey {
|
||||
// tsnet 模式:后台 up 节点 + dial fd,再用外部 fd 建 SSHSession。
|
||||
let mgr = tsnetMgr
|
||||
Task.detached {
|
||||
do {
|
||||
try mgr.ensureUp(authKey: key, timeoutMs: 45000)
|
||||
let fd = try mgr.dialFD(host: config.host, port: config.port, timeoutMs: 15000)
|
||||
await MainActor.run { self.wireAndStart(SSHSession(config: config, preconnectedFD: fd)) }
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.handleTransportState(.failed("tsnet: \(error.localizedDescription)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
wireAndStart(SSHSession(config: config))
|
||||
}
|
||||
}
|
||||
|
||||
private func wireAndStart(_ ssh: SSHSession) {
|
||||
exitTmux() // 新会话回原始模式(tmux 会话在服务端,重连后需重新 -CC attach)
|
||||
let router = tmuxRouter
|
||||
ssh.onBytes = { data in router.feed(data) } // 经路由器分流 raw / tmux 网关
|
||||
ssh.onState = { [weak self] newState in
|
||||
Task { @MainActor in self?.handleTransportState(newState) }
|
||||
}
|
||||
holder.transport = ssh
|
||||
ssh.start()
|
||||
}
|
||||
|
||||
private lazy var tmuxRouter = TmuxRouter(
|
||||
rawGate: gate,
|
||||
enterGateway: { [weak self] after in
|
||||
Task { @MainActor in self?.enterTmux(initialBytes: after) }
|
||||
},
|
||||
gatewayBytes: { [weak self] bytes in
|
||||
Task { @MainActor in self?.tmuxController?.feed(Data(bytes)) }
|
||||
}
|
||||
)
|
||||
|
||||
private func enterTmux(initialBytes: [UInt8]) {
|
||||
let holder = self.holder
|
||||
let controller = TmuxController(sendRaw: { data in holder.transport?.send(data) })
|
||||
controller.onExit = { [weak self] in Task { @MainActor in self?.exitTmux() } }
|
||||
tmuxController = controller
|
||||
if !initialBytes.isEmpty { controller.feed(Data(initialBytes)) }
|
||||
}
|
||||
|
||||
private func exitTmux() {
|
||||
if tmuxController != nil { tmuxController = nil }
|
||||
tmuxRouter.reset()
|
||||
}
|
||||
|
||||
private func handleTransportState(_ st: TransportState) {
|
||||
switch st {
|
||||
case .authenticating: run(machine.reduce(.authenticating))
|
||||
case .connected: run(machine.reduce(.established))
|
||||
case .disconnected(let reason): run(machine.reduce(.transportClosed(reason: reason)))
|
||||
case .failed(let message): run(machine.reduce(.authFailed(reason: message)))
|
||||
case .idle, .connecting: break
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleReconnect(_ delayMS: Int) {
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(delayMS) * 1_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.run(self.machine.reduce(.reconnectTimerFired))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncUI() {
|
||||
switch machine.phase {
|
||||
case .connected, .reconnecting, .waitingToReconnect, .backgroundParked:
|
||||
showsTerminal = true
|
||||
case .idle, .connecting, .authenticating, .closed, .failed:
|
||||
showsTerminal = false
|
||||
}
|
||||
phaseText = Self.describe(machine.phase)
|
||||
}
|
||||
|
||||
private static func describe(_ phase: SessionMachine.Phase) -> String {
|
||||
switch phase {
|
||||
case .idle: "空闲"
|
||||
case .connecting: "连接中…"
|
||||
case .authenticating: "认证中…"
|
||||
case .connected: "已连接"
|
||||
case .backgroundParked: "已挂起(后台)"
|
||||
case .waitingToReconnect(let n): "等待重连(第 \(n) 次)"
|
||||
case .reconnecting(let n): "重连中(第 \(n) 次)"
|
||||
case .failed(let r): "失败:\(r)"
|
||||
case .closed: "已关闭"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user