├── .circleci
└── config.yml
├── .gitignore
├── .swift-version
├── .swiftformat
├── .swiftpm
└── xcode
│ └── xcshareddata
│ └── xcschemes
│ ├── Canopy-Package.xcscheme
│ ├── Canopy.xcscheme
│ └── CanopyTestTools.xcscheme
├── LICENSE
├── Package.swift
├── README.md
├── Targets
├── Canopy
│ ├── Sources
│ │ ├── Archiving
│ │ │ ├── CloudKitCursorArchive.swift
│ │ │ ├── CloudKitLookupInfoArchive.swift
│ │ │ ├── CloudKitRecordArchive.swift
│ │ │ ├── CloudKitRecordIDArchive.swift
│ │ │ ├── CloudKitRecordZoneArchive.swift
│ │ │ ├── CloudKitRecordZoneIDArchive.swift
│ │ │ ├── CloudKitServerChangeTokenArchive.swift
│ │ │ ├── CloudKitShareArchive.swift
│ │ │ ├── CloudKitShareMetadataArchive.swift
│ │ │ ├── CloudKitShareParticipantArchive.swift
│ │ │ └── CloudKitSubscriptionArchive.swift
│ │ ├── CKContainerAPI
│ │ │ ├── CKAccountChangedSequence.swift
│ │ │ ├── CKContainerAPI.swift
│ │ │ ├── CKContainerAPIType+Shorthand.swift
│ │ │ └── CKContainerAPIType.swift
│ │ ├── CKContainerType.swift
│ │ ├── CKDatabaseAPI
│ │ │ ├── CKDatabaseAPI+SimulatedFail.swift
│ │ │ ├── CKDatabaseAPI.swift
│ │ │ ├── CKDatabaseAPIType+Shorthand.swift
│ │ │ └── CKDatabaseAPIType.swift
│ │ ├── CKDatabaseType.swift
│ │ ├── Canopy.docc
│ │ │ ├── Canopy.md
│ │ │ ├── Features and behaviors.md
│ │ │ ├── Motivation and scope.md
│ │ │ ├── Resources
│ │ │ │ ├── ckmethods01query.png
│ │ │ │ ├── ckmethods02fetch.png
│ │ │ │ ├── ckmethods04zones.png
│ │ │ │ ├── ckmethods05zonechanges.png
│ │ │ │ ├── testing-ui.png
│ │ │ │ ├── testing-with-canopy.png
│ │ │ │ ├── testing-without-canopy.png
│ │ │ │ ├── thoughts-architecture.png
│ │ │ │ ├── thoughts-ios-list.png
│ │ │ │ ├── thoughts-ios-onethought.png
│ │ │ │ ├── thoughts-ios-settings.png
│ │ │ │ ├── thoughts-macos.png
│ │ │ │ └── thoughts-previews.png
│ │ │ ├── Testable CloudKit apps with Canopy.md
│ │ │ ├── Thoughts example app.md
│ │ │ ├── Three methods of retrieving records from CloudKit.md
│ │ │ ├── Why use CloudKit.md
│ │ │ └── iCloud Advanced Data Protection.md
│ │ ├── Canopy
│ │ │ ├── Canopy.swift
│ │ │ └── MockCanopyWithCKMocks.swift
│ │ ├── CanopyResultRecord
│ │ │ ├── CanopyResultRecord.swift
│ │ │ ├── CanopyResultRecordType.swift
│ │ │ ├── MockCanopyResultRecord+ValueStore.swift
│ │ │ └── MockCanopyResultRecord.swift
│ │ ├── CanopyType.swift
│ │ ├── Dependency
│ │ │ └── Canopy+Dependency.swift
│ │ ├── Errors
│ │ │ ├── CKRecordError.swift
│ │ │ ├── CKRecordZoneError.swift
│ │ │ ├── CKRequestError.swift
│ │ │ ├── CKSubscriptionError.swift
│ │ │ ├── CKTransactionError.swift
│ │ │ └── CanopyError.swift
│ │ ├── Extensions
│ │ │ ├── CKContainer.swift
│ │ │ ├── CKDatabase.Scope.swift
│ │ │ ├── CKDatabase.swift
│ │ │ ├── CKRecord.swift
│ │ │ └── CKRecordZoneID.swift
│ │ ├── Features
│ │ │ ├── ModifyRecords.swift
│ │ │ └── QueryRecords.swift
│ │ ├── Results
│ │ │ ├── DeletedCKRecord.swift
│ │ │ ├── FetchDatabaseChangesResult.swift
│ │ │ ├── FetchRecordsResult.swift
│ │ │ ├── FetchZoneChangesMethod.swift
│ │ │ ├── FetchZoneChangesResult.swift
│ │ │ ├── ModifyRecordsResult.swift
│ │ │ ├── ModifySubscriptionsResult.swift
│ │ │ └── ModifyZonesResult.swift
│ │ ├── Settings
│ │ │ ├── CanopySettings.swift
│ │ │ └── CanopySettingsType.swift
│ │ └── TokenStore
│ │ │ ├── TestTokenStore.swift
│ │ │ ├── TokenStoreType.swift
│ │ │ └── UserDefaultsTokenStore.swift
│ └── Tests
│ │ ├── CKDatabaseScopeExtensionTests.swift
│ │ ├── CKRecordZoneIDExtensionTests.swift
│ │ ├── CanopyResultRecordTests.swift
│ │ ├── CanopyTests.swift
│ │ ├── ContainerAPITests.swift
│ │ ├── DatabaseAPITests.swift
│ │ ├── DependencyTests.swift
│ │ ├── FetchDatabaseChangesResultTests.swift
│ │ ├── FetchDatabaseChangesTests.swift
│ │ ├── FetchRecordsResultTests.swift
│ │ ├── FetchZoneChangesResultTests.swift
│ │ ├── FetchZoneChangesTests.swift
│ │ ├── Fixtures
│ │ └── textFile.txt
│ │ ├── MockCanopyResultRecordTests.swift
│ │ ├── MockObjectTests.swift
│ │ ├── MockValueStoreTests.swift
│ │ ├── ModifyRecordsFeatureTests.swift
│ │ ├── ModifyRecordsResultTests.swift
│ │ ├── ModifyRecordsTests.swift
│ │ ├── ModifySubscriptionResultTests.swift
│ │ ├── ModifyZonesResultTests.swift
│ │ ├── QueryRecordsFeatureTests.swift
│ │ ├── ReplayingMockContainerTests.swift
│ │ ├── ReplayingMockDatabaseTests.swift
│ │ ├── ResultsTypesTests.swift
│ │ └── SerialFetchChangesTests.swift
└── CanopyTestTools
│ └── Sources
│ ├── CodableResult.swift
│ ├── CodableVoid.swift
│ ├── Extensions
│ ├── CKQueryOperation.Cursor.swift
│ ├── CKRecordZone.swift
│ ├── CKServerChangeToken.swift
│ ├── CKShare.Metadata.swift
│ ├── CKShare.Participant.swift
│ ├── CKShare.swift
│ └── CanopyResultRecord.swift
│ ├── MockCanopy
│ └── MockCanopy.swift
│ ├── ReplayingMockCKContainer
│ ├── ReplayingMockCKContainer+AcceptShares.swift
│ ├── ReplayingMockCKContainer+AccountStatus.swift
│ ├── ReplayingMockCKContainer+FetchShareParticipants.swift
│ ├── ReplayingMockCKContainer+FetchUserRecordID.swift
│ └── ReplayingMockCKContainer.swift
│ ├── ReplayingMockCKDatabase
│ ├── ReplayingMockCKDatabase+Fetch.swift
│ ├── ReplayingMockCKDatabase+FetchDatabaseChanges.swift
│ ├── ReplayingMockCKDatabase+FetchZoneChanges.swift
│ ├── ReplayingMockCKDatabase+FetchZones.swift
│ ├── ReplayingMockCKDatabase+Modify.swift
│ ├── ReplayingMockCKDatabase+ModifySubscriptions.swift
│ ├── ReplayingMockCKDatabase+ModifyZones.swift
│ ├── ReplayingMockCKDatabase+Query.swift
│ └── ReplayingMockCKDatabase.swift
│ ├── ReplayingMockContainer
│ ├── ReplayingMockContainer+Types.swift
│ └── ReplayingMockContainer.swift
│ └── ReplayingMockDatabase
│ ├── ReplayingMockDatabase+Types.swift
│ └── ReplayingMockDatabase.swift
└── justfile
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | # CircleCI configuration file
2 |
3 | version: 2.1
4 | jobs:
5 | build:
6 | macos:
7 | xcode: "16.0.0"
8 | steps:
9 | - checkout
10 | - run: swift build -v
11 |
12 | test:
13 | macos:
14 | xcode: "16.0.0"
15 | steps:
16 | - checkout
17 | - run: swift test -v
18 |
19 | workflows:
20 | build-and-test:
21 | jobs:
22 | - build
23 | - test
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/config/registries.json
8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
9 | .netrc
10 | Package.resolved
11 |
--------------------------------------------------------------------------------
/.swift-version:
--------------------------------------------------------------------------------
1 | 5.7
2 |
--------------------------------------------------------------------------------
/.swiftformat:
--------------------------------------------------------------------------------
1 | --binarygrouping none
2 | --commas inline
3 | --decimalgrouping none
4 | --hexgrouping none
5 | --indent 2
6 | --nospaceoperators ...
7 | --octalgrouping none
8 | --patternlet hoist
9 | --self init-only
10 | --semicolons never
11 | --stripunusedargs closure-only
12 | --trimwhitespace nonblank-lines
13 | --wrapcollections before-first
14 | --wrapparameters before-first
15 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/Canopy-Package.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
9 |
10 |
16 |
22 |
23 |
24 |
30 |
36 |
37 |
38 |
39 |
40 |
46 |
47 |
49 |
55 |
56 |
57 |
58 |
59 |
69 |
70 |
76 |
77 |
83 |
84 |
85 |
86 |
88 |
89 |
92 |
93 |
94 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/Canopy.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
32 |
33 |
35 |
41 |
42 |
43 |
44 |
45 |
55 |
56 |
62 |
63 |
69 |
70 |
71 |
72 |
74 |
75 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/CanopyTestTools.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
42 |
43 |
49 |
50 |
56 |
57 |
58 |
59 |
61 |
62 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019-2023 Tact (Discerning Technologies OÜ)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.10
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import Foundation
5 | import PackageDescription
6 |
7 | var dependencies: [PackageDescription.Package.Dependency] = [
8 | .package(
9 | url: "https://github.com/groue/Semaphore",
10 | from: "0.0.8"
11 | ),
12 | .package(
13 | url: "https://github.com/pointfreeco/swift-dependencies",
14 | from: "1.0.0"
15 | )
16 | ]
17 |
18 | // The SPI_BUILDER environment variable enables documentation building
19 | // in Swift Package Index, should we ever host the docs there.
20 | // See
21 | // for more information.
22 | //
23 | // SPI_BUILDER also enables the `just doc-preview` command.
24 | //
25 | // This approach was lifted from GRDB Package.swift.
26 | if ProcessInfo.processInfo.environment["SPI_BUILDER"] == "1" {
27 | dependencies.append(.package(url: "https://github.com/apple/swift-docc-plugin", from: "1.4.0"))
28 | }
29 |
30 | let package = Package(
31 | name: "Canopy",
32 | defaultLocalization: "en",
33 | platforms: [.iOS(.v15), .macOS(.v12)],
34 | products: [
35 | // Products define the executables and libraries a package produces, and make them visible to other packages.
36 | .library(name: "Canopy", targets: ["Canopy"]),
37 | .library(name: "CanopyTestTools", targets: ["CanopyTestTools"])
38 | ],
39 | dependencies: dependencies,
40 | targets: [
41 | // Targets are the basic building blocks of a package. A target can define a module or a test suite.
42 | // Targets can depend on other targets in this package, and on products in packages this package depends on.
43 | .target(
44 | name: "Canopy",
45 | dependencies: [
46 | "Semaphore",
47 | .product(name: "Dependencies", package: "swift-dependencies")
48 | ],
49 | path: "Targets/Canopy/Sources"
50 | // https://danielsaidi.com/blog/2022/05/18/how-to-suppress-linking-warning
51 | // Canopy by default gives a warning about unsafe code for application extensions. Not sure why it says that.
52 | // See the above blog post for more info.
53 | // The following line is OK to have in local development, but in live setting, cannot be used.
54 | // This could also be obsolete, latest Canopy does not give warnings with extensions any more.
55 | // Keeping this info here just for a while longer.
56 | // linkerSettings: [.unsafeFlags(["-Xlinker", "-no_application_extension"])]
57 | ),
58 | .target(
59 | name: "CanopyTestTools",
60 | dependencies: ["Canopy"],
61 | path: "Targets/CanopyTestTools/Sources"
62 | ),
63 | .testTarget(
64 | name: "CanopyTests",
65 | dependencies: ["Canopy", "CanopyTestTools"],
66 | path: "Targets/Canopy/Tests",
67 | resources: [
68 | .process("Fixtures")
69 | ]
70 | )
71 | ]
72 | )
73 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Canopy
2 |
3 | Canopy helps you write better, more testable CloudKit apps.
4 |
5 | ## Installing Canopy
6 |
7 | Canopy is distributed as a Swift Package Manager package.
8 |
9 | If you use Xcode UI to manage your dependencies, add `https://github.com/Tact/Canopy` as the dependency for your project.
10 |
11 | If you use SPM `Package.swift`, add this:
12 |
13 | ```
14 | dependencies: [
15 | .package(
16 | url: "https://github.com/Tact/Canopy",
17 | from: "0.5.0"
18 | )
19 | ]
20 | ```
21 |
22 | ## Using Canopy
23 |
24 | ### One-line example
25 |
26 | To fetch a record from CloudKit private database which has the record ID `exampleID`, use this Canopy call:
27 |
28 | ```swift
29 | let result = await Canopy().databaseAPI(usingDatabaseScope: .private).fetchRecords(with: [CKRecord.ID(recordName: "exampleID")])
30 | switch result {
31 | case .success(let fetchRecordsResult):
32 | // handle fetchRecordsResult. Examine its foundRecords and notFoundRecordIDs properties.
33 | case .failure(let ckRecordError):
34 | // handle error
35 | }
36 | ```
37 |
38 | ### Using throwing return type
39 |
40 | Canopy provides all its API as `async Result`. Many people prefer to instead use throwing API. It’s easy to convert Canopy API calls to throwing style at the call site with the [`get()`](https://developer.apple.com/documentation/swift/result/get()) API. For the above example, follow this approach:
41 |
42 | ```swift
43 | do {
44 | let result = try await Canopy().databaseAPI(usingDatabaseScope: .private).fetchRecords(…).get()
45 | // use result
46 | } catch {
47 | // handle thrown error
48 | }
49 | ```
50 |
51 | ### Dependency injection for testability
52 |
53 | Canopy is designed for enabling your code to be testable. You do your part by using [dependency injection](https://en.wikipedia.org/wiki/Dependency_injection) pattern in most of your code and features. Most of your code should not instantiate Canopy directly, but should receive it from outside. For example:
54 |
55 | ```swift
56 | actor MyService {
57 | private let canopy: CanopyType
58 | init(canopy: CanopyType) {
59 | self.canopy = canopy
60 | }
61 |
62 | func someFeature() async {
63 | let databaseAPI = await canopy.getDatabaseAPI(usingDatabaseScope: .private)
64 | // call databaseAPI functions to
65 | // retrieve and modify records, zones, subscriptions …
66 | }
67 | }
68 | ```
69 |
70 | In live use of your app, you initiate and inject the live Canopy object that talks to CloudKit. When independently testing your features, you instead inject a mock Canopy object that doesn’t talk to any cloud services, but instead plays back mock responses.
71 |
72 | Read more: [Testable CloudKit apps with Canopy](https://canopy-docs.justtact.com/documentation/canopy/testable-cloudkit-apps-with-canopy)
73 |
74 | ### Dependency injection with swift-dependency
75 |
76 | Canopy implements `cloudKit` dependency key for [swift-dependencies](https://github.com/pointfreeco/swift-dependencies). If you use `swift-dependencies`, you use Canopy like this:
77 |
78 | ```swift
79 | struct MyFeature {
80 | @Dependency(\.cloudKit) private var canopy
81 | func myFeature() async {
82 | let recordsResult = await canopy.databaseAPI(usingDatabaseScope: .private).fetchRecords(…)
83 | }
84 | }
85 | ```
86 |
87 | See swift-dependencies documentation for more info about how to use dependencies, and inject desired values for the Canopy dependency for your previews and tests.
88 |
89 | ## Understanding Canopy
90 |
91 | The Canopy package has three parts.
92 |
93 | ### Libraries
94 |
95 | Libraries provide the main Canopy functionality and value. `Canopy` is the main library, and `CanopyTestTools` helps you build tests.
96 |
97 | ### Documentation
98 |
99 | The Canopy documentation site at has documentation for the libraries, as well as information about the library motivation and some ideas and best practices about using CloudKit. The documentation is generated by DocC from this repository, and can also be used inline in Xcode.
100 |
101 | Some highlights from documentation:
102 |
103 | [Canopy motivation and scope](https://canopy-docs.justtact.com/documentation/canopy/motivation-and-scope)
104 |
105 | [Testable CloudKit apps with Canopy](https://canopy-docs.justtact.com/documentation/canopy/testable-cloudkit-apps-with-canopy)
106 |
107 | [iCloud Advanced Data Protection](https://canopy-docs.justtact.com/documentation/canopy/icloud-advanced-data-protection)
108 |
109 | ### Example app
110 |
111 | The [Thoughts](https://github.com/Tact/Thoughts) example app showcases using Canopy in a real app, and demonstrates some best practices for modern multi-platform, multi-window app development.
112 |
113 | [Thoughts example app](https://canopy-docs.justtact.com/documentation/canopy/thoughts-example-app)
114 |
115 | ## Authors and credits
116 |
117 | Canopy was built, and continues to be built, as part of [Tact app.](https://justtact.com/)
118 |
119 | Major contributors: [Jaanus Kase](https://github.com/jaanus), [Andrew Tetlaw](https://github.com/atetlaw), [Henry Cooper](https://github.com/pillboxer)
120 |
121 | Thanks to: [Priidu Zilmer](https://github.com/priiduzilmer), [Roger Sheen](https://github.com/infotexture), [Margus Holland](https://github.com/margusholland)
122 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Archiving/CloudKitCursorArchive.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | /// Archive optionally containing a cursor.
5 | public struct CloudKitCursorArchive: Codable, Sendable {
6 | private let data: Data
7 |
8 | public var cursor: CKQueryOperation.Cursor? {
9 | do {
10 | let decodedRecord = try NSKeyedUnarchiver.unarchivedObject(ofClass: CKQueryOperation.Cursor.self, from: data)
11 | return decodedRecord
12 | } catch {
13 | return nil
14 | }
15 | }
16 |
17 | public init(cursor: CKQueryOperation.Cursor?) {
18 | if let cursor {
19 | do {
20 | self.data = try NSKeyedArchiver.archivedData(withRootObject: cursor, requiringSecureCoding: true)
21 | } catch {
22 | self.data = Data()
23 | }
24 | } else {
25 | self.data = Data()
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Archiving/CloudKitLookupInfoArchive.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | public struct CloudKitLookupInfoArchive: Codable, Sendable {
5 | private let data: Data
6 |
7 | public var lookupInfos: [CKUserIdentity.LookupInfo] {
8 | do {
9 | let decodedRecords = try NSKeyedUnarchiver.unarchivedArrayOfObjects(ofClass: CKUserIdentity.LookupInfo.self, from: data)
10 | return decodedRecords ?? []
11 | } catch {
12 | return []
13 | }
14 | }
15 |
16 | public init(lookupInfos: [CKUserIdentity.LookupInfo]) {
17 | guard !lookupInfos.isEmpty else {
18 | self.data = Data()
19 | return
20 | }
21 |
22 | do {
23 | self.data = try NSKeyedArchiver.archivedData(withRootObject: lookupInfos, requiringSecureCoding: true)
24 | } catch {
25 | self.data = Data()
26 | }
27 | }
28 | }
29 |
30 | public extension CloudKitLookupInfoArchive {
31 | static func + (lhs: CloudKitLookupInfoArchive, rhs: CloudKitLookupInfoArchive) -> CloudKitLookupInfoArchive {
32 | CloudKitLookupInfoArchive(lookupInfos: lhs.lookupInfos + rhs.lookupInfos)
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Archiving/CloudKitRecordArchive.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CloudKitRecordArchive.swift
3 | // CloudKitRecordArchive
4 | //
5 | // Created by Andrew Tetlaw on 20/8/21.
6 | // Copyright © 2021 Jaanus Kase. All rights reserved.
7 | //
8 |
9 | import CloudKit
10 | import Foundation
11 |
12 | public struct CloudKitRecordArchive: Codable, Sendable {
13 | private let data: Data
14 |
15 | public var records: [CKRecord] {
16 | do {
17 | let decodedRecords = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSArray.self, CKRecord.self], from: data)
18 | return decodedRecords as? [CKRecord] ?? []
19 | } catch {
20 | return []
21 | }
22 | }
23 |
24 | public init(records: [CKRecord]) {
25 | guard !records.isEmpty else {
26 | self.data = Data()
27 | return
28 | }
29 |
30 | do {
31 | self.data = try NSKeyedArchiver.archivedData(withRootObject: records, requiringSecureCoding: true)
32 | } catch {
33 | self.data = Data()
34 | }
35 | }
36 | }
37 |
38 | public extension CloudKitRecordArchive {
39 | static func + (lhs: CloudKitRecordArchive, rhs: CloudKitRecordArchive) -> CloudKitRecordArchive {
40 | CloudKitRecordArchive(records: lhs.records + rhs.records)
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Archiving/CloudKitRecordIDArchive.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CloudKitRecordIDArchive.swift
3 | // CloudKitRecordIDArchive
4 | //
5 | // Created by Andrew Tetlaw on 20/8/21.
6 | // Copyright © 2021 Jaanus Kase. All rights reserved.
7 | //
8 |
9 | import CloudKit
10 | import Foundation
11 |
12 | public struct CloudKitRecordIDArchive: Codable, Sendable {
13 | private let data: Data
14 |
15 | public var recordIDs: [CKRecord.ID] {
16 | do {
17 | let decodedRecords = try NSKeyedUnarchiver.unarchivedArrayOfObjects(ofClass: CKRecord.ID.self, from: data)
18 | return decodedRecords ?? []
19 | } catch {
20 | return []
21 | }
22 | }
23 |
24 | public init(recordIDs: [CKRecord.ID]) {
25 | guard !recordIDs.isEmpty else {
26 | self.data = Data()
27 | return
28 | }
29 |
30 | do {
31 | self.data = try NSKeyedArchiver.archivedData(withRootObject: recordIDs, requiringSecureCoding: true)
32 | } catch {
33 | self.data = Data()
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Archiving/CloudKitRecordZoneArchive.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | public struct CloudKitRecordZoneArchive: Codable, Sendable {
5 | private let data: Data
6 |
7 | public var zones: [CKRecordZone] {
8 | do {
9 | let decodedRecords = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSArray.self, CKRecordZone.self], from: data)
10 | return decodedRecords as? [CKRecordZone] ?? []
11 | } catch {
12 | return []
13 | }
14 | }
15 |
16 | public init(zones: [CKRecordZone]) {
17 | guard !zones.isEmpty else {
18 | self.data = Data()
19 | return
20 | }
21 |
22 | do {
23 | self.data = try NSKeyedArchiver.archivedData(withRootObject: zones, requiringSecureCoding: true)
24 | } catch {
25 | self.data = Data()
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Archiving/CloudKitRecordZoneIDArchive.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CloudKitRecordZoneIDArchive.swift
3 | // Tact
4 | //
5 | // Created by Andrew Tetlaw on 5/1/2022.
6 | // Copyright © 2022 Jaanus Kase. All rights reserved.
7 | //
8 |
9 | import CloudKit
10 | import Foundation
11 |
12 | public struct CloudKitRecordZoneIDArchive: Codable, Sendable {
13 | private let data: Data
14 |
15 | public var zoneIDs: [CKRecordZone.ID] {
16 | do {
17 | let decodedRecords = try NSKeyedUnarchiver.unarchivedArrayOfObjects(ofClass: CKRecordZone.ID.self, from: data)
18 | return decodedRecords ?? []
19 | } catch {
20 | return []
21 | }
22 | }
23 |
24 | public init(zoneIDs: [CKRecordZone.ID]) {
25 | guard !zoneIDs.isEmpty else {
26 | self.data = Data()
27 | return
28 | }
29 |
30 | do {
31 | self.data = try NSKeyedArchiver.archivedData(withRootObject: zoneIDs, requiringSecureCoding: true)
32 | } catch {
33 | self.data = Data()
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Archiving/CloudKitServerChangeTokenArchive.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | public struct CloudKitServerChangeTokenArchive: Codable, Sendable {
5 | private let data: Data
6 |
7 | public var token: CKServerChangeToken {
8 | let decodedRecord = try! NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: data)!
9 | return decodedRecord
10 | }
11 |
12 | public init(token: CKServerChangeToken) {
13 | self.data = try! NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true)
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Archiving/CloudKitShareArchive.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CloudKitShareArchive.swift
3 | // CloudKitShareArchive
4 | //
5 | // Created by Jaanus Kase on 14/10/21.
6 | // Copyright © 2021 Jaanus Kase. All rights reserved.
7 | //
8 |
9 | import CloudKit
10 | import Foundation
11 |
12 | public struct CloudKitShareArchive: Codable, Sendable {
13 | private let data: Data
14 |
15 | public var shares: [CKShare] {
16 | do {
17 | let decodedShares = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSArray.self, CKShare.self], from: data)
18 | return decodedShares as? [CKShare] ?? []
19 | } catch {
20 | return []
21 | }
22 | }
23 |
24 | public init(shares: [CKShare]) {
25 | guard !shares.isEmpty else {
26 | self.data = Data()
27 | return
28 | }
29 |
30 | do {
31 | self.data = try NSKeyedArchiver.archivedData(withRootObject: shares, requiringSecureCoding: true)
32 | } catch {
33 | self.data = Data()
34 | }
35 | }
36 | }
37 |
38 | extension CloudKitShareArchive {
39 | static func + (lhs: CloudKitShareArchive, rhs: CloudKitShareArchive) -> CloudKitShareArchive {
40 | CloudKitShareArchive(shares: lhs.shares + rhs.shares)
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Archiving/CloudKitShareMetadataArchive.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | public struct CloudKitShareMetadataArchive: Codable, Sendable {
5 | private let data: Data
6 |
7 | public var shareMetadatas: [CKShare.Metadata] {
8 | do {
9 | let decodedShareMetadatas = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSArray.self, CKShare.Metadata.self], from: data)
10 | return decodedShareMetadatas as? [CKShare.Metadata] ?? []
11 | } catch {
12 | return []
13 | }
14 | }
15 |
16 | public init(shareMetadatas: [CKShare.Metadata]) {
17 | guard !shareMetadatas.isEmpty else {
18 | self.data = Data()
19 | return
20 | }
21 |
22 | do {
23 | self.data = try NSKeyedArchiver.archivedData(withRootObject: shareMetadatas, requiringSecureCoding: true)
24 | } catch {
25 | self.data = Data()
26 | }
27 | }
28 | }
29 |
30 | extension CloudKitShareMetadataArchive {
31 | static func + (lhs: CloudKitShareMetadataArchive, rhs: CloudKitShareMetadataArchive) -> CloudKitShareMetadataArchive {
32 | CloudKitShareMetadataArchive(shareMetadatas: lhs.shareMetadatas + rhs.shareMetadatas)
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Archiving/CloudKitShareParticipantArchive.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CloudKitShareArchive.swift
3 | // CloudKitShareArchive
4 | //
5 | // Created by Jaanus Kase on 14/10/21.
6 | // Copyright © 2021 Jaanus Kase. All rights reserved.
7 | //
8 |
9 | import CloudKit
10 | import Foundation
11 |
12 | public struct CloudKitShareParticipantArchive: Codable, Sendable {
13 | private let data: Data
14 |
15 | public var shareParticipants: [CKShare.Participant] {
16 | do {
17 | let decodedShareParticipants = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSArray.self, CKShare.Participant.self], from: data)
18 | return decodedShareParticipants as? [CKShare.Participant] ?? []
19 | } catch {
20 | return []
21 | }
22 | }
23 |
24 | public init(shareParticipants: [CKShare.Participant]) {
25 | guard !shareParticipants.isEmpty else {
26 | self.data = Data()
27 | return
28 | }
29 |
30 | do {
31 | self.data = try NSKeyedArchiver.archivedData(withRootObject: shareParticipants, requiringSecureCoding: true)
32 | } catch {
33 | self.data = Data()
34 | }
35 | }
36 | }
37 |
38 | extension CloudKitShareParticipantArchive {
39 | static func + (lhs: CloudKitShareParticipantArchive, rhs: CloudKitShareParticipantArchive) -> CloudKitShareParticipantArchive {
40 | CloudKitShareParticipantArchive(shareParticipants: lhs.shareParticipants + rhs.shareParticipants)
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Archiving/CloudKitSubscriptionArchive.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | public struct CloudKitSubscriptionArchive: Codable, Sendable {
5 | private let data: Data
6 |
7 | public var subscription: CKSubscription {
8 | let decodedRecord = try! NSKeyedUnarchiver.unarchivedObject(ofClass: CKSubscription.self, from: data)!
9 | return decodedRecord
10 | }
11 |
12 | public init(subscription: CKSubscription) {
13 | self.data = try! NSKeyedArchiver.archivedData(withRootObject: subscription, requiringSecureCoding: true)
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/CKContainerAPI/CKAccountChangedSequence.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct CKAccountChangedSequence: AsyncSequence {
4 | public typealias Element = Void
5 |
6 | enum Kind {
7 | case mock(Int)
8 | case live(NotificationCenter.Notifications)
9 | }
10 |
11 | let kind: Kind
12 |
13 | public static var live: CKAccountChangedSequence {
14 | .init(kind: .live(NotificationCenter.default.notifications(named: .CKAccountChanged)))
15 | }
16 |
17 | public static func mock(elementsToProduce: Int) -> CKAccountChangedSequence {
18 | .init(kind: .mock(elementsToProduce))
19 | }
20 |
21 | private init(kind: Kind) {
22 | self.kind = kind
23 | }
24 |
25 | public struct AsyncIterator: AsyncIteratorProtocol {
26 | let kind: Kind
27 | var mockElementsToProduce: Int = 0
28 |
29 | init(kind: Kind) {
30 | self.kind = kind
31 | if case let .mock(elementsToProduce) = kind {
32 | self.mockElementsToProduce = elementsToProduce
33 | }
34 | }
35 |
36 | public mutating func next() async -> Void? {
37 | switch kind {
38 | case .mock:
39 | guard mockElementsToProduce > 0 else {
40 | return nil
41 | }
42 | // Don’t emit the signal too quickly.
43 | // Otherwise this was causing some flaky tests and out-of-order status delivery.
44 | // A better solution would be to better sequence the task creation in ReplayingMockCKContainer.
45 | try? await Task.sleep(nanoseconds: UInt64(0.01 * Double(NSEC_PER_SEC)))
46 | mockElementsToProduce -= 1
47 | return ()
48 | case let .live(notifications):
49 | let _ = await notifications.first(where: { _ in true })
50 | return ()
51 | }
52 | }
53 | }
54 |
55 | public func makeAsyncIterator() -> AsyncIterator {
56 | AsyncIterator(kind: kind)
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/CKContainerAPI/CKContainerAPI.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | actor CKContainerAPI: CKContainerAPIType {
4 | let container: CKContainerType
5 | var statusContinuation: AsyncStream.Continuation?
6 |
7 | init(container: CKContainerType, accountChangedSequence: CKAccountChangedSequence) {
8 | self.container = container
9 | Task {
10 | for await _ in accountChangedSequence {
11 | await emitStatus()
12 | }
13 | }
14 | }
15 |
16 | var accountStatusStream: Result, CKContainerAPIError> {
17 | // I tried to have several streams, by creating a new one and capturing its continuation
18 | // into a set of continuations every time a stream is requested.
19 | // Got nondeterministic test results with multiple streams, so for now, only one stream.
20 | guard statusContinuation == nil else { return .failure(.onlyOneAccountStatusStreamSupported) }
21 |
22 | var cont: AsyncStream.Continuation!
23 | let stream = AsyncStream { cont = $0 }
24 | statusContinuation = cont
25 | Task {
26 | // Emit the first/current account status right when the stream is created.
27 | if let status = try? await accountStatus.get() {
28 | cont.yield(status)
29 | }
30 | }
31 | return .success(stream)
32 | }
33 |
34 | var userRecordID: Result {
35 | get async {
36 | await withCheckedContinuation { continuation in
37 | container.fetchUserRecordID { recordID, error in
38 | if let error {
39 | continuation.resume(returning: .failure(CKRecordError(from: error)))
40 | } else {
41 | continuation.resume(returning: .success(recordID))
42 | }
43 | }
44 | }
45 | }
46 | }
47 |
48 | var accountStatus: Result {
49 | get async {
50 | await withCheckedContinuation { continuation in
51 | container.accountStatus { accountStatus, error in
52 | if let error {
53 | continuation.resume(returning: .failure(CanopyError.accountError(from: error)))
54 | } else {
55 | continuation.resume(returning: .success(accountStatus))
56 | }
57 | }
58 | }
59 | }
60 | }
61 |
62 | private func emitStatus() async {
63 | guard let status = try? await accountStatus.get() else { return }
64 | statusContinuation?.yield(status)
65 | }
66 |
67 | func fetchShareParticipants(
68 | with lookupInfos: [CKUserIdentity.LookupInfo],
69 | qos: QualityOfService
70 | ) async -> Result<[CKShare.Participant], CKRecordError> {
71 | await withCheckedContinuation { continuation in
72 | var participants: [CKShare.Participant] = []
73 | var recordError: CKRecordError?
74 | let fetchParticipantsOperation = CKFetchShareParticipantsOperation(userIdentityLookupInfos: lookupInfos)
75 | fetchParticipantsOperation.qualityOfService = qos
76 |
77 | fetchParticipantsOperation.perShareParticipantResultBlock = { _, result in
78 | switch result {
79 | case let .failure(error):
80 | recordError = CKRecordError(from: error)
81 | case let .success(participant):
82 | participants.append(participant)
83 | }
84 | }
85 |
86 | fetchParticipantsOperation.fetchShareParticipantsResultBlock = { result in
87 | switch result {
88 | case let .failure(error):
89 | continuation.resume(returning: .failure(CKRecordError(from: error)))
90 | case .success:
91 | if let recordError {
92 | continuation.resume(returning: .failure(recordError))
93 | } else {
94 | continuation.resume(returning: .success(participants))
95 | }
96 | }
97 | }
98 |
99 | container.add(fetchParticipantsOperation)
100 | }
101 | }
102 |
103 | nonisolated func acceptShares(with metadatas: [CKShare.Metadata], qos: QualityOfService) async -> Result<[CKShare], CKRecordError> {
104 | await withCheckedContinuation { continuation in
105 | var recordError: CKRecordError?
106 | var acceptedShares: [CKShare] = []
107 |
108 | let acceptSharesOperation = CKAcceptSharesOperation(shareMetadatas: metadatas)
109 | acceptSharesOperation.qualityOfService = qos
110 |
111 | acceptSharesOperation.perShareResultBlock = { _, result in
112 | switch result {
113 | case let .failure(error):
114 | recordError = CKRecordError(from: error)
115 | case let .success(acceptedShare):
116 | acceptedShares.append(acceptedShare)
117 | }
118 | }
119 |
120 | acceptSharesOperation.acceptSharesResultBlock = { result in
121 | switch result {
122 | case .success:
123 | if let recordError {
124 | // Be defensive. Fail the operation if there was at least one per-share error.
125 | continuation.resume(returning: .failure(recordError))
126 | } else {
127 | continuation.resume(returning: .success(acceptedShares))
128 | }
129 | case let .failure(error):
130 | continuation.resume(returning: .failure(CKRecordError(from: error)))
131 | }
132 | }
133 | container.add(acceptSharesOperation)
134 | }
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/CKContainerAPI/CKContainerAPIType+Shorthand.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | public extension CKContainerAPIType {
4 | /// Fetch `CKShare.Participant` based on user record name or other info.
5 | ///
6 | /// This is one step of implementing a custom record sharing interface. You obtain share participants and add them to a `CKShare`.
7 | ///
8 | /// For more info, see [CKFetchShareParticipantsOperation](https://developer.apple.com/documentation/cloudkit/ckfetchshareparticipantsoperation/).
9 | /// Canopy internally calls this operation to execute this function.
10 | ///
11 | /// - Parameters:
12 | /// - lookupInfos: An array of user lookup infos to resolve into share participants.
13 | /// - qualityOfService: The desired quality of service of the request. Defaults to `.default` if not provided.
14 | ///
15 | /// - Returns:
16 | /// An array of `CKShare.Participant` values for the requested look up infos, or `CKRecordError` if there
17 | /// was an error with the request.
18 | func fetchShareParticipants(
19 | with lookupInfos: [CKUserIdentity.LookupInfo],
20 | qualityOfService: QualityOfService = .default
21 | ) async -> Result<[CKShare.Participant], CKRecordError> {
22 | await fetchShareParticipants(
23 | with: lookupInfos,
24 | qos: qualityOfService
25 | )
26 | }
27 |
28 | /// Obtain access to a CKShare after the system has prompted the user to accept the share.
29 | ///
30 | /// After the user uses the OS-provided interface to accept joining a shared record in CloudKit, your app is called with the share metadata.
31 | /// You must then use this `acceptShares` call, to convert the metadata into a real share which you then have access to.
32 | ///
33 | /// For more info, see [CKAcceptSharesOperation.](https://developer.apple.com/documentation/cloudkit/ckacceptsharesoperation)
34 | /// Canopy internally calls this operation to execute this function.
35 | ///
36 | /// - Parameters:
37 | /// - metadatas: An array of `CKShare.Metadata` that you received e.g from the underlying system, which to convert into shares.
38 | /// - qualityOfService: The desired quality of service of the request. Defaults to `.default` if not provided.
39 | ///
40 | /// - Returns:
41 | /// An array of `CKShare` that corresponds to the array of given metadatas, or `CKRecordError` if there was an error with the request.
42 | ///
43 | func acceptShares(
44 | with metadatas: [CKShare.Metadata],
45 | qualityOfService: QualityOfService = .default
46 | ) async -> Result<[CKShare], CKRecordError> {
47 | await acceptShares(
48 | with: metadatas,
49 | qos: qualityOfService
50 | )
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/CKContainerAPI/CKContainerAPIType.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | public enum CKContainerAPIError: Int, Error, Codable, Sendable {
4 | /// There can only be one listener to the account status stream.
5 | case onlyOneAccountStatusStreamSupported
6 | }
7 |
8 | /// Canopy API provider for CKContainer.
9 | ///
10 | /// [CKContainer](https://developer.apple.com/documentation/cloudkit/ckcontainer)
11 | /// is the main unit of abstaction for your app’s data in CloudKit. It has methods to get information about the
12 | /// current CloudKit user and other users.
13 | ///
14 | /// Some methods of this protocol have a preferred shorthand way of calling them via a protocol extension,
15 | /// which lets you skip specifying some parameters and provides reasonable default values for them.
16 | ///
17 | /// To access your app’s actual data in CloudKit, see ``CKDatabaseAPIType``.
18 | public protocol CKContainerAPIType: Sendable {
19 | /// Obtain the user record ID for the current CloudKit user.
20 | ///
21 | /// You don’t need to do this for regular CloudKit use. Your app doesn’t need to know anything about the current user,
22 | /// including their record ID, to use CloudKit.
23 | ///
24 | /// Knowing the current user record ID may be useful in scenarios related to record sharing and other communication
25 | /// between your app’s CloudKit users.
26 | ///
27 | /// You can also store the record ID and compare it with future sessions of your app. The record ID may change if
28 | /// the current user logs out in the device’s iCloud Settings, and another user logs in.
29 | ///
30 | /// The reported user ID is unique for the current user, your app, and CloudKit environment. CloudKit reports the same user record ID
31 | /// for a given iCloud user across all of their devices.
32 | var userRecordID: Result { get async }
33 |
34 | /// Obtain CloudKit account status for the current user.
35 | var accountStatus: Result { get async }
36 |
37 | /// Obtain a stream of CloudKit account statuses of the current user.
38 | ///
39 | /// To obtain this info with vanilla CloudKit API, you must listen to [CKAccountChanged](https://developer.apple.com/documentation/foundation/nsnotification/name/1399172-ckaccountchanged)
40 | /// notifications. Whenever you get one, you need to use the [accountStatus](https://developer.apple.com/documentation/cloudkit/ckcontainer/1399180-accountstatus)
41 | /// API of the CKContainer to find out what the actual status is.
42 | ///
43 | /// Canopy does all of this work internally, and provides you a simple stream of the account statuses.
44 | var accountStatusStream: Result, CKContainerAPIError> { get async }
45 |
46 | /// See ``CKContainerAPIType/fetchShareParticipants(with:qualityOfService:)`` for preferred way of calling this API.
47 | func fetchShareParticipants(
48 | with lookupInfos: [CKUserIdentity.LookupInfo],
49 | qos: QualityOfService
50 | ) async -> Result<[CKShare.Participant], CKRecordError>
51 |
52 | /// See ``CKContainerAPIType/acceptShares(with:qualityOfService:)`` for preferred way of calling this API.
53 | func acceptShares(
54 | with metadatas: [CKShare.Metadata],
55 | qos: QualityOfService
56 | ) async -> Result<[CKShare], CKRecordError>
57 | }
58 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/CKContainerType.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | public protocol CKContainerType: Sendable {
4 | func fetchUserRecordID(completionHandler: @escaping @Sendable (CKRecord.ID?, Error?) -> Void)
5 | func accountStatus(completionHandler: @escaping @Sendable (CKAccountStatus, Error?) -> Void)
6 | func add(_ operation: CKOperation)
7 | }
8 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/CKDatabaseAPI/CKDatabaseAPI+SimulatedFail.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | @available(iOS 16.4, macOS 13.3, *)
4 | extension CKDatabaseAPI {
5 | func randomCKRecordError(
6 | codes: Set,
7 | saving recordsToSave: [CKRecord]? = nil,
8 | deleting recordIDsToDelete: [CKRecord.ID]? = nil,
9 | includePartialErrors: Bool = false
10 | ) -> CKRecordError {
11 | let code = codes.randomElement()!
12 | var info: [String: Any] = [:]
13 |
14 | if CKRecordError.retriableErrors.contains(code) {
15 | info[CKErrorRetryAfterKey] = NSNumber(10)
16 | }
17 |
18 | // Add the partial error dictionary, to be more realistic
19 | if includePartialErrors {
20 | // Don't fall into an infinite hell hole
21 | let otherCodes = codes.subtracting([.partialFailure])
22 |
23 | var partialErrors: [AnyHashable: Error] = [:]
24 | if let saved = recordsToSave {
25 | saved.forEach {
26 | partialErrors[$0.recordID] = randomCKRecordError(codes: otherCodes).ckError
27 | }
28 | }
29 |
30 | if let deleted = recordIDsToDelete {
31 | deleted.forEach {
32 | partialErrors[$0] = randomCKRecordError(codes: otherCodes).ckError
33 | }
34 | }
35 |
36 | info[CKPartialErrorsByItemIDKey] = partialErrors
37 | }
38 |
39 | let error = CKError(code, userInfo: info)
40 | return CKRecordError(from: error)
41 | }
42 |
43 | func randomCKRequestError(
44 | codes: Set
45 | ) -> CKRequestError {
46 | let code = codes.randomElement()!
47 | var info: [String: Any] = [:]
48 |
49 | if CKRecordError.retriableErrors.contains(code) {
50 | info[CKErrorRetryAfterKey] = NSNumber(10)
51 | }
52 |
53 | let error = CKError(code, userInfo: info)
54 | return CKRequestError(from: error)
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/CKDatabaseAPI/CKDatabaseAPIType.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | /// Canopy API provider for CKDatabase.
5 | ///
6 | /// [CKDatabase](https://developer.apple.com/documentation/cloudkit/ckdatabase)
7 | /// is the representation for containers of your app’s records and record zones in the cloud. You interact with CKDatabase
8 | /// to store and obtain `CKRecord` objects that represent your app’s data.
9 | ///
10 | /// Methods of this protocol have a preferred shorthand way of calling them via a protocol extension,
11 | /// which lets you skip specifying some parameters and provides reasonable default values for them.
12 | public protocol CKDatabaseAPIType: Sendable {
13 |
14 | typealias PerRecordProgressBlock = @Sendable (CKRecord, Double) -> Void
15 | typealias PerRecordIDProgressBlock = @Sendable (CKRecord.ID, Double) -> Void
16 |
17 | /// See ``CKDatabaseAPIType/queryRecords(with:in:resultsLimit:qualityOfService:)`` for preferred way of calling this API.
18 | func queryRecords(
19 | with query: CKQuery,
20 | in zoneID: CKRecordZone.ID?,
21 | resultsLimit: Int?,
22 | qos: QualityOfService
23 | ) async -> Result<[CanopyResultRecord], CKRecordError>
24 |
25 | /// See ``CKDatabaseAPIType/modifyRecords(saving:deleting:perRecordProgressBlock:qualityOfService:)`` for preferred way of calling this API.
26 | func modifyRecords(
27 | saving recordsToSave: [CKRecord]?,
28 | deleting recordIDsToDelete: [CKRecord.ID]?,
29 | perRecordProgressBlock: PerRecordProgressBlock?,
30 | qos: QualityOfService
31 | ) async -> Result
32 |
33 | /// See ``CKDatabaseAPIType/deleteRecords(with:in:qualityOfService:)`` for preferred way of calling this API.
34 | func deleteRecords(
35 | with query: CKQuery,
36 | in zoneID: CKRecordZone.ID?,
37 | qos: QualityOfService
38 | ) async -> Result
39 |
40 | /// See ``CKDatabaseAPIType/fetchRecords(with:desiredKeys:perRecordIDProgressBlock:qualityOfService:)`` for preferred way of calling this API.
41 | func fetchRecords(
42 | with recordIDs: [CKRecord.ID],
43 | desiredKeys: [CKRecord.FieldKey]?,
44 | perRecordIDProgressBlock: PerRecordIDProgressBlock?,
45 | qos: QualityOfService
46 | ) async -> Result
47 |
48 | /// See ``CKDatabaseAPIType/modifyZones(saving:deleting:qualityOfService:)`` for preferred way of calling this API.
49 | func modifyZones(
50 | saving recordZonesToSave: [CKRecordZone]?,
51 | deleting recordZoneIDsToDelete: [CKRecordZone.ID]?,
52 | qos: QualityOfService
53 | ) async -> Result
54 |
55 | /// See ``CKDatabaseAPIType/fetchZones(with:qualityOfService:)`` for preferred way of calling this API.
56 | func fetchZones(
57 | with recordZoneIDs: [CKRecordZone.ID],
58 | qos: QualityOfService
59 | ) async -> Result<[CKRecordZone], CKRecordZoneError>
60 |
61 | /// See ``CKDatabaseAPIType/fetchAllZones(qualityOfService:)`` for preferred way of calling this API.
62 | func fetchAllZones(
63 | qos: QualityOfService
64 | ) async -> Result<[CKRecordZone], CKRecordZoneError>
65 |
66 | /// See ``CKDatabaseAPIType/modifySubscriptions(saving:deleting:qualityOfService:)`` for preferred way of calling this API.
67 | func modifySubscriptions(
68 | saving subscriptionsToSave: [CKSubscription]?,
69 | deleting subscriptionIDsToDelete: [CKSubscription.ID]?,
70 | qos: QualityOfService
71 | ) async -> Result
72 |
73 | /// See ``CKDatabaseAPIType/fetchDatabaseChanges(qualityOfService:)`` for preferred way of calling this API.
74 | func fetchDatabaseChanges(
75 | qos: QualityOfService
76 | ) async -> Result
77 |
78 | /// See ``CKDatabaseAPIType/fetchZoneChanges(recordZoneIDs:fetchMethod:qualityOfService:)`` for preferred way of calling this API.
79 | func fetchZoneChanges(
80 | recordZoneIDs: [CKRecordZone.ID],
81 | fetchMethod: FetchZoneChangesMethod,
82 | qos: QualityOfService
83 | ) async -> Result
84 | }
85 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/CKDatabaseType.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | public protocol CKDatabaseType: Sendable {
4 | func add(_ operation: CKDatabaseOperation)
5 | }
6 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Canopy.docc/Canopy.md:
--------------------------------------------------------------------------------
1 | # ``Canopy``
2 |
3 | Write better, testable CloudKit apps.
4 |
5 | Canopy helps you write better, more testable CloudKit apps. It isolates the CloudKit dependency so you can write fast and reliable tests for your CloudKit-related features, and implements standard CloudKit-related behaviors.
6 |
7 | Canopy is built as part of [Tact](https://justtact.com). The Canopy source code and installation instructions (including the source for this site) are available on [GitHub](https://github.com/Tact/Canopy).
8 |
9 | ## Topics
10 |
11 | ### About Canopy
12 |
13 | -
14 | -
15 | -
16 | -
17 |
18 | ### CloudKit articles
19 |
20 | -
21 | -
22 | -
23 |
24 | ### Main Canopy API
25 |
26 | - ``CanopyType``
27 | - ``Canopy/Canopy``
28 |
29 | ### Settings
30 |
31 | - ``CanopySettingsType``
32 | - ``CanopySettings``
33 | - ``RequestBehavior``
34 |
35 | ### Token store
36 |
37 | Token store manages client-side storage of database and zone change tokens. You only need to use TokenStore if you use the ``CKDatabaseAPIType/fetchDatabaseChanges(qualityOfService:)`` or ``CKDatabaseAPIType/fetchZoneChanges(recordZoneIDs:fetchMethod:qualityOfService:)`` Canopy APIs.
38 |
39 | - ``TokenStoreType``
40 | - ``TestTokenStore``
41 | - ``UserDefaultsTokenStore``
42 |
43 | ### CKContainer API
44 |
45 | - ``CKContainerAPIType``
46 | - ``CKContainerAPIError``
47 |
48 | ### CKDatabase API
49 |
50 | - ``CKDatabaseAPIType``
51 | - ``FetchZoneChangesMethod``
52 |
53 | ### Request results
54 |
55 | - ``CanopyResultRecordType``
56 | - ``ModifyRecordsResult``
57 | - ``FetchDatabaseChangesResult``
58 | - ``FetchRecordsResult``
59 | - ``ModifyZonesResult``
60 | - ``ModifySubscriptionsResult``
61 | - ``FetchZoneChangesResult``
62 | - ``DeletedCKRecord``
63 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Canopy.docc/Motivation and scope.md:
--------------------------------------------------------------------------------
1 | # Motivation and scope
2 |
3 | A summary of the motivation, technical design goals, and scope of the Canopy project.
4 |
5 | ## Overview
6 |
7 | [CloudKit](https://developer.apple.com/icloud/cloudkit/) is a set of Apple APIs that let you store your app data in iCloud. It was introduced at [WWDC 14.](https://www.wwdcnotes.com/notes/wwdc14/208/) Since then, it’s gained a few new features, but the fundamentals have remained exactly as they were introduced. It is a stable first-party Apple API and service.
8 |
9 | Out of the box, CloudKit provides you a set of APIs and two runtime environments, “development” and “production”, both running in iCloud. There’s no support for local testing.
10 |
11 | Canopy is built on top of the CloudKit API and makes it easier to develop solid CloudKit apps. This article describes the elements that drive the design of the Canopy package.
12 |
13 | ## Testable CloudKit apps
14 |
15 | Canopy lets you fully isolate the CloudKit dependency and test your CloudKit code without any interaction with the real cloud. You can be confident that your features behave the way you want when you receive the expected responses. Since the tests rely only on local data and have no network interaction, they are fast and predictable.
16 |
17 | Canopy also offers some ideas around isolating dependencies for your UI tests, and simulating failures in the context of a real app.
18 |
19 |
20 |
21 | ## Consistent modern API
22 |
23 | CloudKit offers a family of APIs, varying from operation-based to “immediate” async APIs.
24 |
25 | Canopy wraps many of the operation-based APIs behind a single consistent pattern: async APIs with `Result` return types, returning either the requested results or a failure.
26 |
27 | Many people prefer working with throwing results. You can convert a Canopy result to a throwing result easily using the [`get()`](https://developer.apple.com/documentation/swift/result/get()) function of the Swift result type at the call site.
28 |
29 | As an example, if you have this Canopy API call:
30 |
31 | ```swift
32 | let recordsResult = await canopy.databaseAPI(usingDatabaseScope: .private).fetchRecords(…)
33 | ```
34 |
35 | You can easily convert it to a throwing call like this:
36 |
37 | ```swift
38 | do {
39 | let result = try await canopy.databaseAPI(usingDatabaseScope: .private).fetchRecords(…).get()
40 | // use result
41 | } catch {
42 | // handle thrown error
43 | }
44 | ```
45 |
46 | ## Standard behaviors and features
47 |
48 | There are many CloudKit details you need to understand and implement to use it well. For example: server token handling, correctly sequencing zone and database change fetching, query request cursors, how to split large modification operation batches, modification policies, etc.
49 |
50 | Canopy implements standard behavior for many of them, and is designed to work in a way that is appropriate for most apps, while adding configuration and hooks to modify the behavior where needed.
51 |
52 |
53 |
54 | ## Documentation and best practices
55 |
56 | This site documents best practices and CloudKit quirks that are useful to know when you work with it — with or without Canopy.
57 |
58 | ## Sample app
59 |
60 | Canopy provides a sample app called “Thoughts” that showcases best practices for using and testing your CloudKit code, and offers a playground for modern multi-platform, multi-window app development.
61 |
62 |
63 |
64 | ## Scope
65 |
66 | CloudKit comes in two flavors. The first and earlier one is vanilla CloudKit, which is the main area of interest for Canopy. It’s conceptually very simple: you store a `CKRecord` in CloudKit’s bucket of records, and you get the same `CKRecord` back. It doesn’t prescribe anything about your client-side storage.
67 |
68 | CloudKit started with this approach, and its core design has remained stable. It has received some modifications along the way. For example, initially you could only share record hierarchies with other participants, but later on, entire record zone sharing was added. Canopy does not currently implement record zone sharing, but it fits within the project goals and vision and can be added.
69 |
70 | The other flavor of CloudKit uses a [combination of Core Data and CloudKit](https://developer.apple.com/documentation/coredata/mirroring_a_core_data_store_with_cloudkit/setting_up_core_data_with_cloudkit) with [NSPersistentCloudKitContainer](https://developer.apple.com/documentation/coredata/nspersistentcloudkitcontainer). This is much more powerful than vanilla CloudKit, and heavily determines your client-side stack.
71 |
72 | Canopy does not wish to concern itself initially with client-side storage, and thus the `NSPersistentCloudKitContainer` scenario is initially out of scope for Canopy.
73 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Canopy.docc/Resources/ckmethods01query.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tact/Canopy/1d1883f180006c1a816de2f4e3b28f9a8dc5e8c8/Targets/Canopy/Sources/Canopy.docc/Resources/ckmethods01query.png
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Canopy.docc/Resources/ckmethods02fetch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tact/Canopy/1d1883f180006c1a816de2f4e3b28f9a8dc5e8c8/Targets/Canopy/Sources/Canopy.docc/Resources/ckmethods02fetch.png
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Canopy.docc/Resources/ckmethods04zones.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tact/Canopy/1d1883f180006c1a816de2f4e3b28f9a8dc5e8c8/Targets/Canopy/Sources/Canopy.docc/Resources/ckmethods04zones.png
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Canopy.docc/Resources/ckmethods05zonechanges.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tact/Canopy/1d1883f180006c1a816de2f4e3b28f9a8dc5e8c8/Targets/Canopy/Sources/Canopy.docc/Resources/ckmethods05zonechanges.png
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Canopy.docc/Resources/testing-ui.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tact/Canopy/1d1883f180006c1a816de2f4e3b28f9a8dc5e8c8/Targets/Canopy/Sources/Canopy.docc/Resources/testing-ui.png
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Canopy.docc/Resources/testing-with-canopy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tact/Canopy/1d1883f180006c1a816de2f4e3b28f9a8dc5e8c8/Targets/Canopy/Sources/Canopy.docc/Resources/testing-with-canopy.png
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Canopy.docc/Resources/testing-without-canopy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tact/Canopy/1d1883f180006c1a816de2f4e3b28f9a8dc5e8c8/Targets/Canopy/Sources/Canopy.docc/Resources/testing-without-canopy.png
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Canopy.docc/Resources/thoughts-architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tact/Canopy/1d1883f180006c1a816de2f4e3b28f9a8dc5e8c8/Targets/Canopy/Sources/Canopy.docc/Resources/thoughts-architecture.png
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Canopy.docc/Resources/thoughts-ios-list.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tact/Canopy/1d1883f180006c1a816de2f4e3b28f9a8dc5e8c8/Targets/Canopy/Sources/Canopy.docc/Resources/thoughts-ios-list.png
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Canopy.docc/Resources/thoughts-ios-onethought.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tact/Canopy/1d1883f180006c1a816de2f4e3b28f9a8dc5e8c8/Targets/Canopy/Sources/Canopy.docc/Resources/thoughts-ios-onethought.png
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Canopy.docc/Resources/thoughts-ios-settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tact/Canopy/1d1883f180006c1a816de2f4e3b28f9a8dc5e8c8/Targets/Canopy/Sources/Canopy.docc/Resources/thoughts-ios-settings.png
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Canopy.docc/Resources/thoughts-macos.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tact/Canopy/1d1883f180006c1a816de2f4e3b28f9a8dc5e8c8/Targets/Canopy/Sources/Canopy.docc/Resources/thoughts-macos.png
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Canopy.docc/Resources/thoughts-previews.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tact/Canopy/1d1883f180006c1a816de2f4e3b28f9a8dc5e8c8/Targets/Canopy/Sources/Canopy.docc/Resources/thoughts-previews.png
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Canopy.docc/Thoughts example app.md:
--------------------------------------------------------------------------------
1 | # Thoughts example app
2 |
3 | Thoughts is a sample app that shows how to build and test your code with Canopy, and illustrates several other best practices for modern app development.
4 |
5 | ## Overview
6 |
7 | [Thoughts](https://github.com/Tact/Thoughts) is a companion app for Canopy. It demonstrates how to use Canopy in the context of a real app, and showcases other ideas for modern multi-platform multi-window app development.
8 |
9 | User story for Thoughts:
10 |
11 | _I would like to keep a list of my thoughts that syncs neatly across all my devices via iCloud. My thoughts have a body and possibly a title. They may contain private info, so I’d like them encrypted. I want to emphasize parts of the thought with Markdown._
12 |
13 | 
14 |
15 | ## Key ideas
16 |
17 | **Testability.** Thoughts showcases how to use all three approaches to testing outlined in . It has unit tests with 100% coverage for its central store and view models, UI tests with isolated dependencies, and lets you simulate some errors via its settings interface.
18 |
19 | **Multi-platform, multi-window.** Thoughts works on both macOS and iOS, with single or multiple windows. All UI code is shared native SwiftUI, with minimal platform-specific modifications as needed.
20 |
21 | 
22 |
23 | 
24 |
25 | **Rich tests and previews.** You can explore much of the Thoughts functionality by simply browsing the tests and the SwiftUI previews of all its views, showcasing all possible view states.
26 |
27 | 
28 |
29 | **Ready for Advanced Data Protection.** Thoughts uses [encryptedValues](https://developer.apple.com/documentation/cloudkit/ckrecord/3746821-encryptedvalues) to store all the private user data, so if the user has enabled Advanced Data Protection on their account, end-to-end encryption will apply. For details, see .
30 |
31 | ## Architecture
32 |
33 | 
34 |
35 | The Thoughts architecture could perhaps be labeled “store and viewmodels”. There is a central store that acts as the in-memory source of truth while the app is running, and interacts both with views and the external world. Viewmodels use Store as their data source, and keep view-local state such as the transient state of UI in a view.
36 |
37 | The division of “UI-land” and “Backend-land” is arbitrary. Perhaps the clearest distinction is that the UI-land code is running in the main queue, as all UI code on Apple platforms must, though there are some exceptions (viewmodels have long-running background tasks to subscribe and react to store changes).
38 |
39 | Thoughts is architected to be testable and mockable. You can see this with SwiftUI previews. Even though you may not be able to build and run the app (at least until you replace the team and container IDs with your own), you can get a taste of the whole app UI via tests and previews.
40 |
41 | ## Future ideas
42 |
43 | **Full offline mode.** Although Thoughts functions with CloudKit connectivity problems, it does not function fully as an offline app. When there is a problem saving data to CloudKit, Thoughts retries a few times through Canopy’s auto-retry, but eventually gives up if there really is no connection (or there is a permanent simulated error), and does not attempt another save when the connection is restored. Another save attempt is made only after you again edit a thought. Fully functional offline mode would be an interesting extension.
44 |
45 | **Preserving window state.** Although Thoughts remembers window positions and sizes on macOS automatically through SwiftUI magic, it currently does not preserve the navigation state.
46 |
47 | **Sharing and collaboration.** Currently, Thoughts is designed to work only in a single user scenario, but the data model design has possible sharing in mind. Most importantly, its records on CloudKit are stored in a custom zone in the user’s private CloudKit database. Should sharing with other users ever be implemented in Thoughts, it will be straightforward from a CloudKit perspective.
48 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Canopy/Canopy.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | /// Main Canopy implementation.
5 | ///
6 | /// You construct Canopy with injected CloudKit container and databases, token store, and settings provider.
7 | /// Canopy has reasonable defaults for all of these, and you need to only override the ones that need to use
8 | /// a different value from the default.
9 | @available(iOS 16.4, macOS 13.3, *)
10 | public actor Canopy: CanopyType {
11 | private let containerProvider: @Sendable () -> CKContainerType
12 | private let publicCloudDatabaseProvider: @Sendable () -> CKDatabaseType
13 | private let privateCloudDatabaseProvider: @Sendable () -> CKDatabaseType
14 | private let sharedCloudDatabaseProvider: @Sendable () -> CKDatabaseType
15 | private let settingsProvider: @Sendable () -> CanopySettingsType
16 | private let tokenStoreProvider: @Sendable () -> TokenStoreType
17 |
18 | private var containerAPI: CKContainerAPI?
19 | private var databaseAPIs: [CKDatabase.Scope: CKDatabaseAPI] = [:]
20 |
21 | /// Initialize the live Canopy API.
22 | ///
23 | /// - Parameters:
24 | /// - container: A real or mock `CKContainer`.
25 | /// - publicCloudDatabase: a real or mock `CKDatabase` instance representing the public CloudKit database.
26 | /// - privateCloudDatabase: a real or mock `CKDatabase` instance representing the private CloudKit database.
27 | /// - sharedCloudDatabase: a real or mock `CKDatabase` instance representing the shared CloudKit database.
28 | /// - settings: a closure that returns Canopy settings.
29 | /// Canopy requests settings from the closure every time that it runs a request whose behavior might be altered by the settings.
30 | /// This is designed as a closure because the settings may change during application runtime.
31 | /// - tokenStore: an object that stores and returns zone and database tokens for the requests that work with the tokens.
32 | /// Canopy only interacts with the token store when using the ``CKDatabaseAPIType/fetchDatabaseChanges(qualityOfService:)`` and ``CKDatabaseAPIType/fetchZoneChanges(recordZoneIDs:fetchMethod:qualityOfService:)`` APIs. If you don’t use these APIs, you can ignore this parameter.
33 | public init(
34 | container: @escaping @autoclosure @Sendable () -> CKContainerType = CKContainer.default(),
35 | publicCloudDatabase: @escaping @autoclosure @Sendable () -> CKDatabaseType = CKContainer.default().publicCloudDatabase,
36 | privateCloudDatabase: @escaping @autoclosure @Sendable () -> CKDatabaseType = CKContainer.default().privateCloudDatabase,
37 | sharedCloudDatabase: @escaping @autoclosure @Sendable () -> CKDatabaseType = CKContainer.default().sharedCloudDatabase,
38 | settings: @escaping @Sendable () -> CanopySettingsType = { CanopySettings() },
39 | tokenStore: @escaping @autoclosure @Sendable () -> TokenStoreType = UserDefaultsTokenStore()
40 | ) {
41 | self.containerProvider = container
42 | self.publicCloudDatabaseProvider = publicCloudDatabase
43 | self.privateCloudDatabaseProvider = privateCloudDatabase
44 | self.sharedCloudDatabaseProvider = sharedCloudDatabase
45 | self.settingsProvider = settings
46 | self.tokenStoreProvider = tokenStore
47 | }
48 |
49 | public func databaseAPI(usingDatabaseScope scope: CKDatabase.Scope) async -> CKDatabaseAPIType {
50 | if let existingAPI = databaseAPIs[scope] {
51 | return existingAPI
52 | }
53 | let databaseAPI: CKDatabaseAPI
54 | switch scope {
55 | case .public:
56 | databaseAPI = api(using: publicCloudDatabaseProvider(), scope: .public)
57 | case .private:
58 | databaseAPI = api(using: privateCloudDatabaseProvider(), scope: .private)
59 | case .shared:
60 | databaseAPI = api(using: sharedCloudDatabaseProvider(), scope: .shared)
61 | @unknown default:
62 | fatalError("Unknown CKDatabase scope: \(scope)")
63 | }
64 | databaseAPIs[scope] = databaseAPI
65 | return databaseAPI
66 | }
67 |
68 | public func containerAPI() async -> CKContainerAPIType {
69 | if let containerAPI {
70 | return containerAPI
71 | } else {
72 | let newContainerAPI = CKContainerAPI(
73 | container: containerProvider(),
74 | accountChangedSequence: .live
75 | )
76 | containerAPI = newContainerAPI
77 | return newContainerAPI
78 | }
79 | }
80 |
81 | private func api(using database: CKDatabaseType, scope: CKDatabase.Scope) -> CKDatabaseAPI {
82 | CKDatabaseAPI(
83 | database: database,
84 | databaseScope: scope,
85 | settingsProvider: settingsProvider,
86 | tokenStore: tokenStoreProvider()
87 | )
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Canopy/MockCanopyWithCKMocks.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | /// A static mock version of Canopy, appropriate for using in tests or other contexts
4 | /// where you need to isolate the CloudKit dependency and provide a
5 | /// deterministic view of CloudKit data with simulated mock data.
6 | ///
7 | /// You initialize MockCanopy with instances of mock CKContainer and CKDatabases.
8 | /// The Canopy API then receives API calls and plays back the responses to those
9 | /// requests, without any interaction with real CloudKit.
10 | ///
11 | /// You only need to inject the containers and databases that your tests actually use.
12 | /// If you try to use a dependency that’s not been injected correctly, MockCanopy crashes
13 | /// with an error message indicating that.
14 | ///
15 | /// MockCanopyWithCKMocks is mostly appropriate to use as a testing tool for Canopy’s
16 | /// own logic, or when you need to inject your own Canopy settings for various behaviors.
17 | /// For using in your own tests, `MockCanopy` is more appropriate and simpler to use.
18 | @available(iOS 16.4, macOS 13.3, *)
19 | public struct MockCanopyWithCKMocks: CanopyType {
20 | private let mockPrivateCKDatabase: CKDatabaseType?
21 | private let mockSharedCKDatabase: CKDatabaseType?
22 | private let mockPublicCKDatabase: CKDatabaseType?
23 | private let mockCKContainer: CKContainerType?
24 | private let settingsProvider: @Sendable () async -> CanopySettingsType
25 |
26 | public init(
27 | mockPrivateCKDatabase: CKDatabaseType? = nil,
28 | mockSharedCKDatabase: CKDatabaseType? = nil,
29 | mockPublicCKDatabase: CKDatabaseType? = nil,
30 | mockCKContainer: CKContainerType? = nil,
31 | settingsProvider: @escaping @Sendable () async -> CanopySettingsType = { CanopySettings() }
32 | ) {
33 | self.mockPublicCKDatabase = mockPublicCKDatabase
34 | self.mockSharedCKDatabase = mockSharedCKDatabase
35 | self.mockPrivateCKDatabase = mockPrivateCKDatabase
36 | self.mockCKContainer = mockCKContainer
37 | self.settingsProvider = settingsProvider
38 | }
39 |
40 | public func databaseAPI(usingDatabaseScope scope: CKDatabase.Scope) -> CKDatabaseAPIType {
41 | switch scope {
42 | case .public:
43 | guard let db = mockPublicCKDatabase else { fatalError("Requested public database which wasn’t correctly injected") }
44 | return CKDatabaseAPI(
45 | database: db,
46 | databaseScope: .public,
47 | settingsProvider: settingsProvider,
48 | tokenStore: TestTokenStore()
49 | )
50 | case .private:
51 | guard let db = mockPrivateCKDatabase else { fatalError("Requested private database which wasn’t correctly injected") }
52 | return CKDatabaseAPI(
53 | database: db,
54 | databaseScope: .private,
55 | settingsProvider: settingsProvider,
56 | tokenStore: TestTokenStore()
57 | )
58 | case .shared:
59 | guard let db = mockSharedCKDatabase else { fatalError("Requested shared database which wasn’t correctly injected") }
60 | return CKDatabaseAPI(
61 | database: db,
62 | databaseScope: .shared,
63 | settingsProvider: settingsProvider,
64 | tokenStore: TestTokenStore()
65 | )
66 | @unknown default:
67 | fatalError("Unknown CloudKit database scope")
68 | }
69 | }
70 |
71 | public func containerAPI() -> CKContainerAPIType {
72 | guard let container = mockCKContainer else { fatalError("Requested CKContainer which wasn’t correctly injected") }
73 | return CKContainerAPI(
74 | container: container,
75 | accountChangedSequence: .mock(elementsToProduce: 1)
76 | )
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/CanopyResultRecord/CanopyResultRecordType.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | public protocol CanopyRecordValueGetting {
4 | subscript(_ key: String) -> CKRecordValueProtocol? { get }
5 | }
6 |
7 | /// Access the values of ``CanopyResultRecord`` through this protocol.
8 | ///
9 | /// The API is equivalent to `CKRecord`, except that this is a read-only
10 | /// immutable view, without any setters.
11 | public protocol CanopyResultRecordType: CanopyRecordValueGetting {
12 | var encryptedValues: CanopyRecordValueGetting { get }
13 | var recordID: CKRecord.ID { get }
14 | var recordType: CKRecord.RecordType { get }
15 | var creationDate: Date? { get }
16 | var creatorUserRecordID: CKRecord.ID? { get }
17 | var modificationDate: Date? { get }
18 | var lastModifiedUserRecordID: CKRecord.ID? { get }
19 | var recordChangeTag: String? { get }
20 | var parent: CKRecord.Reference? { get }
21 | var share: CKRecord.Reference? { get }
22 | }
23 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/CanopyType.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | /// The main Canopy entry point, providing you access to the container and database APIs
5 | /// for performing real or simulated CloudKit requests.
6 | ///
7 | /// This is the most appropriate type to use throughout your application to refer to Canopy. You inject the real or mock objects
8 | /// conforming to this protocol into your features. Inside the features, you obtain the container or database interfaces
9 | /// with the API provided in this protocol, and use those obtained APIs to perform actual CloudKit requests.
10 | ///
11 | /// It’s best if you keep a reference to this top-level `Canopy` object in your app, and don’t keep a reference to the
12 | /// database and container APIs that it vends. Instead, just request the database and container API every time when
13 | /// you need to run API requests.
14 | ///
15 | /// For testability, you should build your features in a way where they interact with Canopy CloudKit APIs, without needing
16 | /// to know whether they are talking to a real or mock backend.
17 | public protocol CanopyType: Sendable {
18 |
19 | /// Get the API provider to run requests against a CloudKit container.
20 | func containerAPI() async -> CKContainerAPIType
21 |
22 | /// Get the API provider to run requests against a CloudKit database.
23 | func databaseAPI(usingDatabaseScope scope: CKDatabase.Scope) async -> CKDatabaseAPIType
24 | }
25 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Dependency/Canopy+Dependency.swift:
--------------------------------------------------------------------------------
1 | import Dependencies
2 |
3 | @available(iOS 16.4, macOS 13.3, *)
4 | private enum CanopyKey: DependencyKey, Sendable {
5 | static let liveValue: CanopyType = Canopy()
6 | }
7 |
8 | @available(iOS 16.4, macOS 13.3, *)
9 | public extension DependencyValues {
10 | /// Canopy packaged as CloudKit dependency via swift-dependencies.
11 | var cloudKit: CanopyType {
12 | get { self[CanopyKey.self] }
13 | set { self[CanopyKey.self] = newValue }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Errors/CKRecordError.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | // CKRecordError for CKRecords, more error types in the future for other CK types.
5 | // Reason is that the partial errors dictionary will be using different keys for different operations
6 | // In this type, the keys are expected to be CKRecord.ID,
7 |
8 | public struct CKRecordError: CKTransactionError, Codable, Equatable, Sendable {
9 | public let code: Int
10 | public let localizedDescription: String
11 | public let retryAfterSeconds: Double
12 | public let errorDump: String
13 | public let batchErrors: [String: CKRecordError]
14 |
15 | public var hasMultipleErrors: Bool {
16 | !batchErrors.isEmpty
17 | }
18 |
19 | public init(from error: Error) {
20 | self.errorDump = String(describing: error)
21 | self.localizedDescription = error.localizedDescription
22 |
23 | if let ckError = error as? CKError {
24 | self.code = ckError.errorCode
25 | self.retryAfterSeconds = ckError.retryAfterSeconds ?? 0
26 | self.batchErrors = CKRecordError.parseBatchErrors(dict: ckError.partialErrorsByItemID ?? [:])
27 | } else {
28 | // Probably no need for this, just being complete about it
29 | let nsError = error as NSError
30 | self.code = nsError.code
31 | self.retryAfterSeconds = (nsError.userInfo[CKErrorRetryAfterKey] as? NSNumber)?.doubleValue ?? 0
32 | self.batchErrors = CKRecordError.parseBatchErrors(dict: (nsError.userInfo[CKPartialErrorsByItemIDKey] as? [AnyHashable: Error]) ?? [:])
33 | }
34 | }
35 |
36 | private static func parseBatchErrors(dict: [AnyHashable: Error]) -> [String: CKRecordError] {
37 | dict.reduce(into: [:]) { partial, element in
38 | guard let recordID = element.key as? CKRecord.ID, let recordError = element.value as? CKError else {
39 | return
40 | }
41 |
42 | partial[recordID.recordName] = CKRecordError(from: recordError)
43 | }
44 | }
45 |
46 | public var ckError: CKError {
47 | isRetriable ?
48 | CKError(
49 | ckErrorCode!,
50 | userInfo: [
51 | CKErrorRetryAfterKey: retryAfterSeconds
52 | ]
53 | )
54 | : CKError(ckErrorCode!)
55 | }
56 |
57 | public static func == (lhs: CKRecordError, rhs: CKRecordError) -> Bool {
58 | lhs.code == rhs.code &&
59 | lhs.retryAfterSeconds == rhs.retryAfterSeconds &&
60 | lhs.batchErrors == rhs.batchErrors
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Errors/CKRecordZoneError.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | // CKRecordZoneError for CKRecordZones.
5 | // In this type, the keys are expected to be CKRecord.ID,
6 | public struct CKRecordZoneError: CKTransactionError, Codable, Equatable, Sendable {
7 | public let code: Int
8 | public let localizedDescription: String
9 | public let retryAfterSeconds: Double
10 | public let errorDump: String
11 | public let batchErrors: [String: CKRecordZoneError]
12 |
13 | public var hasMultipleErrors: Bool {
14 | !batchErrors.isEmpty
15 | }
16 |
17 | public init(from error: Error) {
18 | self.errorDump = String(describing: error)
19 | self.localizedDescription = error.localizedDescription
20 |
21 | if let ckError = error as? CKError {
22 | self.code = ckError.errorCode
23 | self.retryAfterSeconds = ckError.retryAfterSeconds ?? 0
24 | self.batchErrors = CKRecordZoneError.parseBatchErrors(dict: ckError.partialErrorsByItemID ?? [:])
25 | } else {
26 | // Probably no need for this, just being complete about it
27 | let nsError = error as NSError
28 | self.code = nsError.code
29 | self.retryAfterSeconds = (nsError.userInfo[CKErrorRetryAfterKey] as? NSNumber)?.doubleValue ?? 0
30 | self.batchErrors = CKRecordZoneError.parseBatchErrors(dict: (nsError.userInfo[CKPartialErrorsByItemIDKey] as? [AnyHashable: Error]) ?? [:])
31 | }
32 | }
33 |
34 | private static func parseBatchErrors(dict: [AnyHashable: Error]) -> [String: CKRecordZoneError] {
35 | dict.reduce(into: [:]) { partial, element in
36 | guard let zoneID = element.key as? CKRecordZone.ID, let zoneError = element.value as? CKError else {
37 | return
38 | }
39 |
40 | partial[zoneID.zoneName] = CKRecordZoneError(from: zoneError)
41 | }
42 | }
43 |
44 | public var ckError: CKError {
45 | isRetriable ?
46 | CKError(
47 | ckErrorCode!,
48 | userInfo: [
49 | CKErrorRetryAfterKey: retryAfterSeconds
50 | ]
51 | )
52 | : CKError(ckErrorCode!)
53 | }
54 |
55 | public static func == (lhs: CKRecordZoneError, rhs: CKRecordZoneError) -> Bool {
56 | lhs.code == rhs.code &&
57 | lhs.retryAfterSeconds == rhs.retryAfterSeconds &&
58 | lhs.batchErrors == rhs.batchErrors
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Errors/CKRequestError.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | // CKRequestError for general requests
5 |
6 | public struct CKRequestError: CKTransactionError, Codable, Equatable {
7 | public let code: Int
8 | public let localizedDescription: String
9 | public let retryAfterSeconds: Double
10 | public let errorDump: String
11 |
12 | public var hasMultipleErrors: Bool {
13 | false
14 | }
15 |
16 | public init(from error: Error) {
17 | self.errorDump = String(describing: error)
18 | self.localizedDescription = error.localizedDescription
19 |
20 | if let ckError = error as? CKError {
21 | self.code = ckError.errorCode
22 | self.retryAfterSeconds = ckError.retryAfterSeconds ?? 0
23 | } else {
24 | // Probably no need for this, just being complete about it
25 | let nsError = error as NSError
26 | self.code = nsError.code
27 | self.retryAfterSeconds = (nsError.userInfo[CKErrorRetryAfterKey] as? NSNumber)?.doubleValue ?? 0
28 | }
29 | }
30 |
31 | public var ckError: CKError {
32 | isRetriable ?
33 | CKError(
34 | ckErrorCode!,
35 | userInfo: [
36 | CKErrorRetryAfterKey: retryAfterSeconds
37 | ]
38 | )
39 | : CKError(ckErrorCode!)
40 | }
41 |
42 | public static func == (lhs: CKRequestError, rhs: CKRequestError) -> Bool {
43 | lhs.code == rhs.code &&
44 | lhs.retryAfterSeconds == rhs.retryAfterSeconds
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Errors/CKSubscriptionError.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | // CKSubscriptionError for CKSubscriptions.
5 | // In this type, the keys are expected to be CKSubscription.ID,
6 | public struct CKSubscriptionError: CKTransactionError, Codable, Equatable, Sendable {
7 | public let code: Int
8 | public let localizedDescription: String
9 | public let retryAfterSeconds: Double
10 | public let errorDump: String
11 | public let batchErrors: [String: CKSubscriptionError]
12 |
13 | public var hasMultipleErrors: Bool {
14 | !batchErrors.isEmpty
15 | }
16 |
17 | public init(from error: Error) {
18 | self.errorDump = String(describing: error)
19 | self.localizedDescription = error.localizedDescription
20 |
21 | if let ckError = error as? CKError {
22 | self.code = ckError.errorCode
23 | self.retryAfterSeconds = ckError.retryAfterSeconds ?? 0
24 | self.batchErrors = CKSubscriptionError.parseBatchErrors(dict: ckError.partialErrorsByItemID ?? [:])
25 | } else {
26 | // Probably no need for this, just being complete about it
27 | let nsError = error as NSError
28 | self.code = nsError.code
29 | self.retryAfterSeconds = (nsError.userInfo[CKErrorRetryAfterKey] as? NSNumber)?.doubleValue ?? 0
30 | self.batchErrors = CKSubscriptionError.parseBatchErrors(dict: (nsError.userInfo[CKPartialErrorsByItemIDKey] as? [AnyHashable: Error]) ?? [:])
31 | }
32 | }
33 |
34 | private static func parseBatchErrors(dict: [AnyHashable: Error]) -> [String: CKSubscriptionError] {
35 | dict.reduce(into: [:]) { partial, element in
36 | guard let subID = element.key as? CKSubscription.ID, let zoneError = element.value as? CKError else {
37 | return
38 | }
39 |
40 | partial[subID] = CKSubscriptionError(from: zoneError)
41 | }
42 | }
43 |
44 | public var ckError: CKError {
45 | isRetriable ?
46 | CKError(
47 | ckErrorCode!,
48 | userInfo: [
49 | CKErrorRetryAfterKey: retryAfterSeconds
50 | ]
51 | )
52 | : CKError(ckErrorCode!)
53 | }
54 |
55 | public static func == (lhs: CKSubscriptionError, rhs: CKSubscriptionError) -> Bool {
56 | lhs.code == rhs.code &&
57 | lhs.retryAfterSeconds == rhs.retryAfterSeconds
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Errors/CKTransactionError.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | public protocol CKTransactionError: Error {
5 | var code: Int { get }
6 | var localizedDescription: String { get }
7 | var retryAfterSeconds: Double { get }
8 | var errorDump: String { get }
9 | var hasMultipleErrors: Bool { get }
10 | }
11 |
12 | public extension CKTransactionError {
13 | var ckErrorCode: CKError.Code? {
14 | CKError.Code(rawValue: code)
15 | }
16 |
17 | var isRetriable: Bool {
18 | guard let error = ckErrorCode else { return false }
19 | return Self.retriableErrors.contains(error)
20 | }
21 |
22 | static var networkErrors: Set {
23 | [.networkUnavailable, .networkFailure, .serviceUnavailable, .serverResponseLost, .requestRateLimited, .zoneBusy]
24 | }
25 |
26 | static var batchErrors: Set {
27 | [.limitExceeded, .partialFailure, .batchRequestFailed]
28 | }
29 |
30 | static var retriableErrors: Set {
31 | networkErrors.union(batchErrors)
32 | }
33 |
34 | static var requestErrors: Set {
35 | [.invalidArguments, .changeTokenExpired, .serverRejectedRequest, .constraintViolation]
36 | }
37 |
38 | static var conflictErrors: Set {
39 | [.serverRecordChanged]
40 | }
41 |
42 | static var notFoundErrors: Set {
43 | [.unknownItem, .assetFileNotFound, .zoneNotFound, .referenceViolation, .assetNotAvailable, .userDeletedZone]
44 | }
45 |
46 | static var accountErrors: Set {
47 | [.quotaExceeded, .notAuthenticated, .participantMayNeedVerification, .managedAccountRestricted]
48 | }
49 |
50 | static var permissionErrors: Set {
51 | [.permissionFailure, .participantMayNeedVerification]
52 | }
53 |
54 | static var shareErrors: Set {
55 | [.tooManyParticipants, .alreadyShared, .participantMayNeedVerification]
56 | }
57 |
58 | static var nonRecoverableErrors: Set {
59 | [.internalError, .badContainer, .missingEntitlement, .incompatibleVersion, .badDatabase]
60 | }
61 | }
62 |
63 | /*
64 | CKError.Code.limitExceeded
65 | The server can change its limits at any time, but the following are general guidelines:
66 | 400 items (records or shares) per operation
67 | 2 MB per request (not counting asset sizes)
68 | If your app receives CKError.Code.limitExceeded, it must split the operation in half and try both requests again.
69 |
70 | CKError.Code.batchRequestFailed
71 | This error occurs when an operation attempts to save multiple items in a custom zone, but one of those items encounters an error. Because custom zones are atomic, the entire batch fails. The items that cause the problem have their own errors, and all other items in the batch have a CKError.Code.batchRequestFailed error to indicate that the system can’t save them.
72 | This error indicates that the system can’t process the associated item due to an error in another item in the operation. Check the other per-item errors under CKPartialErrorsByItemIDKey for any that aren't CKError.Code.batchRequestFailed errors. Handle those errors, and then retry all items in the operation.
73 |
74 | partialFailure
75 | Examine the specific item failures, and act on the failed items. Each specific item error is from the CloudKit error domain. You can inspect the userInfo CKPartialErrorsByItemIDKey to see per-item errors.
76 | Note that in a custom zone, the system processes all items in an operation atomically. As a result, you may get a CKError.Code.batchRequestFailed error for all other items in an operation that don't cause an error.
77 |
78 | internalError
79 | If you receive this error, file a bug report that includes the error log.
80 |
81 | networkUnavailable/networkFailure
82 | You can retry network failures immediately, but have your app implement a backoff period so that it doesn't attempt the same operation repeatedly.
83 |
84 | participantMayNeedVerification
85 | A fetch share metadata operation fails when the user isn’t a participant of the share. However, there are invited participants on the share with email addresses or phone numbers that don’t have associations with an iCloud account. The user may be able to join a share by associating one of those email addresses or phone numbers with the user's iCloud account.
86 | Call openURL(_:) on the share URL to have the user attempt to verify their information.
87 |
88 | requestRateLimited
89 | Check for a CKErrorRetryAfterKey key in the userInfo dictionary of any CloudKit error that you receive. It's especially important to check for it if you receive any of these errors. Use the value of the CKErrorRetryAfterKey key as the number of seconds to wait before retrying this operation.
90 |
91 | zoneBusy
92 | Try the operation again in a few seconds. If you encounter this error again, increase the delay time exponentially for each subsequent retry to minimize server contention for the zone.
93 | Check for a CKErrorRetryAfterKey key in the userInfo dictionary of any CloudKit error that you receive. Use the value of this key as the number of seconds to wait before retrying the operation.
94 | */
95 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Errors/CanopyError.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | public enum CanopyError: Error, Codable, Equatable, Sendable {
5 | case unknown
6 | case ckAccountError(String, Int) // description, code
7 | case ckSavedRecordsIsEmpty
8 | case ckDeletedRecordIDsIsEmpty
9 | case ckTimeout
10 | case ckRecordError(CKRecordError)
11 | case ckRecordZoneError(CKRecordZoneError)
12 | case ckSubscriptionError(CKSubscriptionError)
13 | case ckChangeTokenExpired
14 | case ckRequestError(CKRequestError) // description, code
15 | case canceled
16 |
17 | public var localizedDescription: String {
18 | switch self {
19 | case let .ckRecordError(error):
20 | return error.localizedDescription
21 | case let .ckRecordZoneError(error):
22 | return error.localizedDescription
23 | case let .ckSubscriptionError(error):
24 | return error.localizedDescription
25 | case let .ckRequestError(error):
26 | return error.localizedDescription
27 | case .unknown:
28 | return "Unknown error"
29 | case .ckSavedRecordsIsEmpty:
30 | return "Saved records is empty"
31 | case .ckDeletedRecordIDsIsEmpty:
32 | return "Deleted record IDs is empty"
33 | case .ckTimeout:
34 | return "Request has not completed in the expected time"
35 | case let .ckAccountError(reason, code):
36 | return "Error fetching account status: \(reason) (\(code))"
37 | case .ckChangeTokenExpired:
38 | return "Server change token expired"
39 | case .canceled:
40 | return "Request was canceled"
41 | }
42 | }
43 |
44 | public var code: Int {
45 | switch self {
46 | case let .ckRecordError(error):
47 | return error.code
48 | case let .ckRecordZoneError(error):
49 | return error.code
50 | case let .ckSubscriptionError(error):
51 | return error.code
52 | case let .ckRequestError(error):
53 | return error.code
54 | case .unknown:
55 | return 999
56 | case .ckSavedRecordsIsEmpty:
57 | return 1000
58 | case .ckDeletedRecordIDsIsEmpty:
59 | return 1001
60 | case .ckTimeout:
61 | return 1002
62 | case let .ckAccountError(_, code):
63 | return code
64 | case .ckChangeTokenExpired:
65 | return CKError.Code.changeTokenExpired.rawValue
66 | case .canceled:
67 | return 1003
68 | }
69 | }
70 |
71 | public var retryAfterSeconds: Double {
72 | switch self {
73 | case let .ckRecordError(error):
74 | return error.retryAfterSeconds
75 | case let .ckRecordZoneError(error):
76 | return error.retryAfterSeconds
77 | case let .ckSubscriptionError(error):
78 | return error.retryAfterSeconds
79 | case let .ckRequestError(error):
80 | return error.retryAfterSeconds
81 | default:
82 | return 0
83 | }
84 | }
85 |
86 | public var errorDump: String {
87 | switch self {
88 | case let .ckRecordError(error):
89 | return error.errorDump
90 | case let .ckRecordZoneError(error):
91 | return error.errorDump
92 | case let .ckSubscriptionError(error):
93 | return error.errorDump
94 | case let .ckRequestError(error):
95 | return error.errorDump
96 | default:
97 | return "SyncTransactionError \(code) \(localizedDescription)"
98 | }
99 | }
100 |
101 | public init(from error: Error) {
102 | if let requestError = error as? CKError {
103 | switch requestError.code {
104 | case .changeTokenExpired:
105 | self = .ckChangeTokenExpired
106 | default:
107 | self = .ckRequestError(CKRequestError(from: requestError))
108 | }
109 | } else {
110 | self = .ckRequestError(CKRequestError(from: error as NSError))
111 | }
112 | }
113 |
114 | public static func accountError(from error: Error) -> CanopyError {
115 | if let accountError = error as? CKError {
116 | return CanopyError.ckAccountError(accountError.localizedDescription, accountError.errorCode)
117 | } else {
118 | return CanopyError.ckAccountError(String(describing: error), 0)
119 | }
120 | }
121 |
122 | /// Recreate the CKError.
123 | ///
124 | /// This is likely lossy, resulting in loss of fidelity compared to the original CKError.
125 | /// What’s preserved is the code and description.
126 | ///
127 | /// The main use of this is in testing, to recreate the error from possibly archived CanopyError.
128 | public var ckError: CKError {
129 | switch self {
130 | case let .ckAccountError(description, code):
131 | return CKError(CKError.Code(rawValue: code)!, userInfo: ["localizedDescription": description])
132 | case let .ckRecordError(ckRecordError):
133 | return ckRecordError.ckError
134 | case let .ckRequestError(ckRequestError):
135 | return ckRequestError.ckError
136 | case .ckChangeTokenExpired:
137 | return CKError(CKError.Code.changeTokenExpired)
138 | case let .ckSubscriptionError(subscriptionError):
139 | return subscriptionError.ckError
140 | default:
141 | fatalError("Not implemented")
142 | }
143 | }
144 | }
145 |
146 | extension CanopyError: LocalizedError {
147 | public var errorDescription: String? {
148 | localizedDescription
149 | }
150 |
151 | public var recoverySuggestion: String? {
152 | "Check your network connection and iCloud account settings."
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Extensions/CKContainer.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | extension CKContainer: CKContainerType {}
4 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Extensions/CKDatabase.Scope.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | public extension CKDatabase.Scope {
4 | var asString: String {
5 | switch self {
6 | case .public:
7 | return "public"
8 | case .private:
9 | return "private"
10 | case .shared:
11 | return "shared"
12 | @unknown default:
13 | fatalError("Unknown database scope")
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Extensions/CKDatabase.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | extension CKDatabase: CKDatabaseType {}
4 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Extensions/CKRecord.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | extension CKRecord {
4 | public var canopyResultRecord: CanopyResultRecord {
5 | CanopyResultRecord(ckRecord: self)
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Extensions/CKRecordZoneID.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | public extension CKRecordZone.ID {
4 | /// Determine a database scope for a record zone.
5 | ///
6 | /// This can be derived from the zone owner (who by definition also created the zone).
7 | /// All zones in private database are created by current user. Zones created by other users
8 | /// are in the shared zone.
9 | var ckDatabaseScope: CKDatabase.Scope {
10 | (ownerName == CKCurrentUserDefaultName) ? .private : .shared
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Features/QueryRecords.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | /// Query records from CloudKit, automatically handling multiple pages and cursors.
5 | ///
6 | /// This is a light wrapper around CKQueryOperation, providing a simple async interface
7 | /// and automatically handling paging and cursors, returning one complete set of records in the end.
8 | ///
9 | /// To cancel any follow-up requests, cancel the enclosing task.
10 | struct QueryRecords {
11 | enum QueryOperationStartingPoint {
12 | case query(CKQuery)
13 | case cursor(CKQueryOperation.Cursor)
14 | }
15 |
16 | enum QueryOperationResult {
17 | /// We got records, and there are no more records remaining to get, this is the final page.
18 | case records([CKRecord])
19 |
20 | /// We got records, and there are more to be obtained.
21 | case recordsAndCursor([CKRecord], CKQueryOperation.Cursor)
22 |
23 | /// There was an error getting the records.
24 | case error(CKRecordError)
25 | }
26 |
27 | public static func with(
28 | _ query: CKQuery,
29 | recordZoneID: CKRecordZone.ID?,
30 | database: CKDatabaseType,
31 | desiredKeys: [CKRecord.FieldKey]? = nil,
32 | resultsLimit: Int? = nil,
33 | qualityOfService: QualityOfService = .default
34 | ) async -> Result<[CanopyResultRecord], CKRecordError> {
35 | var startingPoint = QueryOperationStartingPoint.query(query)
36 | var records: [CKRecord] = []
37 |
38 | while true {
39 | let queryOperationResult = await performOneOperation(
40 | with: startingPoint,
41 | recordZoneID: recordZoneID,
42 | database: database,
43 | desiredKeys: desiredKeys,
44 | resultsLimit: resultsLimit,
45 | qualityOfService: qualityOfService
46 | )
47 |
48 | switch queryOperationResult {
49 | case let .error(error):
50 | return .failure(error)
51 | case let .records(newRecords):
52 | let ckRecords = records + newRecords
53 | return .success(ckRecords.map(\.canopyResultRecord))
54 | case let .recordsAndCursor(newRecords, cursor):
55 | guard !Task.isCancelled else {
56 | return .failure(.init(from: CKError(CKError.Code.operationCancelled)))
57 | }
58 | // If there was a results limit, just return the result even if there was a cursor
59 | if resultsLimit != nil {
60 | let ckRecords = records + newRecords
61 | return .success(ckRecords.map(\.canopyResultRecord))
62 | }
63 |
64 | startingPoint = QueryOperationStartingPoint.cursor(cursor)
65 | records += newRecords
66 | }
67 | }
68 | }
69 |
70 | private static func performOneOperation(
71 | with startingPoint: QueryOperationStartingPoint,
72 | recordZoneID: CKRecordZone.ID?,
73 | database: CKDatabaseType,
74 | desiredKeys: [CKRecord.FieldKey]? = nil,
75 | resultsLimit: Int? = nil,
76 | qualityOfService: QualityOfService = .userInitiated
77 | ) async -> QueryOperationResult {
78 | await withCheckedContinuation { continuation in
79 | var records: [CKRecord] = []
80 | var recordError: CKRecordError?
81 |
82 | let operation: CKQueryOperation
83 | switch startingPoint {
84 | case let .cursor(cursor):
85 | operation = CKQueryOperation(cursor: cursor)
86 | case let .query(query):
87 | operation = CKQueryOperation(query: query)
88 | }
89 |
90 | operation.zoneID = recordZoneID
91 | operation.desiredKeys = desiredKeys
92 | operation.qualityOfService = qualityOfService
93 |
94 | if let resultsLimit {
95 | operation.resultsLimit = resultsLimit
96 | }
97 |
98 | operation.recordMatchedBlock = { _, result in
99 | switch result {
100 | case let .failure(error):
101 | recordError = .init(from: error)
102 | case let .success(record):
103 | records.append(record)
104 | }
105 | }
106 |
107 | operation.queryResultBlock = { result in
108 | switch result {
109 | case let .success(cursor):
110 | // Be defensive: if there was a record error, fail the whole request with that.
111 | if let recordError {
112 | continuation.resume(returning: .error(recordError))
113 | return
114 | }
115 | if let cursor {
116 | continuation.resume(returning: .recordsAndCursor(records, cursor))
117 | } else {
118 | continuation.resume(returning: .records(records))
119 | }
120 | case let .failure(error):
121 | continuation.resume(returning: .error(.init(from: error)))
122 | }
123 | }
124 |
125 | database.add(operation)
126 | }
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Results/DeletedCKRecord.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | public struct DeletedCKRecord: Codable, Equatable, Sendable {
4 | private let typeString: String
5 | private let recordName: String
6 | private let zoneName: String
7 | private let zoneOwnerName: String
8 |
9 | public var recordID: CKRecord.ID {
10 | CKRecord.ID(recordName: recordName, zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: zoneOwnerName))
11 | }
12 |
13 | public var recordType: CKRecord.RecordType {
14 | typeString as CKRecord.RecordType
15 | }
16 |
17 | public init(recordID: CKRecord.ID, recordType: CKRecord.RecordType) {
18 | self.typeString = recordType
19 | self.recordName = recordID.recordName
20 | self.zoneName = recordID.zoneID.zoneName
21 | self.zoneOwnerName = recordID.zoneID.ownerName
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Results/FetchDatabaseChangesResult.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | public struct FetchDatabaseChangesResult: Equatable, Sendable {
5 | public let changedRecordZoneIDs: [CKRecordZone.ID]
6 | public let deletedRecordZoneIDs: [CKRecordZone.ID]
7 | public let purgedRecordZoneIDs: [CKRecordZone.ID]
8 |
9 | public static var empty: FetchDatabaseChangesResult {
10 | FetchDatabaseChangesResult(
11 | changedRecordZoneIDs: [],
12 | deletedRecordZoneIDs: [],
13 | purgedRecordZoneIDs: []
14 | )
15 | }
16 |
17 | public init(
18 | changedRecordZoneIDs: [CKRecordZone.ID],
19 | deletedRecordZoneIDs: [CKRecordZone.ID],
20 | purgedRecordZoneIDs: [CKRecordZone.ID]
21 | ) {
22 | self.changedRecordZoneIDs = changedRecordZoneIDs
23 | self.deletedRecordZoneIDs = deletedRecordZoneIDs
24 | self.purgedRecordZoneIDs = purgedRecordZoneIDs
25 | }
26 | }
27 |
28 | extension FetchDatabaseChangesResult: Codable {
29 | enum CodingKeys: CodingKey {
30 | case changedRecordZoneIDs
31 | case deletedRecordZoneIDs
32 | case purgedRecordZoneIDs
33 | }
34 |
35 | public init(from decoder: any Decoder) throws {
36 | let container = try decoder.container(keyedBy: CodingKeys.self)
37 |
38 | let changedRecordZoneIDsData = try container.decode(Data.self, forKey: .changedRecordZoneIDs)
39 | if let changedRecordZoneIDs = try? NSKeyedUnarchiver.unarchivedArrayOfObjects(ofClass: CKRecordZone.ID.self, from: changedRecordZoneIDsData) {
40 | self.changedRecordZoneIDs = changedRecordZoneIDs
41 | } else {
42 | throw DecodingError.dataCorrupted(
43 | DecodingError.Context(
44 | codingPath: [CodingKeys.changedRecordZoneIDs],
45 | debugDescription: "Invalid changed record zone IDs value in source data"
46 | )
47 | )
48 | }
49 |
50 | let deletedRecordZoneIDsData = try container.decode(Data.self, forKey: .deletedRecordZoneIDs)
51 | if let deletedRecordZoneIDs = try? NSKeyedUnarchiver.unarchivedArrayOfObjects(ofClass: CKRecordZone.ID.self, from: deletedRecordZoneIDsData) {
52 | self.deletedRecordZoneIDs = deletedRecordZoneIDs
53 | } else {
54 | throw DecodingError.dataCorrupted(
55 | DecodingError.Context(
56 | codingPath: [CodingKeys.deletedRecordZoneIDs],
57 | debugDescription: "Invalid deleted record zone IDs value in source data"
58 | )
59 | )
60 | }
61 |
62 | let purgedRecordZoneIDsData = try container.decode(Data.self, forKey: .purgedRecordZoneIDs)
63 | if let purgedRecordZoneIDs = try? NSKeyedUnarchiver.unarchivedArrayOfObjects(ofClass: CKRecordZone.ID.self, from: purgedRecordZoneIDsData) {
64 | self.purgedRecordZoneIDs = purgedRecordZoneIDs
65 | } else {
66 | throw DecodingError.dataCorrupted(
67 | DecodingError.Context(
68 | codingPath: [CodingKeys.purgedRecordZoneIDs],
69 | debugDescription: "Invalid purged record zone IDs value in source data"
70 | )
71 | )
72 | }
73 | }
74 |
75 | public func encode(to encoder: any Encoder) throws {
76 | var container = encoder.container(keyedBy: CodingKeys.self)
77 | let changedRecordZoneIDsData = try NSKeyedArchiver.archivedData(withRootObject: changedRecordZoneIDs, requiringSecureCoding: true)
78 | try container.encode(changedRecordZoneIDsData, forKey: .changedRecordZoneIDs)
79 | let deletedRecordZoneIDsData = try NSKeyedArchiver.archivedData(withRootObject: deletedRecordZoneIDs, requiringSecureCoding: true)
80 | try container.encode(deletedRecordZoneIDsData, forKey: .deletedRecordZoneIDs)
81 | let purgedRecordZoneIDsData = try NSKeyedArchiver.archivedData(withRootObject: purgedRecordZoneIDs, requiringSecureCoding: true)
82 | try container.encode(purgedRecordZoneIDsData, forKey: .purgedRecordZoneIDs)
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Results/FetchRecordsResult.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | /// Successful result for a function call to fetch records.
4 | public struct FetchRecordsResult: Equatable, Sendable {
5 | /// Records that were found.
6 | public let foundRecords: [CanopyResultRecord]
7 |
8 | /// Records that were not found based on the ID, but the operation was otherwise successful.
9 | public let notFoundRecordIDs: [CKRecord.ID]
10 |
11 | public init(foundRecords: [CanopyResultRecord] = [], notFoundRecordIDs: [CKRecord.ID] = []) {
12 | self.foundRecords = foundRecords
13 | self.notFoundRecordIDs = notFoundRecordIDs
14 | }
15 | }
16 |
17 | extension FetchRecordsResult: Codable {
18 | enum CodingKeys: CodingKey {
19 | case foundRecords
20 | case notFoundRecordIDs
21 | }
22 |
23 | public init(from decoder: any Decoder) throws {
24 | let container = try decoder.container(keyedBy: CodingKeys.self)
25 | foundRecords = try container.decode([CanopyResultRecord].self, forKey: .foundRecords)
26 | let notFoundRecordIDsData = try container.decode(Data.self, forKey: .notFoundRecordIDs)
27 | if let notFoundRecordIDs = try? NSKeyedUnarchiver.unarchivedArrayOfObjects(ofClass: CKRecord.ID.self, from: notFoundRecordIDsData) {
28 | self.notFoundRecordIDs = notFoundRecordIDs
29 | } else {
30 | throw DecodingError.dataCorrupted(
31 | DecodingError.Context(
32 | codingPath: [CodingKeys.notFoundRecordIDs],
33 | debugDescription: "Invalid not found record IDs value in source data"
34 | )
35 | )
36 | }
37 | }
38 |
39 | public func encode(to encoder: any Encoder) throws {
40 | var container = encoder.container(keyedBy: CodingKeys.self)
41 | try container.encode(foundRecords, forKey: .foundRecords)
42 | let notFoundRecordIDsData = try NSKeyedArchiver.archivedData(withRootObject: notFoundRecordIDs, requiringSecureCoding: true)
43 | try container.encode(notFoundRecordIDsData, forKey: .notFoundRecordIDs)
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Results/FetchZoneChangesMethod.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | /// Indicates what kind of changes you want to fetch for a given record zone.
5 | ///
6 | /// You always get change tokens when fetching changes. In addition,
7 | /// you can choose to fetch specific or all record key fields.
8 | /// Limiting record key fields may reduce the download size if you are
9 | /// interested only in the tokens, or only some specific fields (but e.g
10 | /// not asset fields that may contain large files).
11 | public enum FetchZoneChangesMethod: Sendable {
12 | /// Fetch tokens and all available data.
13 | case changeTokenAndAllData
14 |
15 | /// Only fetch specific record fields.
16 | case changeTokenAndSpecificKeys([CKRecord.FieldKey])
17 |
18 | /// Don’t fetch any record data: only fetch the change token.
19 | ///
20 | /// This is useful to “catch up” with the current state of the zone, when you
21 | /// are not interested in all historic changes.
22 | ///
23 | /// It would be nice to have this as an API on CloudKit record zones, to fetch
24 | /// the current change token. Doing a “playback” of all history to get
25 | /// just the current token is an inefficient hack/workaround. See last chapter
26 | /// of
27 | /// for a longer discussion.
28 | case changeTokenOnly
29 |
30 | public var desiredKeys: [CKRecord.FieldKey]? {
31 | switch self {
32 | case .changeTokenAndAllData:
33 | return nil
34 | case let .changeTokenAndSpecificKeys(keys):
35 | return keys
36 | case .changeTokenOnly:
37 | return []
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Results/FetchZoneChangesResult.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | public struct FetchZoneChangesResult: Sendable, Codable {
5 | public let changedRecords: [CanopyResultRecord]
6 | public let deletedRecords: [DeletedCKRecord]
7 |
8 | public init(records: [CanopyResultRecord], deletedRecords: [DeletedCKRecord]) {
9 | self.changedRecords = records
10 | self.deletedRecords = deletedRecords
11 | }
12 |
13 | public static var empty: FetchZoneChangesResult {
14 | FetchZoneChangesResult(records: [], deletedRecords: [])
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Results/ModifyRecordsResult.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | /// Successful result of record modification and deletion functions, containing details about saved and deleted records.
4 | public struct ModifyRecordsResult: Equatable, Sendable {
5 | /// An array of saved records. The records likely have different metadata from the records that you gave to the modification function
6 | /// as input, because CloudKit updates the record modification timestamp and change tag on the server side when saving records.
7 | public let savedRecords: [CanopyResultRecord]
8 |
9 | /// Array of deleted record ID-s. This matches the array record ID-s that you gave to the function as input.
10 | public let deletedRecordIDs: [CKRecord.ID]
11 |
12 | public init(
13 | savedRecords: [CanopyResultRecord] = [],
14 | deletedRecordIDs: [CKRecord.ID] = []
15 | ) {
16 | self.savedRecords = savedRecords
17 | self.deletedRecordIDs = deletedRecordIDs
18 | }
19 | }
20 |
21 | extension ModifyRecordsResult: Codable {
22 | enum CodingKeys: CodingKey {
23 | case savedRecords
24 | case deletedRecordIDs
25 | }
26 |
27 | public init(from decoder: any Decoder) throws {
28 | let container = try decoder.container(keyedBy: CodingKeys.self)
29 | savedRecords = try container.decode([CanopyResultRecord].self, forKey: .savedRecords)
30 | let deletedRecordIDsData = try container.decode(Data.self, forKey: .deletedRecordIDs)
31 | if let deletedRecordIDs = try? NSKeyedUnarchiver.unarchivedArrayOfObjects(ofClass: CKRecord.ID.self, from: deletedRecordIDsData) {
32 | self.deletedRecordIDs = deletedRecordIDs
33 | } else {
34 | throw DecodingError.dataCorrupted(
35 | DecodingError.Context(
36 | codingPath: [CodingKeys.deletedRecordIDs],
37 | debugDescription: "Invalid deleted record IDs value in source data"
38 | )
39 | )
40 | }
41 | }
42 |
43 | public func encode(to encoder: any Encoder) throws {
44 | var container = encoder.container(keyedBy: CodingKeys.self)
45 | try container.encode(savedRecords, forKey: .savedRecords)
46 | let deletedRecordIDsData = try NSKeyedArchiver.archivedData(withRootObject: deletedRecordIDs, requiringSecureCoding: true)
47 | try container.encode(deletedRecordIDsData, forKey: .deletedRecordIDs)
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Results/ModifySubscriptionsResult.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | public struct ModifySubscriptionsResult: Equatable, Sendable {
4 | public let savedSubscriptions: [CKSubscription]
5 | public let deletedSubscriptionIDs: [CKSubscription.ID]
6 |
7 | public init(savedSubscriptions: [CKSubscription], deletedSubscriptionIDs: [CKSubscription.ID]) {
8 | self.savedSubscriptions = savedSubscriptions
9 | self.deletedSubscriptionIDs = deletedSubscriptionIDs
10 | }
11 | }
12 |
13 | extension ModifySubscriptionsResult: Codable {
14 | enum CodingKeys: CodingKey {
15 | case savedSubscriptions
16 | case deletedSubscriptionIDs
17 | }
18 |
19 | public init(from decoder: any Decoder) throws {
20 | let container = try decoder.container(keyedBy: CodingKeys.self)
21 | let savedSubscriptionsData = try container.decode(Data.self, forKey: .savedSubscriptions)
22 | if let savedSubscriptions = try? NSKeyedUnarchiver.unarchivedArrayOfObjects(ofClass: CKSubscription.self, from: savedSubscriptionsData) {
23 | self.savedSubscriptions = savedSubscriptions
24 | } else {
25 | throw DecodingError.dataCorrupted(
26 | DecodingError.Context(
27 | codingPath: [CodingKeys.savedSubscriptions],
28 | debugDescription: "Invalid saved subscriptions value in source data"
29 | )
30 | )
31 | }
32 | deletedSubscriptionIDs = try container.decode([CKSubscription.ID].self, forKey: .deletedSubscriptionIDs)
33 | }
34 |
35 | public func encode(to encoder: any Encoder) throws {
36 | var container = encoder.container(keyedBy: CodingKeys.self)
37 | try container.encode(deletedSubscriptionIDs, forKey: .deletedSubscriptionIDs)
38 | let savedSubscriptionsData = try NSKeyedArchiver.archivedData(withRootObject: savedSubscriptions, requiringSecureCoding: true)
39 | try container.encode(savedSubscriptionsData, forKey: .savedSubscriptions)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Results/ModifyZonesResult.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | public struct ModifyZonesResult: Equatable, Sendable {
5 | public let savedZones: [CKRecordZone]
6 | public let deletedZoneIDs: [CKRecordZone.ID]
7 |
8 | public init(savedZones: [CKRecordZone], deletedZoneIDs: [CKRecordZone.ID]) {
9 | self.savedZones = savedZones
10 | self.deletedZoneIDs = deletedZoneIDs
11 | }
12 | }
13 |
14 | extension ModifyZonesResult: Codable {
15 | enum CodingKeys: CodingKey {
16 | case savedZones
17 | case deletedZoneIDs
18 | }
19 |
20 | public init(from decoder: any Decoder) throws {
21 | let container = try decoder.container(keyedBy: CodingKeys.self)
22 |
23 | let savedZonesData = try container.decode(Data.self, forKey: .savedZones)
24 | if let savedZones = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: [CKRecordZone.self, NSArray.self], from: savedZonesData) as? [CKRecordZone] {
25 | self.savedZones = savedZones
26 | } else {
27 | throw DecodingError.dataCorrupted(
28 | DecodingError.Context(
29 | codingPath: [CodingKeys.savedZones],
30 | debugDescription: "Invalid saved zones value in source data"
31 | )
32 | )
33 | }
34 |
35 | let deletedZoneIDsData = try container.decode(Data.self, forKey: .deletedZoneIDs)
36 | if let deletedZoneIDs = try? NSKeyedUnarchiver.unarchivedArrayOfObjects(ofClass: CKRecordZone.ID.self, from: deletedZoneIDsData) {
37 | self.deletedZoneIDs = deletedZoneIDs
38 | } else {
39 | throw DecodingError.dataCorrupted(
40 | DecodingError.Context(
41 | codingPath: [CodingKeys.deletedZoneIDs],
42 | debugDescription: "Invalid deleted record zone IDs value in source data"
43 | )
44 | )
45 | }
46 | }
47 |
48 | public func encode(to encoder: any Encoder) throws {
49 | var container = encoder.container(keyedBy: CodingKeys.self)
50 | let savedZonesData = try NSKeyedArchiver.archivedData(withRootObject: savedZones, requiringSecureCoding: true)
51 | try container.encode(savedZonesData, forKey: .savedZones)
52 | let deletedZoneIDsData = try NSKeyedArchiver.archivedData(withRootObject: deletedZoneIDs, requiringSecureCoding: true)
53 | try container.encode(deletedZoneIDsData, forKey: .deletedZoneIDs)
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Settings/CanopySettings.swift:
--------------------------------------------------------------------------------
1 | /// Default Canopy settings.
2 | ///
3 | /// These are reasonable to use as the starting point in most applications.
4 | public struct CanopySettings: CanopySettingsType {
5 | public var modifyRecordsBehavior: RequestBehavior
6 | public var fetchDatabaseChangesBehavior: RequestBehavior
7 | public var fetchZoneChangesBehavior: RequestBehavior
8 | public var autoBatchTooLargeModifyOperations: Bool
9 | public var autoRetryForRetriableErrors: Bool
10 |
11 | public init(
12 | modifyRecordsBehavior: RequestBehavior = .regular(nil),
13 | fetchDatabaseChangesBehavior: RequestBehavior = .regular(nil),
14 | fetchZoneChangesBehavior: RequestBehavior = .regular(nil),
15 | autoBatchTooLargeModifyOperations: Bool = true,
16 | autoRetryForRetriableErrors: Bool = true
17 | ) {
18 | self.modifyRecordsBehavior = modifyRecordsBehavior
19 | self.fetchDatabaseChangesBehavior = fetchDatabaseChangesBehavior
20 | self.fetchZoneChangesBehavior = fetchZoneChangesBehavior
21 | self.autoBatchTooLargeModifyOperations = autoBatchTooLargeModifyOperations
22 | self.autoRetryForRetriableErrors = autoRetryForRetriableErrors
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/Settings/CanopySettingsType.swift:
--------------------------------------------------------------------------------
1 | /// Behavior for Canopy CloudKit requests, controlled by the caller.
2 | ///
3 | /// You can affect the behavior of Canopy requests from both caller and server side.
4 | /// On the server side, you can initialize Canopy with mock replaying databases and
5 | /// containers for controlled test results.
6 | ///
7 | /// On the caller side, you can inform Canopy to simulate failures of the requests,
8 | /// regardless of whether it works against a real or simulated backends. The use for this
9 | /// is to let you test failed requests in a real client environment. You could have a
10 | /// “developer switch” somewhere in your app to simulate errors, to see how your app
11 | /// responds to errors in a real build.
12 | public enum RequestBehavior: Equatable, Sendable {
13 | /// Regular behavior. Attempt to run the request against the backend. If the optional
14 | /// associated value is present, the request is delayed for the given number of seconds,
15 | /// somewhat simulating slow network conditions and letting you see how your UI
16 | /// behaves if the request takes time.
17 | case regular(Double?)
18 |
19 | /// Return a simulated failure without touching the backend. If the optional associated
20 | /// value is present, the request is delayed for the given number of seconds, before
21 | /// returning a failure.
22 | case simulatedFail(Double?)
23 |
24 | /// Same as simulated fail, but also simulate partial errors for the requests where it is applicable.
25 | case simulatedFailWithPartialErrors(Double?)
26 | }
27 |
28 | /// Canopy settings that modify the behavior from the caller (request) side.
29 | ///
30 | /// By default, Canopy uses reasonable defaults for all these settings. If you would like
31 | /// to modify Canopy behavior, you can construct Canopy with a `CanopySettings` struct
32 | /// which has some of the values modified, or pass any custom value that implements this protocol.
33 | public protocol CanopySettingsType: Sendable {
34 | /// Behavior for “modify records” request.
35 | ///
36 | /// Applies to both saving and deleting records.
37 | var modifyRecordsBehavior: RequestBehavior { get async }
38 |
39 | /// Behavior for “fetch database changes” request.
40 | var fetchDatabaseChangesBehavior: RequestBehavior { get async }
41 |
42 | /// Behavior for “fetch zone changes” request.
43 | var fetchZoneChangesBehavior: RequestBehavior { get async }
44 |
45 | /// Resend a modification request if the initial batch is too large.
46 | ///
47 | /// If a modification operation is too large, CloudKit responds with a
48 | /// `limitExceeded` error. The caller should then re-send its request
49 | /// into smaller batches.
50 | ///
51 | /// Canopy does this by default. If you wish, you can turn this off.
52 | /// You will then get `limitExceeded` error returned.
53 | var autoBatchTooLargeModifyOperations: Bool { get async }
54 |
55 | /// Retry failed operations that are retriable.
56 | ///
57 | /// Some CloudKit operations can fail, but CloudKit indicates that you can retry them.
58 | /// For example, modification operations can fail because there is no network,
59 | /// or a zone is busy (multiple writes to a record zone are happening on the cloud side).
60 | ///
61 | /// CloudKit indicates such situations with an error code, and also indicates
62 | /// a delay after which it advises to try again.
63 | ///
64 | /// If this is true (the default value), Canopy tries a retriable operation up to
65 | /// 3 times before giving up and returning an error.
66 | ///
67 | /// Currently this is implemented in Canopy only for modifying records.
68 | /// All other requests fail immediately without retrying if there is an error.
69 | var autoRetryForRetriableErrors: Bool { get async }
70 | }
71 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/TokenStore/TestTokenStore.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | /// A token store that does not actually store any tokens, but counts how often its methods are called.
4 | ///
5 | /// This store is appropriate to use in tests and previews, where you do not need any real interaction with the tokens.
6 | ///
7 | /// The function call counts are used by Canopy test suite. You can also use them in your own tests, to make sure
8 | /// that the tokens are actually stored and requested as you expect.
9 | public actor TestTokenStore: TokenStoreType {
10 | public init() {}
11 |
12 | /// How many times "storeToken:forDatabaseScope:" has been called.
13 | private(set) var storeTokenForDatabaseScopeCalls = 0
14 |
15 | /// How many times "tokenForDatabaseScope" has been called.
16 | private(set) var getTokenForDatabaseScopeCalls = 0
17 |
18 | private(set) var storeTokenForRecordZoneCalls = 0
19 | private(set) var getTokenForRecordZoneCalls = 0
20 |
21 | public func storeToken(_ token: CKServerChangeToken?, forDatabaseScope scope: CKDatabase.Scope) {
22 | storeTokenForDatabaseScopeCalls += 1
23 | }
24 |
25 | public func tokenForDatabaseScope(_ scope: CKDatabase.Scope) -> CKServerChangeToken? {
26 | getTokenForDatabaseScopeCalls += 1
27 | return nil
28 | }
29 |
30 | public func storeToken(_ token: CKServerChangeToken?, forRecordZoneID zoneID: CKRecordZone.ID) {
31 | storeTokenForRecordZoneCalls += 1
32 | }
33 |
34 | public func tokenForRecordZoneID(_ zoneID: CKRecordZone.ID) -> CKServerChangeToken? {
35 | getTokenForRecordZoneCalls += 1
36 | return nil
37 | }
38 |
39 | public func clear() async {}
40 | }
41 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/TokenStore/TokenStoreType.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | /// TokenStore provides a local storage interface to CloudKit database and record zone tokens.
4 | ///
5 | /// When you fetch changes to a CloudKit database or record zone, you provide a previous “change token”
6 | /// to indicate a point in time from which onwards you want to receive the changes. CloudKit then fetches
7 | /// you the changes from the time point identified by the token until “now”, and provides you a new token
8 | /// for “now”. You store the token and provide it to CloudKit in future requests.
9 | ///
10 | /// If you do not provide a change token, CloudKit gives you the changes “from the beginning of time”,
11 | /// which may be large and take a while in case of a bigger data set. If you use the “fetch changes” APIs,
12 | /// it is a good idea to use these tokens.
13 | ///
14 | /// TokenStore provides a storage interface to these tokens. Canopy provides a UserDefaults-based store
15 | /// which is good enough for many applications, as well as a test store that does not persist anything.
16 | ///
17 | /// TokenStore and Canopy currently assume that the application works with only one CKContainer.
18 | /// There is currently no facility to distinguish between multiple CKContainers. This is a good enough assumption
19 | /// for most CloudKit applications.
20 | public protocol TokenStoreType: Sendable {
21 | /// Store a token for the given database scope.
22 | ///
23 | /// - Parameter token: token to be stored. May be nil if it needs to be removed from storage for whatever reason.
24 | /// If receiving a nil token, the store should remove the known token for this scope.
25 | /// - Parameter scope: the CKDatabase scope you are storing the token for.
26 | func storeToken(_ token: CKServerChangeToken?, forDatabaseScope scope: CKDatabase.Scope) async
27 |
28 | /// Return the token for the requested scope, if there is one.
29 | func tokenForDatabaseScope(_ scope: CKDatabase.Scope) async -> CKServerChangeToken?
30 |
31 | /// Store a token for the given record zone ID.
32 | ///
33 | /// - Parameter token: token to be stored. May be nil if it needs to be removed from storage for whatever reason.
34 | /// If receiving a nil token, the store should remove the known token for this record zone ID.
35 | /// - Parameter zoneID: the CloudKit record zone ID that you are storing the token for.
36 | func storeToken(_ token: CKServerChangeToken?, forRecordZoneID zoneID: CKRecordZone.ID) async
37 |
38 | /// Return the token for the requested record zone ID, if there is one.
39 | func tokenForRecordZoneID(_ zoneID: CKRecordZone.ID) async -> CKServerChangeToken?
40 |
41 | /// Clear the content of the token store and go back to the initial state.
42 | ///
43 | /// Canopy never calls this, since it does not manage the client side state and does not know when is the right time to call it.
44 | /// You should call this yourself from your own app when you need to reset your client to a state where it doesn’t have any tokens stored
45 | /// and should start over from a fresh state.
46 | func clear() async
47 | }
48 |
--------------------------------------------------------------------------------
/Targets/Canopy/Sources/TokenStore/UserDefaultsTokenStore.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import os.log
3 |
4 | /// A simple token store that stores the zone and database tokens in `UserDefaults` on the user’s device.
5 | ///
6 | /// This is a good enough way to store the zone tokens for many applications. It is especially handy to use
7 | /// on macOS during development, because the `defaults` command-line utility and many other tools
8 | /// provide you easy access to the stored tokens in your system. You can verify that the tokens do get stored,
9 | /// and clear them manually if needed.
10 | public actor UserDefaultsTokenStore: TokenStoreType {
11 | private let logger = Logger(subsystem: "Canopy", category: "UserDefaultsTokenStore")
12 |
13 | public init() {}
14 |
15 | public func storeToken(_ token: CKServerChangeToken?, forDatabaseScope scope: CKDatabase.Scope) {
16 | guard let token else {
17 | UserDefaults.standard.removeObject(forKey: defaultsKeyForDatabaseScope(scope))
18 | return
19 | }
20 | do {
21 | let tokenData = try NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: false)
22 | UserDefaults.standard.set(tokenData, forKey: defaultsKeyForDatabaseScope(scope))
23 | } catch {
24 | logger.error("Error encoding CloudKit database change token for storage: \(error)")
25 | }
26 | }
27 |
28 | public func tokenForDatabaseScope(_ scope: CKDatabase.Scope) -> CKServerChangeToken? {
29 | if let tokenData = UserDefaults.standard.data(forKey: defaultsKeyForDatabaseScope(scope)),
30 | let token = try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: tokenData)
31 | {
32 | return token
33 | }
34 | return nil
35 | }
36 |
37 | public func storeToken(_ token: CKServerChangeToken?, forRecordZoneID zoneID: CKRecordZone.ID) {
38 | guard let token else {
39 | UserDefaults.standard.removeObject(forKey: defaultsKeyForZoneID(zoneID))
40 | return
41 | }
42 | do {
43 | let tokenData = try NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: false)
44 | UserDefaults.standard.set(tokenData, forKey: defaultsKeyForZoneID(zoneID))
45 | } catch {
46 | logger.error("Error encoding CloudKit record zone change token for storage: \(error)")
47 | }
48 | }
49 |
50 | public func tokenForRecordZoneID(_ zoneID: CKRecordZone.ID) -> CKServerChangeToken? {
51 | if let tokenData = UserDefaults.standard.data(forKey: defaultsKeyForZoneID(zoneID)),
52 | let token = try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: tokenData)
53 | {
54 | return token
55 | }
56 | return nil
57 | }
58 |
59 | public func clear() async {
60 | let scopes: [CKDatabase.Scope] = [.private, .public, .shared]
61 | for scope in scopes {
62 | UserDefaults.standard.removeObject(forKey: defaultsKeyForDatabaseScope(scope))
63 | }
64 |
65 | for key in UserDefaults.standard.dictionaryRepresentation().keys {
66 | if key.hasPrefix("recordZoneServerChangeToken:") {
67 | UserDefaults.standard.removeObject(forKey: key)
68 | }
69 | }
70 | }
71 |
72 | private func defaultsKeyForDatabaseScope(_ scope: CKDatabase.Scope) -> String {
73 | switch scope {
74 | case .public: return "publicCKDatabaseServerChangeToken"
75 | case .private: return "privateCKDatabaseServerChangeToken"
76 | case .shared: return "sharedCKDatabaseServerChangeToken"
77 | @unknown default: fatalError("Unknown CKDatabase scope")
78 | }
79 | }
80 |
81 | private func defaultsKeyForZoneID(_ zoneID: CKRecordZone.ID) -> String {
82 | "recordZoneServerChangeToken:\(zoneID.ckDatabaseScope.asString):\(zoneID.ownerName):\(zoneID.zoneName)"
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/Targets/Canopy/Tests/CKDatabaseScopeExtensionTests.swift:
--------------------------------------------------------------------------------
1 | @testable import Canopy
2 | import CloudKit
3 | import XCTest
4 |
5 | final class CKDatabaseScopeExtensionTests: XCTestCase {
6 | func test_private_scope() {
7 | XCTAssertEqual(CKDatabase.Scope.private.asString, "private")
8 | }
9 |
10 | func test_shared_scope() {
11 | XCTAssertEqual(CKDatabase.Scope.shared.asString, "shared")
12 | }
13 |
14 | func test_public_scope() {
15 | XCTAssertEqual(CKDatabase.Scope.public.asString, "public")
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/Targets/Canopy/Tests/CKRecordZoneIDExtensionTests.swift:
--------------------------------------------------------------------------------
1 | @testable import Canopy
2 | import CloudKit
3 | import XCTest
4 |
5 | final class CKRecordZoneIDExtensionTests: XCTestCase {
6 | func test_private_zone() {
7 | let privateZoneID = CKRecordZone.ID(zoneName: "someZone", ownerName: CKCurrentUserDefaultName)
8 | XCTAssertEqual(privateZoneID.ckDatabaseScope, .private)
9 | }
10 |
11 | func test_shared_zone() {
12 | let sharedZoneID = CKRecordZone.ID(zoneName: "someSharedZone", ownerName: "someOtherPerson")
13 | XCTAssertEqual(sharedZoneID.ckDatabaseScope, .shared)
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Targets/Canopy/Tests/CanopyTests.swift:
--------------------------------------------------------------------------------
1 | @testable import Canopy
2 | import CanopyTestTools
3 | import CloudKit
4 | import XCTest
5 |
6 | @available(iOS 16.4, macOS 13.3, *)
7 | final class CanopyTests: XCTestCase {
8 | func test_init_with_default_settings() async {
9 | let _ = Canopy(
10 | container: ReplayingMockCKContainer(),
11 | publicCloudDatabase: ReplayingMockCKDatabase(),
12 | privateCloudDatabase: ReplayingMockCKDatabase(),
13 | sharedCloudDatabase: ReplayingMockCKDatabase(),
14 | tokenStore: TestTokenStore()
15 | )
16 | }
17 |
18 | func test_settings_provider_uses_modified_value() async {
19 | let changedRecordID = CKRecord.ID(recordName: "SomeRecordName")
20 | let changedRecord = CKRecord(recordType: "TestRecord", recordID: changedRecordID)
21 |
22 | actor ModifiableSettings: CanopySettingsType {
23 | var modifyRecordsBehavior: RequestBehavior = .regular(nil)
24 | let fetchZoneChangesBehavior: RequestBehavior = .regular(nil)
25 | let fetchDatabaseChangesBehavior: RequestBehavior = .regular(nil)
26 | let autoBatchTooLargeModifyOperations: Bool = true
27 | let autoRetryForRetriableErrors: Bool = true
28 |
29 | func setModifyRecordsBehavior(behavior: RequestBehavior) {
30 | modifyRecordsBehavior = behavior
31 | }
32 | }
33 |
34 | let modifiableSettings = ModifiableSettings()
35 |
36 | let canopy = Canopy(
37 | container: ReplayingMockCKContainer(),
38 | publicCloudDatabase: ReplayingMockCKDatabase(),
39 | privateCloudDatabase: ReplayingMockCKDatabase(
40 | operationResults: [
41 | .modify(
42 | .init(
43 | savedRecordResults: [
44 | .init(recordID: changedRecordID, result: .success(changedRecord))
45 | ],
46 | deletedRecordIDResults: [],
47 | modifyResult: .init(result: .success(()))
48 | )
49 | ),
50 | .modify(
51 | .init(
52 | savedRecordResults: [],
53 | deletedRecordIDResults: [],
54 | modifyResult: .init(result: .success(()))
55 | )
56 | )
57 | ]
58 | ),
59 | sharedCloudDatabase: ReplayingMockCKDatabase(),
60 | settings: { modifiableSettings },
61 | tokenStore: TestTokenStore()
62 | )
63 |
64 | let api = await canopy.databaseAPI(usingDatabaseScope: .private)
65 |
66 | // First request will succeed.
67 | let result1 = try! await api.modifyRecords(saving: [changedRecord]).get()
68 | XCTAssertTrue(result1.savedRecords.count == 1)
69 | XCTAssertTrue(result1.savedRecords[0].isEqualToRecord(changedRecord.canopyResultRecord))
70 |
71 | // Second request will fail after modifying the settings.
72 | await modifiableSettings.setModifyRecordsBehavior(behavior: .simulatedFail(nil))
73 |
74 | do {
75 | let _ = try await api.modifyRecords(saving: [changedRecord]).get()
76 | } catch {
77 | XCTAssertNotNil(error)
78 | // Error is CKRecordError, so we use a CKRecordError API to test it
79 | XCTAssertEqual(error.batchErrors, [:])
80 | }
81 | }
82 |
83 | func test_returns_same_api_instances() async {
84 | let canopy = Canopy(
85 | container: ReplayingMockCKContainer(),
86 | publicCloudDatabase: ReplayingMockCKDatabase(),
87 | privateCloudDatabase: ReplayingMockCKDatabase(),
88 | sharedCloudDatabase: ReplayingMockCKDatabase()
89 | )
90 |
91 | let privateApi1 = await canopy.databaseAPI(usingDatabaseScope: .private) as! CKDatabaseAPI
92 | let privateApi2 = await canopy.databaseAPI(usingDatabaseScope: .private) as! CKDatabaseAPI
93 | XCTAssertTrue(privateApi1 === privateApi2)
94 |
95 | let publicApi1 = await canopy.databaseAPI(usingDatabaseScope: .public) as! CKDatabaseAPI
96 | let publicApi2 = await canopy.databaseAPI(usingDatabaseScope: .public) as! CKDatabaseAPI
97 | XCTAssertTrue(publicApi1 === publicApi2)
98 |
99 | let sharedApi1 = await canopy.databaseAPI(usingDatabaseScope: .shared) as! CKDatabaseAPI
100 | let sharedApi2 = await canopy.databaseAPI(usingDatabaseScope: .shared) as! CKDatabaseAPI
101 | XCTAssertTrue(sharedApi1 === sharedApi2)
102 |
103 | let containerApi1 = await canopy.containerAPI() as! CKContainerAPI
104 | let containerApi2 = await canopy.containerAPI() as! CKContainerAPI
105 |
106 | XCTAssertTrue(containerApi1 === containerApi2)
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/Targets/Canopy/Tests/DependencyTests.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CanopyTestTools
3 | import CloudKit
4 | import Dependencies
5 | import XCTest
6 |
7 | @available(iOS 16.4, macOS 13.3, *)
8 | final class DependencyTests: XCTestCase {
9 | struct Fetcher {
10 | @Dependency(\.cloudKit) private var canopy
11 | func fetchRecord(recordID: CKRecord.ID) async -> CanopyResultRecord? {
12 | try! await canopy.databaseAPI(usingDatabaseScope: .private).fetchRecords(
13 | with: [recordID]
14 | ).get().foundRecords.first
15 | }
16 | }
17 |
18 | func test_dependency() async {
19 | let fetcher = withDependencies {
20 | let testRecordID = CKRecord.ID(recordName: "testRecordID")
21 | let testRecord = CKRecord(recordType: "TestRecord", recordID: testRecordID)
22 | testRecord["testKey"] = "testValue"
23 | $0.cloudKit = MockCanopyWithCKMocks(
24 | mockPrivateCKDatabase: ReplayingMockCKDatabase(
25 | operationResults: [
26 | .fetch(
27 | .init(
28 | fetchRecordResults: [
29 | .init(
30 | recordID: testRecordID,
31 | result: .success(testRecord)
32 | )
33 | ],
34 | fetchResult: .init(
35 | result: .success(())
36 | )
37 | )
38 | )
39 | ]
40 | )
41 | )
42 | } operation: {
43 | Fetcher()
44 | }
45 | let record = await fetcher.fetchRecord(recordID: .init(recordName: "testRecordID"))!
46 | XCTAssertEqual(record["testKey"] as! String, "testValue")
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/Targets/Canopy/Tests/FetchDatabaseChangesResultTests.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 | import XCTest
4 |
5 | final class FetchDatabaseChangesResultTests: XCTestCase {
6 | func test_codes() throws {
7 | let result = FetchDatabaseChangesResult(
8 | changedRecordZoneIDs: [.init(zoneName: "changedZone", ownerName: "owner1")],
9 | deletedRecordZoneIDs: [.init(zoneName: "deletedZone", ownerName: "owner2")],
10 | purgedRecordZoneIDs: [.init(zoneName: "purgedZone", ownerName: "owner3")]
11 | )
12 | let coded = try JSONEncoder().encode(result)
13 | let decoded = try JSONDecoder().decode(FetchDatabaseChangesResult.self, from: coded)
14 | XCTAssertEqual(decoded.changedRecordZoneIDs[0].zoneName, "changedZone")
15 | XCTAssertEqual(decoded.deletedRecordZoneIDs[0].zoneName, "deletedZone")
16 | XCTAssertEqual(decoded.purgedRecordZoneIDs[0].zoneName, "purgedZone")
17 | }
18 |
19 | func test_empty() {
20 | let result = FetchDatabaseChangesResult.empty
21 | XCTAssertTrue(result.changedRecordZoneIDs.isEmpty)
22 | XCTAssertTrue(result.deletedRecordZoneIDs.isEmpty)
23 | XCTAssertTrue(result.purgedRecordZoneIDs.isEmpty)
24 | }
25 |
26 | func test_throws_on_changed_data_error() throws {
27 | let badJson = "{\"deletedRecordZoneIDs\":\"YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGnCwwSHB0eJFUkbnVsbNINDg8RWk5TLm9iamVjdHNWJGNsYXNzoRCAAoAG1RMUFRYOFxgZGhtfEBBkYXRhYmFzZVNjb3BlS2V5XxARYW5vbnltb3VzQ0tVc2VySURZb3duZXJOYW1lWFpvbmVOYW1lEACAAIAEgAOABVtkZWxldGVkWm9uZVZvd25lcjLSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXkNLUmVjb3JkWm9uZUlEoiEjWE5TT2JqZWN00h8gJSZXTlNBcnJheaIlIwAIABEAGgAkACkAMgA3AEkATABRAFMAWwBhAGYAcQB4AHoAfAB+AIkAnACwALoAwwDFAMcAyQDLAM0A2QDgAOUA8AD5AQgBCwEUARkBIQAAAAAAAAIBAAAAAAAAACcAAAAAAAAAAAAAAAAAAAEk\",\"changedRecordZoneIDs\":\"deadbeef\",\"purgedRecordZoneIDs\":\"YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGnCwwSHB0eJFUkbnVsbNINDg8RWk5TLm9iamVjdHNWJGNsYXNzoRCAAoAG1RMUFRYOFxgZGhtfEBBkYXRhYmFzZVNjb3BlS2V5XxARYW5vbnltb3VzQ0tVc2VySURZb3duZXJOYW1lWFpvbmVOYW1lEACAAIAEgAOABVpwdXJnZWRab25lVm93bmVyM9IfICEiWiRjbGFzc25hbWVYJGNsYXNzZXNeQ0tSZWNvcmRab25lSUSiISNYTlNPYmplY3TSHyAlJldOU0FycmF5oiUjAAgAEQAaACQAKQAyADcASQBMAFEAUwBbAGEAZgBxAHgAegB8AH4AiQCcALAAugDDAMUAxwDJAMsAzQDYAN8A5ADvAPgBBwEKARMBGAEgAAAAAAAAAgEAAAAAAAAAJwAAAAAAAAAAAAAAAAAAASM=\"}"
28 | let data = badJson.data(using: .utf8)!
29 | do {
30 | let _ = try JSONDecoder().decode(FetchDatabaseChangesResult.self, from: data)
31 | } catch DecodingError.dataCorrupted(let context) {
32 | XCTAssertEqual(context.debugDescription, "Invalid changed record zone IDs value in source data")
33 | } catch {
34 | XCTFail("Unexpected error: \(error)")
35 | }
36 | }
37 |
38 | func test_throws_on_deleted_data_error() throws {
39 | let badJson = "{\"deletedRecordZoneIDs\":\"deadbeef\",\"changedRecordZoneIDs\":\"YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGnCwwSHB0eJFUkbnVsbNINDg8RWk5TLm9iamVjdHNWJGNsYXNzoRCAAoAG1RMUFRYOFxgZGhtfEBBkYXRhYmFzZVNjb3BlS2V5XxARYW5vbnltb3VzQ0tVc2VySURZb3duZXJOYW1lWFpvbmVOYW1lEACAAIAEgAOABVtjaGFuZ2VkWm9uZVZvd25lcjHSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXkNLUmVjb3JkWm9uZUlEoiEjWE5TT2JqZWN00h8gJSZXTlNBcnJheaIlIwAIABEAGgAkACkAMgA3AEkATABRAFMAWwBhAGYAcQB4AHoAfAB+AIkAnACwALoAwwDFAMcAyQDLAM0A2QDgAOUA8AD5AQgBCwEUARkBIQAAAAAAAAIBAAAAAAAAACcAAAAAAAAAAAAAAAAAAAEk\",\"purgedRecordZoneIDs\":\"YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGnCwwSHB0eJFUkbnVsbNINDg8RWk5TLm9iamVjdHNWJGNsYXNzoRCAAoAG1RMUFRYOFxgZGhtfEBBkYXRhYmFzZVNjb3BlS2V5XxARYW5vbnltb3VzQ0tVc2VySURZb3duZXJOYW1lWFpvbmVOYW1lEACAAIAEgAOABVpwdXJnZWRab25lVm93bmVyM9IfICEiWiRjbGFzc25hbWVYJGNsYXNzZXNeQ0tSZWNvcmRab25lSUSiISNYTlNPYmplY3TSHyAlJldOU0FycmF5oiUjAAgAEQAaACQAKQAyADcASQBMAFEAUwBbAGEAZgBxAHgAegB8AH4AiQCcALAAugDDAMUAxwDJAMsAzQDYAN8A5ADvAPgBBwEKARMBGAEgAAAAAAAAAgEAAAAAAAAAJwAAAAAAAAAAAAAAAAAAASM=\"}"
40 | let data = badJson.data(using: .utf8)!
41 | do {
42 | let _ = try JSONDecoder().decode(FetchDatabaseChangesResult.self, from: data)
43 | } catch DecodingError.dataCorrupted(let context) {
44 | XCTAssertEqual(context.debugDescription, "Invalid deleted record zone IDs value in source data")
45 | } catch {
46 | XCTFail("Unexpected error: \(error)")
47 | }
48 | }
49 |
50 | func test_throws_on_purged_data_error() throws {
51 | let badJson = "{\"deletedRecordZoneIDs\":\"YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGnCwwSHB0eJFUkbnVsbNINDg8RWk5TLm9iamVjdHNWJGNsYXNzoRCAAoAG1RMUFRYOFxgZGhtfEBBkYXRhYmFzZVNjb3BlS2V5XxARYW5vbnltb3VzQ0tVc2VySURZb3duZXJOYW1lWFpvbmVOYW1lEACAAIAEgAOABVtkZWxldGVkWm9uZVZvd25lcjLSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXkNLUmVjb3JkWm9uZUlEoiEjWE5TT2JqZWN00h8gJSZXTlNBcnJheaIlIwAIABEAGgAkACkAMgA3AEkATABRAFMAWwBhAGYAcQB4AHoAfAB+AIkAnACwALoAwwDFAMcAyQDLAM0A2QDgAOUA8AD5AQgBCwEUARkBIQAAAAAAAAIBAAAAAAAAACcAAAAAAAAAAAAAAAAAAAEk\",\"changedRecordZoneIDs\":\"YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGnCwwSHB0eJFUkbnVsbNINDg8RWk5TLm9iamVjdHNWJGNsYXNzoRCAAoAG1RMUFRYOFxgZGhtfEBBkYXRhYmFzZVNjb3BlS2V5XxARYW5vbnltb3VzQ0tVc2VySURZb3duZXJOYW1lWFpvbmVOYW1lEACAAIAEgAOABVtjaGFuZ2VkWm9uZVZvd25lcjHSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXkNLUmVjb3JkWm9uZUlEoiEjWE5TT2JqZWN00h8gJSZXTlNBcnJheaIlIwAIABEAGgAkACkAMgA3AEkATABRAFMAWwBhAGYAcQB4AHoAfAB+AIkAnACwALoAwwDFAMcAyQDLAM0A2QDgAOUA8AD5AQgBCwEUARkBIQAAAAAAAAIBAAAAAAAAACcAAAAAAAAAAAAAAAAAAAEk\",\"purgedRecordZoneIDs\":\"deadbeef\"}"
52 | let data = badJson.data(using: .utf8)!
53 | do {
54 | let _ = try JSONDecoder().decode(FetchDatabaseChangesResult.self, from: data)
55 | } catch DecodingError.dataCorrupted(let context) {
56 | XCTAssertEqual(context.debugDescription, "Invalid purged record zone IDs value in source data")
57 | } catch {
58 | XCTFail("Unexpected error: \(error)")
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/Targets/Canopy/Tests/FetchRecordsResultTests.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CanopyTestTools
3 | import CloudKit
4 | import XCTest
5 |
6 | final class FetchRecordsResultTests: XCTestCase {
7 | func test_codes() throws {
8 | let foundRecords = [CanopyResultRecord.mock(.init(recordType: "MockType"))]
9 | let notFoundRecordIDs = [CKRecord.ID(recordName: "notFoundId")]
10 | let fetchRecordsResult = FetchRecordsResult(
11 | foundRecords: foundRecords,
12 | notFoundRecordIDs: notFoundRecordIDs
13 | )
14 | let coded = try JSONEncoder().encode(fetchRecordsResult)
15 | let decoded = try JSONDecoder().decode(FetchRecordsResult.self, from: coded)
16 | XCTAssertEqual(fetchRecordsResult, decoded)
17 | }
18 |
19 | func test_throws_on_bad_deleted_ids_data() {
20 | let badJson = "{\"foundRecords\":[],\"notFoundRecordIDs\":\"deadbeef\"}"
21 | let data = badJson.data(using: .utf8)!
22 | do {
23 | let _ = try JSONDecoder().decode(FetchRecordsResult.self, from: data)
24 | } catch DecodingError.dataCorrupted(let context) {
25 | XCTAssertEqual(context.debugDescription, "Invalid not found record IDs value in source data")
26 | } catch {
27 | XCTFail("Unexpected error: \(error)")
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/Targets/Canopy/Tests/FetchZoneChangesResultTests.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 | import XCTest
4 |
5 | final class FetchZoneChangesResultTests: XCTestCase {
6 | func test_codes() throws {
7 | let result = FetchZoneChangesResult(
8 | records: [.mock(.init(recordID: .init(recordName: "recordName1"), recordType: "SomeType"))],
9 | deletedRecords: [.init(recordID: .init(recordName: "deletedId1"), recordType: "SomeDeletedType")]
10 | )
11 | let coded = try JSONEncoder().encode(result)
12 | let decoded = try JSONDecoder().decode(FetchZoneChangesResult.self, from: coded)
13 | XCTAssertEqual(decoded.changedRecords[0].recordID.recordName, "recordName1")
14 | XCTAssertEqual(decoded.deletedRecords[0].recordID.recordName, "deletedId1")
15 | }
16 |
17 | func test_empty() {
18 | let result = FetchZoneChangesResult.empty
19 | XCTAssertTrue(result.changedRecords.isEmpty)
20 | XCTAssertTrue(result.deletedRecords.isEmpty)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Targets/Canopy/Tests/Fixtures/textFile.txt:
--------------------------------------------------------------------------------
1 | Hello, world.
2 |
3 | The quick brown fox jumps over the lazy dog.
4 |
--------------------------------------------------------------------------------
/Targets/Canopy/Tests/MockObjectTests.swift:
--------------------------------------------------------------------------------
1 | @testable import Canopy
2 | import CanopyTestTools
3 | import CloudKit
4 | import Foundation
5 | import XCTest
6 |
7 | /// Test the validity mock objects provided as part of the test tools.
8 | final class MockObjectTests: XCTestCase {
9 | func test_mock_share_owned_by_another_user() {
10 | let share = CKShare.mock
11 | XCTAssertEqual(share.participants.count, 2)
12 | }
13 |
14 | func test_mock_share_owned_by_current_user() {
15 | let share = CKShare.mock_owned_by_current_user
16 | XCTAssertEqual(share.participants.count, 3)
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Targets/Canopy/Tests/ModifyRecordsResultTests.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CanopyTestTools
3 | import CloudKit
4 | import XCTest
5 |
6 | final class ModifyRecordsResultTests: XCTestCase {
7 | func test_codes() throws {
8 | let savedRecords = [CanopyResultRecord.mock(.init(recordType: "MockType"))]
9 | let deletedRecordIDs = [CKRecord.ID(recordName: "deletedId")]
10 | let modifyRecordsResult = ModifyRecordsResult(
11 | savedRecords: savedRecords,
12 | deletedRecordIDs: deletedRecordIDs
13 | )
14 | let coded = try JSONEncoder().encode(modifyRecordsResult)
15 | let decoded = try JSONDecoder().decode(ModifyRecordsResult.self, from: coded)
16 | XCTAssertEqual(modifyRecordsResult, decoded)
17 | }
18 |
19 | func test_throws_on_bad_deleted_ids_data() {
20 | let badJson = "{\"savedRecords\":[],\"deletedRecordIDs\":\"deadbeef\"}"
21 | let data = badJson.data(using: .utf8)!
22 | do {
23 | let _ = try JSONDecoder().decode(ModifyRecordsResult.self, from: data)
24 | } catch DecodingError.dataCorrupted(let context) {
25 | XCTAssertEqual(context.debugDescription, "Invalid deleted record IDs value in source data")
26 | } catch {
27 | XCTFail("Unexpected error: \(error)")
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/Targets/Canopy/Tests/ModifySubscriptionResultTests.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 | import XCTest
4 |
5 | final class ModifySubscriptionResultTests: XCTestCase {
6 | func test_codes() throws {
7 | let result = ModifySubscriptionsResult(
8 | savedSubscriptions: [
9 | CKDatabaseSubscription(subscriptionID: "db1"),
10 | CKQuerySubscription(
11 | recordType: "SomeType",
12 | predicate: NSPredicate(value: true),
13 | subscriptionID: "query1"
14 | )
15 | ],
16 | deletedSubscriptionIDs: ["deletedID1", "deletedID2"]
17 | )
18 |
19 | let encoded = try JSONEncoder().encode(result)
20 | let decoded = try JSONDecoder().decode(ModifySubscriptionsResult.self, from: encoded)
21 | XCTAssertEqual(decoded.deletedSubscriptionIDs, ["deletedID1", "deletedID2"])
22 | XCTAssertEqual(decoded.savedSubscriptions[0].subscriptionID, "db1")
23 | XCTAssertEqual(decoded.savedSubscriptions[1].subscriptionID, "query1")
24 | }
25 |
26 | func test_throws_on_invalid_saved_subscriptions_data() {
27 | let badJson = "{\"savedSubscriptions\":\"deadBeef\",\"deletedSubscriptionIDs\":[\"sub1\",\"sub2\"]}"
28 | let data = badJson.data(using: .utf8)!
29 | do {
30 | let _ = try JSONDecoder().decode(ModifySubscriptionsResult.self, from: data)
31 | } catch DecodingError.dataCorrupted(let context) {
32 | XCTAssertEqual(context.debugDescription, "Invalid saved subscriptions value in source data")
33 | } catch {
34 | XCTFail("Unexpected error: \(error)")
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/Targets/Canopy/Tests/ModifyZonesResultTests.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CanopyTestTools
3 | import CloudKit
4 | import XCTest
5 |
6 | final class ModifyZonesResultTests: XCTestCase {
7 | func test_codes() throws {
8 | let savedZones = [
9 | CKRecordZone(zoneID: .init(zoneName: "someZone1", ownerName: "someOwner")),
10 | CKRecordZone(zoneID: .init(zoneName: "someZone2", ownerName: "someOwner"))
11 | ]
12 | let deletedZoneIDs = [
13 | CKRecordZone.ID(zoneName: "deletedId1"),
14 | CKRecordZone.ID(zoneName: "deletedId2")
15 | ]
16 | let modifyZonesResult = ModifyZonesResult(
17 | savedZones: savedZones,
18 | deletedZoneIDs: deletedZoneIDs
19 | )
20 | let coded = try JSONEncoder().encode(modifyZonesResult)
21 | let decoded = try JSONDecoder().decode(ModifyZonesResult.self, from: coded)
22 | XCTAssertEqual(decoded.savedZones[1].zoneID.zoneName, "someZone2")
23 | XCTAssertEqual(decoded.deletedZoneIDs[1].zoneName, "deletedId2")
24 | }
25 |
26 | func test_throws_on_bad_saved_zones_data() {
27 | let badJson = "{\"deletedZoneIDs\":\"\",\"savedZones\":\"deadbeef\"}"
28 | let data = badJson.data(using: .utf8)!
29 | do {
30 | let _ = try JSONDecoder().decode(ModifyZonesResult.self, from: data)
31 | } catch DecodingError.dataCorrupted(let context) {
32 | XCTAssertEqual(context.debugDescription, "Invalid saved zones value in source data")
33 | } catch {
34 | XCTFail("Unexpected error: \(error)")
35 | }
36 | }
37 |
38 | func test_throws_on_bad_deleted_zoneIDs_data() {
39 | let badJson = "{\"savedZones\":\"YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGtCwwTNj9AQSpHSlpfYFUkbnVsbNINDg8SWk5TLm9iamVjdHNWJGNsYXNzohARgAKACYAM3xASFBUWFxgZGhscHR4fICEiDiMkJSUlJSkqJSUtKiUvLSUyMyUlWFBDU0tleUlEXFpvbmVpc2hLZXlJRF8QEUNsaWVudENoYW5nZVRva2VuXlNoYXJlUmVmZXJlbmNlW0RldmljZUNvdW50XxAPQXNzZXRRdW90YVVzYWdlXkV4cGlyYXRpb25EYXRlXxATUENTTW9kaWZpY2F0aW9uRGF0ZVdFeHBpcmVkXxASTWV0YWRhdGFRdW90YVVzYWdlXxAWUHJldmlvdXNQcm90ZWN0aW9uRXRhZ1Zab25lSURfEBRIYXNVcGRhdGVkRXhwaXJhdGlvbl8QEVVwZGF0ZWRFeHBpcmF0aW9uXENhcGFiaWxpdGllc18QE0ludml0ZWRLZXlzVG9SZW1vdmVfEBhDdXJyZW50U2VydmVyQ2hhbmdlVG9rZW6AAIAAgACAABAAEACAAIAACIAAgAMIgACAB4AIgACAANU3ODk6DiolPD0+XxAQZGF0YWJhc2VTY29wZUtleV8QEWFub255bW91c0NLVXNlcklEWW93bmVyTmFtZVhab25lTmFtZYAAgAWABIAGWXNvbWVab25lMVlzb21lT3duZXLSQkNERVokY2xhc3NuYW1lWCRjbGFzc2VzXkNLUmVjb3JkWm9uZUlEokRGWE5TT2JqZWN00kJDSElcQ0tSZWNvcmRab25lokhG3xASFBUWFxgZGhscHR4fICEiDiMkJSUlJSkqJSUtKiVTLSUyMyUlgACAAIAAgACAAIAACIAAgAoIgACAB4AIgACAANU3ODk6DiolPF0+gACABYALgAZZc29tZVpvbmUy0kJDYWJXTlNBcnJheaJhRgAIABEAGgAkACkAMgA3AEkATABRAFMAYQBnAGwAdwB+AIEAgwCFAIcArgC3AMQA2ADnAPMBBQEUASoBMgFHAWABZwF+AZIBnwG1AdAB0gHUAdYB2AHaAdwB3gHgAeEB4wHlAeYB6AHqAewB7gHwAfsCDgIiAiwCNQI3AjkCOwI9AkcCUQJWAmECagJ5AnwChQKKApcCmgLBAsMCxQLHAskCywLNAs4C0ALSAtMC1QLXAtkC2wLdAugC6gLsAu4C8AL6Av8DBwAAAAAAAAIBAAAAAAAAAGMAAAAAAAAAAAAAAAAAAAMK\",\"deletedZoneIDs\":\"deadbeef\"}"
40 | let data = badJson.data(using: .utf8)!
41 | do {
42 | let _ = try JSONDecoder().decode(ModifyZonesResult.self, from: data)
43 | } catch DecodingError.dataCorrupted(let context) {
44 | XCTAssertEqual(context.debugDescription, "Invalid deleted record zone IDs value in source data")
45 | } catch {
46 | XCTFail("Unexpected error: \(error)")
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Targets/Canopy/Tests/ReplayingMockContainerTests.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CanopyTestTools
3 | import CloudKit
4 | import XCTest
5 |
6 | final class ReplayingMockContainerTests: XCTestCase {
7 | func test_userRecordID_success() async {
8 | let mockContainer = ReplayingMockContainer(
9 | operationResults: [
10 | .userRecordID(
11 | .init(
12 | userRecordID: CKRecord.ID(recordName: "myRecordId")
13 | )
14 | )
15 | ]
16 | )
17 |
18 | let result = try! await mockContainer.userRecordID.get()!
19 | XCTAssertEqual(result.recordName, "myRecordId")
20 | }
21 |
22 | func test_userRecordID_error() async {
23 | let mockContainer = ReplayingMockContainer(
24 | operationResults: [
25 | .userRecordID(
26 | .init(
27 | error: CKRecordError(from: CKError(CKError.Code.networkFailure))
28 | )
29 | )
30 | ]
31 | )
32 |
33 | do {
34 | let _ = try await mockContainer.userRecordID.get()
35 | } catch {
36 | XCTAssertEqual(error, CKRecordError(from: CKError(CKError.Code.networkFailure)))
37 | }
38 | }
39 |
40 | func test_accountStatus_success() async {
41 | let mockContainer = ReplayingMockContainer(
42 | operationResults: [
43 | .accountStatus(.init(status: .couldNotDetermine, error: nil))
44 | ]
45 | )
46 |
47 | let result = try! await mockContainer.accountStatus.get()
48 | XCTAssertEqual(result, .couldNotDetermine)
49 | }
50 |
51 | func test_accountStatus_error() async {
52 | let mockContainer = ReplayingMockContainer(
53 | operationResults: [
54 | .accountStatus(
55 | .init(
56 | status: .couldNotDetermine,
57 | error: .ckAccountError("some account error", CKError.Code.badContainer.rawValue)
58 | )
59 | )
60 | ]
61 | )
62 |
63 | do {
64 | let _ = try await mockContainer.accountStatus.get()
65 | } catch {
66 | XCTAssertEqual(error.code, CKError.Code.badContainer.rawValue)
67 | }
68 | }
69 |
70 | func test_accountStatusStream_success() async {
71 | let mockContainer = ReplayingMockContainer(
72 | operationResults: [
73 | .accountStatusStream(.init(statuses: [.available, .noAccount, .couldNotDetermine], error: nil))
74 | ]
75 | )
76 |
77 | var statuses: [CKAccountStatus] = []
78 | let accountStatusStream = try! await mockContainer.accountStatusStream.get()
79 | for await status in accountStatusStream.prefix(2) {
80 | statuses.append(status)
81 | }
82 | XCTAssertEqual(statuses, [.available, .noAccount])
83 | }
84 |
85 | func test_accountStatusStream_error() async {
86 | let mockContainer = ReplayingMockContainer(
87 | operationResults: [
88 | .accountStatusStream(.init(statuses: [], error: .onlyOneAccountStatusStreamSupported))
89 | ]
90 | )
91 | do {
92 | let _ = try await mockContainer.accountStatusStream.get()
93 | } catch {
94 | XCTAssertEqual(error, .onlyOneAccountStatusStreamSupported)
95 | }
96 | }
97 |
98 | func test_acceptShares_success() async {
99 | let mockContainer = ReplayingMockContainer(
100 | operationResults: [
101 | .acceptShares(.init(result: .success([CKShare.mock, CKShare.mock_owned_by_current_user])))
102 | ]
103 | )
104 | let result = try! await mockContainer.acceptShares(with: []).get()
105 | XCTAssertEqual(result.count, 2)
106 | }
107 |
108 | func test_acceptShares_error() async {
109 | let mockContainer = ReplayingMockContainer(
110 | operationResults: [
111 | .acceptShares(.init(result: .failure(.init(from: CKError(CKError.Code.networkFailure)))))
112 | ]
113 | )
114 | do {
115 | let _ = try await mockContainer.acceptShares(with: []).get()
116 | } catch {
117 | XCTAssertEqual(error.code, CKError.Code.networkFailure.rawValue)
118 | }
119 | }
120 |
121 | func test_fetchShareParticipants_success() async {
122 | let mockContainer = ReplayingMockContainer(
123 | operationResults: [
124 | .fetchShareParticipants(.init(result: .success([CKShare.Participant.mock, CKShare.Participant.mock])))
125 | ]
126 | )
127 | let result = try! await mockContainer.fetchShareParticipants(with: []).get()
128 | XCTAssertEqual(result.count, 2)
129 | }
130 |
131 | func test_fetchShareParticipants_error() async {
132 | let mockContainer = ReplayingMockContainer(
133 | operationResults: [
134 | .fetchShareParticipants(.init(result: .failure(.init(from: CKError(CKError.Code.networkFailure)))))
135 | ]
136 | )
137 | do {
138 | let _ = try await mockContainer.fetchShareParticipants(with: []).get()
139 | } catch {
140 | XCTAssertEqual(error.code, CKError.Code.networkFailure.rawValue)
141 | }
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/Targets/Canopy/Tests/ResultsTypesTests.swift:
--------------------------------------------------------------------------------
1 | @testable import Canopy
2 | import CloudKit
3 | import XCTest
4 |
5 | final class ResultsTypesTests: XCTestCase {
6 | func test_empty_database_changes_result() {
7 | let empty = FetchDatabaseChangesResult.empty
8 | XCTAssertEqual(
9 | empty,
10 | .init(
11 | changedRecordZoneIDs: [],
12 | deletedRecordZoneIDs: [],
13 | purgedRecordZoneIDs: []
14 | )
15 | )
16 | }
17 |
18 | func test_deleted_ckrecord() {
19 | let zoneID = CKRecordZone.ID(zoneName: "someZone", ownerName: "someOtherPerson")
20 | let recordID = CKRecord.ID(recordName: "deletedRecordID", zoneID: zoneID)
21 | let deletedCKRecord = DeletedCKRecord(recordID: recordID, recordType: "DeletedRecordType")
22 | XCTAssertEqual(deletedCKRecord.recordType, "DeletedRecordType")
23 | XCTAssertEqual(deletedCKRecord.recordID, recordID)
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/CodableResult.swift:
--------------------------------------------------------------------------------
1 | /// Codable representation of Result.
2 | ///
3 | /// Out of the box, Result is not Codable. When the success and error types are both codable,
4 | /// we can have a proxy type for the Result.
5 | ///
6 | /// This does not include conversion to/from the actual Result because we may want to
7 | /// massage the types a bit. Conversion should be done at the sites of use.
8 | enum CodableResult: Codable, Sendable where T: Codable, T: Sendable, E: Error, E: Codable, E: Sendable {
9 | case success(T)
10 | case failure(E)
11 | }
12 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/CodableVoid.swift:
--------------------------------------------------------------------------------
1 | /// Representation of Void for Codable transmission.
2 | struct CodableVoid: Codable {}
3 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/Extensions/CKQueryOperation.Cursor.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | public extension CKQueryOperation.Cursor {
4 | /// Mock CKQueryOperation.Cursor that unarchives to a real cursor value.
5 | ///
6 | /// We can use this cursor as a mock value in our tests, if there’s no need to match it to a real query.
7 | static var mock: CKQueryOperation.Cursor {
8 | /// Archived data representing a real CKQueryOperation.Cursor that we use as a mock value.
9 | let mockCursorBase64 = "YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8QD05" +
10 | "TS2V5ZWRBcmNoaXZlctEICVRyb290gAGoCwwTFB4fICZVJG51bGzTDQ4PEBESVlpvbmVJRFYkY2xhc3" +
11 | "NaQ3Vyc29yRGF0YYADgAeAAk8RAXUKiwEKBgoEQ2hhdBKAAQoOCgxfX19jcmVhdGVkQnkSbAgFSmgIA" +
12 | "hJkCiUKIV9iZGY2YWEyYjUyZDBhYjM0OWEyYzEwYzI1OTg0Njc3MRABEjsKEAoMX2RlZmF1bHRab25l" +
13 | "EAYSJQohX2JkZjZhYTJiNTJkMGFiMzQ5YTJjMTBjMjU5ODQ2NzcxEAcYASABEuQBQW9FL2hRRnBRMnh" +
14 | "2ZFdRdVkyOXRMbXAxYzNSMFlXTjBMbFJoWTNRa01TUnpZVzVrWW05NEpEQTVNak16WXpaaExUUXdObV" +
15 | "V0TkdFMVl5MWlZek0zTFRaa01tWTJaREF3TXpKaVlTRXhaRGxpTWpRMk1DMWlNek0zTFRSbE4yUXRPR" +
16 | "0psWVMwMk56VXhPVE00T1dWaU16RmtNalUzTnpRMUlVMXFUWGhSYW1NMVRYcFJkRkpVV1RGU1V6QXdU" +
17 | "bFZTUlV4VWFFVk9lbFYwVFhwU1EwMVZVa1pTVkVsNFRsUlNSUT091RUWFxgOGRobHB1fEBBkYXRhYmF" +
18 | "zZVNjb3BlS2V5XxARYW5vbnltb3VzQ0tVc2VySURZb3duZXJOYW1lWFpvbmVOYW1lEACAAIAFgASABl" +
19 | "VDaGF0c18QEF9fZGVmYXVsdE93bmVyX1/SISIjJFokY2xhc3NuYW1lWCRjbGFzc2VzXkNLUmVjb3JkW" +
20 | "m9uZUlEoiMlWE5TT2JqZWN00iEiJyhdQ0tRdWVyeUN1cnNvcqInJQAIABEAGgAkACkAMgA3AEkATABR" +
21 | "AFMAXABiAGkAcAB3AIIAhACGAIgCAQIMAh8CMwI9AkYCSAJKAkwCTgJQAlYCaQJuAnkCggKRApQCnQK" +
22 | "iArAAAAAAAAACAQAAAAAAAAApAAAAAAAAAAAAAAAAAAACsw=="
23 |
24 | let mockCursorData = Data(base64Encoded: mockCursorBase64)!
25 | do {
26 | guard let cursor = try NSKeyedUnarchiver.unarchivedObject(
27 | ofClass: CKQueryOperation.Cursor.self,
28 | from: mockCursorData
29 | ) else {
30 | fatalError("Could not unarchive the mock cursor")
31 | }
32 | return cursor
33 | } catch {
34 | fatalError("Could not unarchive the mock cursor: \(error)")
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/Extensions/CKRecordZone.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | public extension CKRecordZone {
4 | /// A simple comparison to determine zone equivalence.
5 | ///
6 | /// Doesn’t compare fields.
7 | func isEqualToZone(_ zone: CKRecordZone) -> Bool {
8 | zoneID == zone.zoneID &&
9 | capabilities == zone.capabilities
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/Extensions/CKServerChangeToken.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | public extension CKServerChangeToken {
4 | /// Mock CKServerChangeToken that unarchives to a real token value.
5 | ///
6 | /// We can use this token as a mock value in our tests, if there’s no need to match it to a real CKDatabase state.
7 | static var mock: CKServerChangeToken {
8 | let mockTokenBase64 = "YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMS" +
9 | "AAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwRElUkbnVsbNINDg8QViRjbGFzc18QD0" +
10 | "NoYW5nZVRva2VuRGF0YYADgAJJAQAAAYYRIGk+0hMUFRZaJGNsYXNzbmFtZVgkY2xhc3Nlc18QE0NL" +
11 | "U2VydmVyQ2hhbmdlVG9rZW6iFRdYTlNPYmplY3QIERokKTI3SUxRU1heY2p8foCKj5qjubwAAAAAAAA" +
12 | "BAQAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAxQ=="
13 |
14 | let mockTokenData = Data(base64Encoded: mockTokenBase64)!
15 | do {
16 | guard let token = try NSKeyedUnarchiver.unarchivedObject(
17 | ofClass: CKServerChangeToken.self,
18 | from: mockTokenData
19 | ) else {
20 | fatalError("Could not unarchive the mock token")
21 | }
22 | return token
23 | } catch {
24 | fatalError("Could not unarchive the mock token: \(error)")
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/Extensions/CKShare.Participant.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 |
3 | public extension CKShare.Participant {
4 | /// Mock CKShare.Participant that unarchives to a real CKShare.Participant value.
5 | ///
6 | /// We can use this participant as a mock value in our tests, if there’s no need to match it to a real user in a current real container.
7 | static var mock: CKShare.Participant {
8 | let mockParticipantBase64 = "YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVR" +
9 | "yb290gAGvEBYLDDM0R01OWFlaYGNnd3t/io6SlZaZVSRudWxs3xAVDQ4PEBESExQVFhcYGRobHB0eHyAhIiMiJCUjJCcoJCojKCQtKCokKCQoWlBlcm1pc3Npb25fEBhPcm" +
10 | "lnaW5hbEFjY2VwdGFuY2VTdGF0dXNfEBJPcmlnaW5hbFBlcm1pc3Npb25fEBd3YW50c05ld0ludml0YXRpb25Ub2tlblYkY2xhc3NfEBxtdXRhYmxlSW52aXRhdGlvblRva" +
11 | "2VuU3RhdHVzXxAdaXNBbm9ueW1vdXNJbnZpdGVkUGFydGljaXBhbnRdUGFydGljaXBhbnRJRFlJbnZpdGVySURfEBBDcmVhdGVkSW5Qcm9jZXNzVFR5cGVfEBBBY2NlcHRh" +
12 | "bmNlU3RhdHVzXxAXUHJvdGVjdGlvbkluZm9QdWJsaWNLZXleSXNPcmdBZG1pblVzZXJcVXNlcklkZW50aXR5XxAPSW52aXRhdGlvblRva2VuXxAXT3JpZ2luYWxQYXJ0aWN" +
13 | "pcGFudFR5cGVfEBFBY2NlcHRlZEluUHJvY2Vzc15Qcm90ZWN0aW9uSW5mb11Jc0N1cnJlbnRVc2VyXxAVRW5jcnlwdGVkUGVyc29uYWxJbmZvEAIQAQiAFQiAAoAACBADgA" +
14 | "AIgAOAAAiAAAiAAF8QJEVFQjlCRkY0LTdFNDgtNEY5Ni04NTFDLThFQjcwMEVFRTJGNdo1Njc4OSEROjs8JD4/QEEoQyhFRlhJc0NhY2hlZF5Qcm90ZWN0aW9uRGF0YVxVc" +
15 | "2VyUmVjb3JkSURfEBBIYXNJQ2xvdWRBY2NvdW50Xk5hbWVDb21wb25lbnRzXxART09OUHJvdGVjdGlvbkRhdGFaTG9va3VwSW5mb18QEkNvbnRhY3RJZGVudGlmaWVycwiA" +
16 | "E4AECYALgACAFIAAgA+AEdMRSElKS0xaUmVjb3JkTmFtZVZab25lSUSACoAFgAZfECFfMmI3ZTI2ODgxY2ZmNjdhOTYzNjkxYjRkNTdjNzNlZmXVT1BRUhFTKFVWV18QEGR" +
17 | "hdGFiYXNlU2NvcGVLZXlfEBFhbm9ueW1vdXNDS1VzZXJJRFlvd25lck5hbWVYWm9uZU5hbWUQAIAAgAiAB4AJXF9kZWZhdWx0Wm9uZV8QEF9fZGVmYXVsdE93bmVyX1/SW1" +
18 | "xdXlokY2xhc3NuYW1lWCRjbGFzc2VzXkNLUmVjb3JkWm9uZUlEol1fWE5TT2JqZWN00ltcYWJaQ0tSZWNvcmRJRKJhX9IRZGVmXxAYTlMubmFtZUNvbXBvbmVudHNQcml2Y" +
19 | "XRlgA6ADNhoEWlqa2xtbihwKCgoKCgoXU5TLm1pZGRsZU5hbWVdTlMuZmFtaWx5TmFtZVtOUy5uaWNrbmFtZVxOUy5naXZlbk5hbWVdTlMubmFtZVByZWZpeF1OUy5uYW1l" +
20 | "U3VmZml4XxAZTlMucGhvbmV0aWNSZXByZXNlbnRhdGlvboAAgA2AAIAAgACAAIAAgADSW1x4eV8QG19OU1BlcnNvbk5hbWVDb21wb25lbnRzRGF0YaJ6X18QG19OU1BlcnN" +
21 | "vbk5hbWVDb21wb25lbnRzRGF0YdJbXHx9XxAWTlNQZXJzb25OYW1lQ29tcG9uZW50c6J+X18QFk5TUGVyc29uTmFtZUNvbXBvbmVudHPWEYCBgiGDhCg/JCgoW1Bob25lTn" +
22 | "VtYmVyWFJlY29yZElEXlJlcG9ydHNNaXNzaW5nXEVtYWlsQWRkcmVzc4AQgACABAiAAIAA0ltci4xfEBhDS1VzZXJJZGVudGl0eUxvb2t1cEluZm+ijV9fEBhDS1VzZXJJZ" +
23 | "GVudGl0eUxvb2t1cEluZm/SjxGQkVpOUy5vYmplY3RzoIAS0ltck5RXTlNBcnJheaKTX08QiTCBhgRBBBHgsvEOwV1neQOxZapFRBUwdhrvSphuMPHNEw7VAYKZ17VBtLBX" +
24 | "mo7uMEwv11cArv9oBTHbo2k7iZs2YLQKPFAEQQRw9jLjKISyLC9nPYZoPizSt7ViqriLjWkKB4hjPDqYU+0aUk5KJEkaccnrjlx+6j9yNWjjOeKGktIINS3UZZOu0ltcl5h" +
25 | "eQ0tVc2VySWRlbnRpdHmil1/SW1yam18QEkNLU2hhcmVQYXJ0aWNpcGFudKKaXwAIABEAGgAkACkAMgA3AEkATABRAFMAbAByAJ8AqgDFANoA9AD7ARoBOgFIAVIBZQFqAX" +
26 | "0BlwGmAbMBxQHfAfMCAgIQAigCKgIsAi0CLwIwAjICNAI1AjcCOQI6AjwCPgI/AkECQgJEAmsCgAKJApgCpQK4AscC2wLmAvsC/AL+AwADAQMDAwUDBwMJAwsDDQMUAx8DJ" +
27 | "gMoAyoDLANQA1sDbgOCA4wDlQOXA5kDmwOdA58DrAO/A8QDzwPYA+cD6gPzA/gEAwQGBAsEJgQoBCoEOwRJBFcEYwRwBH4EjASoBKoErASuBLAEsgS0BLYEuAS9BNsE3gT8" +
28 | "BQEFGgUdBTYFQwVPBVgFZwV0BXYFeAV6BXsFfQV/BYQFnwWiBb0FwgXNBc4F0AXVBd0F4AZsBnEGgAaDBogGnQAAAAAAAAIBAAAAAAAAAJwAAAAAAAAAAAAAAAAAAAag"
29 |
30 | let mockParticipantData = Data(base64Encoded: mockParticipantBase64)!
31 | do {
32 | guard let participant = try NSKeyedUnarchiver.unarchivedObject(
33 | ofClass: CKShare.Participant.self,
34 | from: mockParticipantData
35 | ) else {
36 | fatalError("Could not unarchive the mock participant")
37 | }
38 | return participant
39 | } catch {
40 | fatalError("Could not unarchive the mock participant: \(error)")
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/Extensions/CanopyResultRecord.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 |
4 | public extension CanopyResultRecord {
5 | /// A simple comparison to determine record equivalence.
6 | ///
7 | /// Doesn’t compare fields.
8 | func isEqualToRecord(_ record: CanopyResultRecord) -> Bool {
9 | recordID == record.recordID &&
10 | recordType == record.recordType &&
11 | record.recordChangeTag == record.recordChangeTag
12 | }
13 |
14 | static func mock(_ mock: MockCanopyResultRecord) -> CanopyResultRecord {
15 | CanopyResultRecord(mock: mock)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/MockCanopy/MockCanopy.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 |
4 | /// MockCanopy lets you use Canopy with deterministic responses without any external
5 | /// dependencies like CloudKit on the cloud.
6 | ///
7 | /// You initialize MockCanopy with static container and databases, perhaps instances
8 | /// of `ReplayingMockContainer` and `ReplayingMockDatabase`, and
9 | /// then plays back their static content in response to Canopy API calls.
10 | public struct MockCanopy {
11 | private let container: CKContainerAPIType
12 | private let publicDatabase: CKDatabaseAPIType
13 | private let privateDatabase: CKDatabaseAPIType
14 | private let sharedDatabase: CKDatabaseAPIType
15 |
16 | public init(
17 | container: CKContainerAPIType = ReplayingMockContainer(),
18 | publicDatabase: CKDatabaseAPIType = ReplayingMockDatabase(),
19 | privateDatabase: CKDatabaseAPIType = ReplayingMockDatabase(),
20 | sharedDatabase: CKDatabaseAPIType = ReplayingMockDatabase()
21 | ) {
22 | self.container = container
23 | self.privateDatabase = privateDatabase
24 | self.publicDatabase = publicDatabase
25 | self.sharedDatabase = sharedDatabase
26 | }
27 | }
28 |
29 | extension MockCanopy: CanopyType {
30 | public func containerAPI() async -> CKContainerAPIType {
31 | container
32 | }
33 |
34 | public func databaseAPI(usingDatabaseScope scope: CKDatabase.Scope) async -> CKDatabaseAPIType {
35 | switch scope {
36 | case .public: publicDatabase
37 | case .private: privateDatabase
38 | case .shared: sharedDatabase
39 | @unknown default: fatalError("Requested unknown database type: \(scope)")
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/ReplayingMockCKContainer/ReplayingMockCKContainer+AcceptShares.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 |
4 | public extension ReplayingMockCKContainer {
5 | struct PerShareResult: Codable, Sendable {
6 | let shareMetadataArchive: CloudKitShareMetadataArchive
7 | let codableResult: CodableResult
8 |
9 | public init(metadata: CKShare.Metadata, result: Result) {
10 | self.shareMetadataArchive = CloudKitShareMetadataArchive(shareMetadatas: [metadata])
11 | switch result {
12 | case let .failure(error): self.codableResult = .failure(CKRecordError(from: error))
13 | case let .success(share): self.codableResult = .success(.init(shares: [share]))
14 | }
15 | }
16 |
17 | public var result: Result {
18 | switch codableResult {
19 | case let .failure(recordError): return .failure(recordError.ckError)
20 | case let .success(shareArchive): return .success(shareArchive.shares.first!)
21 | }
22 | }
23 | }
24 |
25 | struct AcceptSharesResult: Codable, Sendable {
26 | let codableResult: CodableResult
27 |
28 | public init(result: Result) {
29 | switch result {
30 | case .success: self.codableResult = .success(CodableVoid())
31 | case let .failure(error): self.codableResult = .failure(CKRecordError(from: error))
32 | }
33 | }
34 |
35 | public var result: Result {
36 | switch codableResult {
37 | case let .failure(recordError): return .failure(recordError.ckError)
38 | case .success: return .success(())
39 | }
40 | }
41 | }
42 |
43 | struct AcceptSharesOperationResult: Codable, Sendable {
44 | let perShareResults: [PerShareResult]
45 | let acceptSharesResult: AcceptSharesResult
46 |
47 | public init(perShareResults: [PerShareResult], acceptSharesResult: AcceptSharesResult) {
48 | self.perShareResults = perShareResults
49 | self.acceptSharesResult = acceptSharesResult
50 | }
51 | }
52 |
53 | internal func runAcceptSharesOperation(
54 | _ operation: CKAcceptSharesOperation,
55 | operationResult: AcceptSharesOperationResult
56 | ) {
57 | for perShareResult in operationResult.perShareResults {
58 | operation.perShareResultBlock?(perShareResult.shareMetadataArchive.shareMetadatas.first!, perShareResult.result)
59 | }
60 | operation.acceptSharesResultBlock?(operationResult.acceptSharesResult.result)
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/ReplayingMockCKContainer/ReplayingMockCKContainer+AccountStatus.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 |
4 | public extension ReplayingMockCKContainer {
5 | struct AccountStatusResult: Codable, Sendable {
6 | let statusValue: Int
7 | let canopyError: CanopyError?
8 |
9 | public init(status: CKAccountStatus, error: Error?) {
10 | self.statusValue = status.rawValue
11 | if let error {
12 | self.canopyError = CanopyError.accountError(from: error)
13 | } else {
14 | self.canopyError = nil
15 | }
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/ReplayingMockCKContainer/ReplayingMockCKContainer+FetchShareParticipants.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 |
4 | public extension ReplayingMockCKContainer {
5 | struct PerShareParticipantResult: Codable, Sendable {
6 | let lookupInfoArchive: CloudKitLookupInfoArchive
7 | let codableResult: CodableResult
8 |
9 | public init(lookupInfo: CKUserIdentity.LookupInfo, result: Result) {
10 | self.lookupInfoArchive = CloudKitLookupInfoArchive(lookupInfos: [lookupInfo])
11 | switch result {
12 | case let .failure(error): self.codableResult = .failure(CKRecordError(from: error))
13 | case let .success(participant): self.codableResult = .success(.init(shareParticipants: [participant]))
14 | }
15 | }
16 |
17 | public var result: Result {
18 | switch codableResult {
19 | case let .failure(recordError): return .failure(recordError.ckError)
20 | case let .success(shareParticipantArchive): return .success(shareParticipantArchive.shareParticipants.first!)
21 | }
22 | }
23 | }
24 |
25 | struct FetchShareParticipantsResult: Codable, Sendable {
26 | let codableResult: CodableResult
27 |
28 | public init(result: Result) {
29 | switch result {
30 | case .success: self.codableResult = .success(CodableVoid())
31 | case let .failure(error): self.codableResult = .failure(CKRecordError(from: error))
32 | }
33 | }
34 |
35 | public var result: Result {
36 | switch codableResult {
37 | case let .failure(recordError): return .failure(recordError.ckError)
38 | case .success: return .success(())
39 | }
40 | }
41 | }
42 |
43 | struct FetchShareParticipantsOperationResult: Codable, Sendable {
44 | let perShareParticipantResults: [PerShareParticipantResult]
45 | let fetchShareParticipantsResult: FetchShareParticipantsResult
46 |
47 | public init(perShareParticipantResults: [PerShareParticipantResult], fetchShareParticipantsResult: FetchShareParticipantsResult) {
48 | self.perShareParticipantResults = perShareParticipantResults
49 | self.fetchShareParticipantsResult = fetchShareParticipantsResult
50 | }
51 | }
52 |
53 | internal func runFetchShareParticipantsOperation(
54 | _ operation: CKFetchShareParticipantsOperation,
55 | operationResult: FetchShareParticipantsOperationResult
56 | ) {
57 | for perShareParticipantResult in operationResult.perShareParticipantResults {
58 | operation.perShareParticipantResultBlock?(perShareParticipantResult.lookupInfoArchive.lookupInfos.first!, perShareParticipantResult.result)
59 | }
60 | operation.fetchShareParticipantsResultBlock?(operationResult.fetchShareParticipantsResult.result)
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/ReplayingMockCKContainer/ReplayingMockCKContainer+FetchUserRecordID.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 |
4 | public extension ReplayingMockCKContainer {
5 | struct UserRecordIDResult: Codable, Sendable {
6 | let userRecordIDArchive: CloudKitRecordIDArchive?
7 | let recordError: CKRecordError?
8 |
9 | public init(userRecordID: CKRecord.ID? = nil, error: Error? = nil) {
10 | if let userRecordID {
11 | self.userRecordIDArchive = CloudKitRecordIDArchive(recordIDs: [userRecordID])
12 | } else {
13 | self.userRecordIDArchive = nil
14 | }
15 | if let error {
16 | self.recordError = CKRecordError(from: error)
17 | } else {
18 | self.recordError = nil
19 | }
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/ReplayingMockCKContainer/ReplayingMockCKContainer.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 | import Foundation
4 |
5 | public actor ReplayingMockCKContainer {
6 | public enum OperationResult: Codable, Sendable {
7 | case userRecordID(UserRecordIDResult)
8 | case accountStatus(AccountStatusResult)
9 | case fetchShareParticipants(FetchShareParticipantsOperationResult)
10 | case acceptShares(AcceptSharesOperationResult)
11 | }
12 |
13 | // Since testing showed that results can be requested in nondeterministic order,
14 | // we bucket and store them per type, so they can be dequeued independently.
15 |
16 | private var userRecordIDResults: [OperationResult] = []
17 | private var accountStatusResults: [OperationResult] = []
18 | private var fetchShareParticipantsResults: [OperationResult] = []
19 | private var acceptSharesResults: [OperationResult] = []
20 |
21 | /// How many operations were tun in this database.
22 | public private(set) var operationsRun = 0
23 |
24 | public init(
25 | operationResults: [OperationResult] = []
26 | ) {
27 | for result in operationResults {
28 | switch result {
29 | case .userRecordID: userRecordIDResults.append(result)
30 | case .accountStatus: accountStatusResults.append(result)
31 | case .fetchShareParticipants: fetchShareParticipantsResults.append(result)
32 | case .acceptShares: acceptSharesResults.append(result)
33 | }
34 | }
35 | }
36 |
37 | func privateFetchUserRecordID(completionHandler: @escaping (CKRecord.ID?, Error?) -> Void) {
38 | guard let operationResult = userRecordIDResults.first, case let .userRecordID(result) = operationResult else {
39 | fatalError("Asked to fetch user record ID without an available result. Likely a logic error on caller side")
40 | }
41 | userRecordIDResults.removeFirst()
42 | operationsRun += 1
43 |
44 | if let error = result.recordError {
45 | completionHandler(nil, error.ckError)
46 | } else {
47 | completionHandler(result.userRecordIDArchive!.recordIDs.first!, nil)
48 | }
49 | }
50 |
51 | func privateAccountStatus(completionHandler: @escaping (CKAccountStatus, Error?) -> Void) {
52 | guard let operationResult = accountStatusResults.first, case let .accountStatus(result) = operationResult else {
53 | fatalError("Asked for account status without an available result. Likely a logic error on caller side")
54 | }
55 | if accountStatusResults.count > 1 {
56 | // Account status behaves differently from the other mocks.
57 | // The other mocks always dequeue the result, resulting in an error
58 | // if you do more requests than you have results waiting in the queue.
59 | // accountStatus keeps replaying the last one without dequeueing it.
60 | accountStatusResults.removeFirst()
61 | }
62 | operationsRun += 1
63 |
64 | if let error = result.canopyError {
65 | completionHandler(.couldNotDetermine, error.ckError)
66 | } else {
67 | if let accountStatus = CKAccountStatus(rawValue: result.statusValue) {
68 | completionHandler(accountStatus, nil)
69 | } else {
70 | fatalError("Could not recreate CKAccountStatus from value \(result.statusValue)")
71 | }
72 | }
73 | }
74 |
75 | func privateAdd(_ operation: CKOperation) async {
76 | if let fetchShareParticipantsOperation = operation as? CKFetchShareParticipantsOperation,
77 | let operationResult = fetchShareParticipantsResults.first,
78 | case let .fetchShareParticipants(result) = operationResult
79 | {
80 | fetchShareParticipantsResults.removeFirst()
81 | operationsRun += 1
82 | runFetchShareParticipantsOperation(fetchShareParticipantsOperation, operationResult: result)
83 | } else if let acceptSharesOperation = operation as? CKAcceptSharesOperation,
84 | let operationResult = acceptSharesResults.first,
85 | case let .acceptShares(result) = operationResult
86 | {
87 | acceptSharesResults.removeFirst()
88 | operationsRun += 1
89 | runAcceptSharesOperation(acceptSharesOperation, operationResult: result)
90 | } else {
91 | fatalError("No result or incorrect result type available for operation: \(operation)")
92 | }
93 | }
94 | }
95 |
96 | extension ReplayingMockCKContainer: CKContainerType {
97 | public nonisolated func accountStatus(completionHandler: @escaping (CKAccountStatus, Error?) -> Void) {
98 | Task {
99 | await privateAccountStatus(completionHandler: completionHandler)
100 | }
101 | }
102 |
103 | public nonisolated func fetchUserRecordID(completionHandler: @escaping (CKRecord.ID?, Error?) -> Void) {
104 | Task {
105 | await privateFetchUserRecordID(completionHandler: completionHandler)
106 | }
107 | }
108 |
109 | public nonisolated func add(_ operation: CKOperation) {
110 | Task {
111 | await privateAdd(operation)
112 | }
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/ReplayingMockCKDatabase/ReplayingMockCKDatabase+Fetch.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 |
4 | public extension ReplayingMockCKDatabase {
5 | struct FetchResult: Codable, Sendable {
6 | let codableResult: CodableResult
7 |
8 | public init(result: Result) {
9 | switch result {
10 | case .success: self.codableResult = .success(CodableVoid())
11 | case let .failure(error): self.codableResult = .failure(CKRecordError(from: error))
12 | }
13 | }
14 |
15 | var result: Result {
16 | switch codableResult {
17 | case .success: return .success(())
18 | case let .failure(error): return .failure(error.ckError)
19 | }
20 | }
21 | }
22 |
23 | struct FetchOperationResult: Codable, Sendable {
24 | public let fetchRecordResults: [QueryRecordResult]
25 | public let fetchResult: FetchResult
26 |
27 | public init(fetchRecordResults: [QueryRecordResult], fetchResult: FetchResult) {
28 | self.fetchRecordResults = fetchRecordResults
29 | self.fetchResult = fetchResult
30 | }
31 | }
32 |
33 | internal func runFetchOperation(
34 | _ operation: CKFetchRecordsOperation,
35 | operationResult: FetchOperationResult
36 | ) {
37 | for recordResult in operationResult.fetchRecordResults {
38 | operation.perRecordResultBlock?(recordResult.recordID, recordResult.result)
39 | }
40 | operation.fetchRecordsResultBlock?(operationResult.fetchResult.result)
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/ReplayingMockCKDatabase/ReplayingMockCKDatabase+FetchDatabaseChanges.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 |
4 | extension ReplayingMockCKDatabase {
5 | struct FetchDatabaseChangesSuccess: Codable {
6 | let serverChangeTokenArchive: CloudKitServerChangeTokenArchive
7 | let moreComing: Bool
8 | }
9 |
10 | public struct FetchDatabaseChangesResult: Codable, Sendable {
11 | let codableResult: CodableResult
12 |
13 | public static let success = FetchDatabaseChangesResult(result: .success((serverChangeToken: CKServerChangeToken.mock, moreComing: false)))
14 |
15 | public init(
16 | result: Result<(serverChangeToken: CKServerChangeToken, moreComing: Bool), Error>
17 | ) {
18 | switch result {
19 | case let .success(tuple): self.codableResult = .success(.init(serverChangeTokenArchive: CloudKitServerChangeTokenArchive(token: tuple.serverChangeToken), moreComing: tuple.moreComing))
20 | case let .failure(error): self.codableResult = .failure(CanopyError(from: error))
21 | }
22 | }
23 |
24 | var result: Result<(serverChangeToken: CKServerChangeToken, moreComing: Bool), Error> {
25 | switch codableResult {
26 | case let .failure(error): return .failure(error.ckError)
27 | case let .success(success): return .success((serverChangeToken: success.serverChangeTokenArchive.token, moreComing: success.moreComing))
28 | }
29 | }
30 | }
31 |
32 | public struct FetchDatabaseChangesOperationResult: Codable, Sendable {
33 | /// A successful result that indicates no changes.
34 | ///
35 | /// Useful to use in tests and previews where you don’t need to inject any results, to save some typing.
36 | public static let blank = FetchDatabaseChangesOperationResult(
37 | changedRecordZoneIDs: [],
38 | deletedRecordZoneIDs: [],
39 | purgedRecordZoneIDs: [],
40 | fetchDatabaseChangesResult: .success
41 | )
42 |
43 | let changedRecordZoneIDsArchive: CloudKitRecordZoneIDArchive
44 | let deletedRecordZoneIDsArchive: CloudKitRecordZoneIDArchive
45 | let purgedRecordZoneIDsArchive: CloudKitRecordZoneIDArchive
46 | let fetchDatabaseChangesResult: FetchDatabaseChangesResult
47 |
48 | public init(
49 | changedRecordZoneIDs: [CKRecordZone.ID],
50 | deletedRecordZoneIDs: [CKRecordZone.ID],
51 | purgedRecordZoneIDs: [CKRecordZone.ID],
52 | fetchDatabaseChangesResult: FetchDatabaseChangesResult
53 | ) {
54 | self.changedRecordZoneIDsArchive = CloudKitRecordZoneIDArchive(zoneIDs: changedRecordZoneIDs)
55 | self.deletedRecordZoneIDsArchive = CloudKitRecordZoneIDArchive(zoneIDs: deletedRecordZoneIDs)
56 | self.purgedRecordZoneIDsArchive = CloudKitRecordZoneIDArchive(zoneIDs: purgedRecordZoneIDs)
57 | self.fetchDatabaseChangesResult = fetchDatabaseChangesResult
58 | }
59 | }
60 |
61 | internal func runFetchDatabaseChangesOperation(
62 | _ operation: CKFetchDatabaseChangesOperation,
63 | operationResult: FetchDatabaseChangesOperationResult,
64 | sleep: Float?
65 | ) async {
66 | if let sleep {
67 | try? await Task.sleep(nanoseconds: UInt64(sleep * Float(NSEC_PER_SEC)))
68 | }
69 |
70 | for changedRecordZoneID in operationResult.changedRecordZoneIDsArchive.zoneIDs {
71 | operation.recordZoneWithIDChangedBlock?(changedRecordZoneID)
72 | }
73 |
74 | for deletedRecordZoneID in operationResult.deletedRecordZoneIDsArchive.zoneIDs {
75 | operation.recordZoneWithIDWasDeletedBlock?(deletedRecordZoneID)
76 | }
77 |
78 | for purgedRecordZoneID in operationResult.purgedRecordZoneIDsArchive.zoneIDs {
79 | operation.recordZoneWithIDWasPurgedBlock?(purgedRecordZoneID)
80 | }
81 |
82 | operation.fetchDatabaseChangesResultBlock?(operationResult.fetchDatabaseChangesResult.result)
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/ReplayingMockCKDatabase/ReplayingMockCKDatabase+FetchZones.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 |
4 | public extension ReplayingMockCKDatabase {
5 | struct FetchZoneResult: Codable, Sendable {
6 | public let zoneIDArchive: CloudKitRecordZoneIDArchive
7 | let codableResult: CodableResult
8 |
9 | public init(zoneID: CKRecordZone.ID, result: Result) {
10 | self.zoneIDArchive = CloudKitRecordZoneIDArchive(zoneIDs: [zoneID])
11 | switch result {
12 | case let .success(zone): self.codableResult = .success(CloudKitRecordZoneArchive(zones: [zone]))
13 | case let .failure(error): self.codableResult = .failure(CKRecordZoneError(from: error))
14 | }
15 | }
16 |
17 | public var result: Result {
18 | switch codableResult {
19 | case let .success(zoneArchive): return .success(zoneArchive.zones.first!)
20 | case let .failure(error): return .failure(error.ckError)
21 | }
22 | }
23 | }
24 |
25 | struct FetchZonesResult: Codable, Sendable {
26 | let codableResult: CodableResult
27 |
28 | public init(result: Result) {
29 | switch result {
30 | case .success: self.codableResult = .success(CodableVoid())
31 | case let .failure(error): self.codableResult = .failure(CKRecordZoneError(from: error))
32 | }
33 | }
34 |
35 | var result: Result {
36 | switch codableResult {
37 | case .success: return .success(())
38 | case let .failure(zoneError): return .failure(zoneError.ckError)
39 | }
40 | }
41 | }
42 |
43 | struct FetchZonesOperationResult: Codable, Sendable {
44 | let fetchZoneResults: [FetchZoneResult]
45 | let fetchZonesResult: FetchZonesResult
46 |
47 | public init(fetchZoneResults: [FetchZoneResult], fetchZonesResult: FetchZonesResult) {
48 | self.fetchZoneResults = fetchZoneResults
49 | self.fetchZonesResult = fetchZonesResult
50 | }
51 | }
52 |
53 | internal func runFetchZonesOperation(
54 | _ operation: CKFetchRecordZonesOperation,
55 | operationResult: FetchZonesOperationResult
56 | ) {
57 | for fetchZoneResult in operationResult.fetchZoneResults {
58 | operation.perRecordZoneResultBlock?(fetchZoneResult.zoneIDArchive.zoneIDs.first!, fetchZoneResult.result)
59 | }
60 | operation.fetchRecordZonesResultBlock?(operationResult.fetchZonesResult.result)
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/ReplayingMockCKDatabase/ReplayingMockCKDatabase+Modify.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 |
4 | // Types and functionality for CKModifyRecordsOperation results.
5 | public extension ReplayingMockCKDatabase {
6 | /// Result for one saved record. perRecordSaveBlock is called with this.
7 | struct SavedRecordResult: Codable, Sendable {
8 | let recordIDArchive: CloudKitRecordIDArchive
9 | let codableResult: CodableResult
10 |
11 | public init(recordID: CKRecord.ID, result: Result) {
12 | self.recordIDArchive = CloudKitRecordIDArchive(recordIDs: [recordID])
13 | switch result {
14 | case let .success(record): self.codableResult = .success(CloudKitRecordArchive(records: [record]))
15 | case let .failure(error): self.codableResult = .failure(CKRecordError(from: error))
16 | }
17 | }
18 |
19 | var result: Result {
20 | switch codableResult {
21 | case let .success(recordArchive): return .success(recordArchive.records.first!)
22 | case let .failure(recordError): return .failure(recordError.ckError)
23 | }
24 | }
25 | }
26 |
27 | /// Result for one deleted record. perRecordDeleteBlock is called with this.
28 | struct DeletedRecordIDResult: Codable, Sendable {
29 | let recordIDArchive: CloudKitRecordIDArchive
30 | let codableResult: CodableResult
31 |
32 | public init(recordID: CKRecord.ID, result: Result) {
33 | self.recordIDArchive = CloudKitRecordIDArchive(recordIDs: [recordID])
34 | switch result {
35 | case let .failure(error): self.codableResult = .failure(CKRecordError(from: error))
36 | case .success: self.codableResult = .success(CodableVoid())
37 | }
38 | }
39 |
40 | var result: Result {
41 | switch codableResult {
42 | case .success: return .success(())
43 | case let .failure(recordError): return .failure(recordError.ckError)
44 | }
45 | }
46 | }
47 |
48 | struct ModifyResult: Codable, Sendable {
49 | let codableResult: CodableResult
50 |
51 | public init(result: Result) {
52 | switch result {
53 | case .success: self.codableResult = .success(CodableVoid())
54 | case let .failure(error): self.codableResult = .failure(CKRecordError(from: error))
55 | }
56 | }
57 |
58 | var result: Result {
59 | switch codableResult {
60 | case .success: return .success(())
61 | case let .failure(recordError): return .failure(recordError.ckError)
62 | }
63 | }
64 | }
65 |
66 | struct ModifyOperationResult: Codable, Sendable {
67 | let savedRecordResults: [SavedRecordResult]
68 | let deletedRecordIDResults: [DeletedRecordIDResult]
69 | let modifyResult: ModifyResult
70 |
71 | public init(
72 | savedRecordResults: [SavedRecordResult],
73 | deletedRecordIDResults: [DeletedRecordIDResult],
74 | modifyResult: ModifyResult
75 | ) {
76 | self.savedRecordResults = savedRecordResults
77 | self.deletedRecordIDResults = deletedRecordIDResults
78 | self.modifyResult = modifyResult
79 | }
80 | }
81 |
82 | internal func runModifyOperation(
83 | _ operation: CKModifyRecordsOperation,
84 | operationResult: ModifyOperationResult
85 | ) {
86 | for savedRecordResult in operationResult.savedRecordResults {
87 | operation.perRecordSaveBlock?(savedRecordResult.recordIDArchive.recordIDs.first!, savedRecordResult.result)
88 | }
89 | for deletedRecordResult in operationResult.deletedRecordIDResults {
90 | operation.perRecordDeleteBlock?(deletedRecordResult.recordIDArchive.recordIDs.first!, deletedRecordResult.result)
91 | }
92 | operation.modifyRecordsResultBlock?(operationResult.modifyResult.result)
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/ReplayingMockCKDatabase/ReplayingMockCKDatabase+ModifySubscriptions.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 |
4 | public extension ReplayingMockCKDatabase {
5 | struct SavedSubscriptionResult: Codable, Sendable {
6 | let subscriptionID: CKSubscription.ID
7 | let codableResult: CodableResult
8 |
9 | public init(subscriptionID: CKSubscription.ID, result: Result) {
10 | self.subscriptionID = subscriptionID
11 | switch result {
12 | case let .success(subscription): self.codableResult = .success(CloudKitSubscriptionArchive(subscription: subscription))
13 | case let .failure(error): self.codableResult = .failure(CKSubscriptionError(from: error))
14 | }
15 | }
16 |
17 | var result: Result {
18 | switch codableResult {
19 | case let .success(subscriptionArchive): return .success(subscriptionArchive.subscription)
20 | case let .failure(subscriptionError): return .failure(subscriptionError.ckError)
21 | }
22 | }
23 | }
24 |
25 | struct DeletedSubscriptionIDResult: Codable, Sendable {
26 | let subscriptionID: CKSubscription.ID
27 | let codableResult: CodableResult
28 |
29 | public init(subscriptionID: CKSubscription.ID, result: Result) {
30 | self.subscriptionID = subscriptionID
31 | switch result {
32 | case .success: self.codableResult = .success(CodableVoid())
33 | case let .failure(error): self.codableResult = .failure(CKSubscriptionError(from: error))
34 | }
35 | }
36 |
37 | var result: Result {
38 | switch codableResult {
39 | case .success: return .success(())
40 | case let .failure(subscriptionError): return .failure(subscriptionError.ckError)
41 | }
42 | }
43 | }
44 |
45 | struct ModifySubscriptionsResult: Codable, Sendable {
46 | let codableResult: CodableResult
47 |
48 | public init(result: Result) {
49 | switch result {
50 | case .success: self.codableResult = .success(CodableVoid())
51 | case let .failure(error): self.codableResult = .failure(CKSubscriptionError(from: error))
52 | }
53 | }
54 |
55 | var result: Result {
56 | switch codableResult {
57 | case .success: return .success(())
58 | case let .failure(subscriptionError): return .failure(subscriptionError.ckError)
59 | }
60 | }
61 | }
62 |
63 | struct ModifySubscriptionsOperationResult: Codable, Sendable {
64 | public let savedSubscriptionResults: [SavedSubscriptionResult]
65 | public let deletedSubscriptionIDResults: [DeletedSubscriptionIDResult]
66 | public let modifySubscriptionsResult: ModifySubscriptionsResult
67 |
68 | public init(savedSubscriptionResults: [SavedSubscriptionResult], deletedSubscriptionIDResults: [DeletedSubscriptionIDResult], modifySubscriptionsResult: ModifySubscriptionsResult) {
69 | self.savedSubscriptionResults = savedSubscriptionResults
70 | self.deletedSubscriptionIDResults = deletedSubscriptionIDResults
71 | self.modifySubscriptionsResult = modifySubscriptionsResult
72 | }
73 | }
74 |
75 | internal func runModifySubscriptionsOperation(
76 | _ operation: CKModifySubscriptionsOperation,
77 | operationResult: ModifySubscriptionsOperationResult
78 | ) {
79 | for savedSubscriptionsResult in operationResult.savedSubscriptionResults {
80 | operation.perSubscriptionSaveBlock?(savedSubscriptionsResult.subscriptionID, savedSubscriptionsResult.result)
81 | }
82 | for deletedSubscriptionIDResult in operationResult.deletedSubscriptionIDResults {
83 | operation.perSubscriptionDeleteBlock?(deletedSubscriptionIDResult.subscriptionID, deletedSubscriptionIDResult.result)
84 | }
85 | operation.modifySubscriptionsResultBlock?(operationResult.modifySubscriptionsResult.result)
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/ReplayingMockCKDatabase/ReplayingMockCKDatabase+ModifyZones.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 |
4 | public extension ReplayingMockCKDatabase {
5 | struct SavedZoneResult: Codable, Sendable {
6 | let zoneIDArchive: CloudKitRecordZoneIDArchive
7 | let codableResult: CodableResult
8 |
9 | public init(zoneID: CKRecordZone.ID, result: Result) {
10 | self.zoneIDArchive = CloudKitRecordZoneIDArchive(zoneIDs: [zoneID])
11 | switch result {
12 | case let .success(zone): self.codableResult = .success(CloudKitRecordZoneArchive(zones: [zone]))
13 | case let .failure(error): self.codableResult = .failure(CKRecordZoneError(from: error))
14 | }
15 | }
16 |
17 | var result: Result {
18 | switch codableResult {
19 | case let .success(zoneArchive): return .success(zoneArchive.zones.first!)
20 | case let .failure(zoneError): return .failure(zoneError.ckError)
21 | }
22 | }
23 | }
24 |
25 | struct DeletedZoneIDResult: Codable, Sendable {
26 | let zoneIDArchive: CloudKitRecordZoneIDArchive
27 | let codableResult: CodableResult
28 |
29 | public init(zoneID: CKRecordZone.ID, result: Result) {
30 | self.zoneIDArchive = CloudKitRecordZoneIDArchive(zoneIDs: [zoneID])
31 | switch result {
32 | case .success: self.codableResult = .success(CodableVoid())
33 | case let .failure(error): self.codableResult = .failure(CKRecordZoneError(from: error))
34 | }
35 | }
36 |
37 | var result: Result {
38 | switch codableResult {
39 | case .success: return .success(())
40 | case let .failure(zoneError): return .failure(zoneError.ckError)
41 | }
42 | }
43 | }
44 |
45 | struct ModifyZonesResult: Codable, Sendable {
46 | let codableResult: CodableResult
47 |
48 | public init(result: Result) {
49 | switch result {
50 | case .success: self.codableResult = .success(CodableVoid())
51 | case let .failure(error): self.codableResult = .failure(CKRecordZoneError(from: error))
52 | }
53 | }
54 |
55 | var result: Result {
56 | switch codableResult {
57 | case .success: return .success(())
58 | case let .failure(zoneError): return .failure(zoneError.ckError)
59 | }
60 | }
61 | }
62 |
63 | struct ModifyZonesOperationResult: Codable, Sendable {
64 | public let savedZoneResults: [SavedZoneResult]
65 | public let deletedZoneIDResults: [DeletedZoneIDResult]
66 | public let modifyZonesResult: ModifyZonesResult
67 |
68 | public init(savedZoneResults: [SavedZoneResult], deletedZoneIDResults: [DeletedZoneIDResult], modifyZonesResult: ModifyZonesResult) {
69 | self.savedZoneResults = savedZoneResults
70 | self.deletedZoneIDResults = deletedZoneIDResults
71 | self.modifyZonesResult = modifyZonesResult
72 | }
73 | }
74 |
75 | internal func runModifyZonesOperation(
76 | _ operation: CKModifyRecordZonesOperation,
77 | operationResult: ModifyZonesOperationResult
78 | ) {
79 | for savedZoneResult in operationResult.savedZoneResults {
80 | operation.perRecordZoneSaveBlock?(savedZoneResult.zoneIDArchive.zoneIDs.first!, savedZoneResult.result)
81 | }
82 | for deletedZoneIDResult in operationResult.deletedZoneIDResults {
83 | operation.perRecordZoneDeleteBlock?(deletedZoneIDResult.zoneIDArchive.zoneIDs.first!, deletedZoneIDResult.result)
84 | }
85 | operation.modifyRecordZonesResultBlock?(operationResult.modifyZonesResult.result)
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/ReplayingMockCKDatabase/ReplayingMockCKDatabase+Query.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 |
4 | // Types and functionality for CKQueryOperation results.
5 | public extension ReplayingMockCKDatabase {
6 | /// Result for one record. recordMatchedBlock is called with this. Also used by ReplayingMockCKDatabase+Fetch.
7 | struct QueryRecordResult: Codable, Sendable {
8 | let recordIDArchive: CloudKitRecordIDArchive
9 | let codableResult: CodableResult
10 |
11 | public init(recordID: CKRecord.ID, result: Result) {
12 | self.recordIDArchive = CloudKitRecordIDArchive(recordIDs: [recordID])
13 | switch result {
14 | case let .success(record): self.codableResult = .success(CloudKitRecordArchive(records: [record]))
15 | case let .failure(error): self.codableResult = .failure(CKRecordError(from: error))
16 | }
17 | }
18 |
19 | var recordID: CKRecord.ID {
20 | recordIDArchive.recordIDs.first!
21 | }
22 |
23 | var result: Result {
24 | switch codableResult {
25 | case let .success(recordArchive): return .success(recordArchive.records.first!)
26 | case let .failure(error): return .failure(error.ckError)
27 | }
28 | }
29 | }
30 |
31 | /// Record for the whole query. queryResultBlock is called with this.
32 | struct QueryResult: Codable, Sendable {
33 | let codableResult: CodableResult
34 |
35 | public init(result: Result) {
36 | switch result {
37 | case let .success(maybeCursor):
38 | self.codableResult = .success(CloudKitCursorArchive(cursor: maybeCursor))
39 | case let .failure(error):
40 | self.codableResult = .failure(CKRecordError(from: error))
41 | }
42 | }
43 |
44 | var result: Result {
45 | switch codableResult {
46 | case let .success(archive):
47 | if let archive {
48 | return .success(archive.cursor)
49 | } else {
50 | return .success(nil)
51 | }
52 | case let .failure(error): return .failure(error.ckError)
53 | }
54 | }
55 | }
56 |
57 | struct QueryOperationResult: Codable, Sendable {
58 | public let queryRecordResults: [QueryRecordResult]
59 | public let queryResult: QueryResult
60 |
61 | public init(queryRecordResults: [QueryRecordResult], queryResult: QueryResult) {
62 | self.queryRecordResults = queryRecordResults
63 | self.queryResult = queryResult
64 | }
65 | }
66 |
67 | internal func runQueryOperation(
68 | _ operation: CKQueryOperation,
69 | operationResult: QueryOperationResult,
70 | sleep: Float?
71 | ) async {
72 | for recordResult in operationResult.queryRecordResults {
73 | operation.recordMatchedBlock?(recordResult.recordID, recordResult.result)
74 | }
75 | if let sleep {
76 | try? await Task.sleep(nanoseconds: UInt64(sleep * Float(NSEC_PER_SEC)))
77 | }
78 | operation.queryResultBlock?(operationResult.queryResult.result)
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/ReplayingMockCKDatabase/ReplayingMockCKDatabase.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 | import Foundation
4 |
5 | /// Mock of CKDatabase, suitable for running CKModifyOperation tests.
6 | public actor ReplayingMockCKDatabase {
7 | /// How many `add` calls were made to this database.
8 | public private(set) var operationsRun = 0
9 |
10 | /// Whether to sleep in the operations where sleep has been enabled.
11 | let sleep: Float?
12 |
13 | public enum OperationResult: Codable, Sendable {
14 | case modify(ModifyOperationResult)
15 | case query(QueryOperationResult)
16 | case fetch(FetchOperationResult)
17 | case modifyZones(ModifyZonesOperationResult)
18 | case fetchZones(FetchZonesOperationResult)
19 | case modifySubscriptions(ModifySubscriptionsOperationResult)
20 | case fetchDatabaseChanges(FetchDatabaseChangesOperationResult)
21 | case fetchZoneChanges(FetchZoneChangesOperationResult)
22 | }
23 |
24 | var operationResults: [OperationResult]
25 |
26 | public init(
27 | operationResults: [OperationResult] = [],
28 | sleep: Float? = nil
29 | ) {
30 | self.operationResults = operationResults
31 | self.sleep = sleep
32 | }
33 |
34 | func privateAdd(_ operation: CKDatabaseOperation) async {
35 | guard let operationResult = operationResults.first else {
36 | fatalError("Asked to run operation without an available result. Operation: \(operation)")
37 | }
38 | operationResults.removeFirst()
39 | operationsRun += 1
40 |
41 | switch (operationResult, operation) {
42 | case let (.modify(modifyOperationResult), modifyOperation as CKModifyRecordsOperation):
43 | runModifyOperation(modifyOperation, operationResult: modifyOperationResult)
44 | case let (.query(queryOperationResult), queryOperation as CKQueryOperation):
45 | await runQueryOperation(queryOperation, operationResult: queryOperationResult, sleep: sleep)
46 | case let (.fetch(fetchOperationResult), fetchOperation as CKFetchRecordsOperation):
47 | runFetchOperation(fetchOperation, operationResult: fetchOperationResult)
48 | case let (.modifyZones(modifyZonesOperationResult), modifyZonesOperation as CKModifyRecordZonesOperation):
49 | runModifyZonesOperation(modifyZonesOperation, operationResult: modifyZonesOperationResult)
50 | case let (.fetchZones(fetchZonesOperationResult), fetchZonesOperation as CKFetchRecordZonesOperation):
51 | runFetchZonesOperation(fetchZonesOperation, operationResult: fetchZonesOperationResult)
52 | case let (.modifySubscriptions(modifySubscriptionsOperationResult), modifySubscriptionsOperation as CKModifySubscriptionsOperation):
53 | runModifySubscriptionsOperation(modifySubscriptionsOperation, operationResult: modifySubscriptionsOperationResult)
54 | case let (.fetchDatabaseChanges(fetchDatabaseChangesOperationResult), fetchDatabaseChangesOperation as CKFetchDatabaseChangesOperation):
55 | await runFetchDatabaseChangesOperation(
56 | fetchDatabaseChangesOperation,
57 | operationResult: fetchDatabaseChangesOperationResult,
58 | sleep: sleep
59 | )
60 | case let (.fetchZoneChanges(fetchZoneChangesOperationResult), fetchZoneChangesOperation as CKFetchRecordZoneChangesOperation):
61 | await runFetchZoneChangesOperation(
62 | fetchZoneChangesOperation,
63 | operationResult: fetchZoneChangesOperationResult,
64 | sleep: sleep
65 | )
66 | default:
67 | fatalError("Dequeued operation and result do not match. Result: \(operationResult), operation: \(operation)")
68 | }
69 | }
70 | }
71 |
72 | extension ReplayingMockCKDatabase: CKDatabaseType {
73 | public nonisolated func add(_ operation: CKDatabaseOperation) {
74 | Task {
75 | await privateAdd(operation)
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/ReplayingMockContainer/ReplayingMockContainer+Types.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 |
4 | public extension ReplayingMockContainer {
5 | struct UserRecordIDResult: Codable, Sendable {
6 | let userRecordIDArchive: CloudKitRecordIDArchive?
7 | let recordError: CKRecordError?
8 |
9 | public init(userRecordID: CKRecord.ID? = nil, error: CKRecordError? = nil) {
10 | if let userRecordID {
11 | self.userRecordIDArchive = CloudKitRecordIDArchive(recordIDs: [userRecordID])
12 | } else {
13 | self.userRecordIDArchive = nil
14 | }
15 | if let error {
16 | self.recordError = error
17 | } else {
18 | self.recordError = nil
19 | }
20 | }
21 | }
22 |
23 | struct AccountStatusResult: Codable, Sendable {
24 | let statusValue: Int
25 | let canopyError: CanopyError?
26 |
27 | public init(status: CKAccountStatus, error: CanopyError?) {
28 | self.statusValue = status.rawValue
29 | if let error {
30 | self.canopyError = error
31 | } else {
32 | self.canopyError = nil
33 | }
34 | }
35 | }
36 |
37 | struct AccountStatusStreamResult: Codable, Sendable {
38 | let statusValues: [Int]
39 | let error: CKContainerAPIError?
40 | public init(statuses: [CKAccountStatus], error: CKContainerAPIError?) {
41 | statusValues = statuses.map { $0.rawValue }
42 | self.error = error
43 | }
44 | }
45 |
46 | struct AcceptSharesResult: Codable, Sendable {
47 | let result: CodableResult
48 | public init(result: Result<[CKShare], CKRecordError>) {
49 | switch result {
50 | case .success(let shares): self.result = .success(CloudKitShareArchive(shares: shares))
51 | case .failure(let error): self.result = .failure(error)
52 | }
53 | }
54 | }
55 |
56 | struct FetchShareParticipantsResult: Codable, Sendable {
57 | let result: CodableResult
58 | public init(result: Result<[CKShare.Participant], CKRecordError>) {
59 | switch result {
60 | case .success(let participants): self.result = .success(CloudKitShareParticipantArchive(shareParticipants: participants))
61 | case .failure(let error): self.result = .failure(error)
62 | }
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/ReplayingMockContainer/ReplayingMockContainer.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 |
4 | public actor ReplayingMockContainer: Sendable {
5 | public enum OperationResult: Codable, Sendable {
6 | case userRecordID(UserRecordIDResult)
7 | case accountStatus(AccountStatusResult)
8 | case accountStatusStream(AccountStatusStreamResult)
9 | case fetchShareParticipants(FetchShareParticipantsResult)
10 | case acceptShares(AcceptSharesResult)
11 | }
12 |
13 | private var operationResults: [OperationResult] = []
14 | private var userRecordIDOperationResults: [OperationResult] = []
15 | private var accountStatusOperationResults: [OperationResult] = []
16 |
17 | /// How many operations were tun in this container.
18 | public private(set) var operationsRun = 0
19 |
20 | public init(
21 | operationResults: [OperationResult] = []
22 | ) {
23 | for operation in operationResults {
24 | switch operation {
25 | case .userRecordID: userRecordIDOperationResults.append(operation)
26 | case .accountStatus: accountStatusOperationResults.append(operation)
27 | default: self.operationResults.append(operation)
28 | }
29 | }
30 | }
31 | }
32 |
33 | extension ReplayingMockContainer: CKContainerAPIType {
34 | public var userRecordID: Result {
35 | get async {
36 | let operationResult = userRecordIDOperationResults.removeFirst()
37 | guard case let .userRecordID(result) = operationResult else {
38 | fatalError("Asked to fetch user record ID without an available result or invalid result type. Likely a logic error on caller side")
39 | }
40 | operationsRun += 1
41 | if let error = result.recordError {
42 | return .failure(error)
43 | } else {
44 | return .success(result.userRecordIDArchive!.recordIDs.first!)
45 | }
46 | }
47 | }
48 |
49 | public var accountStatus: Result {
50 | get async {
51 | let operationResult = accountStatusOperationResults.removeFirst()
52 | guard case let .accountStatus(result) = operationResult else {
53 | fatalError("Asked for account status without an available result or invalid result type. Likely a logic error on caller side")
54 | }
55 | operationsRun += 1
56 | if let error = result.canopyError {
57 | return .failure(error)
58 | } else {
59 | guard let status = CKAccountStatus(rawValue: result.statusValue) else {
60 | fatalError("Could not recreate CKAccountStatus from value \(result.statusValue)")
61 | }
62 | return .success(status)
63 | }
64 | }
65 | }
66 |
67 | public var accountStatusStream: Result, CKContainerAPIError> {
68 | get async {
69 | let operationResult = operationResults.removeFirst()
70 | guard case let .accountStatusStream(result) = operationResult else {
71 | fatalError("Asked for account status stream without an available result or invalid result type. Likely a logic error on caller side")
72 | }
73 | operationsRun += 1
74 | if let error = result.error {
75 | return .failure(error)
76 | } else {
77 | return .success(AsyncStream {
78 | for statusCode in result.statusValues {
79 | $0.yield(CKAccountStatus(rawValue: statusCode)!)
80 | }
81 | })
82 | }
83 | }
84 | }
85 |
86 | public func acceptShares(
87 | with metadatas: [CKShare.Metadata],
88 | qos: QualityOfService
89 | ) async -> Result<[CKShare], CKRecordError> {
90 | let operationResult = operationResults.removeFirst()
91 | guard case let .acceptShares(result) = operationResult else {
92 | fatalError("Asked to fetch user record ID without an available result or invalid result type. Likely a logic error on caller side")
93 | }
94 | operationsRun += 1
95 | switch result.result {
96 | case .success(let recordArchive):
97 | return .success(recordArchive.shares)
98 | case .failure(let e):
99 | return .failure(e)
100 | }
101 | }
102 |
103 | public func fetchShareParticipants(
104 | with lookupInfos: [CKUserIdentity.LookupInfo],
105 | qos: QualityOfService
106 | ) async -> Result<[CKShare.Participant], CKRecordError> {
107 | let operationResult = operationResults.removeFirst()
108 | guard case let .fetchShareParticipants(result) = operationResult else {
109 | fatalError("Asked to fetch user record ID without an available result or invalid result type. Likely a logic error on caller side")
110 | }
111 | operationsRun += 1
112 | switch result.result {
113 | case .success(let participantArchive):
114 | return .success(participantArchive.shareParticipants)
115 | case .failure(let e):
116 | return .failure(e)
117 | }
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/Targets/CanopyTestTools/Sources/ReplayingMockDatabase/ReplayingMockDatabase+Types.swift:
--------------------------------------------------------------------------------
1 | import Canopy
2 | import CloudKit
3 |
4 | public extension ReplayingMockDatabase {
5 | struct QueryRecordsOperationResult: Codable, Sendable {
6 | let result: CodableResult<[CanopyResultRecord], CKRecordError>
7 | public init(result: Result<[CanopyResultRecord], CKRecordError>) {
8 | switch result {
9 | case .success(let records): self.result = .success(records)
10 | case .failure(let error): self.result = .failure(error)
11 | }
12 | }
13 | }
14 |
15 | struct ModifyRecordsOperationResult: Codable, Sendable {
16 | let result: CodableResult
17 | public init(result: Result) {
18 | switch result {
19 | case .success(let modifyResult): self.result = .success(modifyResult)
20 | case .failure(let error): self.result = .failure(error)
21 | }
22 | }
23 | }
24 |
25 | struct FetchRecordsOperationResult: Codable, Sendable {
26 | let result: CodableResult
27 | public init(result: Result) {
28 | switch result {
29 | case .success(let fetchResult): self.result = .success(fetchResult)
30 | case .failure(let error): self.result = .failure(error)
31 | }
32 | }
33 | }
34 |
35 | struct ModifyZonesOperationResult: Codable, Sendable {
36 | let result: CodableResult
37 | public init(result: Result) {
38 | switch result {
39 | case .success(let modifyResult): self.result = .success(modifyResult)
40 | case .failure(let error): self.result = .failure(error)
41 | }
42 | }
43 | }
44 |
45 | struct FetchZonesOperationResult: Codable, Sendable {
46 | let result: CodableResult
47 | public init(result: Result<[CKRecordZone], CKRecordZoneError>) {
48 | switch result {
49 | case .success(let zones): self.result = .success(.init(zones: zones))
50 | case .failure(let recordZoneError): self.result = .failure(recordZoneError)
51 | }
52 | }
53 | }
54 |
55 | struct ModifySubscriptionsOperationResult: Codable, Sendable {
56 | let result: CodableResult
57 | public init(result: Result) {
58 | switch result {
59 | case .success(let modifyResult): self.result = .success(modifyResult)
60 | case .failure(let error): self.result = .failure(error)
61 | }
62 | }
63 | }
64 |
65 | struct FetchDatabaseChangesOperationResult: Codable, Sendable {
66 | let result: CodableResult
67 | public init(result: Result) {
68 | switch result {
69 | case .success(let fetchResult): self.result = .success(fetchResult)
70 | case .failure(let error): self.result = .failure(error)
71 | }
72 | }
73 |
74 | /// Useful to use in tests and previews where you don’t need to inject any results, to save some typing.
75 | public static let blank = FetchDatabaseChangesOperationResult(
76 | result: .success(
77 | FetchDatabaseChangesResult(
78 | changedRecordZoneIDs: [],
79 | deletedRecordZoneIDs: [],
80 | purgedRecordZoneIDs: []
81 | )
82 | )
83 | )
84 | }
85 |
86 | struct FetchZoneChangesOperationResult: Codable, Sendable {
87 | let result: CodableResult
88 | public init(result: Result) {
89 | switch result {
90 | case .success(let fetchResult): self.result = .success(fetchResult)
91 | case .failure(let error): self.result = .failure(error)
92 | }
93 | }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/justfile:
--------------------------------------------------------------------------------
1 | doc-preview:
2 | SPI_BUILDER=1 swift package --disable-sandbox preview-documentation \
3 | --enable-experimental-combined-documentation \
4 | --target Canopy --target CanopyTestTools
5 |
6 | doc-build:
7 | SPI_BUILDER=1 swift package \
8 | generate-documentation \
9 | --enable-experimental-combined-documentation \
10 | --target Canopy --target CanopyTestTools \
11 | --disable-indexing \
12 | --transform-for-static-hosting
13 |
14 | # How to locally serve it as a test.
15 | # python3 -m http.server 8000 -d .build/plugins/Swift-DocC/outputs/Canopy.doccarchive
16 | # To test in local browser, open this URL:
17 | # http://localhost:8000/documentation/Canopy/
18 |
19 | doc-deploy: (doc-build)
20 | rsync -azhv --delete .build/plugins/Swift-DocC/outputs/Canopy.doccarchive/ canopy-docs.justtact.com:/var/www/canopy-docs
21 |
--------------------------------------------------------------------------------