Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions Spectty/Models/ServerConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
104 changes: 103 additions & 1 deletion Spectty/ViewModels/ConnectionStore.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import Foundation
import OSLog
import SwiftData
import SpecttyKeychain

/// Manages persistence of server connections using SwiftData.
@Observable
@MainActor
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] = []

Expand Down Expand Up @@ -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()
}

Expand All @@ -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)")
}
}
}
}
7 changes: 6 additions & 1 deletion Spectty/Views/ConnectionEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand All @@ -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
}
}
Expand Down
9 changes: 8 additions & 1 deletion Spectty/Views/ConnectionListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
Expand Down
132 changes: 132 additions & 0 deletions SpecttyTests/ConnectionCloneTests.swift
Original file line number Diff line number Diff line change
@@ -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]
)
}
}
Loading