#if DEBUG import Foundation import UIKit /// 无头 UI 验证脚手架(仅 DEBUG)。 /// /// 本项目的构建机没有 GUI,每屏都靠 `simctl io screenshot` 取证对拍设计稿;而设计稿里的 /// 首页会话卡、轨道多主机头像、状态徽标都需要**多条已连接会话**才有内容。用真实主机验证既慢 /// 又依赖即时凭据,故提供这条假会话通道:只灌示意输出到 in-memory 终端,**不建任何传输连接**。 /// /// 用法:`xcrun simctl launch 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 ~ $ ", ]), ] /// 把启动参数拼成一台主机(供 `-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 "terminalx://ui/<动作>"`。 /// /// 为什么不用坐标点击:本环境没有 GUI,idb companion 不可用;而坐标点击还会随布局改动失效。 /// 走 URL 直接驱动 `AppRouter`/`SessionManager` 的公开语义,既稳定又正是要验的那条路径。 /// /// 支持:`home` · `minimize` · `close`(弹确认) · `close-now`(直接关) /// · `pick/<会话名>` · `pick-new/<名>` · `pick-native`(连接前的会话选择器) /// · `dismiss` · `session/<序号>`(1 起,按首页卡片顺序)· `sidebar/toggle|pin` /// (`resize/` 已随「pane 拖拽调宽」一起删除 —— pane 尺寸交给 tmux 自己。) @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 "dismiss": router.dismissModal() case "pick": // 会话选择器:attach 指定会话(`ui/pick/`)。此时通常还在首页,故优先取待选择的会话。 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 "session": guard let n = path.dropFirst().first.flatMap(Int.init), let s = manager.session(atShortcutIndex: n - 1) else { return false } manager.activate(s) router.enter(s.id) case "sidebar": // 悬浮态已砍(用户裁决):只剩收起⇄固定,pin 与 toggle 同义(兼容旧脚本)。 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