diff --git a/Account/Package.swift b/Account/Package.swift index 2b94423a7..30b2ae389 100644 --- a/Account/Package.swift +++ b/Account/Package.swift @@ -1,39 +1,26 @@ -// swift-tools-version: 5.9 +// swift-tools-version:5.9 + import PackageDescription -var dependencies: [Package.Dependency] = [ - .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMinor(from: "1.0.0")), - .package(url: "https://github.com/Ranchero-Software/RSDatabase.git", .upToNextMajor(from: "1.0.0")), - .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")), -] - -#if swift(>=5.6) -dependencies.append(contentsOf: [ - .package(path: "../Articles"), - .package(path: "../ArticlesDatabase"), - .package(path: "../Secrets"), - .package(path: "../SyncDatabase"), -]) -#else -dependencies.append(contentsOf: [ - .package(url: "../Articles", .upToNextMajor(from: "1.0.0")), - .package(url: "../ArticlesDatabase", .upToNextMajor(from: "1.0.0")), - .package(url: "../Secrets", .upToNextMajor(from: "1.0.0")), - .package(url: "../SyncDatabase", .upToNextMajor(from: "1.0.0")), -]) -#endif - let package = Package( name: "Account", - platforms: [.macOS(SupportedPlatform.MacOSVersion.v10_15), .iOS(SupportedPlatform.IOSVersion.v13)], + platforms: [.macOS(.v14), .iOS(.v17)], products: [ .library( name: "Account", type: .dynamic, targets: ["Account"]), ], - dependencies: dependencies, + dependencies: [ + .package(path: "../RSWeb"), + .package(path: "../Articles"), + .package(path: "../ArticlesDatabase"), + .package(path: "../Secrets"), + .package(path: "../SyncDatabase"), + .package(path: "../RSCore"), + .package(url: "https://github.com/Ranchero-Software/RSDatabase.git", .upToNextMajor(from: "1.0.0")), + .package(url: "https://github.com/Ranchero-Software/RSParser.git", .upToNextMajor(from: "2.0.2")), + ], targets: [ .target( name: "Account", diff --git a/Articles/Package.swift b/Articles/Package.swift index 32a1caa64..f95ad218b 100644 --- a/Articles/Package.swift +++ b/Articles/Package.swift @@ -1,9 +1,10 @@ -// swift-tools-version: 5.9 +// swift-tools-version:5.9 + import PackageDescription let package = Package( name: "Articles", - platforms: [.macOS(SupportedPlatform.MacOSVersion.v10_15), .iOS(SupportedPlatform.IOSVersion.v13)], + platforms: [.macOS(.v14), .iOS(.v17)], products: [ .library( name: "Articles", @@ -11,7 +12,7 @@ let package = Package( targets: ["Articles"]), ], dependencies: [ - .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMinor(from: "1.0.0")), + .package(path: "../RSCore"), ], targets: [ .target( diff --git a/ArticlesDatabase/Package.swift b/ArticlesDatabase/Package.swift index 112a4633d..b4d723921 100644 --- a/ArticlesDatabase/Package.swift +++ b/ArticlesDatabase/Package.swift @@ -1,41 +1,30 @@ -// swift-tools-version: 5.9 +// swift-tools-version:5.9 import PackageDescription -var dependencies: [Package.Dependency] = [ - .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMinor(from: "1.0.0")), - .package(url: "https://github.com/Ranchero-Software/RSDatabase.git", .upToNextMajor(from: "1.0.0")), - .package(url: "https://github.com/Ranchero-Software/RSParser.git", .upToNextMajor(from: "2.0.2")), -] - -#if swift(>=5.6) -dependencies.append(contentsOf: [ - .package(path: "../Articles"), -]) -#else -dependencies.append(contentsOf: [ - .package(url: "../Articles", .upToNextMajor(from: "1.0.0")), -]) -#endif - let package = Package( - name: "ArticlesDatabase", - platforms: [.macOS(SupportedPlatform.MacOSVersion.v10_15), .iOS(SupportedPlatform.IOSVersion.v13)], - products: [ - .library( - name: "ArticlesDatabase", + name: "ArticlesDatabase", + platforms: [.macOS(.v14), .iOS(.v17)], + products: [ + .library( + name: "ArticlesDatabase", type: .dynamic, - targets: ["ArticlesDatabase"]), - ], - dependencies: dependencies, - targets: [ - .target( - name: "ArticlesDatabase", - dependencies: [ + targets: ["ArticlesDatabase"]), + ], + dependencies: [ + .package(url: "https://github.com/Ranchero-Software/RSDatabase.git", .upToNextMajor(from: "1.0.0")), + .package(url: "https://github.com/Ranchero-Software/RSParser.git", .upToNextMajor(from: "2.0.2")), + .package(path: "../RSCore"), + .package(path: "../Articles"), + ], + targets: [ + .target( + name: "ArticlesDatabase", + dependencies: [ "RSCore", "RSDatabase", "RSParser", "Articles", ]), - ] + ] ) diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 7760988b9..6d77b11cb 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -50,8 +50,6 @@ 17D643B126F8A436008D4C05 /* ArticleThemeDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D643B026F8A436008D4C05 /* ArticleThemeDownloader.swift */; }; 17D643B226F8A436008D4C05 /* ArticleThemeDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D643B026F8A436008D4C05 /* ArticleThemeDownloader.swift */; }; 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 */; }; 3B3A32A5238B820900314204 /* FeedWranglerAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3A328B238B820900314204 /* FeedWranglerAccountViewController.swift */; }; 3B826DCB2385C84800FC1ADB /* AccountsFeedWrangler.xib in Resources */ = {isa = PBXBuildFile; fileRef = 3B826DB02385C84800FC1ADB /* AccountsFeedWrangler.xib */; }; @@ -137,8 +135,6 @@ 5138E94D24D3417A00AFF0FE /* RSDatabase in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 5138E94B24D3417A00AFF0FE /* RSDatabase */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 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 Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 513C5CE6232571C2003D4054 /* NetNewsWire iOS Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -222,8 +218,6 @@ 51A66685238075AE00CB272D /* AddFeedDefaultContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A66684238075AE00CB272D /* AddFeedDefaultContainer.swift */; }; 51A737BF24DB197F0015FA66 /* RSDatabase in Frameworks */ = {isa = PBXBuildFile; productRef = 51A737BE24DB197F0015FA66 /* RSDatabase */; }; 51A737C024DB197F0015FA66 /* RSDatabase in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 51A737BE24DB197F0015FA66 /* RSDatabase */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; - 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 */; }; @@ -409,6 +403,10 @@ 841387882CD89E5200E8490F /* RSCore in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 841387862CD89E5200E8490F /* RSCore */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 8413878A2CD8A38A00E8490F /* UniformTypeIdentifiers+Extras.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841387892CD8A38A00E8490F /* UniformTypeIdentifiers+Extras.swift */; }; 8413878B2CD8A38A00E8490F /* UniformTypeIdentifiers+Extras.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841387892CD8A38A00E8490F /* UniformTypeIdentifiers+Extras.swift */; }; + 8413878E2CDC790C00E8490F /* RSWeb in Frameworks */ = {isa = PBXBuildFile; productRef = 8413878D2CDC790C00E8490F /* RSWeb */; }; + 8413878F2CDC790C00E8490F /* RSWeb in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 8413878D2CDC790C00E8490F /* RSWeb */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + 841387912CDC791B00E8490F /* RSWeb in Frameworks */ = {isa = PBXBuildFile; productRef = 841387902CDC791B00E8490F /* RSWeb */; }; + 841387922CDC791B00E8490F /* RSWeb in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 841387902CDC791B00E8490F /* RSWeb */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 84162A152038C12C00035290 /* MarkCommandValidationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84162A142038C12C00035290 /* MarkCommandValidationStatus.swift */; }; 841ABA4E20145E7300980E11 /* NothingInspectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841ABA4D20145E7300980E11 /* NothingInspectorViewController.swift */; }; 841ABA5E20145E9200980E11 /* FolderInspectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841ABA5D20145E9200980E11 /* FolderInspectorViewController.swift */; }; @@ -637,7 +635,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 17EF6A2225C4E5B4002C9F81 /* RSWeb in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -709,10 +706,10 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( + 841387922CDC791B00E8490F /* RSWeb in Embed Frameworks */, 841387762CD897C500E8490F /* RSCore in Embed Frameworks */, 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 */, @@ -756,10 +753,10 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( + 8413878F2CDC790C00E8490F /* RSWeb in Embed Frameworks */, 8413876E2CD8970B00E8490F /* RSCore in Embed Frameworks */, 513277442590FBB60064F1E7 /* Account in Embed Frameworks */, 5132775F2590FC640064F1E7 /* Articles in Embed Frameworks */, - 51A737C624DB19B50015FA66 /* RSWeb in Embed Frameworks */, 51A737C024DB197F0015FA66 /* RSDatabase in Embed Frameworks */, 513277662590FC780064F1E7 /* Secrets in Embed Frameworks */, 513277652590FC640064F1E7 /* SyncDatabase in Embed Frameworks */, @@ -1054,6 +1051,7 @@ 840D61972029031D009BC708 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 8413876B2CD896E000E8490F /* RSCore */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = RSCore; sourceTree = ""; }; 841387892CD8A38A00E8490F /* UniformTypeIdentifiers+Extras.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UniformTypeIdentifiers+Extras.swift"; sourceTree = ""; }; + 8413878C2CDC78EE00E8490F /* RSWeb */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = RSWeb; sourceTree = ""; }; 84162A142038C12C00035290 /* MarkCommandValidationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkCommandValidationStatus.swift; sourceTree = ""; }; 841ABA4D20145E7300980E11 /* NothingInspectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NothingInspectorViewController.swift; sourceTree = ""; }; 841ABA5D20145E9200980E11 /* FolderInspectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderInspectorViewController.swift; sourceTree = ""; }; @@ -1240,7 +1238,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 17EF6A2125C4E5B4002C9F81 /* RSWeb in Frameworks */, 176813F72564BB2C00D98635 /* SwiftUI.framework in Frameworks */, 176813F52564BB2C00D98635 /* WidgetKit.framework in Frameworks */, ); @@ -1296,7 +1293,6 @@ buildActionMask = 2147483647; files = ( 841387752CD897C500E8490F /* RSCore in Frameworks */, - 5138E95824D3419000AFF0FE /* RSWeb in Frameworks */, 179D280B26F6F93D003B2E0A /* Zip in Frameworks */, 516B695F24D2F33B00B5702F /* Account in Frameworks */, 841387782CD897C500E8490F /* RSCoreObjC in Frameworks */, @@ -1306,6 +1302,7 @@ 513F32712593EE6F0003048F /* Articles in Frameworks */, 513F32772593EE6F0003048F /* Secrets in Frameworks */, 51E4DB082425F9EB0091EB5B /* CloudKit.framework in Frameworks */, + 841387912CDC791B00E8490F /* RSWeb in Frameworks */, 513F32742593EE6F0003048F /* ArticlesDatabase in Frameworks */, 513F327A2593EE6F0003048F /* SyncDatabase in Frameworks */, 5138E93A24D33E5600AFF0FE /* RSTree in Frameworks */, @@ -1318,13 +1315,13 @@ files = ( 513277642590FC640064F1E7 /* SyncDatabase in Frameworks */, 17192ADA2567B3D500AAEACA /* RSSparkle in Frameworks */, - 51A737C524DB19B50015FA66 /* RSWeb in Frameworks */, 514C16DE24D2EF15009A3AFA /* RSTree in Frameworks */, 5132775E2590FC640064F1E7 /* Articles in Frameworks */, 513277612590FC640064F1E7 /* ArticlesDatabase in Frameworks */, 51C4CFF624D37DD500AF9874 /* Secrets in Frameworks */, 51A737C824DB19CC0015FA66 /* RSParser in Frameworks */, 841387732CD8970B00E8490F /* RSCoreResources in Frameworks */, + 8413878E2CDC790C00E8490F /* RSWeb in Frameworks */, 179C39EA26F76B0500D4E741 /* Zip in Frameworks */, 51E4DAED2425F6940091EB5B /* CloudKit.framework in Frameworks */, 841387702CD8970B00E8490F /* RSCoreObjC in Frameworks */, @@ -2049,6 +2046,7 @@ 51CD32C324D2CD57009ABAEF /* ArticlesDatabase */, 51CD32C724D2E06C009ABAEF /* Secrets */, 51CD32A824D2CB25009ABAEF /* SyncDatabase */, + 8413878C2CDC78EE00E8490F /* RSWeb */, 8413876B2CD896E000E8490F /* RSCore */, ); sourceTree = ""; @@ -2431,7 +2429,6 @@ ); name = "NetNewsWire iOS Widget Extension"; packageProductDependencies = ( - 17EF6A2025C4E5B4002C9F81 /* RSWeb */, ); productName = "NetNewsWire WidgetExtension"; productReference = 176813F32564BB2C00D98635 /* NetNewsWire iOS Widget Extension.appex */; @@ -2567,7 +2564,6 @@ 5138E93924D33E5600AFF0FE /* RSTree */, 5138E94B24D3417A00AFF0FE /* RSDatabase */, 5138E95124D3418100AFF0FE /* RSParser */, - 5138E95724D3419000AFF0FE /* RSWeb */, 513F32702593EE6F0003048F /* Articles */, 513F32732593EE6F0003048F /* ArticlesDatabase */, 513F32762593EE6F0003048F /* Secrets */, @@ -2575,6 +2571,7 @@ 179D280A26F6F93D003B2E0A /* Zip */, 841387742CD897C500E8490F /* RSCore */, 841387772CD897C500E8490F /* RSCoreObjC */, + 841387902CDC791B00E8490F /* RSWeb */, ); productName = "NetNewsWire-iOS"; productReference = 840D617C2029031C009BC708 /* NetNewsWire.app */; @@ -2607,7 +2604,6 @@ 514C16DD24D2EF15009A3AFA /* RSTree */, 51C4CFF524D37DD500AF9874 /* Secrets */, 51A737BE24DB197F0015FA66 /* RSDatabase */, - 51A737C424DB19B50015FA66 /* RSWeb */, 51A737C724DB19CC0015FA66 /* RSParser */, 17192AD92567B3D500AAEACA /* RSSparkle */, 519CA8E425841DB700EB079A /* CrashReporter */, @@ -2618,6 +2614,7 @@ 8413876C2CD8970B00E8490F /* RSCore */, 8413876F2CD8970B00E8490F /* RSCoreObjC */, 841387722CD8970B00E8490F /* RSCoreResources */, + 8413878D2CDC790C00E8490F /* RSWeb */, ); productName = NetNewsWire; productReference = 849C64601ED37A5D003D8FC0 /* NetNewsWire.app */; @@ -2722,7 +2719,6 @@ mainGroup = 849C64571ED37A5D003D8FC0; packageReferences = ( 510ECA4024D1DCD0001C31A6 /* XCRemoteSwiftPackageReference "RSTree" */, - 51383A3024D1F90E0027E272 /* XCRemoteSwiftPackageReference "RSWeb" */, 51B0DF0D24D24E3B000AD99E /* XCRemoteSwiftPackageReference "RSDatabase" */, 51B0DF2324D2C7FA000AD99E /* XCRemoteSwiftPackageReference "RSParser" */, 17192AD82567B3D500AAEACA /* XCRemoteSwiftPackageReference "Sparkle-Binary" */, @@ -3909,14 +3905,6 @@ minimumVersion = 1.0.0; }; }; - 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"; @@ -3959,11 +3947,6 @@ package = 179D280926F6F93D003B2E0A /* XCRemoteSwiftPackageReference "Zip" */; productName = Zip; }; - 17EF6A2025C4E5B4002C9F81 /* RSWeb */ = { - isa = XCSwiftPackageProductDependency; - package = 51383A3024D1F90E0027E272 /* XCRemoteSwiftPackageReference "RSWeb" */; - productName = RSWeb; - }; 4679674525E599C100844E8D /* Articles */ = { isa = XCSwiftPackageProductDependency; productName = Articles; @@ -3999,11 +3982,6 @@ package = 51B0DF2324D2C7FA000AD99E /* XCRemoteSwiftPackageReference "RSParser" */; productName = RSParser; }; - 5138E95724D3419000AFF0FE /* RSWeb */ = { - isa = XCSwiftPackageProductDependency; - package = 51383A3024D1F90E0027E272 /* XCRemoteSwiftPackageReference "RSWeb" */; - productName = RSWeb; - }; 513F32702593EE6F0003048F /* Articles */ = { isa = XCSwiftPackageProductDependency; productName = Articles; @@ -4043,11 +4021,6 @@ package = 51B0DF0D24D24E3B000AD99E /* XCRemoteSwiftPackageReference "RSDatabase" */; productName = RSDatabase; }; - 51A737C424DB19B50015FA66 /* RSWeb */ = { - isa = XCSwiftPackageProductDependency; - package = 51383A3024D1F90E0027E272 /* XCRemoteSwiftPackageReference "RSWeb" */; - productName = RSWeb; - }; 51A737C724DB19CC0015FA66 /* RSParser */ = { isa = XCSwiftPackageProductDependency; package = 51B0DF2324D2C7FA000AD99E /* XCRemoteSwiftPackageReference "RSParser" */; @@ -4115,6 +4088,14 @@ isa = XCSwiftPackageProductDependency; productName = RSCore; }; + 8413878D2CDC790C00E8490F /* RSWeb */ = { + isa = XCSwiftPackageProductDependency; + productName = RSWeb; + }; + 841387902CDC791B00E8490F /* RSWeb */ = { + isa = XCSwiftPackageProductDependency; + productName = RSWeb; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 849C64581ED37A5D003D8FC0 /* Project object */; diff --git a/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 536658e08..76494960d 100644 --- a/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -37,15 +37,6 @@ "version": "1.0.0" } }, - { - "package": "RSWeb", - "repositoryURL": "https://github.com/Ranchero-Software/RSWeb.git", - "state": { - "branch": null, - "revision": "2f7849a9ad2cb461b3d6c9c920e163596e6b5d7b", - "version": "1.0.3" - } - }, { "package": "RSSparkle", "repositoryURL": "https://github.com/Ranchero-Software/Sparkle-Binary.git", diff --git a/RSCore/Package.swift b/RSCore/Package.swift index e7e117f73..3b916ef74 100644 --- a/RSCore/Package.swift +++ b/RSCore/Package.swift @@ -1,11 +1,10 @@ -// swift-tools-version:5.3 -// The swift-tools-version declares the minimum version of Swift required to build this package. +// swift-tools-version:5.9 import PackageDescription let package = Package( name: "RSCore", - platforms: [.macOS(SupportedPlatform.MacOSVersion.v10_15), .iOS(SupportedPlatform.IOSVersion.v13)], + platforms: [.macOS(.v14), .iOS(.v17)], products: [ .library(name: "RSCore", type: .dynamic, targets: ["RSCore"]), .library(name: "RSCoreObjC", type: .dynamic, targets: ["RSCoreObjC"]), diff --git a/RSWeb/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/RSWeb/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/RSWeb/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/RSWeb/LICENSE b/RSWeb/LICENSE new file mode 100755 index 000000000..6c6a3472e --- /dev/null +++ b/RSWeb/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Brent Simmons + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/RSWeb/Package.swift b/RSWeb/Package.swift new file mode 100644 index 000000000..afa089807 --- /dev/null +++ b/RSWeb/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version:5.9 + +import PackageDescription + +let package = Package( + name: "RSWeb", + platforms: [.macOS(.v14), .iOS(.v17)], + products: [ + .library( + name: "RSWeb", + type: .dynamic, + targets: ["RSWeb"]), + ], + dependencies: [ + ], + targets: [ + .target( + name: "RSWeb", + resources: [.copy("UTS46/uts46")], + swiftSettings: [.define("SWIFT_PACKAGE")]), + .testTarget( + name: "RSWebTests", + dependencies: ["RSWeb"]), + ] +) diff --git a/RSWeb/README.md b/RSWeb/README.md new file mode 100755 index 000000000..8a439e904 --- /dev/null +++ b/RSWeb/README.md @@ -0,0 +1,19 @@ +# RSWeb + +RSWeb is utility code — all Swift — for downloading things from the web. It builds a Mac framework and an iOS framework. + +#### Easy way + +See `OneShotDownload` for a top-level `download` function that takes a URL and a callback. The callback takes `Data`, `URLResponse`, and `Error` parameters. It’s easy. + +#### Slightly less easy way + +See `DownloadSession` and `DownloadSessionDelegate` for when you’re doing a bunch of downloads and you need to track progress. + +#### Extras + +`HTTPConditionalGetInfo` helps with supporting conditional GET, for when you’re downloading things that may not have changed. See [HTTP Conditional Get for RSS Hackers](http://fishbowl.pastiche.org/2002/10/21/http_conditional_get_for_rss_hackers/) for more about conditional GET. This is especially critical when polling for changes, such as with an RSS reader. + +`MimeType` could use expansion, but is useful for some cases right now. + +`MacWebBrowser` makes it easy to open a URL in the default browser. You can specify whether or not to open in background. \ No newline at end of file diff --git a/RSWeb/Sources/RSWeb/Dictionary+RSWeb.swift b/RSWeb/Sources/RSWeb/Dictionary+RSWeb.swift new file mode 100644 index 000000000..d647fe5e0 --- /dev/null +++ b/RSWeb/Sources/RSWeb/Dictionary+RSWeb.swift @@ -0,0 +1,26 @@ +// +// Dictionary+RSWeb.swift +// RSWeb +// +// Created by Brent Simmons on 1/13/18. +// Copyright © 2018 Ranchero Software. All rights reserved. +// + +import Foundation + +public extension Dictionary where Key == String, Value == String { + + /// Translates a dictionary into a string like `foo=bar¶m2=some%20thing`. + var urlQueryString: String? { + + var components = URLComponents() + + components.queryItems = self.reduce(into: [URLQueryItem]()) { + $0.append(URLQueryItem(name: $1.key, value: $1.value)) + } + + let s = components.percentEncodedQuery + + return s == nil || s!.isEmpty ? nil : s + } +} diff --git a/RSWeb/Sources/RSWeb/DownloadObject.swift b/RSWeb/Sources/RSWeb/DownloadObject.swift new file mode 100755 index 000000000..bc8b3f73b --- /dev/null +++ b/RSWeb/Sources/RSWeb/DownloadObject.swift @@ -0,0 +1,32 @@ +// +// DownloadObject.swift +// RSWeb +// +// Created by Brent Simmons on 8/3/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +public final class DownloadObject: Hashable { + + public let url: URL + public var data = Data() + + public init(url: URL) { + self.url = url + } + + // MARK: - Hashable + + public func hash(into hasher: inout Hasher) { + hasher.combine(url) + } + + // MARK: - Equatable + + public static func ==(lhs: DownloadObject, rhs: DownloadObject) -> Bool { + return lhs.url == rhs.url && lhs.data == rhs.data + } +} + diff --git a/RSWeb/Sources/RSWeb/DownloadProgress.swift b/RSWeb/Sources/RSWeb/DownloadProgress.swift new file mode 100755 index 000000000..84bfffedf --- /dev/null +++ b/RSWeb/Sources/RSWeb/DownloadProgress.swift @@ -0,0 +1,103 @@ +// +// DownloadProgress.swift +// RSWeb +// +// Created by Brent Simmons on 9/17/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +// Main thread only. + +public extension Notification.Name { + + static let DownloadProgressDidChange = Notification.Name(rawValue: "DownloadProgressDidChange") +} + +public final class DownloadProgress { + + public var numberOfTasks = 0 { + didSet { + if numberOfTasks == 0 && numberRemaining != 0 { + numberRemaining = 0 + } + if numberOfTasks != oldValue { + postDidChangeNotification() + } + } + } + + public var numberRemaining = 0 { + didSet { + if numberRemaining == 0 && numberOfTasks != 0 { + numberOfTasks = 0 + } + if numberRemaining != oldValue { + postDidChangeNotification() + } + } + } + + public var numberCompleted: Int { + var n = numberOfTasks - numberRemaining + if n < 0 { + n = 0 + } + if n > numberOfTasks { + n = numberOfTasks + } + return n + } + + public var isComplete: Bool { + assert(Thread.isMainThread) + return numberRemaining < 1 + } + + public init(numberOfTasks: Int) { + assert(Thread.isMainThread) + self.numberOfTasks = numberOfTasks + } + + public func addToNumberOfTasks(_ n: Int) { + assert(Thread.isMainThread) + numberOfTasks = numberOfTasks + n + } + + public func addToNumberOfTasksAndRemaining(_ n: Int) { + assert(Thread.isMainThread) + numberOfTasks = numberOfTasks + n + numberRemaining = numberRemaining + n + } + + public func completeTask() { + assert(Thread.isMainThread) + if numberRemaining > 0 { + numberRemaining = numberRemaining - 1 + } + } + + public func completeTasks(_ tasks: Int) { + assert(Thread.isMainThread) + if numberRemaining >= tasks { + numberRemaining = numberRemaining - tasks + } + } + + public func clear() { + assert(Thread.isMainThread) + numberOfTasks = 0 + } +} + +// MARK: - Private + +private extension DownloadProgress { + + func postDidChangeNotification() { + DispatchQueue.main.async { + NotificationCenter.default.post(name: .DownloadProgressDidChange, object: self) + } + } +} diff --git a/RSWeb/Sources/RSWeb/DownloadSession.swift b/RSWeb/Sources/RSWeb/DownloadSession.swift new file mode 100755 index 000000000..c15edfb9e --- /dev/null +++ b/RSWeb/Sources/RSWeb/DownloadSession.swift @@ -0,0 +1,308 @@ +// +// DownloadSession.swift +// RSWeb +// +// Created by Brent Simmons on 3/12/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +// Create a DownloadSessionDelegate, then create a DownloadSession. +// To download things: call downloadObjects, with a set of represented objects, to download things. DownloadSession will call the various delegate methods. + +public protocol DownloadSessionDelegate { + + func downloadSession(_ downloadSession: DownloadSession, requestForRepresentedObject: AnyObject) -> URLRequest? + func downloadSession(_ downloadSession: DownloadSession, downloadDidCompleteForRepresentedObject: AnyObject, response: URLResponse?, data: Data, error: NSError?, completion: @escaping () -> Void) + func downloadSession(_ downloadSession: DownloadSession, shouldContinueAfterReceivingData: Data, representedObject: AnyObject) -> Bool + func downloadSession(_ downloadSession: DownloadSession, didReceiveUnexpectedResponse: URLResponse, representedObject: AnyObject) + func downloadSession(_ downloadSession: DownloadSession, didReceiveNotModifiedResponse: URLResponse, representedObject: AnyObject) + func downloadSession(_ downloadSession: DownloadSession, didDiscardDuplicateRepresentedObject: AnyObject) + func downloadSessionDidCompleteDownloadObjects(_ downloadSession: DownloadSession) + +} + +@objc public final class DownloadSession: NSObject { + + private var urlSession: URLSession! + private var tasksInProgress = Set() + private var tasksPending = Set() + private var taskIdentifierToInfoDictionary = [Int: DownloadInfo]() + private let representedObjects = NSMutableSet() + private let delegate: DownloadSessionDelegate + private var redirectCache = [String: String]() + private var queue = [AnyObject]() + + public init(delegate: DownloadSessionDelegate) { + + self.delegate = delegate + + super.init() + + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData + sessionConfiguration.timeoutIntervalForRequest = 15.0 + sessionConfiguration.httpShouldSetCookies = false + sessionConfiguration.httpCookieAcceptPolicy = .never + sessionConfiguration.httpMaximumConnectionsPerHost = 2 + sessionConfiguration.httpCookieStorage = nil + sessionConfiguration.urlCache = nil + + if let userAgentHeaders = UserAgent.headers() { + sessionConfiguration.httpAdditionalHeaders = userAgentHeaders + } + + urlSession = URLSession(configuration: sessionConfiguration, delegate: self, delegateQueue: OperationQueue.main) + } + + deinit { + urlSession.invalidateAndCancel() + } + + // MARK: - API + + public func cancelAll() { + urlSession.getTasksWithCompletionHandler { dataTasks, uploadTasks, downloadTasks in + dataTasks.forEach { $0.cancel() } + uploadTasks.forEach { $0.cancel() } + downloadTasks.forEach { $0.cancel() } + } + } + + public func downloadObjects(_ objects: NSSet) { + for oneObject in objects { + if !representedObjects.contains(oneObject) { + representedObjects.add(oneObject) + addDataTask(oneObject as AnyObject) + } else { + delegate.downloadSession(self, didDiscardDuplicateRepresentedObject: oneObject as AnyObject) + } + } + } +} + +// MARK: - URLSessionTaskDelegate + +extension DownloadSession: URLSessionTaskDelegate { + + public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + tasksInProgress.remove(task) + + guard let info = infoForTask(task) else { + return + } + + info.error = error + + delegate.downloadSession(self, downloadDidCompleteForRepresentedObject: info.representedObject, response: info.urlResponse, data: info.data as Data, error: error as NSError?) { + self.removeTask(task) + } + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { + + if response.statusCode == 301 || response.statusCode == 308 { + if let oldURLString = task.originalRequest?.url?.absoluteString, let newURLString = request.url?.absoluteString { + cacheRedirect(oldURLString, newURLString) + } + } + + completionHandler(request) + } +} + +// MARK: - URLSessionDataDelegate + +extension DownloadSession: URLSessionDataDelegate { + + public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + + tasksInProgress.insert(dataTask) + tasksPending.remove(dataTask) + + if let info = infoForTask(dataTask) { + info.urlResponse = response + } + + if response.forcedStatusCode == 304 { + + if let representedObject = infoForTask(dataTask)?.representedObject { + delegate.downloadSession(self, didReceiveNotModifiedResponse: response, representedObject: representedObject) + } + + completionHandler(.cancel) + removeTask(dataTask) + + return + } + + if !response.statusIsOK { + + if let representedObject = infoForTask(dataTask)?.representedObject { + delegate.downloadSession(self, didReceiveUnexpectedResponse: response, representedObject: representedObject) + } + + completionHandler(.cancel) + removeTask(dataTask) + + return + } + + addDataTaskFromQueueIfNecessary() + + completionHandler(.allow) + } + + public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + + guard let info = infoForTask(dataTask) else { + return + } + info.addData(data) + + if !delegate.downloadSession(self, shouldContinueAfterReceivingData: info.data as Data, representedObject: info.representedObject) { + + info.canceled = true + dataTask.cancel() + removeTask(dataTask) + } + } + +} + +// MARK: - Private + +private extension DownloadSession { + + func addDataTask(_ representedObject: AnyObject) { + guard tasksPending.count < 500 else { + queue.insert(representedObject, at: 0) + return + } + + guard let request = delegate.downloadSession(self, requestForRepresentedObject: representedObject) else { + return + } + + var requestToUse = request + + // If received permanent redirect earlier, use that URL. + + if let urlString = request.url?.absoluteString, let redirectedURLString = cachedRedirectForURLString(urlString) { + if let redirectedURL = URL(string: redirectedURLString) { + requestToUse.url = redirectedURL + } + } + + let task = urlSession.dataTask(with: requestToUse) + + let info = DownloadInfo(representedObject, urlRequest: requestToUse) + taskIdentifierToInfoDictionary[task.taskIdentifier] = info + + tasksPending.insert(task) + task.resume() + } + + func addDataTaskFromQueueIfNecessary() { + guard tasksPending.count < 500, let representedObject = queue.popLast() else { return } + addDataTask(representedObject) + } + + func infoForTask(_ task: URLSessionTask) -> DownloadInfo? { + return taskIdentifierToInfoDictionary[task.taskIdentifier] + } + + func removeTask(_ task: URLSessionTask) { + tasksInProgress.remove(task) + tasksPending.remove(task) + taskIdentifierToInfoDictionary[task.taskIdentifier] = nil + + addDataTaskFromQueueIfNecessary() + + if tasksInProgress.count + tasksPending.count < 1 { + representedObjects.removeAllObjects() + delegate.downloadSessionDidCompleteDownloadObjects(self) + } + } + + func urlStringIsBlackListedRedirect(_ urlString: String) -> Bool { + + // Hotels and similar often do permanent redirects. We can catch some of those. + + let s = urlString.lowercased() + let badStrings = ["solutionip", "lodgenet", "monzoon", "landingpage", "btopenzone", "register", "login", "authentic"] + + for oneBadString in badStrings { + if s.contains(oneBadString) { + return true + } + } + + return false + } + + func cacheRedirect(_ oldURLString: String, _ newURLString: String) { + if urlStringIsBlackListedRedirect(newURLString) { + return + } + redirectCache[oldURLString] = newURLString + } + + func cachedRedirectForURLString(_ urlString: String) -> String? { + + // Follow chains of redirects, but avoid loops. + + var urlStrings = Set() + urlStrings.insert(urlString) + + var currentString = urlString + + while(true) { + + if let oneRedirectString = redirectCache[currentString] { + + if urlStrings.contains(oneRedirectString) { + // Cycle. Bail. + return nil + } + urlStrings.insert(oneRedirectString) + currentString = oneRedirectString + } + + else { + break + } + } + + return currentString == urlString ? nil : currentString + } +} + +// MARK: - DownloadInfo + +private final class DownloadInfo { + + let representedObject: AnyObject + let urlRequest: URLRequest + let data = NSMutableData() + var error: Error? + var urlResponse: URLResponse? + var canceled = false + + var statusCode: Int { + return urlResponse?.forcedStatusCode ?? 0 + } + + init(_ representedObject: AnyObject, urlRequest: URLRequest) { + + self.representedObject = representedObject + self.urlRequest = urlRequest + } + + func addData(_ d: Data) { + + data.append(d) + } +} + diff --git a/RSWeb/Sources/RSWeb/HTTPConditionalGetInfo.swift b/RSWeb/Sources/RSWeb/HTTPConditionalGetInfo.swift new file mode 100755 index 000000000..c62a382d4 --- /dev/null +++ b/RSWeb/Sources/RSWeb/HTTPConditionalGetInfo.swift @@ -0,0 +1,46 @@ +// +// HTTPConditionalGetInfo.swift +// RSWeb +// +// Created by Brent Simmons on 4/11/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +public struct HTTPConditionalGetInfo: Codable, Equatable { + + public let lastModified: String? + public let etag: String? + + public init?(lastModified: String?, etag: String?) { + if lastModified == nil && etag == nil { + return nil + } + self.lastModified = lastModified + self.etag = etag + } + + public init?(urlResponse: HTTPURLResponse) { + let lastModified = urlResponse.valueForHTTPHeaderField(HTTPResponseHeader.lastModified) + let etag = urlResponse.valueForHTTPHeaderField(HTTPResponseHeader.etag) + self.init(lastModified: lastModified, etag: etag) + } + + public init?(headers: [AnyHashable : Any]) { + let lastModified = headers[HTTPResponseHeader.lastModified] as? String + let etag = headers[HTTPResponseHeader.etag] as? String + self.init(lastModified: lastModified, etag: etag) + } + + public func addRequestHeadersToURLRequest(_ urlRequest: inout URLRequest) { + // Bug seen in the wild: lastModified with last possible 32-bit date, which is in 2038. Ignore those. + // TODO: drop this check in late 2037. + if let lastModified = lastModified, !lastModified.contains("2038") { + urlRequest.addValue(lastModified, forHTTPHeaderField: HTTPRequestHeader.ifModifiedSince) + } + if let etag = etag { + urlRequest.addValue(etag, forHTTPHeaderField: HTTPRequestHeader.ifNoneMatch) + } + } +} diff --git a/RSWeb/Sources/RSWeb/HTTPDateInfo.swift b/RSWeb/Sources/RSWeb/HTTPDateInfo.swift new file mode 100644 index 000000000..06d95f811 --- /dev/null +++ b/RSWeb/Sources/RSWeb/HTTPDateInfo.swift @@ -0,0 +1,29 @@ +// +// HTTPDateInfo.swift +// RSWeb +// +// Created by Maurice Parker on 5/12/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import Foundation + +public struct HTTPDateInfo: Codable, Equatable { + + private static let formatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "EEEE, dd LLL yyyy HH:mm:ss zzz" + return dateFormatter + }() + + public let date: Date? + + public init?(urlResponse: HTTPURLResponse) { + if let headerDate = urlResponse.valueForHTTPHeaderField(HTTPResponseHeader.date) { + date = HTTPDateInfo.formatter.date(from: headerDate) + } else { + date = nil + } + } + +} diff --git a/RSWeb/Sources/RSWeb/HTTPLinkPagingInfo.swift b/RSWeb/Sources/RSWeb/HTTPLinkPagingInfo.swift new file mode 100644 index 000000000..38f4ed774 --- /dev/null +++ b/RSWeb/Sources/RSWeb/HTTPLinkPagingInfo.swift @@ -0,0 +1,41 @@ +// +// HTTPLinkPagingInfo.swift +// RSWeb +// +// Created by Maurice Parker on 5/12/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import Foundation + +public struct HTTPLinkPagingInfo { + + public let nextPage: String? + public let lastPage: String? + + public init(nextPage: String?, lastPage: String?) { + self.nextPage = nextPage + self.lastPage = lastPage + } + + public init(urlResponse: HTTPURLResponse) { + + guard let linkHeader = urlResponse.valueForHTTPHeaderField(HTTPResponseHeader.link) else { + self.init(nextPage: nil, lastPage: nil) + return + } + + let links = linkHeader.components(separatedBy: ",") + + var dict: [String: String] = [:] + links.forEach({ + let components = $0.components(separatedBy:"; ") + let page = components[0].trimmingCharacters(in: CharacterSet(charactersIn: " <>")) + dict[components[1]] = page + }) + + self.init(nextPage: dict["rel=\"next\""], lastPage: dict["rel=\"last\""]) + + } + +} diff --git a/RSWeb/Sources/RSWeb/HTTPMethod.swift b/RSWeb/Sources/RSWeb/HTTPMethod.swift new file mode 100755 index 000000000..44c796040 --- /dev/null +++ b/RSWeb/Sources/RSWeb/HTTPMethod.swift @@ -0,0 +1,18 @@ +// +// HTTPMethod.swift +// RSWeb +// +// Created by Brent Simmons on 12/26/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +public struct HTTPMethod { + + public static let get = "GET" + public static let post = "POST" + public static let put = "PUT" + public static let patch = "PATCH" + public static let delete = "DELETE" +} diff --git a/RSWeb/Sources/RSWeb/HTTPRequestHeader.swift b/RSWeb/Sources/RSWeb/HTTPRequestHeader.swift new file mode 100755 index 000000000..7021a6fcc --- /dev/null +++ b/RSWeb/Sources/RSWeb/HTTPRequestHeader.swift @@ -0,0 +1,21 @@ +// +// HTTPRequestHeader.swift +// RSWeb +// +// Created by Brent Simmons on 12/26/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +public struct HTTPRequestHeader { + + public static let userAgent = "User-Agent" + public static let authorization = "Authorization" + public static let contentType = "Content-Type" + + // Conditional GET + + public static let ifModifiedSince = "If-Modified-Since" + public static let ifNoneMatch = "If-None-Match" //Etag +} diff --git a/RSWeb/Sources/RSWeb/HTTPResponseCode.swift b/RSWeb/Sources/RSWeb/HTTPResponseCode.swift new file mode 100755 index 000000000..1ece77008 --- /dev/null +++ b/RSWeb/Sources/RSWeb/HTTPResponseCode.swift @@ -0,0 +1,61 @@ +// +// HTTPResponseCode.swift +// RSWeb +// +// Created by Brent Simmons on 12/26/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +public struct HTTPResponseCode { + + // http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html + // Not an enum because the main interest is the actual values. + + public static let responseContinue = 100 //"continue" is a language keyword, hence the weird name + public static let switchingProtocols = 101 + + public static let OK = 200 + public static let created = 201 + public static let accepted = 202 + public static let nonAuthoritativeInformation = 203 + public static let noContent = 204 + public static let resetContent = 205 + public static let partialContent = 206 + + public static let redirectMultipleChoices = 300 + public static let redirectPermanent = 301 + public static let redirectTemporary = 302 + public static let redirectSeeOther = 303 + public static let notModified = 304 + public static let useProxy = 305 + public static let unused = 306 + public static let redirectVeryTemporary = 307 + + public static let badRequest = 400 + public static let unauthorized = 401 + public static let paymentRequired = 402 + public static let forbidden = 403 + public static let notFound = 404 + public static let methodNotAllowed = 405 + public static let notAcceptable = 406 + public static let proxyAuthenticationRequired = 407 + public static let requestTimeout = 408 + public static let conflict = 409 + public static let gone = 410 + public static let lengthRequired = 411 + public static let preconditionFailed = 412 + public static let entityTooLarge = 413 + public static let URITooLong = 414 + public static let unsupportedMediaType = 415 + public static let requestedRangeNotSatisfiable = 416 + public static let expectationFailed = 417 + + public static let internalServerError = 500 + public static let notImplemented = 501 + public static let badGateway = 502 + public static let serviceUnavailable = 503 + public static let gatewayTimeout = 504 + public static let HTTPVersionNotSupported = 505 +} diff --git a/RSWeb/Sources/RSWeb/HTTPResponseHeader.swift b/RSWeb/Sources/RSWeb/HTTPResponseHeader.swift new file mode 100755 index 000000000..15e8c53e1 --- /dev/null +++ b/RSWeb/Sources/RSWeb/HTTPResponseHeader.swift @@ -0,0 +1,25 @@ +// +// HTTPResponseHeader.swift +// RSWeb +// +// Created by Brent Simmons on 12/26/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +public struct HTTPResponseHeader { + + public static let contentType = "Content-Type" + public static let location = "Location" + public static let link = "Links" + public static let date = "Date" + + // Conditional GET. See: + // http://fishbowl.pastiche.org/2002/10/21/http_conditional_get_for_rss_hackers/ + + public static let lastModified = "Last-Modified" + // Changed to the canonical case for lookups against a case sensitive dictionary + // https://developer.apple.com/documentation/foundation/httpurlresponse/1417930-allheaderfields + public static let etag = "Etag" +} diff --git a/RSWeb/Sources/RSWeb/MacWebBrowser.swift b/RSWeb/Sources/RSWeb/MacWebBrowser.swift new file mode 100755 index 000000000..948d3c8ea --- /dev/null +++ b/RSWeb/Sources/RSWeb/MacWebBrowser.swift @@ -0,0 +1,159 @@ +// +// MacWebBrowser.swift +// RSWeb +// +// Created by Brent Simmons on 12/27/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +#if os(macOS) +import AppKit +import UniformTypeIdentifiers + +public class MacWebBrowser { + + /// Opens a URL in the default browser. + @discardableResult public class func openURL(_ url: URL, inBackground: Bool = false) -> Bool { + + // TODO: make this function async + + guard let preparedURL = url.preparedForOpeningInBrowser() else { + return false + } + + if (inBackground) { + + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = false + NSWorkspace.shared.open(url, configuration: configuration, completionHandler: nil) + + return true + } + + 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] { + + let httpsAppURLs = NSWorkspace.shared.urlsForApplications(toOpen: URL(string: "https://apple.com/")!) + let htmlAppURLs = NSWorkspace.shared.urlsForApplications(toOpen: UTType.html) + let browserAppURLs = Set(httpsAppURLs).intersection(Set(htmlAppURLs)) + + return browserAppURLs.compactMap { MacWebBrowser(url: $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 { + + // TODO: make this function async. + + guard let preparedURL = url.preparedForOpeningInBrowser() else { + return false + } + + Task { @MainActor in + + let configuration = NSWorkspace.OpenConfiguration() + if inBackground { + configuration.activates = false + } + + NSWorkspace.shared.open([preparedURL], withApplicationAt: self.url, configuration: configuration, completionHandler: nil) + } + + return true + } +} + +extension MacWebBrowser: CustomDebugStringConvertible { + + public var debugDescription: String { + if let name = name, let bundleIdentifier = bundleIdentifier{ + return "MacWebBrowser: \(name) (\(bundleIdentifier))" + } else { + return "MacWebBrowser" + } + } +} + +#endif diff --git a/RSWeb/Sources/RSWeb/MimeType.swift b/RSWeb/Sources/RSWeb/MimeType.swift new file mode 100755 index 000000000..36225b718 --- /dev/null +++ b/RSWeb/Sources/RSWeb/MimeType.swift @@ -0,0 +1,55 @@ +// +// MimeType.swift +// RSWeb +// +// Created by Brent Simmons on 12/26/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +public struct MimeType { + + // This could certainly use expansion. + + public static let png = "image/png" + public static let jpeg = "image/jpeg" + public static let jpg = "image/jpg" + public static let gif = "image/gif" + public static let tiff = "image/tiff" +} + +public extension String { + + func isMimeTypeImage() -> Bool { + + return self.isOfGeneralMimeType("image") + } + + func isMimeTypeAudio() -> Bool { + + return self.isOfGeneralMimeType("audio") + } + + func isMimeTypeVideo() -> Bool { + + return self.isOfGeneralMimeType("video") + } + + func isMimeTypeTimeBasedMedia() -> Bool { + + return self.isMimeTypeAudio() || self.isMimeTypeVideo() + } + + private func isOfGeneralMimeType(_ type: String) -> Bool { + + let lower = self.lowercased() + if lower.hasPrefix(type) { + return true + } + if lower.hasPrefix("x-\(type)") { + return true + } + return false + } +} diff --git a/RSWeb/Sources/RSWeb/OneShotDownload.swift b/RSWeb/Sources/RSWeb/OneShotDownload.swift new file mode 100755 index 000000000..cfe30d6d1 --- /dev/null +++ b/RSWeb/Sources/RSWeb/OneShotDownload.swift @@ -0,0 +1,192 @@ +// +// OneShotDownload.swift +// RSWeb +// +// Created by Brent Simmons on 8/27/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +// Main thread only. + +public typealias OneShotDownloadCallback = (Data?, URLResponse?, Error?) -> Swift.Void + +private final class OneShotDownloadManager { + + private let urlSession: URLSession + fileprivate static let shared = OneShotDownloadManager() + + public init() { + + let sessionConfiguration = URLSessionConfiguration.ephemeral + sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData + sessionConfiguration.httpShouldSetCookies = false + sessionConfiguration.httpCookieAcceptPolicy = .never + sessionConfiguration.httpMaximumConnectionsPerHost = 2 + sessionConfiguration.httpCookieStorage = nil + sessionConfiguration.urlCache = nil + sessionConfiguration.timeoutIntervalForRequest = 30 + + if let userAgentHeaders = UserAgent.headers() { + sessionConfiguration.httpAdditionalHeaders = userAgentHeaders + } + + urlSession = URLSession(configuration: sessionConfiguration) + } + + deinit { + urlSession.invalidateAndCancel() + } + + public func download(_ url: URL, _ completion: @escaping OneShotDownloadCallback) { + let task = urlSession.dataTask(with: url) { (data, response, error) in + DispatchQueue.main.async() { + completion(data, response, error) + } + } + task.resume() + } + + public func download(_ urlRequest: URLRequest, _ completion: @escaping OneShotDownloadCallback) { + let task = urlSession.dataTask(with: urlRequest) { (data, response, error) in + DispatchQueue.main.async() { + completion(data, response, error) + } + } + task.resume() + } +} + +// Call one of these. It’s easier than referring to OneShotDownloadManager. +// callback is called on the main queue. + +public func download(_ url: URL, _ completion: @escaping OneShotDownloadCallback) { + precondition(Thread.isMainThread) + OneShotDownloadManager.shared.download(url, completion) +} + +public func download(_ urlRequest: URLRequest, _ completion: @escaping OneShotDownloadCallback) { + precondition(Thread.isMainThread) + OneShotDownloadManager.shared.download(urlRequest, completion) +} + +// MARK: - Downloading using a cache + +private struct WebCacheRecord { + + let url: URL + let dateDownloaded: Date + let data: Data + let response: URLResponse +} + +private final class WebCache { + + private var cache = [URL: WebCacheRecord]() + + func cleanup(_ cleanupInterval: TimeInterval) { + + let cutoffDate = Date(timeInterval: -cleanupInterval, since: Date()) + cache.keys.forEach { (key) in + let cacheRecord = self[key]! + if shouldDelete(cacheRecord, cutoffDate) { + cache[key] = nil + } + } + } + + private func shouldDelete(_ cacheRecord: WebCacheRecord, _ cutoffDate: Date) -> Bool { + + return cacheRecord.dateDownloaded < cutoffDate + } + + subscript(_ url: URL) -> WebCacheRecord? { + get { + return cache[url] + } + set { + if let cacheRecord = newValue { + cache[url] = cacheRecord + } + else { + cache[url] = nil + } + } + } +} + +// URLSessionConfiguration has a cache policy. +// But we don’t know how it works, and the unimplemented parts spook us a bit. +// So we use a cache that works exactly as we want it to work. +// It also makes sure we don’t have multiple requests for the same URL at the same time. + +private struct CallbackRecord { + let url: URL + let completion: OneShotDownloadCallback +} + +private final class DownloadWithCacheManager { + + static let shared = DownloadWithCacheManager() + private var cache = WebCache() + private static let timeToLive: TimeInterval = 10 * 60 // 10 minutes + private static let cleanupInterval: TimeInterval = 5 * 60 // clean up the cache at most every 5 minutes + private var lastCleanupDate = Date() + private var pendingCallbacks = [CallbackRecord]() + private var urlsInProgress = Set() + + func download(_ url: URL, _ completion: @escaping OneShotDownloadCallback, forceRedownload: Bool = false) { + + if lastCleanupDate.timeIntervalSinceNow < -DownloadWithCacheManager.cleanupInterval { + lastCleanupDate = Date() + cache.cleanup(DownloadWithCacheManager.timeToLive) + } + + if !forceRedownload { + let cacheRecord: WebCacheRecord? = cache[url] + if let cacheRecord = cacheRecord { + completion(cacheRecord.data, cacheRecord.response, nil) + return + } + } + + let callbackRecord = CallbackRecord(url: url, completion: completion) + pendingCallbacks.append(callbackRecord) + if urlsInProgress.contains(url) { + return // The completion handler will get called later. + } + urlsInProgress.insert(url) + + OneShotDownloadManager.shared.download(url) { (data, response, error) in + + self.urlsInProgress.remove(url) + + if let data = data, let response = response, response.statusIsOK, error == nil { + let cacheRecord = WebCacheRecord(url: url, dateDownloaded: Date(), data: data, response: response) + self.cache[url] = cacheRecord + } + + var callbackCount = 0 + self.pendingCallbacks.forEach{ (callbackRecord) in + if url == callbackRecord.url { + callbackRecord.completion(data, response, error) + callbackCount += 1 + } + } + self.pendingCallbacks.removeAll(where: { (callbackRecord) -> Bool in + return callbackRecord.url == url + }) + } + } +} + +public func downloadUsingCache(_ url: URL, _ completion: @escaping OneShotDownloadCallback) { + precondition(Thread.isMainThread) + DownloadWithCacheManager.shared.download(url, completion) +} + +public func downloadAddingToCache(_ url: URL, _ completion: @escaping OneShotDownloadCallback) { + precondition(Thread.isMainThread) + DownloadWithCacheManager.shared.download(url, completion, forceRedownload: true) +} diff --git a/RSWeb/Sources/RSWeb/Reachability.swift b/RSWeb/Sources/RSWeb/Reachability.swift new file mode 100644 index 000000000..6c8ffa2e4 --- /dev/null +++ b/RSWeb/Sources/RSWeb/Reachability.swift @@ -0,0 +1,406 @@ +/* +Copyright (c) 2014, Ashley Mills +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +*/ + +import SystemConfiguration +import Foundation + +public enum ReachabilityError: Error { + case failedToCreateWithAddress(sockaddr, Int32) + case failedToCreateWithHostname(String, Int32) + case unableToSetCallback(Int32) + case unableToSetDispatchQueue(Int32) + case unableToGetFlags(Int32) +} + +@available(*, unavailable, renamed: "Notification.Name.reachabilityChanged") +public let ReachabilityChangedNotification = NSNotification.Name("ReachabilityChangedNotification") + +public extension Notification.Name { + static let reachabilityChanged = Notification.Name("reachabilityChanged") +} + +public class Reachability { + + public typealias NetworkReachable = (Reachability) -> () + public typealias NetworkUnreachable = (Reachability) -> () + + @available(*, unavailable, renamed: "Connection") + public enum NetworkStatus: CustomStringConvertible { + case notReachable, reachableViaWiFi, reachableViaWWAN + public var description: String { + switch self { + case .reachableViaWWAN: return "Cellular" + case .reachableViaWiFi: return "WiFi" + case .notReachable: return "No Connection" + } + } + } + + public enum Connection: CustomStringConvertible { + @available(*, deprecated, renamed: "unavailable") + case none + case unavailable, wifi, cellular + public var description: String { + switch self { + case .cellular: return "Cellular" + case .wifi: return "WiFi" + case .unavailable: return "No Connection" + case .none: return "unavailable" + } + } + } + + public var whenReachable: NetworkReachable? + public var whenUnreachable: NetworkUnreachable? + + @available(*, deprecated, renamed: "allowsCellularConnection") + public let reachableOnWWAN: Bool = true + + /// Set to `false` to force Reachability.connection to .none when on cellular connection (default value `true`) + public var allowsCellularConnection: Bool + + // The notification center on which "reachability changed" events are being posted + public var notificationCenter: NotificationCenter = NotificationCenter.default + + @available(*, deprecated, renamed: "connection.description") + public var currentReachabilityString: String { + return "\(connection)" + } + + @available(*, unavailable, renamed: "connection") + public var currentReachabilityStatus: Connection { + return connection + } + + public var connection: Connection { + if flags == nil { + try? setReachabilityFlags() + } + + switch flags?.connection { + case .unavailable?, nil: return .unavailable + case .none?: return .unavailable + case .cellular?: return allowsCellularConnection ? .cellular : .unavailable + case .wifi?: return .wifi + } + } + + fileprivate var isRunningOnDevice: Bool = { + #if targetEnvironment(simulator) + return false + #else + return true + #endif + }() + + fileprivate(set) var notifierRunning = false + fileprivate let reachabilityRef: SCNetworkReachability + fileprivate let reachabilitySerialQueue: DispatchQueue + fileprivate let notificationQueue: DispatchQueue? + fileprivate(set) var flags: SCNetworkReachabilityFlags? { + didSet { + guard flags != oldValue else { return } + notifyReachabilityChanged() + } + } + + required public init(reachabilityRef: SCNetworkReachability, + queueQoS: DispatchQoS = .default, + targetQueue: DispatchQueue? = nil, + notificationQueue: DispatchQueue? = .main) { + self.allowsCellularConnection = true + self.reachabilityRef = reachabilityRef + self.reachabilitySerialQueue = DispatchQueue(label: "uk.co.ashleymills.reachability", qos: queueQoS, target: targetQueue) + self.notificationQueue = notificationQueue + } + + public convenience init(hostname: String, + queueQoS: DispatchQoS = .default, + targetQueue: DispatchQueue? = nil, + notificationQueue: DispatchQueue? = .main) throws { + guard let ref = SCNetworkReachabilityCreateWithName(nil, hostname) else { + throw ReachabilityError.failedToCreateWithHostname(hostname, SCError()) + } + self.init(reachabilityRef: ref, queueQoS: queueQoS, targetQueue: targetQueue, notificationQueue: notificationQueue) + } + + public convenience init(queueQoS: DispatchQoS = .default, + targetQueue: DispatchQueue? = nil, + notificationQueue: DispatchQueue? = .main) throws { + var zeroAddress = sockaddr() + zeroAddress.sa_len = UInt8(MemoryLayout.size) + zeroAddress.sa_family = sa_family_t(AF_INET) + + guard let ref = SCNetworkReachabilityCreateWithAddress(nil, &zeroAddress) else { + throw ReachabilityError.failedToCreateWithAddress(zeroAddress, SCError()) + } + + self.init(reachabilityRef: ref, queueQoS: queueQoS, targetQueue: targetQueue, notificationQueue: notificationQueue) + } + + deinit { + stopNotifier() + } +} + +public extension Reachability { + + // MARK: - *** Notifier methods *** + func startNotifier() throws { + guard !notifierRunning else { return } + + let callback: SCNetworkReachabilityCallBack = { (reachability, flags, info) in + guard let info = info else { return } + + // `weakifiedReachability` is guaranteed to exist by virtue of our + // retain/release callbacks which we provided to the `SCNetworkReachabilityContext`. + let weakifiedReachability = Unmanaged.fromOpaque(info).takeUnretainedValue() + + // The weak `reachability` _may_ no longer exist if the `Reachability` + // object has since been deallocated but a callback was already in flight. + weakifiedReachability.reachability?.flags = flags + } + + let weakifiedReachability = ReachabilityWeakifier(reachability: self) + let opaqueWeakifiedReachability = Unmanaged.passUnretained(weakifiedReachability).toOpaque() + + var context = SCNetworkReachabilityContext( + version: 0, + info: UnsafeMutableRawPointer(opaqueWeakifiedReachability), + retain: { (info: UnsafeRawPointer) -> UnsafeRawPointer in + let unmanagedWeakifiedReachability = Unmanaged.fromOpaque(info) + _ = unmanagedWeakifiedReachability.retain() + return UnsafeRawPointer(unmanagedWeakifiedReachability.toOpaque()) + }, + release: { (info: UnsafeRawPointer) -> Void in + let unmanagedWeakifiedReachability = Unmanaged.fromOpaque(info) + unmanagedWeakifiedReachability.release() + }, + copyDescription: { (info: UnsafeRawPointer) -> Unmanaged in + let unmanagedWeakifiedReachability = Unmanaged.fromOpaque(info) + let weakifiedReachability = unmanagedWeakifiedReachability.takeUnretainedValue() + let description = weakifiedReachability.reachability?.description ?? "nil" + return Unmanaged.passRetained(description as CFString) + } + ) + + if !SCNetworkReachabilitySetCallback(reachabilityRef, callback, &context) { + stopNotifier() + throw ReachabilityError.unableToSetCallback(SCError()) + } + + if !SCNetworkReachabilitySetDispatchQueue(reachabilityRef, reachabilitySerialQueue) { + stopNotifier() + throw ReachabilityError.unableToSetDispatchQueue(SCError()) + } + + // Perform an initial check + try setReachabilityFlags() + + notifierRunning = true + } + + func stopNotifier() { + defer { notifierRunning = false } + + SCNetworkReachabilitySetCallback(reachabilityRef, nil, nil) + SCNetworkReachabilitySetDispatchQueue(reachabilityRef, nil) + } + + // MARK: - *** Connection test methods *** + @available(*, deprecated, message: "Please use `connection != .none`") + var isReachable: Bool { + return connection != .unavailable + } + + @available(*, deprecated, message: "Please use `connection == .cellular`") + var isReachableViaWWAN: Bool { + // Check we're not on the simulator, we're REACHABLE and check we're on WWAN + return connection == .cellular + } + + @available(*, deprecated, message: "Please use `connection == .wifi`") + var isReachableViaWiFi: Bool { + return connection == .wifi + } + + var description: String { + return flags?.description ?? "unavailable flags" + } +} + +fileprivate extension Reachability { + + func setReachabilityFlags() throws { + try reachabilitySerialQueue.sync { [unowned self] in + var flags = SCNetworkReachabilityFlags() + if !SCNetworkReachabilityGetFlags(self.reachabilityRef, &flags) { + self.stopNotifier() + throw ReachabilityError.unableToGetFlags(SCError()) + } + + self.flags = flags + } + } + + + func notifyReachabilityChanged() { + let notify = { [weak self] in + guard let self = self else { return } + self.connection != .unavailable ? self.whenReachable?(self) : self.whenUnreachable?(self) + self.notificationCenter.post(name: .reachabilityChanged, object: self) + } + + // notify on the configured `notificationQueue`, or the caller's (i.e. `reachabilitySerialQueue`) + notificationQueue?.async(execute: notify) ?? notify() + } +} + +extension SCNetworkReachabilityFlags { + + typealias Connection = Reachability.Connection + + var connection: Connection { + guard isReachableFlagSet else { return .unavailable } + + // If we're reachable, but not on an iOS device (i.e. simulator), we must be on WiFi + #if targetEnvironment(simulator) + return .wifi + #else + var connection = Connection.unavailable + + if !isConnectionRequiredFlagSet { + connection = .wifi + } + + if isConnectionOnTrafficOrDemandFlagSet { + if !isInterventionRequiredFlagSet { + connection = .wifi + } + } + + if isOnWWANFlagSet { + connection = .cellular + } + + return connection + #endif + } + + var isOnWWANFlagSet: Bool { + #if os(iOS) + return contains(.isWWAN) + #else + return false + #endif + } + var isReachableFlagSet: Bool { + return contains(.reachable) + } + var isConnectionRequiredFlagSet: Bool { + return contains(.connectionRequired) + } + var isInterventionRequiredFlagSet: Bool { + return contains(.interventionRequired) + } + var isConnectionOnTrafficFlagSet: Bool { + return contains(.connectionOnTraffic) + } + var isConnectionOnDemandFlagSet: Bool { + return contains(.connectionOnDemand) + } + var isConnectionOnTrafficOrDemandFlagSet: Bool { + return !intersection([.connectionOnTraffic, .connectionOnDemand]).isEmpty + } + var isTransientConnectionFlagSet: Bool { + return contains(.transientConnection) + } + var isLocalAddressFlagSet: Bool { + return contains(.isLocalAddress) + } + var isDirectFlagSet: Bool { + return contains(.isDirect) + } + var isConnectionRequiredAndTransientFlagSet: Bool { + return intersection([.connectionRequired, .transientConnection]) == [.connectionRequired, .transientConnection] + } + + var description: String { + let W = isOnWWANFlagSet ? "W" : "-" + let R = isReachableFlagSet ? "R" : "-" + let c = isConnectionRequiredFlagSet ? "c" : "-" + let t = isTransientConnectionFlagSet ? "t" : "-" + let i = isInterventionRequiredFlagSet ? "i" : "-" + let C = isConnectionOnTrafficFlagSet ? "C" : "-" + let D = isConnectionOnDemandFlagSet ? "D" : "-" + let l = isLocalAddressFlagSet ? "l" : "-" + let d = isDirectFlagSet ? "d" : "-" + + return "\(W)\(R) \(c)\(t)\(i)\(C)\(D)\(l)\(d)" + } +} + +/** + `ReachabilityWeakifier` weakly wraps the `Reachability` class + in order to break retain cycles when interacting with CoreFoundation. + + CoreFoundation callbacks expect a pair of retain/release whenever an + opaque `info` parameter is provided. These callbacks exist to guard + against memory management race conditions when invoking the callbacks. + + #### Race Condition + + If we passed `SCNetworkReachabilitySetCallback` a direct reference to our + `Reachability` class without also providing corresponding retain/release + callbacks, then a race condition can lead to crashes when: + - `Reachability` is deallocated on thread X + - A `SCNetworkReachability` callback(s) is already in flight on thread Y + + #### Retain Cycle + + If we pass `Reachability` to CoreFoundtion while also providing retain/ + release callbacks, we would create a retain cycle once CoreFoundation + retains our `Reachability` class. This fixes the crashes and his how + CoreFoundation expects the API to be used, but doesn't play nicely with + Swift/ARC. This cycle would only be broken after manually calling + `stopNotifier()` — `deinit` would never be called. + + #### ReachabilityWeakifier + + By providing both retain/release callbacks and wrapping `Reachability` in + a weak wrapper, we: + - interact correctly with CoreFoundation, thereby avoiding a crash. + See "Memory Management Programming Guide for Core Foundation". + - don't alter the public API of `Reachability.swift` in any way + - still allow for automatic stopping of the notifier on `deinit`. + */ +private class ReachabilityWeakifier { + weak var reachability: Reachability? + init(reachability: Reachability) { + self.reachability = reachability + } +} diff --git a/RSWeb/Sources/RSWeb/String+RSWeb.swift b/RSWeb/Sources/RSWeb/String+RSWeb.swift new file mode 100644 index 000000000..3bed1da06 --- /dev/null +++ b/RSWeb/Sources/RSWeb/String+RSWeb.swift @@ -0,0 +1,39 @@ +// +// String+RSWeb.swift +// RSWeb +// +// Created by Brent Simmons on 1/13/18. +// Copyright © 2018 Ranchero Software. All rights reserved. +// + +import Foundation + +public extension String { + + /// Escapes special HTML characters. + /// + /// Escaped characters are `&`, `<`, `>`, `"`, and `'`. + var escapedHTML: String { + var escaped = String() + + for char in self { + switch char { + case "&": + escaped.append("&") + case "<": + escaped.append("<") + case ">": + escaped.append(">") + case "\"": + escaped.append(""") + case "'": + escaped.append("'") + default: + escaped.append(char) + } + } + + return escaped + } + +} diff --git a/RSWeb/Sources/RSWeb/URL+RSWeb.swift b/RSWeb/Sources/RSWeb/URL+RSWeb.swift new file mode 100755 index 000000000..d9bc8790f --- /dev/null +++ b/RSWeb/Sources/RSWeb/URL+RSWeb.swift @@ -0,0 +1,90 @@ +// +// NSURL+RSWeb.swift +// RSWeb +// +// Created by Brent Simmons on 12/26/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +private struct URLConstants { + static let schemeHTTP = "http" + static let schemeHTTPS = "https" + static let prefixHTTP = "http://" + static let prefixHTTPS = "https://" +} + +public extension URL { + + func isHTTPSURL() -> Bool { + return self.scheme?.lowercased() == URLConstants.schemeHTTPS + } + + func isHTTPURL() -> Bool { + return self.scheme?.lowercased() == URLConstants.schemeHTTP + } + + func isHTTPOrHTTPSURL() -> Bool { + return self.isHTTPSURL() || self.isHTTPURL() + } + + func absoluteStringWithHTTPOrHTTPSPrefixRemoved() -> String? { + // Case-inensitive. Turns http://example.com/foo into example.com/foo + + if isHTTPSURL() { + return absoluteString.stringByRemovingCaseInsensitivePrefix(URLConstants.prefixHTTPS) + } + else if isHTTPURL() { + return absoluteString.stringByRemovingCaseInsensitivePrefix(URLConstants.prefixHTTP) + } + + return nil + } + + func appendingQueryItem(_ queryItem: URLQueryItem) -> URL? { + appendingQueryItems([queryItem]) + } + + func appendingQueryItems(_ queryItems: [URLQueryItem]) -> URL? { + guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { + return nil + } + + var newQueryItems = components.queryItems ?? [] + newQueryItems.append(contentsOf: queryItems) + components.queryItems = newQueryItems + + return components.url + } + + func preparedForOpeningInBrowser() -> URL? { + var urlString = absoluteString.replacingOccurrences(of: " ", with: "%20") + urlString = urlString.replacingOccurrences(of: "^", with: "%5E") + urlString = urlString.replacingOccurrences(of: "&", with: "&") + urlString = urlString.replacingOccurrences(of: "&", with: "&") + + return URL(string: urlString) + } + +} + +private extension String { + + func stringByRemovingCaseInsensitivePrefix(_ prefix: String) -> String { + // Returns self if it doesn’t have the given prefix. + + let lowerPrefix = prefix.lowercased() + let lowerSelf = self.lowercased() + + if (lowerSelf == lowerPrefix) { + return "" + } + if !lowerSelf.hasPrefix(lowerPrefix) { + return self + } + + let index = self.index(self.startIndex, offsetBy: prefix.count) + return String(self[.. Bool { + + // Do this *only* with https. And not even then if you can help it. + + let s = "\(username):\(password)" + guard let d = s.data(using: .utf8, allowLossyConversion: false) else { + return false + } + + let base64EncodedString = d.base64EncodedString() + let authorization = "Basic \(base64EncodedString)" + setValue(authorization, forHTTPHeaderField: HTTPRequestHeader.authorization) + + return true + } +} diff --git a/RSWeb/Sources/RSWeb/URLResponse+RSWeb.swift b/RSWeb/Sources/RSWeb/URLResponse+RSWeb.swift new file mode 100755 index 000000000..9e00b2cbb --- /dev/null +++ b/RSWeb/Sources/RSWeb/URLResponse+RSWeb.swift @@ -0,0 +1,45 @@ +// +// URLResponse+RSWeb.swift +// RSWeb +// +// Created by Brent Simmons on 8/14/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +public extension URLResponse { + + var statusIsOK: Bool { + return forcedStatusCode >= 200 && forcedStatusCode <= 299 + } + + var forcedStatusCode: Int { + + // Return actual statusCode or 0 if there isn’t one. + + if let response = self as? HTTPURLResponse { + return response.statusCode + } + return 0 + } +} + +public extension HTTPURLResponse { + + func valueForHTTPHeaderField(_ headerField: String) -> String? { + + // Case-insensitive. HTTP headers may not be in the case you expect. + + let lowerHeaderField = headerField.lowercased() + + for (key, value) in allHeaderFields { + + if lowerHeaderField == (key as? String)?.lowercased() { + return value as? String + } + } + + return nil + } +} diff --git a/RSWeb/Sources/RSWeb/UTS46/Data+Extensions.swift b/RSWeb/Sources/RSWeb/UTS46/Data+Extensions.swift new file mode 100644 index 000000000..140b6498b --- /dev/null +++ b/RSWeb/Sources/RSWeb/UTS46/Data+Extensions.swift @@ -0,0 +1,21 @@ +// +// Data+Extensions.swift +// PunyCocoa Swift +// +// Created by Nate Weaver on 2020-04-12. +// + +import Foundation +import zlib + +extension Data { + + var crc32: UInt32 { + return self.withUnsafeBytes { + let buffer = $0.bindMemory(to: UInt8.self) + let initial = zlib.crc32(0, nil, 0) + return UInt32(zlib.crc32(initial, buffer.baseAddress, numericCast(buffer.count))) + } + } + +} diff --git a/RSWeb/Sources/RSWeb/UTS46/Scanner+Extensions.swift b/RSWeb/Sources/RSWeb/UTS46/Scanner+Extensions.swift new file mode 100644 index 000000000..0ffb0c425 --- /dev/null +++ b/RSWeb/Sources/RSWeb/UTS46/Scanner+Extensions.swift @@ -0,0 +1,54 @@ +// +// Scanner+Extensions.swift +// PunyCocoa Swift +// +// Created by Nate Weaver on 2020-04-20. +// + +import Foundation + +// Wrapper functions for < 10.15 compatibility +// TODO: Remove when support for < 10.15 is dropped. +extension Scanner { + + func shimScanUpToCharacters(from set: CharacterSet) -> String? { + if #available(macOS 10.15, iOS 13.0, *) { + return self.scanUpToCharacters(from: set) + } else { + var str: NSString? + self.scanUpToCharacters(from: set, into: &str) + return str as String? + } + } + + func shimScanCharacters(from set: CharacterSet) -> String? { + if #available(macOS 10.15, iOS 13.0, *) { + return self.scanCharacters(from: set) + } else { + var str: NSString? + self.scanCharacters(from: set, into: &str) + return str as String? + } + } + + func shimScanUpToString(_ substring: String) -> String? { + if #available(macOS 10.15, iOS 13.0, *) { + return self.scanUpToString(substring) + } else { + var str: NSString? + self.scanUpTo(substring, into: &str) + return str as String? + } + } + + func shimScanString(_ searchString: String) -> String? { + if #available(macOS 10.15, iOS 13.0, *) { + return self.scanString(searchString) + } else { + var str: NSString? + self.scanString(searchString, into: &str) + return str as String? + } + } + +} diff --git a/RSWeb/Sources/RSWeb/UTS46/String+Punycode.swift b/RSWeb/Sources/RSWeb/UTS46/String+Punycode.swift new file mode 100644 index 000000000..a6afd15b0 --- /dev/null +++ b/RSWeb/Sources/RSWeb/UTS46/String+Punycode.swift @@ -0,0 +1,596 @@ +// +// String+Punycode.swift +// Punycode +// +// Created by Nate Weaver on 2020-03-16. +// + +import Foundation + +public extension String { + + /// The IDNA-encoded representation of a Unicode domain. + /// + /// This will properly split domains on periods; e.g., + /// "www.bücher.ch" becomes "www.xn--bcher-kva.ch". + var idnaEncoded: String? { + guard let mapped = try? self.mapUTS46() else { return nil } + + let nonASCII = CharacterSet(charactersIn: UnicodeScalar(0)...UnicodeScalar(127)).inverted + var result = "" + + let s = Scanner(string: mapped.precomposedStringWithCanonicalMapping) + let dotAt = CharacterSet(charactersIn: ".@") + + while !s.isAtEnd { + if let input = s.shimScanUpToCharacters(from: dotAt) { + if !input.isValidLabel { return nil } + + if input.rangeOfCharacter(from: nonASCII) != nil { + result.append("xn--") + + if let encoded = input.punycodeEncoded { + result.append(encoded) + } + } else { + result.append(input) + } + } + + if let input = s.shimScanCharacters(from: dotAt) { + result.append(input) + } + } + + return result + } + + /// The Unicode representation of an IDNA-encoded domain. + /// + /// This will properly split domains on periods; e.g., + /// "www.xn--bcher-kva.ch" becomes "www.bücher.ch". + var idnaDecoded: String? { + var result = "" + let s = Scanner(string: self) + let dotAt = CharacterSet(charactersIn: ".@") + + while !s.isAtEnd { + if let input = s.shimScanUpToCharacters(from: dotAt) { + if input.lowercased().hasPrefix("xn--") { + let start = input.index(input.startIndex, offsetBy: 4) + guard let substr = input[start...].punycodeDecoded else { return nil } + guard substr.isValidLabel else { return nil } + result.append(substr) + } else { + result.append(input) + } + } + + if let input = s.shimScanCharacters(from: dotAt) { + result.append(input) + } + } + + return result + } + + /// The IDNA- and percent-encoded representation of a URL string. + var encodedURLString: String? { + let urlParts = self.urlParts + var pathAndQuery = urlParts.pathAndQuery + + var allowedCharacters = CharacterSet.urlPathAllowed + allowedCharacters.insert(charactersIn: "%?") + pathAndQuery = pathAndQuery.addingPercentEncoding(withAllowedCharacters: allowedCharacters) ?? "" + + var result = "\(urlParts.scheme)\(urlParts.delim)" + + if let username = urlParts.username?.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) { + if let password = urlParts.password?.addingPercentEncoding(withAllowedCharacters: .urlPasswordAllowed) { + result.append("\(username):\(password)@") + } else { + result.append("\(username)@") + } + } + + guard let host = urlParts.host.idnaEncoded else { return nil } + + result.append("\(host)\(pathAndQuery)") + + if var fragment = urlParts.fragment { + var fragmentAlloweCharacters = CharacterSet.urlFragmentAllowed + fragmentAlloweCharacters.insert(charactersIn: "%") + fragment = fragment.addingPercentEncoding(withAllowedCharacters: fragmentAlloweCharacters) ?? "" + + result.append("#\(fragment)") + } + + return result + } + + /// The Unicode representation of an IDNA- and percent-encoded URL string. + var decodedURLString: String? { + let urlParts = self.urlParts + var usernamePassword = "" + + if let username = urlParts.username?.removingPercentEncoding { + if let password = urlParts.password?.removingPercentEncoding { + usernamePassword = "\(username):\(password)@" + } else { + usernamePassword = "\(username)@" + } + } + + guard let host = urlParts.host.idnaDecoded else { return nil } + + var result = "\(urlParts.scheme)\(urlParts.delim)\(usernamePassword)\(host)\(urlParts.pathAndQuery.removingPercentEncoding ?? "")" + + if let fragment = urlParts.fragment?.removingPercentEncoding { + result.append("#\(fragment)") + } + + return result + } + +} + +public extension URL { + + /// Initializes a URL with a Unicode URL string. + /// + /// If `unicodeString` can be successfully encoded, equivalent to + /// + /// ``` + /// URL(string: unicodeString.encodedURLString!) + /// ``` + /// + /// - Parameter unicodeString: The unicode URL string with which to create a URL. + init?(unicodeString: String) { + if let url = URL(string: unicodeString) { + self = url + return + } + + guard let encodedString = unicodeString.encodedURLString else { return nil } + self.init(string: encodedString) + } + + /// The IDNA- and percent-decoded representation of the URL. + /// + /// Equivalent to + /// + /// ``` + /// self.absoluteString.decodedURLString + /// ``` + var decodedURLString: String? { + return self.absoluteString.decodedURLString + } + + /// Initializes a URL from a relative Unicode string and a base URL. + /// - Parameters: + /// - unicodeString: The URL string with which to initialize the NSURL object. `unicodeString` is interpreted relative to `baseURL`. + /// - url: The base URL for the URL object + init?(unicodeString: String, relativeTo url: URL?) { + if let url = URL(string: unicodeString, relativeTo: url) { + self = url + return + } + + let parts = unicodeString.urlParts + + if !parts.host.isEmpty { + guard let encodedString = unicodeString.encodedURLString else { return nil } + self.init(string: encodedString, relativeTo: url) + } else { + var allowedCharacters = CharacterSet.urlPathAllowed + allowedCharacters.insert(charactersIn: "%?#") + guard let encoded = unicodeString.addingPercentEncoding(withAllowedCharacters: allowedCharacters) else { return nil } + self.init(string: encoded, relativeTo: url) + } + } + +} + +private extension StringProtocol { + + /// Punycode-encodes a string. + /// + /// Returns `nil` on error. + /// - Todo: Throw errors on failure instead of returning `nil`. + var punycodeEncoded: String? { + var result = "" + let scalars = self.unicodeScalars + let inputLength = scalars.count + + var n = Punycode.initialN + var delta: UInt32 = 0 + var outLen: UInt32 = 0 + var bias = Punycode.initialBias + + for scalar in scalars where scalar.isASCII { + result.unicodeScalars.append(scalar) + outLen += 1 + } + + let b: UInt32 = outLen + var h: UInt32 = outLen + + if b > 0 { + result.append(Punycode.delimiter) + } + + // Main encoding loop: + + while h < inputLength { + var m = UInt32.max + + for c in scalars { + if c.value >= n && c.value < m { + m = c.value + } + } + + if m - n > (UInt32.max - delta) / (h + 1) { + return nil // overflow + } + + delta += (m - n) * (h + 1) + n = m + + for c in scalars { + + if c.value < n { + delta += 1 + + if delta == 0 { + return nil // overflow + } + } + + if c.value == n { + var q = delta + var k = Punycode.base + + while true { + let t = k <= bias ? Punycode.tmin : + k >= bias + Punycode.tmax ? Punycode.tmax : k - bias + + if q < t { + break + } + + let encodedDigit = Punycode.encodeDigit(t + (q - t) % (Punycode.base - t), flag: false) + + result.unicodeScalars.append(UnicodeScalar(encodedDigit)!) + q = (q - t) / (Punycode.base - t) + + k += Punycode.base + } + + result.unicodeScalars.append(UnicodeScalar(Punycode.encodeDigit(q, flag: false))!) + bias = Punycode.adapt(delta: delta, numPoints: h + 1, firstTime: h == b) + delta = 0 + h += 1 + } + } + + delta += 1 + n += 1 + } + + return result + } + + /// Punycode-decodes a string. + /// + /// Returns `nil` on error. + /// - Todo: Throw errors on failure instead of returning `nil`. + var punycodeDecoded: String? { + var result = "" + let scalars = self.unicodeScalars + + let endIndex = scalars.endIndex + var n = Punycode.initialN + var outLen: UInt32 = 0 + var i: UInt32 = 0 + var bias = Punycode.initialBias + + var b = scalars.startIndex + + for j in scalars.indices { + if Character(self.unicodeScalars[j]) == Punycode.delimiter { + b = j + break + } + } + + for j in scalars.indices { + if j >= b { + break + } + + let scalar = scalars[j] + + if !scalar.isASCII { + return nil // bad input + } + + result.unicodeScalars.append(scalar) + outLen += 1 + + } + + var inPos = b > scalars.startIndex ? scalars.index(after: b) : scalars.startIndex + + while inPos < endIndex { + + var k = Punycode.base + var w: UInt32 = 1 + let oldi = i + + while true { + if inPos >= endIndex { + return nil // bad input + } + + let digit = Punycode.decodeDigit(scalars[inPos].value) + + inPos = scalars.index(after: inPos) + + if digit >= Punycode.base { return nil } // bad input + if digit > (UInt32.max - i) / w { return nil } // overflow + + i += digit * w + let t = k <= bias ? Punycode.tmin : + k >= bias + Punycode.tmax ? Punycode.tmax : k - bias + + if digit < t { + break + } + + if w > UInt32.max / (Punycode.base - t) { return nil } // overflow + + w *= Punycode.base - t + + k += Punycode.base + } + + bias = Punycode.adapt(delta: i - oldi, numPoints: outLen + 1, firstTime: oldi == 0) + + if i / (outLen + 1) > UInt32.max - n { return nil } // overflow + + n += i / (outLen + 1) + i %= outLen + 1 + + let index = result.unicodeScalars.index(result.unicodeScalars.startIndex, offsetBy: Int(i)) + result.unicodeScalars.insert(UnicodeScalar(n)!, at: index) + + outLen += 1 + i += 1 + } + + return result + } + +} + +private extension String { + + var urlParts: URLParts { + let colonSlash = CharacterSet(charactersIn: ":/") + let slashQuestion = CharacterSet(charactersIn: "/?") + let s = Scanner(string: self) + var scheme = "" + var delim = "" + var host = "" + var path = "" + var username: String? + var password: String? + var fragment: String? + + if let hostOrScheme = s.shimScanUpToCharacters(from: colonSlash) { + let maybeDelim = s.shimScanCharacters(from: colonSlash) ?? "" + + if maybeDelim.hasPrefix(":") { + delim = maybeDelim + scheme = hostOrScheme + host = s.shimScanUpToCharacters(from: slashQuestion) ?? "" + } else { + path.append(hostOrScheme) + path.append(maybeDelim) + } + } else if let maybeDelim = s.shimScanString("//") { + delim = maybeDelim + + if let maybeHost = s.shimScanUpToCharacters(from: slashQuestion) { + host = maybeHost + } + } + + path.append(s.shimScanUpToString("#") ?? "") + + if s.shimScanString("#") != nil { + fragment = s.shimScanUpToCharacters(from: .newlines) ?? "" + } + + let usernamePasswordHostPort = host.components(separatedBy: "@") + + switch usernamePasswordHostPort.count { + case 1: + host = usernamePasswordHostPort[0] + case 0: + break // error + default: + let usernamePassword = usernamePasswordHostPort[0].components(separatedBy: ":") + username = usernamePassword[0] + password = usernamePassword.count > 1 ? usernamePassword[1] : nil + host = usernamePasswordHostPort[1] + } + + return URLParts(scheme: scheme, delim: delim, host: host, pathAndQuery: path, username: username, password: password, fragment: fragment) + } + + enum UTS46MapError: Error { + /// A disallowed codepoint was found in the string. + case disallowedCodepoint(scalar: UnicodeScalar) + } + + /// Perform a single-pass mapping using UTS #46. + /// + /// - Returns: The mapped string. + /// - Throws: `UTS46Error`. + func mapUTS46() throws -> String { + try UTS46.loadIfNecessary() + + var result = "" + + for scalar in self.unicodeScalars { + if UTS46.disallowedCharacters.contains(scalar) { + throw UTS46MapError.disallowedCodepoint(scalar: scalar) + } + + if UTS46.ignoredCharacters.contains(scalar) { + continue + } + + if let mapped = UTS46.characterMap[scalar.value] { + result.append(mapped) + } else { + result.unicodeScalars.append(scalar) + } + } + + return result + } + + var isValidLabel: Bool { + guard self.precomposedStringWithCanonicalMapping.unicodeScalars.elementsEqual(self.unicodeScalars) else { return false } + + guard (try? self.mapUTS46()) != nil else { return false } + + if let category = self.unicodeScalars.first?.properties.generalCategory { + if category == .nonspacingMark || category == .spacingMark || category == .enclosingMark { return false } + } + + return self.hasValidJoiners + } + + /// Whether a string's joiners (if any) are valid according to IDNA 2008 ContextJ. + /// + /// See [RFC 5892, Appendix A.1 and A.2](https://tools.ietf.org/html/rfc5892#appendix-A). + var hasValidJoiners: Bool { + try! UTS46.loadIfNecessary() + + let scalars = self.unicodeScalars + + for index in scalars.indices { + let scalar = scalars[index] + + if scalar.value == 0x200C { // Zero-width non-joiner + if index == scalars.indices.first { return false } + + var subindex = scalars.index(before: index) + var previous = scalars[subindex] + + if previous.properties.canonicalCombiningClass == .virama { continue } + + while true { + guard let joiningType = UTS46.joiningTypes[previous.value] else { return false } + + if joiningType == .transparent { + if subindex == scalars.startIndex { + return false + } + + subindex = scalars.index(before: subindex) + previous = scalars[subindex] + } else if joiningType == .dual || joiningType == .left { + break + } else { + return false + } + } + + subindex = scalars.index(after: index) + var next = scalars[subindex] + + while true { + if subindex == scalars.endIndex { + return false + } + + guard let joiningType = UTS46.joiningTypes[next.value] else { return false } + + if joiningType == .transparent { + subindex = scalars.index(after: index) + next = scalars[subindex] + } else if joiningType == .right || joiningType == .dual { + break + } else { + return false + } + } + } else if scalar.value == 0x200D { // Zero-width joiner + if index == scalars.startIndex { return false } + + let subindex = scalars.index(before: index) + let previous = scalars[subindex] + + if previous.properties.canonicalCombiningClass != .virama { return false } + } + } + + return true + } + +} + +private enum Punycode { + static let base = UInt32(36) + static let tmin = UInt32(1) + static let tmax = UInt32(26) + static let skew = UInt32(38) + static let damp = UInt32(700) + static let initialBias = UInt32(72) + static let initialN = UInt32(0x80) + static let delimiter: Character = "-" + + static func decodeDigit(_ cp: UInt32) -> UInt32 { + return cp &- 48 < 10 ? cp &- 22 : cp &- 65 < 26 ? cp &- 65 : + cp &- 97 < 26 ? cp &- 97 : Self.base + } + + static func encodeDigit(_ d: UInt32, flag: Bool) -> UInt32 { + return d + 22 + 75 * UInt32(d < 26 ? 1 : 0) - ((flag ? 1 : 0) << 5) + } + + static let maxint = UInt32.max + + static func adapt(delta: UInt32, numPoints: UInt32, firstTime: Bool) -> UInt32 { + + var delta = delta + + delta = firstTime ? delta / Self.damp : delta >> 1 + delta += delta / numPoints + + var k: UInt32 = 0 + + while delta > ((Self.base - Self.tmin) * Self.tmax) / 2 { + delta /= Self.base - Self.tmin + k += Self.base + } + + return k + (Self.base - Self.tmin + 1) * delta / (delta + Self.skew) + } +} + +private struct URLParts { + var scheme: String + var delim: String + var host: String + var pathAndQuery: String + + var username: String? + var password: String? + var fragment: String? +} diff --git a/RSWeb/Sources/RSWeb/UTS46/UTS46+Loading.swift b/RSWeb/Sources/RSWeb/UTS46/UTS46+Loading.swift new file mode 100644 index 000000000..3c6f98144 --- /dev/null +++ b/RSWeb/Sources/RSWeb/UTS46/UTS46+Loading.swift @@ -0,0 +1,227 @@ +// +// UTS46+Loading.swift +// icumap2code +// +// Created by Nate Weaver on 2020-05-08. +// + +import Foundation +import Compression + +extension UTS46 { + + private static func parseHeader(from data: Data) throws -> Header? { + let headerData = data.prefix(8) + + guard headerData.count == 8 else { throw UTS46Error.badSize } + + return Header(rawValue: headerData) + } + + static func load(from url: URL) throws { + let fileData = try Data(contentsOf: url) + + guard let header = try? parseHeader(from: fileData) else { return } + + guard header.version == 1 else { throw UTS46Error.unknownVersion } + + let offset = header.dataOffset + + guard fileData.count > offset else { throw UTS46Error.badSize } + + let compressedData = fileData[offset...] + + guard let data = self.decompress(data: compressedData, algorithm: header.compression) else { + throw UTS46Error.decompressionError + } + + var index = 0 + + while index < data.count { + let marker = data[index] + + index += 1 + + switch marker { + case Marker.characterMap: + index = parseCharacterMap(from: data, start: index) + case Marker.ignoredCharacters: + index = parseIgnoredCharacters(from: data, start: index) + case Marker.disallowedCharacters: + index = parseDisallowedCharacters(from: data, start: index) + case Marker.joiningTypes: + index = parseJoiningTypes(from: data, start: index) + default: + throw UTS46Error.badMarker + } + } + + isLoaded = true + } + + static var bundle: Bundle { + #if SWIFT_PACKAGE + return Bundle.module + #else + return Bundle(for: Self.self) + #endif + } + + static func loadIfNecessary() throws { + guard !isLoaded else { return } + guard let url = Self.bundle.url(forResource: "uts46", withExtension: nil) else { throw CocoaError(.fileNoSuchFile) } + + try load(from: url) + } + + private static func decompress(data: Data, algorithm: CompressionAlgorithm?) -> Data? { + + guard let rawAlgorithm = algorithm?.rawAlgorithm else { return data } + + let capacity = 131_072 // 128 KB + let destinationBuffer = UnsafeMutablePointer.allocate(capacity: capacity) + + let decompressed = data.withUnsafeBytes { (rawBuffer) -> Data? in + let bound = rawBuffer.bindMemory(to: UInt8.self) + let decodedCount = compression_decode_buffer(destinationBuffer, capacity, bound.baseAddress!, rawBuffer.count, nil, rawAlgorithm) + + if decodedCount == 0 || decodedCount == capacity { + return nil + } + + return Data(bytes: destinationBuffer, count: decodedCount) + } + + return decompressed + } + + private static func parseCharacterMap(from data: Data, start: Int) -> Int { + characterMap.removeAll() + var index = start + + main: while index < data.count { + var accumulator = Data() + + while data[index] != Marker.sequenceTerminator { + if data[index] > Marker.min { break main } + + accumulator.append(data[index]) + index += 1 + } + + let str = String(data: accumulator, encoding: .utf8)! + + // FIXME: throw an error here. + guard str.count > 0 else { continue } + + let codepoint = str.unicodeScalars.first!.value + + characterMap[codepoint] = String(str.unicodeScalars.dropFirst()) + + index += 1 + } + + return index + } + + private static func parseRanges(from: String) -> [ClosedRange]? { + guard from.unicodeScalars.count % 2 == 0 else { return nil } + + var ranges = [ClosedRange]() + var first: UnicodeScalar? + + for (index, scalar) in from.unicodeScalars.enumerated() { + if index % 2 == 0 { + first = scalar + } else if let first = first { + ranges.append(first...scalar) + } + } + + return ranges + } + + static func parseCharacterSet(from data: Data, start: Int) -> (index: Int, charset: CharacterSet?) { + var index = start + var accumulator = Data() + + while index < data.count, data[index] < Marker.min { + accumulator.append(data[index]) + index += 1 + } + + let str = String(data: accumulator, encoding: .utf8)! + + guard let ranges = parseRanges(from: str) else { + return (index: index, charset: nil) + } + + var charset = CharacterSet() + + for range in ranges { + charset.insert(charactersIn: range) + } + + return (index: index, charset: charset) + } + + static func parseIgnoredCharacters(from data: Data, start: Int) -> Int { + let (index, charset) = parseCharacterSet(from: data, start: start) + + if let charset = charset { + ignoredCharacters = charset + } + + return index + } + + static func parseDisallowedCharacters(from data: Data, start: Int) -> Int { + let (index, charset) = parseCharacterSet(from: data, start: start) + + if let charset = charset { + disallowedCharacters = charset + } + + return index + } + + static func parseJoiningTypes(from data: Data, start: Int) -> Int { + var index = start + joiningTypes.removeAll() + + main: while index < data.count, data[index] < Marker.min { + var accumulator = Data() + + while index < data.count { + if data[index] > Marker.min { break main } + accumulator.append(data[index]) + + index += 1 + } + + let str = String(data: accumulator, encoding: .utf8)! + + var type: JoiningType? + var first: UnicodeScalar? + + for scalar in str.unicodeScalars { + if scalar.isASCII { + type = JoiningType(rawValue: Character(scalar)) + } else if let type = type { + if first == nil { + first = scalar + } else { + for value in first!.value...scalar.value { + joiningTypes[value] = type + } + + first = nil + } + } + } + } + + return index + } + +} diff --git a/RSWeb/Sources/RSWeb/UTS46/UTS46.swift b/RSWeb/Sources/RSWeb/UTS46/UTS46.swift new file mode 100644 index 000000000..fa7a2faaa --- /dev/null +++ b/RSWeb/Sources/RSWeb/UTS46/UTS46.swift @@ -0,0 +1,189 @@ +// +// UTS46.swift +// PunyCocoa Swift +// +// Created by Nate Weaver on 2020-03-29. +// + +import Foundation +import Compression + +/// UTS46 mapping. +/// +/// Storage file format. Codepoints are stored UTF-8-encoded. +/// +/// All multibyte integers are little-endian. +/// +/// Header: +/// +/// +--------------+---------+---------+---------+ +/// | 6 bytes | 1 byte | 1 byte | 4 bytes | +/// +--------------+---------+---------+---------+ +/// | magic number | version | flags | crc32 | +/// +--------------+---------+---------+---------+ +/// +/// - `magic number`: `"UTS#46"` (`0x55 0x54 0x53 0x23 0x34 0x36`). +/// - `version`: format version (1 byte; currently `0x01`). +/// - `flags`: Bitfield: +/// +/// +-----+-----+-----+-----+-----+-----+-----+-----+ +/// | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | +/// +-----+-----+-----+-----+-----+-----+-----+-----+ +/// | currently unused | crc | compression | +/// +-----+-----+-----+-----+-----+-----+-----+-----+ +/// +/// - `crc`: Contains a CRC32 of the data after the header. +/// - `compression`: compression mode of the data. +/// Currently identical to NSData's compression constants + 1: +/// +/// - 0: no compression +/// - 1: LZFSE +/// - 2: LZ4 +/// - 3: LZMA +/// - 4: ZLIB +/// +/// - `crc32`: CRC32 of the (possibly compressed) data. Implementations can skip +/// parsing this unless data integrity is an issue. +/// +/// The data section is a collection of data blocks of the format +/// +/// [marker][section data] ... +/// +/// Section data formats: +/// +/// If marker is `characterMap`: +/// +/// [codepoint][mapped-codepoint ...][null] ... +/// +/// If marker is `disallowedCharacters` or `ignoredCharacters`: +/// +/// [codepoint-range] ... +/// +/// If marker is `joiningTypes`: +/// +/// [type][[codepoint-range] ...] +/// +/// where `type` is one of `C`, `D`, `L`, `R`, or `T`. +/// +/// `codepoint-range`: two codepoints, marking the first and last codepoints of a +/// closed range. Single-codepoint ranges have the same start and end codepoint. +/// +class UTS46 { + + static var characterMap: [UInt32: String] = [:] + static var ignoredCharacters: CharacterSet = [] + static var disallowedCharacters: CharacterSet = [] + static var joiningTypes = [UInt32: JoiningType]() + + static var isLoaded = false + + enum Marker { + static let characterMap = UInt8.max + static let ignoredCharacters = UInt8.max - 1 + static let disallowedCharacters = UInt8.max - 2 + static let joiningTypes = UInt8.max - 3 + + static let min = UInt8.max - 10 // No valid UTF-8 byte can fall here. + + static let sequenceTerminator: UInt8 = 0 + } + + enum JoiningType: Character { + case causing = "C" + case dual = "D" + case right = "R" + case left = "L" + case transparent = "T" + } + + enum UTS46Error: Error { + case badSize + case compressionError + case decompressionError + case badMarker + case unknownVersion + } + + /// Identical values to `NSData.CompressionAlgorithm + 1`. + enum CompressionAlgorithm: UInt8 { + case none = 0 + case lzfse = 1 + case lz4 = 2 + case lzma = 3 + case zlib = 4 + + var rawAlgorithm: compression_algorithm? { + switch self { + case .lzfse: + return COMPRESSION_LZFSE + case .lz4: + return COMPRESSION_LZ4 + case .lzma: + return COMPRESSION_LZMA + case .zlib: + return COMPRESSION_ZLIB + default: + return nil + } + } + } + + struct Header: RawRepresentable, CustomDebugStringConvertible { + typealias RawValue = [UInt8] + + var rawValue: [UInt8] { + let value = Self.signature + [version, flags.rawValue] + assert(value.count == 8) + return value + } + + private static let compressionMask: UInt8 = 0x07 + private static let signature: [UInt8] = Array("UTS#46".utf8) + + private struct Flags: RawRepresentable { + var rawValue: UInt8 { + return (hasCRC ? hasCRCMask : 0) | compression.rawValue + } + + var hasCRC: Bool + var compression: CompressionAlgorithm + + private let hasCRCMask: UInt8 = 1 << 3 + private let compressionMask: UInt8 = 0x7 + + init(rawValue: UInt8) { + hasCRC = rawValue & hasCRCMask != 0 + let compressionBits = rawValue & compressionMask + + compression = CompressionAlgorithm(rawValue: compressionBits) ?? .none + } + + init(compression: CompressionAlgorithm = .none, hasCRC: Bool = false) { + self.compression = compression + self.hasCRC = hasCRC + } + } + + let version: UInt8 + private var flags: Flags + var hasCRC: Bool { flags.hasCRC } + var compression: CompressionAlgorithm { flags.compression } + var dataOffset: Int { 8 + (flags.hasCRC ? 4 : 0) } + + init?(rawValue: T) where T.Index == Int { + guard rawValue.count == 8 else { return nil } + guard rawValue.prefix(Self.signature.count).elementsEqual(Self.signature) else { return nil } + + version = rawValue[rawValue.index(rawValue.startIndex, offsetBy: 6)] + flags = Flags(rawValue: rawValue[rawValue.index(rawValue.startIndex, offsetBy: 7)]) + } + + init(compression: CompressionAlgorithm = .none, hasCRC: Bool = false) { + self.version = 1 + self.flags = Flags(compression: compression, hasCRC: hasCRC) + } + + var debugDescription: String { "has CRC: \(hasCRC); compression: \(String(describing: compression))" } + } + +} diff --git a/RSWeb/Sources/RSWeb/UTS46/uts46 b/RSWeb/Sources/RSWeb/UTS46/uts46 new file mode 100644 index 000000000..101001987 Binary files /dev/null and b/RSWeb/Sources/RSWeb/UTS46/uts46 differ diff --git a/RSWeb/Sources/RSWeb/UserAgent.swift b/RSWeb/Sources/RSWeb/UserAgent.swift new file mode 100755 index 000000000..0a851c51d --- /dev/null +++ b/RSWeb/Sources/RSWeb/UserAgent.swift @@ -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] + } +} diff --git a/RSWeb/Sources/RSWeb/WebServices/Transport.swift b/RSWeb/Sources/RSWeb/WebServices/Transport.swift new file mode 100644 index 000000000..d9d78d8ca --- /dev/null +++ b/RSWeb/Sources/RSWeb/WebServices/Transport.swift @@ -0,0 +1,235 @@ +// +// Transport.swift +// RSWeb +// +// Created by Maurice Parker on 5/4/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// +// Inspired by: http://robnapier.net/a-mockery-of-protocols + +import Foundation + +public enum TransportError: LocalizedError { + case noData + case noURL + case suspended + case httpError(status: Int) + + public var errorDescription: String? { + switch self { + case .httpError(let status): + switch status { + case 400: + return NSLocalizedString("Bad Request", comment: "Bad Request") + case 401: + return NSLocalizedString("Unauthorized", comment: "Unauthorized") + case 402: + return NSLocalizedString("Payment Required", comment: "Payment Required") + case 403: + return NSLocalizedString("Forbidden", comment: "Forbidden") + case 404: + return NSLocalizedString("Not Found", comment: "Not Found") + case 405: + return NSLocalizedString("Method Not Allowed", comment: "Method Not Allowed") + case 406: + return NSLocalizedString("Not Acceptable", comment: "Not Acceptable") + case 407: + return NSLocalizedString("Proxy Authentication Required", comment: "Proxy Authentication Required") + case 408: + return NSLocalizedString("Request Timeout", comment: "Request Timeout") + case 409: + return NSLocalizedString("Conflict", comment: "Conflict") + case 410: + return NSLocalizedString("Gone", comment: "Gone") + case 411: + return NSLocalizedString("Length Required", comment: "Length Required") + case 412: + return NSLocalizedString("Precondition Failed", comment: "Precondition Failed") + case 413: + return NSLocalizedString("Payload Too Large", comment: "Payload Too Large") + case 414: + return NSLocalizedString("Request-URI Too Long", comment: "Request-URI Too Long") + case 415: + return NSLocalizedString("Unsupported Media Type", comment: "Unsupported Media Type") + case 416: + return NSLocalizedString("Requested Range Not Satisfiable", comment: "Requested Range Not Satisfiable") + case 417: + return NSLocalizedString("Expectation Failed", comment: "Expectation Failed") + case 418: + return NSLocalizedString("I'm a teapot", comment: "I'm a teapot") + case 421: + return NSLocalizedString("Misdirected Request", comment: "Misdirected Request") + case 422: + return NSLocalizedString("Unprocessable Entity", comment: "Unprocessable Entity") + case 423: + return NSLocalizedString("Locked", comment: "Locked") + case 424: + return NSLocalizedString("Failed Dependency", comment: "Failed Dependency") + case 426: + return NSLocalizedString("Upgrade Required", comment: "Upgrade Required") + case 428: + return NSLocalizedString("Precondition Required", comment: "Precondition Required") + case 429: + return NSLocalizedString("Too Many Requests", comment: "Too Many Requests") + case 431: + return NSLocalizedString("Request Header Fields Too Large", comment: "Request Header Fields Too Large") + case 444: + return NSLocalizedString("Connection Closed Without Response", comment: "Connection Closed Without Response") + case 451: + return NSLocalizedString("Unavailable For Legal Reasons", comment: "Unavailable For Legal Reasons") + case 499: + return NSLocalizedString("Client Closed Request", comment: "Client Closed Request") + case 500: + return NSLocalizedString("Internal Server Error", comment: "Internal Server Error") + case 501: + return NSLocalizedString("Not Implemented", comment: "Not Implemented") + case 502: + return NSLocalizedString("Bad Gateway", comment: "Bad Gateway") + case 503: + return NSLocalizedString("Service Unavailable", comment: "Service Unavailable") + case 504: + return NSLocalizedString("Gateway Timeout", comment: "Gateway Timeout") + case 505: + return NSLocalizedString("HTTP Version Not Supported", comment: "HTTP Version Not Supported") + case 506: + return NSLocalizedString("Variant Also Negotiates", comment: "Variant Also Negotiates") + case 507: + return NSLocalizedString("Insufficient Storage", comment: "Insufficient Storage") + case 508: + return NSLocalizedString("Loop Detected", comment: "Loop Detected") + case 510: + return NSLocalizedString("Not Extended", comment: "Not Extended") + case 511: + return NSLocalizedString("Network Authentication Required", comment: "Network Authentication Required") + case 599: + return NSLocalizedString("Network Connect Timeout Error", comment: "Network Connect Timeout Error") + default: + let msg = NSLocalizedString("HTTP Status: ", comment: "Unexpected error") + return "\(msg) \(status)" + } + default: + return NSLocalizedString("An unknown network error occurred.", comment: "Unknown error") + } + } + +} + +public protocol Transport { + + /// Cancels all pending requests + func cancelAll() + + /// Sends URLRequest and returns the HTTP headers and the data payload. + func send(request: URLRequest, completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> Void) + + /// Sends URLRequest that doesn't require any result information. + func send(request: URLRequest, method: String, completion: @escaping (Result) -> Void) + + /// Sends URLRequest with a data payload and returns the HTTP headers and the data payload. + func send(request: URLRequest, method: String, payload: Data, completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> Void) + +} + +extension URLSession: Transport { + + public func cancelAll() { + getTasksWithCompletionHandler { dataTasks, uploadTasks, downloadTasks in + dataTasks.forEach { $0.cancel() } + uploadTasks.forEach { $0.cancel() } + downloadTasks.forEach { $0.cancel() } + } + } + + public func send(request: URLRequest, completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> Void) { + let task = self.dataTask(with: request) { (data, response, error) in + DispatchQueue.main.async { + if let error = error { + return completion(.failure(error)) + } + + guard let response = response as? HTTPURLResponse, let data = data else { + return completion(.failure(TransportError.noData)) + } + + switch response.forcedStatusCode { + case 200...399: + completion(.success((response, data))) + default: + completion(.failure(TransportError.httpError(status: response.forcedStatusCode))) + } + } + } + task.resume() + } + + public func send(request: URLRequest, method: String, completion: @escaping (Result) -> Void) { + + var sendRequest = request + sendRequest.httpMethod = method + + let task = self.dataTask(with: sendRequest) { (data, response, error) in + DispatchQueue.main.async { + if let error = error { + return completion(.failure(error)) + } + + guard let response = response as? HTTPURLResponse else { + return completion(.failure(TransportError.noData)) + } + + switch response.forcedStatusCode { + case 200...399: + completion(.success(())) + default: + completion(.failure(TransportError.httpError(status: response.forcedStatusCode))) + } + } + } + task.resume() + } + + public func send(request: URLRequest, method: String, payload: Data, completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> Void) { + + var sendRequest = request + sendRequest.httpMethod = method + + let task = self.uploadTask(with: sendRequest, from: payload) { (data, response, error) in + DispatchQueue.main.async { + if let error = error { + return completion(.failure(error)) + } + + guard let response = response as? HTTPURLResponse, let data = data else { + return completion(.failure(TransportError.noData)) + } + + switch response.forcedStatusCode { + case 200...399: + completion(.success((response, data))) + default: + completion(.failure(TransportError.httpError(status: response.forcedStatusCode))) + } + + } + } + task.resume() + } + + public static func webserviceTransport() -> Transport { + + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData + sessionConfiguration.timeoutIntervalForRequest = 60.0 + sessionConfiguration.httpShouldSetCookies = false + sessionConfiguration.httpCookieAcceptPolicy = .never + sessionConfiguration.httpMaximumConnectionsPerHost = 2 + sessionConfiguration.httpCookieStorage = nil + sessionConfiguration.urlCache = nil + + if let userAgentHeaders = UserAgent.headers() { + sessionConfiguration.httpAdditionalHeaders = userAgentHeaders + } + + return URLSession(configuration: sessionConfiguration) + } +} diff --git a/RSWeb/Sources/RSWeb/WebServices/TransportJSON.swift b/RSWeb/Sources/RSWeb/WebServices/TransportJSON.swift new file mode 100644 index 000000000..ff8dee89f --- /dev/null +++ b/RSWeb/Sources/RSWeb/WebServices/TransportJSON.swift @@ -0,0 +1,153 @@ +// +// JSONTransport.swift +// RSWeb +// +// Created by Maurice Parker on 5/6/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import Foundation + +extension Transport { + + /** + Sends an HTTP get and returns JSON object(s) + */ + public func send(request: URLRequest, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void) { + + send(request: request) { result in + DispatchQueue.main.async { + + switch result { + case .success(let (response, data)): + if let data = data, !data.isEmpty { + // PBS 27 Sep. 2019: decode the JSON on a background thread. + // The profiler says that this is 45% of what’s happening on the main thread + // during an initial sync with Feedbin. + DispatchQueue.global(qos: .background).async { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = dateDecoding + decoder.keyDecodingStrategy = keyDecoding + do { + let decoded = try decoder.decode(R.self, from: data) + DispatchQueue.main.async { + completion(.success((response, decoded))) + } + } + catch { + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + } + else { + completion(.success((response, nil))) + } + + case .failure(let error): + completion(.failure(error)) + } + } + } + } + + /** + Sends the specified HTTP method with a JSON payload. + */ + public func send(request: URLRequest, method: String, payload: P, completion: @escaping (Result) -> Void) { + + var postRequest = request + postRequest.addValue("application/json; charset=utf-8", forHTTPHeaderField: HTTPRequestHeader.contentType) + + let data: Data + do { + data = try JSONEncoder().encode(payload) + } catch { + completion(.failure(error)) + return + } + + send(request: postRequest, method: method, payload: data) { result in + DispatchQueue.main.async { + switch result { + case .success((_, _)): + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } + } + + /** + Sends the specified HTTP method with a JSON payload and returns JSON object(s). + */ + public func send(request: URLRequest, method: String, payload: P, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void) { + + var postRequest = request + postRequest.addValue("application/json; charset=utf-8", forHTTPHeaderField: HTTPRequestHeader.contentType) + + let data: Data + do { + data = try JSONEncoder().encode(payload) + } catch { + completion(.failure(error)) + return + } + + send(request: postRequest, method: method, payload: data) { result in + DispatchQueue.main.async { + + switch result { + case .success(let (response, data)): + do { + if let data = data, !data.isEmpty { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = dateDecoding + decoder.keyDecodingStrategy = keyDecoding + let decoded = try decoder.decode(R.self, from: data) + completion(.success((response, decoded))) + } else { + completion(.success((response, nil))) + } + } catch { + completion(.failure(error)) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + } + + /** + Sends the specified HTTP method with a Raw payload and returns JSON object(s). + */ + public func send(request: URLRequest, method: String, data: Data, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void) { + + send(request: request, method: method, payload: data) { result in + DispatchQueue.main.async { + + switch result { + case .success(let (response, data)): + do { + if let data = data, !data.isEmpty { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = dateDecoding + decoder.keyDecodingStrategy = keyDecoding + let decoded = try decoder.decode(R.self, from: data) + completion(.success((response, decoded))) + } else { + completion(.success((response, nil))) + } + } catch { + completion(.failure(error)) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + } +} diff --git a/RSWeb/Tests/RSWebTests/DictionaryTests.swift b/RSWeb/Tests/RSWebTests/DictionaryTests.swift new file mode 100644 index 000000000..db759dc28 --- /dev/null +++ b/RSWeb/Tests/RSWebTests/DictionaryTests.swift @@ -0,0 +1,45 @@ +// +// DictionaryTests.swift +// RSWebTests +// +// Created by Brent Simmons on 1/13/18. +// Copyright © 2018 Ranchero Software. All rights reserved. +// + +import XCTest + +class DictionaryTests: XCTestCase { + + func testSimpleQueryString() { + + let d = ["foo": "bar", "param1": "This is a value."] + let s = d.urlQueryString + + XCTAssertTrue(s == "foo=bar¶m1=This%20is%20a%20value." || s == "param1=This%20is%20a%20value.&foo=bar") + } + + func testQueryStringWithAmpersand() { + + let d = ["fo&o": "bar", "param1": "This is a&value."] + let s = d.urlQueryString + + XCTAssertTrue(s == "fo%26o=bar¶m1=This%20is%20a%26value." || s == "param1=This%20is%20a%26value.&fo%26o=bar") + } + + func testQueryStringWithAccentedCharacters() { + + let d = ["fée": "bør"] + let s = d.urlQueryString + + XCTAssertTrue(s == "f%C3%A9e=b%C3%B8r") + } + + func testQueryStringWithEmoji() { + + let d = ["🌴e": "bar🎩🌴"] + let s = d.urlQueryString + + XCTAssertTrue(s == "%F0%9F%8C%B4e=bar%F0%9F%8E%A9%F0%9F%8C%B4") + } + +} diff --git a/RSWeb/Tests/RSWebTests/RSWebTests.swift b/RSWeb/Tests/RSWebTests/RSWebTests.swift new file mode 100755 index 000000000..4297b1daa --- /dev/null +++ b/RSWeb/Tests/RSWebTests/RSWebTests.swift @@ -0,0 +1,42 @@ +// +// RSWebTests.swift +// RSWebTests +// +// Created by Brent Simmons on 12/22/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// + +import XCTest +@testable import RSWeb + +class RSWebTests: XCTestCase { + + override func setUp() { + super.setUp() + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + super.tearDown() + } + + func testExample() { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testPerformanceExample() { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + + func testAllBrowsers() { + let browsers = MacWebBrowser.sortedBrowsers() + + XCTAssertNotNil(browsers); + } + +} diff --git a/RSWeb/Tests/RSWebTests/StringTests.swift b/RSWeb/Tests/RSWebTests/StringTests.swift new file mode 100644 index 000000000..23dc2f4d3 --- /dev/null +++ b/RSWeb/Tests/RSWebTests/StringTests.swift @@ -0,0 +1,19 @@ +// +// StringTests.swift +// RSWebTests +// +// Created by Brent Simmons on 1/13/18. +// Copyright © 2018 Ranchero Software. All rights reserved. +// + +import XCTest + +class StringTests: XCTestCase { + + func testHTMLEscaping() { + + let s = #""bar"&'baz'"#.escapedHTML + XCTAssertEqual(s, "<foo>"bar"&'baz'") + + } +} diff --git a/Secrets/Package.swift b/Secrets/Package.swift index f8f3b109b..715adc925 100644 --- a/Secrets/Package.swift +++ b/Secrets/Package.swift @@ -1,9 +1,10 @@ -// swift-tools-version: 5.9 +// swift-tools-version:5.9 + import PackageDescription let package = Package( name: "Secrets", - platforms: [.macOS(SupportedPlatform.MacOSVersion.v10_15), .iOS(SupportedPlatform.IOSVersion.v13)], + platforms: [.macOS(.v14), .iOS(.v17)], products: [ .library( name: "Secrets", diff --git a/SyncDatabase/Package.swift b/SyncDatabase/Package.swift index e42078b5a..dd63924a4 100644 --- a/SyncDatabase/Package.swift +++ b/SyncDatabase/Package.swift @@ -1,38 +1,28 @@ -// swift-tools-version: 5.9 +// swift-tools-version:5.9 + import PackageDescription -var dependencies: [Package.Dependency] = [ - .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMinor(from: "1.0.0")), - .package(url: "https://github.com/Ranchero-Software/RSDatabase.git", .upToNextMajor(from: "1.0.0")), -] - -#if swift(>=5.6) -dependencies.append(contentsOf: [ - .package(path: "../Articles"), -]) -#else -dependencies.append(contentsOf: [ - .package(url: "../Articles", .upToNextMajor(from: "1.0.0")), -]) -#endif - let package = Package( - name: "SyncDatabase", - platforms: [.macOS(SupportedPlatform.MacOSVersion.v10_15), .iOS(SupportedPlatform.IOSVersion.v13)], - products: [ - .library( - name: "SyncDatabase", + name: "SyncDatabase", + platforms: [.macOS(.v14), .iOS(.v17)], + products: [ + .library( + name: "SyncDatabase", type: .dynamic, - targets: ["SyncDatabase"]), - ], - dependencies: dependencies, - targets: [ - .target( - name: "SyncDatabase", - dependencies: [ + targets: ["SyncDatabase"]), + ], + dependencies: [ + .package(path: "../RSCore"), + .package(path: "../Articles"), + .package(url: "https://github.com/Ranchero-Software/RSDatabase.git", .upToNextMajor(from: "1.0.0")), + ], + targets: [ + .target( + name: "SyncDatabase", + dependencies: [ "RSCore", "RSDatabase", "Articles", ]), - ] + ] )