aboutsummaryrefslogtreecommitdiff
path: root/Hotline
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2025-10-21 23:09:53 -0700
committerDustin Mierau <dustin@mierau.me>2025-10-21 23:09:53 -0700
commit54b85a88efcd455d5c12b7ca65d425d095a675f1 (patch)
treefa4aa78576630ffbe76bebe02491960a3356c3d6 /Hotline
parent926c92be7a32e374c8855b2eb8b5a49485dec072 (diff)
Add initial support for folder downloading.
Diffstat (limited to 'Hotline')
-rw-r--r--Hotline/Hotline/HotlineClient.swift26
-rw-r--r--Hotline/Hotline/HotlineTransferClient.swift880
-rw-r--r--Hotline/Models/Hotline.swift76
-rw-r--r--Hotline/Models/TransferInfo.swift5
-rw-r--r--Hotline/macOS/FilesView.swift17
-rw-r--r--Hotline/macOS/ServerView.swift20
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)