Files
terminalX/apps/TerminalX/iOS/TerminalChrome.swift
kid 28e9cfc207 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>
2026-07-25 22:59:35 +08:00

407 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
// 稿 `1b -` chrome + +
//
// absolute + blur
// 86pt padding clipped
/// blur + + + 11
private struct FloatingCapsule<Content: View>: View {
@EnvironmentObject var theme: TXThemeManager
@ViewBuilder let content: Content
private var p: TXPalette { theme.palette }
var body: some View {
content
.padding(.horizontal, TX.Space.s)
.frame(height: 34)
.background {
RoundedRectangle(cornerRadius: TX.Radius.capsule)
.fill(p.surfaceDark.opacity(0.88))
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: TX.Radius.capsule))
}
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.capsule)
.strokeBorder(p.border, lineWidth: 1)
)
.shadow(color: .black.opacity(0.55), radius: 15, y: 10)
}
}
/// / window title · cwd
struct TerminalTitleCapsule: View {
@ObservedObject var session: TerminalSession
let onClose: () -> Void
let onMinimize: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
FloatingCapsule {
HStack(spacing: TX.Space.xs) {
windowDot(TXWindowDot.close, "关闭会话", action: onClose)
windowDot(TXWindowDot.minimize, "最小化", action: onMinimize)
Rectangle().fill(p.border).frame(width: 1, height: 14)
.padding(.horizontal, 2)
TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 6)
Text(session.displayName)
.font(TX.Font.mono(12.5, .medium))
.foregroundStyle(p.text)
.lineLimit(1)
if !contextLine.isEmpty {
Text(contextLine)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
// 稿 08 · 168×44 @Published
Text(gridLabel)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textFaint)
}
}
}
/// window title · cwd稿 3a tmux 退
private var contextLine: String {
guard let t = session.tmuxSummary else {
return session.isLive ? "" : session.phaseText
}
var parts: [String] = []
if !t.currentTitle.isEmpty { parts.append(t.currentTitle) }
if !t.cwd.isEmpty { parts.append(Self.abbreviate(t.cwd)) }
return parts.joined(separator: " · ")
}
/// `/Users/yz/src/x` `~/src/x`稿
static func abbreviate(_ path: String) -> String {
let home = NSHomeDirectory()
if !home.isEmpty, path.hasPrefix(home) { return "~" + path.dropFirst(home.count) }
// home
for prefix in ["/home/", "/Users/"] where path.hasPrefix(prefix) {
let rest = path.dropFirst(prefix.count)
if let slash = rest.firstIndex(of: "/") { return "~" + rest[slash...] }
return "~"
}
return path
}
private var gridLabel: String {
let g = session.screenGrid.value
return "\(g.cols)×\(g.rows)"
}
private func windowDot(_ color: Color, _ label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Circle()
.fill(color)
.frame(width: 13, height: 13)
.overlay(Circle().strokeBorder(Color.black.opacity(0.25), lineWidth: 0.5))
.frame(width: 22, height: 30)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(label)
}
}
/// `tsnet | mosh | tmux` 1×16pt 线
///
/// **** tmux
/// tmux · RTT HANDOFF §12.4
struct TerminalStatusCapsule: View {
@ObservedObject var session: TerminalSession
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
FloatingCapsule {
HStack(spacing: 0) {
cell(egressText, color: egressColor, dot: true)
separator
cell(transportText, color: transportColor, dot: false)
separator
cell(tmuxText, color: p.text2, dot: false)
}
}
}
private var separator: some View {
Rectangle().fill(p.border).frame(width: 1, height: 16)
.padding(.horizontal, TX.Space.s)
}
private func cell(_ text: String, color: Color, dot: Bool) -> some View {
HStack(spacing: 5) {
if dot { TXStatusDot(color: color, size: 6) }
Text(text)
.font(TX.Font.mono(12, .medium))
.foregroundStyle(color)
.lineLimit(1)
}
}
// tsnet /
private var egressText: String {
switch session.link {
case .relay: "tsnet"
case .direct: "direct"
case .connecting: "连接中"
case .reconnecting: "tsnet"
case .offline: "离线"
}
}
private var egressColor: Color {
switch session.link {
case .direct, .relay: p.online
case .connecting: p.text4
case .reconnecting: p.reconnecting
case .offline: p.offline
}
}
// mosh / SSH
private var transportText: String {
if case .reconnecting(let n) = session.link {
return "\(session.moshEngaged ? "mosh" : "ssh") 重连 \(n)"
}
return session.moshEngaged ? "mosh" : "ssh"
}
private var transportColor: Color {
if case .reconnecting = session.link { return p.reconnecting }
// RTT 绿/ RTT
return p.text2
}
//
private var tmuxText: String {
if let t = session.tmuxSummary { return t.name.isEmpty ? "tmux" : "tmux \(t.name)" }
// / tmux
if session.pendingSessionChoice != nil { return "选择 tmux 会话…" }
if session.tmuxSkipped { return "原生窗口 · 不经 tmux" }
if session.tmuxUnavailable { return "无 tmux · 客户端窗口" }
return session.isLive ? "检测 tmux…" : ""
}
}
/// **** / /
///
/// tsnet Up dial mosh tmux attach
///
/// ****
struct TerminalStateOverlay: View {
@ObservedObject var session: TerminalSession
let onRetry: () -> Void
let onBack: () -> Void
///
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
/// tsnet Up / mosh
@State private var waited = 0
private var p: TXPalette { theme.palette }
/// phaseText SessionMachine ``
private var failure: String? {
session.phaseText.hasPrefix("失败") ? String(session.phaseText.dropFirst(3)) : nil
}
private var isConnecting: Bool {
!session.isLive && failure == nil && session.phaseText != "已关闭"
}
var body: some View {
if let failure {
state(icon: "exclamationmark.triangle", tint: p.danger, title: "连接失败",
detail: failure) {
HStack(spacing: TX.Space.s) {
TXButton(title: "返回首页", action: onBack)
TXButton(title: "重试", emphasized: true, action: onRetry)
}
}
} else if session.phaseText == "已关闭" {
state(icon: "power", tint: p.textWeak, title: "会话已关闭",
detail: "服务端连接已断开。") {
HStack(spacing: TX.Space.s) {
TXButton(title: "返回首页", action: onBack)
TXButton(title: "重新连接", emphasized: true, action: onRetry)
}
}
} else if isConnecting {
state(icon: nil, tint: p.text4, title: session.phaseText,
detail: session.banner.isEmpty ? connectHint : session.banner) {
VStack(spacing: TX.Space.xs) {
if waited >= 3 {
Text("已等待 \(waited)s")
.font(TX.Font.mono(11))
.foregroundStyle(p.textFaint)
}
if waited >= 5 {
// 12s
TXButton(title: "取消连接", action: onCancel)
}
}
}
.task(id: session.phaseText) {
waited = 0
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000)
waited += 1
}
}
}
}
/// tsnet Up mosh
private var connectHint: String {
switch session.link {
case .relay: "正在经 Tailscale 出口建立连接…"
case .connecting, .direct: session.moshEngaged ? "正在引导 mosh…" : "正在建立 SSH 连接…"
case .reconnecting(let n): "\(n) 次重连中…"
case .offline: "等待连接…"
}
}
private func state<Actions: View>(icon: String?, tint: Color, title: String, detail: String,
@ViewBuilder actions: () -> Actions) -> some View {
VStack(spacing: TX.Space.s) {
if let icon {
Image(systemName: icon)
.font(.system(size: 26, weight: .light))
.foregroundStyle(tint)
} else {
ProgressView().controlSize(.regular).tint(TXAccent.base)
}
Text(title)
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Text(detail)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.multilineTextAlignment(.center)
.lineLimit(3)
.frame(maxWidth: 420)
actions()
.padding(.top, TX.Space.xs)
}
.padding(TX.Space.l)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
/// tmux 稿 `2c` App mosh ** App
/// ** tmux****
struct NoTmuxBanner: View {
let onInstall: () -> Void
let onDismiss: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: TX.Space.s) {
Image(systemName: "info.circle")
.font(.system(size: 12))
.foregroundStyle(p.warning)
Text("未检测到 tmux · 当前是客户端窗口mosh 能兜断线,但**关掉 App 后服务端不会继续跑**")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.text3)
.lineLimit(1)
Spacer(minLength: TX.Space.s)
TXButton(title: "安装并启用 tmux", action: onInstall)
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(p.textWeak)
.frame(width: 28, height: 28)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.padding(.horizontal, TX.Space.s)
.frame(height: 40)
.background(p.surfaceDark.opacity(0.92))
.overlay(alignment: .bottom) { Rectangle().fill(p.divider).frame(height: 1) }
}
}
/// tmux
struct TmuxInstallSheet: View {
let onRun: (String) -> Void
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
///
private let combined = "command -v apt-get >/dev/null && sudo apt-get install -y tmux || " +
"command -v dnf >/dev/null && sudo dnf install -y tmux || " +
"command -v pacman >/dev/null && sudo pacman -S --noconfirm tmux || " +
"command -v apk >/dev/null && sudo apk add tmux || " +
"command -v brew >/dev/null && brew install tmux"
var body: some View {
TXModalScrim(onDismiss: onCancel) {
VStack(alignment: .leading, spacing: TX.Space.s) {
Text("安装 tmux")
.font(TX.Font.mono(15, .medium))
.foregroundStyle(p.text)
Text("tmux 让会话留在服务端:关掉 App、换网络、睡一觉回来都能原样接回。\n下面这条会按服务器的包管理器自动选择(需要 sudo 权限)。")
.font(TX.Font.mono(12))
.foregroundStyle(p.text3)
.lineSpacing(4)
Text(combined)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.text4)
.padding(TX.Space.s)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.bgDeep, in: RoundedRectangle(cornerRadius: 8))
HStack(spacing: TX.Space.s) {
Spacer()
TXButton(title: "取消", action: onCancel)
TXButton(title: "在终端执行", emphasized: true) { onRun(combined) }
}
.padding(.top, TX.Space.xs)
}
.padding(TX.Space.l)
.frame(width: 520)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.dialog)
.strokeBorder(p.borderStrong, lineWidth: 1)
)
.shadow(color: .black.opacity(0.7), radius: 40, y: 30)
}
}
}
/// 34ptUI-5
struct TerminalHintBar: View {
/// Pin · / resize
let context: String
let hints: [(key: String, label: String)]
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: TX.Space.s) {
Text(context)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
Spacer(minLength: TX.Space.m)
ForEach(hints, id: \.key) { hint in
HStack(spacing: 5) {
Text(hint.key)
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(p.text4)
Text(hint.label)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
}
}
}
.padding(.horizontal, TX.Space.m)
.frame(height: TX.Layout.footHeight)
}
}