Make RSWeb a local module. Normalize Swift language version and platforms in local modules.
This commit is contained in:
parent
dcef678008
commit
5506187d51
|
@ -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",
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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",
|
||||
]),
|
||||
]
|
||||
]
|
||||
)
|
||||
|
|
|
@ -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 = "<group>"; };
|
||||
8413876B2CD896E000E8490F /* RSCore */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = RSCore; sourceTree = "<group>"; };
|
||||
841387892CD8A38A00E8490F /* UniformTypeIdentifiers+Extras.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UniformTypeIdentifiers+Extras.swift"; sourceTree = "<group>"; };
|
||||
8413878C2CDC78EE00E8490F /* RSWeb */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = RSWeb; sourceTree = "<group>"; };
|
||||
84162A142038C12C00035290 /* MarkCommandValidationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkCommandValidationStatus.swift; sourceTree = "<group>"; };
|
||||
841ABA4D20145E7300980E11 /* NothingInspectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NothingInspectorViewController.swift; sourceTree = "<group>"; };
|
||||
841ABA5D20145E9200980E11 /* FolderInspectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderInspectorViewController.swift; sourceTree = "<group>"; };
|
||||
|
@ -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 = "<group>";
|
||||
|
@ -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 */;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"]),
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
|
@ -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.
|
|
@ -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"]),
|
||||
]
|
||||
)
|
|
@ -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.
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,308 @@
|
|||
//
|
||||
// DownloadSession.swift
|
||||
// RSWeb
|
||||
//
|
||||
// Created by Brent Simmons on 3/12/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// Create a DownloadSessionDelegate, then create a DownloadSession.
|
||||
// To download things: call downloadObjects, with a set of represented objects, to download things. DownloadSession will call the various delegate methods.
|
||||
|
||||
public protocol DownloadSessionDelegate {
|
||||
|
||||
func downloadSession(_ downloadSession: DownloadSession, requestForRepresentedObject: AnyObject) -> URLRequest?
|
||||
func downloadSession(_ downloadSession: DownloadSession, downloadDidCompleteForRepresentedObject: AnyObject, response: URLResponse?, data: Data, error: NSError?, completion: @escaping () -> Void)
|
||||
func downloadSession(_ downloadSession: DownloadSession, shouldContinueAfterReceivingData: Data, representedObject: AnyObject) -> Bool
|
||||
func downloadSession(_ downloadSession: DownloadSession, didReceiveUnexpectedResponse: URLResponse, representedObject: AnyObject)
|
||||
func downloadSession(_ downloadSession: DownloadSession, didReceiveNotModifiedResponse: URLResponse, representedObject: AnyObject)
|
||||
func downloadSession(_ downloadSession: DownloadSession, didDiscardDuplicateRepresentedObject: AnyObject)
|
||||
func downloadSessionDidCompleteDownloadObjects(_ downloadSession: DownloadSession)
|
||||
|
||||
}
|
||||
|
||||
@objc public final class DownloadSession: NSObject {
|
||||
|
||||
private var urlSession: URLSession!
|
||||
private var tasksInProgress = Set<URLSessionTask>()
|
||||
private var tasksPending = Set<URLSessionTask>()
|
||||
private var taskIdentifierToInfoDictionary = [Int: DownloadInfo]()
|
||||
private let representedObjects = NSMutableSet()
|
||||
private let delegate: DownloadSessionDelegate
|
||||
private var redirectCache = [String: String]()
|
||||
private var queue = [AnyObject]()
|
||||
|
||||
public init(delegate: DownloadSessionDelegate) {
|
||||
|
||||
self.delegate = delegate
|
||||
|
||||
super.init()
|
||||
|
||||
let sessionConfiguration = URLSessionConfiguration.default
|
||||
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
sessionConfiguration.timeoutIntervalForRequest = 15.0
|
||||
sessionConfiguration.httpShouldSetCookies = false
|
||||
sessionConfiguration.httpCookieAcceptPolicy = .never
|
||||
sessionConfiguration.httpMaximumConnectionsPerHost = 2
|
||||
sessionConfiguration.httpCookieStorage = nil
|
||||
sessionConfiguration.urlCache = nil
|
||||
|
||||
if let userAgentHeaders = UserAgent.headers() {
|
||||
sessionConfiguration.httpAdditionalHeaders = userAgentHeaders
|
||||
}
|
||||
|
||||
urlSession = URLSession(configuration: sessionConfiguration, delegate: self, delegateQueue: OperationQueue.main)
|
||||
}
|
||||
|
||||
deinit {
|
||||
urlSession.invalidateAndCancel()
|
||||
}
|
||||
|
||||
// MARK: - API
|
||||
|
||||
public func cancelAll() {
|
||||
urlSession.getTasksWithCompletionHandler { dataTasks, uploadTasks, downloadTasks in
|
||||
dataTasks.forEach { $0.cancel() }
|
||||
uploadTasks.forEach { $0.cancel() }
|
||||
downloadTasks.forEach { $0.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
public func downloadObjects(_ objects: NSSet) {
|
||||
for oneObject in objects {
|
||||
if !representedObjects.contains(oneObject) {
|
||||
representedObjects.add(oneObject)
|
||||
addDataTask(oneObject as AnyObject)
|
||||
} else {
|
||||
delegate.downloadSession(self, didDiscardDuplicateRepresentedObject: oneObject as AnyObject)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URLSessionTaskDelegate
|
||||
|
||||
extension DownloadSession: URLSessionTaskDelegate {
|
||||
|
||||
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
tasksInProgress.remove(task)
|
||||
|
||||
guard let info = infoForTask(task) else {
|
||||
return
|
||||
}
|
||||
|
||||
info.error = error
|
||||
|
||||
delegate.downloadSession(self, downloadDidCompleteForRepresentedObject: info.representedObject, response: info.urlResponse, data: info.data as Data, error: error as NSError?) {
|
||||
self.removeTask(task)
|
||||
}
|
||||
}
|
||||
|
||||
public func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
|
||||
|
||||
if response.statusCode == 301 || response.statusCode == 308 {
|
||||
if let oldURLString = task.originalRequest?.url?.absoluteString, let newURLString = request.url?.absoluteString {
|
||||
cacheRedirect(oldURLString, newURLString)
|
||||
}
|
||||
}
|
||||
|
||||
completionHandler(request)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URLSessionDataDelegate
|
||||
|
||||
extension DownloadSession: URLSessionDataDelegate {
|
||||
|
||||
public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
|
||||
|
||||
tasksInProgress.insert(dataTask)
|
||||
tasksPending.remove(dataTask)
|
||||
|
||||
if let info = infoForTask(dataTask) {
|
||||
info.urlResponse = response
|
||||
}
|
||||
|
||||
if response.forcedStatusCode == 304 {
|
||||
|
||||
if let representedObject = infoForTask(dataTask)?.representedObject {
|
||||
delegate.downloadSession(self, didReceiveNotModifiedResponse: response, representedObject: representedObject)
|
||||
}
|
||||
|
||||
completionHandler(.cancel)
|
||||
removeTask(dataTask)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !response.statusIsOK {
|
||||
|
||||
if let representedObject = infoForTask(dataTask)?.representedObject {
|
||||
delegate.downloadSession(self, didReceiveUnexpectedResponse: response, representedObject: representedObject)
|
||||
}
|
||||
|
||||
completionHandler(.cancel)
|
||||
removeTask(dataTask)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
addDataTaskFromQueueIfNecessary()
|
||||
|
||||
completionHandler(.allow)
|
||||
}
|
||||
|
||||
public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||||
|
||||
guard let info = infoForTask(dataTask) else {
|
||||
return
|
||||
}
|
||||
info.addData(data)
|
||||
|
||||
if !delegate.downloadSession(self, shouldContinueAfterReceivingData: info.data as Data, representedObject: info.representedObject) {
|
||||
|
||||
info.canceled = true
|
||||
dataTask.cancel()
|
||||
removeTask(dataTask)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private extension DownloadSession {
|
||||
|
||||
func addDataTask(_ representedObject: AnyObject) {
|
||||
guard tasksPending.count < 500 else {
|
||||
queue.insert(representedObject, at: 0)
|
||||
return
|
||||
}
|
||||
|
||||
guard let request = delegate.downloadSession(self, requestForRepresentedObject: representedObject) else {
|
||||
return
|
||||
}
|
||||
|
||||
var requestToUse = request
|
||||
|
||||
// If received permanent redirect earlier, use that URL.
|
||||
|
||||
if let urlString = request.url?.absoluteString, let redirectedURLString = cachedRedirectForURLString(urlString) {
|
||||
if let redirectedURL = URL(string: redirectedURLString) {
|
||||
requestToUse.url = redirectedURL
|
||||
}
|
||||
}
|
||||
|
||||
let task = urlSession.dataTask(with: requestToUse)
|
||||
|
||||
let info = DownloadInfo(representedObject, urlRequest: requestToUse)
|
||||
taskIdentifierToInfoDictionary[task.taskIdentifier] = info
|
||||
|
||||
tasksPending.insert(task)
|
||||
task.resume()
|
||||
}
|
||||
|
||||
func addDataTaskFromQueueIfNecessary() {
|
||||
guard tasksPending.count < 500, let representedObject = queue.popLast() else { return }
|
||||
addDataTask(representedObject)
|
||||
}
|
||||
|
||||
func infoForTask(_ task: URLSessionTask) -> DownloadInfo? {
|
||||
return taskIdentifierToInfoDictionary[task.taskIdentifier]
|
||||
}
|
||||
|
||||
func removeTask(_ task: URLSessionTask) {
|
||||
tasksInProgress.remove(task)
|
||||
tasksPending.remove(task)
|
||||
taskIdentifierToInfoDictionary[task.taskIdentifier] = nil
|
||||
|
||||
addDataTaskFromQueueIfNecessary()
|
||||
|
||||
if tasksInProgress.count + tasksPending.count < 1 {
|
||||
representedObjects.removeAllObjects()
|
||||
delegate.downloadSessionDidCompleteDownloadObjects(self)
|
||||
}
|
||||
}
|
||||
|
||||
func urlStringIsBlackListedRedirect(_ urlString: String) -> Bool {
|
||||
|
||||
// Hotels and similar often do permanent redirects. We can catch some of those.
|
||||
|
||||
let s = urlString.lowercased()
|
||||
let badStrings = ["solutionip", "lodgenet", "monzoon", "landingpage", "btopenzone", "register", "login", "authentic"]
|
||||
|
||||
for oneBadString in badStrings {
|
||||
if s.contains(oneBadString) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func cacheRedirect(_ oldURLString: String, _ newURLString: String) {
|
||||
if urlStringIsBlackListedRedirect(newURLString) {
|
||||
return
|
||||
}
|
||||
redirectCache[oldURLString] = newURLString
|
||||
}
|
||||
|
||||
func cachedRedirectForURLString(_ urlString: String) -> String? {
|
||||
|
||||
// Follow chains of redirects, but avoid loops.
|
||||
|
||||
var urlStrings = Set<String>()
|
||||
urlStrings.insert(urlString)
|
||||
|
||||
var currentString = urlString
|
||||
|
||||
while(true) {
|
||||
|
||||
if let oneRedirectString = redirectCache[currentString] {
|
||||
|
||||
if urlStrings.contains(oneRedirectString) {
|
||||
// Cycle. Bail.
|
||||
return nil
|
||||
}
|
||||
urlStrings.insert(oneRedirectString)
|
||||
currentString = oneRedirectString
|
||||
}
|
||||
|
||||
else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return currentString == urlString ? nil : currentString
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DownloadInfo
|
||||
|
||||
private final class DownloadInfo {
|
||||
|
||||
let representedObject: AnyObject
|
||||
let urlRequest: URLRequest
|
||||
let data = NSMutableData()
|
||||
var error: Error?
|
||||
var urlResponse: URLResponse?
|
||||
var canceled = false
|
||||
|
||||
var statusCode: Int {
|
||||
return urlResponse?.forcedStatusCode ?? 0
|
||||
}
|
||||
|
||||
init(_ representedObject: AnyObject, urlRequest: URLRequest) {
|
||||
|
||||
self.representedObject = representedObject
|
||||
self.urlRequest = urlRequest
|
||||
}
|
||||
|
||||
func addData(_ d: Data) {
|
||||
|
||||
data.append(d)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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\""])
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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<URL>()
|
||||
|
||||
func download(_ url: URL, _ completion: @escaping OneShotDownloadCallback, forceRedownload: Bool = false) {
|
||||
|
||||
if lastCleanupDate.timeIntervalSinceNow < -DownloadWithCacheManager.cleanupInterval {
|
||||
lastCleanupDate = Date()
|
||||
cache.cleanup(DownloadWithCacheManager.timeToLive)
|
||||
}
|
||||
|
||||
if !forceRedownload {
|
||||
let cacheRecord: WebCacheRecord? = cache[url]
|
||||
if let cacheRecord = cacheRecord {
|
||||
completion(cacheRecord.data, cacheRecord.response, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let callbackRecord = CallbackRecord(url: url, completion: completion)
|
||||
pendingCallbacks.append(callbackRecord)
|
||||
if urlsInProgress.contains(url) {
|
||||
return // The completion handler will get called later.
|
||||
}
|
||||
urlsInProgress.insert(url)
|
||||
|
||||
OneShotDownloadManager.shared.download(url) { (data, response, error) in
|
||||
|
||||
self.urlsInProgress.remove(url)
|
||||
|
||||
if let data = data, let response = response, response.statusIsOK, error == nil {
|
||||
let cacheRecord = WebCacheRecord(url: url, dateDownloaded: Date(), data: data, response: response)
|
||||
self.cache[url] = cacheRecord
|
||||
}
|
||||
|
||||
var callbackCount = 0
|
||||
self.pendingCallbacks.forEach{ (callbackRecord) in
|
||||
if url == callbackRecord.url {
|
||||
callbackRecord.completion(data, response, error)
|
||||
callbackCount += 1
|
||||
}
|
||||
}
|
||||
self.pendingCallbacks.removeAll(where: { (callbackRecord) -> Bool in
|
||||
return callbackRecord.url == url
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func downloadUsingCache(_ url: URL, _ completion: @escaping OneShotDownloadCallback) {
|
||||
precondition(Thread.isMainThread)
|
||||
DownloadWithCacheManager.shared.download(url, completion)
|
||||
}
|
||||
|
||||
public func downloadAddingToCache(_ url: URL, _ completion: @escaping OneShotDownloadCallback) {
|
||||
precondition(Thread.isMainThread)
|
||||
DownloadWithCacheManager.shared.download(url, completion, forceRedownload: true)
|
||||
}
|
|
@ -0,0 +1,406 @@
|
|||
/*
|
||||
Copyright (c) 2014, Ashley Mills
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
import SystemConfiguration
|
||||
import Foundation
|
||||
|
||||
public enum ReachabilityError: Error {
|
||||
case failedToCreateWithAddress(sockaddr, Int32)
|
||||
case failedToCreateWithHostname(String, Int32)
|
||||
case unableToSetCallback(Int32)
|
||||
case unableToSetDispatchQueue(Int32)
|
||||
case unableToGetFlags(Int32)
|
||||
}
|
||||
|
||||
@available(*, unavailable, renamed: "Notification.Name.reachabilityChanged")
|
||||
public let ReachabilityChangedNotification = NSNotification.Name("ReachabilityChangedNotification")
|
||||
|
||||
public extension Notification.Name {
|
||||
static let reachabilityChanged = Notification.Name("reachabilityChanged")
|
||||
}
|
||||
|
||||
public class Reachability {
|
||||
|
||||
public typealias NetworkReachable = (Reachability) -> ()
|
||||
public typealias NetworkUnreachable = (Reachability) -> ()
|
||||
|
||||
@available(*, unavailable, renamed: "Connection")
|
||||
public enum NetworkStatus: CustomStringConvertible {
|
||||
case notReachable, reachableViaWiFi, reachableViaWWAN
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .reachableViaWWAN: return "Cellular"
|
||||
case .reachableViaWiFi: return "WiFi"
|
||||
case .notReachable: return "No Connection"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum Connection: CustomStringConvertible {
|
||||
@available(*, deprecated, renamed: "unavailable")
|
||||
case none
|
||||
case unavailable, wifi, cellular
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .cellular: return "Cellular"
|
||||
case .wifi: return "WiFi"
|
||||
case .unavailable: return "No Connection"
|
||||
case .none: return "unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var whenReachable: NetworkReachable?
|
||||
public var whenUnreachable: NetworkUnreachable?
|
||||
|
||||
@available(*, deprecated, renamed: "allowsCellularConnection")
|
||||
public let reachableOnWWAN: Bool = true
|
||||
|
||||
/// Set to `false` to force Reachability.connection to .none when on cellular connection (default value `true`)
|
||||
public var allowsCellularConnection: Bool
|
||||
|
||||
// The notification center on which "reachability changed" events are being posted
|
||||
public var notificationCenter: NotificationCenter = NotificationCenter.default
|
||||
|
||||
@available(*, deprecated, renamed: "connection.description")
|
||||
public var currentReachabilityString: String {
|
||||
return "\(connection)"
|
||||
}
|
||||
|
||||
@available(*, unavailable, renamed: "connection")
|
||||
public var currentReachabilityStatus: Connection {
|
||||
return connection
|
||||
}
|
||||
|
||||
public var connection: Connection {
|
||||
if flags == nil {
|
||||
try? setReachabilityFlags()
|
||||
}
|
||||
|
||||
switch flags?.connection {
|
||||
case .unavailable?, nil: return .unavailable
|
||||
case .none?: return .unavailable
|
||||
case .cellular?: return allowsCellularConnection ? .cellular : .unavailable
|
||||
case .wifi?: return .wifi
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate var isRunningOnDevice: Bool = {
|
||||
#if targetEnvironment(simulator)
|
||||
return false
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}()
|
||||
|
||||
fileprivate(set) var notifierRunning = false
|
||||
fileprivate let reachabilityRef: SCNetworkReachability
|
||||
fileprivate let reachabilitySerialQueue: DispatchQueue
|
||||
fileprivate let notificationQueue: DispatchQueue?
|
||||
fileprivate(set) var flags: SCNetworkReachabilityFlags? {
|
||||
didSet {
|
||||
guard flags != oldValue else { return }
|
||||
notifyReachabilityChanged()
|
||||
}
|
||||
}
|
||||
|
||||
required public init(reachabilityRef: SCNetworkReachability,
|
||||
queueQoS: DispatchQoS = .default,
|
||||
targetQueue: DispatchQueue? = nil,
|
||||
notificationQueue: DispatchQueue? = .main) {
|
||||
self.allowsCellularConnection = true
|
||||
self.reachabilityRef = reachabilityRef
|
||||
self.reachabilitySerialQueue = DispatchQueue(label: "uk.co.ashleymills.reachability", qos: queueQoS, target: targetQueue)
|
||||
self.notificationQueue = notificationQueue
|
||||
}
|
||||
|
||||
public convenience init(hostname: String,
|
||||
queueQoS: DispatchQoS = .default,
|
||||
targetQueue: DispatchQueue? = nil,
|
||||
notificationQueue: DispatchQueue? = .main) throws {
|
||||
guard let ref = SCNetworkReachabilityCreateWithName(nil, hostname) else {
|
||||
throw ReachabilityError.failedToCreateWithHostname(hostname, SCError())
|
||||
}
|
||||
self.init(reachabilityRef: ref, queueQoS: queueQoS, targetQueue: targetQueue, notificationQueue: notificationQueue)
|
||||
}
|
||||
|
||||
public convenience init(queueQoS: DispatchQoS = .default,
|
||||
targetQueue: DispatchQueue? = nil,
|
||||
notificationQueue: DispatchQueue? = .main) throws {
|
||||
var zeroAddress = sockaddr()
|
||||
zeroAddress.sa_len = UInt8(MemoryLayout<sockaddr>.size)
|
||||
zeroAddress.sa_family = sa_family_t(AF_INET)
|
||||
|
||||
guard let ref = SCNetworkReachabilityCreateWithAddress(nil, &zeroAddress) else {
|
||||
throw ReachabilityError.failedToCreateWithAddress(zeroAddress, SCError())
|
||||
}
|
||||
|
||||
self.init(reachabilityRef: ref, queueQoS: queueQoS, targetQueue: targetQueue, notificationQueue: notificationQueue)
|
||||
}
|
||||
|
||||
deinit {
|
||||
stopNotifier()
|
||||
}
|
||||
}
|
||||
|
||||
public extension Reachability {
|
||||
|
||||
// MARK: - *** Notifier methods ***
|
||||
func startNotifier() throws {
|
||||
guard !notifierRunning else { return }
|
||||
|
||||
let callback: SCNetworkReachabilityCallBack = { (reachability, flags, info) in
|
||||
guard let info = info else { return }
|
||||
|
||||
// `weakifiedReachability` is guaranteed to exist by virtue of our
|
||||
// retain/release callbacks which we provided to the `SCNetworkReachabilityContext`.
|
||||
let weakifiedReachability = Unmanaged<ReachabilityWeakifier>.fromOpaque(info).takeUnretainedValue()
|
||||
|
||||
// The weak `reachability` _may_ no longer exist if the `Reachability`
|
||||
// object has since been deallocated but a callback was already in flight.
|
||||
weakifiedReachability.reachability?.flags = flags
|
||||
}
|
||||
|
||||
let weakifiedReachability = ReachabilityWeakifier(reachability: self)
|
||||
let opaqueWeakifiedReachability = Unmanaged<ReachabilityWeakifier>.passUnretained(weakifiedReachability).toOpaque()
|
||||
|
||||
var context = SCNetworkReachabilityContext(
|
||||
version: 0,
|
||||
info: UnsafeMutableRawPointer(opaqueWeakifiedReachability),
|
||||
retain: { (info: UnsafeRawPointer) -> UnsafeRawPointer in
|
||||
let unmanagedWeakifiedReachability = Unmanaged<ReachabilityWeakifier>.fromOpaque(info)
|
||||
_ = unmanagedWeakifiedReachability.retain()
|
||||
return UnsafeRawPointer(unmanagedWeakifiedReachability.toOpaque())
|
||||
},
|
||||
release: { (info: UnsafeRawPointer) -> Void in
|
||||
let unmanagedWeakifiedReachability = Unmanaged<ReachabilityWeakifier>.fromOpaque(info)
|
||||
unmanagedWeakifiedReachability.release()
|
||||
},
|
||||
copyDescription: { (info: UnsafeRawPointer) -> Unmanaged<CFString> in
|
||||
let unmanagedWeakifiedReachability = Unmanaged<ReachabilityWeakifier>.fromOpaque(info)
|
||||
let weakifiedReachability = unmanagedWeakifiedReachability.takeUnretainedValue()
|
||||
let description = weakifiedReachability.reachability?.description ?? "nil"
|
||||
return Unmanaged.passRetained(description as CFString)
|
||||
}
|
||||
)
|
||||
|
||||
if !SCNetworkReachabilitySetCallback(reachabilityRef, callback, &context) {
|
||||
stopNotifier()
|
||||
throw ReachabilityError.unableToSetCallback(SCError())
|
||||
}
|
||||
|
||||
if !SCNetworkReachabilitySetDispatchQueue(reachabilityRef, reachabilitySerialQueue) {
|
||||
stopNotifier()
|
||||
throw ReachabilityError.unableToSetDispatchQueue(SCError())
|
||||
}
|
||||
|
||||
// Perform an initial check
|
||||
try setReachabilityFlags()
|
||||
|
||||
notifierRunning = true
|
||||
}
|
||||
|
||||
func stopNotifier() {
|
||||
defer { notifierRunning = false }
|
||||
|
||||
SCNetworkReachabilitySetCallback(reachabilityRef, nil, nil)
|
||||
SCNetworkReachabilitySetDispatchQueue(reachabilityRef, nil)
|
||||
}
|
||||
|
||||
// MARK: - *** Connection test methods ***
|
||||
@available(*, deprecated, message: "Please use `connection != .none`")
|
||||
var isReachable: Bool {
|
||||
return connection != .unavailable
|
||||
}
|
||||
|
||||
@available(*, deprecated, message: "Please use `connection == .cellular`")
|
||||
var isReachableViaWWAN: Bool {
|
||||
// Check we're not on the simulator, we're REACHABLE and check we're on WWAN
|
||||
return connection == .cellular
|
||||
}
|
||||
|
||||
@available(*, deprecated, message: "Please use `connection == .wifi`")
|
||||
var isReachableViaWiFi: Bool {
|
||||
return connection == .wifi
|
||||
}
|
||||
|
||||
var description: String {
|
||||
return flags?.description ?? "unavailable flags"
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension Reachability {
|
||||
|
||||
func setReachabilityFlags() throws {
|
||||
try reachabilitySerialQueue.sync { [unowned self] in
|
||||
var flags = SCNetworkReachabilityFlags()
|
||||
if !SCNetworkReachabilityGetFlags(self.reachabilityRef, &flags) {
|
||||
self.stopNotifier()
|
||||
throw ReachabilityError.unableToGetFlags(SCError())
|
||||
}
|
||||
|
||||
self.flags = flags
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func notifyReachabilityChanged() {
|
||||
let notify = { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.connection != .unavailable ? self.whenReachable?(self) : self.whenUnreachable?(self)
|
||||
self.notificationCenter.post(name: .reachabilityChanged, object: self)
|
||||
}
|
||||
|
||||
// notify on the configured `notificationQueue`, or the caller's (i.e. `reachabilitySerialQueue`)
|
||||
notificationQueue?.async(execute: notify) ?? notify()
|
||||
}
|
||||
}
|
||||
|
||||
extension SCNetworkReachabilityFlags {
|
||||
|
||||
typealias Connection = Reachability.Connection
|
||||
|
||||
var connection: Connection {
|
||||
guard isReachableFlagSet else { return .unavailable }
|
||||
|
||||
// If we're reachable, but not on an iOS device (i.e. simulator), we must be on WiFi
|
||||
#if targetEnvironment(simulator)
|
||||
return .wifi
|
||||
#else
|
||||
var connection = Connection.unavailable
|
||||
|
||||
if !isConnectionRequiredFlagSet {
|
||||
connection = .wifi
|
||||
}
|
||||
|
||||
if isConnectionOnTrafficOrDemandFlagSet {
|
||||
if !isInterventionRequiredFlagSet {
|
||||
connection = .wifi
|
||||
}
|
||||
}
|
||||
|
||||
if isOnWWANFlagSet {
|
||||
connection = .cellular
|
||||
}
|
||||
|
||||
return connection
|
||||
#endif
|
||||
}
|
||||
|
||||
var isOnWWANFlagSet: Bool {
|
||||
#if os(iOS)
|
||||
return contains(.isWWAN)
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
var isReachableFlagSet: Bool {
|
||||
return contains(.reachable)
|
||||
}
|
||||
var isConnectionRequiredFlagSet: Bool {
|
||||
return contains(.connectionRequired)
|
||||
}
|
||||
var isInterventionRequiredFlagSet: Bool {
|
||||
return contains(.interventionRequired)
|
||||
}
|
||||
var isConnectionOnTrafficFlagSet: Bool {
|
||||
return contains(.connectionOnTraffic)
|
||||
}
|
||||
var isConnectionOnDemandFlagSet: Bool {
|
||||
return contains(.connectionOnDemand)
|
||||
}
|
||||
var isConnectionOnTrafficOrDemandFlagSet: Bool {
|
||||
return !intersection([.connectionOnTraffic, .connectionOnDemand]).isEmpty
|
||||
}
|
||||
var isTransientConnectionFlagSet: Bool {
|
||||
return contains(.transientConnection)
|
||||
}
|
||||
var isLocalAddressFlagSet: Bool {
|
||||
return contains(.isLocalAddress)
|
||||
}
|
||||
var isDirectFlagSet: Bool {
|
||||
return contains(.isDirect)
|
||||
}
|
||||
var isConnectionRequiredAndTransientFlagSet: Bool {
|
||||
return intersection([.connectionRequired, .transientConnection]) == [.connectionRequired, .transientConnection]
|
||||
}
|
||||
|
||||
var description: String {
|
||||
let W = isOnWWANFlagSet ? "W" : "-"
|
||||
let R = isReachableFlagSet ? "R" : "-"
|
||||
let c = isConnectionRequiredFlagSet ? "c" : "-"
|
||||
let t = isTransientConnectionFlagSet ? "t" : "-"
|
||||
let i = isInterventionRequiredFlagSet ? "i" : "-"
|
||||
let C = isConnectionOnTrafficFlagSet ? "C" : "-"
|
||||
let D = isConnectionOnDemandFlagSet ? "D" : "-"
|
||||
let l = isLocalAddressFlagSet ? "l" : "-"
|
||||
let d = isDirectFlagSet ? "d" : "-"
|
||||
|
||||
return "\(W)\(R) \(c)\(t)\(i)\(C)\(D)\(l)\(d)"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
`ReachabilityWeakifier` weakly wraps the `Reachability` class
|
||||
in order to break retain cycles when interacting with CoreFoundation.
|
||||
|
||||
CoreFoundation callbacks expect a pair of retain/release whenever an
|
||||
opaque `info` parameter is provided. These callbacks exist to guard
|
||||
against memory management race conditions when invoking the callbacks.
|
||||
|
||||
#### Race Condition
|
||||
|
||||
If we passed `SCNetworkReachabilitySetCallback` a direct reference to our
|
||||
`Reachability` class without also providing corresponding retain/release
|
||||
callbacks, then a race condition can lead to crashes when:
|
||||
- `Reachability` is deallocated on thread X
|
||||
- A `SCNetworkReachability` callback(s) is already in flight on thread Y
|
||||
|
||||
#### Retain Cycle
|
||||
|
||||
If we pass `Reachability` to CoreFoundtion while also providing retain/
|
||||
release callbacks, we would create a retain cycle once CoreFoundation
|
||||
retains our `Reachability` class. This fixes the crashes and his how
|
||||
CoreFoundation expects the API to be used, but doesn't play nicely with
|
||||
Swift/ARC. This cycle would only be broken after manually calling
|
||||
`stopNotifier()` — `deinit` would never be called.
|
||||
|
||||
#### ReachabilityWeakifier
|
||||
|
||||
By providing both retain/release callbacks and wrapping `Reachability` in
|
||||
a weak wrapper, we:
|
||||
- interact correctly with CoreFoundation, thereby avoiding a crash.
|
||||
See "Memory Management Programming Guide for Core Foundation".
|
||||
- don't alter the public API of `Reachability.swift` in any way
|
||||
- still allow for automatic stopping of the notifier on `deinit`.
|
||||
*/
|
||||
private class ReachabilityWeakifier {
|
||||
weak var reachability: Reachability?
|
||||
init(reachability: Reachability) {
|
||||
self.reachability = reachability
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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[..<index])
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
//
|
||||
// URLComponents.swift
|
||||
//
|
||||
//
|
||||
// Created by Maurice Parker on 11/8/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension URLComponents {
|
||||
|
||||
// `+` is a valid character in query component as per RFC 3986 (https://developer.apple.com/documentation/foundation/nsurlcomponents/1407752-queryitems)
|
||||
// workaround:
|
||||
// - http://www.openradar.me/24076063
|
||||
// - https://stackoverflow.com/a/37314144
|
||||
var enhancedPercentEncodedQuery: String? {
|
||||
guard !(queryItems?.isEmpty ?? true) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var allowedCharacters = CharacterSet.urlQueryAllowed
|
||||
allowedCharacters.remove(charactersIn: "!*'();:@&=+$,/?%#[]")
|
||||
|
||||
var queries = [String]()
|
||||
for queryItem in queryItems! {
|
||||
if let value = queryItem.value?.addingPercentEncoding(withAllowedCharacters: allowedCharacters)?.replacingOccurrences(of: "%20", with: "+") {
|
||||
queries.append("\(queryItem.name)=\(value)")
|
||||
}
|
||||
}
|
||||
|
||||
return queries.joined(separator: "&")
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
//
|
||||
// NSMutableURLRequest+RSWeb.swift
|
||||
// RSWeb
|
||||
//
|
||||
// Created by Brent Simmons on 12/27/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension URLRequest {
|
||||
|
||||
@discardableResult mutating func addBasicAuthorization(username: String, password: String) -> Bool {
|
||||
|
||||
// Do this *only* with https. And not even then if you can help it.
|
||||
|
||||
let s = "\(username):\(password)"
|
||||
guard let d = s.data(using: .utf8, allowLossyConversion: false) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let base64EncodedString = d.base64EncodedString()
|
||||
let authorization = "Basic \(base64EncodedString)"
|
||||
setValue(authorization, forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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?
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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?
|
||||
}
|
|
@ -0,0 +1,227 @@
|
|||
//
|
||||
// UTS46+Loading.swift
|
||||
// icumap2code
|
||||
//
|
||||
// Created by Nate Weaver on 2020-05-08.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Compression
|
||||
|
||||
extension UTS46 {
|
||||
|
||||
private static func parseHeader(from data: Data) throws -> Header? {
|
||||
let headerData = data.prefix(8)
|
||||
|
||||
guard headerData.count == 8 else { throw UTS46Error.badSize }
|
||||
|
||||
return Header(rawValue: headerData)
|
||||
}
|
||||
|
||||
static func load(from url: URL) throws {
|
||||
let fileData = try Data(contentsOf: url)
|
||||
|
||||
guard let header = try? parseHeader(from: fileData) else { return }
|
||||
|
||||
guard header.version == 1 else { throw UTS46Error.unknownVersion }
|
||||
|
||||
let offset = header.dataOffset
|
||||
|
||||
guard fileData.count > offset else { throw UTS46Error.badSize }
|
||||
|
||||
let compressedData = fileData[offset...]
|
||||
|
||||
guard let data = self.decompress(data: compressedData, algorithm: header.compression) else {
|
||||
throw UTS46Error.decompressionError
|
||||
}
|
||||
|
||||
var index = 0
|
||||
|
||||
while index < data.count {
|
||||
let marker = data[index]
|
||||
|
||||
index += 1
|
||||
|
||||
switch marker {
|
||||
case Marker.characterMap:
|
||||
index = parseCharacterMap(from: data, start: index)
|
||||
case Marker.ignoredCharacters:
|
||||
index = parseIgnoredCharacters(from: data, start: index)
|
||||
case Marker.disallowedCharacters:
|
||||
index = parseDisallowedCharacters(from: data, start: index)
|
||||
case Marker.joiningTypes:
|
||||
index = parseJoiningTypes(from: data, start: index)
|
||||
default:
|
||||
throw UTS46Error.badMarker
|
||||
}
|
||||
}
|
||||
|
||||
isLoaded = true
|
||||
}
|
||||
|
||||
static var bundle: Bundle {
|
||||
#if SWIFT_PACKAGE
|
||||
return Bundle.module
|
||||
#else
|
||||
return Bundle(for: Self.self)
|
||||
#endif
|
||||
}
|
||||
|
||||
static func loadIfNecessary() throws {
|
||||
guard !isLoaded else { return }
|
||||
guard let url = Self.bundle.url(forResource: "uts46", withExtension: nil) else { throw CocoaError(.fileNoSuchFile) }
|
||||
|
||||
try load(from: url)
|
||||
}
|
||||
|
||||
private static func decompress(data: Data, algorithm: CompressionAlgorithm?) -> Data? {
|
||||
|
||||
guard let rawAlgorithm = algorithm?.rawAlgorithm else { return data }
|
||||
|
||||
let capacity = 131_072 // 128 KB
|
||||
let destinationBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: capacity)
|
||||
|
||||
let decompressed = data.withUnsafeBytes { (rawBuffer) -> Data? in
|
||||
let bound = rawBuffer.bindMemory(to: UInt8.self)
|
||||
let decodedCount = compression_decode_buffer(destinationBuffer, capacity, bound.baseAddress!, rawBuffer.count, nil, rawAlgorithm)
|
||||
|
||||
if decodedCount == 0 || decodedCount == capacity {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Data(bytes: destinationBuffer, count: decodedCount)
|
||||
}
|
||||
|
||||
return decompressed
|
||||
}
|
||||
|
||||
private static func parseCharacterMap(from data: Data, start: Int) -> Int {
|
||||
characterMap.removeAll()
|
||||
var index = start
|
||||
|
||||
main: while index < data.count {
|
||||
var accumulator = Data()
|
||||
|
||||
while data[index] != Marker.sequenceTerminator {
|
||||
if data[index] > Marker.min { break main }
|
||||
|
||||
accumulator.append(data[index])
|
||||
index += 1
|
||||
}
|
||||
|
||||
let str = String(data: accumulator, encoding: .utf8)!
|
||||
|
||||
// FIXME: throw an error here.
|
||||
guard str.count > 0 else { continue }
|
||||
|
||||
let codepoint = str.unicodeScalars.first!.value
|
||||
|
||||
characterMap[codepoint] = String(str.unicodeScalars.dropFirst())
|
||||
|
||||
index += 1
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
private static func parseRanges(from: String) -> [ClosedRange<UnicodeScalar>]? {
|
||||
guard from.unicodeScalars.count % 2 == 0 else { return nil }
|
||||
|
||||
var ranges = [ClosedRange<UnicodeScalar>]()
|
||||
var first: UnicodeScalar?
|
||||
|
||||
for (index, scalar) in from.unicodeScalars.enumerated() {
|
||||
if index % 2 == 0 {
|
||||
first = scalar
|
||||
} else if let first = first {
|
||||
ranges.append(first...scalar)
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
static func parseCharacterSet(from data: Data, start: Int) -> (index: Int, charset: CharacterSet?) {
|
||||
var index = start
|
||||
var accumulator = Data()
|
||||
|
||||
while index < data.count, data[index] < Marker.min {
|
||||
accumulator.append(data[index])
|
||||
index += 1
|
||||
}
|
||||
|
||||
let str = String(data: accumulator, encoding: .utf8)!
|
||||
|
||||
guard let ranges = parseRanges(from: str) else {
|
||||
return (index: index, charset: nil)
|
||||
}
|
||||
|
||||
var charset = CharacterSet()
|
||||
|
||||
for range in ranges {
|
||||
charset.insert(charactersIn: range)
|
||||
}
|
||||
|
||||
return (index: index, charset: charset)
|
||||
}
|
||||
|
||||
static func parseIgnoredCharacters(from data: Data, start: Int) -> Int {
|
||||
let (index, charset) = parseCharacterSet(from: data, start: start)
|
||||
|
||||
if let charset = charset {
|
||||
ignoredCharacters = charset
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
static func parseDisallowedCharacters(from data: Data, start: Int) -> Int {
|
||||
let (index, charset) = parseCharacterSet(from: data, start: start)
|
||||
|
||||
if let charset = charset {
|
||||
disallowedCharacters = charset
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
static func parseJoiningTypes(from data: Data, start: Int) -> Int {
|
||||
var index = start
|
||||
joiningTypes.removeAll()
|
||||
|
||||
main: while index < data.count, data[index] < Marker.min {
|
||||
var accumulator = Data()
|
||||
|
||||
while index < data.count {
|
||||
if data[index] > Marker.min { break main }
|
||||
accumulator.append(data[index])
|
||||
|
||||
index += 1
|
||||
}
|
||||
|
||||
let str = String(data: accumulator, encoding: .utf8)!
|
||||
|
||||
var type: JoiningType?
|
||||
var first: UnicodeScalar?
|
||||
|
||||
for scalar in str.unicodeScalars {
|
||||
if scalar.isASCII {
|
||||
type = JoiningType(rawValue: Character(scalar))
|
||||
} else if let type = type {
|
||||
if first == nil {
|
||||
first = scalar
|
||||
} else {
|
||||
for value in first!.value...scalar.value {
|
||||
joiningTypes[value] = type
|
||||
}
|
||||
|
||||
first = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,189 @@
|
|||
//
|
||||
// UTS46.swift
|
||||
// PunyCocoa Swift
|
||||
//
|
||||
// Created by Nate Weaver on 2020-03-29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Compression
|
||||
|
||||
/// UTS46 mapping.
|
||||
///
|
||||
/// Storage file format. Codepoints are stored UTF-8-encoded.
|
||||
///
|
||||
/// All multibyte integers are little-endian.
|
||||
///
|
||||
/// Header:
|
||||
///
|
||||
/// +--------------+---------+---------+---------+
|
||||
/// | 6 bytes | 1 byte | 1 byte | 4 bytes |
|
||||
/// +--------------+---------+---------+---------+
|
||||
/// | magic number | version | flags | crc32 |
|
||||
/// +--------------+---------+---------+---------+
|
||||
///
|
||||
/// - `magic number`: `"UTS#46"` (`0x55 0x54 0x53 0x23 0x34 0x36`).
|
||||
/// - `version`: format version (1 byte; currently `0x01`).
|
||||
/// - `flags`: Bitfield:
|
||||
///
|
||||
/// +-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
/// | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|
||||
/// +-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
/// | currently unused | crc | compression |
|
||||
/// +-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
///
|
||||
/// - `crc`: Contains a CRC32 of the data after the header.
|
||||
/// - `compression`: compression mode of the data.
|
||||
/// Currently identical to NSData's compression constants + 1:
|
||||
///
|
||||
/// - 0: no compression
|
||||
/// - 1: LZFSE
|
||||
/// - 2: LZ4
|
||||
/// - 3: LZMA
|
||||
/// - 4: ZLIB
|
||||
///
|
||||
/// - `crc32`: CRC32 of the (possibly compressed) data. Implementations can skip
|
||||
/// parsing this unless data integrity is an issue.
|
||||
///
|
||||
/// The data section is a collection of data blocks of the format
|
||||
///
|
||||
/// [marker][section data] ...
|
||||
///
|
||||
/// Section data formats:
|
||||
///
|
||||
/// If marker is `characterMap`:
|
||||
///
|
||||
/// [codepoint][mapped-codepoint ...][null] ...
|
||||
///
|
||||
/// If marker is `disallowedCharacters` or `ignoredCharacters`:
|
||||
///
|
||||
/// [codepoint-range] ...
|
||||
///
|
||||
/// If marker is `joiningTypes`:
|
||||
///
|
||||
/// [type][[codepoint-range] ...]
|
||||
///
|
||||
/// where `type` is one of `C`, `D`, `L`, `R`, or `T`.
|
||||
///
|
||||
/// `codepoint-range`: two codepoints, marking the first and last codepoints of a
|
||||
/// closed range. Single-codepoint ranges have the same start and end codepoint.
|
||||
///
|
||||
class UTS46 {
|
||||
|
||||
static var characterMap: [UInt32: String] = [:]
|
||||
static var ignoredCharacters: CharacterSet = []
|
||||
static var disallowedCharacters: CharacterSet = []
|
||||
static var joiningTypes = [UInt32: JoiningType]()
|
||||
|
||||
static var isLoaded = false
|
||||
|
||||
enum Marker {
|
||||
static let characterMap = UInt8.max
|
||||
static let ignoredCharacters = UInt8.max - 1
|
||||
static let disallowedCharacters = UInt8.max - 2
|
||||
static let joiningTypes = UInt8.max - 3
|
||||
|
||||
static let min = UInt8.max - 10 // No valid UTF-8 byte can fall here.
|
||||
|
||||
static let sequenceTerminator: UInt8 = 0
|
||||
}
|
||||
|
||||
enum JoiningType: Character {
|
||||
case causing = "C"
|
||||
case dual = "D"
|
||||
case right = "R"
|
||||
case left = "L"
|
||||
case transparent = "T"
|
||||
}
|
||||
|
||||
enum UTS46Error: Error {
|
||||
case badSize
|
||||
case compressionError
|
||||
case decompressionError
|
||||
case badMarker
|
||||
case unknownVersion
|
||||
}
|
||||
|
||||
/// Identical values to `NSData.CompressionAlgorithm + 1`.
|
||||
enum CompressionAlgorithm: UInt8 {
|
||||
case none = 0
|
||||
case lzfse = 1
|
||||
case lz4 = 2
|
||||
case lzma = 3
|
||||
case zlib = 4
|
||||
|
||||
var rawAlgorithm: compression_algorithm? {
|
||||
switch self {
|
||||
case .lzfse:
|
||||
return COMPRESSION_LZFSE
|
||||
case .lz4:
|
||||
return COMPRESSION_LZ4
|
||||
case .lzma:
|
||||
return COMPRESSION_LZMA
|
||||
case .zlib:
|
||||
return COMPRESSION_ZLIB
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Header: RawRepresentable, CustomDebugStringConvertible {
|
||||
typealias RawValue = [UInt8]
|
||||
|
||||
var rawValue: [UInt8] {
|
||||
let value = Self.signature + [version, flags.rawValue]
|
||||
assert(value.count == 8)
|
||||
return value
|
||||
}
|
||||
|
||||
private static let compressionMask: UInt8 = 0x07
|
||||
private static let signature: [UInt8] = Array("UTS#46".utf8)
|
||||
|
||||
private struct Flags: RawRepresentable {
|
||||
var rawValue: UInt8 {
|
||||
return (hasCRC ? hasCRCMask : 0) | compression.rawValue
|
||||
}
|
||||
|
||||
var hasCRC: Bool
|
||||
var compression: CompressionAlgorithm
|
||||
|
||||
private let hasCRCMask: UInt8 = 1 << 3
|
||||
private let compressionMask: UInt8 = 0x7
|
||||
|
||||
init(rawValue: UInt8) {
|
||||
hasCRC = rawValue & hasCRCMask != 0
|
||||
let compressionBits = rawValue & compressionMask
|
||||
|
||||
compression = CompressionAlgorithm(rawValue: compressionBits) ?? .none
|
||||
}
|
||||
|
||||
init(compression: CompressionAlgorithm = .none, hasCRC: Bool = false) {
|
||||
self.compression = compression
|
||||
self.hasCRC = hasCRC
|
||||
}
|
||||
}
|
||||
|
||||
let version: UInt8
|
||||
private var flags: Flags
|
||||
var hasCRC: Bool { flags.hasCRC }
|
||||
var compression: CompressionAlgorithm { flags.compression }
|
||||
var dataOffset: Int { 8 + (flags.hasCRC ? 4 : 0) }
|
||||
|
||||
init?<T: DataProtocol>(rawValue: T) where T.Index == Int {
|
||||
guard rawValue.count == 8 else { return nil }
|
||||
guard rawValue.prefix(Self.signature.count).elementsEqual(Self.signature) else { return nil }
|
||||
|
||||
version = rawValue[rawValue.index(rawValue.startIndex, offsetBy: 6)]
|
||||
flags = Flags(rawValue: rawValue[rawValue.index(rawValue.startIndex, offsetBy: 7)])
|
||||
}
|
||||
|
||||
init(compression: CompressionAlgorithm = .none, hasCRC: Bool = false) {
|
||||
self.version = 1
|
||||
self.flags = Flags(compression: compression, hasCRC: hasCRC)
|
||||
}
|
||||
|
||||
var debugDescription: String { "has CRC: \(hasCRC); compression: \(String(describing: compression))" }
|
||||
}
|
||||
|
||||
}
|
Binary file not shown.
|
@ -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]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,235 @@
|
|||
//
|
||||
// Transport.swift
|
||||
// RSWeb
|
||||
//
|
||||
// Created by Maurice Parker on 5/4/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
// Inspired by: http://robnapier.net/a-mockery-of-protocols
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum TransportError: LocalizedError {
|
||||
case noData
|
||||
case noURL
|
||||
case suspended
|
||||
case httpError(status: Int)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .httpError(let status):
|
||||
switch status {
|
||||
case 400:
|
||||
return NSLocalizedString("Bad Request", comment: "Bad Request")
|
||||
case 401:
|
||||
return NSLocalizedString("Unauthorized", comment: "Unauthorized")
|
||||
case 402:
|
||||
return NSLocalizedString("Payment Required", comment: "Payment Required")
|
||||
case 403:
|
||||
return NSLocalizedString("Forbidden", comment: "Forbidden")
|
||||
case 404:
|
||||
return NSLocalizedString("Not Found", comment: "Not Found")
|
||||
case 405:
|
||||
return NSLocalizedString("Method Not Allowed", comment: "Method Not Allowed")
|
||||
case 406:
|
||||
return NSLocalizedString("Not Acceptable", comment: "Not Acceptable")
|
||||
case 407:
|
||||
return NSLocalizedString("Proxy Authentication Required", comment: "Proxy Authentication Required")
|
||||
case 408:
|
||||
return NSLocalizedString("Request Timeout", comment: "Request Timeout")
|
||||
case 409:
|
||||
return NSLocalizedString("Conflict", comment: "Conflict")
|
||||
case 410:
|
||||
return NSLocalizedString("Gone", comment: "Gone")
|
||||
case 411:
|
||||
return NSLocalizedString("Length Required", comment: "Length Required")
|
||||
case 412:
|
||||
return NSLocalizedString("Precondition Failed", comment: "Precondition Failed")
|
||||
case 413:
|
||||
return NSLocalizedString("Payload Too Large", comment: "Payload Too Large")
|
||||
case 414:
|
||||
return NSLocalizedString("Request-URI Too Long", comment: "Request-URI Too Long")
|
||||
case 415:
|
||||
return NSLocalizedString("Unsupported Media Type", comment: "Unsupported Media Type")
|
||||
case 416:
|
||||
return NSLocalizedString("Requested Range Not Satisfiable", comment: "Requested Range Not Satisfiable")
|
||||
case 417:
|
||||
return NSLocalizedString("Expectation Failed", comment: "Expectation Failed")
|
||||
case 418:
|
||||
return NSLocalizedString("I'm a teapot", comment: "I'm a teapot")
|
||||
case 421:
|
||||
return NSLocalizedString("Misdirected Request", comment: "Misdirected Request")
|
||||
case 422:
|
||||
return NSLocalizedString("Unprocessable Entity", comment: "Unprocessable Entity")
|
||||
case 423:
|
||||
return NSLocalizedString("Locked", comment: "Locked")
|
||||
case 424:
|
||||
return NSLocalizedString("Failed Dependency", comment: "Failed Dependency")
|
||||
case 426:
|
||||
return NSLocalizedString("Upgrade Required", comment: "Upgrade Required")
|
||||
case 428:
|
||||
return NSLocalizedString("Precondition Required", comment: "Precondition Required")
|
||||
case 429:
|
||||
return NSLocalizedString("Too Many Requests", comment: "Too Many Requests")
|
||||
case 431:
|
||||
return NSLocalizedString("Request Header Fields Too Large", comment: "Request Header Fields Too Large")
|
||||
case 444:
|
||||
return NSLocalizedString("Connection Closed Without Response", comment: "Connection Closed Without Response")
|
||||
case 451:
|
||||
return NSLocalizedString("Unavailable For Legal Reasons", comment: "Unavailable For Legal Reasons")
|
||||
case 499:
|
||||
return NSLocalizedString("Client Closed Request", comment: "Client Closed Request")
|
||||
case 500:
|
||||
return NSLocalizedString("Internal Server Error", comment: "Internal Server Error")
|
||||
case 501:
|
||||
return NSLocalizedString("Not Implemented", comment: "Not Implemented")
|
||||
case 502:
|
||||
return NSLocalizedString("Bad Gateway", comment: "Bad Gateway")
|
||||
case 503:
|
||||
return NSLocalizedString("Service Unavailable", comment: "Service Unavailable")
|
||||
case 504:
|
||||
return NSLocalizedString("Gateway Timeout", comment: "Gateway Timeout")
|
||||
case 505:
|
||||
return NSLocalizedString("HTTP Version Not Supported", comment: "HTTP Version Not Supported")
|
||||
case 506:
|
||||
return NSLocalizedString("Variant Also Negotiates", comment: "Variant Also Negotiates")
|
||||
case 507:
|
||||
return NSLocalizedString("Insufficient Storage", comment: "Insufficient Storage")
|
||||
case 508:
|
||||
return NSLocalizedString("Loop Detected", comment: "Loop Detected")
|
||||
case 510:
|
||||
return NSLocalizedString("Not Extended", comment: "Not Extended")
|
||||
case 511:
|
||||
return NSLocalizedString("Network Authentication Required", comment: "Network Authentication Required")
|
||||
case 599:
|
||||
return NSLocalizedString("Network Connect Timeout Error", comment: "Network Connect Timeout Error")
|
||||
default:
|
||||
let msg = NSLocalizedString("HTTP Status: ", comment: "Unexpected error")
|
||||
return "\(msg) \(status)"
|
||||
}
|
||||
default:
|
||||
return NSLocalizedString("An unknown network error occurred.", comment: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public protocol Transport {
|
||||
|
||||
/// Cancels all pending requests
|
||||
func cancelAll()
|
||||
|
||||
/// Sends URLRequest and returns the HTTP headers and the data payload.
|
||||
func send(request: URLRequest, completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> Void)
|
||||
|
||||
/// Sends URLRequest that doesn't require any result information.
|
||||
func send(request: URLRequest, method: String, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
|
||||
/// Sends URLRequest with a data payload and returns the HTTP headers and the data payload.
|
||||
func send(request: URLRequest, method: String, payload: Data, completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> Void)
|
||||
|
||||
}
|
||||
|
||||
extension URLSession: Transport {
|
||||
|
||||
public func cancelAll() {
|
||||
getTasksWithCompletionHandler { dataTasks, uploadTasks, downloadTasks in
|
||||
dataTasks.forEach { $0.cancel() }
|
||||
uploadTasks.forEach { $0.cancel() }
|
||||
downloadTasks.forEach { $0.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
public func send(request: URLRequest, completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> Void) {
|
||||
let task = self.dataTask(with: request) { (data, response, error) in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
return completion(.failure(error))
|
||||
}
|
||||
|
||||
guard let response = response as? HTTPURLResponse, let data = data else {
|
||||
return completion(.failure(TransportError.noData))
|
||||
}
|
||||
|
||||
switch response.forcedStatusCode {
|
||||
case 200...399:
|
||||
completion(.success((response, data)))
|
||||
default:
|
||||
completion(.failure(TransportError.httpError(status: response.forcedStatusCode)))
|
||||
}
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
|
||||
public func send(request: URLRequest, method: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
var sendRequest = request
|
||||
sendRequest.httpMethod = method
|
||||
|
||||
let task = self.dataTask(with: sendRequest) { (data, response, error) in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
return completion(.failure(error))
|
||||
}
|
||||
|
||||
guard let response = response as? HTTPURLResponse else {
|
||||
return completion(.failure(TransportError.noData))
|
||||
}
|
||||
|
||||
switch response.forcedStatusCode {
|
||||
case 200...399:
|
||||
completion(.success(()))
|
||||
default:
|
||||
completion(.failure(TransportError.httpError(status: response.forcedStatusCode)))
|
||||
}
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
|
||||
public func send(request: URLRequest, method: String, payload: Data, completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> Void) {
|
||||
|
||||
var sendRequest = request
|
||||
sendRequest.httpMethod = method
|
||||
|
||||
let task = self.uploadTask(with: sendRequest, from: payload) { (data, response, error) in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
return completion(.failure(error))
|
||||
}
|
||||
|
||||
guard let response = response as? HTTPURLResponse, let data = data else {
|
||||
return completion(.failure(TransportError.noData))
|
||||
}
|
||||
|
||||
switch response.forcedStatusCode {
|
||||
case 200...399:
|
||||
completion(.success((response, data)))
|
||||
default:
|
||||
completion(.failure(TransportError.httpError(status: response.forcedStatusCode)))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
|
||||
public static func webserviceTransport() -> Transport {
|
||||
|
||||
let sessionConfiguration = URLSessionConfiguration.default
|
||||
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
sessionConfiguration.timeoutIntervalForRequest = 60.0
|
||||
sessionConfiguration.httpShouldSetCookies = false
|
||||
sessionConfiguration.httpCookieAcceptPolicy = .never
|
||||
sessionConfiguration.httpMaximumConnectionsPerHost = 2
|
||||
sessionConfiguration.httpCookieStorage = nil
|
||||
sessionConfiguration.urlCache = nil
|
||||
|
||||
if let userAgentHeaders = UserAgent.headers() {
|
||||
sessionConfiguration.httpAdditionalHeaders = userAgentHeaders
|
||||
}
|
||||
|
||||
return URLSession(configuration: sessionConfiguration)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,153 @@
|
|||
//
|
||||
// JSONTransport.swift
|
||||
// RSWeb
|
||||
//
|
||||
// Created by Maurice Parker on 5/6/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Transport {
|
||||
|
||||
/**
|
||||
Sends an HTTP get and returns JSON object(s)
|
||||
*/
|
||||
public func send<R: Decodable>(request: URLRequest, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void) {
|
||||
|
||||
send(request: request) { result in
|
||||
DispatchQueue.main.async {
|
||||
|
||||
switch result {
|
||||
case .success(let (response, data)):
|
||||
if let data = data, !data.isEmpty {
|
||||
// PBS 27 Sep. 2019: decode the JSON on a background thread.
|
||||
// The profiler says that this is 45% of 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<P: Encodable>(request: URLRequest, method: String, payload: P, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
var postRequest = request
|
||||
postRequest.addValue("application/json; charset=utf-8", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
|
||||
let data: Data
|
||||
do {
|
||||
data = try JSONEncoder().encode(payload)
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
send(request: postRequest, method: method, payload: data) { result in
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .success((_, _)):
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Sends the specified HTTP method with a JSON payload and returns JSON object(s).
|
||||
*/
|
||||
public func send<P: Encodable, R: Decodable>(request: URLRequest, method: String, payload: P, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void) {
|
||||
|
||||
var postRequest = request
|
||||
postRequest.addValue("application/json; charset=utf-8", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
|
||||
let data: Data
|
||||
do {
|
||||
data = try JSONEncoder().encode(payload)
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
send(request: postRequest, method: method, payload: data) { result in
|
||||
DispatchQueue.main.async {
|
||||
|
||||
switch result {
|
||||
case .success(let (response, data)):
|
||||
do {
|
||||
if let data = data, !data.isEmpty {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = dateDecoding
|
||||
decoder.keyDecodingStrategy = keyDecoding
|
||||
let decoded = try decoder.decode(R.self, from: data)
|
||||
completion(.success((response, decoded)))
|
||||
} else {
|
||||
completion(.success((response, nil)))
|
||||
}
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Sends the specified HTTP method with a Raw payload and returns JSON object(s).
|
||||
*/
|
||||
public func send<R: Decodable>(request: URLRequest, method: String, data: Data, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void) {
|
||||
|
||||
send(request: request, method: method, payload: data) { result in
|
||||
DispatchQueue.main.async {
|
||||
|
||||
switch result {
|
||||
case .success(let (response, data)):
|
||||
do {
|
||||
if let data = data, !data.isEmpty {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = dateDecoding
|
||||
decoder.keyDecodingStrategy = keyDecoding
|
||||
let decoded = try decoder.decode(R.self, from: data)
|
||||
completion(.success((response, decoded)))
|
||||
} else {
|
||||
completion(.success((response, nil)))
|
||||
}
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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 = #"<foo>"bar"&'baz'"#.escapedHTML
|
||||
XCTAssertEqual(s, "<foo>"bar"&'baz'")
|
||||
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
]),
|
||||
]
|
||||
]
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue