324 lines
13 KiB
Swift
324 lines
13 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
}
|