Rename OneShotDownload to Downloader. Use built-in caching support.

This commit is contained in:
Brent Simmons
2024-11-27 20:32:36 -08:00
parent a4a41ddfbd
commit 0e8eac3c56
9 changed files with 65 additions and 234 deletions

View File

@@ -14,8 +14,8 @@ import RSCore
class FeedFinder {
static func find(url: URL, completion: @escaping (Result<Set<FeedSpecifier>, Error>) -> Void) {
downloadAddingToCache(url) { (data, response, error) in
Downloader.shared.download(url) { (data, response, error) in
if response?.forcedStatusCode == 404 {
if var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), urlComponents.host == "micro.blog" {
urlComponents.path = "\(urlComponents.path).json"
@@ -147,7 +147,7 @@ private extension FeedFinder {
}
group.enter()
downloadUsingCache(url) { (data, response, error) in
Downloader.shared.download(url) { (data, response, error) in
if let data = data, let response = response, response.statusIsOK, error == nil {
if self.isFeed(data, downloadFeedSpecifier.urlString) {
addFeedSpecifier(downloadFeedSpecifier, feedSpecifiers: &resultFeedSpecifiers)

View File

@@ -14,7 +14,7 @@ struct InitialFeedDownloader {
static func download(_ url: URL,_ completion: @escaping (_ parsedFeed: ParsedFeed?) -> Void) {
downloadUsingCache(url) { (data, response, error) in
Downloader.shared.download(url) { (data, response, error) in
guard let data = data else {
completion(nil)
return

View File

@@ -54,9 +54,7 @@ struct CrashReporter {
let formData = formString.data(using: .utf8, allowLossyConversion: true)
request.httpBody = formData
download(request) { (_, _, _) in
// Dont care about the result.
}
Downloader.shared.download(request) // Dont care about the result.
}
static func runCrashReporterWindow(_ crashLogText: String) {

View File

@@ -1,32 +0,0 @@
//
// DownloadObject.swift
// RSWeb
//
// Created by Brent Simmons on 8/3/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
public final class DownloadObject: Hashable {
public let url: URL
public var data = Data()
public init(url: URL) {
self.url = url
}
// MARK: - Hashable
public func hash(into hasher: inout Hasher) {
hasher.combine(url)
}
// MARK: - Equatable
public static func ==(lhs: DownloadObject, rhs: DownloadObject) -> Bool {
return lhs.url == rhs.url && lhs.data == rhs.data
}
}

View File

@@ -0,0 +1,57 @@
//
// Downloader.swift
// RSWeb
//
// Created by Brent Simmons on 8/27/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
public typealias DownloadCallback = (Data?, URLResponse?, Error?) -> Swift.Void
/// Simple downloader, for a one-shot download like an image
/// or a web page. For a download-feeds session, see DownloadSession.
public final class Downloader {
public static let shared = Downloader()
private let urlSession: URLSession
private init() {
let sessionConfiguration = URLSessionConfiguration.default
sessionConfiguration.requestCachePolicy = .useProtocolCachePolicy
sessionConfiguration.httpShouldSetCookies = false
sessionConfiguration.httpCookieAcceptPolicy = .never
sessionConfiguration.httpMaximumConnectionsPerHost = 1
sessionConfiguration.httpCookieStorage = nil
if let userAgentHeaders = UserAgent.headers() {
sessionConfiguration.httpAdditionalHeaders = userAgentHeaders
}
urlSession = URLSession(configuration: sessionConfiguration)
}
deinit {
urlSession.invalidateAndCancel()
}
public func download(_ url: URL, _ completion: DownloadCallback? = nil) {
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: DownloadCallback? = nil) {
let task = urlSession.dataTask(with: urlRequest) { (data, response, error) in
DispatchQueue.main.async() {
completion?(data, response, error)
}
}
task.resume()
}
}

View File

@@ -1,192 +0,0 @@
//
// 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 = (Data?, URLResponse?, Error?) -> Swift.Void
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.
public func download(_ url: URL, _ completion: @escaping OneShotDownloadCallback) {
precondition(Thread.isMainThread)
OneShotDownloadManager.shared.download(url, completion)
}
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())
cache.keys.forEach { (key) in
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
}
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>()
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
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
self.pendingCallbacks.forEach{ (callbackRecord) in
if url == callbackRecord.url {
callbackRecord.completion(data, response, error)
callbackCount += 1
}
}
self.pendingCallbacks.removeAll(where: { (callbackRecord) -> Bool in
return callbackRecord.url == url
})
}
}
}
public func downloadUsingCache(_ url: URL, _ completion: @escaping OneShotDownloadCallback) {
precondition(Thread.isMainThread)
DownloadWithCacheManager.shared.download(url, completion)
}
public func downloadAddingToCache(_ url: URL, _ completion: @escaping OneShotDownloadCallback) {
precondition(Thread.isMainThread)
DownloadWithCacheManager.shared.download(url, completion, forceRedownload: true)
}

View File

@@ -139,7 +139,7 @@ private extension SingleFaviconDownloader {
return
}
downloadUsingCache(url) { (data, response, error) in
Downloader.shared.download(url) { (data, response, error) in
if let data = data, !data.isEmpty, let response = response, response.statusIsOK, error == nil {
self.saveToDisk(data)

View File

@@ -20,7 +20,7 @@ struct HTMLMetadataDownloader {
return
}
downloadUsingCache(actualURL) { (data, response, error) in
Downloader.shared.download(actualURL) { (data, response, error) in
if let data = data, !data.isEmpty, let response = response, response.statusIsOK, error == nil {
let urlToUse = response.url ?? actualURL
let parserData = ParserData(url: urlToUse.absoluteString, data: data)

View File

@@ -103,7 +103,7 @@ private extension ImageDownloader {
return
}
downloadUsingCache(imageURL) { (data, response, error) in
Downloader.shared.download(imageURL) { (data, response, error) in
if let data = data, !data.isEmpty, let response = response, response.statusIsOK, error == nil {
self.saveToDisk(url, data)