Files

228 lines
8.5 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import ETBootstrap
import ETSession
import Foundation
import TermindApplication
import TermindDomain
actor EternalTerminalEngine: ResumableTerminalEngine {
// Keep the original flat payload keys: existing encrypted recovery files outlive app upgrades.
struct Payload: Codable, Sendable {
let clientID: String
let passkey: Data
let checkpoint: ETSessionCheckpoint
let integration: RemoteShellIntegrationBootstrap?
let commandFilter: RemoteCommandEventFilter?
let integratedShell: ResumableTerminalShellState.IntegratedShell?
let pendingIntegrationCommand: Data?
var shellState: ResumableTerminalShellState {
.init(
integration: integration, commandFilter: commandFilter, integratedShell: integratedShell,
pendingIntegrationCommand: pendingIntegrationCommand)
}
}
private let receive: EventHandler
private var session: ETTerminalSession?
private var credentials: ETCredentials?
private var bridge: TerminalTCPBridge?
private var routeReporter: TerminalRouteReporter?
private var reader: Task<Void, Never>?
private var observer: Task<Void, Never>?
private var connectionState: ETConnectionState = .idle
private var closed = false
init(receive: @escaping EventHandler) {
self.receive = receive
}
func prepare(_ context: ResumableTerminalEngineContext) async throws -> ResumableTerminalEnginePreparation {
try checkOpen()
let payload = try context.checkpoint.map(Self.decodeCheckpoint)
let reporter = TerminalRouteReporter(
hostIDs: context.request.configuration.jumps.map(\.id) + [context.request.configuration.hostID]
) { [receive] snapshot in
Task { await receive(.routeChanged(snapshot)) }
}
routeReporter = reporter
let attemptID = reporter.begin()
let socket: SSHRoutedSocket
do {
socket = try await context.route { reporter.receive($0, attemptID: attemptID) }
} catch {
reporter.retire(attemptID: attemptID)
throw error
}
guard !closed else {
socket.close()
throw CancellationError()
}
reporter.ready(attemptID: attemptID)
let bridge: TerminalTCPBridge
do {
bridge = try TerminalTCPBridge(
open: context.route, initial: socket, reporter: reporter, initialAttemptID: attemptID)
} catch {
socket.close()
reporter.retire(attemptID: attemptID)
throw error
}
self.bridge = bridge
if let payload {
credentials = ETCredentials(clientID: payload.clientID, passkey: payload.passkey)
session = try ETTerminalSession(
host: "127.0.0.1", port: bridge.port, clientID: payload.clientID,
passkey: payload.passkey, checkpoint: payload.checkpoint)
return .init(shell: payload.shellState)
}
let executor = BootstrapExecutor(connection: context.bootstrap)
do {
credentials = try await ETBootstrap().run(using: executor)
} catch {
if let original = await executor.failure { throw original }
throw TerminalTransportError.bootstrapFailed
}
try checkOpen()
guard let credentials else { throw TerminalTransportError.bootstrapFailed }
session = try ETTerminalSession(
host: "127.0.0.1", port: bridge.port, clientID: credentials.clientID, passkey: credentials.passkey,
environmentVariables: Dictionary(
context.environment.map { ($0.variable, $0.value) }, uniquingKeysWith: { _, last in last }))
var result = ResumableTerminalEnginePreparation(startupInput: try context.startup.eternalInput)
if context.shellIntegrationEnabled && context.startup.plan == nil {
let integration = RemoteShellIntegrationBootstrap()
result.shell.integration = integration
result.shell.commandFilter = RemoteCommandEventFilter(token: integration.token)
}
return result
}
static func decodeCheckpoint(_ payload: Data) throws -> Payload {
try JSONDecoder().decode(Payload.self, from: payload)
}
func start(columns: UInt16, rows: UInt16) async throws {
try checkOpen()
guard let session else { throw TerminalTransportError.sessionFailed }
startReaders(output: session.output, states: session.stateChanges)
try await session.connect(initialDimensions: .init(rows: Int(rows), columns: Int(columns)))
try checkOpen()
}
func startReaders(output: AsyncStream<Data>, states: AsyncStream<ETConnectionState>) {
reader = Task { [weak self] in
for await data in output {
guard let self, !Task.isCancelled else { return }
await self.deliver(data)
}
}
observer = Task { [weak self] in
for await state in states {
guard let self, !Task.isCancelled else { return }
await self.receiveState(state)
}
}
}
private func deliver(_ data: Data) async {
guard !closed else { return }
await receive(.output(data))
}
private func receiveState(_ state: ETConnectionState) async {
guard !closed else { return }
connectionState = state
switch state {
case .connected: await receive(.connected)
case .reconnecting, .disconnected: await receive(.recovering)
case .failed: await receive(.failed)
case .sessionEnded:
// Vendor output finishes first, but our consumer may still be draining it.
await reader?.value
guard !closed else { return }
await receive(.ended)
case .closed, .idle, .bootstrapping, .connecting: break
}
}
func write(_ data: Data) async throws {
try checkOpen()
try await session?.send(data)
}
func resize(columns: UInt16, rows: UInt16) async throws {
try checkOpen()
try await session?.resize(rows: Int(rows), cols: Int(columns))
}
func checkpoint(shell: @Sendable () async -> ResumableTerminalShellState) async throws -> Data {
try checkOpen()
guard let session, let credentials else { throw TerminalTransportError.invalidCheckpoint }
let checkpoint = try await session.prepareForApplicationBackground()
let shell = await shell()
return try JSONEncoder().encode(
Payload(
clientID: credentials.clientID, passkey: credentials.passkey, checkpoint: checkpoint,
integration: shell.integration, commandFilter: shell.commandFilter,
integratedShell: shell.integratedShell, pendingIntegrationCommand: shell.pendingIntegrationCommand))
}
func resume() async throws {
try checkOpen()
await session?.resumeFromApplicationBackground()
try checkOpen()
// ET keeps a live connection across suspension and does not repeat its connected event on resume.
if connectionState == .connected { await receive(.connected) }
}
func prepareRetry() async {
await bridge?.retry()
}
func retry() async throws {
try checkOpen()
await session?.notifyNetworkPathChanged()
}
func networkAvailable() async {
await session?.notifyNetworkPathChanged()
}
func close() async {
guard !closed else { return }
closed = true
routeReporter?.close()
reader?.cancel()
observer?.cancel()
await session?.close()
await bridge?.close()
session = nil
bridge = nil
credentials = nil
}
private func checkOpen() throws {
try Task.checkCancellation()
guard !closed else { throw CancellationError() }
}
}
private actor BootstrapExecutor: ETBootstrapExecutor {
let connection: SSHConnection
private(set) var failure: (any Error)?
init(connection: SSHConnection) { self.connection = connection }
func run(command: String) async throws -> String {
do {
let result = try await connection.execute(
command, workingDirectory: nil, stdin: nil, timeout: 20, outputLimit: 65_536, onOutput: nil)
return result.stdout + result.stderr
} catch {
failure = error
throw error
}
}
}