From ddb9c69b24a67ac140af9ff20f5c36bdef6fb51b Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 10 Nov 2025 21:00:43 -0800 Subject: Remove "New" suffix from our transfer clients. Further cleanup. Allow previewing certain files with HFS types (but no extensions). Cleanup preview files when window closes. --- Hotline/Hotline/HotlineClientNew.swift | 108 ++--- .../Transfers/HotlineFileDownloadClient.swift | 269 +++++++++++ .../Transfers/HotlineFileDownloadClientNew.swift | 269 ----------- .../Transfers/HotlineFilePreviewClient.swift | 162 +++++++ .../Transfers/HotlineFilePreviewClientNew.swift | 146 ------ .../Transfers/HotlineFileUploadClient.swift | 225 +++++++++ .../Transfers/HotlineFileUploadClientNew.swift | 225 --------- .../Transfers/HotlineFolderDownloadClient.swift | 450 ++++++++++++++++++ .../Transfers/HotlineFolderDownloadClientNew.swift | 453 ------------------ .../Transfers/HotlineFolderUploadClient.swift | 512 ++++++++++++++++++++ .../Transfers/HotlineFolderUploadClientNew.swift | 518 --------------------- Hotline/Library/Extensions.swift | 90 +++- Hotline/Models/FileInfo.swift | 10 +- Hotline/Models/PreviewFileInfo.swift | 3 + Hotline/State/AppUpdate.swift | 9 +- Hotline/State/FilePreviewState.swift | 155 +++--- Hotline/State/HotlineState.swift | 22 +- Hotline/State/ServerState.swift | 2 +- Hotline/macOS/Files/FilePreviewQuickLookView.swift | 53 +-- Hotline/macOS/Files/FilesView.swift | 17 +- Hotline/macOS/Files/FolderItemView.swift | 2 +- Hotline/macOS/ServerView.swift | 72 ++- Hotline/macOS/TransfersView.swift | 4 +- 23 files changed, 1911 insertions(+), 1865 deletions(-) create mode 100644 Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift delete mode 100644 Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFilePreviewClient.swift delete mode 100644 Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFileUploadClient.swift delete mode 100644 Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift delete mode 100644 Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift delete mode 100644 Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift (limited to 'Hotline') diff --git a/Hotline/Hotline/HotlineClientNew.swift b/Hotline/Hotline/HotlineClientNew.swift index 854c7ff..cd34d9e 100644 --- a/Hotline/Hotline/HotlineClientNew.swift +++ b/Hotline/Hotline/HotlineClientNew.swift @@ -93,11 +93,11 @@ public struct HotlineServerInfo: Sendable { // MARK: - Hotline Client -/// Modern async/await-based Hotline protocol client +/// A client for connecting to and interacting with Hotline servers. /// /// Example usage: /// ```swift -/// let client = try await HotlineClientNew.connect( +/// let client = try await HotlineClient.connect( /// host: "server.example.com", /// port: 5500, /// login: HotlineLogin(login: "guest", password: "", username: "John", iconID: 414) @@ -123,7 +123,7 @@ public struct HotlineServerInfo: Sendable { /// // Get user list /// let users = try await client.getUserList() /// ``` -public actor HotlineClientNew { +public actor HotlineClient { // MARK: - Properties private let socket: NetSocket @@ -189,23 +189,23 @@ public actor HotlineClientNew { host: String, port: UInt16 = 5500, login: HotlineLogin - ) async throws -> HotlineClientNew { - print("HotlineClientNew.connect(): Starting connection to \(host):\(port) as '\(login.username)'") + ) async throws -> HotlineClient { + print("HotlineClient.connect(): Starting connection to \(host):\(port) as '\(login.username)'") // Connect socket - print("HotlineClientNew.connect(): Connecting socket...") + print("HotlineClient.connect(): Connecting socket...") let socket = try await NetSocket.connect(host: host, port: port) - print("HotlineClientNew.connect(): Socket connected") + print("HotlineClient.connect(): Socket connected") // Perform handshake - print("HotlineClientNew.connect(): Sending handshake...") + print("HotlineClient.connect(): Sending handshake...") try await socket.write(handshakeData) let handshakeResponse = try await socket.read(8) - print("HotlineClientNew.connect(): Handshake response received") + print("HotlineClient.connect(): Handshake response received") // Verify handshake guard handshakeResponse.prefix(4) == Data([0x54, 0x52, 0x54, 0x50]) else { - print("HotlineClientNew.connect(): Invalid handshake response") + print("HotlineClient.connect(): Invalid handshake response") throw HotlineClientError.connectionFailed( NSError(domain: "HotlineClient", code: -1, userInfo: [ NSLocalizedDescriptionKey: "Invalid handshake response" @@ -215,7 +215,7 @@ public actor HotlineClientNew { let errorCode = handshakeResponse.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) } guard errorCode.bigEndian == 0 else { - print("HotlineClientNew.connect(): Handshake failed with error code \(errorCode)") + print("HotlineClient.connect(): Handshake failed with error code \(errorCode)") throw HotlineClientError.connectionFailed( NSError(domain: "HotlineClient", code: Int(errorCode), userInfo: [ NSLocalizedDescriptionKey: "Handshake failed with error code \(errorCode)" @@ -224,24 +224,24 @@ public actor HotlineClientNew { } // Create client - print("HotlineClientNew.connect(): Creating client instance") - let client = HotlineClientNew(socket: socket) + print("HotlineClient.connect(): Creating client instance") + let client = HotlineClient(socket: socket) // Start receive loop - print("HotlineClientNew.connect(): Starting receive loop") + print("HotlineClient.connect(): Starting receive loop") await client.startReceiveLoop() // Perform login - print("HotlineClientNew.connect(): Performing login") + print("HotlineClient.connect(): Performing login") let serverInfo = try await client.performLogin(login) await client.setServerInfo(serverInfo) - print("HotlineClientNew.connect(): Login successful") + print("HotlineClient.connect(): Login successful") // Start keep-alive - print("HotlineClientNew.connect(): Starting keep-alive") + print("HotlineClient.connect(): Starting keep-alive") await client.startKeepAlive() - print("HotlineClientNew.connect(): Connected to \(serverInfo.name) (v\(serverInfo.version))") + print("HotlineClient.connect(): Connected to \(serverInfo.name) (v\(serverInfo.version))") return client } @@ -296,19 +296,19 @@ public actor HotlineClientNew { isConnected = false - print("HotlineClientNew.disconnect(): Starting disconnect") + print("HotlineClient.disconnect(): Starting disconnect") self.receiveTask?.cancel() self.keepAliveTask?.cancel() await self.socket.close() self.failAllPendingTransactions(HotlineClientError.notConnected) self.eventContinuation.finish() - print("HotlineClientNew.disconnect(): Disconnect complete") + print("HotlineClient.disconnect(): Disconnect complete") } // MARK: - Receive Loop private func startReceiveLoop() { - print("HotlineClientNew.startReceiveLoop(): Creating receive task") + print("HotlineClient.startReceiveLoop(): Creating receive task") self.receiveTask = Task { [weak self] in guard let self else { return @@ -320,21 +320,21 @@ public actor HotlineClientNew { let transaction = try await self.socket.receive(HotlineTransaction.self, endian: .big) await self.handleTransaction(transaction) } - print("HotlineClientNew.startReceiveLoop(): Task cancelled, exiting loop") + print("HotlineClient.startReceiveLoop(): Task cancelled, exiting loop") } catch { if Task.isCancelled || error is CancellationError { - print("HotlineClientNew.startReceiveLoop(): Receive loop cancelled") + print("HotlineClient.startReceiveLoop(): Receive loop cancelled") } else { - print("HotlineClientNew.startReceiveLoop(): Receive loop error: \(error)") + print("HotlineClient.startReceiveLoop(): Receive loop error: \(error)") await self.disconnect() } } - print("HotlineClientNew.startReceiveLoop(): Receive loop ended") + print("HotlineClient.startReceiveLoop(): Receive loop ended") } } private func handleTransaction(_ transaction: HotlineTransaction) { - print("HotlineClientNew: <= \(transaction.type) [\(transaction.id)]") + print("HotlineClient: <= \(transaction.type) [\(transaction.id)]") // Check if this is a reply to a pending transaction if transaction.isReply == 1 || transaction.type == .reply { @@ -348,7 +348,7 @@ public actor HotlineClientNew { private func handleReply(_ transaction: HotlineTransaction) { guard let continuation = pendingTransactions.removeValue(forKey: transaction.id) else { - print("HotlineClientNew: Received reply for unknown transaction \(transaction.id)") + print("HotlineClient: Received reply for unknown transaction \(transaction.id)") return } @@ -359,7 +359,6 @@ public actor HotlineClientNew { message: errorText )) } else { - print("HELLO") continuation.resume(returning: transaction) } } @@ -417,14 +416,15 @@ public actor HotlineClientNew { } default: - print("HotlineClientNew: Unhandled event type \(transaction.type)") + print("HotlineClient: Unhandled event type \(transaction.type)") } } // MARK: - Transaction Sending + @discardableResult private func sendTransaction(_ transaction: HotlineTransaction, timeout: TimeInterval = 30.0) async throws -> HotlineTransaction { - print("HotlineClientNew: => \(transaction.type) [\(transaction.id)]") + print("HotlineClient: => \(transaction.type) [\(transaction.id)]") let transactionID = transaction.id @@ -518,7 +518,7 @@ public actor HotlineClientNew { let _ = try? await self.getUserList() } } catch { - print("HotlineClientNew: Keep-alive failed: \(error)") + print("HotlineClient: Keep-alive failed: \(error)") } } @@ -605,7 +605,7 @@ public actor HotlineClientNew { try await socket.send(transaction, endian: .big) } - // MARK: - Public API - Files + // MARK: - Files /// Get the file list for a directory /// @@ -617,7 +617,7 @@ public actor HotlineClientNew { transaction.setFieldPath(type: .filePath, val: path) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) var files: [HotlineFile] = [] for field in reply.getFieldList(type: .fileNameWithInfo) { @@ -649,7 +649,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferSize = reply.getField(type: .transferSize)?.getInteger(), @@ -664,7 +664,7 @@ public actor HotlineClientNew { return (referenceNumber, transferSize, fileSize, waitingCount) } - // MARK: - Public API - News + // MARK: - News /// Get news categories at a path /// @@ -676,7 +676,7 @@ public actor HotlineClientNew { transaction.setFieldPath(type: .newsPath, val: path) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) var categories: [HotlineNewsCategory] = [] for field in reply.getFieldList(type: .newsCategoryListData15) { @@ -698,7 +698,7 @@ public actor HotlineClientNew { transaction.setFieldPath(type: .newsPath, val: path) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let articleData = reply.getField(type: .newsArticleListData) else { return [] @@ -725,7 +725,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .newsArticleID, val: id) transaction.setFieldString(type: .newsArticleDataFlavor, val: flavor, encoding: .ascii) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) return reply.getField(type: .newsArticleData)?.getString() } @@ -754,17 +754,17 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .newsArticleFlags, val: 0) transaction.setFieldString(type: .newsArticleData, val: text) - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) } - // MARK: - Public API - Message Board + // MARK: - Message Board /// Get message board posts /// /// - Returns: Array of message strings public func getMessageBoard() async throws -> [String] { let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getMessageBoard) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let text = reply.getField(type: .data)?.getString() else { return [] @@ -784,10 +784,10 @@ public actor HotlineClientNew { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .oldPostNews) transaction.setFieldString(type: .data, val: text, encoding: .macOSRoman) - try await socket.send(transaction, endian: .big) + try await self.socket.send(transaction, endian: .big) } - // MARK: - Public API - File Operations + // MARK: - File Operations /// Get detailed information about a file /// @@ -840,7 +840,7 @@ public actor HotlineClientNew { transaction.setFieldPath(type: .filePath, val: path) do { - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) return true } catch { return false @@ -854,7 +854,7 @@ public actor HotlineClientNew { /// - Returns: Array of user accounts sorted by login public func getAccounts() async throws -> [HotlineAccount] { let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getAccounts) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) let accountFields = reply.getFieldList(type: .data) var accounts: [HotlineAccount] = [] @@ -886,7 +886,7 @@ public actor HotlineClientNew { transaction.setFieldEncodedString(type: .userPassword, val: password) } - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) } /// Update an existing user account (requires admin access) @@ -919,7 +919,7 @@ public actor HotlineClientNew { transaction.setFieldEncodedString(type: .userPassword, val: password!) } - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) } /// Delete a user account (requires admin access) @@ -929,7 +929,7 @@ public actor HotlineClientNew { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteUser) transaction.setFieldEncodedString(type: .userLogin, val: login) - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) } // MARK: - Banners @@ -940,7 +940,7 @@ public actor HotlineClientNew { /// - Throws: HotlineClientError if not connected or server doesn't support banners public func downloadBanner() async throws -> (referenceNumber: UInt32, transferSize: Int)? { let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadBanner) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferSizeField = reply.getField(type: .transferSize), @@ -972,7 +972,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferSizeField = reply.getField(type: .transferSize), @@ -1000,7 +1000,7 @@ public actor HotlineClientNew { transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferSizeField = reply.getField(type: .transferSize), @@ -1027,7 +1027,7 @@ public actor HotlineClientNew { transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferReferenceField = reply.getField(type: .referenceNumber), @@ -1046,7 +1046,7 @@ public actor HotlineClientNew { /// - path: Directory path where the folder should be uploaded /// - Returns: Reference number for the upload transfer public func uploadFolder(name: String, path: [String], fileCount: UInt32, totalSize: UInt32) async throws -> UInt32? { - print("HotlineClientNew: uploadFolder request - name='\(name)', path=\(path), fileCount=\(fileCount), totalSize=\(totalSize)") + print("HotlineClient: uploadFolder request - name='\(name)', path=\(path), fileCount=\(fileCount), totalSize=\(totalSize)") var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .uploadFolder) transaction.setFieldString(type: .fileName, val: name) @@ -1054,7 +1054,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .transferSize, val: totalSize) transaction.setFieldUInt16(type: .folderItemCount, val: UInt16(truncatingIfNeeded: fileCount)) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferReferenceField = reply.getField(type: .referenceNumber), diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift new file mode 100644 index 0000000..82f61d4 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift @@ -0,0 +1,269 @@ +import Foundation +import Network + +public enum HotlineDownloadLocation: Sendable { + case url(URL) + case downloads(String) // filename +} + +public enum HotlineTransferProgress: Sendable { + case error(Error) // An error occurred + case unconnected // Initial state + case preparing // Preparing to begin + case connecting // Connecting to server + case connected // Connected to server + case transfer(name: String, size: Int, total: Int, progress: Double, speed: Double?, estimate: TimeInterval?) // size transferred, total size, progress (0.0-1.0), speed (in bytes/sec), time remaining + case completed(url: URL?) // Download or upload complete (local url valid for downloads) +} + + +@MainActor +public class HotlineFileDownloadClient: @MainActor HotlineTransferClient { + public struct Configuration: Sendable { + public var chunkSize: Int = 256 * 1024 + public init() {} + } + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + + private let config: Configuration + + private var transferSize: Int + private let transferTotal: Int + private var transferProgress: Progress + + private var socket: NetSocket? + private var downloadTask: Task? + + public init( + address: String, + port: UInt16, + reference: UInt32, + size: UInt32, + configuration: Configuration = .init() + ) { + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.config = configuration + + self.transferTotal = Int(size) + self.transferSize = 0 + self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) + } + + // MARK: - API + + public func download( + to location: HotlineDownloadLocation, + progress progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? = nil + ) async throws -> URL { + self.downloadTask?.cancel() + + let task = Task { + try await performDownload(to: location, progressHandler: progressHandler) + } + self.downloadTask = task + + do { + let url = try await task.value + self.downloadTask = nil + return url + } + catch { + self.downloadTask = nil + try? progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current download + public func cancel() { + self.downloadTask?.cancel() + self.downloadTask = nil + } + + // MARK: - Implementation + + 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 { + throw CancellationError() + } + } + + private func performDownload( + to destination: HotlineDownloadLocation, + progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? + ) async throws -> URL { + + let fm = FileManager.default + var fileHandle: FileHandle? + var resourceForkData: Data? + + try progressHandler?(.preparing) + + // Determine the download name + // Determine destination URL based on location + let destinationURL: URL + let destinationFilename: String + switch destination { + case .url(let url): + destinationURL = url.resolvingSymlinksInPath() + destinationFilename = destinationURL.lastPathComponent + case .downloads(let filename): + var downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + downloadsURL = downloadsURL.resolvingSymlinksInPath() + destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) + destinationFilename = destinationURL.lastPathComponent + } + + try self.checkCancelled() + try progressHandler?(.connecting) + + // Connect to transfer server + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + defer { Task { await socket.close() } } + self.socket = socket + + // See if we've been cancelled + try self.checkCancelled() + + // Send magic header + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero + UInt32.zero + }) + + // Read file header + let headerData = try await socket.read(HotlineFileHeader.DataSize) + guard let header = HotlineFileHeader(from: headerData) else { + throw HotlineTransferClientError.failedToTransfer + } + + // Connected + try progressHandler?(.connected) + + do { + // Process each fork + for _ in 0..? - - public init( - address: String, - port: UInt16, - reference: UInt32, - size: UInt32, - configuration: Configuration = .init() - ) { - self.serverAddress = address - self.serverPort = port - self.referenceNumber = reference - self.config = configuration - - self.transferTotal = Int(size) - self.transferSize = 0 - self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) - } - - // MARK: - API - - public func download( - to location: HotlineDownloadLocation, - progress progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? = nil - ) async throws -> URL { - self.downloadTask?.cancel() - - let task = Task { - try await performDownload(to: location, progressHandler: progressHandler) - } - self.downloadTask = task - - do { - let url = try await task.value - self.downloadTask = nil - return url - } - catch { - self.downloadTask = nil - try? progressHandler?(.error(error)) - throw error - } - } - - /// Cancel the current download - public func cancel() { - self.downloadTask?.cancel() - self.downloadTask = nil - } - - // MARK: - Implementation - - 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 { - throw CancellationError() - } - } - - private func performDownload( - to destination: HotlineDownloadLocation, - progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? - ) async throws -> URL { - - let fm = FileManager.default - var fileHandle: FileHandle? - var resourceForkData: Data? - - try progressHandler?(.preparing) - - // Determine the download name - // Determine destination URL based on location - let destinationURL: URL - let destinationFilename: String - switch destination { - case .url(let url): - destinationURL = url.resolvingSymlinksInPath() - destinationFilename = destinationURL.lastPathComponent - case .downloads(let filename): - var downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - downloadsURL = downloadsURL.resolvingSymlinksInPath() - destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) - destinationFilename = destinationURL.lastPathComponent - } - - try self.checkCancelled() - try progressHandler?(.connecting) - - // Connect to transfer server - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - defer { Task { await socket.close() } } - self.socket = socket - - // See if we've been cancelled - try self.checkCancelled() - - // Send magic header - try await socket.write(Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero - UInt32.zero - }) - - // Read file header - let headerData = try await socket.read(HotlineFileHeader.DataSize) - guard let header = HotlineFileHeader(from: headerData) else { - throw HotlineTransferClientError.failedToTransfer - } - - // Connected - try progressHandler?(.connected) - - do { - // Process each fork - for _ in 0..? + private var temporaryFileURL: URL? + + public init( + fileName: String, + address: String, + port: UInt16, + reference: UInt32, + size: UInt32, + fileType: String? = nil, + fileCreator: String? = nil + ) { + self.fileName = fileName + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.transferSize = size + self.fileType = fileType + self.fileCreator = fileCreator + } + + // MARK: - API + + /// Download file to temporary location for preview + /// - Parameter progressHandler: Optional progress callback + /// - Returns: URL to temporary file for preview + public func preview( + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil + ) async throws -> URL { + self.previewTask?.cancel() + + let task = Task { + try await performPreview(progressHandler: progressHandler) + } + self.previewTask = task + + do { + let url = try await task.value + self.previewTask = nil + return url + } catch { + print("HotlineFilePreviewClient[\(referenceNumber)]: Failed to preview file: \(error)") + self.previewTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current preview download + public func cancel() { + self.previewTask?.cancel() + self.previewTask = nil + self.downloadClient?.cancel() + } + + /// Manually cleanup temporary file + /// Call this when preview is complete and you no longer need the file + public func cleanup() { + self.cleanupTempFile() + } + + // MARK: - Implementation + + private func performPreview( + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> URL { + + // Create temporary file path directly in system temp directory + let tempDir = FileManager.default.temporaryDirectory + let uniqueFileName = "\(UUID().uuidString)_\(self.fileName)" + let tempFileURL = tempDir.appendingPathComponent(uniqueFileName) + self.temporaryFileURL = tempFileURL + + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Downloading to temp: \(tempFileURL.path)") + + progressHandler?(.connecting) + + // Connect to transfer server + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connecting to \(self.serverAddress):\(self.serverPort + 1)") + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + defer { Task { await socket.close() } } + + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connected!") + + // Send magic header for raw data download + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Sending magic header") + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero + UInt32.zero + }) + + progressHandler?(.connected) + + // Stream raw data directly to temp file with progress tracking + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Streaming \(self.transferSize) bytes to temp file") + + let totalSize = Int(self.transferSize) + + // Create empty file (with HFS attributes if available) + var attributes: [FileAttributeKey: Any] = [:] + if let creator = self.fileCreator, !creator.isBlank { + attributes[.hfsCreatorCode] = creator.fourCharCode() as NSNumber + } + if let type = self.fileType, !type.isBlank { + attributes[.hfsTypeCode] = type.fourCharCode() as NSNumber + } + + guard FileManager.default.createFile(atPath: tempFileURL.path, contents: nil, attributes: attributes) else { + throw HotlineTransferClientError.failedToTransfer + } + + let fileHandle = try FileHandle(forWritingTo: tempFileURL) + defer { try? fileHandle.close() } + + let updates = await socket.receiveFile(to: fileHandle, length: totalSize) + for try await p in updates { + progressHandler?(.transfer( + name: uniqueFileName, + size: p.sent, + total: totalSize, + progress: totalSize > 0 ? Double(p.sent) / Double(totalSize) : 0.0, + speed: p.bytesPerSecond, + estimate: p.estimatedTimeRemaining + )) + } + + progressHandler?(.completed(url: tempFileURL)) + + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Preview file ready at \(tempFileURL.path)") + + return tempFileURL + } + + private func cleanupTempFile() { + guard let tempURL = self.temporaryFileURL else { return } + self.temporaryFileURL = nil + + // Delete the temp file + try? FileManager.default.removeItem(at: tempURL) + + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Cleaned up temp file") + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift deleted file mode 100644 index 5cf5628..0000000 --- a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift +++ /dev/null @@ -1,146 +0,0 @@ -import Foundation -import Network - -@MainActor -public class HotlineFilePreviewClient { - private let serverAddress: String - private let serverPort: UInt16 - private let referenceNumber: UInt32 - private let fileName: String - private let transferSize: UInt32 - - private var downloadClient: HotlineFileDownloadClient? - private var previewTask: Task? - private var temporaryFileURL: URL? - - public init( - fileName: String, - address: String, - port: UInt16, - reference: UInt32, - size: UInt32 - ) { - self.fileName = fileName - self.serverAddress = address - self.serverPort = port - self.referenceNumber = reference - self.transferSize = size - } - - // MARK: - API - - /// Download file to temporary location for preview - /// - Parameter progressHandler: Optional progress callback - /// - Returns: URL to temporary file for preview - public func preview( - progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil - ) async throws -> URL { - self.previewTask?.cancel() - - let task = Task { - try await performPreview(progressHandler: progressHandler) - } - self.previewTask = task - - do { - let url = try await task.value - self.previewTask = nil - return url - } catch { - print("HotlineFilePreviewClient[\(referenceNumber)]: Failed to preview file: \(error)") - self.previewTask = nil - progressHandler?(.error(error)) - throw error - } - } - - /// Cancel the current preview download - public func cancel() { - self.previewTask?.cancel() - self.previewTask = nil - self.downloadClient?.cancel() - } - - /// Manually cleanup temporary file - /// Call this when preview is complete and you no longer need the file - public func cleanup() { - self.cleanupTempFile() - } - - // MARK: - Implementation - - private func performPreview( - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? - ) async throws -> URL { - - // Create temporary file path directly in system temp directory - let tempDir = FileManager.default.temporaryDirectory - let uniqueFileName = "\(UUID().uuidString)_\(self.fileName)" - let tempFileURL = tempDir.appendingPathComponent(uniqueFileName) - self.temporaryFileURL = tempFileURL - - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Downloading to temp: \(tempFileURL.path)") - - progressHandler?(.connecting) - - // Connect to transfer server - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connecting to \(self.serverAddress):\(self.serverPort + 1)") - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - defer { Task { await socket.close() } } - - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connected!") - - // Send magic header for raw data download - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Sending magic header") - try await socket.write(Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero - UInt32.zero - }) - - progressHandler?(.connected) - - // Stream raw data directly to temp file with progress tracking - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Streaming \(transferSize) bytes to temp file") - - let totalSize = Int(transferSize) - - // Create empty file - FileManager.default.createFile(atPath: tempFileURL.path(percentEncoded: false), contents: nil) - - let fileHandle = try FileHandle(forWritingTo: tempFileURL) - defer { try? fileHandle.close() } - - let updates = await socket.receiveFile(to: fileHandle, length: totalSize) - for try await p in updates { - progressHandler?(.transfer( - name: uniqueFileName, - size: p.sent, - total: totalSize, - progress: totalSize > 0 ? Double(p.sent) / Double(totalSize) : 0.0, - speed: p.bytesPerSecond, - estimate: p.estimatedTimeRemaining - )) - } - - progressHandler?(.completed(url: tempFileURL)) - - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Preview file ready at \(tempFileURL.path)") - - return tempFileURL - } - - private func cleanupTempFile() { - guard let tempURL = self.temporaryFileURL else { return } - self.temporaryFileURL = nil - - // Delete the temp file - try? FileManager.default.removeItem(at: tempURL) - - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Cleaned up temp file") - } -} diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift new file mode 100644 index 0000000..384e9bd --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift @@ -0,0 +1,225 @@ +import Foundation +import Network + +@MainActor +public class HotlineFileUploadClient: @MainActor HotlineTransferClient { + public struct Configuration: Sendable { + public var chunkSize: Int = 256 * 1024 + public init() {} + } + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + private let fileURL: URL + + private let config: Configuration + + private var transferSize: Int + private let transferTotal: Int + private var transferProgress: Progress + + private var socket: NetSocket? + private var uploadTask: Task? + + public init?( + fileURL: URL, + address: String, + port: UInt16, + reference: UInt32, + configuration: Configuration = .init() + ) { + // Validate file and get total size + guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { + return nil + } + + guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { + return nil + } + + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.fileURL = fileURL + self.config = configuration + + self.transferTotal = Int(payloadSize) + self.transferSize = 0 + self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) + } + + // MARK: - Public API + + public func upload( + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil + ) async throws { + self.uploadTask?.cancel() + + let task = Task { + try await performUpload(progressHandler: progressHandler) + } + self.uploadTask = task + + do { + try await task.value + self.uploadTask = nil + } catch { + print("HotlineFileUploadClient[\(self.referenceNumber)]: Failed to upload file: \(error)") + self.uploadTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current upload + public func cancel() { + self.uploadTask?.cancel() + self.uploadTask = nil + + if let socket = self.socket { + Task { + await socket.close() + } + } + } + + // MARK: - Implementation + + private func updateProgress(sent: Int, speed: Double? = nil, estimate: TimeInterval? = nil) { + self.transferSize = sent + self.transferProgress.completedUnitCount = Int64(sent) + + if self.transferProgress.isCancelled { + self.cancel() + } + } + + private func performUpload( + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws { + let filename = self.fileURL.lastPathComponent + + progressHandler?(.connecting) + + // Start accessing security-scoped resource + let didStartAccess = fileURL.startAccessingSecurityScopedResource() + defer { + if didStartAccess { + fileURL.stopAccessingSecurityScopedResource() + } + } + + // Connect to transfer server + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + defer { Task { await socket.close() } } + self.socket = socket + + // Get file metadata + guard let infoFork = HotlineFileInfoFork(file: self.fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + guard let header = HotlineFileHeader(file: self.fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + guard let forkSizes = try? FileManager.default.getFileForkSizes(self.fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + let infoForkData = infoFork.data() + let dataForkSize = forkSizes.dataForkSize + let resourceForkSize = forkSizes.resourceForkSize + + // Configure progress for Finder if enabled + self.transferProgress.fileURL = self.fileURL.resolvingSymlinksInPath() + self.transferProgress.fileOperationKind = .uploading + self.transferProgress.publish() + + // Connected + progressHandler?(.connected) + + // Send magic header + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32(self.transferTotal) + UInt32.zero + }) + + var totalBytesSent = 0 + + // MARK: - Info Fork + // Send file header + let headerData = header.data() + try await socket.write(headerData) + totalBytesSent += headerData.count + + // Send info fork header + let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) + try await socket.write(infoForkHeader.data()) + totalBytesSent += HotlineFileForkHeader.DataSize + + // Send info fork + try await socket.write(infoForkData) + totalBytesSent += infoForkData.count + + self.updateProgress(sent: totalBytesSent) + progressHandler?(.transfer(name: filename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) + + // MARK: - Data Fork + // Send data fork (if present) + if dataForkSize > 0 { + // Data fork header + let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) + try await socket.write(dataForkHeader.data()) + totalBytesSent += HotlineFileForkHeader.DataSize + + // Stream data fork from disk + let fileHandle = try FileHandle(forReadingFrom: self.fileURL) + defer { try? fileHandle.close() } + + let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) + for try await p in updates { + let bytesSentNow = totalBytesSent + p.sent + self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining) + progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) + } + + totalBytesSent += Int(dataForkSize) + } + + // MARK: - Resource Fork + // Send resource fork (if present) + if resourceForkSize > 0 { + let resourceURL = self.fileURL.urlForResourceFork() + + // Resource fork header + let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) + try await socket.write(resourceForkHeader.data()) + totalBytesSent += HotlineFileForkHeader.DataSize + + // Stream resource fork from disk + let resourceHandle = try FileHandle(forReadingFrom: resourceURL) + defer { try? resourceHandle.close() } + + let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) + for try await p in updates { + let bytesSentNow = totalBytesSent + p.sent + self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining) + progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) + } + + totalBytesSent += Int(resourceForkSize) + } + + self.transferProgress.unpublish() + progressHandler?(.completed(url: nil)) + + print("HotlineFileUploadClient[\(self.referenceNumber)]: Complete!") + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift deleted file mode 100644 index 384e9bd..0000000 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift +++ /dev/null @@ -1,225 +0,0 @@ -import Foundation -import Network - -@MainActor -public class HotlineFileUploadClient: @MainActor HotlineTransferClient { - public struct Configuration: Sendable { - public var chunkSize: Int = 256 * 1024 - public init() {} - } - - private let serverAddress: String - private let serverPort: UInt16 - private let referenceNumber: UInt32 - private let fileURL: URL - - private let config: Configuration - - private var transferSize: Int - private let transferTotal: Int - private var transferProgress: Progress - - private var socket: NetSocket? - private var uploadTask: Task? - - public init?( - fileURL: URL, - address: String, - port: UInt16, - reference: UInt32, - configuration: Configuration = .init() - ) { - // Validate file and get total size - guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { - return nil - } - - guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { - return nil - } - - self.serverAddress = address - self.serverPort = port - self.referenceNumber = reference - self.fileURL = fileURL - self.config = configuration - - self.transferTotal = Int(payloadSize) - self.transferSize = 0 - self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) - } - - // MARK: - Public API - - public func upload( - progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil - ) async throws { - self.uploadTask?.cancel() - - let task = Task { - try await performUpload(progressHandler: progressHandler) - } - self.uploadTask = task - - do { - try await task.value - self.uploadTask = nil - } catch { - print("HotlineFileUploadClient[\(self.referenceNumber)]: Failed to upload file: \(error)") - self.uploadTask = nil - progressHandler?(.error(error)) - throw error - } - } - - /// Cancel the current upload - public func cancel() { - self.uploadTask?.cancel() - self.uploadTask = nil - - if let socket = self.socket { - Task { - await socket.close() - } - } - } - - // MARK: - Implementation - - private func updateProgress(sent: Int, speed: Double? = nil, estimate: TimeInterval? = nil) { - self.transferSize = sent - self.transferProgress.completedUnitCount = Int64(sent) - - if self.transferProgress.isCancelled { - self.cancel() - } - } - - private func performUpload( - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? - ) async throws { - let filename = self.fileURL.lastPathComponent - - progressHandler?(.connecting) - - // Start accessing security-scoped resource - let didStartAccess = fileURL.startAccessingSecurityScopedResource() - defer { - if didStartAccess { - fileURL.stopAccessingSecurityScopedResource() - } - } - - // Connect to transfer server - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - defer { Task { await socket.close() } } - self.socket = socket - - // Get file metadata - guard let infoFork = HotlineFileInfoFork(file: self.fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - guard let header = HotlineFileHeader(file: self.fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - guard let forkSizes = try? FileManager.default.getFileForkSizes(self.fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - let infoForkData = infoFork.data() - let dataForkSize = forkSizes.dataForkSize - let resourceForkSize = forkSizes.resourceForkSize - - // Configure progress for Finder if enabled - self.transferProgress.fileURL = self.fileURL.resolvingSymlinksInPath() - self.transferProgress.fileOperationKind = .uploading - self.transferProgress.publish() - - // Connected - progressHandler?(.connected) - - // Send magic header - try await socket.write(Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32(self.transferTotal) - UInt32.zero - }) - - var totalBytesSent = 0 - - // MARK: - Info Fork - // Send file header - let headerData = header.data() - try await socket.write(headerData) - totalBytesSent += headerData.count - - // Send info fork header - let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) - try await socket.write(infoForkHeader.data()) - totalBytesSent += HotlineFileForkHeader.DataSize - - // Send info fork - try await socket.write(infoForkData) - totalBytesSent += infoForkData.count - - self.updateProgress(sent: totalBytesSent) - progressHandler?(.transfer(name: filename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) - - // MARK: - Data Fork - // Send data fork (if present) - if dataForkSize > 0 { - // Data fork header - let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) - try await socket.write(dataForkHeader.data()) - totalBytesSent += HotlineFileForkHeader.DataSize - - // Stream data fork from disk - let fileHandle = try FileHandle(forReadingFrom: self.fileURL) - defer { try? fileHandle.close() } - - let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) - for try await p in updates { - let bytesSentNow = totalBytesSent + p.sent - self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining) - progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) - } - - totalBytesSent += Int(dataForkSize) - } - - // MARK: - Resource Fork - // Send resource fork (if present) - if resourceForkSize > 0 { - let resourceURL = self.fileURL.urlForResourceFork() - - // Resource fork header - let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) - try await socket.write(resourceForkHeader.data()) - totalBytesSent += HotlineFileForkHeader.DataSize - - // Stream resource fork from disk - let resourceHandle = try FileHandle(forReadingFrom: resourceURL) - defer { try? resourceHandle.close() } - - let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) - for try await p in updates { - let bytesSentNow = totalBytesSent + p.sent - self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining) - progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) - } - - totalBytesSent += Int(resourceForkSize) - } - - self.transferProgress.unpublish() - progressHandler?(.completed(url: nil)) - - print("HotlineFileUploadClient[\(self.referenceNumber)]: Complete!") - } -} diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift new file mode 100644 index 0000000..36193bd --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift @@ -0,0 +1,450 @@ +import Foundation +import Network + +/// Item progress callback for folder downloads +public struct HotlineFolderItemProgress: Sendable { + public let fileName: String + public let itemNumber: Int + public let totalItems: Int +} + +@MainActor +public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + + private let transferTotal: Int + private let folderItemCount: Int + private var transferSize: Int = 0 + + private var socket: NetSocket? + private var downloadTask: Task? + private var folderProgress: Progress? + + public init( + address: String, + port: UInt16, + reference: UInt32, + size: UInt32, + itemCount: Int + ) { + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.transferTotal = Int(size) + self.folderItemCount = itemCount + } + + // MARK: - API + + public func download( + to location: HotlineDownloadLocation, + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, + itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? = nil + ) async throws -> URL { + self.downloadTask?.cancel() + + let task = Task { + try await performDownload( + to: location, + progressHandler: progressHandler, + itemProgressHandler: itemProgressHandler + ) + } + self.downloadTask = task + + do { + let url = try await task.value + self.downloadTask = nil + return url + } catch { + print("HotlineFolderDownloadClient[\(referenceNumber)]: Failed to download folder: \(error)") + self.downloadTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current download + public func cancel() { + downloadTask?.cancel() + downloadTask = nil + + if let socket = socket { + Task { + await socket.close() + } + } + } + + // MARK: - Implementation + + private func performDownload( + to destination: HotlineDownloadLocation, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?, + itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? + ) async throws -> URL { + + var destinationFilename: String + + progressHandler?(.connecting) + + // Connect to transfer server + let socket = try await connectToTransferServer() + self.socket = socket + defer { Task { await socket.close() } } + + // Determine destination folder URL + let fm = FileManager.default + let destinationURL: URL + + switch destination { + case .url(let url): + destinationURL = url + destinationFilename = url.lastPathComponent + case .downloads(let filename): + let downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) + destinationFilename = destinationURL.lastPathComponent + } + + print("HotlineFolderDownloadClient[\(referenceNumber)]: Downloading folder to \(destinationURL.path)") + + // Create destination folder + try? fm.removeItem(at: destinationURL) + try fm.createDirectory(at: destinationURL, withIntermediateDirectories: true) + + // Create and publish progress for the entire folder (shows in Finder) + let progress = Progress(totalUnitCount: Int64(self.transferTotal)) + progress.fileURL = destinationURL + progress.fileOperationKind = .downloading + progress.publish() + self.folderProgress = progress + + // Send initial magic header + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Sending HTXF magic") + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero // data size = 0 + UInt16(1) // type = 1 (folder transfer) + UInt16.zero // reserved = 0 + HotlineFolderAction.nextFile.rawValue // action = 3 (next file) + }) + + progressHandler?(.connected) + progressHandler?(.transfer(name: destinationFilename, size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil)) + + var completedItemCount = 0 + var totalBytesTransferred = 0 + + // Process each item in the folder + while completedItemCount < self.folderItemCount { + // Read item header + let headerLenData = try await socket.read(2) + let headerLen = Int(headerLenData.readUInt16(at: 0)!) + let headerData = try await socket.read(headerLen) + + totalBytesTransferred += 2 + headerLen + + guard let (itemType, pathComponents) = self.parseItemHeaderPath(headerData) else { + throw HotlineTransferClientError.failedToTransfer + } + + let joinedPath = pathComponents.joined(separator: "/") + print("HotlineFolderDownloadClient[\(referenceNumber)]: Item type=\(itemType) path=\(joinedPath)") + + if itemType == 1 { + // Folder entry - no progress shown for folder creation + if !pathComponents.isEmpty { + let folderURL = destinationURL.appendingPathComponents(pathComponents) + try fm.createDirectory(at: folderURL, withIntermediateDirectories: true) + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Created folder at \(folderURL.path)") + } + + completedItemCount += 1 + + // Request next item if not done + if completedItemCount < folderItemCount { + try await sendAction(socket: socket, action: .nextFile) // nextFile + } + + } else if itemType == 0 { + // File entry + let parentComponents = pathComponents.dropLast() + let fileName = pathComponents.last ?? "untitled" + + // Request file download + try await sendAction(socket: socket, action: .sendFile) // sendFile + + // Read file size + let fileSizeData = try await socket.read(4) + let fileSize = fileSizeData.readUInt32(at: 0)! + totalBytesTransferred += 4 + + print("HotlineFolderDownloadClient[\(referenceNumber)]: File '\(fileName)' size: \(fileSize) bytes") + + // Notify item progress before download starts + completedItemCount += 1 + itemProgressHandler?(HotlineFolderItemProgress( + fileName: fileName, + itemNumber: completedItemCount, + totalItems: folderItemCount + )) + + // Download the file with overall folder progress tracking + let (fileURL, fileBytesRead) = try await downloadFile( + socket: socket, + fileName: fileName, + parentPath: Array(parentComponents), + destinationFolder: destinationURL, + fileSize: fileSize, + itemNumber: completedItemCount, + totalItems: folderItemCount, + totalBytesTransferredSoFar: totalBytesTransferred, + progressHandler: progressHandler + ) + + totalBytesTransferred += fileBytesRead + self.transferSize = totalBytesTransferred + + print("HotlineFolderDownloadClient[\(referenceNumber)]: Downloaded file to \(fileURL.path)") + + // Request next item if not done + if completedItemCount < folderItemCount { + try await sendAction(socket: socket, action: .nextFile) // nextFile + } + + } else { + // Unknown item type + print("HotlineFolderDownloadClient[\(referenceNumber)]: Unknown item type \(itemType), skipping") + completedItemCount += 1 + + if completedItemCount < folderItemCount { + try await sendAction(socket: socket, action: .nextFile) // nextFile + } + } + } + + print("HotlineFolderDownloadClient[\(referenceNumber)]: Download complete!") + + // Ensure folder progress shows 100% complete + self.folderProgress?.completedUnitCount = Int64(self.transferTotal) + + progressHandler?(.completed(url: destinationURL)) + + return destinationURL + } + + // MARK: - Helper Methods + + private func connectToTransferServer() async throws -> NetSocket { + print("HotlineFolderDownloadClient[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") + + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + + print("HotlineFolderDownloadClient[\(referenceNumber)]: Connected!") + return socket + } + + private func sendAction(socket: NetSocket, action: HotlineFolderAction) async throws { + let actionData = Data(endian: .big) { + action.rawValue + } + try await socket.write(actionData) + print("HotlineFolderDownloadClient[\(referenceNumber)]: Sent action: \(action)") + } + + private func parseItemHeaderPath(_ headerData: Data) -> (type: UInt16, components: [String])? { + // Need at least: type(2) + count(2) + guard headerData.count >= 4, + let type = headerData.readUInt16(at: 0), + let count = headerData.readUInt16(at: 2) else { return nil } + + var ofs = 4 + var comps: [String] = [] + for _ in 0..= ofs + 3 else { return nil } + // per Hotline path encoding: reserved(2) then nameLen(1) then name + ofs += 2 // reserved == 0 + let nameLen = Int(headerData.readUInt8(at: ofs)!) + ofs += 1 + guard headerData.count >= ofs + nameLen else { return nil } + let nameData = headerData.subdata(in: ofs..<(ofs + nameLen)) + ofs += nameLen + + let name = String(data: nameData, encoding: .macOSRoman) + ?? String(data: nameData, encoding: .utf8) + ?? "" + comps.append(name) + } + return (type, comps) + } + + private func downloadFile( + socket: NetSocket, + fileName: String, + parentPath: [String], + destinationFolder: URL, + fileSize: UInt32, + itemNumber: Int, + totalItems: Int, + totalBytesTransferredSoFar: Int, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> (url: URL, bytesRead: Int) { + let fm = FileManager.default + var bytesRead = 0 + + // Read file header + let headerData = try await socket.read(HotlineFileHeader.DataSize) + guard let header = HotlineFileHeader(from: headerData) else { + throw HotlineTransferClientError.failedToTransfer + } + bytesRead += HotlineFileHeader.DataSize + + // Update folder progress for file header + let totalBytesNow = totalBytesTransferredSoFar + bytesRead + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + + print("HotlineFolderDownloadClient[\(referenceNumber)]: File has \(header.forkCount) forks") + + var resourceForkData: Data? + var fileHandle: FileHandle? + var filePath: URL? + var fileDataForkSize: Int = 0 + + defer { + try? fileHandle?.close() + } + + // Process each fork + for _ in 0.. 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 + let overallProgress = min(rawProgress, 1.0) // Clamp to 1.0 to avoid exceeding 100% + + // Update folder-level Finder progress + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + + // Calculate overall folder time estimate based on current speed + let remainingBytes = max(0, self.transferTotal - totalBytesNow) + let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { + TimeInterval(remainingBytes) / speed + } else { + nil + } + + // Report overall folder progress to UI + progressHandler?(.transfer( + name: fileName, + size: totalBytesNow, + total: self.transferTotal, + progress: overallProgress, + speed: p.bytesPerSecond, + estimate: estimate + )) + } + bytesRead += fileDataForkSize + + } else if forkHeader.isResourceFork { + // Read RESOURCE fork + resourceForkData = try await socket.read(Int(forkHeader.dataSize)) + bytesRead += Int(forkHeader.dataSize) + + // Update folder progress for RESOURCE fork + let totalBytesNow = totalBytesTransferredSoFar + bytesRead + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + + } else { + // Skip unsupported fork + try await socket.skip(Int(forkHeader.dataSize)) + bytesRead += Int(forkHeader.dataSize) + + // Update folder progress for skipped fork + let totalBytesNow = totalBytesTransferredSoFar + bytesRead + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + } + } + + // Close file handle + try? fileHandle?.close() + fileHandle = nil + + guard let finalPath = filePath else { + throw HotlineTransferClientError.failedToTransfer + } + + // Write resource fork if present + if let rsrcData = resourceForkData, !rsrcData.isEmpty { + try writeResourceFork(data: rsrcData, to: finalPath) + } + + return (finalPath, bytesRead) + } + + private func writeResourceFork(data: Data, to url: URL) throws { + var resolvedURL = url + resolvedURL.resolveSymlinksInPath() + + let resourceURL = resolvedURL.urlForResourceFork() + try data.write(to: resourceURL) + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift deleted file mode 100644 index 600b9f2..0000000 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift +++ /dev/null @@ -1,453 +0,0 @@ -import Foundation -import Network - -/// Item progress callback for folder downloads -public struct HotlineFolderItemProgress: Sendable { - public let fileName: String - public let itemNumber: Int - public let totalItems: Int -} - -@MainActor -public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { - private let serverAddress: String - private let serverPort: UInt16 - private let referenceNumber: UInt32 - - private let transferTotal: Int - private let folderItemCount: Int - private var transferSize: Int = 0 - - private var socket: NetSocket? - private var downloadTask: Task? - private var folderProgress: Progress? - - // MARK: - Initialization - - public init( - address: String, - port: UInt16, - reference: UInt32, - size: UInt32, - itemCount: Int - ) { - self.serverAddress = address - self.serverPort = port - self.referenceNumber = reference - self.transferTotal = Int(size) - self.folderItemCount = itemCount - - print("HotlineFolderDownloadClientNew[\(reference)]: Server reported transferSize=\(size) bytes, folderItemCount=\(itemCount) items") - } - - // MARK: - Public API - - public func download( - to location: HotlineDownloadLocation, - progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, - itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? = nil - ) async throws -> URL { - self.downloadTask?.cancel() - - let task = Task { - try await performDownload( - to: location, - progressHandler: progressHandler, - itemProgressHandler: itemProgressHandler - ) - } - self.downloadTask = task - - do { - let url = try await task.value - self.downloadTask = nil - return url - } catch { - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Failed to download folder: \(error)") - self.downloadTask = nil - progressHandler?(.error(error)) - throw error - } - } - - /// Cancel the current download - public func cancel() { - downloadTask?.cancel() - downloadTask = nil - - if let socket = socket { - Task { - await socket.close() - } - } - } - - // MARK: - Private Implementation - - private func performDownload( - to destination: HotlineDownloadLocation, - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?, - itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? - ) async throws -> URL { - - var destinationFilename: String - - progressHandler?(.connecting) - - // Connect to transfer server - let socket = try await connectToTransferServer() - self.socket = socket - defer { Task { await socket.close() } } - - // Determine destination folder URL - let fm = FileManager.default - let destinationURL: URL - - switch destination { - case .url(let url): - destinationURL = url - destinationFilename = url.lastPathComponent - case .downloads(let filename): - let downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) - destinationFilename = destinationURL.lastPathComponent - } - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Downloading folder to \(destinationURL.path)") - - // Create destination folder - try? fm.removeItem(at: destinationURL) - try fm.createDirectory(at: destinationURL, withIntermediateDirectories: true) - - // Create and publish progress for the entire folder (shows in Finder) - let progress = Progress(totalUnitCount: Int64(self.transferTotal)) - progress.fileURL = destinationURL - progress.fileOperationKind = .downloading - progress.publish() - self.folderProgress = progress - - // Send initial magic header - print("HotlineFolderDownloadClientNew[\(self.referenceNumber)]: Sending HTXF magic") - try await socket.write(Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero // data size = 0 - UInt16(1) // type = 1 (folder transfer) - UInt16.zero // reserved = 0 - HotlineFolderAction.nextFile.rawValue // action = 3 (next file) - }) - - progressHandler?(.connected) - progressHandler?(.transfer(name: destinationFilename, size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil)) - - var completedItemCount = 0 - var totalBytesTransferred = 0 - - // Process each item in the folder - while completedItemCount < self.folderItemCount { - // Read item header - let headerLenData = try await socket.read(2) - let headerLen = Int(headerLenData.readUInt16(at: 0)!) - let headerData = try await socket.read(headerLen) - - totalBytesTransferred += 2 + headerLen - - guard let (itemType, pathComponents) = self.parseItemHeaderPath(headerData) else { - throw HotlineTransferClientError.failedToTransfer - } - - let joinedPath = pathComponents.joined(separator: "/") - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Item type=\(itemType) path=\(joinedPath)") - - if itemType == 1 { - // Folder entry - no progress shown for folder creation - if !pathComponents.isEmpty { - let folderURL = destinationURL.appendingPathComponents(pathComponents) - try fm.createDirectory(at: folderURL, withIntermediateDirectories: true) - print("HotlineFolderDownloadClientNew[\(self.referenceNumber)]: Created folder at \(folderURL.path)") - } - - completedItemCount += 1 - - // Request next item if not done - if completedItemCount < folderItemCount { - try await sendAction(socket: socket, action: .nextFile) // nextFile - } - - } else if itemType == 0 { - // File entry - let parentComponents = pathComponents.dropLast() - let fileName = pathComponents.last ?? "untitled" - - // Request file download - try await sendAction(socket: socket, action: .sendFile) // sendFile - - // Read file size - let fileSizeData = try await socket.read(4) - let fileSize = fileSizeData.readUInt32(at: 0)! - totalBytesTransferred += 4 - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: File '\(fileName)' size: \(fileSize) bytes") - - // Notify item progress before download starts - completedItemCount += 1 - itemProgressHandler?(HotlineFolderItemProgress( - fileName: fileName, - itemNumber: completedItemCount, - totalItems: folderItemCount - )) - - // Download the file with overall folder progress tracking - let (fileURL, fileBytesRead) = try await downloadFile( - socket: socket, - fileName: fileName, - parentPath: Array(parentComponents), - destinationFolder: destinationURL, - fileSize: fileSize, - itemNumber: completedItemCount, - totalItems: folderItemCount, - totalBytesTransferredSoFar: totalBytesTransferred, - progressHandler: progressHandler - ) - - totalBytesTransferred += fileBytesRead - self.transferSize = totalBytesTransferred - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Downloaded file to \(fileURL.path)") - - // Request next item if not done - if completedItemCount < folderItemCount { - try await sendAction(socket: socket, action: .nextFile) // nextFile - } - - } else { - // Unknown item type - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Unknown item type \(itemType), skipping") - completedItemCount += 1 - - if completedItemCount < folderItemCount { - try await sendAction(socket: socket, action: .nextFile) // nextFile - } - } - } - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Download complete!") - - // Ensure folder progress shows 100% complete - self.folderProgress?.completedUnitCount = Int64(self.transferTotal) - - progressHandler?(.completed(url: destinationURL)) - - return destinationURL - } - - // MARK: - Helper Methods - - private func connectToTransferServer() async throws -> NetSocket { - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connected!") - return socket - } - - private func sendAction(socket: NetSocket, action: HotlineFolderAction) async throws { - let actionData = Data(endian: .big) { - action.rawValue - } - try await socket.write(actionData) - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Sent action: \(action)") - } - - private func parseItemHeaderPath(_ headerData: Data) -> (type: UInt16, components: [String])? { - // Need at least: type(2) + count(2) - guard headerData.count >= 4, - let type = headerData.readUInt16(at: 0), - let count = headerData.readUInt16(at: 2) else { return nil } - - var ofs = 4 - var comps: [String] = [] - for _ in 0..= ofs + 3 else { return nil } - // per Hotline path encoding: reserved(2) then nameLen(1) then name - ofs += 2 // reserved == 0 - let nameLen = Int(headerData.readUInt8(at: ofs)!) - ofs += 1 - guard headerData.count >= ofs + nameLen else { return nil } - let nameData = headerData.subdata(in: ofs..<(ofs + nameLen)) - ofs += nameLen - - let name = String(data: nameData, encoding: .macOSRoman) - ?? String(data: nameData, encoding: .utf8) - ?? "" - comps.append(name) - } - return (type, comps) - } - - private func downloadFile( - socket: NetSocket, - fileName: String, - parentPath: [String], - destinationFolder: URL, - fileSize: UInt32, - itemNumber: Int, - totalItems: Int, - totalBytesTransferredSoFar: Int, - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? - ) async throws -> (url: URL, bytesRead: Int) { - let fm = FileManager.default - var bytesRead = 0 - - // Read file header - let headerData = try await socket.read(HotlineFileHeader.DataSize) - guard let header = HotlineFileHeader(from: headerData) else { - throw HotlineTransferClientError.failedToTransfer - } - bytesRead += HotlineFileHeader.DataSize - - // Update folder progress for file header - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: File has \(header.forkCount) forks") - - var resourceForkData: Data? - var fileHandle: FileHandle? - var filePath: URL? - var fileDataForkSize: Int = 0 - - defer { - try? fileHandle?.close() - } - - // Process each fork - for _ in 0.. 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 - let overallProgress = min(rawProgress, 1.0) // Clamp to 1.0 to avoid exceeding 100% - - // Update folder-level Finder progress - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - - // Calculate overall folder time estimate based on current speed - let remainingBytes = max(0, self.transferTotal - totalBytesNow) - let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { - TimeInterval(remainingBytes) / speed - } else { - nil - } - - // Report overall folder progress to UI - progressHandler?(.transfer( - name: fileName, - size: totalBytesNow, - total: self.transferTotal, - progress: overallProgress, - speed: p.bytesPerSecond, - estimate: estimate - )) - } - bytesRead += fileDataForkSize - - } else if forkHeader.isResourceFork { - // Read RESOURCE fork - resourceForkData = try await socket.read(Int(forkHeader.dataSize)) - bytesRead += Int(forkHeader.dataSize) - - // Update folder progress for RESOURCE fork - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - - } else { - // Skip unsupported fork - try await socket.skip(Int(forkHeader.dataSize)) - bytesRead += Int(forkHeader.dataSize) - - // Update folder progress for skipped fork - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - } - } - - // Close file handle - try? fileHandle?.close() - fileHandle = nil - - guard let finalPath = filePath else { - throw HotlineTransferClientError.failedToTransfer - } - - // Write resource fork if present - if let rsrcData = resourceForkData, !rsrcData.isEmpty { - try writeResourceFork(data: rsrcData, to: finalPath) - } - - return (finalPath, bytesRead) - } - - private func writeResourceFork(data: Data, to url: URL) throws { - var resolvedURL = url - resolvedURL.resolveSymlinksInPath() - - let resourceURL = resolvedURL.urlForResourceFork() - try data.write(to: resourceURL) - } -} diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift new file mode 100644 index 0000000..1947de6 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift @@ -0,0 +1,512 @@ +import Foundation +import Network + +/// Item progress callback for folder uploads +public struct HotlineFolderItemUploadProgress: Sendable { + public let fileName: String + public let itemNumber: Int + public let totalItems: Int +} + +/// Represents a file or folder in the upload queue +private struct FolderItem { + let url: URL + let pathComponents: [String] // Path relative to upload root + let isFolder: Bool +} + +@MainActor +public class HotlineFolderUploadClient: @MainActor HotlineTransferClient { + public struct Configuration: Sendable { + public var chunkSize: Int = 256 * 1024 + public init() {} + } + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + private let folderURL: URL + + private let config: Configuration + + private var transferTotal: Int = 0 + private var transferSize: Int = 0 + private var folderItems: [FolderItem] = [] + private var totalItems: Int = 0 + + private var socket: NetSocket? + private var uploadTask: Task? + + public init?( + folderURL: URL, + address: String, + port: UInt16, + reference: UInt32, + configuration: Configuration = .init() + ) { + guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false)) else { + return nil + } + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false), isDirectory: &isDirectory), + isDirectory.boolValue else { + return nil + } + + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.folderURL = folderURL + self.config = configuration + + print("HotlineFolderUploadClientNew[\(reference)]: Preparing to upload folder '\(folderURL.lastPathComponent)'") + } + + // MARK: - API + + public func upload( + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, + itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? = nil + ) async throws { + self.uploadTask?.cancel() + + let task = Task { + try await performUpload( + progressHandler: progressHandler, + itemProgressHandler: itemProgressHandler + ) + } + self.uploadTask = task + + do { + try await task.value + self.uploadTask = nil + } catch { + print("HotlineFolderUploadClientNew[\(referenceNumber)]: Failed to upload folder: \(error)") + self.uploadTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current upload + public func cancel() { + uploadTask?.cancel() + uploadTask = nil + + if let socket = socket { + Task { + await socket.close() + } + } + } + + // MARK: - Implementation + + private enum UploadStage { + case waitingForNextFile // Waiting for server to send .nextFile action + case sendingItemHeader // Sending item header to server + case waitingForFileAction // Waiting for server action after file header (.sendFile, .nextFile, .resumeFile) + case uploadingFile // Uploading file data + case done // All items uploaded + } + + private func performUpload( + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?, + itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? + ) async throws { + + // Note that we're preparing now. + progressHandler?(.preparing) + + // Start accessing security-scoped resource + let didStartAccess = folderURL.startAccessingSecurityScopedResource() + defer { + if didStartAccess { + self.folderURL.stopAccessingSecurityScopedResource() + } + } + + // Build folder hierarchy (excluding root folder itself) + try buildFolderHierarchy() + + // Fast path if this is an empty folder + if self.totalItems == 0 { + progressHandler?(.completed(url: nil)) + return + } + + // Note that we're connecting now. + progressHandler?(.connecting) + + // Connect to transfer server + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + + self.socket = socket + defer { Task { await socket.close() } } + + // Send magic header for folder upload + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero // data size = 0 + UInt16(1) // type = 1 (folder transfer) + UInt16.zero // reserved = 0 + }) + + progressHandler?(.connected) + + var completedItemCount = 0 + var totalBytesTransferred = 0 + var itemIndex = 0 + var stage: UploadStage = .waitingForNextFile + var currentItem: FolderItem? + + // State machine loop + while stage != .done { + switch stage { + + case .waitingForNextFile: + // Wait for server to send .nextFile action + let action = try await self.readAction(socket: socket) + guard action == .nextFile else { + throw HotlineTransferClientError.failedToTransfer + } + + // Check if we have more items to send + if itemIndex < self.folderItems.count { + currentItem = self.folderItems[itemIndex] + itemIndex += 1 + stage = .sendingItemHeader + } else { + // No more items + stage = .done + } + + case .sendingItemHeader: + // Send item header to server + guard let item = currentItem else { + throw HotlineTransferClientError.failedToTransfer + } + + // Encode and send item header + totalBytesTransferred += try await socket.write(self.encodeItemHeader(item: item)) + + // Next: wait for server's response + if item.isFolder { + // For folders, we're done with this item (just creating the directory) + completedItemCount += 1 + // Server should immediately respond with .nextFile + stage = .waitingForNextFile + } else { + // For files, server will tell us what to do + stage = .waitingForFileAction + } + + case .waitingForFileAction: + // Wait for server action after file header (.sendFile, .nextFile, .resumeFile) + guard currentItem != nil else { + throw HotlineTransferClientError.failedToTransfer + } + + let action = try await self.readAction(socket: socket) + switch action { + case .nextFile: + // Server wants to skip this file + completedItemCount += 1 + // The .nextFile action means send next item, check if we have more + if itemIndex < self.folderItems.count { + currentItem = self.folderItems[itemIndex] + itemIndex += 1 + stage = .sendingItemHeader + } else { + stage = .done + } + + case .sendFile: + // Server wants the file + completedItemCount += 1 + stage = .uploadingFile + + case .resumeFile: + // Server wants to resume + let resumeSizeData = try await socket.read(2) + let resumeSize = Int(resumeSizeData.readUInt16(at: 0)!) + let _ = try await socket.read(resumeSize) + completedItemCount += 1 + stage = .uploadingFile + } + + case .uploadingFile: + // Upload file data + guard let item = currentItem else { + throw HotlineTransferClientError.failedToTransfer + } + + // Notify item progress + itemProgressHandler?(HotlineFolderItemUploadProgress( + fileName: item.url.lastPathComponent, + itemNumber: completedItemCount, + totalItems: self.totalItems + )) + + // Upload the file + let bytesUploaded = try await self.uploadFile( + socket: socket, + fileURL: item.url, + itemNumber: completedItemCount, + totalItems: self.totalItems, + totalBytesTransferredSoFar: totalBytesTransferred, + progressHandler: progressHandler + ) + + totalBytesTransferred += bytesUploaded + self.transferSize = totalBytesTransferred + + // After uploading, wait for server to send .nextFile + stage = .waitingForNextFile + + case .done: + break + } + } + + // All items processed + progressHandler?(.completed(url: nil)) + } + + private func buildFolderHierarchy() throws { + let fm = FileManager.default + folderItems = [] + transferTotal = 0 + + let rootFolderName = folderURL.lastPathComponent + + // Recursively walk the folder + func walkFolder(at url: URL, relativePath: [String]) throws { + let contents = try fm.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], options: [.skipsHiddenFiles]) + + for itemURL in contents { + let resourceValues = try itemURL.resourceValues(forKeys: [.isDirectoryKey]) + let isDirectory = resourceValues.isDirectory ?? false + let itemName = itemURL.lastPathComponent + let itemPath = relativePath + [itemName] + + if isDirectory { + // Add folder to list + folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: true)) + + // Recurse into subfolder + try walkFolder(at: itemURL, relativePath: itemPath) + + } else { + // Add file to list and calculate size + if let fileSize = FileManager.default.getFlattenedFileSize(itemURL) { + folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: false)) + transferTotal += Int(fileSize) + } + } + } + } + + // Start from root folder with root name as first path component + try walkFolder(at: folderURL, relativePath: [rootFolderName]) + totalItems = folderItems.count + + print("BUILD HEIRARCHY (\(folderItems.count) items):\n", folderItems) + } + + private func encodeItemHeader(item: FolderItem) -> Data { + let strippedPath = item.pathComponents.count > 1 ? Array(item.pathComponents.dropFirst()) : item.pathComponents + let strippedPathCount = strippedPath.count + + // Build path components (Hotline format: reserved(2) + nameLen(1) + name) + var pathData = Data() + for component in strippedPath { + let nameData = component.data(using: .macOSRoman) ?? component.data(using: .utf8) ?? Data() + let nameLen = min(nameData.count, 255) + + pathData.append(contentsOf: [0, 0]) // reserved + pathData.append(UInt8(nameLen)) + pathData.append(nameData.prefix(nameLen)) + } + + // Calculate header size (this is what goes in the DataSize field) + // DataSize = isFolder(2) + pathCount(2) + pathData + let headerSize = 2 + 2 + pathData.count + + return Data(endian: .big) { + UInt16(headerSize) + UInt16(item.isFolder ? 1 : 0) + UInt16(strippedPathCount) + pathData + } + } + + private func readAction(socket: NetSocket) async throws -> HotlineFolderAction { + let actionData = try await socket.read(2) + guard let rawAction = actionData.readUInt16(at: 0), + let action = HotlineFolderAction(rawValue: rawAction) else { + throw HotlineTransferClientError.failedToTransfer + } + return action + } + + private func uploadFile( + socket: NetSocket, + fileURL: URL, + itemNumber: Int, + totalItems: Int, + totalBytesTransferredSoFar: Int, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> Int { + var bytesUploaded = 0 + let filename = fileURL.lastPathComponent + + // Get file metadata + guard let infoFork = HotlineFileInfoFork(file: fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + guard let header = HotlineFileHeader(file: fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + let infoForkData = infoFork.data() + let dataForkSize = forkSizes.dataForkSize + let resourceForkSize = forkSizes.resourceForkSize + + // Calculate total flattened file size + guard let flattenedSize = FileManager.default.getFlattenedFileSize(fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + let totalFileSize = Int(flattenedSize) + + // Send file size + let fileSizeData = Data(endian: .big) { + UInt32(totalFileSize) + } + try await socket.write(fileSizeData) + bytesUploaded += 4 + + // Send file header + let headerData = header.data() + try await socket.write(headerData) + bytesUploaded += headerData.count + + // Send INFO fork header + let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) + try await socket.write(infoForkHeader.data()) + bytesUploaded += HotlineFileForkHeader.DataSize + + // Send INFO fork data + try await socket.write(infoForkData) + bytesUploaded += infoForkData.count + + // Create per-file progress for Finder + let fileProgress = Progress(totalUnitCount: Int64(totalFileSize)) + fileProgress.fileURL = fileURL.resolvingSymlinksInPath() + fileProgress.fileOperationKind = Progress.FileOperationKind.uploading + fileProgress.publish() + + defer { + fileProgress.unpublish() + } + + // Send DATA fork if present + let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) + try await socket.write(dataForkHeader.data()) + bytesUploaded += HotlineFileForkHeader.DataSize + + if dataForkSize > 0 { + // Stream DATA fork + let fileHandle = try FileHandle(forReadingFrom: fileURL) + defer { try? fileHandle.close() } + + let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) + for try await p in updates { + // Update per-file Finder progress + fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) + + // Calculate overall folder progress + let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent + let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 + let overallProgress = min(rawProgress, 1.0) + + // Calculate overall time estimate + let remainingBytes = max(0, self.transferTotal - totalBytesNow) + let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { + TimeInterval(remainingBytes) / speed + } else { + nil + } + + // Report overall folder progress + progressHandler?(.transfer( + name: filename, + size: totalBytesNow, + total: self.transferTotal, + progress: overallProgress, + speed: p.bytesPerSecond, + estimate: estimate + )) + } + + bytesUploaded += Int(dataForkSize) + } + + // Send RESOURCE fork if present + if resourceForkSize > 0 { + let resourceURL = fileURL.urlForResourceFork() + + let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) + try await socket.write(resourceForkHeader.data()) + bytesUploaded += HotlineFileForkHeader.DataSize + + // Stream RESOURCE fork + let resourceHandle = try FileHandle(forReadingFrom: resourceURL) + defer { try? resourceHandle.close() } + + let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) + for try await p in updates { + // Update per-file Finder progress + fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) + + // Calculate overall folder progress + let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent + let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 + let overallProgress = min(rawProgress, 1.0) + + // Calculate overall time estimate + let remainingBytes = max(0, self.transferTotal - totalBytesNow) + let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { + TimeInterval(remainingBytes) / speed + } else { + nil + } + + // Report overall folder progress + progressHandler?(.transfer( + name: filename, + size: totalBytesNow, + total: self.transferTotal, + progress: overallProgress, + speed: p.bytesPerSecond, + estimate: estimate + )) + } + + bytesUploaded += Int(resourceForkSize) + } + + return bytesUploaded + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift deleted file mode 100644 index 15636f3..0000000 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift +++ /dev/null @@ -1,518 +0,0 @@ -import Foundation -import Network - -/// Item progress callback for folder uploads -public struct HotlineFolderItemUploadProgress: Sendable { - public let fileName: String - public let itemNumber: Int - public let totalItems: Int -} - -/// Represents a file or folder in the upload queue -private struct FolderItem { - let url: URL - let pathComponents: [String] // Path relative to upload root - let isFolder: Bool -} - -@MainActor -public class HotlineFolderUploadClientNew: @MainActor HotlineTransferClient { - // MARK: - Configuration - - public struct Configuration: Sendable { - public var chunkSize: Int = 256 * 1024 - public init() {} - } - - // MARK: - Properties - - private let serverAddress: String - private let serverPort: UInt16 - private let referenceNumber: UInt32 - private let folderURL: URL - - private let config: Configuration - - private var transferTotal: Int = 0 - private var transferSize: Int = 0 - private var folderItems: [FolderItem] = [] - private var totalItems: Int = 0 - - private var socket: NetSocket? - private var uploadTask: Task? - - // MARK: - Initialization - - public init?( - folderURL: URL, - address: String, - port: UInt16, - reference: UInt32, - configuration: Configuration = .init() - ) { - guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false)) else { - return nil - } - - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false), isDirectory: &isDirectory), - isDirectory.boolValue else { - return nil - } - - self.serverAddress = address - self.serverPort = port - self.referenceNumber = reference - self.folderURL = folderURL - self.config = configuration - - print("HotlineFolderUploadClientNew[\(reference)]: Preparing to upload folder '\(folderURL.lastPathComponent)'") - } - - // MARK: - API - - public func upload( - progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, - itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? = nil - ) async throws { - self.uploadTask?.cancel() - - let task = Task { - try await performUpload( - progressHandler: progressHandler, - itemProgressHandler: itemProgressHandler - ) - } - self.uploadTask = task - - do { - try await task.value - self.uploadTask = nil - } catch { - print("HotlineFolderUploadClientNew[\(referenceNumber)]: Failed to upload folder: \(error)") - self.uploadTask = nil - progressHandler?(.error(error)) - throw error - } - } - - /// Cancel the current upload - public func cancel() { - uploadTask?.cancel() - uploadTask = nil - - if let socket = socket { - Task { - await socket.close() - } - } - } - - // MARK: - - - private enum UploadStage { - case waitingForNextFile // Waiting for server to send .nextFile action - case sendingItemHeader // Sending item header to server - case waitingForFileAction // Waiting for server action after file header (.sendFile, .nextFile, .resumeFile) - case uploadingFile // Uploading file data - case done // All items uploaded - } - - private func performUpload( - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?, - itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? - ) async throws { - - // Note that we're preparing now. - progressHandler?(.preparing) - - // Start accessing security-scoped resource - let didStartAccess = folderURL.startAccessingSecurityScopedResource() - defer { - if didStartAccess { - self.folderURL.stopAccessingSecurityScopedResource() - } - } - - // Build folder hierarchy (excluding root folder itself) - try buildFolderHierarchy() - - // Fast path if this is an empty folder - if self.totalItems == 0 { - progressHandler?(.completed(url: nil)) - return - } - - // Note that we're connecting now. - progressHandler?(.connecting) - - // Connect to transfer server - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - - self.socket = socket - defer { Task { await socket.close() } } - - // Send magic header for folder upload - try await socket.write(Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero // data size = 0 - UInt16(1) // type = 1 (folder transfer) - UInt16.zero // reserved = 0 - }) - - progressHandler?(.connected) - - var completedItemCount = 0 - var totalBytesTransferred = 0 - var itemIndex = 0 - var stage: UploadStage = .waitingForNextFile - var currentItem: FolderItem? - - // State machine loop - while stage != .done { - switch stage { - - case .waitingForNextFile: - // Wait for server to send .nextFile action - let action = try await self.readAction(socket: socket) - guard action == .nextFile else { - throw HotlineTransferClientError.failedToTransfer - } - - // Check if we have more items to send - if itemIndex < self.folderItems.count { - currentItem = self.folderItems[itemIndex] - itemIndex += 1 - stage = .sendingItemHeader - } else { - // No more items - stage = .done - } - - case .sendingItemHeader: - // Send item header to server - guard let item = currentItem else { - throw HotlineTransferClientError.failedToTransfer - } - - // Encode and send item header - totalBytesTransferred += try await socket.write(self.encodeItemHeader(item: item)) - - // Next: wait for server's response - if item.isFolder { - // For folders, we're done with this item (just creating the directory) - completedItemCount += 1 - // Server should immediately respond with .nextFile - stage = .waitingForNextFile - } else { - // For files, server will tell us what to do - stage = .waitingForFileAction - } - - case .waitingForFileAction: - // Wait for server action after file header (.sendFile, .nextFile, .resumeFile) - guard currentItem != nil else { - throw HotlineTransferClientError.failedToTransfer - } - - let action = try await self.readAction(socket: socket) - switch action { - case .nextFile: - // Server wants to skip this file - completedItemCount += 1 - // The .nextFile action means send next item, check if we have more - if itemIndex < self.folderItems.count { - currentItem = self.folderItems[itemIndex] - itemIndex += 1 - stage = .sendingItemHeader - } else { - stage = .done - } - - case .sendFile: - // Server wants the file - completedItemCount += 1 - stage = .uploadingFile - - case .resumeFile: - // Server wants to resume - let resumeSizeData = try await socket.read(2) - let resumeSize = Int(resumeSizeData.readUInt16(at: 0)!) - let _ = try await socket.read(resumeSize) - completedItemCount += 1 - stage = .uploadingFile - } - - case .uploadingFile: - // Upload file data - guard let item = currentItem else { - throw HotlineTransferClientError.failedToTransfer - } - - // Notify item progress - itemProgressHandler?(HotlineFolderItemUploadProgress( - fileName: item.url.lastPathComponent, - itemNumber: completedItemCount, - totalItems: self.totalItems - )) - - // Upload the file - let bytesUploaded = try await self.uploadFile( - socket: socket, - fileURL: item.url, - itemNumber: completedItemCount, - totalItems: self.totalItems, - totalBytesTransferredSoFar: totalBytesTransferred, - progressHandler: progressHandler - ) - - totalBytesTransferred += bytesUploaded - self.transferSize = totalBytesTransferred - - // After uploading, wait for server to send .nextFile - stage = .waitingForNextFile - - case .done: - break - } - } - - // All items processed - progressHandler?(.completed(url: nil)) - } - - private func buildFolderHierarchy() throws { - let fm = FileManager.default - folderItems = [] - transferTotal = 0 - - let rootFolderName = folderURL.lastPathComponent - - // Recursively walk the folder - func walkFolder(at url: URL, relativePath: [String]) throws { - let contents = try fm.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], options: [.skipsHiddenFiles]) - - for itemURL in contents { - let resourceValues = try itemURL.resourceValues(forKeys: [.isDirectoryKey]) - let isDirectory = resourceValues.isDirectory ?? false - let itemName = itemURL.lastPathComponent - let itemPath = relativePath + [itemName] - - if isDirectory { - // Add folder to list - folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: true)) - - // Recurse into subfolder - try walkFolder(at: itemURL, relativePath: itemPath) - - } else { - // Add file to list and calculate size - if let fileSize = FileManager.default.getFlattenedFileSize(itemURL) { - folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: false)) - transferTotal += Int(fileSize) - } - } - } - } - - // Start from root folder with root name as first path component - try walkFolder(at: folderURL, relativePath: [rootFolderName]) - totalItems = folderItems.count - - print("BUILD HEIRARCHY (\(folderItems.count) items):\n", folderItems) - } - - private func encodeItemHeader(item: FolderItem) -> Data { - let strippedPath = item.pathComponents.count > 1 ? Array(item.pathComponents.dropFirst()) : item.pathComponents - let strippedPathCount = strippedPath.count - - // Build path components (Hotline format: reserved(2) + nameLen(1) + name) - var pathData = Data() - for component in strippedPath { - let nameData = component.data(using: .macOSRoman) ?? component.data(using: .utf8) ?? Data() - let nameLen = min(nameData.count, 255) - - pathData.append(contentsOf: [0, 0]) // reserved - pathData.append(UInt8(nameLen)) - pathData.append(nameData.prefix(nameLen)) - } - - // Calculate header size (this is what goes in the DataSize field) - // DataSize = isFolder(2) + pathCount(2) + pathData - let headerSize = 2 + 2 + pathData.count - - return Data(endian: .big) { - UInt16(headerSize) - UInt16(item.isFolder ? 1 : 0) - UInt16(strippedPathCount) - pathData - } - } - - private func readAction(socket: NetSocket) async throws -> HotlineFolderAction { - let actionData = try await socket.read(2) - guard let rawAction = actionData.readUInt16(at: 0), - let action = HotlineFolderAction(rawValue: rawAction) else { - throw HotlineTransferClientError.failedToTransfer - } - return action - } - - private func uploadFile( - socket: NetSocket, - fileURL: URL, - itemNumber: Int, - totalItems: Int, - totalBytesTransferredSoFar: Int, - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? - ) async throws -> Int { - var bytesUploaded = 0 - let filename = fileURL.lastPathComponent - - // Get file metadata - guard let infoFork = HotlineFileInfoFork(file: fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - guard let header = HotlineFileHeader(file: fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - let infoForkData = infoFork.data() - let dataForkSize = forkSizes.dataForkSize - let resourceForkSize = forkSizes.resourceForkSize - - // Calculate total flattened file size - guard let flattenedSize = FileManager.default.getFlattenedFileSize(fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - let totalFileSize = Int(flattenedSize) - - // Send file size - let fileSizeData = Data(endian: .big) { - UInt32(totalFileSize) - } - try await socket.write(fileSizeData) - bytesUploaded += 4 - - // Send file header - let headerData = header.data() - try await socket.write(headerData) - bytesUploaded += headerData.count - - // Send INFO fork header - let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) - try await socket.write(infoForkHeader.data()) - bytesUploaded += HotlineFileForkHeader.DataSize - - // Send INFO fork data - try await socket.write(infoForkData) - bytesUploaded += infoForkData.count - - // Create per-file progress for Finder - let fileProgress = Progress(totalUnitCount: Int64(totalFileSize)) - fileProgress.fileURL = fileURL.resolvingSymlinksInPath() - fileProgress.fileOperationKind = Progress.FileOperationKind.uploading - fileProgress.publish() - - defer { - fileProgress.unpublish() - } - - // Send DATA fork if present - let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) - try await socket.write(dataForkHeader.data()) - bytesUploaded += HotlineFileForkHeader.DataSize - - if dataForkSize > 0 { - // Stream DATA fork - let fileHandle = try FileHandle(forReadingFrom: fileURL) - defer { try? fileHandle.close() } - - let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) - for try await p in updates { - // Update per-file Finder progress - fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) - - // Calculate overall folder progress - let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent - let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 - let overallProgress = min(rawProgress, 1.0) - - // Calculate overall time estimate - let remainingBytes = max(0, self.transferTotal - totalBytesNow) - let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { - TimeInterval(remainingBytes) / speed - } else { - nil - } - - // Report overall folder progress - progressHandler?(.transfer( - name: filename, - size: totalBytesNow, - total: self.transferTotal, - progress: overallProgress, - speed: p.bytesPerSecond, - estimate: estimate - )) - } - - bytesUploaded += Int(dataForkSize) - } - - // Send RESOURCE fork if present - if resourceForkSize > 0 { - let resourceURL = fileURL.urlForResourceFork() - - let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) - try await socket.write(resourceForkHeader.data()) - bytesUploaded += HotlineFileForkHeader.DataSize - - // Stream RESOURCE fork - let resourceHandle = try FileHandle(forReadingFrom: resourceURL) - defer { try? resourceHandle.close() } - - let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) - for try await p in updates { - // Update per-file Finder progress - fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) - - // Calculate overall folder progress - let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent - let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 - let overallProgress = min(rawProgress, 1.0) - - // Calculate overall time estimate - let remainingBytes = max(0, self.transferTotal - totalBytesNow) - let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { - TimeInterval(remainingBytes) / speed - } else { - nil - } - - // Report overall folder progress - progressHandler?(.transfer( - name: filename, - size: totalBytesNow, - total: self.transferTotal, - progress: overallProgress, - speed: p.bytesPerSecond, - estimate: estimate - )) - } - - bytesUploaded += Int(resourceForkSize) - } - - return bytesUploaded - } -} diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift index bb28370..cf9f8ff 100644 --- a/Hotline/Library/Extensions.swift +++ b/Hotline/Library/Extensions.swift @@ -2,23 +2,85 @@ import Foundation import SwiftUI import UniformTypeIdentifiers +extension FileManager { + @discardableResult + func moveToDownloads(from sourceURL: URL, using filename: String, bounceDock: Bool = false) -> Bool { + let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename) + let destinationURL = URL(filePath: filePath).resolvingSymlinksInPath() + + do { + try FileManager.default.moveItem(at: sourceURL.resolvingSymlinksInPath(), to: destinationURL) + } + catch { + return false + } + + if bounceDock { + #if os(macOS) + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: destinationURL.path) + #endif + } + + return true + } + + @discardableResult + func copyToDownloads(from sourceURL: URL, using filename: String, bounceDock: Bool = false) -> Bool { + let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename) + let destinationURL = URL(filePath: filePath).resolvingSymlinksInPath() + + do { + try FileManager.default.copyItem(at: sourceURL.resolvingSymlinksInPath(), to: destinationURL) + } + catch { + return false + } + + if bounceDock { + #if os(macOS) + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: destinationURL.path) + #endif + } + + return true + } +} + +// MARK: - + +extension View { + @ViewBuilder + func applyNavigationDocumentIfPresent(_ url: URL?) -> some View { + if let url { + self.navigationDocument(url) + } else { + self + } + } +} + +// MARK: - + extension Data { func saveAsFileToDownloads(filename: String, bounceDock: Bool = true) -> Bool { - let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - let filePath = folderURL.generateUniqueFilePath(filename: filename) - if FileManager.default.createFile(atPath: filePath, contents: nil) { - if let h = FileHandle(forWritingAtPath: filePath) { - try? h.write(contentsOf: self) - try? h.close() - if bounceDock { - #if os(macOS) - var downloadURL = URL(filePath: filePath) - downloadURL.resolveSymlinksInPath() - DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) - #endif - } - return true + let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename) + + if FileManager.default.createFile(atPath: filePath, contents: self) { + if bounceDock { + #if os(macOS) + var downloadURL = URL(filePath: filePath) + downloadURL.resolveSymlinksInPath() + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) + #endif } + return true + +// if FileManager.default.createFile(atPath: filePath, contents: nil) { +// if let h = FileHandle(forWritingAtPath: filePath) { +// try? h.write(contentsOf: self) +// try? h.close() +// +// } } return false } diff --git a/Hotline/Models/FileInfo.swift b/Hotline/Models/FileInfo.swift index e3081c4..a2f0ea5 100644 --- a/Hotline/Models/FileInfo.swift +++ b/Hotline/Models/FileInfo.swift @@ -49,13 +49,19 @@ import UniformTypeIdentifiers var children: [FileInfo]? = nil var isPreviewable: Bool { - let fileExtension = (self.name as NSString).pathExtension.lowercased() + var fileExtension = (self.name as NSString).pathExtension.lowercased() + if fileExtension.isEmpty && !self.type.isEmpty { + let type = self.type.lowercased() + if let ext = FileManager.HFSTypeToExtension[type] { + fileExtension = ext + } + } + if let fileType = UTType(filenameExtension: fileExtension) { if fileType.canBePreviewedByQuickLook { return true } - print("FILE TYPE?", fileType, fileExtension, fileType.isSubtype(of: .pdf), fileType.isSupertype(of: .pdf)) if fileType.isSubtype(of: .image) { return true } diff --git a/Hotline/Models/PreviewFileInfo.swift b/Hotline/Models/PreviewFileInfo.swift index dcf5314..7e05a97 100644 --- a/Hotline/Models/PreviewFileInfo.swift +++ b/Hotline/Models/PreviewFileInfo.swift @@ -7,6 +7,9 @@ struct PreviewFileInfo: Identifiable, Codable { var size: Int var name: String + var type: String? = nil + var creator: String? = nil + var previewType: FilePreviewType { let fileExtension = (self.name as NSString).pathExtension if let fileType = UTType(filenameExtension: fileExtension) { diff --git a/Hotline/State/AppUpdate.swift b/Hotline/State/AppUpdate.swift index 601859e..558a296 100644 --- a/Hotline/State/AppUpdate.swift +++ b/Hotline/State/AppUpdate.swift @@ -35,8 +35,6 @@ final class AppUpdate { case manual } - // MARK: - Public State - var isChecking = false var isDownloading = false var showWindow = false @@ -55,7 +53,7 @@ final class AppUpdate { private let remindDateKey = "update.remind.date" private let lastPromptedVersionKey = "update.last.prompt.version" - // MARK: - Public API + // MARK: - API func checkForUpdatesOnLaunch() async { await checkForUpdates(trigger: .automatic) @@ -100,7 +98,7 @@ final class AppUpdate { resetAndCloseWindow() } - // MARK: - Internal Logic + // MARK: - Implementation private func checkForUpdates(trigger: CheckTrigger) async { await MainActor.run { @@ -259,8 +257,7 @@ final class AppUpdate { private func downloadRelease(_ release: UpdateReleaseInfo) async { do { let (temporaryURL, _) = try await URLSession.shared.download(from: release.downloadURL) - let downloadsDirectory = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! - let destinationURL = downloadsDirectory.appendingPathComponent(release.assetName) + let destinationURL = URL.downloadsDirectory.appendingPathComponent(release.assetName) if FileManager.default.fileExists(atPath: destinationURL.path) { try? FileManager.default.removeItem(at: destinationURL) diff --git a/Hotline/State/FilePreviewState.swift b/Hotline/State/FilePreviewState.swift index 27f8199..ec79f66 100644 --- a/Hotline/State/FilePreviewState.swift +++ b/Hotline/State/FilePreviewState.swift @@ -1,8 +1,6 @@ import SwiftUI import UniformTypeIdentifiers -// MARK: - Preview Type - enum FilePreviewType: Equatable { case unknown case image @@ -13,13 +11,15 @@ enum FilePreviewType: Equatable { @MainActor @Observable final class FilePreviewState { - // MARK: - Properties - + enum LoadState: Equatable { + case unloaded + case loading + case loaded + case failed + } + let info: PreviewFileInfo - private var previewClient: HotlineFilePreviewClient? - private var previewTask: Task? - var state: LoadState = .unloaded var progress: Double = 0.0 @@ -33,39 +33,35 @@ final class FilePreviewState { var text: String? = nil var styledText: NSAttributedString? = nil - - // MARK: - Computed Properties + + @ObservationIgnored private var previewClient: HotlineFilePreviewClient? + @ObservationIgnored private var previewTask: Task? var previewType: FilePreviewType { - info.previewType + self.info.previewType } - // MARK: - Initialization - init(info: PreviewFileInfo) { self.info = info } - nonisolated deinit { - // Note: Can't access @MainActor properties from deinit - // Cleanup will happen when previewClient is deallocated - } - - // MARK: - Public API + // MARK: - API func download() { // Cancel any existing download - previewTask?.cancel() - previewClient?.cleanup() + self.previewTask?.cancel() + self.previewClient?.cleanup() let task = Task { @MainActor in do { let client = HotlineFilePreviewClient( - fileName: info.name, - address: info.address, - port: UInt16(info.port), - reference: info.id, - size: UInt32(info.size) + fileName: self.info.name, + address: self.info.address, + port: UInt16(self.info.port), + reference: self.info.id, + size: UInt32(self.info.size), + fileType: self.info.type, + fileCreator: self.info.creator ) self.previewClient = client @@ -132,69 +128,58 @@ final class FilePreviewState { } func cancel() { - previewTask?.cancel() - previewTask = nil - previewClient?.cancel() + self.previewTask?.cancel() + self.previewTask = nil + self.previewClient?.cancel() } func cleanup() { - previewClient?.cleanup() - previewClient = nil - fileURL = nil - image = nil - text = nil - styledText = nil - } - - // MARK: - Private Implementation - - private func loadPreview(from url: URL) { - guard let data = try? Data(contentsOf: url) else { - self.state = .failed - print("FilePreviewState: Failed to read preview data from \(url.path)") - return - } - - switch self.previewType { - case .image: - #if os(iOS) - self.image = UIImage(data: data) - #elseif os(macOS) - self.image = NSImage(data: data) - #endif - - if self.image == nil { - self.state = .failed - print("FilePreviewState: Failed to create image from data") - } - - case .text: - let encoding: UInt = NSString.stringEncoding(for: data, convertedString: nil, usedLossyConversion: nil) - if encoding != 0 { - self.text = String(data: data, encoding: String.Encoding(rawValue: encoding)) - } else { - self.text = String(data: data, encoding: .utf8) - } - - if self.text == nil { - self.state = .failed - print("FilePreviewState: Failed to decode text data") - } - - case .unknown: - print("FilePreviewState: Unknown preview type for \(info.name)") - break - } + self.previewClient?.cleanup() + self.previewClient = nil + self.fileURL = nil + self.image = nil + self.text = nil + self.styledText = nil } -} - -// MARK: - Load State -extension FilePreviewState { - enum LoadState: Equatable { - case unloaded - case loading - case loaded - case failed - } + // MARK: - Utility + +// private func loadPreview(from url: URL) { +// guard let data = try? Data(contentsOf: url) else { +// self.state = .failed +// print("FilePreviewState: Failed to read preview data from \(url.path)") +// return +// } +// +// switch self.previewType { +// case .image: +// #if os(iOS) +// self.image = UIImage(data: data) +// #elseif os(macOS) +// self.image = NSImage(data: data) +// #endif +// +// if self.image == nil { +// self.state = .failed +// print("FilePreviewState: Failed to create image from data") +// } +// +// case .text: +// let encoding: UInt = NSString.stringEncoding(for: data, convertedString: nil, usedLossyConversion: nil) +// if encoding != 0 { +// self.text = String(data: data, encoding: String.Encoding(rawValue: encoding)) +// } else { +// self.text = String(data: data, encoding: .utf8) +// } +// +// if self.text == nil { +// self.state = .failed +// print("FilePreviewState: Failed to decode text data") +// } +// +// case .unknown: +// print("FilePreviewState: Unknown preview type for \(info.name)") +// break +// } +// } } diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index e80571a..f814818 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -252,7 +252,7 @@ class HotlineState: Equatable { // MARK: - Private State - @ObservationIgnored private var client: HotlineClientNew? + @ObservationIgnored private var client: HotlineClient? @ObservationIgnored private var eventTask: Task? @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? @@ -308,13 +308,13 @@ class HotlineState: Equatable { iconID: UInt16(iconID) ) - print("HotlineState.login(): Calling HotlineClientNew.connect()...") - let client = try await HotlineClientNew.connect( + print("HotlineState.login(): Calling HotlineClient.connect()...") + let client = try await HotlineClient.connect( host: server.address, port: UInt16(server.port), login: loginInfo ) - print("HotlineState.login(): HotlineClientNew.connect() returned") + print("HotlineState.login(): HotlineClient.connect() returned") self.client = client print("HotlineState.login(): Client stored") @@ -865,7 +865,7 @@ class HotlineState: Equatable { /// - progressCallback: Optional callback for progress updates (receives TransferInfo and progress 0.0-1.0) /// - callback: Optional completion callback (receives TransferInfo and final file URL) @MainActor - func downloadFileNew(_ fileName: String, path: [String], to destination: URL? = nil, progress progressCallback: ((TransferInfo) -> Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil) { + func downloadFile(_ fileName: String, path: [String], to destination: URL? = nil, progress progressCallback: ((TransferInfo) -> Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil) { guard let client = self.client else { return } var fullPath: [String] = [] @@ -975,7 +975,7 @@ class HotlineState: Equatable { /// - itemProgressCallback: Optional callback for per-item updates (receives TransferInfo with current file info) /// - callback: Optional completion callback (receives TransferInfo and final folder URL) @MainActor - func downloadFolderNew( + func downloadFolder( _ folderName: String, path: [String], to destination: URL? = nil, @@ -1018,7 +1018,7 @@ class HotlineState: Equatable { AppState.shared.addTransfer(transfer) // Create download client - let downloadClient = HotlineFolderDownloadClientNew( + let downloadClient = HotlineFolderDownloadClient( address: address, port: UInt16(port), reference: referenceNumber, @@ -1091,7 +1091,7 @@ class HotlineState: Equatable { } } - /// Modern async/await folder upload using HotlineFolderUploadClientNew + /// Upload a folder to the server. /// /// - Parameters: /// - folderURL: URL to the folder on disk to upload @@ -1155,7 +1155,7 @@ class HotlineState: Equatable { print("HotlineState: Got folder upload reference: \(referenceNumber)") // Create upload client - guard let uploadClient = HotlineFolderUploadClientNew( + guard let uploadClient = HotlineFolderUploadClient( folderURL: folderURL, address: address, port: UInt16(port), @@ -1346,9 +1346,9 @@ class HotlineState: Equatable { } func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { - // TODO: Implement setFileInfo in HotlineClientNew + // TODO: Implement setFileInfo in HotlineClient // This method updates file metadata (name and/or comment) - print("setFileInfo not yet implemented in HotlineState/HotlineClientNew") + print("setFileInfo not yet implemented in HotlineState/HotlineClient") } @MainActor diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift index 805672d..5913bf5 100644 --- a/Hotline/State/ServerState.swift +++ b/Hotline/State/ServerState.swift @@ -29,7 +29,7 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable { case .files: return "Files" case .accounts: - return "Admin" + return "Accounts" case .user(let userID): return String(userID) } diff --git a/Hotline/macOS/Files/FilePreviewQuickLookView.swift b/Hotline/macOS/Files/FilePreviewQuickLookView.swift index 26bd286..a504a7a 100644 --- a/Hotline/macOS/Files/FilePreviewQuickLookView.swift +++ b/Hotline/macOS/Files/FilePreviewQuickLookView.swift @@ -11,16 +11,16 @@ struct FilePreviewQuickLookView: View { @Environment(\.dismiss) private var dismiss @Binding var info: PreviewFileInfo? - @State var preview: FilePreviewState? = nil + @State private var preview: FilePreviewState? = nil @FocusState private var focusField: FilePreviewFocus? var body: some View { Group { - if preview?.state != .loaded { + if self.preview?.state != .loaded { VStack(alignment: .center, spacing: 0) { Spacer() - ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) + ProgressView(value: max(0.0, min(1.0, self.preview?.progress ?? 0.0))) .focusable(false) .progressViewStyle(.circular) .controlSize(.extraLarge) @@ -33,7 +33,7 @@ struct FilePreviewQuickLookView: View { .padding() } else { - if let fileURL = preview?.fileURL { + if let fileURL = self.preview?.fileURL { QuickLookPreviewView(fileURL: fileURL) .frame(minWidth: 400, maxWidth: .infinity, minHeight: 400, maxHeight: .infinity) } @@ -68,25 +68,15 @@ struct FilePreviewQuickLookView: View { .focusable() .focusEffectDisabled() .background(Color(nsColor: .textBackgroundColor)) - .focused($focusField, equals: .window) - .navigationTitle(info?.name ?? "File Preview") - .background { - if let fileURL = self.preview?.fileURL { - WindowConfigurator { window in - window.representedURL = fileURL - window.standardWindowButton(.documentIconButton)?.isHidden = false - } - } - } + .focused(self.$focusField, equals: .window) + .navigationTitle(self.info?.name ?? "File Preview") + .applyNavigationDocumentIfPresent(self.preview?.fileURL) .toolbar { - if let _ = preview?.fileURL { + if let fileURL = self.preview?.fileURL { if let info = info { ToolbarItem(placement: .primaryAction) { Button { - if let fileURL = preview?.fileURL, - let data = try? Data(contentsOf: fileURL) { - let _ = data.saveAsFileToDownloads(filename: info.name) - } + FileManager.default.copyToDownloads(from: fileURL, using: info.name, bounceDock: true) } label: { Label("Download File...", systemImage: "arrow.down") } @@ -96,28 +86,27 @@ struct FilePreviewQuickLookView: View { } } .task { - if let info = info { - preview = FilePreviewState(info: info) - preview?.download() + if let info = self.info { + self.preview = FilePreviewState(info: info) + self.preview?.download() } } .onAppear { - if info == nil { - Task { - dismiss() - } + guard self.info != nil else { + self.dismiss() return } - focusField = .window + self.focusField = .window } .onDisappear { - preview?.cancel() - dismiss() + self.preview?.cancel() + self.preview?.cleanup() + self.dismiss() } - .onChange(of: preview?.state) { - if preview?.state == .failed { - dismiss() + .onChange(of: self.preview?.state) { + if self.preview?.state == .failed { + self.dismiss() } } .preferredColorScheme(.dark) diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index d4ba5c8..984b120 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -376,11 +376,11 @@ struct FilesView: View { private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { switch previewInfo.previewType { case .image: - openWindow(id: "preview-quicklook", value: previewInfo) + self.openWindow(id: "preview-quicklook", value: previewInfo) case .text: - openWindow(id: "preview-quicklook", value: previewInfo) + self.openWindow(id: "preview-quicklook", value: previewInfo) case .unknown: - openWindow(id: "preview-quicklook", value: previewInfo) + self.openWindow(id: "preview-quicklook", value: previewInfo) return } } @@ -397,10 +397,10 @@ struct FilesView: View { @MainActor private func downloadFile(_ file: FileInfo) { if file.isFolder { - model.downloadFolderNew(file.name, path: file.path) + model.downloadFolder(file.name, path: file.path) } else { - model.downloadFileNew(file.name, path: file.path) + model.downloadFile(file.name, path: file.path) } } @@ -442,9 +442,12 @@ struct FilesView: View { return } - model.previewFile(file.name, path: file.path) { info in + self.model.previewFile(file.name, path: file.path) { info in if let info = info { - openPreviewWindow(info) + var extendedInfo = info + extendedInfo.creator = file.creator + extendedInfo.type = file.type + self.openPreviewWindow(extendedInfo) } } } diff --git a/Hotline/macOS/Files/FolderItemView.swift b/Hotline/macOS/Files/FolderItemView.swift index 2b1b695..9b13cc0 100644 --- a/Hotline/macOS/Files/FolderItemView.swift +++ b/Hotline/macOS/Files/FolderItemView.swift @@ -81,7 +81,7 @@ struct FolderItemView: View { .opacity(file.isUnavailable ? 0.5 : 1.0) if loading { - ProgressView().controlSize(.small).padding([.leading, .trailing], 5) + ProgressView().controlSize(.mini).padding([.leading, .trailing], 5) } Spacer() if !file.isUnavailable { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 757dbd8..d4b3407 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -93,7 +93,7 @@ struct ServerView: View { ServerMenuItem(type: .board, name: "Board", image: "Section Board"), ServerMenuItem(type: .news, name: "News", image: "Section News"), ServerMenuItem(type: .files, name: "Files", image: "Section Files"), - ServerMenuItem(type: .accounts, name: "Admin", image: "Section Users"), + ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"), ] static var classicMenuItems: [ServerMenuItem] = [ @@ -280,47 +280,43 @@ struct ServerView: View { } var transfersSection: some View { -// Section("Transfers") { - ForEach(model.transfers) { transfer in - TransferItemView(transfer: transfer) - } -// } + ForEach(model.transfers) { transfer in + TransferItemView(transfer: transfer) + } } var usersSection: some View { -// Section("\(model.users.count) Online") { - ForEach(model.users) { user in - HStack(spacing: 5) { - if let iconImage = HotlineState.getClassicIcon(Int(user.iconID)) { - Image(nsImage: iconImage) - .frame(width: 16, height: 16) - .padding(.leading, 2) - .padding(.trailing, 2) - } - else { - Image("User") - .frame(width: 16, height: 16) - .padding(.leading, 2) - .padding(.trailing, 2) - } - - Text(user.name) - .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary) - - Spacer() - - if model.hasUnreadInstantMessages(userID: user.id) { - Circle() - .frame(width: 6, height: 6) - .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary.opacity(0.5)) - .padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 2)) - } + ForEach(model.users) { user in + HStack(spacing: 5) { + if let iconImage = HotlineState.getClassicIcon(Int(user.iconID)) { + Image(nsImage: iconImage) + .frame(width: 16, height: 16) + .padding(.leading, 2) + .padding(.trailing, 2) + } + else { + Image("User") + .frame(width: 16, height: 16) + .padding(.leading, 2) + .padding(.trailing, 2) + } + + Text(user.name) + .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary) + + Spacer() + + if model.hasUnreadInstantMessages(userID: user.id) { + Circle() + .frame(width: 6, height: 6) + .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary.opacity(0.5)) + .padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 2)) } - .opacity(user.isIdle ? 0.5 : 1.0) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - .tag(ServerNavigationType.user(userID: user.id)) } -// } + .opacity(user.isIdle ? 0.5 : 1.0) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + .tag(ServerNavigationType.user(userID: user.id)) + } } var serverView: some View { @@ -353,7 +349,7 @@ struct ServerView: View { case .accounts: AccountManagerView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Administration") + .navigationSubtitle("Accounts") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .user(let userID): let user = model.users.first(where: { $0.id == userID }) diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift index 489a50b..bf8c8bd 100644 --- a/Hotline/macOS/TransfersView.swift +++ b/Hotline/macOS/TransfersView.swift @@ -19,9 +19,7 @@ struct TransfersView: View { ToolbarItem(placement: .primaryAction) { Button { if self.selectedTransfers.isEmpty { - if let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first { - NSWorkspace.shared.open(downloadsURL) - } + NSWorkspace.shared.open(URL.downloadsDirectory) } else { let fileURLs = self.selectedTransfers.compactMap(\.fileURL) -- cgit