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.
321 lines
13 KiB
Swift
321 lines
13 KiB
Swift
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPresentation
|
|
import TermindRemoteInfrastructure
|
|
|
|
struct TerminalUploadConnection {
|
|
let channel: any SFTPChannelProtocol
|
|
let primaryRole: SFTPTransferPrimaryRole
|
|
}
|
|
|
|
enum TerminalUploadConnectionResolver {
|
|
@MainActor
|
|
static func resolve(
|
|
isCurrent: () -> Bool,
|
|
connectIndependent: () async throws -> any SFTPChannelProtocol,
|
|
openPTYSFTPChannel: () async throws -> any SFTPChannelProtocol
|
|
) async throws -> TerminalUploadConnection {
|
|
do {
|
|
return TerminalUploadConnection(
|
|
channel: try await connectIndependent(), primaryRole: .donatedConnection)
|
|
} catch is CancellationError {
|
|
throw CancellationError()
|
|
} catch let error as SFTPError {
|
|
if case .cancelled = error { throw error }
|
|
try Task.checkCancellation()
|
|
guard isCurrent() else { throw CancellationError() }
|
|
return TerminalUploadConnection(
|
|
channel: try await openPTYSFTPChannel(), primaryRole: .ptyConnectionFallback)
|
|
} catch {
|
|
try Task.checkCancellation()
|
|
guard isCurrent() else { throw CancellationError() }
|
|
return TerminalUploadConnection(
|
|
channel: try await openPTYSFTPChannel(), primaryRole: .ptyConnectionFallback)
|
|
}
|
|
}
|
|
}
|
|
|
|
enum TerminalTransferRequest {
|
|
case local([URL])
|
|
case remote(SFTPRemoteItemsPayload, itemCount: Int)
|
|
|
|
var itemCount: Int {
|
|
switch self {
|
|
case .local(let files): files.count
|
|
case .remote(_, let itemCount): itemCount
|
|
}
|
|
}
|
|
|
|
static func remote(
|
|
data: Data,
|
|
coordinator: SFTPTransferCoordinator
|
|
) -> TerminalTransferRequest? {
|
|
guard let payload = try? JSONDecoder().decode(SFTPRemoteItemsPayload.self, from: data),
|
|
let source = coordinator.remoteCopySource(for: payload),
|
|
!source.files.isEmpty
|
|
else { return nil }
|
|
return .remote(payload, itemCount: source.files.count)
|
|
}
|
|
|
|
func prepare(
|
|
on channel: SFTPChannelModel,
|
|
coordinator: SFTPTransferCoordinator
|
|
) async {
|
|
switch self {
|
|
case .local(let files):
|
|
await channel.prepareUpload(files)
|
|
case .remote(let payload, _):
|
|
await channel.prepareRemoteCopy(payload, coordinator: coordinator)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Pending terminal transfers belong to the SSH session, so rebuilding or moving its pane
|
|
/// does not replace preparation, conflict state, or the upload completion destination.
|
|
@Observable @MainActor
|
|
final class TerminalUploadModel {
|
|
struct Target: Equatable {
|
|
let generation: UUID?
|
|
let destination: String
|
|
var context: TerminalContextCoordinator.Ticket? = nil
|
|
}
|
|
|
|
private weak var session: SSHSessionModel?
|
|
private var transfers: SFTPTransferCoordinator?
|
|
private var pendingUploadChannel: SFTPChannelModel?
|
|
private var pendingUploadItemCount = 0
|
|
private var insertUploadedPaths = false
|
|
private(set) var hasConflict = false
|
|
private(set) var notice: WorkspaceUploadNotice?
|
|
private var noticeTask: Task<Void, Never>?
|
|
private var uploadPreparationTask: Task<Void, Never>?
|
|
private var uploadPreparationID: UUID?
|
|
private var uploadEnqueueTask: Task<Void, Never>?
|
|
private var pendingContext: TerminalContextCoordinator.Ticket?
|
|
|
|
init(session: SSHSessionModel) { self.session = session }
|
|
|
|
var conflictCount: Int { pendingUploadChannel?.conflictCount ?? 0 }
|
|
|
|
var uploadTargetProblem: String? {
|
|
TerminalUploadAvailability.problem(
|
|
isConnected: session?.status == .connected,
|
|
hasTransferConfiguration: session?.transferSnapshot != nil,
|
|
isWorkingDirectoryPending: session?.homeResolution.isPending ?? true,
|
|
workingDirectory: session?.workingDirectory)
|
|
}
|
|
|
|
var target: Target? {
|
|
guard uploadTargetProblem == nil, let session, let destination = session.workingDirectory else { return nil }
|
|
return Target(
|
|
generation: session.connectionAttemptID, destination: destination, context: session.directoryContextTicket)
|
|
}
|
|
|
|
@discardableResult
|
|
func receiveFiles(
|
|
_ urls: [URL], selectedTarget: Target? = nil,
|
|
connections: RemoteConnectionCoordinator, transfers: SFTPTransferCoordinator
|
|
) -> Bool {
|
|
if let selectedTarget, selectedTarget != target { return false }
|
|
let files = urls.filter(\.isFileURL)
|
|
guard !files.isEmpty else { return false }
|
|
return prepare(
|
|
.local(files), insertPaths: selectedTarget != nil, connections: connections, transfers: transfers)
|
|
}
|
|
|
|
@discardableResult
|
|
func receiveRemoteDrop(
|
|
_ data: Data, connections: RemoteConnectionCoordinator, transfers: SFTPTransferCoordinator
|
|
) -> Bool {
|
|
guard let request = TerminalTransferRequest.remote(data: data, coordinator: transfers) else {
|
|
showUploadNotice(.rejected(String(localized: AppStrings.SFTP.theCopiedSftpItemsAreInvalid)))
|
|
return false
|
|
}
|
|
return prepare(request, insertPaths: false, connections: connections, transfers: transfers)
|
|
}
|
|
|
|
private func prepare(
|
|
_ request: TerminalTransferRequest, insertPaths: Bool,
|
|
connections: RemoteConnectionCoordinator, transfers: SFTPTransferCoordinator
|
|
) -> Bool {
|
|
guard let sshSession = session else { return false }
|
|
cancelPendingUpload()
|
|
insertUploadedPaths = insertPaths
|
|
guard uploadTargetProblem == nil,
|
|
let snapshot = sshSession.transferSnapshot,
|
|
let workingDirectory = sshSession.workingDirectory
|
|
else {
|
|
showUploadNotice(
|
|
.rejected(
|
|
uploadTargetProblem
|
|
?? String(localized: AppStrings.SFTP.theUploadDestinationIsUnavailable)
|
|
)
|
|
)
|
|
return false
|
|
}
|
|
|
|
self.transfers = transfers
|
|
let destination = RemotePath.normalized(workingDirectory)
|
|
let generation = sshSession.connectionAttemptID
|
|
let context = sshSession.directoryContextTicket
|
|
pendingContext = context
|
|
let preparationID = UUID()
|
|
uploadPreparationID = preparationID
|
|
pendingUploadItemCount = request.itemCount
|
|
notice = .preparing(destination)
|
|
|
|
uploadPreparationTask = Task {
|
|
defer {
|
|
if uploadPreparationID == preparationID {
|
|
uploadPreparationTask = nil
|
|
uploadPreparationID = nil
|
|
}
|
|
}
|
|
var connectedChannel: (any SFTPChannelProtocol)?
|
|
do {
|
|
try await sshSession.confirmDirectoryContext(context)
|
|
let resolved = try await TerminalUploadConnectionResolver.resolve(
|
|
isCurrent: { sshSession.connectionAttemptID == generation },
|
|
connectIndependent: {
|
|
let request = try sshSession.additionalConnectionRequest(from: snapshot)
|
|
return try await connections.connectSFTP(
|
|
using: request, progress: nil
|
|
)
|
|
},
|
|
openPTYSFTPChannel: {
|
|
try await sshSession.openPTYSFTPChannel(for: generation)
|
|
})
|
|
let client = resolved.channel
|
|
connectedChannel = client
|
|
try Task.checkCancellation()
|
|
guard sshSession.connectionAttemptID == generation else { throw CancellationError() }
|
|
let uploadChannel = SFTPChannelModel(
|
|
hostID: sshSession.hostID,
|
|
initialPath: destination,
|
|
client: client,
|
|
additionalSnapshot: snapshot,
|
|
hostStore: sshSession.transferHostStore,
|
|
primaryRole: resolved.primaryRole,
|
|
homeResolution: sshSession.homeResolution
|
|
)
|
|
await request.prepare(on: uploadChannel, coordinator: transfers)
|
|
try await sshSession.confirmDirectoryContext(context)
|
|
try Task.checkCancellation()
|
|
guard sshSession.connectionAttemptID == generation else { throw CancellationError() }
|
|
if let error = uploadChannel.operationError {
|
|
uploadChannel.disconnect()
|
|
showUploadNotice(.rejected(error))
|
|
return
|
|
}
|
|
guard uploadChannel.hasPendingBatch else {
|
|
uploadChannel.disconnect()
|
|
showUploadNotice(.rejected(String(localized: AppStrings.SFTP.noUploadableFilesWereFound)))
|
|
return
|
|
}
|
|
pendingUploadChannel = uploadChannel
|
|
connectedChannel = nil
|
|
if uploadChannel.conflictCount > 0 {
|
|
notice = nil
|
|
hasConflict = true
|
|
} else {
|
|
enqueue(.replace)
|
|
}
|
|
} catch is CancellationError {
|
|
await connectedChannel?.disconnect()
|
|
guard uploadPreparationID == preparationID else { return }
|
|
notice = nil
|
|
} catch {
|
|
await connectedChannel?.disconnect()
|
|
guard uploadPreparationID == preparationID,
|
|
sshSession.connectionAttemptID == generation
|
|
else { return }
|
|
showUploadNotice(.rejected(error.localizedDescription))
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func enqueue(_ policy: TransferConflictPolicy) {
|
|
guard let uploadChannel = pendingUploadChannel, let transfers, let sshSession = session else { return }
|
|
let itemCount = pendingUploadItemCount
|
|
let destination = uploadChannel.path
|
|
let generation = sshSession.connectionAttemptID
|
|
let insertPaths = insertUploadedPaths
|
|
let context = pendingContext
|
|
hasConflict = false
|
|
uploadEnqueueTask = Task {
|
|
do {
|
|
try await sshSession.confirmDirectoryContext(context)
|
|
try Task.checkCancellation()
|
|
} catch {
|
|
cancelPendingUpload()
|
|
return
|
|
}
|
|
if uploadChannel.pendingRemoteCopyBatch != nil {
|
|
await uploadChannel.enqueuePendingRemoteCopy(
|
|
policy: policy,
|
|
coordinator: transfers
|
|
)
|
|
} else {
|
|
await uploadChannel.enqueuePending(
|
|
policy: policy,
|
|
coordinator: transfers,
|
|
uploadedPaths: insertPaths
|
|
? { [weak session = sshSession] paths in
|
|
guard let session, session.status == .connected,
|
|
session.connectionAttemptID == generation, session.isDirectoryContextCurrent(context)
|
|
else { return }
|
|
session.insertUploadedPaths(paths)
|
|
} : nil
|
|
)
|
|
}
|
|
let error = uploadChannel.operationError
|
|
uploadChannel.disconnect()
|
|
guard !Task.isCancelled, pendingUploadChannel === uploadChannel,
|
|
sshSession.connectionAttemptID == generation
|
|
else { return }
|
|
uploadEnqueueTask = nil
|
|
pendingUploadChannel = nil
|
|
pendingUploadItemCount = 0
|
|
self.transfers = nil
|
|
if let error {
|
|
showUploadNotice(.rejected(error))
|
|
} else {
|
|
showUploadNotice(.queued(itemCount, destination))
|
|
}
|
|
}
|
|
}
|
|
|
|
func cancelPendingUpload() {
|
|
pendingContext = nil
|
|
uploadPreparationTask?.cancel()
|
|
uploadPreparationTask = nil
|
|
uploadPreparationID = nil
|
|
uploadEnqueueTask?.cancel()
|
|
uploadEnqueueTask = nil
|
|
noticeTask?.cancel()
|
|
noticeTask = nil
|
|
notice = nil
|
|
hasConflict = false
|
|
pendingUploadItemCount = 0
|
|
transfers = nil
|
|
guard let uploadChannel = pendingUploadChannel else { return }
|
|
uploadChannel.cancelPendingBatch()
|
|
uploadChannel.disconnect()
|
|
pendingUploadChannel = nil
|
|
}
|
|
|
|
private func showUploadNotice(_ value: WorkspaceUploadNotice) {
|
|
noticeTask?.cancel()
|
|
notice = value
|
|
noticeTask = Task {
|
|
try? await Task.sleep(for: .seconds(value.isRejected ? 3 : 2))
|
|
guard !Task.isCancelled, notice == value else { return }
|
|
notice = nil
|
|
noticeTask = nil
|
|
}
|
|
}
|
|
|
|
}
|