feat: UI 商业化改造 + 连接前 tmux 会话选择(真机验证)

按 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>
This commit is contained in:
kid
2026-07-25 22:59:35 +08:00
parent 6dd23275ff
commit 28e9cfc207
46 changed files with 10237 additions and 1204 deletions

View File

@@ -0,0 +1,103 @@
import Foundation
import TsnetBridge
enum TsnetError: Error { case newNodeFailed, notUp, needsAuthKey }
/// tsnet Tailscale id
///
/// tsnet v1.102.0 `tsnet.Server` netstackdial node
/// 100.x host Node dial
/// Go runtime gomobile bind tsnetbridge
///
/// **** Up·**Up ** DownM3 mosh netstack ·
/// ** 3** LRU ·**in-flight ** connect
/// double-Up stateDirtsnet 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()
// Upin-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()
}
}
}