├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Harmony ├── CKRecordCoder │ ├── CKRecordDecoder.swift │ ├── CKRecordEncoder.swift │ ├── CKRecordEncodingError.swift │ ├── CKRecordKeyedDecodingContainer.swift │ ├── CKRecordKeyedEncodingContainer.swift │ ├── CKRecordSingleValueDecoder.swift │ ├── CKRecordSingleValueEncoder.swift │ ├── CloudKitSystemFieldsKeyName.swift │ └── URLTransformer.swift ├── Core │ ├── Configuration.swift │ ├── Harmonic+CRUD.swift │ ├── Harmonic+DatabaseDefaults.swift │ ├── Harmonic.swift │ ├── Keys.swift │ ├── Logger.swift │ └── Record.swift ├── Extensions │ ├── CKRecordID.swift │ └── StateSerialization+Encoder.swift ├── Harmony.swift └── PrivacyInfo.xcprivacy ├── LICENSE.md ├── Package.resolved ├── Package.swift ├── README.md └── Tests └── HarmonyTests └── HarmonyTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Harmony/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 | -------------------------------------------------------------------------------- /Harmony/CKRecordCoder/CKRecordEncoder.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import Foundation 3 | 4 | public final class CKRecordEncoder { 5 | // The maximum amount of data that can be stored by a record (1 MB) 6 | private let maximumAllowedRecordSizeInBytes: Int = 1 * 1024 * 1024 7 | 8 | public var zoneID: CKRecordZone.ID 9 | 10 | public init(zoneID: CKRecordZone.ID) { 11 | self.zoneID = zoneID 12 | } 13 | 14 | public func encode(_ value: E) throws -> CKRecord { 15 | let type = E.recordType 16 | let recordName = value.recordID.recordName 17 | 18 | let encoder = _CKRecordEncoder( 19 | recordTypeName: type, 20 | recordName: recordName, 21 | zoneID: zoneID 22 | ) 23 | 24 | try value.encode(to: encoder) 25 | 26 | let record = encoder.buildRecord() 27 | 28 | try validateSize(for: encoder.storage.keys) 29 | 30 | return record 31 | } 32 | 33 | public static func decodeSystemFields(with systemFields: Data) -> CKRecord? { 34 | guard let coder = try? NSKeyedUnarchiver(forReadingFrom: systemFields) else { return nil } 35 | coder.requiresSecureCoding = true 36 | let record = CKRecord(coder: coder) 37 | coder.finishDecoding() 38 | return record 39 | } 40 | 41 | private func validateSize(for recordKeyValues: [String: CKRecordValue]) throws { 42 | guard 43 | let recordData = try? NSKeyedArchiver.archivedData( 44 | withRootObject: recordKeyValues, 45 | requiringSecureCoding: true 46 | ) 47 | else { return } 48 | 49 | if recordData.count >= maximumAllowedRecordSizeInBytes { 50 | let context = EncodingError.Context( 51 | codingPath: [], 52 | debugDescription: 53 | "CKRecord is too large. Record is \(formattedSize(ofDataCount: recordData.count)), the maxmimum allowed size is \(formattedSize(ofDataCount: maximumAllowedRecordSizeInBytes)))" 54 | ) 55 | throw EncodingError.invalidValue(Any.self, context) 56 | } 57 | } 58 | 59 | private func formattedSize(ofDataCount dataCount: Int) -> String { 60 | let formatter = ByteCountFormatter() 61 | formatter.allowedUnits = [.useKB, .useMB] 62 | formatter.countStyle = .binary 63 | return formatter.string(fromByteCount: Int64(dataCount)) 64 | } 65 | } 66 | 67 | final class _CKRecordEncoder { 68 | let recordTypeName: CKRecord.RecordType 69 | let recordName: String 70 | let zoneID: CKRecordZone.ID 71 | var codingPath: [CodingKey] = [] 72 | var userInfo: [CodingUserInfoKey: Any] = [:] 73 | 74 | var storage: Storage 75 | 76 | init( 77 | recordTypeName: CKRecord.RecordType, 78 | recordName: String, 79 | zoneID: CKRecordZone.ID, 80 | storage: Storage = Storage() 81 | ) { 82 | self.recordTypeName = recordTypeName 83 | self.recordName = recordName 84 | self.zoneID = zoneID 85 | self.storage = storage 86 | } 87 | } 88 | 89 | extension _CKRecordEncoder { 90 | final class Storage { 91 | private(set) var record: CKRecord? 92 | private(set) var keys: [String: CKRecordValue] = [:] 93 | 94 | func set(record: CKRecord?) { 95 | self.record = record 96 | } 97 | 98 | func encode(codingPath: [CodingKey], value: CKRecordValue?) { 99 | let key = 100 | codingPath 101 | .map { $0.stringValue } 102 | .joined(separator: "_") 103 | keys[key] = value 104 | } 105 | } 106 | 107 | func buildRecord() -> CKRecord { 108 | let output: CKRecord = 109 | storage.record 110 | ?? CKRecord( 111 | recordType: recordTypeName, 112 | recordID: CKRecord.ID( 113 | recordName: recordName, 114 | zoneID: zoneID) 115 | ) 116 | 117 | guard output.recordType == recordTypeName else { 118 | fatalError( 119 | """ 120 | CloudKit record type mismatch: the record should be of type \(recordTypeName) but it was 121 | of type \(output.recordType). This is probably a result of corrupted cloudKitSystemData 122 | or a change in record/type name that must be corrected in your type by adopting CustomCloudKitEncodable. 123 | """ 124 | ) 125 | } 126 | 127 | storage.keys.forEach { (key, value) in output[key] = value } 128 | return output 129 | } 130 | } 131 | 132 | extension _CKRecordEncoder: Encoder { 133 | 134 | func container(keyedBy type: Key.Type) -> KeyedEncodingContainer { 135 | let container = CKRecordKeyedEncodingContainer(storage: storage) 136 | container.codingPath = codingPath 137 | return KeyedEncodingContainer(container) 138 | } 139 | 140 | func unkeyedContainer() -> UnkeyedEncodingContainer { 141 | fatalError("Not implemented") 142 | } 143 | 144 | func singleValueContainer() -> SingleValueEncodingContainer { 145 | fatalError("Not implemented") 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /Harmony/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 | -------------------------------------------------------------------------------- /Harmony/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 | -------------------------------------------------------------------------------- /Harmony/CKRecordCoder/CKRecordKeyedEncodingContainer.swift: -------------------------------------------------------------------------------- 1 | import CloudKit 2 | import Foundation 3 | 4 | final class CKRecordKeyedEncodingContainer { 5 | var storage: _CKRecordEncoder.Storage 6 | var codingPath: [CodingKey] = [] 7 | var userInfo: [CodingUserInfoKey: Any] = [:] 8 | lazy var jsonEncoder: JSONEncoder = { 9 | return JSONEncoder() 10 | }() 11 | 12 | init(storage: _CKRecordEncoder.Storage) { 13 | self.storage = storage 14 | } 15 | } 16 | 17 | extension CKRecordKeyedEncodingContainer: KeyedEncodingContainerProtocol { 18 | func encodeNil(forKey key: Key) throws { 19 | storage.encode(codingPath: codingPath + [key], value: nil) 20 | } 21 | 22 | func encode(_ value: T, forKey key: Key) throws where T: Encodable { 23 | // This breaks [URL] where the array is empty. 24 | guard !(value is CloudKitEncodable) && 25 | !(value is [CloudKitEncodable]) else { 26 | 27 | throw CKRecordEncodingError.referencesNotSupported( 28 | codingPath.map { $0.stringValue }.joined(separator: "-")) 29 | } 30 | 31 | if key.stringValue == _CloudKitSystemFieldsKeyName { 32 | guard let systemFieldsData = value as? Data else { 33 | throw CKRecordEncodingError.systemFieldsDecode( 34 | "\(_CloudKitSystemFieldsKeyName) property must be of type Data.") 35 | } 36 | storage.set(record: CKRecordEncoder.decodeSystemFields(with: systemFieldsData)) 37 | } else if let value = value as? URL { 38 | storage.encode(codingPath: codingPath + [key], value: URLTransformer.encode(value)) 39 | } else if let value = value as? [URL] { 40 | storage.encode( 41 | codingPath: codingPath + [key], value: value.map(URLTransformer.encode) as CKRecordValue) 42 | } else if let value = value as? CKRecordValue { 43 | storage.encode(codingPath: codingPath + [key], value: value) 44 | } else { 45 | do { 46 | let encoder = CKRecordSingleValueEncoder(storage: storage, codingPath: codingPath + [key]) 47 | try value.encode(to: encoder) 48 | } catch { 49 | storage.encode( 50 | codingPath: codingPath + [key], value: try jsonEncoder.encode(value) as CKRecordValue) 51 | } 52 | } 53 | } 54 | 55 | func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key) 56 | -> KeyedEncodingContainer where NestedKey: CodingKey 57 | { 58 | fatalError("Not implemented") 59 | } 60 | 61 | func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { 62 | fatalError("Not implemented") 63 | } 64 | 65 | func superEncoder() -> Encoder { 66 | fatalError("Not implemented") 67 | } 68 | 69 | func superEncoder(forKey key: Key) -> Encoder { 70 | fatalError("Not implemented") 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Harmony/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 | -------------------------------------------------------------------------------- /Harmony/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 | -------------------------------------------------------------------------------- /Harmony/CKRecordCoder/CloudKitSystemFieldsKeyName.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let _CloudKitSystemFieldsKeyName = "archivedRecordData" 4 | -------------------------------------------------------------------------------- /Harmony/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 | print("Decoding asset as \(url)") 34 | 35 | // Save a local copy, return that URL. 36 | // Never refer to the file URL somehow. 37 | 38 | return url 39 | } 40 | 41 | guard let str = value as? String else { 42 | let context = DecodingError.Context( 43 | codingPath: codingPath, 44 | debugDescription: "URL should have been encoded as String in CKRecord." 45 | ) 46 | throw DecodingError.typeMismatch(URL.self, context) 47 | } 48 | 49 | guard let url = URL(string: str) else { 50 | let context = DecodingError.Context( 51 | codingPath: codingPath, 52 | debugDescription: "The string \(str) is not a valid url." 53 | ) 54 | throw DecodingError.typeMismatch(URL.self, context) 55 | } 56 | return url 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Harmony/Core/Configuration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Configuration.swift 3 | // Harmony 4 | // 5 | // Created by Aaron Pearce on 8/06/23. 6 | // 7 | 8 | import Foundation 9 | import GRDB 10 | 11 | public struct Configuration: Sendable{ 12 | let cloudKitContainerIdentifier: String? 13 | let sharedAppGroupContainerIdentifier: String? 14 | let databasePath: String? 15 | let databaseConfiguration: GRDB.Configuration? 16 | 17 | internal var isDummy: Bool = false 18 | 19 | public init(cloudKitContainerIdentifier: String? = nil, sharedAppGroupContainerIdentifier: String? = nil, 20 | databasePath: String? = nil, 21 | databaseConfiguration: GRDB.Configuration? = nil 22 | ) { 23 | self.cloudKitContainerIdentifier = cloudKitContainerIdentifier 24 | self.sharedAppGroupContainerIdentifier = sharedAppGroupContainerIdentifier 25 | self.databasePath = databasePath 26 | self.databaseConfiguration = databaseConfiguration 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Harmony/Core/Harmonic+CRUD.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Harmonic+CRUD.swift 3 | // Harmony 4 | // 5 | // Created by Aaron Pearce on 11/06/23. 6 | // 7 | 8 | import Foundation 9 | import GRDB 10 | import CloudKit 11 | import os.log 12 | 13 | public extension Harmonic { 14 | 15 | func read(_ block: (Database) throws -> T) throws -> T { 16 | try reader.read(block) 17 | } 18 | 19 | func read(_ block: @Sendable @escaping (Database) throws -> T) async throws -> T { 20 | try await reader.read { db in 21 | try block(db) 22 | } 23 | } 24 | 25 | func create(record: T) async throws { 26 | try await database.write { db in 27 | try record.insert(db) 28 | } 29 | 30 | queueSaves(for: [record]) 31 | } 32 | 33 | func create(records: [T]) async throws { 34 | try await database.write { db in 35 | try records.forEach { 36 | try $0.insert(db) 37 | } 38 | } 39 | queueSaves(for: records) 40 | } 41 | 42 | func save(record: T) async throws { 43 | try await database.write { db in 44 | try record.save(db) 45 | } 46 | 47 | queueSaves(for: [record]) 48 | } 49 | 50 | func save(records: [T]) async throws { 51 | _ = try await database.write { db in 52 | try records.forEach { 53 | try $0.save(db) 54 | } 55 | } 56 | 57 | queueSaves(for: records) 58 | } 59 | 60 | func delete(record: T) async throws { 61 | _ = try await database.write { db in 62 | try record.delete(db) 63 | } 64 | 65 | queueDeletions(for: [record]) 66 | } 67 | 68 | func delete(records: [T]) async throws { 69 | _ = try await database.write { db in 70 | try records.forEach { 71 | try $0.delete(db) 72 | } 73 | } 74 | 75 | queueDeletions(for: records) 76 | } 77 | 78 | /// Pushes all of the given record type to CloudKit 79 | /// This occurs regardless of changes. 80 | /// Sometimes used during migration for schema changes. 81 | func pushAll(for recordType: T.Type) throws { 82 | let records = try read { db in 83 | return try recordType.fetchAll(db) 84 | } 85 | 86 | queueSaves(for: records) 87 | } 88 | 89 | private func queueSaves(for records: [any HRecord]) { 90 | Logger.database.info("Queuing saves") 91 | let pendingSaves: [CKSyncEngine.PendingRecordZoneChange] = records.map { 92 | .saveRecord($0.recordID) 93 | } 94 | 95 | self.syncEngine.state.add(pendingRecordZoneChanges: pendingSaves) 96 | } 97 | 98 | private func queueDeletions(for records: [any HRecord]) { 99 | Logger.database.info("Queuing deletions") 100 | let pendingDeletions: [CKSyncEngine.PendingRecordZoneChange] = records.map { 101 | .deleteRecord($0.recordID) 102 | } 103 | 104 | self.syncEngine.state.add(pendingRecordZoneChanges: pendingDeletions) 105 | } 106 | 107 | func sendChanges() async throws { 108 | try await self.syncEngine.sendChanges() 109 | } 110 | 111 | func fetchChanges() async throws { 112 | try await self.syncEngine.fetchChanges() 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Harmony/Core/Harmonic+DatabaseDefaults.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Harmonic+DatabaseDefaults.swift 3 | // 4 | // 5 | // Created by Aaron Pearce on 14/06/23. 6 | // 7 | 8 | import GRDB 9 | import os.log 10 | import Foundation 11 | 12 | extension Harmonic { 13 | static let sqlLogger = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "SQL") 14 | 15 | public static var defaultDatabasePath: String { 16 | get throws { 17 | let fileManager = FileManager.default 18 | let appSupportURL = try! fileManager.url( 19 | for: .applicationSupportDirectory, in: .userDomainMask, 20 | appropriateFor: nil, create: true) 21 | let directoryURL = appSupportURL.appendingPathComponent("Database", isDirectory: true) 22 | 23 | // Support for tests: delete the database if requested 24 | if CommandLine.arguments.contains("-reset") { 25 | try? fileManager.removeItem(at: directoryURL) 26 | } 27 | 28 | // Create the database folder if needed 29 | try! fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) 30 | 31 | // Open or create the database 32 | let databaseURL = directoryURL.appendingPathComponent("db.sqlite") 33 | 34 | return databaseURL.path 35 | } 36 | } 37 | 38 | 39 | static func makeDatabaseConfiguration(_ base: GRDB.Configuration = GRDB.Configuration()) -> GRDB.Configuration { 40 | var config = base 41 | 42 | // An opportunity to add required custom SQL functions or 43 | // collations, if needed: 44 | // config.prepareDatabase { db in 45 | // db.add(function: ...) 46 | // } 47 | 48 | // Log SQL statements if the `SQL_TRACE` environment variable is set. 49 | // See 50 | if ProcessInfo.processInfo.environment["SQL_TRACE"] != nil { 51 | config.prepareDatabase { db in 52 | db.trace { 53 | // It's ok to log statements publicly. Sensitive 54 | // information (statement arguments) are not logged 55 | // unless config.publicStatementArguments is set 56 | // (see below). 57 | os_log("%{public}@", log: Harmonic.sqlLogger, type: .debug, String(describing: $0)) 58 | } 59 | } 60 | } 61 | 62 | #if DEBUG 63 | // Protect sensitive information by enabling verbose debugging in 64 | // DEBUG builds only. 65 | // See 66 | config.publicStatementArguments = true 67 | #endif 68 | 69 | return config 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Harmony/Core/Harmonic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Harmony.swift 3 | // Harmony 4 | // 5 | // Created by Aaron Pearce on 8/06/23. 6 | // 7 | 8 | import SwiftUI 9 | import CloudKit 10 | import os.log 11 | import GRDB 12 | import Combine 13 | 14 | /// 15 | /// Harmony becomes your central repository. 16 | /// Every write and read goes via it. 17 | /// 18 | /// Existing GRDB users can pass in their database file path if they wish 19 | /// Harmony will take the following configuration options 20 | /// - recordTypes: [HRecord.Type] 21 | /// - configuration: Configuration 22 | /// - cloudKitContainerIdentifier: String 23 | /// - sharedAppGroupIdentifier: String 24 | /// - databasePath: String ?? 25 | /// - databaseConfiguration: GRDB.Configuration 26 | /// - migrator: GRDB.Migrator 27 | /// 28 | /// Writing will be done via similar methods to the 29 | /// GRDB.DatabaseWriter protocol with syntatic sugar for 30 | /// HRecord. 31 | /// 32 | /// Harmony will then manage all syncing and DB management 33 | /// internally, removing the need for the system to observe 34 | /// the database in any manner and also removing the 35 | /// possiblity for a user to write directly to the database 36 | /// without inherently misusing the library. 37 | /// 38 | /// 39 | public final class Harmonic { 40 | 41 | // Containers 42 | // Shared or private? 43 | let configuration: Configuration 44 | 45 | /// The sync engine being used to sync. 46 | /// This is lazily initialized. You can re-initialize the sync engine by setting `_syncEngine` to nil then calling `self.syncEngine`. 47 | var _syncEngine: CKSyncEngine? 48 | var syncEngine: CKSyncEngine { 49 | if _syncEngine == nil { 50 | self.initializeSyncEngine() 51 | } 52 | return _syncEngine! 53 | } 54 | 55 | private let modelTypes: [any HRecord.Type] 56 | private let container: CKContainer 57 | private let userDefaults: UserDefaults 58 | let database: DatabaseWriter 59 | 60 | public var reader: DatabaseReader { 61 | database 62 | } 63 | 64 | public var databaseChanged: DatabasePublishers.DatabaseRegion { 65 | DatabaseRegionObservation( 66 | tracking: .fullDatabase 67 | ).publisher(in: database) 68 | } 69 | 70 | private var privateDatabase: CKDatabase { 71 | container.privateCloudDatabase 72 | } 73 | 74 | private var lastStateSerialization: CKSyncEngine.State.Serialization? { 75 | get { 76 | if let data = userDefaults.data(forKey: Keys.stateSerialization), 77 | let state = try? CKSyncEngine.State.Serialization.decode(data) { 78 | return state 79 | } else { 80 | return nil 81 | } 82 | } 83 | set { 84 | if let data = try? newValue?.encode() { 85 | userDefaults.set(data, forKey: Keys.stateSerialization) 86 | } 87 | } 88 | } 89 | 90 | public init(for modelTypes: [any HRecord.Type], configuration: Configuration, migrator: DatabaseMigrator) { 91 | self.modelTypes = modelTypes 92 | self.configuration = configuration 93 | 94 | if let cloudKitContainerIdentifier = configuration.cloudKitContainerIdentifier { 95 | self.container = CKContainer(identifier: cloudKitContainerIdentifier) 96 | } else { 97 | self.container = .default() 98 | } 99 | 100 | if let sharedAppGroupContainerIdentifier = configuration.sharedAppGroupContainerIdentifier { 101 | self.userDefaults = UserDefaults(suiteName: sharedAppGroupContainerIdentifier)! 102 | } else { 103 | self.userDefaults = .standard 104 | } 105 | 106 | var databaseHasMigrated = false 107 | if configuration.isDummy { 108 | self.database = try! DatabaseQueue() 109 | } else { 110 | do { 111 | let databasePath = try configuration.databasePath ?? Self.defaultDatabasePath 112 | let databaseConfiguration = configuration.databaseConfiguration ?? Self.makeDatabaseConfiguration() 113 | 114 | self.database = try DatabasePool( 115 | path: databasePath, 116 | configuration: databaseConfiguration 117 | ) 118 | 119 | let initialMigrations = try self.database.read { db in 120 | return try migrator.appliedMigrations(db) 121 | } 122 | 123 | try migrator.migrate(self.database) 124 | 125 | let afterMigrations = try self.database.read { db in 126 | return try migrator.appliedMigrations(db) 127 | } 128 | 129 | // If any migration occurred, we'll sync the whole database to ensure any new default values sync too. 130 | if initialMigrations != afterMigrations { 131 | databaseHasMigrated = true 132 | } 133 | } catch { 134 | fatalError("Unresolved error \(error)") 135 | } 136 | 137 | // Lazily start. 138 | Task { 139 | initializeSyncEngine() 140 | 141 | if databaseHasMigrated { 142 | // Sync all entity types as an initial method, this can get smarter by only migrating those that have been altered. 143 | for modelType in modelTypes { 144 | try? pushAll(for: modelType) 145 | } 146 | } 147 | 148 | try? await syncEngine.fetchChanges() 149 | } 150 | } 151 | } 152 | 153 | static func dummy() -> Harmonic { 154 | var config = Configuration() 155 | config.isDummy = true 156 | let migrator = DatabaseMigrator() 157 | return Harmonic(for: [], configuration: config, migrator: migrator) 158 | } 159 | } 160 | 161 | private extension Harmonic { 162 | 163 | func initializeSyncEngine() { 164 | var configuration = CKSyncEngine.Configuration( 165 | database: self.container.privateCloudDatabase, 166 | stateSerialization: self.lastStateSerialization, 167 | delegate: self 168 | ) 169 | configuration.automaticallySync = true //self.automaticallySync 170 | let syncEngine = CKSyncEngine(configuration) 171 | _syncEngine = syncEngine 172 | Logger.database.log("Initialized sync engine: \(syncEngine)") 173 | } 174 | } 175 | 176 | // MARK: CKSyncEngineDelegate 177 | 178 | extension Harmonic: CKSyncEngineDelegate { 179 | 180 | public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { 181 | 182 | Logger.database.log("Handling event \(event, privacy: .public)") 183 | 184 | switch event { 185 | case .stateUpdate(let stateUpdate): 186 | self.lastStateSerialization = stateUpdate.stateSerialization 187 | 188 | case .accountChange(let event): 189 | self.handleAccountChange(event) 190 | 191 | case .fetchedDatabaseChanges(let event): 192 | self.handleFetchedDatabaseChanges(event) 193 | 194 | case .fetchedRecordZoneChanges(let event): 195 | self.handleFetchedRecordZoneChanges(event) 196 | 197 | case .sentRecordZoneChanges(let event): 198 | self.handleSentRecordZoneChanges(event) 199 | 200 | case .sentDatabaseChanges: 201 | // The sample app doesn't track sent database changes in any meaningful way, but this might be useful depending on your data model. 202 | break 203 | 204 | case .willFetchChanges, .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .didFetchChanges, .willSendChanges, .didSendChanges: 205 | // We don't do anything here in the sample app, but these events might be helpful if you need to do any setup/cleanup when sync starts/ends. 206 | break 207 | 208 | @unknown default: 209 | Logger.database.info("Received unknown event: \(event)") 210 | } 211 | } 212 | 213 | public func nextRecordZoneChangeBatch(_ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine) async -> CKSyncEngine.RecordZoneChangeBatch? { 214 | Logger.database.info("Returning next record change batch for context: \(context.description, privacy: .public)") 215 | 216 | let scope = context.options.scope 217 | let changes = syncEngine.state.pendingRecordZoneChanges.filter { scope.contains($0) } 218 | 219 | let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in 220 | if let recordType = recordID.parsedRecordType, 221 | let internalID = recordID.parsedRecordID { 222 | // We can sync this. 223 | // Find this in our DB 224 | if let modelType = modelType(for: recordType), 225 | let record = try? await database.read({ db in 226 | let uuid = UUID(uuidString: internalID) 227 | return try modelType.fetchOne(db, key: uuid) 228 | }) { 229 | return record.record 230 | } else { 231 | // Could be a deletion? 232 | syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) 233 | return nil 234 | } 235 | } else { 236 | syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) 237 | return nil 238 | } 239 | } 240 | 241 | return batch 242 | } 243 | } 244 | 245 | // MARK: - Event Handlers 246 | private extension Harmonic { 247 | 248 | func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) { 249 | Logger.database.info("Handle account change \(event, privacy: .public)") 250 | } 251 | 252 | func handleFetchedDatabaseChanges(_ event: CKSyncEngine.Event.FetchedDatabaseChanges) { 253 | Logger.database.info("Handle fetched database changes \(event, privacy: .public)") 254 | 255 | // If a zone was deleted, we should delete everything for that zone locally. 256 | #warning("Zone deletion is not handled!") 257 | /* Copied from the example sync sample from Apple 258 | var needsToSave = false 259 | for deletion in event.deletions { 260 | switch deletion.zoneID.zoneName { 261 | case Contact.zoneName: 262 | self.appData.contacts = [:] 263 | needsToSave = true 264 | default: 265 | Logger.database.info("Received deletion for unknown zone: \(deletion.zoneID)") 266 | } 267 | } 268 | 269 | if needsToSave { 270 | try? self.persistLocalData() // This error should be handled, but we'll skip that for brevity in this sample app. 271 | } 272 | */ 273 | } 274 | 275 | func handleFetchedRecordZoneChanges(_ event: CKSyncEngine.Event.FetchedRecordZoneChanges) { 276 | Logger.database.info("Handle fetched record zone changes \(event)") 277 | 278 | for modification in event.modifications { 279 | // The sync engine fetched a record, and we want to merge it into our local persistence. 280 | // If we already have this object locally, let's merge the data from the server. 281 | // Otherwise, let's create a new local object. 282 | let record = modification.record 283 | if let id = record.recordID.parsedRecordID, 284 | let modelType = modelType(for: record) { 285 | try! database.write { db in 286 | if var localRecord = try modelType.fetchOne(db, key: UUID(uuidString: id)) { 287 | try localRecord.updateChanges(db: db, ckRecord: record) 288 | } else { 289 | if let model = modelType.parseFrom(record: record) { 290 | try model.save(db) 291 | } 292 | } 293 | } 294 | } 295 | } 296 | 297 | for deletion in event.deletions { 298 | 299 | // A record was deleted on the server, so let's remove it from our local persistence. 300 | let recordID = deletion.recordID 301 | if let recordType = recordID.parsedRecordType, 302 | let id = recordID.parsedRecordID, 303 | let modelType = modelType(for: recordType) { 304 | // Find it locally and merge it 305 | _ = try! database.write { db in 306 | try modelType.deleteOne(db, key: UUID(uuidString: id)) 307 | } 308 | } 309 | } 310 | 311 | // If we had any changes, let's save to disk. 312 | if !event.modifications.isEmpty || !event.deletions.isEmpty { 313 | // Already saved above... but maybe we should save at the end of a batch? 314 | } 315 | } 316 | 317 | func handleSentRecordZoneChanges(_ event: CKSyncEngine.Event.SentRecordZoneChanges) { 318 | Logger.database.info("Handle sent record zone changes \(event, privacy: .public)") 319 | 320 | // If we failed to save a record, we might want to retry depending on the error code. 321 | var newPendingRecordZoneChanges = [CKSyncEngine.PendingRecordZoneChange]() 322 | var newPendingDatabaseChanges = [CKSyncEngine.PendingDatabaseChange]() 323 | 324 | // Update the last known server record for each of the saved records. 325 | for savedRecord in event.savedRecords { 326 | if let id = savedRecord.recordID.parsedRecordID, 327 | let modelType = modelType(for: savedRecord) { 328 | try! database.write { db in 329 | var localRecord = try? modelType.fetchOne(db, key: UUID(uuidString: id)) 330 | localRecord?.setLastKnownRecordIfNewer(savedRecord) 331 | try! localRecord?.save(db) 332 | } 333 | } 334 | } 335 | 336 | // Handle any failed record saves. 337 | for failedRecordSave in event.failedRecordSaves { 338 | let failedRecord = failedRecordSave.record 339 | guard let id = failedRecord.recordID.parsedRecordID, 340 | let modelType = modelType(for: failedRecord) else { 341 | continue 342 | } 343 | 344 | var shouldClearServerRecord = false 345 | switch failedRecordSave.error.code { 346 | 347 | case .serverRecordChanged: 348 | // Let's merge the record from the server into our own local copy. 349 | // The `mergeFromServerRecord` function takes care of the conflict resolution. 350 | guard let serverRecord = failedRecordSave.error.serverRecord else { 351 | Logger.database.error("No server record for conflict \(failedRecordSave.error)") 352 | continue 353 | } 354 | 355 | try? database.write { db in 356 | var localRecord = try modelType.fetchOne(db, key: UUID(uuidString: id)) 357 | // Merge from server... 358 | try localRecord?.updateChanges(db: db, ckRecord: serverRecord) 359 | } 360 | 361 | newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) 362 | 363 | case .zoneNotFound: 364 | // Looks like we tried to save a record in a zone that doesn't exist. 365 | // Let's save that zone and retry saving the record. 366 | // Also clear the last known server record if we have one, it's no longer valid. 367 | let zone = CKRecordZone(zoneID: failedRecord.recordID.zoneID) 368 | newPendingDatabaseChanges.append(.saveZone(zone)) 369 | newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) 370 | shouldClearServerRecord = true 371 | 372 | case .unknownItem: 373 | // We tried to save a record with a locally-cached server record, but that record no longer exists on the server. 374 | // This might mean that another device deleted the record, but we still have the data for that record locally. 375 | // We have the choice of either deleting the local data or re-uploading the local data. 376 | // For this sample app, let's re-upload the local data. 377 | newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) 378 | shouldClearServerRecord = true 379 | 380 | case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, .notAuthenticated, .operationCancelled: 381 | // There are several errors that the sync engine will automatically retry, let's just log and move on. 382 | Logger.database.debug("Retryable error saving \(failedRecord.recordID): \(failedRecordSave.error)") 383 | 384 | default: 385 | // We got an error, but we don't know what it is or how to handle it. 386 | // If you have any sort of telemetry system, you should consider tracking this scenario so you can understand which errors you see in the wild. 387 | Logger.database.fault("Unknown error saving record \(failedRecord.recordID): \(failedRecordSave.error)") 388 | } 389 | 390 | if shouldClearServerRecord { 391 | try? database.write { db in 392 | var localRecord = try? modelType.fetchOne(db, key: UUID(uuidString: id)) 393 | // Merge from server... 394 | localRecord?.archivedRecord = nil 395 | try localRecord?.save(db) 396 | } 397 | } 398 | } 399 | 400 | if !newPendingDatabaseChanges.isEmpty { 401 | self.syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) 402 | } 403 | 404 | if !newPendingRecordZoneChanges.isEmpty { 405 | self.syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) 406 | } 407 | } 408 | } 409 | 410 | 411 | // MARK: - Model Type Helpers 412 | 413 | private extension Harmonic { 414 | 415 | func modelType(for record: CKRecord) -> (any HRecord.Type)? { 416 | return modelType(for: record.recordType) 417 | } 418 | 419 | func modelType(for recordType: String) -> (any HRecord.Type)? { 420 | guard let modelType = self.modelTypes.first(where: { t in 421 | t.recordType == recordType 422 | }) else { 423 | return nil 424 | } 425 | 426 | return modelType 427 | } 428 | } 429 | -------------------------------------------------------------------------------- /Harmony/Core/Keys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Keys.swift 3 | // 4 | // 5 | // Created by Aaron Pearce on 22/06/23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Harmonic { 11 | enum Keys { 12 | static let stateSerialization = "Harmony.State" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Harmony/Core/Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger.swift 3 | // Harmony 4 | // 5 | // Created by Aaron Pearce on 8/06/23. 6 | // 7 | import os.log 8 | 9 | extension Logger { 10 | 11 | static let loggingSubsystem: String = "com.pearcemedia.Harmony" 12 | 13 | static let database = Logger(subsystem: Self.loggingSubsystem, category: "Database") 14 | } 15 | -------------------------------------------------------------------------------- /Harmony/Core/Record.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HRecord.swift 3 | // CloudSyncTest 4 | // 5 | // Created by Aaron Pearce on 14/07/21. 6 | // 7 | 8 | import GRDB 9 | import Foundation 10 | import CloudKit 11 | 12 | public protocol CloudKitEncodable: Encodable {} 13 | 14 | public protocol CloudKitDecodable: Decodable {} 15 | 16 | public protocol HRecord: CloudKitEncodable & CloudKitDecodable & FetchableRecord & PersistableRecord { 17 | 18 | // Required model values 19 | var id: UUID { get } 20 | // var isDeleted: Bool { get set } 21 | 22 | // CloudKit values 23 | var archivedRecordData: Data? { get set } 24 | var archivedRecord: CKRecord? { get } 25 | 26 | static var recordType: String { get } 27 | var zoneID: CKRecordZone.ID { get } 28 | 29 | var recordID: CKRecord.ID { get } 30 | var record: CKRecord { get } 31 | 32 | var cloudKitLastModifiedDate: Date? { get } 33 | 34 | static func parseFrom(record: CKRecord) -> Self? 35 | 36 | mutating func updateChanges(db: Database, ckRecord: CKRecord) throws 37 | } 38 | 39 | extension HRecord { 40 | public var archivedRecord: CKRecord? { 41 | get { 42 | guard let data = archivedRecordData, 43 | let unarchiver = try? NSKeyedUnarchiver(forReadingFrom: data) else { 44 | return nil 45 | } 46 | 47 | unarchiver.requiresSecureCoding = true 48 | return CKRecord(coder: unarchiver) 49 | } 50 | set { 51 | if let newValue { 52 | let archiver = NSKeyedArchiver(requiringSecureCoding: true) 53 | newValue.encodeSystemFields(with: archiver) 54 | self.archivedRecordData = archiver.encodedData 55 | } else { 56 | self.archivedRecordData = nil 57 | } 58 | } 59 | } 60 | 61 | public static var recordType: String { 62 | String(describing: self) 63 | } 64 | 65 | public var recordID: CKRecord.ID { 66 | CKRecord.ID( 67 | recordName: "\(Self.recordType)|\(id.uuidString)", 68 | zoneID: zoneID 69 | ) 70 | } 71 | 72 | public var cloudKitLastModifiedDate: Date? { 73 | archivedRecord?.modificationDate 74 | } 75 | 76 | public var cloudKitCreationDate: Date? { 77 | archivedRecord?.creationDate 78 | } 79 | 80 | public static func parseFrom(record: CKRecord) -> Self? { 81 | try? CKRecordDecoder().decode(Self.self, from: record) 82 | } 83 | 84 | public mutating func updateChanges(db: Database, ckRecord: CKRecord) throws { 85 | if let cloudRecord = Self.parseFrom(record: ckRecord) { 86 | try cloudRecord.updateChanges(db, from: self) 87 | } 88 | } 89 | 90 | mutating func setLastKnownRecordIfNewer(_ otherRecord: CKRecord) { 91 | let localRecord = self.archivedRecord 92 | if let localDate = localRecord?.modificationDate { 93 | if let otherDate = otherRecord.modificationDate, localDate < otherDate { 94 | self.archivedRecord = otherRecord 95 | } else { 96 | // The other record is older than the one we already have. 97 | } 98 | } else { 99 | self.archivedRecord = otherRecord 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Harmony/Extensions/CKRecordID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CKRecordID.swift 3 | // Harmony 4 | // 5 | // Created by Aaron Pearce on 11/06/23. 6 | // 7 | 8 | import Foundation 9 | import CloudKit 10 | 11 | extension CKRecord.ID { 12 | var parsedRecordType: String? { 13 | if let recordType = recordName 14 | .split(separator: "|", maxSplits: 1) 15 | .first { 16 | return String(recordType) 17 | } else { 18 | return nil 19 | } 20 | } 21 | 22 | var parsedRecordID: String? { 23 | if let recordID = recordName 24 | .split(separator: "|", maxSplits: 1) 25 | .last { 26 | return String(recordID) 27 | } else { 28 | return nil 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Harmony/Extensions/StateSerialization+Encoder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StateSerialization+Encoder.swift 3 | // 4 | // 5 | // Created by Aaron Pearce on 22/06/23. 6 | // 7 | 8 | import CloudKit 9 | 10 | extension CKSyncEngine.State.Serialization { 11 | 12 | func encode() throws -> Data? { 13 | return try JSONEncoder().encode(self) 14 | } 15 | 16 | static func decode(_ data: Data) throws -> Self? { 17 | return try JSONDecoder().decode(Self.self, from: data) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Harmony/Harmony.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Harmony.swift 3 | // 4 | // 5 | // Created by Aaron Pearce on 14/06/23. 6 | // 7 | 8 | import Foundation 9 | import GRDB 10 | 11 | @propertyWrapper 12 | public struct Harmony { 13 | static var current: Harmonic = .dummy() 14 | 15 | public var wrappedValue: Harmonic { 16 | get { Self.current } 17 | } 18 | 19 | static var hasBeenInitialized = false 20 | 21 | public init() { 22 | if !Self.hasBeenInitialized { 23 | fatalError("Ensure your first usage of @Harmonic initializes the system.") 24 | } 25 | } 26 | 27 | public init(records modelTypes: [any HRecord.Type], configuration: Configuration, migrator: DatabaseMigrator) { 28 | 29 | guard !Self.hasBeenInitialized else { 30 | fatalError("Do not try to initialize @Harmonic twice!") 31 | } 32 | 33 | Self.current = Harmonic(for: modelTypes, configuration: configuration, migrator: migrator) 34 | Self.hasBeenInitialized = true 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Harmony/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyTracking 6 | 7 | NSPrivacyTrackingDomains 8 | 9 | NSPrivacyCollectedDataTypes 10 | 11 | NSPrivacyAccessedAPITypes 12 | 13 | 14 | NSPrivacyAccessedAPIType 15 | NSPrivacyAccessedAPICategoryUserDefaults 16 | NSPrivacyAccessedAPITypeReasons 17 | 18 | 1C8F.1 19 | CA92.1 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Aaron Pearce 4 | 5 | Portions of this library were taken and modified from [Cirrus](https://github.com/jayhickey/Cirrus) under the MIT license: 6 | 7 | Copyright (c) 2020 Jay Hickey 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all 17 | copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. 26 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "grdb.swift", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/groue/GRDB.swift", 7 | "state" : { 8 | "revision" : "77b85bed259b7f107710a0b78c439b1c5839dc45", 9 | "version" : "6.26.0" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Harmony", 8 | platforms: [.iOS(.v17), .macOS(.v14), .watchOS(.v10), .macCatalyst(.v17)], 9 | products: [ 10 | .library( 11 | name: "Harmony", 12 | targets: ["Harmony"] 13 | ), 14 | ], 15 | dependencies: [ 16 | .package(url: "https://github.com/groue/GRDB.swift", from: "6.26.0"), 17 | ], 18 | targets: [ 19 | .target( 20 | name: "Harmony", 21 | dependencies: [ 22 | .product(name: "GRDB", package: "GRDB.swift") 23 | ], 24 | path: "Harmony", 25 | resources: [.copy("PrivacyInfo.xcprivacy")] 26 | ), 27 | .testTarget( 28 | name: "HarmonyTests", 29 | dependencies: ["Harmony"] 30 | ), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Harmony 2 | 3 | Harmony provides CloudKit sync for GRDB and only GRDB. 4 | 5 | **Requirements:** iOS 17.0+, macOS 14.0+, tvOS 17.0+, watchOS 10.0+. 6 | 7 | I am open to feedback to style, functionality and other contributions. 8 | 9 | ## Usage 10 | 11 | After installing via Swift Package Manager and enabling CloudKit for your app, add the following to your `App.swift` or equivalent to initialize Harmony. 12 | 13 | ``` 14 | @Harmony( 15 | records: [Model1.self, Model2.self], 16 | configuration: Harmony.Configuration( 17 | cloudKitContainerIdentifier: "xxx" \\ Or leave nil for your default container. 18 | ), 19 | migrator: // Your GRDB DatabaseMigrator 20 | ) var harmony 21 | ``` 22 | 23 | You can then access your Harmony instance from anywhere for writing or reading by simply using: 24 | 25 | ``` 26 | @Harmony var harmony 27 | ``` 28 | 29 | Harmony provides access to write methods that will automatically sync to CloudKit and hides direct database writing access for this purpose. If you write via another means to your GRDB database, Harmony will not see those changes. 30 | 31 | Harmony also provides direct access to your `DatabaseReader` if you wish to read directly. I highly suggest seeing if GRDBQuery will fit your needs first. 32 | 33 | ### Foreign Keys 34 | 35 | Harmony does not support usage of foreign keys in GRDB. This is due to CloudKit being a system of eventual consistency. Records can return in sets where they don't have the related data within the same set or out of order. Foreign keys are incompatible with this style of system so we have taken the choice to not support foreign keys after many attempts to do so. 36 | -------------------------------------------------------------------------------- /Tests/HarmonyTests/HarmonyTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Harmony 3 | 4 | final class HarmonyTests: XCTestCase { 5 | func testExample() throws { 6 | // XCTest Documentation 7 | // https://developer.apple.com/documentation/xctest 8 | 9 | // Defining Test Cases and Test Methods 10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods 11 | } 12 | } 13 | --------------------------------------------------------------------------------