feat: 完成 M2 mosh(LAN + tsnet 端到端验证)
- mosh(blinksh/ios C++)+protobuf-lite 交叉编译为 MoshCore.xcframework (CommonCrypto 后端、含 arm64 模拟器 slice;绕 autotools 用 CMake 直编 + 手写 config.h) - tsnet 桥新增 UDP relay(StartMoshRelay:loopback↔tsnet.Dial 逐包搬运) - MoshSession(pipe↔FILE*/pthread/SIGWINCH)+ MoshConnectScanner(TXCore,+4 单测=39) - SSHTerminalModel 编排:SSH 引导 mosh-server→解析 MOSH CONNECT→relay→mosh 接管 - LAN 直连 + mosh-over-tsnet 均端到端验证通过,R1(tsnet UDP 数据报语义) 证伪 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,14 @@ final class TsnetManager: @unchecked Sendable {
|
||||
}
|
||||
throw lastError ?? TsnetError.notUp
|
||||
}
|
||||
|
||||
/// 为 mosh 建立 UDP relay:loopback ↔ tsnet(host:moshPort)。返回句柄(含 localPort)。
|
||||
/// 阻塞(tsnet UDP Dial),需后台线程调用。
|
||||
func startMoshRelay(host: String, moshPort: Int, timeoutMs: Int) throws -> TsnetbridgeMoshRelay {
|
||||
lock.lock(); let n = node; lock.unlock()
|
||||
guard let n else { throw TsnetError.notUp }
|
||||
return try n.startMoshRelay(host, moshPort: moshPort, timeoutMs: timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
/// 线程安全地持有 transport 引用,供 libghostty 的输入/resize 回调(非主线程)安全取用。
|
||||
@@ -59,6 +67,16 @@ final class TransportHolder: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 线程安全地持有 mosh 会话引用;非 nil 时终端输入/resize 路由到 mosh 而非 SSH transport。
|
||||
final class MoshHolder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var value: MoshSession?
|
||||
var session: MoshSession? {
|
||||
get { lock.lock(); defer { lock.unlock() }; return value }
|
||||
set { lock.lock(); value = newValue; lock.unlock() }
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出闸门:surface 未 attach 时 receive 会丢弃写入,故未就绪先缓冲、就绪后按序 flush(修白屏)。
|
||||
final class OutputGate: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
@@ -109,15 +127,33 @@ final class SSHTerminalModel: ObservableObject {
|
||||
private var tsnetAuthKey: String?
|
||||
private let tsnetMgr = TsnetManager()
|
||||
|
||||
/// M2 mosh 模式:SSH 登录后跑 mosh-server,解析 MOSH CONNECT,改由 mosh(UDP over tsnet relay) 承载会话。
|
||||
private var moshMode = false
|
||||
private var moshServerCmd = "mosh-server new -s -c 256 -l LANG=en_US.UTF-8 -l LC_ALL=en_US.UTF-8"
|
||||
private let moshHolder: MoshHolder
|
||||
private var moshSession: MoshSession?
|
||||
private var moshRelay: TsnetbridgeMoshRelay?
|
||||
private let moshScanner = MoshConnectScanner()
|
||||
private var moshBootstrapSent = false
|
||||
|
||||
init() {
|
||||
let holder = TransportHolder()
|
||||
let moshHolder = MoshHolder()
|
||||
let session = InMemoryTerminalSession(
|
||||
write: { data in holder.transport?.send(data) },
|
||||
resize: { vp in holder.transport?.resize(cols: vp.columns, rows: vp.rows) }
|
||||
// 输入/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
|
||||
if let m = moshHolder.session { m.resize(cols: Int(vp.columns), rows: Int(vp.rows)) }
|
||||
else { holder.transport?.resize(cols: vp.columns, rows: vp.rows) }
|
||||
}
|
||||
)
|
||||
let state = TerminalViewState()
|
||||
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
|
||||
self.holder = holder
|
||||
self.moshHolder = moshHolder
|
||||
self.session = session
|
||||
self.state = state
|
||||
self.gate = OutputGate(session: session)
|
||||
@@ -158,6 +194,11 @@ final class SSHTerminalModel: ObservableObject {
|
||||
let port = d.integer(forKey: "txPort")
|
||||
autoCommand = d.string(forKey: "txAutoCommand")
|
||||
if let key = d.string(forKey: "txTsnetKey"), !key.isEmpty { tsnetAuthKey = key }
|
||||
if d.bool(forKey: "txMosh") {
|
||||
moshMode = true
|
||||
if let cmd = d.string(forKey: "txMoshServerCmd"), !cmd.isEmpty { moshServerCmd = cmd }
|
||||
autoCommand = nil // mosh 模式用自己的引导流程,不用 txAutoCommand
|
||||
}
|
||||
connect(host: host, port: port == 0 ? 22 : port,
|
||||
username: user, password: d.string(forKey: "txPass") ?? "")
|
||||
}
|
||||
@@ -201,7 +242,14 @@ final class SSHTerminalModel: ObservableObject {
|
||||
private func wireAndStart(_ ssh: SSHSession) {
|
||||
exitTmux() // 新会话回原始模式(tmux 会话在服务端,重连后需重新 -CC attach)
|
||||
let router = tmuxRouter
|
||||
ssh.onBytes = { data in router.feed(data) } // 经路由器分流 raw / tmux 网关
|
||||
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) }
|
||||
}
|
||||
@@ -232,10 +280,85 @@ final class SSHTerminalModel: ObservableObject {
|
||||
tmuxRouter.reset()
|
||||
}
|
||||
|
||||
// MARK: - M2 mosh 编排
|
||||
|
||||
/// SSH 已连接后引导 mosh-server(延迟等 shell 提示符,只发一次)。
|
||||
private func scheduleMoshBootstrap() {
|
||||
guard moshMode, !moshBootstrapSent, moshSession == nil else { return }
|
||||
moshBootstrapSent = true
|
||||
let holder = self.holder
|
||||
let cmd = moshServerCmd
|
||||
banner = "启动 mosh-server…"
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 2_500_000_000) // 等 shell 提示符就绪
|
||||
holder.transport?.send(Data("\n".utf8))
|
||||
try? await Task.sleep(nanoseconds: 800_000_000)
|
||||
NSLog("MOSHDBG sent mosh-server cmd: \(cmd)")
|
||||
holder.transport?.send(Data((cmd + "\n").utf8))
|
||||
}
|
||||
}
|
||||
|
||||
/// mosh 引导期消费 SSH 字节:显示进度 + 扫描 MOSH CONNECT,命中即切换到 mosh。
|
||||
/// mosh 接管后忽略 SSH 残余字节,避免污染 mosh 的屏幕状态。
|
||||
private func moshBootstrapFeed(_ data: Data) {
|
||||
guard moshSession == nil else { return }
|
||||
gate.deliver(data) // 让用户看到 mosh-server 引导输出
|
||||
let text = String(decoding: data, as: UTF8.self)
|
||||
if let info = moshScanner.feed(text) {
|
||||
NSLog("MOSHDBG parsed MOSH CONNECT port=\(info.port) keyLen=\(info.key.count)")
|
||||
launchMosh(info)
|
||||
}
|
||||
}
|
||||
|
||||
/// 拿到 MOSH CONNECT 后:tsnet 起 UDP relay(loopback:port) → 建 MoshSession → 接管会话。
|
||||
private func launchMosh(_ info: MoshConnectInfo) {
|
||||
guard moshSession == nil, let config else { return }
|
||||
banner = "mosh 连接中(server port \(info.port))…"
|
||||
if tsnetAuthKey != nil {
|
||||
let mgr = tsnetMgr
|
||||
let host = config.host
|
||||
Task.detached {
|
||||
do {
|
||||
NSLog("MOSHDBG startMoshRelay host=\(host) moshPort=\(info.port)")
|
||||
let relay = try mgr.startMoshRelay(host: host, moshPort: info.port, timeoutMs: 15000)
|
||||
let localPort = relay.localPort()
|
||||
NSLog("MOSHDBG relay up localPort=\(localPort)")
|
||||
await MainActor.run { self.activateMosh(ip: "127.0.0.1", port: localPort, key: info.key, relay: relay) }
|
||||
} catch {
|
||||
NSLog("MOSHDBG relay FAILED: \(error.localizedDescription)")
|
||||
await MainActor.run { self.banner = "mosh relay 失败:\(error.localizedDescription)" }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 直连模式:iOS 允许出站 UDP,直接连 host:port。
|
||||
activateMosh(ip: config.host, port: info.port, key: info.key, relay: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func activateMosh(ip: String, port: Int, key: String, relay: TsnetbridgeMoshRelay?) {
|
||||
guard moshSession == nil else { return }
|
||||
let mosh = MoshSession(ip: ip, port: port, key: key, cols: 80, rows: 24)
|
||||
mosh.onBytes = { [weak self] bytes in
|
||||
guard let self else { return }
|
||||
self.gate.deliver(Data(bytes))
|
||||
}
|
||||
mosh.onClosed = { [weak self] rc in
|
||||
Task { @MainActor in self?.banner = "mosh 已结束(rc=\(rc))" }
|
||||
}
|
||||
moshRelay = relay
|
||||
moshSession = mosh
|
||||
moshHolder.session = mosh // 输入/resize 从此路由到 mosh
|
||||
mosh.start()
|
||||
NSLog("MOSHDBG activateMosh ip=\(ip) port=\(port) started")
|
||||
banner = "mosh 已连接"
|
||||
}
|
||||
|
||||
private func handleTransportState(_ st: TransportState) {
|
||||
switch st {
|
||||
case .authenticating: run(machine.reduce(.authenticating))
|
||||
case .connected: run(machine.reduce(.established))
|
||||
case .connected:
|
||||
run(machine.reduce(.established))
|
||||
if moshMode { scheduleMoshBootstrap() }
|
||||
case .disconnected(let reason): run(machine.reduce(.transportClosed(reason: reason)))
|
||||
case .failed(let message): run(machine.reduce(.authFailed(reason: message)))
|
||||
case .idle, .connecting: break
|
||||
|
||||
Reference in New Issue
Block a user