按 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>
268 lines
14 KiB
Swift
268 lines
14 KiB
Swift
#if DEBUG
|
||
import Foundation
|
||
import UIKit
|
||
|
||
/// 无头 UI 验证脚手架(仅 DEBUG)。
|
||
///
|
||
/// 本项目的构建机没有 GUI,每屏都靠 `simctl io screenshot` 取证对拍设计稿;而设计稿里的
|
||
/// 首页会话卡、轨道多主机头像、状态徽标都需要**多条已连接会话**才有内容。用真实主机验证既慢
|
||
/// 又依赖即时凭据,故提供这条假会话通道:只灌示意输出到 in-memory 终端,**不建任何传输连接**。
|
||
///
|
||
/// 用法:`xcrun simctl launch <UDID> ai.athom.terminalx --args -txUIFixture 1`
|
||
/// 真实端到端回归仍走 `-txHost/-txUser/-txPass`(见 CLAUDE.md 启动参数表),二者互斥。
|
||
enum UIPreviewFixture {
|
||
/// 三条会话对应设计稿 `1e 主机-会话卡` 的三张卡(直连 / 经 tsnet / 重连中)。
|
||
/// `name` 与 `hosts` 里的主机同名 → 会话复用同一 `SavedHost.id`,主机卡才能亮「已有会话」。
|
||
struct Spec {
|
||
let name: String
|
||
let link: LinkState
|
||
let tmux: TmuxSummary?
|
||
let lines: [String]
|
||
}
|
||
|
||
/// 假 Tailscale 出口 id(只为让主机卡亮 tsnet 徽章;fixture 不建连接)。
|
||
static let fixtureTailnetID = UUID(uuidString: "00000000-0000-0000-0000-0000000000FE")!
|
||
|
||
/// 示意 tailnet(首页副行 + 侧边栏脚的状态)。只进内存,不写盘。
|
||
static let tailnets: [TailscaleConnection] = [
|
||
TailscaleConnection(id: fixtureTailnetID, name: "athom.ts.net",
|
||
hostname: "ipad-pro", needsAuthKey: false, lastSelfIP: "100.92.14.7"),
|
||
]
|
||
|
||
/// 首页「全部主机」的示意主机(对拍设计稿 `1e` 的 7 台 + 添加卡)。只进内存,不写盘。
|
||
static let hosts: [SavedHost] = [
|
||
SavedHost(name: "mac-studio", host: "100.71.203.4", useMosh: true,
|
||
auth: .inlinePassword(username: "yz", password: ""),
|
||
tailscaleID: fixtureTailnetID, group: "生产", os: "macOS · M4 Max"),
|
||
SavedHost(name: "ubuntu-cn2", host: "100.88.9.61", useMosh: true,
|
||
auth: .inlinePassword(username: "yz", password: ""),
|
||
tailscaleID: fixtureTailnetID, group: "云", os: "Ubuntu 26.04"),
|
||
SavedHost(name: "edge-sg1", host: "10.1.2.11", useMosh: false,
|
||
auth: .inlinePassword(username: "root", password: ""),
|
||
tailscaleID: fixtureTailnetID, group: "云", os: "Debian 13"),
|
||
SavedHost(name: "edge-sg2", host: "10.1.2.12", useMosh: false,
|
||
auth: .inlinePassword(username: "root", password: ""),
|
||
group: "云", os: "Debian 13"),
|
||
SavedHost(name: "nas-home", host: "100.104.2.18", useMosh: true,
|
||
auth: .inlinePassword(username: "admin", password: ""),
|
||
group: "家里", os: "TrueNAS"),
|
||
SavedHost(name: "pi-lab", host: "192.168.1.31", useMosh: false,
|
||
auth: .inlinePassword(username: "pi", password: ""),
|
||
group: "家里", os: "Raspberry Pi OS"),
|
||
SavedHost(name: "mosh-lab", host: "192.168.1.44", useMosh: true,
|
||
auth: .inlinePassword(username: "yz", password: ""),
|
||
group: "家里", os: "Fedora 44"),
|
||
]
|
||
|
||
static let specs: [Spec] = [
|
||
Spec(name: "mac-studio", link: .direct,
|
||
tmux: TmuxSummary(name: "dev", windows: 3, panes: 4, currentTitle: "运行测试"), lines: [
|
||
"→ terminalx git:(main) git fetch --all --prune",
|
||
"Fetching origin … done",
|
||
"→ terminalx git:(main) swiftformat --lint Sources",
|
||
"✓ 0 issues in 84 files",
|
||
"→ terminalx git:(main) swift build -c release --arch arm64",
|
||
"[142/142] Compiling TXCore",
|
||
"✓ build complete (14.2s)",
|
||
"→ terminalx git:(main) swift test --package-path packages/TXCore",
|
||
"◇ Test run started.",
|
||
" ✔ parses %output escaped octal",
|
||
" ✔ layout diff keeps pane identity",
|
||
" ✔ host-key pin survives wipe",
|
||
"◇ 50 tests passed after 0.412s",
|
||
"→ terminalx git:(main) xcodegen generate --spec project.yml",
|
||
"✅ Created TerminalX.xcodeproj",
|
||
"→ terminalx git:(main) xcodebuild -scheme TerminalX build",
|
||
"** BUILD SUCCEEDED **",
|
||
"→ terminalx git:(main) git status --short",
|
||
" M apps/TerminalX/iOS/RailSidebar.swift",
|
||
" M apps/TerminalX/iOS/HomeView.swift",
|
||
"?? docs/design/UI-DESIGN-HANDOFF.md",
|
||
"→ terminalx git:(main) scripts/tx-shot.sh /tmp/v3.png",
|
||
" pixelWidth: 2752",
|
||
" pixelHeight: 2064",
|
||
"→ terminalx git:(main) swift test --package-path packages/TXCore",
|
||
"◇ 50 tests passed after 0.398s",
|
||
"→ terminalx git:(main) tmux ls",
|
||
"dev: 3 windows (created Fri 09:12:03)",
|
||
"scratch: 1 windows (created Tue)",
|
||
"→ terminalx git:(main) tailscale status | head -3",
|
||
"100.71.203.4 mac-studio direct",
|
||
"100.88.9.61 ubuntu-cn2 relay hkg",
|
||
"→ terminalx git:(main) ",
|
||
]),
|
||
Spec(name: "ubuntu-cn2", link: .relay,
|
||
tmux: TmuxSummary(name: "build", windows: 1, panes: 1, currentTitle: "集成测试"), lines: [
|
||
"→ cn2 ~ journalctl -u build-runner -n 3 --no-pager",
|
||
"12:05:02 Started build-runner@42",
|
||
"12:06:02 build-runner@42 succeeded",
|
||
"→ cn2 ~ systemctl status build-runner --no-pager",
|
||
"● build-runner.service — active (running)",
|
||
" since Fri 09:12:03 CST; 2h 54min ago",
|
||
"→ cn2 ~ ninja -C build -j 8",
|
||
"[148/148] Linking libmosh-core.a",
|
||
"✓ ninja: no work to do.",
|
||
"→ cn2 ~ tailscale netcheck | head -5",
|
||
" * UDP: true",
|
||
" * IPv4: yes",
|
||
" * UPnP: false",
|
||
"→ cn2 ~ ./run-integration.sh --tsnet",
|
||
"▸ joining tailnet as ci-runner…",
|
||
"▸ peer enumeration: 7 nodes",
|
||
"▸ mosh relay loopback:60122 ⇄ tsnet",
|
||
"▸ handshake ok, AES-OCB session up",
|
||
" roaming: peer addr switched",
|
||
" resume ok in 1.8s, 0 bytes lost",
|
||
" suspend 90s → wake",
|
||
" resume ok in 2.4s",
|
||
"▸ 14/14 passed",
|
||
"→ cn2 ~ df -h /tank | tail -1",
|
||
"tank 187T 217T 7.87 42%",
|
||
"→ cn2 ~ ",
|
||
]),
|
||
Spec(name: "nas-home", link: .reconnecting(attempt: 3), tmux: nil, lines: [
|
||
"nas ~ $ zfs get compressratio tank",
|
||
"tank compressratio 1.34x",
|
||
"nas ~ $ docker ps --format 'table {{.Names}}'",
|
||
"NAMES STATUS",
|
||
"plex Up 6 days",
|
||
"syncthing Up 6 days",
|
||
"nas ~ $ uname -sr",
|
||
"Linux 6.12.9-truenas",
|
||
"nas ~ $ zpool status -x",
|
||
" all pools are healthy",
|
||
"nas ~ $ df -h /tank",
|
||
"Filesystem Size Used Avail Use%",
|
||
"tank 187T 217T 7.87 42%",
|
||
"nas ~ $ zfs list -t snapshot | tail -3",
|
||
"tank/media@auto-2026-07-24 1.2G",
|
||
"tank/docs@auto-2026-07-24 318M",
|
||
"tank/vm@auto-2026-07-24 2.4G",
|
||
"nas ~ $ smartctl -H /dev/sda | tail -2",
|
||
"SMART overall-health: PASSED",
|
||
"nas ~ $ systemctl is-active smbd nfs-server",
|
||
"active",
|
||
"active",
|
||
"nas ~ $ tail -2 /var/log/messages",
|
||
"12:04:31 nas kernel: wlan0 rssi -74",
|
||
"12:06:20 nas tailscaled: peer offline",
|
||
"nas ~ $ ",
|
||
]),
|
||
]
|
||
|
||
/// 分屏菜单要列的会话(当前 attached + 两个 detached)。对拍设计稿 `3b`。
|
||
static func tmuxSessions(current: String) -> [TmuxSessionInfo] {
|
||
// 相对时刻用 timeIntervalSinceNow 造(脚本里不能用固定日期,会随时间漂移)。
|
||
[
|
||
TmuxSessionInfo(name: current, windows: 3, isAttached: true, lastActivity: nil),
|
||
TmuxSessionInfo(name: "scratch", windows: 1, isAttached: false,
|
||
lastActivity: Date(timeIntervalSinceNow: -3 * 86400)),
|
||
TmuxSessionInfo(name: "deploy", windows: 2, isAttached: false,
|
||
lastActivity: Date(timeIntervalSinceNow: -86400)),
|
||
]
|
||
}
|
||
|
||
/// 把启动参数拼成一台主机(供 `-txOpenAsHost 1` 走真实的「点主机连接」路径)。
|
||
static func hostFromLaunchArgs() -> SavedHost? {
|
||
let d = UserDefaults.standard
|
||
guard let host = d.string(forKey: "txHost"), !host.isEmpty,
|
||
let user = d.string(forKey: "txUser") else { return nil }
|
||
let port = d.integer(forKey: "txPort")
|
||
return SavedHost(
|
||
name: host, host: host, port: port == 0 ? 22 : port,
|
||
useMosh: d.bool(forKey: "txMosh"),
|
||
useTmux: d.object(forKey: "txTmux") == nil ? true : d.bool(forKey: "txTmux"),
|
||
tmuxSession: d.string(forKey: "txTmuxSession"),
|
||
auth: .inlinePassword(username: user, password: d.string(forKey: "txPass") ?? ""))
|
||
}
|
||
|
||
/// 是否启用(启动参数 `-txUIFixture 1`)。
|
||
static var isEnabled: Bool {
|
||
UserDefaults.standard.bool(forKey: "txUIFixture")
|
||
}
|
||
|
||
/// 无头导航驱动:`xcrun simctl openurl <UDID> "terminalx://ui/<动作>"`。
|
||
///
|
||
/// 为什么不用坐标点击:本环境没有 GUI,idb companion 不可用;而坐标点击还会随布局改动失效。
|
||
/// 走 URL 直接驱动 `AppRouter`/`SessionManager` 的公开语义,既稳定又正是要验的那条路径。
|
||
///
|
||
/// 支持:`home` · `minimize` · `close`(弹确认) · `close-now`(直接关) · `split/v|h`
|
||
/// · `split-now/v|h`(直接分屏) · `resize/<cols>`(resize-pane)
|
||
/// · `pick/<会话名>` · `pick-new/<名>` · `pick-native`(连接前的会话选择器)
|
||
/// · `dismiss` · `session/<序号>`(1 起,按首页卡片顺序)· `sidebar/toggle|pin`
|
||
@MainActor
|
||
static func handle(uiCommand path: [String], manager: SessionManager, router: AppRouter) -> Bool {
|
||
switch path.first {
|
||
case "home":
|
||
router.goHome()
|
||
case "minimize":
|
||
if let id = router.route.sessionID { manager.minimize(id); router.goHome() }
|
||
case "close":
|
||
// 与红点一致:弹确认对话框(真正关闭要在对话框里选)。
|
||
guard router.route.sessionID != nil else { return false }
|
||
router.present(.closeConfirm)
|
||
case "close-now":
|
||
// 跳过确认直接关(回归脚本用)。
|
||
if let id = router.route.sessionID { router.goHome(); manager.close(id) }
|
||
case "split":
|
||
guard router.route.sessionID != nil else { return false }
|
||
router.present(.splitMenu(vertical: path.dropFirst().first != "h"))
|
||
case "dismiss":
|
||
router.dismissModal()
|
||
case "split-now":
|
||
// 跳过菜单直接对当前会话分屏(验证 split-window 链路)。
|
||
guard let id = router.route.sessionID, let s = manager.session(id),
|
||
let tmux = s.tmuxController else { return false }
|
||
tmux.splitWindow(vertical: path.dropFirst().first != "h")
|
||
case "pick":
|
||
// 会话选择器:attach 指定会话(`ui/pick/<name>`)。此时通常还在首页,故优先取待选择的会话。
|
||
guard let s = manager.sessionAwaitingChoice ?? router.route.sessionID.flatMap(manager.session),
|
||
let name = path.dropFirst().first else { return false }
|
||
s.attachTmuxSession(name)
|
||
case "pick-new":
|
||
guard let s = manager.sessionAwaitingChoice ?? router.route.sessionID.flatMap(manager.session)
|
||
else { return false }
|
||
s.createTmuxSession(path.dropFirst().first ?? "main")
|
||
case "pick-native":
|
||
// 选择器里的「原生终端」。会话可能还没进终端页,故不依赖 route。
|
||
guard let s = manager.sessionAwaitingChoice ?? router.route.sessionID.flatMap(manager.session)
|
||
else { return false }
|
||
s.useNativeTerminal()
|
||
case "resize":
|
||
// 验证 resize-pane 链路(拖拽手势本身是标准 DragGesture):`ui/resize/<cols>`。
|
||
guard let id = router.route.sessionID, let s = manager.session(id),
|
||
let tmux = s.tmuxController,
|
||
let win = tmux.windows.first(where: { $0.id == tmux.activeWindowID }),
|
||
let pane = win.activePane,
|
||
let cols = path.dropFirst().first.flatMap(Int.init) else { return false }
|
||
tmux.resizePane(pane, cols: cols)
|
||
case "session":
|
||
guard let n = path.dropFirst().first.flatMap(Int.init),
|
||
let s = manager.session(atShortcutIndex: n - 1) else { return false }
|
||
manager.focus(s)
|
||
router.enter(s.id)
|
||
case "sidebar":
|
||
if path.dropFirst().first == "pin" { router.togglePin() } else { router.toggleSidebar() }
|
||
default:
|
||
return false
|
||
}
|
||
NSLog("TXUI cmd=\(path.joined(separator: "/")) route=\(router.route) sidebar=\(router.sidebar)")
|
||
return true
|
||
}
|
||
|
||
/// 无头验证强制横屏(`-txLandscape 1`)。
|
||
///
|
||
/// 没有 GUI 时模拟器起来是竖屏,而设计基准是 iPad Pro 13″ 横屏 1366×1024;`simctl` 没有旋转
|
||
/// 能力,故用 iOS 16+ 的 `requestGeometryUpdate` 由 app 侧请求方向,让 `simctl io screenshot`
|
||
/// 取到横屏帧。仅 DEBUG,生产路径不碰方向(用户可自由旋转)。
|
||
@MainActor
|
||
static func forceLandscapeIfRequested() {
|
||
guard UserDefaults.standard.bool(forKey: "txLandscape") else { return }
|
||
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
|
||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: .landscapeRight)) { error in
|
||
NSLog("TXUI landscape request failed: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
}
|
||
#endif
|