diff --git a/Spectty/Models/ServerConnection.swift b/Spectty/Models/ServerConnection.swift index 94c103f..9db0476 100644 --- a/Spectty/Models/ServerConnection.swift +++ b/Spectty/Models/ServerConnection.swift @@ -422,3 +422,32 @@ enum SpecttyMigrationPlan: SchemaMigrationPlan { } typealias ServerConnection = SpecttySchemaV4.ServerConnection + +extension ServerConnection { + var displayName: String { + name.isEmpty ? host : name + } + + func makeClone(named cloneName: String? = nil) -> ServerConnection { + let clone = ServerConnection( + name: cloneName ?? name, + host: host, + port: port, + username: username, + transport: transport, + authMethod: authMethod + ) + clone.profileName = profileName + clone.lastConnected = nil + clone.startupCommand = startupCommand + clone.moshPreset = moshPreset + clone.moshServerPath = moshServerPath + clone.moshUDPPortRange = moshUDPPortRange + clone.moshCompatibilityMode = moshCompatibilityMode + clone.moshBindFamily = moshBindFamily + clone.moshIPResolution = moshIPResolution + clone.password = password + clone.privateKeyPEM = privateKeyPEM + return clone + } +} diff --git a/Spectty/ViewModels/ConnectionStore.swift b/Spectty/ViewModels/ConnectionStore.swift index 35b7791..f88973d 100644 --- a/Spectty/ViewModels/ConnectionStore.swift +++ b/Spectty/ViewModels/ConnectionStore.swift @@ -1,6 +1,7 @@ import Foundation import OSLog import SwiftData +import SpecttyKeychain /// Manages persistence of server connections using SwiftData. @Observable @@ -8,6 +9,7 @@ import SwiftData final class ConnectionStore { private let modelContext: ModelContext private let logger = Logger(subsystem: "com.oceancheung.spectty-terminal", category: "ConnectionStore") + private let keychain = KeychainManager() var connections: [ServerConnection] = [] @@ -40,13 +42,25 @@ final class ConnectionStore { fetchConnections() } - func delete(_ connection: ServerConnection) { + func clone(_ connection: ServerConnection) async { + let clone = connection.makeClone() + await copyCredentials(from: connection, to: clone) + clone.name = makeUniqueCloneName(from: connection) + add(clone) + } + + func delete(_ connection: ServerConnection) async { + let credentialAccounts = keychainAccounts(for: connection) modelContext.delete(connection) do { try modelContext.save() } catch { logPersistenceError("Failed to delete connection: \(error)") + fetchConnections() + return } + + await deleteCredentials(accounts: credentialAccounts) fetchConnections() } @@ -62,4 +76,92 @@ final class ConnectionStore { private func logPersistenceError(_ message: String) { logger.error("\(message)") } + + private func makeUniqueCloneName(from connection: ServerConnection) -> String { + let existingNames = Set(connections.map(\.displayName)) + let baseName = connection.displayName.isEmpty ? "Connection" : connection.displayName + var candidate = "\(baseName) Copy" + var copyNumber = 2 + + while existingNames.contains(candidate) { + candidate = "\(baseName) Copy \(copyNumber)" + copyNumber += 1 + } + + return candidate + } + + private func copyCredentials(from source: ServerConnection, to clone: ServerConnection) async { + switch source.authMethod { + case .password, .keyboardInteractive: + clone.privateKeyKeychainAccount = nil + + let credentialData: Data? + if !source.password.isEmpty { + credentialData = Data(source.password.utf8) + } else { + credentialData = try? await keychain.load(account: "password-\(source.id.uuidString)") + } + + guard let credentialData else { + clone.passwordKeychainAccount = nil + return + } + + let targetAccount = "password-\(clone.id.uuidString)" + do { + try await keychain.saveOrUpdate(key: credentialData, account: targetAccount) + clone.passwordKeychainAccount = targetAccount + } catch { + clone.passwordKeychainAccount = nil + logPersistenceError("Failed to clone password credential: \(error)") + } + + case .publicKey: + clone.passwordKeychainAccount = nil + + let keyData: Data? + if !source.privateKeyPEM.isEmpty { + keyData = Data(source.privateKeyPEM.utf8) + } else { + keyData = try? await keychain.load(account: "private-key-\(source.id.uuidString)") + } + + guard let keyData else { + clone.privateKeyKeychainAccount = nil + return + } + + let targetAccount = "private-key-\(clone.id.uuidString)" + do { + try await keychain.saveOrUpdate(key: keyData, account: targetAccount) + clone.privateKeyKeychainAccount = targetAccount + } catch { + clone.privateKeyKeychainAccount = nil + logPersistenceError("Failed to clone private key credential: \(error)") + } + } + } + + private func keychainAccounts(for connection: ServerConnection) -> [String] { + let uuid = connection.id.uuidString + let accounts = [ + connection.passwordKeychainAccount, + connection.privateKeyKeychainAccount, + "password-\(uuid)", + "private-key-\(uuid)" + ] + + return Array(Set(accounts.compactMap { $0 })) + } + + private func deleteCredentials(accounts: [String]) async { + for account in accounts { + do { + try await keychain.delete(account: account) + } catch { + logPersistenceError("Failed to delete credential for account \(account): \(error)") + } + } + } } diff --git a/Spectty/Views/ConnectionEditorView.swift b/Spectty/Views/ConnectionEditorView.swift index f2a6c83..da50285 100644 --- a/Spectty/Views/ConnectionEditorView.swift +++ b/Spectty/Views/ConnectionEditorView.swift @@ -318,16 +318,20 @@ struct ConnectionEditorView: View { try? await keychain.delete(account: "private-key-\(uuid)") connection.privateKeyKeychainAccount = nil - guard !connection.password.isEmpty else { return } let account = "password-\(uuid)" + connection.passwordKeychainAccount = nil + + guard !connection.password.isEmpty else { return } try? await keychain.saveOrUpdate( key: Data(connection.password.utf8), account: account ) + connection.passwordKeychainAccount = account case .publicKey: // Clean up the credential from the other auth method. try? await keychain.delete(account: "password-\(uuid)") + connection.passwordKeychainAccount = nil guard !connection.privateKeyPEM.isEmpty else { return } let account = "private-key-\(uuid)" @@ -341,6 +345,7 @@ struct ConnectionEditorView: View { // Clean up credentials from both other methods. try? await keychain.delete(account: "password-\(uuid)") try? await keychain.delete(account: "private-key-\(uuid)") + connection.passwordKeychainAccount = nil connection.privateKeyKeychainAccount = nil } } diff --git a/Spectty/Views/ConnectionListView.swift b/Spectty/Views/ConnectionListView.swift index 4ab1a4a..6caf4b4 100644 --- a/Spectty/Views/ConnectionListView.swift +++ b/Spectty/Views/ConnectionListView.swift @@ -89,8 +89,15 @@ struct ConnectionListView: View { isNewConnection = false editingConnection = connection } + Button("Clone") { + Task { + await connectionStore.clone(connection) + } + } Button("Delete", role: .destructive) { - connectionStore.delete(connection) + Task { + await connectionStore.delete(connection) + } } } } diff --git a/SpecttyTests/ConnectionCloneTests.swift b/SpecttyTests/ConnectionCloneTests.swift new file mode 100644 index 0000000..ccbdc92 --- /dev/null +++ b/SpecttyTests/ConnectionCloneTests.swift @@ -0,0 +1,132 @@ +import XCTest +import SwiftData +import SpecttyKeychain +@testable import Spectty + +final class ConnectionCloneTests: XCTestCase { + func testMakeCloneCopiesConnectionSettingsAndResetsIdentity() { + let original = ServerConnection( + name: "Work", + host: "work.example.com", + port: 2222, + username: "ocean", + transport: .mosh, + authMethod: .publicKey + ) + original.profileName = "Solarized" + original.lastConnected = Date() + original.startupCommand = "tmux new-session -A -s main" + original.moshPreset = .troubleshoot + original.moshServerPath = "/usr/local/bin/mosh-server" + original.moshUDPPortRange = "60001:60010" + original.moshCompatibilityMode = true + original.moshBindFamily = .ipv4 + original.moshIPResolution = .remote + original.password = "secret" + original.privateKeyPEM = "PRIVATE KEY" + + let clone = original.makeClone(named: "Work Copy") + + XCTAssertNotEqual(clone.id, original.id) + XCTAssertEqual(clone.name, "Work Copy") + XCTAssertEqual(clone.host, original.host) + XCTAssertEqual(clone.port, original.port) + XCTAssertEqual(clone.username, original.username) + XCTAssertEqual(clone.transport, original.transport) + XCTAssertEqual(clone.authMethod, original.authMethod) + XCTAssertEqual(clone.profileName, original.profileName) + XCTAssertEqual(clone.startupCommand, original.startupCommand) + XCTAssertEqual(clone.moshPreset, original.moshPreset) + XCTAssertEqual(clone.moshServerPath, original.moshServerPath) + XCTAssertEqual(clone.moshUDPPortRange, original.moshUDPPortRange) + XCTAssertEqual(clone.moshCompatibilityMode, original.moshCompatibilityMode) + XCTAssertEqual(clone.moshBindFamily, original.moshBindFamily) + XCTAssertEqual(clone.moshIPResolution, original.moshIPResolution) + XCTAssertEqual(clone.password, original.password) + XCTAssertEqual(clone.privateKeyPEM, original.privateKeyPEM) + XCTAssertNil(clone.lastConnected) + } + + @MainActor + func testCloneCreatesUniqueCopyName() async throws { + let container = try makeInMemoryContainer() + let context = ModelContext(container) + let store = ConnectionStore(modelContext: context) + + let original = ServerConnection( + name: "Work", + host: "work.example.com", + port: 22, + username: "ocean" + ) + let existingCopy = ServerConnection( + name: "Work Copy", + host: "copy.example.com", + port: 22, + username: "ocean" + ) + + store.add(original) + store.add(existingCopy) + + await store.clone(original) + + XCTAssertEqual(store.connections.count, 3) + + let clone = try XCTUnwrap(store.connections.first { $0.name == "Work Copy 2" }) + XCTAssertNotEqual(clone.id, original.id) + XCTAssertEqual(clone.host, original.host) + XCTAssertEqual(clone.port, original.port) + XCTAssertEqual(clone.username, original.username) + XCTAssertEqual(clone.sortOrder, 2) + XCTAssertNil(clone.lastConnected) + } + + @MainActor + func testDeleteRemovesOnlyDeletedConnectionCredentials() async throws { + let container = try makeInMemoryContainer() + let context = ModelContext(container) + let store = ConnectionStore(modelContext: context) + let keychain = KeychainManager() + + let original = ServerConnection( + name: "Work", + host: "work.example.com", + port: 22, + username: "ocean" + ) + let clone = original.makeClone(named: "Work Copy") + + store.add(original) + store.add(clone) + + let originalAccount = "password-\(original.id.uuidString)" + let cloneAccount = "password-\(clone.id.uuidString)" + + try await keychain.saveOrUpdate(key: Data("original-secret".utf8), account: originalAccount) + try await keychain.saveOrUpdate(key: Data("clone-secret".utf8), account: cloneAccount) + + await store.delete(original) + + XCTAssertEqual(store.connections.count, 1) + XCTAssertEqual(store.connections.first?.id, clone.id) + let originalCredential = try await keychain.load(account: originalAccount) + XCTAssertNil(originalCredential) + + let cloneCredential = try await keychain.load(account: cloneAccount) + let cloneData = try XCTUnwrap(cloneCredential) + XCTAssertEqual(String(data: cloneData, encoding: .utf8), "clone-secret") + + try await keychain.delete(account: cloneAccount) + } + + private func makeInMemoryContainer() throws -> ModelContainer { + let schema = Schema(versionedSchema: SpecttySchemaV4.self) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer( + for: schema, + migrationPlan: SpecttyMigrationPlan.self, + configurations: [config] + ) + } +}