From ea3a78b84135de0843cd556d9164fa7568a97157 Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Sat, 25 May 2024 22:48:17 -0700 Subject: [PATCH] Create FeedDownloader local package. --- FeedDownloader/.gitignore | 8 + .../xcschemes/FeedDownloader.xcscheme | 67 ++++++++ FeedDownloader/Package.swift | 32 ++++ .../FeedDownloader/FeedDownloader.swift | 155 ++++++++++++++++++ .../FeedDownloaderTests.swift | 12 ++ NetNewsWire.xcodeproj/project.pbxproj | 2 + 6 files changed, 276 insertions(+) create mode 100644 FeedDownloader/.gitignore create mode 100644 FeedDownloader/.swiftpm/xcode/xcshareddata/xcschemes/FeedDownloader.xcscheme create mode 100644 FeedDownloader/Package.swift create mode 100644 FeedDownloader/Sources/FeedDownloader/FeedDownloader.swift create mode 100644 FeedDownloader/Tests/FeedDownloaderTests/FeedDownloaderTests.swift diff --git a/FeedDownloader/.gitignore b/FeedDownloader/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/FeedDownloader/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/FeedDownloader/.swiftpm/xcode/xcshareddata/xcschemes/FeedDownloader.xcscheme b/FeedDownloader/.swiftpm/xcode/xcshareddata/xcschemes/FeedDownloader.xcscheme new file mode 100644 index 000000000..d39224af1 --- /dev/null +++ b/FeedDownloader/.swiftpm/xcode/xcshareddata/xcschemes/FeedDownloader.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FeedDownloader/Package.swift b/FeedDownloader/Package.swift new file mode 100644 index 000000000..d9b9954f2 --- /dev/null +++ b/FeedDownloader/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 5.10 + +import PackageDescription + +let package = Package( + name: "FeedDownloader", + platforms: [.macOS(.v14), .iOS(.v17)], + products: [ + .library( + name: "FeedDownloader", + targets: ["FeedDownloader"]), + ], + dependencies: [ + .package(path: "../Web"), + .package(path: "../FoundationExtras"), + ], + targets: [ + .target( + name: "FeedDownloader", + dependencies: [ + "Web", + "FoundationExtras" + ], + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency") + ] + ), + .testTarget( + name: "FeedDownloaderTests", + dependencies: ["FeedDownloader"]), + ] +) diff --git a/FeedDownloader/Sources/FeedDownloader/FeedDownloader.swift b/FeedDownloader/Sources/FeedDownloader/FeedDownloader.swift new file mode 100644 index 000000000..d8ec8e9a9 --- /dev/null +++ b/FeedDownloader/Sources/FeedDownloader/FeedDownloader.swift @@ -0,0 +1,155 @@ +// +// FeedDownloader.swift +// NetNewsWire +// +// Created by Brent Simmons on 5/22/24. +// Copyright © 2024 Brent Simmons. All rights reserved. +// + +import Foundation +import FoundationExtras +import os +import Web + +public protocol FeedDownloaderDelegate: AnyObject { + + @MainActor func feedDownloader(_: FeedDownloader, requestCompletedForFeedURL: URL, response: URLResponse?, data: Data?, error: Error?) + + @MainActor func feedDownloader(_: FeedDownloader, requestCanceledForFeedURL: URL, response: URLResponse?, data: Data?, error: Error?, reason: FeedDownloader.CancellationReason) + + @MainActor func feedDownloaderSessionDidComplete(_: FeedDownloader) + + @MainActor func feedDownloader(_: FeedDownloader, conditionalGetInfoFor: URL) -> HTTPConditionalGetInfo? +} + +/// Use this to download feeds directly (local and iCloud accounts). +@MainActor public final class FeedDownloader { + + public enum CancellationReason { + case suspended + case notFeedData + case unexpectedResponse + case notModified + } + + public weak var delegate: FeedDownloaderDelegate? + public var downloadProgress: DownloadProgress { + downloadSession.downloadProgress + } + + private let downloadSession: DownloadSession + private var isSuspended = false + + public init() { + + self.downloadSession = DownloadSession() + downloadSession.delegate = self + } + + public func downloadFeeds(_ feedURLs: Set) { + + let feedIdentifiers = Set(feedURLs.map { $0.absoluteString }) + downloadSession.download(feedIdentifiers) + } + + public func suspend() async { + + isSuspended = true + await downloadSession.cancelAll() + } + + public func resume() { + + isSuspended = false + } +} + +extension FeedDownloader: DownloadSessionDelegate { + + public func downloadSession(_ downloadSession: DownloadSession, requestForIdentifier identifier: String) -> URLRequest? { + + guard let url = URL(string: identifier) else { + assertionFailure("There should be no case where identifier is not convertible to URL.") + return nil + } + + var request = URLRequest(url: url) + if let conditionalGetInfo = delegate?.feedDownloader(self, conditionalGetInfoFor: url) { + conditionalGetInfo.addRequestHeadersToURLRequest(&request) + } + + return request + } + + public func downloadSession(_ downloadSession: DownloadSession, downloadDidCompleteForIdentifier identifier: String, response: URLResponse?, data: Data?, error: Error?) { + + guard let url = URL(string: identifier) else { + assertionFailure("There should be no case where identifier is not convertible to URL.") + return + } + + delegate?.feedDownloader(self, requestCompletedForFeedURL: url, response: response, data: data, error: error) + } + + public func downloadSession(_ downloadSession: DownloadSession, shouldContinueAfterReceivingData data: Data, identifier: String) -> Bool { + + guard let url = URL(string: identifier) else { + assertionFailure("There should be no case where identifier is not convertible to URL.") + return false + } + + if isSuspended { + delegate?.feedDownloader(self, requestCanceledForFeedURL: url, response: nil, data: nil, error: nil, reason: .suspended) + return false + } + + if data.isEmpty { + return true + } + + if data.isNotAFeed() { + delegate?.feedDownloader(self, requestCanceledForFeedURL: url, response: nil, data: data, error: nil, reason: .notFeedData) + return false + } + + return true + } + + public func downloadSession(_ downloadSession: DownloadSession, didReceiveUnexpectedResponse response: URLResponse, identifier: String) { + + guard let url = URL(string: identifier) else { + assertionFailure("There should be no case where identifier is not convertible to URL.") + return + } + + delegate?.feedDownloader(self, requestCanceledForFeedURL: url, response: response, data: nil, error: nil, reason: .unexpectedResponse) + } + + public func downloadSession(_ downloadSession: DownloadSession, didReceiveNotModifiedResponse response: URLResponse, identifier: String) { + + guard let url = URL(string: identifier) else { + assertionFailure("There should be no case where identifier is not convertible to URL.") + return + } + + delegate?.feedDownloader(self, requestCanceledForFeedURL: url, response: response, data: nil, error: nil, reason: .notModified) + } + + public func downloadSession(_ downloadSession: DownloadSession, didDiscardDuplicateIdentifier: String) { + + // nothing to do + } + + public func downloadSessionDidComplete(_ downloadSession: DownloadSession) { + + delegate?.feedDownloaderSessionDidComplete(self) + } +} + +extension Data { + + func isNotAFeed() -> Bool { + + isImage // TODO: expand this + } +} diff --git a/FeedDownloader/Tests/FeedDownloaderTests/FeedDownloaderTests.swift b/FeedDownloader/Tests/FeedDownloaderTests/FeedDownloaderTests.swift new file mode 100644 index 000000000..ce5d8936d --- /dev/null +++ b/FeedDownloader/Tests/FeedDownloaderTests/FeedDownloaderTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import FeedDownloader + +final class FeedDownloaderTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +} diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 97adf55a1..d8f925847 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -1020,6 +1020,7 @@ 8426119D1FCB6ED40086A189 /* HTMLMetadataDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLMetadataDownloader.swift; sourceTree = ""; }; 842E45CD1ED8C308000A8B52 /* AppNotifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppNotifications.swift; sourceTree = ""; }; 842E45DC1ED8C54B000A8B52 /* Browser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Browser.swift; sourceTree = ""; }; + 843429CC2BFEE3B6003D6C70 /* FeedDownloader */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = FeedDownloader; sourceTree = ""; }; 843EA3EA2BFC293B003F2E97 /* Mac.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = Mac.xctestplan; sourceTree = ""; }; 84411E701FE5FBFA004B527F /* SmallIconProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmallIconProvider.swift; sourceTree = ""; }; 8444C8F11FED81840051386C /* OPMLExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLExporter.swift; sourceTree = ""; }; @@ -2013,6 +2014,7 @@ 84A699132BC34E8500605AB8 /* ArticleExtractor */, 84FB9FAE2BC3494B00B7AFC3 /* FeedFinder */, 51CD32C624D2DEF9009ABAEF /* Account */, + 843429CC2BFEE3B6003D6C70 /* FeedDownloader */, 84A699182BC3524C00605AB8 /* LocalAccount */, 84FB9FAD2BC344F800B7AFC3 /* Feedbin */, 84A699192BC36EDB00605AB8 /* Feedly */,