aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hotline/Hotline/HotlineExtensions.swift4
-rw-r--r--Hotline/Hotline/HotlineTransferClient.swift26
-rw-r--r--Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift79
-rw-r--r--Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift8
-rw-r--r--Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift14
-rw-r--r--Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift20
-rw-r--r--Hotline/Models/TransferInfo.swift5
-rw-r--r--Hotline/State/AppState.swift29
-rw-r--r--Hotline/State/HotlineState.swift124
-rw-r--r--Hotline/macOS/TransfersView.swift255
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