按 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>
104 lines
4.5 KiB
Swift
104 lines
4.5 KiB
Swift
import Foundation
|
||
import TsnetBridge
|
||
|
||
enum TsnetError: Error { case newNodeFailed, notUp, needsAuthKey }
|
||
|
||
/// 多 tsnet 节点注册表:按 Tailscale 连接 id 复用节点。
|
||
///
|
||
/// tsnet v1.102.0 官方支持一进程多 `tsnet.Server`(各自独立 netstack;dial 走具体 node 句柄、
|
||
/// 无全局路由,故重叠 100.x 不会误连——只要每个 host 始终经其绑定的那个 Node dial)。铁律不破:
|
||
/// 仍是一个 Go runtime、一次 gomobile bind(全在 tsnetbridge 包)。
|
||
///
|
||
/// 策略:**懒启动**(用到才 Up)·**Up 后常驻**(不空闲 Down,M3 mosh 恢复依赖 netstack 存活)·
|
||
/// **软上限 3**(超限 LRU 关闭无活跃会话的最久未用节点)·**in-flight 去重**(同连接并发 connect
|
||
/// 绝不 double-Up 同一 stateDir——tsnet 无文件锁,同 Dir 双 Up 会损坏状态)。
|
||
///
|
||
/// 线程安全(`@unchecked Sendable` + 锁):Up/dial 阻塞,由 `TerminalSession` 在后台线程调用。
|
||
final class TsnetRegistry: @unchecked Sendable {
|
||
static let shared = TsnetRegistry()
|
||
|
||
private let lock = NSLock()
|
||
private var nodes: [UUID: TsnetbridgeNode] = [:]
|
||
private var upLocks: [UUID: NSLock] = [:]
|
||
private var lastUsed: [UUID: Date] = [:]
|
||
private var activeSessions: Set<UUID> = []
|
||
private let maxLive = 3
|
||
|
||
private let stateBase: String
|
||
|
||
init() {
|
||
stateBase = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0]
|
||
}
|
||
|
||
/// 取(必要时懒启动)某连接的 tsnet 节点。阻塞,需后台线程调用。
|
||
/// `authKey` 仅在节点尚未注册(首次 Up)时需要;已持久化 state 的节点用 nil(走已存 node key)。
|
||
func node(connID: UUID, stateDirName: String, hostname: String,
|
||
authKey: String?, timeoutMs: Int) throws -> TsnetbridgeNode {
|
||
lock.lock()
|
||
if let n = nodes[connID] { lastUsed[connID] = Date(); lock.unlock(); return n }
|
||
let upLock = upLocks[connID] ?? { let l = NSLock(); upLocks[connID] = l; return l }()
|
||
lock.unlock()
|
||
|
||
// 同连接串行 Up(in-flight 去重);不同连接可并发。
|
||
upLock.lock()
|
||
defer { upLock.unlock() }
|
||
|
||
lock.lock()
|
||
if let n = nodes[connID] { lastUsed[connID] = Date(); lock.unlock(); return n }
|
||
lock.unlock()
|
||
|
||
let dir = stateBase + "/" + stateDirName
|
||
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||
guard let n = TsnetbridgeNewNode(dir, hostname) else { throw TsnetError.newNodeFailed }
|
||
try n.up(withAuthKey: authKey ?? "", timeoutMs: timeoutMs)
|
||
|
||
lock.lock()
|
||
nodes[connID] = n
|
||
lastUsed[connID] = Date()
|
||
evictIfNeededLocked(keeping: connID)
|
||
lock.unlock()
|
||
return n
|
||
}
|
||
|
||
/// 为 mosh 建立 UDP relay(阻塞,后台线程调用)。节点必须已 Up。
|
||
func startMoshRelay(connID: UUID, host: String, moshPort: Int, timeoutMs: Int) throws -> TsnetbridgeMoshRelay {
|
||
lock.lock(); let n = nodes[connID]; lock.unlock()
|
||
guard let n else { throw TsnetError.notUp }
|
||
return try n.startMoshRelay(host, moshPort: moshPort, timeoutMs: timeoutMs)
|
||
}
|
||
|
||
/// M3:唤醒某连接的 tsnet 网络层(前台恢复,可能阻塞)。
|
||
func wakeUp(connID: UUID) {
|
||
lock.lock(); let n = nodes[connID]; lock.unlock()
|
||
n?.wakeUp()
|
||
}
|
||
|
||
/// 标记某连接是否有活跃会话(供 LRU 不驱逐在用节点)。
|
||
func markSessionActive(_ connID: UUID, _ active: Bool) {
|
||
lock.lock()
|
||
if active { activeSessions.insert(connID) } else { activeSessions.remove(connID) }
|
||
lock.unlock()
|
||
}
|
||
|
||
/// 关闭并销毁某连接的节点(删连接 / LRU 驱逐)。
|
||
func close(_ connID: UUID) {
|
||
lock.lock()
|
||
let n = nodes[connID]
|
||
nodes[connID] = nil; lastUsed[connID] = nil; activeSessions.remove(connID)
|
||
lock.unlock()
|
||
try? n?.close()
|
||
}
|
||
|
||
/// 超出软上限时,关闭无活跃会话、最久未用的节点(保护刚取到的 keeping)。
|
||
private func evictIfNeededLocked(keeping: UUID) {
|
||
while nodes.count > maxLive {
|
||
let candidates = nodes.keys
|
||
.filter { $0 != keeping && !activeSessions.contains($0) }
|
||
.sorted { (lastUsed[$0] ?? .distantPast) < (lastUsed[$1] ?? .distantPast) }
|
||
guard let victim = candidates.first, let n = nodes[victim] else { break }
|
||
nodes[victim] = nil; lastUsed[victim] = nil
|
||
try? n.close()
|
||
}
|
||
}
|
||
}
|