Files
Termind/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift
T
Kaede Akino c2958a05b3 Add iOS workspaces and shared feature services
Share Vault, persistence, SSH/SFTP, forwarding, and SwiftUI components across platforms. Add mobile editors, terminal input, standalone Assistant, Metrics navigation, transfers, and a unified Live Activity with host system icons.

Validation: 35 iOS tests and 100 focused macOS tests pass; formatting and zh-Hans checks pass. The full macOS run reports 14 issues in the documented timing, Keychain, and popover suites. Remaining Assistant tool and CloudKit work is tracked in Memory/MOBILE_ROLLOUT.md.
2026-09-11 13:47:16 +08:00

260 lines
10 KiB
Swift

import Foundation
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
public nonisolated enum PortForwardingRuntimeState: Hashable, Sendable {
case stopped
case preparing
case awaitingTrust
case awaitingAuthentication
case connecting
case starting
case running(activeConnections: Int)
case reconnecting(attempt: Int, nextRetry: Date, message: String)
case failed(String)
public var isActive: Bool {
switch self {
case .preparing, .awaitingTrust, .awaitingAuthentication, .connecting,
.starting, .running, .reconnecting:
true
case .stopped, .failed: false
}
}
public var label: String {
switch self {
case .stopped: String(localized: "Stopped")
case .preparing: String(localized: "Preparing")
case .awaitingTrust: String(localized: "Awaiting trust")
case .awaitingAuthentication: String(localized: "Awaiting authentication")
case .connecting: String(localized: "Connecting")
case .starting: String(localized: "Starting")
case .running(let count):
count == 0
? String(localized: "Running")
: count == 1
? String(localized: "Running · 1 connection")
: String(localized: "Running · \(count) connections")
case .reconnecting(let attempt, _, _): String(localized: "Reconnecting · attempt \(attempt)")
case .failed(let message): message
}
}
}
/// Process-local intent and live resources. No state in this type is persisted,
/// which guarantees that launching Termind never silently exposes a saved port.
@Observable
public final class PortForwardingController {
private let hosts: HostLibraryModel
public private(set) var states: [PortForwardingRule.ID: PortForwardingRuntimeState] = [:]
private var desired: Set<PortForwardingRule.ID> = []
private var sessions: [Host.ID: SSHForwardingSession] = [:]
private var connectionTasks: [Host.ID: Task<Void, any Error>] = [:]
private var reconnectTasks: [Host.ID: Task<Void, Never>] = [:]
private var reconnectAttempts: [Host.ID: Int] = [:]
private var connectedAt: [Host.ID: Date] = [:]
public init(hosts: HostLibraryModel) { self.hosts = hosts }
public func state(for id: PortForwardingRule.ID) -> PortForwardingRuntimeState {
states[id] ?? .stopped
}
public func isEnabled(_ id: PortForwardingRule.ID) -> Bool { desired.contains(id) }
public func start(_ id: PortForwardingRule.ID) {
guard let rule = hosts.portForwarding(id), !desired.contains(id) else { return }
desired.insert(id)
states[id] = .preparing
Task { await start(rule) }
}
public func start(_ ids: [PortForwardingRule.ID]) { ids.forEach(start) }
public func stop(_ id: PortForwardingRule.ID) {
desired.remove(id)
states[id] = .stopped
guard let rule = hosts.portForwarding(id), let session = sessions[rule.hostID] else { return }
Task {
await session.stop(id)
await disconnectHostIfIdle(rule.hostID)
}
}
public func stop(_ ids: [PortForwardingRule.ID]) { ids.forEach(stop) }
public func stopAll() {
let all = Array(desired)
desired.removeAll()
for id in all { states[id] = .stopped }
let active = sessions
sessions.removeAll()
for task in connectionTasks.values { task.cancel() }
connectionTasks.removeAll()
for task in reconnectTasks.values { task.cancel() }
reconnectTasks.removeAll()
reconnectAttempts.removeAll()
connectedAt.removeAll()
Task { for session in active.values { await session.disconnect() } }
}
private func start(_ rule: PortForwardingRule) async {
do {
guard rule.isValid else {
throw TermindSSHError.route(String(localized: "The forwarding rule is incomplete."))
}
guard let host = hosts.host(rule.hostID) else {
throw TermindSSHError.route(String(localized: "The forwarding host is unavailable."))
}
let session: SSHForwardingSession
if let existing = sessions[host.id] {
session = existing
} else {
let created = makeSession(for: host.id)
sessions[host.id] = created
session = created
states[rule.id] = .connecting
let task = Task {
let configuration = try hosts.sshConnectionConfiguration(for: host)
try await created.connect(configuration)
}
connectionTasks[host.id] = task
do { try await task.value } catch {
sessions[host.id] = nil
connectionTasks[host.id] = nil
throw error
}
connectionTasks[host.id] = nil
connectedAt[host.id] = Date()
}
if let task = connectionTasks[host.id] { try await task.value }
guard desired.contains(rule.id) else { return }
states[rule.id] = .starting
try await session.start(rule)
guard desired.contains(rule.id) else {
await session.stop(rule.id)
return
}
states[rule.id] = .running(activeConnections: 0)
} catch {
if desired.contains(rule.id),
let sshError = error as? TermindSSHError,
sshError.isRetryableForwardingFailure
{
if let session = sessions.removeValue(forKey: rule.hostID) {
await session.disconnect()
}
if reconnectTasks[rule.hostID] == nil {
scheduleReconnect(
rule.hostID, attempt: 1, message: error.localizedDescription
)
}
} else {
desired.remove(rule.id)
states[rule.id] = .failed(error.localizedDescription)
await disconnectHostIfIdle(rule.hostID)
}
}
}
private func disconnectHostIfIdle(_ hostID: Host.ID) async {
let hasActiveRule = desired.contains { hosts.portForwarding($0)?.hostID == hostID }
guard !hasActiveRule else { return }
let session = sessions.removeValue(forKey: hostID)
connectionTasks[hostID]?.cancel()
connectionTasks[hostID] = nil
reconnectTasks[hostID]?.cancel()
reconnectTasks[hostID] = nil
reconnectAttempts[hostID] = nil
connectedAt[hostID] = nil
if let session { await session.disconnect() }
}
private func makeSession(for hostID: Host.ID) -> SSHForwardingSession {
SSHForwardingSession(
connectionHandler: { [weak self] id, count in
Task { @MainActor in
guard let self, self.desired.contains(id) else { return }
self.states[id] = .running(activeConnections: count)
}
},
failureHandler: { [weak self] message in
Task { @MainActor in self?.handleConnectionFailure(hostID, message: message) }
}
)
}
private func handleConnectionFailure(_ hostID: Host.ID, message: String) {
guard reconnectTasks[hostID] == nil else { return }
let ids = desiredRuleIDs(for: hostID)
guard !ids.isEmpty else { return }
sessions[hostID] = nil
connectionTasks[hostID]?.cancel()
connectionTasks[hostID] = nil
let wasStable = connectedAt[hostID].map { Date().timeIntervalSince($0) >= 60 } == true
let attempt = wasStable ? 1 : (reconnectAttempts[hostID] ?? 0) + 1
scheduleReconnect(hostID, attempt: attempt, message: message)
}
private func scheduleReconnect(_ hostID: Host.ID, attempt: Int, message: String) {
let delays: [TimeInterval] = [1, 2, 5, 10, 30]
let delay = delays[min(attempt - 1, delays.count - 1)]
let nextRetry = Date().addingTimeInterval(delay)
reconnectAttempts[hostID] = attempt
for id in desiredRuleIDs(for: hostID) {
states[id] = .reconnecting(attempt: attempt, nextRetry: nextRetry, message: message)
}
reconnectTasks[hostID] = Task { [weak self] in
do { try await Task.sleep(for: .seconds(delay)) } catch { return }
guard let self else { return }
await self.reconnect(hostID, attempt: attempt)
}
}
private func reconnect(_ hostID: Host.ID, attempt: Int) async {
reconnectTasks[hostID] = nil
let ids = desiredRuleIDs(for: hostID)
guard !ids.isEmpty, let host = hosts.host(hostID) else { return }
let session = makeSession(for: hostID)
do {
let configuration = try hosts.sshConnectionConfiguration(for: host)
try await session.connect(configuration)
guard !desiredRuleIDs(for: hostID).isEmpty else {
await session.disconnect()
return
}
sessions[hostID] = session
connectedAt[hostID] = Date()
for id in desiredRuleIDs(for: hostID) {
guard let rule = hosts.portForwarding(id) else { continue }
states[id] = .starting
try await session.start(rule)
states[id] = .running(activeConnections: 0)
}
} catch {
sessions[hostID] = nil
await session.disconnect()
if let sshError = error as? TermindSSHError,
!sshError.isRetryableForwardingFailure
{
for id in desiredRuleIDs(for: hostID) {
desired.remove(id)
states[id] = .failed(error.localizedDescription)
}
reconnectAttempts[hostID] = nil
} else {
scheduleReconnect(
hostID, attempt: attempt + 1, message: error.localizedDescription
)
}
}
}
private func desiredRuleIDs(for hostID: Host.ID) -> [PortForwardingRule.ID] {
desired.filter { hosts.portForwarding($0)?.hostID == hostID }
}
}