diff options
| -rw-r--r-- | Hotline/Hotline/HotlineExtensions.swift | 4 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineTransferClient.swift | 26 | ||||
| -rw-r--r-- | Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift | 79 | ||||
| -rw-r--r-- | Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift | 8 | ||||
| -rw-r--r-- | Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift | 14 | ||||
| -rw-r--r-- | Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift | 20 | ||||
| -rw-r--r-- | Hotline/Models/TransferInfo.swift | 5 | ||||
| -rw-r--r-- | Hotline/State/AppState.swift | 29 | ||||
| -rw-r--r-- | Hotline/State/HotlineState.swift | 124 | ||||
| -rw-r--r-- | Hotline/macOS/TransfersView.swift | 255 |
10 files changed, 303 insertions, 261 deletions
diff --git a/Hotline/Hotline/HotlineExtensions.swift b/Hotline/Hotline/HotlineExtensions.swift index 049ebf9..6782a41 100644 --- a/Hotline/Hotline/HotlineExtensions.swift +++ b/Hotline/Hotline/HotlineExtensions.swift @@ -432,11 +432,11 @@ extension FileManager { attributes[.modificationDate] = infoFork.modifiedDate as NSDate guard self.createFile(atPath: url.path, contents: nil, attributes: attributes) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } guard let handle = FileHandle(forWritingAtPath: url.path) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } return handle diff --git a/Hotline/Hotline/HotlineTransferClient.swift b/Hotline/Hotline/HotlineTransferClient.swift index 156b2b0..596155b 100644 --- a/Hotline/Hotline/HotlineTransferClient.swift +++ b/Hotline/Hotline/HotlineTransferClient.swift @@ -2,9 +2,20 @@ import Foundation import Network import UniformTypeIdentifiers -enum HotlineFileClientError: Error { +protocol HotlineTransferClient { +// var serverAddress: NWEndpoint.Host { get } +// var serverPort: NWEndpoint.Port { get } +// var referenceNumber: UInt32 { get } +// var status: HotlineTransferStatus { get set } + +// func start() + func cancel() +} + +enum HotlineTransferClientError: Error { case failedToConnect case failedToTransfer + case cancelled } enum HotlineFileFork { @@ -22,7 +33,7 @@ enum HotlineTransferStatus: Equatable { case progress(Double) case completing case completed - case failed(HotlineFileClientError) + case failed(HotlineTransferClientError) } enum HotlineFileForkType: UInt32 { @@ -39,17 +50,6 @@ public enum HotlineFolderAction: UInt16 { case nextFile = 3 } -protocol HotlineTransferClient { - var serverAddress: NWEndpoint.Host { get } - var serverPort: NWEndpoint.Port { get } - var referenceNumber: UInt32 { get } - var status: HotlineTransferStatus { get set } - - func start() - func cancel() -} - - struct HotlineFileHeader { static let DataSize: Int = 4 + 2 + 16 + 2 diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift index 77d2e38..981965a 100644 --- a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift @@ -6,7 +6,6 @@ public enum HotlineDownloadLocation: Sendable { case downloads(String) // filename } - public enum HotlineTransferProgress: Sendable { case error(Error) // An error occurred case unconnected // Initial state @@ -19,7 +18,7 @@ public enum HotlineTransferProgress: Sendable { /// Modern async/await file download client for Hotline protocol @MainActor -public class HotlineFileDownloadClientNew { +public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { // MARK: - Configuration public struct Configuration: Sendable { @@ -61,11 +60,11 @@ public class HotlineFileDownloadClientNew { self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) } - // MARK: - Public API + // MARK: - API public func download( to location: HotlineDownloadLocation, - progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil + progress progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? = nil ) async throws -> URL { self.downloadTask?.cancel() @@ -78,49 +77,50 @@ public class HotlineFileDownloadClientNew { let url = try await task.value self.downloadTask = nil return url - } catch { - print("FAILED TO DOWNLOAD!", error) + } + catch { self.downloadTask = nil - progressHandler?(.error(error)) + try? progressHandler?(.error(error)) throw error } } /// Cancel the current download public func cancel() { - downloadTask?.cancel() - downloadTask = nil - - if let socket = socket { - Task { - await socket.close() - } - } + self.downloadTask?.cancel() + self.downloadTask = nil } // MARK: - Private Implementation - private func updateProgress(sent: Int) { + private func updateProgress(sent: Int) throws { self.transferSize = sent self.transferProgress.completedUnitCount = Int64(sent) + try self.checkCancelled() + } + + private func checkCancelled() throws { + if Task.isCancelled { + throw CancellationError() + } // People can cancel a transfer from the file icon in the Finder. // This code handles that. if self.transferProgress.isCancelled { - self.cancel() + throw CancellationError() } } private func performDownload( to destination: HotlineDownloadLocation, - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? ) async throws -> URL { let fm = FileManager.default var fileHandle: FileHandle? var resourceForkData: Data? - progressHandler?(.preparing) + try progressHandler?(.preparing) // Determine the download name // Determine destination URL based on location @@ -137,12 +137,16 @@ public class HotlineFileDownloadClientNew { destinationFilename = destinationURL.lastPathComponent } - progressHandler?(.connecting) + try self.checkCancelled() + try progressHandler?(.connecting) // Connect to transfer server let socket = try await connectToTransferServer() self.socket = socket - defer {Task { await socket.close() } } + defer { Task { await socket.close() } } + + // See if we've been cancelled + try self.checkCancelled() // Send magic header try await socket.write(Data(endian: .big) { @@ -155,11 +159,11 @@ public class HotlineFileDownloadClientNew { // Read file header let headerData = try await socket.read(HotlineFileHeader.DataSize) guard let header = HotlineFileHeader(from: headerData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Connected - progressHandler?(.connected) + try progressHandler?(.connected) do { // Process each fork @@ -167,7 +171,7 @@ public class HotlineFileDownloadClientNew { // Read fork header let forkHeaderData = try await socket.read(HotlineFileForkHeader.DataSize) guard let forkHeader = HotlineFileForkHeader(from: forkHeaderData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Handle whichever fork is being sent. @@ -175,7 +179,7 @@ public class HotlineFileDownloadClientNew { // Read info fork let infoData = try await socket.read(Int(forkHeader.dataSize)) guard let info = HotlineFileInfoFork(from: infoData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } self.transferSize += infoData.count @@ -191,35 +195,35 @@ public class HotlineFileDownloadClientNew { self.transferProgress.publish() // Update progress - self.updateProgress(sent: infoData.count) + try self.updateProgress(sent: infoData.count) } else if forkHeader.isDataFork { guard let fh = fileHandle else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Stream data fork to disk let updates = await socket.receiveFile(to: fh, length: Int(forkHeader.dataSize)) for try await p in updates { - self.updateProgress(sent: p.sent) - progressHandler?(.transfer(name: destinationFilename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) + try self.updateProgress(sent: p.sent) + try progressHandler?(.transfer(name: destinationFilename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) } } else if forkHeader.isResourceFork { // Read resource fork into memory let rsrcData = try await socket.read(Int(forkHeader.dataSize)) resourceForkData = rsrcData - self.updateProgress(sent: Int(rsrcData.count)) + try self.updateProgress(sent: Int(rsrcData.count)) } else { // Skip unsupported fork let dataSize = Int(forkHeader.dataSize) try await socket.skip(dataSize) - self.updateProgress(sent: dataSize) + try self.updateProgress(sent: dataSize) } - progressHandler?(.transfer(name: destinationFilename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) + try progressHandler?(.transfer(name: destinationFilename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) } self.transferProgress.unpublish() @@ -232,18 +236,22 @@ public class HotlineFileDownloadClientNew { if let rsrcData = resourceForkData, !rsrcData.isEmpty { try writeResourceFork(data: rsrcData, to: destinationURL) } + + // See if we've been cancelled + try self.checkCancelled() - progressHandler?(.completed(url: destinationURL)) + try progressHandler?(.completed(url: destinationURL)) return destinationURL - } catch { + } + catch { // Cleanup on failure try? fileHandle?.close() try? fm.removeItem(at: destinationURL) self.transferProgress.unpublish() - progressHandler?(.error(error)) + try? progressHandler?(.error(error)) throw error } @@ -289,3 +297,4 @@ public class HotlineFileDownloadClientNew { print("HotlineFileDownloadClientNew[\(referenceNumber)]: Wrote resource fork (\(data.count) bytes)") } } + diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift index 5acff2a..3bf3339 100644 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift @@ -2,7 +2,7 @@ import Foundation import Network @MainActor -public class HotlineFileUploadClientNew { +public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { // MARK: - Configuration public struct Configuration: Sendable { @@ -126,15 +126,15 @@ public class HotlineFileUploadClientNew { // Get file metadata guard let infoFork = HotlineFileInfoFork(file: fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } guard let header = HotlineFileHeader(file: fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } let infoForkData = infoFork.data() diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift index 254a1c6..8303d11 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift @@ -9,7 +9,7 @@ public struct HotlineFolderItemProgress: Sendable { } @MainActor -public class HotlineFolderDownloadClientNew { +public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { // MARK: - Properties private let serverAddress: String @@ -155,7 +155,7 @@ public class HotlineFolderDownloadClientNew { totalBytesTransferred += 2 + headerLen guard let (itemType, pathComponents) = parseItemHeaderPath(headerData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } let joinedPath = pathComponents.joined(separator: "/") @@ -313,7 +313,7 @@ public class HotlineFolderDownloadClientNew { // Read file header let headerData = try await socket.read(HotlineFileHeader.DataSize) guard let header = HotlineFileHeader(from: headerData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } bytesRead += HotlineFileHeader.DataSize @@ -337,7 +337,7 @@ public class HotlineFolderDownloadClientNew { // Read fork header let forkHeaderData = try await socket.read(HotlineFileForkHeader.DataSize) guard let forkHeader = HotlineFileForkHeader(from: forkHeaderData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } bytesRead += HotlineFileForkHeader.DataSize @@ -356,7 +356,7 @@ public class HotlineFolderDownloadClientNew { self.folderProgress?.completedUnitCount = Int64(totalBytesNow) guard let info = HotlineFileInfoFork(from: infoData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Create parent folders @@ -378,7 +378,7 @@ public class HotlineFolderDownloadClientNew { print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Reading DATA fork (\(forkHeader.dataSize) bytes)") guard let fh = fileHandle else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } fileDataForkSize = Int(forkHeader.dataSize) @@ -439,7 +439,7 @@ public class HotlineFolderDownloadClientNew { fileHandle = nil guard let finalPath = filePath else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Write resource fork if present diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift index 38532f1..4b98b54 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift @@ -16,7 +16,7 @@ private struct FolderItem { } @MainActor -public class HotlineFolderUploadClientNew { +public class HotlineFolderUploadClientNew: @MainActor HotlineTransferClient { // MARK: - Configuration public struct Configuration: Sendable { @@ -176,7 +176,7 @@ public class HotlineFolderUploadClientNew { // Wait for server to send .nextFile action let action = try await self.readAction(socket: socket) guard action == .nextFile else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Check if we have more items to send @@ -192,7 +192,7 @@ public class HotlineFolderUploadClientNew { case .sendingItemHeader: // Send item header to server guard let item = currentItem else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Encode and send item header @@ -212,7 +212,7 @@ public class HotlineFolderUploadClientNew { case .waitingForFileAction: // Wait for server action after file header (.sendFile, .nextFile, .resumeFile) guard currentItem != nil else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } let action = try await self.readAction(socket: socket) @@ -246,7 +246,7 @@ public class HotlineFolderUploadClientNew { case .uploadingFile: // Upload file data guard let item = currentItem else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Notify item progress @@ -365,7 +365,7 @@ public class HotlineFolderUploadClientNew { let actionData = try await socket.read(2) guard let rawAction = actionData.readUInt16(at: 0), let action = HotlineFolderAction(rawValue: rawAction) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } return action } @@ -383,15 +383,15 @@ public class HotlineFolderUploadClientNew { // Get file metadata guard let infoFork = HotlineFileInfoFork(file: fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } guard let header = HotlineFileHeader(file: fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } let infoForkData = infoFork.data() @@ -400,7 +400,7 @@ public class HotlineFolderUploadClientNew { // Calculate total flattened file size guard let flattenedSize = FileManager.default.getFlattenedFileSize(fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } let totalFileSize = Int(flattenedSize) diff --git a/Hotline/Models/TransferInfo.swift b/Hotline/Models/TransferInfo.swift index cb2b0fd..10535bf 100644 --- a/Hotline/Models/TransferInfo.swift +++ b/Hotline/Models/TransferInfo.swift @@ -12,7 +12,12 @@ class TransferInfo: Identifiable, Equatable, Hashable { var timeRemaining: TimeInterval? = nil var completed: Bool = false var failed: Bool = false + var cancelled: Bool = false var isFolder: Bool = false + + var done: Bool { + self.completed || self.failed || self.cancelled + } // Server association - tracks which HotlineState this transfer belongs to var serverID: UUID diff --git a/Hotline/State/AppState.swift b/Hotline/State/AppState.swift index 3aadf08..2d7820b 100644 --- a/Hotline/State/AppState.swift +++ b/Hotline/State/AppState.swift @@ -24,6 +24,8 @@ final class AppState { /// All active transfers across all servers /// Transfers persist even if you disconnect from the server var transfers: [TransferInfo] = [] + + @ObservationIgnored private var transferClients: [UUID: HotlineTransferClient] = [:] /// Track download tasks by reference number for cancellation @ObservationIgnored private var transferTasks: [UUID: Task<Void, Never>] = [:] @@ -46,6 +48,11 @@ final class AppState { task.cancel() self.transferTasks.removeValue(forKey: id) } + + if let client = self.transferClients[id] { + client.cancel() + self.transferClients.removeValue(forKey: id) + } // Remove from transfers list self.transfers.remove(at: transferIndex) @@ -58,10 +65,25 @@ final class AppState { task.cancel() } self.transferTasks.removeAll() + + for (_, client) in self.transferClients { + client.cancel() + } + self.transferClients.removeAll() // Clear transfers self.transfers.removeAll() } + + /// Remove all completed transfers + @MainActor + func sweepTransfers() { + for t in self.transfers { + if t.done { + self.cancelTransfer(id: t.id) + } + } + } /// Register a transfer task @MainActor @@ -69,9 +91,16 @@ final class AppState { self.transferTasks[transferID] = task } + @MainActor + func registerTransferTask(_ task: Task<Void, Never>, transferID: UUID, client: HotlineTransferClient) { + self.transferTasks[transferID] = task + self.transferClients[transferID] = client + } + /// Unregister a download task (called on completion/failure) @MainActor func unregisterTransferTask(for transferID: UUID) { self.transferTasks.removeValue(forKey: transferID) + self.transferClients.removeValue(forKey: transferID) } } diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 4e28af8..14fb9aa 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -850,56 +850,7 @@ class HotlineState: Equatable { return success } -// @MainActor -// func downloadFile(_ fileName: String, path: [String], to destination: URL? = nil, progress progressCallback: ((TransferInfo, Double) -> Void)? = nil, complete callback: ((TransferInfo, URL) -> Void)? = nil) { -// guard let client = self.client else { return } -// -// var fullPath: [String] = [] -// if path.count > 1 { -// fullPath = Array(path[0..<path.count-1]) -// } -// -// Task { @MainActor in -// guard let result = try? await client.downloadFile(name: fileName, path: fullPath), -// let server = self.server, -// let address = server.address as String?, -// let port = server.port as Int? -// else { -// return -// } -// -// let fileClient = HotlineFileDownloadClient( -// address: address, -// port: UInt16(port), -// reference: result.referenceNumber, -// size: UInt32(result.transferSize) -// ) -// fileClient.delegate = self -// self.downloads.append(fileClient) -// -// let transfer = TransferInfo( -// id: result.referenceNumber, -// title: fileName, -// size: UInt(result.transferSize), -// serverID: self.id, -// serverName: self.serverName ?? self.serverTitle -// ) -// transfer.downloadCallback = callback -// transfer.progressCallback = progressCallback -// AppState.shared.transfers.append(transfer) -// -// if let destination { -// fileClient.start(to: destination) -// } else { -// fileClient.start() -// } -// } -// } - - /// Modern async/await file download using HotlineFileDownloadClientNew - /// - /// This is a cleaner implementation that uses the new async/await download client. - /// Unlike the old version, it doesn't use delegates and returns the file URL directly. + /// Download a file from the server. /// /// - Parameters: /// - fileName: Name of the file to download @@ -955,8 +906,6 @@ class HotlineState: Equatable { guard self != nil else { return } do { - let fileURL: URL - // Download file with progress tracking let location: HotlineDownloadLocation = if let destination { .url(destination) @@ -964,10 +913,9 @@ class HotlineState: Equatable { .downloads(fileName) } - fileURL = try await downloadClient.download(to: location) { progress in + let fileURL: URL = try await downloadClient.download(to: location) { progress in switch progress { - case .preparing: - break + case .preparing: break case .unconnected, .connected, .connecting: transfer.progressCallback?(transfer) case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate): @@ -982,20 +930,21 @@ class HotlineState: Equatable { transfer.fileURL = url } } - + // Mark as completed transfer.progress = 1.0 - + // Call completion callback transfer.downloadCallback?(transfer) - fileURL.notifyDownloadFinished() print("HotlineState: Download complete - \(fileURL.path)") } catch is CancellationError { // Download was cancelled + transfer.cancelled = true print("HotlineState: Download cancelled") + } catch { // Mark as failed transfer.failed = true @@ -1006,66 +955,11 @@ class HotlineState: Equatable { } // Store the task in AppState so it can be cancelled later - AppState.shared.registerTransferTask(downloadTask, transferID: transfer.id) + AppState.shared.registerTransferTask(downloadTask, transferID: transfer.id, client: downloadClient) } } - /// Cancel all downloads for this server only -// @MainActor -// func cancelAllDownloads() { -// let myTransfers = self.transfers -// for transfer in myTransfers { -// AppState.shared.cancelDownload(transfer.id) -// } -// } - -// @MainActor -// func downloadFolder(_ folderName: String, path: [String], complete callback: ((TransferInfo) -> Void)? = nil) { -// guard let client = self.client else { return } -// -// var fullPath: [String] = [] -// if path.count > 1 { -// fullPath = Array(path[0..<path.count-1]) -// } -// -// Task { @MainActor in -// guard let result = try? await client.downloadFolder(name: folderName, path: fullPath), -// let server = self.server, -// let address = server.address as String?, -// let port = server.port as Int? -// else { -// return -// } -// -// let folderClient = HotlineFolderDownloadClient( -// address: address, -// port: UInt16(port), -// reference: result.referenceNumber, -// size: UInt32(result.transferSize), -// itemCount: result.itemCount -// ) -// folderClient.delegate = self -// self.downloads.append(folderClient) -// -// let transfer = TransferInfo( -// id: result.referenceNumber, -// title: folderName, -// size: UInt(result.transferSize), -// serverID: self.id, -// serverName: self.serverName ?? self.serverTitle -// ) -// transfer.isFolder = true -// transfer.downloadCallback = callback -// AppState.shared.transfers.append(transfer) -// -// folderClient.start() -// } -// } - - /// Modern async/await folder download using HotlineFolderDownloadClientNew - /// - /// This is a cleaner implementation that uses the new async/await download client. - /// Unlike the old version, it doesn't use delegates and returns the folder URL directly. + /// Download a folder and its contents from the server. /// /// - Parameters: /// - folderName: Name of the folder to download diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift index 9932c0a..6b7a21b 100644 --- a/Hotline/macOS/TransfersView.swift +++ b/Hotline/macOS/TransfersView.swift @@ -2,25 +2,40 @@ import SwiftUI struct TransfersView: View { @Environment(\.appState) private var appState - + + @State private var selectedTransfers = Set<TransferInfo>() + var body: some View { VStack(spacing: 0) { - if appState.transfers.isEmpty { - emptyState + if self.appState.transfers.isEmpty { + self.emptyState } else { - transfersList + self.transfersList } } .frame(minWidth: 500, minHeight: 200) .navigationTitle("Transfers") .toolbar { +// ToolbarItem(placement: .primaryAction) { +// Button { +// self.appState.sweepTransfers() +// self.selectedTransfers = [] +// } label: { +// Label("Remove Completed", systemImage: "checklist") +// } +// .disabled(self.appState.transfers.isEmpty) +// } + ToolbarItem(placement: .primaryAction) { Button { - appState.cancelAllTransfers() + for transfer in self.selectedTransfers { + self.appState.cancelTransfer(id: transfer.id) + } + self.selectedTransfers = [] } label: { - Label("Cancel All", systemImage: "xmark.circle") + Label("Cancel Transfer", systemImage: "xmark") } - .disabled(appState.transfers.isEmpty) + .disabled(self.selectedTransfers.isEmpty) } } } @@ -38,96 +53,186 @@ struct TransfersView: View { // MARK: - Transfers List private var transfersList: some View { - List { - ForEach(appState.transfers) { transfer in + List(selection: self.$selectedTransfers) { + ForEach(self.appState.transfers) { transfer in TransferRow(transfer: transfer) + .id(transfer) + } + } + .listStyle(.inset) + .environment(\.defaultMinListRowHeight, 56) + .contextMenu(forSelectionType: TransferInfo.self) { items in + if let item = items.first { + if item.completed, + let fileURL = item.fileURL { + Button("Remove Transfer") { + self.appState.cancelTransfer(id: item.id) + } + + Divider() + + Button("Show in Finder") { + NSWorkspace.shared.activateFileViewerSelecting([fileURL]) + } + + Button("Open") { + NSWorkspace.shared.open(fileURL) + } + + Divider() + + Button("Move to Trash") { + NSWorkspace.shared.recycle([fileURL]) + } + } + else if !item.done { + Button("Cancel Transfer") { + self.appState.cancelTransfer(id: item.id) + } + } + } + } primaryAction: { items in + let fileURLs: [URL] = items.compactMap { $0.fileURL } + if !fileURLs.isEmpty { + NSWorkspace.shared.activateFileViewerSelecting(fileURLs) } } - .listStyle(.inset(alternatesRowBackgrounds: true)) } } // MARK: - Transfer Row struct TransferRow: View { + @Environment(\.appState) private var appState + @Bindable var transfer: TransferInfo + + private var statsView: some View { + HStack(spacing: 8) { + // Progress percentage +// Text("\(Int(self.transfer.progress * 100))%") + + // Speed + if let speed = self.transfer.speed { + Text(self.formatSpeed(speed)) + } - var body: some View { - VStack(alignment: .leading, spacing: 8) { - // File name and server - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(transfer.title) - .font(.system(.body, design: .default, weight: .medium)) + // Time remaining + if let timeRemaining = self.transfer.timeRemaining { + Text(self.formatTimeRemaining(timeRemaining)) + } + + // File size + Text(self.formatSize(self.transfer.size)) + } + .font(.subheadline) + .foregroundStyle(.secondary) + .monospacedDigit() + } + + private var fileIconView: some View { + FileIconView(filename: self.transfer.title, fileType: nil) + .frame(width: 32, height: 32) + .overlay(alignment: .bottomTrailing) { + if self.transfer.cancelled || self.transfer.failed { + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .symbolRenderingMode(.multicolor) + .scaledToFit() + .frame(width: 16, height: 16) + } + else if self.transfer.completed { + Image(systemName: "checkmark.circle.fill") + .resizable() + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .fileComplete) + .scaledToFit() + .frame(width: 16, height: 16) + } + } + } - if let serverName = transfer.serverName { - Text(serverName) - .font(.caption) - .foregroundStyle(.secondary) + var body: some View { + HStack(alignment: .center, spacing: 8) { + self.fileIconView + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(self.transfer.title) + .font(.headline) + .lineLimit(1) + .truncationMode(.tail) + + Spacer() + + if !self.transfer.done { + self.statsView } } - - Spacer() - - // Cancel button + + // Progress bar and status + if self.transfer.cancelled { + Text("Cancelled") + .font(.subheadline) + .foregroundStyle(.secondary) + } + else if self.transfer.failed { + Text("Failed") + .font(.subheadline) + .foregroundStyle(.secondary) + } + else if self.transfer.completed { + Text("Complete") + .font(.subheadline) + .foregroundStyle(.fileComplete) + } + else { + ProgressView(value: self.transfer.progress, total: 1.0) + .progressViewStyle(.linear) + .controlSize(.large) + } + } + + if self.transfer.completed { Button { - AppState.shared.cancelTransfer(id: transfer.id) + guard let fileURL = self.transfer.fileURL else { + return + } + + NSWorkspace.shared.activateFileViewerSelecting([fileURL]) } label: { - Image(systemName: "xmark.circle.fill") + Image(systemName: "eye.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) .foregroundStyle(.secondary) } + .buttonBorderShape(.circle) .buttonStyle(.plain) - .help("Cancel download") } + } + +// VStack(alignment: .leading, spacing: 2) { + - // Progress bar and status - VStack(alignment: .leading, spacing: 4) { - if transfer.failed { - Label("Failed", systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(.red) - } else if transfer.completed { - Label("Complete", systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(.green) - } else { - // Progress bar - ProgressView(value: transfer.progress, total: 1.0) - .progressViewStyle(.linear) - - // Progress info - HStack(spacing: 8) { - // Progress percentage - Text("\(Int(transfer.progress * 100))%") - .font(.caption) - .foregroundStyle(.secondary) - .monospacedDigit() - - // File size - Text(formatSize(transfer.size)) - .font(.caption) - .foregroundStyle(.secondary) - - // Speed - if let speed = transfer.speed { - Text(formatSpeed(speed)) - .font(.caption) - .foregroundStyle(.secondary) - .monospacedDigit() - } +// if let serverName = self.transfer.serverName { +// Text(serverName) +// .font(.caption) +// .foregroundStyle(.secondary) +// } +// } - // Time remaining - if let timeRemaining = transfer.timeRemaining { - Text(formatTimeRemaining(timeRemaining)) - .font(.caption) - .foregroundStyle(.secondary) - .monospacedDigit() - } - } - } - } - } - .padding(.vertical, 4) +// // Cancel button +// Button { +// self.appState.cancelTransfer(id: transfer.id) +// } label: { +// Image(systemName: "xmark.circle.fill") +// .foregroundStyle(.secondary) +// } +// .buttonStyle(.plain) +// .help("Cancel download") +// } +// } } // MARK: - Formatting |