From 9d792228506653db7c9d1a114c2d5f83a605f7f9 Mon Sep 17 00:00:00 2001 From: Radmir Date: Fri, 20 Feb 2026 16:39:24 +0500 Subject: [PATCH 1/4] Add CloudStore module for iCloud sync --- .../CloudStore/Protocols/CloudSyncable.swift | 18 ++++ .../Protocols/DataTransformable.swift | 8 ++ .../Services/CloudSyncService.swift | 91 +++++++++++++++++++ .../CloudStore/Types/CloudSyncError.swift | 8 ++ .../Types/EncryptedTransformer.swift | 23 +++++ .../CloudStore/Types/PlainTransformer.swift | 10 ++ Packages/Store/Package.resolved | 15 --- Packages/Store/Package.swift | 12 ++- .../EncryptedTransformerTests.swift | 30 ++++++ 9 files changed, 199 insertions(+), 16 deletions(-) create mode 100644 Packages/Store/CloudStore/Protocols/CloudSyncable.swift create mode 100644 Packages/Store/CloudStore/Protocols/DataTransformable.swift create mode 100644 Packages/Store/CloudStore/Services/CloudSyncService.swift create mode 100644 Packages/Store/CloudStore/Types/CloudSyncError.swift create mode 100644 Packages/Store/CloudStore/Types/EncryptedTransformer.swift create mode 100644 Packages/Store/CloudStore/Types/PlainTransformer.swift delete mode 100644 Packages/Store/Package.resolved create mode 100644 Packages/Store/Tests/StoreTests/EncryptedTransformerTests.swift diff --git a/Packages/Store/CloudStore/Protocols/CloudSyncable.swift b/Packages/Store/CloudStore/Protocols/CloudSyncable.swift new file mode 100644 index 000000000..57bd662fa --- /dev/null +++ b/Packages/Store/CloudStore/Protocols/CloudSyncable.swift @@ -0,0 +1,18 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import CloudKit + +public protocol CloudSyncable: Identifiable, Codable, Sendable where ID == String { + static var recordType: String { get } +} + +extension CloudSyncable { + public static var recordType: String { + String(describing: Self.self) + } + + var recordID: CKRecord.ID { + CKRecord.ID(recordName: id) + } +} diff --git a/Packages/Store/CloudStore/Protocols/DataTransformable.swift b/Packages/Store/CloudStore/Protocols/DataTransformable.swift new file mode 100644 index 000000000..d7833f1e5 --- /dev/null +++ b/Packages/Store/CloudStore/Protocols/DataTransformable.swift @@ -0,0 +1,8 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public protocol DataTransformable: Sendable { + func transform(_ data: Data) throws -> Data + func restore(_ data: Data) throws -> Data +} diff --git a/Packages/Store/CloudStore/Services/CloudSyncService.swift b/Packages/Store/CloudStore/Services/CloudSyncService.swift new file mode 100644 index 000000000..d654abca7 --- /dev/null +++ b/Packages/Store/CloudStore/Services/CloudSyncService.swift @@ -0,0 +1,91 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import CloudKit + +public actor CloudSyncService { + private let container: CKContainer + private let database: CKDatabase + private let transformer: DataTransformable + + public init( + containerIdentifier: String, + transformer: DataTransformable + ) { + self.container = CKContainer(identifier: containerIdentifier) + self.database = container.privateCloudDatabase + self.transformer = transformer + } + + // MARK: - Account Status + + public func checkAccountStatus() async throws -> CKAccountStatus { + try await container.accountStatus() + } + + public func isAvailable() async -> Bool { + (try? await checkAccountStatus()) == .available + } + + // MARK: - Save + + public func save(_ item: T) async throws { + try await database.save(try createRecord(for: item)) + } + + public func save(_ items: [T]) async throws { + let records = try items.map { try createRecord(for: $0) } + _ = try await database.modifyRecords(saving: records, deleting: []) + } + + // MARK: - Fetch + + public func fetch(_ type: T.Type) async throws -> [T] { + let query = CKQuery(recordType: T.recordType, predicate: NSPredicate(value: true)) + let (results, _) = try await database.records(matching: query) + + return try results.compactMap { _, result in + switch result { + case .success(let record): try decodeRecord(record, as: type) + case .failure: nil + } + } + } + + // MARK: - Delete + + public func delete(_ item: T) async throws { + try await database.deleteRecord(withID: item.recordID) + } + + public func delete(_ items: [T]) async throws { + _ = try await database.modifyRecords(saving: [], deleting: items.map { $0.recordID }) + } + + public func deleteAll(_ type: T.Type) async throws { + try await delete(try await fetch(type)) + } + + // MARK: - Private + + private func createRecord(for item: T) throws -> CKRecord { + let data = try JSONEncoder().encode(item) + let record = CKRecord(recordType: T.recordType, recordID: item.recordID) + record.payload = try transformer.transform(data) + return record + } + + private func decodeRecord(_ record: CKRecord, as type: T.Type) throws -> T { + guard let data = record.payload else { + throw CloudSyncError.invalidRecordData + } + return try JSONDecoder().decode(type, from: try transformer.restore(data)) + } +} + +private extension CKRecord { + var payload: Data? { + get { self["data"] as? Data } + set { self["data"] = newValue } + } +} diff --git a/Packages/Store/CloudStore/Types/CloudSyncError.swift b/Packages/Store/CloudStore/Types/CloudSyncError.swift new file mode 100644 index 000000000..ec21312dd --- /dev/null +++ b/Packages/Store/CloudStore/Types/CloudSyncError.swift @@ -0,0 +1,8 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public enum CloudSyncError: Error, Sendable { + case invalidRecordData + case encryptionFailed +} diff --git a/Packages/Store/CloudStore/Types/EncryptedTransformer.swift b/Packages/Store/CloudStore/Types/EncryptedTransformer.swift new file mode 100644 index 000000000..8bf9ab3a7 --- /dev/null +++ b/Packages/Store/CloudStore/Types/EncryptedTransformer.swift @@ -0,0 +1,23 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import CryptoKit + +public struct EncryptedTransformer: DataTransformable { + private let key: SymmetricKey + + public init(key: SymmetricKey) { + self.key = key + } + + public func transform(_ data: Data) throws -> Data { + guard let combined = try AES.GCM.seal(data, using: key).combined else { + throw CloudSyncError.encryptionFailed + } + return combined + } + + public func restore(_ data: Data) throws -> Data { + try AES.GCM.open(try AES.GCM.SealedBox(combined: data), using: key) + } +} diff --git a/Packages/Store/CloudStore/Types/PlainTransformer.swift b/Packages/Store/CloudStore/Types/PlainTransformer.swift new file mode 100644 index 000000000..787027236 --- /dev/null +++ b/Packages/Store/CloudStore/Types/PlainTransformer.swift @@ -0,0 +1,10 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public struct PlainTransformer: DataTransformable { + public init() {} + + public func transform(_ data: Data) throws -> Data { data } + public func restore(_ data: Data) throws -> Data { data } +} diff --git a/Packages/Store/Package.resolved b/Packages/Store/Package.resolved deleted file mode 100644 index 3851ee1d8..000000000 --- a/Packages/Store/Package.resolved +++ /dev/null @@ -1,15 +0,0 @@ -{ - "originHash" : "967d39c8b3922e71eebcea9724f14480569febbaed95f2bf271eaf332523426c", - "pins" : [ - { - "identity" : "grdb.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/groue/GRDB.swift.git", - "state" : { - "revision" : "aa0079aeb82a4bf00324561a40bffe68c6fe1c26", - "version" : "7.9.0" - } - } - ], - "version" : 3 -} diff --git a/Packages/Store/Package.swift b/Packages/Store/Package.swift index 64947c9f5..e9e170ccc 100644 --- a/Packages/Store/Package.swift +++ b/Packages/Store/Package.swift @@ -12,7 +12,12 @@ let package = Package( ), .library( name: "StoreTestKit", - targets: ["StoreTestKit"]), + targets: ["StoreTestKit"] + ), + .library( + name: "CloudStore", + targets: ["CloudStore"] + ), ], dependencies: [ .package(name: "Primitives", path: "../Primitives"), @@ -42,8 +47,13 @@ let package = Package( dependencies: [ "Store", "StoreTestKit", + "CloudStore", .product(name: "PrimitivesTestKit", package: "Primitives"), ] ), + .target( + name: "CloudStore", + path: "CloudStore" + ), ] ) diff --git a/Packages/Store/Tests/StoreTests/EncryptedTransformerTests.swift b/Packages/Store/Tests/StoreTests/EncryptedTransformerTests.swift new file mode 100644 index 000000000..ecc1f1be7 --- /dev/null +++ b/Packages/Store/Tests/StoreTests/EncryptedTransformerTests.swift @@ -0,0 +1,30 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Testing +import Foundation +import CryptoKit +@testable import CloudStore + +struct EncryptedTransformerTests { + + @Test + func encryptDecrypt() throws { + let transformer = EncryptedTransformer(key: SymmetricKey(size: .bits256)) + let original = Data("secret data".utf8) + + let encrypted = try transformer.transform(original) + let decrypted = try transformer.restore(encrypted) + + #expect(decrypted == original) + #expect(encrypted != original) + } + + @Test + func wrongKeyFails() throws { + let encrypted = try EncryptedTransformer(key: SymmetricKey(size: .bits256)).transform(Data("secret".utf8)) + + #expect(throws: CryptoKitError.self) { + try EncryptedTransformer(key: SymmetricKey(size: .bits256)).restore(encrypted) + } + } +} From 1770e10c2dfadf75abcb215ff354d9de36415216 Mon Sep 17 00:00:00 2001 From: Radmir Date: Fri, 20 Feb 2026 17:01:22 +0500 Subject: [PATCH 2/4] Add pagination support and optimize deleteAll --- .../Services/CloudSyncService.swift | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/Packages/Store/CloudStore/Services/CloudSyncService.swift b/Packages/Store/CloudStore/Services/CloudSyncService.swift index d654abca7..4239f2159 100644 --- a/Packages/Store/CloudStore/Services/CloudSyncService.swift +++ b/Packages/Store/CloudStore/Services/CloudSyncService.swift @@ -41,10 +41,7 @@ public actor CloudSyncService { // MARK: - Fetch public func fetch(_ type: T.Type) async throws -> [T] { - let query = CKQuery(recordType: T.recordType, predicate: NSPredicate(value: true)) - let (results, _) = try await database.records(matching: query) - - return try results.compactMap { _, result in + try await fetchAllRecords(recordType: T.recordType).compactMap { _, result in switch result { case .success(let record): try decodeRecord(record, as: type) case .failure: nil @@ -63,7 +60,9 @@ public actor CloudSyncService { } public func deleteAll(_ type: T.Type) async throws { - try await delete(try await fetch(type)) + let ids = try await fetchAllRecords(recordType: T.recordType, desiredKeys: []).map { $0.0 } + guard !ids.isEmpty else { return } + _ = try await database.modifyRecords(saving: [], deleting: ids) } // MARK: - Private @@ -81,6 +80,22 @@ public actor CloudSyncService { } return try JSONDecoder().decode(type, from: try transformer.restore(data)) } + + private func fetchAllRecords( + recordType: String, + desiredKeys: [CKRecord.FieldKey]? = nil + ) async throws -> [(CKRecord.ID, Result)] { + let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true)) + var (allResults, cursor) = try await database.records(matching: query, desiredKeys: desiredKeys) + + while let currentCursor = cursor { + let (nextResults, nextCursor) = try await database.records(continuingMatchFrom: currentCursor, desiredKeys: desiredKeys) + allResults.append(contentsOf: nextResults) + cursor = nextCursor + } + + return allResults + } } private extension CKRecord { From e2b6b7f9eafd6ba2b2c56e31dddafdae79772cd6 Mon Sep 17 00:00:00 2001 From: Radmir Date: Mon, 23 Feb 2026 10:27:01 +0500 Subject: [PATCH 3/4] Remove DataTransformable protocol and simplify CloudSyncService - Remove DataTransformable protocol and related implementations - Remove PlainTransformer and EncryptedTransformer types - Remove EncryptedTransformerTests - Simplify CloudSyncService to use JSONEncoder/JSONDecoder directly - Add configurable encoder/decoder parameters with defaults --- .../Protocols/DataTransformable.swift | 8 ----- .../Services/CloudSyncService.swift | 14 +++++---- .../CloudStore/Types/CloudSyncError.swift | 1 - .../Types/EncryptedTransformer.swift | 23 -------------- .../CloudStore/Types/PlainTransformer.swift | 10 ------- .../EncryptedTransformerTests.swift | 30 ------------------- 6 files changed, 8 insertions(+), 78 deletions(-) delete mode 100644 Packages/Store/CloudStore/Protocols/DataTransformable.swift delete mode 100644 Packages/Store/CloudStore/Types/EncryptedTransformer.swift delete mode 100644 Packages/Store/CloudStore/Types/PlainTransformer.swift delete mode 100644 Packages/Store/Tests/StoreTests/EncryptedTransformerTests.swift diff --git a/Packages/Store/CloudStore/Protocols/DataTransformable.swift b/Packages/Store/CloudStore/Protocols/DataTransformable.swift deleted file mode 100644 index d7833f1e5..000000000 --- a/Packages/Store/CloudStore/Protocols/DataTransformable.swift +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation - -public protocol DataTransformable: Sendable { - func transform(_ data: Data) throws -> Data - func restore(_ data: Data) throws -> Data -} diff --git a/Packages/Store/CloudStore/Services/CloudSyncService.swift b/Packages/Store/CloudStore/Services/CloudSyncService.swift index 4239f2159..66cadd644 100644 --- a/Packages/Store/CloudStore/Services/CloudSyncService.swift +++ b/Packages/Store/CloudStore/Services/CloudSyncService.swift @@ -6,15 +6,18 @@ import CloudKit public actor CloudSyncService { private let container: CKContainer private let database: CKDatabase - private let transformer: DataTransformable + private let encoder: JSONEncoder + private let decoder: JSONDecoder public init( containerIdentifier: String, - transformer: DataTransformable + encoder: JSONEncoder = JSONEncoder(), + decoder: JSONDecoder = JSONDecoder() ) { self.container = CKContainer(identifier: containerIdentifier) self.database = container.privateCloudDatabase - self.transformer = transformer + self.encoder = encoder + self.decoder = decoder } // MARK: - Account Status @@ -68,9 +71,8 @@ public actor CloudSyncService { // MARK: - Private private func createRecord(for item: T) throws -> CKRecord { - let data = try JSONEncoder().encode(item) let record = CKRecord(recordType: T.recordType, recordID: item.recordID) - record.payload = try transformer.transform(data) + record.payload = try encoder.encode(item) return record } @@ -78,7 +80,7 @@ public actor CloudSyncService { guard let data = record.payload else { throw CloudSyncError.invalidRecordData } - return try JSONDecoder().decode(type, from: try transformer.restore(data)) + return try decoder.decode(type, from: data) } private func fetchAllRecords( diff --git a/Packages/Store/CloudStore/Types/CloudSyncError.swift b/Packages/Store/CloudStore/Types/CloudSyncError.swift index ec21312dd..9f960fa6e 100644 --- a/Packages/Store/CloudStore/Types/CloudSyncError.swift +++ b/Packages/Store/CloudStore/Types/CloudSyncError.swift @@ -4,5 +4,4 @@ import Foundation public enum CloudSyncError: Error, Sendable { case invalidRecordData - case encryptionFailed } diff --git a/Packages/Store/CloudStore/Types/EncryptedTransformer.swift b/Packages/Store/CloudStore/Types/EncryptedTransformer.swift deleted file mode 100644 index 8bf9ab3a7..000000000 --- a/Packages/Store/CloudStore/Types/EncryptedTransformer.swift +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import CryptoKit - -public struct EncryptedTransformer: DataTransformable { - private let key: SymmetricKey - - public init(key: SymmetricKey) { - self.key = key - } - - public func transform(_ data: Data) throws -> Data { - guard let combined = try AES.GCM.seal(data, using: key).combined else { - throw CloudSyncError.encryptionFailed - } - return combined - } - - public func restore(_ data: Data) throws -> Data { - try AES.GCM.open(try AES.GCM.SealedBox(combined: data), using: key) - } -} diff --git a/Packages/Store/CloudStore/Types/PlainTransformer.swift b/Packages/Store/CloudStore/Types/PlainTransformer.swift deleted file mode 100644 index 787027236..000000000 --- a/Packages/Store/CloudStore/Types/PlainTransformer.swift +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation - -public struct PlainTransformer: DataTransformable { - public init() {} - - public func transform(_ data: Data) throws -> Data { data } - public func restore(_ data: Data) throws -> Data { data } -} diff --git a/Packages/Store/Tests/StoreTests/EncryptedTransformerTests.swift b/Packages/Store/Tests/StoreTests/EncryptedTransformerTests.swift deleted file mode 100644 index ecc1f1be7..000000000 --- a/Packages/Store/Tests/StoreTests/EncryptedTransformerTests.swift +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Testing -import Foundation -import CryptoKit -@testable import CloudStore - -struct EncryptedTransformerTests { - - @Test - func encryptDecrypt() throws { - let transformer = EncryptedTransformer(key: SymmetricKey(size: .bits256)) - let original = Data("secret data".utf8) - - let encrypted = try transformer.transform(original) - let decrypted = try transformer.restore(encrypted) - - #expect(decrypted == original) - #expect(encrypted != original) - } - - @Test - func wrongKeyFails() throws { - let encrypted = try EncryptedTransformer(key: SymmetricKey(size: .bits256)).transform(Data("secret".utf8)) - - #expect(throws: CryptoKitError.self) { - try EncryptedTransformer(key: SymmetricKey(size: .bits256)).restore(encrypted) - } - } -} From cc30c734d0ce8047562c6738730d133e5af0ff9b Mon Sep 17 00:00:00 2001 From: Radmir Date: Mon, 23 Feb 2026 22:43:40 +0500 Subject: [PATCH 4/4] Move CloudStore from Store to SystemServices --- Packages/Store/Package.swift | 9 --------- .../CloudStore/Protocols/CloudSyncable.swift | 0 .../CloudStore/Services/CloudSyncService.swift | 0 .../CloudStore/Types/CloudSyncError.swift | 0 Packages/SystemServices/Package.swift | 8 +++++++- 5 files changed, 7 insertions(+), 10 deletions(-) rename Packages/{Store => SystemServices}/CloudStore/Protocols/CloudSyncable.swift (100%) rename Packages/{Store => SystemServices}/CloudStore/Services/CloudSyncService.swift (100%) rename Packages/{Store => SystemServices}/CloudStore/Types/CloudSyncError.swift (100%) diff --git a/Packages/Store/Package.swift b/Packages/Store/Package.swift index ce3ebaf10..d7ed48525 100644 --- a/Packages/Store/Package.swift +++ b/Packages/Store/Package.swift @@ -14,10 +14,6 @@ let package = Package( name: "StoreTestKit", targets: ["StoreTestKit"] ), - .library( - name: "CloudStore", - targets: ["CloudStore"] - ), ], dependencies: [ .package(name: "Primitives", path: "../Primitives"), @@ -45,13 +41,8 @@ let package = Package( dependencies: [ "Store", "StoreTestKit", - "CloudStore", .product(name: "PrimitivesTestKit", package: "Primitives"), ] ), - .target( - name: "CloudStore", - path: "CloudStore" - ), ] ) diff --git a/Packages/Store/CloudStore/Protocols/CloudSyncable.swift b/Packages/SystemServices/CloudStore/Protocols/CloudSyncable.swift similarity index 100% rename from Packages/Store/CloudStore/Protocols/CloudSyncable.swift rename to Packages/SystemServices/CloudStore/Protocols/CloudSyncable.swift diff --git a/Packages/Store/CloudStore/Services/CloudSyncService.swift b/Packages/SystemServices/CloudStore/Services/CloudSyncService.swift similarity index 100% rename from Packages/Store/CloudStore/Services/CloudSyncService.swift rename to Packages/SystemServices/CloudStore/Services/CloudSyncService.swift diff --git a/Packages/Store/CloudStore/Types/CloudSyncError.swift b/Packages/SystemServices/CloudStore/Types/CloudSyncError.swift similarity index 100% rename from Packages/Store/CloudStore/Types/CloudSyncError.swift rename to Packages/SystemServices/CloudStore/Types/CloudSyncError.swift diff --git a/Packages/SystemServices/Package.swift b/Packages/SystemServices/Package.swift index 450ec974f..2edf84e2f 100644 --- a/Packages/SystemServices/Package.swift +++ b/Packages/SystemServices/Package.swift @@ -9,7 +9,8 @@ let package = Package( .macOS(.v15) ], products: [ - .library(name: "ImageGalleryService", targets: ["ImageGalleryService"]) + .library(name: "ImageGalleryService", targets: ["ImageGalleryService"]), + .library(name: "CloudStore", targets: ["CloudStore"]) ], dependencies: [], targets: [ @@ -18,6 +19,11 @@ let package = Package( dependencies: [], path: "ImageGalleryService", exclude: ["Tests", "TestKit"] + ), + .target( + name: "CloudStore", + dependencies: [], + path: "CloudStore" ) ] )