diff options
| author | Dustin Mierau <dustin@mierau.me> | 2025-10-21 23:09:53 -0700 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2025-10-21 23:09:53 -0700 |
| commit | 54b85a88efcd455d5c12b7ca65d425d095a675f1 (patch) | |
| tree | fa4aa78576630ffbe76bebe02491960a3356c3d6 /Hotline | |
| parent | 926c92be7a32e374c8855b2eb8b5a49485dec072 (diff) | |
Add initial support for folder downloading.
Diffstat (limited to 'Hotline')
| -rw-r--r-- | Hotline/Hotline/HotlineClient.swift | 26 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineTransferClient.swift | 880 | ||||
| -rw-r--r-- | Hotline/Models/Hotline.swift | 76 | ||||
| -rw-r--r-- | Hotline/Models/TransferInfo.swift | 5 | ||||
| -rw-r--r-- | Hotline/macOS/FilesView.swift | 17 | ||||
| -rw-r--r-- | Hotline/macOS/ServerView.swift | 20 |
6 files changed, 985 insertions, 39 deletions
diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 434285f..99aa167 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -804,9 +804,33 @@ class HotlineClient: NetSocketDelegate { } } + @MainActor func sendDownloadFolder(name folderName: String, path folderPath: [String], callback: ((Bool, UInt32?, Int?, Int?, Int?) -> Void)? = nil) { + var t = HotlineTransaction(type: .downloadFolder) + t.setFieldString(type: .fileName, val: folderName) + t.setFieldPath(type: .filePath, val: folderPath) + + self.sendPacket(t) { reply, err in + guard err == nil, + let transferSizeField = reply.getField(type: .transferSize), + let transferSize = transferSizeField.getInteger(), + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() else { + callback?(false, nil, nil, nil, nil) + return + } + + let folderItemCountField = reply.getField(type: .folderItemCount) + let folderItemCount = folderItemCountField?.getInteger() + let transferWaitingCountField = reply.getField(type: .waitingCount) + let transferWaitingCount = transferWaitingCountField?.getInteger() + + callback?(true, referenceNumber, transferSize, folderItemCount, transferWaitingCount) + } + } + @MainActor func sendDownloadBanner(callback: ((Bool, UInt32?, Int?) -> Void)? = nil) { let t = HotlineTransaction(type: .downloadBanner) - + self.sendPacket(t) { reply, err in guard err == nil, let transferSizeField = reply.getField(type: .transferSize), diff --git a/Hotline/Hotline/HotlineTransferClient.swift b/Hotline/Hotline/HotlineTransferClient.swift index 4322c70..40dd598 100644 --- a/Hotline/Hotline/HotlineTransferClient.swift +++ b/Hotline/Hotline/HotlineTransferClient.swift @@ -51,6 +51,11 @@ protocol HotlineFileUploadClientDelegate: HotlineTransferDelegate { func hotlineFileUploadComplete(client: HotlineFileUploadClient, reference: UInt32) } +protocol HotlineFolderDownloadClientDelegate: HotlineTransferDelegate { + func hotlineFolderDownloadReceivedFileInfo(client: HotlineFolderDownloadClient, reference: UInt32, fileName: String, itemNumber: Int, totalItems: Int) + func hotlineFolderDownloadComplete(client: HotlineFolderDownloadClient, reference: UInt32, at: URL) +} + enum HotlineFileTransferStage: Int { case fileHeader = 1 case fileForkHeader = 2 @@ -72,6 +77,23 @@ enum HotlineFileUploadStage: Int { case fileComplete = 9 } +enum HotlineFolderDownloadStage: Int { + case itemHeader = 0 // Read 2-byte length + item header + case waitingForFileSize = 1 // Read 4-byte file size before FILP + case fileHeader = 2 + case fileForkHeader = 3 + case fileInfoFork = 4 + case fileDataFork = 5 + case fileResourceFork = 6 + case fileUnsupportedFork = 7 +} + +private enum HotlineFolderAction: UInt16 { + case sendFile = 1 + case resumeFile = 2 + case nextFile = 3 +} + protocol HotlineTransferClient { var serverAddress: NWEndpoint.Host { get } var serverPort: NWEndpoint.Port { get } @@ -242,7 +264,7 @@ class HotlineFileUploadClient: HotlineTransferClient { } self.status = .completing - + c.send(content: nil, contentContext: .finalMessage, completion: .contentProcessed({ error in })) } @@ -291,11 +313,11 @@ class HotlineFileUploadClient: HotlineTransferClient { self.payloadSize UInt32.zero } -// var magicData = Data() -// magicData.appendUInt32("HTXF".fourCharCode()) -// magicData.appendUInt32(self.referenceNumber) -// magicData.appendUInt32(self.payloadSize) -// magicData.appendUInt32(0) + // var magicData = Data() + // magicData.appendUInt32("HTXF".fourCharCode()) + // magicData.appendUInt32(self.referenceNumber) + // magicData.appendUInt32(self.payloadSize) + // magicData.appendUInt32(0) self.stage = .fileHeader self.sendFileData(magicData) @@ -359,7 +381,7 @@ class HotlineFileUploadClient: HotlineTransferClient { self.fileHandle = nil fallthrough } - + print("Upload: Sending data Fork \(String(describing: fileData?.count))") self.sendFileData(fileData!) } @@ -561,7 +583,7 @@ class HotlineFilePreviewClient: HotlineTransferClient { guard let c = self.connection else { return } - + c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in guard let self = self else { return @@ -644,12 +666,12 @@ class HotlineFileDownloadClient: HotlineTransferClient { deinit { self.invalidate() } - + func start() { guard self.status == .unconnected else { return } - + self.filePath = nil self.connect() } @@ -810,7 +832,7 @@ class HotlineFileDownloadClient: HotlineTransferClient { } case .fileForkHeader: if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) { -// let fileForkHeader = HotlineFileForkHeader(from: self.fileBytes) + // let fileForkHeader = HotlineFileForkHeader(from: self.fileBytes) self.fileBytes.removeSubrange(0..<HotlineFileForkHeader.DataSize) self.fileCurrentForkHeader = forkHeader self.fileCurrentForkBytesLeft = Int(forkHeader.dataSize) @@ -960,10 +982,10 @@ class HotlineFileDownloadClient: HotlineTransferClient { self.delegate?.hotlineFileDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) - #if os(macOS) +#if os(macOS) // Bounce dock icon when download completes. Weird this is the only API to do so. DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) - #endif +#endif } } } @@ -1000,7 +1022,7 @@ class HotlineFileDownloadClient: HotlineTransferClient { let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] filePath = folderURL.generateUniqueFilePath(filename: name) } - + var fileAttributes: [FileAttributeKey: Any] = [:] if let creatorCode = self.fileInfoFork?.creator { fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber @@ -1032,7 +1054,7 @@ class HotlineFileDownloadClient: HotlineTransferClient { return true } } - + return false } } @@ -1181,7 +1203,7 @@ struct HotlineFileInfoFork { self.headerSize = 0 } - + init?(from data: Data) { // Make sure we have at least enough data to read basic header data guard data.count >= HotlineFileInfoFork.BaseDataSize else { @@ -1273,3 +1295,829 @@ struct HotlineFileInfoFork { return data } } + +// MARK: - + +class HotlineFolderDownloadClient: HotlineTransferClient { + let serverAddress: NWEndpoint.Host + let serverPort: NWEndpoint.Port + let referenceNumber: UInt32 + + private var connection: NWConnection? + private var transferStage: HotlineFolderDownloadStage = .fileHeader + + weak var delegate: HotlineFolderDownloadClientDelegate? = nil + + var status: HotlineTransferStatus = .unconnected { + didSet { + DispatchQueue.main.async { + self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) + } + } + } + + private let referenceDataSize: UInt32 + private let folderItemCount: Int + private var fileBytes = Data() + private var fileResourceBytes = Data() + + private var fileHeader: HotlineFileHeader? = nil + private var fileCurrentForkHeader: HotlineFileForkHeader? = nil + private var fileCurrentForkBytesLeft: Int = 0 + private var fileForksRemaining: Int = 0 + private var currentFileSize: UInt32 = 0 // Size of current file from server + private var currentFileBytesRead: Int = 0 // Bytes read for current file + private var fileInfoFork: HotlineFileInfoFork? = nil + private var fileHandle: FileHandle? = nil + private var currentFilePath: String? = nil + private var fileBytesTransferred: Int = 0 + private var fileProgress: Progress + + private var currentItemNumber: Int = 0 + private var completedItemCount: Int = 0 // Track actually completed files + private var folderPath: String? = nil + private var currentItemRelativePath: [String] = [] + private var currentFileName: String? = nil + + private let FILP_MAGIC: UInt32 = "FILP".fourCharCode() + + init(address: String, port: UInt16, reference: UInt32, size: UInt32, itemCount: Int) { + self.serverAddress = NWEndpoint.Host(address) + self.serverPort = NWEndpoint.Port(rawValue: port + 1)! + self.referenceNumber = reference + self.referenceDataSize = size + self.folderItemCount = itemCount + self.transferStage = .fileHeader + self.fileProgress = Progress(totalUnitCount: Int64(size)) + } + + deinit { + self.invalidate() + } + + func start() { + guard self.status == .unconnected else { + return + } + + self.folderPath = nil + self.connect() + } + + func start(to folderURL: URL) { + print("HotlineFolderDownloadClient: start(to:) called with path: \(folderURL.path)") + guard self.status == .unconnected else { + print("HotlineFolderDownloadClient: Already connected, status: \(self.status)") + return + } + + self.folderPath = folderURL.path + print("HotlineFolderDownloadClient: Calling connect()") + self.connect() + } + + func cancel() { + self.delegate = nil + + if self.status == .unconnected { + return + } + + // Close file before we try to potentially delete it. + if let fh = self.fileHandle { + try? fh.close() + self.fileHandle = nil + } + + if let downloadPath = self.folderPath { + print("HotlineFolderDownloadClient: Deleting folder fragment at", downloadPath) + if FileManager.default.isDeletableFile(atPath: downloadPath) { + try? FileManager.default.removeItem(atPath: downloadPath) + } + self.folderPath = nil + } + + self.invalidate() + + print("HotlineFolderDownloadClient: Cancelled transfer") + } + + private func connect() { + print("HotlineFolderDownloadClient: connect() called, connecting to \(self.serverAddress):\(self.serverPort)") + self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) + print("HotlineFolderDownloadClient: NWConnection created") + self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in + switch newState { + case .ready: + print("HotlineFolderDownloadClient: Connection ready!") + self?.status = .connected + self?.sendMagic() + case .waiting(let err): + print("HotlineFolderDownloadClient: Waiting", err) + case .cancelled: + print("HotlineFolderDownloadClient: Cancelled") + self?.invalidate() + case .failed(let err): + print("HotlineFolderDownloadClient: Connection error \(err)") + switch self?.status { + case .connecting: + print("HotlineFolderDownloadClient: Failed to connect to file transfer server.") + self?.invalidate() + self?.status = .failed(.failedToConnect) + case .connected, .progress(_): + print("HotlineFolderDownloadClient: Failed to finish transfer.") + self?.invalidate() + self?.status = .failed(.failedToDownload) + default: + break + } + default: + return + } + } + + self.status = .connecting + self.connection?.start(queue: .global()) + } + + func invalidate() { + if let c = self.connection { + c.stateUpdateHandler = nil + c.cancel() + + self.connection = nil + } + + self.fileBytes = Data() + + if let fh = self.fileHandle { + try? fh.close() + self.fileHandle = nil + } + + self.fileProgress.unpublish() + } + + 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..<Int(count) { + guard headerData.count >= 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) + } + + /// Find and align the buffer to the start of a FILP header. + /// Returns the number of bytes dropped. + @discardableResult + private func alignBufferToFILP() -> Int { + // We need at least the 4-byte magic to try + guard self.fileBytes.count >= 4 else { return 0 } + let magicData = Data([0x46, 0x49, 0x4C, 0x50]) // "FILP" + if self.fileBytes.starts(with: magicData) { return 0 } + + if let r = self.fileBytes.firstRange(of: magicData) { + let toDrop = r.lowerBound + if toDrop > 0 { + print("HotlineFolderDownloadClient: Resync — dropping \(toDrop) stray bytes before FILP") + self.fileBytes.removeSubrange(0..<toDrop) + } + return toDrop + } + + // If we can't find FILP yet, wait for more data. + return 0 + } + + private func sendMagic() { + print("HotlineFolderDownloadClient: sendMagic() called") + guard let c = connection, self.status == .connected else { + self.invalidate() + print("HotlineFolderDownloadClient: invalid connection to send header.") + return + } + + // Folder transfer initial record: HTXF + ref + dataSize(0) + type(1) + rsvd(0) + action(NextFile) + // Classic clients send a zero data size and append the action immediately after the header. + let headerData = Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero // data size = 0 (action appended implicitly) + UInt16(1) // type = 1 (folder transfer) + UInt16.zero // reserved = 0 + UInt16(HotlineFolderAction.nextFile.rawValue) // action = 3 (next file) + } + + print("HotlineFolderDownloadClient: Sending HTXF magic with reference: \(self.referenceNumber)") + + c.send(content: headerData, completion: .contentProcessed { [weak self] (error) in + guard let self = self else { + print("HotlineFolderDownloadClient: self is nil in sendMagic completion") + return + } + + guard error == nil else { + print("HotlineFolderDownloadClient: Error sending magic: \(error!)") + self.status = .failed(.failedToConnect) + self.invalidate() + return + } + + print("HotlineFolderDownloadClient: Magic sent successfully, expecting item header") + self.status = .progress(0.0) + self.currentItemNumber = 0 + self.transferStage = .itemHeader + self.receiveFolder() + }) + } + + private func sendAction(_ action: HotlineFolderAction, resumeData: Data? = nil) { + guard let c = connection else { + self.invalidate() + print("HotlineFolderDownloadClient: invalid connection to send action.") + return + } + + var actionData = Data(endian: .big) { + UInt16(action.rawValue) + } + + if let resume = resumeData, !resume.isEmpty { + actionData.append(Data(endian: .big) { UInt16(resume.count) }) + actionData.append(resume) + } + + print("HotlineFolderDownloadClient: Sending action: \(action)") + + c.send(content: actionData, completion: .contentProcessed { [weak self] (error) in + guard let self = self else { + return + } + + guard error == nil else { + print("HotlineFolderDownloadClient: Error sending action: \(error!)") + self.status = .failed(.failedToConnect) + self.invalidate() + return + } + + print("HotlineFolderDownloadClient: Action sent successfully") + // Don't call receiveFolder() here - the main receive loop handles it + }) + } + + private func receiveFolder() { + print("HotlineFolderDownloadClient: receiveFolder() called, status: \(self.status)") + guard let c = self.connection else { + print("HotlineFolderDownloadClient: No connection in receiveFolder") + return + } + + print("HotlineFolderDownloadClient: Connection state: \(c.state)") + print("HotlineFolderDownloadClient: About to call c.receive()") + + c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in + print("HotlineFolderDownloadClient: Receive completion handler called! isComplete: \(isComplete), error: \(String(describing: error)), dataCount: \(data?.count ?? 0)") + + guard let self = self else { + print("HotlineFolderDownloadClient: self is nil in receiveFolder") + return + } + + if let error = error { + if self.status == .completed, + case .posix(let posixError) = error, + posixError == .ENODATA { + print("HotlineFolderDownloadClient: Ignoring ENODATA after completion") + return + } + + print("HotlineFolderDownloadClient: Error in receiveFolder: \(error)") + self.status = .failed(.failedToDownload) + self.invalidate() + return + } + + if let newData = data, !newData.isEmpty { + print("HotlineFolderDownloadClient: Received \(newData.count) bytes (total: \(self.fileBytesTransferred + newData.count)/\(self.referenceDataSize))") + self.fileBytesTransferred += newData.count + self.fileBytes.append(newData) + self.fileProgress.completedUnitCount = Int64(self.fileBytesTransferred) + self.status = .progress(Double(self.fileBytesTransferred) / Double(self.referenceDataSize)) + } + else { + print("HotlineFolderDownloadClient: Received empty or nil data, isComplete: \(isComplete)") + } + + // Process received data + var keepProcessing = false + repeat { + keepProcessing = false + + switch self.transferStage { + case .itemHeader: + // Need 2-byte length + header data + guard self.fileBytes.count >= 2 else { break } + let headerLen = Int(self.fileBytes.readUInt16(at: 0)!) + guard self.fileBytes.count >= 2 + headerLen else { break } + + let headerData = self.fileBytes.subdata(in: 2..<(2 + headerLen)) + + guard let parsed = parseItemHeaderPath(headerData) else { + print("HotlineFolderDownloadClient: Invalid item header; waiting for more data") + break + } + + // Consume the header from the buffer + self.fileBytes.removeSubrange(0..<(2 + headerLen)) + + let itemType = parsed.type + let comps = parsed.components + let joinedPath = comps.joined(separator: "/") + print("HotlineFolderDownloadClient: item type=\(itemType) path=\(joinedPath)") + + guard !comps.isEmpty else { + print("HotlineFolderDownloadClient: Empty path components for item type \(itemType); requesting next item") + self.sendAction(.nextFile) + keepProcessing = !self.fileBytes.isEmpty + break + } + + if itemType == 1 { + // Folder entries: create the directory locally and continue. + self.currentItemRelativePath = comps + + if let base = self.folderPath { + var dir = base + for c in comps { dir = (dir as NSString).appendingPathComponent(c) } + do { + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + print("HotlineFolderDownloadClient: Created folder at \(dir)") + } catch { + print("HotlineFolderDownloadClient: Failed to create subfolder: \(error)") + } + } + + self.completedItemCount += 1 + + if self.completedItemCount >= self.folderItemCount { + self.handleAllItemsDownloaded() + return + } + + self.sendAction(.nextFile) + keepProcessing = !self.fileBytes.isEmpty + break + } + else if itemType == 0 { + // File entries include the full path; split parent components from filename. + self.currentItemRelativePath = Array(comps.dropLast()) + self.currentFileName = comps.last + + self.transferStage = .waitingForFileSize + print("HotlineFolderDownloadClient: Requesting file download for '\(self.currentFileName ?? "?")'") + self.sendAction(.sendFile) + + if self.fileBytes.count >= 4 { + keepProcessing = true + } + break + } + else { + print("HotlineFolderDownloadClient: Unknown item type \(itemType); skipping") + self.sendAction(.nextFile) + keepProcessing = !self.fileBytes.isEmpty + break + } + + case .waitingForFileSize: + // Read 4-byte file size that comes before FILP in folder mode + guard self.fileBytes.count >= 4 else { break } + let fileSize = self.fileBytes.readUInt32(at: 0)! + self.fileBytes.removeSubrange(0..<4) + + print("HotlineFolderDownloadClient: File size: \(fileSize) bytes") + + self.currentFileSize = fileSize + self.currentFileBytesRead = 0 + + // Align to FILP boundary before decoding the file header + let dropped = self.alignBufferToFILP() + if dropped > 0 { + // These bytes were in the stream for this file but not part of FILP. + // Keep byte-accounting consistent by shrinking the expected size. + if self.currentFileSize >= UInt32(dropped) { + self.currentFileSize -= UInt32(dropped) + } else { + // Defensive: if weird, treat as zero-size to avoid underflow + self.currentFileSize = 0 + } + } + + self.transferStage = .fileHeader + keepProcessing = true + + case .fileHeader: + // Make sure we're actually at a FILP header + if self.fileBytes.count >= HotlineFileHeader.DataSize { + // If the 4-byte magic doesn't match, try one more resync here + if self.fileBytes.readUInt32(at: 0)! != FILP_MAGIC { + let dropped = self.alignBufferToFILP() + if dropped > 0 { + if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } + } + // If still not aligned or not enough bytes, wait for more data + guard self.fileBytes.count >= HotlineFileHeader.DataSize, + self.fileBytes.readUInt32(at: 0)! == FILP_MAGIC else { break } + } + + if let header = HotlineFileHeader(from: self.fileBytes) { + // Sanity gate: version and fork count + if header.format != FILP_MAGIC || header.version == 0 || header.forkCount > 3 { + print("HotlineFolderDownloadClient: Invalid FILP header (fmt=\(String(format:"0x%08X", header.format)), ver=\(header.version), forks=\(header.forkCount)). Resyncing.") + // Try resync and wait for more data + let dropped = self.alignBufferToFILP() + if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } + break + } + + self.fileBytes.removeSubrange(0..<HotlineFileHeader.DataSize) + self.currentFileBytesRead += HotlineFileHeader.DataSize + self.fileHeader = header + self.fileForksRemaining = Int(header.forkCount) + print("HotlineFolderDownloadClient: File has \(header.forkCount) forks") + self.transferStage = .fileForkHeader + keepProcessing = true + } + } + case .fileForkHeader: + let allForksConsumed = self.fileForksRemaining == 0 && self.fileCurrentForkHeader == nil && self.fileCurrentForkBytesLeft == 0 + + if allForksConsumed { + if self.currentFileBytesRead != Int(self.currentFileSize) { + self.currentFileSize = UInt32(min(Int.max, self.currentFileBytesRead)) + } + + print("HotlineFolderDownloadClient: File complete (consumed all forks, bytes read: \(self.currentFileBytesRead)/\(self.currentFileSize))") + + self.finishCurrentFile() + self.completedItemCount += 1 + + // Check if we've completed all items + if self.completedItemCount >= self.folderItemCount { + self.handleAllItemsDownloaded() + return + } + + // More files to download + // Check if server has pipelined all remaining data (we've received more than expected total) + print("HotlineFolderDownloadClient: Completed \(self.completedItemCount)/\(self.folderItemCount) items, requesting next entry") + self.transferStage = .itemHeader + self.sendAction(.nextFile) + keepProcessing = !self.fileBytes.isEmpty + } + // File not complete - try to read next fork header + else if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) { + // Quick validation: first fork must be INFO, and sizes must be plausible + let remainingForThisFile = max(0, Int(self.currentFileSize) - self.currentFileBytesRead) + let plausible = forkHeader.dataSize <= UInt32(remainingForThisFile) + let knownType = forkHeader.isInfoFork || forkHeader.isDataFork || forkHeader.isResourceFork + + if !plausible || (!knownType && self.currentFileBytesRead == HotlineFileHeader.DataSize) { + print("HotlineFolderDownloadClient: Implausible fork header (type=\(String(format:"0x%08X", forkHeader.forkType)), size=\(forkHeader.dataSize), remaining=\(remainingForThisFile)). Resyncing.") + let dropped = self.alignBufferToFILP() + if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } + break + } + + self.fileBytes.removeSubrange(0..<HotlineFileForkHeader.DataSize) + self.currentFileBytesRead += HotlineFileForkHeader.DataSize + self.fileCurrentForkHeader = forkHeader + self.fileCurrentForkBytesLeft = Int(forkHeader.dataSize) + self.fileForksRemaining -= 1 // Decrement fork count + + print("HotlineFolderDownloadClient: Read fork header (type: \(String(format: "0x%08X", forkHeader.forkType)), size: \(forkHeader.dataSize) bytes, \(self.fileForksRemaining) forks remaining)") + + // Handle zero-length forks immediately + if forkHeader.dataSize == 0 { + print("HotlineFolderDownloadClient: Skipping zero-length fork") + self.fileCurrentForkHeader = nil + self.fileCurrentForkBytesLeft = 0 + // Stay in .fileForkHeader to read next fork or complete file + keepProcessing = true + } + else if forkHeader.isInfoFork { + print("HotlineFolderDownloadClient: Downloading info fork for item \(self.currentItemNumber + 1)/\(self.folderItemCount)") + self.transferStage = .fileInfoFork + keepProcessing = true + } + else if forkHeader.isDataFork { + print("HotlineFolderDownloadClient: Downloading data fork") + self.transferStage = .fileDataFork + keepProcessing = true + } + else if forkHeader.isResourceFork { + print("HotlineFolderDownloadClient: Downloading resource fork") + self.fileResourceBytes = Data() + self.transferStage = .fileResourceFork + keepProcessing = true + } + else { + print("HotlineFolderDownloadClient: Downloading unsupported fork (type: \(String(format: "0x%08X", forkHeader.forkType)), size: \(forkHeader.dataSize) bytes)") + self.transferStage = .fileUnsupportedFork + keepProcessing = true + } + } + // Not enough data yet for next fork header - wait for more data + + case .fileInfoFork: + if let infoForkDataSize = self.fileCurrentForkHeader?.dataSize, self.fileBytes.count >= infoForkDataSize { + let infoForkData = self.fileBytes.subdata(in: 0..<Int(infoForkDataSize)) + if let infoFork = HotlineFileInfoFork(from: infoForkData) { + self.fileInfoFork = infoFork + + self.fileBytes.removeSubrange(0..<infoFork.headerSize) + self.currentFileBytesRead += infoFork.headerSize + + self.fileCurrentForkBytesLeft -= Int(infoForkDataSize) + self.fileCurrentForkHeader = nil + + print("HotlineFolderDownloadClient: Received info fork for \(infoFork.name)") + + // Create the file on disk using the filename we extracted from the item header + if !self.prepareDownloadFile(name: self.currentFileName ?? infoFork.name) { + print("HotlineFolderDownloadClient: Failed to create file on disk") + } + + self.currentItemNumber += 1 + + let reference = self.referenceNumber + let fileName = infoFork.name + let itemNum = self.currentItemNumber + let totalItems = self.folderItemCount + DispatchQueue.main.async { + self.delegate?.hotlineFolderDownloadReceivedFileInfo(client: self, reference: reference, fileName: fileName, itemNumber: itemNum, totalItems: totalItems) + } + + // Info fork complete, check for next fork + self.transferStage = .fileForkHeader + keepProcessing = true + } + } + case .fileDataFork: + if self.fileBytes.count > 0 { + if let f = self.fileHandle { + do { + var dataToWrite = self.fileBytes + + if dataToWrite.count >= self.fileCurrentForkBytesLeft { + dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft) + self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft) + self.currentFileBytesRead += dataToWrite.count + + self.fileCurrentForkBytesLeft = 0 + self.fileCurrentForkHeader = nil + + // Write the final chunk of data + try f.write(contentsOf: dataToWrite) + + print("HotlineFolderDownloadClient: Data fork complete") + + self.transferStage = .fileForkHeader + keepProcessing = true + } + else { + self.fileCurrentForkBytesLeft -= dataToWrite.count + self.currentFileBytesRead += dataToWrite.count + self.fileBytes = Data() + + // Write partial data + try f.write(contentsOf: dataToWrite) + } + } + catch { + print("HotlineFolderDownloadClient: Download write error", error) + } + } + } + case .fileResourceFork: + if self.fileBytes.count > 0 { + var dataToWrite = self.fileBytes + + if dataToWrite.count >= self.fileCurrentForkBytesLeft { + dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft) + self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft) + self.currentFileBytesRead += dataToWrite.count + + self.fileCurrentForkBytesLeft = 0 + self.fileCurrentForkHeader = nil + + print("HotlineFolderDownloadClient: Writing to resource fork", dataToWrite.count) + self.fileResourceBytes.append(dataToWrite) + + print("HotlineFolderDownloadClient: Resource fork complete") + + self.transferStage = .fileForkHeader + keepProcessing = true + } + else { + self.fileCurrentForkBytesLeft -= dataToWrite.count + self.currentFileBytesRead += dataToWrite.count + self.fileBytes = Data() + + print("HotlineFolderDownloadClient: Writing to resource fork", dataToWrite.count) + self.fileResourceBytes.append(dataToWrite) + } + } + case .fileUnsupportedFork: + if self.fileBytes.count > 0 { + var dataToWrite = self.fileBytes + + if dataToWrite.count >= self.fileCurrentForkBytesLeft { + dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft) + self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft) + self.currentFileBytesRead += dataToWrite.count + + self.fileCurrentForkBytesLeft = 0 + self.fileCurrentForkHeader = nil + + print("HotlineFolderDownloadClient: Unsupported fork complete (discarded \(dataToWrite.count) bytes)") + + self.transferStage = .fileForkHeader + keepProcessing = true + } + else { + self.fileCurrentForkBytesLeft -= dataToWrite.count + self.currentFileBytesRead += dataToWrite.count + print("HotlineFolderDownloadClient: Unsupported fork partial data (discarded \(dataToWrite.count) bytes, \(self.fileCurrentForkBytesLeft) bytes remaining)") + self.fileBytes = Data() + } + } + } + } while keepProcessing + + // Continue receiving data - completion is handled in .fileForkHeader when all items are done + if self.connection != nil && self.status != .completed { + self.receiveFolder() + } + } + } + + private func finishCurrentFile() { + // Close current file handle + if let fh = self.fileHandle { + try? fh.close() + self.fileHandle = nil + } + + // Write resource fork if present + if self.fileResourceBytes.count > 0 { + let _ = self.writeResourceFork() + self.fileResourceBytes = Data() + } + + self.fileCurrentForkHeader = nil + self.fileCurrentForkBytesLeft = 0 + self.fileForksRemaining = 0 + self.currentFileBytesRead = 0 + self.currentFileSize = 0 + self.currentFilePath = nil + self.fileInfoFork = nil + self.fileHeader = nil + self.currentFileName = nil + } + + private func handleAllItemsDownloaded() { + guard self.status != .completed else { + return + } + + print("HotlineFolderDownloadClient: All \(self.folderItemCount) items downloaded") + + self.invalidate() + self.status = .completed + + if let downloadPath = self.folderPath { + DispatchQueue.main.sync { + print("HotlineFolderDownloadClient: Folder download complete", downloadPath) + + var downloadURL = URL(filePath: downloadPath) + downloadURL.resolveSymlinksInPath() + + self.delegate?.hotlineFolderDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) + +#if os(macOS) + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) +#endif + } + } + } + + private func writeResourceFork() -> Bool { + guard let filePath = self.currentFilePath else { + return false + } + + var resolvedFileURL = URL(filePath: filePath) + resolvedFileURL.resolveSymlinksInPath() + + let resourceFilePath = resolvedFileURL.appendingPathComponent("..namedfork/rsrc") + + do { + try self.fileResourceBytes.write(to: resourceFilePath) + } + catch { + return false + } + + return true + } + + private func prepareDownloadFile(name: String) -> Bool { + var filePath: String + + if self.folderPath != nil { + // Build the full path including subfolders + var fullPath = self.folderPath! + for component in self.currentItemRelativePath { + fullPath = (fullPath as NSString).appendingPathComponent(component) + } + + let folderURL = URL(filePath: fullPath) + + // Create folder if it doesn't exist + if !FileManager.default.fileExists(atPath: folderURL.path) { + do { + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + } + catch { + print("HotlineFolderDownloadClient: Failed to create folder", error) + return false + } + } + + filePath = folderURL.appendingPathComponent(name).path + print("HotlineFolderDownloadClient: Creating file at \(filePath)") + } + else { + let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + filePath = downloadsURL.generateUniqueFilePath(filename: name) + } + + var fileAttributes: [FileAttributeKey: Any] = [:] + if let creatorCode = self.fileInfoFork?.creator { + fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber + } + if let typeCode = self.fileInfoFork?.type { + fileAttributes[.hfsTypeCode] = typeCode as NSNumber + } + if let createdDate = self.fileInfoFork?.createdDate { + fileAttributes[.creationDate] = createdDate as NSDate + } + if let modifiedDate = self.fileInfoFork?.modifiedDate { + fileAttributes[.modificationDate] = modifiedDate as NSDate + } + if let comment = self.fileInfoFork?.comment { + if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { + fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ + FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData + ] + } + } + + if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { + if let h = FileHandle(forWritingAtPath: filePath) { + self.currentFilePath = filePath + self.fileHandle = h + + // Only set file progress on first file + if self.currentItemNumber == 1 && self.folderPath != nil { + self.fileProgress.fileURL = URL(filePath: self.folderPath!).resolvingSymlinksInPath() + self.fileProgress.fileOperationKind = .downloading + self.fileProgress.publish() + } + + return true + } + } + + return false + } +} diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 9ea29c5..48cae54 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -1,7 +1,7 @@ import SwiftUI @Observable -class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelegate, HotlineFilePreviewClientDelegate, HotlineFileUploadClientDelegate { +class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelegate, HotlineFilePreviewClientDelegate, HotlineFileUploadClientDelegate, HotlineFolderDownloadClientDelegate { let id: UUID = UUID() let trackerClient: HotlineTrackerClient let client: HotlineClient @@ -559,7 +559,51 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega } } } - + + @MainActor func downloadFolder(_ folderName: String, path: [String], complete callback: ((TransferInfo, URL) -> Void)? = nil) { + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0..<path.count-1]) + } + + self.client.sendDownloadFolder(name: folderName, path: fullPath) { [weak self] success, downloadReferenceNumber, downloadTransferSize, downloadItemCount, downloadWaitingCount in + print("GOT DOWNLOAD FOLDER REPLY:") + print("\tSUCCESS?", success) + print("\tTRANSFER SIZE: \(downloadTransferSize.debugDescription)") + print("\tITEM COUNT: \(downloadItemCount.debugDescription)") + print("\tREFERENCE NUM: \(downloadReferenceNumber.debugDescription)") + print("\tWAITING COUNT: \(downloadWaitingCount.debugDescription)") + + if + let self = self, + let address = self.server?.address, + let port = self.server?.port, + let referenceNumber = downloadReferenceNumber, + let transferSize = downloadTransferSize, + let itemCount = downloadItemCount { + + print("Creating HotlineFolderDownloadClient with address: \(address), port: \(port)") + + let folderClient = HotlineFolderDownloadClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize), itemCount: itemCount) + folderClient.delegate = self + self.downloads.append(folderClient) + + let transfer = TransferInfo(id: referenceNumber, title: folderName, size: UInt(transferSize)) + transfer.isFolder = true + transfer.downloadCallback = callback + self.transfers.append(transfer) + + // Create destination folder in Downloads + let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + let folderURL = downloadsURL.appendingPathComponent(folderName) + + print("Starting folder download to: \(folderURL.path)") + folderClient.start(to: folderURL) + print("Folder download started") + } + } + } + @MainActor func uploadFile(url fileURL: URL, path: [String], complete callback: ((TransferInfo) -> Void)? = nil) { let fileName = fileURL.lastPathComponent @@ -1003,12 +1047,36 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega SoundEffectPlayer.shared.playSoundEffect(.transferComplete) } } - + if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) { self.downloads.remove(at: i) } } - + + func hotlineFolderDownloadReceivedFileInfo(client: HotlineFolderDownloadClient, reference: UInt32, fileName: String, itemNumber: Int, totalItems: Int) { + // Optional: Update transfer title to show current file being downloaded + if let i = self.transfers.firstIndex(where: { $0.id == reference }) { + let transfer = self.transfers[i] + // You could update the title here to show progress: "FolderName (file 3 of 10)" + // transfer.title = "\(originalFolderName) (\(itemNumber) of \(totalItems))" + } + } + + func hotlineFolderDownloadComplete(client: HotlineFolderDownloadClient, reference: UInt32, at: URL) { + if let i = self.transfers.firstIndex(where: { $0.id == reference }) { + let transfer = self.transfers[i] + transfer.fileURL = at + transfer.downloadCallback?(transfer, at) + if Prefs.shared.playSounds && Prefs.shared.playFileTransferCompleteSound { + SoundEffectPlayer.shared.playSoundEffect(.transferComplete) + } + } + + if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) { + self.downloads.remove(at: i) + } + } + // MARK: - Utilities func updateServerTitle() { diff --git a/Hotline/Models/TransferInfo.swift b/Hotline/Models/TransferInfo.swift index 50b23f5..ba25793 100644 --- a/Hotline/Models/TransferInfo.swift +++ b/Hotline/Models/TransferInfo.swift @@ -3,14 +3,15 @@ import SwiftUI @Observable class TransferInfo: Identifiable, Equatable, Hashable { var id: UInt32 - + var title: String var size: UInt var progress: Double = 0.0 var timeRemaining: TimeInterval = 0.0 var completed: Bool = false var failed: Bool = false - + var isFolder: Bool = false + // For file based transfers (i.e. not previews) var fileURL: URL? = nil diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index 2befa18..f399372 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -236,11 +236,12 @@ struct FilesView: View { } @MainActor private func downloadFile(_ file: FileInfo) { - guard !file.isFolder else { - return + if file.isFolder { + model.downloadFolder(file.name, path: file.path) + } + else { + model.downloadFile(file.name, path: file.path) } - - model.downloadFile(file.name, path: file.path) } @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { @@ -297,13 +298,13 @@ struct FilesView: View { let selectedFile = items.first Button { - if let s = selectedFile, !s.isFolder { + if let s = selectedFile { downloadFile(s) } } label: { Label("Download", systemImage: "arrow.down") } - .disabled(selectedFile == nil || (selectedFile != nil && selectedFile!.isFolder)) + .disabled(selectedFile == nil) Divider() @@ -419,14 +420,14 @@ struct FilesView: View { ToolbarItem(placement: .primaryAction) { Button { - if let selectedFile = selection, !selectedFile.isFolder { + if let selectedFile = selection { downloadFile(selectedFile) } } label: { Label("Download", systemImage: "arrow.down") } .help("Download") - .disabled(selection == nil || selection?.isFolder == true || model.access?.contains(.canDownloadFiles) != true) + .disabled(selection == nil || model.access?.contains(.canDownloadFiles) != true) } } } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 154bd7c..115709d 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -585,10 +585,6 @@ struct ServerView: View { } } -//#Preview { -// ServerView(server: Server(name: "", description: "", address: "", port: 0)) -//} - struct TransferItemView: View { let transfer: TransferInfo @@ -619,10 +615,18 @@ struct TransferItemView: View { HStack(alignment: .center, spacing: 5) { HStack(spacing: 0) { Spacer() - FileIconView(filename: transfer.title, fileType: nil) - .frame(width: 16, height: 16) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) -// .padding(.leading, 2) + if transfer.isFolder { + Image("Folder") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } + else { + FileIconView(filename: transfer.title, fileType: nil) + .frame(width: 16, height: 16) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } Spacer() } .frame(width: 20) |