542 lines
23 KiB
Swift
542 lines
23 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPresentation
|
|
|
|
struct MobileCredentialsView: View {
|
|
@AppStorage("mobile.compactCards") private var compactCards = false
|
|
let app: MobileAppModel
|
|
@State private var editingSecurityKey: SSHKeyRef?
|
|
@State private var editing: Identity?
|
|
@State private var keyEditor: MobileKeyEditorModel?
|
|
@State private var exportingKey: SSHKeyRef?
|
|
@State private var deletingKey: SSHKeyRef?
|
|
@State private var deletingIdentity: Identity?
|
|
@State private var error: String?
|
|
@State private var query = ""
|
|
|
|
private var keys: [SSHKeyRef] { app.credentials.searchKeys(query) }
|
|
private var identities: [Identity] { app.credentials.searchIdentities(query) }
|
|
|
|
init(app: MobileAppModel, initialQuery: String = "") {
|
|
self.app = app
|
|
_query = State(initialValue: initialQuery)
|
|
}
|
|
|
|
var body: some View {
|
|
MobileCardPage {
|
|
if !query.isEmpty, keys.isEmpty, identities.isEmpty {
|
|
ContentUnavailableView.search(text: query)
|
|
}
|
|
if query.isEmpty || !keys.isEmpty {
|
|
MobileCardSection(title: AppStrings.Keychain.keys, count: keys.count) {
|
|
if app.credentials.keys.isEmpty {
|
|
ContentUnavailableView(AppStrings.Keychain.noKeys.swiftUIKey, systemImage: "key")
|
|
}
|
|
MobileCardGrid(compact: compactCards) {
|
|
ForEach(keys) { key in
|
|
MobileActionCard(compact: compactCards) {
|
|
exportingKey = key
|
|
} content: {
|
|
MobileKeyCredentialCard(key: key, credentials: app.credentials, compact: compactCards)
|
|
} actions: {
|
|
Button(AppStrings.Keychain.exportToHost.swiftUIKey, systemImage: "square.and.arrow.up")
|
|
{
|
|
exportingKey = key
|
|
}
|
|
Divider()
|
|
Menu(AppStrings.Common.copy.swiftUIKey, systemImage: "doc.on.doc") {
|
|
Button(AppStrings.Keychain.publicKey.swiftUIKey, systemImage: "key") {
|
|
UIPasteboard.general.string = key.publicKey
|
|
}
|
|
.disabled(key.publicKey.isEmpty)
|
|
Button(AppStrings.KnownHosts.fingerprint.swiftUIKey, systemImage: "touchid") {
|
|
UIPasteboard.general.string = key.fingerprint
|
|
}
|
|
.disabled(key.publicKey.isEmpty || key.fingerprint.isEmpty)
|
|
}
|
|
Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil") {
|
|
if key.algorithm == .securityKey {
|
|
editingSecurityKey = key
|
|
} else {
|
|
keyEditor = MobileKeyEditorModel(
|
|
mode: .edit, credentials: app.credentials, key: key)
|
|
}
|
|
}
|
|
Divider()
|
|
Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) {
|
|
deletingKey = key
|
|
}
|
|
}
|
|
.operationConfirmation(
|
|
.init(AppStrings.Keychain.deleteKeyConfirmation),
|
|
isPresented: $deletingKey.isPresenting { $0.id == key.id },
|
|
actions: [
|
|
.init(AppStrings.Common.delete, role: .destructive) {
|
|
if let key = deletingKey {
|
|
do { try app.credentials.delete(keyID: key.id) } catch {
|
|
self.error = error.localizedDescription
|
|
}
|
|
}
|
|
deletingKey = nil
|
|
},
|
|
.init(AppStrings.Common.cancel, role: .cancel) { deletingKey = nil },
|
|
],
|
|
message: .init(AppStrings.Keychain.deleteKeyWarning)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if query.isEmpty || !identities.isEmpty {
|
|
MobileCardSection(title: AppStrings.Keychain.identities, count: identities.count) {
|
|
if app.credentials.identities.isEmpty {
|
|
ContentUnavailableView(
|
|
AppStrings.Keychain.noIdentities.swiftUIKey, systemImage: "person.crop.square")
|
|
}
|
|
MobileCardGrid(compact: compactCards) {
|
|
ForEach(identities) { identity in
|
|
MobileActionCard(compact: compactCards) {
|
|
editing = identity
|
|
} content: {
|
|
MobileIdentityCredentialCard(
|
|
identity: identity, credentials: app.credentials, compact: compactCards)
|
|
} actions: {
|
|
Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil") { editing = identity }
|
|
Divider()
|
|
Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) {
|
|
deletingIdentity = identity
|
|
}
|
|
}
|
|
.operationConfirmation(
|
|
.init(AppStrings.Keychain.deleteIdentityConfirmation),
|
|
isPresented: $deletingIdentity.isPresenting { $0.id == identity.id },
|
|
actions: [
|
|
.init(AppStrings.Common.delete, role: .destructive) {
|
|
if let identity = deletingIdentity,
|
|
!app.credentials.delete(identityID: identity.id)
|
|
{
|
|
error = app.credentials.storageError
|
|
}
|
|
deletingIdentity = nil
|
|
},
|
|
.init(AppStrings.Common.cancel, role: .cancel) { deletingIdentity = nil },
|
|
],
|
|
message: .init(AppStrings.Keychain.deleteIdentityWarning)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.searchable(text: $query.singleLineText)
|
|
.navigationTitle(AppStrings.Navigation.keychain.swiftUIKey)
|
|
.toolbar {
|
|
Menu {
|
|
Button(AppStrings.Keychain.generateKey.swiftUIKey, systemImage: "key") {
|
|
keyEditor = MobileKeyEditorModel(
|
|
mode: .generate, credentials: app.credentials,
|
|
defaultStorage: app.newVaultKeyStorage)
|
|
}
|
|
Button(AppStrings.Keychain.importKey.swiftUIKey, systemImage: "square.and.arrow.down") {
|
|
keyEditor = MobileKeyEditorModel(
|
|
mode: .importKey, credentials: app.credentials,
|
|
defaultStorage: app.newVaultKeyStorage)
|
|
}
|
|
Divider()
|
|
Button(AppStrings.Keychain.newIdentity.swiftUIKey, systemImage: "person.crop.circle.badge.plus") {
|
|
editing = Identity(
|
|
name: "", username: "",
|
|
passwordStorage: app.newVaultPasswordStorage)
|
|
}
|
|
} label: {
|
|
Label(AppStrings.Common.add.swiftUIKey, systemImage: "plus").labelStyle(.iconOnly)
|
|
}
|
|
}
|
|
.sheet(item: $editingSecurityKey) { key in SecurityKeyEditor(credentials: app.credentials, existing: key) }
|
|
.sheet(item: $editing) { MobileIdentityEditor(app: app, identity: $0) }
|
|
.sheet(item: $keyEditor) { MobileKeyEditor(model: $0) }
|
|
.navigationDestination(item: $exportingKey) { key in
|
|
MobileKeyExportDetailView(
|
|
library: app.library,
|
|
model: KeyExportModel(key: key, library: app.library, services: app.keyExportServices))
|
|
}
|
|
.alert(
|
|
AppStrings.Common.unableToDelete,
|
|
isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })
|
|
) {
|
|
Button(AppStrings.Common.ok.swiftUIKey, role: .cancel) { error = nil }
|
|
} message: {
|
|
Text(error ?? "")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MobileKeyCredentialCard: View {
|
|
let key: SSHKeyRef
|
|
let credentials: CredentialLibraryModel
|
|
var compact = false
|
|
@Environment(\.locale) private var locale
|
|
|
|
private var dependents: Int { credentials.identities(using: key.id).count }
|
|
|
|
var body: some View {
|
|
if compact {
|
|
let warnings = compactWarnings
|
|
MobileCompactCardContent(
|
|
title: key.label,
|
|
status: Text(
|
|
verbatim: warnings.map { AppLocalization.resolve($0, locale: locale) }
|
|
.formatted(.list(type: .and).locale(locale)))
|
|
) {
|
|
MobileCardIcon(symbol: "key.fill", tint: key.colorTag.color ?? .accentColor)
|
|
.overlay(alignment: .bottomTrailing) {
|
|
if !warnings.isEmpty {
|
|
MobileCompactCardBadge(symbol: "exclamationmark.triangle.fill")
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
standardContent
|
|
}
|
|
}
|
|
|
|
var compactWarnings: [LocalizedStringResource] {
|
|
var warnings: [LocalizedStringResource] = []
|
|
switch credentials.keyAvailability(for: key) {
|
|
case .missing: warnings.append(AppStrings.Keychain.privateKeyMissing)
|
|
case .failed: warnings.append(AppStrings.Keychain.keychainUnavailable)
|
|
case .available, .notConfigured: break
|
|
}
|
|
if key.hasPassphrase && !credentials.hasSavedPassphrase(for: key) {
|
|
warnings.append(AppStrings.Keychain.passphraseRequired)
|
|
}
|
|
return warnings
|
|
}
|
|
|
|
private var standardContent: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
MobileCardIcon(symbol: "key.fill", tint: key.colorTag.color ?? .accentColor)
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(key.label).font(.headline).lineLimit(1)
|
|
Text(
|
|
key.algorithm == .unknown
|
|
? AppStrings.Keychain.unknownKeyType : AppStrings.Keychain.keyType(keyDisplayName)
|
|
).font(.caption).foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
HStack(spacing: 6) {
|
|
primaryChips
|
|
secondaryChips
|
|
Spacer(minLength: 6)
|
|
dependentLabel
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.clipped()
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
|
|
@ViewBuilder private var primaryChips: some View {
|
|
if key.hasPassphrase {
|
|
MobileCardChip(
|
|
title: credentials.hasSavedPassphrase(for: key)
|
|
? AppStrings.Keychain.passphraseSaved : AppStrings.Keychain.passphraseRequired,
|
|
symbol: "lock.fill")
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private var secondaryChips: some View {
|
|
MobileCardChip(
|
|
text: key.algorithm == .securityKey
|
|
? String(localized: AppStrings.SecurityKey.title) : key.storage.mobileDisplayName,
|
|
symbol: key.algorithm == .securityKey ? "key.radiowaves.forward.fill" : key.storage.mobileSymbol)
|
|
switch credentials.keyAvailability(for: key) {
|
|
case .missing:
|
|
MobileCardChip(
|
|
title: AppStrings.Keychain.privateKeyMissing, symbol: "exclamationmark.triangle.fill", tint: .orange)
|
|
case .failed:
|
|
MobileCardChip(
|
|
title: AppStrings.Keychain.keychainUnavailable, symbol: "exclamationmark.triangle.fill", tint: .orange)
|
|
case .available, .notConfigured:
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private var dependentLabel: some View {
|
|
if dependents > 0 {
|
|
Text(AppStrings.Keychain.identityCount(dependents))
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
.lineLimit(1)
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
.layoutPriority(1)
|
|
}
|
|
}
|
|
|
|
private var keyDisplayName: String {
|
|
if key.algorithm == .rsa, let bits = key.keySize { return "RSA \(bits)" }
|
|
return key.algorithm.displayName
|
|
}
|
|
}
|
|
|
|
struct MobileIdentityCredentialCard: View {
|
|
let identity: Identity
|
|
let credentials: CredentialLibraryModel
|
|
var compact = false
|
|
@Environment(\.locale) private var locale
|
|
|
|
private var key: SSHKeyRef? { identity.keyID.flatMap(credentials.key) }
|
|
|
|
var body: some View {
|
|
if compact {
|
|
let warnings = compactWarnings
|
|
MobileCompactCardContent(
|
|
title: identity.name.isEmpty ? identity.username : identity.name,
|
|
status: Text(
|
|
verbatim: warnings.map { AppLocalization.resolve($0, locale: locale) }
|
|
.formatted(.list(type: .and).locale(locale)))
|
|
) {
|
|
MobileCardIcon(symbol: "person.badge.key.fill", tint: identity.colorTag.color ?? .accentColor)
|
|
.overlay(alignment: .bottomTrailing) {
|
|
if !warnings.isEmpty {
|
|
MobileCompactCardBadge(symbol: "exclamationmark.triangle.fill")
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
standardContent
|
|
}
|
|
}
|
|
|
|
var compactWarnings: [LocalizedStringResource] {
|
|
var warnings: [LocalizedStringResource] = []
|
|
if identity.usesPassword {
|
|
switch credentials.passwordAvailability(for: identity) {
|
|
case .missing: warnings.append(AppStrings.Keychain.passwordMissing)
|
|
case .failed: warnings.append(AppStrings.Keychain.passwordUnavailable)
|
|
case .available, .notConfigured: break
|
|
}
|
|
}
|
|
if identity.usesKey && key == nil { warnings.append(AppStrings.Keychain.keyMissing) }
|
|
if !identity.hasSecret { warnings.append(AppStrings.Keychain.noSecret) }
|
|
return warnings
|
|
}
|
|
|
|
private var standardContent: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
MobileCardIcon(
|
|
symbol: "person.badge.key.fill", tint: identity.colorTag.color ?? .accentColor)
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(identity.name).font(.headline).lineLimit(1)
|
|
Text(identity.username).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
HStack(spacing: 6) {
|
|
secretChips
|
|
Spacer(minLength: 6)
|
|
modifiedLabel
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.clipped()
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
|
|
@ViewBuilder private var secretChips: some View {
|
|
if identity.usesPassword {
|
|
switch credentials.passwordAvailability(for: identity) {
|
|
case .available:
|
|
MobileCardChip(title: AppStrings.Vault.password, symbol: "lock.fill")
|
|
case .missing:
|
|
MobileCardChip(
|
|
title: AppStrings.Keychain.passwordMissing, symbol: "exclamationmark.triangle.fill", tint: .orange)
|
|
case .failed:
|
|
MobileCardChip(
|
|
title: AppStrings.Keychain.passwordUnavailable, symbol: "exclamationmark.triangle.fill",
|
|
tint: .orange)
|
|
case .notConfigured:
|
|
EmptyView()
|
|
}
|
|
MobileCardChip(
|
|
text: identity.passwordStorage.mobileDisplayName,
|
|
symbol: identity.passwordStorage.mobileSymbol)
|
|
}
|
|
if identity.usesKey {
|
|
if let key {
|
|
MobileCardChip(text: key.label, symbol: "key.fill", tint: key.colorTag.color ?? .accentColor)
|
|
} else {
|
|
MobileCardChip(
|
|
title: AppStrings.Keychain.keyMissing, symbol: "exclamationmark.triangle.fill", tint: .orange)
|
|
}
|
|
}
|
|
if !identity.hasSecret {
|
|
MobileCardChip(title: AppStrings.Keychain.noSecret, symbol: "questionmark.circle")
|
|
}
|
|
}
|
|
|
|
private var modifiedLabel: some View {
|
|
Text(
|
|
modifiedAt?.formatted(.relative(presentation: .numeric))
|
|
?? String(localized: AppStrings.SFTP.unknown)
|
|
)
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
.lineLimit(1)
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
.layoutPriority(1)
|
|
}
|
|
|
|
private var modifiedAt: Date? {
|
|
guard let date = credentials.identityModifiedAt(identity.id),
|
|
date > VaultVersion.baselineDate
|
|
else { return nil }
|
|
return date
|
|
}
|
|
}
|
|
|
|
extension PasswordStorage {
|
|
fileprivate var mobileDisplayName: String {
|
|
switch self {
|
|
case .thisDevice: String(localized: AppStrings.Keychain.thisDevice)
|
|
case .iCloud: String(localized: AppStrings.Keychain.iCloud)
|
|
}
|
|
}
|
|
|
|
fileprivate var mobileSymbol: String {
|
|
switch self {
|
|
case .thisDevice: "ipad.and.iphone"
|
|
case .iCloud: "icloud.fill"
|
|
}
|
|
}
|
|
}
|
|
|
|
extension KeyStorage {
|
|
fileprivate var mobileDisplayName: String {
|
|
switch self {
|
|
case .thisDevice: String(localized: AppStrings.Keychain.thisDevice)
|
|
case .iCloud: String(localized: AppStrings.Keychain.iCloud)
|
|
case .secureEnclave: String(localized: AppStrings.Keychain.secureEnclave)
|
|
case .external: String(localized: AppStrings.SecurityKey.title)
|
|
}
|
|
}
|
|
|
|
fileprivate var mobileSymbol: String {
|
|
switch self {
|
|
case .thisDevice: "ipad.and.iphone"
|
|
case .iCloud: "icloud.fill"
|
|
case .secureEnclave: "checkmark.shield.fill"
|
|
case .external: "key.radiowaves.forward.fill"
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated struct MobileIdentityEditorConfiguration: Hashable, Sendable {
|
|
var showsKey: Bool
|
|
var requiresPassword: Bool
|
|
|
|
static let standard = MobileIdentityEditorConfiguration(showsKey: true, requiresPassword: false)
|
|
static let proxy = MobileIdentityEditorConfiguration(showsKey: false, requiresPassword: true)
|
|
|
|
func canSave(identity: Identity, password: String, hasConfiguredPassword: Bool) -> Bool {
|
|
guard !identity.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
return false
|
|
}
|
|
guard requiresPassword else { return true }
|
|
return hasConfiguredPassword || !password.isEmpty
|
|
}
|
|
}
|
|
|
|
struct MobileIdentityEditor: View {
|
|
let app: MobileAppModel
|
|
let original: Identity
|
|
let isNew: Bool
|
|
let configuration: MobileIdentityEditorConfiguration
|
|
let saved: (Identity) -> Void
|
|
@State private var draft: Identity
|
|
@State private var password = ""
|
|
@State private var originalPassword = ""
|
|
@State private var didLoadPassword = false
|
|
|
|
init(
|
|
app: MobileAppModel, identity: Identity,
|
|
configuration: MobileIdentityEditorConfiguration = .standard,
|
|
saved: @escaping (Identity) -> Void = { _ in }
|
|
) {
|
|
self.app = app
|
|
original = identity
|
|
isNew = app.library.identity(identity.id) == nil
|
|
self.configuration = configuration
|
|
self.saved = saved
|
|
_draft = State(initialValue: identity)
|
|
}
|
|
|
|
var body: some View {
|
|
MobileEditorSheet(
|
|
title: title,
|
|
isDirty: draft != original || password != originalPassword,
|
|
canSave: configuration.canSave(
|
|
identity: draft, password: password,
|
|
hasConfiguredPassword: original.usesPassword),
|
|
discardQuestion: isNew
|
|
? AppStrings.Common.discardNewIdentityQuestion : AppStrings.Common.discardChangesQuestion,
|
|
save: {
|
|
draft.username = draft.username.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
draft.name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
try app.library.upsert(
|
|
draft,
|
|
passwordUpdate: !canEditPassword
|
|
? .keep
|
|
: password == originalPassword ? .keep : password.isEmpty ? .remove : .replace(password))
|
|
saved(draft)
|
|
},
|
|
content: {
|
|
IdentityFormSections(
|
|
name: $draft.name, username: $draft.username, password: $password,
|
|
passwordStorage: $draft.passwordStorage, keyID: $draft.keyID,
|
|
colorTag: $draft.colorTag, namePrompt: Text(verbatim: draft.defaultName),
|
|
keys: app.credentials.keys, showsKey: configuration.showsKey,
|
|
canEditPassword: canEditPassword,
|
|
canEditPasswordStorage: canEditPassword
|
|
&& !(original.usesPassword && original.passwordStorage == .iCloud)
|
|
)
|
|
}
|
|
)
|
|
.onAppear(perform: loadPasswordOnce)
|
|
.onDisappear {
|
|
password = ""
|
|
originalPassword = ""
|
|
}
|
|
}
|
|
|
|
var title: String {
|
|
String(localized: isNew ? AppStrings.Keychain.newIdentity : AppStrings.Keychain.editIdentity)
|
|
}
|
|
|
|
private var hasAvailablePassword: Bool {
|
|
original.usesPassword && app.credentials.passwordAvailability(for: original) == .available
|
|
}
|
|
|
|
var canEditPassword: Bool {
|
|
!original.usesPassword || hasAvailablePassword
|
|
}
|
|
|
|
private func loadPasswordOnce() {
|
|
guard !didLoadPassword else { return }
|
|
didLoadPassword = true
|
|
guard hasAvailablePassword,
|
|
let loaded = try? app.credentials.editablePassword(for: original)
|
|
else { return }
|
|
password = loaded
|
|
originalPassword = loaded
|
|
}
|
|
}
|