Add colors to keys and identities
This commit is contained in:
1 parent
f5edd63a9a
commit
e5ce06c456
16 files changed
+356
-58
No files matched your search
@@ -16,13 +16,13 @@ extension HostLibraryModel {
|
||||
let personalKey = SSHKeyRef(
|
||||
label: "Personal", algorithm: .ed25519,
|
||||
fingerprint: "SHA256:9pKQ2vC0nJ7hR4mXbL1sT8yWfE3dA6uZgN5qOiVxHkM",
|
||||
credentialRef: "termind.key.personal"
|
||||
credentialRef: "termind.key.personal", colorTag: .blue
|
||||
)
|
||||
let workKey = SSHKeyRef(
|
||||
label: "Work (no sync)", algorithm: .ecdsa256,
|
||||
fingerprint: "SHA256:3tBn8xLpQ2wZ7yKvR1mHcE9dS4fJ6uA0gT5iNqOxVbY",
|
||||
credentialRef: "termind.key.work",
|
||||
storage: .thisDevice
|
||||
storage: .thisDevice, colorTag: .purple
|
||||
)
|
||||
|
||||
// Built before the hosts, because a host now references its login by id.
|
||||
@@ -30,18 +30,18 @@ extension HostLibraryModel {
|
||||
// screen at P0: key only, key *and* password, password only, neither.
|
||||
let deploy = Identity(
|
||||
name: "Deploy", username: "deploy", keyID: personalKey.id,
|
||||
notes: "Production web tier", lastUsedAt: now
|
||||
notes: "Production web tier", lastUsedAt: now, colorTag: .green
|
||||
)
|
||||
let postgres = Identity(
|
||||
name: "Postgres admin", username: "postgres",
|
||||
credentialRef: "termind.password.postgres", keyID: workKey.id,
|
||||
notes: "Key to get in, password for sudo",
|
||||
lastUsedAt: now.addingTimeInterval(-7_200)
|
||||
lastUsedAt: now.addingTimeInterval(-7_200), colorTag: .orange
|
||||
)
|
||||
let consoleRoot = Identity(
|
||||
name: "Console root", username: "root",
|
||||
credentialRef: "termind.password.console-root",
|
||||
notes: "Out-of-band only"
|
||||
notes: "Out-of-band only", colorTag: .red
|
||||
)
|
||||
let unfinished = Identity(
|
||||
name: "New admin", username: "admin",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A row of color swatches for picking a `HostColor`.
|
||||
/// A row of color swatches for picking a `TagColor`.
|
||||
///
|
||||
/// A control rather than a `Picker`, because on macOS a `Picker` inside a `Form`
|
||||
/// renders as a pop-up menu and AppKit templates the images in menu items — the
|
||||
@@ -10,9 +10,9 @@ import SwiftUI
|
||||
/// the swatches directly is what makes the color visible.
|
||||
///
|
||||
/// Lives in `Design/` rather than beside the host editor: it is a swatch row over
|
||||
/// `HostColor` and knows nothing about what is being tagged.
|
||||
/// `TagColor` and knows nothing about what is being tagged.
|
||||
struct ColorTagPicker: View {
|
||||
@Binding var selection: HostColor
|
||||
@Binding var selection: TagColor
|
||||
|
||||
/// Large enough to read as a color at a glance and to hit without aiming;
|
||||
/// the ring is drawn outside it, so the row is taller than the swatch.
|
||||
@@ -20,7 +20,7 @@ struct ColorTagPicker: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Metrics.Space.m) {
|
||||
ForEach(HostColor.allCases) { color in
|
||||
ForEach(TagColor.allCases) { color in
|
||||
swatchButton(color)
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ struct ColorTagPicker: View {
|
||||
.accessibilityLabel(Text("Color"))
|
||||
}
|
||||
|
||||
private func swatchButton(_ color: HostColor) -> some View {
|
||||
private func swatchButton(_ color: TagColor) -> some View {
|
||||
Button {
|
||||
selection = color
|
||||
} label: {
|
||||
@@ -63,7 +63,7 @@ struct ColorTagPicker: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
@Previewable @State var selection: HostColor = .teal
|
||||
@Previewable @State var selection: TagColor = .teal
|
||||
|
||||
Form {
|
||||
LabeledContent("Color") {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
extension HostColor {
|
||||
extension TagColor {
|
||||
var displayName: LocalizedStringKey {
|
||||
switch self {
|
||||
case .none: "None"
|
||||
@@ -72,6 +72,7 @@ nonisolated struct Identity: Identifiable, Codable, Hashable, Sendable {
|
||||
var keyID: SSHKeyRef.ID?
|
||||
var notes: String = ""
|
||||
var lastUsedAt: Date?
|
||||
var colorTag: TagColor = .none
|
||||
/// User-defined position on the Keychain page. It is independent from the
|
||||
/// display name so renaming an identity never silently moves its card.
|
||||
var sortIndex: Double = 0
|
||||
@@ -99,3 +100,29 @@ nonisolated struct Identity: Identifiable, Codable, Hashable, Sendable {
|
||||
|| notes.lowercased().contains(q)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated extension Identity {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, username, credentialRef, passwordStorage, keyID, notes
|
||||
case lastUsedAt, colorTag, sortIndex
|
||||
}
|
||||
|
||||
/// Older Vault payloads predate identity colors. Missing or unrecognized
|
||||
/// values intentionally become `.none` instead of quarantining an otherwise
|
||||
/// valid credential record.
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.init(
|
||||
id: try container.decode(UUID.self, forKey: .id),
|
||||
name: try container.decode(String.self, forKey: .name),
|
||||
username: try container.decode(String.self, forKey: .username),
|
||||
credentialRef: try container.decode(String.self, forKey: .credentialRef),
|
||||
passwordStorage: try container.decode(PasswordStorage.self, forKey: .passwordStorage),
|
||||
keyID: try container.decodeIfPresent(SSHKeyRef.ID.self, forKey: .keyID),
|
||||
notes: try container.decode(String.self, forKey: .notes),
|
||||
lastUsedAt: try container.decodeIfPresent(Date.self, forKey: .lastUsedAt),
|
||||
colorTag: (try? container.decode(TagColor.self, forKey: .colorTag)) ?? .none,
|
||||
sortIndex: try container.decode(Double.self, forKey: .sortIndex)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,39 @@ nonisolated struct SSHKeyRef: Identifiable, Codable, Hashable, Sendable {
|
||||
var hasPassphrase = false
|
||||
var requiresUserPresence = false
|
||||
var createdAt: Date = Date(timeIntervalSince1970: 0)
|
||||
var colorTag: TagColor = .none
|
||||
var sortIndex: Double = 0
|
||||
|
||||
var requiresPassphraseEntry: Bool { hasPassphrase }
|
||||
}
|
||||
|
||||
nonisolated extension SSHKeyRef {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, label, algorithm, publicKey, certificate, fingerprint
|
||||
case credentialRef, storage, keySize, hasPassphrase, requiresUserPresence
|
||||
case createdAt, colorTag, sortIndex
|
||||
}
|
||||
|
||||
/// Vault records written before key colors existed do not contain
|
||||
/// `colorTag`. Treating that absence as `.none` keeps those records valid
|
||||
/// while preserving synthesized encoding for new records.
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.init(
|
||||
id: try container.decode(UUID.self, forKey: .id),
|
||||
label: try container.decode(String.self, forKey: .label),
|
||||
algorithm: try container.decode(SSHKeyAlgorithm.self, forKey: .algorithm),
|
||||
publicKey: try container.decode(String.self, forKey: .publicKey),
|
||||
certificate: try container.decode(String.self, forKey: .certificate),
|
||||
fingerprint: try container.decode(String.self, forKey: .fingerprint),
|
||||
credentialRef: try container.decode(String.self, forKey: .credentialRef),
|
||||
storage: try container.decode(KeyStorage.self, forKey: .storage),
|
||||
keySize: try container.decodeIfPresent(Int.self, forKey: .keySize),
|
||||
hasPassphrase: try container.decode(Bool.self, forKey: .hasPassphrase),
|
||||
requiresUserPresence: try container.decode(Bool.self, forKey: .requiresUserPresence),
|
||||
createdAt: try container.decode(Date.self, forKey: .createdAt),
|
||||
colorTag: (try? container.decode(TagColor.self, forKey: .colorTag)) ?? .none,
|
||||
sortIndex: try container.decode(Double.self, forKey: .sortIndex)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
|
||||
var sftpSessionInitialPath: String = ""
|
||||
var groupID: HostGroup.ID?
|
||||
var tags: [String] = []
|
||||
var colorTag: HostColor = .none
|
||||
var colorTag: TagColor = .none
|
||||
/// Fractional index so reordering never has to renumber siblings — CloudKit
|
||||
/// has no ordered relationships, so order has to be an ordinary attribute.
|
||||
var sortIndex: Double = 0
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// A persisted host label. Presentation details live in DesignSystem so the
|
||||
/// domain value remains usable without SwiftUI.
|
||||
nonisolated enum HostColor: String, CaseIterable, Codable, Sendable, Identifiable {
|
||||
case none, red, orange, yellow, green, teal, blue, purple, pink
|
||||
|
||||
nonisolated var id: String { rawValue }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
/// A persisted label color shared by hosts, keys, and identities.
|
||||
/// Presentation details live in DesignSystem so the domain value remains
|
||||
/// usable without SwiftUI.
|
||||
nonisolated enum TagColor: String, CaseIterable, Codable, Sendable, Identifiable {
|
||||
case none, red, orange, yellow, green, teal, blue, purple, pink
|
||||
|
||||
nonisolated var id: String { rawValue }
|
||||
}
|
||||
@@ -314,7 +314,7 @@ struct KeychainImportDetail: View {
|
||||
}
|
||||
|
||||
private func save(
|
||||
name: String, storage: KeyStorage, privateKey: String,
|
||||
name: String, colorTag: TagColor, storage: KeyStorage, privateKey: String,
|
||||
passphrase: String?, certificate: String, material: SSHKeyMaterial
|
||||
) async throws -> SSHKeyRef {
|
||||
let key = SSHKeyRef(
|
||||
@@ -323,7 +323,7 @@ struct KeychainImportDetail: View {
|
||||
certificate: certificate, fingerprint: material.fingerprint,
|
||||
credentialRef: "termind.key.\(UUID().uuidString)", storage: storage,
|
||||
keySize: material.keySize, hasPassphrase: passphrase != nil,
|
||||
createdAt: Date()
|
||||
createdAt: Date(), colorTag: colorTag
|
||||
)
|
||||
try app.credentials.upsert(
|
||||
key,
|
||||
@@ -344,11 +344,12 @@ private struct SoftwareKeyEditor: View {
|
||||
let loadPrivateKey: (() throws -> String)?
|
||||
let save:
|
||||
(
|
||||
_ name: String, _ storage: KeyStorage, _ privateKey: String,
|
||||
_ name: String, _ colorTag: TagColor, _ storage: KeyStorage, _ privateKey: String,
|
||||
_ passphrase: String?, _ certificate: String, _ material: SSHKeyMaterial
|
||||
) async throws -> SSHKeyRef
|
||||
|
||||
@State private var name: String
|
||||
@State private var colorTag: TagColor
|
||||
@State private var storage: KeyStorage
|
||||
@State private var privateKey = ""
|
||||
@State private var originalPrivateKey = ""
|
||||
@@ -365,7 +366,7 @@ private struct SoftwareKeyEditor: View {
|
||||
symbol: String, close: @escaping () -> Void,
|
||||
loadPrivateKey: (() throws -> String)? = nil,
|
||||
save: @escaping (
|
||||
String, KeyStorage, String, String?, String, SSHKeyMaterial
|
||||
String, TagColor, KeyStorage, String, String?, String, SSHKeyMaterial
|
||||
) async throws -> SSHKeyRef
|
||||
) {
|
||||
self.existing = existing
|
||||
@@ -375,6 +376,7 @@ private struct SoftwareKeyEditor: View {
|
||||
self.loadPrivateKey = loadPrivateKey
|
||||
self.save = save
|
||||
_name = State(initialValue: existing?.label ?? "")
|
||||
_colorTag = State(initialValue: existing?.colorTag ?? .none)
|
||||
_storage = State(initialValue: existing?.storage ?? .iCloud)
|
||||
_certificate = State(initialValue: existing?.certificate ?? "")
|
||||
_isLoading = State(initialValue: loadPrivateKey != nil)
|
||||
@@ -396,6 +398,9 @@ private struct SoftwareKeyEditor: View {
|
||||
|
||||
Section("Key") {
|
||||
TextField("Name", text: $name, prompt: Text("Defaults to key type"))
|
||||
LabeledContent("Color") {
|
||||
ColorTagPicker(selection: $colorTag)
|
||||
}
|
||||
Picker("Store in", selection: $storage) {
|
||||
Label("iCloud", systemImage: "icloud.fill").tag(KeyStorage.iCloud)
|
||||
Label("This Mac", systemImage: "lock.laptopcomputer").tag(KeyStorage.thisDevice)
|
||||
@@ -508,7 +513,7 @@ private struct SoftwareKeyEditor: View {
|
||||
)
|
||||
}
|
||||
_ = try await save(
|
||||
name.trimmingCharacters(in: .whitespacesAndNewlines), storage,
|
||||
name.trimmingCharacters(in: .whitespacesAndNewlines), colorTag, storage,
|
||||
privateKey, passphrase, normalizedCertificate, inspected
|
||||
)
|
||||
close()
|
||||
@@ -617,7 +622,7 @@ struct KeychainGenerateDetail: View {
|
||||
}
|
||||
|
||||
private func generate(
|
||||
label: String, generation: SSHKeyMaterial.Generation, passphrase: String?,
|
||||
label: String, colorTag: TagColor, generation: SSHKeyMaterial.Generation, passphrase: String?,
|
||||
cipher: SSHKeyMaterial.Cipher, rounds: Int, storage: KeyStorage,
|
||||
requiresUserPresence: Bool
|
||||
) async throws -> SSHKeyRef {
|
||||
@@ -635,7 +640,7 @@ struct KeychainGenerateDetail: View {
|
||||
credentialRef: "termind.key.\(UUID().uuidString)",
|
||||
storage: .secureEnclave, keySize: 256, hasPassphrase: false,
|
||||
requiresUserPresence: requiresUserPresence,
|
||||
createdAt: Date()
|
||||
createdAt: Date(), colorTag: colorTag
|
||||
)
|
||||
try app.credentials.upsert(key, credential: .secureEnclave(material.credential))
|
||||
} else {
|
||||
@@ -650,7 +655,7 @@ struct KeychainGenerateDetail: View {
|
||||
publicKey: material.publicKey, fingerprint: material.fingerprint,
|
||||
credentialRef: "termind.key.\(UUID().uuidString)", storage: storage,
|
||||
keySize: material.keySize, hasPassphrase: passphrase != nil,
|
||||
createdAt: Date()
|
||||
createdAt: Date(), colorTag: colorTag
|
||||
)
|
||||
try app.credentials.upsert(
|
||||
key,
|
||||
@@ -675,12 +680,13 @@ private struct GenerateKeySheet: View {
|
||||
|
||||
let generate:
|
||||
(
|
||||
String, SSHKeyMaterial.Generation, String?, SSHKeyMaterial.Cipher, Int,
|
||||
String, TagColor, SSHKeyMaterial.Generation, String?, SSHKeyMaterial.Cipher, Int,
|
||||
KeyStorage, Bool
|
||||
) async throws -> SSHKeyRef
|
||||
let close: () -> Void
|
||||
|
||||
@State private var label = ""
|
||||
@State private var colorTag = TagColor.none
|
||||
@State private var storage = KeyStorage.iCloud
|
||||
@State private var kind = Kind.ed25519
|
||||
@State private var curveBits = 256
|
||||
@@ -716,6 +722,9 @@ private struct GenerateKeySheet: View {
|
||||
VaultDetailScaffold(title: "Generate Key", symbol: "key.fill", close: close) {
|
||||
Form {
|
||||
TextField("Name", text: $label, prompt: Text(defaultName))
|
||||
LabeledContent("Color") {
|
||||
ColorTagPicker(selection: $colorTag)
|
||||
}
|
||||
Picker("Store in", selection: $storage) {
|
||||
ForEach(availableStorage) { location in
|
||||
Label(location.displayName, systemImage: location.symbol)
|
||||
@@ -803,7 +812,7 @@ private struct GenerateKeySheet: View {
|
||||
errorMessage = nil
|
||||
do {
|
||||
_ = try await generate(
|
||||
resolvedLabel, generation,
|
||||
resolvedLabel, colorTag, generation,
|
||||
storage == .secureEnclave || passphrase.isEmpty ? nil : passphrase,
|
||||
cipher, rounds, storage,
|
||||
storage == .secureEnclave && requiresUserPresence
|
||||
@@ -837,16 +846,19 @@ struct KeychainEditDetail: View {
|
||||
if key.storage == .secureEnclave {
|
||||
SecureEnclaveKeyEditor(
|
||||
key: key, close: window.closeVaultWork,
|
||||
save: { name in try saveSecureEnclave(key, name: name) }
|
||||
save: { name, colorTag in
|
||||
try saveSecureEnclave(key, name: name, colorTag: colorTag)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
SoftwareKeyEditor(
|
||||
existing: key, title: "Edit Key", symbol: "pencil",
|
||||
close: window.closeVaultWork,
|
||||
loadPrivateKey: { try loadPrivateKey(key) },
|
||||
save: { name, storage, privateKey, passphrase, certificate, material in
|
||||
save: {
|
||||
name, colorTag, storage, privateKey, passphrase, certificate, material in
|
||||
try await saveSoftwareKey(
|
||||
key, name: name, storage: storage,
|
||||
key, name: name, colorTag: colorTag, storage: storage,
|
||||
privateKey: privateKey, passphrase: passphrase,
|
||||
certificate: certificate, material: material
|
||||
)
|
||||
@@ -868,12 +880,13 @@ struct KeychainEditDetail: View {
|
||||
}
|
||||
|
||||
private func saveSoftwareKey(
|
||||
_ original: SSHKeyRef, name: String, storage: KeyStorage,
|
||||
_ original: SSHKeyRef, name: String, colorTag: TagColor, storage: KeyStorage,
|
||||
privateKey: String, passphrase: String?, certificate: String,
|
||||
material: SSHKeyMaterial
|
||||
) async throws -> SSHKeyRef {
|
||||
var updated = original
|
||||
updated.label = name.isEmpty ? material.algorithm.typeName : name
|
||||
updated.colorTag = colorTag
|
||||
updated.storage = storage
|
||||
updated.algorithm = material.algorithm
|
||||
updated.publicKey = material.publicKey
|
||||
@@ -892,9 +905,12 @@ struct KeychainEditDetail: View {
|
||||
return updated
|
||||
}
|
||||
|
||||
private func saveSecureEnclave(_ original: SSHKeyRef, name: String) throws {
|
||||
private func saveSecureEnclave(
|
||||
_ original: SSHKeyRef, name: String, colorTag: TagColor
|
||||
) throws {
|
||||
var updated = original
|
||||
updated.label = name
|
||||
updated.colorTag = colorTag
|
||||
try app.credentials.upsert(updated)
|
||||
window.selectVaultItem(.key(updated.id), on: .keychain)
|
||||
}
|
||||
@@ -903,16 +919,21 @@ struct KeychainEditDetail: View {
|
||||
private struct SecureEnclaveKeyEditor: View {
|
||||
let key: SSHKeyRef
|
||||
let close: () -> Void
|
||||
let save: (String) throws -> Void
|
||||
let save: (String, TagColor) throws -> Void
|
||||
|
||||
@State private var name: String
|
||||
@State private var colorTag: TagColor
|
||||
@State private var errorMessage: String?
|
||||
|
||||
init(key: SSHKeyRef, close: @escaping () -> Void, save: @escaping (String) throws -> Void) {
|
||||
init(
|
||||
key: SSHKeyRef, close: @escaping () -> Void,
|
||||
save: @escaping (String, TagColor) throws -> Void
|
||||
) {
|
||||
self.key = key
|
||||
self.close = close
|
||||
self.save = save
|
||||
_name = State(initialValue: key.label)
|
||||
_colorTag = State(initialValue: key.colorTag)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -920,6 +941,9 @@ private struct SecureEnclaveKeyEditor: View {
|
||||
Form {
|
||||
Section("Key") {
|
||||
TextField("Name", text: $name)
|
||||
LabeledContent("Color") {
|
||||
ColorTagPicker(selection: $colorTag)
|
||||
}
|
||||
}
|
||||
Section("Secure Enclave") {
|
||||
LabeledContent("Stored in", value: key.storage.displayName)
|
||||
@@ -944,7 +968,9 @@ private struct SecureEnclaveKeyEditor: View {
|
||||
Button("Cancel", action: close).keyboardShortcut(.cancelAction)
|
||||
Button("Save") {
|
||||
do {
|
||||
try save(name.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
try save(
|
||||
name.trimmingCharacters(in: .whitespacesAndNewlines), colorTag
|
||||
)
|
||||
close()
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
@@ -1449,7 +1475,10 @@ private struct KeyCard: View {
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.m) {
|
||||
HStack(spacing: Metrics.Space.m) {
|
||||
CardIcon(symbol: "key.fill", tint: TermindColor.accent)
|
||||
CardIcon(
|
||||
symbol: "key.fill",
|
||||
tint: key.colorTag.color ?? TermindColor.accent
|
||||
)
|
||||
|
||||
// The fingerprint is deliberately *not* selectable, and this
|
||||
// is not a style preference.
|
||||
@@ -1580,7 +1609,10 @@ private struct IdentityCard: View {
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.m) {
|
||||
HStack(spacing: Metrics.Space.m) {
|
||||
CardIcon(symbol: "person.badge.key.fill", tint: .accentColor)
|
||||
CardIcon(
|
||||
symbol: "person.badge.key.fill",
|
||||
tint: identity.colorTag.color ?? .accentColor
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(identity.name)
|
||||
|
||||
@@ -66,6 +66,7 @@ struct NewIdentitySheet: View {
|
||||
@State private var password = ""
|
||||
@State private var passwordStorage: PasswordStorage
|
||||
@State private var keyID: SSHKeyRef.ID?
|
||||
@State private var colorTag: TagColor
|
||||
@State private var removesPassword = false
|
||||
@State private var saveError: String?
|
||||
|
||||
@@ -85,6 +86,7 @@ struct NewIdentitySheet: View {
|
||||
_username = State(initialValue: identity?.username ?? "")
|
||||
_passwordStorage = State(initialValue: identity?.passwordStorage ?? .iCloud)
|
||||
_keyID = State(initialValue: identity?.keyID)
|
||||
_colorTag = State(initialValue: identity?.colorTag ?? .none)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -152,6 +154,10 @@ struct NewIdentitySheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
LabeledContent("Color") {
|
||||
ColorTagPicker(selection: $colorTag)
|
||||
}
|
||||
|
||||
Text(passwordHelp)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -260,7 +266,8 @@ struct NewIdentitySheet: View {
|
||||
passwordStorage: passwordStorage,
|
||||
keyID: configuration.showsKey ? keyID : nil,
|
||||
notes: existing?.notes ?? "",
|
||||
lastUsedAt: existing?.lastUsedAt
|
||||
lastUsedAt: existing?.lastUsedAt,
|
||||
colorTag: colorTag
|
||||
)
|
||||
do {
|
||||
try create(identity, passwordUpdate)
|
||||
|
||||
@@ -711,7 +711,8 @@ final class SFTPSessionModel: Identifiable {
|
||||
username: submittedUsername, credentialRef: existing?.credentialRef ?? "",
|
||||
passwordStorage: existing?.passwordStorage ?? .iCloud,
|
||||
keyID: submittedKeyID, notes: existing?.notes ?? "",
|
||||
lastUsedAt: existing?.lastUsedAt
|
||||
lastUsedAt: existing?.lastUsedAt,
|
||||
colorTag: existing?.colorTag ?? .none
|
||||
)
|
||||
let update: IdentityPasswordUpdate =
|
||||
submittedPassword.map(IdentityPasswordUpdate.replace)
|
||||
|
||||
@@ -884,7 +884,8 @@ final class SSHSessionModel: Identifiable {
|
||||
passwordStorage: existing?.passwordStorage ?? .iCloud,
|
||||
keyID: submittedKeyID,
|
||||
notes: existing?.notes ?? "",
|
||||
lastUsedAt: existing?.lastUsedAt
|
||||
lastUsedAt: existing?.lastUsedAt,
|
||||
colorTag: existing?.colorTag ?? .none
|
||||
)
|
||||
let passwordUpdate: IdentityPasswordUpdate =
|
||||
submittedPassword.map(
|
||||
|
||||
@@ -17,7 +17,7 @@ final class PersistentHost {
|
||||
var sftpSessionInitialPath = ""
|
||||
var groupID: UUID?
|
||||
var tags: [String] = []
|
||||
var colorTag = HostColor.none.rawValue
|
||||
var colorTag = TagColor.none.rawValue
|
||||
/// Legacy V1–V5 storage retained only so existing stores can migrate.
|
||||
/// Recents and last-connected presentation are derived from connection
|
||||
/// history; this column is never read or written by current code.
|
||||
@@ -51,7 +51,7 @@ final class PersistentHost {
|
||||
var host = Host(
|
||||
id: id, name: name, host: address, port: port,
|
||||
identityID: identityID, groupID: groupID, tags: tags,
|
||||
colorTag: HostColor(rawValue: colorTag) ?? .none,
|
||||
colorTag: TagColor(rawValue: colorTag) ?? .none,
|
||||
sortIndex: sortIndex
|
||||
)
|
||||
host.jumpHostID = jumpHostID
|
||||
@@ -150,6 +150,69 @@ final class PersistentSnippet {
|
||||
}
|
||||
}
|
||||
|
||||
/// The V1–V6 shapes are frozen here so adding color fields to the live models
|
||||
/// does not rewrite the checksums of already-shipped schemas.
|
||||
enum TermindSchemaV6Models {
|
||||
@Model
|
||||
final class PersistentIdentity {
|
||||
@Attribute(.unique) var id: UUID = UUID()
|
||||
var name = ""
|
||||
var username = ""
|
||||
var credentialRef = ""
|
||||
var passwordStorage = PasswordStorage.iCloud.rawValue
|
||||
var keyID: UUID?
|
||||
var notes = ""
|
||||
var lastUsedAt: Date?
|
||||
var sortIndex = 0.0
|
||||
|
||||
init(_ value: Identity) {
|
||||
id = value.id
|
||||
name = value.name
|
||||
username = value.username
|
||||
credentialRef = value.credentialRef
|
||||
passwordStorage = value.passwordStorage.rawValue
|
||||
keyID = value.keyID
|
||||
notes = value.notes
|
||||
lastUsedAt = value.lastUsedAt
|
||||
sortIndex = value.sortIndex
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
final class PersistentSSHKey {
|
||||
@Attribute(.unique) var id: UUID = UUID()
|
||||
var label = ""
|
||||
var algorithm = SSHKeyAlgorithm.ed25519.rawValue
|
||||
var publicKey = ""
|
||||
var certificate = ""
|
||||
var fingerprint = ""
|
||||
var credentialRef = ""
|
||||
var storage = KeyStorage.iCloud.rawValue
|
||||
var keySize: Int?
|
||||
var hasPassphrase = false
|
||||
var requiresUserPresence = false
|
||||
var createdAt = Date(timeIntervalSince1970: 0)
|
||||
var sortIndex = 0.0
|
||||
|
||||
init(_ value: SSHKeyRef) {
|
||||
id = value.id
|
||||
label = value.label
|
||||
algorithm = value.algorithm.rawValue
|
||||
publicKey = value.publicKey
|
||||
certificate = value.certificate
|
||||
fingerprint = value.fingerprint
|
||||
credentialRef = value.credentialRef
|
||||
storage = value.storage.rawValue
|
||||
keySize = value.keySize
|
||||
hasPassphrase = value.hasPassphrase
|
||||
requiresUserPresence = value.requiresUserPresence
|
||||
createdAt = value.createdAt
|
||||
sortIndex = value.sortIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TermindSchemaV7Models {
|
||||
@Model
|
||||
final class PersistentIdentity {
|
||||
@Attribute(.unique) var id: UUID = UUID()
|
||||
@@ -160,6 +223,7 @@ final class PersistentIdentity {
|
||||
var keyID: UUID?
|
||||
var notes = ""
|
||||
var lastUsedAt: Date?
|
||||
var colorTag = TagColor.none.rawValue
|
||||
var sortIndex = 0.0
|
||||
|
||||
init(_ value: Identity) {
|
||||
@@ -175,6 +239,7 @@ final class PersistentIdentity {
|
||||
keyID = value.keyID
|
||||
notes = value.notes
|
||||
lastUsedAt = value.lastUsedAt
|
||||
colorTag = value.colorTag.rawValue
|
||||
sortIndex = value.sortIndex
|
||||
}
|
||||
|
||||
@@ -182,7 +247,8 @@ final class PersistentIdentity {
|
||||
Identity(
|
||||
id: id, name: name, username: username, credentialRef: credentialRef,
|
||||
passwordStorage: PasswordStorage(rawValue: passwordStorage) ?? .iCloud,
|
||||
keyID: keyID, notes: notes, lastUsedAt: lastUsedAt, sortIndex: sortIndex
|
||||
keyID: keyID, notes: notes, lastUsedAt: lastUsedAt,
|
||||
colorTag: TagColor(rawValue: colorTag) ?? .none, sortIndex: sortIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -201,6 +267,7 @@ final class PersistentSSHKey {
|
||||
var hasPassphrase = false
|
||||
var requiresUserPresence = false
|
||||
var createdAt = Date(timeIntervalSince1970: 0)
|
||||
var colorTag = TagColor.none.rawValue
|
||||
var sortIndex = 0.0
|
||||
|
||||
init(_ value: SSHKeyRef) {
|
||||
@@ -220,6 +287,7 @@ final class PersistentSSHKey {
|
||||
hasPassphrase = value.hasPassphrase
|
||||
requiresUserPresence = value.requiresUserPresence
|
||||
createdAt = value.createdAt
|
||||
colorTag = value.colorTag.rawValue
|
||||
sortIndex = value.sortIndex
|
||||
}
|
||||
|
||||
@@ -232,10 +300,14 @@ final class PersistentSSHKey {
|
||||
storage: KeyStorage(rawValue: storage) ?? .iCloud,
|
||||
keySize: keySize, hasPassphrase: hasPassphrase,
|
||||
requiresUserPresence: requiresUserPresence, createdAt: createdAt,
|
||||
sortIndex: sortIndex
|
||||
colorTag: TagColor(rawValue: colorTag) ?? .none, sortIndex: sortIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typealias PersistentIdentity = TermindSchemaV7Models.PersistentIdentity
|
||||
typealias PersistentSSHKey = TermindSchemaV7Models.PersistentSSHKey
|
||||
|
||||
@Model
|
||||
final class PersistentKnownHost {
|
||||
@@ -313,8 +385,8 @@ enum TermindSchemaV1: VersionedSchema {
|
||||
[
|
||||
PersistentHost.self,
|
||||
PersistentHostGroup.self,
|
||||
PersistentIdentity.self,
|
||||
PersistentSSHKey.self,
|
||||
TermindSchemaV6Models.PersistentIdentity.self,
|
||||
TermindSchemaV6Models.PersistentSSHKey.self,
|
||||
PersistentKnownHost.self,
|
||||
PersistentSnippetPackage.self,
|
||||
PersistentSnippet.self,
|
||||
@@ -381,11 +453,23 @@ enum TermindSchemaV6: VersionedSchema {
|
||||
}
|
||||
}
|
||||
|
||||
enum TermindSchemaV7: VersionedSchema {
|
||||
static let versionIdentifier = Schema.Version(7, 0, 0)
|
||||
|
||||
static var models: [any PersistentModel.Type] {
|
||||
TermindSchemaV6.models.filter {
|
||||
$0 != TermindSchemaV6Models.PersistentIdentity.self
|
||||
&& $0 != TermindSchemaV6Models.PersistentSSHKey.self
|
||||
} + [PersistentIdentity.self, PersistentSSHKey.self]
|
||||
}
|
||||
}
|
||||
|
||||
enum TermindMigrationPlan: SchemaMigrationPlan {
|
||||
static var schemas: [any VersionedSchema.Type] {
|
||||
[
|
||||
TermindSchemaV1.self, TermindSchemaV2.self, TermindSchemaV3.self,
|
||||
TermindSchemaV4.self, TermindSchemaV5.self, TermindSchemaV6.self,
|
||||
TermindSchemaV7.self,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -412,14 +496,17 @@ enum TermindMigrationPlan: SchemaMigrationPlan {
|
||||
}
|
||||
try context.save()
|
||||
}
|
||||
)
|
||||
),
|
||||
MigrationStage.lightweight(
|
||||
fromVersion: TermindSchemaV6.self, toVersion: TermindSchemaV7.self
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class HostPersistence {
|
||||
static let schema = Schema(versionedSchema: TermindSchemaV6.self)
|
||||
static let schema = Schema(versionedSchema: TermindSchemaV7.self)
|
||||
|
||||
let context: ModelContext
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ struct ColorSystemTests {
|
||||
.appending(path: "Termind", directoryHint: .isDirectory)
|
||||
let exemptPaths = [
|
||||
"Design/TerminalTheme.swift",
|
||||
"DesignSystem/HostColor+Presentation.swift",
|
||||
"DesignSystem/TagColor+Presentation.swift",
|
||||
]
|
||||
let forbidden = [
|
||||
"Color.white", "Color.black", "NSColor.white", "NSColor.gray",
|
||||
|
||||
@@ -18,12 +18,13 @@ struct RepositoryOutboxTests {
|
||||
let host = Host(name: "Production", host: "prod.example")
|
||||
let group = HostGroup(name: "Servers")
|
||||
let identity = Identity(
|
||||
name: "Operator", username: "deploy", passwordStorage: .iCloud
|
||||
name: "Operator", username: "deploy", passwordStorage: .iCloud,
|
||||
colorTag: .orange
|
||||
)
|
||||
let key = SSHKeyRef(
|
||||
label: "Deployment", publicKey: "ssh-ed25519 AAAA baseline",
|
||||
fingerprint: "SHA256:baseline", credentialRef: "key-baseline",
|
||||
storage: .iCloud
|
||||
storage: .iCloud, colorTag: .purple
|
||||
)
|
||||
let knownHost = KnownHostEntry(
|
||||
hostname: "prod.example", algorithm: .ed25519,
|
||||
@@ -81,6 +82,68 @@ struct RepositoryOutboxTests {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func schemaV6MigratesKeysAndIdentitiesWithNoColor() throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("CredentialColorMigration-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory, withIntermediateDirectories: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let storeURL = directory.appendingPathComponent("Termind.store")
|
||||
let legacySchema = Schema(versionedSchema: TermindSchemaV6.self)
|
||||
let identity = Identity(name: "Legacy", username: "legacy")
|
||||
let key = SSHKeyRef(label: "Legacy Key", fingerprint: "SHA256:legacy")
|
||||
|
||||
var legacyContainer: ModelContainer? = try ModelContainer(
|
||||
for: legacySchema,
|
||||
configurations: [
|
||||
localModelConfiguration(
|
||||
"CredentialColorMigration", schema: legacySchema, url: storeURL
|
||||
)
|
||||
]
|
||||
)
|
||||
legacyContainer?.mainContext.insert(
|
||||
TermindSchemaV6Models.PersistentIdentity(identity)
|
||||
)
|
||||
legacyContainer?.mainContext.insert(
|
||||
TermindSchemaV6Models.PersistentSSHKey(key)
|
||||
)
|
||||
try legacyContainer?.mainContext.save()
|
||||
legacyContainer = nil
|
||||
|
||||
let migrated = try ModelContainer(
|
||||
for: HostPersistence.schema,
|
||||
migrationPlan: TermindMigrationPlan.self,
|
||||
configurations: [
|
||||
localModelConfiguration(
|
||||
"CredentialColorMigration", schema: HostPersistence.schema,
|
||||
url: storeURL
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
let identities = try migrated.mainContext.fetch(FetchDescriptor<PersistentIdentity>())
|
||||
let keys = try migrated.mainContext.fetch(FetchDescriptor<PersistentSSHKey>())
|
||||
#expect(identities.map(\.value.colorTag) == [.none])
|
||||
#expect(keys.map(\.value.colorTag) == [.none])
|
||||
}
|
||||
|
||||
@Test
|
||||
func invalidPersistedCredentialColorsFallBackToNone() throws {
|
||||
let container = try makeContainer("InvalidCredentialColors")
|
||||
let identity = PersistentIdentity(Identity(name: "Deploy", username: "deploy"))
|
||||
let key = PersistentSSHKey(SSHKeyRef(label: "Deploy Key"))
|
||||
identity.colorTag = "future-identity-color"
|
||||
key.colorTag = "future-key-color"
|
||||
container.mainContext.insert(identity)
|
||||
container.mainContext.insert(key)
|
||||
try container.mainContext.save()
|
||||
|
||||
#expect(identity.value.colorTag == .none)
|
||||
#expect(key.value.colorTag == .none)
|
||||
}
|
||||
|
||||
@Test
|
||||
func repositoryPublishesMonotonicMutationSnapshots() async throws {
|
||||
let container = try makeContainer("RepositoryStream")
|
||||
|
||||
@@ -12,8 +12,10 @@ struct VaultSyncTests {
|
||||
func allVaultPayloadsRoundTripWithStableRecordNames() throws {
|
||||
let host = Host(name: "Production", host: "prod.example")
|
||||
let group = HostGroup(name: "Servers")
|
||||
let identity = Identity(name: "Deploy", username: "deploy")
|
||||
let key = SSHKeyRef(label: "Deploy Key", publicKey: "ssh-ed25519 AAAA")
|
||||
let identity = Identity(name: "Deploy", username: "deploy", colorTag: .green)
|
||||
let key = SSHKeyRef(
|
||||
label: "Deploy Key", publicKey: "ssh-ed25519 AAAA", colorTag: .blue
|
||||
)
|
||||
let knownHost = KnownHostEntry(hostname: "prod.example", fingerprint: "SHA256:test")
|
||||
let package = SnippetPackage(name: "Operations")
|
||||
let snippet = Snippet(title: "Uptime", body: "uptime", packageID: package.id)
|
||||
@@ -35,6 +37,35 @@ struct VaultSyncTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func legacyCredentialPayloadsWithoutColorsDecodeAsNone() throws {
|
||||
let writer = UUID()
|
||||
let identity = Identity(name: "Deploy", username: "deploy", colorTag: .pink)
|
||||
let key = SSHKeyRef(label: "Deploy Key", colorTag: .teal)
|
||||
let envelopes = [
|
||||
VaultEnvelope(
|
||||
key: VaultRecordKey(kind: .identity, id: identity.id),
|
||||
version: .baseline(writerID: writer), payload: .identity(identity)
|
||||
),
|
||||
VaultEnvelope(
|
||||
key: VaultRecordKey(kind: .sshKey, id: key.id),
|
||||
version: .baseline(writerID: writer), payload: .sshKey(key)
|
||||
),
|
||||
]
|
||||
|
||||
for envelope in envelopes {
|
||||
let legacyData = try removingJSONKey(
|
||||
"colorTag", from: VaultSyncCodec.encode(envelope)
|
||||
)
|
||||
let decoded = try VaultSyncCodec.decode(legacyData)
|
||||
switch decoded.payload {
|
||||
case .identity(let value): #expect(value.colorTag == .none)
|
||||
case .sshKey(let value): #expect(value.colorTag == .none)
|
||||
default: Issue.record("Expected a credential payload")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func journalRecordsOnlyRealChangesAndKeepsDeletionTombstones() throws {
|
||||
let container = try makeContainer("JournalDiff")
|
||||
@@ -196,3 +227,19 @@ struct VaultSyncTests {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func removingJSONKey(_ key: String, from data: Data) throws -> Data {
|
||||
func remove(from value: Any) -> Any {
|
||||
if var dictionary = value as? [String: Any] {
|
||||
dictionary.removeValue(forKey: key)
|
||||
return dictionary.mapValues(remove)
|
||||
}
|
||||
if let array = value as? [Any] {
|
||||
return array.map(remove)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
let object = try JSONSerialization.jsonObject(with: data)
|
||||
return try JSONSerialization.data(withJSONObject: remove(from: object))
|
||||
}
|
||||
Reference in new issue
Block a user