diff --git a/.github/workflows/swiftpm.yml b/.github/workflows/swiftpm.yml
index f8272297..26515a14 100644
--- a/.github/workflows/swiftpm.yml
+++ b/.github/workflows/swiftpm.yml
@@ -2,9 +2,9 @@ name: SwiftPM regression tests
on:
push:
- branches: [ master ]
+ branches: [ main, ci-experiments ]
pull_request:
- branches: [ master ]
+ branches: [ main ]
jobs:
test:
diff --git a/.github/workflows/xcode.yml b/.github/workflows/xcode.yml
index 3760d146..dcf58c56 100644
--- a/.github/workflows/xcode.yml
+++ b/.github/workflows/xcode.yml
@@ -2,9 +2,9 @@ name: Xcode regression tests
on:
push:
- branches: [ $default-branch, main, master, ci-experiments ]
+ branches: [ main, ci-experiments ]
pull_request:
- branches: [ $default-branch, main, master ]
+ branches: [ main ]
jobs:
test:
diff --git a/Examples/GithubBrowser/GithubBrowser.xcodeproj/xcshareddata/xcschemes/GithubBrowser.xcscheme b/Examples/GithubBrowser/GithubBrowser.xcodeproj/xcshareddata/xcschemes/GithubBrowser.xcscheme
new file mode 100644
index 00000000..aca962d4
--- /dev/null
+++ b/Examples/GithubBrowser/GithubBrowser.xcodeproj/xcshareddata/xcschemes/GithubBrowser.xcscheme
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Examples/GithubBrowser/Source/API/GithubAPI.swift b/Examples/GithubBrowser/Source/API/GithubAPI.swift
index d7c2ae09..99bc94d9 100644
--- a/Examples/GithubBrowser/Source/API/GithubAPI.swift
+++ b/Examples/GithubBrowser/Source/API/GithubAPI.swift
@@ -17,7 +17,7 @@ class _GitHubAPI {
fileprivate init() {
#if DEBUG
// Bare-bones logging of which network calls Siesta makes:
- SiestaLog.Category.enabled = [.network]
+ SiestaLog.Category.enabled = [.network, .cache]
// For more info about how Siesta decides whether to make a network call,
// and which state updates it broadcasts to the app:
@@ -44,13 +44,48 @@ class _GitHubAPI {
$0.pipeline[.cleanup].add(
GitHubErrorMessageExtractor(jsonDecoder: jsonDecoder))
+
+ // Cache API results for fast launch & offline access:
+
+ $0.pipeline[.rawData].cacheUsing {
+ try FileCache(
+ poolName: "api.github.com",
+ dataIsolation: .perUser(identifiedBy: self.username)) // Show each user their own data
+ }
+
+ // Using the closure form of cacheUsing above signals that if we encounter an error trying create a cache
+ // directory or generate a cache isolation key from the username, we should simply proceed silently without
+ // having a persistent cache.
+
+ // Note that the dataIsolation uses only username. This means that users will not _see_ other users’ data;
+ // however, it does not _secure_ one user’s data from another. A user with permission to see the cache
+ // directory could in principle see all the cached data.
+ //
+ // To fully secure one user’s data from another, the application would need to generate some long-lived
+ // secret that is unique to each user. A password can work, though it will essentially empty the user’s
+ // cache if the password changes. The server could also send some kind of high-entropy per-user token in
+ // the authentication response.
}
+ RemoteImageView.defaultImageService.configure {
+ // We can cache images offline too:
+
+ $0.pipeline[.rawData].cacheUsing {
+ try FileCache(
+ poolName: "images",
+ dataIsolation: .sharedByAllUsers) // images aren't secret, so no need to isolate them
+ }
+ }
+
+
// –––––– Resource-specific configuration ––––––
service.configure("/search/**") {
// Refresh search results after 10 seconds (Siesta default is 30)
$0.expirationTime = 10
+
+ // Don't cache search results between runs, so we don't see stale results on launch
+ $0.pipeline.removeAllCaches()
}
// –––––– Auth configuration ––––––
@@ -116,12 +151,14 @@ class _GitHubAPI {
// MARK: - Authentication
func logIn(username: String, password: String) {
- if let auth = "\(username):\(password)".data(using: String.Encoding.utf8) {
+ self.username = username
+ if let auth = "\(username):\(password)".data(using: .utf8) {
basicAuthHeader = "Basic \(auth.base64EncodedString())"
}
}
func logOut() {
+ username = nil
basicAuthHeader = nil
}
@@ -129,6 +166,8 @@ class _GitHubAPI {
return basicAuthHeader != nil
}
+ private var username: String?
+
private var basicAuthHeader: String? {
didSet {
// These two calls are almost always necessary when you have changing auth for your API:
diff --git a/Examples/GithubBrowser/Source/UI/RepositoryListViewController.swift b/Examples/GithubBrowser/Source/UI/RepositoryListViewController.swift
index a5fc1fd8..805ee237 100644
--- a/Examples/GithubBrowser/Source/UI/RepositoryListViewController.swift
+++ b/Examples/GithubBrowser/Source/UI/RepositoryListViewController.swift
@@ -38,7 +38,9 @@ class RepositoryListViewController: UITableViewController, ResourceObserver {
super.viewDidLoad()
view.backgroundColor = SiestaTheme.darkColor
+
statusOverlay.embed(in: self)
+ statusOverlay.displayPriority = [.anyData, .loading, .error]
}
override func viewDidLayoutSubviews() {
diff --git a/Examples/GithubBrowser/Source/UI/RepositoryViewController.swift b/Examples/GithubBrowser/Source/UI/RepositoryViewController.swift
index 64e56103..acfbd3db 100644
--- a/Examples/GithubBrowser/Source/UI/RepositoryViewController.swift
+++ b/Examples/GithubBrowser/Source/UI/RepositoryViewController.swift
@@ -76,6 +76,7 @@ class RepositoryViewController: UIViewController, ResourceObserver {
super.viewDidLoad()
view.backgroundColor = SiestaTheme.darkColor
+
statusOverlay.embed(in: self)
statusOverlay.displayPriority = [.anyData, .loading, .error] // Prioritize partial data over loading indicator
diff --git a/Examples/GithubBrowser/Source/UI/UserViewController.swift b/Examples/GithubBrowser/Source/UI/UserViewController.swift
index 1a938185..ef7bf60f 100644
--- a/Examples/GithubBrowser/Source/UI/UserViewController.swift
+++ b/Examples/GithubBrowser/Source/UI/UserViewController.swift
@@ -53,6 +53,8 @@ class UserViewController: UIViewController, UISearchBarDelegate, ResourceObserve
view.backgroundColor = SiestaTheme.darkColor
statusOverlay.embed(in: self)
+ statusOverlay.displayPriority = [.anyData, .loading, .error]
+
showUser(nil)
searchBar.becomeFirstResponder()
diff --git a/Package.swift b/Package.swift
index 912c5df0..42036fe1 100644
--- a/Package.swift
+++ b/Package.swift
@@ -27,6 +27,10 @@ let package = Package(
.target(
name: "Siesta"
),
+ .target(
+ name: "SiestaTools",
+ dependencies: ["Siesta"]
+ ),
.target(
name: "SiestaUI",
dependencies: ["Siesta"],
@@ -42,7 +46,7 @@ let package = Package(
),
.testTarget(
name: "SiestaTests",
- dependencies: ["SiestaUI", "Siesta_Alamofire", "Quick", "Nimble"],
+ dependencies: ["SiestaUI", "SiestaTools", "Siesta_Alamofire", "Quick", "Nimble"],
path: "Tests/Functional",
exclude: ["ObjcCompatibilitySpec.m"] // SwiftPM currently only supports Swift
),
diff --git a/Siesta.podspec b/Siesta.podspec
index a721af78..1c9e2a2b 100644
--- a/Siesta.podspec
+++ b/Siesta.podspec
@@ -79,6 +79,12 @@ Pod::Spec.new do |s|
s.exclude_files = "**/Info*.plist"
end
+ s.subspec "Tools" do |s|
+ s.source_files = "Source/SiestaTools/**/*"
+ s.exclude_files = "**/Info*.plist"
+ s.dependency "Siesta/Core"
+ end
+
s.subspec "UI" do |s|
s.ios.source_files = "Source/SiestaUI/**/*.{swift,m,h}"
s.dependency "Siesta/Core"
diff --git a/Siesta.xcodeproj/project.pbxproj b/Siesta.xcodeproj/project.pbxproj
index e800c8c1..7fd6db1c 100644
--- a/Siesta.xcodeproj/project.pbxproj
+++ b/Siesta.xcodeproj/project.pbxproj
@@ -144,6 +144,12 @@
DA34E2C9222BAAE70025A77A /* Optional+Siesta.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA34E2C7222BA8650025A77A /* Optional+Siesta.swift */; };
DA34E2CA222BAAEA0025A77A /* Optional+Siesta.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA34E2C7222BA8650025A77A /* Optional+Siesta.swift */; };
DA34E2CB222BAAEB0025A77A /* Optional+Siesta.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA34E2C7222BA8650025A77A /* Optional+Siesta.swift */; };
+ DA3E165F243C24A3001F7CCA /* FileCacheSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3E165E243C24A3001F7CCA /* FileCacheSpec.swift */; };
+ DA3E1660243C24A3001F7CCA /* FileCacheSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3E165E243C24A3001F7CCA /* FileCacheSpec.swift */; };
+ DA3E1661243C24A3001F7CCA /* FileCacheSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3E165E243C24A3001F7CCA /* FileCacheSpec.swift */; };
+ DA3E1662243D6D00001F7CCA /* SiestaTools.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DA8B5B5A22DEAC93008E47B0 /* SiestaTools.framework */; };
+ DA3E1663243D6D0E001F7CCA /* SiestaTools.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DA8C83521FC600A900C947F9 /* SiestaTools.framework */; };
+ DA3E1664243D6D18001F7CCA /* SiestaTools.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DA8B5B6922DEADDB008E47B0 /* SiestaTools.framework */; };
DA3FBD9B1B55917600161A25 /* Siesta-ObjC.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3FBD9A1B55917600161A25 /* Siesta-ObjC.swift */; };
DA3FBDB11B55BF0E00161A25 /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3FBDB01B55BF0E00161A25 /* Logging.swift */; };
DA3FD66C1D0DF5DE00C75742 /* PipelineConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACE0BAD1D0201F800607F3E /* PipelineConfiguration.swift */; };
@@ -188,6 +194,12 @@
DA788A8F1D6AC1590085C820 /* ObjcCompatibilitySpec.m in Sources */ = {isa = PBXBuildFile; fileRef = DA4D61971B751FEE00F6BB9C /* ObjcCompatibilitySpec.m */; };
DA7D05C41D57C2B500431980 /* PipelineProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7D05C31D57C2B500431980 /* PipelineProcessing.swift */; };
DA7D05C51D57C30400431980 /* PipelineProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7D05C31D57C2B500431980 /* PipelineProcessing.swift */; };
+ DA8B5B5522DEAC93008E47B0 /* SiestaTools.h in Headers */ = {isa = PBXBuildFile; fileRef = DA8C83571FC6096100C947F9 /* SiestaTools.h */; settings = {ATTRIBUTES = (Public, ); }; };
+ DA8B5B6422DEADDB008E47B0 /* SiestaTools.h in Headers */ = {isa = PBXBuildFile; fileRef = DA8C83571FC6096100C947F9 /* SiestaTools.h */; settings = {ATTRIBUTES = (Public, ); }; };
+ DA8B5B7122DEB0DF008E47B0 /* FileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8DEB491FC6763300555D92 /* FileCache.swift */; };
+ DA8B5B7222DEB0DF008E47B0 /* FileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8DEB491FC6763300555D92 /* FileCache.swift */; };
+ DA8C83581FC6096100C947F9 /* SiestaTools.h in Headers */ = {isa = PBXBuildFile; fileRef = DA8C83571FC6096100C947F9 /* SiestaTools.h */; settings = {ATTRIBUTES = (Public, ); }; };
+ DA8DEB4A1FC6763300555D92 /* FileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8DEB491FC6763300555D92 /* FileCache.swift */; };
DA8EF6D11BC20AFE002175EB /* ProgressSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8EF6CF1BC1A917002175EB /* ProgressSpec.swift */; };
DA99B5C91B38C8E6009C6937 /* String+Siesta.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA99B5C81B38C8E6009C6937 /* String+Siesta.swift */; };
DA9AA8151CCAFDD20016DB18 /* ConfigurationPatternConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9AA8141CCAFDD20016DB18 /* ConfigurationPatternConvertible.swift */; };
@@ -293,6 +305,48 @@
remoteGlobalIDString = DA5E4B3922DCE9670059ED10;
remoteInfo = "SiestaUI tvOS";
};
+ DA8B5B5C22DEACA4008E47B0 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = DA336E461B2E6DDB006F702A /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 9F2FB51D1C28645E0068DFFA;
+ remoteInfo = "Siesta macOS";
+ };
+ DA8B5B6B22DEAE72008E47B0 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = DA336E461B2E6DDB006F702A /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = CDCCAF711E6A31D900860D18;
+ remoteInfo = "Siesta tvOS";
+ };
+ DA8B5B6D22DEAE97008E47B0 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = DA336E461B2E6DDB006F702A /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = DA8B5B4F22DEAC93008E47B0;
+ remoteInfo = "SiestaTools macOS";
+ };
+ DA8B5B6F22DEAEA2008E47B0 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = DA336E461B2E6DDB006F702A /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = DA8B5B5E22DEADDB008E47B0;
+ remoteInfo = "SiestaTools tvOS";
+ };
+ DA8C83421FC600A900C947F9 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = DA336E461B2E6DDB006F702A /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = DA336E4E1B2E6DDB006F702A;
+ remoteInfo = "SiestaTools iOS";
+ };
+ DA8C83591FC60A2900C947F9 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = DA336E461B2E6DDB006F702A /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = DA8C83401FC600A900C947F9;
+ remoteInfo = "SiestaTools iOS";
+ };
DAC4CFCA1DAC10EC00EECEDE /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = DA336E461B2E6DDB006F702A /* Project object */;
@@ -391,6 +445,7 @@
DA336E601B2E6DDB006F702A /* Info-iOS.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info-iOS.plist"; sourceTree = ""; };
DA336E701B2F6659006F702A /* Service.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Service.swift; sourceTree = ""; };
DA34E2C7222BA8650025A77A /* Optional+Siesta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Siesta.swift"; sourceTree = ""; };
+ DA3E165E243C24A3001F7CCA /* FileCacheSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCacheSpec.swift; sourceTree = ""; };
DA3FBD9A1B55917600161A25 /* Siesta-ObjC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Siesta-ObjC.swift"; sourceTree = ""; };
DA3FBDB01B55BF0E00161A25 /* Logging.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = ""; };
DA4353511B6AD63C00543843 /* Networking.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Networking.swift; sourceTree = ""; };
@@ -408,6 +463,11 @@
DA60F5FB1B30B2F800D76DC6 /* Resource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Resource.swift; sourceTree = ""; };
DA62C97C2428589B00398674 /* NetworkStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkStub.swift; sourceTree = ""; };
DA7D05C31D57C2B500431980 /* PipelineProcessing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PipelineProcessing.swift; sourceTree = ""; };
+ DA8B5B5A22DEAC93008E47B0 /* SiestaTools.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SiestaTools.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ DA8B5B6922DEADDB008E47B0 /* SiestaTools.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SiestaTools.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ DA8C83521FC600A900C947F9 /* SiestaTools.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SiestaTools.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ DA8C83571FC6096100C947F9 /* SiestaTools.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SiestaTools.h; sourceTree = ""; };
+ DA8DEB491FC6763300555D92 /* FileCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileCache.swift; sourceTree = ""; };
DA8EF6CF1BC1A917002175EB /* ProgressSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgressSpec.swift; sourceTree = ""; };
DA99B5C81B38C8E6009C6937 /* String+Siesta.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Siesta.swift"; sourceTree = ""; };
DA9AA8141CCAFDD20016DB18 /* ConfigurationPatternConvertible.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigurationPatternConvertible.swift; sourceTree = ""; };
@@ -456,6 +516,7 @@
buildActionMask = 2147483647;
files = (
9F0FAFB01D033FF400CE1B61 /* Siesta.framework in Frameworks */,
+ DA3E1662243D6D00001F7CCA /* SiestaTools.framework in Frameworks */,
DAC4CFCE1DAC10FD00EECEDE /* SiestaUI.framework in Frameworks */,
DAF2B7A827189B5A00E5B31A /* Quick in Frameworks */,
DAF2B7A627189B5A00E5B31A /* Alamofire in Frameworks */,
@@ -489,6 +550,7 @@
buildActionMask = 2147483647;
files = (
CDCCAF7B1E6A31DA00860D18 /* Siesta.framework in Frameworks */,
+ DA3E1664243D6D18001F7CCA /* SiestaTools.framework in Frameworks */,
DA5E4B5B22DCF0BB0059ED10 /* SiestaUI.framework in Frameworks */,
DAA9102427239BDE00B2211D /* Nimble in Frameworks */,
DAA9102027239BDE00B2211D /* Alamofire in Frameworks */,
@@ -516,6 +578,7 @@
buildActionMask = 2147483647;
files = (
DA336E5A1B2E6DDB006F702A /* Siesta.framework in Frameworks */,
+ DA3E1663243D6D0E001F7CCA /* SiestaTools.framework in Frameworks */,
DAC4CFCF1DAC110500EECEDE /* SiestaUI.framework in Frameworks */,
DAA9101E27239BD700B2211D /* Nimble in Frameworks */,
DAA9101A27239BD700B2211D /* Alamofire in Frameworks */,
@@ -530,6 +593,27 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ DA8B5B5322DEAC93008E47B0 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ DA8B5B6222DEADDB008E47B0 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ DA8C834A1FC600A900C947F9 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
DAC0B3A21D651CB500D25C44 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -617,6 +701,9 @@
CDCCAF721E6A31D900860D18 /* Siesta.framework */,
CDCCAF7A1E6A31DA00860D18 /* SiestaTests.xctest */,
DA5E4B4B22DCE9670059ED10 /* SiestaUI.framework */,
+ DA8C83521FC600A900C947F9 /* SiestaTools.framework */,
+ DA8B5B5A22DEAC93008E47B0 /* SiestaTools.framework */,
+ DA8B5B6922DEADDB008E47B0 /* SiestaTools.framework */,
);
name = Products;
sourceTree = "";
@@ -625,6 +712,7 @@
isa = PBXGroup;
children = (
DAC0B37B1D651A4600D25C44 /* Core */,
+ DA8C833F1FC5FFDA00C947F9 /* Tools */,
DAE490981B4E51AA004D97D6 /* UI (iOS) */,
);
path = Source;
@@ -647,10 +735,20 @@
DA9F4BDD1B3F8E2E00E8966F /* WeakCacheSpec.swift */,
DA4D61971B751FEE00F6BB9C /* ObjcCompatibilitySpec.m */,
DA99B5C71B38C36A009C6937 /* Support */,
+ DA3E165E243C24A3001F7CCA /* FileCacheSpec.swift */,
);
path = Functional;
sourceTree = "";
};
+ DA8C833F1FC5FFDA00C947F9 /* Tools */ = {
+ isa = PBXGroup;
+ children = (
+ DA8DEB491FC6763300555D92 /* FileCache.swift */,
+ );
+ name = Tools;
+ path = SiestaTools;
+ sourceTree = "";
+ };
DA99B5C61B38C356009C6937 /* Support */ = {
isa = PBXGroup;
children = (
@@ -659,6 +757,7 @@
CDCCAF681E6A313800860D18 /* Info-watchOS.plist */,
CDCCAF751E6A31D900860D18 /* Info-tvOS.plist */,
DA336E521B2E6DDB006F702A /* Siesta.h */,
+ DA8C83571FC6096100C947F9 /* SiestaTools.h */,
DA6022801D65590800FB5673 /* SiestaUI.h */,
DA3FBD9A1B55917600161A25 /* Siesta-ObjC.swift */,
DA9F4BDB1B3DFE3700E8966F /* WeakCache.swift */,
@@ -811,6 +910,30 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ DA8B5B5422DEAC93008E47B0 /* Headers */ = {
+ isa = PBXHeadersBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ DA8B5B5522DEAC93008E47B0 /* SiestaTools.h in Headers */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ DA8B5B6322DEADDB008E47B0 /* Headers */ = {
+ isa = PBXHeadersBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ DA8B5B6422DEADDB008E47B0 /* SiestaTools.h in Headers */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ DA8C834B1FC600A900C947F9 /* Headers */ = {
+ isa = PBXHeadersBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ DA8C83581FC6096100C947F9 /* SiestaTools.h in Headers */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
DAC0B3A31D651CB500D25C44 /* Headers */ = {
isa = PBXHeadersBuildPhase;
buildActionMask = 2147483647;
@@ -844,6 +967,7 @@
);
dependencies = (
9F0FAFAF1D033FE800CE1B61 /* PBXTargetDependency */,
+ DA8B5B6E22DEAE97008E47B0 /* PBXTargetDependency */,
DAC4CFCD1DAC10F500EECEDE /* PBXTargetDependency */,
);
name = "SiestaTests macOS";
@@ -926,6 +1050,7 @@
);
dependencies = (
CDCCAF7D1E6A31DA00860D18 /* PBXTargetDependency */,
+ DA8B5B7022DEAEA2008E47B0 /* PBXTargetDependency */,
DA5E4B5122DCEFCA0059ED10 /* PBXTargetDependency */,
);
name = "SiestaTests tvOS";
@@ -991,6 +1116,7 @@
);
dependencies = (
DA336E5C1B2E6DDB006F702A /* PBXTargetDependency */,
+ DA8C835A1FC60A2900C947F9 /* PBXTargetDependency */,
DAC4CFCB1DAC10EC00EECEDE /* PBXTargetDependency */,
);
name = "SiestaTests iOS";
@@ -1022,6 +1148,63 @@
productReference = DA5E4B4B22DCE9670059ED10 /* SiestaUI.framework */;
productType = "com.apple.product-type.framework";
};
+ DA8B5B4F22DEAC93008E47B0 /* SiestaTools macOS */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = DA8B5B5722DEAC93008E47B0 /* Build configuration list for PBXNativeTarget "SiestaTools macOS" */;
+ buildPhases = (
+ DA8B5B5222DEAC93008E47B0 /* Sources */,
+ DA8B5B5322DEAC93008E47B0 /* Frameworks */,
+ DA8B5B5422DEAC93008E47B0 /* Headers */,
+ DA8B5B5622DEAC93008E47B0 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ DA8B5B5D22DEACA4008E47B0 /* PBXTargetDependency */,
+ );
+ name = "SiestaTools macOS";
+ productName = Siesta;
+ productReference = DA8B5B5A22DEAC93008E47B0 /* SiestaTools.framework */;
+ productType = "com.apple.product-type.framework";
+ };
+ DA8B5B5E22DEADDB008E47B0 /* SiestaTools tvOS */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = DA8B5B6622DEADDB008E47B0 /* Build configuration list for PBXNativeTarget "SiestaTools tvOS" */;
+ buildPhases = (
+ DA8B5B6122DEADDB008E47B0 /* Sources */,
+ DA8B5B6222DEADDB008E47B0 /* Frameworks */,
+ DA8B5B6322DEADDB008E47B0 /* Headers */,
+ DA8B5B6522DEADDB008E47B0 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ DA8B5B6C22DEAE72008E47B0 /* PBXTargetDependency */,
+ );
+ name = "SiestaTools tvOS";
+ productName = Siesta;
+ productReference = DA8B5B6922DEADDB008E47B0 /* SiestaTools.framework */;
+ productType = "com.apple.product-type.framework";
+ };
+ DA8C83401FC600A900C947F9 /* SiestaTools iOS */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = DA8C834F1FC600A900C947F9 /* Build configuration list for PBXNativeTarget "SiestaTools iOS" */;
+ buildPhases = (
+ DA8C83431FC600A900C947F9 /* Sources */,
+ DA8C834A1FC600A900C947F9 /* Frameworks */,
+ DA8C834B1FC600A900C947F9 /* Headers */,
+ DA8C834D1FC600A900C947F9 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ DA8C83411FC600A900C947F9 /* PBXTargetDependency */,
+ );
+ name = "SiestaTools iOS";
+ productName = Siesta;
+ productReference = DA8C83521FC600A900C947F9 /* SiestaTools.framework */;
+ productType = "com.apple.product-type.framework";
+ };
DAC0B37D1D651CB500D25C44 /* SiestaUI iOS */ = {
isa = PBXNativeTarget;
buildConfigurationList = DAC0B3A71D651CB500D25C44 /* Build configuration list for PBXNativeTarget "SiestaUI iOS" */;
@@ -1105,6 +1288,9 @@
DA5E4B3922DCE9670059ED10 = {
ProvisioningStyle = Manual;
};
+ DA8C83401FC600A900C947F9 = {
+ LastSwiftMigration = 0910;
+ };
DAC0B37D1D651CB500D25C44 = {
LastSwiftMigration = 1000;
};
@@ -1135,6 +1321,9 @@
9F2FB51D1C28645E0068DFFA /* Siesta macOS */,
CDCCAF641E6A313800860D18 /* Siesta watchOS */,
CDCCAF711E6A31D900860D18 /* Siesta tvOS */,
+ DA8C83401FC600A900C947F9 /* SiestaTools iOS */,
+ DA8B5B4F22DEAC93008E47B0 /* SiestaTools macOS */,
+ DA8B5B5E22DEADDB008E47B0 /* SiestaTools tvOS */,
DAC0B37D1D651CB500D25C44 /* SiestaUI iOS */,
DAC0B3AC1D651CC700D25C44 /* SiestaUI macOS */,
DA5E4B3922DCE9670059ED10 /* SiestaUI tvOS */,
@@ -1210,6 +1399,27 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ DA8B5B5622DEAC93008E47B0 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ DA8B5B6522DEADDB008E47B0 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ DA8C834D1FC600A900C947F9 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
DAC0B3A51D651CB500D25C44 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -1302,6 +1512,7 @@
DA788A8F1D6AC1590085C820 /* ObjcCompatibilitySpec.m in Sources */,
9F0FAF991D033FCD00CE1B61 /* TestService.swift in Sources */,
9F0FAF9A1D033FCD00CE1B61 /* SiestaSpec.swift in Sources */,
+ DA3E1660243C24A3001F7CCA /* FileCacheSpec.swift in Sources */,
DA5A90312054E0C500309D8B /* EntityCacheSpec.swift in Sources */,
9F0FAF9B1D033FCD00CE1B61 /* ResourcePathsSpec.swift in Sources */,
DA7285E71D0DF46600132CD9 /* PipelineSpec.swift in Sources */,
@@ -1451,6 +1662,7 @@
CD5651F71E6F306B009F224A /* ResponseDataHandlingSpec.swift in Sources */,
CD5651F21E6F306B009F224A /* ResourcePathsSpec.swift in Sources */,
CD5651F81E6F306B009F224A /* ProgressSpec.swift in Sources */,
+ DA3E1661243C24A3001F7CCA /* FileCacheSpec.swift in Sources */,
DA5E4B5C22DCF1260059ED10 /* RemoteImageViewSpec.swift in Sources */,
DA62C9862428670500398674 /* NetworkStub.swift in Sources */,
CD5651F11E6F306B009F224A /* ResourceSpecBase.swift in Sources */,
@@ -1529,6 +1741,7 @@
DA2FF1061C0F97F600C98FF1 /* Networking-Alamofire.swift in Sources */,
DA8EF6D11BC20AFE002175EB /* ProgressSpec.swift in Sources */,
DAC0CE6D1B4A3ADA004FBB4B /* ResourceObserversSpec.swift in Sources */,
+ DA3E165F243C24A3001F7CCA /* FileCacheSpec.swift in Sources */,
DA49EA801B36174700AE1B8F /* SpecHelpers.swift in Sources */,
DA62C9842428670400398674 /* NetworkStub.swift in Sources */,
DA9F4BDE1B3F8E2E00E8966F /* WeakCacheSpec.swift in Sources */,
@@ -1559,6 +1772,30 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ DA8B5B5222DEAC93008E47B0 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ DA8B5B7122DEB0DF008E47B0 /* FileCache.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ DA8B5B6122DEADDB008E47B0 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ DA8B5B7222DEB0DF008E47B0 /* FileCache.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ DA8C83431FC600A900C947F9 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ DA8DEB4A1FC6763300555D92 /* FileCache.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
DAC0B37E1D651CB500D25C44 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -1612,6 +1849,36 @@
target = DA5E4B3922DCE9670059ED10 /* SiestaUI tvOS */;
targetProxy = DA5E4B5022DCEFCA0059ED10 /* PBXContainerItemProxy */;
};
+ DA8B5B5D22DEACA4008E47B0 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 9F2FB51D1C28645E0068DFFA /* Siesta macOS */;
+ targetProxy = DA8B5B5C22DEACA4008E47B0 /* PBXContainerItemProxy */;
+ };
+ DA8B5B6C22DEAE72008E47B0 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = CDCCAF711E6A31D900860D18 /* Siesta tvOS */;
+ targetProxy = DA8B5B6B22DEAE72008E47B0 /* PBXContainerItemProxy */;
+ };
+ DA8B5B6E22DEAE97008E47B0 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = DA8B5B4F22DEAC93008E47B0 /* SiestaTools macOS */;
+ targetProxy = DA8B5B6D22DEAE97008E47B0 /* PBXContainerItemProxy */;
+ };
+ DA8B5B7022DEAEA2008E47B0 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = DA8B5B5E22DEADDB008E47B0 /* SiestaTools tvOS */;
+ targetProxy = DA8B5B6F22DEAEA2008E47B0 /* PBXContainerItemProxy */;
+ };
+ DA8C83411FC600A900C947F9 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = DA336E4E1B2E6DDB006F702A /* Siesta iOS */;
+ targetProxy = DA8C83421FC600A900C947F9 /* PBXContainerItemProxy */;
+ };
+ DA8C835A1FC60A2900C947F9 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = DA8C83401FC600A900C947F9 /* SiestaTools iOS */;
+ targetProxy = DA8C83591FC60A2900C947F9 /* PBXContainerItemProxy */;
+ };
DAC4CFCB1DAC10EC00EECEDE /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = DAC0B37D1D651CB500D25C44 /* SiestaUI iOS */;
@@ -1638,6 +1905,7 @@
9F0FAFAA1D033FCD00CE1B61 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
+ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CLANG_ENABLE_MODULES = YES;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = "$(SRCROOT)/Tests/Info-macOS.plist";
@@ -1655,6 +1923,7 @@
9F0FAFAB1D033FCD00CE1B61 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
+ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CLANG_ENABLE_MODULES = YES;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = "$(SRCROOT)/Tests/Info-macOS.plist";
@@ -2068,6 +2337,7 @@
DA336E671B2E6DDB006F702A /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
+ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CLANG_ENABLE_MODULES = YES;
INFOPLIST_FILE = "$(SRCROOT)/Tests/Info-iOS.plist";
LD_RUNPATH_SEARCH_PATHS = (
@@ -2083,6 +2353,7 @@
DA336E681B2E6DDB006F702A /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
+ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CLANG_ENABLE_MODULES = YES;
INFOPLIST_FILE = "$(SRCROOT)/Tests/Info-iOS.plist";
LD_RUNPATH_SEARCH_PATHS = (
@@ -2144,6 +2415,127 @@
};
name = Release;
};
+ DA8B5B5822DEAC93008E47B0 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CLANG_ENABLE_MODULES = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "";
+ DYLIB_INSTALL_NAME_BASE = "@rpath";
+ INFOPLIST_FILE = "$(SRCROOT)/Source/Info-macOS.plist";
+ INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@loader_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.bustoutsolutions.SiestaTools;
+ PRODUCT_NAME = SiestaTools;
+ SDKROOT = macosx;
+ SKIP_INSTALL = YES;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ DA8B5B5922DEAC93008E47B0 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CLANG_ENABLE_MODULES = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "";
+ DYLIB_INSTALL_NAME_BASE = "@rpath";
+ INFOPLIST_FILE = "$(SRCROOT)/Source/Info-macOS.plist";
+ INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@loader_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.bustoutsolutions.SiestaTools;
+ PRODUCT_NAME = SiestaTools;
+ SDKROOT = macosx;
+ SKIP_INSTALL = YES;
+ };
+ name = Release;
+ };
+ DA8B5B6722DEADDB008E47B0 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CLANG_ENABLE_MODULES = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "";
+ DYLIB_INSTALL_NAME_BASE = "@rpath";
+ INFOPLIST_FILE = "$(SRCROOT)/Source/Info-tvOS.plist";
+ INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@loader_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.bustoutsolutions.SiestaTools;
+ PRODUCT_NAME = SiestaTools;
+ SDKROOT = appletvos;
+ SKIP_INSTALL = YES;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ DA8B5B6822DEADDB008E47B0 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CLANG_ENABLE_MODULES = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "";
+ DYLIB_INSTALL_NAME_BASE = "@rpath";
+ INFOPLIST_FILE = "$(SRCROOT)/Source/Info-tvOS.plist";
+ INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@loader_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.bustoutsolutions.SiestaTools;
+ PRODUCT_NAME = SiestaTools;
+ SDKROOT = appletvos;
+ SKIP_INSTALL = YES;
+ };
+ name = Release;
+ };
+ DA8C83501FC600A900C947F9 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CLANG_ENABLE_MODULES = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "";
+ DYLIB_INSTALL_NAME_BASE = "@rpath";
+ INFOPLIST_FILE = "$(SRCROOT)/Source/Info-iOS.plist";
+ INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@loader_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.bustoutsolutions.SiestaTools;
+ PRODUCT_NAME = SiestaTools;
+ SKIP_INSTALL = YES;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ DA8C83511FC600A900C947F9 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CLANG_ENABLE_MODULES = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "";
+ DYLIB_INSTALL_NAME_BASE = "@rpath";
+ INFOPLIST_FILE = "$(SRCROOT)/Source/Info-iOS.plist";
+ INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@loader_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.bustoutsolutions.SiestaTools;
+ PRODUCT_NAME = SiestaTools;
+ SKIP_INSTALL = YES;
+ };
+ name = Release;
+ };
DAC0B3A81D651CB500D25C44 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -2318,6 +2710,33 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ DA8B5B5722DEAC93008E47B0 /* Build configuration list for PBXNativeTarget "SiestaTools macOS" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ DA8B5B5822DEAC93008E47B0 /* Debug */,
+ DA8B5B5922DEAC93008E47B0 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ DA8B5B6622DEADDB008E47B0 /* Build configuration list for PBXNativeTarget "SiestaTools tvOS" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ DA8B5B6722DEADDB008E47B0 /* Debug */,
+ DA8B5B6822DEADDB008E47B0 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ DA8C834F1FC600A900C947F9 /* Build configuration list for PBXNativeTarget "SiestaTools iOS" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ DA8C83501FC600A900C947F9 /* Debug */,
+ DA8C83511FC600A900C947F9 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
DAC0B3A71D651CB500D25C44 /* Build configuration list for PBXNativeTarget "SiestaUI iOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
diff --git a/Siesta.xcodeproj/xcshareddata/xcschemes/SiestaTools iOS.xcscheme b/Siesta.xcodeproj/xcshareddata/xcschemes/SiestaTools iOS.xcscheme
new file mode 100644
index 00000000..c6d389c8
--- /dev/null
+++ b/Siesta.xcodeproj/xcshareddata/xcschemes/SiestaTools iOS.xcscheme
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Siesta.xcodeproj/xcshareddata/xcschemes/SiestaTools macOS.xcscheme b/Siesta.xcodeproj/xcshareddata/xcschemes/SiestaTools macOS.xcscheme
new file mode 100644
index 00000000..cb3d95ba
--- /dev/null
+++ b/Siesta.xcodeproj/xcshareddata/xcschemes/SiestaTools macOS.xcscheme
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Siesta.xcodeproj/xcshareddata/xcschemes/SiestaTools tvOS.xcscheme b/Siesta.xcodeproj/xcshareddata/xcschemes/SiestaTools tvOS.xcscheme
new file mode 100644
index 00000000..20720125
--- /dev/null
+++ b/Siesta.xcodeproj/xcshareddata/xcschemes/SiestaTools tvOS.xcscheme
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/Siesta/Entity.swift b/Source/Siesta/Entity.swift
index 13e210ca..4fa1b8cf 100644
--- a/Source/Siesta/Entity.swift
+++ b/Source/Siesta/Entity.swift
@@ -156,6 +156,12 @@ public struct Entity
}
+extension Entity: Codable where ContentType: Codable
+ {
+ // codability synthesized
+ }
+
+
/**
Mixin that provides convenience accessors for the content of an optional contained entity.
diff --git a/Source/Siesta/EntityCache.swift b/Source/Siesta/EntityCache.swift
index 4e043edd..9a2c055c 100644
--- a/Source/Siesta/EntityCache.swift
+++ b/Source/Siesta/EntityCache.swift
@@ -15,8 +15,10 @@ import Foundation
- recover from low memory situations with fewer reissued network requests, and
- work offline.
- Siesta uses any HTTP request caching provided by the networking layer (e.g. `URLCache`). Why another type of
- caching, then? Because `URLCache` has a subtle but significant mismatch with the use cases above:
+ Siesta can aldo use whatever HTTP request the networking layer provides (e.g. `URLCache`). Why another type of
+ caching, then? Because `URLCache` has a several subtle but significant mismatches with the use cases above.
+
+ The big one:
* The purpose of HTTP caching is to _prevent_ network requests, but what we need is a way to show old data _while
issuing new requests_. This is the real deal-killer.
@@ -29,7 +31,7 @@ import Foundation
exhibit the behavior we want; the logic involved is far more tangled and brittle than implementing a separate cache.
* Precisely because of the complexity of these rules, APIs frequently disable all caching via headers.
* HTTP caching does not preserve Siesta’s timestamps, which thwarts the staleness logic.
- * HTTP caching stores raw responses; storing parsed responses offers the opportunity for faster app launch.
+ * HTTP caching stores raw responses. Apps may wish instead to cache responses in an app-specific parsed form.
Siesta currently does not include any implementations of `EntityCache`, but a future version will.
@@ -46,6 +48,12 @@ public protocol EntityCache
*/
associatedtype Key
+ /**
+ The type of payload this cache knows how to store and retrieve. If the response data configured at a particular
+ point in the cache does not match this content type, Siesta will log a warning and bypass the cache.
+ */
+ associatedtype ContentType
+
/**
Provides the key appropriate to this cache for the given resource.
@@ -53,8 +61,11 @@ public protocol EntityCache
This method is called for both cache writes _and_ for cache reads. The `resource` therefore may not have
any content. Implementations will almost always examine `resource.url`. (Cache keys should be _at least_ as unique
- as URLs except in very unusual circumstances.) Implementations may also want to examine `resource.configuration`,
- for example to take authentication into account.
+ as URLs except in very unusual circumstances.)
+
+ - Warning: When working with an authenticated API, caches must take care not to accidentally mix cached responses
+ for different users. The usual solution to this is to make `Key` vary with some sort of user ID as
+ well as the URL.
- Note: This method is always called on the **main thread**. However, the key it returns will be passed repeatedly
across threads. Siesta therefore strongly recommends making `Key` a value type, i.e. a struct.
@@ -64,42 +75,39 @@ public protocol EntityCache
/**
Return the entity associated with the given key, or nil if it is not in the cache.
- If this method returns an entity, it does _not_ pass through the transformer pipeline. Implementations should
- return the entity as if already fully parsed and transformed — with the same type of `entity.content` that was
- originally sent to `writeEntity(...)`.
+ If this method returns an entity, it passes through the portion of the transformer pipeline _after_ this cache.
- Warning: This method may be called on a background thread. Make sure your implementation is threadsafe.
*/
- func readEntity(forKey key: Key) -> Entity?
+ func readEntity(forKey key: Key) throws -> Entity?
/**
- Store the given entity in the cache, associated with the given key. The key’s format is arbitrary, and internal
- to Siesta. (OK, it’s just the resource’s URL, but you should pretend you don’t know that in your implementation.
- Cache implementations should treat the `forKey` parameter as an opaque value.)
+ Store the given entity in the cache, associated with the given key.
- This method receives entities _after_ they have been through the transformer pipeline. The `entity.content` will
- be a parsed object, not raw data.
+ This method receives entities _after_ they have been through the stage of the transformer pipeline for which this
+ cache is configured.
Implementations are under no obligation to actually perform the write. This method can — and should — examine the
- type of the entity’s `content` and/or its header values, and ignore it if it is not encodable.
+ type of the entity’s `content` and/or its header values, and ignore it if it is unencodable or otherwise
+ unsuitable for caching.
Note that this method does not receive a URL as input; if you need to limit caching to specific resources, use
Siesta’s configuration mechanism to control which resources are cacheable.
- Warning: The method may be called on a background thread. Make sure your implementation is threadsafe.
*/
- func writeEntity(_ entity: Entity, forKey key: Key)
+ func writeEntity(_ entity: Entity, forKey key: Key) throws
/**
Update the timestamp of the entity for the given key. If there is no such cache entry, do nothing.
*/
- func updateEntityTimestamp(_ timestamp: TimeInterval, forKey key: Key)
+ func updateEntityTimestamp(_ timestamp: TimeInterval, forKey key: Key) throws
/**
Remove any entities cached for the given key. After a call to `removeEntity(forKey:)`, subsequent calls to
`readEntity(forKey:)` for the same key **must** return nil until the next call to `writeEntity(_:forKey:)`.
*/
- func removeEntity(forKey key: Key)
+ func removeEntity(forKey key: Key) throws
/**
Returns the GCD queue on which this cache implementation will do its work.
@@ -125,11 +133,11 @@ extension EntityCache
While this default implementation always gives the correct behavior, cache implementations may choose to override
it for performance reasons.
*/
- public func updateEntityTimestamp(_ timestamp: TimeInterval, forKey key: Key)
+ public func updateEntityTimestamp(_ timestamp: TimeInterval, forKey key: Key) throws
{
- guard var entity = readEntity(forKey: key) else
+ guard var entity = try readEntity(forKey: key) else
{ return }
entity.timestamp = timestamp
- writeEntity(entity, forKey: key)
+ try writeEntity(entity, forKey: key)
}
}
diff --git a/Source/Siesta/Pipeline/PipelineConfiguration.swift b/Source/Siesta/Pipeline/PipelineConfiguration.swift
index ae758741..c9f52106 100644
--- a/Source/Siesta/Pipeline/PipelineConfiguration.swift
+++ b/Source/Siesta/Pipeline/PipelineConfiguration.swift
@@ -69,7 +69,7 @@ public struct Pipeline
.map(\.key)
let missingStages = Set(nonEmptyStages).subtracting(newValue)
if !missingStages.isEmpty
- { SiestaLog.log(.pipeline, ["WARNING: Stages", missingStages, "configured but not present in custom pipeline order, will be ignored:", newValue]) }
+ { SiestaLog.log(.pipeline, ["WARNING: Stages", missingStages, "are configured but not present in custom pipeline order", newValue, "and will be ignored"]) }
}
}
@@ -183,7 +183,8 @@ public struct PipelineStage
}
/**
- An optional persistent cache for this stage.
+ Configures a persistent cache for responses after they pass this stage. Passing nil removes any previously
+ configured caching.
When processing a response, the cache will receive the resulting entity after this stage’s transformers have run.
@@ -195,7 +196,26 @@ public struct PipelineStage
initially see an empty resources and then get a `newData(Cache)` event — even if you never call `load()`.
*/
public mutating func cacheUsing(_ cache: T)
- { cacheBox = CacheBox(cache: cache) }
+ {
+ cacheBox = CacheBox(cache: cache)
+ }
+
+ /**
+ Convenience for `cacheUsing(_:)` that takes a failable closure, for situations where caching is optional.
+
+ Configures a persistent cache at this stage if the given closure succeeds. Disables caching at this stage if the
+ closure throws an error.
+ */
+ public mutating func cacheUsing(_ cache: () throws -> T)
+ {
+ do
+ { cacheBox = CacheBox(cache: try cache()) }
+ catch
+ {
+ SiestaLog.log(.cache, ["Error while attempting to create persistent cache for", self, "; caching disabled at this stage:", error])
+ doNotCache()
+ }
+ }
/**
Removes any caching that had been configured at this stage.
diff --git a/Source/Siesta/Pipeline/PipelineProcessing.swift b/Source/Siesta/Pipeline/PipelineProcessing.swift
index a1eb394e..2aece5b9 100644
--- a/Source/Siesta/Pipeline/PipelineProcessing.swift
+++ b/Source/Siesta/Pipeline/PipelineProcessing.swift
@@ -13,7 +13,7 @@ extension Pipeline
private var stagesInOrder: [PipelineStage]
{ order.compactMap { self[$0] } }
- private typealias StageAndEntry = (PipelineStage, CacheEntryProtocol?)
+ private typealias StageAndEntry = (stage: PipelineStage, cacheEntry: CacheEntryProtocol?)
private func stagesAndEntries(for resource: Resource) -> [StageAndEntry]
{
@@ -21,7 +21,7 @@ extension Pipeline
{ stage in (stage, stage.cacheBox?.buildEntry(resource)) }
}
- internal func makeProcessor(_ rawResponse: Response, resource: Resource) -> () -> Response
+ internal func makeProcessor(_ rawResponse: Response, resource: Resource) -> () -> ResponseInfo
{
// Generate cache keys on main thread (because this touches Resource)
let stagesAndEntries = self.stagesAndEntries(for: resource)
@@ -29,40 +29,53 @@ extension Pipeline
// Return deferred processor to run on background queue
return
{
- let result = Pipeline.processAndCache(rawResponse, using: stagesAndEntries)
+ let result = Pipeline.process(rawResponse, using: stagesAndEntries)
- SiestaLog.log(.pipeline, [" └╴Response after pipeline:", result.summary()])
- SiestaLog.log(.networkDetails, [" Details:", result.dump(" ")])
+ SiestaLog.log(.pipeline, [" └╴Response after pipeline:", result.response.summary()])
+ SiestaLog.log(.networkDetails, [" Details:", result.response.dump(" ")])
return result
}
}
// Runs on a background queue
- private static func processAndCache(
+ private static func process(
_ rawResponse: Response,
using stagesAndEntries: StagesAndEntries)
- -> Response
+ -> ResponseInfo
where StagesAndEntries.Iterator.Element == StageAndEntry
{
- stagesAndEntries.reduce(rawResponse)
+ stagesAndEntries.reduce(into: ResponseInfo(response: rawResponse))
{
- let input = $0,
- (stage, cacheEntry) = $1
+ let (stage, cacheEntry) = $1
- let output = stage.process(input)
+ $0.response = stage.process($0.response)
- if case .success(let entity) = output,
+ if case .success(let entity) = $0.response,
let cacheEntry = cacheEntry
{
- SiestaLog.log(.cache, [" ├╴Caching entity with", type(of: entity.content), "content in", cacheEntry])
- cacheEntry.write(entity)
+ $0.cacheActions.append(
+ cacheAction(writing: entity, into: cacheEntry))
}
+ }
+ }
+
+ // swiftlint:disable implicit_return
- return output
+ fileprivate static func cacheAction(
+ writing entity: Entity,
+ into cacheEntry: CacheEntryProtocol)
+ -> () -> Void
+ {
+ return
+ {
+ SiestaLog.log(.cache, ["Caching entity with", type(of: entity.content), "content for", cacheEntry])
+ cacheEntry.write(entity)
}
}
+ // swiftlint:enable implicit_return
+
internal func checkCache(for resource: Resource) -> Request
{
Resource
@@ -92,11 +105,13 @@ extension Pipeline
private struct CacheRequestDelegate: RequestDelegate
{
let requestDescription: String
+ private weak var resource: Resource?
private let stagesAndEntries: [StageAndEntry]
init(for resource: Resource, searching stagesAndEntries: [StageAndEntry])
{
- requestDescription = "Cache request for \(resource)"
+ requestDescription = "Cache check for \(resource)"
+ self.resource = resource
self.stagesAndEntries = stagesAndEntries
}
@@ -104,19 +119,18 @@ extension Pipeline
{
defaultEntityCacheWorkQueue.async
{
- let response: Response
- if let entity = self.performCacheLookup()
- { response = .success(entity) }
- else
- {
- response = .failure(RequestError(
- userMessage: NSLocalizedString("Cache miss", comment: "userMessage"),
- cause: RequestError.Cause.CacheMiss()))
- }
+ var result = performCacheLookup()
+ ?? ResponseInfo(
+ response: .failure(RequestError(
+ userMessage: NSLocalizedString("Cache miss", comment: "userMessage"),
+ cause: RequestError.Cause.CacheMiss())))
+
+ if let resource = resource
+ { result.configurationSource = .init(method: .get, resource: resource) }
DispatchQueue.main.async
{
- completionHandler.broadcastResponse(ResponseInfo(response: response))
+ completionHandler.broadcastResponse(result)
}
}
}
@@ -128,7 +142,7 @@ extension Pipeline
{ self }
// Runs on a background queue
- private func performCacheLookup() -> Entity?
+ private func performCacheLookup() -> ResponseInfo?
{
for (index, (_, cacheEntry)) in stagesAndEntries.enumerated().reversed()
{
@@ -136,22 +150,38 @@ extension Pipeline
{
SiestaLog.log(.cache, ["Cache hit for", cacheEntry])
- let processed = Pipeline.processAndCache(
+ var processed = Pipeline.process(
.success(result),
using: stagesAndEntries.suffix(from: index + 1))
- switch processed
+ // TODO: explain this
+
+ if let cacheEntry = cacheEntry
+ {
+ processed.cacheActions.insert(
+ Pipeline.cacheAction(writing: result, into: cacheEntry),
+ at: 0)
+ }
+
+ processed.cacheActions.append(contentsOf:
+ stagesAndEntries.prefix(upTo: index)
+ .compactMap { $0.cacheEntry?.remove }) // Can't use keypath due to https://bugs.swift.org/browse/SR-12519
+
+ switch processed.response
{
case .failure:
SiestaLog.log(.cache, ["Error processing cached entity; will ignore cached value. Error:", processed])
- case .success(let entity):
- return entity
+ case .success:
+ return processed
}
}
}
return nil
}
+
+ var logCategory: SiestaLog.Category?
+ { .cache }
}
}
@@ -180,7 +210,10 @@ private protocol CacheEntryProtocol
func remove()
}
-private struct CacheEntry: CacheEntryProtocol
+
+// MARK: Cache Entry
+
+private struct CacheEntry: CacheEntryProtocol, CustomStringConvertible
where Cache: EntityCache, Cache.Key == Key
{
let cache: Cache
@@ -199,24 +232,56 @@ private struct CacheEntry: CacheEntryProtocol
func read() -> Entity?
{
cache.workQueue.sync
- { self.cache.readEntity(forKey: self.key) }
+ {
+ catchAndLogErrors(attemptingTo: "read cached entity")
+ { try cache.readEntity(forKey: key)?.withContentRetyped() }
+ }
}
func write(_ entity: Entity)
{
+ guard let cacheableEntity = entity.withContentRetyped() as Entity? else
+ {
+ SiestaLog.log(.cache, ["WARNING: Unable to cache entity:", Cache.self, "expects", Cache.ContentType.self, "but content at this stage of the pipeline is", type(of: entity.content)])
+ return
+ }
+
cache.workQueue.async
- { self.cache.writeEntity(entity, forKey: self.key) }
+ {
+ catchAndLogErrors(attemptingTo: "write cached entity")
+ { try cache.writeEntity(cacheableEntity, forKey: key) }
+ }
}
func updateTimestamp(_ timestamp: TimeInterval)
{
cache.workQueue.async
- { self.cache.updateEntityTimestamp(timestamp, forKey: self.key) }
+ {
+ catchAndLogErrors(attemptingTo: "update entity timestamp")
+ { try cache.updateEntityTimestamp(timestamp, forKey: key) }
+ }
}
func remove()
{
cache.workQueue.async
- { self.cache.removeEntity(forKey: self.key) }
+ {
+ catchAndLogErrors(attemptingTo: "remove entity from cache")
+ { try cache.removeEntity(forKey: key) }
+ }
}
+
+ private func catchAndLogErrors(attemptingTo actionName: String, action: () throws -> T?) -> T?
+ {
+ do
+ { return try action() }
+ catch
+ {
+ SiestaLog.log(.cache, ["WARNING:", cache, "unable to", actionName, "for", key, ":", error])
+ return nil
+ }
+ }
+
+ var description: String
+ { "\(key) in \(cache)" }
}
diff --git a/Source/Siesta/Request/LiveRequest.swift b/Source/Siesta/Request/LiveRequest.swift
index f562f345..3ab779ab 100644
--- a/Source/Siesta/Request/LiveRequest.swift
+++ b/Source/Siesta/Request/LiveRequest.swift
@@ -92,6 +92,11 @@ public protocol RequestDelegate
A description of the underlying operation suitable for logging and debugging.
*/
var requestDescription: String { get }
+
+ /**
+ Indicates where information about requests using this delegate should be logged.
+ */
+ var logCategory: SiestaLog.Category? { get }
}
extension RequestDelegate
@@ -107,6 +112,12 @@ extension RequestDelegate
*/
public var progressReportingInterval: TimeInterval
{ 0.05 }
+
+ /**
+ Log info to `SiestaLog.Category.network`.
+ */
+ public var logCategory: SiestaLog.Category?
+ { .network }
}
/**
@@ -164,7 +175,7 @@ private final class LiveRequest: Request, RequestCompletionHandler, CustomDebugS
return self
}
- SiestaLog.log(.network, [delegate.requestDescription])
+ logDelegateStateChange([delegate.requestDescription])
underlyingOperationStarted = true
delegate.startUnderlyingOperation(passingResponseTo: self)
@@ -186,7 +197,7 @@ private final class LiveRequest: Request, RequestCompletionHandler, CustomDebugS
return
}
- SiestaLog.log(.network, ["Cancelled", delegate.requestDescription])
+ logDelegateStateChange(["Cancelled", delegate.requestDescription])
delegate.cancelUnderlyingOperation()
@@ -274,6 +285,12 @@ private final class LiveRequest: Request, RequestCompletionHandler, CustomDebugS
// MARK: Debug
+ func logDelegateStateChange(_ messageParts: @autoclosure () -> [Any?])
+ {
+ if let category = delegate.logCategory
+ { SiestaLog.log(category, messageParts()) }
+ }
+
final var debugDescription: String
{
"Request:"
diff --git a/Source/Siesta/Request/NetworkRequest.swift b/Source/Siesta/Request/NetworkRequest.swift
index 2e094d87..ba355483 100644
--- a/Source/Siesta/Request/NetworkRequest.swift
+++ b/Source/Siesta/Request/NetworkRequest.swift
@@ -12,8 +12,9 @@ internal final class NetworkRequestDelegate: RequestDelegate
{
// Basic metadata
private let resource: Resource
+ private let method: RequestMethod
internal var config: Configuration
- { resource.configuration(for: underlyingRequest) }
+ { resource.configuration(for: method) }
internal let requestDescription: String
// Networking
@@ -32,6 +33,9 @@ internal final class NetworkRequestDelegate: RequestDelegate
self.requestBuilder = requestBuilder
underlyingRequest = requestBuilder()
+ method = RequestMethod(rawValue: underlyingRequest.httpMethod?.lowercased() ?? "")
+ ?? .get // All unrecognized methods default to .get
+
requestDescription =
SiestaLog.Category.enabled.contains(.network) || SiestaLog.Category.enabled.contains(.networkDetails)
? debugStr([underlyingRequest.httpMethod, underlyingRequest.url])
@@ -90,7 +94,7 @@ internal final class NetworkRequestDelegate: RequestDelegate
{
DispatchQueue.mainThreadPrecondition()
- SiestaLog.log(.network, ["Response: ", underlyingResponse?.statusCode ?? error, "←", requestDescription])
+ SiestaLog.log(.network, ["Response:", underlyingResponse?.statusCode ?? error, "←", requestDescription])
SiestaLog.log(.networkDetails, ["Raw response headers:", underlyingResponse?.allHeaderFields])
SiestaLog.log(.networkDetails, ["Raw response body:", body?.count ?? 0, "bytes"])
@@ -148,12 +152,18 @@ internal final class NetworkRequestDelegate: RequestDelegate
{
let processor = config.pipeline.makeProcessor(rawInfo.response, resource: resource)
- DispatchQueue.global(qos: DispatchQoS.QoSClass.userInitiated).async
+ let processingQueue = DispatchQueue.global(qos: DispatchQoS.QoSClass.userInitiated)
+ processingQueue.async
{
- let processedInfo =
- rawInfo.isNew
- ? ResponseInfo(response: processor(), isNew: true)
- : rawInfo
+ var processedInfo: ResponseInfo
+ if rawInfo.isNew
+ {
+ processedInfo = processor()
+ processedInfo.isNew = true
+ processedInfo.configurationSource = .init(method: self.method, resource: self.resource)
+ }
+ else
+ { processedInfo = rawInfo } // result from a 304 is already transformed, cached, etc.
DispatchQueue.main.async
{ afterTransformation(processedInfo) }
diff --git a/Source/Siesta/Request/Request.swift b/Source/Siesta/Request/Request.swift
index 7a6a8a09..e681bdfa 100644
--- a/Source/Siesta/Request/Request.swift
+++ b/Source/Siesta/Request/Request.swift
@@ -118,7 +118,7 @@ public protocol Request: AnyObject
The property will always be 1 if a request is completed. Note that the converse is not true: a value of 1 does
not necessarily mean the request is completed; it means only that we estimate the request _should_ be completed
- by now. Use the `isCompleted` property to test for actual completion.
+ by now. Use the `state` property to test for actual completion.
*/
var progress: Double { get }
@@ -252,6 +252,12 @@ public struct ResponseInfo
/// Used to distinguish `ResourceEvent.newData` from `ResourceEvent.notModified`.
public var isNew: Bool
+ /// Used to determine whether the response is suitable for caching when loaded by a particular resource
+ var configurationSource: ConfigurationSource?
+
+ /// Callbacks to cache this response according to the pipeline config originally used to process it
+ var cacheActions: [() -> Void] = []
+
/// Creates new responseInfo, with `isNew` true by default.
public init(response: Response, isNew: Bool = true)
{
@@ -264,4 +270,10 @@ public struct ResponseInfo
response: .failure(RequestError(
userMessage: NSLocalizedString("Request cancelled", comment: "userMessage"),
cause: RequestError.Cause.RequestCancelled(networkError: nil))))
+
+ struct ConfigurationSource: Equatable
+ {
+ var method: RequestMethod
+ weak var resource: Resource?
+ }
}
diff --git a/Source/Siesta/Request/RequestCallbacks.swift b/Source/Siesta/Request/RequestCallbacks.swift
index 03c8b516..48e8ffb3 100644
--- a/Source/Siesta/Request/RequestCallbacks.swift
+++ b/Source/Siesta/Request/RequestCallbacks.swift
@@ -90,7 +90,7 @@ internal struct CallbackGroup
completedValue = arguments
// We need to let this mutating method finish before calling the callbacks. Some of them inspect
- // completeValue (via isCompleted), which causes a simultaneous access error at runtime.
+ // completeValue (via request.state), which causes a simultaneous access error at runtime.
// See https://github.com/apple/swift-evolution/blob/master/proposals/0176-enforce-exclusive-access-to-memory.md
let snapshot = self
diff --git a/Source/Siesta/Request/RequestChaining.swift b/Source/Siesta/Request/RequestChaining.swift
index c9558b3d..0da605b4 100644
--- a/Source/Siesta/Request/RequestChaining.swift
+++ b/Source/Siesta/Request/RequestChaining.swift
@@ -135,4 +135,10 @@ internal struct RequestChainDelgate: RequestDelegate
{
RequestChainDelgate(wrapping: wrappedRequest.repeated(), whenCompleted: determineAction)
}
+
+ /**
+ Chain requests are silent since their underlying requests are logged already.
+ */
+ var logCategory: SiestaLog.Category?
+ { nil }
}
diff --git a/Source/Siesta/Resource/Resource.swift b/Source/Siesta/Resource/Resource.swift
index 94e94b02..bc21c893 100644
--- a/Source/Siesta/Resource/Resource.swift
+++ b/Source/Siesta/Resource/Resource.swift
@@ -68,13 +68,6 @@ public final class Resource: NSObject
{ service.configuration(forResource: self, requestMethod: method) }
}
- internal func configuration(for request: URLRequest) -> Configuration
- {
- configuration(for:
- RequestMethod(rawValue: request.httpMethod?.lowercased() ?? "")
- ?? .get) // All unrecognized methods default to .get
- }
-
private var cachedConfig: [RequestMethod:Configuration] = [:]
private var configVersion: UInt64 = 0
@@ -376,8 +369,8 @@ public final class Resource: NSObject
if case .inProgress(let cacheRequest) = cacheCheckStatus
{
// isLoading needs to be:
- // - false at first,
- // - true after loadIfNeeded() while the cache check is in progress, but
+ // - false at first, even if a cache check has already started,
+ // - true after loadIfNeeded() while the cache check is still in progress, but
// - false again before observers receive a cache hit.
//
// To make this happen, we need to add the chained cacheThenNetwork below
@@ -397,10 +390,10 @@ public final class Resource: NSObject
{
_ in // We don’t need the result of the cache request here; resource state is already updated
- if self.isUpToDate // If cached data is up to date...
+ if self.isUpToDate // If cached data is up to date...
{
- self.receiveDataNotModified() // ...tell observers isLoading is false...
- return .useThisResponse // ...and no need to make a network call!
+ self.notifyObservers(.notModified) // ...tell observers isLoading is false...
+ return .useThisResponse // ...and no need to make a network call!
}
else
{
@@ -474,6 +467,23 @@ public final class Resource: NSObject
req.onProgress(notifyObservers(ofProgress:))
+ req.onCompletion
+ {
+ // TODO: explain this
+ if let configurationSource = $0.configurationSource
+ {
+ if configurationSource == .init(method: .get, resource: self)
+ {
+ for action in $0.cacheActions
+ { action() }
+ }
+ else
+ {
+ SiestaLog.log(.cache, ["Resource.load(using:) will not cache the results of this request because it is not a GET and/or is for a different resource:", configurationSource.method, configurationSource.resource])
+ }
+ }
+ }
+
req.onNewData(receiveNewDataFromNetwork)
req.onNotModified(receiveDataNotModified)
req.onFailure(receiveError)
@@ -710,19 +720,25 @@ public final class Resource: NSObject
{
if case .notStarted = cacheCheckStatus
{
+ if _latestData != nil
+ {
+ cacheCheckStatus = .completed
+ return
+ }
+
cacheCheckStatus = .inProgress(
configuration.pipeline.checkCache(for: self)
.onCompletion
{
[weak self] result in
- guard let resource = self, resource.latestData == nil else
+ self?.cacheCheckStatus = .completed
+
+ guard let resource = self, resource._latestData == nil else
{
SiestaLog.log(.cache, ["Ignoring cache hit for", self, " because it is either deallocated or already has data"])
return
}
- resource.cacheCheckStatus = .completed
-
if case .success(let entity) = result.response
{ resource.receiveNewData(entity, source: .cache) }
}
diff --git a/Source/Siesta/Support/Logging.swift b/Source/Siesta/Support/Logging.swift
index a52be73f..d70b3e31 100644
--- a/Source/Siesta/Support/Logging.swift
+++ b/Source/Siesta/Support/Logging.swift
@@ -77,7 +77,7 @@ public enum SiestaLog
.forceUnwrapped(because: "Modulus always maps thread IDs to valid unicode scalars")))
threadID /= 0x55
}
- threadName += "]"
+ threadName += "] "
}
let prefix = "Siesta:\(paddedCategory) │ \(threadName)"
let indentedMessage = $1.replacingOccurrences(of: "\n", with: "\n" + prefix)
diff --git a/Source/Siesta/Support/SiestaTools.h b/Source/Siesta/Support/SiestaTools.h
new file mode 100644
index 00000000..a984896b
--- /dev/null
+++ b/Source/Siesta/Support/SiestaTools.h
@@ -0,0 +1,16 @@
+//
+// SiestaTools.h
+// Siesta
+//
+// Created by Paul on 2017/11/22.
+// Copyright © 2016 Bust Out Solutions. All rights reserved.
+//
+
+#import
+
+//! Project version number for SiestaTools.
+FOUNDATION_EXPORT double SiestaToolsVersionNumber;
+
+//! Project version string for SiestaTools.
+FOUNDATION_EXPORT const unsigned char SiestaToolsVersionString[];
+
diff --git a/Source/SiestaTools/FileCache.swift b/Source/SiestaTools/FileCache.swift
new file mode 100644
index 00000000..9cef7d15
--- /dev/null
+++ b/Source/SiestaTools/FileCache.swift
@@ -0,0 +1,173 @@
+//
+// FileCache.swift
+// Siesta
+//
+// Created by Paul on 2017/11/22.
+// Copyright © 2017 Bust Out Solutions. All rights reserved.
+//
+
+#if !COCOAPODS
+ import Siesta
+#endif
+import Foundation
+import CommonCrypto
+
+private typealias File = URL
+
+private let fileCacheFormatVersion: [UInt8] = [0]
+
+private let decoder = PropertyListDecoder()
+private let encoder: PropertyListEncoder =
+ {
+ let encoder = PropertyListEncoder()
+ encoder.outputFormat = .binary
+ return encoder
+ }()
+
+public struct FileCache: EntityCache, CustomStringConvertible
+ where ContentType: Codable
+ {
+ private let isolationStrategy: DataIsolationStrategy
+ private let cacheDir: File
+
+ public let description: String
+
+ public init(poolName: String = "Default", dataIsolation isolationStrategy: DataIsolationStrategy) throws
+ {
+ let cacheDir = try FileManager.default
+ .url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
+ .appendingPathComponent(Bundle.main.bundleIdentifier ?? "") // no bundle → directly inside cache dir
+ .appendingPathComponent("Siesta")
+ .appendingPathComponent(poolName)
+ try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
+
+ self.init(inDirectory: cacheDir, dataIsolation: isolationStrategy, cacheName: "poolName: " + poolName)
+ }
+
+ public init(
+ inDirectory cacheDir: URL,
+ dataIsolation isolationStrategy: DataIsolationStrategy,
+ cacheName: String? = nil)
+ {
+ self.cacheDir = cacheDir
+ self.isolationStrategy = isolationStrategy
+ self.description = "\(type(of: self))(\(cacheName ?? cacheDir.path))"
+ }
+
+ // MARK: - Keys and filenames
+
+ public func key(for resource: Resource) -> Key?
+ { Key(resource: resource, isolationStrategy: isolationStrategy) }
+
+ public struct Key: CustomStringConvertible
+ {
+ fileprivate var hash: String
+ private var url: URL
+
+ fileprivate init(resource: Resource, isolationStrategy: DataIsolationStrategy)
+ {
+ url = resource.url
+ hash = isolationStrategy.keyData(for: url)
+ .sha256
+ .urlSafeBase64EncodedString
+ }
+
+ public var description: String
+ { "FileCache.Key(\(url))" }
+ }
+
+ private func file(for key: Key) -> File
+ { cacheDir.appendingPathComponent(key.hash + ".cache") }
+
+ // MARK: - Reading and writing
+
+ public func readEntity(forKey key: Key) throws -> Entity?
+ {
+ do {
+ return try
+ decoder.decode(
+ Entity.self,
+ from: Data(contentsOf: file(for: key)))
+ }
+ catch CocoaError.fileReadNoSuchFile
+ { } // a cache miss is just fine; don't log it
+ return nil
+ }
+
+ public func writeEntity(_ entity: Entity, forKey key: Key) throws
+ {
+ #if os(macOS)
+ let options: Data.WritingOptions = [.atomic]
+ #else
+ let options: Data.WritingOptions = [.atomic, .completeFileProtection]
+ #endif
+
+ try encoder.encode(entity)
+ .write(to: file(for: key), options: options)
+ }
+
+ public func removeEntity(forKey key: Key) throws
+ {
+ try FileManager.default.removeItem(at: file(for: key))
+ }
+ }
+
+extension FileCache
+ {
+ public struct DataIsolationStrategy
+ {
+ private let keyPrefix: Data
+
+ private init(keyIsolator: Data)
+ {
+ keyPrefix =
+ fileCacheFormatVersion // prevents us from parsing old cache entries using some new future format
+ // TODO: include pipeline stage name here
+ + "\(ContentType.self)".utf8 // prevent data collision when caching at multiple pipeline stages
+ + [0] // null-terminate ContentType to prevent bleed into username
+ + keyIsolator // prevents one user from seeing another’s cached requests
+ + [0] // separator for URL
+ }
+
+ fileprivate func keyData(for url: URL) -> Data
+ {
+ Data(keyPrefix + url.absoluteString.utf8)
+ }
+
+ public static var sharedByAllUsers: DataIsolationStrategy
+ { DataIsolationStrategy(keyIsolator: Data()) }
+
+ public static func perUser(identifiedBy partitionID: T) throws -> DataIsolationStrategy
+ where T: Codable
+ {
+ DataIsolationStrategy(
+ keyIsolator: try encoder.encode([partitionID]))
+ }
+ }
+ }
+
+// MARK: - Encryption helpers
+
+extension Data
+ {
+ fileprivate var sha256: Data
+ {
+ var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
+ _ = withUnsafeBytes
+ { CC_SHA256($0.baseAddress, CC_LONG(count), &hash) }
+ return Data(hash)
+ }
+
+ fileprivate var shortenWithSHA256: Data
+ {
+ count > 32 ? sha256 : self
+ }
+
+ fileprivate var urlSafeBase64EncodedString: String
+ {
+ base64EncodedString()
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "=", with: "")
+ }
+ }
diff --git a/Tests/.swiftlint.yml b/Tests/.swiftlint.yml
index 70f5c2d6..8fbcc6c8 100644
--- a/Tests/.swiftlint.yml
+++ b/Tests/.swiftlint.yml
@@ -8,5 +8,5 @@ disabled_rules:
custom_rules:
focused_spec:
- name: "Focused spec in effect"
- regex: '(fit|fdescribe)\s*\('
+ name: "Remember not to commit any focused or disabled specs!"
+ regex: '\b[fx](it|describe)\s*\('
diff --git a/Tests/Functional/EntityCacheSpec.swift b/Tests/Functional/EntityCacheSpec.swift
index da3c3d06..6487e832 100644
--- a/Tests/Functional/EntityCacheSpec.swift
+++ b/Tests/Functional/EntityCacheSpec.swift
@@ -6,7 +6,7 @@
// Copyright © 2018 Bust Out Solutions. All rights reserved.
//
-import Siesta
+@testable import Siesta
import Foundation
import Quick
@@ -16,17 +16,26 @@ class EntityCacheSpec: ResourceSpecBase
{
override func resourceSpec(_ service: @escaping () -> Service, _ resource: @escaping () -> Resource)
{
- func configureCache(_ cache: C, at stageKey: PipelineStageKey)
+ func configureCache(
+ _ cache: C,
+ for pattern: ConfigurationPatternConvertible = "**",
+ at stageKey: PipelineStageKey)
{
- service().configure
+ service().configure(pattern)
{ $0.pipeline[stageKey].cacheUsing(cache) }
}
func waitForCacheRead(_ cache: TestCache)
- { expect(cache.receivedCacheRead).toEventually(beTrue()) }
+ {
+ expect(cache.receivedCacheRead).toEventually(beTrue())
+ cache.receivedCacheRead = false
+ }
func waitForCacheWrite(_ cache: TestCache)
- { expect(cache.receivedCacheWrite).toEventually(beTrue()) }
+ {
+ expect(cache.receivedCacheWrite).toEventually(beTrue())
+ cache.receivedCacheWrite = false
+ }
beforeEach
{
@@ -168,6 +177,15 @@ class EntityCacheSpec: ResourceSpecBase
describe("write")
{
+ @discardableResult
+ func stubAndAwaitRequestWithoutLoading(for resource: Resource, method: RequestMethod) -> Request
+ {
+ NetworkStub.add(method, { resource })
+ let req = resource.request(method)
+ awaitNewData(req, initialState: .inProgress)
+ return req
+ }
+
func expectCacheWrite(to cache: TestCache, content: String)
{
waitForCacheWrite(cache)
@@ -175,7 +193,7 @@ class EntityCacheSpec: ResourceSpecBase
expect(cache.entries.values.first?.typedContent()) == content
}
- it("caches new data on success")
+ it("caches new data on a successful load()")
{
let testCache = TestCache("new data")
configureCache(testCache, at: .cleanup)
@@ -219,6 +237,17 @@ class EntityCacheSpec: ResourceSpecBase
.toEventually(equal(2000))
}
+ it("preserves the timestamp of cached data")
+ {
+ let testCache = UnwritableCache(cachedValue:
+ Entity(content: "hi", charset: nil, headers: [:], timestamp: 2001))
+ configureCache(testCache, at: .cleanup)
+
+ setResourceTime(2010)
+ awaitNewData(resource().loadIfNeeded()!)
+ expect(resource().latestData?.timestamp) == 2001
+ }
+
it("clears cached data on local override")
{
let testCache = TestCache("local override")
@@ -231,6 +260,119 @@ class EntityCacheSpec: ResourceSpecBase
expect(testCache.entries).toEventually(beEmpty())
}
+
+ it("does not write previously cached data back to the cache when reading it")
+ {
+ let testCache = TestCache("does not write previously cached")
+ configureCache(testCache, at: .parsing)
+ configureCache(UnwritableCache(), at: .model)
+ configureCache(UnwritableCache(), at: .cleanup)
+
+ testCache.entries[TestCacheKey(forTestResourceIn: testCache)] =
+ Entity(content: "🌮", contentType: "text/plain")
+ awaitNewData(resource().loadIfNeeded()!, initialState: .inProgress)
+ expect(resource().typedContent()) == "🌮modcle"
+ }
+
+ it("does not cache anything for call to Resource.request() without load()")
+ {
+ configureCache(UnwritableCache(), at: .cleanup)
+ stubAndAwaitRequestWithoutLoading(for: resource(), method: .get)
+ }
+
+ it("caches new data for a GET on the same resource passed to load(using:)")
+ {
+ let testCache = TestCache("new data from load(using:)")
+ configureCache(testCache, at: .cleanup)
+ let req = stubAndAwaitRequestWithoutLoading(for: resource(), method: .get)
+ resource().load(using: req)
+ expectCacheWrite(to: testCache, content: "decparmodcle")
+ }
+
+ it("does not cache anything for a non-GET request, even if passed to load(using:)")
+ {
+ configureCache(UnwritableCache(), at: .cleanup)
+ for method in RequestMethod.allCases
+ where method != .get
+ {
+ let req = stubAndAwaitRequestWithoutLoading(for: resource(), method: method)
+ resource().load(using: req)
+ }
+ }
+
+ it("does not cache anything for a GET request for a different resource, even if passed to load(using:)")
+ {
+ let otherResource = service().resource("/otherResource")
+ configureCache(UnwritableCache(), at: .cleanup)
+ let req = stubAndAwaitRequestWithoutLoading(for: otherResource, method: .get)
+ resource().load(using: req)
+ }
+
+ func stubText(_ text: String)
+ {
+ NetworkStub.add(
+ .get, resource,
+ returning: HTTPResponse(
+ headers: ["content-type": "text/plain; charset=utf-8"],
+ body: text))
+ }
+
+ it("will restore cache state to an older request if passed to load(using:)")
+ {
+ let testCache = TestCache("restore cache state")
+ configureCache(testCache, at: .model)
+ service().configure
+ {
+ $0.pipeline[.decoding].removeTransformers()
+ $0.pipeline[.decoding].add(TextResponseTransformer())
+ }
+
+ stubText("🌮")
+ let originalReq = resource().load()
+ awaitNewData(originalReq, initialState: .inProgress)
+ expectCacheWrite(to: testCache, content: "🌮parmod")
+
+ stubText("🧇")
+ awaitNewData(resource().load(), initialState: .inProgress)
+ expectCacheWrite(to: testCache, content: "🧇parmod")
+
+ resource().load(using: originalReq)
+ expectCacheWrite(to: testCache, content: "🌮parmod")
+ }
+
+ it("will restore cache state to original state if original cache request is passed to load(using:)")
+ {
+ let testCacheDec = TestCache("restore cache state - dec")
+ let testCacheMod = TestCache("restore cache state - mod")
+ let testCacheCle = TestCache("restore cache state - cle")
+ configureCache(testCacheDec, at: .decoding)
+ configureCache(testCacheMod, at: .model)
+ configureCache(testCacheCle, at: .cleanup)
+ service().configure
+ {
+ $0.pipeline[.decoding].removeTransformers()
+ $0.pipeline[.decoding].add(TextResponseTransformer())
+ }
+
+ testCacheMod.entries[TestCacheKey(forTestResourceIn: testCacheMod)] =
+ Entity(content: "🌮", contentType: "text/plain")
+ let originalReq = resource().loadIfNeeded()!
+ awaitNewData(originalReq, initialState: .inProgress)
+ expect(resource().typedContent()) == "🌮cle"
+
+ stubText("🧇")
+ awaitNewData(resource().load(), initialState: .inProgress)
+ expectCacheWrite(to: testCacheDec, content: "🧇")
+ expectCacheWrite(to: testCacheMod, content: "🧇parmod")
+ expectCacheWrite(to: testCacheCle, content: "🧇parmodcle")
+
+ resource().load(using: originalReq)
+ expectCacheWrite(to: testCacheMod, content: "🌮")
+ expectCacheWrite(to: testCacheCle, content: "🌮cle")
+ waitForCacheWrite(testCacheDec)
+ expect(testCacheDec.entries[TestCacheKey(forTestResourceIn: testCacheDec)])
+ .toEventually(beNil())
+ }
}
func exerciseCache()
@@ -300,8 +442,11 @@ private class TestCache: EntityCache
func removeEntity(forKey key: TestCacheKey)
{
- _ = DispatchQueue.main.sync
- { entries.removeValue(forKey: key) }
+ DispatchQueue.main.sync
+ {
+ entries.removeValue(forKey: key)
+ self.receivedCacheWrite = true
+ }
}
}
@@ -369,17 +514,22 @@ private class KeylessCache: EntityCache
private struct UnwritableCache: EntityCache
{
+ let cachedValue: Entity?
+
+ init(cachedValue: Entity? = nil)
+ { self.cachedValue = cachedValue }
+
func key(for resource: Resource) -> URL?
{ resource.url }
func readEntity(forKey key: URL) -> Entity?
- { nil }
+ { cachedValue }
func writeEntity(_ entity: Entity, forKey key: URL)
- { fatalError("cache should never be written to") }
+ { fail("cache should never be written to") }
func removeEntity(forKey key: URL)
- { fatalError("cache should never be written to") }
+ { fail("cache should never be written to") }
}
private class ObserverEventRecorder: ResourceObserver
diff --git a/Tests/Functional/FileCacheSpec.swift b/Tests/Functional/FileCacheSpec.swift
new file mode 100644
index 00000000..f98f117a
--- /dev/null
+++ b/Tests/Functional/FileCacheSpec.swift
@@ -0,0 +1,24 @@
+//
+// FileCacheSpec.swift
+// Siesta
+//
+// Created by Paul on 2020/4/6.
+// Copyright © 2020 Bust Out Solutions. All rights reserved.
+//
+
+import Siesta
+import SiestaTools
+
+import Foundation
+import Quick
+import Nimble
+
+class FileCacheSpec: ResourceSpecBase
+ {
+ override func resourceSpec(_ service: @escaping () -> Service, _ resource: @escaping () -> Resource)
+ {
+ it("needs testing")
+ {
+ }
+ }
+ }
diff --git a/Tests/Functional/RequestSpec.swift b/Tests/Functional/RequestSpec.swift
index d2d89bda..f7bf5624 100644
--- a/Tests/Functional/RequestSpec.swift
+++ b/Tests/Functional/RequestSpec.swift
@@ -670,7 +670,7 @@ class RequestSpec: ResourceSpecBase
expectResult("yoyo", for: chainedReq)
}
- it("isCompleted is false until a “use” action")
+ it("state is inProgress until callback returns a “use response” action")
{
let reqStub = stubText("yo").delay()
let req = resource().request(.get).chained