Move RSWeb from remote to local project; rename as Web.

This commit is contained in:
Brent Simmons 2024-04-01 19:31:57 -07:00
parent 4a4ece71f9
commit e56b1513b6
100 changed files with 3372 additions and 129 deletions

View File

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

View File

@ -15,7 +15,7 @@ import Articles
import RSParser
import Database
import ArticlesDatabase
import RSWeb
import Web
import os.log
import Secrets
import Core

View File

@ -8,7 +8,7 @@
import Foundation
import Articles
import RSWeb
import Web
import Secrets
@MainActor protocol AccountDelegate {

View File

@ -7,7 +7,7 @@
//
import Foundation
import RSWeb
import Web
public enum AccountError: LocalizedError {

View File

@ -7,7 +7,7 @@
//
import Foundation
import RSWeb
import Web
import Articles
import ArticlesDatabase
import Database

View File

@ -7,7 +7,7 @@
//
import Foundation
import RSWeb
import Web
protocol AccountMetadataDelegate: AnyObject {
func valueDidChange(_ accountMetadata: AccountMetadata, key: AccountMetadata.CodingKeys)

View File

@ -14,7 +14,7 @@ import SyncDatabase
import RSParser
import Articles
import ArticlesDatabase
import RSWeb
import Web
import Secrets
import Core
import CloudKitExtras

View File

@ -8,7 +8,7 @@
import Foundation
import os.log
import RSWeb
import Web
import RSParser
import CloudKit
import FoundationExtras

View File

@ -8,7 +8,7 @@
import Foundation
import os.log
import RSWeb
import Web
import CloudKit
import Articles
import CloudKitExtras

View File

@ -9,7 +9,7 @@
import Foundation
import os.log
import RSParser
import RSWeb
import Web
import CloudKit
import Articles
import SyncDatabase

View File

@ -9,7 +9,7 @@
import Foundation
import os.log
import RSParser
import RSWeb
import Web
import CloudKit
import SyncDatabase
import Articles

View File

@ -9,7 +9,7 @@
import Foundation
import Articles
import os.log
import RSWeb
import Web
import SyncDatabase
import Database
import Core

View File

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

View File

@ -7,7 +7,7 @@
//
import Foundation
import RSWeb
import Web
import Articles
import Core

View File

@ -8,7 +8,7 @@
import Foundation
import RSParser
import RSWeb
import Web
class FeedFinder {

View File

@ -7,7 +7,7 @@
//
import Foundation
import RSWeb
import Web
import Articles
protocol FeedMetadataDelegate: AnyObject {

View File

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

View File

@ -9,7 +9,7 @@
import Articles
import Database
import RSParser
import RSWeb
import Web
import SyncDatabase
import os.log
import Secrets

View File

@ -7,7 +7,7 @@
//
import Foundation
import RSWeb
import Web
import Secrets
protocol FeedlyAPICallerDelegate: AnyObject {

View File

@ -7,7 +7,7 @@
//
import Foundation
import RSWeb
import Web
import Secrets
/// Models the access token response from Feedly.

View File

@ -8,7 +8,7 @@
import Articles
import RSParser
import RSWeb
import Web
import SyncDatabase
import os.log
import Secrets

View File

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

View File

@ -7,7 +7,7 @@
//
import Foundation
import RSWeb
import Web
import Secrets
/// Client-specific information for requesting an authorization code grant.

View File

@ -8,7 +8,7 @@
import Foundation
import os.log
import RSWeb
import Web
import Secrets
import Core

View File

@ -9,7 +9,7 @@
import Foundation
import os.log
import SyncDatabase
import RSWeb
import Web
import Secrets
import Core

View File

@ -8,7 +8,7 @@
import Foundation
import os.log
import RSWeb
import Web
import Core
class FeedlyDownloadArticlesOperation: FeedlyOperation {

View File

@ -7,7 +7,7 @@
//
import Foundation
import RSWeb
import Web
import Core
protocol FeedlyOperationDelegate: AnyObject {

View File

@ -8,7 +8,7 @@
import Foundation
import os.log
import RSWeb
import Web
import Secrets
final class FeedlyRefreshAccessTokenOperation: FeedlyOperation {

View File

@ -9,7 +9,7 @@
import Foundation
import os.log
import SyncDatabase
import RSWeb
import Web
import Secrets
import Core

View File

@ -9,7 +9,7 @@
import Foundation
import os.log
import RSParser
import RSWeb
import Web
import Secrets
import Core

View File

@ -8,7 +8,7 @@
import Foundation
import RSParser
import RSWeb
import Web
struct InitialFeedDownloader {

View File

@ -11,7 +11,7 @@ import os.log
import RSParser
import Articles
import ArticlesDatabase
import RSWeb
import Web
import Secrets
import Core

View File

@ -8,7 +8,7 @@
import Foundation
import RSParser
import RSWeb
import Web
import Articles
import ArticlesDatabase
import FoundationExtras

View File

@ -7,7 +7,7 @@
//
import Foundation
import RSWeb
import Web
protocol NewsBlurDataConvertible {
var asData: Data? { get }

View File

@ -10,7 +10,7 @@
import Articles
import Database
import RSParser
import RSWeb
import Web
import SyncDatabase
import os.log
import Core

View File

@ -7,7 +7,7 @@
//
import Foundation
import RSWeb
import Web
import Secrets
final class NewsBlurAPICaller: NSObject {

View File

@ -9,7 +9,7 @@
import Articles
import Database
@preconcurrency import RSParser
import RSWeb
import Web
import SyncDatabase
import os.log
import Secrets

View File

@ -8,7 +8,7 @@
import Articles
import RSParser
import RSWeb
import Web
import SyncDatabase
import os.log
import Secrets

View File

@ -7,7 +7,7 @@
//
import Foundation
import RSWeb
import Web
import Secrets
enum CreateReaderAPISubscriptionResult {

View File

@ -7,7 +7,7 @@
//
import Foundation
import RSWeb
import Web
import Secrets
public extension URLRequest {

View File

@ -7,7 +7,7 @@
//
import XCTest
import RSWeb
import Web
@testable import Account
import Secrets

View File

@ -8,7 +8,7 @@
import XCTest
@testable import Account
import RSWeb
import Web
class FeedlyOperationTests: XCTestCase {

View File

@ -8,7 +8,7 @@
import XCTest
@testable import Account
import RSWeb
import Web
import Secrets
class FeedlyRefreshAccessTokenOperationTests: XCTestCase {

View File

@ -7,7 +7,7 @@
//
import Foundation
import RSWeb
import Web
@testable import Account

View File

@ -7,7 +7,7 @@
//
import Foundation
import RSWeb
import Web
import XCTest
protocol TestTransportMockResponseProviding: AnyObject {

View File

@ -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: [

View File

@ -10,7 +10,7 @@ import AppKit
import UserNotifications
import Articles
import Tree
import RSWeb
import Web
import Account
import CoreResources
import Secrets

View File

@ -7,7 +7,7 @@
//
import Foundation
import RSWeb
import Web
struct Browser {

View File

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

View File

@ -9,7 +9,7 @@
import Foundation
import WebKit
import Articles
import RSWeb
import Web
enum DetailState: Equatable {
case noSelection

View File

@ -8,7 +8,7 @@
import AppKit
import WebKit
import RSWeb
import Web
import Articles
import Core

View File

@ -8,7 +8,7 @@
import AppKit
import Articles
import RSWeb
import Web
import Account
import Core

View File

@ -8,7 +8,7 @@
import AppKit
import Account
import RSWeb
import Web
import Secrets
class AccountsFeedbinWindowController: NSWindowController {

View File

@ -8,7 +8,7 @@
import AppKit
import Account
import RSWeb
import Web
import Secrets
class AccountsNewsBlurWindowController: NSWindowController {

View File

@ -8,7 +8,7 @@
import AppKit
import Account
import RSWeb
import Web
import Secrets
class AccountsReaderAPIWindowController: NSWindowController {

View File

@ -7,7 +7,7 @@
//
import AppKit
import RSWeb
import Web
import UserNotifications
import UniformTypeIdentifiers

View File

@ -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 = "<group>"; };
849EE70E203919360082A1EA /* AppAssets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAssets.swift; sourceTree = "<group>"; };
849EE72020391F560082A1EA /* SharingServicePickerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharingServicePickerDelegate.swift; sourceTree = "<group>"; };
849FEDC32BBB225E0053FB21 /* Web */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Web; sourceTree = "<group>"; };
84A14FF220048CA70046AD9A /* SendToMicroBlogCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendToMicroBlogCommand.swift; sourceTree = "<group>"; };
84A1500420048DDF0046AD9A /* SendToMarsEditCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendToMarsEditCommand.swift; sourceTree = "<group>"; };
84A37CB4201ECD610087C5AF /* RenameWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RenameWindowController.swift; sourceTree = "<group>"; };
@ -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;

View File

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

View File

@ -8,7 +8,7 @@
import Foundation
import os.log
import RSWeb
import Web
struct CacheCleaner {

View File

@ -8,7 +8,7 @@
import Foundation
import os.log
import RSWeb
import Web
import FoundationExtras
import Core

View File

@ -7,7 +7,7 @@
//
import Foundation
import RSWeb
import Web
import RSParser
struct HTMLMetadataDownloader {

View File

@ -9,7 +9,7 @@
import Foundation
import Articles
import Account
import RSWeb
import Web
import RSParser
import Core

View File

@ -8,7 +8,7 @@
import Foundation
import os.log
import RSWeb
import Web
import FoundationExtras
import Core

8
Web/.gitignore vendored Normal file
View File

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

31
Web/Package.swift Normal file
View File

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

View File

@ -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&param2=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
}
}

View File

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

View File

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

View File

@ -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<URLSessionTask>()
private var tasksPending = Set<URLSessionTask>()
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<String>()
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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

55
Web/Sources/Web/MimeType.swift Executable file
View File

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

View File

@ -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. Its 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 dont 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 dont 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<URL>()
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)
}

View File

@ -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<sockaddr>.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<ReachabilityWeakifier>.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<ReachabilityWeakifier>.passUnretained(weakifiedReachability).toOpaque()
var context = SCNetworkReachabilityContext(
version: 0,
info: UnsafeMutableRawPointer(opaqueWeakifiedReachability),
retain: { (info: UnsafeRawPointer) -> UnsafeRawPointer in
let unmanagedWeakifiedReachability = Unmanaged<ReachabilityWeakifier>.fromOpaque(info)
_ = unmanagedWeakifiedReachability.retain()
return UnsafeRawPointer(unmanagedWeakifiedReachability.toOpaque())
},
release: { (info: UnsafeRawPointer) -> Void in
let unmanagedWeakifiedReachability = Unmanaged<ReachabilityWeakifier>.fromOpaque(info)
unmanagedWeakifiedReachability.release()
},
copyDescription: { (info: UnsafeRawPointer) -> Unmanaged<CFString> in
let unmanagedWeakifiedReachability = Unmanaged<ReachabilityWeakifier>.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
}
}

View File

@ -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("&amp;")
case "<":
escaped.append("&lt;")
case ">":
escaped.append("&gt;")
case "\"":
escaped.append("&quot;")
case "'":
escaped.append("&apos;")
default:
escaped.append(char)
}
}
return escaped
}
}

90
Web/Sources/Web/URL+RSWeb.swift Executable file
View File

@ -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: "&amp;", with: "&")
urlString = urlString.replacingOccurrences(of: "&#38;", with: "&")
return URL(string: urlString)
}
}
private extension String {
func stringByRemovingCaseInsensitivePrefix(_ prefix: String) -> String {
// Returns self if it doesnt 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[..<index])
}
}

View File

@ -0,0 +1,34 @@
//
// URLComponents.swift
//
//
// Created by Maurice Parker on 11/8/20.
//
import Foundation
public extension URLComponents {
// `+` is a valid character in query component as per RFC 3986 (https://developer.apple.com/documentation/foundation/nsurlcomponents/1407752-queryitems)
// workaround:
// - http://www.openradar.me/24076063
// - https://stackoverflow.com/a/37314144
var enhancedPercentEncodedQuery: String? {
guard !(queryItems?.isEmpty ?? true) else {
return nil
}
var allowedCharacters = CharacterSet.urlQueryAllowed
allowedCharacters.remove(charactersIn: "!*'();:@&=+$,/?%#[]")
var queries = [String]()
for queryItem in queryItems! {
if let value = queryItem.value?.addingPercentEncoding(withAllowedCharacters: allowedCharacters)?.replacingOccurrences(of: "%20", with: "+") {
queries.append("\(queryItem.name)=\(value)")
}
}
return queries.joined(separator: "&")
}
}

View File

@ -0,0 +1,28 @@
//
// NSMutableURLRequest+RSWeb.swift
// RSWeb
//
// Created by Brent Simmons on 12/27/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
public extension URLRequest {
@discardableResult mutating func addBasicAuthorization(username: String, password: String) -> 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
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<UInt8>.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<UnicodeScalar>]? {
guard from.unicodeScalars.count % 2 == 0 else { return nil }
var ranges = [ClosedRange<UnicodeScalar>]()
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
}
}

View File

@ -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?<T: DataProtocol>(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))" }
}
}

BIN
Web/Sources/Web/UTS46/uts46 Normal file

Binary file not shown.

26
Web/Sources/Web/UserAgent.swift Executable file
View File

@ -0,0 +1,26 @@
//
// UserAgent.swift
// RSWeb
//
// Created by Brent Simmons on 8/27/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
public struct UserAgent {
public static func fromInfoPlist() -> 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]
}
}

View File

@ -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, Error>) -> 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, Error>) -> 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)
}
}

View File

@ -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<R: Decodable>(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 whats 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<P: Encodable>(request: URLRequest, method: String, payload: P, completion: @escaping (Result<Void, 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((_, _)):
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<P: Encodable, R: Decodable>(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<R: Decodable>(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))
}
}
}
}
}

View File

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

View File

@ -7,7 +7,7 @@
//
import SwiftUI
import RSWeb
import Web
struct ArticleItemView: View {

View File

@ -9,7 +9,7 @@
import UIKit
import Account
import Secrets
import RSWeb
import Web
import SafariServices
class FeedbinAccountViewController: UITableViewController {

View File

@ -9,7 +9,7 @@
import UIKit
import Account
import Secrets
import RSWeb
import Web
import SafariServices
class NewsBlurAccountViewController: UITableViewController {

View File

@ -9,7 +9,7 @@
import UIKit
import Account
import Secrets
import RSWeb
import Web
import SafariServices
class ReaderAPIAccountViewController: UITableViewController {

View File

@ -7,7 +7,7 @@
//
import UIKit
import RSWeb
import Web
import Account
import BackgroundTasks
import os.log