Move common CloudKit classes to RSCore
This commit is contained in:
parent
7ccc0e6b17
commit
1c23f02803
|
@ -8,6 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSCore
|
||||
import RSWeb
|
||||
import RSParser
|
||||
import CloudKit
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSCore
|
||||
import RSParser
|
||||
import RSWeb
|
||||
import CloudKit
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSCore
|
||||
import RSParser
|
||||
import RSWeb
|
||||
import CloudKit
|
||||
|
|
|
@ -1,98 +0,0 @@
|
|||
//
|
||||
// CloudKitError.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 3/26/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
// Derived from https://github.com/caiyue1993/IceCream
|
||||
|
||||
import Foundation
|
||||
import CloudKit
|
||||
|
||||
class CloudKitError: LocalizedError {
|
||||
|
||||
let error: Error
|
||||
|
||||
init(_ error: Error) {
|
||||
self.error = error
|
||||
}
|
||||
|
||||
public var errorDescription: String? {
|
||||
guard let ckError = error as? CKError else {
|
||||
return error.localizedDescription
|
||||
}
|
||||
|
||||
switch ckError.code {
|
||||
case .alreadyShared:
|
||||
return NSLocalizedString("Already Shared: a record or share cannot be saved because doing so would cause the same hierarchy of records to exist in multiple shares.", comment: "Known iCloud Error")
|
||||
case .assetFileModified:
|
||||
return NSLocalizedString("Asset File Modified: the content of the specified asset file was modified while being saved.", comment: "Known iCloud Error")
|
||||
case .assetFileNotFound:
|
||||
return NSLocalizedString("Asset File Not Found: the specified asset file is not found.", comment: "Known iCloud Error")
|
||||
case .badContainer:
|
||||
return NSLocalizedString("Bad Container: the specified container is unknown or unauthorized.", comment: "Known iCloud Error")
|
||||
case .badDatabase:
|
||||
return NSLocalizedString("Bad Database: the operation could not be completed on the given database.", comment: "Known iCloud Error")
|
||||
case .batchRequestFailed:
|
||||
return NSLocalizedString("Batch Request Failed: the entire batch was rejected.", comment: "Known iCloud Error")
|
||||
case .changeTokenExpired:
|
||||
return NSLocalizedString("Change Token Expired: the previous server change token is too old.", comment: "Known iCloud Error")
|
||||
case .constraintViolation:
|
||||
return NSLocalizedString("Constraint Violation: the server rejected the request because of a conflict with a unique field.", comment: "Known iCloud Error")
|
||||
case .incompatibleVersion:
|
||||
return NSLocalizedString("Incompatible Version: your app version is older than the oldest version allowed.", comment: "Known iCloud Error")
|
||||
case .internalError:
|
||||
return NSLocalizedString("Internal Error: a nonrecoverable error was encountered by CloudKit.", comment: "Known iCloud Error")
|
||||
case .invalidArguments:
|
||||
return NSLocalizedString("Invalid Arguments: the specified request contains bad information.", comment: "Known iCloud Error")
|
||||
case .limitExceeded:
|
||||
return NSLocalizedString("Limit Exceeded: the request to the server is too large.", comment: "Known iCloud Error")
|
||||
case .managedAccountRestricted:
|
||||
return NSLocalizedString("Managed Account Restricted: the request was rejected due to a managed-account restriction.", comment: "Known iCloud Error")
|
||||
case .missingEntitlement:
|
||||
return NSLocalizedString("Missing Entitlement: the app is missing a required entitlement.", comment: "Known iCloud Error")
|
||||
case .networkUnavailable:
|
||||
return NSLocalizedString("Network Unavailable: the internet connection appears to be offline.", comment: "Known iCloud Error")
|
||||
case .networkFailure:
|
||||
return NSLocalizedString("Network Failure: the internet connection appears to be offline.", comment: "Known iCloud Error")
|
||||
case .notAuthenticated:
|
||||
return NSLocalizedString("Not Authenticated: to use the iCloud account, you must enable iCloud syncing. Go to device Settings, sign in to iCloud, then in the app settings, be sure the iCloud feature is enabled.", comment: "Known iCloud Error")
|
||||
case .operationCancelled:
|
||||
return NSLocalizedString("Operation Cancelled: the operation was explicitly canceled.", comment: "Known iCloud Error")
|
||||
case .partialFailure:
|
||||
return NSLocalizedString("Partial Failure: some items failed, but the operation succeeded overall.", comment: "Known iCloud Error")
|
||||
case .participantMayNeedVerification:
|
||||
return NSLocalizedString("Participant May Need Verification: you are not a member of the share.", comment: "Known iCloud Error")
|
||||
case .permissionFailure:
|
||||
return NSLocalizedString("Permission Failure: to use this app, you must enable iCloud syncing. Go to device Settings, sign in to iCloud, then in the app settings, be sure the iCloud feature is enabled.", comment: "Known iCloud Error")
|
||||
case .quotaExceeded:
|
||||
return NSLocalizedString("Quota Exceeded: saving would exceed your current iCloud storage quota.", comment: "Known iCloud Error")
|
||||
case .referenceViolation:
|
||||
return NSLocalizedString("Reference Violation: the target of a record's parent or share reference was not found.", comment: "Known iCloud Error")
|
||||
case .requestRateLimited:
|
||||
return NSLocalizedString("Request Rate Limited: transfers to and from the server are being rate limited at this time.", comment: "Known iCloud Error")
|
||||
case .serverRecordChanged:
|
||||
return NSLocalizedString("Server Record Changed: the record was rejected because the version on the server is different.", comment: "Known iCloud Error")
|
||||
case .serverRejectedRequest:
|
||||
return NSLocalizedString("Server Rejected Request", comment: "Known iCloud Error")
|
||||
case .serverResponseLost:
|
||||
return NSLocalizedString("Server Response Lost", comment: "Known iCloud Error")
|
||||
case .serviceUnavailable:
|
||||
return NSLocalizedString("Service Unavailable: Please try again.", comment: "Known iCloud Error")
|
||||
case .tooManyParticipants:
|
||||
return NSLocalizedString("Too Many Participants: a share cannot be saved because too many participants are attached to the share.", comment: "Known iCloud Error")
|
||||
case .unknownItem:
|
||||
return NSLocalizedString("Unknown Item: the specified record does not exist.", comment: "Known iCloud Error")
|
||||
case .userDeletedZone:
|
||||
return NSLocalizedString("User Deleted Zone: the user has deleted this zone from the settings UI.", comment: "Known iCloud Error")
|
||||
case .zoneBusy:
|
||||
return NSLocalizedString("Zone Busy: the server is too busy to handle the zone operation.", comment: "Known iCloud Error")
|
||||
case .zoneNotFound:
|
||||
return NSLocalizedString("Zone Not Found: the specified record zone does not exist on the server.", comment: "Known iCloud Error")
|
||||
default:
|
||||
return NSLocalizedString("Unhandled Error.", comment: "Unknown iCloud Error")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,687 +0,0 @@
|
|||
//
|
||||
// CloudKitZone.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 3/21/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import CloudKit
|
||||
import os.log
|
||||
import RSWeb
|
||||
|
||||
enum CloudKitZoneError: LocalizedError {
|
||||
case userDeletedZone
|
||||
case invalidParameter
|
||||
case unknown
|
||||
|
||||
var errorDescription: String? {
|
||||
if case .userDeletedZone = self {
|
||||
return NSLocalizedString("The iCloud data was deleted. Please delete the NetNewsWire iCloud account and add it again to continue using NetNewsWire's iCloud support.", comment: "User deleted zone.")
|
||||
} else {
|
||||
return NSLocalizedString("An unexpected CloudKit error occurred.", comment: "An unexpected CloudKit error occurred.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol CloudKitZoneDelegate: class {
|
||||
func cloudKitDidModify(changed: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result<Void, Error>) -> Void);
|
||||
}
|
||||
|
||||
typealias CloudKitRecordKey = (recordType: CKRecord.RecordType, recordID: CKRecord.ID)
|
||||
|
||||
protocol CloudKitZone: class {
|
||||
|
||||
static var zoneID: CKRecordZone.ID { get }
|
||||
static var qualityOfService: QualityOfService { get }
|
||||
|
||||
var log: OSLog { get }
|
||||
|
||||
var container: CKContainer? { get }
|
||||
var database: CKDatabase? { get }
|
||||
var delegate: CloudKitZoneDelegate? { get set }
|
||||
|
||||
/// Reset the change token used to determine what point in time we are doing changes fetches
|
||||
func resetChangeToken()
|
||||
|
||||
/// Generates a new CKRecord.ID using a UUID for the record's name
|
||||
func generateRecordID() -> CKRecord.ID
|
||||
|
||||
/// Subscribe to changes at a zone level
|
||||
func subscribeToZoneChanges()
|
||||
|
||||
/// Process a remove notification
|
||||
func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void)
|
||||
|
||||
}
|
||||
|
||||
extension CloudKitZone {
|
||||
|
||||
// My observation has been that QoS is treated differently for CloudKit operations on macOS vs iOS.
|
||||
// .userInitiated is too aggressive on iOS and can lead the UI slowing down and appearing to block.
|
||||
// .default (or lower) on macOS will sometimes hang for extended periods of time and appear to hang.
|
||||
static var qualityOfService: QualityOfService {
|
||||
#if os(macOS)
|
||||
return .userInitiated
|
||||
#else
|
||||
return .default
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Reset the change token used to determine what point in time we are doing changes fetches
|
||||
func resetChangeToken() {
|
||||
changeToken = nil
|
||||
}
|
||||
|
||||
func generateRecordID() -> CKRecord.ID {
|
||||
return CKRecord.ID(recordName: UUID().uuidString, zoneID: Self.zoneID)
|
||||
}
|
||||
|
||||
func retryIfPossible(after: Double, block: @escaping () -> ()) {
|
||||
let delayTime = DispatchTime.now() + after
|
||||
DispatchQueue.main.asyncAfter(deadline: delayTime, execute: {
|
||||
block()
|
||||
})
|
||||
}
|
||||
|
||||
func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) {
|
||||
let note = CKRecordZoneNotification(fromRemoteNotificationDictionary: userInfo)
|
||||
guard note?.recordZoneID?.zoneName == Self.zoneID.zoneName else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
|
||||
fetchChangesInZone() { result in
|
||||
if case .failure(let error) = result {
|
||||
os_log(.error, log: self.log, "%@ zone remote notification fetch error: %@", Self.zoneID.zoneName, error.localizedDescription)
|
||||
}
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the zone record
|
||||
func createZoneRecord(completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let database = database else {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
return
|
||||
}
|
||||
|
||||
database.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in
|
||||
if let error = error {
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitError(error)))
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribes to zone changes
|
||||
func subscribeToZoneChanges() {
|
||||
let subscription = CKRecordZoneSubscription(zoneID: Self.zoneID)
|
||||
|
||||
let info = CKSubscription.NotificationInfo()
|
||||
info.shouldSendContentAvailable = true
|
||||
subscription.notificationInfo = info
|
||||
|
||||
save(subscription) { result in
|
||||
if case .failure(let error) = result {
|
||||
os_log(.error, log: self.log, "%@ zone subscribe to changes error: %@", Self.zoneID.zoneName, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue a CKQuery and return the resulting CKRecords.s
|
||||
func query(_ query: CKQuery, completion: @escaping (Result<[CKRecord], Error>) -> Void) {
|
||||
guard let database = database else {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
return
|
||||
}
|
||||
|
||||
database.perform(query, inZoneWith: Self.zoneID) { [weak self] records, error in
|
||||
guard let self = self else {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
return
|
||||
}
|
||||
|
||||
switch CloudKitZoneResult.resolve(error) {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
if let records = records {
|
||||
completion(.success(records))
|
||||
} else {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
}
|
||||
}
|
||||
case .zoneNotFound:
|
||||
self.createZoneRecord() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.query(query, completion: completion)
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
case .retry(let timeToWait):
|
||||
os_log(.error, log: self.log, "%@ zone query retry in %f seconds.", Self.zoneID.zoneName, timeToWait)
|
||||
self.retryIfPossible(after: timeToWait) {
|
||||
self.query(query, completion: completion)
|
||||
}
|
||||
case .userDeletedZone:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitZoneError.userDeletedZone))
|
||||
}
|
||||
default:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitError(error!)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch a CKRecord by using its externalID
|
||||
func fetch(externalID: String?, completion: @escaping (Result<CKRecord, Error>) -> Void) {
|
||||
guard let externalID = externalID else {
|
||||
completion(.failure(CloudKitZoneError.invalidParameter))
|
||||
return
|
||||
}
|
||||
|
||||
let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID)
|
||||
|
||||
database?.fetch(withRecordID: recordID) { [weak self] record, error in
|
||||
guard let self = self else {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
return
|
||||
}
|
||||
|
||||
switch CloudKitZoneResult.resolve(error) {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
if let record = record {
|
||||
completion(.success(record))
|
||||
} else {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
}
|
||||
}
|
||||
case .zoneNotFound:
|
||||
self.createZoneRecord() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.fetch(externalID: externalID, completion: completion)
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
case .retry(let timeToWait):
|
||||
os_log(.error, log: self.log, "%@ zone fetch retry in %f seconds.", Self.zoneID.zoneName, timeToWait)
|
||||
self.retryIfPossible(after: timeToWait) {
|
||||
self.fetch(externalID: externalID, completion: completion)
|
||||
}
|
||||
case .userDeletedZone:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitZoneError.userDeletedZone))
|
||||
}
|
||||
default:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitError(error!)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the CKRecord
|
||||
func save(_ record: CKRecord, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion)
|
||||
}
|
||||
|
||||
/// Save the CKRecords
|
||||
func save(_ records: [CKRecord], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
modify(recordsToSave: records, recordIDsToDelete: [], completion: completion)
|
||||
}
|
||||
|
||||
/// Saves or modifies the records as long as they are unchanged relative to the local version
|
||||
func saveIfNew(_ records: [CKRecord], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
let op = CKModifyRecordsOperation(recordsToSave: records, recordIDsToDelete: [CKRecord.ID]())
|
||||
op.savePolicy = .ifServerRecordUnchanged
|
||||
op.isAtomic = false
|
||||
op.qualityOfService = Self.qualityOfService
|
||||
|
||||
op.modifyRecordsCompletionBlock = { [weak self] (_, _, error) in
|
||||
|
||||
guard let self = self else { return }
|
||||
|
||||
switch CloudKitZoneResult.resolve(error) {
|
||||
case .success, .partialFailure:
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
case .zoneNotFound:
|
||||
self.createZoneRecord() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.saveIfNew(records, completion: completion)
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .userDeletedZone:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitZoneError.userDeletedZone))
|
||||
}
|
||||
|
||||
case .retry(let timeToWait):
|
||||
self.retryIfPossible(after: timeToWait) {
|
||||
self.saveIfNew(records, completion: completion)
|
||||
}
|
||||
|
||||
case .limitExceeded:
|
||||
|
||||
let chunkedRecords = records.chunked(into: 300)
|
||||
|
||||
let group = DispatchGroup()
|
||||
var errorOccurred = false
|
||||
|
||||
for chunk in chunkedRecords {
|
||||
group.enter()
|
||||
self.saveIfNew(chunk) { result in
|
||||
if case .failure(let error) = result {
|
||||
os_log(.error, log: self.log, "%@ zone modify records error: %@", Self.zoneID.zoneName, error.localizedDescription)
|
||||
errorOccurred = true
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
if errorOccurred {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
} else {
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitError(error!)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
database?.add(op)
|
||||
}
|
||||
|
||||
/// Save the CKSubscription
|
||||
func save(_ subscription: CKSubscription, completion: @escaping (Result<CKSubscription, Error>) -> Void) {
|
||||
database?.save(subscription) { [weak self] savedSubscription, error in
|
||||
guard let self = self else {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
return
|
||||
}
|
||||
|
||||
switch CloudKitZoneResult.resolve(error) {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
completion(.success((savedSubscription!)))
|
||||
}
|
||||
case .zoneNotFound:
|
||||
self.createZoneRecord() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.save(subscription, completion: completion)
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
case .retry(let timeToWait):
|
||||
os_log(.error, log: self.log, "%@ zone save subscription retry in %f seconds.", Self.zoneID.zoneName, timeToWait)
|
||||
self.retryIfPossible(after: timeToWait) {
|
||||
self.save(subscription, completion: completion)
|
||||
}
|
||||
default:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitError(error!)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete CKRecords using a CKQuery
|
||||
func delete(ckQuery: CKQuery, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
var records = [CKRecord]()
|
||||
|
||||
let op = CKQueryOperation(query: ckQuery)
|
||||
op.qualityOfService = Self.qualityOfService
|
||||
op.recordFetchedBlock = { record in
|
||||
records.append(record)
|
||||
}
|
||||
|
||||
op.queryCompletionBlock = { [weak self] (cursor, error) in
|
||||
guard let self = self else {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if let cursor = cursor {
|
||||
self.delete(cursor: cursor, carriedRecords: records, completion: completion)
|
||||
} else {
|
||||
guard !records.isEmpty else {
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let recordIDs = records.map { $0.recordID }
|
||||
self.modify(recordsToSave: [], recordIDsToDelete: recordIDs, completion: completion)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
database?.add(op)
|
||||
}
|
||||
|
||||
/// Delete CKRecords using a CKQuery
|
||||
func delete(cursor: CKQueryOperation.Cursor, carriedRecords: [CKRecord], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
var records = [CKRecord]()
|
||||
|
||||
let op = CKQueryOperation(cursor: cursor)
|
||||
op.qualityOfService = Self.qualityOfService
|
||||
op.recordFetchedBlock = { record in
|
||||
records.append(record)
|
||||
}
|
||||
|
||||
op.queryCompletionBlock = { [weak self] (cursor, error) in
|
||||
guard let self = self else {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
return
|
||||
}
|
||||
|
||||
records.append(contentsOf: carriedRecords)
|
||||
|
||||
if let cursor = cursor {
|
||||
self.delete(cursor: cursor, carriedRecords: records, completion: completion)
|
||||
} else {
|
||||
let recordIDs = records.map { $0.recordID }
|
||||
self.modify(recordsToSave: [], recordIDsToDelete: recordIDs, completion: completion)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
database?.add(op)
|
||||
}
|
||||
|
||||
/// Delete a CKRecord using its recordID
|
||||
func delete(recordID: CKRecord.ID, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion)
|
||||
}
|
||||
|
||||
/// Delete CKRecords
|
||||
func delete(recordIDs: [CKRecord.ID], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
modify(recordsToSave: [], recordIDsToDelete: recordIDs, completion: completion)
|
||||
}
|
||||
|
||||
/// Delete a CKRecord using its externalID
|
||||
func delete(externalID: String?, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let externalID = externalID else {
|
||||
completion(.failure(CloudKitZoneError.invalidParameter))
|
||||
return
|
||||
}
|
||||
|
||||
let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID)
|
||||
modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion)
|
||||
}
|
||||
|
||||
/// Delete a CKSubscription
|
||||
func delete(subscriptionID: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
database?.delete(withSubscriptionID: subscriptionID) { [weak self] _, error in
|
||||
guard let self = self else {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
return
|
||||
}
|
||||
|
||||
switch CloudKitZoneResult.resolve(error) {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
}
|
||||
case .retry(let timeToWait):
|
||||
os_log(.error, log: self.log, "%@ zone delete subscription retry in %f seconds.", Self.zoneID.zoneName, timeToWait)
|
||||
self.retryIfPossible(after: timeToWait) {
|
||||
self.delete(subscriptionID: subscriptionID, completion: completion)
|
||||
}
|
||||
default:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitError(error!)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Modify and delete the supplied CKRecords and CKRecord.IDs
|
||||
func modify(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard !(recordsToSave.isEmpty && recordIDsToDelete.isEmpty) else {
|
||||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
let op = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete)
|
||||
op.savePolicy = .changedKeys
|
||||
op.isAtomic = true
|
||||
op.qualityOfService = Self.qualityOfService
|
||||
|
||||
op.modifyRecordsCompletionBlock = { [weak self] (_, _, error) in
|
||||
|
||||
guard let self = self else {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
return
|
||||
}
|
||||
|
||||
switch CloudKitZoneResult.resolve(error) {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
}
|
||||
case .zoneNotFound:
|
||||
self.createZoneRecord() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.modify(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, completion: completion)
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
case .userDeletedZone:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitZoneError.userDeletedZone))
|
||||
}
|
||||
case .retry(let timeToWait):
|
||||
os_log(.error, log: self.log, "%@ zone modify retry in %f seconds.", Self.zoneID.zoneName, timeToWait)
|
||||
self.retryIfPossible(after: timeToWait) {
|
||||
self.modify(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, completion: completion)
|
||||
}
|
||||
case .limitExceeded:
|
||||
let recordToSaveChunks = recordsToSave.chunked(into: 300)
|
||||
let recordIDsToDeleteChunks = recordIDsToDelete.chunked(into: 300)
|
||||
|
||||
let group = DispatchGroup()
|
||||
var errorOccurred = false
|
||||
|
||||
for chunk in recordToSaveChunks {
|
||||
group.enter()
|
||||
self.modify(recordsToSave: chunk, recordIDsToDelete: []) { result in
|
||||
if case .failure(let error) = result {
|
||||
os_log(.error, log: self.log, "%@ zone modify records error: %@", Self.zoneID.zoneName, error.localizedDescription)
|
||||
errorOccurred = true
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
for chunk in recordIDsToDeleteChunks {
|
||||
group.enter()
|
||||
self.modify(recordsToSave: [], recordIDsToDelete: chunk) { result in
|
||||
if case .failure(let error) = result {
|
||||
os_log(.error, log: self.log, "%@ zone modify records error: %@", Self.zoneID.zoneName, error.localizedDescription)
|
||||
errorOccurred = true
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.global(qos: .background)) {
|
||||
if errorOccurred {
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitError(error!)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
database?.add(op)
|
||||
}
|
||||
|
||||
/// Fetch all the changes in the CKZone since the last time we checked
|
||||
func fetchChangesInZone(completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
var savedChangeToken = changeToken
|
||||
|
||||
var changedRecords = [CKRecord]()
|
||||
var deletedRecordKeys = [CloudKitRecordKey]()
|
||||
|
||||
let zoneConfig = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
|
||||
zoneConfig.previousServerChangeToken = changeToken
|
||||
let op = CKFetchRecordZoneChangesOperation(recordZoneIDs: [Self.zoneID], configurationsByRecordZoneID: [Self.zoneID: zoneConfig])
|
||||
op.fetchAllChanges = true
|
||||
op.qualityOfService = Self.qualityOfService
|
||||
|
||||
op.recordZoneChangeTokensUpdatedBlock = { zoneID, token, _ in
|
||||
savedChangeToken = token
|
||||
}
|
||||
|
||||
op.recordChangedBlock = { record in
|
||||
changedRecords.append(record)
|
||||
}
|
||||
|
||||
op.recordWithIDWasDeletedBlock = { recordID, recordType in
|
||||
let recordKey = CloudKitRecordKey(recordType: recordType, recordID: recordID)
|
||||
deletedRecordKeys.append(recordKey)
|
||||
}
|
||||
|
||||
op.recordZoneFetchCompletionBlock = { zoneID ,token, _, _, error in
|
||||
if case .success = CloudKitZoneResult.resolve(error) {
|
||||
savedChangeToken = token
|
||||
}
|
||||
}
|
||||
|
||||
op.fetchRecordZoneChangesCompletionBlock = { [weak self] error in
|
||||
guard let self = self else {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
return
|
||||
}
|
||||
|
||||
switch CloudKitZoneResult.resolve(error) {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.cloudKitDidModify(changed: changedRecords, deleted: deletedRecordKeys) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.changeToken = savedChangeToken
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
case .zoneNotFound:
|
||||
self.createZoneRecord() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.fetchChangesInZone(completion: completion)
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
case .userDeletedZone:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitZoneError.userDeletedZone))
|
||||
}
|
||||
case .retry(let timeToWait):
|
||||
os_log(.error, log: self.log, "%@ zone fetch changes retry in %f seconds.", Self.zoneID.zoneName, timeToWait)
|
||||
self.retryIfPossible(after: timeToWait) {
|
||||
self.fetchChangesInZone(completion: completion)
|
||||
}
|
||||
case .changeTokenExpired:
|
||||
DispatchQueue.main.async {
|
||||
self.changeToken = nil
|
||||
self.fetchChangesInZone(completion: completion)
|
||||
}
|
||||
default:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitError(error!)))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
database?.add(op)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension CloudKitZone {
|
||||
|
||||
var changeTokenKey: String {
|
||||
return "cloudkit.server.token.\(Self.zoneID.zoneName)"
|
||||
}
|
||||
|
||||
var changeToken: CKServerChangeToken? {
|
||||
get {
|
||||
guard let tokenData = UserDefaults.standard.object(forKey: changeTokenKey) as? Data else { return nil }
|
||||
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: tokenData)
|
||||
}
|
||||
set {
|
||||
guard let token = newValue, let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: false) else {
|
||||
UserDefaults.standard.removeObject(forKey: changeTokenKey)
|
||||
return
|
||||
}
|
||||
UserDefaults.standard.set(data, forKey: changeTokenKey)
|
||||
}
|
||||
}
|
||||
|
||||
var zoneConfiguration: CKFetchRecordZoneChangesOperation.ZoneConfiguration {
|
||||
let config = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
|
||||
config.previousServerChangeToken = changeToken
|
||||
return config
|
||||
}
|
||||
|
||||
}
|
|
@ -1,81 +0,0 @@
|
|||
//
|
||||
// CloudKitResult.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 3/26/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CloudKit
|
||||
|
||||
enum CloudKitZoneResult {
|
||||
case success
|
||||
case retry(afterSeconds: Double)
|
||||
case limitExceeded
|
||||
case changeTokenExpired
|
||||
case partialFailure(errors: [AnyHashable: CKError])
|
||||
case serverRecordChanged
|
||||
case zoneNotFound
|
||||
case userDeletedZone
|
||||
case failure(error: Error)
|
||||
|
||||
static func resolve(_ error: Error?) -> CloudKitZoneResult {
|
||||
|
||||
guard error != nil else { return .success }
|
||||
|
||||
guard let ckError = error as? CKError else {
|
||||
return .failure(error: error!)
|
||||
}
|
||||
|
||||
switch ckError.code {
|
||||
case .serviceUnavailable, .requestRateLimited, .zoneBusy:
|
||||
if let retry = ckError.userInfo[CKErrorRetryAfterKey] as? NSNumber {
|
||||
return .retry(afterSeconds: retry.doubleValue)
|
||||
} else {
|
||||
return .failure(error: CloudKitError(ckError))
|
||||
}
|
||||
case .zoneNotFound:
|
||||
return .zoneNotFound
|
||||
case .userDeletedZone:
|
||||
return .userDeletedZone
|
||||
case .changeTokenExpired:
|
||||
return .changeTokenExpired
|
||||
case .serverRecordChanged:
|
||||
return .serverRecordChanged
|
||||
case .partialFailure:
|
||||
if let partialErrors = ckError.userInfo[CKPartialErrorsByItemIDKey] as? [AnyHashable: CKError] {
|
||||
if let zoneResult = anyRequestErrors(partialErrors) {
|
||||
return zoneResult
|
||||
} else {
|
||||
return .partialFailure(errors: partialErrors)
|
||||
}
|
||||
} else {
|
||||
return .failure(error: CloudKitError(ckError))
|
||||
}
|
||||
case .limitExceeded:
|
||||
return .limitExceeded
|
||||
default:
|
||||
return .failure(error: CloudKitError(ckError))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension CloudKitZoneResult {
|
||||
|
||||
static func anyRequestErrors(_ errors: [AnyHashable: CKError]) -> CloudKitZoneResult? {
|
||||
if errors.values.contains(where: { $0.code == .changeTokenExpired } ) {
|
||||
return .changeTokenExpired
|
||||
}
|
||||
if errors.values.contains(where: { $0.code == .zoneNotFound } ) {
|
||||
return .zoneNotFound
|
||||
}
|
||||
if errors.values.contains(where: { $0.code == .userDeletedZone } ) {
|
||||
return .userDeletedZone
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
|
@ -51,8 +51,8 @@
|
|||
"repositoryURL": "https://github.com/Ranchero-Software/RSCore.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "1f72115989c05ca1e79fe694f25a17b9b731d0df",
|
||||
"version": "1.0.0-beta3"
|
||||
"revision": "3aa7710dda2b540834c05b9bb544a93e1166e25e",
|
||||
"version": "1.0.0-beta5"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
Loading…
Reference in New Issue