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,358 @@
import SwiftUI
import UniformTypeIdentifiers
import TXCore
/// Keychain
struct KeychainListView: View {
@ObservedObject var store: FileCredentialStore
@EnvironmentObject var theme: TXThemeManager
@State private var editing: Credential?
@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.credentials.isEmpty {
TXEmptyState(icon: "key.fill", title: "还没有凭据",
subtitle: "添加密码或 SSH 私钥,供主机复用")
} else {
ForEach(store.credentials) { cred in
credRow(cred)
.contextMenu {
Button { editing = cred; showEditor = true } label: {
Label("编辑", systemImage: "pencil")
}
Button(role: .destructive) { store.delete(cred.id) } label: {
Label("删除", systemImage: "trash")
}
}
}
}
}
.padding(TX.Space.m)
}
}
.navigationTitle("Keychain 凭据")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(p.bg, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { editing = nil; showEditor = true } label: { Image(systemName: "plus") }
}
}
.sheet(isPresented: $showEditor) {
CredentialEditorView(existing: editing, store: store) { _ in }
}
}
private func credRow(_ cred: Credential) -> some View {
HStack(spacing: TX.Space.m) {
Image(systemName: cred.iconName)
.font(.system(size: 18))
.foregroundStyle(p.accent)
.frame(width: 40, height: 40)
.background(p.surfaceElevated, in: RoundedRectangle(cornerRadius: TX.Radius.chip))
VStack(alignment: .leading, spacing: 2) {
Text(cred.name).font(.system(size: 15, weight: .semibold)).foregroundStyle(p.textPrimary)
Text("\(cred.kindLabel) · \(cred.username)")
.font(TX.Font.mono(12)).foregroundStyle(p.textSecondary)
}
Spacer()
}
.padding(TX.Space.m)
.background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1))
}
}
/// / SSH / Face IDSSH / /
struct CredentialEditorView: View {
let existing: Credential?
@ObservedObject var store: FileCredentialStore
let onSave: (Credential) -> Void
@EnvironmentObject var theme: TXThemeManager
@Environment(\.dismiss) private var dismiss
private enum Kind: Hashable { case password, sshKey, faceID }
private enum KeySource: Hashable { case generate, paste, importFile }
@State private var name = ""
@State private var username = ""
@State private var kind: Kind = .password
@State private var password = ""
@State private var keySource: KeySource = .generate
@State private var keyText = ""
@State private var passphrase = ""
@State private var parsed: CredentialAuth.ParsedKey?
//
@State private var generatedKind: Credential.Kind?
@State private var generatedSecret: CredentialSecret?
@State private var generatedFingerprint = ""
@State private var generatedAuthLine = ""
@State private var showFileImporter = false
@State private var importError: String?
private let seAvailable = SigningKeyProvider.secureEnclaveAvailable()
private let isEditing: Bool
private var p: TXPalette { theme.palette }
init(existing: Credential?, store: FileCredentialStore, onSave: @escaping (Credential) -> Void) {
self.existing = existing
self.store = store
self.onSave = onSave
self.isEditing = existing != nil
}
private var canSave: Bool {
guard !username.isEmpty else { return false }
switch kind {
case .password: return true
case .sshKey:
if isEditing { return true } // /
switch keySource {
case .generate: return generatedSecret != nil
case .paste, .importFile: return !keyText.isEmpty
}
case .faceID: return false
}
}
var body: some View {
NavigationStack {
ZStack {
p.bg.ignoresSafeArea()
Form {
Section("基本") {
TXTextField(title: "名称", text: $name, placeholder: "我的密钥")
TXTextField(title: "用户名", text: $username, placeholder: "登录服务器的用户名")
}
Section("类型") {
Picker("类型", selection: $kind) {
Text("密码").tag(Kind.password)
Text("SSH 密钥").tag(Kind.sshKey)
Text("Face ID").tag(Kind.faceID)
}
.pickerStyle(.segmented)
.txRow(p)
}
switch kind {
case .password: passwordSection
case .sshKey: sshKeySection
case .faceID: faceIDSection
}
}
.scrollContentBackground(.hidden)
}
.navigationTitle(isEditing ? "编辑凭据" : "添加凭据")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }.tint(p.textSecondary)
}
ToolbarItem(placement: .confirmationAction) {
Button("保存") { save() }.disabled(!canSave).tint(p.accent)
}
}
.toolbarBackground(p.bg, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.data, .text]) { result in
handleImport(result)
}
.onAppear(perform: populate)
}
.tint(p.accent)
}
// MARK: -
@ViewBuilder private var passwordSection: some View {
Section("密码") {
TXTextField(title: "密码", text: $password, secure: true)
}
}
@ViewBuilder private var sshKeySection: some View {
Section("SSH 私钥") {
Picker("来源", selection: $keySource) {
Text("生成").tag(KeySource.generate)
Text("粘贴").tag(KeySource.paste)
Text("导入").tag(KeySource.importFile)
}
.pickerStyle(.segmented)
.txRow(p)
switch keySource {
case .generate: generateContent
case .paste: pasteContent
case .importFile: importContent
}
}
}
@ViewBuilder private var generateContent: some View {
if generatedSecret == nil {
Button {
generate()
} label: {
Label("生成 ECDSA P-256 密钥", systemImage: "sparkles")
}
.txRow(p)
} else {
VStack(alignment: .leading, spacing: 6) {
Text("已生成 ECDSA P-256").font(.system(size: 13, weight: .medium)).foregroundStyle(p.accent)
Text("公钥指纹:\(generatedFingerprint)").font(TX.Font.mono(11)).foregroundStyle(p.textSecondary)
Text("authorized_keys复制到服务器").font(.system(size: 12)).foregroundStyle(p.textSecondary)
Text(generatedAuthLine).font(TX.Font.mono(10)).foregroundStyle(p.textPrimary)
.textSelection(.enabled).lineLimit(3)
Button { UIPasteboard.general.string = generatedAuthLine } label: {
Label("复制 authorized_keys 行", systemImage: "doc.on.doc")
}
.font(.system(size: 13)).padding(.top, 4)
}
.txRow(p)
}
}
@ViewBuilder private var pasteContent: some View {
VStack(alignment: .leading, spacing: 6) {
Text("粘贴 PEM / PKCS#8 私钥内容").font(.system(size: 12)).foregroundStyle(p.textSecondary)
TextEditor(text: $keyText)
.font(TX.Font.mono(11)).frame(minHeight: 120)
.foregroundStyle(p.textPrimary).scrollContentBackground(.hidden)
.background(p.bg, in: RoundedRectangle(cornerRadius: 8))
.onChange(of: keyText) { _, t in parsed = CredentialAuth.parsePrivateKey(t, source: .pasted) }
detectionLabel
}
.txRow(p)
if parsedNeedsPassphrase {
TXTextField(title: "私钥口令passphrase", text: $passphrase, secure: true)
}
}
@ViewBuilder private var importContent: some View {
VStack(alignment: .leading, spacing: 6) {
Button { showFileImporter = true } label: {
Label("从文件导入私钥", systemImage: "folder")
}
if let e = importError {
Text(e).font(.system(size: 12)).foregroundStyle(.red)
}
if !keyText.isEmpty { detectionLabel }
}
.txRow(p)
if parsedNeedsPassphrase {
TXTextField(title: "私钥口令passphrase", text: $passphrase, secure: true)
}
}
@ViewBuilder private var faceIDSection: some View {
Section {
HStack {
Image(systemName: "faceid").foregroundStyle(seAvailable ? p.accent : p.textTertiary)
Text(seAvailable ? "Face ID 密钥Secure Enclave" : "Face ID 密钥(需签名构建)")
.foregroundStyle(seAvailable ? p.textPrimary : p.textTertiary)
}
.txRow(p)
} footer: {
Text(seAvailable
? "私钥生成于 Secure Enclave不可导出签名时经 Face ID 授权。"
: "当前构建未签名Secure Enclave 不可用。签名真机构建后此选项自动可用。")
}
}
@ViewBuilder private var detectionLabel: some View {
if let parsed {
VStack(alignment: .leading, spacing: 3) {
Text("检测到:\(parsed.formatLabel)").font(.system(size: 12)).foregroundStyle(p.accent)
if let w = parsed.warning {
Text(w).font(.system(size: 11)).foregroundStyle(p.warning)
}
}
}
}
private var parsedNeedsPassphrase: Bool {
if case .privateKey(_, _, let hasPass, _) = parsed?.kind { return hasPass }
return false
}
// MARK: -
private func generate() {
guard let (k, s) = CredentialAuth.generateECDSAP256() else { return }
generatedKind = k; generatedSecret = s
if case .privateKey(_, _, _, let blob) = k {
generatedAuthLine = "ecdsa-sha2-nistp256 \(blob.base64EncodedString()) \(username.isEmpty ? "terminalx" : username)@terminalx"
generatedFingerprint = HostKey.opensshFingerprint(blob)
}
}
private func handleImport(_ result: Result<URL, Error>) {
switch result {
case .failure(let e): importError = e.localizedDescription
case .success(let url):
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
guard let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) else {
importError = "无法读取文件(需 UTF-8 文本私钥)"; return
}
importError = nil
keyText = text
parsed = CredentialAuth.parsePrivateKey(text, source: .imported)
}
}
private func populate() {
guard let c = existing else { return }
name = c.name; username = c.username
switch c.kind {
case .password:
kind = .password
if case .password(let pw)? = store.secret(for: c.id) { password = pw }
case .privateKey:
kind = .sshKey; keySource = .generate // /
case .secureEnclave:
kind = .faceID
}
}
private func save() {
let credID = existing?.id ?? UUID()
var credKind: Credential.Kind
var secret: CredentialSecret?
switch kind {
case .password:
credKind = .password
secret = .password(password)
case .sshKey:
if isEditing, let c = existing, case .privateKey = c.kind {
credKind = c.kind; secret = nil // meta
} else if keySource == .generate, let gk = generatedKind, let gs = generatedSecret {
credKind = gk; secret = gs
} else {
let parsedKey = parsed ?? CredentialAuth.parsePrivateKey(keyText, source: keySource == .paste ? .pasted : .imported)
// passphrase
if case .privateKey(let t, let src, _, let blob) = parsedKey.kind {
credKind = .privateKey(keyType: t, source: src, hasPassphrase: !passphrase.isEmpty, publicKeyBlob: blob)
} else {
credKind = parsedKey.kind
}
secret = .pemPrivateKey(pem: Data(keyText.utf8), passphrase: passphrase.isEmpty ? nil : passphrase)
}
case .faceID:
return
}
let cred = Credential(id: credID, name: name.isEmpty ? username : name,
username: username, kind: credKind,
createdAt: existing?.createdAt ?? Date())
store.upsert(cred, secret: secret)
onSave(cred)
dismiss()
}
}