Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions Packages/Store/CloudStore/Protocols/CloudSyncable.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 8 additions & 0 deletions Packages/Store/CloudStore/Protocols/DataTransformable.swift
Original file line number Diff line number Diff line change
@@ -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
}
91 changes: 91 additions & 0 deletions Packages/Store/CloudStore/Services/CloudSyncService.swift
Original file line number Diff line number Diff line change
@@ -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<T: CloudSyncable>(_ item: T) async throws {
try await database.save(try createRecord(for: item))
}

public func save<T: CloudSyncable>(_ 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<T: CloudSyncable>(_ 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<T: CloudSyncable>(_ item: T) async throws {
try await database.deleteRecord(withID: item.recordID)
}

public func delete<T: CloudSyncable>(_ items: [T]) async throws {
_ = try await database.modifyRecords(saving: [], deleting: items.map { $0.recordID })
}

public func deleteAll<T: CloudSyncable>(_ type: T.Type) async throws {
try await delete(try await fetch(type))
}

// MARK: - Private

private func createRecord<T: CloudSyncable>(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<T: CloudSyncable>(_ 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 }
}
}
8 changes: 8 additions & 0 deletions Packages/Store/CloudStore/Types/CloudSyncError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Copyright (c). Gem Wallet. All rights reserved.

import Foundation

public enum CloudSyncError: Error, Sendable {
case invalidRecordData
case encryptionFailed
}
23 changes: 23 additions & 0 deletions Packages/Store/CloudStore/Types/EncryptedTransformer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright (c). Gem Wallet. All rights reserved.

import Foundation
import CryptoKit
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move to SystemServices


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)
}
}
10 changes: 10 additions & 0 deletions Packages/Store/CloudStore/Types/PlainTransformer.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
15 changes: 0 additions & 15 deletions Packages/Store/Package.resolved

This file was deleted.

12 changes: 11 additions & 1 deletion Packages/Store/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -42,8 +47,13 @@ let package = Package(
dependencies: [
"Store",
"StoreTestKit",
"CloudStore",
.product(name: "PrimitivesTestKit", package: "Primitives"),
]
),
.target(
name: "CloudStore",
path: "CloudStore"
),
]
)
30 changes: 30 additions & 0 deletions Packages/Store/Tests/StoreTests/EncryptedTransformerTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}