Files
Termind/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift
T
Kaede Akino d2cce7d2ce feat: make core features free and add host-based Pro limits
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.
2026-10-17 06:13:38 +08:00

806 lines
37 KiB
Swift

import Foundation
import TermindDomain
import struct TermindDomain.Host
@Observable
public final class SSHImportModel {
public var items: [SSHImportItem] = []
public var expanded = Set(SSHImportCategory.allCases)
public var storage: PasswordStorage
public var importColor: TagColor = .none
public var hostGroupID: HostGroup.ID?
public var hostTags: [String] = []
public private(set) var isWorking = false
public private(set) var error: String?
public private(set) var completed = false
public private(set) var importedCount = 0
private let library: HostLibraryModel
private let reader: any SSHImportReading
private var root: URL?
private var grants: [URL] = []
public var sourcePath: String? { root?.path }
private var knownHostsOnly = false
private let defaultUsername: String
private var jumpIDs: [String: UUID] = [:]
private var stableIdentityIDs: [IdentitySignature: UUID] = [:]
private var itemIndexByID: [UUID: Int] = [:]
private var keyOptionsByHostID: [UUID: [SSHKeyRef]] = [:]
private var keyItemIDByKeyID: [UUID: UUID] = [:]
private var hostIDsByIdentityID: [UUID: [UUID]] = [:]
private var selectedHostNameCounts: [String: Int] = [:]
private var existingHostNames: Set<String> = []
private struct IdentitySignature: Hashable {
var username: String
var keyID: UUID?
}
public init(
library: HostLibraryModel,
reader: any SSHImportReading,
defaultUsername: String = "",
storage: PasswordStorage = .iCloud
) {
self.defaultUsername = defaultUsername
self.library = library
self.reader = reader
self.storage = storage
}
public func scan(_ url: URL, knownHostsOnly: Bool) async {
guard !isWorking else { return }
grants = []
items = []
completed = false
root = url
self.knownHostsOnly = knownHostsOnly
await reload()
}
public func authorize(_ url: URL) async {
grants.append(url)
await reload()
}
private func reload() async {
guard let root else { return }
isWorking = true
error = nil
defer { isWorking = false }
do {
let previousItems = items
let previous = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) })
items = try await reader.scan(root: root, knownHostsOnly: knownHostsOnly, grants: grants)
addJumpDestinations()
for index in items.indices {
if let old = previous[items[index].id] {
items[index].selected = old.selected
items[index].acceptedWarnings = old.acceptedWarnings
items[index].preferredKeyID = old.preferredKeyID
if items[index].host != nil { items[index].name = old.name }
}
}
refreshDuplicates()
rebuildIdentityPlan(preserving: previousItems)
} catch {
self.error =
(error as? SSHImportSourceError)?.localizedDescription
?? String(localized: "The SSH files could not be read.")
}
}
private func addJumpDestinations() {
let originalIndexes = items.indices.filter { items[$0].host != nil }
var itemIndexByAlias: [String: Int] = [:]
for index in originalIndexes {
if let alias = items[index].host?.alias { itemIndexByAlias[alias] = index }
}
let savedHostsByName = Dictionary(
library.hosts.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first })
let jumps = items.compactMap(\.host).flatMap(\.jumps)
for jump in jumps where itemIndexByAlias[jump] == nil {
let userParts = jump.split(separator: "@", maxSplits: 1).map(String.init)
let address = userParts.last ?? jump
let host: String
let port: Int?
if address.hasPrefix("["), let endpoint = SSHImportParser.endpoint(address) {
(host, port) = endpoint
} else if address.filter({ $0 == ":" }).count == 1 {
let parts = address.split(separator: ":").map(String.init)
host = parts.first ?? address
port = parts.count == 2 ? Int(parts[1]) ?? 0 : 0
} else {
host = address
port = nil
}
let saved = savedHostsByName[host]
let identity = saved?.identityID.flatMap(library.identity)
let base = itemIndexByAlias[host].flatMap { items[$0].host }
let value = SSHImportedHost(
alias: jump, hostname: base?.hostname ?? saved?.host ?? host,
port: port ?? base?.port ?? saved?.port ?? 22,
username: userParts.count == 2 ? userParts[0] : base?.username ?? identity?.username ?? defaultUsername,
identityPaths: base?.identityPaths ?? [], existingKeyID: identity?.keyID)
let id = jumpIDs[jump] ?? UUID()
jumpIDs[jump] = id
items.append(SSHImportItem(id: id, category: .hosts, name: jump, source: "ProxyJump", host: value))
itemIndexByAlias[jump] = items.index(before: items.endIndex)
}
// A multi-hop route must not rewrite the standalone configuration of its intermediate aliases.
for index in originalIndexes {
guard let chain = items[index].host?.jumps, chain.count > 1 else { continue }
var previous = chain[0]
for alias in chain.dropFirst() {
guard let baseIndex = itemIndexByAlias[alias], var host = items[baseIndex].host else { continue }
let routeAlias = previous + " → " + alias
if itemIndexByAlias[routeAlias] == nil {
host.alias = routeAlias
host.jumps = [previous]
let id = jumpIDs[routeAlias] ?? UUID()
jumpIDs[routeAlias] = id
items.append(
SSHImportItem(
id: id, category: .hosts, name: routeAlias,
source: items[index].source, warnings: items[baseIndex].warnings, host: host))
itemIndexByAlias[routeAlias] = items.index(before: items.endIndex)
}
previous = routeAlias
}
items[index].host?.jumps = [previous]
}
}
public func refreshDuplicates() {
for index in items.indices where items[index].category != .identities {
items[index].existingID = nil
}
rebuildItemIndex()
rebuildKeyOptionIndex()
let existingKeys = Dictionary(
library.keys.compactMap { key in
library.keyAvailability(for: key) == .available
? (KeySignature(fingerprint: key.fingerprint, certificate: key.certificate), key.id) : nil
}, uniquingKeysWith: { first, _ in first })
var seenKeys: [KeySignature: UUID] = [:]
var existingKnownHosts: [KnownHostSignature: UUID] = [:]
for entry in library.knownHosts {
existingKnownHosts[KnownHostSignature(entry), default: entry.id] = entry.id
}
var seenKnownHosts: [KnownHostSignature: UUID] = [:]
for index in items.indices {
guard !items[index].unavailable else { continue }
if let key = items[index].key {
let signature = KeySignature(fingerprint: key.fingerprint, certificate: key.certificate)
if let existingID = existingKeys[signature] {
items[index].existingID = existingID
items[index].selected = false
} else if let duplicateID = seenKeys[signature] {
items[index].existingID = duplicateID
items[index].selected = false
}
seenKeys[signature] = items[index].existingID ?? items[index].id
}
if let entry = items[index].knownHost {
let signature = KnownHostSignature(entry)
if let existingID = existingKnownHosts[signature] {
items[index].existingID = existingID
items[index].selected = false
} else if let duplicateID = seenKnownHosts[signature] {
items[index].existingID = duplicateID
items[index].selected = false
}
seenKnownHosts[signature] = items[index].existingID ?? items[index].id
}
}
for index in items.indices where items[index].host != nil {
if let existing = identicalHost(items[index]) {
items[index].existingID = existing.id
items[index].selected = false
}
}
rebuildItemIndex()
}
public func selectable(_ item: SSHImportItem) -> Bool { !item.unavailable && item.existingID == nil }
public func selectedItems(_ category: SSHImportCategory? = nil) -> [SSHImportItem] {
items.filter { (category == nil || $0.category == category) && selectable($0) && $0.selected }
}
public func availableItems(_ category: SSHImportCategory? = nil) -> [SSHImportItem] {
items.filter { (category == nil || $0.category == category) && selectable($0) }
}
public func setSelected(_ selected: Bool, category: SSHImportCategory? = nil) {
var changedIdentities: Set<UUID> = []
var deselectedKeyIDs: Set<UUID> = []
for index in items.indices
where selectable(items[index]) && (category == nil || items[index].category == category) {
items[index].selected = selected
if items[index].category == .identities { changedIdentities.insert(items[index].id) }
if !selected, let key = items[index].key { deselectedKeyIDs.insert(key.id) }
}
applyIdentityKeyDependencies(selected: selected, identityIDs: changedIdentities)
if !deselectedKeyIDs.isEmpty {
changedIdentities.formUnion(deselectIdentities(using: deselectedKeyIDs))
}
rebuildHostNameIndex()
updateHostBindings(for: changedIdentities)
}
public func setSelected(_ selected: Bool, itemID: UUID) {
guard let index = itemIndexByID[itemID], selectable(items[index]) else { return }
let category = items[index].category
let wasSelected = items[index].selected
items[index].selected = selected
if category == .hosts, wasSelected != selected {
selectedHostNameCounts[items[index].name, default: 0] += selected ? 1 : -1
}
var changedIdentities: Set<UUID> = category == .identities ? [itemID] : []
if category == .identities, selected {
applyIdentityKeyDependencies(selected: true, identityIDs: changedIdentities)
} else if category == .keys, !selected, let keyID = items[index].key?.id {
changedIdentities.formUnion(deselectIdentities(using: [keyID]))
}
updateHostBindings(for: changedIdentities)
}
public func isSelected(_ itemID: UUID) -> Bool {
itemIndexByID[itemID].map { items[$0].selected } ?? false
}
public func item(_ itemID: UUID) -> SSHImportItem? {
itemIndexByID[itemID].map { items[$0] }
}
public func updateItem(_ updated: SSHImportItem) {
guard let initialIndex = itemIndexByID[updated.id] else { return }
let current = items[initialIndex]
if current.selected != updated.selected { setSelected(updated.selected, itemID: updated.id) }
if current.preferredKeyID != updated.preferredKeyID {
setPreferredKey(updated.preferredKeyID, for: updated.id)
}
if current.name != updated.name { setName(updated.name, for: updated.id) }
guard let finalIndex = itemIndexByID[updated.id] else { return }
items[finalIndex].acceptedWarnings = updated.acceptedWarnings
}
public func keyOptions(for item: SSHImportItem) -> [SSHKeyRef] {
if let cached = keyOptionsByHostID[item.id] { return cached }
guard let host = item.host else { return [] }
if let id = host.existingKeyID, let key = library.key(id) { return [key] }
var seen: Set<UUID> = []
return host.identityPaths.flatMap { path in
items.filter {
guard $0.source == path && $0.category == .keys else { return false }
if host.certificatePaths.isEmpty {
return $0.certificatePaths.isEmpty || $0.certificatePaths.contains(path + "-cert.pub")
}
return !$0.certificatePaths.isEmpty && !Set($0.certificatePaths).isDisjoint(with: host.certificatePaths)
}.compactMap { candidate -> SSHKeyRef? in
if let id = candidate.existingID {
if let key = library.key(id) { return key }
if let other = items.first(where: { $0.id == id && $0.selected }) { return other.key }
return nil
}
return candidate.key
}
}.filter { seen.insert($0.id).inserted }
}
public func chosenKey(for item: SSHImportItem) -> SSHKeyRef? {
let options = keyOptions(for: item)
return options.first { $0.id == item.preferredKeyID } ?? options.first
}
public func setPreferredKey(_ keyID: UUID?, for hostID: UUID) {
guard let index = itemIndexByID[hostID], items[index].host != nil else { return }
let previousItems = items
items[index].preferredKeyID = keyID
rebuildIdentityPlan(preserving: previousItems, changedHostID: hostID)
}
public func setName(_ name: String, for itemID: UUID) {
guard let index = itemIndexByID[itemID] else { return }
if items[index].category == .hosts, items[index].selected, selectable(items[index]) {
selectedHostNameCounts[items[index].name, default: 0] -= 1
selectedHostNameCounts[name, default: 0] += 1
}
items[index].name = name
}
public func identityName(for hostItem: SSHImportItem) -> String? {
guard let identityID = hostItem.host?.identityID else { return nil }
if let index = itemIndexByID[identityID], items[index].identity != nil {
return items[index].name
}
return library.identity(identityID)?.name
}
public func linkedHostNames(for identityItem: SSHImportItem) -> [String] {
(hostIDsByIdentityID[identityItem.id] ?? []).compactMap { id in
itemIndexByID[id].map { items[$0].name }
}
}
public func keyName(_ id: UUID) -> String? {
library.key(id)?.label
?? keyItemIDByKeyID[id].flatMap { itemIndexByID[$0] }.flatMap { items[$0].key?.label }
}
private struct KeySignature: Hashable {
var fingerprint: String
var certificate: String
}
private struct KnownHostSignature: Hashable {
var hostname: String
var port: Int
var identity: SSHHostKeyIdentity
init(_ entry: KnownHostEntry) {
hostname = entry.hostname
port = entry.isHashed ? 0 : entry.port
identity = entry.identity
}
}
/// Rebuilds public Identity previews from public key metadata. This never
/// reads private material. Dictionaries keep the scan linear in items plus
/// Host-to-key relationships instead of repeating array searches per row.
private func rebuildIdentityPlan(preserving previousItems: [SSHImportItem], changedHostID: UUID? = nil) {
let previousIdentities = Dictionary(
previousItems.compactMap { item -> (IdentitySignature, SSHImportItem)? in
guard let identity = item.identity else { return nil }
return (IdentitySignature(username: identity.username, keyID: identity.keyID), item)
}, uniquingKeysWith: { first, _ in first })
let previousHostIdentitySelection: Bool? = changedHostID.flatMap { hostID in
guard let oldHost = previousItems.first(where: { $0.id == hostID }),
let identityID = oldHost.host?.identityID,
let identityItem = previousItems.first(where: { $0.id == identityID })
else { return nil }
return identityItem.existingID != nil || identityItem.selected
}
items.removeAll { $0.category == .identities }
rebuildItemIndex()
rebuildKeyOptionIndex()
let existingIdentities = Dictionary(
library.reusableIdentities.map {
(IdentitySignature(username: $0.username, keyID: $0.keyID), $0)
}, uniquingKeysWith: { first, _ in first })
var signatures: [IdentitySignature] = []
var hostsBySignature: [IdentitySignature: [UUID]] = [:]
for item in items where item.host != nil {
guard let host = item.host else { continue }
let signature = IdentitySignature(username: host.username, keyID: chosenKey(for: item)?.id)
if hostsBySignature[signature] == nil { signatures.append(signature) }
hostsBySignature[signature, default: []].append(item.id)
}
let signaturesPerUsername = Dictionary(grouping: signatures, by: \.username)
var usedNames: Set<String> = Set(library.reusableIdentities.map(\.name))
var identityItems: [SSHImportItem] = []
identityItems.reserveCapacity(signatures.count)
hostIDsByIdentityID = [:]
for signature in signatures {
let hostIDs = hostsBySignature[signature] ?? []
if let existing = existingIdentities[signature] {
identityItems.append(
SSHImportItem(
id: existing.id, category: .identities, name: existing.name,
source: identitySource(for: hostIDs), selected: false,
identity: existing, existingID: existing.id))
hostIDsByIdentityID[existing.id] = hostIDs
continue
}
let id = stableIdentityIDs[signature] ?? UUID()
stableIdentityIDs[signature] = id
let previous = previousIdentities[signature]
var name =
previous?.name
?? defaultIdentityName(
for: signature,
hasAmbiguousUsername: (signaturesPerUsername[signature.username]?.count ?? 0) > 1,
usedNames: usedNames)
if name.isEmpty { name = signature.username }
usedNames.insert(name)
let identity = Identity(
id: id, name: name, username: signature.username,
passwordStorage: storage, keyID: signature.keyID,
colorTag: importColor)
let selected: Bool
if let previous {
selected = previous.selected
} else if let changedHostID, hostIDs.contains(changedHostID), let previousHostIdentitySelection {
selected = previousHostIdentitySelection
} else {
selected = true
}
identityItems.append(
SSHImportItem(
id: id, category: .identities, name: name,
source: identitySource(for: hostIDs), selected: selected, identity: identity))
hostIDsByIdentityID[id] = hostIDs
}
items.append(contentsOf: identityItems)
rebuildItemIndex()
updateAllHostBindings()
}
private func rebuildKeyOptionIndex() {
keyOptionsByHostID = [:]
keyItemIDByKeyID = [:]
var canonicalKeysBySignature: [KeySignature: SSHKeyRef] = [:]
for key in library.keys where library.keyAvailability(for: key) == .available {
canonicalKeysBySignature[KeySignature(fingerprint: key.fingerprint, certificate: key.certificate)] = key
}
for item in items where item.category == .keys {
guard let key = item.key else { continue }
let signature = KeySignature(fingerprint: key.fingerprint, certificate: key.certificate)
if canonicalKeysBySignature[signature] == nil { canonicalKeysBySignature[signature] = key }
}
var keyRowsBySource: [String: [(SSHImportItem, SSHKeyRef)]] = [:]
for item in items where item.category == .keys {
guard let key = item.key,
let canonical = canonicalKeysBySignature[
KeySignature(fingerprint: key.fingerprint, certificate: key.certificate)]
else { continue }
keyRowsBySource[item.source, default: []].append((item, canonical))
if library.key(canonical.id) == nil { keyItemIDByKeyID[canonical.id] = canonical.id }
}
for item in items where item.host != nil {
guard let host = item.host else { continue }
if let keyID = host.existingKeyID, let key = library.key(keyID) {
keyOptionsByHostID[item.id] = [key]
continue
}
var seen: Set<UUID> = []
var options: [SSHKeyRef] = []
for path in host.identityPaths {
for (candidate, key) in keyRowsBySource[path] ?? [] {
let matchesCertificate: Bool
if host.certificatePaths.isEmpty {
matchesCertificate =
candidate.certificatePaths.isEmpty
|| candidate.certificatePaths.contains(path + "-cert.pub")
} else {
matchesCertificate =
!candidate.certificatePaths.isEmpty
&& !Set(candidate.certificatePaths).isDisjoint(with: host.certificatePaths)
}
if matchesCertificate, seen.insert(key.id).inserted { options.append(key) }
}
}
keyOptionsByHostID[item.id] = options
}
}
private func defaultIdentityName(
for signature: IdentitySignature, hasAmbiguousUsername: Bool, usedNames: Set<String>
) -> String {
guard hasAmbiguousUsername || usedNames.contains(signature.username) else { return signature.username }
let keyLabel =
signature.keyID.flatMap { keyID in
library.key(keyID)?.label
?? keyItemIDByKeyID[keyID].flatMap { itemIndexByID[$0] }.flatMap { items[$0].key?.label }
} ?? String(localized: "No Key")
let base = "\(signature.username) (\(keyLabel))"
guard usedNames.contains(base), let keyID = signature.keyID,
let key = library.key(keyID)
?? keyItemIDByKeyID[keyID].flatMap({ itemIndexByID[$0] }).flatMap({ items[$0].key })
else { return base }
let shortFingerprint = key.fingerprint.split(separator: ":").last.map(String.init)?.prefix(8) ?? ""
return "\(base) \(shortFingerprint)"
}
private func identitySource(for hostIDs: [UUID]) -> String {
hostIDs.compactMap { id in itemIndexByID[id].map { items[$0].name } }.joined(separator: ", ")
}
private func rebuildItemIndex() {
itemIndexByID = Dictionary(uniqueKeysWithValues: items.indices.map { (items[$0].id, $0) })
rebuildHostNameIndex()
}
private func rebuildHostNameIndex() {
existingHostNames = Set(library.hosts.map(\.name))
selectedHostNameCounts = [:]
for item in items where item.host != nil && item.selected && selectable(item) {
selectedHostNameCounts[item.name, default: 0] += 1
}
}
private func applyIdentityKeyDependencies(selected: Bool, identityIDs: Set<UUID>) {
guard selected else { return }
for identityID in identityIDs {
guard let identityIndex = itemIndexByID[identityID], let keyID = items[identityIndex].identity?.keyID,
let keyItemID = keyItemIDByKeyID[keyID], let keyIndex = itemIndexByID[keyItemID],
selectable(items[keyIndex])
else { continue }
items[keyIndex].selected = true
}
}
private func deselectIdentities(using keyIDs: Set<UUID>) -> Set<UUID> {
var changed: Set<UUID> = []
for index in items.indices where items[index].category == .identities && items[index].existingID == nil {
guard let keyID = items[index].identity?.keyID, keyIDs.contains(keyID), items[index].selected else {
continue
}
items[index].selected = false
changed.insert(items[index].id)
}
return changed
}
private func updateAllHostBindings() {
for identityID in hostIDsByIdentityID.keys { updateHostBindings(for: [identityID]) }
}
private func updateHostBindings(for identityIDs: Set<UUID>) {
for identityID in identityIDs {
guard let identityIndex = itemIndexByID[identityID] else { continue }
let identityItem = items[identityIndex]
let keyAvailable =
identityItem.identity?.keyID.map { keyID in
library.key(keyID) != nil
|| keyItemIDByKeyID[keyID].flatMap { itemIndexByID[$0] }.map { items[$0].selected } == true
} ?? true
let available = identityItem.existingID != nil || (identityItem.selected && keyAvailable)
for hostID in hostIDsByIdentityID[identityID] ?? [] {
guard let hostIndex = itemIndexByID[hostID] else { continue }
items[hostIndex].host?.identityID = available ? identityID : nil
}
}
}
private func identicalHost(_ item: SSHImportItem) -> Host? {
func matches(_ row: SSHImportItem, _ saved: Host, visited: Set<UUID>) -> Bool {
guard !visited.contains(saved.id), visited.count < 8, let candidate = row.host,
saved.name == row.name && saved.host == candidate.hostname && saved.port == candidate.port,
let identity = saved.identityID.flatMap(library.identity), identity.username == candidate.username,
identity.keyID == chosenKey(for: row)?.id
else { return false }
if candidate.jumps.isEmpty { return saved.jumpHostID == nil }
guard candidate.jumps.count == 1, let alias = candidate.jumps.first,
let jump = saved.jumpHostID.flatMap(library.host)
else { return false }
if let next = items.first(where: { $0.host?.alias == alias }) {
return matches(next, jump, visited: visited.union([saved.id]))
}
return jump.name == alias
}
return library.hosts.first { matches(item, $0, visited: []) }
}
public func warnings(for item: SSHImportItem) -> [String] {
var warnings = item.warnings
if let entry = item.knownHost,
library.knownHosts.contains(where: {
let sameAddress =
$0.hostname == entry.hostname && ($0.isHashed || $0.port == entry.port)
|| (!entry.isHashed && $0.matches(hostname: entry.hostname, port: entry.port))
|| (!$0.isHashed && entry.matches(hostname: $0.hostname, port: $0.port))
return sameAddress && $0.identity != entry.identity
})
{
warnings.append(
String(localized: "This address already has a different trusted fingerprint. Confirm adding this key."))
}
return warnings
}
public func hasNameConflict(_ item: SSHImportItem) -> Bool {
guard item.host != nil else { return false }
return (existingHostNames.contains(item.name) && item.existingID == nil)
|| selectedHostNameCounts[item.name, default: 0] > 1
}
public func problem(for item: SSHImportItem, includeWarningReminder: Bool = true) -> String? {
if !item.issue.isEmpty { return item.issue }
if item.requiresPassphrase {
return String(localized: "Enter the key passphrase to validate this private key.")
}
if includeWarningReminder && !warnings(for: item).isEmpty && !item.acceptedWarnings {
return String(localized: "Review and accept the import warnings.")
}
if let identity = item.identity {
guard !item.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!identity.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else { return String(localized: "Invalid SSH record.") }
if let keyID = identity.keyID, library.key(keyID) == nil,
keyItemIDByKeyID[keyID].flatMap({ itemIndexByID[$0] }).map({ items[$0].selected }) != true
{
return String(localized: "Invalid SSH record.")
}
}
if let host = item.host {
guard !item.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!host.hostname.isEmpty, !host.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
(1...65_535).contains(host.port)
else { return String(localized: "Invalid SSH record.") }
if hasNameConflict(item) {
return String(localized: "A different host already uses this name. Rename this import or deselect it.")
}
for jump in host.jumps {
if !library.hosts.contains(where: { $0.name == jump })
&& !items.contains(where: { $0.host?.alias == jump && ($0.selected || $0.existingID != nil) })
{
return String(localized: "Select the required jump host or deselect this host.")
}
}
}
return nil
}
public var canImport: Bool {
!isWorking && !completed && !selectedItems().isEmpty
&& selectedItems().allSatisfy { problem(for: $0) == nil }
&& (try? makeBatch()) != nil
}
public func unlock(_ id: UUID, passphrase: String) async {
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
isWorking = true
error = nil
defer { isWorking = false }
do {
let previous = items[index]
var replacements = try await reader.unlock(id: id, name: previous.name, passphrase: passphrase)
guard !replacements.isEmpty, replacements.allSatisfy({ $0.category == .keys && $0.key != nil }) else {
throw CocoaError(.coderInvalidValue)
}
for replacementIndex in replacements.indices {
replacements[replacementIndex].selected = previous.selected
replacements[replacementIndex].acceptedWarnings = previous.acceptedWarnings
}
items.replaceSubrange(index...index, with: replacements)
refreshDuplicates()
rebuildIdentityPlan(preserving: items)
} catch {
if let sourceError = error as? SSHImportSourceError {
self.error = sourceError.localizedDescription
} else {
items[index].issue = String(localized: "The key or passphrase is invalid. Retry or deselect this key.")
}
}
}
public func commit() async {
let beforeInitialRefresh = items
refreshDuplicates()
rebuildIdentityPlan(preserving: beforeInitialRefresh)
guard canImport else { return }
isWorking = true
error = nil
defer { isWorking = false }
do {
var batch = try makeBatch()
try await library.resolveSavingHostsAccess(batch.hosts)
batch.credentials = try await reader.credentials(for: Set(batch.keys.map(\.id)))
// Revalidate after awaiting the reader, because another window or sync can write meanwhile.
let beforeFinalRefresh = items
refreshDuplicates()
rebuildIdentityPlan(preserving: beforeFinalRefresh)
let refreshed = try makeBatch()
guard Set(refreshed.keys.map(\.id)) == Set(batch.keys.map(\.id)),
selectedItems().allSatisfy({ problem(for: $0) == nil })
else {
throw CocoaError(.coderInvalidValue)
}
batch.hosts = refreshed.hosts
batch.identities = refreshed.identities
batch.knownHosts = refreshed.knownHosts
try library.commitSSHImport(batch)
importedCount = batch.keys.count + batch.identities.count + batch.hosts.count + batch.knownHosts.count
completed = true
await reader.discard()
} catch HostAccessError.hostLimit {
// The host quota presents the Paywall without replacing the import selection with an error.
self.error = nil
} catch { self.error = error.localizedDescription }
}
public func discard() async { await reader.discard() }
private func makeBatch() throws -> SSHImportBatch {
var batch = SSHImportBatch()
batch.keys = selectedItems(.keys).compactMap { item in
guard var key = item.key else { return nil }
key.storage =
switch storage {
case .thisDevice: .thisDevice
case .iCloud: .iCloud
}
key.colorTag = importColor
return key
}
batch.knownHosts = selectedItems(.knownHosts).compactMap(\.knownHost)
let selectedKeyIDs = Set(batch.keys.map(\.id))
batch.identities = selectedItems(.identities).compactMap { item in
guard var identity = item.identity,
identity.keyID.map({ library.key($0) != nil || selectedKeyIDs.contains($0) }) ?? true
else { return nil }
identity.name = item.name
identity.passwordStorage = storage
identity.colorTag = importColor
return identity
}
let importedIdentityIDs = Set(batch.identities.map(\.id))
var built: [UUID: Host] = [:]
var visiting: Set<UUID> = []
func build(_ item: SSHImportItem, forcedJump: UUID? = nil) throws -> Host {
if let existing = item.existingID.flatMap(library.host) { return existing }
if let existing = built[item.id] {
if let forcedJump, existing.jumpHostID != forcedJump { throw ImportRouteError.conflictingChain }
return existing
}
guard visiting.insert(item.id).inserted, visiting.count <= 8, let imported = item.host else {
throw ImportRouteError.invalid
}
defer { visiting.remove(item.id) }
var jumpID = forcedJump
for alias in imported.jumps {
if let candidate = items.first(where: {
$0.host?.alias == alias && ($0.selected || $0.existingID != nil)
}) {
jumpID = try build(candidate, forcedJump: jumpID).id
} else if let existing = library.hosts.first(where: { $0.name == alias }) {
if let jumpID, existing.jumpHostID != jumpID { throw ImportRouteError.conflictingChain }
jumpID = existing.id
} else {
throw ImportRouteError.invalid
}
}
let identityID = imported.identityID.flatMap { id in
library.identity(id) != nil || importedIdentityIDs.contains(id) ? id : nil
}
let host = Host(
id: item.id, name: item.name, host: imported.hostname, port: imported.port,
identityID: identityID, jumpHostID: jumpID, groupID: hostGroupID,
tags: hostTags, colorTag: importColor)
built[item.id] = host
return host
}
// Build routes with the most hops first so intermediate aliases get their chain before reuse.
for item in selectedItems(.hosts).sorted(by: { ($0.host?.jumps.count ?? 0) > ($1.host?.jumps.count ?? 0) }) {
_ = try build(item)
}
batch.hosts = built.values.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
let all = Dictionary(uniqueKeysWithValues: (library.hosts + batch.hosts).map { ($0.id, $0) })
for host in batch.hosts {
var current: Host? = host
var seen: Set<UUID> = []
while let node = current {
guard seen.insert(node.id).inserted, seen.count <= 8 else { throw ImportRouteError.invalid }
current = node.jumpHostID.flatMap { all[$0] }
}
}
return batch
}
public var routeProblem: String? {
do {
_ = try makeBatch()
return nil
} catch { return error.localizedDescription }
}
}
nonisolated private enum ImportRouteError: LocalizedError {
case invalid, conflictingChain
public var errorDescription: String? {
switch self {
case .invalid: String(localized: "The imported jump route is missing, cyclic, or exceeds 8 hops.")
case .conflictingChain:
String(localized: "These hosts require conflicting jump routes. Import the routes separately.")
}
}
}