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,102 @@
import Foundation
/// ** shell** control mode tmux
///
/// attach / /
/// control mode **** `tmux -CC new`
///
/// **PTY ** PTY
/// `"__TX""_SESSIONS_BEGIN"`shell
///
///
/// ** `tmux -V`**`tmux list-sessions` tmux
/// stderr 西 `2>/dev/null`
/// UI vs
public struct TmuxSessionProbe: Sendable {
public static let beginMark = "__TX_SESSIONS_BEGIN"
public static let endMark = "__TX_SESSIONS_END"
///
public static let sessionPrefix = "S"
/// `TmuxController.fieldSep` tab PTY 线
public static let fieldSep = "|:|"
/// shell `printf` `echo` shellfish/bash/zsh
///
/// **** PTY
/// 西 motd/shell
/// ANSI`[H` + `[2J` + `[3J` scrollback `clear` terminfo
public static var command: String {
let begin = #"printf '%s\n' "__TX""_SESSIONS_BEGIN""#
let end = #"printf '%s\n' "__TX""_SESSIONS_END""#
let version = "tmux -V 2>/dev/null"
let list = "tmux list-sessions -F '\(sessionPrefix)\(fieldSep)#{session_name}\(fieldSep)#{session_windows}\(fieldSep)#{session_attached}\(fieldSep)#{session_activity}' 2>/dev/null"
let clear = #"printf '\033[H\033[2J\033[3J'"#
return "\(begin); \(version); \(list); \(end); \(clear)\n"
}
///
public struct Session: Equatable, Sendable {
public let name: String
public let windows: Int
public let isAttached: Bool
/// `#{session_activity}`Unix N
public let lastActivity: Date?
public init(name: String, windows: Int, isAttached: Bool, lastActivity: Date? = nil) {
self.name = name
self.windows = windows
self.isAttached = isAttached
self.lastActivity = lastActivity
}
}
public enum Result: Equatable, Sendable {
/// tmux `sessions` UI /
case available(version: String, sessions: [Session])
/// tmux`tmux -V` +
case unavailable
}
private var buffer = ""
private var inBlock = false
private var version: String?
private var sessions: [Session] = []
private var done = false
public init() {}
/// END nil
public mutating func feed(_ text: String) -> Result? {
guard !done else { return nil }
buffer += text
// PTY CRLF Swift "\r\n" Character isNewline
while let idx = buffer.firstIndex(where: { $0.isNewline }) {
let raw = String(buffer[buffer.startIndex ..< idx])
buffer.removeSubrange(buffer.startIndex ... idx)
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !inBlock {
if line == Self.beginMark { inBlock = true }
continue
}
if line == Self.endMark {
done = true
guard let version else { return .unavailable }
return .available(version: version, sessions: sessions)
}
if line.hasPrefix(Self.sessionPrefix + Self.fieldSep) {
if let s = Self.parseSession(line) { sessions.append(s) }
} else if line.hasPrefix("tmux ") {
version = line // tmux 3.7b
}
}
return nil
}
/// `S|:|name|:|windows|:|attached|:|activity` Session
static func parseSession(_ line: String) -> Session? {
let f = line.components(separatedBy: fieldSep)
guard f.count >= 4, f[0] == sessionPrefix, !f[1].isEmpty, let w = Int(f[2]) else { return nil }
let activity = f.count >= 5 ? Double(f[4]).map { Date(timeIntervalSince1970: $0) } : nil
return Session(name: f[1], windows: w, isAttached: f[3] == "1", lastActivity: activity)
}
}

View File

