// // DownloadManager.swift // Zyquo Atlas // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Handles file downloads via WKDownload: routes new downloads to ~/Downloads, // tracks progress, and exposes an observable list for the toolbar downloads // popover (reveal / open). One manager per window, set as each web view's // download-triggering delegate path through the Tab. // import Foundation import WebKit import Combine @MainActor final class DownloadManager: NSObject, ObservableObject { @Published private(set) var items: [DownloadItem] = [] private var progressObservations: [ObjectIdentifier: NSKeyValueObservation] = [:] /// Attaches to a WKDownload (from WKNavigation/UI delegate download hooks). func attach(_ download: WKDownload) { download.delegate = self } func reveal(_ item: DownloadItem) { guard let url = item.fileURL else { return } NSWorkspace.shared.activateFileViewerSelecting([url]) } func open(_ item: DownloadItem) { guard let url = item.fileURL, item.state == .finished else { return } NSWorkspace.shared.open(url) } func clearFinished() { items.removeAll { $0.state == .finished } } private func index(of download: WKDownload) -> Int? { items.firstIndex { $0.downloadID == ObjectIdentifier(download) } } } // MARK: - Model struct DownloadItem: Identifiable { enum State: Equatable { case inProgress, finished, failed } let id = UUID() let downloadID: ObjectIdentifier var filename: String var fileURL: URL? var receivedBytes: Int64 = 0 var totalBytes: Int64 = 0 var state: State = .inProgress var fraction: Double { totalBytes > 0 ? Double(receivedBytes) / Double(totalBytes) : 0 } } // MARK: - WKDownloadDelegate extension DownloadManager: WKDownloadDelegate { func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) { let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Downloads") var dest = downloads.appendingPathComponent(suggestedFilename) var n = 1 while FileManager.default.fileExists(atPath: dest.path) { let ext = (suggestedFilename as NSString).pathExtension let base = (suggestedFilename as NSString).deletingPathExtension dest = downloads.appendingPathComponent(ext.isEmpty ? "\(base) \(n)" : "\(base) \(n).\(ext)") n += 1 } let item = DownloadItem(downloadID: ObjectIdentifier(download), filename: dest.lastPathComponent, fileURL: dest, totalBytes: response.expectedContentLength) items.insert(item, at: 0) progressObservations[ObjectIdentifier(download)] = download.progress.observe(\.completedUnitCount) { [weak self] progress, _ in Task { @MainActor in guard let self, let i = self.index(of: download) else { return } self.items[i].receivedBytes = progress.completedUnitCount self.items[i].totalBytes = progress.totalUnitCount } } completionHandler(dest) } func downloadDidFinish(_ download: WKDownload) { if let i = index(of: download) { items[i].state = .finished } progressObservations[ObjectIdentifier(download)]?.invalidate() progressObservations[ObjectIdentifier(download)] = nil } func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { if let i = index(of: download) { items[i].state = .failed } progressObservations[ObjectIdentifier(download)]?.invalidate() progressObservations[ObjectIdentifier(download)] = nil } }