Files
Termind/TFM/Features/SFTP/SFTPChannelModel.swift
T
Kaede Akino 8e3f1128da feat(sessions): add tmux persistence and terminal context tracking
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.
2026-10-16 01:04:12 +08:00

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
}
}