Files

2554 lines
113 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Combine
import Foundation
import GhosttyTerminal
import Observation
import TermindApplication
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
import UIKit
import struct TermindDomain.Host
private enum MobileWorkspaceConnectionResumeIntent: Equatable {
case none
case foreground
}
private nonisolated enum MobileSSHSessionEvent: Sendable {
case exit(Result<Int32, TermindSSHError>)
case shellIntegration(SSHShellIntegrationEvent)
}
private struct MobilePendingUpload {
let id = UUID()
var batch: SFTPLocalUploadBatch
var destination: String
var homeDirectory: String?
var client: any SFTPClientProtocol
var ownsClient: Bool
var context: SFTPTransferContext
var connectionGeneration: UUID
var insertPaths: Bool
var terminalContext: TerminalContextCoordinator.Ticket?
}
private struct MobilePendingDownload {
let id = UUID()
var batch: SFTPRemoteDownloadBatch
var connectionGeneration: UUID
}
private struct MobilePendingRemoteCopy {
let id = UUID()
var batch: SFTPRemoteCopyBatch
var connectionGeneration: UUID
}
private struct MobilePendingInitialCommand {
var command: String
var recordsInHistory: Bool
}
@MainActor
@Observable
final class MobileWorkspace: Identifiable {
typealias ID = UUID
private(set) var descriptor: MobileWorkspaceDescriptor
var id: UUID { descriptor.id }
let sessionPersistence = RemoteSessionCoordinator()
@ObservationIgnored private(set) var activitySession: WorkspaceActivityAttributes.Session
@ObservationIgnored var didChangeState: ((WorkspaceActivityAttributes.Session) -> Void)?
@ObservationIgnored var didRequestClose: ((ID) -> Void)?
private(set) var status = String(localized: AppStrings.Connections.disconnected)
private(set) var connected = false {
didSet {
if !connected { commandMonitor.endConnection() }
guard activitySession.connected != connected else { return }
activitySession.connected = connected
didChangeState?(activitySession)
}
}
private(set) var busy = false
var reconnectState: ReconnectState? { connection.reconnectState }
var journey: ConnectionJourney { connection.journey }
var acceptsTerminalInput: Bool {
!transport.isResumable
|| (!journey.isPresented
&& transportState != .recovering && transportState != .suspended
&& transportState != .failed && transportState != .ended && transportState != .closed)
}
var operationError: String?
private var connectionFailure: String?
private(set) var authenticationPrompt: SSHAuthenticationPrompt?
private var authenticationMode = SSHAuthenticationPrompt.Mode.localIdentity
var authenticationRequired = false
var username = ""
private var authenticationHostID: UUID?
private var temporaryKeys: [UUID: SSHHostKeyIdentity] {
get { connection.temporaryHostKeys }
set { connection.temporaryHostKeys = newValue }
}
private var submissions: [UUID: SSHAuthenticationSubmission] = [:]
private var proxySubmissions: [UUID: SSHProxyAuthenticationSubmission] = [:]
let sftpChannel: MobileSFTPChannelModel
let sftpEdits: SFTPEditService?
let transfers: SFTPTransferCoordinator?
private var transferContext: SFTPTransferContext?
private var transferSnapshot: SSHAdditionalConnectionSnapshot?
private var persistentSessionSnapshot: SSHAdditionalConnectionSnapshot?
var resumeCheckpoint: TerminalTransportCheckpoint?
var requiresTransportRecovery = false
var terminalRecoveryStore: TerminalRecoveryStore = .shared
private var restoringTransport = false
private var resumeUsername: String?
private(set) var transportState: ResumableTerminalState?
var recoveryID: UUID { id }
private var recoveryLease: UUID?
private var recoveryRecord: TerminalRecoveryRecord?
private var transportSuspension: Task<Void, Never>?
private var transport: HostTerminalProtocol { descriptor.kind.terminalProtocol ?? .ssh }
private var connectionPurpose: ConnectionPurpose {
descriptor.kind.terminalProtocol.map(ConnectionPurpose.terminal) ?? .sftpChannel
}
var restorationDescriptor: MobileWorkspaceDescriptor {
var result = descriptor
result.remoteSession = sessionPersistence.attachment
return result
}
private(set) var uploadNotice: WorkspaceUploadNotice?
private var pendingUpload: MobilePendingUpload?
private var uploadPreparationTask: Task<Void, Never>?
private var uploadPreparationID: UUID?
private var uploadEnqueueTask: Task<Void, Never>?
private var uploadEnqueueID: UUID?
private var uploadNoticeTask: Task<Void, Never>?
private var uploadNoticeID: UUID?
private var remoteTransferPreparationTask: Task<Void, Never>?
private var remoteTransferPreparationID: UUID?
private var pendingDownload: MobilePendingDownload?
private var pendingRemoteCopy: MobilePendingRemoteCopy?
let terminalSettings: TerminalSettingsModel
let commandHistory: CommandHistoryModel
let sftpHistory: SFTPHistoryModel
@ObservationIgnored lazy var find = TerminalFindController(
perform: { [weak self] in _ = self?.terminal.performBindingAction($0) },
focus: { [weak self] in self?.terminal.requestFocus() }
)
let commandMonitor = TerminalCommandMonitor()
private var commandObservation: TerminalCommandObservation?
private var activeHistoryID: UUID?
let terminalContext = TerminalContextCoordinator()
private var hasRuntimeContext = false
private var historyCharset: RemoteCharset = .utf8
private var contextHistory: [TerminalContextIdentity: UUID] = [:]
/// Resolved once at the beginning of each connection attempt.
private(set) var isShellIntegrationEnabled = true
private var shellIntegrationAvailable = false
private var isInitialCommandSubmissionReady = false
private var initialCommandStartupTicket: SSHOrdinaryStartupTicket?
private var pendingCommand: MobilePendingInitialCommand?
/// Re-resolved from the Host and Snippet library for each physical terminal shell.
private var pendingConnectionStartupCommand: MobilePendingInitialCommand?
private var commandExcludedFromHistory: String?
private var pendingInitialSFTPPath: String?
private var externalSFTPResourcePath: String?
private var externalSFTPMustBeDirectory = false
private var transientHost: Host?
private var connectionRoute: SSHConnectionRoute?
private var usernameHint: String?
private let connectionOrigin: RemoteConnectionOrigin
var isExternalTemporaryConnection: Bool { !connectionOrigin.allowsPersistence }
var presentationHost: Host? { connectionHost(descriptor.hostID) }
var temporaryLoginAddress: String? {
guard hosts.host(descriptor.hostID) == nil, let host = presentationHost else { return nil }
let connectionProtocol: HostTerminalProtocol = descriptor.kind == .telnet ? .telnet : .ssh
let address = host.displayAddress(for: connectionProtocol)
guard let usernameHint, !usernameHint.isEmpty else { return address }
return "\(usernameHint)@\(address)"
}
private var advisoryTelnetPassword: SSHSecret?
private(set) var pendingExternalPreviewURL: URL?
var pendingInitialCommand: String? { pendingCommand?.command }
private(set) var workingDirectory: String?
var commandGeneratorState: CommandGeneratorState { commandGeneration.state }
private var integratedShell: String?
private var remoteSystemInfo: RemoteSystemInfo?
private var isShellPromptReady = false
private var commandGeneratorRevision: UInt64 = 0
private let commandGeneration = CommandGenerationCoordinator()
private let commandDraft = TerminalCommandDraftController()
private var workingDirectoryObservation: AnyCancellable?
private var bellObservation: TerminalBellObservation?
private let surfaceOwner = MobileTerminalSurface()
var uploadConnectionGeneration: UUID { generation }
func configureTerminalUpload(files: (() -> Void)?, photos: (() -> Void)? = nil) {
surfaceOwner.view.uploadFiles = descriptor.kind.isSSHTerminal ? files : nil
surfaceOwner.view.uploadPhotos = descriptor.kind.isSSHTerminal ? photos : nil
surfaceOwner.view.canUploadFiles = { [weak self] in self?.uploadTargetProblem == nil && self != nil }
}
var uploadTerminalView: UIView? { descriptor.kind.isSSHTerminal ? surfaceOwner.view : nil }
let terminal: TerminalViewState
private let backend: InMemoryTerminalSession
func configureTerminalBell(_ ring: @escaping @MainActor () -> Void) {
guard descriptor.kind != .sftp else { return }
bellObservation = TerminalBellObservation(state: terminal, ring: ring)
}
func readViewportText() -> String? {
backend.readViewportText()
}
private var ssh: SSHPTYChannel?
private var telnet: TelnetConnection?
private var sftp: (any SFTPClientProtocol)?
private var sshEventContinuation: AsyncStream<MobileSSHSessionEvent>.Continuation?
private var sshEventTask: Task<Void, Never>?
private var attempt: Task<Void, Never>?
private var generation = UUID() {
didSet { commandHistory.cancelLoading() }
}
private let hosts: any RemoteSessionHostStore
private let connectionServices: MobileWorkspaceConnectionServices
var securityKeySigning: SecurityKeySigningCoordinator { connection.securityKeySigning }
var keyboardAuthentication: SSHKeyboardInteractiveQueue { connection.keyboardAuthentication }
private let connection: RemoteSessionConnectionController
private var viewport = InMemoryTerminalViewport(columns: 80, rows: 24)
var transferConflictCount: Int {
pendingRemoteCopy?.batch.conflictCount
?? pendingDownload?.batch.conflictCount
?? pendingUpload?.batch.conflictCount
?? 0
}
var hasPendingTransferConflict: Bool { transferConflictCount > 0 }
var transferConflictID: UUID? {
guard hasPendingTransferConflict else { return nil }
return pendingRemoteCopy?.id ?? pendingDownload?.id ?? pendingUpload?.id
}
/// SwiftUI can write the dialog binding before invoking its button action.
/// Let that action consume the request first, and never cancel a replacement.
func dismissTransferConflict(_ id: UUID) {
DispatchQueue.main.async { [weak self] in
guard let self, transferConflictID == id else { return }
cancelPendingUpload()
}
}
var sameDirectoryLaunch: MobileWorkspaceDirectoryLaunch? {
guard connected else { return nil }
switch descriptor.kind {
case .ssh, .mosh, .eternalTerminal:
guard isShellIntegrationEnabled,
shellIntegrationAvailable || (hasRuntimeContext && terminalContext.active?.directory != nil),
let workingDirectory,
!workingDirectory.isEmpty
else { return nil }
return MobileWorkspaceDirectoryLaunch(
hostID: descriptor.hostID,
kind: .sftp,
path: RemotePath.normalized(workingDirectory)
)
case .sftp:
guard !sftpChannel.path.isEmpty, let host = connectionHost(descriptor.hostID),
host.sshEnabled, let transport = host.preferredTerminalProtocol
else { return nil }
return MobileWorkspaceDirectoryLaunch(
hostID: descriptor.hostID,
kind: .terminal(transport),
path: RemotePath.normalized(sftpChannel.path)
)
case .telnet:
return nil
}
}
var uploadDestination: String? {
switch descriptor.kind {
case .ssh, .mosh, .eternalTerminal:
guard uploadTargetProblem == nil, let workingDirectory else { return nil }
return RemotePath.normalized(workingDirectory)
case .sftp:
guard uploadTargetProblem == nil else { return nil }
return sftpChannel.path
case .telnet:
return nil
}
}
var uploadTargetProblem: String? {
switch descriptor.kind {
case .ssh, .mosh, .eternalTerminal:
return TerminalUploadAvailability.problem(
isConnected: connected,
hasTransferConfiguration: transferSnapshot != nil,
isWorkingDirectoryPending: workingDirectory == nil,
workingDirectory: workingDirectory
)
case .sftp:
guard connected, transferSnapshot != nil, sftp != nil else {
return String(localized: AppStrings.Workspace.theSftpChannelIsNotConnected)
}
return nil
case .telnet:
return String(localized: AppStrings.Workspace.telnetDoesNotSupportFileUpload)
}
}
private var connectionResumeIntent: MobileWorkspaceConnectionResumeIntent
init(
descriptor: MobileWorkspaceDescriptor, hosts: any RemoteSessionHostStore,
transfers: SFTPTransferCoordinator? = nil,
sftpEdits: SFTPEditService? = nil,
terminalSettings: TerminalSettingsModel = TerminalSettingsModel(),
connectionServices: MobileWorkspaceConnectionServices = .live,
reconnectController: SessionReconnectController = SessionReconnectController(),
initialCommand: String? = nil,
recordsInitialCommandInHistory: Bool = true,
initialSFTPPath: String? = nil,
transientHost: Host? = nil,
connectionRoute: SSHConnectionRoute? = nil,
usernameHint: String? = nil,
connectionOrigin: RemoteConnectionOrigin = .library,
advisoryTelnetPassword: SSHSecret? = nil,
externalSFTPResourcePath: String? = nil,
externalSFTPMustBeDirectory: Bool = false
) {
self.terminalSettings = terminalSettings
commandHistory = CommandHistoryModel(sessionID: descriptor.id)
sftpHistory = SFTPHistoryModel(sessionID: descriptor.id)
sftpChannel = MobileSFTPChannelModel(history: sftpHistory)
self.transfers = transfers
self.sftpEdits = sftpEdits
self.descriptor = descriptor
self.hosts = hosts
self.transientHost = transientHost
self.connectionRoute = connectionRoute
self.usernameHint = usernameHint
self.connectionOrigin = connectionOrigin
self.advisoryTelnetPassword = advisoryTelnetPassword
self.externalSFTPResourcePath = externalSFTPResourcePath
self.externalSFTPMustBeDirectory = externalSFTPMustBeDirectory
let artwork = HostIconCatalog.resolvedArtwork(for: hosts.host(descriptor.hostID) ?? transientHost)
activitySession = WorkspaceActivityAttributes.Session(
id: descriptor.id, title: descriptor.title, kind: descriptor.kind.rawValue,
connected: false,
iconAsset: artwork.assetName, iconSystemName: artwork.systemImageName)
if connectionOrigin.allowsPersistence, hosts.host(descriptor.hostID) != nil {
activitySession.recovery = .init(
sourceWorkspaceID: descriptor.id, hostID: descriptor.hostID, kind: descriptor.kind.rawValue)
}
pendingCommand = initialCommand.map {
MobilePendingInitialCommand(command: $0, recordsInHistory: recordsInitialCommandInHistory)
}
pendingInitialSFTPPath = initialSFTPPath.flatMap { path in
path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : RemotePath.normalized(path)
}
self.connectionServices = connectionServices
connection = RemoteSessionConnectionController(reconnect: reconnectController)
connectionResumeIntent = .none
let relay = MobileTerminalRelay()
backend = InMemoryTerminalSession(
write: { data in
Task { @MainActor in
await relay.owner?.receiveTerminalWrite(data)
}
},
resize: { size in Task { @MainActor in await relay.owner?.resize(size) } },
suppressesPixelOnlyResizes: true
)
terminal = TerminalViewState(configSource: .none)
terminal.configuration = TerminalSurfaceOptions(backend: .inMemory(backend), resizeThrottleMilliseconds: 35)
let owner = surfaceOwner
terminal.makePlatformView = { owner.view }
relay.owner = self
workingDirectoryObservation = terminal.$workingDirectory.sink { [weak relay] path in
Task { @MainActor in
relay?.owner?.receiveWorkingDirectory(path)
}
}
commandObservation = TerminalCommandObservation(state: terminal, monitor: commandMonitor)
surfaceOwner.bindFind(find)
sessionPersistence.didChangePrompt = { [weak self] pending in
if pending { self?.journey.waitForSessionSelection() } else { self?.journey.resume() }
}
}
@discardableResult
func rename(to proposedTitle: String) -> Bool {
let title = proposedTitle.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty, title != descriptor.title else { return false }
descriptor.title = title
activitySession.title = title
didChangeState?(activitySession)
return true
}
private func makeAuthenticationPrompt() -> SSHAuthenticationPrompt? {
guard authenticationRequired, let host = connectionHost(authenticationHostID ?? descriptor.hostID) else {
return nil
}
if host.proxy != nil, authenticationIsProxy {
let staged = proxySubmissions[host.id]
return SSHAuthenticationPromptResolver.proxy(
for: host, using: hosts, submission: staged,
failureMessage: connectionFailure, mode: authenticationMode,
allowsSaving: connectionOrigin.allowsPersistence)
}
let staged = submissions[host.id]
return SSHAuthenticationPromptResolver.host(
host, using: hosts, submission: staged, username: username,
failureMessage: connectionFailure,
mode: authenticationMode,
allowsSaving: connectionOrigin.allowsPersistence)
}
private var authenticationIsProxy = false
var hostKeyPrompt: SSHHostKeyPrompt? { connection.pendingHostKey }
func submitAuthentication(
identityID: UUID?, username: String, password: String?, passphrase: String?, keyID: UUID?, save: Bool
) {
guard var host = connectionHost(authenticationHostID ?? descriptor.hostID) else { return }
let identity = identityID.flatMap { hosts.identity($0) }
let isProxy = authenticationPrompt?.isProxy == true
let name = ((isProxy ? nil : identity?.username) ?? username)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return }
if isProxy {
let submission = SSHProxyAuthenticationSubmission(
username: name, password: password, identityID: identity?.id)
do {
if save {
host = try hosts.saveProxyAuthentication(
submission, for: host, selectedIdentity: identity,
suppliedPassword: password)
}
proxySubmissions[host.id] = submission
} catch {
connectionFailure = error.localizedDescription
authenticationPrompt = makeAuthenticationPrompt()
return
}
authenticationRequired = false
authenticationIsProxy = false
authenticationPrompt = nil
journey.resume()
connect()
return
}
if let prompt = authenticationPrompt, prompt.requiresPassphrase {
do {
submissions[host.id] = try hosts.passphraseSubmission(
for: prompt, existing: submissions[host.id], passphrase: passphrase, save: save)
} catch {
connectionFailure = error.localizedDescription
authenticationPrompt?.failureMessage = error.localizedDescription
return
}
} else if host.isTailscaleManaged {
if save, !hosts.saveTailscaleUsername(name, hostID: host.id) {
connectionFailure = TailscaleConfigurationError.unavailable.localizedDescription
authenticationPrompt = makeAuthenticationPrompt()
return
}
submissions[host.id] = SSHAuthenticationSubmission(username: name)
} else {
let chosenKey = identity?.keyID ?? keyID
if save {
do {
let value = identity ?? Identity(name: name, username: name, keyID: chosenKey)
try hosts.upsert(value, passwordUpdate: password.map(IdentityPasswordUpdate.replace) ?? .keep)
host.identityID = value.id
hosts.upsert(host)
} catch {
connectionFailure = error.localizedDescription
authenticationPrompt = makeAuthenticationPrompt()
return
}
}
submissions[host.id] = SSHAuthenticationSubmission(
username: name, password: password, passphrase: passphrase,
keyID: chosenKey, identityID: identity?.id)
}
authenticationRequired = false
authenticationPrompt = nil
journey.resume()
connect()
}
func insertTerminalText(_ text: String) {
guard connected else { return }
do { try requireTerminalInputAccess() } catch {
operationError = error.localizedDescription
return
}
_ = terminal.paste(text: text)
terminal.requestFocus()
}
func requireTerminalInputAccess() throws {
if let host = connectionHost(descriptor.hostID) {
try hosts.requireHostAccess(host, protocol: descriptor.kind.terminalProtocol ?? .ssh)
}
}
func generateCommand(
using generator: any CommandGenerating,
readViewport: @MainActor (MobileWorkspace) -> String? = { $0.readViewportText() }
) {
if commandGeneration.isRunning {
cancelCommandGeneration()
return
}
guard descriptor.kind.isSSHTerminal, canGenerateFromCurrentDraft else {
showCommandGenerationFailure(
String(
localized:
AppStrings.Workspace.commandGeneratorShellRequirement
)
)
return
}
guard let workingDirectory, !workingDirectory.isEmpty, let integratedShell else {
showCommandGenerationFailure(
String(localized: AppStrings.Workspace.theFocusedShellHasNotReportedItsCurrentDirectory)
)
return
}
// Capture before the shell draft request can redraw or scroll the terminal.
let viewport = readViewport(self).map { CommandGeneratorContext.Viewport(text: $0) }
let connectionGeneration = generation
let revision = commandGeneratorRevision
let includesSystemInfo = isShellIntegrationEnabled
commandGeneration.start(
targetCount: 1,
readContext: { [weak self] in
guard let self else { throw CancellationError() }
let draft = try await requestCurrentDraft()
return CommandGeneratorContext(
draft: draft,
shell: integratedShell,
cwd: workingDirectory,
remoteSystemInfo: includesSystemInfo ? remoteSystemInfo : nil,
viewport: viewport
)
},
isCurrent: { [weak self] in
self?.commandGenerationSnapshotIsCurrent(
connectionGeneration: connectionGeneration,
revision: revision,
shell: integratedShell,
workingDirectory: workingDirectory
) == true
},
generate: { try await generator.generate($0) },
insert: { [weak self] command in await self?.replaceCurrentDraft(with: command) }
)
}
func cancelCommandGeneration() {
guard commandGeneration.isRunning || commandDraft.hasPendingRequest else { return }
commandGeneration.cancel()
commandDraft.cancel(CancellationError())
}
func receiveWorkingDirectory(_ path: String?) {
guard !descriptor.kind.isSSHTerminal || isShellIntegrationEnabled else { return }
guard !hasRuntimeContext else { return }
let path = path?.isEmpty == false ? path : nil
guard workingDirectory != path else { return }
workingDirectory = path
cancelPendingUpload()
invalidateCommandGeneration()
}
var canGenerateFromCurrentDraft: Bool {
connected && isShellIntegrationEnabled && shellIntegrationAvailable && isShellPromptReady
&& TerminalCommandDraftController.supportedShells.contains(integratedShell ?? "")
&& (!hasRuntimeContext || terminalContext.ticket(requiring: [.readDraft, .replaceDraft]) != nil)
}
func receiveInteractiveTerminalInput(_ data: Data) async {
guard acceptsTerminalInput else { return }
await receiveTerminalWrite(data)
}
private func receiveTerminalWrite(_ data: Data) async {
// Native interaction is disabled under Journey; this callback also carries
// protocol replies, which must still reach the existing transport.
invalidateCommandGeneration()
if descriptor.kind == .telnet {
telnet?.write(data)
} else if let ssh {
terminalContext.noteInput()
await connectionServices.writeSSH(ssh, data)
}
}
private func writeSSHDirectly(_ data: Data) async {
guard acceptsTerminalInput else { return }
terminalContext.noteInput()
guard let ssh else { return }
await connectionServices.writeSSH(ssh, data)
}
private func requestCurrentDraft() async throws -> String {
if hasRuntimeContext {
guard let ticket = terminalContext.ticket(requiring: .readDraft),
let context = terminalContext.active, let ssh
else { throw TerminalDraftError.unavailable }
let value = try await ssh.contextDraft(context)
guard terminalContext.isCurrent(ticket, requiring: .readDraft) else {
throw TerminalDraftError.unavailable
}
return value
}
return try await commandDraft.read(isAvailable: canGenerateFromCurrentDraft) { [weak self] data in
await self?.writeSSHDirectly(data)
}
}
private func replaceCurrentDraft(with command: String) async {
if hasRuntimeContext {
guard let ticket = terminalContext.ticket(requiring: .replaceDraft),
let context = terminalContext.active, let ssh,
terminalContext.isCurrent(ticket, requiring: .replaceDraft)
else { return }
do {
_ = try await ssh.contextDraft(context, replacing: command)
} catch {
if terminalContext.isCurrent(ticket, requiring: .replaceDraft) {
receiveRuntimeContext(.init(status: .unknown))
}
showCommandGenerationFailure(TerminalDraftError.unavailable.localizedDescription)
}
return
}
await writeSSHDirectly(commandDraft.replacementPayload(for: command))
terminal.requestFocus()
}
func validatedSameDirectoryLaunch(_ expected: MobileWorkspaceDirectoryLaunch) async
-> MobileWorkspaceDirectoryLaunch?
{
guard sameDirectoryLaunch == expected else { return nil }
do {
if hasRuntimeContext, let context = terminalContext.active, let ssh {
let generation = terminalContext.generation
try await ssh.confirmContext(context)
guard terminalContext.generation == generation, terminalContext.active == context else { return nil }
} else {
try await confirmDirectoryContext(terminalContext.ticket(requiring: .directory))
}
return sameDirectoryLaunch == expected ? expected : nil
} catch {
return nil
}
}
private func confirmDirectoryContext(_ ticket: TerminalContextCoordinator.Ticket?) async throws {
guard hasRuntimeContext else { return }
guard let ticket, terminalContext.isCurrent(ticket, requiring: .directory),
let context = terminalContext.active, let ssh
else { throw TerminalDraftError.unavailable }
do {
try await ssh.confirmContext(context)
guard terminalContext.isCurrent(ticket, requiring: .directory) else { throw TerminalDraftError.unavailable }
} catch {
if terminalContext.isCurrent(ticket, requiring: .directory) {
receiveRuntimeContext(.init(status: .unknown))
}
throw error
}
}
private func commandGenerationSnapshotIsCurrent(
connectionGeneration: UUID,
revision: UInt64,
shell: String,
workingDirectory: String
) -> Bool {
generation == connectionGeneration
&& commandGeneratorRevision == revision
&& integratedShell == shell
&& self.workingDirectory == workingDirectory
&& canGenerateFromCurrentDraft
}
private func invalidateCommandGeneration() {
commandGeneratorRevision &+= 1
if commandGeneration.isRunning { cancelCommandGeneration() }
}
private func resetCommandGeneratorShellState() {
terminalContext.reset()
if hasRuntimeContext { commandHistory.selectContext(nil, generation: terminalContext.generation) }
hasRuntimeContext = false
contextHistory.removeAll()
cancelCommandGeneration()
integratedShell = nil
remoteSystemInfo = nil
isShellPromptReady = false
shellIntegrationAvailable = false
isInitialCommandSubmissionReady = false
initialCommandStartupTicket = nil
pendingConnectionStartupCommand = nil
workingDirectory = nil
commandExcludedFromHistory = nil
commandDraft.cancel()
commandGeneratorRevision &+= 1
}
private func showCommandGenerationFailure(_ message: String) {
commandGeneration.fail(message)
}
func receiveShellEvent(_ event: SSHShellIntegrationEvent) {
guard isShellIntegrationEnabled else {
activeHistoryID = nil
resetCommandGeneratorShellState()
return
}
if case .ordinaryStartupUnavailable(let ticket) = event {
guard sessionPersistence.attachment == nil else { return }
guard let ticket else {
pendingCommand = nil
pendingConnectionStartupCommand = nil
initialCommandStartupTicket = nil
return
}
initialCommandStartupTicket = ticket
isInitialCommandSubmissionReady = true
submitPendingInitialCommandIfReady()
return
}
if case .runtime(let update) = event {
receiveRuntimeContext(update)
return
}
guard !hasRuntimeContext else { return }
commandMonitor.receiveShellEvent(event)
switch event {
case .runtime, .ordinaryStartupUnavailable: break
case .available(let shell, let systemInfo, _):
integratedShell = shell
remoteSystemInfo = systemInfo
shellIntegrationAvailable = true
// Installation completes by entering the shell's prompt hook. Treat
// that authenticated boundary as ready even if the following
// TermindPrompt OSC is swallowed by the UIKit terminal path.
isShellPromptReady = true
isInitialCommandSubmissionReady = true
invalidateCommandGeneration()
recordRemoteSystemInfo(systemInfo)
submitPendingInitialCommandIfReady()
case .systemInfo(let systemInfo):
remoteSystemInfo = systemInfo
invalidateCommandGeneration()
recordRemoteSystemInfo(systemInfo)
case .homeResolutionUnavailable:
isInitialCommandSubmissionReady = true
submitPendingInitialCommandIfReady()
case .promptReady:
if !isShellPromptReady {
isShellPromptReady = true
invalidateCommandGeneration()
}
isInitialCommandSubmissionReady = true
submitPendingInitialCommandIfReady()
case .commandStarted(let command):
guard shellIntegrationAvailable else { return }
let command = CommandHistoryEntry.normalizingLineContinuations(in: command)
isShellPromptReady = false
invalidateCommandGeneration()
if command == commandExcludedFromHistory {
commandExcludedFromHistory = nil
activeHistoryID = nil
} else {
activeHistoryID = commandHistory.record(command)?.id
}
case .commandFinished(let status, let duration):
// CommandFinished is emitted from the prompt hook immediately before
// cwd and prompt reporting, so it is also a trustworthy recovery
// point when the separate prompt marker never reaches mobile state.
if !isShellPromptReady, shellIntegrationAvailable {
isShellPromptReady = true
invalidateCommandGeneration()
}
isInitialCommandSubmissionReady = true
submitPendingInitialCommandIfReady()
guard let id = activeHistoryID else { return }
commandHistory.recordCompletion(
for: id, exitStatus: status, duration: duration)
activeHistoryID = nil
case .draft(let draft):
commandDraft.receive(draft)
}
}
private func receiveRuntimeContext(_ update: TerminalRuntimeUpdate) {
hasRuntimeContext = true
let previous = terminalContext.revision
let events = terminalContext.receive(update, generation: terminalContext.generation)
loadContextHistory()
commandMonitor.receiveRuntimeEvents(events)
if previous != terminalContext.revision {
commandDraft.cancel()
cancelPendingUpload()
invalidateCommandGeneration()
}
workingDirectory = terminalContext.directory
integratedShell = terminalContext.active?.shell
shellIntegrationAvailable = terminalContext.active?.capabilities.contains(.commandLifecycle) == true
isShellPromptReady = terminalContext.active?.promptReady == true
for event in events {
switch event.payload {
case .commandStarted(let command):
if command == commandExcludedFromHistory {
commandExcludedFromHistory = nil
} else {
contextHistory[event.context] = commandHistory.record(command)?.id
}
case .commandFinished(let status, let duration):
if let id = contextHistory.removeValue(forKey: event.context) {
commandHistory.recordCompletion(for: id, exitStatus: status, duration: duration)
}
case .draft(_, let value):
if terminalContext.active?.identity == event.context { commandDraft.receive(value) }
default: break
}
}
if update.status == .ordinary, isShellPromptReady {
isInitialCommandSubmissionReady = true
submitPendingInitialCommandIfReady()
}
}
private func loadContextHistory() {
let context = terminalContext.active
let source = commandHistory.selectContext(context, generation: terminalContext.generation)
guard descriptor.kind == .ssh, let context, let source, let ssh, commandHistory.loadError == nil else {
return
}
commandHistory.load(
using: RemoteShellHistoryLoader(channel: ssh, charset: historyCharset, context: context), source: source)
}
private func restoreTransport() {
guard !restoringTransport else { return }
restoringTransport = true
let token = generation
attempt = Task { [weak self] in
guard let self else { return }
defer { restoringTransport = false }
do {
guard let host = connectionHost(descriptor.hostID), host.isEnabled(transport) else {
throw TerminalTransportError.invalidCheckpoint
}
let record = try await terminalRecoveryStore.record(recoveryID)
let currentUsername =
host.isTailscaleManaged
? hosts.savedTailscaleUsername(for: host) : hosts.identity(for: host)?.username
guard record.transport == transport,
currentUsername == nil || currentUsername == record.username
else { throw TerminalTransportError.invalidCheckpoint }
try Task.checkCancellation()
guard generation == token else { return }
let checkpoint = try await terminalRecoveryStore.consume(recoveryID, host: host)
try Task.checkCancellation()
guard generation == token else { return }
resumeCheckpoint = checkpoint
resumeUsername = record.username
requiresTransportRecovery = false
pendingCommand = nil
pendingConnectionStartupCommand = nil
connect()
} catch {
guard generation == token, !Task.isCancelled else { return }
if sessionPersistence.attachment != nil {
do {
try await terminalRecoveryStore.discard(recoveryID)
guard generation == token, !Task.isCancelled else { return }
// tmux has its own server-side identity; validate it over a new transport,
// never replay the launch command or silently replace a missing session.
requiresTransportRecovery = false
resumeCheckpoint = nil
resumeUsername = nil
sessionPersistence.requiresSelection = true
pendingCommand = nil
pendingConnectionStartupCommand = nil
connect()
return
} catch {
guard generation == token, !Task.isCancelled else { return }
}
}
transportState = .failed
connectionFailure = TerminalTransportError.invalidCheckpoint.localizedDescription
status = connectionFailure ?? ""
busy = false
presentTransportRecovery(.failed, message: connectionFailure ?? "")
}
}
}
func connect() {
if transportSuspension != nil {
resumeAfterBackground()
return
}
if requiresTransportRecovery {
restoreTransport()
return
}
if transport.isResumable, let ssh {
Task { try? await ssh.retryTransport() }
return
}
guard !busy, !connected else { return }
if !descriptor.kind.isSSHTerminal, sessionPersistence.requiresSelection {
busy = true
presentConnectionJourney(mode: .connection)
attempt = Task { [weak self] in
guard let self else { return }
do {
try await sessionPersistence.confirmPlainConnection()
busy = false
connect()
} catch {
busy = false
status = String(localized: AppStrings.Connections.disconnected)
}
}
return
}
cancelPendingUpload()
stopSSHEventConsumer()
let reconnecting = connection.isRecovering
let token = UUID()
generation = token
transportState = nil
commandMonitor.beginConnection()
if descriptor.kind.isSSHTerminal { commandMonitor.requireScopedEvents() }
activeHistoryID = nil
resetCommandGeneratorShellState()
operationError = nil
busy = true
connectionFailure = nil
status = String(
localized: reconnecting ? AppStrings.Connections.reconnecting : AppStrings.Connections.connecting)
guard let host = connectionHost(descriptor.hostID) else {
let message = String(localized: AppStrings.Connections.theHostIsNoLongerAvailable)
connection.stopRecovery()
connectionFailure = message
status = String(localized: AppStrings.Connections.disconnected)
busy = false
journey.fail(message)
return
}
guard host.isEnabled(transport) else {
busy = false
connectionFailure = TerminalTransportError.unsupportedRoute.localizedDescription
journey.fail(connectionFailure ?? "")
return
}
if descriptor.kind == .telnet {
connectTelnet(host: host, token: token, reconnecting: reconnecting)
return
}
isShellIntegrationEnabled =
descriptor.kind != .mosh
&& host.shellIntegrationMode.isEnabled(
globalSetting: terminalSettings.preferences.shellIntegration)
if resumeCheckpoint == nil, descriptor.kind.isSSHTerminal, pendingCommand == nil,
let snippetID = host.startupSnippetID,
let body = hosts.snippet(snippetID)?.body,
!body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
pendingConnectionStartupCommand = MobilePendingInitialCommand(
command: body, recordsInHistory: true
)
}
let route = connectionRoute?.hosts ?? hosts.route(for: host)
let authenticationRoute = resumeCheckpoint == nil ? route : (transport == .mosh ? [] : Array(route.dropLast()))
journey.begin(
host: host, route: route,
purpose: connectionPurpose,
username: usernameHint ?? hosts.identity(for: host)?.username,
mode: reconnecting ? .reconnection : .connection)
if resumeCheckpoint != nil, transport == .mosh {
journey.updateTransportRecovery(
.recovering, message: ConnectionJourney.TransportRecovery.recovering.statusMessage)
}
if resumeCheckpoint == nil, host.isTailscaleManaged, submissions[host.id] == nil, usernameHint == nil,
hosts.savedTailscaleUsername(for: host) == nil
{
authenticationRequired = true
authenticationIsProxy = false
authenticationMode = .localIdentity
authenticationHostID = host.id
username = hosts.tailscaleUsername(for: host)
authenticationPrompt = makeAuthenticationPrompt()
journey.waitForTailscaleUsername(at: host.id)
busy = false
return
}
if let proxyHost = proxySubmissions.firstHostRequiringProxyPassword(in: authenticationRoute) {
authenticationRequired = true
authenticationIsProxy = true
authenticationMode = .localIdentity
authenticationHostID = proxyHost.id
username = proxySubmissions[proxyHost.id]?.username ?? proxyHost.proxy?.username ?? ""
authenticationPrompt = makeAuthenticationPrompt()
journey.waitForProxyAuthentication(at: proxyHost.id)
busy = false
return
}
if let passwordHost = submissions.firstHostRequiringPassword(in: authenticationRoute) {
authenticationRequired = true
authenticationMode = .localIdentity
authenticationHostID = passwordHost.id
username =
submissions[passwordHost.id]?.username
?? hosts.identity(for: passwordHost)?.username ?? ""
authenticationPrompt = makeAuthenticationPrompt()
journey.waitForLocalIdentity(at: passwordHost.id)
busy = false
return
}
if let passphraseHost = submissions.firstHostRequiringPassphrase(
in: authenticationRoute, key: hosts.key, requiresEntry: hosts.requiresPassphraseEntry
) {
authenticationRequired = true
authenticationMode = .localIdentity
authenticationHostID = passphraseHost.id
username =
submissions[passphraseHost.id]?.username
?? hosts.identity(for: passphraseHost)?.username ?? ""
authenticationPrompt = makeAuthenticationPrompt()
journey.waitForLocalPassphrase(at: passphraseHost.id)
busy = false
return
}
var attemptSubmissions = submissions.takeAuthenticationSecrets()
if let usernameHint, attemptSubmissions[host.id] == nil {
attemptSubmissions[host.id] = SSHAuthenticationSubmission(username: usernameHint)
}
let attemptProxySubmissions = proxySubmissions.takeProxyAuthenticationSecrets()
let progressHandler = connection.progressHandler(attemptID: token) { [weak self] in
self?.generation == $0
}
connection.clearHostKeyPrompt()
attempt = Task { [weak self, weak hosts] in
guard let self, let hosts else { return }
defer { if generation == token { busy = false } }
guard generation == token, !Task.isCancelled else { return }
do {
try await hosts.resolveHostAccess(host)
var resolvedConfiguration: SSHConnectionConfiguration
if resumeCheckpoint != nil, transport == .mosh {
resolvedConfiguration = SSHConnectionConfiguration(
hostID: host.id, host: host.host, port: host.sshPort,
username: resumeUsername ?? "", credentials: [], expectedHostKeys: [])
} else {
let preparation = try hosts.prepareSSHConnection(
for: host, submissions: attemptSubmissions,
proxySubmissions: attemptProxySubmissions,
temporaryHostKeys: temporaryKeys)
resolvedConfiguration = try await preparation.resolve(
discoveryOnly: resumeCheckpoint != nil, trustPolicy: .allowChallenge)
}
if resolvedConfiguration.accessHostIDs.isEmpty {
resolvedConfiguration.accessHostIDs = hosts.route(for: host).map(\.id)
resolvedConfiguration.accessCheck = hosts.connectionAccessCheck(
for: resolvedConfiguration.accessHostIDs)
}
resolvedConfiguration.beforeConnect = hosts.connectionWakeAction(
for: resolvedConfiguration.accessHostIDs)
if let resumeUsername { resolvedConfiguration.username = resumeUsername }
let config = resolvedConfiguration
guard generation == token, !Task.isCancelled else { return }
var request = SSHConnectionRequest(
configuration: config,
authorizeHostKey: { [weak self] challenge in
guard let self else { throw CancellationError() }
return await self.connection.authorizeHostKey(challenge)
},
keyboardInteractive: { [weak self] challenge in
guard let self, await self.generation == token else { throw CancellationError() }
return try await self.connection.answerKeyboardInteractive(challenge)
},
securityKeySigning: { [weak self] request in
guard let self, await self.generation == token else { throw CancellationError() }
return try await self.connection.signWithSecurityKey(request)
},
hostKeyConstraints: connectionOrigin.hostKeyConstraints
)
request.agentForwardingContextID = id
var externalFile: RemoteFile?
if descriptor.kind.isSSHTerminal {
let backend = backend
let handlers = startSSHEventConsumer(for: token)
let session = connectionServices.makePTYChannel(
{ backend.receive($0) },
handlers.exit,
handlers.shellIntegration)
ssh = session
historyCharset = host.charset
if transport.isResumable {
recoveryRecord = TerminalRecoveryRecord(
id: recoveryID, host: host, username: config.username, transport: transport)
recoveryLease = await terminalRecoveryStore.register(recoveryID)
guard generation == token else { throw CancellationError() }
let snapshot = hosts.additionalConnectionSnapshot(from: config)
let hostKeyAuthorizer = request.authorizeHostKey
let keyboardInteractive = request.keyboardInteractive
let securityKeySigning = request.securityKeySigning
let hostKeyConstraints = request.hostKeyConstraints
let needsControlRoute = transport == .mosh && resumeCheckpoint != nil
let controlUsername = config.username
let requestFactory: @Sendable (Bool) async throws -> SSHConnectionRequest = {
[weak self, weak hosts] authenticatingTarget in
guard let self, let hosts, await self.generation == token else {
throw CancellationError()
}
let selectedSnapshot =
if authenticatingTarget && needsControlRoute {
try await hosts.resumableControlSnapshot(for: host, username: controlUsername)
} else {
snapshot
}
return try await hosts.resumableRouteRequest(
from: selectedSnapshot, authorizeHostKey: hostKeyAuthorizer,
keyboardInteractive: keyboardInteractive, securityKeySigning: securityKeySigning,
hostKeyConstraints: hostKeyConstraints, authenticatingTarget: authenticatingTarget)
}
await session.configureTransport(
transport, options: host.terminalTransports, checkpoint: resumeCheckpoint,
requestFactory: requestFactory,
stateChanged: { [weak self] state in
Task { @MainActor in
guard let self, self.generation == token else { return }
self.receiveTransportState(state)
}
},
routeChanged: { [weak self] route in
Task { @MainActor in
guard let self, self.generation == token else { return }
self.receiveTransportRoute(route)
}
})
}
await session.configureEnvironment(host.environmentVariables) { variables in
let list = variables.joined(separator: ", ")
let warning = String(localized: AppStrings.Terminal.rejectedEnvironmentVariables(list))
backend.receive(Data("\r\n⚠︎ \(warning)\r\n".utf8))
}
let persistence = sessionPersistence
let explicit = pendingCommand?.command
let startup = explicit ?? pendingConnectionStartupCommand?.command
let integration = isShellIntegrationEnabled
if host.sessionPersistence.enabled || persistence.attachment != nil || persistence.requiresSelection
{
persistence.didChangeAttachment = { [weak self] reference in
guard let self, self.generation == token else { return }
if reference != nil {
self.pendingCommand = nil
self.pendingConnectionStartupCommand = nil
}
self.activitySession.recovery?.remoteSession = reference.flatMap {
try? JSONEncoder().encode($0)
}
self.didChangeState?(self.activitySession)
}
await session.configureStartup { execute in
try await persistence.prepare(
preferences: host.sessionPersistence, registry: .live, execute: execute,
hostID: host.id, endpoint: "\(host.host):\(host.sshPort)", username: config.username,
startupCommand: startup, requiresNewSession: explicit != nil,
shellIntegration: integration, environment: host.environmentVariables)
}
}
try await connectionServices.connectSSH(
session,
request,
isShellIntegrationEnabled,
UInt16(clamping: viewport.columns),
UInt16(clamping: viewport.rows),
progressHandler
)
if resumeCheckpoint != nil {
pendingCommand = nil
pendingConnectionStartupCommand = nil
resumeCheckpoint = nil
}
} else {
let client = try await connectionServices.connectSFTP(
request, progressHandler,
{ [weak self] message in
Task { @MainActor in
guard let self, self.generation == token else { return }
self.handleConnectionFailure(message)
}
})
guard generation == token, !Task.isCancelled else {
await client.disconnect()
return
}
sftp = client
let homeDirectoryResult: Result<String, any Error>
do {
homeDirectoryResult = .success(try await client.homeDirectory())
} catch {
homeDirectoryResult = .failure(error)
}
guard generation == token, !Task.isCancelled else { return }
let initialPath: String
if reconnecting {
initialPath = sftpChannel.path
} else if let requested = externalSFTPResourcePath {
let resolved: String
if requested == "/~" || requested.hasPrefix("/~/") {
let home = RemotePath.normalized(try homeDirectoryResult.get())
resolved = requested == "/~" ? home : RemotePath.join(home, String(requested.dropFirst(3)))
} else {
resolved = RemotePath.normalized(requested)
}
guard let resource = try await client.attributes(at: resolved) else {
throw SFTPError.operation(
String(localized: AppStrings.SFTP.theRemoteFileIsNoLongerAvailable))
}
if resource.isDirectory {
initialPath = resource.path
} else {
guard !externalSFTPMustBeDirectory else {
throw SFTPError.operation(
String(localized: AppStrings.SFTP.theSftpLinkRequiresADirectoryButItsPathIsAFile))
}
initialPath = RemotePath.parent(of: resource.path)
externalFile = resource
}
externalSFTPResourcePath = nil
} else if let pendingInitialSFTPPath {
initialPath = pendingInitialSFTPPath
} else if host.sftpChannelInitialPath.isEmpty {
initialPath = try homeDirectoryResult.get()
} else {
initialPath = host.sftpChannelInitialPath
}
try await sftpChannel.attach(
fileSystem: client,
path: initialPath,
homeDirectory: try? homeDirectoryResult.get(),
connectionGeneration: token,
handlesConnectionError: { [weak self] error in
self?.handleSFTPConnectionError(error, connection: token) == true
},
didChangePath: { [weak self] in
self?.cancelPendingUpload()
}
)
pendingInitialSFTPPath = nil
journey.receive(.connected(host.id))
}
guard generation == token, !Task.isCancelled, transportState != .ended else { return }
if transportState == .recovering || transportState == .suspended || transportState == .failed { return }
let additionalSnapshot = hosts.additionalConnectionSnapshot(from: config)
transferSnapshot = additionalSnapshot
persistentSessionSnapshot = additionalSnapshot
if let sftp {
transferContext = SFTPTransferContext(
snapshot: additionalSnapshot, hostStore: hosts, channel: sftp)
}
submissions.removeAll()
proxySubmissions.removeAll()
let wasReconnecting = connection.markConnected()
connected = true
if descriptor.kind == .ssh, let ssh {
if hasRuntimeContext, terminalContext.active?.historySource != nil {
loadContextHistory()
} else {
commandHistory.load(
using: RemoteShellHistoryLoader(channel: ssh, charset: host.charset), source: nil)
}
}
status = String(localized: AppStrings.Connections.connected)
if journey.phase != .succeeded { journey.succeed() }
if sessionPersistence.attachment != nil {
pendingCommand = nil
pendingConnectionStartupCommand = nil
}
if wasReconnecting, descriptor.kind == .ssh, sessionPersistence.attachment == nil {
appendTerminalNotice(String(localized: AppStrings.Terminal.reconnectedANewRemoteShellHasStarted))
}
if descriptor.kind.isSSHTerminal {
if !isShellIntegrationEnabled {
isInitialCommandSubmissionReady = true
}
submitPendingInitialCommandIfReady()
} else if let externalFile {
_ = preview(externalFile) { [weak self] url in
self?.pendingExternalPreviewURL = url
}
}
} catch {
guard generation == token else { return }
if resumeCheckpoint != nil, ssh != nil {
// Once a restored engine could have transmitted, never reuse its saved counters.
resumeCheckpoint = nil
requiresTransportRecovery = true
transportState = .failed
}
stopSSHEventConsumer()
connectionFailure =
error is CancellationError
? String(localized: AppStrings.Connections.connectionCancelled) : error.localizedDescription
let managedSFTPFailure =
host.isTailscaleManaged && descriptor.kind == .sftp
&& !(error is CancellationError)
if managedSFTPFailure {
connectionFailure = String(
localized: AppStrings.Tailscale.sftpFailure(
username: submissions[host.id]?.username ?? usernameHint
?? hosts.tailscaleUsername(for: host),
address: host.host, detail: error.localizedDescription))
authenticationRequired = false
authenticationPrompt = nil
}
var requiresUserInteraction = false
var preventsAutomaticReconnect =
error is CancellationError || managedSFTPFailure || transport.isResumable
if let sshError = error as? TermindSSHError, !managedSFTPFailure {
switch sshError {
case .missingCredential, .missingUsername, .keyPassphraseRequired, .authenticationFailed:
authenticationRequired = true
default: authenticationRequired = sshError.shouldRequestAuthentication
}
if authenticationRequired {
requiresUserInteraction = true
connection.pauseForUserInteraction()
authenticationMode =
sshError.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity
authenticationHostID = sshError.routeHostID ?? descriptor.hostID
authenticationIsProxy = sshError.isProxyAuthentication
if let endpoint = connectionHost(authenticationHostID ?? descriptor.hostID) {
username =
authenticationIsProxy
? (proxySubmissions[endpoint.id]?.username ?? endpoint.proxy?.username ?? "")
: (submissions[endpoint.id]?.username ?? hosts.identity(for: endpoint)?.username ?? "")
}
authenticationPrompt = makeAuthenticationPrompt()
if authenticationIsProxy {
journey.waitForProxyAuthentication(
at: authenticationHostID ?? descriptor.hostID,
failure: error.localizedDescription)
} else if authenticationPrompt?.usernameOnly == true {
journey.waitForTailscaleUsername(
at: authenticationHostID ?? descriptor.hostID, failure: error.localizedDescription)
} else if authenticationPrompt?.requiresPassphrase == true {
journey.waitForLocalPassphrase(
at: authenticationHostID ?? descriptor.hostID, failure: error.localizedDescription)
} else if sshError.isServerAuthenticationChallenge {
journey.waitForAuthentication(
at: authenticationHostID ?? descriptor.hostID, failure: error.localizedDescription)
} else {
journey.waitForLocalIdentity(
at: authenticationHostID ?? descriptor.hostID, failure: error.localizedDescription)
}
}
if sshError.isHostKeyChanged || sshError.shouldRequestHostKey {
preventsAutomaticReconnect = true
}
switch sshError {
case .untrustedHost, .mismatchedHostKey:
preventsAutomaticReconnect = true
default:
break
}
}
if !requiresUserInteraction {
if requiresTransportRecovery, transport.isResumable {
connection.stopRecovery()
presentTransportRecovery(.failed, message: connectionFailure ?? error.localizedDescription)
} else if (connection.hasConnectedOnce || reconnecting) && !preventsAutomaticReconnect {
scheduleReconnect(after: error.localizedDescription)
} else {
connection.stopRecovery()
journey.fail(connectionFailure ?? error.localizedDescription)
status = String(localized: AppStrings.Connections.disconnected)
}
}
await ssh?.disconnect()
if let transferContext { await transferContext.retire() } else { await sftp?.disconnect() }
transferContext = nil
ssh = nil
sftp = nil
sftpChannel.detach()
}
}
}
private func connectTelnet(host: Host, token: UUID, reconnecting: Bool) {
guard host.telnetEnabled else {
busy = false
let message = String(localized: AppStrings.Connections.telnetIsDisabledForThisHost)
connectionFailure = message
status = String(localized: AppStrings.Connections.disconnected)
journey.fail(message)
return
}
journey.beginTelnet(
host: host,
username: usernameHint ?? hosts.identity(for: host, protocol: .telnet)?.username,
mode: reconnecting ? .reconnection : .connection)
let terminalBackend = backend
let session = TelnetConnection(
output: { data in terminalBackend.receive(data) },
event: { [weak self] event in
Task { @MainActor in
guard let self, self.generation == token else { return }
self.journey.receive(event)
if case .connected = event {
_ = self.connection.markConnected()
self.connected = true
self.busy = false
self.status = String(localized: AppStrings.Connections.connected)
self.journey.succeed()
}
}
},
closed: { [weak self] message in
Task { @MainActor in
guard let self, self.generation == token else { return }
self.telnet = nil
let message = message ?? String(localized: AppStrings.Terminal.theTelnetConnectionClosed)
if self.connection.hasConnectedOnce {
self.handleConnectionFailure(message)
} else {
self.busy = false
self.connected = false
self.status = String(localized: AppStrings.Connections.disconnected)
self.journey.fail(message)
}
}
})
telnet?.disconnect()
telnet = session
attempt = Task { [weak self, weak hosts] in
guard let self, let hosts else { return }
do {
try await hosts.resolveHostAccess(host, protocol: .telnet)
var configuration = try hosts.telnetConnectionConfiguration(for: host)
if let usernameHint { configuration.username = usernameHint }
if let password = self.advisoryTelnetPassword {
configuration.password = password
self.advisoryTelnetPassword = nil
}
try await session.connect(
using: configuration,
columns: UInt16(clamping: viewport.columns),
rows: UInt16(clamping: viewport.rows))
} catch is CancellationError {
guard generation == token else { return }
busy = false
status = String(localized: AppStrings.Connections.disconnected)
} catch {
guard generation == token else { return }
telnet = nil
busy = false
connectionFailure = error.localizedDescription
status = String(localized: AppStrings.Connections.disconnected)
journey.fail(error.localizedDescription)
}
}
}
/// Consumes the explicit launch command once, then the Startup Snippet that
/// was freshly resolved for this physical terminal shell.
func consumePendingInitialCommand() -> String? {
let command: MobilePendingInitialCommand
if let explicit = pendingCommand {
command = explicit
pendingCommand = nil
} else if let startup = pendingConnectionStartupCommand {
command = startup
pendingConnectionStartupCommand = nil
} else {
return nil
}
if !command.recordsInHistory {
commandExcludedFromHistory = CommandHistoryEntry.normalizingLineContinuations(
in: command.command
)
}
return command.command
}
private func submitPendingInitialCommandIfReady() {
guard descriptor.kind.isSSHTerminal, connected, isInitialCommandSubmissionReady,
let session = ssh, let command = consumePendingInitialCommand()
else { return }
let token = generation
let data = Data((command + "\r").utf8)
let startupTicket = initialCommandStartupTicket
initialCommandStartupTicket = nil
Task { [weak self] in
guard let self, generation == token, connected, ssh === session else { return }
if let startupTicket {
await connectionServices.submitInitialCommand(session, data, startupTicket)
} else {
await connectionServices.writeSSH(session, data)
}
}
}
func retryReconnectNow() {
if transportSuspension != nil {
resumeAfterBackground()
return
}
if transport.isResumable {
guard transportState == .recovering, let ssh else { return }
let token = generation
Task { [weak self] in
do {
try await ssh.retryTransport()
} catch {
guard let self, self.generation == token else { return }
self.receiveTransportState(.failed, failure: error.localizedDescription)
}
}
return
}
connection.reconnect.retryNow()
}
func stopReconnecting() {
guard let reconnectState = connection.stopRecovery() else { return }
cancelPendingUpload()
stopSSHEventConsumer()
resetCommandGeneratorShellState()
connectionResumeIntent = .none
generation = UUID()
attempt?.cancel()
attempt = nil
let oldSSH = ssh
let oldTelnet = telnet
let oldSFTP = sftp
let oldTransferContext = transferContext
transferContext = nil
ssh = nil
telnet = nil
sftp = nil
sftpChannel.detach()
busy = false
connected = false
transferSnapshot = nil
status = String(localized: AppStrings.Connections.disconnected)
connectionFailure = reconnectState.lastError
journey.fail(reconnectState.lastError)
Task {
await oldSSH?.disconnect()
oldTelnet?.disconnect()
if let oldTransferContext { await oldTransferContext.retire() } else { await oldSFTP?.disconnect() }
}
}
func suspendForBackgroundExpiration() {
if transport.isResumable, ssh != nil {
suspendResumableTransport()
return
}
guard connectionResumeIntent != .foreground, hasActiveConnectionFlow else { return }
cancelPendingUpload()
stopSSHEventConsumer()
resetCommandGeneratorShellState()
let reconnecting = connection.hasConnectedOnce
connectionResumeIntent = .foreground
generation = UUID()
attempt?.cancel()
attempt = nil
let oldSSH = ssh
let oldTelnet = telnet
let oldSFTP = sftp
let oldTransferContext = transferContext
transferContext = nil
ssh = nil
telnet = nil
sftp = nil
sftpChannel.detach()
busy = false
connected = false
transferSnapshot = nil
activeHistoryID = nil
authenticationRequired = false
authenticationPrompt = nil
connection.clearHostKeyPrompt()
temporaryKeys.removeAll()
submissions.removeAll()
proxySubmissions.removeAll()
connectionFailure = nil
operationError = nil
connection.suspendForForegroundResume(reconnecting: reconnecting)
status = String(
localized: reconnecting ? AppStrings.Connections.reconnecting : AppStrings.Connections.connecting)
presentConnectionJourney(mode: reconnecting ? .reconnection : .connection)
Task {
await oldSSH?.disconnect()
oldTelnet?.disconnect()
if let oldTransferContext { await oldTransferContext.retire() } else { await oldSFTP?.disconnect() }
}
}
func resumeAfterBackground() {
guard connectionResumeIntent == .foreground else { return }
connectionResumeIntent = .none
if transport.isResumable, let ssh {
let token = generation
let suspension = transportSuspension
Task { [weak self] in
await suspension?.value
guard let self, self.generation == token else { return }
do {
try await self.terminalRecoveryStore.invalidate(self.recoveryID)
guard self.generation == token else { return }
try await ssh.resumeTransport()
self.transportSuspension = nil
} catch {
guard self.generation == token else { return }
self.receiveTransportState(.failed, failure: error.localizedDescription)
}
}
return
}
connect()
}
func suspendResumableTransport() {
guard transport.isResumable, transportSuspension == nil, let ssh,
let record = recoveryRecord, let lease = recoveryLease
else { return }
connectionResumeIntent = .foreground
let token = generation
transportSuspension = Task { [weak self] in
do {
let checkpoint = try await ssh.suspendTransport()
guard let self, self.generation == token, self.ssh === ssh else { return }
try await self.terminalRecoveryStore.save(record, checkpoint: checkpoint, lease: lease)
} catch {
guard let self, self.generation == token else { return }
self.operationError = error.localizedDescription
}
}
}
func receiveTransportRoute(_ route: TerminalRouteSnapshot) {
guard transportState != .ended, transportState != .closed else { return }
prepareTransportRecoveryJourney()
journey.receiveTransportRoute(route)
}
func receiveTransportState(_ state: ResumableTerminalState, failure: String? = nil) {
guard transportState != .ended, transportState != .closed else { return }
guard state != transportState || failure != nil else { return }
transportState = state
let context = connection.transportJourneyContext(
restoring: resumeCheckpoint != nil || requiresTransportRecovery)
switch state {
case .connected:
connected = true
busy = false
status = String(localized: AppStrings.Connections.connected)
if journey.transportRecovery != nil {
_ = connection.markConnected()
connectionFailure = nil
}
case .recovering, .suspended:
connected = false
status = String(localized: AppStrings.Connections.reconnecting)
case .ended:
connected = false
busy = false
connectionResumeIntent = .none
recoveryLease = nil
transportSuspension?.cancel()
transportSuspension = nil
let recoveryID = recoveryID
Task { [terminalRecoveryStore] in try? await terminalRecoveryStore.discard(recoveryID) }
handleRemoteExit(.success(0))
case .failed:
connected = false
busy = false
connection.resetRecovery()
connectionFailure = failure ?? TerminalTransportError.sessionFailed.localizedDescription
status = connectionFailure ?? ""
let recoveryID = recoveryID
Task { [terminalRecoveryStore] in try? await terminalRecoveryStore.discard(recoveryID) }
// Transport cleanup can follow a failure; the workspace owns dismissing its Journey.
case .connecting, .closed: break
}
journey.receiveTransportState(
state, context: context, failure: failure,
recoveryMessage: { $0.statusMessage }, prepareRecovery: prepareTransportRecoveryJourney)
}
private func presentTransportRecovery(_ state: ConnectionJourney.TransportRecovery, message: String) {
prepareTransportRecoveryJourney()
journey.updateTransportRecovery(state, message: message)
}
private func prepareTransportRecoveryJourney() {
if journey.endpoints.isEmpty {
let host =
connectionHost(descriptor.hostID)
?? Host(id: descriptor.hostID, name: descriptor.title, host: descriptor.title)
journey.begin(
host: host, route: connectionRoute?.hosts ?? hosts.route(for: host), purpose: connectionPurpose,
username: resumeUsername ?? connectionRoute?.usernames[host.id] ?? usernameHint,
mode: .reconnection)
}
}
func restoreRemoteSession(_ reference: RemoteSessionReference?) {
sessionPersistence.attachment = reference
activitySession.recovery?.remoteSession = reference.flatMap { try? JSONEncoder().encode($0) }
didChangeState?(activitySession)
}
func takePersistentSessionCloseRequest() -> RemoteSessionCloseRequest? {
guard let reference = sessionPersistence.attachment, reference.isManaged else { return nil }
let snapshot: SSHAdditionalConnectionSnapshot
if let persistentSessionSnapshot {
snapshot = persistentSessionSnapshot
} else {
guard let host = connectionHost(reference.hostID),
let restored = try? hosts.resumableControlSnapshot(for: host, username: reference.username)
else { return nil }
snapshot = restored
}
guard snapshot.hostID == reference.hostID,
"\(snapshot.target.host):\(snapshot.target.port)" == reference.endpoint
else { return nil }
// Resumable transports have no reusable SSH control connection; their control callback
// can prompt for authentication, so only an ordinary SSH transport is transferred.
let transferred = transport == .ssh ? ssh : nil
if transferred != nil { ssh = nil }
return RemoteSessionCloseRequest(
reference: reference, snapshot: snapshot,
connection: transferred.map { RemoteSessionCloseConnection(channel: $0) })
}
private func handleRemoteExit(_ result: Result<Int32, TermindSSHError>) {
cancelPendingUpload()
resetCommandGeneratorShellState()
if case .success(let exitCode) = result, let reference = sessionPersistence.attachment, let channel = ssh {
let attemptID = generation
Task { [weak self] in
guard let self else { return }
var sessionExists: Bool?
do {
let execute = try await channel.sessionCommandExecutor()
sessionExists = try await RemoteSessionBackendRegistry.live.sessionExists(
reference, execute: execute)
} catch {
// An unverified exit must not erase identity or start a replacement session.
}
guard self.generation == attemptID, self.sessionPersistence.attachment == reference else { return }
let shouldClose = self.sessionPersistence.finishExit(exitCode: exitCode, sessionExists: sessionExists)
await channel.disconnect()
guard self.generation == attemptID else { return }
self.ssh = nil
self.connection.resetRecovery()
self.connected = false
self.busy = false
self.connectionResumeIntent = .none
if shouldClose {
self.didRequestClose?(self.id)
return
}
self.status = String(
localized: exitCode != 0
? AppStrings.SessionPersistence.failed
: self.sessionPersistence.detached
? AppStrings.SessionPersistence.detached : AppStrings.SessionPersistence.ended)
}
return
}
cancelPendingUpload()
stopSSHEventConsumer()
resetCommandGeneratorShellState()
generation = UUID()
attempt?.cancel()
attempt = nil
busy = false
connected = false
ssh = nil
transferSnapshot = nil
switch result {
case .success:
connectionResumeIntent = .none
connection.resetRecovery()
status = String(localized: AppStrings.Connections.disconnected)
didRequestClose?(id)
case .failure(let failure):
handleConnectionFailure(failure.localizedDescription)
}
}
private func handleConnectionFailure(_ message: String) {
if transport.isResumable {
receiveTransportState(.failed, failure: message)
return
}
guard connection.hasConnectedOnce else { return }
scheduleReconnect(after: message)
}
private func scheduleReconnect(after message: String) {
cancelPendingUpload()
stopSSHEventConsumer()
resetCommandGeneratorShellState()
let announcesLoss = connection.scheduleRecovery(after: message) { [weak self] in self?.connect() }
generation = UUID()
attempt?.cancel()
attempt = nil
let oldSSH = ssh
let oldTelnet = telnet
let oldSFTP = sftp
let oldTransferContext = transferContext
transferContext = nil
ssh = nil
telnet = nil
sftp = nil
sftpChannel.detach()
busy = false
connected = false
transferSnapshot = nil
status = String(localized: AppStrings.Connections.reconnecting)
if announcesLoss, descriptor.kind.isSSHTerminal {
appendTerminalNotice(String(localized: AppStrings.Connections.connectionLostReconnectingProgress))
}
if let host = connectionHost(descriptor.hostID) {
if descriptor.kind == .telnet {
journey.beginTelnet(
host: host,
username: usernameHint ?? hosts.identity(for: host, protocol: .telnet)?.username,
mode: .reconnection)
} else {
journey.begin(
host: host, route: connectionRoute?.hosts ?? hosts.route(for: host),
purpose: connectionPurpose,
username: usernameHint ?? hosts.identity(for: host)?.username, mode: .reconnection)
}
}
Task {
await oldSSH?.disconnect()
oldTelnet?.disconnect()
if let oldTransferContext { await oldTransferContext.retire() } else { await oldSFTP?.disconnect() }
}
}
private func appendTerminalNotice(_ message: String) {
backend.receive(Data("\r\n\u{001B}[2m[Termind] \(message)\u{001B}[0m\r\n".utf8))
}
func trustHostKey(remember: Bool = true) {
guard let observation = connection.pendingHostKey?.observation else { return }
if remember, connectionOrigin.allowsPersistence {
connection.trustHostKey(observation, using: hosts)
} else {
guard connection.useTemporaryHostKey(observation) else { return }
}
}
func close() {
bellObservation = nil
commandHistory.close()
sftpHistory.close()
disconnect()
}
/// Connection intent only: closed runtimes, entered credentials and one-shot commands are not retained.
struct ReopenState {
var descriptor: MobileWorkspaceDescriptor
var transientHost: Host? = nil
var connectionRoute: SSHConnectionRoute? = nil
var usernameHint: String? = nil
var connectionOrigin: RemoteConnectionOrigin = .library
var externalSFTPResourcePath: String? = nil
var externalSFTPMustBeDirectory = false
}
var reopenState: ReopenState {
ReopenState(
descriptor: restorationDescriptor, transientHost: transientHost, connectionRoute: connectionRoute,
usernameHint: usernameHint, connectionOrigin: connectionOrigin,
externalSFTPResourcePath: externalSFTPResourcePath,
externalSFTPMustBeDirectory: externalSFTPMustBeDirectory)
}
func disconnect() {
if transport.isResumable {
transportState = .closed
let recoveryID = recoveryID
Task { [terminalRecoveryStore] in try? await terminalRecoveryStore.discard(recoveryID) }
recoveryLease = nil
transportSuspension?.cancel()
transportSuspension = nil
}
sessionPersistence.cancel()
commandHistory.cancelLoading()
commandMonitor.endConnection()
cancelPendingUpload()
stopSSHEventConsumer()
resetCommandGeneratorShellState()
connectionResumeIntent = .none
connection.disconnect()
advisoryTelnetPassword?.clear()
advisoryTelnetPassword = nil
connectionFailure = nil
operationError = nil
authenticationRequired = false
authenticationPrompt = nil
submissions.removeAll()
proxySubmissions.removeAll()
activeHistoryID = nil
pendingCommand = nil
pendingConnectionStartupCommand = nil
pendingInitialSFTPPath = nil
commandExcludedFromHistory = nil
generation = UUID()
attempt?.cancel()
attempt = nil
transferSnapshot = nil
let oldSSH = ssh
let oldTelnet = telnet
let oldSFTP = sftp
let oldTransferContext = transferContext
transferContext = nil
ssh = nil
telnet = nil
sftp = nil
sftpChannel.detach()
Task {
await oldSSH?.disconnect()
oldTelnet?.disconnect()
if let oldTransferContext { await oldTransferContext.releaseOwner() } else { await oldSFTP?.disconnect() }
}
busy = false
connected = false
status = String(localized: AppStrings.Connections.disconnected)
}
func consumePendingExternalPreviewURL() -> URL? {
defer { pendingExternalPreviewURL = nil }
return pendingExternalPreviewURL
}
private func connectionHost(_ id: Host.ID) -> Host? {
connectionRoute?.host(id) ?? hosts.host(id) ?? (id == descriptor.hostID ? transientHost : nil)
}
private func startSSHEventConsumer(
for connectionGeneration: UUID
) -> (
exit: SSHPTYChannel.ExitHandler,
shellIntegration: SSHPTYChannel.ShellIntegrationHandler
) {
stopSSHEventConsumer()
let (events, continuation) = AsyncStream<MobileSSHSessionEvent>.makeStream()
sshEventContinuation = continuation
sshEventTask = Task { @MainActor [weak self] in
for await event in events {
guard !Task.isCancelled, let self,
self.generation == connectionGeneration
else { break }
switch event {
case .exit(let result):
self.handleRemoteExit(result)
case .shellIntegration(let event):
self.receiveShellEvent(event)
}
}
}
return (
exit: { result in
continuation.yield(.exit(result))
continuation.finish()
},
shellIntegration: { event in
continuation.yield(.shellIntegration(event))
}
)
}
private func recordRemoteSystemInfo(_ systemInfo: RemoteSystemInfo?) {
hosts.recordRemoteSystemInfo(systemInfo, for: descriptor.hostID)
refreshHostArtwork()
}
func refreshHostArtwork() {
let savedHost = hosts.host(descriptor.hostID)
let host = savedHost ?? transientHost
let distribution: DetectedDistribution?
if let savedHost {
distribution = savedHost.detectedDistribution
} else if case .resolved(let detected) = remoteSystemInfo?.distributionDetection {
distribution = detected
} else {
distribution = host?.detectedDistribution
}
let artwork = HostIconCatalog.bundled.resolvedArtwork(
overrideID: host?.iconOverrideID, distribution: distribution)
guard
activitySession.iconAsset != artwork.assetName || activitySession.iconSystemName != artwork.systemImageName
else { return }
activitySession.iconAsset = artwork.assetName
activitySession.iconSystemName = artwork.systemImageName
didChangeState?(activitySession)
}
private func stopSSHEventConsumer() {
sshEventContinuation?.finish()
sshEventContinuation = nil
sshEventTask?.cancel()
sshEventTask = nil
}
private var hasActiveConnectionFlow: Bool {
if connected || busy || connection.isRecovering || authenticationRequired || connection.pendingHostKey != nil {
return true
}
switch journey.phase {
case .connecting, .waiting, .succeeded: return true
case .hidden, .failed: return false
}
}
private func presentConnectionJourney(mode: ConnectionJourney.Mode) {
guard let host = hosts.host(descriptor.hostID) else {
journey.fail(String(localized: AppStrings.Connections.theHostIsNoLongerAvailable))
return
}
journey.begin(
host: host, route: hosts.route(for: host),
purpose: connectionPurpose,
username: hosts.identity(for: host)?.username, mode: mode)
}
@discardableResult
func prepareUpload(_ urls: [URL], insertPaths: Bool = false) -> Bool {
let urls = urls.filter(\.isFileURL)
guard !urls.isEmpty else { return false }
cancelPendingUpload()
guard transfers != nil, let snapshot = transferSnapshot,
let destination = uploadDestination
else {
showUploadNotice(
.rejected(
uploadTargetProblem
?? String(localized: AppStrings.SFTP.theUploadDestinationIsUnavailable)
)
)
return false
}
let connectionGeneration = generation
let preparationID = UUID()
let contextTicket = terminalContext.ticket(requiring: .directory)
uploadPreparationID = preparationID
uploadNotice = .preparing(destination)
uploadPreparationTask = Task { [weak self] in
guard let self else { return }
defer {
if uploadPreparationID == preparationID {
uploadPreparationTask = nil
uploadPreparationID = nil
}
}
var client: (any SFTPClientProtocol)?
var ownsClient = false
do {
try await confirmDirectoryContext(contextTicket)
if descriptor.kind == .sftp {
guard let sftp else { throw SFTPError.notConnected }
client = sftp
} else {
client = try await connectionServices.connectSFTP(
hosts.additionalConnectionRequest(from: snapshot), nil, nil)
ownsClient = true
}
try Task.checkCancellation()
guard uploadPreparationID == preparationID,
generation == connectionGeneration, uploadDestination == destination,
let client
else { throw CancellationError() }
let homeDirectory: String?
if descriptor.kind == .sftp {
homeDirectory = sftpChannel.homeDirectory
} else {
let proposed = try? await client.homeDirectory()
homeDirectory = proposed.flatMap { $0.hasPrefix("/") ? RemotePath.normalized($0) : nil }
}
try Task.checkCancellation()
guard uploadPreparationID == preparationID,
generation == connectionGeneration, uploadDestination == destination
else { throw CancellationError() }
let batch = try await SFTPLocalUploadPlanner.prepare(
urls,
destination: destination,
client: client
)
try await confirmDirectoryContext(contextTicket)
try Task.checkCancellation()
guard uploadPreparationID == preparationID,
generation == connectionGeneration, uploadDestination == destination
else {
throw CancellationError()
}
guard !batch.isEmpty else {
throw SFTPError.localFile(String(localized: AppStrings.SFTP.noUploadableFilesWereFound))
}
let context: SFTPTransferContext
if ownsClient {
context = SFTPTransferContext(
snapshot: snapshot, hostStore: hosts,
channel: client, primaryRole: .donatedConnection)
} else {
guard let existing = transferContext else { throw SFTPError.notConnected }
context = existing
}
pendingUpload = MobilePendingUpload(
batch: batch,
destination: destination,
homeDirectory: homeDirectory,
client: client,
ownsClient: ownsClient,
context: context,
connectionGeneration: connectionGeneration,
insertPaths: insertPaths, terminalContext: contextTicket
)
if batch.conflictCount > 0 {
if uploadNotice?.isRejected != true { uploadNotice = nil }
} else {
enqueuePendingUpload(.replace)
}
} catch is CancellationError {
if ownsClient { await client?.disconnect() }
if uploadPreparationID == preparationID { uploadNotice = nil }
} catch {
if ownsClient { await client?.disconnect() }
guard uploadPreparationID == preparationID, generation == connectionGeneration else { return }
reportUploadFailure(error.localizedDescription)
}
}
return true
}
func remoteCopySource(for file: RemoteFile) -> SFTPRemoteCopySource? {
guard descriptor.kind == .sftp, connected, let transferContext else {
return nil
}
return SFTPRemoteCopySource(
hostID: descriptor.hostID,
connection: .context(transferContext),
files: [file],
homeDirectory: sftpChannel.homeDirectory
)
}
func prepareRemoteCopy(_ payload: SFTPRemoteItemsPayload) {
guard let transfers, transferSnapshot != nil,
let sftp, connected
else {
operationError = String(localized: AppStrings.SFTP.theSftpConnectionIsNotAvailable)
return
}
cancelPendingUpload()
let connectionGeneration = generation
let preparationID = UUID()
remoteTransferPreparationID = preparationID
remoteTransferPreparationTask = Task { [weak self] in
guard let self else { return }
defer {
if remoteTransferPreparationID == preparationID {
remoteTransferPreparationTask = nil
remoteTransferPreparationID = nil
}
}
do {
let batch = try await SFTPRemoteCopyPlanner.prepare(
payload, destination: sftpChannel.path,
destinationHostID: descriptor.hostID,
client: sftp, coordinator: transfers
)
try Task.checkCancellation()
guard remoteTransferPreparationID == preparationID,
generation == connectionGeneration
else { return }
pendingRemoteCopy = MobilePendingRemoteCopy(
batch: batch,
connectionGeneration: connectionGeneration
)
if batch.conflictCount == 0 { enqueuePendingTransfer(.replace) }
} catch is CancellationError {
} catch {
guard remoteTransferPreparationID == preparationID else { return }
operationError = error.localizedDescription
}
}
}
func prepareDownload(_ file: RemoteFile, to destination: URL) {
guard transfers != nil, transferSnapshot != nil,
let sftp, connected
else {
operationError = String(localized: AppStrings.SFTP.theSftpConnectionIsNotAvailable)
return
}
cancelPendingUpload()
let connectionGeneration = generation
let preparationID = UUID()
remoteTransferPreparationID = preparationID
remoteTransferPreparationTask = Task { [weak self] in
guard let self else { return }
defer {
if remoteTransferPreparationID == preparationID {
remoteTransferPreparationTask = nil
remoteTransferPreparationID = nil
}
}
do {
let batch = try await SFTPRemoteDownloadPlanner.prepare(
[file], destination: destination, client: sftp
)
try Task.checkCancellation()
guard remoteTransferPreparationID == preparationID,
generation == connectionGeneration
else { return }
pendingDownload = MobilePendingDownload(
batch: batch,
connectionGeneration: connectionGeneration
)
if batch.conflictCount == 0 { enqueuePendingTransfer(.replace) }
} catch is CancellationError {
} catch {
guard remoteTransferPreparationID == preparationID else { return }
operationError = error.localizedDescription
}
}
}
func enqueuePendingTransfer(_ policy: TransferConflictPolicy) {
if let pendingRemoteCopy, let transfers, let sftp {
self.pendingRemoteCopy = nil
guard generation == pendingRemoteCopy.connectionGeneration, let transferContext else { return }
let generation = pendingRemoteCopy.connectionGeneration
Task { [weak self] in
guard let self else { return }
do {
try await SFTPRemoteCopyPlanner.enqueue(
pendingRemoteCopy.batch, policy: policy,
destinationHostID: descriptor.hostID,
destinationConnection: .context(transferContext),
destinationHomeDirectory: sftpChannel.homeDirectory,
client: sftp, coordinator: transfers
) { [weak self] state in
guard state == .completed else { return }
Task { @MainActor in
guard let self, self.generation == generation else { return }
await self.sftpChannel.reload()
}
}
} catch { operationError = error.localizedDescription }
}
return
}
if let pendingDownload, let transfers {
self.pendingDownload = nil
guard generation == pendingDownload.connectionGeneration, let transferContext else { return }
do {
try SFTPRemoteDownloadPlanner.enqueue(
pendingDownload.batch, policy: policy,
hostID: descriptor.hostID,
connection: .context(transferContext),
homeDirectory: sftpChannel.homeDirectory,
coordinator: transfers
) { [sftpHistory] path, state in
guard state == .completed else { return }
sftpHistory.record(kind: .file, path: path)
}
} catch { operationError = error.localizedDescription }
return
}
enqueuePendingUpload(policy)
}
func cancelPendingRemoteTransfer() {
remoteTransferPreparationID = nil
remoteTransferPreparationTask?.cancel()
remoteTransferPreparationTask = nil
pendingDownload = nil
pendingRemoteCopy = nil
}
func enqueuePendingUpload(_ policy: TransferConflictPolicy) {
guard let pendingUpload, let transfers else { return }
self.pendingUpload = nil
uploadEnqueueID = nil
uploadEnqueueTask?.cancel()
let enqueueID = UUID()
uploadEnqueueID = enqueueID
uploadEnqueueTask = Task { [weak self] in
guard let self else { return }
defer {
if uploadEnqueueID == enqueueID {
uploadEnqueueTask = nil
uploadEnqueueID = nil
}
}
do {
try await confirmDirectoryContext(pendingUpload.terminalContext)
let result = try await SFTPLocalUploadPlanner.enqueue(
pendingUpload.batch,
policy: policy,
hostID: descriptor.hostID,
connection: .context(pendingUpload.context),
homeDirectory: pendingUpload.homeDirectory,
client: pendingUpload.client,
coordinator: transfers,
uploadedPaths: pendingUpload.insertPaths
? { [weak self] paths in
guard let self, self.connected,
self.generation == pendingUpload.connectionGeneration
else { return }
let text = paths.map(RemoteShellCommand.escapeArgument).joined(separator: " ") + " "
if self.hasRuntimeContext {
guard let ticket = pendingUpload.terminalContext,
self.terminalContext.isCurrent(ticket, requiring: .directory)
else { return }
Task {
guard self.terminalContext.isCurrent(ticket, requiring: .directory) else { return }
await self.replaceCurrentDraft(with: text)
}
} else {
_ = self.terminal.paste(text: text)
}
} : nil
) { [weak self] state in
guard state == .completed else { return }
Task { @MainActor in
guard let self,
self.descriptor.kind == .sftp,
self.generation == pendingUpload.connectionGeneration
else { return }
await self.sftpChannel.openDirectory(self.sftpChannel.path)
}
}
if pendingUpload.ownsClient { await pendingUpload.context.releaseOwner() }
guard uploadEnqueueID == enqueueID,
generation == pendingUpload.connectionGeneration
else { return }
if result.changedRemoteDirectories, descriptor.kind == .sftp {
await sftpChannel.openDirectory(sftpChannel.path)
}
showUploadNotice(
.queued(pendingUpload.batch.rootCount, pendingUpload.destination)
)
} catch is CancellationError {
if pendingUpload.ownsClient { await pendingUpload.context.releaseOwner() }
if uploadEnqueueID == enqueueID { uploadNotice = nil }
} catch {
if pendingUpload.ownsClient { await pendingUpload.context.releaseOwner() }
guard uploadEnqueueID == enqueueID, generation == pendingUpload.connectionGeneration else { return }
reportUploadFailure(error.localizedDescription)
}
}
}
func cancelPendingUpload() {
cancelPendingRemoteTransfer()
uploadPreparationID = nil
uploadPreparationTask?.cancel()
uploadPreparationTask = nil
uploadEnqueueID = nil
uploadEnqueueTask?.cancel()
uploadEnqueueTask = nil
uploadNoticeID = nil
uploadNoticeTask?.cancel()
uploadNoticeTask = nil
uploadNotice = nil
guard let pendingUpload else { return }
self.pendingUpload = nil
if pendingUpload.ownsClient {
Task { await pendingUpload.context.releaseOwner() }
}
}
func reportUploadFailure(_ message: String) {
if descriptor.kind.isSSHTerminal {
showUploadNotice(.rejected(message))
} else {
uploadNotice = nil
operationError = message
}
}
private func showUploadNotice(_ notice: WorkspaceUploadNotice) {
// A partially loaded drop can enqueue its readable files while its failure is still visible.
if case .queued = notice, uploadNotice?.isRejected == true { return }
uploadNoticeID = nil
uploadNoticeTask?.cancel()
let noticeID = UUID()
uploadNoticeID = noticeID
uploadNotice = notice
uploadNoticeTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(notice.isRejected ? 3 : 2))
guard !Task.isCancelled, self?.uploadNoticeID == noticeID,
self?.uploadNotice == notice
else { return }
self?.uploadNotice = nil
self?.uploadNoticeTask = nil
self?.uploadNoticeID = nil
}
}
@discardableResult
func preview(_ file: RemoteFile, completion: @escaping (URL) -> Void) -> Bool {
guard let transfers, let transferContext, connected, !file.isDirectory else {
return false
}
if let sftpEdits, let sftp {
Task {
do {
let url = try await sftpEdits.open(
file, hostID: descriptor.hostID, connection: .context(transferContext), client: sftp,
homeDirectory: sftpChannel.homeDirectory)
sftpHistory.record(kind: .file, path: file.path)
completion(url)
} catch { operationError = error.localizedDescription }
}
return true
}
do {
guard RemoteFileName.isValid(file.name) else { throw CocoaError(.fileWriteInvalidFileName) }
let directory = FileManager.default.temporaryDirectory.appending(
path: "TermindDownloads", directoryHint: .isDirectory
)
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let localURL = directory.appending(path: file.name)
transfers.enqueue(
direction: .download, batchID: UUID(), localURL: localURL,
remotePath: file.path, byteCount: file.size, hostID: descriptor.hostID,
connection: .context(transferContext), scopedRoot: directory, overwrite: false,
homeDirectory: sftpChannel.homeDirectory
) { [sftpHistory, path = file.path] state in
guard state == .completed else { return }
sftpHistory.record(kind: .file, path: path)
completion(localURL)
}
return true
} catch {
operationError = error.localizedDescription
return false
}
}
func uploadEdit(_ id: UUID, replacing: Bool) async -> Bool {
guard connected, let sftpEdits, let sftp, let transferContext else { return false }
return await sftpEdits.upload(
id, connection: .context(transferContext), client: sftp,
homeDirectory: sftpChannel.homeDirectory, replacing: replacing
) {
[weak self] in
Task { await self?.sftpChannel.reload() }
}
}
func importEditedCopy(_ source: URL, for id: UUID) async {
guard let edits = sftpEdits, let file = edits.model.file(id), file.hostID == descriptor.hostID else { return }
do {
let copy = try await MobileSFTPEditedCopyImporter().stage(source, matching: file.localURL)
defer { try? FileManager.default.removeItem(at: copy.deletingLastPathComponent()) }
guard let current = edits.model.file(id), !current.isInvalidated,
!current.isPreparing, current.transferID == nil
else { throw SFTPEditFailure.unavailable }
_ = try FileManager.default.replaceItemAt(current.localURL, withItemAt: copy)
await edits.recheck()
} catch { operationError = error.localizedDescription }
}
func reopenHistoryFile(at path: String, completion: @escaping (URL) -> Void = { _ in }) async -> Bool {
guard let sftp, connected else {
operationError = String(localized: AppStrings.SFTP.theSftpConnectionIsNotAvailable)
return false
}
let connectionGeneration = generation
do {
let file = try await sftp.attributes(at: path)
guard generation == connectionGeneration, connected else { return false }
guard let file, !file.isDirectory else {
operationError = String(localized: AppStrings.SFTP.theRemoteFileIsNoLongerAvailable)
return false
}
return preview(file, completion: completion)
} catch {
if !handleSFTPConnectionError(error, connection: connectionGeneration) {
operationError = error.localizedDescription
}
return false
}
}
private func handleSFTPConnectionError(_ error: any Error, connection: UUID) -> Bool {
guard generation == connection, self.connection.hasConnectedOnce, let sftpError = error as? SFTPError else {
return false
}
switch sftpError {
case .connectionLost(let message):
handleConnectionFailure(message)
return true
case .notConnected:
handleConnectionFailure(sftpError.localizedDescription)
return true
case .cancelled, .localFile, .operation:
return false
}
}
private func resize(_ size: InMemoryTerminalViewport) async {
guard size.columns > 0, size.rows > 0, size.widthPixels > 0, size.heightPixels > 0 else { return }
viewport = size
if descriptor.kind == .telnet {
telnet?.resize(
columns: UInt16(clamping: size.columns), rows: UInt16(clamping: size.rows))
return
}
await ssh?.resize(
columns: UInt16(clamping: size.columns), rows: UInt16(clamping: size.rows),
widthPixels: UInt32(clamping: size.widthPixels), heightPixels: UInt32(clamping: size.heightPixels))
}
}
@MainActor
private final class MobileTerminalRelay {
weak var owner: MobileWorkspace?
}