@@ -0,0 +1,69 @@
import Foundation
import Testing
@testable import TXCore
@Suite("tmux 会话探测(连接前选择用)")
struct TmuxSessionProbeTests {
/// **** PTY
@Test("命令不含完整标记字面量,避免 PTY 回显误命中")
func commandAvoidsEchoSelfMatch() {
let cmd = TmuxSessionProbe.command
#expect(!cmd.contains(TmuxSessionProbe.beginMark))
#expect(!cmd.contains(TmuxSessionProbe.endMark))
#expect(cmd.contains("tmux -V"))
#expect(cmd.contains("list-sessions"))
}
@Test("解析会话列表CRLF 行尾 + 版本行)")
func parsesSessions() {
var p = TmuxSessionProbe()
//
#expect(p.feed("kid@Air ~> printf '%s\\n' \"__TX\"\"_SESSIONS_BEGIN\"\r\n") == nil)
#expect(p.feed("\(TmuxSessionProbe.beginMark)\r\n") == nil)
#expect(p.feed("tmux 3.7b\r\n") == nil)
#expect(p.feed("S|:|main|:|5|:|0|:|1784988802\r\n") == nil)
#expect(p.feed("S|:|demo|:|2|:|1|:|1784901778\r\n") == nil)
let result = p.feed("\(TmuxSessionProbe.endMark)\r\n")
guard case .available(let version, let sessions) = result else {
Issue.record("期望 available实际 \(String(describing: result))")
return
}
#expect(version == "tmux 3.7b")
#expect(sessions.count == 2)
#expect(sessions[0] == TmuxSessionProbe.Session(
name: "main", windows: 5, isAttached: false,
lastActivity: Date(timeIntervalSince1970: 1784988802)))
#expect(sessions[1].name == "demo")
#expect(sessions[1].isAttached)
//
#expect(p.feed("S|:|late|:|1|:|0|:|1\r\n") == nil)
}
@Test("装了 tmux 但没有会话 → available 且列表为空UI 只给新建/跳过)")
func availableWithNoSessions() {
var p = TmuxSessionProbe()
_ = p.feed("\(TmuxSessionProbe.beginMark)\n")
_ = p.feed("tmux 3.5a\n")
let r = p.feed("\(TmuxSessionProbe.endMark)\n")
#expect(r == .available(version: "tmux 3.5a", sessions: []))
}
@Test("没装 tmux无版本行→ unavailable直接走原生终端")
func unavailableWithoutVersion() {
var p = TmuxSessionProbe()
_ = p.feed("\(TmuxSessionProbe.beginMark)\n")
let r = p.feed("\(TmuxSessionProbe.endMark)\n")
#expect(r == .unavailable)
}
@Test("畸形会话行被跳过而非崩溃")
func skipsMalformedLines() {
var p = TmuxSessionProbe()
_ = p.feed("\(TmuxSessionProbe.beginMark)\ntmux 3.7\n")
_ = p.feed("S|:|\n") //
_ = p.feed("S|:||:|3|:|0\n") //
_ = p.feed("S|:|ok|:|x|:|0\n") // windows
let r = p.feed("\(TmuxSessionProbe.endMark)\n")
#expect(r == .available(version: "tmux 3.7", sessions: []))
}
}

View File

@@ -102,6 +102,30 @@ public final class SSHSession: Transport, @unchecked Sendable {
// MARK: - Connect / Handshake
/// TCP connect connect `poll` SO_ERROR
/// `Darwin.connect` TCP 75s+UI
private static func connectWithTimeout(_ fd: Int32, _ addr: UnsafeMutablePointer<sockaddr>,
_ len: socklen_t, seconds: Int) -> Bool {
let flags = fcntl(fd, F_GETFL, 0)
guard flags >= 0, fcntl(fd, F_SETFL, flags | O_NONBLOCK) >= 0 else {
return Darwin.connect(fd, addr, len) == 0 // flags 退
}
defer { _ = fcntl(fd, F_SETFL, flags) } // libssh2
if Darwin.connect(fd, addr, len) == 0 { return true }
guard errno == EINPROGRESS else { return false }
var pfd = pollfd(fd: fd, events: Int16(POLLOUT), revents: 0)
let ready = withUnsafeMutablePointer(to: &pfd) { poll($0, 1, Int32(seconds * 1000)) }
guard ready == 1, pfd.revents & Int16(POLLOUT) != 0 else { return false }
// SO_ERROR fd
var soErr: Int32 = 0
var soLen = socklen_t(MemoryLayout<Int32>.size)
guard getsockopt(fd, SOL_SOCKET, SO_ERROR, &soErr, &soLen) == 0, soErr == 0 else { return false }
return true
}
private func connect() throws {
let fd: Int32
if preconnectedFD >= 0 {
@@ -124,12 +148,16 @@ public final class SSHSession: Transport, @unchecked Sendable {
while let addr = node {
dialed = socket(addr.pointee.ai_family, addr.pointee.ai_socktype, addr.pointee.ai_protocol)
if dialed >= 0 {
if Darwin.connect(dialed, addr.pointee.ai_addr, addr.pointee.ai_addrlen) == 0 { break }
if Self.connectWithTimeout(dialed, addr.pointee.ai_addr,
addr.pointee.ai_addrlen,
seconds: config.connectTimeoutSec) { break }
close(dialed); dialed = -1
}
node = addr.pointee.ai_next
}
guard dialed >= 0 else { throw SSHError.socket("连接失败: \(config.host):\(config.port)") }
guard dialed >= 0 else {
throw SSHError.socket("连接失败(\(config.connectTimeoutSec)s 超时): \(config.host):\(config.port)")
}
fd = dialed
}

View File

@@ -35,6 +35,9 @@ public struct SSHConfig: Sendable {
/// trusted firstUse pinfalse=mismatch SSHSession hostKeyMismatch
/// nil /pin UI app
public var hostKeyVerifier: (@Sendable (Data, Int32) -> Bool)?
/// TCP connect ****`Darwin.connect` TCP 75s+
/// tsnet preconnectedFD
public var connectTimeoutSec: Int
public enum Authentication: Sendable {
case password(String)
@@ -51,9 +54,11 @@ public struct SSHConfig: Sendable {
terminalType: String = "xterm-256color",
initialCols: UInt16 = 80,
initialRows: UInt16 = 24,
hostKeyVerifier: (@Sendable (Data, Int32) -> Bool)? = nil
hostKeyVerifier: (@Sendable (Data, Int32) -> Bool)? = nil,
connectTimeoutSec: Int = 12
) {
self.host = host
self.connectTimeoutSec = connectTimeoutSec
self.port = port
self.username = username
self.authentication = authentication