diff --git a/Account/Sources/Account/FeedFinder/FeedFinder.swift b/Account/Sources/Account/FeedFinder/FeedFinder.swift index e81c5160c..5a9084acb 100644 --- a/Account/Sources/Account/FeedFinder/FeedFinder.swift +++ b/Account/Sources/Account/FeedFinder/FeedFinder.swift @@ -14,8 +14,8 @@ import RSCore class FeedFinder { static func find(url: URL, completion: @escaping (Result, 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) diff --git a/Account/Sources/Account/LocalAccount/InitialFeedDownloader.swift b/Account/Sources/Account/LocalAccount/InitialFeedDownloader.swift index 34b1ddea3..d7ac118ed 100644 --- a/Account/Sources/Account/LocalAccount/InitialFeedDownloader.swift +++ b/Account/Sources/Account/LocalAccount/InitialFeedDownloader.swift @@ -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 diff --git a/Mac/CrashReporter/CrashReporter.swift b/Mac/CrashReporter/CrashReporter.swift index b37810a9d..95423994d 100644 --- a/Mac/CrashReporter/CrashReporter.swift +++ b/Mac/CrashReporter/CrashReporter.swift @@ -54,9 +54,7 @@ struct CrashReporter { let formData = formString.data(using: .utf8, allowLossyConversion: true) request.httpBody = formData - download(request) { (_, _, _) in - // Don’t care about the result. - } + Downloader.shared.download(request) // Don’t care about the result. } static func runCrashReporterWindow(_ crashLogText: String) { diff --git a/RSWeb/Sources/RSWeb/DownloadObject.swift b/RSWeb/Sources/RSWeb/DownloadObject.swift deleted file mode 100755 index bc8b3f73b..000000000 --- a/RSWeb/Sources/RSWeb/DownloadObject.swift +++ /dev/null @@ -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 - } -} - diff --git a/RSWeb/Sources/RSWeb/Downloader.swift b/RSWeb/Sources/RSWeb/Downloader.swift new file mode 100755 index 000000000..2705c442e --- /dev/null +++ b/RSWeb/Sources/RSWeb/Downloader.swift @@ -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() + } +} diff --git a/RSWeb/Sources/RSWeb/OneShotDownload.swift b/RSWeb/Sources/RSWeb/OneShotDownload.swift deleted file mode 100755 index cfe30d6d1..000000000 --- a/RSWeb/Sources/RSWeb/OneShotDownload.swift +++ /dev/null @@ -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. It’s 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 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 -} - -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() - - 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) -} diff --git a/Shared/Favicons/SingleFaviconDownloader.swift b/Shared/Favicons/SingleFaviconDownloader.swift index 1b82802f6..67e30dc19 100644 --- a/Shared/Favicons/SingleFaviconDownloader.swift +++ b/Shared/Favicons/SingleFaviconDownloader.swift @@ -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) diff --git a/Shared/HTMLMetadata/HTMLMetadataDownloader.swift b/Shared/HTMLMetadata/HTMLMetadataDownloader.swift index 4f2404868..122c488d6 100644 --- a/Shared/HTMLMetadata/HTMLMetadataDownloader.swift +++ b/Shared/HTMLMetadata/HTMLMetadataDownloader.swift @@ -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) diff --git a/Shared/Images/ImageDownloader.swift b/Shared/Images/ImageDownloader.swift index 9a549d3ef..d5eb2f0b2 100644 --- a/Shared/Images/ImageDownloader.swift +++ b/Shared/Images/ImageDownloader.swift @@ -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)