From e56b1513b692a86c5211cae9b17058b557183ede Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Mon, 1 Apr 2024 19:31:57 -0700 Subject: [PATCH] Move RSWeb from remote to local project; rename as Web. --- Account/Package.swift | 4 +- Account/Sources/Account/Account.swift | 2 +- Account/Sources/Account/AccountDelegate.swift | 2 +- Account/Sources/Account/AccountError.swift | 2 +- Account/Sources/Account/AccountManager.swift | 2 +- Account/Sources/Account/AccountMetadata.swift | 2 +- .../CloudKit/CloudKitAccountDelegate.swift | 2 +- .../CloudKit/CloudKitAccountZone.swift | 2 +- .../CloudKitAccountZoneDelegate.swift | 2 +- .../CloudKit/CloudKitArticlesZone.swift | 2 +- .../CloudKitArticlesZoneDelegate.swift | 2 +- .../CloudKitSendStatusOperation.swift | 2 +- .../Account/CombinedRefreshProgress.swift | 2 +- Account/Sources/Account/Feed.swift | 2 +- .../Account/FeedFinder/FeedFinder.swift | 2 +- Account/Sources/Account/FeedMetadata.swift | 2 +- .../Account/Feedbin/FeedbinAPICaller.swift | 2 +- .../Feedbin/FeedbinAccountDelegate.swift | 2 +- .../Account/Feedly/FeedlyAPICaller.swift | 2 +- .../Feedly/FeedlyAccountDelegate+OAuth.swift | 2 +- .../Feedly/FeedlyAccountDelegate.swift | 2 +- .../Feedly/OAuthAcessTokenRefreshing.swift | 2 +- .../OAuthAuthorizationCodeGranting.swift | 2 +- .../FeedlyAddExistingFeedOperation.swift | 2 +- .../FeedlyAddNewFeedOperation.swift | 2 +- .../FeedlyDownloadArticlesOperation.swift | 2 +- .../Feedly/Operations/FeedlyOperation.swift | 2 +- .../FeedlyRefreshAccessTokenOperation.swift | 2 +- .../Operations/FeedlySyncAllOperation.swift | 2 +- .../FeedlySyncStreamContentsOperation.swift | 2 +- .../LocalAccount/InitialFeedDownloader.swift | 2 +- .../LocalAccount/LocalAccountDelegate.swift | 2 +- .../LocalAccount/LocalAccountRefresher.swift | 2 +- .../NewsBlurAPICaller+Internal.swift | 2 +- .../NewsBlurAccountDelegate+Internal.swift | 2 +- .../Account/NewsBlur/NewsBlurAPICaller.swift | 2 +- .../NewsBlur/NewsBlurAccountDelegate.swift | 2 +- .../ReaderAPI/ReaderAPIAccountDelegate.swift | 2 +- .../Account/ReaderAPI/ReaderAPICaller.swift | 2 +- .../Sources/Account/URLRequest+Account.swift | 2 +- .../AccountTests/AccountCredentialsTest.swift | 2 +- .../Feedly/FeedlyOperationTests.swift | 2 +- ...edlyRefreshAccessTokenOperationTests.swift | 2 +- .../AccountTests/TestAccountManager.swift | 2 +- .../Tests/AccountTests/TestTransport.swift | 2 +- Core/Package.swift | 4 - Mac/AppDelegate.swift | 2 +- Mac/Browser.swift | 2 +- Mac/CrashReporter/CrashReporter.swift | 2 +- .../Detail/DetailViewController.swift | 2 +- .../Detail/DetailWebViewController.swift | 2 +- .../Sidebar/SidebarStatusBarView.swift | 2 +- .../AccountsFeedbinWindowController.swift | 2 +- .../AccountsNewsBlurWindowController.swift | 2 +- .../AccountsReaderAPIWindowController.swift | 2 +- .../GeneralPrefencesViewController.swift | 2 +- NetNewsWire.xcodeproj/project.pbxproj | 51 +- .../xcshareddata/swiftpm/Package.resolved | 11 +- Shared/Extensions/CacheCleaner.swift | 2 +- Shared/Favicons/SingleFaviconDownloader.swift | 2 +- .../HTMLMetadata/HTMLMetadataDownloader.swift | 2 +- Shared/Images/FeedIconDownloader.swift | 2 +- Shared/Images/ImageDownloader.swift | 2 +- Web/.gitignore | 8 + Web/Package.swift | 31 + Web/Sources/Web/Dictionary+RSWeb.swift | 26 + Web/Sources/Web/DownloadObject.swift | 32 + Web/Sources/Web/DownloadProgress.swift | 103 +++ Web/Sources/Web/DownloadSession.swift | 308 +++++++++ Web/Sources/Web/HTTPConditionalGetInfo.swift | 46 ++ Web/Sources/Web/HTTPDateInfo.swift | 29 + Web/Sources/Web/HTTPLinkPagingInfo.swift | 41 ++ Web/Sources/Web/HTTPMethod.swift | 18 + Web/Sources/Web/HTTPRequestHeader.swift | 21 + Web/Sources/Web/HTTPResponseCode.swift | 61 ++ Web/Sources/Web/HTTPResponseHeader.swift | 25 + Web/Sources/Web/MacWebBrowser.swift | 152 +++++ Web/Sources/Web/MimeType.swift | 55 ++ Web/Sources/Web/OneShotDownload.swift | 192 ++++++ Web/Sources/Web/Reachability.swift | 406 ++++++++++++ Web/Sources/Web/String+RSWeb.swift | 39 ++ Web/Sources/Web/URL+RSWeb.swift | 90 +++ Web/Sources/Web/URLComponents+RSWeb.swift | 34 + Web/Sources/Web/URLRequest+RSWeb.swift | 28 + Web/Sources/Web/URLResponse+RSWeb.swift | 45 ++ Web/Sources/Web/UTS46/Data+Extensions.swift | 21 + .../Web/UTS46/Scanner+Extensions.swift | 54 ++ Web/Sources/Web/UTS46/String+Punycode.swift | 596 ++++++++++++++++++ Web/Sources/Web/UTS46/UTS46+Loading.swift | 227 +++++++ Web/Sources/Web/UTS46/UTS46.swift | 189 ++++++ Web/Sources/Web/UTS46/uts46 | Bin 0 -> 15368 bytes Web/Sources/Web/UserAgent.swift | 26 + Web/Sources/Web/WebServices/Transport.swift | 235 +++++++ .../Web/WebServices/TransportJSON.swift | 153 +++++ Web/Tests/WebTests/WebTests.swift | 12 + Widget/Shared Views/ArticleItemView.swift | 2 +- .../FeedbinAccountViewController.swift | 2 +- .../NewsBlurAccountViewController.swift | 2 +- .../ReaderAPIAccountViewController.swift | 2 +- iOS/AppDelegate.swift | 2 +- 100 files changed, 3372 insertions(+), 129 deletions(-) create mode 100644 Web/.gitignore create mode 100644 Web/Package.swift create mode 100644 Web/Sources/Web/Dictionary+RSWeb.swift create mode 100755 Web/Sources/Web/DownloadObject.swift create mode 100755 Web/Sources/Web/DownloadProgress.swift create mode 100755 Web/Sources/Web/DownloadSession.swift create mode 100755 Web/Sources/Web/HTTPConditionalGetInfo.swift create mode 100644 Web/Sources/Web/HTTPDateInfo.swift create mode 100644 Web/Sources/Web/HTTPLinkPagingInfo.swift create mode 100755 Web/Sources/Web/HTTPMethod.swift create mode 100755 Web/Sources/Web/HTTPRequestHeader.swift create mode 100755 Web/Sources/Web/HTTPResponseCode.swift create mode 100755 Web/Sources/Web/HTTPResponseHeader.swift create mode 100755 Web/Sources/Web/MacWebBrowser.swift create mode 100755 Web/Sources/Web/MimeType.swift create mode 100755 Web/Sources/Web/OneShotDownload.swift create mode 100644 Web/Sources/Web/Reachability.swift create mode 100644 Web/Sources/Web/String+RSWeb.swift create mode 100755 Web/Sources/Web/URL+RSWeb.swift create mode 100644 Web/Sources/Web/URLComponents+RSWeb.swift create mode 100755 Web/Sources/Web/URLRequest+RSWeb.swift create mode 100755 Web/Sources/Web/URLResponse+RSWeb.swift create mode 100644 Web/Sources/Web/UTS46/Data+Extensions.swift create mode 100644 Web/Sources/Web/UTS46/Scanner+Extensions.swift create mode 100644 Web/Sources/Web/UTS46/String+Punycode.swift create mode 100644 Web/Sources/Web/UTS46/UTS46+Loading.swift create mode 100644 Web/Sources/Web/UTS46/UTS46.swift create mode 100644 Web/Sources/Web/UTS46/uts46 create mode 100755 Web/Sources/Web/UserAgent.swift create mode 100644 Web/Sources/Web/WebServices/Transport.swift create mode 100644 Web/Sources/Web/WebServices/TransportJSON.swift create mode 100644 Web/Tests/WebTests/WebTests.swift diff --git a/Account/Package.swift b/Account/Package.swift index 97a193743..542906ae4 100644 --- a/Account/Package.swift +++ b/Account/Package.swift @@ -12,9 +12,9 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/Ranchero-Software/RSParser.git", .upToNextMajor(from: "2.0.2")), - .package(url: "https://github.com/Ranchero-Software/RSWeb.git", .upToNextMajor(from: "1.0.0")), .package(path: "../Articles"), .package(path: "../ArticlesDatabase"), + .package(path: "../Web"), .package(path: "../Secrets"), .package(path: "../Database"), .package(path: "../SyncDatabase"), @@ -26,7 +26,7 @@ let package = Package( name: "Account", dependencies: [ "RSParser", - "RSWeb", + "Web", "Articles", "ArticlesDatabase", "Secrets", diff --git a/Account/Sources/Account/Account.swift b/Account/Sources/Account/Account.swift index 8f63b795c..e05b26ac6 100644 --- a/Account/Sources/Account/Account.swift +++ b/Account/Sources/Account/Account.swift @@ -15,7 +15,7 @@ import Articles import RSParser import Database import ArticlesDatabase -import RSWeb +import Web import os.log import Secrets import Core diff --git a/Account/Sources/Account/AccountDelegate.swift b/Account/Sources/Account/AccountDelegate.swift index 8533c132b..1d40c7ed2 100644 --- a/Account/Sources/Account/AccountDelegate.swift +++ b/Account/Sources/Account/AccountDelegate.swift @@ -8,7 +8,7 @@ import Foundation import Articles -import RSWeb +import Web import Secrets @MainActor protocol AccountDelegate { diff --git a/Account/Sources/Account/AccountError.swift b/Account/Sources/Account/AccountError.swift index ddd64c2b2..5220d4c40 100644 --- a/Account/Sources/Account/AccountError.swift +++ b/Account/Sources/Account/AccountError.swift @@ -7,7 +7,7 @@ // import Foundation -import RSWeb +import Web public enum AccountError: LocalizedError { diff --git a/Account/Sources/Account/AccountManager.swift b/Account/Sources/Account/AccountManager.swift index 9a96fe421..1ec8b3841 100644 --- a/Account/Sources/Account/AccountManager.swift +++ b/Account/Sources/Account/AccountManager.swift @@ -7,7 +7,7 @@ // import Foundation -import RSWeb +import Web import Articles import ArticlesDatabase import Database diff --git a/Account/Sources/Account/AccountMetadata.swift b/Account/Sources/Account/AccountMetadata.swift index f54bdd7eb..66046684b 100644 --- a/Account/Sources/Account/AccountMetadata.swift +++ b/Account/Sources/Account/AccountMetadata.swift @@ -7,7 +7,7 @@ // import Foundation -import RSWeb +import Web protocol AccountMetadataDelegate: AnyObject { func valueDidChange(_ accountMetadata: AccountMetadata, key: AccountMetadata.CodingKeys) diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift index d33201ece..dfa45eff9 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift @@ -14,7 +14,7 @@ import SyncDatabase import RSParser import Articles import ArticlesDatabase -import RSWeb +import Web import Secrets import Core import CloudKitExtras diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift b/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift index 4b5cc8400..08f4ee6ed 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift @@ -8,7 +8,7 @@ import Foundation import os.log -import RSWeb +import Web import RSParser import CloudKit import FoundationExtras diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitAccountZoneDelegate.swift index 19c82585d..06efe6b90 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountZoneDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -8,7 +8,7 @@ import Foundation import os.log -import RSWeb +import Web import CloudKit import Articles import CloudKitExtras diff --git a/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift b/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift index e1c1c1641..18f8eccea 100644 --- a/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift +++ b/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift @@ -9,7 +9,7 @@ import Foundation import os.log import RSParser -import RSWeb +import Web import CloudKit import Articles import SyncDatabase diff --git a/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift index fe082fa65..691c76e8d 100644 --- a/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift @@ -9,7 +9,7 @@ import Foundation import os.log import RSParser -import RSWeb +import Web import CloudKit import SyncDatabase import Articles diff --git a/Account/Sources/Account/CloudKit/CloudKitSendStatusOperation.swift b/Account/Sources/Account/CloudKit/CloudKitSendStatusOperation.swift index a82a17013..35e703a88 100644 --- a/Account/Sources/Account/CloudKit/CloudKitSendStatusOperation.swift +++ b/Account/Sources/Account/CloudKit/CloudKitSendStatusOperation.swift @@ -9,7 +9,7 @@ import Foundation import Articles import os.log -import RSWeb +import Web import SyncDatabase import Database import Core diff --git a/Account/Sources/Account/CombinedRefreshProgress.swift b/Account/Sources/Account/CombinedRefreshProgress.swift index 803fb9e1c..6f3044ce4 100644 --- a/Account/Sources/Account/CombinedRefreshProgress.swift +++ b/Account/Sources/Account/CombinedRefreshProgress.swift @@ -7,7 +7,7 @@ // import Foundation -import RSWeb +import Web // Combines the refresh progress of multiple accounts into one struct, // for use by refresh status view and so on. diff --git a/Account/Sources/Account/Feed.swift b/Account/Sources/Account/Feed.swift index 254de9df7..8d123cc0c 100644 --- a/Account/Sources/Account/Feed.swift +++ b/Account/Sources/Account/Feed.swift @@ -7,7 +7,7 @@ // import Foundation -import RSWeb +import Web import Articles import Core diff --git a/Account/Sources/Account/FeedFinder/FeedFinder.swift b/Account/Sources/Account/FeedFinder/FeedFinder.swift index 19b892a1f..5640f2f7a 100644 --- a/Account/Sources/Account/FeedFinder/FeedFinder.swift +++ b/Account/Sources/Account/FeedFinder/FeedFinder.swift @@ -8,7 +8,7 @@ import Foundation import RSParser -import RSWeb +import Web class FeedFinder { diff --git a/Account/Sources/Account/FeedMetadata.swift b/Account/Sources/Account/FeedMetadata.swift index ca834ee9a..6f2e2a89b 100644 --- a/Account/Sources/Account/FeedMetadata.swift +++ b/Account/Sources/Account/FeedMetadata.swift @@ -7,7 +7,7 @@ // import Foundation -import RSWeb +import Web import Articles protocol FeedMetadataDelegate: AnyObject { diff --git a/Account/Sources/Account/Feedbin/FeedbinAPICaller.swift b/Account/Sources/Account/Feedbin/FeedbinAPICaller.swift index e8e1d8ec1..2de938c94 100644 --- a/Account/Sources/Account/Feedbin/FeedbinAPICaller.swift +++ b/Account/Sources/Account/Feedbin/FeedbinAPICaller.swift @@ -11,7 +11,7 @@ // IP address will become unblocked and you can use the service again. import Foundation -import RSWeb +import Web import Secrets enum CreateSubscriptionResult { diff --git a/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift b/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift index fbd4c4184..53d9b9154 100644 --- a/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift +++ b/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift @@ -9,7 +9,7 @@ import Articles import Database import RSParser -import RSWeb +import Web import SyncDatabase import os.log import Secrets diff --git a/Account/Sources/Account/Feedly/FeedlyAPICaller.swift b/Account/Sources/Account/Feedly/FeedlyAPICaller.swift index 3124b5dea..6b61237d5 100644 --- a/Account/Sources/Account/Feedly/FeedlyAPICaller.swift +++ b/Account/Sources/Account/Feedly/FeedlyAPICaller.swift @@ -7,7 +7,7 @@ // import Foundation -import RSWeb +import Web import Secrets protocol FeedlyAPICallerDelegate: AnyObject { diff --git a/Account/Sources/Account/Feedly/FeedlyAccountDelegate+OAuth.swift b/Account/Sources/Account/Feedly/FeedlyAccountDelegate+OAuth.swift index c1f1128f6..814ca074c 100644 --- a/Account/Sources/Account/Feedly/FeedlyAccountDelegate+OAuth.swift +++ b/Account/Sources/Account/Feedly/FeedlyAccountDelegate+OAuth.swift @@ -7,7 +7,7 @@ // import Foundation -import RSWeb +import Web import Secrets /// Models the access token response from Feedly. diff --git a/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift b/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift index abd02792e..a103ce664 100644 --- a/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift +++ b/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift @@ -8,7 +8,7 @@ import Articles import RSParser -import RSWeb +import Web import SyncDatabase import os.log import Secrets diff --git a/Account/Sources/Account/Feedly/OAuthAcessTokenRefreshing.swift b/Account/Sources/Account/Feedly/OAuthAcessTokenRefreshing.swift index 306815c77..28d6ef604 100644 --- a/Account/Sources/Account/Feedly/OAuthAcessTokenRefreshing.swift +++ b/Account/Sources/Account/Feedly/OAuthAcessTokenRefreshing.swift @@ -7,7 +7,7 @@ // import Foundation -import RSWeb +import Web /// Models section 6 of the OAuth 2.0 Authorization Framework /// https://tools.ietf.org/html/rfc6749#section-6 diff --git a/Account/Sources/Account/Feedly/OAuthAuthorizationCodeGranting.swift b/Account/Sources/Account/Feedly/OAuthAuthorizationCodeGranting.swift index cff25cafb..3d276ae97 100644 --- a/Account/Sources/Account/Feedly/OAuthAuthorizationCodeGranting.swift +++ b/Account/Sources/Account/Feedly/OAuthAuthorizationCodeGranting.swift @@ -7,7 +7,7 @@ // import Foundation -import RSWeb +import Web import Secrets /// Client-specific information for requesting an authorization code grant. diff --git a/Account/Sources/Account/Feedly/Operations/FeedlyAddExistingFeedOperation.swift b/Account/Sources/Account/Feedly/Operations/FeedlyAddExistingFeedOperation.swift index cf4680a41..0b77ef60a 100644 --- a/Account/Sources/Account/Feedly/Operations/FeedlyAddExistingFeedOperation.swift +++ b/Account/Sources/Account/Feedly/Operations/FeedlyAddExistingFeedOperation.swift @@ -8,7 +8,7 @@ import Foundation import os.log -import RSWeb +import Web import Secrets import Core diff --git a/Account/Sources/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift b/Account/Sources/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift index bb8e17915..0a2f45a23 100644 --- a/Account/Sources/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift +++ b/Account/Sources/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift @@ -9,7 +9,7 @@ import Foundation import os.log import SyncDatabase -import RSWeb +import Web import Secrets import Core diff --git a/Account/Sources/Account/Feedly/Operations/FeedlyDownloadArticlesOperation.swift b/Account/Sources/Account/Feedly/Operations/FeedlyDownloadArticlesOperation.swift index 1ce73b3cf..0a97bd6ae 100644 --- a/Account/Sources/Account/Feedly/Operations/FeedlyDownloadArticlesOperation.swift +++ b/Account/Sources/Account/Feedly/Operations/FeedlyDownloadArticlesOperation.swift @@ -8,7 +8,7 @@ import Foundation import os.log -import RSWeb +import Web import Core class FeedlyDownloadArticlesOperation: FeedlyOperation { diff --git a/Account/Sources/Account/Feedly/Operations/FeedlyOperation.swift b/Account/Sources/Account/Feedly/Operations/FeedlyOperation.swift index 0241daade..f55358a72 100644 --- a/Account/Sources/Account/Feedly/Operations/FeedlyOperation.swift +++ b/Account/Sources/Account/Feedly/Operations/FeedlyOperation.swift @@ -7,7 +7,7 @@ // import Foundation -import RSWeb +import Web import Core protocol FeedlyOperationDelegate: AnyObject { diff --git a/Account/Sources/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift b/Account/Sources/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift index ad8271d12..c518f6e55 100644 --- a/Account/Sources/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift +++ b/Account/Sources/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift @@ -8,7 +8,7 @@ import Foundation import os.log -import RSWeb +import Web import Secrets final class FeedlyRefreshAccessTokenOperation: FeedlyOperation { diff --git a/Account/Sources/Account/Feedly/Operations/FeedlySyncAllOperation.swift b/Account/Sources/Account/Feedly/Operations/FeedlySyncAllOperation.swift index 99d085171..91a6d1f73 100644 --- a/Account/Sources/Account/Feedly/Operations/FeedlySyncAllOperation.swift +++ b/Account/Sources/Account/Feedly/Operations/FeedlySyncAllOperation.swift @@ -9,7 +9,7 @@ import Foundation import os.log import SyncDatabase -import RSWeb +import Web import Secrets import Core diff --git a/Account/Sources/Account/Feedly/Operations/FeedlySyncStreamContentsOperation.swift b/Account/Sources/Account/Feedly/Operations/FeedlySyncStreamContentsOperation.swift index 4de17867b..50e6425ad 100644 --- a/Account/Sources/Account/Feedly/Operations/FeedlySyncStreamContentsOperation.swift +++ b/Account/Sources/Account/Feedly/Operations/FeedlySyncStreamContentsOperation.swift @@ -9,7 +9,7 @@ import Foundation import os.log import RSParser -import RSWeb +import Web import Secrets import Core diff --git a/Account/Sources/Account/LocalAccount/InitialFeedDownloader.swift b/Account/Sources/Account/LocalAccount/InitialFeedDownloader.swift index 34b1ddea3..d1cd7b02c 100644 --- a/Account/Sources/Account/LocalAccount/InitialFeedDownloader.swift +++ b/Account/Sources/Account/LocalAccount/InitialFeedDownloader.swift @@ -8,7 +8,7 @@ import Foundation import RSParser -import RSWeb +import Web struct InitialFeedDownloader { diff --git a/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift b/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift index f3a27b070..97afbf4e3 100644 --- a/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift +++ b/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift @@ -11,7 +11,7 @@ import os.log import RSParser import Articles import ArticlesDatabase -import RSWeb +import Web import Secrets import Core diff --git a/Account/Sources/Account/LocalAccount/LocalAccountRefresher.swift b/Account/Sources/Account/LocalAccount/LocalAccountRefresher.swift index 37c7e1a3e..bf0682fd9 100644 --- a/Account/Sources/Account/LocalAccount/LocalAccountRefresher.swift +++ b/Account/Sources/Account/LocalAccount/LocalAccountRefresher.swift @@ -8,7 +8,7 @@ import Foundation import RSParser -import RSWeb +import Web import Articles import ArticlesDatabase import FoundationExtras diff --git a/Account/Sources/Account/NewsBlur/Internals/NewsBlurAPICaller+Internal.swift b/Account/Sources/Account/NewsBlur/Internals/NewsBlurAPICaller+Internal.swift index 078bb1d36..7881e21f2 100644 --- a/Account/Sources/Account/NewsBlur/Internals/NewsBlurAPICaller+Internal.swift +++ b/Account/Sources/Account/NewsBlur/Internals/NewsBlurAPICaller+Internal.swift @@ -7,7 +7,7 @@ // import Foundation -import RSWeb +import Web protocol NewsBlurDataConvertible { var asData: Data? { get } diff --git a/Account/Sources/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift b/Account/Sources/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift index bfc5fba53..f463c8d9c 100644 --- a/Account/Sources/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift +++ b/Account/Sources/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift @@ -10,7 +10,7 @@ import Articles import Database import RSParser -import RSWeb +import Web import SyncDatabase import os.log import Core diff --git a/Account/Sources/Account/NewsBlur/NewsBlurAPICaller.swift b/Account/Sources/Account/NewsBlur/NewsBlurAPICaller.swift index 58849c6cd..c3e4bbfc4 100644 --- a/Account/Sources/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Account/Sources/Account/NewsBlur/NewsBlurAPICaller.swift @@ -7,7 +7,7 @@ // import Foundation -import RSWeb +import Web import Secrets final class NewsBlurAPICaller: NSObject { diff --git a/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift index 329567cf2..ece731373 100644 --- a/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -9,7 +9,7 @@ import Articles import Database @preconcurrency import RSParser -import RSWeb +import Web import SyncDatabase import os.log import Secrets diff --git a/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift b/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift index 115c02c6e..925c03624 100644 --- a/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift +++ b/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift @@ -8,7 +8,7 @@ import Articles import RSParser -import RSWeb +import Web import SyncDatabase import os.log import Secrets diff --git a/Account/Sources/Account/ReaderAPI/ReaderAPICaller.swift b/Account/Sources/Account/ReaderAPI/ReaderAPICaller.swift index 0332319d7..7ce22c650 100644 --- a/Account/Sources/Account/ReaderAPI/ReaderAPICaller.swift +++ b/Account/Sources/Account/ReaderAPI/ReaderAPICaller.swift @@ -7,7 +7,7 @@ // import Foundation -import RSWeb +import Web import Secrets enum CreateReaderAPISubscriptionResult { diff --git a/Account/Sources/Account/URLRequest+Account.swift b/Account/Sources/Account/URLRequest+Account.swift index 035106a7c..29de76daa 100755 --- a/Account/Sources/Account/URLRequest+Account.swift +++ b/Account/Sources/Account/URLRequest+Account.swift @@ -7,7 +7,7 @@ // import Foundation -import RSWeb +import Web import Secrets public extension URLRequest { diff --git a/Account/Tests/AccountTests/AccountCredentialsTest.swift b/Account/Tests/AccountTests/AccountCredentialsTest.swift index b549c2e6d..edb254be7 100644 --- a/Account/Tests/AccountTests/AccountCredentialsTest.swift +++ b/Account/Tests/AccountTests/AccountCredentialsTest.swift @@ -7,7 +7,7 @@ // import XCTest -import RSWeb +import Web @testable import Account import Secrets diff --git a/Account/Tests/AccountTests/Feedly/FeedlyOperationTests.swift b/Account/Tests/AccountTests/Feedly/FeedlyOperationTests.swift index 1cbb3635f..9c34d0c61 100644 --- a/Account/Tests/AccountTests/Feedly/FeedlyOperationTests.swift +++ b/Account/Tests/AccountTests/Feedly/FeedlyOperationTests.swift @@ -8,7 +8,7 @@ import XCTest @testable import Account -import RSWeb +import Web class FeedlyOperationTests: XCTestCase { diff --git a/Account/Tests/AccountTests/Feedly/FeedlyRefreshAccessTokenOperationTests.swift b/Account/Tests/AccountTests/Feedly/FeedlyRefreshAccessTokenOperationTests.swift index df9921b09..30c6fab3b 100644 --- a/Account/Tests/AccountTests/Feedly/FeedlyRefreshAccessTokenOperationTests.swift +++ b/Account/Tests/AccountTests/Feedly/FeedlyRefreshAccessTokenOperationTests.swift @@ -8,7 +8,7 @@ import XCTest @testable import Account -import RSWeb +import Web import Secrets class FeedlyRefreshAccessTokenOperationTests: XCTestCase { diff --git a/Account/Tests/AccountTests/TestAccountManager.swift b/Account/Tests/AccountTests/TestAccountManager.swift index d40df9d25..a6216eb05 100644 --- a/Account/Tests/AccountTests/TestAccountManager.swift +++ b/Account/Tests/AccountTests/TestAccountManager.swift @@ -7,7 +7,7 @@ // import Foundation -import RSWeb +import Web @testable import Account diff --git a/Account/Tests/AccountTests/TestTransport.swift b/Account/Tests/AccountTests/TestTransport.swift index d6070ef35..758584940 100644 --- a/Account/Tests/AccountTests/TestTransport.swift +++ b/Account/Tests/AccountTests/TestTransport.swift @@ -7,7 +7,7 @@ // import Foundation -import RSWeb +import Web import XCTest protocol TestTransportMockResponseProviding: AnyObject { diff --git a/Core/Package.swift b/Core/Package.swift index e53319224..a9d7ce0ba 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -1,5 +1,4 @@ // swift-tools-version: 5.10 -// The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -7,7 +6,6 @@ let package = Package( name: "Core", platforms: [.macOS(.v14), .iOS(.v17)], products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. .library(name: "Core", targets: ["Core"]), .library(name: "CoreResources", type: .static, targets: ["CoreResources"]) ], @@ -15,8 +13,6 @@ let package = Package( .package(path: "../AppKitExtras") ], targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. .target( name: "Core", dependencies: [ diff --git a/Mac/AppDelegate.swift b/Mac/AppDelegate.swift index 92898ebb1..e9829aa40 100644 --- a/Mac/AppDelegate.swift +++ b/Mac/AppDelegate.swift @@ -10,7 +10,7 @@ import AppKit import UserNotifications import Articles import Tree -import RSWeb +import Web import Account import CoreResources import Secrets diff --git a/Mac/Browser.swift b/Mac/Browser.swift index d574bc7ad..e3b4bac6a 100644 --- a/Mac/Browser.swift +++ b/Mac/Browser.swift @@ -7,7 +7,7 @@ // import Foundation -import RSWeb +import Web struct Browser { diff --git a/Mac/CrashReporter/CrashReporter.swift b/Mac/CrashReporter/CrashReporter.swift index 09d1db06b..2afe95ff8 100644 --- a/Mac/CrashReporter/CrashReporter.swift +++ b/Mac/CrashReporter/CrashReporter.swift @@ -7,7 +7,7 @@ // import Foundation -import RSWeb +import Web import CrashReporter // Displays a window that shows the crash log — gives the user the chance to add data. diff --git a/Mac/MainWindow/Detail/DetailViewController.swift b/Mac/MainWindow/Detail/DetailViewController.swift index 7feea9850..845f2fafc 100644 --- a/Mac/MainWindow/Detail/DetailViewController.swift +++ b/Mac/MainWindow/Detail/DetailViewController.swift @@ -9,7 +9,7 @@ import Foundation import WebKit import Articles -import RSWeb +import Web enum DetailState: Equatable { case noSelection diff --git a/Mac/MainWindow/Detail/DetailWebViewController.swift b/Mac/MainWindow/Detail/DetailWebViewController.swift index 96dd47f29..559fa76e2 100644 --- a/Mac/MainWindow/Detail/DetailWebViewController.swift +++ b/Mac/MainWindow/Detail/DetailWebViewController.swift @@ -8,7 +8,7 @@ import AppKit import WebKit -import RSWeb +import Web import Articles import Core diff --git a/Mac/MainWindow/Sidebar/SidebarStatusBarView.swift b/Mac/MainWindow/Sidebar/SidebarStatusBarView.swift index 24f955fb2..bab58ae9e 100644 --- a/Mac/MainWindow/Sidebar/SidebarStatusBarView.swift +++ b/Mac/MainWindow/Sidebar/SidebarStatusBarView.swift @@ -8,7 +8,7 @@ import AppKit import Articles -import RSWeb +import Web import Account import Core diff --git a/Mac/Preferences/Accounts/AccountsFeedbinWindowController.swift b/Mac/Preferences/Accounts/AccountsFeedbinWindowController.swift index f89397937..aa5dbb57a 100644 --- a/Mac/Preferences/Accounts/AccountsFeedbinWindowController.swift +++ b/Mac/Preferences/Accounts/AccountsFeedbinWindowController.swift @@ -8,7 +8,7 @@ import AppKit import Account -import RSWeb +import Web import Secrets class AccountsFeedbinWindowController: NSWindowController { diff --git a/Mac/Preferences/Accounts/AccountsNewsBlurWindowController.swift b/Mac/Preferences/Accounts/AccountsNewsBlurWindowController.swift index 026b50d90..7831db2c1 100644 --- a/Mac/Preferences/Accounts/AccountsNewsBlurWindowController.swift +++ b/Mac/Preferences/Accounts/AccountsNewsBlurWindowController.swift @@ -8,7 +8,7 @@ import AppKit import Account -import RSWeb +import Web import Secrets class AccountsNewsBlurWindowController: NSWindowController { diff --git a/Mac/Preferences/Accounts/AccountsReaderAPIWindowController.swift b/Mac/Preferences/Accounts/AccountsReaderAPIWindowController.swift index 71fc0e469..4882d613c 100644 --- a/Mac/Preferences/Accounts/AccountsReaderAPIWindowController.swift +++ b/Mac/Preferences/Accounts/AccountsReaderAPIWindowController.swift @@ -8,7 +8,7 @@ import AppKit import Account -import RSWeb +import Web import Secrets class AccountsReaderAPIWindowController: NSWindowController { diff --git a/Mac/Preferences/General/GeneralPrefencesViewController.swift b/Mac/Preferences/General/GeneralPrefencesViewController.swift index 2ec312517..2fd286e58 100644 --- a/Mac/Preferences/General/GeneralPrefencesViewController.swift +++ b/Mac/Preferences/General/GeneralPrefencesViewController.swift @@ -7,7 +7,7 @@ // import AppKit -import RSWeb +import Web import UserNotifications import UniformTypeIdentifiers diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 7980a6374..e36cc2696 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -54,8 +54,6 @@ 17D643B226F8A436008D4C05 /* ArticleThemeDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D643B026F8A436008D4C05 /* ArticleThemeDownloader.swift */; }; 17D7586F2679C21800B17787 /* OnePasswordExtension.m in Sources */ = {isa = PBXBuildFile; fileRef = 17D7586E2679C21800B17787 /* OnePasswordExtension.m */; }; 17E0084625941887000C23F0 /* SizeCategories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E0084525941887000C23F0 /* SizeCategories.swift */; }; - 17EF6A2125C4E5B4002C9F81 /* RSWeb in Frameworks */ = {isa = PBXBuildFile; productRef = 17EF6A2025C4E5B4002C9F81 /* RSWeb */; }; - 17EF6A2225C4E5B4002C9F81 /* RSWeb in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 17EF6A2025C4E5B4002C9F81 /* RSWeb */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 27B86EEB25A53AAB00264340 /* Account in Frameworks */ = {isa = PBXBuildFile; productRef = 51BC2F4A24D343A500E90810 /* Account */; }; 4679674625E599C100844E8D /* Articles in Frameworks */ = {isa = PBXBuildFile; productRef = 4679674525E599C100844E8D /* Articles */; }; 4679674725E599C100844E8D /* Articles in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 4679674525E599C100844E8D /* Articles */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; @@ -145,8 +143,6 @@ 51386A8F25673277005F3762 /* AccountCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51386A8D25673276005F3762 /* AccountCell.swift */; }; 5138E95224D3418100AFF0FE /* RSParser in Frameworks */ = {isa = PBXBuildFile; productRef = 5138E95124D3418100AFF0FE /* RSParser */; }; 5138E95324D3418100AFF0FE /* RSParser in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 5138E95124D3418100AFF0FE /* RSParser */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; - 5138E95824D3419000AFF0FE /* RSWeb in Frameworks */ = {isa = PBXBuildFile; productRef = 5138E95724D3419000AFF0FE /* RSWeb */; }; - 5138E95924D3419000AFF0FE /* RSWeb in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 5138E95724D3419000AFF0FE /* RSWeb */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 513C5CE9232571C2003D4054 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 513C5CE8232571C2003D4054 /* ShareViewController.swift */; }; 513C5CEC232571C2003D4054 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 513C5CEA232571C2003D4054 /* MainInterface.storyboard */; }; 513C5CF0232571C2003D4054 /* NetNewsWire iOS Share Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 513C5CE6232571C2003D4054 /* NetNewsWire iOS Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -226,8 +222,6 @@ 51A1699F235E10D700EB091F /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A16995235E10D600EB091F /* AboutViewController.swift */; }; 51A169A0235E10D700EB091F /* FeedbinAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A16996235E10D700EB091F /* FeedbinAccountViewController.swift */; }; 51A66685238075AE00CB272D /* AddFeedDefaultContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A66684238075AE00CB272D /* AddFeedDefaultContainer.swift */; }; - 51A737C524DB19B50015FA66 /* RSWeb in Frameworks */ = {isa = PBXBuildFile; productRef = 51A737C424DB19B50015FA66 /* RSWeb */; }; - 51A737C624DB19B50015FA66 /* RSWeb in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 51A737C424DB19B50015FA66 /* RSWeb */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 51A737C824DB19CC0015FA66 /* RSParser in Frameworks */ = {isa = PBXBuildFile; productRef = 51A737C724DB19CC0015FA66 /* RSParser */; }; 51A737C924DB19CC0015FA66 /* RSParser in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 51A737C724DB19CC0015FA66 /* RSParser */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 51A9A5E12380C4FE0033AADF /* AppDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C45255226507D200C03939 /* AppDefaults.swift */; }; @@ -393,8 +387,6 @@ 653813282680E1EC007A082C /* CrashReporter in Frameworks */ = {isa = PBXBuildFile; productRef = 653813272680E1EC007A082C /* CrashReporter */; }; 653813302680E20C007A082C /* RSParser in Frameworks */ = {isa = PBXBuildFile; productRef = 6538132F2680E20C007A082C /* RSParser */; }; 653813312680E20C007A082C /* RSParser in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 6538132F2680E20C007A082C /* RSParser */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; - 653813362680E224007A082C /* RSWeb in Frameworks */ = {isa = PBXBuildFile; productRef = 653813352680E224007A082C /* RSWeb */; }; - 653813372680E224007A082C /* RSWeb in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 653813352680E224007A082C /* RSWeb */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 653813392680E22B007A082C /* Secrets in Frameworks */ = {isa = PBXBuildFile; productRef = 653813382680E22B007A082C /* Secrets */; }; 6538133A2680E22B007A082C /* Secrets in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 653813382680E22B007A082C /* Secrets */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 6538133B2680E28D007A082C /* Subscribe to Feed MAS.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 65ED409D235DEF770081F399 /* Subscribe to Feed MAS.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -875,7 +867,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 17EF6A2225C4E5B4002C9F81 /* RSWeb in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -942,7 +933,6 @@ files = ( 513F32782593EE6F0003048F /* Secrets in Embed Frameworks */, 5138E95324D3418100AFF0FE /* RSParser in Embed Frameworks */, - 5138E95924D3419000AFF0FE /* RSWeb in Embed Frameworks */, 513F327B2593EE6F0003048F /* SyncDatabase in Embed Frameworks */, 513F32722593EE6F0003048F /* Articles in Embed Frameworks */, 513F32812593EF180003048F /* Account in Embed Frameworks */, @@ -979,7 +969,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 653813372680E224007A082C /* RSWeb in Embed Frameworks */, 653813312680E20C007A082C /* RSParser in Embed Frameworks */, 6538133A2680E22B007A082C /* Secrets in Embed Frameworks */, 653813252680E1D6007A082C /* ArticlesDatabase in Embed Frameworks */, @@ -1023,7 +1012,6 @@ files = ( 513277442590FBB60064F1E7 /* Account in Embed Frameworks */, 5132775F2590FC640064F1E7 /* Articles in Embed Frameworks */, - 51A737C624DB19B50015FA66 /* RSWeb in Embed Frameworks */, 513277662590FC780064F1E7 /* Secrets in Embed Frameworks */, 513277652590FC640064F1E7 /* SyncDatabase in Embed Frameworks */, 513277622590FC640064F1E7 /* ArticlesDatabase in Embed Frameworks */, @@ -1393,6 +1381,7 @@ 849C78912362AB04009A71E4 /* ExportOPMLWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExportOPMLWindowController.swift; sourceTree = ""; }; 849EE70E203919360082A1EA /* AppAssets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAssets.swift; sourceTree = ""; }; 849EE72020391F560082A1EA /* SharingServicePickerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharingServicePickerDelegate.swift; sourceTree = ""; }; + 849FEDC32BBB225E0053FB21 /* Web */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Web; sourceTree = ""; }; 84A14FF220048CA70046AD9A /* SendToMicroBlogCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendToMicroBlogCommand.swift; sourceTree = ""; }; 84A1500420048DDF0046AD9A /* SendToMarsEditCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendToMarsEditCommand.swift; sourceTree = ""; }; 84A37CB4201ECD610087C5AF /* RenameWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RenameWindowController.swift; sourceTree = ""; }; @@ -1506,7 +1495,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 17EF6A2125C4E5B4002C9F81 /* RSWeb in Frameworks */, 176813F72564BB2C00D98635 /* SwiftUI.framework in Frameworks */, 176813F52564BB2C00D98635 /* WidgetKit.framework in Frameworks */, ); @@ -1565,7 +1553,6 @@ buildActionMask = 2147483647; files = ( 653813392680E22B007A082C /* Secrets in Frameworks */, - 653813362680E224007A082C /* RSWeb in Frameworks */, 84DCA51C2BABB78E00792720 /* CloudKitExtras in Frameworks */, 84DCA5272BABBB6200792720 /* Core in Frameworks */, 841CECDA2BAD04B80001EE72 /* Tree in Frameworks */, @@ -1591,7 +1578,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5138E95824D3419000AFF0FE /* RSWeb in Frameworks */, 84DCA5202BABB7A200792720 /* UIKitExtras in Frameworks */, 179D280B26F6F93D003B2E0A /* Zip in Frameworks */, 84DCA51E2BABB79900792720 /* FoundationExtras in Frameworks */, @@ -1615,7 +1601,6 @@ files = ( 513277642590FC640064F1E7 /* SyncDatabase in Frameworks */, 17192ADA2567B3D500AAEACA /* RSSparkle in Frameworks */, - 51A737C524DB19B50015FA66 /* RSWeb in Frameworks */, 8438C2DB2BABE0B00040C9EE /* CoreResources in Frameworks */, 5132775E2590FC640064F1E7 /* Articles in Frameworks */, 84DCA5252BABBB5A00792720 /* Core in Frameworks */, @@ -2363,6 +2348,7 @@ 841550F42B9E3F8000D4B345 /* Database */, 841550F52B9E4D6800D4B345 /* FMDB */, 84DCA50F2BABB65600792720 /* CloudKitExtras */, + 849FEDC32BBB225E0053FB21 /* Web */, 84DCA5232BABBA8100792720 /* Core */, 841CECD62BAD03C60001EE72 /* Tree */, 84DCA5102BABB6A100792720 /* UIKitExtras */, @@ -2753,7 +2739,6 @@ ); name = "NetNewsWire iOS Widget Extension"; packageProductDependencies = ( - 17EF6A2025C4E5B4002C9F81 /* RSWeb */, ); productName = "NetNewsWire WidgetExtension"; productReference = 176813F32564BB2C00D98635 /* NetNewsWire iOS Widget Extension.appex */; @@ -2909,7 +2894,6 @@ 653813232680E1D6007A082C /* ArticlesDatabase */, 653813272680E1EC007A082C /* CrashReporter */, 6538132F2680E20C007A082C /* RSParser */, - 653813352680E224007A082C /* RSWeb */, 653813382680E22B007A082C /* Secrets */, 84DCA5172BABB77E00792720 /* FoundationExtras */, 84DCA5192BABB78700792720 /* AppKitExtras */, @@ -2960,7 +2944,6 @@ packageProductDependencies = ( 516B695E24D2F33B00B5702F /* Account */, 5138E95124D3418100AFF0FE /* RSParser */, - 5138E95724D3419000AFF0FE /* RSWeb */, 513F32702593EE6F0003048F /* Articles */, 513F32732593EE6F0003048F /* ArticlesDatabase */, 513F32762593EE6F0003048F /* Secrets */, @@ -3003,7 +2986,6 @@ packageProductDependencies = ( 514C16CD24D2E63F009A3AFA /* Account */, 51C4CFF524D37DD500AF9874 /* Secrets */, - 51A737C424DB19B50015FA66 /* RSWeb */, 51A737C724DB19CC0015FA66 /* RSParser */, 17192AD92567B3D500AAEACA /* RSSparkle */, 519CA8E425841DB700EB079A /* CrashReporter */, @@ -3132,7 +3114,6 @@ ); mainGroup = 849C64571ED37A5D003D8FC0; packageReferences = ( - 51383A3024D1F90E0027E272 /* XCRemoteSwiftPackageReference "RSWeb" */, 51B0DF2324D2C7FA000AD99E /* XCRemoteSwiftPackageReference "RSParser" */, 17192AD82567B3D500AAEACA /* XCRemoteSwiftPackageReference "Sparkle-Binary" */, 519CA8E325841DB700EB079A /* XCRemoteSwiftPackageReference "plcrashreporter" */, @@ -4758,14 +4739,6 @@ revision = 059e7346082d02de16220cd79df7db18ddeba8c3; }; }; - 51383A3024D1F90E0027E272 /* XCRemoteSwiftPackageReference "RSWeb" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/Ranchero-Software/RSWeb.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.0; - }; - }; 519CA8E325841DB700EB079A /* XCRemoteSwiftPackageReference "plcrashreporter" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/microsoft/plcrashreporter.git"; @@ -4800,11 +4773,6 @@ package = 179D280926F6F93D003B2E0A /* XCRemoteSwiftPackageReference "Zip" */; productName = Zip; }; - 17EF6A2025C4E5B4002C9F81 /* RSWeb */ = { - isa = XCSwiftPackageProductDependency; - package = 51383A3024D1F90E0027E272 /* XCRemoteSwiftPackageReference "RSWeb" */; - productName = RSWeb; - }; 4679674525E599C100844E8D /* Articles */ = { isa = XCSwiftPackageProductDependency; productName = Articles; @@ -4830,11 +4798,6 @@ package = 51B0DF2324D2C7FA000AD99E /* XCRemoteSwiftPackageReference "RSParser" */; productName = RSParser; }; - 5138E95724D3419000AFF0FE /* RSWeb */ = { - isa = XCSwiftPackageProductDependency; - package = 51383A3024D1F90E0027E272 /* XCRemoteSwiftPackageReference "RSWeb" */; - productName = RSWeb; - }; 513F32702593EE6F0003048F /* Articles */ = { isa = XCSwiftPackageProductDependency; productName = Articles; @@ -4864,11 +4827,6 @@ package = 519CA8E325841DB700EB079A /* XCRemoteSwiftPackageReference "plcrashreporter" */; productName = CrashReporter; }; - 51A737C424DB19B50015FA66 /* RSWeb */ = { - isa = XCSwiftPackageProductDependency; - package = 51383A3024D1F90E0027E272 /* XCRemoteSwiftPackageReference "RSWeb" */; - productName = RSWeb; - }; 51A737C724DB19CC0015FA66 /* RSParser */ = { isa = XCSwiftPackageProductDependency; package = 51B0DF2324D2C7FA000AD99E /* XCRemoteSwiftPackageReference "RSParser" */; @@ -4908,11 +4866,6 @@ package = 51B0DF2324D2C7FA000AD99E /* XCRemoteSwiftPackageReference "RSParser" */; productName = RSParser; }; - 653813352680E224007A082C /* RSWeb */ = { - isa = XCSwiftPackageProductDependency; - package = 51383A3024D1F90E0027E272 /* XCRemoteSwiftPackageReference "RSWeb" */; - productName = RSWeb; - }; 653813382680E22B007A082C /* Secrets */ = { isa = XCSwiftPackageProductDependency; productName = Secrets; diff --git a/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b1ab83243..6386deeea 100644 --- a/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "143979811fdacc63ea101f03b6eeae2ff838828510390a5acdd415b90ffc549f", + "originHash" : "cdabcd707e9a2d05d419a03b18324728290923e8dae1ae2d92a2cdd8f1627191", "pins" : [ { "identity" : "plcrashreporter", @@ -19,15 +19,6 @@ "version" : "2.0.3" } }, - { - "identity" : "rsweb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Ranchero-Software/RSWeb.git", - "state" : { - "revision" : "2f7849a9ad2cb461b3d6c9c920e163596e6b5d7b", - "version" : "1.0.3" - } - }, { "identity" : "sparkle-binary", "kind" : "remoteSourceControl", diff --git a/Shared/Extensions/CacheCleaner.swift b/Shared/Extensions/CacheCleaner.swift index fa00603e7..47b609883 100644 --- a/Shared/Extensions/CacheCleaner.swift +++ b/Shared/Extensions/CacheCleaner.swift @@ -8,7 +8,7 @@ import Foundation import os.log -import RSWeb +import Web struct CacheCleaner { diff --git a/Shared/Favicons/SingleFaviconDownloader.swift b/Shared/Favicons/SingleFaviconDownloader.swift index aacde51f2..6fb13c9da 100644 --- a/Shared/Favicons/SingleFaviconDownloader.swift +++ b/Shared/Favicons/SingleFaviconDownloader.swift @@ -8,7 +8,7 @@ import Foundation import os.log -import RSWeb +import Web import FoundationExtras import Core diff --git a/Shared/HTMLMetadata/HTMLMetadataDownloader.swift b/Shared/HTMLMetadata/HTMLMetadataDownloader.swift index 4f2404868..552240967 100644 --- a/Shared/HTMLMetadata/HTMLMetadataDownloader.swift +++ b/Shared/HTMLMetadata/HTMLMetadataDownloader.swift @@ -7,7 +7,7 @@ // import Foundation -import RSWeb +import Web import RSParser struct HTMLMetadataDownloader { diff --git a/Shared/Images/FeedIconDownloader.swift b/Shared/Images/FeedIconDownloader.swift index 2ea3ff69a..b40ce6637 100644 --- a/Shared/Images/FeedIconDownloader.swift +++ b/Shared/Images/FeedIconDownloader.swift @@ -9,7 +9,7 @@ import Foundation import Articles import Account -import RSWeb +import Web import RSParser import Core diff --git a/Shared/Images/ImageDownloader.swift b/Shared/Images/ImageDownloader.swift index f2d3d6898..dd1d15ece 100644 --- a/Shared/Images/ImageDownloader.swift +++ b/Shared/Images/ImageDownloader.swift @@ -8,7 +8,7 @@ import Foundation import os.log -import RSWeb +import Web import FoundationExtras import Core diff --git a/Web/.gitignore b/Web/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/Web/.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/Web/Package.swift b/Web/Package.swift new file mode 100644 index 000000000..dd1f1f80c --- /dev/null +++ b/Web/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 5.10 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Web", + platforms: [.macOS(.v14), .iOS(.v17)], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "Web", + targets: ["Web"]), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "Web", + dependencies: [], + resources: [.copy("UTS46/uts46")], + swiftSettings: [ + .define("SWIFT_PACKAGE"), + .enableExperimentalFeature("StrictConcurrency") + ] + ), + .testTarget( + name: "WebTests", + dependencies: ["Web"]), + ] +) diff --git a/Web/Sources/Web/Dictionary+RSWeb.swift b/Web/Sources/Web/Dictionary+RSWeb.swift new file mode 100644 index 000000000..d647fe5e0 --- /dev/null +++ b/Web/Sources/Web/Dictionary+RSWeb.swift @@ -0,0 +1,26 @@ +// +// Dictionary+RSWeb.swift +// RSWeb +// +// Created by Brent Simmons on 1/13/18. +// Copyright © 2018 Ranchero Software. All rights reserved. +// + +import Foundation + +public extension Dictionary where Key == String, Value == String { + + /// Translates a dictionary into a string like `foo=bar¶m2=some%20thing`. + var urlQueryString: String? { + + var components = URLComponents() + + components.queryItems = self.reduce(into: [URLQueryItem]()) { + $0.append(URLQueryItem(name: $1.key, value: $1.value)) + } + + let s = components.percentEncodedQuery + + return s == nil || s!.isEmpty ? nil : s + } +} diff --git a/Web/Sources/Web/DownloadObject.swift b/Web/Sources/Web/DownloadObject.swift new file mode 100755 index 000000000..bc8b3f73b --- /dev/null +++ b/Web/Sources/Web/DownloadObject.swift @@ -0,0 +1,32 @@ +// +// 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/Web/Sources/Web/DownloadProgress.swift b/Web/Sources/Web/DownloadProgress.swift new file mode 100755 index 000000000..84bfffedf --- /dev/null +++ b/Web/Sources/Web/DownloadProgress.swift @@ -0,0 +1,103 @@ +// +// DownloadProgress.swift +// RSWeb +// +// Created by Brent Simmons on 9/17/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +// Main thread only. + +public extension Notification.Name { + + static let DownloadProgressDidChange = Notification.Name(rawValue: "DownloadProgressDidChange") +} + +public final class DownloadProgress { + + public var numberOfTasks = 0 { + didSet { + if numberOfTasks == 0 && numberRemaining != 0 { + numberRemaining = 0 + } + if numberOfTasks != oldValue { + postDidChangeNotification() + } + } + } + + public var numberRemaining = 0 { + didSet { + if numberRemaining == 0 && numberOfTasks != 0 { + numberOfTasks = 0 + } + if numberRemaining != oldValue { + postDidChangeNotification() + } + } + } + + public var numberCompleted: Int { + var n = numberOfTasks - numberRemaining + if n < 0 { + n = 0 + } + if n > numberOfTasks { + n = numberOfTasks + } + return n + } + + public var isComplete: Bool { + assert(Thread.isMainThread) + return numberRemaining < 1 + } + + public init(numberOfTasks: Int) { + assert(Thread.isMainThread) + self.numberOfTasks = numberOfTasks + } + + public func addToNumberOfTasks(_ n: Int) { + assert(Thread.isMainThread) + numberOfTasks = numberOfTasks + n + } + + public func addToNumberOfTasksAndRemaining(_ n: Int) { + assert(Thread.isMainThread) + numberOfTasks = numberOfTasks + n + numberRemaining = numberRemaining + n + } + + public func completeTask() { + assert(Thread.isMainThread) + if numberRemaining > 0 { + numberRemaining = numberRemaining - 1 + } + } + + public func completeTasks(_ tasks: Int) { + assert(Thread.isMainThread) + if numberRemaining >= tasks { + numberRemaining = numberRemaining - tasks + } + } + + public func clear() { + assert(Thread.isMainThread) + numberOfTasks = 0 + } +} + +// MARK: - Private + +private extension DownloadProgress { + + func postDidChangeNotification() { + DispatchQueue.main.async { + NotificationCenter.default.post(name: .DownloadProgressDidChange, object: self) + } + } +} diff --git a/Web/Sources/Web/DownloadSession.swift b/Web/Sources/Web/DownloadSession.swift new file mode 100755 index 000000000..c15edfb9e --- /dev/null +++ b/Web/Sources/Web/DownloadSession.swift @@ -0,0 +1,308 @@ +// +// DownloadSession.swift +// RSWeb +// +// Created by Brent Simmons on 3/12/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +// Create a DownloadSessionDelegate, then create a DownloadSession. +// To download things: call downloadObjects, with a set of represented objects, to download things. DownloadSession will call the various delegate methods. + +public protocol DownloadSessionDelegate { + + func downloadSession(_ downloadSession: DownloadSession, requestForRepresentedObject: AnyObject) -> URLRequest? + func downloadSession(_ downloadSession: DownloadSession, downloadDidCompleteForRepresentedObject: AnyObject, response: URLResponse?, data: Data, error: NSError?, completion: @escaping () -> Void) + func downloadSession(_ downloadSession: DownloadSession, shouldContinueAfterReceivingData: Data, representedObject: AnyObject) -> Bool + func downloadSession(_ downloadSession: DownloadSession, didReceiveUnexpectedResponse: URLResponse, representedObject: AnyObject) + func downloadSession(_ downloadSession: DownloadSession, didReceiveNotModifiedResponse: URLResponse, representedObject: AnyObject) + func downloadSession(_ downloadSession: DownloadSession, didDiscardDuplicateRepresentedObject: AnyObject) + func downloadSessionDidCompleteDownloadObjects(_ downloadSession: DownloadSession) + +} + +@objc public final class DownloadSession: NSObject { + + private var urlSession: URLSession! + private var tasksInProgress = Set() + private var tasksPending = Set() + private var taskIdentifierToInfoDictionary = [Int: DownloadInfo]() + private let representedObjects = NSMutableSet() + private let delegate: DownloadSessionDelegate + private var redirectCache = [String: String]() + private var queue = [AnyObject]() + + public init(delegate: DownloadSessionDelegate) { + + self.delegate = delegate + + super.init() + + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData + sessionConfiguration.timeoutIntervalForRequest = 15.0 + sessionConfiguration.httpShouldSetCookies = false + sessionConfiguration.httpCookieAcceptPolicy = .never + sessionConfiguration.httpMaximumConnectionsPerHost = 2 + sessionConfiguration.httpCookieStorage = nil + sessionConfiguration.urlCache = nil + + if let userAgentHeaders = UserAgent.headers() { + sessionConfiguration.httpAdditionalHeaders = userAgentHeaders + } + + urlSession = URLSession(configuration: sessionConfiguration, delegate: self, delegateQueue: OperationQueue.main) + } + + deinit { + urlSession.invalidateAndCancel() + } + + // MARK: - API + + public func cancelAll() { + urlSession.getTasksWithCompletionHandler { dataTasks, uploadTasks, downloadTasks in + dataTasks.forEach { $0.cancel() } + uploadTasks.forEach { $0.cancel() } + downloadTasks.forEach { $0.cancel() } + } + } + + public func downloadObjects(_ objects: NSSet) { + for oneObject in objects { + if !representedObjects.contains(oneObject) { + representedObjects.add(oneObject) + addDataTask(oneObject as AnyObject) + } else { + delegate.downloadSession(self, didDiscardDuplicateRepresentedObject: oneObject as AnyObject) + } + } + } +} + +// MARK: - URLSessionTaskDelegate + +extension DownloadSession: URLSessionTaskDelegate { + + public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + tasksInProgress.remove(task) + + guard let info = infoForTask(task) else { + return + } + + info.error = error + + delegate.downloadSession(self, downloadDidCompleteForRepresentedObject: info.representedObject, response: info.urlResponse, data: info.data as Data, error: error as NSError?) { + self.removeTask(task) + } + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { + + if response.statusCode == 301 || response.statusCode == 308 { + if let oldURLString = task.originalRequest?.url?.absoluteString, let newURLString = request.url?.absoluteString { + cacheRedirect(oldURLString, newURLString) + } + } + + completionHandler(request) + } +} + +// MARK: - URLSessionDataDelegate + +extension DownloadSession: URLSessionDataDelegate { + + public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + + tasksInProgress.insert(dataTask) + tasksPending.remove(dataTask) + + if let info = infoForTask(dataTask) { + info.urlResponse = response + } + + if response.forcedStatusCode == 304 { + + if let representedObject = infoForTask(dataTask)?.representedObject { + delegate.downloadSession(self, didReceiveNotModifiedResponse: response, representedObject: representedObject) + } + + completionHandler(.cancel) + removeTask(dataTask) + + return + } + + if !response.statusIsOK { + + if let representedObject = infoForTask(dataTask)?.representedObject { + delegate.downloadSession(self, didReceiveUnexpectedResponse: response, representedObject: representedObject) + } + + completionHandler(.cancel) + removeTask(dataTask) + + return + } + + addDataTaskFromQueueIfNecessary() + + completionHandler(.allow) + } + + public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + + guard let info = infoForTask(dataTask) else { + return + } + info.addData(data) + + if !delegate.downloadSession(self, shouldContinueAfterReceivingData: info.data as Data, representedObject: info.representedObject) { + + info.canceled = true + dataTask.cancel() + removeTask(dataTask) + } + } + +} + +// MARK: - Private + +private extension DownloadSession { + + func addDataTask(_ representedObject: AnyObject) { + guard tasksPending.count < 500 else { + queue.insert(representedObject, at: 0) + return + } + + guard let request = delegate.downloadSession(self, requestForRepresentedObject: representedObject) else { + return + } + + var requestToUse = request + + // If received permanent redirect earlier, use that URL. + + if let urlString = request.url?.absoluteString, let redirectedURLString = cachedRedirectForURLString(urlString) { + if let redirectedURL = URL(string: redirectedURLString) { + requestToUse.url = redirectedURL + } + } + + let task = urlSession.dataTask(with: requestToUse) + + let info = DownloadInfo(representedObject, urlRequest: requestToUse) + taskIdentifierToInfoDictionary[task.taskIdentifier] = info + + tasksPending.insert(task) + task.resume() + } + + func addDataTaskFromQueueIfNecessary() { + guard tasksPending.count < 500, let representedObject = queue.popLast() else { return } + addDataTask(representedObject) + } + + func infoForTask(_ task: URLSessionTask) -> DownloadInfo? { + return taskIdentifierToInfoDictionary[task.taskIdentifier] + } + + func removeTask(_ task: URLSessionTask) { + tasksInProgress.remove(task) + tasksPending.remove(task) + taskIdentifierToInfoDictionary[task.taskIdentifier] = nil + + addDataTaskFromQueueIfNecessary() + + if tasksInProgress.count + tasksPending.count < 1 { + representedObjects.removeAllObjects() + delegate.downloadSessionDidCompleteDownloadObjects(self) + } + } + + func urlStringIsBlackListedRedirect(_ urlString: String) -> Bool { + + // Hotels and similar often do permanent redirects. We can catch some of those. + + let s = urlString.lowercased() + let badStrings = ["solutionip", "lodgenet", "monzoon", "landingpage", "btopenzone", "register", "login", "authentic"] + + for oneBadString in badStrings { + if s.contains(oneBadString) { + return true + } + } + + return false + } + + func cacheRedirect(_ oldURLString: String, _ newURLString: String) { + if urlStringIsBlackListedRedirect(newURLString) { + return + } + redirectCache[oldURLString] = newURLString + } + + func cachedRedirectForURLString(_ urlString: String) -> String? { + + // Follow chains of redirects, but avoid loops. + + var urlStrings = Set() + urlStrings.insert(urlString) + + var currentString = urlString + + while(true) { + + if let oneRedirectString = redirectCache[currentString] { + + if urlStrings.contains(oneRedirectString) { + // Cycle. Bail. + return nil + } + urlStrings.insert(oneRedirectString) + currentString = oneRedirectString + } + + else { + break + } + } + + return currentString == urlString ? nil : currentString + } +} + +// MARK: - DownloadInfo + +private final class DownloadInfo { + + let representedObject: AnyObject + let urlRequest: URLRequest + let data = NSMutableData() + var error: Error? + var urlResponse: URLResponse? + var canceled = false + + var statusCode: Int { + return urlResponse?.forcedStatusCode ?? 0 + } + + init(_ representedObject: AnyObject, urlRequest: URLRequest) { + + self.representedObject = representedObject + self.urlRequest = urlRequest + } + + func addData(_ d: Data) { + + data.append(d) + } +} + diff --git a/Web/Sources/Web/HTTPConditionalGetInfo.swift b/Web/Sources/Web/HTTPConditionalGetInfo.swift new file mode 100755 index 000000000..c62a382d4 --- /dev/null +++ b/Web/Sources/Web/HTTPConditionalGetInfo.swift @@ -0,0 +1,46 @@ +// +// HTTPConditionalGetInfo.swift +// RSWeb +// +// Created by Brent Simmons on 4/11/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +public struct HTTPConditionalGetInfo: Codable, Equatable { + + public let lastModified: String? + public let etag: String? + + public init?(lastModified: String?, etag: String?) { + if lastModified == nil && etag == nil { + return nil + } + self.lastModified = lastModified + self.etag = etag + } + + public init?(urlResponse: HTTPURLResponse) { + let lastModified = urlResponse.valueForHTTPHeaderField(HTTPResponseHeader.lastModified) + let etag = urlResponse.valueForHTTPHeaderField(HTTPResponseHeader.etag) + self.init(lastModified: lastModified, etag: etag) + } + + public init?(headers: [AnyHashable : Any]) { + let lastModified = headers[HTTPResponseHeader.lastModified] as? String + let etag = headers[HTTPResponseHeader.etag] as? String + self.init(lastModified: lastModified, etag: etag) + } + + public func addRequestHeadersToURLRequest(_ urlRequest: inout URLRequest) { + // Bug seen in the wild: lastModified with last possible 32-bit date, which is in 2038. Ignore those. + // TODO: drop this check in late 2037. + if let lastModified = lastModified, !lastModified.contains("2038") { + urlRequest.addValue(lastModified, forHTTPHeaderField: HTTPRequestHeader.ifModifiedSince) + } + if let etag = etag { + urlRequest.addValue(etag, forHTTPHeaderField: HTTPRequestHeader.ifNoneMatch) + } + } +} diff --git a/Web/Sources/Web/HTTPDateInfo.swift b/Web/Sources/Web/HTTPDateInfo.swift new file mode 100644 index 000000000..06d95f811 --- /dev/null +++ b/Web/Sources/Web/HTTPDateInfo.swift @@ -0,0 +1,29 @@ +// +// HTTPDateInfo.swift +// RSWeb +// +// Created by Maurice Parker on 5/12/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import Foundation + +public struct HTTPDateInfo: Codable, Equatable { + + private static let formatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "EEEE, dd LLL yyyy HH:mm:ss zzz" + return dateFormatter + }() + + public let date: Date? + + public init?(urlResponse: HTTPURLResponse) { + if let headerDate = urlResponse.valueForHTTPHeaderField(HTTPResponseHeader.date) { + date = HTTPDateInfo.formatter.date(from: headerDate) + } else { + date = nil + } + } + +} diff --git a/Web/Sources/Web/HTTPLinkPagingInfo.swift b/Web/Sources/Web/HTTPLinkPagingInfo.swift new file mode 100644 index 000000000..38f4ed774 --- /dev/null +++ b/Web/Sources/Web/HTTPLinkPagingInfo.swift @@ -0,0 +1,41 @@ +// +// HTTPLinkPagingInfo.swift +// RSWeb +// +// Created by Maurice Parker on 5/12/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import Foundation + +public struct HTTPLinkPagingInfo { + + public let nextPage: String? + public let lastPage: String? + + public init(nextPage: String?, lastPage: String?) { + self.nextPage = nextPage + self.lastPage = lastPage + } + + public init(urlResponse: HTTPURLResponse) { + + guard let linkHeader = urlResponse.valueForHTTPHeaderField(HTTPResponseHeader.link) else { + self.init(nextPage: nil, lastPage: nil) + return + } + + let links = linkHeader.components(separatedBy: ",") + + var dict: [String: String] = [:] + links.forEach({ + let components = $0.components(separatedBy:"; ") + let page = components[0].trimmingCharacters(in: CharacterSet(charactersIn: " <>")) + dict[components[1]] = page + }) + + self.init(nextPage: dict["rel=\"next\""], lastPage: dict["rel=\"last\""]) + + } + +} diff --git a/Web/Sources/Web/HTTPMethod.swift b/Web/Sources/Web/HTTPMethod.swift new file mode 100755 index 000000000..44c796040 --- /dev/null +++ b/Web/Sources/Web/HTTPMethod.swift @@ -0,0 +1,18 @@ +// +// HTTPMethod.swift +// RSWeb +// +// Created by Brent Simmons on 12/26/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +public struct HTTPMethod { + + public static let get = "GET" + public static let post = "POST" + public static let put = "PUT" + public static let patch = "PATCH" + public static let delete = "DELETE" +} diff --git a/Web/Sources/Web/HTTPRequestHeader.swift b/Web/Sources/Web/HTTPRequestHeader.swift new file mode 100755 index 000000000..7021a6fcc --- /dev/null +++ b/Web/Sources/Web/HTTPRequestHeader.swift @@ -0,0 +1,21 @@ +// +// HTTPRequestHeader.swift +// RSWeb +// +// Created by Brent Simmons on 12/26/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +public struct HTTPRequestHeader { + + public static let userAgent = "User-Agent" + public static let authorization = "Authorization" + public static let contentType = "Content-Type" + + // Conditional GET + + public static let ifModifiedSince = "If-Modified-Since" + public static let ifNoneMatch = "If-None-Match" //Etag +} diff --git a/Web/Sources/Web/HTTPResponseCode.swift b/Web/Sources/Web/HTTPResponseCode.swift new file mode 100755 index 000000000..1ece77008 --- /dev/null +++ b/Web/Sources/Web/HTTPResponseCode.swift @@ -0,0 +1,61 @@ +// +// HTTPResponseCode.swift +// RSWeb +// +// Created by Brent Simmons on 12/26/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +public struct HTTPResponseCode { + + // http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html + // Not an enum because the main interest is the actual values. + + public static let responseContinue = 100 //"continue" is a language keyword, hence the weird name + public static let switchingProtocols = 101 + + public static let OK = 200 + public static let created = 201 + public static let accepted = 202 + public static let nonAuthoritativeInformation = 203 + public static let noContent = 204 + public static let resetContent = 205 + public static let partialContent = 206 + + public static let redirectMultipleChoices = 300 + public static let redirectPermanent = 301 + public static let redirectTemporary = 302 + public static let redirectSeeOther = 303 + public static let notModified = 304 + public static let useProxy = 305 + public static let unused = 306 + public static let redirectVeryTemporary = 307 + + public static let badRequest = 400 + public static let unauthorized = 401 + public static let paymentRequired = 402 + public static let forbidden = 403 + public static let notFound = 404 + public static let methodNotAllowed = 405 + public static let notAcceptable = 406 + public static let proxyAuthenticationRequired = 407 + public static let requestTimeout = 408 + public static let conflict = 409 + public static let gone = 410 + public static let lengthRequired = 411 + public static let preconditionFailed = 412 + public static let entityTooLarge = 413 + public static let URITooLong = 414 + public static let unsupportedMediaType = 415 + public static let requestedRangeNotSatisfiable = 416 + public static let expectationFailed = 417 + + public static let internalServerError = 500 + public static let notImplemented = 501 + public static let badGateway = 502 + public static let serviceUnavailable = 503 + public static let gatewayTimeout = 504 + public static let HTTPVersionNotSupported = 505 +} diff --git a/Web/Sources/Web/HTTPResponseHeader.swift b/Web/Sources/Web/HTTPResponseHeader.swift new file mode 100755 index 000000000..15e8c53e1 --- /dev/null +++ b/Web/Sources/Web/HTTPResponseHeader.swift @@ -0,0 +1,25 @@ +// +// HTTPResponseHeader.swift +// RSWeb +// +// Created by Brent Simmons on 12/26/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +public struct HTTPResponseHeader { + + public static let contentType = "Content-Type" + public static let location = "Location" + public static let link = "Links" + public static let date = "Date" + + // Conditional GET. See: + // http://fishbowl.pastiche.org/2002/10/21/http_conditional_get_for_rss_hackers/ + + public static let lastModified = "Last-Modified" + // Changed to the canonical case for lookups against a case sensitive dictionary + // https://developer.apple.com/documentation/foundation/httpurlresponse/1417930-allheaderfields + public static let etag = "Etag" +} diff --git a/Web/Sources/Web/MacWebBrowser.swift b/Web/Sources/Web/MacWebBrowser.swift new file mode 100755 index 000000000..74c784169 --- /dev/null +++ b/Web/Sources/Web/MacWebBrowser.swift @@ -0,0 +1,152 @@ +// +// MacWebBrowser.swift +// RSWeb +// +// Created by Brent Simmons on 12/27/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +#if os(macOS) +import AppKit + +public class MacWebBrowser { + + /// Opens a URL in the default browser. + @discardableResult public class func openURL(_ url: URL, inBackground: Bool = false) -> Bool { + + guard let preparedURL = url.preparedForOpeningInBrowser() else { + return false + } + + if (inBackground) { + do { + try NSWorkspace.shared.open(preparedURL, options: [.withoutActivation], configuration: [:]) + return true + } + catch { + return false + } + } + + return NSWorkspace.shared.open(preparedURL) + } + + /// Returns an array of the browsers installed on the system, sorted by name. + /// + /// "Browsers" are applications that can both handle `https` URLs, and display HTML documents. + public class func sortedBrowsers() -> [MacWebBrowser] { + guard let httpsIDs = LSCopyAllHandlersForURLScheme("https" as CFString)?.takeRetainedValue() as? [String] else { + return [] + } + + guard let htmlIDs = LSCopyAllRoleHandlersForContentType(kUTTypeHTML, .viewer)?.takeRetainedValue() as? [String] else { + return [] + } + + let browserIDs = Set(httpsIDs).intersection(Set(htmlIDs)) + + return browserIDs.compactMap { MacWebBrowser(bundleIdentifier: $0) }.sorted { + if let leftName = $0.name, let rightName = $1.name { + return leftName < rightName + } + + return false + } + } + + /// The filesystem URL of the default web browser. + private class var defaultBrowserURL: URL? { + return NSWorkspace.shared.urlForApplication(toOpen: URL(string: "https:///")!) + } + + /// The user's default web browser. + public class var `default`: MacWebBrowser { + return MacWebBrowser(url: defaultBrowserURL!) + } + + /// The filesystem URL of the web browser. + public let url: URL + + private lazy var _icon: NSImage? = { + if let values = try? url.resourceValues(forKeys: [.effectiveIconKey]) { + return values.effectiveIcon as? NSImage + } + + return nil + }() + + /// The application icon of the web browser. + public var icon: NSImage? { + return _icon + } + + private lazy var _name: String? = { + if let values = try? url.resourceValues(forKeys: [.localizedNameKey]), var name = values.localizedName { + if let extensionRange = name.range(of: ".app", options: [.anchored, .backwards]) { + name = name.replacingCharacters(in: extensionRange, with: "") + } + + return name + } + + return nil + }() + + /// The localized name of the web browser, with any `.app` extension removed. + public var name: String? { + return _name + } + + private lazy var _bundleIdentifier: String? = { + return Bundle(url: url)?.bundleIdentifier + }() + + /// The bundle identifier of the web browser. + public var bundleIdentifier: String? { + return _bundleIdentifier + } + + /// Initializes a `MacWebBrowser` with a URL on disk. + /// - Parameter url: The filesystem URL of the browser. + public init(url: URL) { + self.url = url + } + + /// Initializes a `MacWebBrowser` from a bundle identifier. + /// - Parameter bundleIdentifier: The bundle identifier of the browser. + public convenience init?(bundleIdentifier: String) { + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) else { + return nil + } + + self.init(url: url) + } + + /// Opens a URL in this browser. + /// - Parameters: + /// - url: The URL to open. + /// - inBackground: If `true`, attempt to load the URL without bringing the browser to the foreground. + @discardableResult public func openURL(_ url: URL, inBackground: Bool = false) -> Bool { + guard let preparedURL = url.preparedForOpeningInBrowser() else { + return false + } + + let options: NSWorkspace.LaunchOptions = inBackground ? [.withoutActivation] : [] + + return NSWorkspace.shared.open([preparedURL], withAppBundleIdentifier: self.bundleIdentifier, options: options, additionalEventParamDescriptor: nil, launchIdentifiers: nil) + } + +} + +extension MacWebBrowser: CustomDebugStringConvertible { + + public var debugDescription: String { + if let name = name, let bundleIdentifier = bundleIdentifier{ + return "MacWebBrowser: \(name) (\(bundleIdentifier))" + } else { + return "MacWebBrowser" + } + } +} + +#endif diff --git a/Web/Sources/Web/MimeType.swift b/Web/Sources/Web/MimeType.swift new file mode 100755 index 000000000..36225b718 --- /dev/null +++ b/Web/Sources/Web/MimeType.swift @@ -0,0 +1,55 @@ +// +// MimeType.swift +// RSWeb +// +// Created by Brent Simmons on 12/26/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +public struct MimeType { + + // This could certainly use expansion. + + public static let png = "image/png" + public static let jpeg = "image/jpeg" + public static let jpg = "image/jpg" + public static let gif = "image/gif" + public static let tiff = "image/tiff" +} + +public extension String { + + func isMimeTypeImage() -> Bool { + + return self.isOfGeneralMimeType("image") + } + + func isMimeTypeAudio() -> Bool { + + return self.isOfGeneralMimeType("audio") + } + + func isMimeTypeVideo() -> Bool { + + return self.isOfGeneralMimeType("video") + } + + func isMimeTypeTimeBasedMedia() -> Bool { + + return self.isMimeTypeAudio() || self.isMimeTypeVideo() + } + + private func isOfGeneralMimeType(_ type: String) -> Bool { + + let lower = self.lowercased() + if lower.hasPrefix(type) { + return true + } + if lower.hasPrefix("x-\(type)") { + return true + } + return false + } +} diff --git a/Web/Sources/Web/OneShotDownload.swift b/Web/Sources/Web/OneShotDownload.swift new file mode 100755 index 000000000..cfe30d6d1 --- /dev/null +++ b/Web/Sources/Web/OneShotDownload.swift @@ -0,0 +1,192 @@ +// +// 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/Web/Sources/Web/Reachability.swift b/Web/Sources/Web/Reachability.swift new file mode 100644 index 000000000..6c8ffa2e4 --- /dev/null +++ b/Web/Sources/Web/Reachability.swift @@ -0,0 +1,406 @@ +/* +Copyright (c) 2014, Ashley Mills +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +*/ + +import SystemConfiguration +import Foundation + +public enum ReachabilityError: Error { + case failedToCreateWithAddress(sockaddr, Int32) + case failedToCreateWithHostname(String, Int32) + case unableToSetCallback(Int32) + case unableToSetDispatchQueue(Int32) + case unableToGetFlags(Int32) +} + +@available(*, unavailable, renamed: "Notification.Name.reachabilityChanged") +public let ReachabilityChangedNotification = NSNotification.Name("ReachabilityChangedNotification") + +public extension Notification.Name { + static let reachabilityChanged = Notification.Name("reachabilityChanged") +} + +public class Reachability { + + public typealias NetworkReachable = (Reachability) -> () + public typealias NetworkUnreachable = (Reachability) -> () + + @available(*, unavailable, renamed: "Connection") + public enum NetworkStatus: CustomStringConvertible { + case notReachable, reachableViaWiFi, reachableViaWWAN + public var description: String { + switch self { + case .reachableViaWWAN: return "Cellular" + case .reachableViaWiFi: return "WiFi" + case .notReachable: return "No Connection" + } + } + } + + public enum Connection: CustomStringConvertible { + @available(*, deprecated, renamed: "unavailable") + case none + case unavailable, wifi, cellular + public var description: String { + switch self { + case .cellular: return "Cellular" + case .wifi: return "WiFi" + case .unavailable: return "No Connection" + case .none: return "unavailable" + } + } + } + + public var whenReachable: NetworkReachable? + public var whenUnreachable: NetworkUnreachable? + + @available(*, deprecated, renamed: "allowsCellularConnection") + public let reachableOnWWAN: Bool = true + + /// Set to `false` to force Reachability.connection to .none when on cellular connection (default value `true`) + public var allowsCellularConnection: Bool + + // The notification center on which "reachability changed" events are being posted + public var notificationCenter: NotificationCenter = NotificationCenter.default + + @available(*, deprecated, renamed: "connection.description") + public var currentReachabilityString: String { + return "\(connection)" + } + + @available(*, unavailable, renamed: "connection") + public var currentReachabilityStatus: Connection { + return connection + } + + public var connection: Connection { + if flags == nil { + try? setReachabilityFlags() + } + + switch flags?.connection { + case .unavailable?, nil: return .unavailable + case .none?: return .unavailable + case .cellular?: return allowsCellularConnection ? .cellular : .unavailable + case .wifi?: return .wifi + } + } + + fileprivate var isRunningOnDevice: Bool = { + #if targetEnvironment(simulator) + return false + #else + return true + #endif + }() + + fileprivate(set) var notifierRunning = false + fileprivate let reachabilityRef: SCNetworkReachability + fileprivate let reachabilitySerialQueue: DispatchQueue + fileprivate let notificationQueue: DispatchQueue? + fileprivate(set) var flags: SCNetworkReachabilityFlags? { + didSet { + guard flags != oldValue else { return } + notifyReachabilityChanged() + } + } + + required public init(reachabilityRef: SCNetworkReachability, + queueQoS: DispatchQoS = .default, + targetQueue: DispatchQueue? = nil, + notificationQueue: DispatchQueue? = .main) { + self.allowsCellularConnection = true + self.reachabilityRef = reachabilityRef + self.reachabilitySerialQueue = DispatchQueue(label: "uk.co.ashleymills.reachability", qos: queueQoS, target: targetQueue) + self.notificationQueue = notificationQueue + } + + public convenience init(hostname: String, + queueQoS: DispatchQoS = .default, + targetQueue: DispatchQueue? = nil, + notificationQueue: DispatchQueue? = .main) throws { + guard let ref = SCNetworkReachabilityCreateWithName(nil, hostname) else { + throw ReachabilityError.failedToCreateWithHostname(hostname, SCError()) + } + self.init(reachabilityRef: ref, queueQoS: queueQoS, targetQueue: targetQueue, notificationQueue: notificationQueue) + } + + public convenience init(queueQoS: DispatchQoS = .default, + targetQueue: DispatchQueue? = nil, + notificationQueue: DispatchQueue? = .main) throws { + var zeroAddress = sockaddr() + zeroAddress.sa_len = UInt8(MemoryLayout.size) + zeroAddress.sa_family = sa_family_t(AF_INET) + + guard let ref = SCNetworkReachabilityCreateWithAddress(nil, &zeroAddress) else { + throw ReachabilityError.failedToCreateWithAddress(zeroAddress, SCError()) + } + + self.init(reachabilityRef: ref, queueQoS: queueQoS, targetQueue: targetQueue, notificationQueue: notificationQueue) + } + + deinit { + stopNotifier() + } +} + +public extension Reachability { + + // MARK: - *** Notifier methods *** + func startNotifier() throws { + guard !notifierRunning else { return } + + let callback: SCNetworkReachabilityCallBack = { (reachability, flags, info) in + guard let info = info else { return } + + // `weakifiedReachability` is guaranteed to exist by virtue of our + // retain/release callbacks which we provided to the `SCNetworkReachabilityContext`. + let weakifiedReachability = Unmanaged.fromOpaque(info).takeUnretainedValue() + + // The weak `reachability` _may_ no longer exist if the `Reachability` + // object has since been deallocated but a callback was already in flight. + weakifiedReachability.reachability?.flags = flags + } + + let weakifiedReachability = ReachabilityWeakifier(reachability: self) + let opaqueWeakifiedReachability = Unmanaged.passUnretained(weakifiedReachability).toOpaque() + + var context = SCNetworkReachabilityContext( + version: 0, + info: UnsafeMutableRawPointer(opaqueWeakifiedReachability), + retain: { (info: UnsafeRawPointer) -> UnsafeRawPointer in + let unmanagedWeakifiedReachability = Unmanaged.fromOpaque(info) + _ = unmanagedWeakifiedReachability.retain() + return UnsafeRawPointer(unmanagedWeakifiedReachability.toOpaque()) + }, + release: { (info: UnsafeRawPointer) -> Void in + let unmanagedWeakifiedReachability = Unmanaged.fromOpaque(info) + unmanagedWeakifiedReachability.release() + }, + copyDescription: { (info: UnsafeRawPointer) -> Unmanaged in + let unmanagedWeakifiedReachability = Unmanaged.fromOpaque(info) + let weakifiedReachability = unmanagedWeakifiedReachability.takeUnretainedValue() + let description = weakifiedReachability.reachability?.description ?? "nil" + return Unmanaged.passRetained(description as CFString) + } + ) + + if !SCNetworkReachabilitySetCallback(reachabilityRef, callback, &context) { + stopNotifier() + throw ReachabilityError.unableToSetCallback(SCError()) + } + + if !SCNetworkReachabilitySetDispatchQueue(reachabilityRef, reachabilitySerialQueue) { + stopNotifier() + throw ReachabilityError.unableToSetDispatchQueue(SCError()) + } + + // Perform an initial check + try setReachabilityFlags() + + notifierRunning = true + } + + func stopNotifier() { + defer { notifierRunning = false } + + SCNetworkReachabilitySetCallback(reachabilityRef, nil, nil) + SCNetworkReachabilitySetDispatchQueue(reachabilityRef, nil) + } + + // MARK: - *** Connection test methods *** + @available(*, deprecated, message: "Please use `connection != .none`") + var isReachable: Bool { + return connection != .unavailable + } + + @available(*, deprecated, message: "Please use `connection == .cellular`") + var isReachableViaWWAN: Bool { + // Check we're not on the simulator, we're REACHABLE and check we're on WWAN + return connection == .cellular + } + + @available(*, deprecated, message: "Please use `connection == .wifi`") + var isReachableViaWiFi: Bool { + return connection == .wifi + } + + var description: String { + return flags?.description ?? "unavailable flags" + } +} + +fileprivate extension Reachability { + + func setReachabilityFlags() throws { + try reachabilitySerialQueue.sync { [unowned self] in + var flags = SCNetworkReachabilityFlags() + if !SCNetworkReachabilityGetFlags(self.reachabilityRef, &flags) { + self.stopNotifier() + throw ReachabilityError.unableToGetFlags(SCError()) + } + + self.flags = flags + } + } + + + func notifyReachabilityChanged() { + let notify = { [weak self] in + guard let self = self else { return } + self.connection != .unavailable ? self.whenReachable?(self) : self.whenUnreachable?(self) + self.notificationCenter.post(name: .reachabilityChanged, object: self) + } + + // notify on the configured `notificationQueue`, or the caller's (i.e. `reachabilitySerialQueue`) + notificationQueue?.async(execute: notify) ?? notify() + } +} + +extension SCNetworkReachabilityFlags { + + typealias Connection = Reachability.Connection + + var connection: Connection { + guard isReachableFlagSet else { return .unavailable } + + // If we're reachable, but not on an iOS device (i.e. simulator), we must be on WiFi + #if targetEnvironment(simulator) + return .wifi + #else + var connection = Connection.unavailable + + if !isConnectionRequiredFlagSet { + connection = .wifi + } + + if isConnectionOnTrafficOrDemandFlagSet { + if !isInterventionRequiredFlagSet { + connection = .wifi + } + } + + if isOnWWANFlagSet { + connection = .cellular + } + + return connection + #endif + } + + var isOnWWANFlagSet: Bool { + #if os(iOS) + return contains(.isWWAN) + #else + return false + #endif + } + var isReachableFlagSet: Bool { + return contains(.reachable) + } + var isConnectionRequiredFlagSet: Bool { + return contains(.connectionRequired) + } + var isInterventionRequiredFlagSet: Bool { + return contains(.interventionRequired) + } + var isConnectionOnTrafficFlagSet: Bool { + return contains(.connectionOnTraffic) + } + var isConnectionOnDemandFlagSet: Bool { + return contains(.connectionOnDemand) + } + var isConnectionOnTrafficOrDemandFlagSet: Bool { + return !intersection([.connectionOnTraffic, .connectionOnDemand]).isEmpty + } + var isTransientConnectionFlagSet: Bool { + return contains(.transientConnection) + } + var isLocalAddressFlagSet: Bool { + return contains(.isLocalAddress) + } + var isDirectFlagSet: Bool { + return contains(.isDirect) + } + var isConnectionRequiredAndTransientFlagSet: Bool { + return intersection([.connectionRequired, .transientConnection]) == [.connectionRequired, .transientConnection] + } + + var description: String { + let W = isOnWWANFlagSet ? "W" : "-" + let R = isReachableFlagSet ? "R" : "-" + let c = isConnectionRequiredFlagSet ? "c" : "-" + let t = isTransientConnectionFlagSet ? "t" : "-" + let i = isInterventionRequiredFlagSet ? "i" : "-" + let C = isConnectionOnTrafficFlagSet ? "C" : "-" + let D = isConnectionOnDemandFlagSet ? "D" : "-" + let l = isLocalAddressFlagSet ? "l" : "-" + let d = isDirectFlagSet ? "d" : "-" + + return "\(W)\(R) \(c)\(t)\(i)\(C)\(D)\(l)\(d)" + } +} + +/** + `ReachabilityWeakifier` weakly wraps the `Reachability` class + in order to break retain cycles when interacting with CoreFoundation. + + CoreFoundation callbacks expect a pair of retain/release whenever an + opaque `info` parameter is provided. These callbacks exist to guard + against memory management race conditions when invoking the callbacks. + + #### Race Condition + + If we passed `SCNetworkReachabilitySetCallback` a direct reference to our + `Reachability` class without also providing corresponding retain/release + callbacks, then a race condition can lead to crashes when: + - `Reachability` is deallocated on thread X + - A `SCNetworkReachability` callback(s) is already in flight on thread Y + + #### Retain Cycle + + If we pass `Reachability` to CoreFoundtion while also providing retain/ + release callbacks, we would create a retain cycle once CoreFoundation + retains our `Reachability` class. This fixes the crashes and his how + CoreFoundation expects the API to be used, but doesn't play nicely with + Swift/ARC. This cycle would only be broken after manually calling + `stopNotifier()` — `deinit` would never be called. + + #### ReachabilityWeakifier + + By providing both retain/release callbacks and wrapping `Reachability` in + a weak wrapper, we: + - interact correctly with CoreFoundation, thereby avoiding a crash. + See "Memory Management Programming Guide for Core Foundation". + - don't alter the public API of `Reachability.swift` in any way + - still allow for automatic stopping of the notifier on `deinit`. + */ +private class ReachabilityWeakifier { + weak var reachability: Reachability? + init(reachability: Reachability) { + self.reachability = reachability + } +} diff --git a/Web/Sources/Web/String+RSWeb.swift b/Web/Sources/Web/String+RSWeb.swift new file mode 100644 index 000000000..3bed1da06 --- /dev/null +++ b/Web/Sources/Web/String+RSWeb.swift @@ -0,0 +1,39 @@ +// +// String+RSWeb.swift +// RSWeb +// +// Created by Brent Simmons on 1/13/18. +// Copyright © 2018 Ranchero Software. All rights reserved. +// + +import Foundation + +public extension String { + + /// Escapes special HTML characters. + /// + /// Escaped characters are `&`, `<`, `>`, `"`, and `'`. + var escapedHTML: String { + var escaped = String() + + for char in self { + switch char { + case "&": + escaped.append("&") + case "<": + escaped.append("<") + case ">": + escaped.append(">") + case "\"": + escaped.append(""") + case "'": + escaped.append("'") + default: + escaped.append(char) + } + } + + return escaped + } + +} diff --git a/Web/Sources/Web/URL+RSWeb.swift b/Web/Sources/Web/URL+RSWeb.swift new file mode 100755 index 000000000..d9bc8790f --- /dev/null +++ b/Web/Sources/Web/URL+RSWeb.swift @@ -0,0 +1,90 @@ +// +// NSURL+RSWeb.swift +// RSWeb +// +// Created by Brent Simmons on 12/26/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +private struct URLConstants { + static let schemeHTTP = "http" + static let schemeHTTPS = "https" + static let prefixHTTP = "http://" + static let prefixHTTPS = "https://" +} + +public extension URL { + + func isHTTPSURL() -> Bool { + return self.scheme?.lowercased() == URLConstants.schemeHTTPS + } + + func isHTTPURL() -> Bool { + return self.scheme?.lowercased() == URLConstants.schemeHTTP + } + + func isHTTPOrHTTPSURL() -> Bool { + return self.isHTTPSURL() || self.isHTTPURL() + } + + func absoluteStringWithHTTPOrHTTPSPrefixRemoved() -> String? { + // Case-inensitive. Turns http://example.com/foo into example.com/foo + + if isHTTPSURL() { + return absoluteString.stringByRemovingCaseInsensitivePrefix(URLConstants.prefixHTTPS) + } + else if isHTTPURL() { + return absoluteString.stringByRemovingCaseInsensitivePrefix(URLConstants.prefixHTTP) + } + + return nil + } + + func appendingQueryItem(_ queryItem: URLQueryItem) -> URL? { + appendingQueryItems([queryItem]) + } + + func appendingQueryItems(_ queryItems: [URLQueryItem]) -> URL? { + guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { + return nil + } + + var newQueryItems = components.queryItems ?? [] + newQueryItems.append(contentsOf: queryItems) + components.queryItems = newQueryItems + + return components.url + } + + func preparedForOpeningInBrowser() -> URL? { + var urlString = absoluteString.replacingOccurrences(of: " ", with: "%20") + urlString = urlString.replacingOccurrences(of: "^", with: "%5E") + urlString = urlString.replacingOccurrences(of: "&", with: "&") + urlString = urlString.replacingOccurrences(of: "&", with: "&") + + return URL(string: urlString) + } + +} + +private extension String { + + func stringByRemovingCaseInsensitivePrefix(_ prefix: String) -> String { + // Returns self if it doesn’t have the given prefix. + + let lowerPrefix = prefix.lowercased() + let lowerSelf = self.lowercased() + + if (lowerSelf == lowerPrefix) { + return "" + } + if !lowerSelf.hasPrefix(lowerPrefix) { + return self + } + + let index = self.index(self.startIndex, offsetBy: prefix.count) + return String(self[.. Bool { + + // Do this *only* with https. And not even then if you can help it. + + let s = "\(username):\(password)" + guard let d = s.data(using: .utf8, allowLossyConversion: false) else { + return false + } + + let base64EncodedString = d.base64EncodedString() + let authorization = "Basic \(base64EncodedString)" + setValue(authorization, forHTTPHeaderField: HTTPRequestHeader.authorization) + + return true + } +} diff --git a/Web/Sources/Web/URLResponse+RSWeb.swift b/Web/Sources/Web/URLResponse+RSWeb.swift new file mode 100755 index 000000000..9e00b2cbb --- /dev/null +++ b/Web/Sources/Web/URLResponse+RSWeb.swift @@ -0,0 +1,45 @@ +// +// URLResponse+RSWeb.swift +// RSWeb +// +// Created by Brent Simmons on 8/14/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +public extension URLResponse { + + var statusIsOK: Bool { + return forcedStatusCode >= 200 && forcedStatusCode <= 299 + } + + var forcedStatusCode: Int { + + // Return actual statusCode or 0 if there isn’t one. + + if let response = self as? HTTPURLResponse { + return response.statusCode + } + return 0 + } +} + +public extension HTTPURLResponse { + + func valueForHTTPHeaderField(_ headerField: String) -> String? { + + // Case-insensitive. HTTP headers may not be in the case you expect. + + let lowerHeaderField = headerField.lowercased() + + for (key, value) in allHeaderFields { + + if lowerHeaderField == (key as? String)?.lowercased() { + return value as? String + } + } + + return nil + } +} diff --git a/Web/Sources/Web/UTS46/Data+Extensions.swift b/Web/Sources/Web/UTS46/Data+Extensions.swift new file mode 100644 index 000000000..140b6498b --- /dev/null +++ b/Web/Sources/Web/UTS46/Data+Extensions.swift @@ -0,0 +1,21 @@ +// +// Data+Extensions.swift +// PunyCocoa Swift +// +// Created by Nate Weaver on 2020-04-12. +// + +import Foundation +import zlib + +extension Data { + + var crc32: UInt32 { + return self.withUnsafeBytes { + let buffer = $0.bindMemory(to: UInt8.self) + let initial = zlib.crc32(0, nil, 0) + return UInt32(zlib.crc32(initial, buffer.baseAddress, numericCast(buffer.count))) + } + } + +} diff --git a/Web/Sources/Web/UTS46/Scanner+Extensions.swift b/Web/Sources/Web/UTS46/Scanner+Extensions.swift new file mode 100644 index 000000000..0ffb0c425 --- /dev/null +++ b/Web/Sources/Web/UTS46/Scanner+Extensions.swift @@ -0,0 +1,54 @@ +// +// Scanner+Extensions.swift +// PunyCocoa Swift +// +// Created by Nate Weaver on 2020-04-20. +// + +import Foundation + +// Wrapper functions for < 10.15 compatibility +// TODO: Remove when support for < 10.15 is dropped. +extension Scanner { + + func shimScanUpToCharacters(from set: CharacterSet) -> String? { + if #available(macOS 10.15, iOS 13.0, *) { + return self.scanUpToCharacters(from: set) + } else { + var str: NSString? + self.scanUpToCharacters(from: set, into: &str) + return str as String? + } + } + + func shimScanCharacters(from set: CharacterSet) -> String? { + if #available(macOS 10.15, iOS 13.0, *) { + return self.scanCharacters(from: set) + } else { + var str: NSString? + self.scanCharacters(from: set, into: &str) + return str as String? + } + } + + func shimScanUpToString(_ substring: String) -> String? { + if #available(macOS 10.15, iOS 13.0, *) { + return self.scanUpToString(substring) + } else { + var str: NSString? + self.scanUpTo(substring, into: &str) + return str as String? + } + } + + func shimScanString(_ searchString: String) -> String? { + if #available(macOS 10.15, iOS 13.0, *) { + return self.scanString(searchString) + } else { + var str: NSString? + self.scanString(searchString, into: &str) + return str as String? + } + } + +} diff --git a/Web/Sources/Web/UTS46/String+Punycode.swift b/Web/Sources/Web/UTS46/String+Punycode.swift new file mode 100644 index 000000000..a6afd15b0 --- /dev/null +++ b/Web/Sources/Web/UTS46/String+Punycode.swift @@ -0,0 +1,596 @@ +// +// String+Punycode.swift +// Punycode +// +// Created by Nate Weaver on 2020-03-16. +// + +import Foundation + +public extension String { + + /// The IDNA-encoded representation of a Unicode domain. + /// + /// This will properly split domains on periods; e.g., + /// "www.bücher.ch" becomes "www.xn--bcher-kva.ch". + var idnaEncoded: String? { + guard let mapped = try? self.mapUTS46() else { return nil } + + let nonASCII = CharacterSet(charactersIn: UnicodeScalar(0)...UnicodeScalar(127)).inverted + var result = "" + + let s = Scanner(string: mapped.precomposedStringWithCanonicalMapping) + let dotAt = CharacterSet(charactersIn: ".@") + + while !s.isAtEnd { + if let input = s.shimScanUpToCharacters(from: dotAt) { + if !input.isValidLabel { return nil } + + if input.rangeOfCharacter(from: nonASCII) != nil { + result.append("xn--") + + if let encoded = input.punycodeEncoded { + result.append(encoded) + } + } else { + result.append(input) + } + } + + if let input = s.shimScanCharacters(from: dotAt) { + result.append(input) + } + } + + return result + } + + /// The Unicode representation of an IDNA-encoded domain. + /// + /// This will properly split domains on periods; e.g., + /// "www.xn--bcher-kva.ch" becomes "www.bücher.ch". + var idnaDecoded: String? { + var result = "" + let s = Scanner(string: self) + let dotAt = CharacterSet(charactersIn: ".@") + + while !s.isAtEnd { + if let input = s.shimScanUpToCharacters(from: dotAt) { + if input.lowercased().hasPrefix("xn--") { + let start = input.index(input.startIndex, offsetBy: 4) + guard let substr = input[start...].punycodeDecoded else { return nil } + guard substr.isValidLabel else { return nil } + result.append(substr) + } else { + result.append(input) + } + } + + if let input = s.shimScanCharacters(from: dotAt) { + result.append(input) + } + } + + return result + } + + /// The IDNA- and percent-encoded representation of a URL string. + var encodedURLString: String? { + let urlParts = self.urlParts + var pathAndQuery = urlParts.pathAndQuery + + var allowedCharacters = CharacterSet.urlPathAllowed + allowedCharacters.insert(charactersIn: "%?") + pathAndQuery = pathAndQuery.addingPercentEncoding(withAllowedCharacters: allowedCharacters) ?? "" + + var result = "\(urlParts.scheme)\(urlParts.delim)" + + if let username = urlParts.username?.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) { + if let password = urlParts.password?.addingPercentEncoding(withAllowedCharacters: .urlPasswordAllowed) { + result.append("\(username):\(password)@") + } else { + result.append("\(username)@") + } + } + + guard let host = urlParts.host.idnaEncoded else { return nil } + + result.append("\(host)\(pathAndQuery)") + + if var fragment = urlParts.fragment { + var fragmentAlloweCharacters = CharacterSet.urlFragmentAllowed + fragmentAlloweCharacters.insert(charactersIn: "%") + fragment = fragment.addingPercentEncoding(withAllowedCharacters: fragmentAlloweCharacters) ?? "" + + result.append("#\(fragment)") + } + + return result + } + + /// The Unicode representation of an IDNA- and percent-encoded URL string. + var decodedURLString: String? { + let urlParts = self.urlParts + var usernamePassword = "" + + if let username = urlParts.username?.removingPercentEncoding { + if let password = urlParts.password?.removingPercentEncoding { + usernamePassword = "\(username):\(password)@" + } else { + usernamePassword = "\(username)@" + } + } + + guard let host = urlParts.host.idnaDecoded else { return nil } + + var result = "\(urlParts.scheme)\(urlParts.delim)\(usernamePassword)\(host)\(urlParts.pathAndQuery.removingPercentEncoding ?? "")" + + if let fragment = urlParts.fragment?.removingPercentEncoding { + result.append("#\(fragment)") + } + + return result + } + +} + +public extension URL { + + /// Initializes a URL with a Unicode URL string. + /// + /// If `unicodeString` can be successfully encoded, equivalent to + /// + /// ``` + /// URL(string: unicodeString.encodedURLString!) + /// ``` + /// + /// - Parameter unicodeString: The unicode URL string with which to create a URL. + init?(unicodeString: String) { + if let url = URL(string: unicodeString) { + self = url + return + } + + guard let encodedString = unicodeString.encodedURLString else { return nil } + self.init(string: encodedString) + } + + /// The IDNA- and percent-decoded representation of the URL. + /// + /// Equivalent to + /// + /// ``` + /// self.absoluteString.decodedURLString + /// ``` + var decodedURLString: String? { + return self.absoluteString.decodedURLString + } + + /// Initializes a URL from a relative Unicode string and a base URL. + /// - Parameters: + /// - unicodeString: The URL string with which to initialize the NSURL object. `unicodeString` is interpreted relative to `baseURL`. + /// - url: The base URL for the URL object + init?(unicodeString: String, relativeTo url: URL?) { + if let url = URL(string: unicodeString, relativeTo: url) { + self = url + return + } + + let parts = unicodeString.urlParts + + if !parts.host.isEmpty { + guard let encodedString = unicodeString.encodedURLString else { return nil } + self.init(string: encodedString, relativeTo: url) + } else { + var allowedCharacters = CharacterSet.urlPathAllowed + allowedCharacters.insert(charactersIn: "%?#") + guard let encoded = unicodeString.addingPercentEncoding(withAllowedCharacters: allowedCharacters) else { return nil } + self.init(string: encoded, relativeTo: url) + } + } + +} + +private extension StringProtocol { + + /// Punycode-encodes a string. + /// + /// Returns `nil` on error. + /// - Todo: Throw errors on failure instead of returning `nil`. + var punycodeEncoded: String? { + var result = "" + let scalars = self.unicodeScalars + let inputLength = scalars.count + + var n = Punycode.initialN + var delta: UInt32 = 0 + var outLen: UInt32 = 0 + var bias = Punycode.initialBias + + for scalar in scalars where scalar.isASCII { + result.unicodeScalars.append(scalar) + outLen += 1 + } + + let b: UInt32 = outLen + var h: UInt32 = outLen + + if b > 0 { + result.append(Punycode.delimiter) + } + + // Main encoding loop: + + while h < inputLength { + var m = UInt32.max + + for c in scalars { + if c.value >= n && c.value < m { + m = c.value + } + } + + if m - n > (UInt32.max - delta) / (h + 1) { + return nil // overflow + } + + delta += (m - n) * (h + 1) + n = m + + for c in scalars { + + if c.value < n { + delta += 1 + + if delta == 0 { + return nil // overflow + } + } + + if c.value == n { + var q = delta + var k = Punycode.base + + while true { + let t = k <= bias ? Punycode.tmin : + k >= bias + Punycode.tmax ? Punycode.tmax : k - bias + + if q < t { + break + } + + let encodedDigit = Punycode.encodeDigit(t + (q - t) % (Punycode.base - t), flag: false) + + result.unicodeScalars.append(UnicodeScalar(encodedDigit)!) + q = (q - t) / (Punycode.base - t) + + k += Punycode.base + } + + result.unicodeScalars.append(UnicodeScalar(Punycode.encodeDigit(q, flag: false))!) + bias = Punycode.adapt(delta: delta, numPoints: h + 1, firstTime: h == b) + delta = 0 + h += 1 + } + } + + delta += 1 + n += 1 + } + + return result + } + + /// Punycode-decodes a string. + /// + /// Returns `nil` on error. + /// - Todo: Throw errors on failure instead of returning `nil`. + var punycodeDecoded: String? { + var result = "" + let scalars = self.unicodeScalars + + let endIndex = scalars.endIndex + var n = Punycode.initialN + var outLen: UInt32 = 0 + var i: UInt32 = 0 + var bias = Punycode.initialBias + + var b = scalars.startIndex + + for j in scalars.indices { + if Character(self.unicodeScalars[j]) == Punycode.delimiter { + b = j + break + } + } + + for j in scalars.indices { + if j >= b { + break + } + + let scalar = scalars[j] + + if !scalar.isASCII { + return nil // bad input + } + + result.unicodeScalars.append(scalar) + outLen += 1 + + } + + var inPos = b > scalars.startIndex ? scalars.index(after: b) : scalars.startIndex + + while inPos < endIndex { + + var k = Punycode.base + var w: UInt32 = 1 + let oldi = i + + while true { + if inPos >= endIndex { + return nil // bad input + } + + let digit = Punycode.decodeDigit(scalars[inPos].value) + + inPos = scalars.index(after: inPos) + + if digit >= Punycode.base { return nil } // bad input + if digit > (UInt32.max - i) / w { return nil } // overflow + + i += digit * w + let t = k <= bias ? Punycode.tmin : + k >= bias + Punycode.tmax ? Punycode.tmax : k - bias + + if digit < t { + break + } + + if w > UInt32.max / (Punycode.base - t) { return nil } // overflow + + w *= Punycode.base - t + + k += Punycode.base + } + + bias = Punycode.adapt(delta: i - oldi, numPoints: outLen + 1, firstTime: oldi == 0) + + if i / (outLen + 1) > UInt32.max - n { return nil } // overflow + + n += i / (outLen + 1) + i %= outLen + 1 + + let index = result.unicodeScalars.index(result.unicodeScalars.startIndex, offsetBy: Int(i)) + result.unicodeScalars.insert(UnicodeScalar(n)!, at: index) + + outLen += 1 + i += 1 + } + + return result + } + +} + +private extension String { + + var urlParts: URLParts { + let colonSlash = CharacterSet(charactersIn: ":/") + let slashQuestion = CharacterSet(charactersIn: "/?") + let s = Scanner(string: self) + var scheme = "" + var delim = "" + var host = "" + var path = "" + var username: String? + var password: String? + var fragment: String? + + if let hostOrScheme = s.shimScanUpToCharacters(from: colonSlash) { + let maybeDelim = s.shimScanCharacters(from: colonSlash) ?? "" + + if maybeDelim.hasPrefix(":") { + delim = maybeDelim + scheme = hostOrScheme + host = s.shimScanUpToCharacters(from: slashQuestion) ?? "" + } else { + path.append(hostOrScheme) + path.append(maybeDelim) + } + } else if let maybeDelim = s.shimScanString("//") { + delim = maybeDelim + + if let maybeHost = s.shimScanUpToCharacters(from: slashQuestion) { + host = maybeHost + } + } + + path.append(s.shimScanUpToString("#") ?? "") + + if s.shimScanString("#") != nil { + fragment = s.shimScanUpToCharacters(from: .newlines) ?? "" + } + + let usernamePasswordHostPort = host.components(separatedBy: "@") + + switch usernamePasswordHostPort.count { + case 1: + host = usernamePasswordHostPort[0] + case 0: + break // error + default: + let usernamePassword = usernamePasswordHostPort[0].components(separatedBy: ":") + username = usernamePassword[0] + password = usernamePassword.count > 1 ? usernamePassword[1] : nil + host = usernamePasswordHostPort[1] + } + + return URLParts(scheme: scheme, delim: delim, host: host, pathAndQuery: path, username: username, password: password, fragment: fragment) + } + + enum UTS46MapError: Error { + /// A disallowed codepoint was found in the string. + case disallowedCodepoint(scalar: UnicodeScalar) + } + + /// Perform a single-pass mapping using UTS #46. + /// + /// - Returns: The mapped string. + /// - Throws: `UTS46Error`. + func mapUTS46() throws -> String { + try UTS46.loadIfNecessary() + + var result = "" + + for scalar in self.unicodeScalars { + if UTS46.disallowedCharacters.contains(scalar) { + throw UTS46MapError.disallowedCodepoint(scalar: scalar) + } + + if UTS46.ignoredCharacters.contains(scalar) { + continue + } + + if let mapped = UTS46.characterMap[scalar.value] { + result.append(mapped) + } else { + result.unicodeScalars.append(scalar) + } + } + + return result + } + + var isValidLabel: Bool { + guard self.precomposedStringWithCanonicalMapping.unicodeScalars.elementsEqual(self.unicodeScalars) else { return false } + + guard (try? self.mapUTS46()) != nil else { return false } + + if let category = self.unicodeScalars.first?.properties.generalCategory { + if category == .nonspacingMark || category == .spacingMark || category == .enclosingMark { return false } + } + + return self.hasValidJoiners + } + + /// Whether a string's joiners (if any) are valid according to IDNA 2008 ContextJ. + /// + /// See [RFC 5892, Appendix A.1 and A.2](https://tools.ietf.org/html/rfc5892#appendix-A). + var hasValidJoiners: Bool { + try! UTS46.loadIfNecessary() + + let scalars = self.unicodeScalars + + for index in scalars.indices { + let scalar = scalars[index] + + if scalar.value == 0x200C { // Zero-width non-joiner + if index == scalars.indices.first { return false } + + var subindex = scalars.index(before: index) + var previous = scalars[subindex] + + if previous.properties.canonicalCombiningClass == .virama { continue } + + while true { + guard let joiningType = UTS46.joiningTypes[previous.value] else { return false } + + if joiningType == .transparent { + if subindex == scalars.startIndex { + return false + } + + subindex = scalars.index(before: subindex) + previous = scalars[subindex] + } else if joiningType == .dual || joiningType == .left { + break + } else { + return false + } + } + + subindex = scalars.index(after: index) + var next = scalars[subindex] + + while true { + if subindex == scalars.endIndex { + return false + } + + guard let joiningType = UTS46.joiningTypes[next.value] else { return false } + + if joiningType == .transparent { + subindex = scalars.index(after: index) + next = scalars[subindex] + } else if joiningType == .right || joiningType == .dual { + break + } else { + return false + } + } + } else if scalar.value == 0x200D { // Zero-width joiner + if index == scalars.startIndex { return false } + + let subindex = scalars.index(before: index) + let previous = scalars[subindex] + + if previous.properties.canonicalCombiningClass != .virama { return false } + } + } + + return true + } + +} + +private enum Punycode { + static let base = UInt32(36) + static let tmin = UInt32(1) + static let tmax = UInt32(26) + static let skew = UInt32(38) + static let damp = UInt32(700) + static let initialBias = UInt32(72) + static let initialN = UInt32(0x80) + static let delimiter: Character = "-" + + static func decodeDigit(_ cp: UInt32) -> UInt32 { + return cp &- 48 < 10 ? cp &- 22 : cp &- 65 < 26 ? cp &- 65 : + cp &- 97 < 26 ? cp &- 97 : Self.base + } + + static func encodeDigit(_ d: UInt32, flag: Bool) -> UInt32 { + return d + 22 + 75 * UInt32(d < 26 ? 1 : 0) - ((flag ? 1 : 0) << 5) + } + + static let maxint = UInt32.max + + static func adapt(delta: UInt32, numPoints: UInt32, firstTime: Bool) -> UInt32 { + + var delta = delta + + delta = firstTime ? delta / Self.damp : delta >> 1 + delta += delta / numPoints + + var k: UInt32 = 0 + + while delta > ((Self.base - Self.tmin) * Self.tmax) / 2 { + delta /= Self.base - Self.tmin + k += Self.base + } + + return k + (Self.base - Self.tmin + 1) * delta / (delta + Self.skew) + } +} + +private struct URLParts { + var scheme: String + var delim: String + var host: String + var pathAndQuery: String + + var username: String? + var password: String? + var fragment: String? +} diff --git a/Web/Sources/Web/UTS46/UTS46+Loading.swift b/Web/Sources/Web/UTS46/UTS46+Loading.swift new file mode 100644 index 000000000..3c6f98144 --- /dev/null +++ b/Web/Sources/Web/UTS46/UTS46+Loading.swift @@ -0,0 +1,227 @@ +// +// UTS46+Loading.swift +// icumap2code +// +// Created by Nate Weaver on 2020-05-08. +// + +import Foundation +import Compression + +extension UTS46 { + + private static func parseHeader(from data: Data) throws -> Header? { + let headerData = data.prefix(8) + + guard headerData.count == 8 else { throw UTS46Error.badSize } + + return Header(rawValue: headerData) + } + + static func load(from url: URL) throws { + let fileData = try Data(contentsOf: url) + + guard let header = try? parseHeader(from: fileData) else { return } + + guard header.version == 1 else { throw UTS46Error.unknownVersion } + + let offset = header.dataOffset + + guard fileData.count > offset else { throw UTS46Error.badSize } + + let compressedData = fileData[offset...] + + guard let data = self.decompress(data: compressedData, algorithm: header.compression) else { + throw UTS46Error.decompressionError + } + + var index = 0 + + while index < data.count { + let marker = data[index] + + index += 1 + + switch marker { + case Marker.characterMap: + index = parseCharacterMap(from: data, start: index) + case Marker.ignoredCharacters: + index = parseIgnoredCharacters(from: data, start: index) + case Marker.disallowedCharacters: + index = parseDisallowedCharacters(from: data, start: index) + case Marker.joiningTypes: + index = parseJoiningTypes(from: data, start: index) + default: + throw UTS46Error.badMarker + } + } + + isLoaded = true + } + + static var bundle: Bundle { + #if SWIFT_PACKAGE + return Bundle.module + #else + return Bundle(for: Self.self) + #endif + } + + static func loadIfNecessary() throws { + guard !isLoaded else { return } + guard let url = Self.bundle.url(forResource: "uts46", withExtension: nil) else { throw CocoaError(.fileNoSuchFile) } + + try load(from: url) + } + + private static func decompress(data: Data, algorithm: CompressionAlgorithm?) -> Data? { + + guard let rawAlgorithm = algorithm?.rawAlgorithm else { return data } + + let capacity = 131_072 // 128 KB + let destinationBuffer = UnsafeMutablePointer.allocate(capacity: capacity) + + let decompressed = data.withUnsafeBytes { (rawBuffer) -> Data? in + let bound = rawBuffer.bindMemory(to: UInt8.self) + let decodedCount = compression_decode_buffer(destinationBuffer, capacity, bound.baseAddress!, rawBuffer.count, nil, rawAlgorithm) + + if decodedCount == 0 || decodedCount == capacity { + return nil + } + + return Data(bytes: destinationBuffer, count: decodedCount) + } + + return decompressed + } + + private static func parseCharacterMap(from data: Data, start: Int) -> Int { + characterMap.removeAll() + var index = start + + main: while index < data.count { + var accumulator = Data() + + while data[index] != Marker.sequenceTerminator { + if data[index] > Marker.min { break main } + + accumulator.append(data[index]) + index += 1 + } + + let str = String(data: accumulator, encoding: .utf8)! + + // FIXME: throw an error here. + guard str.count > 0 else { continue } + + let codepoint = str.unicodeScalars.first!.value + + characterMap[codepoint] = String(str.unicodeScalars.dropFirst()) + + index += 1 + } + + return index + } + + private static func parseRanges(from: String) -> [ClosedRange]? { + guard from.unicodeScalars.count % 2 == 0 else { return nil } + + var ranges = [ClosedRange]() + var first: UnicodeScalar? + + for (index, scalar) in from.unicodeScalars.enumerated() { + if index % 2 == 0 { + first = scalar + } else if let first = first { + ranges.append(first...scalar) + } + } + + return ranges + } + + static func parseCharacterSet(from data: Data, start: Int) -> (index: Int, charset: CharacterSet?) { + var index = start + var accumulator = Data() + + while index < data.count, data[index] < Marker.min { + accumulator.append(data[index]) + index += 1 + } + + let str = String(data: accumulator, encoding: .utf8)! + + guard let ranges = parseRanges(from: str) else { + return (index: index, charset: nil) + } + + var charset = CharacterSet() + + for range in ranges { + charset.insert(charactersIn: range) + } + + return (index: index, charset: charset) + } + + static func parseIgnoredCharacters(from data: Data, start: Int) -> Int { + let (index, charset) = parseCharacterSet(from: data, start: start) + + if let charset = charset { + ignoredCharacters = charset + } + + return index + } + + static func parseDisallowedCharacters(from data: Data, start: Int) -> Int { + let (index, charset) = parseCharacterSet(from: data, start: start) + + if let charset = charset { + disallowedCharacters = charset + } + + return index + } + + static func parseJoiningTypes(from data: Data, start: Int) -> Int { + var index = start + joiningTypes.removeAll() + + main: while index < data.count, data[index] < Marker.min { + var accumulator = Data() + + while index < data.count { + if data[index] > Marker.min { break main } + accumulator.append(data[index]) + + index += 1 + } + + let str = String(data: accumulator, encoding: .utf8)! + + var type: JoiningType? + var first: UnicodeScalar? + + for scalar in str.unicodeScalars { + if scalar.isASCII { + type = JoiningType(rawValue: Character(scalar)) + } else if let type = type { + if first == nil { + first = scalar + } else { + for value in first!.value...scalar.value { + joiningTypes[value] = type + } + + first = nil + } + } + } + } + + return index + } + +} diff --git a/Web/Sources/Web/UTS46/UTS46.swift b/Web/Sources/Web/UTS46/UTS46.swift new file mode 100644 index 000000000..fa7a2faaa --- /dev/null +++ b/Web/Sources/Web/UTS46/UTS46.swift @@ -0,0 +1,189 @@ +// +// UTS46.swift +// PunyCocoa Swift +// +// Created by Nate Weaver on 2020-03-29. +// + +import Foundation +import Compression + +/// UTS46 mapping. +/// +/// Storage file format. Codepoints are stored UTF-8-encoded. +/// +/// All multibyte integers are little-endian. +/// +/// Header: +/// +/// +--------------+---------+---------+---------+ +/// | 6 bytes | 1 byte | 1 byte | 4 bytes | +/// +--------------+---------+---------+---------+ +/// | magic number | version | flags | crc32 | +/// +--------------+---------+---------+---------+ +/// +/// - `magic number`: `"UTS#46"` (`0x55 0x54 0x53 0x23 0x34 0x36`). +/// - `version`: format version (1 byte; currently `0x01`). +/// - `flags`: Bitfield: +/// +/// +-----+-----+-----+-----+-----+-----+-----+-----+ +/// | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | +/// +-----+-----+-----+-----+-----+-----+-----+-----+ +/// | currently unused | crc | compression | +/// +-----+-----+-----+-----+-----+-----+-----+-----+ +/// +/// - `crc`: Contains a CRC32 of the data after the header. +/// - `compression`: compression mode of the data. +/// Currently identical to NSData's compression constants + 1: +/// +/// - 0: no compression +/// - 1: LZFSE +/// - 2: LZ4 +/// - 3: LZMA +/// - 4: ZLIB +/// +/// - `crc32`: CRC32 of the (possibly compressed) data. Implementations can skip +/// parsing this unless data integrity is an issue. +/// +/// The data section is a collection of data blocks of the format +/// +/// [marker][section data] ... +/// +/// Section data formats: +/// +/// If marker is `characterMap`: +/// +/// [codepoint][mapped-codepoint ...][null] ... +/// +/// If marker is `disallowedCharacters` or `ignoredCharacters`: +/// +/// [codepoint-range] ... +/// +/// If marker is `joiningTypes`: +/// +/// [type][[codepoint-range] ...] +/// +/// where `type` is one of `C`, `D`, `L`, `R`, or `T`. +/// +/// `codepoint-range`: two codepoints, marking the first and last codepoints of a +/// closed range. Single-codepoint ranges have the same start and end codepoint. +/// +class UTS46 { + + static var characterMap: [UInt32: String] = [:] + static var ignoredCharacters: CharacterSet = [] + static var disallowedCharacters: CharacterSet = [] + static var joiningTypes = [UInt32: JoiningType]() + + static var isLoaded = false + + enum Marker { + static let characterMap = UInt8.max + static let ignoredCharacters = UInt8.max - 1 + static let disallowedCharacters = UInt8.max - 2 + static let joiningTypes = UInt8.max - 3 + + static let min = UInt8.max - 10 // No valid UTF-8 byte can fall here. + + static let sequenceTerminator: UInt8 = 0 + } + + enum JoiningType: Character { + case causing = "C" + case dual = "D" + case right = "R" + case left = "L" + case transparent = "T" + } + + enum UTS46Error: Error { + case badSize + case compressionError + case decompressionError + case badMarker + case unknownVersion + } + + /// Identical values to `NSData.CompressionAlgorithm + 1`. + enum CompressionAlgorithm: UInt8 { + case none = 0 + case lzfse = 1 + case lz4 = 2 + case lzma = 3 + case zlib = 4 + + var rawAlgorithm: compression_algorithm? { + switch self { + case .lzfse: + return COMPRESSION_LZFSE + case .lz4: + return COMPRESSION_LZ4 + case .lzma: + return COMPRESSION_LZMA + case .zlib: + return COMPRESSION_ZLIB + default: + return nil + } + } + } + + struct Header: RawRepresentable, CustomDebugStringConvertible { + typealias RawValue = [UInt8] + + var rawValue: [UInt8] { + let value = Self.signature + [version, flags.rawValue] + assert(value.count == 8) + return value + } + + private static let compressionMask: UInt8 = 0x07 + private static let signature: [UInt8] = Array("UTS#46".utf8) + + private struct Flags: RawRepresentable { + var rawValue: UInt8 { + return (hasCRC ? hasCRCMask : 0) | compression.rawValue + } + + var hasCRC: Bool + var compression: CompressionAlgorithm + + private let hasCRCMask: UInt8 = 1 << 3 + private let compressionMask: UInt8 = 0x7 + + init(rawValue: UInt8) { + hasCRC = rawValue & hasCRCMask != 0 + let compressionBits = rawValue & compressionMask + + compression = CompressionAlgorithm(rawValue: compressionBits) ?? .none + } + + init(compression: CompressionAlgorithm = .none, hasCRC: Bool = false) { + self.compression = compression + self.hasCRC = hasCRC + } + } + + let version: UInt8 + private var flags: Flags + var hasCRC: Bool { flags.hasCRC } + var compression: CompressionAlgorithm { flags.compression } + var dataOffset: Int { 8 + (flags.hasCRC ? 4 : 0) } + + init?(rawValue: T) where T.Index == Int { + guard rawValue.count == 8 else { return nil } + guard rawValue.prefix(Self.signature.count).elementsEqual(Self.signature) else { return nil } + + version = rawValue[rawValue.index(rawValue.startIndex, offsetBy: 6)] + flags = Flags(rawValue: rawValue[rawValue.index(rawValue.startIndex, offsetBy: 7)]) + } + + init(compression: CompressionAlgorithm = .none, hasCRC: Bool = false) { + self.version = 1 + self.flags = Flags(compression: compression, hasCRC: hasCRC) + } + + var debugDescription: String { "has CRC: \(hasCRC); compression: \(String(describing: compression))" } + } + +} diff --git a/Web/Sources/Web/UTS46/uts46 b/Web/Sources/Web/UTS46/uts46 new file mode 100644 index 0000000000000000000000000000000000000000..101001987a758fd1755c9ed24fab6afa8bb16611 GIT binary patch literal 15368 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 String? { + + return Bundle.main.object(forInfoDictionaryKey: "UserAgent") as? String + } + + public static func headers() -> [AnyHashable: String]? { + + guard let userAgent = fromInfoPlist() else { + return nil + } + + return [HTTPRequestHeader.userAgent: userAgent] + } +} diff --git a/Web/Sources/Web/WebServices/Transport.swift b/Web/Sources/Web/WebServices/Transport.swift new file mode 100644 index 000000000..d9d78d8ca --- /dev/null +++ b/Web/Sources/Web/WebServices/Transport.swift @@ -0,0 +1,235 @@ +// +// Transport.swift +// RSWeb +// +// Created by Maurice Parker on 5/4/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// +// Inspired by: http://robnapier.net/a-mockery-of-protocols + +import Foundation + +public enum TransportError: LocalizedError { + case noData + case noURL + case suspended + case httpError(status: Int) + + public var errorDescription: String? { + switch self { + case .httpError(let status): + switch status { + case 400: + return NSLocalizedString("Bad Request", comment: "Bad Request") + case 401: + return NSLocalizedString("Unauthorized", comment: "Unauthorized") + case 402: + return NSLocalizedString("Payment Required", comment: "Payment Required") + case 403: + return NSLocalizedString("Forbidden", comment: "Forbidden") + case 404: + return NSLocalizedString("Not Found", comment: "Not Found") + case 405: + return NSLocalizedString("Method Not Allowed", comment: "Method Not Allowed") + case 406: + return NSLocalizedString("Not Acceptable", comment: "Not Acceptable") + case 407: + return NSLocalizedString("Proxy Authentication Required", comment: "Proxy Authentication Required") + case 408: + return NSLocalizedString("Request Timeout", comment: "Request Timeout") + case 409: + return NSLocalizedString("Conflict", comment: "Conflict") + case 410: + return NSLocalizedString("Gone", comment: "Gone") + case 411: + return NSLocalizedString("Length Required", comment: "Length Required") + case 412: + return NSLocalizedString("Precondition Failed", comment: "Precondition Failed") + case 413: + return NSLocalizedString("Payload Too Large", comment: "Payload Too Large") + case 414: + return NSLocalizedString("Request-URI Too Long", comment: "Request-URI Too Long") + case 415: + return NSLocalizedString("Unsupported Media Type", comment: "Unsupported Media Type") + case 416: + return NSLocalizedString("Requested Range Not Satisfiable", comment: "Requested Range Not Satisfiable") + case 417: + return NSLocalizedString("Expectation Failed", comment: "Expectation Failed") + case 418: + return NSLocalizedString("I'm a teapot", comment: "I'm a teapot") + case 421: + return NSLocalizedString("Misdirected Request", comment: "Misdirected Request") + case 422: + return NSLocalizedString("Unprocessable Entity", comment: "Unprocessable Entity") + case 423: + return NSLocalizedString("Locked", comment: "Locked") + case 424: + return NSLocalizedString("Failed Dependency", comment: "Failed Dependency") + case 426: + return NSLocalizedString("Upgrade Required", comment: "Upgrade Required") + case 428: + return NSLocalizedString("Precondition Required", comment: "Precondition Required") + case 429: + return NSLocalizedString("Too Many Requests", comment: "Too Many Requests") + case 431: + return NSLocalizedString("Request Header Fields Too Large", comment: "Request Header Fields Too Large") + case 444: + return NSLocalizedString("Connection Closed Without Response", comment: "Connection Closed Without Response") + case 451: + return NSLocalizedString("Unavailable For Legal Reasons", comment: "Unavailable For Legal Reasons") + case 499: + return NSLocalizedString("Client Closed Request", comment: "Client Closed Request") + case 500: + return NSLocalizedString("Internal Server Error", comment: "Internal Server Error") + case 501: + return NSLocalizedString("Not Implemented", comment: "Not Implemented") + case 502: + return NSLocalizedString("Bad Gateway", comment: "Bad Gateway") + case 503: + return NSLocalizedString("Service Unavailable", comment: "Service Unavailable") + case 504: + return NSLocalizedString("Gateway Timeout", comment: "Gateway Timeout") + case 505: + return NSLocalizedString("HTTP Version Not Supported", comment: "HTTP Version Not Supported") + case 506: + return NSLocalizedString("Variant Also Negotiates", comment: "Variant Also Negotiates") + case 507: + return NSLocalizedString("Insufficient Storage", comment: "Insufficient Storage") + case 508: + return NSLocalizedString("Loop Detected", comment: "Loop Detected") + case 510: + return NSLocalizedString("Not Extended", comment: "Not Extended") + case 511: + return NSLocalizedString("Network Authentication Required", comment: "Network Authentication Required") + case 599: + return NSLocalizedString("Network Connect Timeout Error", comment: "Network Connect Timeout Error") + default: + let msg = NSLocalizedString("HTTP Status: ", comment: "Unexpected error") + return "\(msg) \(status)" + } + default: + return NSLocalizedString("An unknown network error occurred.", comment: "Unknown error") + } + } + +} + +public protocol Transport { + + /// Cancels all pending requests + func cancelAll() + + /// Sends URLRequest and returns the HTTP headers and the data payload. + func send(request: URLRequest, completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> Void) + + /// Sends URLRequest that doesn't require any result information. + func send(request: URLRequest, method: String, completion: @escaping (Result) -> Void) + + /// Sends URLRequest with a data payload and returns the HTTP headers and the data payload. + func send(request: URLRequest, method: String, payload: Data, completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> Void) + +} + +extension URLSession: Transport { + + public func cancelAll() { + getTasksWithCompletionHandler { dataTasks, uploadTasks, downloadTasks in + dataTasks.forEach { $0.cancel() } + uploadTasks.forEach { $0.cancel() } + downloadTasks.forEach { $0.cancel() } + } + } + + public func send(request: URLRequest, completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> Void) { + let task = self.dataTask(with: request) { (data, response, error) in + DispatchQueue.main.async { + if let error = error { + return completion(.failure(error)) + } + + guard let response = response as? HTTPURLResponse, let data = data else { + return completion(.failure(TransportError.noData)) + } + + switch response.forcedStatusCode { + case 200...399: + completion(.success((response, data))) + default: + completion(.failure(TransportError.httpError(status: response.forcedStatusCode))) + } + } + } + task.resume() + } + + public func send(request: URLRequest, method: String, completion: @escaping (Result) -> Void) { + + var sendRequest = request + sendRequest.httpMethod = method + + let task = self.dataTask(with: sendRequest) { (data, response, error) in + DispatchQueue.main.async { + if let error = error { + return completion(.failure(error)) + } + + guard let response = response as? HTTPURLResponse else { + return completion(.failure(TransportError.noData)) + } + + switch response.forcedStatusCode { + case 200...399: + completion(.success(())) + default: + completion(.failure(TransportError.httpError(status: response.forcedStatusCode))) + } + } + } + task.resume() + } + + public func send(request: URLRequest, method: String, payload: Data, completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> Void) { + + var sendRequest = request + sendRequest.httpMethod = method + + let task = self.uploadTask(with: sendRequest, from: payload) { (data, response, error) in + DispatchQueue.main.async { + if let error = error { + return completion(.failure(error)) + } + + guard let response = response as? HTTPURLResponse, let data = data else { + return completion(.failure(TransportError.noData)) + } + + switch response.forcedStatusCode { + case 200...399: + completion(.success((response, data))) + default: + completion(.failure(TransportError.httpError(status: response.forcedStatusCode))) + } + + } + } + task.resume() + } + + public static func webserviceTransport() -> Transport { + + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData + sessionConfiguration.timeoutIntervalForRequest = 60.0 + sessionConfiguration.httpShouldSetCookies = false + sessionConfiguration.httpCookieAcceptPolicy = .never + sessionConfiguration.httpMaximumConnectionsPerHost = 2 + sessionConfiguration.httpCookieStorage = nil + sessionConfiguration.urlCache = nil + + if let userAgentHeaders = UserAgent.headers() { + sessionConfiguration.httpAdditionalHeaders = userAgentHeaders + } + + return URLSession(configuration: sessionConfiguration) + } +} diff --git a/Web/Sources/Web/WebServices/TransportJSON.swift b/Web/Sources/Web/WebServices/TransportJSON.swift new file mode 100644 index 000000000..ff8dee89f --- /dev/null +++ b/Web/Sources/Web/WebServices/TransportJSON.swift @@ -0,0 +1,153 @@ +// +// JSONTransport.swift +// RSWeb +// +// Created by Maurice Parker on 5/6/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import Foundation + +extension Transport { + + /** + Sends an HTTP get and returns JSON object(s) + */ + public func send(request: URLRequest, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void) { + + send(request: request) { result in + DispatchQueue.main.async { + + switch result { + case .success(let (response, data)): + if let data = data, !data.isEmpty { + // PBS 27 Sep. 2019: decode the JSON on a background thread. + // The profiler says that this is 45% of what’s happening on the main thread + // during an initial sync with Feedbin. + DispatchQueue.global(qos: .background).async { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = dateDecoding + decoder.keyDecodingStrategy = keyDecoding + do { + let decoded = try decoder.decode(R.self, from: data) + DispatchQueue.main.async { + completion(.success((response, decoded))) + } + } + catch { + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + } + else { + completion(.success((response, nil))) + } + + case .failure(let error): + completion(.failure(error)) + } + } + } + } + + /** + Sends the specified HTTP method with a JSON payload. + */ + public func send(request: URLRequest, method: String, payload: P, completion: @escaping (Result) -> Void) { + + var postRequest = request + postRequest.addValue("application/json; charset=utf-8", forHTTPHeaderField: HTTPRequestHeader.contentType) + + let data: Data + do { + data = try JSONEncoder().encode(payload) + } catch { + completion(.failure(error)) + return + } + + send(request: postRequest, method: method, payload: data) { result in + DispatchQueue.main.async { + switch result { + case .success((_, _)): + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } + } + + /** + Sends the specified HTTP method with a JSON payload and returns JSON object(s). + */ + public func send(request: URLRequest, method: String, payload: P, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void) { + + var postRequest = request + postRequest.addValue("application/json; charset=utf-8", forHTTPHeaderField: HTTPRequestHeader.contentType) + + let data: Data + do { + data = try JSONEncoder().encode(payload) + } catch { + completion(.failure(error)) + return + } + + send(request: postRequest, method: method, payload: data) { result in + DispatchQueue.main.async { + + switch result { + case .success(let (response, data)): + do { + if let data = data, !data.isEmpty { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = dateDecoding + decoder.keyDecodingStrategy = keyDecoding + let decoded = try decoder.decode(R.self, from: data) + completion(.success((response, decoded))) + } else { + completion(.success((response, nil))) + } + } catch { + completion(.failure(error)) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + } + + /** + Sends the specified HTTP method with a Raw payload and returns JSON object(s). + */ + public func send(request: URLRequest, method: String, data: Data, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void) { + + send(request: request, method: method, payload: data) { result in + DispatchQueue.main.async { + + switch result { + case .success(let (response, data)): + do { + if let data = data, !data.isEmpty { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = dateDecoding + decoder.keyDecodingStrategy = keyDecoding + let decoded = try decoder.decode(R.self, from: data) + completion(.success((response, decoded))) + } else { + completion(.success((response, nil))) + } + } catch { + completion(.failure(error)) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + } +} diff --git a/Web/Tests/WebTests/WebTests.swift b/Web/Tests/WebTests/WebTests.swift new file mode 100644 index 000000000..9339ad47d --- /dev/null +++ b/Web/Tests/WebTests/WebTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import Web + +final class WebTests: 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/Widget/Shared Views/ArticleItemView.swift b/Widget/Shared Views/ArticleItemView.swift index ff922155e..6261154d4 100644 --- a/Widget/Shared Views/ArticleItemView.swift +++ b/Widget/Shared Views/ArticleItemView.swift @@ -7,7 +7,7 @@ // import SwiftUI -import RSWeb +import Web struct ArticleItemView: View { diff --git a/iOS/Account/FeedbinAccountViewController.swift b/iOS/Account/FeedbinAccountViewController.swift index 85c560f1c..baa3ee095 100644 --- a/iOS/Account/FeedbinAccountViewController.swift +++ b/iOS/Account/FeedbinAccountViewController.swift @@ -9,7 +9,7 @@ import UIKit import Account import Secrets -import RSWeb +import Web import SafariServices class FeedbinAccountViewController: UITableViewController { diff --git a/iOS/Account/NewsBlurAccountViewController.swift b/iOS/Account/NewsBlurAccountViewController.swift index 33dfb5450..9a4e51d0e 100644 --- a/iOS/Account/NewsBlurAccountViewController.swift +++ b/iOS/Account/NewsBlurAccountViewController.swift @@ -9,7 +9,7 @@ import UIKit import Account import Secrets -import RSWeb +import Web import SafariServices class NewsBlurAccountViewController: UITableViewController { diff --git a/iOS/Account/ReaderAPIAccountViewController.swift b/iOS/Account/ReaderAPIAccountViewController.swift index fa1847bca..574951309 100644 --- a/iOS/Account/ReaderAPIAccountViewController.swift +++ b/iOS/Account/ReaderAPIAccountViewController.swift @@ -9,7 +9,7 @@ import UIKit import Account import Secrets -import RSWeb +import Web import SafariServices class ReaderAPIAccountViewController: UITableViewController { diff --git a/iOS/AppDelegate.swift b/iOS/AppDelegate.swift index a7b0bf016..119fbfaa7 100644 --- a/iOS/AppDelegate.swift +++ b/iOS/AppDelegate.swift @@ -7,7 +7,7 @@ // import UIKit -import RSWeb +import Web import Account import BackgroundTasks import os.log