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 */,