// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import Foundation import Observation import TermindDomain public nonisolated struct RemoteSessionLaunch: Sendable { public var command: String public var attachment: RemoteSessionReference public var created: Bool public init(command: String, attachment: RemoteSessionReference, created: Bool) { self.command = command self.attachment = attachment self.created = created } } public nonisolated struct SSHPTYStartupPlan: Sendable { public let command: String public let runtimeAttachment: RemoteSessionReference? public init(command: String, runtimeAttachment: RemoteSessionReference? = nil) { self.command = command self.runtimeAttachment = runtimeAttachment } } public nonisolated struct RemoteSessionCommandResult: Sendable { public let stdout: String public let stderr: String public let exitCode: Int32 public let truncated: Bool public init(stdout: String, stderr: String, exitCode: Int32, truncated: Bool = false) { self.stdout = stdout self.stderr = stderr self.exitCode = exitCode self.truncated = truncated } } public typealias RemoteSessionCommandExecutor = @Sendable (String) async throws -> RemoteSessionCommandResult public nonisolated struct RemoteSessionCapabilities: Sendable, Equatable { public var windowCount: Bool public var clientCount: Bool public var shellIntegration: Bool public init(windowCount: Bool = false, clientCount: Bool = false, shellIntegration: Bool = false) { self.windowCount = windowCount self.clientCount = clientCount self.shellIntegration = shellIntegration } } public nonisolated struct RemoteSessionBackendRegistry: Sendable { public typealias Factory = @Sendable (@escaping RemoteSessionCommandExecutor) -> any RemoteSessionBackend private let factories: [SessionPersistenceBackendID: Factory] public init(_ factories: [SessionPersistenceBackendID: Factory]) { self.factories = factories } public func backend(_ id: SessionPersistenceBackendID, execute: @escaping RemoteSessionCommandExecutor) throws -> any RemoteSessionBackend { guard let factory = factories[id] else { throw RemoteSessionFailure.unsupported } return factory(execute) } /// A failed lookup is unknown, not evidence that the remote session ended. public func sessionExists( _ reference: RemoteSessionReference, execute: @escaping RemoteSessionCommandExecutor ) async throws -> Bool { let backend = try backend(reference.backend, execute: execute) let sessions = try await backend.sessions() return sessions.contains { backend.matches(reference, session: $0) } } } public nonisolated protocol RemoteSessionBackend: Sendable { var id: SessionPersistenceBackendID { get } var capabilities: RemoteSessionCapabilities { get } func probe() async throws -> RemoteSessionCapabilities func sessions() async throws -> [RemoteSessionDescriptor] func validate(_ reference: RemoteSessionReference) throws func matches(_ reference: RemoteSessionReference, session: RemoteSessionDescriptor) -> Bool func create( token: UUID, startupCommand: String?, shellIntegration: Bool, environment: [HostEnvironmentVariable] ) async throws -> RemoteSessionDescriptor func launch(_ attachment: RemoteSessionReference, created: Bool) throws -> RemoteSessionLaunch func terminate(_ attachment: RemoteSessionReference) async throws } extension RemoteSessionBackend { public nonisolated func probe() async throws -> RemoteSessionCapabilities { capabilities } public nonisolated func validate(_ reference: RemoteSessionReference) throws { guard reference.version == 1, reference.backend == id else { throw RemoteSessionFailure.unsupported } } } @MainActor @Observable public final class RemoteSessionCoordinator { public nonisolated struct Prompt: Identifiable, Sendable { public let id: UUID public var sessions: [RemoteSessionDescriptor] public var failure: RemoteSessionFailure? } public nonisolated enum Selection: Sendable { case create case attach(RemoteSessionDescriptor) case plainShell case retry case cancel } public private(set) var prompt: Prompt? { didSet { didChangePrompt?(prompt != nil) } } public var didChangePrompt: (@MainActor (Bool) -> Void)? public var attachment: RemoteSessionReference? public private(set) var detached = false public var requiresSelection = false public var didChangeAttachment: (@MainActor (RemoteSessionReference?) -> Void)? private var generation = UUID() private var continuation: CheckedContinuation? private var creationToken: UUID? private var creationIntent: CreationIntent? private struct CreationIntent: Equatable { let backend: SessionPersistenceBackendID let hostID: UUID let endpoint: String let username: String let command: String? let shellIntegration: Bool let environment: [HostEnvironmentVariable] } public init() {} public func choose(_ selection: Selection, for id: UUID) { guard prompt?.id == id else { return } prompt = nil let pending = continuation continuation = nil pending?.resume(returning: selection) } public func cancel() { generation = UUID() if let prompt { choose(.cancel, for: prompt.id) } } public func cancelSelection(for id: UUID, close: @MainActor () -> Void) { guard prompt?.id == id else { return } // Cancelling preparation abandons this connection, not a remote session we have not attached to. clearAttachment() choose(.cancel, for: id) // Close synchronously, so the connection generation expires before cancellation is reported. close() } public func markDetached() { detached = true } /// Only a verified, successful session end closes the local terminal. /// A failed inspection preserves the recovery reference, just like detach. public func finishExit(exitCode: Int32, sessionExists: Bool?) -> Bool { if sessionExists == false { markEnded() return exitCode == 0 } markDetached() return false } public func markEnded() { detached = false requiresSelection = true creationToken = nil clearAttachment() } public func clearAttachment() { attachment = nil didChangeAttachment?(nil) } public func confirmPlainConnection() async throws { cancel() let attempt = generation let selection = await ask(sessions: [], failure: .unsupported, generation: attempt) try check(attempt) guard case .plainShell = selection else { throw CancellationError() } requiresSelection = false creationToken = nil clearAttachment() } public func prepare( preferences: SessionPersistencePreferences, registry: RemoteSessionBackendRegistry, execute: @escaping RemoteSessionCommandExecutor, hostID: UUID, endpoint: String, username: String, startupCommand: String?, requiresNewSession: Bool, shellIntegration: Bool, environment: [HostEnvironmentVariable] = [] ) async throws -> SSHPTYStartupPlan? { cancel() if attachment == nil, !requiresSelection, !preferences.enabled || preferences.onConnect == .plainShell { creationToken = nil return nil } if !preferences.enabled, attachment == nil { try await confirmPlainConnection() return nil } while true { let service: any RemoteSessionBackend do { service = try registry.backend(attachment?.backend ?? preferences.backend, execute: execute) } catch { let attempt = generation let selection = await ask(sessions: [], failure: .unsupported, generation: attempt) try check(attempt) switch selection { case .plainShell: clearAttachment() creationToken = nil requiresSelection = false return nil case .retry: continue default: throw CancellationError() } } let launch = try await prepare( service: service, hostID: hostID, endpoint: endpoint, username: username, startupCommand: startupCommand, requiresNewSession: requiresNewSession, shellIntegration: shellIntegration, onConnect: preferences.onConnect, environment: environment) return launch.map { SSHPTYStartupPlan(command: $0.command, runtimeAttachment: $0.attachment) } } } public func prepare( service: any RemoteSessionBackend, hostID: UUID, endpoint: String, username: String, startupCommand: String?, requiresNewSession: Bool, shellIntegration: Bool, onConnect: SessionPersistencePreferences.OnConnect = .ask, environment: [HostEnvironmentVariable] = [] ) async throws -> RemoteSessionLaunch? { cancel() let attempt = generation detached = false let intent = CreationIntent( backend: service.id, hostID: hostID, endpoint: endpoint, username: username, command: startupCommand, shellIntegration: shellIntegration, environment: environment) if creationIntent != intent { // An uncertain create may be retried, but its ownership capability must never // attach a different launch request to an earlier session. creationToken = nil creationIntent = intent } if attachment == nil, !requiresSelection, onConnect == .plainShell { creationToken = nil return nil } var mustAsk = requiresSelection while true { try check(attempt) var sessions: [RemoteSessionDescriptor] = [] var capabilities = service.capabilities var failure: RemoteSessionFailure? do { if let saved = attachment, !requiresNewSession { try service.validate(saved) } capabilities = try await service.probe() try check(attempt) sessions = try await service.sessions() try check(attempt) if let saved = attachment, !requiresNewSession { if saved.version == 1, saved.backend == service.id, saved.hostID == hostID, saved.endpoint == endpoint, saved.username == username, let current = sessions.first(where: { service.matches(saved, session: $0) }) { var updated = saved updated.session = current attachment = updated didChangeAttachment?(updated) return try service.launch(updated, created: false) } attachment = nil didChangeAttachment?(nil) requiresSelection = true mustAsk = true failure = saved.version == 1 ? .sessionMissing : .unsupported } } catch is CancellationError { throw CancellationError() } catch { failure = error as? RemoteSessionFailure ?? .commandFailed(error.localizedDescription) } try check(attempt) let selection: Selection if !mustAsk, requiresNewSession || onConnect == .createManaged, failure == nil { selection = .create } else { selection = await ask(sessions: sessions, failure: failure, generation: attempt) } try check(attempt) switch selection { case .cancel: throw CancellationError() case .retry: continue case .plainShell: attachment = nil creationToken = nil requiresSelection = false didChangeAttachment?(nil) return nil case .create, .attach: do { let session: RemoteSessionDescriptor let token: UUID? if case .attach(let existing) = selection { guard sessions.contains(existing), !requiresNewSession else { continue } session = existing token = nil } else { let owner = creationToken ?? UUID() creationToken = owner session = try await service.create( token: owner, startupCommand: startupCommand, shellIntegration: shellIntegration && capabilities.shellIntegration, environment: environment) token = owner } try check(attempt) let reference = RemoteSessionReference( backend: service.id, session: session, hostID: hostID, endpoint: endpoint, username: username, ownershipToken: token) let result = try service.launch(reference, created: token != nil) attachment = reference creationToken = nil requiresSelection = false didChangeAttachment?(reference) return result } catch is CancellationError { throw CancellationError() } catch { try check(attempt) let choice = await ask( sessions: [], failure: error as? RemoteSessionFailure ?? .commandFailed(error.localizedDescription), generation: attempt) try check(attempt) if case .plainShell = choice { attachment = nil creationToken = nil requiresSelection = false didChangeAttachment?(nil) return nil } if case .retry = choice { continue } throw CancellationError() } } } } private func check(_ attempt: UUID) throws { try Task.checkCancellation() guard attempt == generation else { throw CancellationError() } } private func ask(sessions: [RemoteSessionDescriptor], failure: RemoteSessionFailure?, generation: UUID) async -> Selection { let id = UUID() return await withTaskCancellationHandler { guard !Task.isCancelled, self.generation == generation else { return .cancel } return await withCheckedContinuation { continuation = $0 prompt = Prompt(id: id, sessions: sessions, failure: failure) } } onCancel: { Task { @MainActor [weak self] in self?.choose(.cancel, for: id) } } } }