Files
Termind/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift
T

2381 lines
100 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] = []
@ObservationIgnored public var sftpTransferStateDidChange: ((SFTPTransfer) -> Void)?
@ObservationIgnored public var sftpTransfersDidRemove: ((Set<UUID>) -> Void)?
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 for Host-level consumers, including
/// the Vault and Recents dots and the agent Host listing. 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
sftpTransferStateDidChange?(sftpTransfers[index])
}
public func enqueueSFTPTransfer(_ sftpTransfer: SFTPTransfer) {
sftpTransfers.append(sftpTransfer)
sftpTransferStateDidChange?(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
}
sftpTransferStateDidChange?(sftpTransfers[index])
}
/// 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() {
let ids = Set(sftpTransfers.filter { $0.state.isFinished }.map(\.id))
sftpTransfers.removeAll { $0.state.isFinished }
if !ids.isEmpty { sftpTransfersDidRemove?(ids) }
}
/// 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 }
sftpTransfersDidRemove?([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
}