Files
Termind/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift
T
Kaede Akino 80f08b089f refactor: share transfer presentation and polish workspace actions
Centralize transfer state and presentation rules, extract the macOS transfer center, and preserve remote home metadata for compact iOS transfer paths.

Correct the shared Close Others action and extend transfer regression coverage.
2026-09-26 12:05:26 +08:00

2374 lines
99 KiB
Swift

import Foundation
import TermindDomain
import struct TermindDomain.Host
/// The single source of truth for saved hosts, groups, keys, snippets, and port
/// forwarding rules.
///
/// Core host metadata is backed by SwiftData in the app and projected into value
/// types for the views. Previews can still construct an in-memory store directly.
@Observable
public final class HostLibraryModel: RemoteSessionHostStore {
public private(set) var hosts: [Host] = []
public private(set) var groups: [HostGroup] = []
public private(set) var keys: [SSHKeyRef] = []
public private(set) var identities: [Identity] = []
public private(set) var snippets: [Snippet] = []
public private(set) var snippetPackages: [SnippetPackage] = []
public private(set) var portForwardings: [PortForwardingRule] = []
public private(set) var sftpTransfers: [SFTPTransfer] = []
public private(set) var knownHosts: [KnownHostEntry] = []
public private(set) var workspaceTemplates: [WorkspaceTemplate] = []
/// The latest local persistence failure. Secret text is never included.
public private(set) var storageError: String?
private let vault: VaultStore
private let keychain: any SecretStoring
private let defaults: UserDefaults
private var expansionState = LibraryExpansionState()
/// Runtime resources live in AppModel, so deletion announces before model
/// references are removed and the controller can stop listeners cleanly.
public var willDeleteHost: ((Host.ID, [PortForwardingRule.ID]) -> Void)?
public var willDeletePortForwardings: (([PortForwardingRule.ID]) -> Void)?
public var didChangeHostConnection: ((Host.ID) -> Void)?
public var didChangeTelnetConnection: ((Host.ID) -> Void)?
public var didPersistCore: (() -> Void)? {
get { vault.didPersist }
set { vault.didPersist = newValue }
}
public var didRecordVaultChanges: ((VaultMutationSet) -> Void)? {
get { vault.didRecordChanges }
set { vault.didRecordChanges = newValue }
}
public func reportStorageError(_ error: any Error) {
storageError = error.localizedDescription
}
/// Live connection state, keyed by host. Lives here rather than on `Host`
/// because it is session state, not user data — it must never be persisted
/// or synced.
public private(set) var statuses: [Host.ID: ConnectionStatus] = [:]
private var connectionStatuses: [UUID: (hostID: Host.ID, status: ConnectionStatus)] = [:]
public var isUngroupedExpanded: Bool { expansionState.isHostsUngroupedExpanded }
public var isSnippetsUngroupedExpanded: Bool { expansionState.isSnippetsUngroupedExpanded }
public init(
hosts: [Host] = [],
groups: [HostGroup] = [],
keys: [SSHKeyRef] = [],
identities: [Identity] = [],
snippets: [Snippet] = [],
snippetPackages: [SnippetPackage] = [],
portForwardings: [PortForwardingRule] = [],
sftpTransfers: [SFTPTransfer] = [],
knownHosts: [KnownHostEntry] = [],
workspaceTemplates: [WorkspaceTemplate] = [],
persistence: (any VaultPersisting)? = nil,
secrets: any SecretStoring = InMemorySecretStore(),
localDefaults: UserDefaults? = nil
) {
self.hosts = hosts
self.groups = groups
self.keys = normalizedManualOrder(
keys, sortIndex: \SSHKeyRef.sortIndex, name: \SSHKeyRef.label
)
self.identities = normalizedManualOrder(
identities, sortIndex: \Identity.sortIndex, name: \Identity.name
)
self.snippets = normalizedSnippetOrder(snippets)
self.snippetPackages = snippetPackages
self.portForwardings = portForwardings
self.sftpTransfers = sftpTransfers
self.knownHosts = knownHosts
self.workspaceTemplates = workspaceTemplates
self.vault = VaultStore(persistence: persistence)
self.keychain = secrets
self.defaults = localDefaults ?? .standard
do {
let loaded = try vault.load(
fallback: VaultSnapshot(
hosts: hosts, groups: groups, identities: identities, keys: keys,
knownHosts: knownHosts, snippets: snippets,
snippetPackages: snippetPackages, portForwardings: portForwardings,
workspaceTemplates: workspaceTemplates
)
)
self.hosts = loaded.hosts
self.groups = loaded.groups
self.identities = loaded.identities.sorted {
manuallyOrdered($0.sortIndex, $0.name, $1.sortIndex, $1.name)
}
self.keys = loaded.keys.sorted {
manuallyOrdered($0.sortIndex, $0.label, $1.sortIndex, $1.label)
}
self.knownHosts = loaded.knownHosts
self.snippets = loaded.snippets.sorted { $0.sortIndex < $1.sortIndex }
self.snippetPackages = loaded.snippetPackages
self.portForwardings = loaded.portForwardings
self.workspaceTemplates = loaded.workspaceTemplates
} catch {
storageError = error.localizedDescription
}
expansionState = LibraryExpansionState(
defaults: localDefaults,
legacyGroups: self.groups,
legacySnippetPackages: self.snippetPackages
)
reconcileExpansionState()
}
// MARK: - Reads
public func host(_ id: Host.ID) -> Host? {
hosts.first { $0.id == id }
}
public func group(_ id: HostGroup.ID) -> HostGroup? {
groups.first { $0.id == id }
}
public func isHostGroupExpanded(_ id: HostGroup.ID) -> Bool {
expansionState.isHostGroupExpanded(id)
}
public func snippet(_ id: Snippet.ID) -> Snippet? {
snippets.first { $0.id == id }
}
public func snippetModifiedAt(_ id: Snippet.ID) -> Date? {
vault.modifiedAt(for: VaultRecordKey(kind: .snippet, id: id))
}
public func identityModifiedAt(_ id: Identity.ID) -> Date? {
vault.modifiedAt(for: VaultRecordKey(kind: .identity, id: id))
}
public func snippetPackage(_ id: SnippetPackage.ID) -> SnippetPackage? {
snippetPackages.first { $0.id == id }
}
public func isSnippetPackageExpanded(_ id: SnippetPackage.ID) -> Bool {
expansionState.isSnippetPackageExpanded(id)
}
public var rootSnippetPackages: [SnippetPackage] {
snippetPackages.filter { $0.parentID == nil }.sorted { $0.sortIndex < $1.sortIndex }
}
public func childSnippetPackages(of parent: SnippetPackage.ID) -> [SnippetPackage] {
snippetPackages.filter { $0.parentID == parent }.sorted { $0.sortIndex < $1.sortIndex }
}
public func descendantSnippetPackages(of parent: SnippetPackage.ID) -> [SnippetPackage] {
childSnippetPackages(of: parent).flatMap {
[$0] + descendantSnippetPackages(of: $0.id)
}
}
public func snippets(in package: SnippetPackage.ID?) -> [Snippet] {
snippets.filter { $0.packageID == package }.sorted { $0.sortIndex < $1.sortIndex }
}
public func snippetPackagePath(_ packageID: SnippetPackage.ID?) -> String? {
guard let packageID, let package = snippetPackage(packageID) else { return nil }
var names = [package.name]
var parentID = package.parentID
var visited: Set<SnippetPackage.ID> = [package.id]
while let id = parentID, let parent = snippetPackage(id), visited.insert(id).inserted {
names.append(parent.name)
parentID = parent.parentID
}
return names.reversed().joined(separator: " / ")
}
public func status(of id: Host.ID) -> ConnectionStatus {
if statuses[id] == .connected { return .connected }
if sftpTransfers.contains(where: {
$0.involves(id) && $0.state == .running
}) {
return .connected
}
if let status = statuses[id] { return status }
if sftpTransfers.contains(where: {
$0.involves(id) && $0.state == .queued
}) {
return .connecting
}
return .disconnected
}
/// Top-level groups, in sort order.
public var rootGroups: [HostGroup] {
groups.filter { $0.parentID == nil }.sorted { $0.sortIndex < $1.sortIndex }
}
public func childGroups(of parent: HostGroup.ID) -> [HostGroup] {
groups.filter { $0.parentID == parent }.sorted { $0.sortIndex < $1.sortIndex }
}
/// Every group below `parent`, at any depth, parents before children.
///
/// What a delete has to take with it, and — read the other way — the set a
/// group may *not* be re-filed into, since a group cannot become its own
/// descendant.
public func descendantGroups(of parent: HostGroup.ID) -> [HostGroup] {
childGroups(of: parent).flatMap { [$0] + descendantGroups(of: $0.id) }
}
/// Hosts directly inside a group — `nil` means "not filed in any group".
public func hosts(in group: HostGroup.ID?) -> [Host] {
hosts.filter { $0.groupID == group }.sorted { $0.sortIndex < $1.sortIndex }
}
/// Hosts anywhere in a group's subtree. The number a delete has to warn
/// about: deleting Production takes Edge with it, so the machines filed in
/// Edge are just as much at stake as the ones filed in Production.
public func hosts(inTreeOf group: HostGroup.ID) -> [Host] {
let tree = Set([group] + descendantGroups(of: group).map(\.id))
return hosts.filter { host in host.groupID.map(tree.contains) ?? false }
}
public func portForwardings(for host: Host.ID) -> [PortForwardingRule] {
portForwardings.filter { $0.hostID == host }
}
public func portForwarding(_ id: PortForwardingRule.ID) -> PortForwardingRule? {
portForwardings.first { $0.id == id }
}
public var sortedWorkspaceTemplates: [WorkspaceTemplate] {
workspaceTemplates.sorted {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
}
public func workspaceTemplate(_ id: WorkspaceTemplate.ID) -> WorkspaceTemplate? {
workspaceTemplates.first { $0.id == id }
}
@discardableResult
public func saveWorkspaceTemplate(_ template: WorkspaceTemplate) -> Bool {
var template = template
template.name = template.name.trimmingCharacters(in: .whitespacesAndNewlines)
guard template.isValid else {
storageError = String(localized: "The Workspace template is invalid.")
return false
}
guard
!workspaceTemplates.contains(where: {
$0.id != template.id
&& $0.name.compare(template.name, options: [.caseInsensitive, .diacriticInsensitive])
== .orderedSame
})
else {
storageError = String(localized: "A Workspace template with this name already exists.")
return false
}
let previous = workspaceTemplates
if let index = workspaceTemplates.firstIndex(where: { $0.id == template.id }) {
workspaceTemplates[index] = template
} else {
workspaceTemplates.append(template)
}
guard persistCore() else {
workspaceTemplates = previous
return false
}
return true
}
@discardableResult
public func renameWorkspaceTemplate(_ id: WorkspaceTemplate.ID, to name: String) -> Bool {
guard var template = workspaceTemplate(id) else { return false }
template.name = name
return saveWorkspaceTemplate(template)
}
@discardableResult
public func deleteWorkspaceTemplate(_ id: WorkspaceTemplate.ID) -> Bool {
let previous = workspaceTemplates
workspaceTemplates.removeAll { $0.id == id }
guard workspaceTemplates.count != previous.count else { return false }
guard persistCore() else {
workspaceTemplates = previous
return false
}
return true
}
public func sftpTransfers(for host: Host.ID) -> [SFTPTransfer] {
sftpTransfers.filter { $0.involves(host) }
}
public func key(_ id: SSHKeyRef.ID) -> SSHKeyRef? {
keys.first { $0.id == id }
}
public func identity(_ id: Identity.ID) -> Identity? {
identities.first { $0.id == id }
}
public var reusableIdentities: [Identity] {
identities.filter { !$0.isHostCustom }
}
public func customIdentity(for hostID: Host.ID, purpose: HostCustomIdentityPurpose) -> Identity? {
identities.first { $0.ownerHostID == hostID && $0.customPurpose == purpose }
}
/// The login a host connects with, resolved through the store so renaming an
/// identity renames it everywhere it is shown.
public func identity(for host: Host) -> Identity? {
identity(for: host, protocol: .ssh)
}
public func identity(for host: Host, protocol connectionProtocol: HostTerminalProtocol) -> Identity? {
host.identityID(for: connectionProtocol).flatMap { identity($0) }
}
/// `user@host:port` — what the UI shows almost everywhere a host is named.
///
/// Falls back to the bare address when no identity is set, rather than
/// inventing a login: a host that does not yet know who it connects as
/// should look like one.
public func loginAddress(for host: Host) -> String {
loginAddress(for: host, protocol: .ssh)
}
public func loginAddress(for host: Host, protocol connectionProtocol: HostTerminalProtocol) -> String {
let address = host.displayAddress(for: connectionProtocol)
guard let username = identity(for: host, protocol: connectionProtocol)?.username, !username.isEmpty else {
return address
}
return "\(username)@\(address)"
}
public func preferredTerminalLoginAddress(for host: Host) -> String {
guard let connectionProtocol = host.preferredTerminalProtocol else { return host.host }
return loginAddress(for: host, protocol: connectionProtocol)
}
/// Which identities would break if a key were deleted. The Keys list shows
/// this count, because "delete" needs to say what it takes with it.
public func identities(using key: SSHKeyRef.ID) -> [Identity] {
identities.filter { $0.keyID == key }
}
/// The pinned host keys for a saved host, matched the way OpenSSH matches:
/// hostname plus port. Newest keys are shown first in the host's details.
public func knownHosts(for host: Host) -> [KnownHostEntry] {
knownHosts
.filter { $0.matches(hostname: host.host, port: host.port) }
.sorted {
if $0.addedAt != $1.addedAt { return $0.addedAt > $1.addedAt }
if $0.fingerprint != $1.fingerprint { return $0.fingerprint < $1.fingerprint }
return $0.id.uuidString < $1.id.uuidString
}
}
/// Resolves persistent trust plus approvals scoped to the current connection
/// journey. Temporary approvals never enter SwiftData or sync.
public func expectedHostKeys(
for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:],
trustPolicy: SSHHostTrustPolicy = .requireKnownHost
) throws -> Set<SSHHostKeyIdentity> {
var keys = Set(knownHosts(for: host).map(\.identity))
if let temporary = temporaryHostKeys[host.id] {
keys.insert(temporary)
}
if trustPolicy == .requireKnownHost, keys.isEmpty {
throw TermindSSHError.untrustedHost
}
return keys
}
/// Why a saved host cannot currently receive a key without stopping for
/// another question. Export is deliberately limited to hosts whose login
/// and host-key trust have already been established elsewhere in the app.
public func keyExportUnavailableReason(for host: Host) -> String? {
guard isEligible(host, for: .keyExport) else {
return HostProtocolUnavailableError(useCase: .keyExport).localizedDescription
}
guard let identity = identity(for: host) else {
return String(localized: "No identity")
}
guard !identity.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return String(localized: "No username")
}
let hasAvailablePassword = passwordAvailability(for: identity) == .available
let hasUsableKey =
identity.keyID.flatMap { key($0) }.map {
keyAvailability(for: $0) == .available
} == true
guard !identity.hasSecret || hasAvailablePassword || hasUsableKey else {
return String(localized: "No saved credential")
}
let knownHosts = knownHosts(for: host)
guard !knownHosts.isEmpty else {
return String(localized: "Connect once to trust this host")
}
return nil
}
/// Resolves model references and Keychain secrets before work leaves the
/// main actor. The resulting value is Sendable and contains no persistence
/// objects, so several one-shot exports can safely run in parallel.
public func prepareSSHConnection(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] = [:]
) throws -> SSHConnectionPreparation {
try require(host, for: .sshTerminal)
guard let route = validatedRoute(for: host) else {
throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts."))
}
return try prepareSSHConnection(
for: SSHConnectionRoute(hosts: route), submissions: submissions,
proxySubmissions: proxySubmissions)
}
public func telnetConnectionConfiguration(for host: Host) throws -> TelnetConnectionConfiguration {
try require(host, for: .telnetTerminal)
let identity = identity(for: host, protocol: .telnet)
let username = identity?.username.trimmingCharacters(in: .whitespacesAndNewlines)
let password: SSHSecret?
if let identity, identity.usesPassword {
password = try connectionPasswordSecret(for: identity)
guard password != nil else { throw TermindSSHError.missingCredential }
} else {
password = nil
}
return TelnetConnectionConfiguration(
hostID: host.id, host: host.host, port: host.telnetPort,
username: username?.isEmpty == false ? username : nil,
password: password, charset: host.telnetCharset,
proxy: try terminalTransportProxy(for: host.telnetProxy, ownerHostID: host.id)
)
}
private func terminalTransportProxy(
for proxy: HostProxy?, ownerHostID: Host.ID
) throws -> TerminalTransportProxy? {
guard let proxy else { return nil }
guard proxy.isValid else {
throw TermindSSHError.proxy(String(localized: "The proxy configuration is incomplete."))
}
var password: SSHSecret?
if let identityID = proxy.identityID {
guard let identity = identity(identityID), identity.usesPassword else {
throw TermindSSHError.proxyAuthentication(
hostID: ownerHostID, serverChallenge: false,
message: String(localized: "The proxy password identity is unavailable."))
}
password = try connectionPasswordSecret(for: identity)
guard password != nil else {
throw TermindSSHError.proxyAuthentication(
hostID: ownerHostID, serverChallenge: false,
message: String(localized: "The proxy password is unavailable."))
}
}
return TerminalTransportProxy(
kind: proxy.kind, hostname: proxy.hostname, port: proxy.port,
username: proxy.username, password: password,
identityID: proxy.identityID, ownerHostID: ownerHostID)
}
public func prepareSSHConnection(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] = [:],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity]
) throws -> SSHConnectionPreparation {
guard let route = validatedRoute(for: host) else {
throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts."))
}
return try prepareSSHConnection(
for: SSHConnectionRoute(hosts: route), submissions: submissions,
proxySubmissions: proxySubmissions, temporaryHostKeys: temporaryHostKeys)
}
public func prepareSSHConnection(
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] = [:]
) throws -> SSHConnectionPreparation {
try prepareSSHConnection(
for: route, submissions: submissions, proxySubmissions: proxySubmissions,
temporaryHostKeys: [:])
}
public func prepareSSHConnection(
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] = [:],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity]
) throws -> SSHConnectionPreparation {
guard route.isValid else { throw SSHConnectionRoute.invalidRouteError }
let submissions = route.submissions(merging: submissions)
return SSHConnectionPreparation(
nodes: route.hosts.map { node in
let submission = submissions[node.id]
let identity = submission?.identityID.flatMap { self.identity($0) } ?? identity(for: node)
let keyID = submission != nil ? submission?.keyID : identity?.keyID
return SSHConnectionPreparation.Node(
host: node, identity: identity,
key: keyID.flatMap { key($0) },
submission: submission,
trustedKeys: Set(knownHosts(for: node).map(\.identity))
.union(temporaryHostKeys[node.id].map { [$0] } ?? []),
proxyIdentity: (proxySubmissions[node.id]?.identityID ?? node.proxy?.identityID)
.flatMap { self.identity($0) },
proxySubmission: proxySubmissions[node.id]
)
},
secrets: keychain
)
}
public func sshConnectionConfiguration(
for host: Host,
submissions: [Host.ID: SSHAuthenticationSubmission] = [:],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] = [:],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:],
trustPolicy: SSHHostTrustPolicy = .requireKnownHost
) throws -> SSHConnectionConfiguration {
let username: String
let credentials: [SSHCredentials]
if let submission = submissions[host.id] {
username = submission.username.trimmingCharacters(in: .whitespacesAndNewlines)
guard !username.isEmpty else { throw TermindSSHError.missingUsername }
credentials = try sshCredentials(for: submission)
} else {
guard let identity = identity(for: host) else {
throw TermindSSHError.missingCredential
}
username = identity.username.trimmingCharacters(in: .whitespacesAndNewlines)
guard !username.isEmpty else { throw TermindSSHError.missingUsername }
let resolution = sshCredentials(for: identity)
guard resolution.requiredPassphraseKeyID == nil else {
throw TermindSSHError.keyPassphraseRequired
}
credentials = resolution.credentials
}
let expectsCredential =
submissions[host.id].map { $0.keyID != nil || $0.hasPassword }
?? (identity(for: host)?.hasSecret == true)
if credentials.isEmpty, expectsCredential { throw TermindSSHError.missingCredential }
let expectedHostKeys = try expectedHostKeys(
for: host, temporaryHostKeys: temporaryHostKeys,
trustPolicy: trustPolicy
)
return SSHConnectionConfiguration(
hostID: host.id, host: host.host, port: host.port, username: username,
credentials: credentials,
expectedHostKeys: expectedHostKeys,
proxy: try transportProxy(for: host, submission: proxySubmissions[host.id]),
jumps: try sshJumpConfigurations(
for: host, submissions: submissions, proxySubmissions: proxySubmissions,
temporaryHostKeys: temporaryHostKeys,
trustPolicy: trustPolicy
),
charset: host.charset,
authenticationReference: additionalAuthenticationReference(
host: host, submission: submissions[host.id]
)
)
}
public func sshJumpConfigurations(
for host: Host,
submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] = [:],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:],
trustPolicy: SSHHostTrustPolicy = .requireKnownHost
) throws -> [SSHRouteHopConfiguration] {
guard let route = validatedRoute(for: host) else {
throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts."))
}
return try sshJumpConfigurations(
route: route, submissions: submissions, proxySubmissions: proxySubmissions,
temporaryHostKeys: temporaryHostKeys,
trustPolicy: trustPolicy
)
}
public func sshJumpConfigurations(
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] = [:],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:],
trustPolicy: SSHHostTrustPolicy = .requireKnownHost
) throws -> [SSHRouteHopConfiguration] {
guard route.isValid else { throw SSHConnectionRoute.invalidRouteError }
return try sshJumpConfigurations(
route: route.hosts, submissions: route.submissions(merging: submissions),
proxySubmissions: proxySubmissions,
temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy
)
}
private func sshJumpConfigurations(
route: [Host], submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], trustPolicy: SSHHostTrustPolicy
) throws -> [SSHRouteHopConfiguration] {
return try route.dropLast().enumerated().map { offset, jump in
do {
if let submission = submissions[jump.id] {
return try sshRouteHopConfiguration(
for: jump, submission: submission,
proxySubmission: proxySubmissions[jump.id],
temporaryHostKeys: temporaryHostKeys,
trustPolicy: trustPolicy
)
}
return try sshRouteHopConfiguration(
for: jump, proxySubmission: proxySubmissions[jump.id],
temporaryHostKeys: temporaryHostKeys,
trustPolicy: trustPolicy
)
} catch {
if (error as? TermindSSHError)?.isProxyAuthentication == true { throw error }
let authentication =
switch error as? TermindSSHError {
case .missingCredential, .missingUsername, .keyPassphraseRequired: true
default: false
}
let hostKey =
switch error as? TermindSSHError {
case .untrustedHost: true
default: false
}
throw TermindSSHError.routeHost(
hostID: jump.id, authentication: authentication,
serverChallenge: false, hostKey: hostKey,
message: String(localized: "Jump Host \(offset + 1) (\(jump.name)): \(error.localizedDescription)")
)
}
}
}
private func sshRouteHopConfiguration(
for host: Host, proxySubmission: SSHProxyAuthenticationSubmission?,
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
trustPolicy: SSHHostTrustPolicy
) throws -> SSHRouteHopConfiguration {
guard let identity = identity(for: host) else { throw TermindSSHError.missingCredential }
let username = identity.username.trimmingCharacters(in: .whitespacesAndNewlines)
guard !username.isEmpty else { throw TermindSSHError.missingUsername }
let expectedHostKeys = try expectedHostKeys(
for: host, temporaryHostKeys: temporaryHostKeys,
trustPolicy: trustPolicy
)
let resolution = sshCredentials(for: identity)
guard resolution.requiredPassphraseKeyID == nil else {
throw TermindSSHError.keyPassphraseRequired
}
guard !identity.hasSecret || !resolution.credentials.isEmpty else { throw TermindSSHError.missingCredential }
return SSHRouteHopConfiguration(
id: host.id, name: host.name, host: host.host, port: host.port,
username: username, credentials: resolution.credentials,
expectedHostKeys: expectedHostKeys,
proxy: try transportProxy(for: host, submission: proxySubmission),
authenticationReference: additionalAuthenticationReference(
host: host, submission: nil
)
)
}
private func sshRouteHopConfiguration(
for host: Host, submission: SSHAuthenticationSubmission,
proxySubmission: SSHProxyAuthenticationSubmission?,
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
trustPolicy: SSHHostTrustPolicy
) throws -> SSHRouteHopConfiguration {
let username = submission.username.trimmingCharacters(in: .whitespacesAndNewlines)
guard !username.isEmpty else { throw TermindSSHError.missingUsername }
let expectedHostKeys = try expectedHostKeys(
for: host, temporaryHostKeys: temporaryHostKeys,
trustPolicy: trustPolicy
)
let credentials = try sshCredentials(for: submission)
guard submission.keyID == nil || !credentials.isEmpty else { throw TermindSSHError.missingCredential }
return SSHRouteHopConfiguration(
id: host.id, name: host.name, host: host.host, port: host.port,
username: username, credentials: credentials,
expectedHostKeys: expectedHostKeys,
proxy: try transportProxy(for: host, submission: proxySubmission),
authenticationReference: additionalAuthenticationReference(
host: host, submission: submission
)
)
}
private func additionalAuthenticationReference(
host: Host, submission: SSHAuthenticationSubmission?
) -> SSHAuthenticationReference {
let resolvedIdentity =
submission?.identityID.flatMap(identity)
?? (submission == nil ? identity(for: host) : nil)
if let resolvedIdentity {
return SSHAuthenticationReference(
identityID: resolvedIdentity.id,
username: resolvedIdentity.username,
permitsNone: !resolvedIdentity.hasSecret,
hasEphemeralSecret: submission?.hasPassword == true
|| submission?.hasPassphrase == true
)
}
return SSHAuthenticationReference(
fallbackKeyID: submission?.keyID,
username: submission?.username ?? "",
permitsNone: submission?.keyID == nil && submission?.hasPassword != true,
hasEphemeralSecret: submission?.hasPassword == true
|| submission?.hasPassphrase == true
)
}
private func sshCredentials(for submission: SSHAuthenticationSubmission) throws -> [SSHCredentials] {
var credentials: [SSHCredentials] = []
if let keyID = submission.keyID, let key = key(keyID) {
if key.requiresPassphraseEntry, !submission.hasPassphrase {
throw TermindSSHError.keyPassphraseRequired
}
if let stored = try keySecretCredential(for: key) {
credentials.append(
stored.sshCredential(
publicKey: key.publicKey, certificate: key.certificate,
passphraseOverride: submission.passphraseForConnection(),
requiresPassphrase: key.requiresPassphraseEntry,
requiresUserPresence: key.requiresUserPresence
)
)
}
}
if let password = submission.passwordForConnection() {
credentials.append(.password(password))
} else if let identityID = submission.identityID,
let identity = identity(identityID), identity.usesPassword,
let password = try passwordSecret(for: identity)
{
credentials.append(.password(password))
}
return credentials
}
public func transportProxy(for host: Host) throws -> SSHTransportProxy? {
try transportProxy(for: host, submission: nil)
}
public func transportProxy(
for host: Host, submission: SSHProxyAuthenticationSubmission?
) throws -> SSHTransportProxy? {
guard let proxy = host.proxy else { return nil }
guard proxy.isValid else {
throw TermindSSHError.proxy(String(localized: "The proxy configuration is incomplete."))
}
var password = submission?.passwordForConnection()
if password == nil, let identityID = submission?.identityID ?? proxy.identityID {
guard let identity = identity(identityID), identity.usesPassword else {
throw TermindSSHError.proxyAuthentication(
hostID: host.id, serverChallenge: false,
message: String(localized: "The proxy password identity is unavailable."))
}
do {
password = try passwordSecret(for: identity)
} catch {
throw TermindSSHError.proxyAuthentication(
hostID: host.id, serverChallenge: false, message: error.localizedDescription)
}
guard password != nil else {
throw TermindSSHError.proxyAuthentication(
hostID: host.id, serverChallenge: false,
message: String(localized: "The proxy password is unavailable."))
}
}
return SSHTransportProxy(
kind: proxy.kind, hostname: proxy.hostname, port: proxy.port,
username: submission?.username ?? proxy.username, password: password,
identityID: submission?.identityID ?? proxy.identityID,
ownerHostID: host.id
)
}
/// Loads Key and Password independently so one damaged or missing Keychain
/// item does not suppress the other usable credential.
public func sshCredentials(for identity: Identity) -> SSHCredentialResolution {
var credentials: [SSHCredentials] = []
var failures: [String] = []
var requiredPassphraseKeyID: SSHKeyRef.ID?
if let keyID = identity.keyID {
if let key = key(keyID) {
if key.requiresPassphraseEntry {
requiredPassphraseKeyID = key.id
} else {
do {
if let credential = try keySecretCredential(for: key) {
credentials.append(
credential.sshCredential(
publicKey: key.publicKey, certificate: key.certificate,
requiresPassphrase: key.requiresPassphraseEntry,
requiresUserPresence: key.requiresUserPresence
)
)
} else {
failures.append(
String(localized: "The selected key has no saved private key.")
)
}
} catch {
storageError = error.localizedDescription
failures.append(error.localizedDescription)
}
}
} else {
failures.append(
String(localized: "The selected key is no longer available.")
)
}
}
if identity.usesPassword {
do {
if let password = try passwordSecret(for: identity) {
credentials.append(.password(password))
} else {
failures.append(
String(localized: "The saved password is no longer available.")
)
}
} catch {
storageError = error.localizedDescription
failures.append(error.localizedDescription)
}
}
return SSHCredentialResolution(
credentials: credentials, failures: failures,
hasConfiguredCredential: identity.hasSecret,
requiredPassphraseKeyID: requiredPassphraseKeyID
)
}
/// Every host tag, deduplicated case-insensitively and sorted.
public var allTags: [String] {
uniqueSortedTags(hosts.flatMap(\.tags))
}
/// Tags in use on hosts. What the Hosts page offers to filter by: a
/// snippet-only tag there would be a filter that matches nothing.
public var hostTags: [String] {
uniqueSortedTags(hosts.flatMap(\.tags))
}
private func uniqueSortedTags(_ tags: [String]) -> [String] {
var seen = Set<String>()
let uniqueTags = tags.filter { seen.insert($0.lowercased()).inserted }
return uniqueTags.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
}
// MARK: - Search
/// Also matches the resolved identity, so filtering by `deploy` still finds
/// every box that logs in as `deploy` now that the name lives one hop away.
public func searchHosts(_ query: String) -> [Host] {
hosts
.filter { host in
host.matches(query)
|| host.supportedTerminalProtocols.contains {
loginAddress(for: host, protocol: $0).localizedCaseInsensitiveContains(query)
}
|| (!query.isEmpty
&& host.supportedTerminalProtocols.contains {
identity(for: host, protocol: $0)?.matches(query) == true
})
}
.sorted { $0.name < $1.name }
}
public func hosts(matching query: String, requiring requirement: HostProtocolRequirement) -> [Host] {
searchHosts(query).filter { $0.supports(requirement) }
}
public func hosts(matching query: String, for useCase: HostUseCase) -> [Host] {
hosts(matching: query, requiring: useCase.protocolRequirement)
}
public func isEligible(_ host: Host, for useCase: HostUseCase) -> Bool {
host.supports(useCase.protocolRequirement)
}
public func require(_ host: Host, for useCase: HostUseCase) throws {
guard isEligible(host, for: useCase) else {
throw HostProtocolUnavailableError(useCase: useCase)
}
}
public func searchSnippets(_ query: String) -> [Snippet] {
snippets.filter {
$0.matches(query)
|| (!query.isEmpty
&& snippetPackagePath($0.packageID)?.localizedCaseInsensitiveContains(query) == true)
}.sorted { $0.sortIndex < $1.sortIndex }
}
public func searchKeys(_ query: String) -> [SSHKeyRef] {
keys
.filter { key in
query.isEmpty
|| key.label.localizedCaseInsensitiveContains(query)
|| key.fingerprint.localizedCaseInsensitiveContains(query)
}
.sorted { manuallyOrdered($0.sortIndex, $0.label, $1.sortIndex, $1.label) }
}
public func searchIdentities(_ query: String) -> [Identity] {
reusableIdentities.filter { $0.matches(query) }.sorted {
manuallyOrdered($0.sortIndex, $0.name, $1.sortIndex, $1.name)
}
}
// MARK: - Writes
public func upsert(_ host: Host) { _ = saveHost(host) }
@discardableResult
public func saveHost(_ host: Host) -> Bool {
var host = host
host.name = SavedName.resolve(host.name, default: host.defaultName)
host.portForwardingMaxConnections = Host.normalizedPortForwardingMaxConnections(
host.portForwardingMaxConnections)
guard host.preferredTerminalProtocol != nil else {
storageError = String(localized: "Enable SSH or Telnet before saving this Host.")
return false
}
guard !host.sshEnabled || (1...65_535).contains(host.sshPort),
!host.telnetEnabled || (1...65_535).contains(host.telnetPort)
else {
storageError = String(localized: "The enabled protocol port must be between 1 and 65535.")
return false
}
guard !host.sshEnabled || validatedRoute(for: host) != nil else {
storageError = String(localized: "The selected jump host creates a routing loop or exceeds 8 hops.")
return false
}
let previous = hosts
let old = self.host(host.id)
if let index = hosts.firstIndex(where: { $0.id == host.id }) {
var updated = host
updated.detectedDistribution = hosts[index].detectedDistribution
hosts[index] = updated
} else {
var new = host
new.sortIndex = nextSortIndex(in: host.groupID)
hosts.append(new)
}
guard persistCore() else {
hosts = previous
return false
}
if let old {
if !old.hasSameSSHConnectionRoute(as: host) { didChangeHostConnection?(host.id) }
if !old.hasSameTelnetConnection(as: host) { didChangeTelnetConnection?(host.id) }
}
return true
}
/// Transitional in-memory projection for repository-owned durable deletion.
/// The repository is the only writer; this keeps legacy views in sync until
/// they consume `HostLibraryModel` directly.
public func projectHostDeletion(_ hostID: Host.ID) {
let owned = identities.filter { $0.ownerHostID == hostID }
for identity in owned where !identity.credentialRef.isEmpty {
do {
try keychain.removePassword(identity.credentialRef, storage: identity.passwordStorage)
} catch {
storageError = error.localizedDescription
}
}
identities.removeAll { $0.ownerHostID == hostID }
removeHostProjection(hostID)
}
private func removeHostProjection(_ hostID: Host.ID) {
hosts.removeAll { $0.id == hostID }
for index in hosts.indices where hosts[index].jumpHostID == hostID {
hosts[index].jumpHostID = nil
}
for index in snippets.indices {
snippets[index].targetHostIDs.removeAll { $0 == hostID }
}
portForwardings.removeAll { $0.hostID == hostID }
connectionStatuses = connectionStatuses.filter { $0.value.hostID != hostID }
statuses[hostID] = nil
}
public func setStatus(_ status: ConnectionStatus, for id: Host.ID) {
statuses[id] = status
}
/// Records one live connection's state and projects terminals and File
/// Transfer browsers for the same host into the status dot shown by Hosts
/// and host pickers. A connected source wins over a connecting or failed
/// sibling, so closing one pane cannot make an otherwise-online host appear
/// offline.
public func setConnectionStatus(
_ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID
) {
if status == .disconnected {
connectionStatuses[connectionID] = nil
} else {
connectionStatuses[connectionID] = (hostID, status)
}
refreshStatus(for: hostID)
}
/// Persists only a meaningful, validated distribution transition. The
/// current Host projection is mutated in place so a shell result cannot
/// overwrite editor changes made while the connection was starting.
public func recordRemoteSystemInfo(_ systemInfo: RemoteSystemInfo?, for hostID: Host.ID) {
guard let index = hosts.firstIndex(where: { $0.id == hostID }),
let systemInfo,
case .resolved(let next) = systemInfo.distributionDetection
else { return }
guard hosts[index].detectedDistribution != next else { return }
hosts[index].detectedDistribution = next
persistCore()
}
private func refreshStatus(for hostID: Host.ID) {
let active = connectionStatuses.values
.filter { $0.hostID == hostID }
.map(\.status)
if active.contains(.connected) {
statuses[hostID] = .connected
} else if active.contains(.reconnecting) {
statuses[hostID] = .reconnecting
} else if active.contains(.connecting) {
statuses[hostID] = .connecting
} else if let failure = active.first(where: {
if case .failed = $0 { return true }
return false
}) {
statuses[hostID] = failure
} else {
statuses[hostID] = nil
}
}
/// Drops a pinned host key. The next connection to that host is treated as a
/// first connection, which is the whole point — it is the escape hatch for a
/// server that was legitimately rekeyed.
public func forget(knownHost id: KnownHostEntry.ID) {
deleteKnownHosts([id])
}
public func forgetKnownHosts(for host: Host) {
deleteKnownHosts(Set(knownHosts(for: host).map(\.id)))
}
public func trust(_ entry: KnownHostEntry) {
let endpointMatches: (KnownHostEntry) -> Bool = {
$0.matches(hostname: entry.hostname, port: entry.port)
}
if let index = knownHosts.firstIndex(where: {
endpointMatches($0) && $0.fingerprint == entry.fingerprint
}) {
let retainedID = knownHosts[index].id
knownHosts[index].algorithm = entry.algorithm
knownHosts[index].addedAt = entry.addedAt
knownHosts.removeAll {
$0.id != retainedID && endpointMatches($0)
&& $0.fingerprint == entry.fingerprint
}
} else {
knownHosts.append(entry)
}
persistCore()
}
public func setExpanded(_ expanded: Bool, for groupID: HostGroup.ID) {
guard group(groupID) != nil else { return }
expansionState.setHostGroupExpanded(expanded, for: groupID)
}
public func setUngroupedExpanded(_ expanded: Bool) {
expansionState.setHostsUngroupedExpanded(expanded)
}
public func setSnippetPackageExpanded(_ expanded: Bool, for packageID: SnippetPackage.ID) {
guard snippetPackage(packageID) != nil else { return }
expansionState.setSnippetPackageExpanded(expanded, for: packageID)
}
public func setSnippetsUngroupedExpanded(_ expanded: Bool) {
expansionState.setSnippetsUngroupedExpanded(expanded)
}
/// Adds or updates an identity. New ones land at the end of the list, which
/// is sorted by name wherever it is shown, so there is no order to keep.
@discardableResult
public func upsert(_ identity: Identity) -> Bool {
upsert(identity, touchingModificationDate: false)
}
@discardableResult
private func upsert(_ identity: Identity, touchingModificationDate: Bool) -> Bool {
var identity = identity
identity.name = SavedName.resolve(identity.name, default: identity.defaultName)
if let previous = self.identity(identity.id),
previous.usesPassword,
previous.passwordStorage == .iCloud,
identity.passwordStorage == .thisDevice
{
storageError = String(localized: "iCloud credentials cannot be moved to This Device.")
return false
}
let identitySnapshot = identities
let hostSnapshot = hosts
if let index = identities.firstIndex(where: { $0.id == identity.id }) {
let previous = identities[index]
var updated = identity
updated.sortIndex = previous.sortIndex
identities[index] = updated
} else {
var new = identity
new.sortIndex = (identities.map(\.sortIndex).max() ?? -1) + 1
identities.append(new)
}
if !identity.isProxyEligible {
for index in hosts.indices where hosts[index].proxy?.identityID == identity.id {
hosts[index].proxy?.identityID = nil
hosts[index].proxy?.username = ""
}
}
let touchedKeys: Set<VaultRecordKey> =
touchingModificationDate
? [VaultRecordKey(kind: .identity, id: identity.id)]
: []
let saved = persistCore(touching: touchedKeys)
if !saved {
identities = identitySnapshot
hosts = hostSnapshot
}
return saved
}
/// Updates an Identity and its Keychain password as one operation.
///
/// The editor supplies intent rather than an optional string so a blank
/// field can keep an existing password while an explicit remove action can
/// delete it. The old reference is resolved here, before the model changes,
/// which prevents a cleared Identity from making its former Keychain item
/// unreachable.
public func upsert(_ identity: Identity, passwordUpdate: IdentityPasswordUpdate) throws {
let previousIdentity = self.identity(identity.id)
let previousReference = previousIdentity?.credentialRef ?? identity.credentialRef
let previousStorage = previousIdentity?.passwordStorage ?? identity.passwordStorage
var updated = identity
if previousIdentity?.usesPassword == true,
previousStorage == .iCloud,
updated.passwordStorage == .thisDevice
{
throw TermindSSHError.library(
String(localized: "iCloud credentials cannot be moved to This Device.")
)
}
var oldPassword: String?
func loadOldPassword() throws -> String? {
if let oldPassword { return oldPassword }
guard !previousReference.isEmpty else { return nil }
let loaded = try keychain.string(for: previousReference, storage: previousStorage)
oldPassword = loaded
return loaded
}
let identitySnapshot = identities
let hostSnapshot = hosts
var wroteDestination = false
do {
switch passwordUpdate {
case .keep:
updated.credentialRef = previousReference
if previousStorage != updated.passwordStorage, !previousReference.isEmpty {
guard let oldPassword = try loadOldPassword() else {
throw TermindSSHError.library(
String(
localized:
"The saved password is unavailable on this Mac. Enter it again before changing storage."
)
)
}
try keychain.setString(
oldPassword, for: previousReference, storage: updated.passwordStorage
)
wroteDestination = true
}
case .replace(let password):
guard !password.isEmpty else {
throw TermindSSHError.library(
String(localized: "A replacement password cannot be empty.")
)
}
let reference =
previousReference.isEmpty
? "termind.password.\(UUID().uuidString)"
: previousReference
_ = try loadOldPassword()
try keychain.setString(password, for: reference, storage: updated.passwordStorage)
wroteDestination = true
updated.credentialRef = reference
case .remove:
updated.credentialRef = ""
}
storageError = nil
} catch {
storageError = error.localizedDescription
throw error
}
let changesPassword: Bool
switch passwordUpdate {
case .keep: changesPassword = false
case .replace, .remove: changesPassword = true
}
guard upsert(updated, touchingModificationDate: changesPassword) else {
identities = identitySnapshot
hosts = hostSnapshot
do {
if wroteDestination {
if updated.passwordStorage == previousStorage, let oldPassword {
try keychain.setString(
oldPassword, for: previousReference, storage: previousStorage
)
} else if !updated.credentialRef.isEmpty {
try keychain.removePassword(
updated.credentialRef, storage: updated.passwordStorage
)
}
}
} catch {
storageError = String(
localized: "The database save and Keychain rollback both failed: \(error.localizedDescription)"
)
}
throw TermindSSHError.library(
storageError ?? String(localized: "The identity could not be saved.")
)
}
let removesPrevious =
!previousReference.isEmpty
&& (updated.credentialRef.isEmpty || previousStorage != updated.passwordStorage)
if removesPrevious {
do {
try keychain.removePassword(previousReference, storage: previousStorage)
} catch {
// The newly committed destination stays authoritative. Retaining
// the source is safer than rolling a successful save backward.
storageError = String(
localized:
"The password was saved, but its previous Keychain copy could not be removed: \(error.localizedDescription)"
)
}
}
}
/// Throwing counterpart used by connection setup, where a Keychain failure
/// must remain distinguishable from an Identity that has no password.
public func passwordCredential(for identity: Identity) throws -> String? {
guard identity.usesPassword else { return nil }
return try keychain.string(
for: identity.credentialRef, storage: identity.passwordStorage
)
}
public func editablePassword(for identity: Identity) throws -> String? {
try passwordCredential(for: identity)
}
@discardableResult
public func saveCustomIdentity(
ownerHostID: Host.ID, purpose: HostCustomIdentityPurpose, displayName: String,
username: String, password: String, keyID: SSHKeyRef.ID?
) throws -> Identity? {
let username = username.trimmingCharacters(in: .whitespacesAndNewlines)
let existing = customIdentity(for: ownerHostID, purpose: purpose)
if username.isEmpty && password.isEmpty && keyID == nil {
if let existing, !delete(identityID: existing.id) {
throw TermindSSHError.library(
storageError ?? String(localized: "The identity could not be saved."))
}
return nil
}
var value = Identity(
id: existing?.id ?? UUID(), name: displayName, username: username,
credentialRef: existing?.credentialRef ?? "",
passwordStorage: existing?.passwordStorage ?? customPasswordStorage,
keyID: purpose == .ssh ? keyID : nil, notes: existing?.notes ?? "",
colorTag: existing?.colorTag ?? .none,
sortIndex: existing?.sortIndex ?? 0, ownerHostID: ownerHostID, customPurpose: purpose
)
let update: IdentityPasswordUpdate
if let existing, passwordAvailability(for: existing) != .available {
update = .keep
} else {
let previous = try existing.flatMap { try editablePassword(for: $0) } ?? ""
update = password == previous ? .keep : password.isEmpty ? .remove : .replace(password)
}
try upsert(value, passwordUpdate: update)
value = identity(value.id) ?? value
return value
}
public var customPasswordStorage: PasswordStorage {
let cloud =
defaults.object(forKey: SettingsKey.iCloudSyncEnabled) as? Bool
?? SettingsKey.defaultICloudSyncEnabled
return cloud ? .iCloud : .thisDevice
}
/// One-way migration for Host-private passwords when Vault sync becomes active.
/// The local copy is removed only after the iCloud value is verified and the
/// Identity metadata has committed successfully.
public func migrateCustomPasswordsToICloud() throws {
guard customPasswordStorage == .iCloud else { return }
for identity in identities where identity.isHostCustom && identity.passwordStorage == .thisDevice {
var updated = identity
if identity.credentialRef.isEmpty {
updated.passwordStorage = .iCloud
guard upsert(updated) else {
throw TermindSSHError.library(
storageError ?? String(localized: "The identity could not be saved."))
}
continue
}
guard let password = try keychain.string(for: identity.credentialRef, storage: .thisDevice) else {
throw TermindSSHError.library(String(localized: "The saved password is no longer available."))
}
try keychain.setString(password, for: identity.credentialRef, storage: .iCloud)
guard try keychain.string(for: identity.credentialRef, storage: .iCloud) == password else {
try? keychain.removePassword(identity.credentialRef, storage: .iCloud)
throw TermindSSHError.library(String(localized: "The saved password is no longer available."))
}
updated.passwordStorage = .iCloud
guard upsert(updated) else {
try? keychain.removePassword(identity.credentialRef, storage: .iCloud)
throw TermindSSHError.library(
storageError ?? String(localized: "The identity could not be saved."))
}
try keychain.removePassword(identity.credentialRef, storage: .thisDevice)
}
}
public func passwordSecret(for identity: Identity) throws -> SSHSecret? {
guard identity.usesPassword else { return nil }
return try keychain.sshSecret(
for: identity.credentialRef, storage: identity.passwordStorage
)
}
public func passwordAvailability(for identity: Identity) -> SecretAvailability {
guard identity.usesPassword else { return .notConfigured }
do {
return try keychain.containsString(
for: identity.credentialRef, storage: identity.passwordStorage
) ? .available : .missing
} catch {
return .failed(error.localizedDescription)
}
}
public func isProxyEligibleOnThisDevice(_ identity: Identity) -> Bool {
identity.isProxyEligible && passwordAvailability(for: identity) == .available
}
/// Removes an identity, along with the password it was the only way to
/// reach, and unfiles it from every host that logged in with it — those
/// hosts keep their address and go back to "no login chosen yet".
@discardableResult
public func delete(identityID: Identity.ID) -> Bool {
guard let identity = identity(identityID) else { return false }
let password: String?
do {
password =
identity.credentialRef.isEmpty
? nil
: try keychain.string(
for: identity.credentialRef, storage: identity.passwordStorage
)
try keychain.removePassword(
identity.credentialRef, storage: identity.passwordStorage
)
} catch {
storageError = error.localizedDescription
return false
}
let identitySnapshot = identities
let hostSnapshot = hosts
identities.removeAll { $0.id == identityID }
for index in hosts.indices where hosts[index].identityID == identityID {
hosts[index].identityID = nil
}
for index in hosts.indices where hosts[index].proxy?.identityID == identityID {
hosts[index].proxy?.identityID = nil
hosts[index].proxy?.username = ""
}
guard persistCore() else {
identities = identitySnapshot
hosts = hostSnapshot
if let password {
do {
try keychain.setString(
password, for: identity.credentialRef,
storage: identity.passwordStorage
)
} catch {
storageError = String(
localized: "The database save and Keychain rollback both failed: \(error.localizedDescription)"
)
}
}
return false
}
return true
}
public func upsert(_ key: SSHKeyRef, credential: StoredSSHKeyCredential? = nil) throws {
var key = key
key.label = SavedName.resolve(key.label, default: key.defaultName)
let previousKey = self.key(key.id)
if previousKey?.storage == .iCloud, key.storage == .thisDevice {
throw TermindSSHError.library(
String(localized: "iCloud credentials cannot be moved to This Device.")
)
}
if key.storage == .secureEnclave, !key.certificate.isEmpty {
throw TermindSSHError.library(
String(localized: "Secure Enclave keys cannot store an SSH certificate.")
)
}
if !key.certificate.isEmpty {
_ = try SSHUserCertificate.parse(key.certificate, matching: key.publicKey)
}
let keySnapshot = keys
let changesLocation =
previousKey.map {
$0.storage != key.storage || $0.credentialRef != key.credentialRef
} ?? false
if changesLocation,
previousKey?.storage == .secureEnclave || key.storage == .secureEnclave
{
throw TermindSSHError.library(
String(localized: "Secure Enclave keys cannot be moved to another storage location.")
)
}
let previousCredential =
try (credential != nil || changesLocation)
? previousKey.flatMap { try keyCredential(for: $0) }
: nil
let destinationCredential = credential ?? (changesLocation ? previousCredential : nil)
if let destinationCredential {
try keychain.setKeyCredential(
destinationCredential, for: key.credentialRef, storage: key.storage
)
}
if let index = keys.firstIndex(where: { $0.id == key.id }) {
var updated = key
updated.sortIndex = keys[index].sortIndex
keys[index] = updated
} else {
var new = key
new.sortIndex = (keys.map(\.sortIndex).max() ?? -1) + 1
keys.append(new)
}
guard persistCore() else {
keys = keySnapshot
do {
if changesLocation, destinationCredential != nil {
try keychain.removeKeyCredential(
key.credentialRef, storage: key.storage
)
} else if destinationCredential != nil, let previousKey, let previousCredential {
try keychain.setKeyCredential(
previousCredential, for: previousKey.credentialRef,
storage: previousKey.storage
)
} else if destinationCredential != nil {
try keychain.removeKeyCredential(key.credentialRef, storage: key.storage)
}
} catch {
storageError = String(
localized: "The database save and Keychain rollback both failed: \(error.localizedDescription)"
)
}
throw TermindSSHError.library(
storageError ?? String(localized: "The key could not be saved.")
)
}
if changesLocation, let previousKey {
do {
try keychain.removeKeyCredential(
previousKey.credentialRef, storage: previousKey.storage
)
} catch {
storageError = String(
localized:
"The key was saved, but its previous Keychain copy could not be removed: \(error.localizedDescription)"
)
}
}
}
/// Reorders Keys without allowing them to leave their fixed Keychain
/// section. The supplied order is the order of a multi-card drag.
public func move(keyIDs: [SSHKeyRef.ID], before next: SSHKeyRef.ID? = nil) {
guard
reorderManually(
keyIDs, before: next, items: &keys, sortIndex: \SSHKeyRef.sortIndex
)
else { return }
persistCore()
}
/// Reorders Identities independently from Keys. A mixed Keychain selection
/// is split by the page before it reaches this fixed-section operation.
public func move(identityIDs: [Identity.ID], before next: Identity.ID? = nil) {
guard
reorderManually(
identityIDs, before: next, items: &identities,
sortIndex: \Identity.sortIndex
)
else { return }
persistCore()
}
public func keyCredential(for key: SSHKeyRef) throws -> StoredSSHKeyCredential? {
try keychain.keyCredential(for: key.credentialRef, storage: key.storage)
}
public func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential? {
try keychain.sshKeyCredential(for: key.credentialRef, storage: key.storage)
}
public func keyAvailability(for key: SSHKeyRef) -> SecretAvailability {
do {
return try keychain.containsKeyCredential(
for: key.credentialRef, storage: key.storage
) ? .available : .missing
} catch {
return .failed(error.localizedDescription)
}
}
public func delete(keyID: SSHKeyRef.ID) throws {
guard let key = key(keyID) else { return }
let credential = try keyCredential(for: key)
try keychain.removeKeyCredential(key.credentialRef, storage: key.storage)
let keySnapshot = keys
let identitySnapshot = identities
keys.removeAll { $0.id == keyID }
for index in identities.indices where identities[index].keyID == keyID {
identities[index].keyID = nil
}
guard persistCore() else {
keys = keySnapshot
identities = identitySnapshot
if let credential {
try keychain.setKeyCredential(
credential, for: key.credentialRef, storage: key.storage
)
}
throw TermindSSHError.library(
storageError ?? String(localized: "The key could not be deleted.")
)
}
}
/// Adds or updates a group. A new group is placed after its last sibling
/// rather than at the end of the flat array — `sortIndex` is per-parent, so
/// a child of Production has to be numbered against Production's children.
public func upsert(_ group: HostGroup) {
if let index = groups.firstIndex(where: { $0.id == group.id }) {
groups[index] = group
} else {
var new = group
new.sortIndex = nextSortIndex(under: group.parentID)
groups.append(new)
}
persistCore()
}
/// Deletes a group and every group beneath it.
///
/// Removes a group subtree after callers have durably requested deletion
/// for any hosts they do not intend to keep.
public func deleteGroupProjection(_ groupID: HostGroup.ID) {
let doomed = Set([groupID] + descendantGroups(of: groupID).map(\.id))
for index in hosts.indices where hosts[index].groupID.map(doomed.contains) ?? false {
hosts[index].groupID = nil
}
groups.removeAll { doomed.contains($0.id) }
reconcileExpansionState()
persistCore()
}
public func upsert(_ snippet: Snippet) {
if let index = snippets.firstIndex(where: { $0.id == snippet.id }) {
var updated = snippet
if snippets[index].packageID == updated.packageID {
updated.sortIndex = snippets[index].sortIndex
} else {
updated.sortIndex = nextSnippetSortIndex(in: updated.packageID)
}
snippets[index] = updated
} else {
var new = snippet
new.sortIndex = nextSnippetSortIndex(in: snippet.packageID)
snippets.append(new)
}
persistCore()
}
public func delete(snippetIDs: [Snippet.ID]) {
let doomed = Set(snippetIDs)
clearStartupSnippetReferences(to: doomed)
snippets.removeAll { doomed.contains($0.id) }
persistCore()
}
public func upsert(_ package: SnippetPackage) {
if let index = snippetPackages.firstIndex(where: { $0.id == package.id }) {
let forbidden = Set(
[package.id] + descendantSnippetPackages(of: package.id).map(\.id)
)
guard package.parentID.map(forbidden.contains) != true else { return }
var updated = package
if snippetPackages[index].parentID != updated.parentID {
updated.sortIndex = nextSnippetPackageSortIndex(under: updated.parentID)
}
snippetPackages[index] = updated
} else {
guard package.parentID != package.id else { return }
var new = package
new.sortIndex = nextSnippetPackageSortIndex(under: package.parentID)
snippetPackages.append(new)
}
expandSnippetPackageAncestors(package.id)
persistCore()
}
public func delete(snippetPackageID: SnippetPackage.ID, keepSnippets: Bool) {
let doomed = Set(
[snippetPackageID] + descendantSnippetPackages(of: snippetPackageID).map(\.id)
)
if keepSnippets {
for index in snippets.indices where snippets[index].packageID.map(doomed.contains) == true {
snippets[index].packageID = nil
snippets[index].sortIndex = nextSnippetSortIndex(in: nil)
}
} else {
let doomedSnippetIDs = Set(
snippets.lazy.filter { $0.packageID.map(doomed.contains) == true }.map(\.id)
)
clearStartupSnippetReferences(to: doomedSnippetIDs)
snippets.removeAll { doomedSnippetIDs.contains($0.id) }
}
snippetPackages.removeAll { doomed.contains($0.id) }
reconcileExpansionState()
persistCore()
}
private func clearStartupSnippetReferences(to snippetIDs: Set<Snippet.ID>) {
guard !snippetIDs.isEmpty else { return }
for index in hosts.indices where hosts[index].startupSnippetID.map(snippetIDs.contains) == true {
hosts[index].startupSnippetID = nil
}
}
public func move(
snippetIDs: [Snippet.ID], into packageID: SnippetPackage.ID?,
before next: Snippet.ID? = nil
) {
if let packageID, snippetPackage(packageID) == nil { return }
guard
let placement = manualOrderPlacement(
snippetIDs, before: next,
destinationItems: snippets(in: packageID),
validIDs: Set(snippets.map(\.id)), sortIndex: \Snippet.sortIndex
)
else { return }
if placement.moving.allSatisfy({ snippet($0)?.packageID == packageID })
&& snippets(in: packageID).map(\.id) == placement.resulting
{
return
}
let moving = Set(placement.moving)
for index in snippets.indices {
if moving.contains(snippets[index].id) { snippets[index].packageID = packageID }
if let position = placement.positions[snippets[index].id] {
snippets[index].sortIndex = position
}
}
expandSnippetPackageAncestors(packageID)
persistCore()
}
@discardableResult
public func upsert(_ forwarding: PortForwardingRule) -> Bool {
guard var normalized = PortForwardingEditorPolicy.normalized(forwarding),
let host = host(forwarding.hostID), host.sshEnabled
else {
storageError = String(localized: "The forwarding rule requires an SSH-enabled Host.")
return false
}
normalized.label = SavedName.resolve(
normalized.label,
default: normalized.defaultName(hostName: SavedName.resolve(host.name, default: host.defaultName)))
let previous = portForwardings
if let index = portForwardings.firstIndex(where: { $0.id == normalized.id }) {
if portForwardings[index] == normalized { return true }
willDeletePortForwardings?([normalized.id])
portForwardings[index] = normalized
} else {
portForwardings.append(normalized)
}
guard persistCore() else {
portForwardings = previous
return false
}
return true
}
@discardableResult
public func deletePortForwardings(_ ids: Set<PortForwardingRule.ID>) -> Bool {
let affected = portForwardings.filter { ids.contains($0.id) }.map(\.id)
guard !affected.isEmpty else { return true }
let previous = portForwardings
willDeletePortForwardings?(affected)
portForwardings.removeAll { ids.contains($0.id) }
guard persistCore() else {
portForwardings = previous
return false
}
return true
}
/// Root jump first and destination last. A nil result means the proposed
/// host closes a cycle, references a missing jump, or exceeds eight hosts.
public func validatedRoute(for proposed: Host) -> [Host]? {
var route: [Host] = []
var cursor: Host? = proposed
var visited: Set<Host.ID> = []
while let current = cursor {
guard current.sshEnabled else { return nil }
guard visited.insert(current.id).inserted, route.count < 8 else { return nil }
route.append(current)
guard let jumpID = current.jumpHostID else { break }
if jumpID == proposed.id { return nil }
cursor = jumpID == proposed.id ? proposed : host(jumpID)
if cursor == nil { return nil }
}
return route.reversed()
}
public func route(for host: Host) -> [Host] {
validatedRoute(for: host) ?? [host]
}
public func routeSummary(for host: Host) -> String {
route(for: host).map { node in
if let proxy = node.proxy {
return "\(proxy.kind.displayName) \(proxy.hostname):\(proxy.port) → \(node.displayAddress)"
}
return node.displayAddress
}.joined(separator: " → ")
}
/// OpenSSH equivalent when the route needs no in-process Proxy. Passwords,
/// private keys, and proxy credentials never enter the clipboard.
public func sshCommand(for host: Host) -> String? {
sshCommand(for: host, additionalArguments: [])
}
public func telnetCommand(for host: Host) -> String {
var arguments: [String] = []
if let username = identity(for: host, protocol: .telnet)?.username
.trimmingCharacters(in: .whitespacesAndNewlines), !username.isEmpty
{
arguments += ["-l", username]
}
arguments.append(host.host)
if host.telnetPort != HostTerminalProtocol.telnet.defaultPort {
arguments.append(String(host.telnetPort))
}
return (["telnet"] + arguments).map(Self.shellQuoted).joined(separator: " ")
}
public func sshCommand(for rule: PortForwardingRule, host proposedHost: Host? = nil) -> String? {
guard let destination = proposedHost ?? host(rule.hostID), destination.id == rule.hostID
else { return nil }
var arguments = ["-N"]
switch rule.kind {
case .local:
guard let host = rule.targetHost, let port = rule.targetPort else { return nil }
arguments += ["-L", "\(rule.bindAddress):\(Self.commandAddress(host, port: port))"]
case .remote:
guard let host = rule.targetHost, let port = rule.targetPort else { return nil }
arguments += ["-R", "\(rule.bindAddress):\(Self.commandAddress(host, port: port))"]
case .dynamic:
arguments += ["-D", rule.bindAddress]
}
return sshCommand(for: destination, additionalArguments: arguments)
}
private func sshCommand(for destination: Host, additionalArguments: [String]) -> String? {
guard destination.sshEnabled, let route = validatedRoute(for: destination),
route.allSatisfy({ $0.proxy == nil })
else { return nil }
let jumps = route.dropLast().map { node in
Self.openSSHAddress(
host: node.host, port: node.port,
username: identity(for: node)?.username, bracketsIPv6: true
)
}
var arguments = additionalArguments
if !jumps.isEmpty { arguments += ["-J", jumps.joined(separator: ",")] }
if destination.port != 22 { arguments += ["-p", String(destination.port)] }
arguments.append(
Self.openSSHAddress(
host: destination.host, port: 22,
username: identity(for: destination)?.username, bracketsIPv6: false
)
)
let suffix = arguments.map(Self.shellQuoted).joined(separator: " ")
return suffix.isEmpty ? "ssh" : "ssh \(suffix)"
}
private static func openSSHAddress(
host: String, port: Int, username: String?, bracketsIPv6: Bool
) -> String {
let trimmedUsername = username?.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedHost = bracketsIPv6 && host.contains(":") ? "[\(host)]" : host
let address = port == 22 ? resolvedHost : "\(resolvedHost):\(port)"
guard let trimmedUsername, !trimmedUsername.isEmpty else { return address }
return "\(trimmedUsername)@\(address)"
}
/// POSIX-shell quoting for a single argument. Common SSH punctuation stays
/// readable; everything else uses single quotes without losing apostrophes.
private static func shellQuoted(_ value: String) -> String {
let safe = CharacterSet(
charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./-")
if !value.isEmpty, value.unicodeScalars.allSatisfy(safe.contains) { return value }
return "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'"
}
private static func commandAddress(_ host: String, port: Int) -> String {
"\(host.contains(":") ? "[\(host)]" : host):\(port)"
}
public func setSFTPTransferState(_ state: SFTPTransfer.State, for id: SFTPTransfer.ID) {
guard let index = sftpTransfers.firstIndex(where: { $0.id == id }) else { return }
sftpTransfers[index].state = state
}
public func enqueueSFTPTransfer(_ sftpTransfer: SFTPTransfer) {
sftpTransfers.append(sftpTransfer)
}
public func sftpTransfer(_ id: SFTPTransfer.ID) -> SFTPTransfer? {
sftpTransfers.first { $0.id == id }
}
public func updateSFTPTransfer(
_ id: SFTPTransfer.ID, completedBytes: Int64, totalBytes: Int64,
bytesPerSecond: Int64
) {
guard let index = sftpTransfers.firstIndex(where: { $0.id == id }) else { return }
sftpTransfers[index].transferredBytes = completedBytes
sftpTransfers[index].byteCount = totalBytes
sftpTransfers[index].bytesPerSecond = bytesPerSecond
}
public func finishSFTPTransfer(_ id: SFTPTransfer.ID, state: SFTPTransfer.State) {
guard let index = sftpTransfers.firstIndex(where: { $0.id == id }) else { return }
sftpTransfers[index].state = state
sftpTransfers[index].bytesPerSecond = 0
sftpTransfers[index].completedAt = Date()
if state == .completed {
sftpTransfers[index].transferredBytes = sftpTransfers[index].byteCount
}
}
/// Clears terminal transfer records while leaving queued, running and paused work
/// untouched. The transfer center is a current-session history, not a log.
public func clearFinishedSFTPTransfers() {
sftpTransfers.removeAll { $0.state.isFinished }
}
/// Removes one terminal record without affecting its downloaded file or live transfers.
public func removeFinishedSFTPTransfer(_ id: SFTPTransfer.ID) {
guard let transfer = sftpTransfer(id), transfer.state.isFinished else { return }
sftpTransfers.removeAll { $0.id == id }
}
/// Files one or more hosts into a group, preserving the order supplied.
///
/// `next` is the host that should follow the moved batch; `nil` appends it.
/// Updating every host before persisting makes a multi-selection one logical
/// drag instead of a series of partially saved moves.
public func move(hostIDs: [Host.ID], into group: HostGroup.ID?, before next: Host.ID? = nil) {
if let group, self.group(group) == nil { return }
guard
let placement = manualOrderPlacement(
hostIDs, before: next,
destinationItems: hosts(in: group),
validIDs: Set(hosts.map(\.id)),
sortIndex: \Host.sortIndex
)
else { return }
if placement.moving.allSatisfy({ host($0)?.groupID == group })
&& hosts(in: group).map(\.id) == placement.resulting
{
return
}
let movingSet = Set(placement.moving)
for index in hosts.indices {
if movingSet.contains(hosts[index].id) { hosts[index].groupID = group }
if let value = placement.positions[hosts[index].id] {
hosts[index].sortIndex = value
}
}
persistCore()
}
@discardableResult
public func saveKnownHost(_ entry: KnownHostEntry) -> Bool {
var entry = entry
entry.hostname = entry.hostname.trimmingCharacters(in: .whitespacesAndNewlines)
entry.fingerprint = entry.fingerprint.trimmingCharacters(in: .whitespacesAndNewlines)
guard entry.isValid,
!knownHosts.contains(where: {
$0.id != entry.id && $0.hostname == entry.hostname && $0.port == entry.port
&& $0.identity == entry.identity
})
else {
storageError = String(localized: "One or more Known Hosts entries are incomplete or duplicated.")
return false
}
let previous = knownHosts
if let index = knownHosts.firstIndex(where: { $0.id == entry.id }) {
if knownHosts[index].identity != entry.identity || knownHosts[index].hostname != entry.hostname
|| knownHosts[index].port != entry.port
{
entry.addedAt = Date()
}
knownHosts[index] = entry
} else {
entry.addedAt = Date()
knownHosts.append(entry)
}
guard persistCore() else {
knownHosts = previous
return false
}
return true
}
@discardableResult
public func deleteKnownHosts(_ ids: Set<UUID>) -> Bool {
let previous = knownHosts
knownHosts.removeAll { ids.contains($0.id) }
guard persistCore() else {
knownHosts = previous
return false
}
return true
}
/// Saves a complete temporary route in one Vault transaction. Existing Hosts
/// and Identities are reused or copied, never edited by a one-off command.
public func saveConnectionRoute(
_ route: SSHConnectionRoute, authentication: SSHAuthenticationSubmission? = nil,
authenticationHostID: Host.ID? = nil, suppliedPassword: String? = nil
) throws -> SSHConnectionRoute {
guard route.isValid else { throw SSHConnectionRoute.invalidRouteError }
var batch = SSHImportBatch()
var saved: [Host] = []
var usernames: [Host.ID: String] = [:]
var passwords: [(Identity, String)] = []
var usedNames = Set(hosts.map(\.name))
for var node in route.hosts {
let originalID = node.id
let source = route.sourceHostIDs[originalID].flatMap(host) ?? host(originalID)
let username = route.usernames[originalID] ?? identity(for: node)?.username
if node.id == authenticationHostID, let authentication {
if let selected = authentication.identityID.flatMap(identity), suppliedPassword == nil {
node.identityID = selected.id
} else {
let login = Identity(
name: authentication.username, username: authentication.username,
credentialRef: suppliedPassword == nil ? "" : "termind.password.\(UUID().uuidString)",
keyID: authentication.keyID
)
batch.identities.append(login)
if let suppliedPassword { passwords.append((login, suppliedPassword)) }
node.identityID = login.id
}
} else if node.identityID == nil, let username, !username.isEmpty {
let matches = (identities + batch.identities).filter { $0.username == username }
if matches.count == 1 {
node.identityID = matches[0].id
} else {
let login = Identity(name: username, username: username)
batch.identities.append(login)
node.identityID = login.id
}
}
node.jumpHostID = saved.last?.id
if let existing = host(originalID), existing != node { node.id = UUID() }
if host(node.id) == nil {
let base = source.map { String(localized: "\($0.name) copy") } ?? node.name
var name = base
var suffix = 2
while usedNames.contains(name) {
name = "\(base) (\(suffix))"
suffix += 1
}
node.name = name
usedNames.insert(name)
batch.hosts.append(node)
}
usernames[node.id] = originalID == authenticationHostID ? authentication?.username : username
saved.append(node)
}
var written: [Identity] = []
do {
for (identity, password) in passwords {
try keychain.setString(password, for: identity.credentialRef, storage: identity.passwordStorage)
written.append(identity)
}
if !batch.hosts.isEmpty || !batch.identities.isEmpty { try commitSSHImport(batch) }
} catch {
let saveError = error
for identity in written {
do {
try keychain.removePassword(identity.credentialRef, storage: identity.passwordStorage)
} catch {
throw TermindSSHError.library(
String(
localized:
"The database save and Keychain rollback both failed: \(error.localizedDescription)")
)
}
}
throw saveError
}
return SSHConnectionRoute(
hosts: saved.map { host($0.id) ?? $0 }, usernames: usernames,
sourceHostIDs: Dictionary(uniqueKeysWithValues: saved.map { ($0.id, $0.id) })
)
}
/// New records only. Existing credentials and host configurations are never overwritten.
public func commitSSHImport(_ batch: SSHImportBatch) throws {
let oldHosts = hosts
let oldIdentities = identities
let oldKeys = keys
let oldKnownHosts = knownHosts
guard Set(batch.hosts.map(\.id)).isDisjoint(with: hosts.map(\.id)),
Set(batch.keys.map(\.id)).isDisjoint(with: keys.map(\.id)),
Set(batch.identities.map(\.id)).isDisjoint(with: identities.map(\.id)),
batch.knownHosts.allSatisfy(\.isValid)
else { throw CocoaError(.coderInvalidValue) }
var written: [SSHKeyRef] = []
do {
for key in batch.keys {
guard let secret = batch.credentials[key.id] else {
throw CocoaError(.coderValueNotFound)
}
try keychain.setKeyCredential(secret, for: key.credentialRef, storage: key.storage)
written.append(key)
}
for var key in batch.keys {
key.sortIndex = (keys.map(\.sortIndex).max() ?? -1) + 1
keys.append(key)
}
for var identity in batch.identities {
identity.sortIndex = (identities.map(\.sortIndex).max() ?? -1) + 1
identities.append(identity)
}
for var host in batch.hosts {
host.sortIndex = nextSortIndex(in: nil)
hosts.append(host)
}
guard batch.hosts.allSatisfy({ validatedRoute(for: $0) != nil }) else {
throw CocoaError(.coderInvalidValue)
}
knownHosts.append(contentsOf: batch.knownHosts)
guard persistCore() else {
throw TermindSSHError.library(storageError ?? String(localized: "Import failed."))
}
} catch {
hosts = oldHosts
identities = oldIdentities
keys = oldKeys
knownHosts = oldKnownHosts
var cleanupFailed = false
for key in written {
do { try keychain.removeKeyCredential(key.credentialRef, storage: key.storage) } catch {
cleanupFailed = true
}
}
if cleanupFailed {
storageError = String(
localized: "Import failed. Some newly stored keys could not be removed from Keychain.")
throw TermindSSHError.library(storageError!)
}
throw error
}
}
private func nextSortIndex(in group: HostGroup.ID?) -> Double {
(hosts(in: group).last?.sortIndex ?? 0) + 1
}
/// The same, for a group among its siblings. Separate from the hosts version
/// because the two number different collections: a new child of Production is
/// placed after Production's last *group*, and the hosts filed directly in
/// Production have their own independent run of indices.
private func nextSortIndex(under parent: HostGroup.ID?) -> Double {
let siblings = parent.map { childGroups(of: $0) } ?? rootGroups
return (siblings.last?.sortIndex ?? 0) + 1
}
private func nextSnippetSortIndex(in package: SnippetPackage.ID?) -> Double {
(snippets(in: package).last?.sortIndex ?? -1) + 1
}
private func nextSnippetPackageSortIndex(under parent: SnippetPackage.ID?) -> Double {
let siblings = parent.map { childSnippetPackages(of: $0) } ?? rootSnippetPackages
return (siblings.last?.sortIndex ?? -1) + 1
}
private func expandSnippetPackageAncestors(_ packageID: SnippetPackage.ID?) {
var current = packageID
var visited: Set<SnippetPackage.ID> = []
while let id = current, let package = snippetPackage(id),
visited.insert(id).inserted
{
current = package.parentID
}
expansionState.setSnippetPackagesExpanded(visited)
}
private func reconcileExpansionState() {
expansionState.reconcile(
hostGroupIDs: Set(groups.map(\.id)),
snippetPackageIDs: Set(snippetPackages.map(\.id))
)
}
@discardableResult
private func persistCore(touching touchedKeys: Set<VaultRecordKey> = []) -> Bool {
do {
try vault.save(
VaultSnapshot(
hosts: hosts, groups: groups, identities: identities,
keys: keys, knownHosts: knownHosts, snippets: snippets,
snippetPackages: snippetPackages, portForwardings: portForwardings,
workspaceTemplates: workspaceTemplates
),
touching: touchedKeys)
storageError = nil
return true
} catch {
storageError = error.localizedDescription
return false
}
}
/// Applies a CloudKit-imported projection without writing it back as a new
/// local edit. Runtime connection and transfer state intentionally remain.
public func replacePersistedSnapshot(_ snapshot: VaultSnapshot) {
hosts = snapshot.hosts
groups = snapshot.groups
identities = normalizedManualOrder(
snapshot.identities, sortIndex: \Identity.sortIndex, name: \Identity.name
)
keys = normalizedManualOrder(
snapshot.keys, sortIndex: \SSHKeyRef.sortIndex, name: \SSHKeyRef.label
)
knownHosts = snapshot.knownHosts
snippets = normalizedSnippetOrder(snapshot.snippets)
snippetPackages = snapshot.snippetPackages
portForwardings = snapshot.portForwardings
workspaceTemplates = snapshot.workspaceTemplates
statuses = statuses.filter { id, _ in hosts.contains { $0.id == id } }
connectionStatuses = connectionStatuses.filter { _, value in
hosts.contains { $0.id == value.hostID }
}
reconcileExpansionState()
storageError = nil
vault.didApplyRemoteSnapshot()
}
}
// MARK: - Manual ordering
/// A deterministic tie-break keeps hand-built preview data usable before its
/// first move. Persisted data normally has unique
/// fractional indices, so the name is not otherwise part of the ordering.
private func manuallyOrdered(
_ lhsIndex: Double, _ lhsName: String,
_ rhsIndex: Double, _ rhsName: String
) -> Bool {
if lhsIndex != rhsIndex { return lhsIndex < rhsIndex }
return lhsName.localizedCaseInsensitiveCompare(rhsName) == .orderedAscending
}
/// Normalize duplicate preview and unit-test defaults once so their first
/// fractional move has real alphabetical neighbours.
private func normalizedManualOrder<Item>(
_ values: [Item],
sortIndex: WritableKeyPath<Item, Double>,
name: KeyPath<Item, String>
) -> [Item] {
let indices = values.map { $0[keyPath: sortIndex] }
guard indices.contains(where: { !$0.isFinite }) || Set(indices).count != indices.count else {
return values
}
var ordered = values.sorted {
manuallyOrdered(
$0[keyPath: sortIndex], $0[keyPath: name],
$1[keyPath: sortIndex], $1[keyPath: name]
)
}
for index in ordered.indices { ordered[index][keyPath: sortIndex] = Double(index) }
return ordered
}
/// Snippet indices are local to a Package. Equal indices in two different
/// Packages are expected, so normalization must never compare them globally.
private func normalizedSnippetOrder(_ values: [Snippet]) -> [Snippet] {
var result = values
for packageID in Set(values.map(\.packageID)) {
let members = result.indices.filter { result[$0].packageID == packageID }
let sortIndices = members.map { result[$0].sortIndex }
guard
sortIndices.contains(where: { !$0.isFinite })
|| Set(sortIndices).count != sortIndices.count
else { continue }
let ordered = members.sorted {
manuallyOrdered(
result[$0].sortIndex, result[$0].title,
result[$1].sortIndex, result[$1].title
)
}
for (position, index) in ordered.enumerated() {
result[index].sortIndex = Double(position)
}
}
return result
}
private struct ManualOrderPlacement<ID: Hashable> {
let moving: [ID]
let resulting: [ID]
let positions: [ID: Double]
}
/// Generic remove-then-insert calculation shared by Hosts, Keys and Identities.
/// The destination may omit moving IDs (Hosts crossing groups), so validity is
/// supplied separately from the concrete destination collection.
private func manualOrderPlacement<Item: Identifiable>(
_ requestedIDs: [Item.ID],
before next: Item.ID?,
destinationItems: [Item],
validIDs: Set<Item.ID>,
sortIndex: KeyPath<Item, Double>
) -> ManualOrderPlacement<Item.ID>? where Item.ID: Hashable {
var seen: Set<Item.ID> = []
let moving = requestedIDs.filter {
validIDs.contains($0) && seen.insert($0).inserted
}
guard !moving.isEmpty else { return nil }
let movingSet = Set(moving)
let remaining = destinationItems.filter { !movingSet.contains($0.id) }
let insertionIndex: Int
if let next {
guard let index = remaining.firstIndex(where: { $0.id == next }) else { return nil }
insertionIndex = index
} else {
insertionIndex = remaining.endIndex
}
let resulting =
Array(remaining[..<insertionIndex]).map(\.id) + moving
+ Array(remaining[insertionIndex...]).map(\.id)
let lower = insertionIndex > 0 ? remaining[insertionIndex - 1][keyPath: sortIndex] : nil
let upper = insertionIndex < remaining.endIndex ? remaining[insertionIndex][keyPath: sortIndex] : nil
var positions: [Item.ID: Double] = [:]
switch (lower, upper) {
case (let lower?, let upper?):
let step = (upper - lower) / Double(moving.count + 1)
if step.isFinite && step > 0 {
for (offset, id) in moving.enumerated() {
positions[id] = lower + step * Double(offset + 1)
}
}
case (let lower?, nil):
for (offset, id) in moving.enumerated() {
positions[id] = lower + Double(offset + 1)
}
case (nil, let upper?):
for (offset, id) in moving.enumerated() {
positions[id] = upper - Double(moving.count - offset)
}
case (nil, nil):
for (offset, id) in moving.enumerated() { positions[id] = Double(offset) }
}
if positions.count != moving.count {
positions = Dictionary(
uniqueKeysWithValues: resulting.enumerated().map { ($0.element, Double($0.offset)) }
)
}
return ManualOrderPlacement(moving: moving, resulting: resulting, positions: positions)
}
/// Shared fixed-section wrapper for every manually ordered Keychain collection.
private func reorderManually<Item: Identifiable>(
_ requestedIDs: [Item.ID],
before next: Item.ID?,
items: inout [Item],
sortIndex: WritableKeyPath<Item, Double>
) -> Bool where Item.ID: Hashable {
let current = items.sorted { $0[keyPath: sortIndex] < $1[keyPath: sortIndex] }
guard
let placement = manualOrderPlacement(
requestedIDs, before: next, destinationItems: current,
validIDs: Set(current.map(\.id)), sortIndex: sortIndex
), placement.resulting != current.map(\.id)
else { return false }
for index in items.indices {
if let value = placement.positions[items[index].id] {
items[index][keyPath: sortIndex] = value
}
}
items.sort { $0[keyPath: sortIndex] < $1[keyPath: sortIndex] }
return true
}