Files
Termind/TFM/Features/Terminal/TerminalUploadModel.swift
T
Kaede Akino 82d871c4fe refactor: separate feature policies and UI workflows
Move connection policy out of ProAccess, keep terminal uploads with their
session, and share question interaction and highlight editing operations.
Consolidate CPU usage and settings snapshot handling, and remove obsolete
terminal selection, theme helpers, and hard-coded latest-schema assertions.

Fix draining connection-pool recovery and stale pane upload bindings. Keep
screenshot matrices opt-in and retain behavioral migration coverage.

Validation: 2468 macOS tests passed serially, 31 iOS tests passed, and both
SSH forwarding regressions passed against the local OpenSSH fixture.
Both app builds, Swift formatting, AppStrings, and Chinese localization
checks passed. Parallel test runs still exhibit intermittent timeouts.
2026-10-02 16:40:27 +08:00

305 lines
12 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
}
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>?
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)
}
@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 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 {
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 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
hasConflict = false
uploadEnqueueTask = Task {
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
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() {
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
}
}
}