// // OneShotDownload.swift // RSWeb // // Created by Brent Simmons on 8/27/16. // Copyright © 2016 Ranchero Software, LLC. All rights reserved. // import Foundation // Main thread only. public typealias OneShotDownloadCallback = @Sendable (Data?, URLResponse?, Error?) -> Swift.Void @MainActor private final class OneShotDownloadManager { private let urlSession: URLSession fileprivate static let shared = OneShotDownloadManager() public init() { let sessionConfiguration = URLSessionConfiguration.ephemeral sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData sessionConfiguration.httpShouldSetCookies = false sessionConfiguration.httpCookieAcceptPolicy = .never sessionConfiguration.httpMaximumConnectionsPerHost = 2 sessionConfiguration.httpCookieStorage = nil sessionConfiguration.urlCache = nil sessionConfiguration.timeoutIntervalForRequest = 30 if let userAgentHeaders = UserAgent.headers() { sessionConfiguration.httpAdditionalHeaders = userAgentHeaders } urlSession = URLSession(configuration: sessionConfiguration) } deinit { urlSession.invalidateAndCancel() } public func download(_ url: URL, _ completion: @escaping OneShotDownloadCallback) { let task = urlSession.dataTask(with: url) { (data, response, error) in DispatchQueue.main.async() { completion(data, response, error) } } task.resume() } public func download(_ urlRequest: URLRequest, _ completion: @escaping OneShotDownloadCallback) { let task = urlSession.dataTask(with: urlRequest) { (data, response, error) in DispatchQueue.main.async() { completion(data, response, error) } } task.resume() } } // Call one of these. It’s easier than referring to OneShotDownloadManager. // callback is called on the main queue. @MainActor public func download(_ url: URL, _ completion: @escaping OneShotDownloadCallback) { precondition(Thread.isMainThread) OneShotDownloadManager.shared.download(url, completion) } @MainActor public func download(_ urlRequest: URLRequest, _ completion: @escaping OneShotDownloadCallback) { precondition(Thread.isMainThread) OneShotDownloadManager.shared.download(urlRequest, completion) } // MARK: - Downloading using a cache private struct WebCacheRecord { let url: URL let dateDownloaded: Date let data: Data let response: URLResponse } private final class WebCache { private var cache = [URL: WebCacheRecord]() func cleanup(_ cleanupInterval: TimeInterval) { let cutoffDate = Date(timeInterval: -cleanupInterval, since: Date()) for key in cache.keys { let cacheRecord = self[key]! if shouldDelete(cacheRecord, cutoffDate) { cache[key] = nil } } } private func shouldDelete(_ cacheRecord: WebCacheRecord, _ cutoffDate: Date) -> Bool { return cacheRecord.dateDownloaded < cutoffDate } subscript(_ url: URL) -> WebCacheRecord? { get { return cache[url] } set { if let cacheRecord = newValue { cache[url] = cacheRecord } else { cache[url] = nil } } } } // URLSessionConfiguration has a cache policy. // But we don’t know how it works, and the unimplemented parts spook us a bit. // So we use a cache that works exactly as we want it to work. // It also makes sure we don’t have multiple requests for the same URL at the same time. private struct CallbackRecord { let url: URL let completion: OneShotDownloadCallback } @MainActor private final class DownloadWithCacheManager { static let shared = DownloadWithCacheManager() private var cache = WebCache() private static let timeToLive: TimeInterval = 10 * 60 // 10 minutes private static let cleanupInterval: TimeInterval = 5 * 60 // clean up the cache at most every 5 minutes private var lastCleanupDate = Date() private var pendingCallbacks = [CallbackRecord]() private var urlsInProgress = Set() @MainActor func download(_ url: URL, _ completion: @escaping OneShotDownloadCallback, forceRedownload: Bool = false) { if lastCleanupDate.timeIntervalSinceNow < -DownloadWithCacheManager.cleanupInterval { lastCleanupDate = Date() cache.cleanup(DownloadWithCacheManager.timeToLive) } if !forceRedownload { let cacheRecord: WebCacheRecord? = cache[url] if let cacheRecord = cacheRecord { completion(cacheRecord.data, cacheRecord.response, nil) return } } let callbackRecord = CallbackRecord(url: url, completion: completion) pendingCallbacks.append(callbackRecord) if urlsInProgress.contains(url) { return // The completion handler will get called later. } urlsInProgress.insert(url) OneShotDownloadManager.shared.download(url) { (data, response, error) in MainActor.assumeIsolated { self.urlsInProgress.remove(url) if let data = data, let response = response, response.statusIsOK, error == nil { let cacheRecord = WebCacheRecord(url: url, dateDownloaded: Date(), data: data, response: response) self.cache[url] = cacheRecord } var callbackCount = 0 for callbackRecord in self.pendingCallbacks { if url == callbackRecord.url { callbackRecord.completion(data, response, error) callbackCount += 1 } } self.pendingCallbacks.removeAll(where: { (callbackRecord) -> Bool in return callbackRecord.url == url }) } } } } @MainActor public func downloadUsingCache(_ url: URL, _ completion: @escaping OneShotDownloadCallback) { precondition(Thread.isMainThread) DownloadWithCacheManager.shared.download(url, completion) } @MainActor public func downloadAddingToCache(_ url: URL, _ completion: @escaping OneShotDownloadCallback) { precondition(Thread.isMainThread) DownloadWithCacheManager.shared.download(url, completion, forceRedownload: true) }