From e5ce06c456dd0f7869015973bd028f8cfa260abc Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sun, 30 Aug 2026 01:43:54 +0800 Subject: [PATCH] Add colors to keys and identities --- .../FeatureModels/HostLibraryModel+Demo.swift | 10 +- Termind/Design/ColorTagPicker.swift | 12 +- ...tion.swift => TagColor+Presentation.swift} | 2 +- Termind/Domain/Credentials/Identity.swift | 27 +++++ Termind/Domain/Credentials/SSHKey.swift | 32 ++++++ Termind/Domain/Hosts/Host.swift | 2 +- Termind/Domain/Hosts/HostColor.swift | 9 -- Termind/Domain/TagColor.swift | 10 ++ .../Features/Keychain/KeychainPageView.swift | 72 ++++++++---- .../Features/Keychain/NewIdentitySheet.swift | 9 +- Termind/Features/SFTP/SFTPSessionModel.swift | 3 +- .../Features/Terminal/SSHSessionModel.swift | 3 +- .../Database/HostPersistence.swift | 103 ++++++++++++++++-- TermindTests/ColorSystemTests.swift | 2 +- TermindTests/RepositoryOutboxTests.swift | 67 +++++++++++- TermindTests/VaultSyncTests.swift | 51 ++++++++- 16 files changed, 356 insertions(+), 58 deletions(-) rename Termind/DesignSystem/{HostColor+Presentation.swift => TagColor+Presentation.swift} (96%) delete mode 100644 Termind/Domain/Hosts/HostColor.swift create mode 100644 Termind/Domain/TagColor.swift diff --git a/Termind/Application/FeatureModels/HostLibraryModel+Demo.swift b/Termind/Application/FeatureModels/HostLibraryModel+Demo.swift index 90a77032..f1656868 100644 --- a/Termind/Application/FeatureModels/HostLibraryModel+Demo.swift +++ b/Termind/Application/FeatureModels/HostLibraryModel+Demo.swift @@ -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", diff --git a/Termind/Design/ColorTagPicker.swift b/Termind/Design/ColorTagPicker.swift index def0118b..87292b5a 100644 --- a/Termind/Design/ColorTagPicker.swift +++ b/Termind/Design/ColorTagPicker.swift @@ -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") { diff --git a/Termind/DesignSystem/HostColor+Presentation.swift b/Termind/DesignSystem/TagColor+Presentation.swift similarity index 96% rename from Termind/DesignSystem/HostColor+Presentation.swift rename to Termind/DesignSystem/TagColor+Presentation.swift index 1b0885b9..1b6c7860 100644 --- a/Termind/DesignSystem/HostColor+Presentation.swift +++ b/Termind/DesignSystem/TagColor+Presentation.swift @@ -1,6 +1,6 @@ import SwiftUI -extension HostColor { +extension TagColor { var displayName: LocalizedStringKey { switch self { case .none: "None" diff --git a/Termind/Domain/Credentials/Identity.swift b/Termind/Domain/Credentials/Identity.swift index 11edf99f..d72284c7 100644 --- a/Termind/Domain/Credentials/Identity.swift +++ b/Termind/Domain/Credentials/Identity.swift @@ -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) + ) + } +} diff --git a/Termind/Domain/Credentials/SSHKey.swift b/Termind/Domain/Credentials/SSHKey.swift index 74a24366..736cda62 100644 --- a/Termind/Domain/Credentials/SSHKey.swift +++ b/Termind/Domain/Credentials/SSHKey.swift @@ -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) + ) + } +} diff --git a/Termind/Domain/Hosts/Host.swift b/Termind/Domain/Hosts/Host.swift index 36e0a6c3..76c819ae 100644 --- a/Termind/Domain/Hosts/Host.swift +++ b/Termind/Domain/Hosts/Host.swift @@ -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 diff --git a/Termind/Domain/Hosts/HostColor.swift b/Termind/Domain/Hosts/HostColor.swift deleted file mode 100644 index 4c58ab8f..00000000 --- a/Termind/Domain/Hosts/HostColor.swift +++ /dev/null @@ -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 } -} diff --git a/Termind/Domain/TagColor.swift b/Termind/Domain/TagColor.swift new file mode 100644 index 00000000..a4405882 --- /dev/null +++ b/Termind/Domain/TagColor.swift @@ -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 } +} diff --git a/Termind/Features/Keychain/KeychainPageView.swift b/Termind/Features/Keychain/KeychainPageView.swift index 5be510f6..f2f06b4a 100644 --- a/Termind/Features/Keychain/KeychainPageView.swift +++ b/Termind/Features/Keychain/KeychainPageView.swift @@ -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) diff --git a/Termind/Features/Keychain/NewIdentitySheet.swift b/Termind/Features/Keychain/NewIdentitySheet.swift index dadf9151..7f177e34 100644 --- a/Termind/Features/Keychain/NewIdentitySheet.swift +++ b/Termind/Features/Keychain/NewIdentitySheet.swift @@ -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) diff --git a/Termind/Features/SFTP/SFTPSessionModel.swift b/Termind/Features/SFTP/SFTPSessionModel.swift index c25655bf..dcb36b7a 100644 --- a/Termind/Features/SFTP/SFTPSessionModel.swift +++ b/Termind/Features/SFTP/SFTPSessionModel.swift @@ -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) diff --git a/Termind/Features/Terminal/SSHSessionModel.swift b/Termind/Features/Terminal/SSHSessionModel.swift index 0aecf8a6..9b2dfe10 100644 --- a/Termind/Features/Terminal/SSHSessionModel.swift +++ b/Termind/Features/Terminal/SSHSessionModel.swift @@ -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( diff --git a/Termind/Infrastructure/Database/HostPersistence.swift b/Termind/Infrastructure/Database/HostPersistence.swift index c45bcfc4..5444f80e 100644 --- a/Termind/Infrastructure/Database/HostPersistence.swift +++ b/Termind/Infrastructure/Database/HostPersistence.swift @@ -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 diff --git a/TermindTests/ColorSystemTests.swift b/TermindTests/ColorSystemTests.swift index 3a58c0cb..67c63ee6 100644 --- a/TermindTests/ColorSystemTests.swift +++ b/TermindTests/ColorSystemTests.swift @@ -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", diff --git a/TermindTests/RepositoryOutboxTests.swift b/TermindTests/RepositoryOutboxTests.swift index fa7654bd..865fae77 100644 --- a/TermindTests/RepositoryOutboxTests.swift +++ b/TermindTests/RepositoryOutboxTests.swift @@ -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()) + let keys = try migrated.mainContext.fetch(FetchDescriptor()) + #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") diff --git a/TermindTests/VaultSyncTests.swift b/TermindTests/VaultSyncTests.swift index d6e920f2..488c3d34 100644 --- a/TermindTests/VaultSyncTests.swift +++ b/TermindTests/VaultSyncTests.swift @@ -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)) +}