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,206 @@
import SwiftUI
/// Tailscale tsnet
struct TailscaleListView: View {
@ObservedObject var store: TailscaleStore
@EnvironmentObject var theme: TXThemeManager
@State private var editing: TailscaleConnection?
@State private var showEditor = false
private var p: TXPalette { theme.palette }
var body: some View {
ZStack {
p.bg.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: TX.Space.s) {
if store.connections.isEmpty {
TXEmptyState(icon: "network", title: "还没有 Tailscale 连接",
subtitle: "添加一个 tailnet主机可选择经它连接")
} else {
ForEach(store.connections) { conn in
connRow(conn)
.contextMenu {
Button { editing = conn; showEditor = true } label: {
Label("编辑", systemImage: "pencil")
}
Button(role: .destructive) { store.remove(conn) } label: {
Label("删除", systemImage: "trash")
}
}
}
}
}
.padding(TX.Space.m)
}
}
.navigationTitle("Tailscale")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(p.bg, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { editing = nil; showEditor = true } label: { Image(systemName: "plus") }
}
}
.sheet(isPresented: $showEditor) {
TailscaleEditorView(existing: editing, store: store)
}
}
private func connRow(_ conn: TailscaleConnection) -> some View {
HStack(spacing: TX.Space.m) {
Image(systemName: "network")
.font(.system(size: 18))
.foregroundStyle(conn.needsAuthKey ? p.textTertiary : p.mosh)
.frame(width: 40, height: 40)
.background(p.surfaceElevated, in: RoundedRectangle(cornerRadius: TX.Radius.chip))
VStack(alignment: .leading, spacing: 2) {
Text(conn.name).font(.system(size: 15, weight: .semibold)).foregroundStyle(p.textPrimary)
Text(conn.needsAuthKey ? "未认证" : (conn.lastSelfIP.map { "已注册 · \($0)" } ?? "已注册"))
.font(TX.Font.mono(12))
.foregroundStyle(conn.needsAuthKey ? p.warning : p.textSecondary)
}
Spacer()
Circle().fill(conn.needsAuthKey ? p.offline : p.mosh).frame(width: 8, height: 8)
}
.padding(TX.Space.m)
.background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1))
}
}
/// Tailscale name/hostname/authKey Up tsnet SelfIP
struct TailscaleEditorView: View {
let existing: TailscaleConnection?
@ObservedObject var store: TailscaleStore
@EnvironmentObject var theme: TXThemeManager
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var hostname = "terminalx-ipad"
@State private var authKey = ""
@State private var registering = false
@State private var statusText = ""
@State private var registered = false
@State private var draft: TailscaleConnection?
private var p: TXPalette { theme.palette }
private var canRegister: Bool { !name.isEmpty && (!authKey.isEmpty || registered) }
var body: some View {
NavigationStack {
ZStack {
p.bg.ignoresSafeArea()
Form {
Section("连接") {
TXTextField(title: "名称", text: $name, placeholder: "公司 tailnet")
TXTextField(title: "本节点主机名", text: $hostname)
}
Section {
TXTextField(title: "Auth Keytskey-…)", text: $authKey, secure: true)
Button {
register()
} label: {
HStack {
if registering { ProgressView().controlSize(.small) }
Text(registering ? "注册中…" : "注册 / 连接测试")
}
}
.disabled(!canRegister || registering)
.txRow(p)
} header: {
Text("认证")
} footer: {
Text("Auth key 仅用于首次注册,注册成功后不保存(节点身份持久化在设备本地)。")
}
if !statusText.isEmpty {
Section {
Text(statusText)
.font(.system(size: 13))
.foregroundStyle(registered ? p.accent : p.warning)
.txRow(p)
}
}
}
.scrollContentBackground(.hidden)
}
.navigationTitle(existing == nil ? "添加 Tailscale" : "编辑 Tailscale")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }.tint(p.textSecondary)
}
ToolbarItem(placement: .confirmationAction) {
Button("完成") { finish() }.tint(p.accent)
}
}
.toolbarBackground(p.bg, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.onAppear(perform: populate)
}
.tint(p.accent)
}
private func populate() {
guard let c = existing else { return }
name = c.name; hostname = c.hostname
registered = !c.needsAuthKey
draft = c
if registered { statusText = c.lastSelfIP.map { "已注册 · \($0)" } ?? "已注册" }
}
/// TsnetRegistry Up SelfIP needsAuthKey=false
private func register() {
// stateDir
let conn = draft ?? existing ?? {
let c = TailscaleConnection(name: name, hostname: hostname)
return c
}()
draft = conn
registering = true; statusText = ""
let connID = conn.id, dir = conn.stateDirName, host = hostname, key = authKey
Task.detached {
do {
let node = try TsnetRegistry.shared.node(
connID: connID, stateDirName: dir, hostname: host, authKey: key, timeoutMs: 45000)
let ip = node.selfIP()
await MainActor.run {
registering = false; registered = true
statusText = ip.isEmpty ? "已注册IP 获取中)" : "已注册 · \(ip)"
// id/stateDir
let updated = TailscaleConnection(id: connID, name: name.isEmpty ? "tailnet" : name,
hostname: host, needsAuthKey: false,
lastSelfIP: ip.isEmpty ? nil : ip)
persist(updated)
}
} catch {
await MainActor.run {
registering = false
statusText = "注册失败:\(error.localizedDescription)"
}
}
}
}
/// metaneedsAuthKey=true
private func finish() {
let base = draft ?? existing
let conn: TailscaleConnection
if let base {
conn = TailscaleConnection(id: base.id, name: name.isEmpty ? "tailnet" : name,
hostname: hostname, needsAuthKey: !registered,
lastSelfIP: registered ? base.lastSelfIP : nil)
} else {
conn = TailscaleConnection(name: name.isEmpty ? "tailnet" : name,
hostname: hostname, needsAuthKey: !registered)
}
persist(conn)
dismiss()
}
private func persist(_ conn: TailscaleConnection) {
if store.connections.contains(where: { $0.id == conn.id }) { store.update(conn) }
else { store.add(conn) }
draft = conn
}
}