Bind independent Tailnet connections to group IDs while preserving legacy host identities through the V3 sync format. Persist profile cleanup retries, isolate test storage, and reject stale dialers after re-enrollment. Recover quarantined sync records safely, refresh metrics compatibility, fix multiline shell history, and remove obsolete mobile history gates. Pin libtailscale-spm to 1.0.1 alongside its submodule revision. Validation: default macOS suites (2679 tests), targeted iOS suites (26 tests), strict Swift formatting, shared AppStrings checks, and Chinese localization checks passed. Both subagent reviews approved.
1865 lines
80 KiB
Swift
1865 lines
80 KiB
Swift
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 {
|
|
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
|
|
}
|
|
|
|
private struct MobilePendingDownload {
|
|
var batch: SFTPRemoteDownloadBatch
|
|
var connectionGeneration: UUID
|
|
}
|
|
|
|
private struct MobilePendingRemoteCopy {
|
|
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 }
|
|
@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 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(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 terminalHighlightSettings: TerminalHighlightSettingsModel
|
|
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?
|
|
/// Resolved once at the beginning of each connection attempt.
|
|
private(set) var isShellIntegrationEnabled = true
|
|
private var shellIntegrationAvailable = false
|
|
private var isInitialCommandSubmissionReady = false
|
|
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 let surfaceOwner = MobileTerminalSurface()
|
|
var uploadConnectionGeneration: UUID { generation }
|
|
|
|
func configureTerminalUpload(files: (() -> Void)?, photos: (() -> Void)? = nil) {
|
|
surfaceOwner.view.uploadFiles = descriptor.kind == .ssh ? files : nil
|
|
surfaceOwner.view.uploadPhotos = descriptor.kind == .ssh ? photos : nil
|
|
surfaceOwner.view.canUploadFiles = { [weak self] in self?.uploadTargetProblem == nil && self != nil }
|
|
}
|
|
|
|
var uploadTerminalView: UIView? { descriptor.kind == .ssh ? surfaceOwner.view : nil }
|
|
let terminal: TerminalViewState
|
|
private let backend: InMemoryTerminalSession
|
|
|
|
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 sameDirectoryLaunch: MobileWorkspaceDirectoryLaunch? {
|
|
guard connected else { return nil }
|
|
switch descriptor.kind {
|
|
case .ssh:
|
|
guard isShellIntegrationEnabled,
|
|
shellIntegrationAvailable,
|
|
let workingDirectory,
|
|
!workingDirectory.isEmpty
|
|
else { return nil }
|
|
return MobileWorkspaceDirectoryLaunch(
|
|
hostID: descriptor.hostID,
|
|
kind: .sftp,
|
|
path: RemotePath.normalized(workingDirectory)
|
|
)
|
|
case .sftp:
|
|
guard !sftpChannel.path.isEmpty else { return nil }
|
|
return MobileWorkspaceDirectoryLaunch(
|
|
hostID: descriptor.hostID,
|
|
kind: .ssh,
|
|
path: RemotePath.normalized(sftpChannel.path)
|
|
)
|
|
case .telnet:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var uploadDestination: String? {
|
|
switch descriptor.kind {
|
|
case .ssh:
|
|
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:
|
|
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,
|
|
terminalHighlightSettings: TerminalHighlightSettingsModel = TerminalHighlightSettingsModel(),
|
|
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.terminalHighlightSettings = terminalHighlightSettings
|
|
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
|
|
activitySession = WorkspaceActivityAttributes.Session(
|
|
id: descriptor.id, title: descriptor.title, kind: descriptor.kind.rawValue,
|
|
connected: false,
|
|
iconAsset: LinuxDistributionIconCatalog.entry(
|
|
for: (hosts.host(descriptor.hostID) ?? transientHost)?.detectedDistribution)?.asset)
|
|
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?.receiveInteractiveTerminalInput(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)
|
|
}
|
|
|
|
@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 }
|
|
_ = terminal.paste(text: text)
|
|
terminal.requestFocus()
|
|
}
|
|
|
|
func generateCommand(
|
|
using generator: any CommandGenerating,
|
|
readViewport: @MainActor (MobileWorkspace) -> String? = { $0.readViewportText() }
|
|
) {
|
|
if commandGeneration.isRunning {
|
|
cancelCommandGeneration()
|
|
return
|
|
}
|
|
guard descriptor.kind == .ssh, 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?) {
|
|
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 ?? "")
|
|
}
|
|
|
|
func receiveInteractiveTerminalInput(_ data: Data) async {
|
|
invalidateCommandGeneration()
|
|
if descriptor.kind == .telnet {
|
|
telnet?.write(data)
|
|
} else {
|
|
await writeSSHDirectly(data)
|
|
}
|
|
}
|
|
|
|
private func writeSSHDirectly(_ data: Data) async {
|
|
guard let ssh else { return }
|
|
await connectionServices.writeSSH(ssh, data)
|
|
}
|
|
|
|
private func requestCurrentDraft() async throws -> String {
|
|
try await commandDraft.read(isAvailable: canGenerateFromCurrentDraft) { [weak self] data in
|
|
await self?.writeSSHDirectly(data)
|
|
}
|
|
}
|
|
|
|
private func replaceCurrentDraft(with command: String) async {
|
|
await writeSSHDirectly(commandDraft.replacementPayload(for: command))
|
|
terminal.requestFocus()
|
|
}
|
|
|
|
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() {
|
|
cancelCommandGeneration()
|
|
integratedShell = nil
|
|
remoteSystemInfo = nil
|
|
isShellPromptReady = false
|
|
shellIntegrationAvailable = false
|
|
isInitialCommandSubmissionReady = false
|
|
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
|
|
}
|
|
commandMonitor.receiveShellEvent(event)
|
|
switch event {
|
|
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)
|
|
}
|
|
}
|
|
|
|
func connect() {
|
|
guard !busy, !connected else { return }
|
|
cancelPendingUpload()
|
|
stopSSHEventConsumer()
|
|
let reconnecting = connection.isRecovering
|
|
let token = UUID()
|
|
generation = token
|
|
commandMonitor.beginConnection()
|
|
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
|
|
}
|
|
if descriptor.kind == .telnet {
|
|
connectTelnet(host: host, token: token, reconnecting: reconnecting)
|
|
return
|
|
}
|
|
isShellIntegrationEnabled = host.shellIntegrationMode.isEnabled(
|
|
globalSetting: terminalSettings.preferences.shellIntegration)
|
|
if descriptor.kind == .ssh, 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)
|
|
journey.begin(
|
|
host: host, route: route,
|
|
purpose: descriptor.kind == .ssh ? .sshSession : .sftpChannel,
|
|
username: usernameHint ?? hosts.identity(for: host)?.username,
|
|
mode: reconnecting ? .reconnection : .connection)
|
|
if 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: route) {
|
|
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: route) {
|
|
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: route, 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 {
|
|
let preparation = try hosts.prepareSSHConnection(
|
|
for: host, submissions: attemptSubmissions,
|
|
proxySubmissions: attemptProxySubmissions,
|
|
temporaryHostKeys: temporaryKeys)
|
|
let config = try await preparation.resolve(
|
|
trustPolicy: .allowChallenge
|
|
)
|
|
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 == .ssh {
|
|
let backend = backend
|
|
let handlers = startSSHEventConsumer(for: token)
|
|
let session = connectionServices.makePTYChannel(
|
|
{ backend.receive($0) },
|
|
handlers.exit,
|
|
handlers.shellIntegration)
|
|
ssh = session
|
|
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))
|
|
}
|
|
try await connectionServices.connectSSH(
|
|
session,
|
|
request,
|
|
isShellIntegrationEnabled,
|
|
UInt16(clamping: viewport.columns),
|
|
UInt16(clamping: viewport.rows),
|
|
progressHandler
|
|
)
|
|
} 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 else { return }
|
|
let additionalSnapshot = hosts.additionalConnectionSnapshot(from: config)
|
|
transferSnapshot = additionalSnapshot
|
|
if let sftp {
|
|
transferContext = SFTPTransferContext(
|
|
snapshot: additionalSnapshot, hostStore: hosts, channel: sftp)
|
|
}
|
|
submissions.removeAll()
|
|
proxySubmissions.removeAll()
|
|
let wasReconnecting = connection.markConnected()
|
|
connected = true
|
|
if let ssh {
|
|
commandHistory.load(
|
|
using: RemoteShellHistoryLoader(channel: ssh, charset: host.charset), source: nil)
|
|
}
|
|
status = String(localized: AppStrings.Connections.connected)
|
|
journey.succeed()
|
|
if wasReconnecting, descriptor.kind == .ssh {
|
|
appendTerminalNotice(String(localized: AppStrings.Terminal.reconnectedANewRemoteShellHasStarted))
|
|
}
|
|
if descriptor.kind == .ssh {
|
|
if !isShellIntegrationEnabled {
|
|
isInitialCommandSubmissionReady = true
|
|
}
|
|
submitPendingInitialCommandIfReady()
|
|
} else if let externalFile {
|
|
_ = preview(externalFile) { [weak self] url in
|
|
self?.pendingExternalPreviewURL = url
|
|
}
|
|
}
|
|
} catch {
|
|
guard generation == token else { return }
|
|
stopSSHEventConsumer()
|
|
connectionFailure =
|
|
error is CancellationError
|
|
? String(localized: AppStrings.Connections.authenticationCancelled) : 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
|
|
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 (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 {
|
|
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 == .ssh, connected, isInitialCommandSubmissionReady,
|
|
let session = ssh, let command = consumePendingInitialCommand()
|
|
else { return }
|
|
let token = generation
|
|
let data = Data((command + "\r").utf8)
|
|
Task { [weak self] in
|
|
guard let self, generation == token, connected, ssh === session else { return }
|
|
await connectionServices.writeSSH(session, data)
|
|
}
|
|
}
|
|
|
|
func retryReconnectNow() {
|
|
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() {
|
|
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
|
|
connect()
|
|
}
|
|
|
|
private func handleRemoteExit(_ result: Result<Int32, TermindSSHError>) {
|
|
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) {
|
|
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 == .ssh {
|
|
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: descriptor.kind == .ssh ? .sshSession : .sftpChannel,
|
|
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() {
|
|
commandHistory.close()
|
|
sftpHistory.close()
|
|
disconnect()
|
|
}
|
|
|
|
func disconnect() {
|
|
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)
|
|
guard let systemInfo,
|
|
case .resolved(let distribution) = systemInfo.distributionDetection
|
|
else { return }
|
|
let iconAsset = LinuxDistributionIconCatalog.entry(for: distribution)?.asset
|
|
guard activitySession.iconAsset != iconAsset else { return }
|
|
activitySession.iconAsset = iconAsset
|
|
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: descriptor.kind == .ssh ? .sshSession : .sftpChannel,
|
|
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()
|
|
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 {
|
|
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 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
|
|
)
|
|
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 {
|
|
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: " ") + " "
|
|
_ = 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 == .ssh {
|
|
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?
|
|
}
|