├── .github └── workflows │ ├── ci.yml │ └── format.yml ├── .gitignore ├── Cirrus.xcodeproj ├── project.pbxproj └── xcshareddata │ └── xcschemes │ └── Cirrus-Package.xcscheme ├── Example ├── CirrusExample.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── CirrusExample.xcscheme └── CirrusExample │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── Base.lproj │ └── LaunchScreen.storyboard │ ├── CirrusExample.entitlements │ ├── Info.plist │ ├── Models │ └── Bookmark.swift │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ ├── SceneDelegate.swift │ ├── State Management │ ├── AppAction.swift │ ├── AppReducer.swift │ ├── AppState.swift │ ├── PersistentStore.swift │ └── Store.swift │ ├── Sync │ └── AppSyncManager.swift │ ├── Test Data │ └── TestURLs.swift │ └── Views │ ├── BookmarkRow.swift │ ├── ContentView.swift │ └── MultipleSelectionRow.swift ├── LICENSE ├── Makefile ├── Package.swift ├── README.md ├── Sources ├── CKRecordCoder │ ├── CKRecordDecoder.swift │ ├── CKRecordEncoder.swift │ ├── CKRecordEncodingError.swift │ ├── CKRecordKeyedDecodingContainer.swift │ ├── CKRecordKeyedEncodingContainer.swift │ ├── CKRecordSingleValueDecoder.swift │ ├── CKRecordSingleValueEncoder.swift │ ├── CloudKitCodable+RecordType.swift │ ├── CloudKitSystemFieldsKeyName.swift │ └── URLTransformer.swift ├── Cirrus │ ├── DeleteRecordContext.swift │ ├── Error+CloudKit.swift │ ├── RecordModifyingContext.swift │ ├── SyncEngine+AccountStatus.swift │ ├── SyncEngine+RecordModification.swift │ ├── SyncEngine+RemoteChangeTracking.swift │ ├── SyncEngine+Subscription.swift │ ├── SyncEngine+Zone.swift │ ├── SyncEngine.swift │ └── UploadRecordContext.swift └── CloudKitCodable │ ├── CloudKitCodable+LastModifiedDate.swift │ └── CloudKitCodable.swift └── Tests └── CKRecordCoderTests ├── CKRecordDecoderTests.swift ├── CKRecordEncoderDecoderRoundTripTests.swift ├── CKRecordEncoderTests.swift └── Mocks ├── Bookmark.swift ├── Numbers.swift ├── ParentChild.swift ├── Person.swift ├── URLModel.swift └── UUIDModel.swift /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | mac: 7 | name: macOS 8 | runs-on: macOS-12 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Select Xcode 13.3 12 | run: sudo xcode-select -s /Applications/Xcode_13.3.app 13 | - name: Run tests 14 | run: make test-swift -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Format 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | swift_format: 11 | name: swift-format 12 | runs-on: macOS-12 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Select Xcode 13.3 16 | run: sudo xcode-select -s /Applications/Xcode_13.3.app 17 | - name: Install 18 | run: brew install swift-format 19 | - name: Format 20 | run: make format 21 | - uses: stefanzweifel/git-auto-commit-action@v4 22 | with: 23 | commit_message: Run swift-format 24 | branch: 'main' 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/swift,xcode 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=swift,xcode 4 | 5 | ### Swift ### 6 | # Xcode 7 | # 8 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 9 | 10 | ## User settings 11 | xcuserdata/ 12 | 13 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 14 | *.xcscmblueprint 15 | *.xccheckout 16 | 17 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 18 | build/ 19 | DerivedData/ 20 | *.moved-aside 21 | *.pbxuser 22 | !default.pbxuser 23 | *.mode1v3 24 | !default.mode1v3 25 | *.mode2v3 26 | !default.mode2v3 27 | *.perspectivev3 28 | !default.perspectivev3 29 | 30 | ## Obj-C/Swift specific 31 | *.hmap 32 | 33 | ## App packaging 34 | *.ipa 35 | *.dSYM.zip 36 | *.dSYM 37 | 38 | ## Playgrounds 39 | timeline.xctimeline 40 | playground.xcworkspace 41 | 42 | # Swift Package Manager 43 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 44 | # Packages/ 45 | # Package.pins 46 | # Package.resolved 47 | # *.xcodeproj 48 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 49 | # hence it is not needed unless you have added a package configuration file to your project 50 | # .swiftpm 51 | 52 | .build/ 53 | 54 | # CocoaPods 55 | # We recommend against adding the Pods directory to your .gitignore. However 56 | # you should judge for yourself, the pros and cons are mentioned at: 57 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 58 | # Pods/ 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 64 | # Carthage/Checkouts 65 | 66 | Carthage/Build/ 67 | 68 | # Accio dependency management 69 | Dependencies/ 70 | .accio/ 71 | 72 | # fastlane 73 | # It is recommended to not store the screenshots in the git repo. 74 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 75 | # For more information about the recommended setup visit: 76 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 77 | 78 | fastlane/report.xml 79 | fastlane/Preview.html 80 | fastlane/screenshots/**/*.png 81 | fastlane/test_output 82 | 83 | # Code Injection 84 | # After new code Injection tools there's a generated folder /iOSInjectionProject 85 | # https://github.com/johnno1962/injectionforxcode 86 | 87 | iOSInjectionProject/ 88 | 89 | ### Xcode ### 90 | # Xcode 91 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 92 | 93 | 94 | 95 | 96 | ## Gcc Patch 97 | /*.gcno 98 | 99 | ### Xcode Patch ### 100 | *.xcodeproj/* 101 | !*.xcodeproj/project.pbxproj 102 | !*.xcodeproj/xcshareddata/ 103 | !*.xcworkspace/contents.xcworkspacedata 104 | **/xcshareddata/WorkspaceSettings.xcsettings 105 | 106 | # End of https://www.toptal.com/developers/gitignore/api/swift,xcode -------------------------------------------------------------------------------- /Cirrus.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 46; 7 | objects = { 8 | 9 | /* Begin PBXAggregateTarget section */ 10 | "Cirrus::CirrusPackageTests::ProductTarget" /* CirrusPackageTests */ = { 11 | isa = PBXAggregateTarget; 12 | buildConfigurationList = OBJ_119 /* Build configuration list for PBXAggregateTarget "CirrusPackageTests" */; 13 | buildPhases = ( 14 | ); 15 | dependencies = ( 16 | OBJ_122 /* PBXTargetDependency */, 17 | ); 18 | name = CirrusPackageTests; 19 | productName = CirrusPackageTests; 20 | }; 21 | /* End PBXAggregateTarget section */ 22 | 23 | /* Begin PBXBuildFile section */ 24 | OBJ_100 /* SyncEngine+AccountStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_23 /* SyncEngine+AccountStatus.swift */; }; 25 | OBJ_101 /* SyncEngine+RecordModification.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_24 /* SyncEngine+RecordModification.swift */; }; 26 | OBJ_102 /* SyncEngine+RemoteChangeTracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_25 /* SyncEngine+RemoteChangeTracking.swift */; }; 27 | OBJ_103 /* SyncEngine+Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_26 /* SyncEngine+Subscription.swift */; }; 28 | OBJ_104 /* SyncEngine+Zone.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_27 /* SyncEngine+Zone.swift */; }; 29 | OBJ_105 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_28 /* SyncEngine.swift */; }; 30 | OBJ_106 /* UploadRecordContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_29 /* UploadRecordContext.swift */; }; 31 | OBJ_108 /* CKRecordCoder.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = "Cirrus::CKRecordCoder::Product" /* CKRecordCoder.framework */; }; 32 | OBJ_109 /* CloudKitCodable.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = "Cirrus::CloudKitCodable::Product" /* CloudKitCodable.framework */; }; 33 | OBJ_117 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_6 /* Package.swift */; }; 34 | OBJ_127 /* CloudKitCodable+LastModifiedDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_31 /* CloudKitCodable+LastModifiedDate.swift */; }; 35 | OBJ_128 /* CloudKitCodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_32 /* CloudKitCodable.swift */; }; 36 | OBJ_59 /* CKRecordDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_9 /* CKRecordDecoder.swift */; }; 37 | OBJ_60 /* CKRecordEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_10 /* CKRecordEncoder.swift */; }; 38 | OBJ_61 /* CKRecordEncodingError.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_11 /* CKRecordEncodingError.swift */; }; 39 | OBJ_62 /* CKRecordKeyedDecodingContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_12 /* CKRecordKeyedDecodingContainer.swift */; }; 40 | OBJ_63 /* CKRecordKeyedEncodingContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_13 /* CKRecordKeyedEncodingContainer.swift */; }; 41 | OBJ_64 /* CKRecordSingleValueDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_14 /* CKRecordSingleValueDecoder.swift */; }; 42 | OBJ_65 /* CKRecordSingleValueEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_15 /* CKRecordSingleValueEncoder.swift */; }; 43 | OBJ_66 /* CloudKitCodable+RecordType.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_16 /* CloudKitCodable+RecordType.swift */; }; 44 | OBJ_67 /* CloudKitSystemFieldsKeyName.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_17 /* CloudKitSystemFieldsKeyName.swift */; }; 45 | OBJ_68 /* URLTransformer.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_18 /* URLTransformer.swift */; }; 46 | OBJ_70 /* CloudKitCodable.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = "Cirrus::CloudKitCodable::Product" /* CloudKitCodable.framework */; }; 47 | OBJ_78 /* CKRecordDecoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_35 /* CKRecordDecoderTests.swift */; }; 48 | OBJ_79 /* CKRecordEncoderDecoderRoundTripTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_36 /* CKRecordEncoderDecoderRoundTripTests.swift */; }; 49 | OBJ_80 /* CKRecordEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_37 /* CKRecordEncoderTests.swift */; }; 50 | OBJ_81 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_39 /* Bookmark.swift */; }; 51 | OBJ_82 /* Numbers.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_40 /* Numbers.swift */; }; 52 | OBJ_83 /* ParentChild.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_41 /* ParentChild.swift */; }; 53 | OBJ_84 /* Person.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_42 /* Person.swift */; }; 54 | OBJ_85 /* URLModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_43 /* URLModel.swift */; }; 55 | OBJ_86 /* UUIDModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_44 /* UUIDModel.swift */; }; 56 | OBJ_88 /* CKRecordCoder.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = "Cirrus::CKRecordCoder::Product" /* CKRecordCoder.framework */; }; 57 | OBJ_89 /* CloudKitCodable.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = "Cirrus::CloudKitCodable::Product" /* CloudKitCodable.framework */; }; 58 | OBJ_97 /* DeleteRecordContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_20 /* DeleteRecordContext.swift */; }; 59 | OBJ_98 /* Error+CloudKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_21 /* Error+CloudKit.swift */; }; 60 | OBJ_99 /* RecordModifyingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_22 /* RecordModifyingContext.swift */; }; 61 | /* End PBXBuildFile section */ 62 | 63 | /* Begin PBXContainerItemProxy section */ 64 | E501325B24A8C3380055FF6E /* PBXContainerItemProxy */ = { 65 | isa = PBXContainerItemProxy; 66 | containerPortal = OBJ_1 /* Project object */; 67 | proxyType = 1; 68 | remoteGlobalIDString = "Cirrus::CKRecordCoder"; 69 | remoteInfo = CKRecordCoder; 70 | }; 71 | E501325C24A8C3380055FF6E /* PBXContainerItemProxy */ = { 72 | isa = PBXContainerItemProxy; 73 | containerPortal = OBJ_1 /* Project object */; 74 | proxyType = 1; 75 | remoteGlobalIDString = "Cirrus::CloudKitCodable"; 76 | remoteInfo = CloudKitCodable; 77 | }; 78 | E501325D24A8C3380055FF6E /* PBXContainerItemProxy */ = { 79 | isa = PBXContainerItemProxy; 80 | containerPortal = OBJ_1 /* Project object */; 81 | proxyType = 1; 82 | remoteGlobalIDString = "Cirrus::CloudKitCodable"; 83 | remoteInfo = CloudKitCodable; 84 | }; 85 | E501325E24A8C3390055FF6E /* PBXContainerItemProxy */ = { 86 | isa = PBXContainerItemProxy; 87 | containerPortal = OBJ_1 /* Project object */; 88 | proxyType = 1; 89 | remoteGlobalIDString = "Cirrus::CKRecordCoder"; 90 | remoteInfo = CKRecordCoder; 91 | }; 92 | E501325F24A8C3390055FF6E /* PBXContainerItemProxy */ = { 93 | isa = PBXContainerItemProxy; 94 | containerPortal = OBJ_1 /* Project object */; 95 | proxyType = 1; 96 | remoteGlobalIDString = "Cirrus::CloudKitCodable"; 97 | remoteInfo = CloudKitCodable; 98 | }; 99 | E501326324A8C3780055FF6E /* PBXContainerItemProxy */ = { 100 | isa = PBXContainerItemProxy; 101 | containerPortal = OBJ_1 /* Project object */; 102 | proxyType = 1; 103 | remoteGlobalIDString = "Cirrus::CKRecordCoderTests"; 104 | remoteInfo = CKRecordCoderTests; 105 | }; 106 | /* End PBXContainerItemProxy section */ 107 | 108 | /* Begin PBXFileReference section */ 109 | "Cirrus::CKRecordCoder::Product" /* CKRecordCoder.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = CKRecordCoder.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 110 | "Cirrus::CKRecordCoderTests::Product" /* CKRecordCoderTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; path = CKRecordCoderTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 111 | "Cirrus::Cirrus::Product" /* Cirrus.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Cirrus.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 112 | "Cirrus::CloudKitCodable::Product" /* CloudKitCodable.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = CloudKitCodable.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 113 | OBJ_10 /* CKRecordEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CKRecordEncoder.swift; sourceTree = ""; }; 114 | OBJ_11 /* CKRecordEncodingError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CKRecordEncodingError.swift; sourceTree = ""; }; 115 | OBJ_12 /* CKRecordKeyedDecodingContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CKRecordKeyedDecodingContainer.swift; sourceTree = ""; }; 116 | OBJ_13 /* CKRecordKeyedEncodingContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CKRecordKeyedEncodingContainer.swift; sourceTree = ""; }; 117 | OBJ_14 /* CKRecordSingleValueDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CKRecordSingleValueDecoder.swift; sourceTree = ""; }; 118 | OBJ_15 /* CKRecordSingleValueEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CKRecordSingleValueEncoder.swift; sourceTree = ""; }; 119 | OBJ_16 /* CloudKitCodable+RecordType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudKitCodable+RecordType.swift"; sourceTree = ""; }; 120 | OBJ_17 /* CloudKitSystemFieldsKeyName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitSystemFieldsKeyName.swift; sourceTree = ""; }; 121 | OBJ_18 /* URLTransformer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTransformer.swift; sourceTree = ""; }; 122 | OBJ_20 /* DeleteRecordContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteRecordContext.swift; sourceTree = ""; }; 123 | OBJ_21 /* Error+CloudKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Error+CloudKit.swift"; sourceTree = ""; }; 124 | OBJ_22 /* RecordModifyingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordModifyingContext.swift; sourceTree = ""; }; 125 | OBJ_23 /* SyncEngine+AccountStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncEngine+AccountStatus.swift"; sourceTree = ""; }; 126 | OBJ_24 /* SyncEngine+RecordModification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncEngine+RecordModification.swift"; sourceTree = ""; }; 127 | OBJ_25 /* SyncEngine+RemoteChangeTracking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncEngine+RemoteChangeTracking.swift"; sourceTree = ""; }; 128 | OBJ_26 /* SyncEngine+Subscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncEngine+Subscription.swift"; sourceTree = ""; }; 129 | OBJ_27 /* SyncEngine+Zone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncEngine+Zone.swift"; sourceTree = ""; }; 130 | OBJ_28 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = ""; }; 131 | OBJ_29 /* UploadRecordContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadRecordContext.swift; sourceTree = ""; }; 132 | OBJ_31 /* CloudKitCodable+LastModifiedDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudKitCodable+LastModifiedDate.swift"; sourceTree = ""; }; 133 | OBJ_32 /* CloudKitCodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitCodable.swift; sourceTree = ""; }; 134 | OBJ_35 /* CKRecordDecoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CKRecordDecoderTests.swift; sourceTree = ""; }; 135 | OBJ_36 /* CKRecordEncoderDecoderRoundTripTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CKRecordEncoderDecoderRoundTripTests.swift; sourceTree = ""; }; 136 | OBJ_37 /* CKRecordEncoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CKRecordEncoderTests.swift; sourceTree = ""; }; 137 | OBJ_39 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = ""; }; 138 | OBJ_40 /* Numbers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Numbers.swift; sourceTree = ""; }; 139 | OBJ_41 /* ParentChild.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParentChild.swift; sourceTree = ""; }; 140 | OBJ_42 /* Person.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Person.swift; sourceTree = ""; }; 141 | OBJ_43 /* URLModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLModel.swift; sourceTree = ""; }; 142 | OBJ_44 /* UUIDModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UUIDModel.swift; sourceTree = ""; }; 143 | OBJ_50 /* Example */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Example; sourceTree = SOURCE_ROOT; }; 144 | OBJ_51 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; 145 | OBJ_52 /* Makefile */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.make; path = Makefile; sourceTree = ""; }; 146 | OBJ_53 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 147 | OBJ_6 /* Package.swift */ = {isa = PBXFileReference; explicitFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; 148 | OBJ_9 /* CKRecordDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CKRecordDecoder.swift; sourceTree = ""; }; 149 | /* End PBXFileReference section */ 150 | 151 | /* Begin PBXFrameworksBuildPhase section */ 152 | OBJ_107 /* Frameworks */ = { 153 | isa = PBXFrameworksBuildPhase; 154 | buildActionMask = 0; 155 | files = ( 156 | OBJ_108 /* CKRecordCoder.framework in Frameworks */, 157 | OBJ_109 /* CloudKitCodable.framework in Frameworks */, 158 | ); 159 | runOnlyForDeploymentPostprocessing = 0; 160 | }; 161 | OBJ_129 /* Frameworks */ = { 162 | isa = PBXFrameworksBuildPhase; 163 | buildActionMask = 0; 164 | files = ( 165 | ); 166 | runOnlyForDeploymentPostprocessing = 0; 167 | }; 168 | OBJ_69 /* Frameworks */ = { 169 | isa = PBXFrameworksBuildPhase; 170 | buildActionMask = 0; 171 | files = ( 172 | OBJ_70 /* CloudKitCodable.framework in Frameworks */, 173 | ); 174 | runOnlyForDeploymentPostprocessing = 0; 175 | }; 176 | OBJ_87 /* Frameworks */ = { 177 | isa = PBXFrameworksBuildPhase; 178 | buildActionMask = 0; 179 | files = ( 180 | OBJ_88 /* CKRecordCoder.framework in Frameworks */, 181 | OBJ_89 /* CloudKitCodable.framework in Frameworks */, 182 | ); 183 | runOnlyForDeploymentPostprocessing = 0; 184 | }; 185 | /* End PBXFrameworksBuildPhase section */ 186 | 187 | /* Begin PBXGroup section */ 188 | OBJ_19 /* Cirrus */ = { 189 | isa = PBXGroup; 190 | children = ( 191 | OBJ_20 /* DeleteRecordContext.swift */, 192 | OBJ_21 /* Error+CloudKit.swift */, 193 | OBJ_22 /* RecordModifyingContext.swift */, 194 | OBJ_23 /* SyncEngine+AccountStatus.swift */, 195 | OBJ_24 /* SyncEngine+RecordModification.swift */, 196 | OBJ_25 /* SyncEngine+RemoteChangeTracking.swift */, 197 | OBJ_26 /* SyncEngine+Subscription.swift */, 198 | OBJ_27 /* SyncEngine+Zone.swift */, 199 | OBJ_28 /* SyncEngine.swift */, 200 | OBJ_29 /* UploadRecordContext.swift */, 201 | ); 202 | name = Cirrus; 203 | path = Sources/Cirrus; 204 | sourceTree = SOURCE_ROOT; 205 | }; 206 | OBJ_30 /* CloudKitCodable */ = { 207 | isa = PBXGroup; 208 | children = ( 209 | OBJ_31 /* CloudKitCodable+LastModifiedDate.swift */, 210 | OBJ_32 /* CloudKitCodable.swift */, 211 | ); 212 | name = CloudKitCodable; 213 | path = Sources/CloudKitCodable; 214 | sourceTree = SOURCE_ROOT; 215 | }; 216 | OBJ_33 /* Tests */ = { 217 | isa = PBXGroup; 218 | children = ( 219 | OBJ_34 /* CKRecordCoderTests */, 220 | ); 221 | name = Tests; 222 | sourceTree = SOURCE_ROOT; 223 | }; 224 | OBJ_34 /* CKRecordCoderTests */ = { 225 | isa = PBXGroup; 226 | children = ( 227 | OBJ_35 /* CKRecordDecoderTests.swift */, 228 | OBJ_36 /* CKRecordEncoderDecoderRoundTripTests.swift */, 229 | OBJ_37 /* CKRecordEncoderTests.swift */, 230 | OBJ_38 /* Mocks */, 231 | ); 232 | name = CKRecordCoderTests; 233 | path = Tests/CKRecordCoderTests; 234 | sourceTree = SOURCE_ROOT; 235 | }; 236 | OBJ_38 /* Mocks */ = { 237 | isa = PBXGroup; 238 | children = ( 239 | OBJ_39 /* Bookmark.swift */, 240 | OBJ_40 /* Numbers.swift */, 241 | OBJ_41 /* ParentChild.swift */, 242 | OBJ_42 /* Person.swift */, 243 | OBJ_43 /* URLModel.swift */, 244 | OBJ_44 /* UUIDModel.swift */, 245 | ); 246 | path = Mocks; 247 | sourceTree = ""; 248 | }; 249 | OBJ_45 /* Products */ = { 250 | isa = PBXGroup; 251 | children = ( 252 | "Cirrus::CKRecordCoderTests::Product" /* CKRecordCoderTests.xctest */, 253 | "Cirrus::CloudKitCodable::Product" /* CloudKitCodable.framework */, 254 | "Cirrus::CKRecordCoder::Product" /* CKRecordCoder.framework */, 255 | "Cirrus::Cirrus::Product" /* Cirrus.framework */, 256 | ); 257 | name = Products; 258 | sourceTree = BUILT_PRODUCTS_DIR; 259 | }; 260 | OBJ_5 = { 261 | isa = PBXGroup; 262 | children = ( 263 | OBJ_6 /* Package.swift */, 264 | OBJ_7 /* Sources */, 265 | OBJ_33 /* Tests */, 266 | OBJ_45 /* Products */, 267 | OBJ_50 /* Example */, 268 | OBJ_51 /* LICENSE */, 269 | OBJ_52 /* Makefile */, 270 | OBJ_53 /* README.md */, 271 | ); 272 | sourceTree = ""; 273 | }; 274 | OBJ_7 /* Sources */ = { 275 | isa = PBXGroup; 276 | children = ( 277 | OBJ_8 /* CKRecordCoder */, 278 | OBJ_19 /* Cirrus */, 279 | OBJ_30 /* CloudKitCodable */, 280 | ); 281 | name = Sources; 282 | sourceTree = SOURCE_ROOT; 283 | }; 284 | OBJ_8 /* CKRecordCoder */ = { 285 | isa = PBXGroup; 286 | children = ( 287 | OBJ_9 /* CKRecordDecoder.swift */, 288 | OBJ_10 /* CKRecordEncoder.swift */, 289 | OBJ_11 /* CKRecordEncodingError.swift */, 290 | OBJ_12 /* CKRecordKeyedDecodingContainer.swift */, 291 | OBJ_13 /* CKRecordKeyedEncodingContainer.swift */, 292 | OBJ_14 /* CKRecordSingleValueDecoder.swift */, 293 | OBJ_15 /* CKRecordSingleValueEncoder.swift */, 294 | OBJ_16 /* CloudKitCodable+RecordType.swift */, 295 | OBJ_17 /* CloudKitSystemFieldsKeyName.swift */, 296 | OBJ_18 /* URLTransformer.swift */, 297 | ); 298 | name = CKRecordCoder; 299 | path = Sources/CKRecordCoder; 300 | sourceTree = SOURCE_ROOT; 301 | }; 302 | /* End PBXGroup section */ 303 | 304 | /* Begin PBXNativeTarget section */ 305 | "Cirrus::CKRecordCoder" /* CKRecordCoder */ = { 306 | isa = PBXNativeTarget; 307 | buildConfigurationList = OBJ_55 /* Build configuration list for PBXNativeTarget "CKRecordCoder" */; 308 | buildPhases = ( 309 | OBJ_58 /* Sources */, 310 | OBJ_69 /* Frameworks */, 311 | ); 312 | buildRules = ( 313 | ); 314 | dependencies = ( 315 | OBJ_71 /* PBXTargetDependency */, 316 | ); 317 | name = CKRecordCoder; 318 | productName = CKRecordCoder; 319 | productReference = "Cirrus::CKRecordCoder::Product" /* CKRecordCoder.framework */; 320 | productType = "com.apple.product-type.framework"; 321 | }; 322 | "Cirrus::CKRecordCoderTests" /* CKRecordCoderTests */ = { 323 | isa = PBXNativeTarget; 324 | buildConfigurationList = OBJ_74 /* Build configuration list for PBXNativeTarget "CKRecordCoderTests" */; 325 | buildPhases = ( 326 | OBJ_77 /* Sources */, 327 | OBJ_87 /* Frameworks */, 328 | ); 329 | buildRules = ( 330 | ); 331 | dependencies = ( 332 | OBJ_90 /* PBXTargetDependency */, 333 | OBJ_91 /* PBXTargetDependency */, 334 | ); 335 | name = CKRecordCoderTests; 336 | productName = CKRecordCoderTests; 337 | productReference = "Cirrus::CKRecordCoderTests::Product" /* CKRecordCoderTests.xctest */; 338 | productType = "com.apple.product-type.bundle.unit-test"; 339 | }; 340 | "Cirrus::Cirrus" /* Cirrus */ = { 341 | isa = PBXNativeTarget; 342 | buildConfigurationList = OBJ_93 /* Build configuration list for PBXNativeTarget "Cirrus" */; 343 | buildPhases = ( 344 | OBJ_96 /* Sources */, 345 | OBJ_107 /* Frameworks */, 346 | ); 347 | buildRules = ( 348 | ); 349 | dependencies = ( 350 | OBJ_110 /* PBXTargetDependency */, 351 | OBJ_111 /* PBXTargetDependency */, 352 | ); 353 | name = Cirrus; 354 | productName = Cirrus; 355 | productReference = "Cirrus::Cirrus::Product" /* Cirrus.framework */; 356 | productType = "com.apple.product-type.framework"; 357 | }; 358 | "Cirrus::CloudKitCodable" /* CloudKitCodable */ = { 359 | isa = PBXNativeTarget; 360 | buildConfigurationList = OBJ_123 /* Build configuration list for PBXNativeTarget "CloudKitCodable" */; 361 | buildPhases = ( 362 | OBJ_126 /* Sources */, 363 | OBJ_129 /* Frameworks */, 364 | ); 365 | buildRules = ( 366 | ); 367 | dependencies = ( 368 | ); 369 | name = CloudKitCodable; 370 | productName = CloudKitCodable; 371 | productReference = "Cirrus::CloudKitCodable::Product" /* CloudKitCodable.framework */; 372 | productType = "com.apple.product-type.framework"; 373 | }; 374 | "Cirrus::SwiftPMPackageDescription" /* CirrusPackageDescription */ = { 375 | isa = PBXNativeTarget; 376 | buildConfigurationList = OBJ_113 /* Build configuration list for PBXNativeTarget "CirrusPackageDescription" */; 377 | buildPhases = ( 378 | OBJ_116 /* Sources */, 379 | ); 380 | buildRules = ( 381 | ); 382 | dependencies = ( 383 | ); 384 | name = CirrusPackageDescription; 385 | productName = CirrusPackageDescription; 386 | productType = "com.apple.product-type.framework"; 387 | }; 388 | /* End PBXNativeTarget section */ 389 | 390 | /* Begin PBXProject section */ 391 | OBJ_1 /* Project object */ = { 392 | isa = PBXProject; 393 | attributes = { 394 | LastSwiftMigration = 9999; 395 | LastUpgradeCheck = 9999; 396 | }; 397 | buildConfigurationList = OBJ_2 /* Build configuration list for PBXProject "Cirrus" */; 398 | compatibilityVersion = "Xcode 3.2"; 399 | developmentRegion = en; 400 | hasScannedForEncodings = 0; 401 | knownRegions = ( 402 | en, 403 | ); 404 | mainGroup = OBJ_5; 405 | productRefGroup = OBJ_45 /* Products */; 406 | projectDirPath = ""; 407 | projectRoot = ""; 408 | targets = ( 409 | "Cirrus::CKRecordCoder" /* CKRecordCoder */, 410 | "Cirrus::CKRecordCoderTests" /* CKRecordCoderTests */, 411 | "Cirrus::Cirrus" /* Cirrus */, 412 | "Cirrus::SwiftPMPackageDescription" /* CirrusPackageDescription */, 413 | "Cirrus::CirrusPackageTests::ProductTarget" /* CirrusPackageTests */, 414 | "Cirrus::CloudKitCodable" /* CloudKitCodable */, 415 | ); 416 | }; 417 | /* End PBXProject section */ 418 | 419 | /* Begin PBXSourcesBuildPhase section */ 420 | OBJ_116 /* Sources */ = { 421 | isa = PBXSourcesBuildPhase; 422 | buildActionMask = 0; 423 | files = ( 424 | OBJ_117 /* Package.swift in Sources */, 425 | ); 426 | runOnlyForDeploymentPostprocessing = 0; 427 | }; 428 | OBJ_126 /* Sources */ = { 429 | isa = PBXSourcesBuildPhase; 430 | buildActionMask = 0; 431 | files = ( 432 | OBJ_127 /* CloudKitCodable+LastModifiedDate.swift in Sources */, 433 | OBJ_128 /* CloudKitCodable.swift in Sources */, 434 | ); 435 | runOnlyForDeploymentPostprocessing = 0; 436 | }; 437 | OBJ_58 /* Sources */ = { 438 | isa = PBXSourcesBuildPhase; 439 | buildActionMask = 0; 440 | files = ( 441 | OBJ_59 /* CKRecordDecoder.swift in Sources */, 442 | OBJ_60 /* CKRecordEncoder.swift in Sources */, 443 | OBJ_61 /* CKRecordEncodingError.swift in Sources */, 444 | OBJ_62 /* CKRecordKeyedDecodingContainer.swift in Sources */, 445 | OBJ_63 /* CKRecordKeyedEncodingContainer.swift in Sources */, 446 | OBJ_64 /* CKRecordSingleValueDecoder.swift in Sources */, 447 | OBJ_65 /* CKRecordSingleValueEncoder.swift in Sources */, 448 | OBJ_66 /* CloudKitCodable+RecordType.swift in Sources */, 449 | OBJ_67 /* CloudKitSystemFieldsKeyName.swift in Sources */, 450 | OBJ_68 /* URLTransformer.swift in Sources */, 451 | ); 452 | runOnlyForDeploymentPostprocessing = 0; 453 | }; 454 | OBJ_77 /* Sources */ = { 455 | isa = PBXSourcesBuildPhase; 456 | buildActionMask = 0; 457 | files = ( 458 | OBJ_78 /* CKRecordDecoderTests.swift in Sources */, 459 | OBJ_79 /* CKRecordEncoderDecoderRoundTripTests.swift in Sources */, 460 | OBJ_80 /* CKRecordEncoderTests.swift in Sources */, 461 | OBJ_81 /* Bookmark.swift in Sources */, 462 | OBJ_82 /* Numbers.swift in Sources */, 463 | OBJ_83 /* ParentChild.swift in Sources */, 464 | OBJ_84 /* Person.swift in Sources */, 465 | OBJ_85 /* URLModel.swift in Sources */, 466 | OBJ_86 /* UUIDModel.swift in Sources */, 467 | ); 468 | runOnlyForDeploymentPostprocessing = 0; 469 | }; 470 | OBJ_96 /* Sources */ = { 471 | isa = PBXSourcesBuildPhase; 472 | buildActionMask = 0; 473 | files = ( 474 | OBJ_97 /* DeleteRecordContext.swift in Sources */, 475 | OBJ_98 /* Error+CloudKit.swift in Sources */, 476 | OBJ_99 /* RecordModifyingContext.swift in Sources */, 477 | OBJ_100 /* SyncEngine+AccountStatus.swift in Sources */, 478 | OBJ_101 /* SyncEngine+RecordModification.swift in Sources */, 479 | OBJ_102 /* SyncEngine+RemoteChangeTracking.swift in Sources */, 480 | OBJ_103 /* SyncEngine+Subscription.swift in Sources */, 481 | OBJ_104 /* SyncEngine+Zone.swift in Sources */, 482 | OBJ_105 /* SyncEngine.swift in Sources */, 483 | OBJ_106 /* UploadRecordContext.swift in Sources */, 484 | ); 485 | runOnlyForDeploymentPostprocessing = 0; 486 | }; 487 | /* End PBXSourcesBuildPhase section */ 488 | 489 | /* Begin PBXTargetDependency section */ 490 | OBJ_110 /* PBXTargetDependency */ = { 491 | isa = PBXTargetDependency; 492 | target = "Cirrus::CKRecordCoder" /* CKRecordCoder */; 493 | targetProxy = E501325B24A8C3380055FF6E /* PBXContainerItemProxy */; 494 | }; 495 | OBJ_111 /* PBXTargetDependency */ = { 496 | isa = PBXTargetDependency; 497 | target = "Cirrus::CloudKitCodable" /* CloudKitCodable */; 498 | targetProxy = E501325D24A8C3380055FF6E /* PBXContainerItemProxy */; 499 | }; 500 | OBJ_122 /* PBXTargetDependency */ = { 501 | isa = PBXTargetDependency; 502 | target = "Cirrus::CKRecordCoderTests" /* CKRecordCoderTests */; 503 | targetProxy = E501326324A8C3780055FF6E /* PBXContainerItemProxy */; 504 | }; 505 | OBJ_71 /* PBXTargetDependency */ = { 506 | isa = PBXTargetDependency; 507 | target = "Cirrus::CloudKitCodable" /* CloudKitCodable */; 508 | targetProxy = E501325C24A8C3380055FF6E /* PBXContainerItemProxy */; 509 | }; 510 | OBJ_90 /* PBXTargetDependency */ = { 511 | isa = PBXTargetDependency; 512 | target = "Cirrus::CKRecordCoder" /* CKRecordCoder */; 513 | targetProxy = E501325E24A8C3390055FF6E /* PBXContainerItemProxy */; 514 | }; 515 | OBJ_91 /* PBXTargetDependency */ = { 516 | isa = PBXTargetDependency; 517 | target = "Cirrus::CloudKitCodable" /* CloudKitCodable */; 518 | targetProxy = E501325F24A8C3390055FF6E /* PBXContainerItemProxy */; 519 | }; 520 | /* End PBXTargetDependency section */ 521 | 522 | /* Begin XCBuildConfiguration section */ 523 | OBJ_114 /* Debug */ = { 524 | isa = XCBuildConfiguration; 525 | buildSettings = { 526 | LD = /usr/bin/true; 527 | OTHER_SWIFT_FLAGS = "-swift-version 5 -I $(TOOLCHAIN_DIR)/usr/lib/swift/pm/4_2 -target x86_64-apple-macosx10.10 -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.15.sdk -package-description-version 5.1.0"; 528 | SDKROOT = iphoneos; 529 | SWIFT_VERSION = 5.0; 530 | }; 531 | name = Debug; 532 | }; 533 | OBJ_115 /* Release */ = { 534 | isa = XCBuildConfiguration; 535 | buildSettings = { 536 | LD = /usr/bin/true; 537 | OTHER_SWIFT_FLAGS = "-swift-version 5 -I $(TOOLCHAIN_DIR)/usr/lib/swift/pm/4_2 -target x86_64-apple-macosx10.10 -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.15.sdk -package-description-version 5.1.0"; 538 | SDKROOT = iphoneos; 539 | SWIFT_VERSION = 5.0; 540 | }; 541 | name = Release; 542 | }; 543 | OBJ_120 /* Debug */ = { 544 | isa = XCBuildConfiguration; 545 | buildSettings = { 546 | SDKROOT = iphoneos; 547 | }; 548 | name = Debug; 549 | }; 550 | OBJ_121 /* Release */ = { 551 | isa = XCBuildConfiguration; 552 | buildSettings = { 553 | SDKROOT = iphoneos; 554 | }; 555 | name = Release; 556 | }; 557 | OBJ_124 /* Debug */ = { 558 | isa = XCBuildConfiguration; 559 | buildSettings = { 560 | ENABLE_TESTABILITY = YES; 561 | FRAMEWORK_SEARCH_PATHS = ( 562 | "$(inherited)", 563 | "$(PLATFORM_DIR)/Developer/Library/Frameworks", 564 | ); 565 | HEADER_SEARCH_PATHS = "$(inherited)"; 566 | INFOPLIST_FILE = Cirrus.xcodeproj/CloudKitCodable_Info.plist; 567 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 568 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) $(TOOLCHAIN_DIR)/usr/lib/swift/macosx"; 569 | MACOSX_DEPLOYMENT_TARGET = 10.15; 570 | OTHER_CFLAGS = "$(inherited)"; 571 | OTHER_LDFLAGS = "$(inherited)"; 572 | OTHER_SWIFT_FLAGS = "$(inherited)"; 573 | PRODUCT_BUNDLE_IDENTIFIER = CloudKitCodable; 574 | PRODUCT_MODULE_NAME = "$(TARGET_NAME:c99extidentifier)"; 575 | PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; 576 | SDKROOT = iphoneos; 577 | SKIP_INSTALL = YES; 578 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; 579 | SWIFT_VERSION = 5.0; 580 | TARGET_NAME = CloudKitCodable; 581 | TVOS_DEPLOYMENT_TARGET = 13.0; 582 | WATCHOS_DEPLOYMENT_TARGET = 6.0; 583 | }; 584 | name = Debug; 585 | }; 586 | OBJ_125 /* Release */ = { 587 | isa = XCBuildConfiguration; 588 | buildSettings = { 589 | ENABLE_TESTABILITY = YES; 590 | FRAMEWORK_SEARCH_PATHS = ( 591 | "$(inherited)", 592 | "$(PLATFORM_DIR)/Developer/Library/Frameworks", 593 | ); 594 | HEADER_SEARCH_PATHS = "$(inherited)"; 595 | INFOPLIST_FILE = Cirrus.xcodeproj/CloudKitCodable_Info.plist; 596 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 597 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) $(TOOLCHAIN_DIR)/usr/lib/swift/macosx"; 598 | MACOSX_DEPLOYMENT_TARGET = 10.15; 599 | OTHER_CFLAGS = "$(inherited)"; 600 | OTHER_LDFLAGS = "$(inherited)"; 601 | OTHER_SWIFT_FLAGS = "$(inherited)"; 602 | PRODUCT_BUNDLE_IDENTIFIER = CloudKitCodable; 603 | PRODUCT_MODULE_NAME = "$(TARGET_NAME:c99extidentifier)"; 604 | PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; 605 | SDKROOT = iphoneos; 606 | SKIP_INSTALL = YES; 607 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; 608 | SWIFT_VERSION = 5.0; 609 | TARGET_NAME = CloudKitCodable; 610 | TVOS_DEPLOYMENT_TARGET = 13.0; 611 | WATCHOS_DEPLOYMENT_TARGET = 6.0; 612 | }; 613 | name = Release; 614 | }; 615 | OBJ_3 /* Debug */ = { 616 | isa = XCBuildConfiguration; 617 | buildSettings = { 618 | CLANG_ENABLE_OBJC_ARC = YES; 619 | COMBINE_HIDPI_IMAGES = YES; 620 | COPY_PHASE_STRIP = NO; 621 | DEBUG_INFORMATION_FORMAT = dwarf; 622 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 623 | ENABLE_NS_ASSERTIONS = YES; 624 | GCC_OPTIMIZATION_LEVEL = 0; 625 | GCC_PREPROCESSOR_DEFINITIONS = ( 626 | "$(inherited)", 627 | "SWIFT_PACKAGE=1", 628 | "DEBUG=1", 629 | ); 630 | MACOSX_DEPLOYMENT_TARGET = 10.10; 631 | ONLY_ACTIVE_ARCH = YES; 632 | OTHER_SWIFT_FLAGS = "$(inherited) -DXcode"; 633 | PRODUCT_NAME = "$(TARGET_NAME)"; 634 | SDKROOT = macosx; 635 | SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator appletvos appletvsimulator watchos watchsimulator"; 636 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) SWIFT_PACKAGE DEBUG"; 637 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 638 | USE_HEADERMAP = NO; 639 | }; 640 | name = Debug; 641 | }; 642 | OBJ_4 /* Release */ = { 643 | isa = XCBuildConfiguration; 644 | buildSettings = { 645 | CLANG_ENABLE_OBJC_ARC = YES; 646 | COMBINE_HIDPI_IMAGES = YES; 647 | COPY_PHASE_STRIP = YES; 648 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 649 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 650 | GCC_OPTIMIZATION_LEVEL = s; 651 | GCC_PREPROCESSOR_DEFINITIONS = ( 652 | "$(inherited)", 653 | "SWIFT_PACKAGE=1", 654 | ); 655 | MACOSX_DEPLOYMENT_TARGET = 10.10; 656 | OTHER_SWIFT_FLAGS = "$(inherited) -DXcode"; 657 | PRODUCT_NAME = "$(TARGET_NAME)"; 658 | SDKROOT = macosx; 659 | SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator appletvos appletvsimulator watchos watchsimulator"; 660 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) SWIFT_PACKAGE"; 661 | SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; 662 | USE_HEADERMAP = NO; 663 | }; 664 | name = Release; 665 | }; 666 | OBJ_56 /* Debug */ = { 667 | isa = XCBuildConfiguration; 668 | buildSettings = { 669 | ENABLE_TESTABILITY = YES; 670 | FRAMEWORK_SEARCH_PATHS = ( 671 | "$(inherited)", 672 | "$(PLATFORM_DIR)/Developer/Library/Frameworks", 673 | ); 674 | HEADER_SEARCH_PATHS = "$(inherited)"; 675 | INFOPLIST_FILE = Cirrus.xcodeproj/CKRecordCoder_Info.plist; 676 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 677 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) $(TOOLCHAIN_DIR)/usr/lib/swift/macosx"; 678 | MACOSX_DEPLOYMENT_TARGET = 10.15; 679 | OTHER_CFLAGS = "$(inherited)"; 680 | OTHER_LDFLAGS = "$(inherited)"; 681 | OTHER_SWIFT_FLAGS = "$(inherited)"; 682 | PRODUCT_BUNDLE_IDENTIFIER = CKRecordCoder; 683 | PRODUCT_MODULE_NAME = "$(TARGET_NAME:c99extidentifier)"; 684 | PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; 685 | SDKROOT = iphoneos; 686 | SKIP_INSTALL = YES; 687 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; 688 | SWIFT_VERSION = 5.0; 689 | TARGET_NAME = CKRecordCoder; 690 | TVOS_DEPLOYMENT_TARGET = 13.0; 691 | WATCHOS_DEPLOYMENT_TARGET = 6.0; 692 | }; 693 | name = Debug; 694 | }; 695 | OBJ_57 /* Release */ = { 696 | isa = XCBuildConfiguration; 697 | buildSettings = { 698 | ENABLE_TESTABILITY = YES; 699 | FRAMEWORK_SEARCH_PATHS = ( 700 | "$(inherited)", 701 | "$(PLATFORM_DIR)/Developer/Library/Frameworks", 702 | ); 703 | HEADER_SEARCH_PATHS = "$(inherited)"; 704 | INFOPLIST_FILE = Cirrus.xcodeproj/CKRecordCoder_Info.plist; 705 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 706 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) $(TOOLCHAIN_DIR)/usr/lib/swift/macosx"; 707 | MACOSX_DEPLOYMENT_TARGET = 10.15; 708 | OTHER_CFLAGS = "$(inherited)"; 709 | OTHER_LDFLAGS = "$(inherited)"; 710 | OTHER_SWIFT_FLAGS = "$(inherited)"; 711 | PRODUCT_BUNDLE_IDENTIFIER = CKRecordCoder; 712 | PRODUCT_MODULE_NAME = "$(TARGET_NAME:c99extidentifier)"; 713 | PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; 714 | SDKROOT = iphoneos; 715 | SKIP_INSTALL = YES; 716 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; 717 | SWIFT_VERSION = 5.0; 718 | TARGET_NAME = CKRecordCoder; 719 | TVOS_DEPLOYMENT_TARGET = 13.0; 720 | WATCHOS_DEPLOYMENT_TARGET = 6.0; 721 | }; 722 | name = Release; 723 | }; 724 | OBJ_75 /* Debug */ = { 725 | isa = XCBuildConfiguration; 726 | buildSettings = { 727 | CLANG_ENABLE_MODULES = YES; 728 | EMBEDDED_CONTENT_CONTAINS_SWIFT = YES; 729 | FRAMEWORK_SEARCH_PATHS = ( 730 | "$(inherited)", 731 | "$(PLATFORM_DIR)/Developer/Library/Frameworks", 732 | ); 733 | HEADER_SEARCH_PATHS = "$(inherited)"; 734 | INFOPLIST_FILE = Cirrus.xcodeproj/CKRecordCoderTests_Info.plist; 735 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 736 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @loader_path/../Frameworks @loader_path/Frameworks"; 737 | MACOSX_DEPLOYMENT_TARGET = 10.15; 738 | OTHER_CFLAGS = "$(inherited)"; 739 | OTHER_LDFLAGS = "$(inherited)"; 740 | OTHER_SWIFT_FLAGS = "$(inherited)"; 741 | SDKROOT = iphoneos; 742 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; 743 | SWIFT_VERSION = 5.0; 744 | TARGET_NAME = CKRecordCoderTests; 745 | TVOS_DEPLOYMENT_TARGET = 13.0; 746 | WATCHOS_DEPLOYMENT_TARGET = 6.0; 747 | }; 748 | name = Debug; 749 | }; 750 | OBJ_76 /* Release */ = { 751 | isa = XCBuildConfiguration; 752 | buildSettings = { 753 | CLANG_ENABLE_MODULES = YES; 754 | EMBEDDED_CONTENT_CONTAINS_SWIFT = YES; 755 | FRAMEWORK_SEARCH_PATHS = ( 756 | "$(inherited)", 757 | "$(PLATFORM_DIR)/Developer/Library/Frameworks", 758 | ); 759 | HEADER_SEARCH_PATHS = "$(inherited)"; 760 | INFOPLIST_FILE = Cirrus.xcodeproj/CKRecordCoderTests_Info.plist; 761 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 762 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @loader_path/../Frameworks @loader_path/Frameworks"; 763 | MACOSX_DEPLOYMENT_TARGET = 10.15; 764 | OTHER_CFLAGS = "$(inherited)"; 765 | OTHER_LDFLAGS = "$(inherited)"; 766 | OTHER_SWIFT_FLAGS = "$(inherited)"; 767 | SDKROOT = iphoneos; 768 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; 769 | SWIFT_VERSION = 5.0; 770 | TARGET_NAME = CKRecordCoderTests; 771 | TVOS_DEPLOYMENT_TARGET = 13.0; 772 | WATCHOS_DEPLOYMENT_TARGET = 6.0; 773 | }; 774 | name = Release; 775 | }; 776 | OBJ_94 /* Debug */ = { 777 | isa = XCBuildConfiguration; 778 | buildSettings = { 779 | ENABLE_TESTABILITY = YES; 780 | FRAMEWORK_SEARCH_PATHS = ( 781 | "$(inherited)", 782 | "$(PLATFORM_DIR)/Developer/Library/Frameworks", 783 | ); 784 | HEADER_SEARCH_PATHS = "$(inherited)"; 785 | INFOPLIST_FILE = Cirrus.xcodeproj/Cirrus_Info.plist; 786 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 787 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) $(TOOLCHAIN_DIR)/usr/lib/swift/macosx"; 788 | MACOSX_DEPLOYMENT_TARGET = 10.15; 789 | OTHER_CFLAGS = "$(inherited)"; 790 | OTHER_LDFLAGS = "$(inherited)"; 791 | OTHER_SWIFT_FLAGS = "$(inherited)"; 792 | PRODUCT_BUNDLE_IDENTIFIER = Cirrus; 793 | PRODUCT_MODULE_NAME = "$(TARGET_NAME:c99extidentifier)"; 794 | PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; 795 | SDKROOT = iphoneos; 796 | SKIP_INSTALL = YES; 797 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; 798 | SWIFT_VERSION = 5.0; 799 | TARGET_NAME = Cirrus; 800 | TVOS_DEPLOYMENT_TARGET = 13.0; 801 | WATCHOS_DEPLOYMENT_TARGET = 6.0; 802 | }; 803 | name = Debug; 804 | }; 805 | OBJ_95 /* Release */ = { 806 | isa = XCBuildConfiguration; 807 | buildSettings = { 808 | ENABLE_TESTABILITY = YES; 809 | FRAMEWORK_SEARCH_PATHS = ( 810 | "$(inherited)", 811 | "$(PLATFORM_DIR)/Developer/Library/Frameworks", 812 | ); 813 | HEADER_SEARCH_PATHS = "$(inherited)"; 814 | INFOPLIST_FILE = Cirrus.xcodeproj/Cirrus_Info.plist; 815 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 816 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) $(TOOLCHAIN_DIR)/usr/lib/swift/macosx"; 817 | MACOSX_DEPLOYMENT_TARGET = 10.15; 818 | OTHER_CFLAGS = "$(inherited)"; 819 | OTHER_LDFLAGS = "$(inherited)"; 820 | OTHER_SWIFT_FLAGS = "$(inherited)"; 821 | PRODUCT_BUNDLE_IDENTIFIER = Cirrus; 822 | PRODUCT_MODULE_NAME = "$(TARGET_NAME:c99extidentifier)"; 823 | PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; 824 | SDKROOT = iphoneos; 825 | SKIP_INSTALL = YES; 826 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; 827 | SWIFT_VERSION = 5.0; 828 | TARGET_NAME = Cirrus; 829 | TVOS_DEPLOYMENT_TARGET = 13.0; 830 | WATCHOS_DEPLOYMENT_TARGET = 6.0; 831 | }; 832 | name = Release; 833 | }; 834 | /* End XCBuildConfiguration section */ 835 | 836 | /* Begin XCConfigurationList section */ 837 | OBJ_113 /* Build configuration list for PBXNativeTarget "CirrusPackageDescription" */ = { 838 | isa = XCConfigurationList; 839 | buildConfigurations = ( 840 | OBJ_114 /* Debug */, 841 | OBJ_115 /* Release */, 842 | ); 843 | defaultConfigurationIsVisible = 0; 844 | defaultConfigurationName = Release; 845 | }; 846 | OBJ_119 /* Build configuration list for PBXAggregateTarget "CirrusPackageTests" */ = { 847 | isa = XCConfigurationList; 848 | buildConfigurations = ( 849 | OBJ_120 /* Debug */, 850 | OBJ_121 /* Release */, 851 | ); 852 | defaultConfigurationIsVisible = 0; 853 | defaultConfigurationName = Release; 854 | }; 855 | OBJ_123 /* Build configuration list for PBXNativeTarget "CloudKitCodable" */ = { 856 | isa = XCConfigurationList; 857 | buildConfigurations = ( 858 | OBJ_124 /* Debug */, 859 | OBJ_125 /* Release */, 860 | ); 861 | defaultConfigurationIsVisible = 0; 862 | defaultConfigurationName = Release; 863 | }; 864 | OBJ_2 /* Build configuration list for PBXProject "Cirrus" */ = { 865 | isa = XCConfigurationList; 866 | buildConfigurations = ( 867 | OBJ_3 /* Debug */, 868 | OBJ_4 /* Release */, 869 | ); 870 | defaultConfigurationIsVisible = 0; 871 | defaultConfigurationName = Release; 872 | }; 873 | OBJ_55 /* Build configuration list for PBXNativeTarget "CKRecordCoder" */ = { 874 | isa = XCConfigurationList; 875 | buildConfigurations = ( 876 | OBJ_56 /* Debug */, 877 | OBJ_57 /* Release */, 878 | ); 879 | defaultConfigurationIsVisible = 0; 880 | defaultConfigurationName = Release; 881 | }; 882 | OBJ_74 /* Build configuration list for PBXNativeTarget "CKRecordCoderTests" */ = { 883 | isa = XCConfigurationList; 884 | buildConfigurations = ( 885 | OBJ_75 /* Debug */, 886 | OBJ_76 /* Release */, 887 | ); 888 | defaultConfigurationIsVisible = 0; 889 | defaultConfigurationName = Release; 890 | }; 891 | OBJ_93 /* Build configuration list for PBXNativeTarget "Cirrus" */ = { 892 | isa = XCConfigurationList; 893 | buildConfigurations = ( 894 | OBJ_94 /* Debug */, 895 | OBJ_95 /* Release */, 896 | ); 897 | defaultConfigurationIsVisible = 0; 898 | defaultConfigurationName = Release; 899 | }; 900 | /* End XCConfigurationList section */ 901 | }; 902 | rootObject = OBJ_1 /* Project object */; 903 | } 904 | -------------------------------------------------------------------------------- /Cirrus.xcodeproj/xcshareddata/xcschemes/Cirrus-Package.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 43 | 49 | 50 | 51 | 52 | 53 | 58 | 59 | 61 | 67 | 68 | 69 | 70 | 71 | 81 | 82 | 88 | 89 | 91 | 92 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /Example/CirrusExample.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | E501325824A8C2CE0055FF6E /* Cirrus.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E501325224A8C2C70055FF6E /* Cirrus.framework */; }; 11 | E501325924A8C2CE0055FF6E /* Cirrus.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E501325224A8C2C70055FF6E /* Cirrus.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 12 | E501326A24A8C3DA0055FF6E /* CloudKitCodable.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E501325424A8C2C70055FF6E /* CloudKitCodable.framework */; }; 13 | E501326B24A8C3DA0055FF6E /* CloudKitCodable.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E501325424A8C2C70055FF6E /* CloudKitCodable.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 14 | E501326C24A8C3F50055FF6E /* CKRecordCoder.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E501324E24A8C2C70055FF6E /* CKRecordCoder.framework */; }; 15 | E501326D24A8C3F50055FF6E /* CKRecordCoder.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E501324E24A8C2C70055FF6E /* CKRecordCoder.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 16 | E568AFC924A86F2600B34F66 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E568AFC824A86F2600B34F66 /* AppDelegate.swift */; }; 17 | E568AFCB24A86F2600B34F66 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E568AFCA24A86F2600B34F66 /* SceneDelegate.swift */; }; 18 | E568AFCF24A86F2900B34F66 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E568AFCE24A86F2900B34F66 /* Assets.xcassets */; }; 19 | E568AFD224A86F2900B34F66 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E568AFD124A86F2900B34F66 /* Preview Assets.xcassets */; }; 20 | E568AFD524A86F2900B34F66 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E568AFD324A86F2900B34F66 /* LaunchScreen.storyboard */; }; 21 | E568AFF124A86F8500B34F66 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = E568AFE224A86F8500B34F66 /* Bookmark.swift */; }; 22 | E568AFF224A86F8500B34F66 /* TestURLs.swift in Sources */ = {isa = PBXBuildFile; fileRef = E568AFE424A86F8500B34F66 /* TestURLs.swift */; }; 23 | E568AFF324A86F8500B34F66 /* MultipleSelectionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E568AFE624A86F8500B34F66 /* MultipleSelectionRow.swift */; }; 24 | E568AFF424A86F8500B34F66 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E568AFE724A86F8500B34F66 /* ContentView.swift */; }; 25 | E568AFF524A86F8500B34F66 /* BookmarkRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E568AFE824A86F8500B34F66 /* BookmarkRow.swift */; }; 26 | E568AFF624A86F8500B34F66 /* AppSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E568AFEA24A86F8500B34F66 /* AppSyncManager.swift */; }; 27 | E568AFF724A86F8500B34F66 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E568AFEC24A86F8500B34F66 /* AppState.swift */; }; 28 | E568AFF824A86F8500B34F66 /* AppReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E568AFED24A86F8500B34F66 /* AppReducer.swift */; }; 29 | E568AFF924A86F8500B34F66 /* AppAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E568AFEE24A86F8500B34F66 /* AppAction.swift */; }; 30 | E568AFFA24A86F8500B34F66 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = E568AFEF24A86F8500B34F66 /* Store.swift */; }; 31 | E568AFFB24A86F8500B34F66 /* PersistentStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E568AFF024A86F8500B34F66 /* PersistentStore.swift */; }; 32 | E568AFFF24A86FB100B34F66 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E568AFFE24A86FB100B34F66 /* CloudKit.framework */; }; 33 | /* End PBXBuildFile section */ 34 | 35 | /* Begin PBXContainerItemProxy section */ 36 | E501324D24A8C2C70055FF6E /* PBXContainerItemProxy */ = { 37 | isa = PBXContainerItemProxy; 38 | containerPortal = E501324124A8C2C70055FF6E /* Cirrus.xcodeproj */; 39 | proxyType = 2; 40 | remoteGlobalIDString = "Cirrus::CKRecordCoder::Product"; 41 | remoteInfo = CKRecordCoder; 42 | }; 43 | E501324F24A8C2C70055FF6E /* PBXContainerItemProxy */ = { 44 | isa = PBXContainerItemProxy; 45 | containerPortal = E501324124A8C2C70055FF6E /* Cirrus.xcodeproj */; 46 | proxyType = 2; 47 | remoteGlobalIDString = "Cirrus::CKRecordCoderTests::Product"; 48 | remoteInfo = CKRecordCoderTests; 49 | }; 50 | E501325124A8C2C70055FF6E /* PBXContainerItemProxy */ = { 51 | isa = PBXContainerItemProxy; 52 | containerPortal = E501324124A8C2C70055FF6E /* Cirrus.xcodeproj */; 53 | proxyType = 2; 54 | remoteGlobalIDString = "Cirrus::Cirrus::Product"; 55 | remoteInfo = Cirrus; 56 | }; 57 | E501325324A8C2C70055FF6E /* PBXContainerItemProxy */ = { 58 | isa = PBXContainerItemProxy; 59 | containerPortal = E501324124A8C2C70055FF6E /* Cirrus.xcodeproj */; 60 | proxyType = 2; 61 | remoteGlobalIDString = "Cirrus::CloudKitCodable::Product"; 62 | remoteInfo = CloudKitCodable; 63 | }; 64 | /* End PBXContainerItemProxy section */ 65 | 66 | /* Begin PBXCopyFilesBuildPhase section */ 67 | E501325A24A8C2CE0055FF6E /* Embed Frameworks */ = { 68 | isa = PBXCopyFilesBuildPhase; 69 | buildActionMask = 2147483647; 70 | dstPath = ""; 71 | dstSubfolderSpec = 10; 72 | files = ( 73 | E501326B24A8C3DA0055FF6E /* CloudKitCodable.framework in Embed Frameworks */, 74 | E501325924A8C2CE0055FF6E /* Cirrus.framework in Embed Frameworks */, 75 | E501326D24A8C3F50055FF6E /* CKRecordCoder.framework in Embed Frameworks */, 76 | ); 77 | name = "Embed Frameworks"; 78 | runOnlyForDeploymentPostprocessing = 0; 79 | }; 80 | /* End PBXCopyFilesBuildPhase section */ 81 | 82 | /* Begin PBXFileReference section */ 83 | E501324124A8C2C70055FF6E /* Cirrus.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = Cirrus.xcodeproj; path = ../Cirrus.xcodeproj; sourceTree = ""; }; 84 | E568AFC524A86F2600B34F66 /* CirrusExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CirrusExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 85 | E568AFC824A86F2600B34F66 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 86 | E568AFCA24A86F2600B34F66 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 87 | E568AFCE24A86F2900B34F66 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 88 | E568AFD124A86F2900B34F66 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 89 | E568AFD424A86F2900B34F66 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 90 | E568AFD624A86F2900B34F66 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 91 | E568AFE224A86F8500B34F66 /* Bookmark.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = ""; }; 92 | E568AFE424A86F8500B34F66 /* TestURLs.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestURLs.swift; sourceTree = ""; }; 93 | E568AFE624A86F8500B34F66 /* MultipleSelectionRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MultipleSelectionRow.swift; sourceTree = ""; }; 94 | E568AFE724A86F8500B34F66 /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 95 | E568AFE824A86F8500B34F66 /* BookmarkRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarkRow.swift; sourceTree = ""; }; 96 | E568AFEA24A86F8500B34F66 /* AppSyncManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppSyncManager.swift; sourceTree = ""; }; 97 | E568AFEC24A86F8500B34F66 /* AppState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; 98 | E568AFED24A86F8500B34F66 /* AppReducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppReducer.swift; sourceTree = ""; }; 99 | E568AFEE24A86F8500B34F66 /* AppAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppAction.swift; sourceTree = ""; }; 100 | E568AFEF24A86F8500B34F66 /* Store.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; 101 | E568AFF024A86F8500B34F66 /* PersistentStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PersistentStore.swift; sourceTree = ""; }; 102 | E568AFFC24A86FA100B34F66 /* CirrusExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CirrusExample.entitlements; sourceTree = ""; }; 103 | E568AFFE24A86FB100B34F66 /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; }; 104 | /* End PBXFileReference section */ 105 | 106 | /* Begin PBXFrameworksBuildPhase section */ 107 | E568AFC224A86F2600B34F66 /* Frameworks */ = { 108 | isa = PBXFrameworksBuildPhase; 109 | buildActionMask = 2147483647; 110 | files = ( 111 | E501325824A8C2CE0055FF6E /* Cirrus.framework in Frameworks */, 112 | E501326C24A8C3F50055FF6E /* CKRecordCoder.framework in Frameworks */, 113 | E501326A24A8C3DA0055FF6E /* CloudKitCodable.framework in Frameworks */, 114 | E568AFFF24A86FB100B34F66 /* CloudKit.framework in Frameworks */, 115 | ); 116 | runOnlyForDeploymentPostprocessing = 0; 117 | }; 118 | /* End PBXFrameworksBuildPhase section */ 119 | 120 | /* Begin PBXGroup section */ 121 | E501324224A8C2C70055FF6E /* Products */ = { 122 | isa = PBXGroup; 123 | children = ( 124 | E501324E24A8C2C70055FF6E /* CKRecordCoder.framework */, 125 | E501325024A8C2C70055FF6E /* CKRecordCoderTests.xctest */, 126 | E501325224A8C2C70055FF6E /* Cirrus.framework */, 127 | E501325424A8C2C70055FF6E /* CloudKitCodable.framework */, 128 | ); 129 | name = Products; 130 | sourceTree = ""; 131 | }; 132 | E568AFBC24A86F2600B34F66 = { 133 | isa = PBXGroup; 134 | children = ( 135 | E501324124A8C2C70055FF6E /* Cirrus.xcodeproj */, 136 | E568AFC724A86F2600B34F66 /* CirrusExample */, 137 | E568AFC624A86F2600B34F66 /* Products */, 138 | E568AFFD24A86FB100B34F66 /* Frameworks */, 139 | ); 140 | sourceTree = ""; 141 | }; 142 | E568AFC624A86F2600B34F66 /* Products */ = { 143 | isa = PBXGroup; 144 | children = ( 145 | E568AFC524A86F2600B34F66 /* CirrusExample.app */, 146 | ); 147 | name = Products; 148 | sourceTree = ""; 149 | }; 150 | E568AFC724A86F2600B34F66 /* CirrusExample */ = { 151 | isa = PBXGroup; 152 | children = ( 153 | E568AFFC24A86FA100B34F66 /* CirrusExample.entitlements */, 154 | E568AFC824A86F2600B34F66 /* AppDelegate.swift */, 155 | E568AFCA24A86F2600B34F66 /* SceneDelegate.swift */, 156 | E568AFE524A86F8500B34F66 /* Views */, 157 | E568AFE124A86F8500B34F66 /* Models */, 158 | E568AFE924A86F8500B34F66 /* Sync */, 159 | E568AFEB24A86F8500B34F66 /* State Management */, 160 | E568AFE324A86F8500B34F66 /* Test Data */, 161 | E568AFCE24A86F2900B34F66 /* Assets.xcassets */, 162 | E568AFD324A86F2900B34F66 /* LaunchScreen.storyboard */, 163 | E568AFD624A86F2900B34F66 /* Info.plist */, 164 | E568AFD024A86F2900B34F66 /* Preview Content */, 165 | ); 166 | path = CirrusExample; 167 | sourceTree = ""; 168 | }; 169 | E568AFD024A86F2900B34F66 /* Preview Content */ = { 170 | isa = PBXGroup; 171 | children = ( 172 | E568AFD124A86F2900B34F66 /* Preview Assets.xcassets */, 173 | ); 174 | path = "Preview Content"; 175 | sourceTree = ""; 176 | }; 177 | E568AFE124A86F8500B34F66 /* Models */ = { 178 | isa = PBXGroup; 179 | children = ( 180 | E568AFE224A86F8500B34F66 /* Bookmark.swift */, 181 | ); 182 | path = Models; 183 | sourceTree = ""; 184 | }; 185 | E568AFE324A86F8500B34F66 /* Test Data */ = { 186 | isa = PBXGroup; 187 | children = ( 188 | E568AFE424A86F8500B34F66 /* TestURLs.swift */, 189 | ); 190 | path = "Test Data"; 191 | sourceTree = ""; 192 | }; 193 | E568AFE524A86F8500B34F66 /* Views */ = { 194 | isa = PBXGroup; 195 | children = ( 196 | E568AFE624A86F8500B34F66 /* MultipleSelectionRow.swift */, 197 | E568AFE724A86F8500B34F66 /* ContentView.swift */, 198 | E568AFE824A86F8500B34F66 /* BookmarkRow.swift */, 199 | ); 200 | path = Views; 201 | sourceTree = ""; 202 | }; 203 | E568AFE924A86F8500B34F66 /* Sync */ = { 204 | isa = PBXGroup; 205 | children = ( 206 | E568AFEA24A86F8500B34F66 /* AppSyncManager.swift */, 207 | ); 208 | path = Sync; 209 | sourceTree = ""; 210 | }; 211 | E568AFEB24A86F8500B34F66 /* State Management */ = { 212 | isa = PBXGroup; 213 | children = ( 214 | E568AFEC24A86F8500B34F66 /* AppState.swift */, 215 | E568AFED24A86F8500B34F66 /* AppReducer.swift */, 216 | E568AFEE24A86F8500B34F66 /* AppAction.swift */, 217 | E568AFEF24A86F8500B34F66 /* Store.swift */, 218 | E568AFF024A86F8500B34F66 /* PersistentStore.swift */, 219 | ); 220 | path = "State Management"; 221 | sourceTree = ""; 222 | }; 223 | E568AFFD24A86FB100B34F66 /* Frameworks */ = { 224 | isa = PBXGroup; 225 | children = ( 226 | E568AFFE24A86FB100B34F66 /* CloudKit.framework */, 227 | ); 228 | name = Frameworks; 229 | sourceTree = ""; 230 | }; 231 | /* End PBXGroup section */ 232 | 233 | /* Begin PBXNativeTarget section */ 234 | E568AFC424A86F2600B34F66 /* CirrusExample */ = { 235 | isa = PBXNativeTarget; 236 | buildConfigurationList = E568AFD924A86F2900B34F66 /* Build configuration list for PBXNativeTarget "CirrusExample" */; 237 | buildPhases = ( 238 | E568AFC124A86F2600B34F66 /* Sources */, 239 | E568AFC224A86F2600B34F66 /* Frameworks */, 240 | E568AFC324A86F2600B34F66 /* Resources */, 241 | E501325A24A8C2CE0055FF6E /* Embed Frameworks */, 242 | ); 243 | buildRules = ( 244 | ); 245 | dependencies = ( 246 | ); 247 | name = CirrusExample; 248 | packageProductDependencies = ( 249 | ); 250 | productName = CirrusExample; 251 | productReference = E568AFC524A86F2600B34F66 /* CirrusExample.app */; 252 | productType = "com.apple.product-type.application"; 253 | }; 254 | /* End PBXNativeTarget section */ 255 | 256 | /* Begin PBXProject section */ 257 | E568AFBD24A86F2600B34F66 /* Project object */ = { 258 | isa = PBXProject; 259 | attributes = { 260 | LastSwiftUpdateCheck = 1140; 261 | LastUpgradeCheck = 1140; 262 | ORGANIZATIONNAME = "Jay Hickey"; 263 | TargetAttributes = { 264 | E568AFC424A86F2600B34F66 = { 265 | CreatedOnToolsVersion = 11.4.1; 266 | }; 267 | }; 268 | }; 269 | buildConfigurationList = E568AFC024A86F2600B34F66 /* Build configuration list for PBXProject "CirrusExample" */; 270 | compatibilityVersion = "Xcode 9.3"; 271 | developmentRegion = en; 272 | hasScannedForEncodings = 0; 273 | knownRegions = ( 274 | en, 275 | Base, 276 | ); 277 | mainGroup = E568AFBC24A86F2600B34F66; 278 | packageReferences = ( 279 | ); 280 | productRefGroup = E568AFC624A86F2600B34F66 /* Products */; 281 | projectDirPath = ""; 282 | projectReferences = ( 283 | { 284 | ProductGroup = E501324224A8C2C70055FF6E /* Products */; 285 | ProjectRef = E501324124A8C2C70055FF6E /* Cirrus.xcodeproj */; 286 | }, 287 | ); 288 | projectRoot = ""; 289 | targets = ( 290 | E568AFC424A86F2600B34F66 /* CirrusExample */, 291 | ); 292 | }; 293 | /* End PBXProject section */ 294 | 295 | /* Begin PBXReferenceProxy section */ 296 | E501324E24A8C2C70055FF6E /* CKRecordCoder.framework */ = { 297 | isa = PBXReferenceProxy; 298 | fileType = wrapper.framework; 299 | path = CKRecordCoder.framework; 300 | remoteRef = E501324D24A8C2C70055FF6E /* PBXContainerItemProxy */; 301 | sourceTree = BUILT_PRODUCTS_DIR; 302 | }; 303 | E501325024A8C2C70055FF6E /* CKRecordCoderTests.xctest */ = { 304 | isa = PBXReferenceProxy; 305 | fileType = wrapper.cfbundle; 306 | path = CKRecordCoderTests.xctest; 307 | remoteRef = E501324F24A8C2C70055FF6E /* PBXContainerItemProxy */; 308 | sourceTree = BUILT_PRODUCTS_DIR; 309 | }; 310 | E501325224A8C2C70055FF6E /* Cirrus.framework */ = { 311 | isa = PBXReferenceProxy; 312 | fileType = wrapper.framework; 313 | path = Cirrus.framework; 314 | remoteRef = E501325124A8C2C70055FF6E /* PBXContainerItemProxy */; 315 | sourceTree = BUILT_PRODUCTS_DIR; 316 | }; 317 | E501325424A8C2C70055FF6E /* CloudKitCodable.framework */ = { 318 | isa = PBXReferenceProxy; 319 | fileType = wrapper.framework; 320 | path = CloudKitCodable.framework; 321 | remoteRef = E501325324A8C2C70055FF6E /* PBXContainerItemProxy */; 322 | sourceTree = BUILT_PRODUCTS_DIR; 323 | }; 324 | /* End PBXReferenceProxy section */ 325 | 326 | /* Begin PBXResourcesBuildPhase section */ 327 | E568AFC324A86F2600B34F66 /* Resources */ = { 328 | isa = PBXResourcesBuildPhase; 329 | buildActionMask = 2147483647; 330 | files = ( 331 | E568AFD524A86F2900B34F66 /* LaunchScreen.storyboard in Resources */, 332 | E568AFD224A86F2900B34F66 /* Preview Assets.xcassets in Resources */, 333 | E568AFCF24A86F2900B34F66 /* Assets.xcassets in Resources */, 334 | ); 335 | runOnlyForDeploymentPostprocessing = 0; 336 | }; 337 | /* End PBXResourcesBuildPhase section */ 338 | 339 | /* Begin PBXSourcesBuildPhase section */ 340 | E568AFC124A86F2600B34F66 /* Sources */ = { 341 | isa = PBXSourcesBuildPhase; 342 | buildActionMask = 2147483647; 343 | files = ( 344 | E568AFC924A86F2600B34F66 /* AppDelegate.swift in Sources */, 345 | E568AFF924A86F8500B34F66 /* AppAction.swift in Sources */, 346 | E568AFF824A86F8500B34F66 /* AppReducer.swift in Sources */, 347 | E568AFF724A86F8500B34F66 /* AppState.swift in Sources */, 348 | E568AFF324A86F8500B34F66 /* MultipleSelectionRow.swift in Sources */, 349 | E568AFF524A86F8500B34F66 /* BookmarkRow.swift in Sources */, 350 | E568AFF424A86F8500B34F66 /* ContentView.swift in Sources */, 351 | E568AFFA24A86F8500B34F66 /* Store.swift in Sources */, 352 | E568AFF224A86F8500B34F66 /* TestURLs.swift in Sources */, 353 | E568AFCB24A86F2600B34F66 /* SceneDelegate.swift in Sources */, 354 | E568AFF124A86F8500B34F66 /* Bookmark.swift in Sources */, 355 | E568AFFB24A86F8500B34F66 /* PersistentStore.swift in Sources */, 356 | E568AFF624A86F8500B34F66 /* AppSyncManager.swift in Sources */, 357 | ); 358 | runOnlyForDeploymentPostprocessing = 0; 359 | }; 360 | /* End PBXSourcesBuildPhase section */ 361 | 362 | /* Begin PBXVariantGroup section */ 363 | E568AFD324A86F2900B34F66 /* LaunchScreen.storyboard */ = { 364 | isa = PBXVariantGroup; 365 | children = ( 366 | E568AFD424A86F2900B34F66 /* Base */, 367 | ); 368 | name = LaunchScreen.storyboard; 369 | sourceTree = ""; 370 | }; 371 | /* End PBXVariantGroup section */ 372 | 373 | /* Begin XCBuildConfiguration section */ 374 | E568AFD724A86F2900B34F66 /* Debug */ = { 375 | isa = XCBuildConfiguration; 376 | buildSettings = { 377 | ALWAYS_SEARCH_USER_PATHS = NO; 378 | CLANG_ANALYZER_NONNULL = YES; 379 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 380 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 381 | CLANG_CXX_LIBRARY = "libc++"; 382 | CLANG_ENABLE_MODULES = YES; 383 | CLANG_ENABLE_OBJC_ARC = YES; 384 | CLANG_ENABLE_OBJC_WEAK = YES; 385 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 386 | CLANG_WARN_BOOL_CONVERSION = YES; 387 | CLANG_WARN_COMMA = YES; 388 | CLANG_WARN_CONSTANT_CONVERSION = YES; 389 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 390 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 391 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 392 | CLANG_WARN_EMPTY_BODY = YES; 393 | CLANG_WARN_ENUM_CONVERSION = YES; 394 | CLANG_WARN_INFINITE_RECURSION = YES; 395 | CLANG_WARN_INT_CONVERSION = YES; 396 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 397 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 398 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 399 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 400 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 401 | CLANG_WARN_STRICT_PROTOTYPES = YES; 402 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 403 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 404 | CLANG_WARN_UNREACHABLE_CODE = YES; 405 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 406 | COPY_PHASE_STRIP = NO; 407 | DEBUG_INFORMATION_FORMAT = dwarf; 408 | ENABLE_STRICT_OBJC_MSGSEND = YES; 409 | ENABLE_TESTABILITY = YES; 410 | GCC_C_LANGUAGE_STANDARD = gnu11; 411 | GCC_DYNAMIC_NO_PIC = NO; 412 | GCC_NO_COMMON_BLOCKS = YES; 413 | GCC_OPTIMIZATION_LEVEL = 0; 414 | GCC_PREPROCESSOR_DEFINITIONS = ( 415 | "DEBUG=1", 416 | "$(inherited)", 417 | ); 418 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 419 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 420 | GCC_WARN_UNDECLARED_SELECTOR = YES; 421 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 422 | GCC_WARN_UNUSED_FUNCTION = YES; 423 | GCC_WARN_UNUSED_VARIABLE = YES; 424 | IPHONEOS_DEPLOYMENT_TARGET = 13.4; 425 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 426 | MTL_FAST_MATH = YES; 427 | ONLY_ACTIVE_ARCH = YES; 428 | SDKROOT = iphoneos; 429 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 430 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 431 | }; 432 | name = Debug; 433 | }; 434 | E568AFD824A86F2900B34F66 /* Release */ = { 435 | isa = XCBuildConfiguration; 436 | buildSettings = { 437 | ALWAYS_SEARCH_USER_PATHS = NO; 438 | CLANG_ANALYZER_NONNULL = YES; 439 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 440 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 441 | CLANG_CXX_LIBRARY = "libc++"; 442 | CLANG_ENABLE_MODULES = YES; 443 | CLANG_ENABLE_OBJC_ARC = YES; 444 | CLANG_ENABLE_OBJC_WEAK = YES; 445 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 446 | CLANG_WARN_BOOL_CONVERSION = YES; 447 | CLANG_WARN_COMMA = YES; 448 | CLANG_WARN_CONSTANT_CONVERSION = YES; 449 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 450 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 451 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 452 | CLANG_WARN_EMPTY_BODY = YES; 453 | CLANG_WARN_ENUM_CONVERSION = YES; 454 | CLANG_WARN_INFINITE_RECURSION = YES; 455 | CLANG_WARN_INT_CONVERSION = YES; 456 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 457 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 458 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 459 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 460 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 461 | CLANG_WARN_STRICT_PROTOTYPES = YES; 462 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 463 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 464 | CLANG_WARN_UNREACHABLE_CODE = YES; 465 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 466 | COPY_PHASE_STRIP = NO; 467 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 468 | ENABLE_NS_ASSERTIONS = NO; 469 | ENABLE_STRICT_OBJC_MSGSEND = YES; 470 | GCC_C_LANGUAGE_STANDARD = gnu11; 471 | GCC_NO_COMMON_BLOCKS = YES; 472 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 473 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 474 | GCC_WARN_UNDECLARED_SELECTOR = YES; 475 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 476 | GCC_WARN_UNUSED_FUNCTION = YES; 477 | GCC_WARN_UNUSED_VARIABLE = YES; 478 | IPHONEOS_DEPLOYMENT_TARGET = 13.4; 479 | MTL_ENABLE_DEBUG_INFO = NO; 480 | MTL_FAST_MATH = YES; 481 | SDKROOT = iphoneos; 482 | SWIFT_COMPILATION_MODE = wholemodule; 483 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 484 | VALIDATE_PRODUCT = YES; 485 | }; 486 | name = Release; 487 | }; 488 | E568AFDA24A86F2900B34F66 /* Debug */ = { 489 | isa = XCBuildConfiguration; 490 | buildSettings = { 491 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 492 | CODE_SIGN_ENTITLEMENTS = CirrusExample/CirrusExample.entitlements; 493 | CODE_SIGN_STYLE = Automatic; 494 | DEVELOPMENT_ASSET_PATHS = "\"CirrusExample/Preview Content\""; 495 | DEVELOPMENT_TEAM = M7NZ5ETEY2; 496 | ENABLE_PREVIEWS = YES; 497 | INFOPLIST_FILE = CirrusExample/Info.plist; 498 | LD_RUNPATH_SEARCH_PATHS = ( 499 | "$(inherited)", 500 | "@executable_path/Frameworks", 501 | ); 502 | PRODUCT_BUNDLE_IDENTIFIER = com.jayhickey.CirrusExample; 503 | PRODUCT_NAME = "$(TARGET_NAME)"; 504 | SUPPORTS_MACCATALYST = YES; 505 | SWIFT_VERSION = 5.0; 506 | TARGETED_DEVICE_FAMILY = "1,2"; 507 | }; 508 | name = Debug; 509 | }; 510 | E568AFDB24A86F2900B34F66 /* Release */ = { 511 | isa = XCBuildConfiguration; 512 | buildSettings = { 513 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 514 | CODE_SIGN_ENTITLEMENTS = CirrusExample/CirrusExample.entitlements; 515 | CODE_SIGN_STYLE = Automatic; 516 | DEVELOPMENT_ASSET_PATHS = "\"CirrusExample/Preview Content\""; 517 | DEVELOPMENT_TEAM = M7NZ5ETEY2; 518 | ENABLE_PREVIEWS = YES; 519 | INFOPLIST_FILE = CirrusExample/Info.plist; 520 | LD_RUNPATH_SEARCH_PATHS = ( 521 | "$(inherited)", 522 | "@executable_path/Frameworks", 523 | ); 524 | PRODUCT_BUNDLE_IDENTIFIER = com.jayhickey.CirrusExample; 525 | PRODUCT_NAME = "$(TARGET_NAME)"; 526 | SUPPORTS_MACCATALYST = YES; 527 | SWIFT_VERSION = 5.0; 528 | TARGETED_DEVICE_FAMILY = "1,2"; 529 | }; 530 | name = Release; 531 | }; 532 | /* End XCBuildConfiguration section */ 533 | 534 | /* Begin XCConfigurationList section */ 535 | E568AFC024A86F2600B34F66 /* Build configuration list for PBXProject "CirrusExample" */ = { 536 | isa = XCConfigurationList; 537 | buildConfigurations = ( 538 | E568AFD724A86F2900B34F66 /* Debug */, 539 | E568AFD824A86F2900B34F66 /* Release */, 540 | ); 541 | defaultConfigurationIsVisible = 0; 542 | defaultConfigurationName = Release; 543 | }; 544 | E568AFD924A86F2900B34F66 /* Build configuration list for PBXNativeTarget "CirrusExample" */ = { 545 | isa = XCConfigurationList; 546 | buildConfigurations = ( 547 | E568AFDA24A86F2900B34F66 /* Debug */, 548 | E568AFDB24A86F2900B34F66 /* Release */, 549 | ); 550 | defaultConfigurationIsVisible = 0; 551 | defaultConfigurationName = Release; 552 | }; 553 | /* End XCConfigurationList section */ 554 | }; 555 | rootObject = E568AFBD24A86F2600B34F66 /* Project object */; 556 | } 557 | -------------------------------------------------------------------------------- /Example/CirrusExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/CirrusExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/CirrusExample.xcodeproj/xcshareddata/xcschemes/CirrusExample.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Example/CirrusExample/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Cirrus 2 | import UIKit 3 | 4 | @UIApplicationMain 5 | class AppDelegate: UIResponder, UIApplicationDelegate { 6 | 7 | func application( 8 | _ application: UIApplication, 9 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 10 | ) -> Bool { 11 | 12 | // Register for CloudKit remote push notifications 13 | application.registerForRemoteNotifications() 14 | 15 | return true 16 | } 17 | 18 | // MARK: UISceneSession Lifecycle 19 | 20 | func application( 21 | _ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, 22 | options: UIScene.ConnectionOptions 23 | ) -> UISceneConfiguration { 24 | // Called when a new scene session is being created. 25 | // Use this method to select a configuration to create the new scene with. 26 | return UISceneConfiguration( 27 | name: "Default Configuration", sessionRole: connectingSceneSession.role) 28 | } 29 | 30 | func application( 31 | _ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any] 32 | ) { 33 | // Process CloudKit change notifications 34 | syncManager?.engine.processRemoteChangeNotification(with: userInfo) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Example/CirrusExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Example/CirrusExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/CirrusExample/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Example/CirrusExample/CirrusExample.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.icloud-container-identifiers 8 | 9 | iCloud.com.jayhickey.CirrusExample 10 | 11 | com.apple.developer.icloud-services 12 | 13 | CloudKit 14 | 15 | com.apple.security.app-sandbox 16 | 17 | com.apple.security.network.client 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Example/CirrusExample/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | 37 | 38 | 39 | 40 | UILaunchStoryboardName 41 | LaunchScreen 42 | UIRequiredDeviceCapabilities 43 | 44 | armv7 45 | 46 | UISupportedInterfaceOrientations 47 | 48 | UIInterfaceOrientationPortrait 49 | UIInterfaceOrientationLandscapeLeft 50 | UIInterfaceOrientationLandscapeRight 51 | 52 | UISupportedInterfaceOrientations~ipad 53 | 54 | UIInterfaceOrientationPortrait 55 | UIInterfaceOrientationPortraitUpsideDown 56 | UIInterfaceOrientationLandscapeLeft 57 | UIInterfaceOrientationLandscapeRight 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /Example/CirrusExample/Models/Bookmark.swift: -------------------------------------------------------------------------------- 1 | import CloudKitCodable 2 | import Foundation 3 | 4 | public struct Bookmark { 5 | public var id: UUID 6 | public var cloudKitSystemFields: Data? 7 | public var cloudKitIdentifier: CloudKitIdentifier { 8 | return id.uuidString 9 | } 10 | 11 | public var created: Date 12 | public var title: String 13 | public var url: URL 14 | 15 | public init( 16 | id: UUID = UUID(), 17 | created: Date = Date(), 18 | title: String, 19 | url: URL 20 | ) { 21 | self.id = id 22 | self.created = created 23 | self.title = title 24 | self.url = url 25 | } 26 | } 27 | 28 | extension Bookmark: CloudKitCodable { 29 | public static func resolveConflict(clientModel: Self, serverModel: Self) -> Self? { 30 | if let clientDate = clientModel.cloudKitLastModifiedDate, 31 | let serverDate = serverModel.cloudKitLastModifiedDate 32 | { 33 | return clientDate > serverDate ? clientModel : serverModel 34 | } 35 | return serverModel 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Example/CirrusExample/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/CirrusExample/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | var syncManager: AppSyncManager? 5 | 6 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 7 | 8 | var window: UIWindow? 9 | 10 | func scene( 11 | _ scene: UIScene, willConnectTo session: UISceneSession, 12 | options connectionOptions: UIScene.ConnectionOptions 13 | ) { 14 | 15 | let store = defaultStore() 16 | let sync = AppSyncManager(initialItems: store.value.bookmarks, dispatch: store.dispatch) 17 | syncManager = sync 18 | 19 | let contentView = ContentView(store: store) 20 | .environmentObject(sync) 21 | 22 | if let windowScene = scene as? UIWindowScene { 23 | let window = UIWindow(windowScene: windowScene) 24 | window.rootViewController = UIHostingController(rootView: contentView) 25 | self.window = window 26 | window.makeKeyAndVisible() 27 | } 28 | } 29 | 30 | func sceneDidBecomeActive(_ scene: UIScene) { 31 | syncManager?.engine.forceSync() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Example/CirrusExample/State Management/AppAction.swift: -------------------------------------------------------------------------------- 1 | import CloudKitCodable 2 | import Foundation 3 | 4 | public enum AppAction { 5 | case addBookmark(URL) 6 | case removeBookmarks(Set) 7 | case modifyBookmarks(Set) 8 | 9 | // Cloud Sync 10 | case cloudUpdated(Set) 11 | case cloudDeleted(Set) 12 | case fetchCloudChanges 13 | } 14 | -------------------------------------------------------------------------------- /Example/CirrusExample/State Management/AppReducer.swift: -------------------------------------------------------------------------------- 1 | import Cirrus 2 | import Foundation 3 | 4 | public func appReducer(appState: inout AppState, actions: AppAction) -> [Effect] { 5 | switch actions { 6 | case let .addBookmark(url): 7 | let bookmark = Bookmark( 8 | created: Date(), title: url.host ?? "New Bookmark", 9 | url: url) 10 | appState.bookmarks.insert(bookmark) 11 | return [{ syncManager?.engine.upload(bookmark) }] 12 | 13 | case .removeBookmarks(let bookmarks): 14 | let bookmarksToDelete = appState.bookmarks 15 | .filter { bookmarks.map(\.cloudKitIdentifier).contains($0.cloudKitIdentifier) } 16 | bookmarksToDelete 17 | .forEach { appState.bookmarks.remove($0) } 18 | return [{ syncManager?.engine.delete(Array(bookmarksToDelete)) }] 19 | 20 | case .modifyBookmarks(let bookmarks): 21 | let bookmarks: [Bookmark] = bookmarks.compactMap { updatedBookmark in 22 | guard 23 | var bookmark = appState.bookmarks.first(where: { 24 | $0.cloudKitIdentifier == updatedBookmark.cloudKitIdentifier 25 | }), 26 | let randomNewURL = testURLs.randomElement() 27 | else { return nil } 28 | bookmark.title = randomNewURL.host ?? "New Bookmark" 29 | bookmark.url = randomNewURL 30 | return bookmark 31 | } 32 | bookmarks.forEach { appState.bookmarks.insertOrReplace($0) } 33 | return [{ syncManager?.engine.upload(bookmarks) }] 34 | 35 | case .cloudUpdated(let bookmarks): 36 | bookmarks.forEach { appState.bookmarks.insertOrReplace($0) } 37 | return [] 38 | 39 | case .cloudDeleted(let identifiers): 40 | let bookmarksToDelete = appState.bookmarks 41 | .filter { identifiers.contains($0.cloudKitIdentifier) } 42 | bookmarksToDelete 43 | .forEach { appState.bookmarks.remove($0) } 44 | return [] 45 | 46 | case .fetchCloudChanges: 47 | return [{ syncManager?.engine.forceSync() }] 48 | } 49 | } 50 | 51 | extension Set where Set.Element == Bookmark { 52 | mutating func insertOrReplace(_ item: Bookmark) { 53 | if let bookmark = self.first(where: { $0.cloudKitIdentifier == item.cloudKitIdentifier }) { 54 | remove(bookmark) 55 | } 56 | insert(item) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Example/CirrusExample/State Management/AppState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct AppState: Codable { 4 | public internal(set) var bookmarks: Set = [] 5 | } 6 | -------------------------------------------------------------------------------- /Example/CirrusExample/State Management/PersistentStore.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | private let saveQueue = DispatchQueue(label: "com.jayhickey.persistentStoreQueue") 4 | 5 | public enum PersistentStore { 6 | static var fileURL = URL( 7 | fileURLWithPath: NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true) 8 | .first! + "/data") 9 | 10 | static let key = "AppState" 11 | 12 | public static func save(state: AppState) { 13 | saveQueue.async { 14 | let encoder = JSONEncoder() 15 | do { 16 | let data = try encoder.encode(state) 17 | try data.write(to: fileURL, options: [.atomic]) 18 | } catch let error { 19 | fatalError("Unable to save app state: \(error)") 20 | } 21 | } 22 | } 23 | 24 | public static func load() -> AppState? { 25 | guard let data = try? Data(contentsOf: fileURL) else { 26 | return nil 27 | } 28 | let decoder = JSONDecoder() 29 | return try? decoder.decode(AppState.self, from: data) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Example/CirrusExample/State Management/Store.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public typealias Effect = () -> Void 4 | 5 | public typealias Reducer = (inout AppState, AppAction) -> [Effect] 6 | 7 | public func defaultStore() -> Store { 8 | let appState = PersistentStore.load() 9 | return Store(value: appState ?? AppState(), reducer: appReducer) 10 | } 11 | 12 | public class Store: ObservableObject { 13 | @Published public internal(set) var value: AppState 14 | private let reducer: Reducer 15 | 16 | lazy var defaultEffects: [Effect] = [ 17 | { PersistentStore.save(state: self.value) } 18 | ] 19 | 20 | public init(value: AppState, reducer: @escaping Reducer) { 21 | self.value = value 22 | self.reducer = reducer 23 | } 24 | 25 | public func dispatch(_ action: AppAction) { 26 | let effects = reducer(&value, action) 27 | (effects + defaultEffects).forEach { $0() } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Example/CirrusExample/Sync/AppSyncManager.swift: -------------------------------------------------------------------------------- 1 | import Cirrus 2 | import CloudKit 3 | import CloudKitCodable 4 | import Combine 5 | import Foundation 6 | 7 | public class AppSyncManager: ObservableObject { 8 | public let engine: SyncEngine 9 | 10 | @Published public private(set) var accountStatus: AccountStatus = .unknown 11 | 12 | private let dispatch: (AppAction) -> Void 13 | private var cancellables = Set() 14 | 15 | public init( 16 | initialItems: Set, 17 | dispatch: @escaping (AppAction) -> Void 18 | ) { 19 | 20 | self.engine = SyncEngine(initialItems: Array(initialItems)) 21 | 22 | self.dispatch = dispatch 23 | 24 | self.engine.$accountStatus 25 | .assign(to: \.accountStatus, on: self) 26 | .store(in: &cancellables) 27 | 28 | engine.modelsChanged 29 | .receive(on: DispatchQueue.main) 30 | .sink { [weak self] change in 31 | switch change { 32 | case let .updated(models): 33 | self?.dispatch(.cloudUpdated(models)) 34 | case let .deleted(bookmarkIDs): 35 | self?.dispatch(.cloudDeleted(bookmarkIDs)) 36 | } 37 | } 38 | .store(in: &cancellables) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Example/CirrusExample/Test Data/TestURLs.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let testURLs: [URL] = [ 4 | "http://idsssksemxw.fyrqfagujag.de", 5 | "http://apwlrrtukmnu.rhbochnu.ru", 6 | "http://uemeetpcte.fwrzqbmewqve.bg", 7 | "http://gaxaypvc.zhvokpt.cn", 8 | "http://pavgchkyygc.qirnjpderf.info", 9 | "http://bsjqlsmbthhv.stnzwepfvd.gov", 10 | "http://gscmwbno.dadpbwxjr.org", 11 | "http://laahe.maublbunnq.net", 12 | "http://faege.harqseseetl.cn", 13 | "http://yjfzugti.kxtqyxwcred.cn", 14 | "http://vnozshlbskl.kfdenxolrfjo.biz", 15 | "http://ggoqckeq.wyvyqgjoq.gov", 16 | "http://ucpjngpwvr.bywsahwr.no", 17 | "http://pnrgtaukpm.rboehdbc.br", 18 | "http://etfwumt.embrdhldc.br", 19 | "http://pakqooyspb.yggecarwmv.sg", 20 | "http://mlwyoythn.seihdaxfovl.br", 21 | "http://ovrki.swadsysrsa.com.au", 22 | "http://skvznwws.sefssbkcj.ru", 23 | "http://gxyywqqoqvb.fwitsemdx.net", 24 | "http://qijalty.zwdxsung.de", 25 | "http://huoecix.uaeliuts.br", 26 | "http://ltkkpo.hjwoufwo.br", 27 | "http://rcxyx.digxczthkmru.biz", 28 | "http://dkpzyj.vyzvpcto", 29 | "http://riypkxirhv.cxpfheego.fr", 30 | "http://mjxcl.qliiuhxfegx.de", 31 | "http://gozwt.bynpipcr.org", 32 | "http://dfvpod.vbozhllis", 33 | "http://ptoqscg.sjtfowk.fr", 34 | "http://yovupktw.ffpeneycureb.bg", 35 | "http://klczinjumxqg.aakxfpmkdq.cn", 36 | "http://lajmtg.ejwnxghkexrr.ru", 37 | "http://bvxrhiuyrz.sjwlqtqaq.info", 38 | "http://wlhajzrhqtdo.kwejnesgn.net", 39 | "http://eihezlbvwj.finnzhqorbkw.fr", 40 | "http://adeoswsbdwap.wlisrrgernuf.com.au", 41 | "http://qbfobjtpcph.teeicau.fr", 42 | "http://lbzpsmjyrf.sldtuwjwmr.eu", 43 | "http://veoxfj.xukxkdjtb.eu", 44 | "http://qsktmfpvbcmc.hgxflgoj.eu", 45 | "http://glcpfeqeuioo.tdqvfcxokel.net", 46 | "http://rezsdfdgv.kmnfubtpv.ru", 47 | "http://rdiors.ciiuninqnqx.fr", 48 | "http://ivmnqohgkemb.vpzoicxr.hu", 49 | "http://zkbnbyxb.tnujcbqm.co.uk", 50 | "http://noydo.mrkeoudyw.no", 51 | "http://vobch.wrryhefksu.cn", 52 | "http://iwiagnpbrnx.osxpufer.hu", 53 | "http://peavptpypyu.yblnccbalpti.com.au", 54 | "http://njfkgvogq.zfcpdxn.biz", 55 | "http://ypbbrbmhuvmi.votbjhizm.cn", 56 | "http://oxjmll.janlspxzk.bg", 57 | "http://tchhvjrd.rpaweyfrjqd.bg", 58 | "http://qejgciqvtj.brskivczlcvp.eu", 59 | "http://gjrjc.ahsgjbwev.co.uk", 60 | "http://wxtgf.jfalaqmcxw.tw", 61 | "http://zdhgwnp.ktsrupoowuef", 62 | "http://qavdcs.wzyzgevuuten.de", 63 | "http://ibkwxfbcegt.cwhuweuu.info", 64 | "http://zzuus.hevqffndkogp.br", 65 | "http://uxwcssgmnknn.ihchphdfn.cn", 66 | "http://qtgwjbwezs.rkneyxrl.sg", 67 | "http://tkhirknetfun.rwonanghe.no", 68 | "http://ipsabaaukhc.sqglway.cn", 69 | "http://vwgvkmdpdy.tqyurypcfrey.sg", 70 | "http://jelimddj.zuwcjaaht.no", 71 | "http://oiddlj.phczrhkatoe.info", 72 | "http://dxvfgvgnpxtd.wgrgbgne.co.uk", 73 | "http://lrffgj.engatnvzb.fr", 74 | "http://uodqvvxxckci.oawtggcltj.de", 75 | "http://wglxrisf.jbhgyjras.sg", 76 | "http://sdizjlkcu.prdapujhavqb.biz", 77 | "http://amoaevowzwwj.gmccbtgc.cn", 78 | "http://lqbhh.jedvtda.gov", 79 | "http://xoptxxakad.ujodjzulh.biz", 80 | "http://qgsmzwmop.dfdbcdlcg.ru", 81 | "http://plazlulswajc.wcpiresvjww.de", 82 | "http://ipgglwrmwdgh.chffabipkztd.br", 83 | "http://aroza.ggpcycz.biz", 84 | "http://gxlodqo.zdpzwtuz.tw", 85 | "http://otmszsic.kbstiqewtv.de", 86 | "http://uooulholbazu.sufbwwlyp.com.au", 87 | "http://fjezfpy.dmuoujz.br", 88 | "http://zpwsjct.yffnkmtu.no", 89 | "http://zfrajduyye.tnyjkqsmjzkp.com.au", 90 | "http://zrstiltoctxg.vestygshqi.eu", 91 | "http://sznhe.hwfaequtt.gov", 92 | "http://zdmevgccyj.hkwzjjhowol.biz", 93 | "http://pskjlyzlb.pxtrzrbsy.de", 94 | "http://yvyfjvuux.kpttbstmt.co.uk", 95 | "http://qzsqq.jpfynawtjsn.com.au", 96 | "http://xperjgjcs.itthmky.co.uk", 97 | "http://nlrblovvgj.vhpayyg.sg", 98 | "http://yljse.eoplidwzfhn.biz", 99 | "http://tkdpr.pqrvysujcmo.tw", 100 | "http://djclnylsgz.jtfmixfynwt.org", 101 | "http://owqbkksnt.zgtlzakujdz.hu", 102 | "http://wcjkydwn.tetdolrigrp", 103 | "http://pzmkj.jgbgilrhoo.br", 104 | "http://hzakolc.stmrvcrimbxw.sg", 105 | "http://cpktwzhrgpqg.escbkvocrruz.info", 106 | "http://xzcwcshwpge.ntxttcmv.com.au", 107 | "http://rszijtinofnr.pjjlypp.com.au", 108 | "http://jcwce.xjgobfxly.co.uk", 109 | "http://nlmenbownnmc.plumozvliba.fr", 110 | "http://xwgcvtn.ybimywzk", 111 | "http://aknmymikvk.grhcxkyqy.co.uk", 112 | "http://zocykciju.uhwtuedqo.gov", 113 | "http://fwzdgxufdjf.mdcommjvgrr.eu", 114 | "http://weblsyqo.uuuoaxxfpjis.ru", 115 | "http://ehqlzhlkepm.ilgwiardor.biz", 116 | "http://wrwejuzn.pzbxlmb.eu", 117 | "http://qjkxfsxwwmny.kpupypomrelt.biz", 118 | "http://fcyethormhoj.bxczhru.hu", 119 | "http://jlcbpourl.ujeortfdaumf.br", 120 | "http://hvrypooz.rbqgvhr.br", 121 | "http://bzpts.wtpjyktgglf.hu", 122 | "http://uvblw.rszjnblnqeg.de", 123 | "http://ablym.fsqkorek.bg", 124 | "http://hkhzjrnkc.bhhniho.tw", 125 | "http://byltpv.gaszqajx", 126 | "http://pnerofzvth.ibijauc.net", 127 | "http://kwqdvgdfe.ztqdlei.fr", 128 | "http://csckt.ttfvjvgfl.fr", 129 | "http://sohwr.pikantln.br", 130 | "http://qfwbyq.uacbfolh.co.uk", 131 | "http://pcrwragsnzdb.gradszuztuc.br", 132 | "http://qhiwh.zytryzjmznnu.tw", 133 | "http://vyxutx.nzjoqqwnxu.org", 134 | "http://ndvnninavhv.dtxxmvl.eu", 135 | "http://vplmmykglgu.chckvdfcyg.co.uk", 136 | "http://edfzpgulvg.ifhoqoieqqha.de", 137 | "http://gojnubry.rnnmyjsw.no", 138 | "http://dgorptifajrk.gtdbvuzcmn.net", 139 | "http://mzrirqlxedmx.sxukikqbo.no", 140 | "http://jqzvepuqomyg.kdgnqez.cn", 141 | "http://utkfj.tbjcsiywy.tw", 142 | "http://nfktiuwp.ntivvcogh.ru", 143 | "http://bzberkcniv.vblokgl", 144 | "http://ytxupzjvh.njhpnzzq.org", 145 | "http://mndnyry.dytbrqwh.no", 146 | "http://cynqhv.uuekhmxu.net", 147 | "http://thjylhrn.ijgzpjxczeyf", 148 | "http://kkzwhtmsnu.lfjdsjl.biz", 149 | "http://lrziuznsfn.pxjlfdyyq.bg", 150 | "http://bxkfp.qrkbikkckxv.net", 151 | "http://feipqoto.jhmlexqur.sg", 152 | "http://bjuluxvs.ldyqlngagvm.net", 153 | "http://zchwt.oaopkjb.com.au", 154 | "http://xwzjzyz.lflsbyiex.fr", 155 | "http://udojedyp.attyqthqr.sg", 156 | "http://dnnvo.etkpgetvn.ru", 157 | "http://mnlngfmxzunr.puodqdp.br", 158 | "http://zkcedysbwfoh.vnfsmzfdavy.net", 159 | "http://osojpntssxqk.mpnuhiinbumh.ru", 160 | "http://cwcbkvpuk.ocultfk.co.uk", 161 | "http://xbcgjphdbpc.ryrsjmhdxk.bg", 162 | "http://fdtko.elagskwaoxpq.org", 163 | "http://oezyrhc.suoxyihmim", 164 | "http://trtprhn.xanmenlv.br", 165 | "http://knhzkghst.erzyitnaaa.sg", 166 | "http://aulfiwadjkqr.bysuriyih.hu", 167 | "http://akrblyaltl.chrfrcwimx.co.uk", 168 | "http://rxmqgu.yvyqxjpxvj.cn", 169 | "http://lqdqhgmq.kxxcvktbfha.eu", 170 | "http://qypgvk.ewauaqcgds", 171 | "http://pxrkilmns.oyennkjxzn.br", 172 | "http://ivrkc.cbjszbdhmqv.com.au", 173 | "http://kdhxqr.orujrcej.org", 174 | "http://epioih.lpcblhfvkm.tw", 175 | "http://eapwu.oxdxkjbaspia.com.au", 176 | "http://qhwbobxyoqz.qppkodisbsc.biz", 177 | "http://usavxvlerm.tjrhaqaqgqb.br", 178 | "http://jnubpxtkpuf.pqrhdkambi.org", 179 | "http://jdzzeutoho.xmjhbempuc.sg", 180 | "http://frdss.flvikuneo.biz", 181 | "http://crjobqqf.gafncke.co.uk", 182 | "http://xriim.thdywfkzwu.gov", 183 | "http://keenkesyhccn.aenjrecz.cn", 184 | "http://vnluk.ijqmndxs.br", 185 | "http://zyymeyrshix.hfldtwxec.co.uk", 186 | "http://tsawqssms.kfvjworelb.org", 187 | "http://mpkjnomtcf.cccvupola.tw", 188 | "http://jpwbuhc.zoxjykyk.info", 189 | "http://qqdst.mioyjhftxbus.fr", 190 | "http://zilwsjgruks.avctvocjn.de", 191 | "http://teouygeufn.cfpixktp.br", 192 | "http://kwnglppzbhs.vnecrzhf.com.au", 193 | "http://lvsifmxa.ixtojjei.de", 194 | "http://aqhnujft.kzbvutdzgb.eu", 195 | "http://jxgygpco.oorvcme.sg", 196 | "http://vsfwoz.szwtzicxob.info", 197 | "http://drfrgx.ijqqqmjvi.ru", 198 | "http://ypvujvdmssn.wqnciuzvdjlu.eu", 199 | "http://dvgbrfrnzajd.cvzysqmuy.sg", 200 | "http://tkdfeddizkj.pafpapsnrnn.net", 201 | "http://dcacju.uyczcghcqruf.bg", 202 | "http://utonggatwhxz.aicwdazc.info", 203 | "http://kfvbza.zvmoitujnrq.fr", 204 | ].compactMap(URL.init(string:)) 205 | -------------------------------------------------------------------------------- /Example/CirrusExample/Views/BookmarkRow.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct BookmarkRow: View { 4 | @State var bookmark: Bookmark 5 | 6 | var body: some View { 7 | VStack(alignment: .leading) { 8 | 9 | Text("\(bookmark.url)") 10 | .font(.headline) 11 | 12 | Text( 13 | { () -> String in 14 | let formatter = DateFormatter() 15 | formatter.dateFormat = "MM/dd @ hh:mm:ss.SSS" 16 | formatter.locale = .autoupdatingCurrent 17 | formatter.timeZone = .autoupdatingCurrent 18 | return "Created on \(formatter.string(from: bookmark.created))" 19 | }() 20 | ) 21 | .font(.subheadline) 22 | 23 | Text(bookmark.cloudKitIdentifier) 24 | .font(.caption) 25 | .scaledToFill() 26 | } 27 | } 28 | } 29 | 30 | struct BookmarkRow_Previews: PreviewProvider { 31 | static var previews: some View { 32 | BookmarkRow( 33 | bookmark: Bookmark( 34 | title: "Apple", 35 | url: testURLs.randomElement()! 36 | ) 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Example/CirrusExample/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | import Cirrus 2 | import CryptoKit 3 | import SwiftUI 4 | 5 | struct SyncEngineKey: EnvironmentKey { 6 | static let defaultValue: AppSyncManager = AppSyncManager(initialItems: [], dispatch: { _ in }) 7 | } 8 | 9 | struct ContentView: View { 10 | @ObservedObject var store: Store 11 | @State var accountStatus: AccountStatus = .unknown 12 | @State private var selections: Set = [] 13 | @EnvironmentObject var syncManager: AppSyncManager 14 | 15 | var body: some View { 16 | NavigationView { 17 | VStack { 18 | VStack(alignment: .leading) { 19 | Text("iCloud Account Status: \(syncManager.accountStatus.stringValue)") 20 | Text("Hash: \(hash)") 21 | HStack { 22 | Text("Total Count: \(store.value.bookmarks.count)") 23 | if selections.count > 0 { 24 | Text("Selected: \(selections.count)") 25 | } 26 | } 27 | } 28 | .padding([.leading], 20) 29 | .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) 30 | .foregroundColor(.gray) 31 | List { 32 | ForEach(store.value.bookmarks.sorted(by: { $0.created > $1.created }), id: \.self) { 33 | bookmark in 34 | MultipleSelectionRow( 35 | childView: BookmarkRow(bookmark: bookmark), 36 | isSelected: self.selections.contains(bookmark) 37 | ) { 38 | if self.selections.contains(bookmark) { 39 | self.selections.remove(bookmark) 40 | } else { 41 | self.selections.insert(bookmark) 42 | } 43 | } 44 | } 45 | } 46 | 47 | Button("Add Bookmark") { 48 | if let element = testURLs.randomElement() { 49 | self.addBookmark(element) 50 | } 51 | }.padding() 52 | } 53 | .navigationBarTitle(Text("Bookmarks")) 54 | .navigationBarItems( 55 | leading: leadingNavigationItems(), 56 | trailing: trailingNavigationItems() 57 | ) 58 | } 59 | .navigationViewStyle(StackNavigationViewStyle()) 60 | } 61 | 62 | public var hash: String { 63 | do { 64 | var bookmarksCopy = store.value.bookmarks.sorted(by: { $0.created > $1.created }) 65 | for (idx, val) in bookmarksCopy.enumerated() { 66 | var copy = val 67 | copy.cloudKitSystemFields = nil 68 | bookmarksCopy[idx] = copy 69 | } 70 | let data = try JSONEncoder().encode(bookmarksCopy) 71 | return Insecure.MD5.hash(data: data) 72 | .map { 73 | String(format: "%02hhx", $0) 74 | }.joined() 75 | } catch { 76 | return "Unknown" 77 | } 78 | } 79 | 80 | func leadingNavigationItems() -> some View { 81 | !selections.isEmpty 82 | ? Button(action: { 83 | let selections = self.selections 84 | self.modify(bookmarks: selections) 85 | }) { 86 | Text("Modify") 87 | } 88 | : Button(action: { 89 | self.store.dispatch(.fetchCloudChanges) 90 | }) { 91 | Text("Force Sync") 92 | } 93 | } 94 | 95 | func trailingNavigationItems() -> some View { 96 | !selections.isEmpty 97 | ? Button(action: { 98 | self.delete(bookmarks: self.selections) 99 | }) { 100 | Text("Delete") 101 | } 102 | : nil 103 | } 104 | 105 | func delete(bookmarks: Set) { 106 | self.selections = [] 107 | store.dispatch(.removeBookmarks(bookmarks)) 108 | } 109 | 110 | func modify(bookmarks: Set) { 111 | self.selections = [] 112 | store.dispatch(.modifyBookmarks(bookmarks)) 113 | } 114 | 115 | func addBookmark(_ url: URL) { 116 | store.dispatch(.addBookmark(url)) 117 | } 118 | } 119 | 120 | extension AccountStatus { 121 | var stringValue: String { 122 | switch self { 123 | case .available: 124 | return "Available" 125 | case .couldNotDetermine: 126 | return "Could not determine" 127 | case .noAccount: 128 | return "No account" 129 | case .restricted: 130 | return "Restricted" 131 | case .unknown: 132 | return "Unknown" 133 | } 134 | } 135 | } 136 | 137 | struct ContentView_Previews: PreviewProvider { 138 | static let store = Store( 139 | value: AppState( 140 | bookmarks: [ 141 | Bookmark( 142 | title: "Apple", 143 | url: URL(string: "https://apple.com")! 144 | ) 145 | ] 146 | ), 147 | reducer: appReducer 148 | ) 149 | static var previews: some View { 150 | ContentView(store: store) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Example/CirrusExample/Views/MultipleSelectionRow.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct MultipleSelectionRow: View { 4 | var childView: Content 5 | var isSelected: Bool 6 | var action: () -> Void 7 | 8 | var body: some View { 9 | Button(action: self.action) { 10 | HStack { 11 | childView 12 | if self.isSelected { 13 | Spacer() 14 | Image(systemName: "checkmark") 15 | } 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jay Hickey 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. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PLATFORM_IOS = iOS Simulator,name=iPhone 11 Pro Max 2 | PLATFORM_MACOS = macOS 3 | 4 | default: test-all 5 | 6 | test-all: test-swift build-example 7 | 8 | test-swift: 9 | swift test --parallel 10 | 11 | build-example: 12 | xcodebuild \ 13 | -project Example/CirrusExample.xcodeproj 14 | -scheme CirrusExample \ 15 | -destination platform="$(PLATFORM_IOS)" 16 | 17 | xcodebuild \ 18 | -project Example/CirrusExample.xcodeproj 19 | -scheme CirrusExample \ 20 | -destination platform="$(PLATFORM_MACOS)" 21 | 22 | format: 23 | swift format --in-place --recursive ./Package.swift ./Sources ./Tests ./Example 24 | 25 | .PHONY: format test-all test-swift build-example -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "Cirrus", 6 | platforms: [ 7 | .iOS(.v13), 8 | .macOS(.v10_15), 9 | .tvOS(.v13), 10 | .watchOS(.v6), 11 | ], 12 | products: [ 13 | .library( 14 | name: "Cirrus", 15 | targets: ["Cirrus"] 16 | ), 17 | .library( 18 | name: "CloudKitCodable", 19 | targets: ["CloudKitCodable"] 20 | ), 21 | ], 22 | targets: [ 23 | .target( 24 | name: "Cirrus", 25 | dependencies: [ 26 | "CKRecordCoder", 27 | "CloudKitCodable", 28 | ] 29 | ), 30 | .target( 31 | name: "CKRecordCoder", 32 | dependencies: [ 33 | "CloudKitCodable" 34 | ] 35 | ), 36 | .target( 37 | name: "CloudKitCodable" 38 | ), 39 | .testTarget( 40 | name: "CKRecordCoderTests", 41 | dependencies: ["CKRecordCoder"] 42 | ), 43 | ] 44 | ) 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ☁️ Cirrus 2 | 3 | [![SPM](https://img.shields.io/badge/Swift%20Package%20Manager-compatible-brightgreen.svg)](#installation) 4 | [![License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](#license) 5 | [![CI](https://github.com/jayhickey/Cirrus/workflows/CI/badge.svg)](https://github.com/jayhickey/Cirrus/actions?query=workflow%3ACI) 6 | 7 | Cirrus provides simple [CloudKit](https://developer.apple.com/documentation/cloudkit) sync for [`Codable`](https://developer.apple.com/documentation/swift/codable) Swift models. Rather than support every CloudKit feature, Cirrus is opinionated and prioritizes simplicity, reliability, and ergonomics with Swift value types. 8 | 9 | | | Main Features | 10 | ----------|----------------- 11 | 🙅 | No more dealing with `CKRecord`, `CKOperation`, or `CKSubscription` 12 | 👀 | Observe models and iCloud account changes with [Combine](https://developer.apple.com/documentation/combine) 13 | 📲 | Automatic CloudKit push notification subscriptions 14 | 🚀 | Clean architecture with concise but powerful API 15 | 🎁 | Self-contained, no external dependencies 16 | 17 | ## Usage 18 | 19 | After [installing](#installation) and following Apple's steps for [Enabling CloudKit in Your App](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitQuickStart/EnablingiCloudandConfiguringCloudKit/EnablingiCloudandConfiguringCloudKit.html): 20 | 21 | 1. Register your app for remote CloudKit push notifications 22 | 23 | ```swift 24 | // AppDelegate.swift 25 | func application( 26 | _ application: UIApplication, 27 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 28 | ) -> Bool { 29 | ... 30 | application.registerForRemoteNotifications() 31 | ... 32 | } 33 | ``` 34 | 35 | 2. Conform your model(s) to `CloudKitCodable` 36 | 37 | ```swift 38 | import CloudKitCodable 39 | 40 | struct Landmark: CloudKitCodable { 41 | struct Coordinate: Codable { 42 | let latitude: Double 43 | let longitude: Double 44 | } 45 | 46 | let identifier: UUID 47 | let name: String 48 | let coordinate: Coordinate 49 | 50 | // MARK: - CloudKitCodable 51 | 52 | /// A key that uniquely identifies the model. Use this identifier to update your 53 | /// associated local models when the sync engine emits changes. 54 | var cloudKitIdentifier: CloudKitIdentifier { 55 | return identifier.uuidString 56 | } 57 | 58 | /// Managed by the sync engine, this should be set to nil when creating a new model. 59 | /// Be sure to save this when persisting models locally. 60 | var cloudKitSystemFields: Data? = nil 61 | 62 | /// Describes how to handle conflicts between client and server models. 63 | public static func resolveConflict(clientModel: Self, serverModel: Self) -> Self? { 64 | 65 | // Use `cloudKitLastModifiedDate` to check when models were last saved to the server 66 | guard let clientDate = clientModel.cloudKitLastModifiedDate, 67 | let serverDate = serverModel.cloudKitLastModifiedDate else { 68 | return clientModel 69 | } 70 | return clientDate > serverDate ? clientModel : serverModel 71 | } 72 | } 73 | ``` 74 | 75 | 3. Initialize a `SyncEngine` for the model 76 | 77 | ```swift 78 | import Cirrus 79 | 80 | let syncEngine = SyncEngine() 81 | ``` 82 | 83 | 4. Configure the `SyncEngine` to process remote changes 84 | 85 | ```swift 86 | // AppDelegate.swift 87 | func application( 88 | _ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any] 89 | ) { 90 | syncEngine.processRemoteChangeNotification(with: userInfo) 91 | ... 92 | } 93 | ``` 94 | 95 | 5. Start syncing 96 | 97 | ```swift 98 | // Upload new or updated models 99 | syncEngine.upload(newLandmarks) 100 | 101 | // Delete models 102 | syncEngine.delete(oldLandmark) 103 | 104 | // Observe remote model changes 105 | syncEngine.modelsChanged 106 | .sink { change in 107 | // Update local models 108 | switch change { 109 | case let .updated(models): 110 | ... 111 | case let .deleted(modelIDs): 112 | ... 113 | } 114 | } 115 | 116 | // Observe iCloud account status changes 117 | syncEngine.$accountStatus 118 | .sink { accountStatus in 119 | switch accountStatus { 120 | case .available: 121 | ... 122 | case .noAccount: 123 | ... 124 | ... 125 | } 126 | } 127 | ``` 128 | 129 | And that's it! Cirrus supports syncing multiple model types too, just initialize and configure a new `SyncEngine` for every type you want to sync. 130 | 131 | To see an example of how Cirrus can be integrated into an app, clone this repository and open the [CirrusExample](https://github.com/jayhickey/Cirrus/tree/main/Example) Xcode project. 132 | 133 | ## Installation 134 | 135 | You can add Cirrus to an Xcode project by adding it as a package dependency. 136 | 137 | 1. From the **File** menu, select **Swift Packages › Add Package Dependency…** 138 | 2. Enter "https://github.com/jayhickey/cirrus" into the package repository URL text field 139 | 3. Depending on how your project is structured: 140 | - If you have a single application target that needs access to the library, add both **Cirrus** and **CloudKitCodable** directly to your application. 141 | - If you have multiple targets where your models are in one target but you would like to handle syncing with Cirrus in another, then add **CloudKitCodable** to your model target and **Cirrus** to your syncing target. 142 | 143 | ## Limitations 144 | 145 | Cirrus only supports private iCloud databases. If you need to store data in a public iCloud database, Cirrus is not the right tool for you. 146 | 147 | Nested `Codable` types on `CloudKitCodable` models will _not_ be stored as separate `CKRecord` references; they are saved as `Data` blobs on the top level `CKRecord`. This leads to two important caveats: 148 | 149 | 1. `CKRecord` has a [1 MB data limit](https://developer.apple.com/documentation/cloudkit/ckrecord), so large models may not fit within a single record. The `SyncEngine` will not attempt to sync any models that are larger than 1 MB. If you are hitting this limitation, consider normalizing your data by creating discrete `CloudKitCodable` models that have identifier references to each other. You can use multiple `SyncEngine`s to sync each model type. 150 | 2. If any child models have properties that reference on-disk file URLs, they will not be converted into `CKAsset`s and stored in CloudKit. If you have a need to store files that are referenced by local file URLs on child models, you can override the `Encodable` `encode(to:)` and `Decodable` `init(from:)` methods on your model to set the file URLs as keys on the coding container of the top level `CloudKitCodable` type. The `SyncEngine` will then be able to sync your files to iCloud. 151 | 152 | ## License 153 | 154 | This library is released under the MIT license. See [LICENSE](LICENSE) for details. 155 | 156 | ## 🙌 Special Thanks 157 | 158 | Thanks to [Tim Bueno](https://github.com/timbueno) for helping to build Cirrus. 159 | -------------------------------------------------------------------------------- /Sources/CKRecordCoder/CKRecordDecoder.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import Foundation 3 | 4 | public final class CKRecordDecoder { 5 | 6 | public func decode(_ type: T.Type, from record: CKRecord) throws -> T { 7 | let decoder = _CKRecordDecoder(record: record) 8 | return try T(from: decoder) 9 | } 10 | 11 | public init() {} 12 | } 13 | 14 | final class _CKRecordDecoder { 15 | var codingPath: [CodingKey] = [] 16 | var userInfo: [CodingUserInfoKey: Any] = [:] 17 | 18 | private var record: CKRecord 19 | 20 | init(record: CKRecord) { 21 | self.record = record 22 | } 23 | } 24 | 25 | extension _CKRecordDecoder: Decoder { 26 | 27 | func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer { 28 | let container = CKRecordKeyedDecodingContainer(record: record) 29 | return KeyedDecodingContainer(container) 30 | } 31 | 32 | func unkeyedContainer() throws -> UnkeyedDecodingContainer { 33 | fatalError("Not implemented") 34 | } 35 | 36 | func singleValueContainer() throws -> SingleValueDecodingContainer { 37 | fatalError("No implemented") 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/CKRecordCoder/CKRecordEncoder.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import CloudKitCodable 3 | import Foundation 4 | 5 | public final class CKRecordEncoder { 6 | // The maximum amount of data that can be stored by a record (1 MB) 7 | private let maximumAllowedRecordSizeInBytes: Int = 1 * 1024 * 1024 8 | 9 | public var zoneID: CKRecordZone.ID 10 | 11 | public init(zoneID: CKRecordZone.ID) { 12 | self.zoneID = zoneID 13 | } 14 | 15 | public func encode(_ value: E) throws -> CKRecord { 16 | let type = value.cloudKitRecordType 17 | let recordName = value.cloudKitIdentifier 18 | 19 | let encoder = _CKRecordEncoder( 20 | recordTypeName: type, 21 | recordName: recordName, 22 | zoneID: zoneID 23 | ) 24 | 25 | try value.encode(to: encoder) 26 | 27 | let record = encoder.buildRecord() 28 | 29 | try validateSize(for: encoder.storage.keys) 30 | 31 | return record 32 | } 33 | 34 | public static func decodeSystemFields(with systemFields: Data) -> CKRecord? { 35 | guard let coder = try? NSKeyedUnarchiver(forReadingFrom: systemFields) else { return nil } 36 | coder.requiresSecureCoding = true 37 | let record = CKRecord(coder: coder) 38 | coder.finishDecoding() 39 | return record 40 | } 41 | 42 | private func validateSize(for recordKeyValues: [String: CKRecordValue]) throws { 43 | guard 44 | let recordData = try? NSKeyedArchiver.archivedData( 45 | withRootObject: recordKeyValues, 46 | requiringSecureCoding: true 47 | ) 48 | else { return } 49 | 50 | if recordData.count >= maximumAllowedRecordSizeInBytes { 51 | let context = EncodingError.Context( 52 | codingPath: [], 53 | debugDescription: 54 | "CKRecord is too large. Record is \(formattedSize(ofDataCount: recordData.count)), the maxmimum allowed size is \(formattedSize(ofDataCount: maximumAllowedRecordSizeInBytes)))" 55 | ) 56 | throw EncodingError.invalidValue(Any.self, context) 57 | } 58 | } 59 | 60 | private func formattedSize(ofDataCount dataCount: Int) -> String { 61 | let formatter = ByteCountFormatter() 62 | formatter.allowedUnits = [.useKB, .useMB] 63 | formatter.countStyle = .binary 64 | return formatter.string(fromByteCount: Int64(dataCount)) 65 | } 66 | } 67 | 68 | final class _CKRecordEncoder { 69 | let recordTypeName: CKRecord.RecordType 70 | let recordName: String 71 | let zoneID: CKRecordZone.ID 72 | var codingPath: [CodingKey] = [] 73 | var userInfo: [CodingUserInfoKey: Any] = [:] 74 | 75 | var storage: Storage 76 | 77 | init( 78 | recordTypeName: CKRecord.RecordType, 79 | recordName: String, 80 | zoneID: CKRecordZone.ID, 81 | storage: Storage = Storage() 82 | ) { 83 | self.recordTypeName = recordTypeName 84 | self.recordName = recordName 85 | self.zoneID = zoneID 86 | self.storage = storage 87 | } 88 | } 89 | 90 | extension _CKRecordEncoder { 91 | final class Storage { 92 | private(set) var record: CKRecord? 93 | private(set) var keys: [String: CKRecordValue] = [:] 94 | 95 | func set(record: CKRecord?) { 96 | self.record = record 97 | } 98 | 99 | func encode(codingPath: [CodingKey], value: CKRecordValue?) { 100 | let key = 101 | codingPath 102 | .map { $0.stringValue } 103 | .joined(separator: "_") 104 | keys[key] = value 105 | } 106 | } 107 | 108 | func buildRecord() -> CKRecord { 109 | let output: CKRecord = 110 | storage.record 111 | ?? CKRecord( 112 | recordType: recordTypeName, 113 | recordID: CKRecord.ID( 114 | recordName: recordName, 115 | zoneID: zoneID) 116 | ) 117 | 118 | guard output.recordType == recordTypeName else { 119 | fatalError( 120 | """ 121 | CloudKit record type mismatch: the record should be of type \(recordTypeName) but it was 122 | of type \(output.recordType). This is probably a result of corrupted cloudKitSystemData 123 | or a change in record/type name that must be corrected in your type by adopting CustomCloudKitEncodable. 124 | """ 125 | ) 126 | } 127 | 128 | storage.keys.forEach { (key, value) in output[key] = value } 129 | return output 130 | } 131 | } 132 | 133 | extension _CKRecordEncoder: Encoder { 134 | 135 | func container(keyedBy type: Key.Type) -> KeyedEncodingContainer { 136 | let container = CKRecordKeyedEncodingContainer(storage: storage) 137 | container.codingPath = codingPath 138 | return KeyedEncodingContainer(container) 139 | } 140 | 141 | func unkeyedContainer() -> UnkeyedEncodingContainer { 142 | fatalError("Not implemented") 143 | } 144 | 145 | func singleValueContainer() -> SingleValueEncodingContainer { 146 | fatalError("Not implemented") 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Sources/CKRecordCoder/CKRecordEncodingError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum CKRecordEncodingError: Error { 4 | case unsupportedValueForKey(String) 5 | case systemFieldsDecode(String) 6 | case referencesNotSupported(String) 7 | 8 | public var localizedDescription: String { 9 | switch self { 10 | case .unsupportedValueForKey(let key): 11 | return """ 12 | The value of key \(key) is not supported. Only values that can be converted to 13 | CKRecordValue are supported. Check the CloudKit documentation to see which types 14 | can be used. 15 | """ 16 | case .systemFieldsDecode(let info): 17 | return "Failed to process \(_CloudKitSystemFieldsKeyName): \(info)" 18 | case .referencesNotSupported(let key): 19 | return "References are not supported by CKRecordEncoder yet. Key \(key)." 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/CKRecordCoder/CKRecordKeyedDecodingContainer.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import Foundation 3 | 4 | final class CKRecordKeyedDecodingContainer { 5 | var record: CKRecord 6 | var codingPath: [CodingKey] = [] 7 | var userInfo: [CodingUserInfoKey: Any] = [:] 8 | lazy var jsonDecoder: JSONDecoder = { 9 | return JSONDecoder() 10 | }() 11 | 12 | init(record: CKRecord) { 13 | self.record = record 14 | } 15 | 16 | private lazy var systemFieldsData: Data = { 17 | return encodeSystemFields() 18 | }() 19 | 20 | func nestedCodingPath(forKey key: CodingKey) -> [CodingKey] { 21 | return self.codingPath + [key] 22 | } 23 | } 24 | 25 | extension CKRecordKeyedDecodingContainer: KeyedDecodingContainerProtocol { 26 | var allKeys: [Key] { 27 | return self.record.allKeys().compactMap { Key(stringValue: $0) } 28 | } 29 | 30 | func contains(_ key: Key) -> Bool { 31 | // CKRecord does not contain a key that represents the system field information. The system fields data 32 | // must be extracted separately. Returning true here tells the decoder that we can extract this value. 33 | guard key.stringValue != _CloudKitSystemFieldsKeyName else { return true } 34 | 35 | // All other keys must be present in the CKRecord in order to be decoded. 36 | return allKeys.contains(where: { $0.stringValue == key.stringValue }) 37 | } 38 | 39 | func decodeNil(forKey key: Key) throws -> Bool { 40 | if key.stringValue == _CloudKitSystemFieldsKeyName { 41 | return systemFieldsData.count == 0 42 | } else { 43 | return record[key.stringValue] == nil 44 | } 45 | } 46 | 47 | func decode(_ type: T.Type, forKey key: Key) throws -> T { 48 | // Extract system fields data from CKRecord. 49 | if key.stringValue == _CloudKitSystemFieldsKeyName { 50 | return systemFieldsData as! T 51 | } else if type == URL.self { 52 | return try URLTransformer.decodeSingle(record: record, key: key, codingPath: codingPath) as! T 53 | } else if type == [URL].self { 54 | return try URLTransformer.decodeMany(record: record, key: key, codingPath: codingPath) as! T 55 | } else if let value = record[key.stringValue] as? T { 56 | return value 57 | } else if let value = record[key.stringValue] as? Data, 58 | let decodedValue = try? jsonDecoder.decode(type, from: value) 59 | { 60 | return decodedValue 61 | } 62 | 63 | let decoder = CKRecordSingleValueDecoder(record: record, codingPath: codingPath + [key]) 64 | guard let decodedValue = try? type.init(from: decoder) else { 65 | let context = DecodingError.Context( 66 | codingPath: codingPath, 67 | debugDescription: "Value could not be decoded for key \(key)." 68 | ) 69 | throw DecodingError.typeMismatch(type, context) 70 | } 71 | 72 | return decodedValue 73 | } 74 | 75 | func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) throws 76 | -> KeyedDecodingContainer 77 | { 78 | fatalError("Not implemented") 79 | } 80 | 81 | func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer { 82 | fatalError("Not implemented") 83 | } 84 | 85 | func superDecoder() throws -> Decoder { 86 | return _CKRecordDecoder(record: record) 87 | } 88 | 89 | func superDecoder(forKey key: Key) throws -> Decoder { 90 | let decoder = _CKRecordDecoder(record: record) 91 | decoder.codingPath = [key] 92 | return decoder 93 | } 94 | 95 | private func encodeSystemFields() -> Data { 96 | let coder = NSKeyedArchiver(requiringSecureCoding: true) 97 | record.encodeSystemFields(with: coder) 98 | coder.finishEncoding() 99 | return coder.encodedData 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Sources/CKRecordCoder/CKRecordKeyedEncodingContainer.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import CloudKitCodable 3 | import Foundation 4 | 5 | final class CKRecordKeyedEncodingContainer { 6 | var storage: _CKRecordEncoder.Storage 7 | var codingPath: [CodingKey] = [] 8 | var userInfo: [CodingUserInfoKey: Any] = [:] 9 | lazy var jsonEncoder: JSONEncoder = { 10 | return JSONEncoder() 11 | }() 12 | 13 | init(storage: _CKRecordEncoder.Storage) { 14 | self.storage = storage 15 | } 16 | } 17 | 18 | extension CKRecordKeyedEncodingContainer: KeyedEncodingContainerProtocol { 19 | func encodeNil(forKey key: Key) throws { 20 | storage.encode(codingPath: codingPath + [key], value: nil) 21 | } 22 | 23 | func encode(_ value: T, forKey key: Key) throws where T: Encodable { 24 | guard !(value is CloudKitEncodable) && !(value is [CloudKitEncodable]) else { 25 | throw CKRecordEncodingError.referencesNotSupported( 26 | codingPath.map { $0.stringValue }.joined(separator: "-")) 27 | } 28 | 29 | if key.stringValue == _CloudKitSystemFieldsKeyName { 30 | guard let systemFieldsData = value as? Data else { 31 | throw CKRecordEncodingError.systemFieldsDecode( 32 | "\(_CloudKitSystemFieldsKeyName) property must be of type Data.") 33 | } 34 | storage.set(record: CKRecordEncoder.decodeSystemFields(with: systemFieldsData)) 35 | } else if let value = value as? URL { 36 | storage.encode(codingPath: codingPath + [key], value: URLTransformer.encode(value)) 37 | } else if let value = value as? [URL] { 38 | storage.encode( 39 | codingPath: codingPath + [key], value: value.map(URLTransformer.encode) as CKRecordValue) 40 | } else if let value = value as? CKRecordValue { 41 | storage.encode(codingPath: codingPath + [key], value: value) 42 | } else { 43 | do { 44 | let encoder = CKRecordSingleValueEncoder(storage: storage, codingPath: codingPath + [key]) 45 | try value.encode(to: encoder) 46 | } catch { 47 | storage.encode( 48 | codingPath: codingPath + [key], value: try jsonEncoder.encode(value) as CKRecordValue) 49 | } 50 | } 51 | } 52 | 53 | func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key) 54 | -> KeyedEncodingContainer where NestedKey: CodingKey 55 | { 56 | fatalError("Not implemented") 57 | } 58 | 59 | func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { 60 | fatalError("Not implemented") 61 | } 62 | 63 | func superEncoder() -> Encoder { 64 | fatalError("Not implemented") 65 | } 66 | 67 | func superEncoder(forKey key: Key) -> Encoder { 68 | fatalError("Not implemented") 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/CKRecordCoder/CKRecordSingleValueDecoder.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import Foundation 3 | 4 | enum CKRecordSingleValueDecodingError: Error { 5 | case codingPathMissing 6 | case unableToDecode 7 | } 8 | 9 | final class CKRecordSingleValueDecoder: Decoder { 10 | private var record: CKRecord 11 | var codingPath: [CodingKey] 12 | var userInfo: [CodingUserInfoKey: Any] = [:] 13 | 14 | init(record: CKRecord, codingPath: [CodingKey]) { 15 | self.record = record 16 | self.codingPath = codingPath 17 | } 18 | 19 | func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer 20 | where Key: CodingKey { 21 | return KeyedDecodingContainer(DummyKeyedDecodingContainer()) 22 | } 23 | 24 | func unkeyedContainer() throws -> UnkeyedDecodingContainer { 25 | return DummyUnkeyedDecodingContainer() 26 | } 27 | 28 | func singleValueContainer() throws -> SingleValueDecodingContainer { 29 | var container = SingleCKRecordValueDecodingContainer(record: record) 30 | container.codingPath = codingPath 31 | return container 32 | } 33 | } 34 | 35 | struct SingleCKRecordValueDecodingContainer: SingleValueDecodingContainer { 36 | var codingPath: [CodingKey] = [] 37 | var record: CKRecord 38 | 39 | func decodeNil() -> Bool { 40 | guard let key = codingPath.first else { return true } 41 | guard let _ = record[key.stringValue] else { return true } 42 | return false 43 | } 44 | 45 | func decode(_ type: T.Type) throws -> T where T: Decodable { 46 | guard let key = codingPath.first else { 47 | throw CKRecordSingleValueDecodingError.codingPathMissing 48 | } 49 | guard let value = record[key.stringValue] as? T else { 50 | throw CKRecordSingleValueDecodingError.unableToDecode 51 | } 52 | return value 53 | } 54 | } 55 | 56 | struct DummyKeyedDecodingContainer: KeyedDecodingContainerProtocol { 57 | var codingPath: [CodingKey] = [] 58 | var allKeys: [Key] = [] 59 | 60 | func contains(_ key: Key) -> Bool { return true } 61 | 62 | func decodeNil(forKey key: Key) throws -> Bool { 63 | throw CKRecordSingleValueDecodingError.unableToDecode 64 | } 65 | 66 | func decode(_ type: T.Type, forKey key: Key) throws -> T where T: Decodable { 67 | throw CKRecordSingleValueDecodingError.unableToDecode 68 | } 69 | 70 | func nestedContainer( 71 | keyedBy type: NestedKey.Type, 72 | forKey key: Key 73 | ) throws -> KeyedDecodingContainer where NestedKey: CodingKey { 74 | fatalError("Not implemented") 75 | } 76 | 77 | func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer { 78 | fatalError("Not implemented") 79 | } 80 | 81 | func superDecoder() throws -> Decoder { fatalError("Not implemented") } 82 | 83 | func superDecoder(forKey key: Key) throws -> Decoder { fatalError("Not implemented") } 84 | } 85 | 86 | struct DummyUnkeyedDecodingContainer: UnkeyedDecodingContainer { 87 | var codingPath: [CodingKey] = [] 88 | var count: Int? = nil 89 | var isAtEnd: Bool = true 90 | var currentIndex: Int = 0 91 | 92 | mutating func decodeNil() throws -> Bool { throw CKRecordSingleValueDecodingError.unableToDecode } 93 | 94 | mutating func decode(_ type: T.Type) throws -> T where T: Decodable { 95 | throw CKRecordSingleValueDecodingError.unableToDecode 96 | } 97 | 98 | mutating func nestedContainer( 99 | keyedBy type: NestedKey.Type 100 | ) throws -> KeyedDecodingContainer where NestedKey: CodingKey { 101 | fatalError("Not implemented") 102 | } 103 | 104 | mutating func nestedUnkeyedContainer() throws -> UnkeyedDecodingContainer { 105 | fatalError("Not implemented") 106 | } 107 | 108 | mutating func superDecoder() throws -> Decoder { fatalError("Not implemented") } 109 | } 110 | -------------------------------------------------------------------------------- /Sources/CKRecordCoder/CKRecordSingleValueEncoder.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import Foundation 3 | 4 | enum CKRecordSingleValueEncodingError: Error { 5 | case unableToEncode 6 | } 7 | 8 | struct CKRecordSingleValueEncoder: Encoder { 9 | private var storage: _CKRecordEncoder.Storage 10 | var codingPath: [CodingKey] 11 | var userInfo: [CodingUserInfoKey: Any] = [:] 12 | 13 | init(storage: _CKRecordEncoder.Storage, codingPath: [CodingKey]) { 14 | self.storage = storage 15 | self.codingPath = codingPath 16 | } 17 | 18 | func container(keyedBy type: Key.Type) -> KeyedEncodingContainer where Key: CodingKey { 19 | return KeyedEncodingContainer(DummyKeyedEncodingContainer()) 20 | } 21 | 22 | func unkeyedContainer() -> UnkeyedEncodingContainer { 23 | return DummyUnkeyedCodingContainer() 24 | } 25 | 26 | func singleValueContainer() -> SingleValueEncodingContainer { 27 | var container = SingleCKRecordValueEncodingContainer(storage: storage) 28 | container.codingPath = codingPath 29 | return container 30 | } 31 | } 32 | 33 | struct SingleCKRecordValueEncodingContainer: SingleValueEncodingContainer { 34 | var storage: _CKRecordEncoder.Storage 35 | var codingPath: [CodingKey] = [] 36 | 37 | mutating func encodeNil() throws { 38 | storage.encode(codingPath: codingPath, value: nil) 39 | } 40 | 41 | mutating func encode(_ value: T) throws where T: Encodable { 42 | guard let value = value as? CKRecordValue else { 43 | throw CKRecordSingleValueEncodingError.unableToEncode 44 | } 45 | storage.encode(codingPath: codingPath, value: value) 46 | } 47 | } 48 | 49 | struct DummyKeyedEncodingContainer: KeyedEncodingContainerProtocol { 50 | var codingPath: [CodingKey] = [] 51 | 52 | mutating func encodeNil(forKey key: Key) throws { 53 | throw CKRecordSingleValueEncodingError.unableToEncode 54 | } 55 | 56 | mutating func encode(_ value: T, forKey key: Key) throws where T: Encodable { 57 | throw CKRecordSingleValueEncodingError.unableToEncode 58 | } 59 | 60 | mutating func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key) 61 | -> KeyedEncodingContainer where NestedKey: CodingKey 62 | { 63 | fatalError("Not implemented") 64 | } 65 | 66 | mutating func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { 67 | fatalError("Not implemented") 68 | } 69 | 70 | mutating func superEncoder() -> Encoder { 71 | fatalError("Not implemented") 72 | } 73 | 74 | mutating func superEncoder(forKey key: Key) -> Encoder { 75 | fatalError("Not implemented") 76 | } 77 | } 78 | 79 | struct DummyUnkeyedCodingContainer: UnkeyedEncodingContainer { 80 | var codingPath: [CodingKey] = [] 81 | var count: Int = 0 82 | 83 | mutating func encodeNil() throws { 84 | throw CKRecordSingleValueEncodingError.unableToEncode 85 | } 86 | 87 | mutating func encode(_ value: T) throws where T: Encodable { 88 | throw CKRecordSingleValueEncodingError.unableToEncode 89 | } 90 | 91 | mutating func nestedContainer(keyedBy keyType: NestedKey.Type) 92 | -> KeyedEncodingContainer where NestedKey: CodingKey 93 | { 94 | fatalError("Not implemented") 95 | } 96 | 97 | mutating func nestedUnkeyedContainer() -> UnkeyedEncodingContainer { 98 | fatalError("Not implemented") 99 | } 100 | 101 | mutating func superEncoder() -> Encoder { 102 | fatalError("Not implemented") 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Sources/CKRecordCoder/CloudKitCodable+RecordType.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import CloudKitCodable 3 | import Foundation 4 | 5 | extension CloudKitCodable { 6 | var cloudKitRecordType: CKRecord.RecordType { 7 | return String(describing: type(of: self)) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/CKRecordCoder/CloudKitSystemFieldsKeyName.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let _CloudKitSystemFieldsKeyName = "cloudKitSystemFields" 4 | -------------------------------------------------------------------------------- /Sources/CKRecordCoder/URLTransformer.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import Foundation 3 | 4 | enum URLTransformer { 5 | static func encode(_ value: URL) -> CKRecordValue { 6 | if value.isFileURL { 7 | return CKAsset(fileURL: value) 8 | } else { 9 | return value.absoluteString as CKRecordValue 10 | } 11 | } 12 | 13 | static func decodeMany(record: CKRecord, key: CodingKey, codingPath: [CodingKey]) throws -> [URL] 14 | { 15 | if let array = record[key.stringValue] as? [Any] { 16 | return try array.map { try decodeValue(value: $0, codingPath: codingPath) } 17 | } 18 | return [] 19 | } 20 | 21 | static func decodeSingle(record: CKRecord, key: CodingKey, codingPath: [CodingKey]) throws -> URL 22 | { 23 | return try decodeValue(value: record[key.stringValue] as Any, codingPath: codingPath) 24 | } 25 | 26 | private static func decodeValue(value: Any, codingPath: [CodingKey]) throws -> URL { 27 | if let asset = value as? CKAsset { 28 | guard let url = asset.fileURL else { 29 | let context = DecodingError.Context( 30 | codingPath: codingPath, debugDescription: "CKAsset URL was nil.") 31 | throw DecodingError.valueNotFound(URL.self, context) 32 | } 33 | return url 34 | } 35 | 36 | guard let str = value as? String else { 37 | let context = DecodingError.Context( 38 | codingPath: codingPath, 39 | debugDescription: "URL should have been encoded as String in CKRecord." 40 | ) 41 | throw DecodingError.typeMismatch(URL.self, context) 42 | } 43 | 44 | guard let url = URL(string: str) else { 45 | let context = DecodingError.Context( 46 | codingPath: codingPath, 47 | debugDescription: "The string \(str) is not a valid url." 48 | ) 49 | throw DecodingError.typeMismatch(URL.self, context) 50 | } 51 | return url 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/Cirrus/DeleteRecordContext.swift: -------------------------------------------------------------------------------- 1 | @_implementationOnly import CKRecordCoder 2 | import CloudKit 3 | import CloudKitCodable 4 | import Foundation 5 | import os.log 6 | 7 | final class DeleteRecordContext: RecordModifyingContext { 8 | 9 | private let defaults: UserDefaults 10 | private let zoneID: CKRecordZone.ID 11 | private let logHandler: (String, OSLogType) -> Void 12 | 13 | private lazy var deleteBufferKey = "DELETEBUFFER-\(zoneID.zoneName))" 14 | 15 | init( 16 | defaults: UserDefaults, zoneID: CKRecordZone.ID, 17 | logHandler: @escaping (String, OSLogType) -> Void 18 | ) { 19 | self.defaults = defaults 20 | self.zoneID = zoneID 21 | self.logHandler = logHandler 22 | } 23 | 24 | func buffer(_ values: [Persistable]) { 25 | let recordIDs: [CKRecord.ID] 26 | do { 27 | recordIDs = try values.map { try CKRecordEncoder(zoneID: zoneID).encode($0).recordID } 28 | } catch let error { 29 | logHandler("Failed to encode records for delete: \(String(describing: error))", .error) 30 | recordIDs = values.compactMap { try? CKRecordEncoder(zoneID: zoneID).encode($0).recordID } 31 | } 32 | recordIDsToDelete.append(contentsOf: recordIDs) 33 | } 34 | 35 | // MARK: - RecordModifying 36 | 37 | let name = "delete" 38 | var savePolicy: CKModifyRecordsOperation.RecordSavePolicy = .ifServerRecordUnchanged 39 | 40 | var recordsToSave: [CKRecord.ID: CKRecord] = [:] 41 | 42 | var recordIDsToDelete: [CKRecord.ID] { 43 | get { 44 | guard let data = defaults.data(forKey: deleteBufferKey) else { return [] } 45 | do { 46 | return try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? [CKRecord.ID] ?? [] 47 | } catch { 48 | logHandler("Failed to decode CKRecord.IDs from defaults key deleteBufferKey", .error) 49 | return [] 50 | } 51 | } 52 | set { 53 | do { 54 | logHandler("Updating \(self.name) buffer with \(newValue.count) items", .info) 55 | let data = try NSKeyedArchiver.archivedData( 56 | withRootObject: newValue, requiringSecureCoding: true) 57 | defaults.set(data, forKey: deleteBufferKey) 58 | } catch { 59 | logHandler("Failed to encode record ids for deletion: \(String(describing: error))", .error) 60 | } 61 | } 62 | } 63 | 64 | func modelChangeForUpdatedRecords( 65 | recordsSaved: [CKRecord], recordIDsDeleted: [CKRecord.ID] 66 | ) 67 | -> SyncEngine.ModelChange 68 | { 69 | let recordIdentifiersDeletedSet = Set(recordIDsDeleted.map(\.recordName)) 70 | 71 | recordIDsToDelete.removeAll { recordIDsDeleted.contains($0) } 72 | 73 | return .deleted(recordIdentifiersDeletedSet) 74 | } 75 | 76 | func failedToUpdateRecords(recordsSaved: [CKRecord], recordIDsDeleted: [CKRecord.ID]) { 77 | recordIDsToDelete.removeAll { recordIDsDeleted.contains($0) } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/Cirrus/Error+CloudKit.swift: -------------------------------------------------------------------------------- 1 | @_implementationOnly import CKRecordCoder 2 | import CloudKit 3 | import CloudKitCodable 4 | import Foundation 5 | import os.log 6 | 7 | extension Error { 8 | 9 | /// Whether this error represents a "zone not found" or a "user deleted zone" error 10 | var isCloudKitZoneDeleted: Bool { 11 | guard let effectiveError = self as? CKError else { return false } 12 | 13 | return [.zoneNotFound, .userDeletedZone].contains(effectiveError.code) 14 | } 15 | 16 | /// Uses the `resolver` closure to resolve a conflict, returning the conflict-free record 17 | /// 18 | /// - Parameter resolver: A closure that will receive the client record as the first param and the server record as the second param. 19 | /// This closure is responsible for handling the conflict and returning the conflict-free record. 20 | /// - Returns: The conflict-free record returned by `resolver` 21 | func resolveConflict( 22 | _ logger: ((String, OSLogType) -> Void)? = nil, 23 | with resolver: (Persistable, Persistable) -> Persistable? 24 | ) -> CKRecord? { 25 | guard let effectiveError = self as? CKError else { 26 | logger?( 27 | "resolveConflict called on an error that was not a CKError. The error was \(String(describing: self))", 28 | .fault) 29 | return nil 30 | } 31 | 32 | guard effectiveError.code == .serverRecordChanged else { 33 | logger?( 34 | "resolveConflict called on a CKError that was not a serverRecordChanged error. The error was \(String(describing: effectiveError))", 35 | .fault) 36 | return nil 37 | } 38 | 39 | guard let clientRecord = effectiveError.clientRecord else { 40 | logger?( 41 | "Failed to obtain client record from serverRecordChanged error. The error was \(String(describing: effectiveError))", 42 | .fault) 43 | return nil 44 | } 45 | 46 | guard let serverRecord = effectiveError.serverRecord else { 47 | logger?( 48 | "Failed to obtain server record from serverRecordChanged error. The error was \(String(describing: effectiveError))", 49 | .fault) 50 | return nil 51 | } 52 | 53 | logger?( 54 | "CloudKit conflict with record of type \(serverRecord.recordType). Running conflict resolver", 55 | .error) 56 | 57 | // Always return the server record so we don't end up in a conflict loop (the server record has the change tag we want to use) 58 | // https://developer.apple.com/documentation/cloudkit/ckerror/2325208-serverrecordchanged 59 | guard 60 | let clientPersistable = try? CKRecordDecoder().decode( 61 | Persistable.self, from: clientRecord), 62 | let serverPersistable = try? CKRecordDecoder().decode( 63 | Persistable.self, from: serverRecord), 64 | let resolvedPersistable = resolver(clientPersistable, serverPersistable), 65 | let resolvedRecord = try? CKRecordEncoder(zoneID: serverRecord.recordID.zoneID).encode( 66 | resolvedPersistable) 67 | else { return nil } 68 | resolvedRecord.allKeys().forEach { serverRecord[$0] = resolvedRecord[$0] } 69 | return serverRecord 70 | } 71 | 72 | /// Retries a CloudKit operation if the error suggests it 73 | /// 74 | /// - Parameters: 75 | /// - log: The logger to use for logging information about the error handling, uses the default one if not set 76 | /// - block: The block that will execute the operation later if it can be retried 77 | /// - Returns: Whether or not it was possible to retry the operation 78 | @discardableResult func retryCloudKitOperationIfPossible( 79 | _ logger: ((String, OSLogType) -> Void)? = nil, queue: DispatchQueue, 80 | with block: @escaping () -> Void 81 | ) -> Bool { 82 | guard let effectiveError = self as? CKError else { return false } 83 | 84 | let retryDelay: Double 85 | if let suggestedRetryDelay = effectiveError.retryAfterSeconds { 86 | retryDelay = suggestedRetryDelay 87 | } else if effectiveError.code == CKError.Code.limitExceeded { 88 | retryDelay = 0 89 | } else { 90 | logger?("Error is not recoverable", .error) 91 | return false 92 | } 93 | 94 | logger?("Error is recoverable. Will retry after \(retryDelay) seconds", .error) 95 | 96 | queue.asyncAfter(deadline: .now() + retryDelay) { 97 | block() 98 | } 99 | 100 | return true 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /Sources/Cirrus/RecordModifyingContext.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import Foundation 3 | 4 | protocol RecordModifyingContext: RecordModifyingContextProvider { 5 | var recordsToSave: [CKRecord.ID: CKRecord] { get } 6 | var recordIDsToDelete: [CKRecord.ID] { get } 7 | } 8 | 9 | protocol RecordModifyingContextProvider { 10 | var name: String { get } 11 | var savePolicy: CKModifyRecordsOperation.RecordSavePolicy { get } 12 | func modelChangeForUpdatedRecords(recordsSaved: [CKRecord], recordIDsDeleted: [CKRecord.ID]) 13 | -> SyncEngine.ModelChange 14 | func failedToUpdateRecords(recordsSaved: [CKRecord], recordIDsDeleted: [CKRecord.ID]) 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Cirrus/SyncEngine+AccountStatus.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import Foundation 3 | import os.log 4 | 5 | public enum AccountStatus: Equatable { 6 | case unknown 7 | case couldNotDetermine 8 | case available 9 | case restricted 10 | case noAccount 11 | } 12 | 13 | extension SyncEngine { 14 | 15 | // MARK: - Internal 16 | 17 | func observeAccountStatus() { 18 | NotificationCenter.default.publisher(for: .CKAccountChanged, object: nil).sink { 19 | [weak self] _ in 20 | self?.updateAccountStatus() 21 | } 22 | .store(in: &cancellables) 23 | 24 | updateAccountStatus() 25 | } 26 | 27 | // MARK: - Private 28 | 29 | private func updateAccountStatus() { 30 | logHandler(#function, .debug) 31 | container.accountStatus { [weak self] status, error in 32 | if let error = error { 33 | self?.logHandler( 34 | "Error retriving iCloud account status: \(error.localizedDescription)", .error) 35 | } 36 | 37 | DispatchQueue.main.async { 38 | let accountStatus: AccountStatus 39 | switch status { 40 | case .available: 41 | accountStatus = .available 42 | case .couldNotDetermine: 43 | accountStatus = .couldNotDetermine 44 | case .noAccount: 45 | accountStatus = .noAccount 46 | case .restricted: 47 | accountStatus = .restricted 48 | default: 49 | accountStatus = .unknown 50 | } 51 | self?.accountStatus = accountStatus 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/Cirrus/SyncEngine+RecordModification.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import Foundation 3 | import os.log 4 | 5 | extension SyncEngine { 6 | 7 | // MARK: - Internal 8 | 9 | func performUpdate(with context: RecordModifyingContext) { 10 | self.logHandler("\(#function)", .debug) 11 | 12 | guard !context.recordIDsToDelete.isEmpty || !context.recordsToSave.isEmpty else { return } 13 | 14 | self.logHandler( 15 | "Using \(context.name) context, found \(context.recordsToSave.count) local items(s) for upload and \(context.recordIDsToDelete.count) for deletion.", 16 | .debug 17 | ) 18 | 19 | modifyRecords(with: context) 20 | } 21 | 22 | func modifyRecords(with context: RecordModifyingContext) { 23 | modifyRecords( 24 | toSave: Array(context.recordsToSave.values), recordIDsToDelete: context.recordIDsToDelete, 25 | context: context) 26 | } 27 | 28 | // MARK: - Private 29 | 30 | private func modifyRecords( 31 | toSave recordsToSave: [CKRecord], 32 | recordIDsToDelete: [CKRecord.ID], 33 | context: RecordModifyingContextProvider 34 | ) { 35 | guard !recordIDsToDelete.isEmpty || !recordsToSave.isEmpty else { return } 36 | 37 | logHandler( 38 | "Sending \(recordsToSave.count) record(s) for upload and \(recordIDsToDelete.count) record(s) for deletion.", 39 | .debug) 40 | 41 | let operation = CKModifyRecordsOperation( 42 | recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete) 43 | 44 | operation.modifyRecordsCompletionBlock = { [weak self] serverRecords, deletedRecordIDs, error in 45 | guard let self = self else { return } 46 | 47 | if let error = error { 48 | self.logHandler("Failed to \(context.name) records: \(String(describing: error))", .error) 49 | 50 | self.workQueue.async { 51 | self.handleError( 52 | error, 53 | toSave: recordsToSave, 54 | recordIDsToDelete: recordIDsToDelete, 55 | context: context 56 | ) 57 | } 58 | } else { 59 | self.logHandler( 60 | "Successfully \(context.name) record(s). Saved \(recordsToSave.count) and deleted \(recordIDsToDelete.count)", 61 | .info) 62 | 63 | self.workQueue.async { 64 | self.modelsChangedSubject.send( 65 | context.modelChangeForUpdatedRecords( 66 | recordsSaved: serverRecords ?? [], 67 | recordIDsDeleted: deletedRecordIDs ?? [] 68 | ) 69 | ) 70 | } 71 | } 72 | } 73 | 74 | operation.savePolicy = context.savePolicy 75 | operation.qualityOfService = .userInitiated 76 | operation.database = privateDatabase 77 | 78 | cloudOperationQueue.addOperation(operation) 79 | } 80 | 81 | // MARK: - Private 82 | 83 | private func handleError( 84 | _ error: Error, 85 | toSave recordsToSave: [CKRecord], 86 | recordIDsToDelete: [CKRecord.ID], 87 | context: RecordModifyingContextProvider 88 | ) { 89 | guard let ckError = error as? CKError else { 90 | logHandler( 91 | "Error was not a CKError, giving up: \(String(describing: error))", .fault) 92 | return 93 | } 94 | 95 | switch ckError { 96 | 97 | case _ where ckError.isCloudKitZoneDeleted: 98 | logHandler( 99 | "Zone was deleted, recreating zone: \(String(describing: error))", .error) 100 | guard initializeZone(with: self.cloudOperationQueue) else { 101 | logHandler( 102 | "Unable to create zone, error is not recoverable: \(String(describing: error))", .fault) 103 | return 104 | } 105 | self.modifyRecords( 106 | toSave: recordsToSave, 107 | recordIDsToDelete: recordIDsToDelete, 108 | context: context 109 | ) 110 | 111 | case _ where ckError.code == CKError.Code.limitExceeded: 112 | logHandler( 113 | "CloudKit batch limit exceeded, trying to \(context.name) records in chunks", .error) 114 | 115 | let firstHalfSave = Array(recordsToSave[0.. = Set( 170 | changedRecords.compactMap { record in 171 | do { 172 | let decoder = CKRecordDecoder() 173 | return try decoder.decode(Model.self, from: record) 174 | } catch { 175 | logHandler( 176 | "Error decoding item from record: \(String(describing: error))", .error) 177 | return nil 178 | } 179 | }) 180 | 181 | let deletedIdentifiers = Set(deletedRecordIDs.map(\.recordName)) 182 | 183 | modelsChangedSubject.send(.updated(models)) 184 | modelsChangedSubject.send(.deleted(deletedIdentifiers)) 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /Sources/Cirrus/SyncEngine+Subscription.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import Foundation 3 | import os.log 4 | 5 | extension SyncEngine { 6 | 7 | // MARK: - Internal 8 | 9 | func initializeSubscription(with queue: OperationQueue) -> Bool { 10 | self.createPrivateSubscriptionsIfNeeded() 11 | queue.waitUntilAllOperationsAreFinished() 12 | guard self.createdPrivateSubscription else { return false } 13 | return true 14 | } 15 | 16 | // MARK: - Private 17 | 18 | private var createdPrivateSubscription: Bool { 19 | get { 20 | return defaults.bool(forKey: createdPrivateSubscriptionKey) 21 | } 22 | set { 23 | defaults.set(newValue, forKey: createdPrivateSubscriptionKey) 24 | } 25 | } 26 | 27 | private func createPrivateSubscriptionsIfNeeded() { 28 | guard !createdPrivateSubscription else { 29 | logHandler( 30 | "Already subscribed to private database changes, skipping subscription but checking if it really exists", 31 | .debug) 32 | 33 | checkSubscription() 34 | 35 | return 36 | } 37 | 38 | let subscription = CKRecordZoneSubscription( 39 | zoneID: zoneIdentifier, subscriptionID: privateSubscriptionIdentifier) 40 | 41 | let notificationInfo = CKSubscription.NotificationInfo() 42 | notificationInfo.shouldSendContentAvailable = true 43 | 44 | subscription.notificationInfo = notificationInfo 45 | subscription.recordType = recordType 46 | 47 | let operation = CKModifySubscriptionsOperation( 48 | subscriptionsToSave: [subscription], subscriptionIDsToDelete: nil) 49 | 50 | operation.database = privateDatabase 51 | operation.qualityOfService = .userInitiated 52 | 53 | operation.modifySubscriptionsCompletionBlock = { [weak self] _, _, error in 54 | guard let self = self else { return } 55 | 56 | if let error = error { 57 | self.logHandler( 58 | "Failed to create private CloudKit subscription: \(String(describing: error))", .error) 59 | 60 | error.retryCloudKitOperationIfPossible(self.logHandler, queue: self.workQueue) { 61 | self.createPrivateSubscriptionsIfNeeded() 62 | } 63 | } else { 64 | self.logHandler("Private subscription created successfully", .info) 65 | self.createdPrivateSubscription = true 66 | } 67 | } 68 | 69 | cloudOperationQueue.addOperation(operation) 70 | } 71 | 72 | private func checkSubscription() { 73 | let operation = CKFetchSubscriptionsOperation(subscriptionIDs: [privateSubscriptionIdentifier]) 74 | 75 | operation.fetchSubscriptionCompletionBlock = { [weak self] ids, error in 76 | guard let self = self else { return } 77 | 78 | if let error = error { 79 | self.logHandler( 80 | "Failed to check for private zone subscription existence: \(String(describing: error))", 81 | .error) 82 | 83 | if !error.retryCloudKitOperationIfPossible( 84 | self.logHandler, queue: self.workQueue, with: { self.checkSubscription() }) 85 | { 86 | self.logHandler( 87 | "Irrecoverable error when fetching private zone subscription, assuming it doesn't exist: \(String(describing: error))", 88 | .error) 89 | 90 | self.workQueue.async { 91 | self.createdPrivateSubscription = false 92 | self.createPrivateSubscriptionsIfNeeded() 93 | } 94 | } 95 | } else if ids?.isEmpty ?? true { 96 | self.logHandler( 97 | "Private subscription reported as existing, but it doesn't exist. Creating.", .error 98 | ) 99 | 100 | self.workQueue.async { 101 | self.createdPrivateSubscription = false 102 | self.createPrivateSubscriptionsIfNeeded() 103 | } 104 | } else { 105 | self.logHandler( 106 | "Private subscription found, the device is subscribed to CloudKit change notifications.", 107 | .info 108 | ) 109 | } 110 | } 111 | 112 | operation.qualityOfService = .userInitiated 113 | operation.database = privateDatabase 114 | 115 | cloudOperationQueue.addOperation(operation) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Sources/Cirrus/SyncEngine+Zone.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import Foundation 3 | import os.log 4 | 5 | extension SyncEngine { 6 | 7 | // MARK: - Internal 8 | 9 | func initializeZone(with queue: OperationQueue) -> Bool { 10 | self.createCustomZoneIfNeeded() 11 | queue.waitUntilAllOperationsAreFinished() 12 | guard self.createdCustomZone else { return false } 13 | return true 14 | } 15 | 16 | // MARK: - Private 17 | 18 | private var createdCustomZone: Bool { 19 | get { 20 | return defaults.bool(forKey: createdCustomZoneKey) 21 | } 22 | set { 23 | defaults.set(newValue, forKey: createdCustomZoneKey) 24 | } 25 | } 26 | 27 | private func createCustomZoneIfNeeded() { 28 | guard !createdCustomZone else { 29 | logHandler( 30 | "Already have custom zone, skipping creation but checking if zone really exists", .debug) 31 | 32 | checkCustomZone() 33 | 34 | return 35 | } 36 | 37 | logHandler("Creating CloudKit zone \(zoneIdentifier.zoneName)", .info) 38 | 39 | let zone = CKRecordZone(zoneID: zoneIdentifier) 40 | let operation = CKModifyRecordZonesOperation( 41 | recordZonesToSave: [zone], 42 | recordZoneIDsToDelete: nil 43 | ) 44 | 45 | operation.modifyRecordZonesCompletionBlock = { [weak self] _, _, error in 46 | guard let self = self else { return } 47 | 48 | if let error = error { 49 | self.logHandler( 50 | "Failed to create custom CloudKit zone: \(String(describing: error))", .error) 51 | 52 | error.retryCloudKitOperationIfPossible(self.logHandler, queue: self.workQueue) { 53 | self.createCustomZoneIfNeeded() 54 | } 55 | } else { 56 | self.logHandler("Zone created successfully", .info) 57 | self.createdCustomZone = true 58 | } 59 | } 60 | 61 | operation.qualityOfService = .userInitiated 62 | operation.database = privateDatabase 63 | 64 | cloudOperationQueue.addOperation(operation) 65 | } 66 | 67 | private func checkCustomZone() { 68 | let operation = CKFetchRecordZonesOperation(recordZoneIDs: [zoneIdentifier]) 69 | 70 | operation.fetchRecordZonesCompletionBlock = { [weak self] ids, error in 71 | guard let self = self else { return } 72 | 73 | if let error = error { 74 | self.logHandler( 75 | "Failed to check for custom zone existence: \(String(describing: error))", .error) 76 | 77 | if !error.retryCloudKitOperationIfPossible( 78 | self.logHandler, queue: self.workQueue, with: { self.checkCustomZone() }) 79 | { 80 | self.logHandler( 81 | "Irrecoverable error when fetching custom zone, assuming it doesn't exist: \(String(describing: error))", 82 | .error) 83 | 84 | self.workQueue.async { 85 | self.createdCustomZone = false 86 | self.createCustomZoneIfNeeded() 87 | } 88 | } 89 | } else if ids?.isEmpty ?? true { 90 | self.logHandler("Custom zone reported as existing, but it doesn't exist. Creating.", .error) 91 | self.workQueue.async { 92 | self.createdCustomZone = false 93 | self.createCustomZoneIfNeeded() 94 | } 95 | } 96 | } 97 | 98 | operation.qualityOfService = .userInitiated 99 | operation.database = privateDatabase 100 | 101 | cloudOperationQueue.addOperation(operation) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Sources/Cirrus/SyncEngine.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import CloudKitCodable 3 | import Combine 4 | import Foundation 5 | import os.log 6 | 7 | public final class SyncEngine { 8 | 9 | public enum ModelChange { 10 | case deleted(Set) 11 | case updated(Set) 12 | } 13 | 14 | // MARK: - Public Properties 15 | 16 | /// A publisher that sends a `ModelChange` when models are updated or deleted on iCloud. No thread guarantees. 17 | public private(set) lazy var modelsChanged = modelsChangedSubject.eraseToAnyPublisher() 18 | 19 | /// The current iCloud account status for the user. 20 | @Published public internal(set) var accountStatus: AccountStatus = .unknown { 21 | willSet { 22 | // Setup the environment and force a sync if the user account status changes to available while the app is running 23 | if accountStatus != .unknown, 24 | newValue == .available 25 | { 26 | setupCloudEnvironment() 27 | } 28 | } 29 | } 30 | 31 | // MARK: - Internal Properties 32 | 33 | lazy var privateSubscriptionIdentifier = "\(zoneIdentifier.zoneName).subscription" 34 | lazy var privateChangeTokenKey = "TOKEN-\(zoneIdentifier.zoneName)" 35 | lazy var createdPrivateSubscriptionKey = "CREATEDSUBDB-\(zoneIdentifier.zoneName))" 36 | lazy var createdCustomZoneKey = "CREATEDZONE-\(zoneIdentifier.zoneName))" 37 | 38 | lazy var workQueue = DispatchQueue( 39 | label: "SyncEngine.Work.\(zoneIdentifier.zoneName)", 40 | qos: .userInitiated 41 | ) 42 | private lazy var cloudQueue = DispatchQueue( 43 | label: "SyncEngine.Cloud.\(zoneIdentifier.zoneName)", 44 | qos: .userInitiated 45 | ) 46 | 47 | let defaults: UserDefaults 48 | let recordType: CKRecord.RecordType 49 | let zoneIdentifier: CKRecordZone.ID 50 | 51 | let container: CKContainer 52 | let logHandler: (String, OSLogType) -> Void 53 | 54 | lazy var privateDatabase: CKDatabase = container.privateCloudDatabase 55 | 56 | var cancellables = Set() 57 | let modelsChangedSubject = PassthroughSubject() 58 | 59 | private lazy var uploadContext: UploadRecordContext = UploadRecordContext( 60 | defaults: defaults, zoneID: zoneIdentifier, logHandler: logHandler) 61 | private lazy var deleteContext: DeleteRecordContext = DeleteRecordContext( 62 | defaults: defaults, zoneID: zoneIdentifier, logHandler: logHandler) 63 | 64 | lazy var cloudOperationQueue: OperationQueue = { 65 | let queue = OperationQueue() 66 | 67 | queue.underlyingQueue = cloudQueue 68 | queue.name = "SyncEngine.Cloud.\(zoneIdentifier.zoneName))" 69 | 70 | return queue 71 | }() 72 | 73 | /// - Parameters: 74 | /// - defaults: The `UserDefaults` used to store sync state information 75 | /// - containerIdentifier: An optional bundle identifier of the app whose container you want to access. The bundle identifier must be in the app’s com.apple.developer.icloud-container-identifiers entitlement. If this value is nil, the default container object will be used. 76 | /// - initialItems: An initial array of items to sync 77 | /// 78 | /// `initialItems` is used to perform a sync of any local models that don't yet exist in CloudKit. The engine uses the 79 | /// presence of data in `cloudKitSystemFields` to determine what models to upload. Alternatively, you can just call `upload(_:)` to sync initial items. 80 | public init( 81 | defaults: UserDefaults = .standard, 82 | containerIdentifier: String? = nil, 83 | initialItems: [Model] = [], 84 | logHandler: ((String, OSLogType) -> Void)? = nil 85 | ) { 86 | self.defaults = defaults 87 | self.recordType = String(describing: Model.self) 88 | let zoneIdent = CKRecordZone.ID( 89 | zoneName: self.recordType, 90 | ownerName: CKCurrentUserDefaultName 91 | ) 92 | self.zoneIdentifier = zoneIdent 93 | if let containerIdentifier = containerIdentifier { 94 | self.container = CKContainer(identifier: containerIdentifier) 95 | } else { 96 | self.container = CKContainer.default() 97 | } 98 | 99 | self.logHandler = 100 | logHandler ?? { string, level in 101 | if #available(iOS 14.0, macOS 11.0, watchOS 7.0, tvOS 14.0, *) { 102 | let logger = Logger.init( 103 | subsystem: "com.jayhickey.Cirrus.\(zoneIdent)", 104 | category: String(describing: SyncEngine.self) 105 | ) 106 | logger.log(level: level, "\(string)") 107 | } 108 | } 109 | 110 | // Add items that haven't been uploaded yet. 111 | self.uploadContext.buffer(initialItems.filter { $0.cloudKitSystemFields == nil }) 112 | 113 | observeAccountStatus() 114 | setupCloudEnvironment() 115 | } 116 | 117 | // MARK: - Public Methods 118 | 119 | /// Upload models to CloudKit. 120 | public func upload(_ models: Model...) { 121 | upload(models) 122 | } 123 | 124 | /// Upload an array of models to CloudKit. 125 | public func upload(_ models: [Model]) { 126 | logHandler(#function, .debug) 127 | 128 | workQueue.async { 129 | self.uploadContext.buffer(models) 130 | self.modifyRecords(with: self.uploadContext) 131 | } 132 | } 133 | 134 | /// Delete models from CloudKit. 135 | public func delete(_ models: Model...) { 136 | delete(models) 137 | } 138 | 139 | /// Delete an array of models from CloudKit. 140 | public func delete(_ models: [Model]) { 141 | logHandler(#function, .debug) 142 | 143 | workQueue.async { 144 | // Remove any pending upload items that match the items we want to delete 145 | self.uploadContext.removeFromBuffer(models) 146 | 147 | self.deleteContext.buffer(models) 148 | self.modifyRecords(with: self.deleteContext) 149 | } 150 | } 151 | 152 | /// Forces a data synchronization with CloudKit. 153 | /// 154 | /// Use this method for force sync any data that may not have been able to upload 155 | /// to CloudKit automatically due to network conditions or other factors. 156 | /// 157 | /// This method performs the following actions (in this order): 158 | /// 1. Uploads any models that were passed to `upload(_:)` and were unable to be uploaded to CloudKit. 159 | /// 2. Deletes any models that were passed to `delete(_:)` and were unable to be deleted from CloudKit. 160 | /// 3. Fetches any new model changes from CloudKit. 161 | public func forceSync() { 162 | logHandler(#function, .debug) 163 | 164 | workQueue.async { 165 | self.performUpdate(with: self.uploadContext) 166 | self.performUpdate(with: self.deleteContext) 167 | self.fetchRemoteChanges() 168 | } 169 | } 170 | 171 | /// Processes remote change push notifications from CloudKit. 172 | /// 173 | /// To subscribe to automatic changes, register for CloudKit push notifications by calling `application.registerForRemoteNotifications()` 174 | /// in your AppDelegate's `application(_:didFinishLaunchingWithOptions:)`. Then, call this method in 175 | /// `application(_:didReceiveRemoteNotification:fetchCompletionHandler:)` to process remote changes from CloudKit. 176 | /// - Parameters: 177 | /// - userInfo: A dictionary that contains information about the remove notification. Pass the `userInfo` dictionary from `application(_:didReceiveRemoteNotification:fetchCompletionHandler:)` here. 178 | /// - Returns: Whether or not this notification was processed by the sync engine. 179 | @discardableResult public func processRemoteChangeNotification(with userInfo: [AnyHashable: Any]) 180 | -> Bool 181 | { 182 | logHandler(#function, .debug) 183 | 184 | guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo) else { 185 | logHandler("Not a CKNotification", .error) 186 | return false 187 | } 188 | 189 | guard notification.subscriptionID == privateSubscriptionIdentifier else { 190 | logHandler("Not our subscription ID", .error) 191 | return false 192 | } 193 | 194 | logHandler("Received remote CloudKit notification for user data", .debug) 195 | 196 | self.workQueue.async { [weak self] in 197 | self?.fetchRemoteChanges() 198 | } 199 | 200 | return true 201 | } 202 | 203 | // MARK: - Private Methods 204 | 205 | private func setupCloudEnvironment() { 206 | workQueue.async { [weak self] in 207 | guard let self = self else { return } 208 | 209 | // Initialize CloudKit with private custom zone, but bail early if we fail 210 | guard self.initializeZone(with: self.cloudOperationQueue) else { 211 | self.logHandler("Unable to initialize zone, bailing from setup early", .error) 212 | return 213 | } 214 | 215 | // Subscribe to CloudKit changes, but bail early if we fail 216 | guard self.initializeSubscription(with: self.cloudOperationQueue) else { 217 | self.logHandler( 218 | "Unable to initialize subscription to changes, bailing from setup early", .error) 219 | return 220 | } 221 | self.logHandler("Cloud environment preparation done", .debug) 222 | 223 | self.performUpdate(with: self.uploadContext) 224 | self.performUpdate(with: self.deleteContext) 225 | self.fetchRemoteChanges() 226 | } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /Sources/Cirrus/UploadRecordContext.swift: -------------------------------------------------------------------------------- 1 | @_implementationOnly import CKRecordCoder 2 | import CloudKit 3 | import CloudKitCodable 4 | import Foundation 5 | import os.log 6 | 7 | final class UploadRecordContext: RecordModifyingContext { 8 | 9 | private let defaults: UserDefaults 10 | private let zoneID: CKRecordZone.ID 11 | private let logHandler: (String, OSLogType) -> Void 12 | 13 | private lazy var uploadBufferKey = "UPLOADBUFFER-\(zoneID.zoneName))" 14 | 15 | init( 16 | defaults: UserDefaults, zoneID: CKRecordZone.ID, 17 | logHandler: @escaping (String, OSLogType) -> Void 18 | ) { 19 | self.defaults = defaults 20 | self.zoneID = zoneID 21 | self.logHandler = logHandler 22 | } 23 | 24 | func buffer(_ values: [Persistable]) { 25 | let records: [CKRecord] 26 | do { 27 | records = try values.map { try CKRecordEncoder(zoneID: zoneID).encode($0) } 28 | } catch let error { 29 | logHandler("Failed to encode records for upload: \(String(describing: error))", .error) 30 | records = values.compactMap { try? CKRecordEncoder(zoneID: zoneID).encode($0) } 31 | } 32 | records.forEach { recordsToSave[$0.recordID] = $0 } 33 | } 34 | 35 | func removeFromBuffer(_ values: [Persistable]) { 36 | let records = values.compactMap { try? CKRecordEncoder(zoneID: zoneID).encode($0) } 37 | records.forEach { recordsToSave.removeValue(forKey: $0.recordID) } 38 | } 39 | 40 | // MARK: - RecordModifying 41 | 42 | let name = "upload" 43 | let savePolicy: CKModifyRecordsOperation.RecordSavePolicy = .ifServerRecordUnchanged 44 | 45 | var recordsToSave: [CKRecord.ID: CKRecord] { 46 | get { 47 | guard let data = defaults.data(forKey: uploadBufferKey) else { return [:] } 48 | do { 49 | return try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) 50 | as? [CKRecord.ID: CKRecord] ?? [:] 51 | } catch { 52 | logHandler("Failed to decode CKRecord.IDs from defaults key uploadBufferKey", .error) 53 | return [:] 54 | } 55 | } 56 | set { 57 | do { 58 | logHandler("Updating \(self.name) buffer with \(newValue.count) items", .info) 59 | let data = try NSKeyedArchiver.archivedData( 60 | withRootObject: newValue, requiringSecureCoding: true) 61 | defaults.set(data, forKey: uploadBufferKey) 62 | } catch { 63 | logHandler("Failed to encode record ids for upload: \(String(describing: error))", .error) 64 | } 65 | } 66 | } 67 | 68 | var recordIDsToDelete: [CKRecord.ID] = [] 69 | 70 | func modelChangeForUpdatedRecords( 71 | recordsSaved: [CKRecord], recordIDsDeleted: [CKRecord.ID] 72 | ) -> SyncEngine.ModelChange { 73 | let models: Set = Set( 74 | recordsSaved.compactMap { record in 75 | do { 76 | let decoder = CKRecordDecoder() 77 | return try decoder.decode(T.self, from: record) 78 | } catch { 79 | logHandler("Error decoding item from record: \(String(describing: error))", .error) 80 | return nil 81 | } 82 | }) 83 | 84 | recordsSaved.forEach { recordsToSave.removeValue(forKey: $0.recordID) } 85 | 86 | return .updated(models) 87 | } 88 | 89 | func failedToUpdateRecords(recordsSaved: [CKRecord], recordIDsDeleted: [CKRecord.ID]) { 90 | recordsSaved.forEach { recordsToSave.removeValue(forKey: $0.recordID) } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/CloudKitCodable/CloudKitCodable+LastModifiedDate.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import Foundation 3 | 4 | extension CloudKitCodable { 5 | /// The time when the record was last saved to the server. 6 | public var cloudKitLastModifiedDate: Date? { 7 | guard let data = cloudKitSystemFields, 8 | let coder = try? NSKeyedUnarchiver(forReadingFrom: data) 9 | else { return nil } 10 | coder.requiresSecureCoding = true 11 | let record = CKRecord(coder: coder) 12 | coder.finishDecoding() 13 | return record?.modificationDate 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/CloudKitCodable/CloudKitCodable.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import Foundation 3 | 4 | public protocol CloudKitEncodable: Encodable {} 5 | 6 | public protocol CloudKitDecodable: Decodable {} 7 | 8 | public typealias CloudKitIdentifier = String 9 | 10 | public protocol CloudKitCodable: CloudKitEncodable & CloudKitDecodable & Hashable { 11 | /// A property for storing system fields from CloudKit. 12 | /// 13 | /// This value is managed by the sync engine and should be set to nil when creating a new model. 14 | /// If you are persisting your models locally, be sure to persist this property so it can be read by the sync 15 | /// engine when peforming updates to your model. 16 | var cloudKitSystemFields: Data? { get set } 17 | 18 | /// A unique identifier for the model used to locate records in the CloudKit database. 19 | var cloudKitIdentifier: CloudKitIdentifier { get } 20 | 21 | /// A function for resolving conflicts between a the server and client record. Use this to determine 22 | /// how conflicts between two models with the same `cloudKitIdentifier` should be resolved. 23 | static func resolveConflict(clientModel: Self, serverModel: Self) -> Self? 24 | } 25 | -------------------------------------------------------------------------------- /Tests/CKRecordCoderTests/CKRecordDecoderTests.swift: -------------------------------------------------------------------------------- 1 | import CKRecordCoder 2 | import CloudKit 3 | import XCTest 4 | 5 | class CKRecordDecoderTests: XCTestCase { 6 | 7 | func testDecodingWithSystemFields() throws { 8 | let record = Person.testRecord 9 | record["cloudKitIdentifier"] = Person.testIdentifier as CKRecordValue 10 | record["name"] = "Tobias Funke" as CKRecordValue 11 | record["age"] = 50 as CKRecordValue 12 | record["avatar"] = CKAsset(fileURL: URL(fileURLWithPath: "/path/to/file")) as CKRecordValue 13 | 14 | let person = try CKRecordDecoder().decode(Person.self, from: record) 15 | 16 | XCTAssertEqual(person.cloudKitSystemFields!, Person.systemFieldsDataForTesting) 17 | XCTAssertEqual(person.cloudKitIdentifier, Person.testIdentifier) 18 | } 19 | 20 | func testDecodingPrimitives() throws { 21 | let record = Person.testRecord 22 | record["cloudKitIdentifier"] = Person.testIdentifier as CKRecordValue 23 | record["name"] = "Tobias Funke" as CKRecordValue 24 | record["age"] = 50 as CKRecordValue 25 | record["website"] = "https://blueman.com" as CKRecordValue 26 | record["twitter"] = nil 27 | record["isDeveloper"] = true as CKRecordValue 28 | record["access"] = "user" 29 | 30 | let person = try CKRecordDecoder().decode(Person.self, from: record) 31 | 32 | XCTAssertEqual(person.cloudKitSystemFields!, Person.systemFieldsDataForTesting) 33 | XCTAssertEqual(person.cloudKitIdentifier, Person.testIdentifier) 34 | XCTAssertEqual(person.name, "Tobias Funke") 35 | XCTAssertEqual(person.age, 50) 36 | XCTAssertEqual(person.website, URL(string: "https://blueman.com")!) 37 | XCTAssertEqual(person.twitter, nil) 38 | XCTAssertEqual(person.isDeveloper, true) 39 | XCTAssertEqual(person.access, .user) 40 | } 41 | 42 | func testDecodingFileURL() throws { 43 | let record = Person.testRecord 44 | record["cloudKitIdentifier"] = Person.testIdentifier as CKRecordValue 45 | record["avatar"] = CKAsset(fileURL: URL(fileURLWithPath: "/path/to/file")) as CKRecordValue 46 | 47 | let person = try CKRecordDecoder().decode(Person.self, from: record) 48 | 49 | XCTAssertEqual(person.avatar, URL(fileURLWithPath: "/path/to/file")) 50 | } 51 | 52 | func testDecodingNestedValues() throws { 53 | let pet = Pet(name: "Buster") 54 | let child = Child(age: 22, name: "George Michael Bluth", gender: .male, pet: pet) 55 | 56 | let record = Parent.testRecord 57 | record["name"] = "Michael Bluth" 58 | record["cloudKitIdentifier"] = Parent.testIdentifier as CKRecordValue 59 | record["child"] = try? JSONEncoder().encode(child) 60 | 61 | let parent = try CKRecordDecoder().decode(Parent.self, from: record) 62 | 63 | XCTAssertEqual(parent.child.pet, pet) 64 | XCTAssertEqual(parent.child, child) 65 | } 66 | 67 | func testDecodingLists() throws { 68 | let record = Numbers.testRecord 69 | record["cloudKitIdentifier"] = Numbers.testIdentifier as CKRecordValue 70 | record["favorites"] = [1, 2, 3, 4] 71 | 72 | let numbers = try CKRecordDecoder().decode(Numbers.self, from: record) 73 | 74 | XCTAssertEqual(numbers.favorites, [1, 2, 3, 4]) 75 | } 76 | 77 | func testDecodingUUID() throws { 78 | let record = CKRecord(recordType: "UUIDModel") 79 | record["cloudKitIdentifier"] = UUID().uuidString as CKRecordValue 80 | record["uuid"] = try JSONEncoder().encode( 81 | UUID(uuidString: "0D2E7B29-AC4C-4A04-B57E-5CA0D208E55F")) 82 | 83 | let model = try CKRecordDecoder().decode(UUIDModel.self, from: record) 84 | 85 | XCTAssertEqual(model.uuid, UUID(uuidString: "0D2E7B29-AC4C-4A04-B57E-5CA0D208E55F")!) 86 | } 87 | 88 | func testDecodingURLArray() throws { 89 | let record = CKRecord(recordType: "URLModel") 90 | record["cloudKitIdentifier"] = try JSONEncoder().encode(UUID().uuidString) 91 | record["urls"] = [ 92 | "http://tkdfeddizkj.pafpapsnrnn.net", 93 | "http://dcacju.uyczcghcqruf.bg", 94 | "http://utonggatwhxz.aicwdazc.info", 95 | "http://kfvbza.zvmoitujnrq.fr", 96 | ] 97 | 98 | let model = try CKRecordDecoder().decode(URLModel.self, from: record) 99 | XCTAssertEqual(model.urls.map { $0.absoluteString }, record["urls"]) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Tests/CKRecordCoderTests/CKRecordEncoderDecoderRoundTripTests.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import XCTest 3 | 4 | @testable import CKRecordCoder 5 | 6 | class CKRecordEncoderDecoderRoundTripTests: XCTestCase { 7 | 8 | func testRoundTripWithCustomIdentifier() throws { 9 | let bookmark = Bookmark.bookmarkWithoutSystemFields 10 | 11 | let zoneID = CKRecordZone.ID(zoneName: "ABCDE", ownerName: "12345") 12 | let encoder = CKRecordEncoder(zoneID: zoneID) 13 | 14 | let bookmarkRecord = try encoder.encode(bookmark) 15 | 16 | let decodedBookmark = try CKRecordDecoder().decode(Bookmark.self, from: bookmarkRecord) 17 | 18 | XCTAssertNotEqual(bookmark.cloudKitSystemFields, decodedBookmark.cloudKitSystemFields) 19 | XCTAssertEqual(Bookmark.testIdentifier, decodedBookmark.cloudKitIdentifier) 20 | XCTAssertEqual(bookmark.cloudKitRecordType, decodedBookmark.cloudKitRecordType) 21 | XCTAssertEqual(bookmark.title, decodedBookmark.title) 22 | } 23 | 24 | func testRoundTripWithSystemFields() throws { 25 | let person = Person.personWithSystemFields 26 | 27 | let zoneID = CKRecordZone.ID(zoneName: "ABCDE", ownerName: "12345") 28 | let encoder = CKRecordEncoder(zoneID: zoneID) 29 | 30 | let personRecord = try encoder.encode(person) 31 | 32 | let decodedPerson = try CKRecordDecoder().decode(Person.self, from: personRecord) 33 | 34 | XCTAssertEqual(person.cloudKitSystemFields, decodedPerson.cloudKitSystemFields) 35 | XCTAssertEqual(person.cloudKitRecordType, decodedPerson.cloudKitRecordType) 36 | } 37 | 38 | func testRoundTripPrimitives() throws { 39 | let person = Person.personWithSystemFields 40 | 41 | let zoneID = CKRecordZone.ID(zoneName: "ABCDE", ownerName: "12345") 42 | let encoder = CKRecordEncoder(zoneID: zoneID) 43 | 44 | let personRecord = try encoder.encode(person) 45 | 46 | let decodedPerson = try CKRecordDecoder().decode(Person.self, from: personRecord) 47 | 48 | XCTAssertEqual(person.name, decodedPerson.name) 49 | XCTAssertEqual(person.age, decodedPerson.age) 50 | XCTAssertEqual(person.website, decodedPerson.website) 51 | XCTAssertEqual(person.twitter, decodedPerson.twitter) 52 | XCTAssertEqual(person.isDeveloper, decodedPerson.isDeveloper) 53 | XCTAssertEqual(person.access, decodedPerson.access) 54 | } 55 | 56 | func testRoundTripFileURL() throws { 57 | let person = Person.personWithSystemFields 58 | 59 | let zoneID = CKRecordZone.ID(zoneName: "ABCDE", ownerName: "12345") 60 | let encoder = CKRecordEncoder(zoneID: zoneID) 61 | 62 | let personRecord = try encoder.encode(person) 63 | 64 | let decodedPerson = try CKRecordDecoder().decode(Person.self, from: personRecord) 65 | 66 | XCTAssertEqual(person.avatar, decodedPerson.avatar) 67 | } 68 | 69 | func testRoundTripNestedValues() throws { 70 | let pet = Pet(name: "Buster") 71 | let child = Child(age: 22, name: "George Michael Bluth", gender: .male, pet: pet) 72 | 73 | let inputParent = Parent( 74 | cloudKitSystemFields: nil, 75 | cloudKitIdentifier: UUID().uuidString, 76 | name: "Michael Bluth", 77 | child: child 78 | ) 79 | 80 | let encoder = CKRecordEncoder( 81 | zoneID: CKRecordZone.ID( 82 | zoneName: String(describing: Person.self), 83 | ownerName: CKCurrentUserDefaultName 84 | ) 85 | ) 86 | let record = try encoder.encode(inputParent) 87 | 88 | let parent = try CKRecordDecoder().decode(Parent.self, from: record) 89 | 90 | XCTAssertEqual(parent.child.pet, pet) 91 | XCTAssertEqual(parent.child, child) 92 | } 93 | 94 | func testRoundTripLists() throws { 95 | let encoder = CKRecordEncoder( 96 | zoneID: CKRecordZone.ID( 97 | zoneName: String(describing: Person.self), 98 | ownerName: CKCurrentUserDefaultName 99 | ) 100 | ) 101 | let record = try encoder.encode(Numbers.numbersMock) 102 | 103 | let numbers = try CKRecordDecoder().decode(Numbers.self, from: record) 104 | 105 | XCTAssertEqual(numbers.favorites, [1, 2, 3, 4]) 106 | } 107 | 108 | func testRoundTripUUID() throws { 109 | let inputModel = UUIDModel.uuidModelMock 110 | 111 | let encoder = CKRecordEncoder( 112 | zoneID: CKRecordZone.ID( 113 | zoneName: String(describing: Person.self), 114 | ownerName: CKCurrentUserDefaultName 115 | ) 116 | ) 117 | 118 | let record = try encoder.encode(inputModel) 119 | 120 | let model = try CKRecordDecoder().decode(UUIDModel.self, from: record) 121 | 122 | XCTAssertEqual(model.uuid, UUID(uuidString: "0D2E7B29-AC4C-4A04-B57E-5CA0D208E55F")!) 123 | } 124 | 125 | func testRoundTripURLArray() throws { 126 | let uuid = UUID() 127 | let model = URLModel( 128 | cloudKitIdentifier: uuid.uuidString, 129 | urls: [ 130 | "http://tkdfeddizkj.pafpapsnrnn.net", 131 | "http://dcacju.uyczcghcqruf.bg", 132 | "http://utonggatwhxz.aicwdazc.info", 133 | "http://kfvbza.zvmoitujnrq.fr", 134 | ].compactMap(URL.init(string:)) 135 | ) 136 | 137 | let encoder = CKRecordEncoder( 138 | zoneID: CKRecordZone.ID( 139 | zoneName: String(describing: UUIDModel.self), ownerName: CKCurrentUserDefaultName) 140 | ) 141 | 142 | let data = try encoder.encode(model) 143 | 144 | let decodedModel = try CKRecordDecoder().decode(URLModel.self, from: data) 145 | XCTAssertEqual(model.urls, decodedModel.urls) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /Tests/CKRecordCoderTests/CKRecordEncoderTests.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import XCTest 3 | 4 | @testable import CKRecordCoder 5 | 6 | class CKRecordEncoderTests: XCTestCase { 7 | 8 | func testEncodingWithSystemFields() throws { 9 | let encoder = CKRecordEncoder( 10 | zoneID: CKRecordZone.ID( 11 | zoneName: String(describing: Person.self), 12 | ownerName: CKCurrentUserDefaultName 13 | ) 14 | ) 15 | let record = try encoder.encode(Person.personWithSystemFields) 16 | 17 | XCTAssertEqual(record.recordID, Person.testRecordID) 18 | XCTAssertEqual(record.recordType, "Person") 19 | XCTAssertEqual(record["cloudKitIdentifier"], Person.testIdentifier) 20 | 21 | XCTAssertNil( 22 | record[_CloudKitSystemFieldsKeyName], 23 | "\(_CloudKitSystemFieldsKeyName) should NOT be encoded to the record directly" 24 | ) 25 | } 26 | 27 | func testEncodingPrimitives() throws { 28 | let encoder = CKRecordEncoder( 29 | zoneID: CKRecordZone.ID( 30 | zoneName: String(describing: Person.self), 31 | ownerName: CKCurrentUserDefaultName 32 | ) 33 | ) 34 | let record = try encoder.encode(Person.personWithSystemFields) 35 | 36 | XCTAssertEqual(record["name"] as? String, "Tobias Funke") 37 | XCTAssertEqual(record["age"] as? Int, 50) 38 | XCTAssertEqual(record["website"] as? String, "https://blueman.com") 39 | XCTAssertEqual(record["isDeveloper"] as? Bool, true) 40 | XCTAssertEqual(record["access"] as? String, "admin") 41 | XCTAssertNil(record["twitter"]) 42 | } 43 | 44 | func testEncodingFileURL() throws { 45 | let encoder = CKRecordEncoder( 46 | zoneID: CKRecordZone.ID( 47 | zoneName: String(describing: Person.self), 48 | ownerName: CKCurrentUserDefaultName 49 | ) 50 | ) 51 | let record = try encoder.encode(Person.personWithSystemFields) 52 | 53 | guard let asset = record["avatar"] as? CKAsset else { 54 | XCTFail("URL property with file url should encode to CKAsset.") 55 | return 56 | } 57 | XCTAssertEqual(asset.fileURL?.path, "/path/to/file") 58 | } 59 | 60 | func testEncodingWithoutSystemFields() throws { 61 | let encoder = CKRecordEncoder( 62 | zoneID: CKRecordZone.ID( 63 | zoneName: String(describing: Bookmark.self), 64 | ownerName: CKCurrentUserDefaultName 65 | ) 66 | ) 67 | let record = try encoder.encode(Bookmark.bookmarkWithoutSystemFields) 68 | 69 | XCTAssertEqual( 70 | record.recordID, 71 | CKRecord.ID( 72 | recordName: Bookmark.testIdentifier, 73 | zoneID: CKRecordZone.ID( 74 | zoneName: String(describing: Bookmark.self), 75 | ownerName: CKCurrentUserDefaultName 76 | ) 77 | ) 78 | ) 79 | XCTAssertEqual(record["title"], "Apple") 80 | } 81 | 82 | func testEncodingNestedValues() throws { 83 | let pet = Pet(name: "Buster") 84 | let child = Child(age: 22, name: "George Michael Bluth", gender: .male, pet: pet) 85 | 86 | let parent = Parent( 87 | cloudKitSystemFields: nil, 88 | cloudKitIdentifier: UUID().uuidString, 89 | name: "Michael Bluth", 90 | child: child 91 | ) 92 | 93 | let encoder = CKRecordEncoder( 94 | zoneID: CKRecordZone.ID( 95 | zoneName: String(describing: Person.self), 96 | ownerName: CKCurrentUserDefaultName 97 | ) 98 | ) 99 | let record = try encoder.encode(parent) 100 | 101 | XCTAssertEqual(record["name"], "Michael Bluth") 102 | XCTAssertEqual(record["child"], try JSONEncoder().encode(child)) 103 | } 104 | 105 | func testEncodingLists() throws { 106 | let encoder = CKRecordEncoder( 107 | zoneID: CKRecordZone.ID( 108 | zoneName: String(describing: Person.self), 109 | ownerName: CKCurrentUserDefaultName 110 | ) 111 | ) 112 | let record = try encoder.encode(Numbers.numbersMock) 113 | XCTAssertEqual(record["favorites"], [1, 2, 3, 4]) 114 | } 115 | 116 | func testEncodingUUID() throws { 117 | let model = UUIDModel.uuidModelMock 118 | 119 | let encoder = CKRecordEncoder( 120 | zoneID: CKRecordZone.ID( 121 | zoneName: String(describing: Person.self), 122 | ownerName: CKCurrentUserDefaultName 123 | ) 124 | ) 125 | 126 | let record = try encoder.encode(model) 127 | 128 | XCTAssertEqual(record["uuid"], model.uuid.uuidString) 129 | } 130 | 131 | func testEncodingURLArray() throws { 132 | let model = URLModel( 133 | cloudKitIdentifier: UUID().uuidString, 134 | urls: [ 135 | "http://tkdfeddizkj.pafpapsnrnn.net", 136 | "http://dcacju.uyczcghcqruf.bg", 137 | "http://utonggatwhxz.aicwdazc.info", 138 | "http://kfvbza.zvmoitujnrq.fr", 139 | ].compactMap(URL.init(string:)) 140 | ) 141 | 142 | let encoder = CKRecordEncoder( 143 | zoneID: CKRecordZone.ID( 144 | zoneName: String(describing: Person.self), 145 | ownerName: CKCurrentUserDefaultName 146 | ) 147 | ) 148 | let record = try encoder.encode(model) 149 | let urls = (record["urls"] as! [CKRecordValue]) 150 | .compactMap { $0 as? String } 151 | .compactMap({ URL(string: $0) }) 152 | 153 | XCTAssertEqual(urls.count, model.urls.count) 154 | } 155 | 156 | } 157 | -------------------------------------------------------------------------------- /Tests/CKRecordCoderTests/Mocks/Bookmark.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import CloudKitCodable 3 | import Foundation 4 | 5 | struct Bookmark: CloudKitCodable { 6 | var cloudKitSystemFields: Data? 7 | var cloudKitIdentifier: String 8 | var title: String 9 | 10 | static func resolveConflict( 11 | clientModel clientRecord: Bookmark, serverModel serverRecord: Bookmark 12 | ) -> Bookmark? { 13 | return nil 14 | } 15 | } 16 | 17 | extension Bookmark { 18 | static var testIdentifier = "29C1D1AD-18E0-47C1-B064-265D2458E650" 19 | static var bookmarkWithoutSystemFields = Bookmark( 20 | cloudKitSystemFields: nil, 21 | cloudKitIdentifier: Bookmark.testIdentifier, 22 | title: "Apple" 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /Tests/CKRecordCoderTests/Mocks/Numbers.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import CloudKitCodable 3 | import Foundation 4 | 5 | struct Numbers: CloudKitCodable { 6 | var cloudKitSystemFields: Data? 7 | var cloudKitIdentifier: String 8 | var favorites: [Int] 9 | 10 | static func resolveConflict(clientModel clientRecord: Numbers, serverModel serverRecord: Numbers) 11 | -> Numbers? 12 | { 13 | return nil 14 | } 15 | } 16 | 17 | extension Numbers { 18 | static var numbersMock = Numbers( 19 | cloudKitSystemFields: nil, 20 | cloudKitIdentifier: UUID().uuidString, 21 | favorites: [1, 2, 3, 4] 22 | ) 23 | 24 | static var testIdentifier = "8B14FD76-EA56-49B0-A184-6C01828BA20A" 25 | 26 | static var testZoneID = CKRecordZone.ID( 27 | zoneName: String(describing: Numbers.self), 28 | ownerName: CKCurrentUserDefaultName 29 | ) 30 | 31 | static var testRecordID = CKRecord.ID( 32 | recordName: Numbers.testIdentifier, 33 | zoneID: Numbers.testZoneID 34 | ) 35 | 36 | static var testRecord = CKRecord( 37 | recordType: String(describing: Numbers.self), 38 | recordID: Numbers.testRecordID 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /Tests/CKRecordCoderTests/Mocks/ParentChild.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import CloudKitCodable 3 | import Foundation 4 | 5 | struct Parent: CloudKitCodable { 6 | var cloudKitSystemFields: Data? 7 | var cloudKitIdentifier: String 8 | var name: String 9 | var child: Child 10 | static func resolveConflict(clientModel: Parent, serverModel: Parent) -> Parent? { 11 | nil 12 | } 13 | } 14 | 15 | struct Child: Codable, Hashable { 16 | let age: Int 17 | let name: String 18 | let gender: Gender 19 | let pet: Pet? 20 | } 21 | 22 | struct Pet: Codable, Hashable { 23 | let name: String 24 | } 25 | 26 | enum Gender: Int, Codable { 27 | case male 28 | case female 29 | } 30 | 31 | extension Parent { 32 | static var testIdentifier = "4DA396F5-9903-4565-AED1-24E16164A479" 33 | 34 | static var testZoneID = CKRecordZone.ID( 35 | zoneName: String(describing: Parent.self), 36 | ownerName: CKCurrentUserDefaultName 37 | ) 38 | 39 | static var testRecordID = CKRecord.ID( 40 | recordName: Parent.testIdentifier, 41 | zoneID: Parent.testZoneID 42 | ) 43 | 44 | static var testRecord = CKRecord( 45 | recordType: String(describing: Parent.self), 46 | recordID: Parent.testRecordID 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /Tests/CKRecordCoderTests/Mocks/Person.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import CloudKitCodable 3 | import Foundation 4 | 5 | struct Person: CloudKitCodable { 6 | enum Access: String, Codable { 7 | case admin 8 | case user 9 | } 10 | 11 | var cloudKitSystemFields: Data? 12 | var cloudKitIdentifier: String 13 | var name: String? = "George Michael" 14 | var age: Int? = 22 15 | var website = URL(string: "https://blueman.com") 16 | var twitter: URL? = nil 17 | var avatar: URL? = URL(fileURLWithPath: "/path/to/file") 18 | var isDeveloper: Bool? = false 19 | var access: Access? = .user 20 | 21 | static func resolveConflict(clientModel clientRecord: Person, serverModel serverRecord: Person) 22 | -> Person? 23 | { 24 | return nil 25 | } 26 | } 27 | 28 | extension Person { 29 | static let personWithSystemFields = Person( 30 | cloudKitSystemFields: Person.systemFieldsDataForTesting, 31 | cloudKitIdentifier: Person.testIdentifier, 32 | name: "Tobias Funke", 33 | age: 50, 34 | website: URL(string: "https://blueman.com")!, 35 | twitter: nil, 36 | avatar: URL(fileURLWithPath: "/path/to/file"), 37 | isDeveloper: true, 38 | access: .admin 39 | ) 40 | 41 | static var testIdentifier = "29C1D1AD-18E0-47C1-B064-265D2458E650" 42 | 43 | static var testZoneID = CKRecordZone.ID( 44 | zoneName: String(describing: Person.self), 45 | ownerName: CKCurrentUserDefaultName 46 | ) 47 | 48 | static var testRecordID = CKRecord.ID( 49 | recordName: Person.testIdentifier, 50 | zoneID: Person.testZoneID 51 | ) 52 | 53 | static var testRecord = CKRecord( 54 | recordType: String(describing: Person.self), 55 | recordID: Person.testRecordID 56 | ) 57 | 58 | static var systemFieldsDataForTesting: Data { 59 | let coder = NSKeyedArchiver(requiringSecureCoding: true) 60 | Person.testRecord.encodeSystemFields(with: coder) 61 | coder.finishEncoding() 62 | return coder.encodedData 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Tests/CKRecordCoderTests/Mocks/URLModel.swift: -------------------------------------------------------------------------------- 1 | import CloudKitCodable 2 | import Foundation 3 | 4 | struct URLModel: CloudKitCodable { 5 | var cloudKitSystemFields: Data? = nil 6 | 7 | var cloudKitIdentifier: String 8 | let urls: [URL] 9 | 10 | static func resolveConflict(clientModel: URLModel, serverModel: URLModel) -> Self? { 11 | return nil 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tests/CKRecordCoderTests/Mocks/UUIDModel.swift: -------------------------------------------------------------------------------- 1 | import CloudKitCodable 2 | import Foundation 3 | 4 | struct UUIDModel: CloudKitCodable { 5 | var cloudKitSystemFields: Data? 6 | var cloudKitIdentifier: String 7 | var uuid: UUID 8 | 9 | static func resolveConflict( 10 | clientModel clientRecord: UUIDModel, serverModel serverRecord: UUIDModel 11 | ) -> UUIDModel? { 12 | return nil 13 | } 14 | } 15 | 16 | extension UUIDModel { 17 | static var uuidModelMock = UUIDModel( 18 | cloudKitSystemFields: nil, 19 | cloudKitIdentifier: UUID().uuidString, 20 | uuid: UUID(uuidString: "0D2E7B29-AC4C-4A04-B57E-5CA0D208E55F")! 21 | ) 22 | } 23 | --------------------------------------------------------------------------------