384 lines
15 KiB
Swift
384 lines
15 KiB
Swift
// 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<Selection, Never>?
|
|
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) }
|
|
}
|
|
}
|
|
}
|