按 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>
359 lines
14 KiB
Swift
359 lines
14 KiB
Swift
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 ID);SSH 密钥支持 粘贴 / 导入 / 生成。
|
||
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()
|
||
}
|
||
}
|