Create FeedDownloader local package.

This commit is contained in:
Brent Simmons 2024-05-25 22:48:17 -07:00
parent fb5a1b28d0
commit ea3a78b841
6 changed files with 276 additions and 0 deletions

8
FeedDownloader/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1530"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FeedDownloader"
BuildableName = "FeedDownloader"
BlueprintName = "FeedDownloader"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FeedDownloader"
BuildableName = "FeedDownloader"
BlueprintName = "FeedDownloader"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -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"]),
]
)

View File

@ -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<URL>) {
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
}
}

View File

@ -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
}
}

View File

@ -1020,6 +1020,7 @@
8426119D1FCB6ED40086A189 /* HTMLMetadataDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLMetadataDownloader.swift; sourceTree = "<group>"; };
842E45CD1ED8C308000A8B52 /* AppNotifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppNotifications.swift; sourceTree = "<group>"; };
842E45DC1ED8C54B000A8B52 /* Browser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Browser.swift; sourceTree = "<group>"; };
843429CC2BFEE3B6003D6C70 /* FeedDownloader */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = FeedDownloader; sourceTree = "<group>"; };
843EA3EA2BFC293B003F2E97 /* Mac.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = Mac.xctestplan; sourceTree = "<group>"; };
84411E701FE5FBFA004B527F /* SmallIconProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmallIconProvider.swift; sourceTree = "<group>"; };
8444C8F11FED81840051386C /* OPMLExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLExporter.swift; sourceTree = "<group>"; };
@ -2013,6 +2014,7 @@
84A699132BC34E8500605AB8 /* ArticleExtractor */,
84FB9FAE2BC3494B00B7AFC3 /* FeedFinder */,
51CD32C624D2DEF9009ABAEF /* Account */,
843429CC2BFEE3B6003D6C70 /* FeedDownloader */,
84A699182BC3524C00605AB8 /* LocalAccount */,
84FB9FAD2BC344F800B7AFC3 /* Feedbin */,
84A699192BC36EDB00605AB8 /* Feedly */,