Files
Termind/Frameworks/TermindApplication/Connections/RemoteSessionConnectionController.swift
T

197 lines
6.6 KiB
Swift

import Foundation
import TermindDomain
import struct TermindDomain.Host
/// Shared connection-state composition for interactive sessions.
/// Transport-specific models own their I/O, while this object owns the common
/// connection journey and retry lifecycle shown by both Terminal and SFTP.
@Observable
public final class RemoteSessionConnectionController {
public let journey = ConnectionJourney()
public let reconnect: SessionReconnectController
public private(set) var pendingHostKey: SSHHostKeyPrompt?
public var temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:]
public private(set) var hasConnectedOnce = false
public private(set) var isRecovering = false
private var pendingHostKeyHostID: Host.ID?
private var hostKeyAuthorizationID: UUID?
private var hostKeyAuthorization: CheckedContinuation<SSHHostKeyDecision, Never>?
public var reconnectState: ReconnectState? { reconnect.state }
public init(reconnect: SessionReconnectController = SessionReconnectController()) {
self.reconnect = reconnect
}
public func disconnect() {
finishHostKeyAuthorization(.reject)
temporaryHostKeys.removeAll()
reconnect.stop()
hasConnectedOnce = false
isRecovering = false
journey.hide()
}
@discardableResult
public func markConnected() -> Bool {
let wasRecovering = isRecovering
let initial = !hasConnectedOnce
hasConnectedOnce = true
isRecovering = false
reconnect.markConnected(initial: initial)
return wasRecovering
}
@discardableResult
public func scheduleRecovery(
after error: String, retry: @escaping SessionReconnectController.RetryAction
) -> Bool {
guard hasConnectedOnce else { return false }
let announcesLoss = !isRecovering
isRecovering = true
reconnect.schedule(after: error, retry: retry)
return announcesLoss
}
public func pauseForUserInteraction() {
reconnect.pauseForUserInteraction()
}
@discardableResult
public func stopRecovery() -> ReconnectState? {
let state = reconnect.state
reconnect.stop()
isRecovering = false
return state
}
public func suspendForForegroundResume(reconnecting: Bool) {
reconnect.stop()
isRecovering = reconnecting && hasConnectedOnce
}
public func resetRecovery() {
reconnect.stop()
hasConnectedOnce = false
isRecovering = false
}
public func trustHostKey(
_ observation: SSHHostKeyObservation, using store: any RemoteSessionHostStore
) {
guard pendingHostKey?.observation == observation else { return }
store.trust(
KnownHostEntry(
hostname: observation.hostname, port: observation.port,
algorithm: observation.algorithm, fingerprint: observation.fingerprint,
addedAt: Date()
))
if let pendingHostKeyHostID { temporaryHostKeys[pendingHostKeyHostID] = nil }
finishHostKeyAuthorization(.accept)
journey.resume()
}
public func useTemporaryHostKey(_ observation: SSHHostKeyObservation) -> Bool {
guard pendingHostKey?.observation == observation,
let pendingHostKeyHostID
else { return false }
temporaryHostKeys[pendingHostKeyHostID] = observation.identity
finishHostKeyAuthorization(.accept)
journey.resume()
return true
}
public func rejectHostKey() { finishHostKeyAuthorization(.reject) }
public func authorizeHostKey(
_ challenge: SSHHostKeyChallenge,
using store: any RemoteSessionHostStore
) async -> SSHHostKeyDecision {
if challenge.expectedHostKeys.contains(challenge.observation.identity) {
return .accept
}
finishHostKeyAuthorization(.reject)
if !challenge.expectedHostKeys.isEmpty {
store.markKnownHostMismatched(
hostname: challenge.observation.hostname,
port: challenge.observation.port
)
}
let authorizationID = UUID()
hostKeyAuthorizationID = authorizationID
waitForHostKey(
SSHHostKeyPrompt(
observation: challenge.observation,
kind: challenge.expectedHostKeys.isEmpty
? .firstUse
: .additionalKey(trustedKeyCount: challenge.expectedHostKeys.count)
),
hostID: challenge.hostID,
fingerprint: challenge.observation.fingerprint
)
return await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
guard hostKeyAuthorizationID == authorizationID else {
continuation.resume(returning: .reject)
return
}
hostKeyAuthorization = continuation
}
} onCancel: {
Task { @MainActor [weak self] in
self?.cancelHostKeyAuthorization(id: authorizationID)
}
}
}
public func waitForHostKey(
_ prompt: SSHHostKeyPrompt, hostID: Host.ID, fingerprint: String
) {
pendingHostKey = prompt
pendingHostKeyHostID = hostID
reconnect.pauseForUserInteraction()
journey.waitForHostKey(at: hostID, fingerprint: fingerprint)
}
public func progressHandler(
attemptID: UUID, isCurrent: @escaping @MainActor @Sendable (UUID) -> Bool
) -> SSHRouteProgressHandler {
{ [weak self] event in
Task { @MainActor in
guard isCurrent(attemptID) else { return }
self?.journey.receive(event)
}
}
}
public func presentReconnectJourney(
host: Host?, using store: any RemoteSessionHostStore,
purpose: ConnectionPurpose, username: String?
) {
guard let host else { return }
journey.begin(
host: host, route: store.route(for: host), purpose: purpose,
username: username, mode: .reconnection
)
}
public func clearHostKeyPrompt() {
finishHostKeyAuthorization(.reject)
}
private func cancelHostKeyAuthorization(id: UUID) {
guard hostKeyAuthorizationID == id else { return }
finishHostKeyAuthorization(.reject)
}
private func finishHostKeyAuthorization(_ decision: SSHHostKeyDecision) {
let continuation = hostKeyAuthorization
hostKeyAuthorization = nil
hostKeyAuthorizationID = nil
pendingHostKey = nil
pendingHostKeyHostID = nil
continuation?.resume(returning: decision)
}
}