Add extensible remote session backends, safe ownership and restore handling, and pane-scoped shell integration over the existing SSH connection. Keep iOS workspaces in memory and rebuild expired Activity targets on demand. Integrate session selection into Connection Journey with consistent controls, cancellation behavior, and localized counts.
1338 lines
57 KiB
Swift
1338 lines
57 KiB
Swift
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPresentation
|
|
import TermindRemoteInfrastructure
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
protocol SFTPChannelNavigationRouting: AnyObject {
|
|
func sftpChannel(_ sftpChannel: SFTPChannelModel, didChangePath path: String)
|
|
}
|
|
|
|
/// One row of the browser, with its display strings already rendered.
|
|
///
|
|
/// The formatters are the reason this type exists. A hand-rolled `LazyVStack`
|
|
/// rebuilds every realised row on every body pass, and a divider drag runs one
|
|
/// of those per mouse-move frame — so `Date.formatted` and `.byteCount` were
|
|
/// being re-run dozens of times a frame for text that only changes when the
|
|
/// directory does. Rendering them once per listing moves that cost off the
|
|
/// drag entirely.
|
|
nonisolated struct SFTPRow: Identifiable, Hashable, Sendable {
|
|
let file: RemoteFile
|
|
let sizeText: String
|
|
let modifiedText: String
|
|
let kindText: String
|
|
let permissionText: String
|
|
|
|
var id: RemoteFile.ID { file.id }
|
|
|
|
init(file: RemoteFile, kindText: String? = nil) {
|
|
self.file = file
|
|
self.sizeText = file.isDirectory ? "" : DataSizeFormat.bytes(file.size)
|
|
self.modifiedText =
|
|
file.modifiedAt?.formatted(date: .abbreviated, time: .shortened) ?? "—"
|
|
self.kindText = kindText ?? file.sftpKindDescription
|
|
self.permissionText = file.permissionDescription
|
|
}
|
|
}
|
|
|
|
/// Rows, filtering, and selection change as one value so a refreshed directory
|
|
/// never exposes a selection that is absent from the visible snapshot.
|
|
///
|
|
/// `visibleRows` is stored rather than computed. Filtering and sorting are
|
|
/// O(n log n) over `localizedStandardCompare`, and a computed property put that
|
|
/// on every read — which meant twice per SwiftUI body pass, which meant twice
|
|
/// per frame of a pane-divider drag. It is rebuilt only when the inputs to it
|
|
/// change: the listing, hidden-file visibility, the filter, or the sort.
|
|
private struct SFTPDirectoryPresentation {
|
|
var files: [RemoteFile] = []
|
|
var selection: Set<RemoteFile.ID> = []
|
|
var query = ""
|
|
var showsHiddenFiles = false
|
|
var sortField: RemoteFileSortField = .name
|
|
var sortDirection: RemoteFileSortDirection = .ascending
|
|
private(set) var visibleRows: [SFTPRow] = []
|
|
|
|
/// Re-filters, re-sorts, and drops any selection that fell out of view.
|
|
/// Every mutation of `files`, hidden visibility, `query`, or the sort has
|
|
/// to go through here.
|
|
mutating func refresh() {
|
|
let displayable = showsHiddenFiles ? files : files.filter { !$0.isHidden }
|
|
let matches =
|
|
query.isEmpty
|
|
? displayable
|
|
: displayable.filter { $0.name.localizedCaseInsensitiveContains(query) }
|
|
// Decorated before sorting so `sftpKindDescription` — a localised
|
|
// string lookup — runs once per file rather than once per comparison.
|
|
var rows = matches.map { SFTPRow(file: $0) }
|
|
rows.sort(by: rowPrecedes)
|
|
visibleRows = rows
|
|
reconcileSelection()
|
|
}
|
|
|
|
/// The cheap path: selection changes cannot alter what is on screen, so
|
|
/// they intersect against the rows already built.
|
|
mutating func reconcileSelection() {
|
|
selection.formIntersection(Set(visibleRows.map(\.id)))
|
|
}
|
|
|
|
private func rowPrecedes(_ lhs: SFTPRow, _ rhs: SFTPRow) -> Bool {
|
|
RemoteFileSortDescriptor(field: sortField, direction: sortDirection).precedes(
|
|
lhs.file, rhs.file, lhsKind: lhs.kindText, rhsKind: rhs.kindText
|
|
)
|
|
}
|
|
}
|
|
|
|
@Observable
|
|
final class SFTPChannelModel: RemotePathDisplaying, Identifiable {
|
|
var history = SFTPHistoryModel()
|
|
var id: UUID { history.sessionID }
|
|
let hostID: Host.ID
|
|
let initialPath: String
|
|
var homeResolution: RemoteHomeResolutionState = .pending
|
|
private(set) var path: String {
|
|
didSet {
|
|
guard path != oldValue else { return }
|
|
navigationRouter?.sftpChannel(self, didChangePath: path)
|
|
}
|
|
}
|
|
var pathText: String
|
|
private(set) var backHistory: [String] = []
|
|
private(set) var forwardHistory: [String] = []
|
|
private var directoryPresentation = SFTPDirectoryPresentation()
|
|
var selection: Set<RemoteFile.ID> {
|
|
get { directoryPresentation.selection }
|
|
set {
|
|
var presentation = directoryPresentation
|
|
presentation.selection = newValue
|
|
presentation.reconcileSelection()
|
|
directoryPresentation = presentation
|
|
}
|
|
}
|
|
var query: String {
|
|
get { directoryPresentation.query }
|
|
set {
|
|
guard newValue != directoryPresentation.query else { return }
|
|
var presentation = directoryPresentation
|
|
presentation.query = newValue
|
|
presentation.refresh()
|
|
directoryPresentation = presentation
|
|
}
|
|
}
|
|
var showsHiddenFiles: Bool {
|
|
get { directoryPresentation.showsHiddenFiles }
|
|
set {
|
|
guard newValue != directoryPresentation.showsHiddenFiles else { return }
|
|
var presentation = directoryPresentation
|
|
presentation.showsHiddenFiles = newValue
|
|
presentation.refresh()
|
|
directoryPresentation = presentation
|
|
}
|
|
}
|
|
var sortField: RemoteFileSortField { directoryPresentation.sortField }
|
|
var sortDirection: RemoteFileSortDirection { directoryPresentation.sortDirection }
|
|
var files: [RemoteFile] { directoryPresentation.files }
|
|
private(set) var status: ConnectionStatus = .disconnected {
|
|
didSet {
|
|
statusStore?.setConnectionStatus(status, connectionID: id, hostID: hostID)
|
|
}
|
|
}
|
|
private(set) var isLoading = false
|
|
private(set) var hint = ""
|
|
var pendingHostKey: SSHHostKeyPrompt? { connection.pendingHostKey }
|
|
var pendingAuthentication: SSHAuthenticationPrompt? {
|
|
get { connection.pendingAuthentication }
|
|
set { connection.pendingAuthentication = newValue }
|
|
}
|
|
let connection = RemoteSessionConnectionController()
|
|
var connectionJourney: ConnectionJourney { connection.journey }
|
|
private var pendingDownloadBatch: SFTPRemoteDownloadBatch?
|
|
var pendingUploadBatch: SFTPLocalUploadBatch?
|
|
var pendingRemoteCopyBatch: SFTPRemoteCopyBatch?
|
|
var operationError: String?
|
|
private(set) var canCancelOperation = false
|
|
|
|
private var client: (any SFTPChannelProtocol)?
|
|
private var transferContext: SFTPTransferContext?
|
|
private var additionalSnapshot: SSHAdditionalConnectionSnapshot?
|
|
private var usesEphemeralCredentials = false
|
|
private var connectionTask: Task<Void, Never>?
|
|
private var startsOnMount = true
|
|
private var automaticRefreshTask: Task<Void, Never>?
|
|
private var uploadRefreshTask: Task<Void, Never>?
|
|
private var suppliedAuthentications: [Host.ID: SSHAuthenticationSubmission] {
|
|
get { connection.suppliedAuthentications }
|
|
set { connection.suppliedAuthentications = newValue }
|
|
}
|
|
private var suppliedProxyAuthentications: [Host.ID: SSHProxyAuthenticationSubmission] {
|
|
get { connection.suppliedProxyAuthentications }
|
|
set { connection.suppliedProxyAuthentications = newValue }
|
|
}
|
|
private var temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] {
|
|
get { connection.temporaryHostKeys }
|
|
set { connection.temporaryHostKeys = newValue }
|
|
}
|
|
private var connectionAttemptID: UUID? {
|
|
get { connection.connectionAttemptID }
|
|
set { connection.connectionAttemptID = newValue }
|
|
}
|
|
private var reconnectController: SessionReconnectController { connection.reconnect }
|
|
private var operationCancellation: TransferCancellationToken?
|
|
weak var navigationRouter: (any SFTPChannelNavigationRouting)?
|
|
private weak var statusStore: (any RemoteSessionHostStore)?
|
|
private weak var transferCoordinator: SFTPTransferCoordinator?
|
|
private var didResolveHomeForConnection = false
|
|
private let automaticRefreshInterval: Duration
|
|
var connectionCoordinator: RemoteConnectionCoordinator?
|
|
private var transientHost: Host? {
|
|
get { connection.transientHost }
|
|
set { connection.transientHost = newValue }
|
|
}
|
|
private var usernameHint: String?
|
|
private var connectionRoute: SSHConnectionRoute? {
|
|
get { connection.connectionRoute }
|
|
set { connection.connectionRoute = newValue }
|
|
}
|
|
private var connectionOrigin: RemoteConnectionOrigin = .library
|
|
private var externalResourcePath: String?
|
|
private var externalResourceMustBeDirectory = false
|
|
var openExternalFile: ((RemoteFile) -> Void)?
|
|
|
|
init(
|
|
hostID: Host.ID,
|
|
initialPath: String = "",
|
|
client: (any SFTPChannelProtocol)? = nil,
|
|
configuration: SSHConnectionConfiguration? = nil,
|
|
additionalSnapshot: SSHAdditionalConnectionSnapshot? = nil,
|
|
hostStore: (any RemoteSessionHostStore)? = nil,
|
|
primaryRole: SFTPTransferPrimaryRole = .control,
|
|
homeResolution: RemoteHomeResolutionState = .pending,
|
|
automaticRefreshInterval: Duration = .seconds(15)
|
|
) {
|
|
self.hostID = hostID
|
|
let configuredPath = initialPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let path = configuredPath.isEmpty ? "/" : RemotePath.normalized(configuredPath)
|
|
self.initialPath = configuredPath.isEmpty ? "" : path
|
|
self.path = path
|
|
self.pathText = path
|
|
self.client = client
|
|
startsOnMount = client == nil
|
|
let retainedSnapshot =
|
|
additionalSnapshot ?? configuration.map(SSHAdditionalConnectionSnapshot.init(configuration:))
|
|
self.additionalSnapshot = retainedSnapshot
|
|
if let client, let retainedSnapshot {
|
|
transferContext = SFTPTransferContext(
|
|
snapshot: retainedSnapshot, hostStore: hostStore,
|
|
channel: client, primaryRole: primaryRole)
|
|
}
|
|
self.homeResolution = homeResolution
|
|
self.automaticRefreshInterval = automaticRefreshInterval
|
|
if client != nil { status = .connected }
|
|
if client != nil { _ = connection.markConnected() }
|
|
}
|
|
|
|
var reconnectState: ReconnectState? { connection.reconnectState }
|
|
var allowsRemoteMutations: Bool { status == .connected }
|
|
|
|
func configureExternalConnection(
|
|
host: Host, username: String?, route: SSHConnectionRoute,
|
|
fingerprint: SSHHostKeyConstraint?, resourcePath: String?,
|
|
mustBeDirectory: Bool, openFile: @escaping (RemoteFile) -> Void
|
|
) {
|
|
transientHost = host
|
|
usernameHint = username
|
|
connectionRoute = route
|
|
connectionOrigin = .externalLink(
|
|
hostKeyConstraints: fingerprint.map { [host.id: $0] } ?? [:])
|
|
externalResourcePath = resourcePath
|
|
externalResourceMustBeDirectory = mustBeDirectory
|
|
openExternalFile = openFile
|
|
}
|
|
var displayedPath: String? {
|
|
guard !homeResolution.isPending else { return nil }
|
|
return displayPath(path)
|
|
}
|
|
|
|
var canGoBack: Bool { !backHistory.isEmpty }
|
|
var canGoForward: Bool { !forwardHistory.isEmpty }
|
|
var conflictCount: Int {
|
|
pendingRemoteCopyBatch?.conflictCount
|
|
?? pendingUploadBatch?.conflictCount
|
|
?? pendingDownloadBatch?.conflictCount
|
|
?? 0
|
|
}
|
|
var hasPendingBatch: Bool {
|
|
pendingUploadBatch != nil || pendingDownloadBatch != nil || pendingRemoteCopyBatch != nil
|
|
}
|
|
/// What the list draws. Already filtered, sorted, and formatted.
|
|
var visibleRows: [SFTPRow] { directoryPresentation.visibleRows }
|
|
/// The same rows as plain files, for selection and menu work. Only ever
|
|
/// reached from event handlers, never from a body pass.
|
|
var filteredFiles: [RemoteFile] { directoryPresentation.visibleRows.map(\.file) }
|
|
var selectedFiles: [RemoteFile] {
|
|
directoryPresentation.visibleRows.filter { selection.contains($0.id) }.map(\.file)
|
|
}
|
|
|
|
/// Returns whether a new single selection was made. The view owns the
|
|
/// corresponding keyboard anchor and focus changes.
|
|
func selectForContextMenu(_ file: RemoteFile) -> Bool {
|
|
guard !selection.contains(file.id) else { return false }
|
|
selection = [file.id]
|
|
return true
|
|
}
|
|
|
|
func revealExternalFile(_ file: RemoteFile) {
|
|
selection = [file.id]
|
|
}
|
|
|
|
func menuTargets(for file: RemoteFile) -> [RemoteFile] {
|
|
guard selection.contains(file.id), selection.count > 1 else { return [file] }
|
|
return selectedFiles
|
|
}
|
|
|
|
func sort(by field: RemoteFileSortField) {
|
|
var presentation = directoryPresentation
|
|
if presentation.sortField == field {
|
|
presentation.sortDirection.toggle()
|
|
} else {
|
|
presentation.sortField = field
|
|
presentation.sortDirection = field.defaultDirection
|
|
}
|
|
presentation.refresh()
|
|
directoryPresentation = presentation
|
|
}
|
|
|
|
func remoteCopySource(for files: [RemoteFile]) -> SFTPRemoteCopySource? {
|
|
guard status == .connected, let connection = transferConnection, !files.isEmpty else {
|
|
return nil
|
|
}
|
|
return SFTPRemoteCopySource(
|
|
hostID: hostID, connection: connection, files: files,
|
|
homeDirectory: homeResolution.resolvedDirectory
|
|
)
|
|
}
|
|
|
|
private var transferConnection: SFTPTransferConnection? {
|
|
transferContext.map(SFTPTransferConnection.context)
|
|
}
|
|
|
|
func prepareForReopen() {
|
|
history = SFTPHistoryModel()
|
|
startsOnMount = true
|
|
}
|
|
|
|
func connectIfNeeded(using store: any RemoteSessionHostStore) {
|
|
guard startsOnMount, status == .disconnected else { return }
|
|
connect(using: store)
|
|
}
|
|
|
|
func connect(using store: any RemoteSessionHostStore) {
|
|
startsOnMount = false
|
|
startConnection(using: store)
|
|
}
|
|
|
|
private func startConnection(using store: any RemoteSessionHostStore) {
|
|
let reconnecting = connection.hasConnectedOnce && connection.isRecovering
|
|
homeResolution = .pending
|
|
didResolveHomeForConnection = false
|
|
bindStatus(to: store)
|
|
connectionTask?.cancel()
|
|
connection.clearHostKeyPrompt()
|
|
guard let host = connectionHost(hostID, using: store) else {
|
|
connection.stopRecovery()
|
|
let message = String(localized: AppStrings.Connections.theHostIsNoLongerAvailable)
|
|
status = .failed(message)
|
|
homeResolution = .unavailable
|
|
connectionJourney.fail(message)
|
|
return
|
|
}
|
|
let attemptID = UUID()
|
|
connectionAttemptID = attemptID
|
|
let route = connectionRoute?.hosts ?? store.route(for: host)
|
|
let stagedAuthentication = suppliedAuthentications[host.id]
|
|
connectionJourney.begin(
|
|
host: host, route: route, purpose: .sftpChannel,
|
|
username: stagedAuthentication?.username ?? usernameHint
|
|
?? (host.isTailscaleManaged
|
|
? store.savedTailscaleUsername(for: host) : store.identity(for: host)?.username),
|
|
mode: reconnecting ? .reconnection : .connection
|
|
)
|
|
if host.isTailscaleManaged, stagedAuthentication == nil, usernameHint == nil,
|
|
store.savedTailscaleUsername(for: host) == nil
|
|
{
|
|
requestAuthentication(host: host, store: store)
|
|
return
|
|
}
|
|
if let proxyHost = suppliedProxyAuthentications.firstHostRequiringProxyPassword(in: route) {
|
|
requestProxyAuthentication(host: proxyHost, store: store)
|
|
return
|
|
}
|
|
if let passwordHost = suppliedAuthentications.firstHostRequiringPassword(in: route) {
|
|
requestAuthentication(
|
|
host: passwordHost, store: store,
|
|
requiresPassphrase: false
|
|
)
|
|
return
|
|
}
|
|
if let passphraseHost = suppliedAuthentications.firstHostRequiringPassphrase(
|
|
in: route, key: store.key, requiresEntry: store.requiresPassphraseEntry
|
|
) {
|
|
requestAuthentication(
|
|
host: passphraseHost, store: store,
|
|
requiresPassphrase: true, preserveSubmission: true
|
|
)
|
|
return
|
|
}
|
|
if reconnecting, usesEphemeralCredentials {
|
|
requestAuthentication(host: host, store: store)
|
|
return
|
|
}
|
|
var attemptAuthentications = suppliedAuthentications.takeAuthenticationSecrets()
|
|
if let usernameHint, attemptAuthentications[host.id] == nil {
|
|
attemptAuthentications[host.id] = SSHAuthenticationSubmission(username: usernameHint)
|
|
}
|
|
let attemptProxyAuthentications = suppliedProxyAuthentications.takeProxyAuthenticationSecrets()
|
|
let suppliedAuthentication = attemptAuthentications[host.id]
|
|
status = reconnecting ? .reconnecting : .connecting
|
|
hint =
|
|
reconnecting
|
|
? String(localized: AppStrings.Connections.reconnecting)
|
|
: String(localized: AppStrings.Connections.connecting)
|
|
connectionTask = Task { [weak self, weak store] in
|
|
guard let self, let store else { return }
|
|
do {
|
|
let configuration: SSHConnectionConfiguration
|
|
if let suppliedAuthentication, !host.isTailscaleManaged {
|
|
let expectedHostKeys = try store.expectedHostKeys(
|
|
for: host, temporaryHostKeys: temporaryHostKeys,
|
|
trustPolicy: .allowChallenge
|
|
)
|
|
var credentials: [SSHCredentials] = []
|
|
if let keyID = suppliedAuthentication.keyID,
|
|
let key = store.key(keyID),
|
|
let stored = try? store.connectionKeySecretCredential(for: key)
|
|
{
|
|
credentials.append(
|
|
stored.sshCredential(
|
|
for: key, passphraseOverride: suppliedAuthentication.passphraseForConnection()
|
|
))
|
|
}
|
|
if let password = suppliedAuthentication.passwordForConnection() {
|
|
credentials.append(.password(password))
|
|
} else if let identityID = suppliedAuthentication.identityID,
|
|
let selectedIdentity = store.identity(identityID), selectedIdentity.usesPassword
|
|
{
|
|
guard let password = try store.connectionPasswordSecret(for: selectedIdentity) else {
|
|
throw TermindSSHError.missingCredential
|
|
}
|
|
credentials.append(.password(password))
|
|
}
|
|
configuration = SSHConnectionConfiguration(
|
|
hostID: host.id, host: host.host, port: host.port,
|
|
username: suppliedAuthentication.username,
|
|
credentials: credentials,
|
|
expectedHostKeys: expectedHostKeys,
|
|
proxy: try store.transportProxy(
|
|
for: host, submission: attemptProxyAuthentications[host.id]),
|
|
jumps: try connectionRoute.map {
|
|
try store.sshJumpConfigurations(
|
|
for: $0, submissions: attemptAuthentications,
|
|
proxySubmissions: attemptProxyAuthentications,
|
|
temporaryHostKeys: temporaryHostKeys,
|
|
trustPolicy: .allowChallenge)
|
|
}
|
|
?? store.sshJumpConfigurations(
|
|
for: host, submissions: attemptAuthentications,
|
|
proxySubmissions: attemptProxyAuthentications,
|
|
temporaryHostKeys: temporaryHostKeys,
|
|
trustPolicy: .allowChallenge),
|
|
charset: host.charset,
|
|
authenticationReference: SSHAuthenticationReference(
|
|
identityID: suppliedAuthentication.identityID,
|
|
fallbackKeyID: suppliedAuthentication.identityID == nil
|
|
? suppliedAuthentication.keyID : nil,
|
|
username: suppliedAuthentication.username,
|
|
permitsNone: suppliedAuthentication.keyID == nil
|
|
&& !suppliedAuthentication.hasPassword,
|
|
hasEphemeralSecret: suppliedAuthentication.hasPassword
|
|
|| suppliedAuthentication.hasPassphrase
|
|
)
|
|
)
|
|
} else {
|
|
configuration = try store.sshConnectionConfiguration(
|
|
for: host, submissions: attemptAuthentications,
|
|
proxySubmissions: attemptProxyAuthentications,
|
|
temporaryHostKeys: temporaryHostKeys,
|
|
trustPolicy: .allowChallenge
|
|
)
|
|
}
|
|
var routedConfiguration = configuration
|
|
routedConfiguration.network = try store.tailscaleNetwork(
|
|
for: (connectionRoute?.hosts ?? store.route(for: host)).first ?? host)
|
|
routedConfiguration.tailscaleNodeID = host.tailscale?.stableNodeID
|
|
let coordinator = self.connectionCoordinator ?? RemoteConnectionCoordinator()
|
|
let client = try await coordinator.connectSFTP(
|
|
using: SSHConnectionRequest(
|
|
configuration: routedConfiguration,
|
|
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.connectionAttemptID == attemptID else {
|
|
throw CancellationError()
|
|
}
|
|
return try await self.connection.answerKeyboardInteractive(challenge)
|
|
},
|
|
securityKeySigning: { [weak self] request in
|
|
guard let self, await self.connectionAttemptID == attemptID else {
|
|
throw CancellationError()
|
|
}
|
|
return try await self.connection.signWithSecurityKey(request)
|
|
},
|
|
hostKeyConstraints: connectionOrigin.hostKeyConstraints
|
|
),
|
|
progress: connection.progressHandler(attemptID: attemptID),
|
|
connectionFailure: { [weak self] message in
|
|
Task { @MainActor in
|
|
self?.handleConnectionFailure(message, attemptID: attemptID)
|
|
}
|
|
}
|
|
)
|
|
guard !Task.isCancelled, self.connectionAttemptID == attemptID else {
|
|
await client.disconnect()
|
|
return
|
|
}
|
|
do {
|
|
try await self.resolveInitialPathIfNeeded(using: client)
|
|
} catch {
|
|
await client.disconnect()
|
|
throw error
|
|
}
|
|
guard !Task.isCancelled, self.connectionAttemptID == attemptID else {
|
|
await client.disconnect()
|
|
return
|
|
}
|
|
self.connectionJourney.receive(.connected(host.id))
|
|
self.client = client
|
|
let additionalSnapshot = store.additionalConnectionSnapshot(from: configuration)
|
|
self.transferContext = SFTPTransferContext(
|
|
snapshot: additionalSnapshot, hostStore: store, channel: client)
|
|
self.additionalSnapshot = additionalSnapshot
|
|
self.suppliedAuthentications.removeAll()
|
|
self.suppliedProxyAuthentications.removeAll()
|
|
self.temporaryHostKeys.removeAll()
|
|
self.pendingAuthentication = nil
|
|
let presentsConnectionJourney = self.connectionJourney.isPresented
|
|
_ = self.connection.markConnected()
|
|
self.status = .connected
|
|
if presentsConnectionJourney {
|
|
self.connectionJourney.succeed()
|
|
} else {
|
|
self.connectionJourney.hide()
|
|
}
|
|
self.hint = ""
|
|
await self.openExternalResourceIfNeeded(using: client)
|
|
self.startAutomaticRefresh()
|
|
} catch is CancellationError {
|
|
guard self.connectionAttemptID == attemptID else { return }
|
|
self.status = .disconnected
|
|
self.homeResolution = .unavailable
|
|
self.connection.stopRecovery()
|
|
self.connectionJourney.fail(String(localized: AppStrings.Connections.connectionCancelled))
|
|
} catch where host.isTailscaleManaged {
|
|
guard self.connectionAttemptID == attemptID else { return }
|
|
let username = suppliedAuthentication?.username ?? usernameHint ?? store.tailscaleUsername(for: host)
|
|
let message = String(
|
|
localized: AppStrings.Tailscale.sftpFailure(
|
|
username: username, address: host.host, detail: error.localizedDescription))
|
|
self.pendingAuthentication = nil
|
|
self.connection.stopRecovery()
|
|
self.status = .failed(message)
|
|
self.homeResolution = .unavailable
|
|
self.hint = message
|
|
self.connectionJourney.fail(message, at: host.id)
|
|
} catch TermindSSHError.missingCredential,
|
|
TermindSSHError.missingUsername,
|
|
TermindSSHError.keyPassphraseRequired
|
|
{
|
|
guard self.connectionAttemptID == attemptID else { return }
|
|
self.requestAuthentication(host: host, store: store)
|
|
} catch let error as TermindSSHError where error.shouldRequestAuthentication {
|
|
guard self.connectionAttemptID == attemptID else { return }
|
|
let authenticationHost: Host
|
|
if let id = error.routeHostID, let jump = self.connectionHost(id, using: store) {
|
|
authenticationHost = jump
|
|
} else {
|
|
authenticationHost = host
|
|
}
|
|
if error.isProxyAuthentication {
|
|
self.requestProxyAuthentication(
|
|
host: authenticationHost, store: store,
|
|
failure: error.localizedDescription,
|
|
mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity)
|
|
return
|
|
}
|
|
let submitted = self.suppliedAuthentications[authenticationHost.id]
|
|
let retryNeedsPassphrase =
|
|
submitted?.requiresPasswordReentry != true
|
|
&& submitted?.hasPassphrase != true
|
|
&& (submitted?.keyID ?? store.identity(for: authenticationHost)?.keyID)
|
|
.flatMap { store.key($0) }?.requiresPassphraseEntry == true
|
|
let returnsToIdentity = submitted?.requiresPassphraseReentry == true
|
|
self.requestAuthentication(
|
|
host: authenticationHost, store: store,
|
|
failure: error.localizedDescription,
|
|
mode: !returnsToIdentity && error.isServerAuthenticationChallenge
|
|
? .serverChallenge : .localIdentity,
|
|
requiresPassphrase: retryNeedsPassphrase,
|
|
preserveSubmission: retryNeedsPassphrase && submitted != nil
|
|
)
|
|
} catch {
|
|
guard self.connectionAttemptID == attemptID else { return }
|
|
if self.connection.hasConnectedOnce || reconnecting {
|
|
self.scheduleReconnect(after: error.localizedDescription, using: store)
|
|
} else {
|
|
self.status = .failed(error.localizedDescription)
|
|
self.homeResolution = .unavailable
|
|
self.hint = error.localizedDescription
|
|
self.connectionJourney.fail(
|
|
error.localizedDescription,
|
|
at: (error as? TermindSSHError)?.routeHostID
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func bindStatus(to store: any RemoteSessionHostStore) {
|
|
statusStore = store
|
|
transferContext?.configureHostStore(store)
|
|
store.setConnectionStatus(status, connectionID: id, hostID: hostID)
|
|
}
|
|
|
|
func bindTransfers(to coordinator: SFTPTransferCoordinator) {
|
|
transferCoordinator = coordinator
|
|
}
|
|
|
|
func disconnect() {
|
|
startsOnMount = false
|
|
cancelPendingBatch()
|
|
connectionTask?.cancel()
|
|
connectionTask = nil
|
|
connectionAttemptID = nil
|
|
connection.disconnect()
|
|
automaticRefreshTask?.cancel()
|
|
automaticRefreshTask = nil
|
|
uploadRefreshTask?.cancel()
|
|
uploadRefreshTask = nil
|
|
let client = client
|
|
let transferContext = transferContext
|
|
self.transferContext = nil
|
|
self.client = nil
|
|
additionalSnapshot = nil
|
|
usesEphemeralCredentials = false
|
|
status = .disconnected
|
|
homeResolution = .unavailable
|
|
replaceDirectoryContents(with: [])
|
|
Task {
|
|
if let transferContext { await transferContext.releaseOwner() } else { await client?.disconnect() }
|
|
}
|
|
}
|
|
|
|
func retryReconnectNow() {
|
|
reconnectController.retryNow()
|
|
}
|
|
|
|
func stopReconnecting() {
|
|
guard let reconnectState else { return }
|
|
connection.stopRecovery()
|
|
connectionAttemptID = nil
|
|
connectionTask?.cancel()
|
|
connectionTask = nil
|
|
automaticRefreshTask?.cancel()
|
|
automaticRefreshTask = nil
|
|
let client = client
|
|
let transferContext = transferContext
|
|
self.transferContext = nil
|
|
self.client = nil
|
|
additionalSnapshot = nil
|
|
status = .failed(reconnectState.lastError)
|
|
homeResolution = .unavailable
|
|
hint = reconnectState.lastError
|
|
connectionJourney.fail(reconnectState.lastError)
|
|
Task {
|
|
if let transferContext { await transferContext.retire() } else { await client?.disconnect() }
|
|
}
|
|
}
|
|
|
|
func resolveInitialPathIfNeeded(using client: any SFTPChannelProtocol) async throws {
|
|
guard !didResolveHomeForConnection else { return }
|
|
didResolveHomeForConnection = true
|
|
do {
|
|
let proposed = try await client.homeDirectory()
|
|
guard proposed.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("/") else {
|
|
throw SFTPError.operation(String(localized: AppStrings.SFTP.homeDirectoryMustBeAbsolute))
|
|
}
|
|
let home = RemotePath.normalized(proposed)
|
|
homeResolution = .resolved(home)
|
|
if initialPath.isEmpty {
|
|
path = home
|
|
pathText = home
|
|
}
|
|
} catch {
|
|
homeResolution = .unavailable
|
|
guard !initialPath.isEmpty else { throw error }
|
|
}
|
|
navigationRouter?.sftpChannel(self, didChangePath: path)
|
|
}
|
|
|
|
private func openExternalResourceIfNeeded(using client: any SFTPChannelProtocol) async {
|
|
guard let requested = externalResourcePath else {
|
|
await reload()
|
|
return
|
|
}
|
|
externalResourcePath = nil
|
|
let resolved: String
|
|
if requested == "/~" || requested.hasPrefix("/~/") {
|
|
guard case .resolved(let home) = homeResolution else {
|
|
operationError = String(localized: AppStrings.SFTP.theRemoteHomeDirectoryIsUnavailable)
|
|
return
|
|
}
|
|
resolved = requested == "/~" ? home : RemotePath.join(home, String(requested.dropFirst(3)))
|
|
} else {
|
|
resolved = RemotePath.normalized(requested)
|
|
}
|
|
do {
|
|
guard let resource = try await client.attributes(at: resolved) else {
|
|
operationError = String(localized: AppStrings.SFTP.theRemoteFileIsNoLongerAvailable)
|
|
return
|
|
}
|
|
if resource.isDirectory {
|
|
path = resource.path
|
|
pathText = resource.path
|
|
navigationRouter?.sftpChannel(self, didChangePath: path)
|
|
await reload()
|
|
return
|
|
}
|
|
guard !externalResourceMustBeDirectory else {
|
|
operationError = String(localized: AppStrings.SFTP.theSftpLinkRequiresADirectoryButItsPathIsAFile)
|
|
path = RemotePath.parent(of: resource.path)
|
|
pathText = path
|
|
await reload()
|
|
return
|
|
}
|
|
path = RemotePath.parent(of: resource.path)
|
|
pathText = path
|
|
navigationRouter?.sftpChannel(self, didChangePath: path)
|
|
await reload()
|
|
openExternalFile?(resource)
|
|
} catch {
|
|
operationError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
func trustAndConnect(_ observation: SSHHostKeyObservation, using store: any RemoteSessionHostStore) {
|
|
connection.trustHostKey(observation, using: store)
|
|
}
|
|
|
|
func continueWithTemporaryHostKey(_ observation: SSHHostKeyObservation) {
|
|
_ = connection.useTemporaryHostKey(observation)
|
|
}
|
|
|
|
func continueAuthentication(
|
|
identityID: Identity.ID?, username: String, password: String?, passphrase: String?,
|
|
keyID: SSHKeyRef.ID?, save: Bool, using store: any RemoteSessionHostStore
|
|
) {
|
|
connection.continueAuthentication(
|
|
identityID: identityID, username: username, password: password, passphrase: passphrase,
|
|
keyID: keyID, save: save, destinationID: hostID, using: store
|
|
) { _, submission in
|
|
usesEphemeralCredentials =
|
|
!save
|
|
&& (password != nil || passphrase != nil
|
|
|| (submission.identityID == nil && submission.keyID == nil))
|
|
} saveIdentity: { host, submission, selectedIdentity in
|
|
_ = try store.saveAuthentication(
|
|
submission, for: host, selectedIdentity: selectedIdentity, suppliedPassword: password)
|
|
} onFailure: { failure in
|
|
switch failure {
|
|
case .proxy(let host, let error):
|
|
requestProxyAuthentication(host: host, store: store, failure: error.localizedDescription)
|
|
case .identity(let host, _, _, let saving, let error):
|
|
if saving { operationError = error.localizedDescription }
|
|
requestAuthentication(host: host, store: store, failure: error.localizedDescription)
|
|
}
|
|
} connect: {
|
|
connect(using: store)
|
|
}
|
|
}
|
|
|
|
func navigate(to destination: String) {
|
|
let destination = RemotePath.normalized(destination)
|
|
guard destination != path else {
|
|
reloadNow()
|
|
return
|
|
}
|
|
backHistory.append(path)
|
|
path = destination
|
|
pathText = destination
|
|
forwardHistory.removeAll()
|
|
selection.removeAll()
|
|
reloadNow()
|
|
}
|
|
|
|
func goBack() {
|
|
guard let destination = backHistory.popLast() else { return }
|
|
forwardHistory.append(path)
|
|
path = destination
|
|
pathText = destination
|
|
selection.removeAll()
|
|
reloadNow()
|
|
}
|
|
|
|
func goForward() {
|
|
guard let destination = forwardHistory.popLast() else { return }
|
|
backHistory.append(path)
|
|
path = destination
|
|
pathText = destination
|
|
selection.removeAll()
|
|
reloadNow()
|
|
}
|
|
|
|
func commitPathText() { navigate(to: pathText) }
|
|
func reloadNow() { Task { await reload() } }
|
|
|
|
func reload(silently: Bool = false) async {
|
|
guard let client, status == .connected else { return }
|
|
let requestedPath = path
|
|
let requestedHistory = history
|
|
let requestedAttempt = connectionAttemptID
|
|
if !silently {
|
|
isLoading = true
|
|
hint = String(localized: AppStrings.SFTP.loading)
|
|
}
|
|
defer { if !silently { isLoading = false } }
|
|
do {
|
|
let snapshot = try await client.listDirectory(at: requestedPath)
|
|
guard status == .connected, path == requestedPath, connectionAttemptID == requestedAttempt,
|
|
history === requestedHistory
|
|
else { return }
|
|
replaceDirectoryContents(with: snapshot)
|
|
if !silently {
|
|
history.record(kind: .directory, path: requestedPath)
|
|
}
|
|
hint = String(localized: AppStrings.SFTP.items(snapshot.count))
|
|
} catch let error as SFTPError {
|
|
if case .connectionLost(let message) = error {
|
|
handleConnectionFailure(message, attemptID: connectionAttemptID)
|
|
return
|
|
}
|
|
if case .notConnected = error {
|
|
handleConnectionFailure(
|
|
error.localizedDescription, attemptID: connectionAttemptID
|
|
)
|
|
return
|
|
}
|
|
guard !silently else { return }
|
|
operationError = error.localizedDescription
|
|
hint = error.localizedDescription
|
|
} catch {
|
|
guard !silently else { return }
|
|
operationError = error.localizedDescription
|
|
hint = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
func startAutomaticRefresh() {
|
|
guard status == .connected, automaticRefreshTask == nil else { return }
|
|
let interval = automaticRefreshInterval
|
|
automaticRefreshTask = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
do { try await Task.sleep(for: interval) } catch { return }
|
|
guard let self else { return }
|
|
await self.reload(silently: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Publishes rows and their valid selection in one observable mutation so
|
|
/// SwiftUI never receives an intermediate table state.
|
|
private func replaceDirectoryContents(with files: [RemoteFile]) {
|
|
var presentation = directoryPresentation
|
|
presentation.files = files
|
|
presentation.refresh()
|
|
directoryPresentation = presentation
|
|
}
|
|
|
|
func scheduleSilentRefresh(after delay: Duration = .milliseconds(250)) {
|
|
uploadRefreshTask?.cancel()
|
|
uploadRefreshTask = Task { [weak self] in
|
|
do { try await Task.sleep(for: delay) } catch { return }
|
|
await self?.reload(silently: true)
|
|
}
|
|
}
|
|
|
|
func createFolder(named name: String) async {
|
|
guard let client else { return }
|
|
do {
|
|
try await client.createDirectory(named: name, in: path)
|
|
await reload()
|
|
} catch { operationError = error.localizedDescription }
|
|
}
|
|
|
|
func rename(_ file: RemoteFile, to name: String) async {
|
|
guard let client else { return }
|
|
do {
|
|
try await client.rename(file, to: name)
|
|
await reload()
|
|
} catch { operationError = error.localizedDescription }
|
|
}
|
|
|
|
@discardableResult
|
|
func updatePermissions(for file: RemoteFile, to permissions: UInt32) async -> Bool {
|
|
guard status == .connected, let client else {
|
|
operationError = String(localized: AppStrings.SFTP.theSftpConnectionIsNotAvailable)
|
|
return false
|
|
}
|
|
do {
|
|
try await client.setPermissions(of: file, to: permissions)
|
|
await reload()
|
|
return true
|
|
} catch {
|
|
operationError = error.localizedDescription
|
|
return false
|
|
}
|
|
}
|
|
|
|
func delete(_ files: [RemoteFile]) async {
|
|
guard let client else { return }
|
|
let cancellation = TransferCancellationToken()
|
|
operationCancellation = cancellation
|
|
canCancelOperation = true
|
|
isLoading = true
|
|
do {
|
|
for file in files {
|
|
try await deleteRecursively(file, client: client, cancellation: cancellation)
|
|
}
|
|
selection.subtract(files.map(\.id))
|
|
await reload()
|
|
} catch SFTPError.cancelled {
|
|
hint = String(localized: AppStrings.SFTP.deletionCancelled)
|
|
isLoading = false
|
|
} catch {
|
|
operationError = error.localizedDescription
|
|
isLoading = false
|
|
}
|
|
operationCancellation = nil
|
|
canCancelOperation = false
|
|
}
|
|
|
|
func cancelOperation() { operationCancellation?.cancel() }
|
|
|
|
func cancelPendingBatch() {
|
|
pendingUploadBatch = nil
|
|
pendingDownloadBatch = nil
|
|
pendingRemoteCopyBatch = nil
|
|
}
|
|
|
|
func prepareUpload(_ urls: [URL]) async {
|
|
guard let client else { return }
|
|
do {
|
|
let batch = try await SFTPLocalUploadPlanner.prepare(
|
|
urls,
|
|
destination: path,
|
|
client: client
|
|
)
|
|
pendingDownloadBatch = nil
|
|
pendingRemoteCopyBatch = nil
|
|
pendingUploadBatch = batch.isEmpty ? nil : batch
|
|
} catch {
|
|
pendingUploadBatch = nil
|
|
operationError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
func prepareDownload(_ files: [RemoteFile], to directory: URL) async {
|
|
guard let client else { return }
|
|
do {
|
|
pendingUploadBatch = nil
|
|
let batch = try await SFTPRemoteDownloadPlanner.prepare(
|
|
files, destination: directory, client: client
|
|
)
|
|
pendingRemoteCopyBatch = nil
|
|
pendingDownloadBatch = batch
|
|
} catch { operationError = error.localizedDescription }
|
|
}
|
|
|
|
func prepareRemoteCopy(
|
|
_ payload: SFTPRemoteItemsPayload,
|
|
coordinator: SFTPTransferCoordinator
|
|
) async {
|
|
bindTransfers(to: coordinator)
|
|
guard let client else { return }
|
|
do {
|
|
pendingUploadBatch = nil
|
|
let batch = try await SFTPRemoteCopyPlanner.prepare(
|
|
payload, destination: path, destinationHostID: hostID,
|
|
client: client, coordinator: coordinator
|
|
)
|
|
pendingDownloadBatch = nil
|
|
pendingRemoteCopyBatch = batch
|
|
} catch {
|
|
operationError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
func open(
|
|
_ file: RemoteFile, coordinator: SFTPTransferCoordinator,
|
|
edits: SFTPEditService? = nil, completion: @escaping (URL) -> Void
|
|
) {
|
|
bindTransfers(to: coordinator)
|
|
guard !file.isDirectory, let connection = transferConnection else { return }
|
|
if let edits, let client {
|
|
Task {
|
|
do {
|
|
let url = try await edits.open(
|
|
file, hostID: hostID, connection: connection, client: client,
|
|
homeDirectory: homeResolution.resolvedDirectory, snapshot: additionalSnapshot)
|
|
completion(url)
|
|
} catch { operationError = error.localizedDescription }
|
|
}
|
|
return
|
|
}
|
|
let directory = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("Termind", isDirectory: true)
|
|
.appendingPathComponent("Open", isDirectory: true)
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
let localURL = directory.appendingPathComponent(file.name)
|
|
do {
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
coordinator.enqueue(
|
|
direction: .download, batchID: UUID(), localURL: localURL,
|
|
remotePath: file.path, byteCount: file.size, hostID: hostID,
|
|
connection: connection, scopedRoot: directory, overwrite: false,
|
|
homeDirectory: homeResolution.resolvedDirectory
|
|
) { state in
|
|
if state == .completed {
|
|
completion(localURL)
|
|
}
|
|
}
|
|
} catch { operationError = error.localizedDescription }
|
|
}
|
|
|
|
func uploadEdit(_ id: UUID, using edits: SFTPEditService, replacing: Bool) async -> Bool {
|
|
guard status == .connected, let connection = transferConnection, let client else { return false }
|
|
return await edits.upload(
|
|
id, connection: connection, client: client,
|
|
homeDirectory: homeResolution.resolvedDirectory, replacing: replacing, snapshot: additionalSnapshot
|
|
) { [weak self] in self?.scheduleSilentRefresh() }
|
|
}
|
|
|
|
func recordOpenedFile(at path: String) {
|
|
history.record(kind: .file, path: path)
|
|
}
|
|
|
|
func reopenHistoryFile(
|
|
at path: String, coordinator: SFTPTransferCoordinator,
|
|
edits: SFTPEditService? = nil, completion: @escaping (URL) -> Void
|
|
) {
|
|
guard let client, status == .connected else {
|
|
operationError = String(localized: AppStrings.SFTP.theSftpConnectionIsNotAvailable)
|
|
return
|
|
}
|
|
Task {
|
|
do {
|
|
guard let file = try await client.attributes(at: path), !file.isDirectory else {
|
|
operationError = String(localized: AppStrings.SFTP.theRemoteFileIsNoLongerAvailable)
|
|
return
|
|
}
|
|
open(file, coordinator: coordinator, edits: edits, completion: completion)
|
|
} catch {
|
|
operationError = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
func downloadPromisedFile(
|
|
_ file: RemoteFile, to destinationDirectory: URL,
|
|
coordinator: SFTPTransferCoordinator,
|
|
completion: @escaping ((any Error)?) -> Void
|
|
) {
|
|
bindTransfers(to: coordinator)
|
|
guard !file.isDirectory, let connection = transferConnection else {
|
|
completion(SFTPError.notConnected)
|
|
return
|
|
}
|
|
let localURL = destinationDirectory.appendingPathComponent(file.name)
|
|
coordinator.enqueue(
|
|
direction: .download, batchID: UUID(), localURL: localURL,
|
|
remotePath: file.path, byteCount: file.size, hostID: hostID,
|
|
connection: connection, scopedRoot: destinationDirectory,
|
|
overwrite: false, homeDirectory: homeResolution.resolvedDirectory
|
|
) { state in
|
|
switch state {
|
|
case .completed:
|
|
completion(nil)
|
|
case .cancelled:
|
|
completion(SFTPError.cancelled)
|
|
case .failed(let message):
|
|
completion(SFTPError.operation(message))
|
|
case .queued, .running, .paused:
|
|
completion(SFTPError.operation(String(localized: AppStrings.SFTP.promisedDownloadIncomplete)))
|
|
}
|
|
}
|
|
}
|
|
|
|
func enqueuePending(
|
|
policy: TransferConflictPolicy, coordinator: SFTPTransferCoordinator,
|
|
uploadedPaths: (([String]) -> Void)? = nil
|
|
) async {
|
|
bindTransfers(to: coordinator)
|
|
if let batch = pendingUploadBatch {
|
|
guard let connection = transferConnection, let client else { return }
|
|
pendingUploadBatch = nil
|
|
do {
|
|
let result = try await SFTPLocalUploadPlanner.enqueue(
|
|
batch,
|
|
policy: policy,
|
|
hostID: hostID,
|
|
connection: connection,
|
|
homeDirectory: homeResolution.resolvedDirectory,
|
|
client: client,
|
|
coordinator: coordinator,
|
|
uploadedPaths: uploadedPaths
|
|
) { [weak self] state in
|
|
if state == .completed { self?.scheduleSilentRefresh() }
|
|
}
|
|
if result.changedRemoteDirectories { scheduleSilentRefresh() }
|
|
} catch {
|
|
operationError = error.localizedDescription
|
|
}
|
|
return
|
|
}
|
|
guard let batch = pendingDownloadBatch, let connection = transferConnection, client != nil else {
|
|
return
|
|
}
|
|
pendingDownloadBatch = nil
|
|
do {
|
|
try SFTPRemoteDownloadPlanner.enqueue(
|
|
batch, policy: policy, hostID: hostID, connection: connection,
|
|
homeDirectory: homeResolution.resolvedDirectory, coordinator: coordinator
|
|
)
|
|
} catch { operationError = error.localizedDescription }
|
|
}
|
|
|
|
func enqueuePendingRemoteCopy(
|
|
policy: TransferConflictPolicy,
|
|
coordinator: SFTPTransferCoordinator
|
|
) async {
|
|
bindTransfers(to: coordinator)
|
|
guard let batch = pendingRemoteCopyBatch,
|
|
let destinationConnection = transferConnection,
|
|
let client
|
|
else { return }
|
|
pendingRemoteCopyBatch = nil
|
|
do {
|
|
let changedDirectories = try await SFTPRemoteCopyPlanner.enqueue(
|
|
batch, policy: policy, destinationHostID: hostID,
|
|
destinationConnection: destinationConnection,
|
|
destinationHomeDirectory: homeResolution.resolvedDirectory,
|
|
client: client, coordinator: coordinator
|
|
) { [weak self] state in
|
|
if state == .completed { self?.scheduleSilentRefresh() }
|
|
}
|
|
if changedDirectories { scheduleSilentRefresh() }
|
|
} catch {
|
|
operationError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
func copyForBrowsing() -> SFTPChannelModel {
|
|
let copy = SFTPChannelModel(hostID: hostID, initialPath: initialPath)
|
|
copy.connectionCoordinator = connectionCoordinator
|
|
return copy
|
|
}
|
|
|
|
private func requestAuthentication(
|
|
host: Host, store: any RemoteSessionHostStore, failure: String? = nil,
|
|
mode: SSHAuthenticationPrompt.Mode = .localIdentity,
|
|
requiresPassphrase: Bool? = nil,
|
|
preserveSubmission: Bool = false
|
|
) {
|
|
connection.pauseForUserInteraction()
|
|
if !preserveSubmission { suppliedAuthentications[host.id] = nil }
|
|
let submission = suppliedAuthentications[host.id]
|
|
let selectedKeyID = submission?.keyID ?? store.identity(for: host)?.keyID
|
|
let username = submission?.username ?? store.identity(for: host)?.username ?? ""
|
|
let needsPassphrase =
|
|
requiresPassphrase
|
|
?? (!username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
&& selectedKeyID.flatMap { store.key($0) }.map { store.requiresPassphraseEntry(for: $0) } == true)
|
|
pendingAuthentication = SSHAuthenticationPromptResolver.host(
|
|
host, using: store, submission: submission, username: username,
|
|
selectedKeyID: selectedKeyID,
|
|
failureMessage: failure,
|
|
mode: mode, requiresPassphrase: needsPassphrase,
|
|
allowsSaving: connectionOrigin.allowsPersistence
|
|
)
|
|
status = .disconnected
|
|
if host.isTailscaleManaged {
|
|
connectionJourney.waitForTailscaleUsername(at: host.id, failure: failure)
|
|
return
|
|
}
|
|
switch mode {
|
|
case .localIdentity:
|
|
if pendingAuthentication?.requiresPassphrase == true {
|
|
connectionJourney.waitForLocalPassphrase(at: host.id, failure: failure)
|
|
} else {
|
|
connectionJourney.waitForLocalIdentity(at: host.id, failure: failure)
|
|
}
|
|
case .serverChallenge:
|
|
connectionJourney.waitForAuthentication(at: host.id, failure: failure)
|
|
}
|
|
}
|
|
|
|
private func requestProxyAuthentication(
|
|
host: Host, store: any RemoteSessionHostStore, failure: String? = nil,
|
|
mode: SSHAuthenticationPrompt.Mode = .localIdentity
|
|
) {
|
|
guard host.proxy != nil else { return }
|
|
connection.pauseForUserInteraction()
|
|
let staged = suppliedProxyAuthentications.removeValue(forKey: host.id)
|
|
pendingAuthentication = SSHAuthenticationPromptResolver.proxy(
|
|
for: host, using: store, submission: staged,
|
|
failureMessage: failure, mode: mode,
|
|
allowsSaving: connectionOrigin.allowsPersistence)
|
|
status = .disconnected
|
|
connectionJourney.waitForProxyAuthentication(at: host.id, failure: failure)
|
|
}
|
|
|
|
private func handleConnectionFailure(_ message: String, attemptID: UUID?) {
|
|
guard connection.hasConnectedOnce, connectionAttemptID == attemptID,
|
|
let store = statusStore
|
|
else { return }
|
|
scheduleReconnect(after: message, using: store)
|
|
}
|
|
|
|
private func scheduleReconnect(after message: String, using store: any RemoteSessionHostStore) {
|
|
connection.scheduleReconnect(
|
|
after: message, hostID: hostID, using: store,
|
|
purpose: .sftpChannel
|
|
) {
|
|
connectionAttemptID = nil
|
|
connectionTask?.cancel()
|
|
connectionTask = nil
|
|
automaticRefreshTask?.cancel()
|
|
automaticRefreshTask = nil
|
|
uploadRefreshTask?.cancel()
|
|
uploadRefreshTask = nil
|
|
let client = client
|
|
let transferContext = transferContext
|
|
self.transferContext = nil
|
|
self.client = nil
|
|
additionalSnapshot = nil
|
|
status = .reconnecting
|
|
hint = String(localized: AppStrings.Connections.reconnecting)
|
|
Task {
|
|
if let transferContext { await transferContext.retire() } else { await client?.disconnect() }
|
|
}
|
|
} retry: { [weak self] store in
|
|
self?.startConnection(using: store)
|
|
}
|
|
}
|
|
|
|
private func connectionHost(_ id: Host.ID, using store: any RemoteSessionHostStore) -> Host? {
|
|
connection.connectionHost(id, destinationID: hostID, using: store)
|
|
}
|
|
|
|
private func deleteRecursively(
|
|
_ file: RemoteFile, client: any SFTPChannelProtocol,
|
|
cancellation: TransferCancellationToken? = nil
|
|
) async throws {
|
|
if cancellation?.isCancelled == true { throw SFTPError.cancelled }
|
|
hint = displayPath(file.path)
|
|
if file.isDirectory {
|
|
for child in try await client.listDirectory(at: file.path) {
|
|
try await deleteRecursively(child, client: client, cancellation: cancellation)
|
|
}
|
|
if cancellation?.isCancelled == true { throw SFTPError.cancelled }
|
|
try await client.removeDirectory(at: file.path)
|
|
} else {
|
|
try await client.removeFile(at: file.path)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Demo data
|
|
|
|
extension SFTPChannelModel {
|
|
/// A populated browser backed only by value data. It deliberately has no
|
|
/// client, so screenshot and preview work can never mutate a remote host.
|
|
static func demo(hostID: Host.ID, path: String = "/etc/nginx") -> SFTPChannelModel {
|
|
let session = SFTPChannelModel(hostID: hostID, initialPath: path)
|
|
session.status = .connected
|
|
let now = Date(timeIntervalSinceReferenceDate: 809_020_800)
|
|
session.replaceDirectoryContents(with: [
|
|
RemoteFile(
|
|
path: "\(path)/conf.d", name: "conf.d", kind: .directory, size: 0,
|
|
modifiedAt: now.addingTimeInterval(-3_600), permissions: 0o755,
|
|
ownerUID: 0, ownerGID: 0
|
|
),
|
|
RemoteFile(
|
|
path: "\(path)/modules-enabled", name: "modules-enabled", kind: .directory, size: 0,
|
|
modifiedAt: now.addingTimeInterval(-10_800), permissions: 0o755,
|
|
ownerUID: 0, ownerGID: 0
|
|
),
|
|
RemoteFile(
|
|
path: "\(path)/sites-available", name: "sites-available", kind: .directory, size: 0,
|
|
modifiedAt: now.addingTimeInterval(-21_600), permissions: 0o755,
|
|
ownerUID: 0, ownerGID: 0
|
|
),
|
|
RemoteFile(
|
|
path: "\(path)/sites-enabled", name: "sites-enabled", kind: .directory, size: 0,
|
|
modifiedAt: now.addingTimeInterval(-43_200), permissions: 0o755,
|
|
ownerUID: 0, ownerGID: 0
|
|
),
|
|
RemoteFile(
|
|
path: "\(path)/snippets", name: "snippets", kind: .directory, size: 0,
|
|
modifiedAt: now.addingTimeInterval(-64_800), permissions: 0o755,
|
|
ownerUID: 0, ownerGID: 0
|
|
),
|
|
RemoteFile(
|
|
path: "\(path)/fastcgi.conf", name: "fastcgi.conf", kind: .file, size: 1_127,
|
|
modifiedAt: now.addingTimeInterval(-82_800), permissions: 0o644,
|
|
ownerUID: 0, ownerGID: 0
|
|
),
|
|
RemoteFile(
|
|
path: "\(path)/fastcgi_params", name: "fastcgi_params", kind: .file, size: 1_005,
|
|
modifiedAt: now.addingTimeInterval(-84_600), permissions: 0o644,
|
|
ownerUID: 0, ownerGID: 0
|
|
),
|
|
RemoteFile(
|
|
path: "\(path)/nginx.conf", name: "nginx.conf", kind: .file, size: 1_842,
|
|
modifiedAt: now.addingTimeInterval(-7_200), permissions: 0o644,
|
|
ownerUID: 0, ownerGID: 0
|
|
),
|
|
RemoteFile(
|
|
path: "\(path)/mime.types", name: "mime.types", kind: .file, size: 5_346,
|
|
modifiedAt: now.addingTimeInterval(-86_400), permissions: 0o644,
|
|
ownerUID: 0, ownerGID: 0
|
|
),
|
|
RemoteFile(
|
|
path: "\(path)/proxy_params", name: "proxy_params", kind: .file, size: 636,
|
|
modifiedAt: now.addingTimeInterval(-172_800), permissions: 0o644,
|
|
ownerUID: 0, ownerGID: 0
|
|
),
|
|
RemoteFile(
|
|
path: "\(path)/uwsgi_params", name: "uwsgi_params", kind: .file, size: 664,
|
|
modifiedAt: now.addingTimeInterval(-259_200), permissions: 0o644,
|
|
ownerUID: 0, ownerGID: 0
|
|
),
|
|
])
|
|
return session
|
|
}
|
|
}
|