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) { 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() } }