diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift b/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift index eac0f70ab..e0a45e0cb 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift @@ -8,6 +8,7 @@ import Foundation import os.log +import RSCore import RSWeb import RSParser import CloudKit diff --git a/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift b/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift index 36922b3a5..cb33b8f62 100644 --- a/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift +++ b/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift @@ -8,6 +8,7 @@ import Foundation import os.log +import RSCore import RSParser import RSWeb import CloudKit diff --git a/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift index 53c587ce6..c3801c1ec 100644 --- a/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift @@ -8,6 +8,7 @@ import Foundation import os.log +import RSCore import RSParser import RSWeb import CloudKit diff --git a/Account/Sources/Account/CloudKit/CloudKitError.swift b/Account/Sources/Account/CloudKit/CloudKitError.swift deleted file mode 100644 index fafcb5b61..000000000 --- a/Account/Sources/Account/CloudKit/CloudKitError.swift +++ /dev/null @@ -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") - } - } - -} diff --git a/Account/Sources/Account/CloudKit/CloudKitZone.swift b/Account/Sources/Account/CloudKit/CloudKitZone.swift deleted file mode 100644 index f70bdb391..000000000 --- a/Account/Sources/Account/CloudKit/CloudKitZone.swift +++ /dev/null @@ -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); -} - -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) { - 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) -> 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) { - modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion) - } - - /// Save the CKRecords - func save(_ records: [CKRecord], completion: @escaping (Result) -> 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) { - 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) -> 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) { - - 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) { - - 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) { - modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion) - } - - /// Delete CKRecords - func delete(recordIDs: [CKRecord.ID], completion: @escaping (Result) -> Void) { - modify(recordsToSave: [], recordIDsToDelete: recordIDs, completion: completion) - } - - /// Delete a CKRecord using its externalID - func delete(externalID: String?, completion: @escaping (Result) -> 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) { - 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) { - 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) { - - 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 - } - -} diff --git a/Account/Sources/Account/CloudKit/CloudKitZoneResult.swift b/Account/Sources/Account/CloudKit/CloudKitZoneResult.swift deleted file mode 100644 index 9d01f33f8..000000000 --- a/Account/Sources/Account/CloudKit/CloudKitZoneResult.swift +++ /dev/null @@ -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 - } - -} diff --git a/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 75f9fbe98..d17a637c5 100644 --- a/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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" } }, {