Add colors to keys and identities

This commit is contained in:
Kaede Akino committed 2026-08-30 01:43:54 +08:00
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",
+6 -6
View File
@@ -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,6 +1,6 @@
import SwiftUI
extension HostColor {
extension TagColor {
var displayName: LocalizedStringKey {
switch self {
case .none: "None"
+27
View File
@@ -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
View File
@@ -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)
)
}
}
+1 -1
View File
@@ -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
-9
View File
@@ -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 }
}
+10
View File
@@ -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)
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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",
+65 -2
View File
@@ -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")
+49 -2
View File
@@ -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))
}