NetNewsWire/Web/Sources/Web/OneShotDownload.swift

195 lines
5.8 KiB
Swift
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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. Its 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 dont 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 dont 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<URL>()
@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)
}