Make AI, MCP, Metrics, and Wake-on-LAN free while retaining iCloud sync as a Pro feature. Limit free accounts to three saved hosts and preserve excess hosts with remote-operation locks. Gate host creation, import, and runtime access; preserve stable host admission across persistence and sync. Add upgrade states on macOS and iOS, open the paywall from locked Wake actions, and use wrench icons for MCP. Include regression coverage, opt-in UI review fixtures, localized copy, and the updated pricing website revision.
2831 lines
121 KiB
Swift
2831 lines
121 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 var hostAccess = HostAccessPolicy(entitlement: .unrestrictedForTesting) {
|
|
didSet { operationAccess.update(policy: hostAccess, hosts: hosts) }
|
|
}
|
|
@ObservationIgnored private let operationAccess = HostOperationAccess()
|
|
public func connectionAccessCheck(for hostIDs: [UUID]) -> HostOperationAccessCheck? {
|
|
let access = operationAccess
|
|
return { try access.check(hostIDs) }
|
|
}
|
|
@ObservationIgnored public var requestHostUpgrade: (() -> Void)?
|
|
@ObservationIgnored public var resolveInitialHostEntitlement: (() async -> Void)?
|
|
public var lastHostAccessError: HostAccessError?
|
|
|
|
public func isLocked(_ host: Host) -> Bool { hostAccess.isLocked(host.id, in: hosts) }
|
|
|
|
public func requireHostAccess(_ host: Host) throws {
|
|
for node in route(for: host) {
|
|
try hostAccess.requireAccess(to: node.id, in: hosts)
|
|
}
|
|
}
|
|
|
|
public func resolveHostAccess(_ host: Host) async throws {
|
|
do { try requireHostAccess(host) } catch HostAccessError.verifyingEntitlement {
|
|
await resolveInitialHostEntitlement?()
|
|
try Task.checkCancellation()
|
|
try requireHostAccess(host)
|
|
}
|
|
}
|
|
|
|
public func resolveSavingHostsAccess(_ proposed: [Host], requestsUpgrade: Bool = true) async throws {
|
|
do { try hostAccess.requireSaving(proposed, in: hosts) } catch {
|
|
if case HostAccessError.verifyingEntitlement = error {
|
|
await resolveInitialHostEntitlement?()
|
|
try Task.checkCancellation()
|
|
}
|
|
}
|
|
if requestsUpgrade {
|
|
try requireSavingHosts(proposed)
|
|
} else {
|
|
try hostAccess.requireSaving(proposed, in: hosts)
|
|
}
|
|
}
|
|
|
|
public func requireSavingHosts(_ proposed: [Host]) throws {
|
|
do {
|
|
try hostAccess.requireSaving(proposed, in: hosts)
|
|
lastHostAccessError = nil
|
|
} catch let error as HostAccessError {
|
|
lastHostAccessError = error
|
|
if error == .hostLimit { requestHostUpgrade?() }
|
|
throw error
|
|
}
|
|
}
|
|
@ObservationIgnored public weak var tailscaleConnections: (any TailscaleConnectionProviding)?
|
|
@ObservationIgnored public var didChangeTailscaleGroups: (() -> Void)?
|
|
/// Retires managed sessions when a binding is removed or replaced by another network.
|
|
@ObservationIgnored public var didDeleteTailscaleGroups: ((Set<UUID>, Set<UUID>) -> Void)?
|
|
private var tailscalePeers: [UUID: [TailscalePeer]] = [:]
|
|
public private(set) var hosts: [Host] = [] {
|
|
didSet {
|
|
operationAccess.update(policy: hostAccess, hosts: hosts)
|
|
for old in oldValue where old.agentForwardingEnabled {
|
|
if !hosts.contains(where: { $0.id == old.id && $0.agentForwardingEnabled }) {
|
|
agentForwardingRevisions[old.id, default: 0] += 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
@ObservationIgnored private var agentForwardingRevisions: [UUID: UInt64] = [:]
|
|
public func agentForwardingRevision(for hostID: UUID) -> UInt64 {
|
|
agentForwardingRevisions[hostID, default: 0]
|
|
}
|
|
public var agentForwardingSecrets: any SSHRuntimeSecretResolving { keychain }
|
|
|
|
public private(set) var groups: [HostGroup] = []
|
|
public private(set) var keys: [SSHKeyRef] = [] {
|
|
didSet { if oldValue != keys { keyCredentialRevision &+= 1 } }
|
|
}
|
|
public var agentForwardingKeyRevision: Int { keyCredentialRevision }
|
|
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 var keyCredentialRevision = 0
|
|
private let keychain: any SecretStoring
|
|
private let defaults: UserDefaults
|
|
private var expansionState = LibraryExpansionState()
|
|
@ObservationIgnored public var wakeOnLAN: WakeOnLANController?
|
|
|
|
public func connectionWakeAction(for hostIDs: [UUID]) -> ConnectionWakeAction? {
|
|
guard wakeOnLAN != nil else { return nil }
|
|
return { [weak self] in
|
|
await self?.wakeBeforeConnecting(hostIDs)
|
|
}
|
|
}
|
|
|
|
private func wakeBeforeConnecting(_ ids: [UUID]) {
|
|
guard
|
|
ids.allSatisfy({ id in
|
|
guard let host = host(id) else { return true }
|
|
return (try? requireHostAccess(host)) != nil
|
|
})
|
|
else { return }
|
|
for id in ids {
|
|
guard !Task.isCancelled else { return }
|
|
if let host = host(id) { wakeOnLAN?.automaticallyWake(host.wakeOnLAN) }
|
|
}
|
|
}
|
|
|
|
/// 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? {
|
|
guard !host.isTailscaleManaged else { return nil }
|
|
return 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 = host.isTailscaleManaged
|
|
? savedTailscaleUsername(for: host) : 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] {
|
|
guard !host.isTailscaleManaged else { return [] }
|
|
return
|
|
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
|
|
}
|
|
|
|
public func keyExportUnavailableReason(for host: Host) -> String? {
|
|
isEligible(host, for: .keyExport) ? nil : HostProtocolUnavailableError(useCase: .keyExport).localizedDescription
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
var configuration = 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)
|
|
)
|
|
configuration.accessCheck = connectionAccessCheck(for: [host.id])
|
|
configuration.beforeConnect = connectionWakeAction(for: [host.id])
|
|
return configuration
|
|
}
|
|
|
|
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 }
|
|
for node in route.hosts { try requireHostAccess(node) }
|
|
for id in route.sourceHostIDs.values {
|
|
if let source = host(id) { try requireHostAccess(source) }
|
|
}
|
|
let submissions = route.submissions(merging: submissions)
|
|
var preparation = SSHConnectionPreparation(
|
|
nodes: try route.hosts.map { node in
|
|
if node.isTailscaleManaged {
|
|
var prepared = SSHConnectionPreparation.Node(
|
|
host: node, identity: nil, key: nil, submission: nil, trustedKeys: [], proxyIdentity: nil)
|
|
prepared.network = try tailscaleNetwork(for: node)
|
|
prepared.tailscaleUsername = submissions[node.id]?.username ?? tailscaleUsername(for: node)
|
|
return prepared
|
|
}
|
|
let submission = submissions[node.id]
|
|
let identity = submission?.identityID.flatMap { self.identity($0) } ?? identity(for: node)
|
|
let keyID = submission != nil ? submission?.keyID : identity?.keyID
|
|
var prepared = 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]
|
|
)
|
|
prepared.network = try tailscaleNetwork(for: node)
|
|
return prepared
|
|
},
|
|
secrets: keychain
|
|
)
|
|
preparation.accessHostIDs = route.hosts.map(\.id) + Array(route.sourceHostIDs.values)
|
|
preparation.accessCheck = connectionAccessCheck(for: preparation.accessHostIDs)
|
|
preparation.beforeConnect = connectionWakeAction(for: preparation.accessHostIDs)
|
|
return preparation
|
|
}
|
|
|
|
public func sshConnectionConfiguration(
|
|
for host: Host,
|
|
submissions: [Host.ID: SSHAuthenticationSubmission] = [:],
|
|
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] = [:],
|
|
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:],
|
|
trustPolicy: SSHHostTrustPolicy = .requireKnownHost
|
|
) throws -> SSHConnectionConfiguration {
|
|
try requireHostAccess(host)
|
|
if host.isTailscaleManaged {
|
|
let username = (submissions[host.id]?.username ?? tailscaleUsername(for: host))
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !username.isEmpty else { throw TailscaleConfigurationError.unavailable }
|
|
var configuration = SSHConnectionConfiguration(
|
|
hostID: host.id, host: host.host, port: 22, username: username,
|
|
credentials: [], expectedHostKeys: [], charset: host.charset,
|
|
authenticationReference: SSHAuthenticationReference(username: username, permitsNone: true)
|
|
)
|
|
configuration.accessCheck = connectionAccessCheck(for: [host.id])
|
|
configuration.beforeConnect = connectionWakeAction(for: [host.id])
|
|
configuration.network = try tailscaleNetwork(for: host)
|
|
configuration.tailscaleNodeID = host.tailscale?.stableNodeID
|
|
return configuration
|
|
}
|
|
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
|
|
)
|
|
|
|
var configuration = 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]
|
|
)
|
|
)
|
|
configuration.accessCheck = connectionAccessCheck(for: route(for: host).map(\.id))
|
|
configuration.beforeConnect = connectionWakeAction(for: route(for: host).map(\.id))
|
|
configuration.network = try tailscaleNetwork(for: route(for: host).first ?? host)
|
|
return configuration
|
|
}
|
|
|
|
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 }
|
|
for node in route.hosts { try requireHostAccess(node) }
|
|
for id in route.sourceHostIDs.values {
|
|
if let source = host(id) { try requireHostAccess(source) }
|
|
}
|
|
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 requiresPassphraseEntry(for: key), !submission.hasPassphrase {
|
|
throw TermindSSHError.keyPassphraseRequired
|
|
}
|
|
if let stored = try keySecretCredential(for: key) {
|
|
credentials.append(
|
|
stored.sshCredential(
|
|
for: key, passphraseOverride: submission.passphraseForConnection()
|
|
)
|
|
)
|
|
}
|
|
}
|
|
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, 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 requiresPassphraseEntry(for: key) {
|
|
requiredPassphraseKeyID = key.id
|
|
} else {
|
|
do {
|
|
if let credential = try keySecretCredential(for: key) {
|
|
credentials.append(
|
|
credential.sshCredential(
|
|
for: key
|
|
)
|
|
)
|
|
} 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).filter { isEligible($0, for: useCase) }
|
|
}
|
|
|
|
public func isEligible(_ host: Host, for useCase: HostUseCase) -> Bool {
|
|
guard (try? requireHostAccess(host)) != nil else { return false }
|
|
if host.isTailscaleManaged && [.jumpHost, .keyExport, .portForwarding].contains(useCase) { return false }
|
|
return host.supports(useCase.protocolRequirement)
|
|
}
|
|
|
|
public func require(_ host: Host, for useCase: HostUseCase) throws {
|
|
try requireHostAccess(host)
|
|
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 {
|
|
do { try requireSavingHosts([host]) } catch HostAccessError.hostLimit {
|
|
storageError = nil
|
|
return false
|
|
} catch {
|
|
storageError = error.localizedDescription
|
|
return false
|
|
}
|
|
if self.host(host.id)?.isTailscaleManaged == true || host.isTailscaleManaged {
|
|
return saveTailscaleHost(host)
|
|
}
|
|
var host = host
|
|
host.createdAt = self.host(host.id)?.createdAt ?? (self.host(host.id) == nil ? .now : nil)
|
|
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) {
|
|
guard host(hostID)?.isTailscaleManaged != true else { return }
|
|
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 }
|
|
if hosts[index].isTailscaleManaged {
|
|
guard let groupIndex = groups.firstIndex(where: { $0.id == hosts[index].groupID }),
|
|
groups[groupIndex].tailscale != nil
|
|
else { return }
|
|
let previous = groups
|
|
var preferences = groups[groupIndex].tailscale!.hostPreferences[hostID] ?? TailscaleHostPreferences()
|
|
preferences.detectedDistribution = next
|
|
groups[groupIndex].tailscale!.hostPreferences[hostID] = preferences
|
|
guard persistCore() else {
|
|
groups = previous
|
|
return
|
|
}
|
|
rebuildTailscaleHosts()
|
|
} else {
|
|
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
|
|
}
|
|
|
|
/// Commit the destination's private login and host reference together. Reusable
|
|
/// identities (and their password references) are never changed by an export.
|
|
public func attachExportedKey(_ keyID: SSHKeyRef.ID, to hostID: Host.ID, username: String) throws {
|
|
guard let index = hosts.firstIndex(where: { $0.id == hostID }), key(keyID) != nil,
|
|
isEligible(hosts[index], for: .keyExport), !username.isEmpty
|
|
else { throw TermindSSHError.missingCredential }
|
|
let host = hosts[index]
|
|
let source = identity(for: host)
|
|
let owned = customIdentity(for: hostID, purpose: .ssh)
|
|
var destination =
|
|
owned ?? Identity(name: host.name, username: username, ownerHostID: hostID, customPurpose: .ssh)
|
|
destination.username = username
|
|
destination.keyID = keyID
|
|
var copiedReference: String?
|
|
if source?.id != owned?.id || owned == nil {
|
|
destination.credentialRef = ""
|
|
if let source, source.usesPassword {
|
|
guard let password = try editablePassword(for: source) else {
|
|
throw TermindSSHError.missingCredential
|
|
}
|
|
let reference = "termind.password.\(UUID().uuidString)"
|
|
destination.passwordStorage = source.passwordStorage
|
|
try keychain.setString(password, for: reference, storage: destination.passwordStorage)
|
|
destination.credentialRef = reference
|
|
copiedReference = reference
|
|
}
|
|
}
|
|
let previousIdentities = identities
|
|
let previousHosts = hosts
|
|
if let identityIndex = identities.firstIndex(where: { $0.id == destination.id }) {
|
|
identities[identityIndex] = destination
|
|
} else {
|
|
identities.append(destination)
|
|
}
|
|
hosts[index].sshIdentityID = destination.id
|
|
guard persistCore() else {
|
|
identities = previousIdentities
|
|
hosts = previousHosts
|
|
if let copiedReference {
|
|
try keychain.removePassword(copiedReference, storage: destination.passwordStorage)
|
|
}
|
|
throw TermindSSHError.library(storageError ?? String(localized: "Unable to Save"))
|
|
}
|
|
if let owned, !owned.credentialRef.isEmpty, owned.credentialRef != destination.credentialRef {
|
|
try? keychain.removePassword(owned.credentialRef, storage: owned.passwordStorage)
|
|
}
|
|
didChangeHostConnection?(hostID)
|
|
}
|
|
|
|
/// 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 key.algorithm == .securityKey {
|
|
guard credential == nil, let reference = key.securityKey,
|
|
!reference.credentialID.isEmpty, reference.publicKeyX963.count == 65,
|
|
key.certificate.isEmpty, key.storage != .secureEnclave,
|
|
previousKey == nil || previousKey?.algorithm == .securityKey
|
|
else { throw CocoaError(.coderInvalidValue) }
|
|
key.credentialRef = ""
|
|
key.storage = .external
|
|
key.hasPassphrase = false
|
|
key.requiresUserPresence = reference.requiresUserPresence
|
|
let snapshot = keys
|
|
if let index = keys.firstIndex(where: { $0.id == key.id }) {
|
|
key.sortIndex = keys[index].sortIndex
|
|
keys[index] = key
|
|
} else {
|
|
key.sortIndex = (keys.map(\.sortIndex).max() ?? -1) + 1
|
|
keys.append(key)
|
|
}
|
|
guard persistCore() else {
|
|
keys = snapshot
|
|
throw TermindSSHError.library(storageError ?? String(localized: "The key could not be saved."))
|
|
}
|
|
keyCredentialRevision &+= 1
|
|
return
|
|
}
|
|
guard key.securityKey == nil, key.storage != .external else { throw CocoaError(.coderInvalidValue) }
|
|
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.isEmpty ? nil : 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 keySecretCredential(for: $0) }
|
|
: nil
|
|
let destinationCredential = credential?.resolvedCredential() ?? (changesLocation ? previousCredential : nil)
|
|
defer {
|
|
previousCredential?.clearSecrets()
|
|
destinationCredential?.clearSecrets()
|
|
}
|
|
if let destinationCredential {
|
|
try keychain.setResolvedKeyCredential(
|
|
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.setResolvedKeyCredential(
|
|
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 destinationCredential != nil { keyCredentialRevision &+= 1 }
|
|
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? {
|
|
guard key.algorithm != .securityKey else { return nil }
|
|
return try keychain.keyCredential(for: key.credentialRef, storage: key.storage)
|
|
}
|
|
|
|
public func saveKeyPassphrase(_ passphrase: String, for keyID: SSHKeyRef.ID) throws {
|
|
guard !passphrase.isEmpty, let key = key(keyID), key.hasPassphrase, key.storage != .secureEnclave else {
|
|
throw TermindSSHError.missingCredential
|
|
}
|
|
let secret = SSHSecret(passphrase)
|
|
defer { secret.clear() }
|
|
try keychain.setKeyPassphrase(secret, for: key.credentialRef, storage: key.storage)
|
|
keyCredentialRevision &+= 1
|
|
}
|
|
|
|
public func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential? {
|
|
_ = keyCredentialRevision
|
|
return try keychain.sshKeyCredential(for: key)
|
|
}
|
|
|
|
public func keyAvailability(for key: SSHKeyRef) -> SecretAvailability {
|
|
if key.algorithm == .securityKey { return key.securityKey == nil ? .missing : .available }
|
|
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 keySecretCredential(for: key)
|
|
defer { credential?.clearSecrets() }
|
|
if key.algorithm != .securityKey {
|
|
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 key.algorithm != .securityKey, let credential {
|
|
try keychain.setResolvedKeyCredential(
|
|
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) {
|
|
let invalidated: Set<UUID> =
|
|
groups.contains {
|
|
$0.id == group.id && $0.tailscale != nil && $0.tailscale?.network != group.tailscale?.network
|
|
} ? [group.id] : []
|
|
let removedManaged = Set(
|
|
hosts.filter { $0.tailscale.map { invalidated.contains($0.groupID) } == true }.map(\.id))
|
|
let previous = groups
|
|
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)
|
|
}
|
|
guard persistCore() else {
|
|
groups = previous
|
|
return
|
|
}
|
|
for id in invalidated { tailscalePeers[id] = nil }
|
|
rebuildTailscaleHosts()
|
|
if !invalidated.isEmpty { didDeleteTailscaleGroups?(invalidated, removedManaged) }
|
|
didChangeTailscaleGroups?()
|
|
}
|
|
|
|
/// 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))
|
|
let tailnets = Set(groups.filter { doomed.contains($0.id) && $0.tailscale != nil }.map(\.id))
|
|
let managed = Set(hosts.filter { $0.tailscale.map { doomed.contains($0.groupID) } == true }.map(\.id))
|
|
let previousHosts = hosts
|
|
let previousGroups = groups
|
|
hosts.removeAll { managed.contains($0.id) }
|
|
for index in hosts.indices where hosts[index].groupID.map(doomed.contains) ?? false {
|
|
hosts[index].groupID = nil
|
|
}
|
|
groups.removeAll { doomed.contains($0.id) }
|
|
guard persistCore() else {
|
|
hosts = previousHosts
|
|
groups = previousGroups
|
|
return
|
|
}
|
|
for id in doomed { tailscalePeers[id] = nil }
|
|
reconcileExpansionState()
|
|
didDeleteTailscaleGroups?(tailnets, managed)
|
|
didChangeTailscaleGroups?()
|
|
}
|
|
|
|
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 }
|
|
if current.isTailscaleManaged && (current.id != proposed.id || current.jumpHostID != nil) { 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, username: String? = nil) -> String? {
|
|
sshCommand(for: host, additionalArguments: [], username: username)
|
|
}
|
|
|
|
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], username: String? = nil
|
|
) -> 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: username
|
|
?? (destination.isTailscaleManaged
|
|
? savedTailscaleUsername(for: destination) : 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 {
|
|
// Keep this an explicit byte loop: the Release build has miscompiled
|
|
// the CharacterSet.contains method reference passed to allSatisfy.
|
|
var requiresQuoting = value.isEmpty
|
|
for byte in value.utf8 {
|
|
switch byte {
|
|
case 0x30...0x39, 0x41...0x5A, 0x61...0x7A: // 0-9, A-Z, a-z
|
|
continue
|
|
case 0x5F, 0x40, 0x25, 0x2B, 0x3D, 0x3A, 0x2C, 0x2E, 0x2F, 0x2D: // _@%+=:,./-
|
|
continue
|
|
default:
|
|
requiresQuoting = true
|
|
}
|
|
break
|
|
}
|
|
if !requiresQuoting { 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) {
|
|
guard hostIDs.allSatisfy({ host($0)?.isTailscaleManaged != true }) else { return }
|
|
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 }
|
|
for node in route.hosts { try requireHostAccess(node) }
|
|
for id in route.sourceHostIDs.values {
|
|
if let source = host(id) { try requireHostAccess(source) }
|
|
}
|
|
if let managed = route.hosts.first(where: \.isTailscaleManaged) {
|
|
guard route.hosts.count == 1, host(managed.id)?.tailscale == managed.tailscale else {
|
|
throw TailscaleConfigurationError.managedHost
|
|
}
|
|
if let username = authentication?.username ?? route.usernames[managed.id],
|
|
!saveTailscaleUsername(username, hostID: managed.id)
|
|
{
|
|
throw TailscaleConfigurationError.unavailable
|
|
}
|
|
return route
|
|
}
|
|
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] = []
|
|
try requireSavingHosts(batch.hosts)
|
|
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 {
|
|
try requireSavingHosts(batch.hosts)
|
|
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.createdAt = .now
|
|
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.filter { !$0.isTailscaleManaged }, 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) {
|
|
let incomingNetworks = Dictionary(
|
|
uniqueKeysWithValues: snapshot.groups.compactMap { group in
|
|
group.tailscale.map { (group.id, $0.network) }
|
|
})
|
|
let removedTailnets = Set(
|
|
groups.filter {
|
|
$0.tailscale != nil && $0.tailscale?.network != incomingNetworks[$0.id]
|
|
}.map(\.id))
|
|
let removedManaged = Set(
|
|
hosts.filter { $0.tailscale.map { removedTailnets.contains($0.groupID) } == true }.map(\.id))
|
|
|
|
for id in removedTailnets { tailscalePeers[id] = nil }
|
|
hosts = snapshot.hosts
|
|
groups = snapshot.groups
|
|
rebuildTailscaleHosts()
|
|
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()
|
|
didDeleteTailscaleGroups?(removedTailnets, removedManaged)
|
|
didChangeTailscaleGroups?()
|
|
}
|
|
|
|
public func tailscaleGroup(for host: Host) -> HostGroup? {
|
|
var next = (self.host(host.id) ?? host).groupID
|
|
var visited = Set<UUID>()
|
|
while let id = next, visited.insert(id).inserted, let group = group(id) {
|
|
if group.tailscale != nil { return group }
|
|
next = group.parentID
|
|
}
|
|
return nil
|
|
}
|
|
|
|
public func networkConfigurationStamp(for host: Host) -> Data {
|
|
guard let group = tailscaleGroup(for: host), let configuration = group.tailscale else { return Data() }
|
|
// Only connection inputs belong here; discovery and other peers' preferences
|
|
// must not restart this host's Metrics worker.
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = .sortedKeys
|
|
return
|
|
(try? encoder.encode([
|
|
"groupID": group.id.uuidString,
|
|
"controlURL": configuration.network.controlURL,
|
|
"dnsSuffix": configuration.network.dnsSuffix,
|
|
"exitNodeID": configuration.exitNodeID ?? "",
|
|
"username": host.isTailscaleManaged ? tailscaleUsername(for: host) : "",
|
|
])) ?? Data()
|
|
}
|
|
|
|
public func tailscaleNetwork(for host: Host) throws -> (any SSHNetworkDialing)? {
|
|
guard let group = tailscaleGroup(for: host) else {
|
|
if host.isTailscaleManaged { throw TailscaleConfigurationError.unavailable }
|
|
return nil
|
|
}
|
|
guard let tailscaleConnections else { throw TailscaleConfigurationError.unavailable }
|
|
return try tailscaleConnections.connection(for: group.id)
|
|
}
|
|
|
|
public func tailscaleUsername(for host: Host) -> String {
|
|
savedTailscaleUsername(for: host) ?? "root"
|
|
}
|
|
|
|
public func savedTailscaleUsername(for host: Host) -> String? {
|
|
guard let configuration = tailscaleGroup(for: host)?.tailscale else { return nil }
|
|
let override = configuration.hostPreferences[host.id]?.username ?? ""
|
|
let username = (override.isEmpty ? configuration.username : override)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return username.isEmpty ? nil : username
|
|
}
|
|
|
|
public func replaceTailscalePeers(_ peers: [TailscalePeer], groupID: UUID) {
|
|
tailscalePeers[groupID] = peers
|
|
rebuildTailscaleHosts()
|
|
}
|
|
|
|
private func rebuildTailscaleHosts() {
|
|
var projected = hosts.filter { !$0.isTailscaleManaged }
|
|
var ids = Set(projected.map(\.id))
|
|
for group in groups {
|
|
guard let configuration = group.tailscale else { continue }
|
|
for peer in tailscalePeers[group.id] ?? [] where !peer.stableID.isEmpty && peer.supportsSSH {
|
|
let id = configuration.hostID(groupID: group.id, stableNodeID: peer.stableID)
|
|
guard ids.insert(id).inserted else { continue }
|
|
let preferences = configuration.hostPreferences[id] ?? TailscaleHostPreferences()
|
|
var host = Host(id: id, name: peer.name, host: peer.dnsName, groupID: group.id)
|
|
host.tailscale = TailscaleHostReference(groupID: group.id, stableNodeID: peer.stableID)
|
|
host.detectedDistribution = preferences.detectedDistribution
|
|
host.tags = preferences.tags
|
|
host.colorTag = preferences.colorTag
|
|
host.iconOverrideID = preferences.iconOverrideID
|
|
host.sshCharset = preferences.charset
|
|
host.sftpChannelInitialPath = preferences.sftpInitialPath
|
|
host.wakeOnLAN = preferences.wakeOnLAN
|
|
host.terminalTransports = preferences.terminalTransports
|
|
host.sessionPersistence = preferences.sessionPersistence
|
|
host.shellIntegrationMode = preferences.shellIntegrationMode
|
|
host.startupSnippetID = preferences.startupSnippetID
|
|
host.environmentVariables = preferences.environmentVariables
|
|
projected.append(host)
|
|
}
|
|
}
|
|
hosts = projected
|
|
}
|
|
|
|
public func saveTailscaleHost(_ host: Host, username: String? = nil) -> Bool {
|
|
guard let existing = self.host(host.id), existing.isTailscaleManaged,
|
|
host.tailscale == existing.tailscale, host.groupID == existing.groupID,
|
|
host.name == existing.name, host.host == existing.host, host.port == 22,
|
|
host.identityID == nil, host.jumpHostID == nil, host.proxy == nil,
|
|
!host.telnetEnabled, !host.agentForwardingEnabled,
|
|
let index = groups.firstIndex(where: { $0.id == host.groupID }), groups[index].tailscale != nil
|
|
else {
|
|
storageError = TailscaleConfigurationError.managedHost.localizedDescription
|
|
return false
|
|
}
|
|
let previous = groups
|
|
var preferences = groups[index].tailscale!.hostPreferences[host.id] ?? TailscaleHostPreferences()
|
|
if let username { preferences.username = username.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
preferences.tags = host.tags
|
|
preferences.colorTag = host.colorTag
|
|
preferences.iconOverrideID = host.iconOverrideID
|
|
preferences.charset = host.sshCharset
|
|
preferences.sftpInitialPath = host.sftpChannelInitialPath
|
|
preferences.wakeOnLAN = host.wakeOnLAN
|
|
preferences.terminalTransports = host.terminalTransports
|
|
preferences.sessionPersistence = host.sessionPersistence
|
|
preferences.shellIntegrationMode = host.shellIntegrationMode
|
|
preferences.startupSnippetID = host.startupSnippetID
|
|
preferences.environmentVariables = host.environmentVariables
|
|
groups[index].tailscale!.hostPreferences[host.id] = preferences
|
|
guard persistCore() else {
|
|
groups = previous
|
|
return false
|
|
}
|
|
rebuildTailscaleHosts()
|
|
return true
|
|
}
|
|
|
|
public func saveTailscaleUsername(_ username: String, hostID: UUID) -> Bool {
|
|
guard let host = host(hostID), let index = groups.firstIndex(where: { $0.id == host.groupID }),
|
|
groups[index].tailscale != nil
|
|
else { return false }
|
|
let previous = groups
|
|
var preferences = groups[index].tailscale!.hostPreferences[hostID] ?? TailscaleHostPreferences()
|
|
preferences.username = username.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
groups[index].tailscale!.hostPreferences[hostID] = preferences
|
|
guard persistCore() else {
|
|
groups = previous
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|