Files
Termind/TFM/Application/QuickConnectTarget.swift

160 lines
6.8 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import TermindApplication
import TermindDomain
import TermindPresentation
import struct TermindDomain.Host
nonisolated struct QuickConnectTarget: Hashable, Sendable {
var host: Host
var route: SSHConnectionRoute
var isSaved: Bool
var connectionProtocol: HostTerminalProtocol
var username: String? { route.usernames[host.id] }
var loginAddress: String { address(for: host) }
var routeSummary: String { route.hosts.map(address).joined(separator: " → ") }
private func address(for host: Host) -> String {
let displayAddress = host.displayAddress(for: connectionProtocol)
guard let username = route.usernames[host.id], !username.isEmpty else { return displayAddress }
return "\(username)@\(displayAddress)"
}
}
/// Resolves public Vault metadata only. Credential lookup belongs to Connect.
@MainActor
enum QuickConnectResolver {
static func resolve(
_ input: QuickConnectInput, in library: HostLibraryModel, locale: Locale = .current
) throws -> QuickConnectTarget {
if input.connectionProtocol == .telnet {
return try resolveTelnet(input, in: library, locale: locale)
}
var usernames: [Host.ID: String] = [:]
var originals: [Host.ID: Host] = [:]
func resolveEndpoint(_ endpoint: QuickConnectInput.Endpoint) throws -> Host {
let matches = input.isCommand ? library.hosts.filter { $0.name == endpoint.host } : []
guard matches.count <= 1 else {
throw TermindSSHError.route(
AppLocalization.resolve(
AppStrings.Connections.moreThanOneSavedHostIsNamed(endpoint.host), locale: locale))
}
if matches.isEmpty, endpoint.host.contains(where: \.isWhitespace) {
throw TermindSSHError.route(
AppLocalization.resolve(AppStrings.Connections.invalidSshDestination, locale: locale))
}
var host = matches.first ?? Host(name: endpoint.host, host: endpoint.host)
if let original = matches.first { originals[host.id] = original }
if let port = endpoint.port { host.port = port }
if let username = endpoint.username {
if library.identity(for: host)?.username != username {
let identities = library.reusableIdentities.filter { $0.username == username }
host.identityID = identities.count == 1 ? identities.first?.id : nil
}
usernames[host.id] = username
} else if let username = library.identity(for: host)?.username {
usernames[host.id] = username
}
return host
}
func inheritedRoute(_ host: Host) throws -> [Host] {
guard let route = library.validatedRoute(for: host) else { throw SSHConnectionRoute.invalidRouteError }
for node in route.dropLast() {
originals[node.id] = node
usernames[node.id] = library.identity(for: node)?.username
}
return route
}
var destination = try resolveEndpoint(input.destination)
if let hostname = input.hostname {
guard !hostname.contains(where: \.isWhitespace) else {
throw TermindSSHError.route(
AppLocalization.resolve(AppStrings.Connections.invalidSshDestination, locale: locale))
}
destination.host = hostname
}
var nodes: [Host]
if let jumps = input.jumps {
nodes = []
for (index, endpoint) in jumps.enumerated() {
let jump = try resolveEndpoint(endpoint)
if index == 0 {
nodes += try inheritedRoute(jump)
} else {
nodes.append(jump)
}
}
nodes.append(destination)
} else {
nodes = try inheritedRoute(destination)
}
// Check source IDs before copying modified records; cloning must never
// turn an alias cycle into an apparently distinct sequence of Hosts.
guard nodes.count <= 8, Set(nodes.map(\.id)).count == nodes.count else {
throw SSHConnectionRoute.invalidRouteError
}
var resolved: [Host] = []
var resolvedUsernames: [Host.ID: String] = [:]
var sourceIDs: [Host.ID: Host.ID] = [:]
for var node in nodes {
let oldID = node.id
node.jumpHostID = resolved.last?.id
if let original = originals[oldID] {
if node != original || usernames[oldID] != library.identity(for: original)?.username {
node.id = UUID()
}
sourceIDs[node.id] = original.id
}
resolvedUsernames[node.id] = usernames[oldID]
resolved.append(node)
}
let host = resolved[resolved.count - 1]
return QuickConnectTarget(
host: host,
route: SSHConnectionRoute(hosts: resolved, usernames: resolvedUsernames, sourceHostIDs: sourceIDs),
isSaved: library.host(host.id) == host,
connectionProtocol: .ssh
)
}
private static func resolveTelnet(
_ input: QuickConnectInput, in library: HostLibraryModel, locale: Locale
) throws -> QuickConnectTarget {
let matches = library.hosts.filter { $0.name == input.destination.host }
guard matches.count <= 1 else {
throw TermindSSHError.route(
AppLocalization.resolve(
AppStrings.Connections.moreThanOneSavedHostIsNamed(input.destination.host), locale: locale))
}
var host =
matches.first
?? Host(
name: input.destination.host, host: input.destination.host,
sshEnabled: false, telnetEnabled: true)
if matches.first != nil, !host.telnetEnabled {
throw HostProtocolUnavailableError(useCase: .telnetTerminal)
}
if let port = input.destination.port { host.telnetPort = port }
var usernames: [Host.ID: String] = [:]
if let username = input.destination.username {
let identities = library.reusableIdentities.filter {
$0.username == username && library.isProxyEligibleOnThisDevice($0)
}
host.telnetIdentityID = identities.count == 1 ? identities[0].id : nil
usernames[host.id] = username
} else if let username = library.identity(for: host, protocol: .telnet)?.username {
usernames[host.id] = username
}
return QuickConnectTarget(
host: host, route: SSHConnectionRoute(hosts: [host], usernames: usernames),
isSaved: library.host(host.id) == host, connectionProtocol: .telnet)
}
}