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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ public struct SSHConfig: Sendable {
|
||||
/// (trusted 或 firstUse 已 pin),false=拒绝(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
|
||||
|
||||
Reference in New Issue
Block a user