From 8105756ccbb60070ecaabc3296ff13e04b9bb2d9 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sun, 22 Mar 2020 16:35:03 -0500 Subject: [PATCH] Add some work in progress for CloudKit syncing --- Frameworks/Account/Account.swift | 4 +- .../Account/Account.xcodeproj/project.pbxproj | 24 +++ .../CloudKit/CloudKitAccountDelegate.swift | 32 ++- .../CloudKit/CloudKitAccountZone.swift | 122 +++++++++++ .../CloudKit/CloudKitErrorHandler.swift | 194 ++++++++++++++++++ .../CloudKit/CloudKitRecordConvertable.swift | 33 +++ .../Account/CloudKit/CloudKitZone.swift | 130 ++++++++++++ .../Account/CloudKit/Folder+CloudKit.swift | 49 +++++ .../Account/CloudKit/WebFeed+CloudKit.swift | 53 +++++ Mac/Resources/Info.plist | 2 + iOS/IntentsExtension/Info.plist | 2 + iOS/Resources/Info.plist | 2 + iOS/ShareExtension/Info.plist | 2 + 13 files changed, 644 insertions(+), 5 deletions(-) create mode 100644 Frameworks/Account/CloudKit/CloudKitAccountZone.swift create mode 100644 Frameworks/Account/CloudKit/CloudKitErrorHandler.swift create mode 100644 Frameworks/Account/CloudKit/CloudKitRecordConvertable.swift create mode 100644 Frameworks/Account/CloudKit/CloudKitZone.swift create mode 100644 Frameworks/Account/CloudKit/Folder+CloudKit.swift create mode 100644 Frameworks/Account/CloudKit/WebFeed+CloudKit.swift diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index 891d1522e..ae5ea045e 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -234,7 +234,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, case .onMyMac: self.delegate = LocalAccountDelegate() case .cloudKit: - self.delegate = CloudKitAccountDelegate() + self.delegate = CloudKitAccountDelegate(dataFolder: dataFolder) case .feedbin: self.delegate = FeedbinAccountDelegate(dataFolder: dataFolder, transport: transport) case .freshRSS: @@ -245,8 +245,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, self.delegate = FeedWranglerAccountDelegate(dataFolder: dataFolder, transport: transport) case .newsBlur: self.delegate = NewsBlurAccountDelegate(dataFolder: dataFolder, transport: transport) - default: - return nil } self.delegate.accountMetadata = metadata diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 5a546215d..b8ceac19a 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -64,6 +64,12 @@ 51E148EC234B8FFC0004F7A5 /* SyncDatabase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 51E148EB234B8FFC0004F7A5 /* SyncDatabase.framework */; }; 51E3EB41229AF61B00645299 /* AccountError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E3EB40229AF61B00645299 /* AccountError.swift */; }; 51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E490352288C37100C791F0 /* FeedbinDate.swift */; }; + 51E4DB2C242632DC0091EB5B /* CloudKitErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4DB2B242632DC0091EB5B /* CloudKitErrorHandler.swift */; }; + 51E4DB2E242633ED0091EB5B /* CloudKitZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */; }; + 51E4DB302426353D0091EB5B /* CloudKitAccountZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */; }; + 51E4DB3224264B470091EB5B /* WebFeed+CloudKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4DB3124264B470091EB5B /* WebFeed+CloudKit.swift */; }; + 51E4DB3424264CD50091EB5B /* Folder+CloudKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4DB3324264CD50091EB5B /* Folder+CloudKit.swift */; }; + 51E4DB362426693F0091EB5B /* CloudKitRecordConvertable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4DB352426693F0091EB5B /* CloudKitRecordConvertable.swift */; }; 51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */; }; 51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */; }; 552032F8229D5D5A009559E0 /* ReaderAPIEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032ED229D5D5A009559E0 /* ReaderAPIEntry.swift */; }; @@ -291,6 +297,12 @@ 51E148EB234B8FFC0004F7A5 /* SyncDatabase.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SyncDatabase.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 51E3EB40229AF61B00645299 /* AccountError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountError.swift; sourceTree = ""; }; 51E490352288C37100C791F0 /* FeedbinDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinDate.swift; sourceTree = ""; }; + 51E4DB2B242632DC0091EB5B /* CloudKitErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitErrorHandler.swift; sourceTree = ""; }; + 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitZone.swift; sourceTree = ""; }; + 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAccountZone.swift; sourceTree = ""; }; + 51E4DB3124264B470091EB5B /* WebFeed+CloudKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebFeed+CloudKit.swift"; sourceTree = ""; }; + 51E4DB3324264CD50091EB5B /* Folder+CloudKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Folder+CloudKit.swift"; sourceTree = ""; }; + 51E4DB352426693F0091EB5B /* CloudKitRecordConvertable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitRecordConvertable.swift; sourceTree = ""; }; 51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinUnreadEntry.swift; sourceTree = ""; }; 51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinStarredEntry.swift; sourceTree = ""; }; 552032ED229D5D5A009559E0 /* ReaderAPIEntry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPIEntry.swift; sourceTree = ""; }; @@ -504,6 +516,12 @@ isa = PBXGroup; children = ( 5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */, + 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */, + 51E4DB2B242632DC0091EB5B /* CloudKitErrorHandler.swift */, + 51E4DB352426693F0091EB5B /* CloudKitRecordConvertable.swift */, + 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */, + 51E4DB3324264CD50091EB5B /* Folder+CloudKit.swift */, + 51E4DB3124264B470091EB5B /* WebFeed+CloudKit.swift */, ); path = CloudKit; sourceTree = ""; @@ -1052,6 +1070,7 @@ files = ( 84C8B3F41F89DE430053CCA6 /* DataExtensions.swift in Sources */, 552032F9229D5D5A009559E0 /* ReaderAPISubscription.swift in Sources */, + 51E4DB2E242633ED0091EB5B /* CloudKitZone.swift in Sources */, 84C3654A1F899F3B001EC85C /* CombinedRefreshProgress.swift in Sources */, 9EC688EE232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift in Sources */, 9EEAE071235D019B00E3FEE4 /* FeedlyGetStreamContentsService.swift in Sources */, @@ -1089,9 +1108,11 @@ 51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */, 9EEAE06E235D002D00E3FEE4 /* FeedlyGetCollectionsService.swift in Sources */, 5165D72922835F7A00D9D53D /* FeedSpecifier.swift in Sources */, + 51E4DB3424264CD50091EB5B /* Folder+CloudKit.swift in Sources */, 9E85C8ED2367020700D0F1F7 /* FeedlyGetEntriesService.swift in Sources */, 9E5EC15923E01D8A00A4E503 /* FeedlyCollectionParser.swift in Sources */, 9E84DC492359A73600D6E809 /* FeedlyCheckpointOperation.swift in Sources */, + 51E4DB3224264B470091EB5B /* WebFeed+CloudKit.swift in Sources */, 9E85C8EB236700E600D0F1F7 /* FeedlyGetEntriesOperation.swift in Sources */, 9E1D154D233370D800F4944C /* FeedlySyncAllOperation.swift in Sources */, 9E44C90F23C6FF3600CCC286 /* FeedlyIngestStreamArticleIdsOperation.swift in Sources */, @@ -1132,6 +1153,7 @@ 51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */, 552032F8229D5D5A009559E0 /* ReaderAPIEntry.swift in Sources */, 552032FB229D5D5A009559E0 /* ReaderAPITag.swift in Sources */, + 51E4DB362426693F0091EB5B /* CloudKitRecordConvertable.swift in Sources */, 5165D72822835F7800D9D53D /* FeedFinder.swift in Sources */, 9EBD49C023C67602005AD5CD /* FeedlyDownloadArticlesOperation.swift in Sources */, 51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */, @@ -1154,6 +1176,7 @@ 5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */, 841974011F6DD1EC006346C4 /* Folder.swift in Sources */, 510BD111232C3801002692E4 /* AccountMetadataFile.swift in Sources */, + 51E4DB302426353D0091EB5B /* CloudKitAccountZone.swift in Sources */, 3B826DAD2385C81C00FC1ADB /* FeedWranglerFeedItemsRequest.swift in Sources */, 846E774F1F6EF9C000A165E2 /* LocalAccountDelegate.swift in Sources */, 515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */, @@ -1162,6 +1185,7 @@ 51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */, 9E1D1555233431A600F4944C /* FeedlyOperation.swift in Sources */, 84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */, + 51E4DB2C242632DC0091EB5B /* CloudKitErrorHandler.swift in Sources */, 9EF1B10723590D61000A486A /* FeedlyGetStreamIdsOperation.swift in Sources */, 84245C851FDDD8CB0074AFBB /* FeedbinSubscription.swift in Sources */, 9EF2602C23C91FFE006D160C /* FeedlyGetUpdatedArticleIdsOperation.swift in Sources */, diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 44e921850..2f49f8234 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -7,6 +7,9 @@ // import Foundation +import CloudKit +import os.log +import SyncDatabase import RSCore import RSParser import Articles @@ -18,6 +21,19 @@ public enum CloudKitAccountDelegateError: String, Error { final class CloudKitAccountDelegate: AccountDelegate { + private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") + + private let database: SyncDatabase + + private let container: CKContainer = { + let orgID = Bundle.main.object(forInfoDictionaryKey: "OrganizationIdentifier") as! String + return CKContainer(identifier: "iCloud.\(orgID).NetNewsWire") + }() + + private let accountZone: CloudKitAccountZone + + private let refresher = LocalAccountRefresher() + let behaviors: AccountBehaviors = [] let isOPMLImportInProgress = false @@ -25,12 +41,24 @@ final class CloudKitAccountDelegate: AccountDelegate { var credentials: Credentials? var accountMetadata: AccountMetadata? - private let refresher = LocalAccountRefresher() - var refreshProgress: DownloadProgress { return refresher.progress } +// init() { +// accountZone.startUp() { result in +// if case .failure(let error) = result { +// os_log(.error, log: self.log, "Account zone startup error: %@.", error.localizedDescription) +// } +// } +// } + + init(dataFolder: String) { + accountZone = CloudKitAccountZone(container: container) + let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3") + database = SyncDatabase(databaseFilePath: databaseFilePath) + } + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { refresher.refreshFeeds(account.flattenedWebFeeds()) { account.metadata.lastArticleFetchEndTime = Date() diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift new file mode 100644 index 000000000..1b3d49b94 --- /dev/null +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -0,0 +1,122 @@ +// +// CloudKitAccountZone.swift +// Account +// +// Created by Maurice Parker on 3/21/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import CloudKit + +final class CloudKitAccountZone: CloudKitZone { + + static var zoneID: CKRecordZone.ID { + return CKRecordZone.ID(zoneName: "Account", ownerName: CKCurrentUserDefaultName) + } + + let container: CKContainer + let database: CKDatabase + + init(container: CKContainer) { + self.container = container + self.database = container.privateCloudDatabase + } + + + +// func fetchChangesInDatabase(_ callback: ((Error?) -> Void)?) { +// let changesOperation = CKFetchDatabaseChangesOperation(previousServerChangeToken: databaseChangeToken) +// +// /// Only update the changeToken when fetch process completes +// changesOperation.changeTokenUpdatedBlock = { [weak self] newToken in +// self?.databaseChangeToken = newToken +// } +// +// changesOperation.fetchDatabaseChangesCompletionBlock = { +// [weak self] +// newToken, _, error in +// guard let self = self else { return } +// switch CloudKitErrorHandler.shared.resultType(with: error) { +// case .success: +// self.databaseChangeToken = newToken +// // Fetch the changes in zone level +// self.fetchChangesInZones(callback) +// case .retry(let timeToWait, _): +// CloudKitErrorHandler.shared.retryOperationIfPossible(retryAfter: timeToWait, block: { +// self.fetchChangesInDatabase(callback) +// }) +// case .recoverableError(let reason, _): +// switch reason { +// case .changeTokenExpired: +// /// The previousServerChangeToken value is too old and the client must re-sync from scratch +// self.databaseChangeToken = nil +// self.fetchChangesInDatabase(callback) +// default: +// return +// } +// default: +// return +// } +// } +// +// database.add(changesOperation) +// } + +// private func fetchChangesInZones(_ callback: ((Error?) -> Void)? = nil) { +// let changesOp = CKFetchRecordZoneChangesOperation(recordZoneIDs: zoneIds, optionsByRecordZoneID: zoneIdOptions) +// changesOp.fetchAllChanges = true +// +// changesOp.recordZoneChangeTokensUpdatedBlock = { [weak self] zoneId, token, _ in +// guard let self = self else { return } +// guard let syncObject = self.syncObjects.first(where: { $0.zoneID == zoneId }) else { return } +// syncObject.zoneChangesToken = token +// } +// +// changesOp.recordChangedBlock = { [weak self] record in +// /// The Cloud will return the modified record since the last zoneChangesToken, we need to do local cache here. +// /// Handle the record: +// guard let self = self else { return } +// guard let syncObject = self.syncObjects.first(where: { $0.recordType == record.recordType }) else { return } +// syncObject.add(record: record) +// } +// +// changesOp.recordWithIDWasDeletedBlock = { [weak self] recordId, _ in +// guard let self = self else { return } +// guard let syncObject = self.syncObjects.first(where: { $0.zoneID == recordId.zoneID }) else { return } +// syncObject.delete(recordID: recordId) +// } +// +// changesOp.recordZoneFetchCompletionBlock = { [weak self](zoneId ,token, _, _, error) in +// guard let self = self else { return } +// switch ErrorHandler.shared.resultType(with: error) { +// case .success: +// guard let syncObject = self.syncObjects.first(where: { $0.zoneID == zoneId }) else { return } +// syncObject.zoneChangesToken = token +// case .retry(let timeToWait, _): +// ErrorHandler.shared.retryOperationIfPossible(retryAfter: timeToWait, block: { +// self.fetchChangesInZones(callback) +// }) +// case .recoverableError(let reason, _): +// switch reason { +// case .changeTokenExpired: +// /// The previousServerChangeToken value is too old and the client must re-sync from scratch +// guard let syncObject = self.syncObjects.first(where: { $0.zoneID == zoneId }) else { return } +// syncObject.zoneChangesToken = nil +// self.fetchChangesInZones(callback) +// default: +// return +// } +// default: +// return +// } +// } +// +// changesOp.fetchRecordZoneChangesCompletionBlock = { error in +// callback?(error) +// } +// +// database.add(changesOp) +// } + +} diff --git a/Frameworks/Account/CloudKit/CloudKitErrorHandler.swift b/Frameworks/Account/CloudKit/CloudKitErrorHandler.swift new file mode 100644 index 000000000..5a69c9ce7 --- /dev/null +++ b/Frameworks/Account/CloudKit/CloudKitErrorHandler.swift @@ -0,0 +1,194 @@ +// +// CloudKitErrorHandler.swift +// Account +// +// Created by @randycarney on 12/12/17. +// Derived from https://github.com/caiyue1993/IceCream +// + +import Foundation +import CloudKit + +/// This struct helps you handle all the CKErrors and has been updated to the current Apple documentation(12/15/2017): +/// https://developer.apple.com/documentation/cloudkit/ckerror.code + +struct CloudKitErrorHandler { + + static let shared = CloudKitErrorHandler() + + /// We could classify all the results that CKOperation returns into the following five CKOperationResultTypes + enum CloudKitOperationResultType { + case success + case retry(afterSeconds: Double, message: String) + case chunk + case recoverableError(reason: CloudKitOperationFailReason, message: String) + case fail(reason: CloudKitOperationFailReason, message: String) + } + + /// The reason of CloudKit failure could be classified into following 8 cases + enum CloudKitOperationFailReason { + case changeTokenExpired + case network + case quotaExceeded + case partialFailure + case serverRecordChanged + case shareRelated + case unhandledErrorCode + case unknown + } + + func resultType(with error: Error?) -> CloudKitOperationResultType { + guard error != nil else { return .success } + + guard let e = error as? CKError else { + return .fail(reason: .unknown, message: "The error returned is not a CKError") + } + + let message = returnErrorMessage(for: e.code) + + switch e.code { + + // SHOULD RETRY + case .serviceUnavailable, + .requestRateLimited, + .zoneBusy: + + // If there is a retry delay specified in the error, then use that. + let userInfo = e.userInfo + if let retry = userInfo[CKErrorRetryAfterKey] as? Double { + print("ErrorHandler - \(message). Should retry in \(retry) seconds.") + return .retry(afterSeconds: retry, message: message) + } else { + return .fail(reason: .unknown, message: message) + } + + // RECOVERABLE ERROR + case .networkUnavailable, + .networkFailure: + print("ErrorHandler.recoverableError: \(message)") + return .recoverableError(reason: .network, message: message) + case .changeTokenExpired: + print("ErrorHandler.recoverableError: \(message)") + return .recoverableError(reason: .changeTokenExpired, message: message) + case .serverRecordChanged: + print("ErrorHandler.recoverableError: \(message)") + return .recoverableError(reason: .serverRecordChanged, message: message) + case .partialFailure: + // Normally it shouldn't happen since if CKOperation `isAtomic` set to true + if let dictionary = e.userInfo[CKPartialErrorsByItemIDKey] as? NSDictionary { + print("ErrorHandler.partialFailure for \(dictionary.count) items; CKPartialErrorsByItemIDKey: \(dictionary)") + } + return .recoverableError(reason: .partialFailure, message: message) + + // SHOULD CHUNK IT UP + case .limitExceeded: + print("ErrorHandler.Chunk: \(message)") + return .chunk + + // SHARE DATABASE RELATED + case .alreadyShared, + .participantMayNeedVerification, + .referenceViolation, + .tooManyParticipants: + print("ErrorHandler.Fail: \(message)") + return .fail(reason: .shareRelated, message: message) + + // quota exceeded is sort of a special case where the user has to take action(like spare more room in iCloud) before retry + case .quotaExceeded: + print("ErrorHandler.Fail: \(message)") + return .fail(reason: .quotaExceeded, message: message) + + // FAIL IS THE FINAL, WE REALLY CAN'T DO MORE + default: + print("ErrorHandler.Fail: \(message)") + return .fail(reason: .unknown, message: message) + + } + + } + + func retryOperationIfPossible(retryAfter: Double, block: @escaping () -> ()) { + let delayTime = DispatchTime.now() + retryAfter + DispatchQueue.main.asyncAfter(deadline: delayTime, execute: { + block() + }) + } + + private func returnErrorMessage(for code: CKError.Code) -> String { + var returnMessage = "" + + switch code { + case .alreadyShared: + returnMessage = "Already Shared: a record or share cannot be saved because doing so would cause the same hierarchy of records to exist in multiple shares." + case .assetFileModified: + returnMessage = "Asset File Modified: the content of the specified asset file was modified while being saved." + case .assetFileNotFound: + returnMessage = "Asset File Not Found: the specified asset file is not found." + case .badContainer: + returnMessage = "Bad Container: the specified container is unknown or unauthorized." + case .badDatabase: + returnMessage = "Bad Database: the operation could not be completed on the given database." + case .batchRequestFailed: + returnMessage = "Batch Request Failed: the entire batch was rejected." + case .changeTokenExpired: + returnMessage = "Change Token Expired: the previous server change token is too old." + case .constraintViolation: + returnMessage = "Constraint Violation: the server rejected the request because of a conflict with a unique field." + case .incompatibleVersion: + returnMessage = "Incompatible Version: your app version is older than the oldest version allowed." + case .internalError: + returnMessage = "Internal Error: a nonrecoverable error was encountered by CloudKit." + case .invalidArguments: + returnMessage = "Invalid Arguments: the specified request contains bad information." + case .limitExceeded: + returnMessage = "Limit Exceeded: the request to the server is too large." + case .managedAccountRestricted: + returnMessage = "Managed Account Restricted: the request was rejected due to a managed-account restriction." + case .missingEntitlement: + returnMessage = "Missing Entitlement: the app is missing a required entitlement." + case .networkUnavailable: + returnMessage = "Network Unavailable: the internet connection appears to be offline." + case .networkFailure: + returnMessage = "Network Failure: the internet connection appears to be offline." + case .notAuthenticated: + returnMessage = "Not Authenticated: 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." + case .operationCancelled: + returnMessage = "Operation Cancelled: the operation was explicitly canceled." + case .partialFailure: + returnMessage = "Partial Failure: some items failed, but the operation succeeded overall." + case .participantMayNeedVerification: + returnMessage = "Participant May Need Verification: you are not a member of the share." + case .permissionFailure: + returnMessage = "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." + case .quotaExceeded: + returnMessage = "Quota Exceeded: saving would exceed your current iCloud storage quota." + case .referenceViolation: + returnMessage = "Reference Violation: the target of a record's parent or share reference was not found." + case .requestRateLimited: + returnMessage = "Request Rate Limited: transfers to and from the server are being rate limited at this time." + case .serverRecordChanged: + returnMessage = "Server Record Changed: the record was rejected because the version on the server is different." + case .serverRejectedRequest: + returnMessage = "Server Rejected Request" + case .serverResponseLost: + returnMessage = "Server Response Lost" + case .serviceUnavailable: + returnMessage = "Service Unavailable: Please try again." + case .tooManyParticipants: + returnMessage = "Too Many Participants: a share cannot be saved because too many participants are attached to the share." + case .unknownItem: + returnMessage = "Unknown Item: the specified record does not exist." + case .userDeletedZone: + returnMessage = "User Deleted Zone: the user has deleted this zone from the settings UI." + case .zoneBusy: + returnMessage = "Zone Busy: the server is too busy to handle the zone operation." + case .zoneNotFound: + returnMessage = "Zone Not Found: the specified record zone does not exist on the server." + default: + returnMessage = "Unhandled Error." + } + + return returnMessage + "CKError.Code: \(code.rawValue)" + } + +} diff --git a/Frameworks/Account/CloudKit/CloudKitRecordConvertable.swift b/Frameworks/Account/CloudKit/CloudKitRecordConvertable.swift new file mode 100644 index 000000000..b4463f63a --- /dev/null +++ b/Frameworks/Account/CloudKit/CloudKitRecordConvertable.swift @@ -0,0 +1,33 @@ +// +// CloudKitRecordConvertable.swift +// Account +// +// Created by Maurice Parker on 3/21/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import CloudKit + +protocol CloudKitRecordConvertible { + static var cloudKitRecordType: String { get } + static var cloudKitZoneID: CKRecordZone.ID { get } + + var cloudKitPrimaryKey: String { get } + var recordID: CKRecord.ID { get } + var cloudKitRecord: CKRecord { get } + + func assignCloudKitPrimaryKeyIfNecessary() +} + +extension CloudKitRecordConvertible { + + public static var cloudKitRecordType: String { + return String(describing: self) + } + + public var recordID: CKRecord.ID { + return CKRecord.ID(recordName: cloudKitPrimaryKey, zoneID: Self.cloudKitZoneID) + } + +} diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift new file mode 100644 index 000000000..52acfbc96 --- /dev/null +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -0,0 +1,130 @@ +// +// CloudKitZone.swift +// Account +// +// Created by Maurice Parker on 3/21/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import CloudKit + +public protocol CloudKitZone: class { + + var container: CKContainer { get } + var database: CKDatabase { get } + static var zoneID: CKRecordZone.ID { get } + + func startUp(completion: @escaping (Result) -> Void) + + // func prepare() + + // func fetchChangesInDatabase(_ callback: ((Error?) -> Void)?) + + /// The CloudKit Best Practice is out of date, now use this: + /// https://developer.apple.com/documentation/cloudkit/ckoperation + /// Which problem does this func solve? E.g.: + /// 1.(Offline) You make a local change, involve a operation + /// 2. App exits or ejected by user + /// 3. Back to app again + /// The operation resumes! All works like a magic! + func resumeLongLivedOperationIfPossible() + +} + +extension CloudKitZone { + + func startUp(completion: @escaping (Result) -> Void) { + database.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in + if let error = error { + completion(.failure(error)) + } else { + completion(.success(())) + } + } + } + + // func prepare() { + // syncObjects.forEach { + // $0.pipeToEngine = { [weak self] recordsToStore, recordIDsToDelete in + // guard let self = self else { return } + // self.syncRecordsToCloudKit(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete) + // } + // } + // } + + func resumeLongLivedOperationIfPossible() { + container.fetchAllLongLivedOperationIDs { [weak self]( opeIDs, error) in + guard let self = self, error == nil, let ids = opeIDs else { return } + for id in ids { + self.container.fetchLongLivedOperation(withID: id, completionHandler: { [weak self](ope, error) in + guard let self = self, error == nil else { return } + if let modifyOp = ope as? CKModifyRecordsOperation { + modifyOp.modifyRecordsCompletionBlock = { (_,_,_) in + print("Resume modify records success!") + } + self.container.add(modifyOp) + } + }) + } + } + } + + // func startObservingRemoteChanges() { + // NotificationCenter.default.addObserver(forName: Notifications.cloudKitDataDidChangeRemotely.name, object: nil, queue: nil, using: { [weak self](_) in + // guard let self = self else { return } + // DispatchQueue.global(qos: .utility).async { + // self.fetchChangesInDatabase(nil) + // } + // }) + // } + + /// Sync local data to CloudKit + /// For more about the savePolicy: https://developer.apple.com/documentation/cloudkit/ckrecordsavepolicy + public func syncRecordsToCloudKit(recordsToStore: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: ((Error?) -> ())? = nil) { + let modifyOpe = CKModifyRecordsOperation(recordsToSave: recordsToStore, recordIDsToDelete: recordIDsToDelete) + + let config = CKOperation.Configuration() + config.isLongLived = true + modifyOpe.configuration = config + + // We use .changedKeys savePolicy to do unlocked changes here cause my app is contentious and off-line first + // Apple suggests using .ifServerRecordUnchanged save policy + // For more, see Advanced CloudKit(https://developer.apple.com/videos/play/wwdc2014/231/) + modifyOpe.savePolicy = .changedKeys + + // To avoid CKError.partialFailure, make the operation atomic (if one record fails to get modified, they all fail) + // If you want to handle partial failures, set .isAtomic to false and implement CKOperationResultType .fail(reason: .partialFailure) where appropriate + modifyOpe.isAtomic = true + + modifyOpe.modifyRecordsCompletionBlock = { + [weak self] + (_, _, error) in + + guard let self = self else { return } + + switch CloudKitErrorHandler.shared.resultType(with: error) { + case .success: + DispatchQueue.main.async { + completion?(nil) + } + case .retry(let timeToWait, _): + CloudKitErrorHandler.shared.retryOperationIfPossible(retryAfter: timeToWait) { + self.syncRecordsToCloudKit(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete, completion: completion) + } + case .chunk: + /// CloudKit says maximum number of items in a single request is 400. + /// So I think 300 should be fine by them. + let chunkedRecords = recordsToStore.chunked(into: 300) + for chunk in chunkedRecords { + self.syncRecordsToCloudKit(recordsToStore: chunk, recordIDsToDelete: recordIDsToDelete, completion: completion) + } + default: + return + } + } + + database.add(modifyOpe) + } + +} + diff --git a/Frameworks/Account/CloudKit/Folder+CloudKit.swift b/Frameworks/Account/CloudKit/Folder+CloudKit.swift new file mode 100644 index 000000000..cc9271b20 --- /dev/null +++ b/Frameworks/Account/CloudKit/Folder+CloudKit.swift @@ -0,0 +1,49 @@ +// +// Folder+CloudKit.swift +// Account +// +// Created by Maurice Parker on 3/21/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import CloudKit + +extension Folder: CloudKitRecordConvertible { + + enum CloudKitKey: String { + case name + } + + static var cloudKitZoneID: CKRecordZone.ID { + return CloudKitAccountZone.zoneID + } + + var cloudKitPrimaryKey: String { + return externalID! + } + + var cloudKitRecord: CKRecord { + let record = CKRecord(recordType: Self.cloudKitRecordType) + record[.name] = name + return record + } + + func assignCloudKitPrimaryKeyIfNecessary() { + if externalID == nil { + externalID = UUID().uuidString + } + } + +} + +extension CKRecord { + subscript(key: Folder.CloudKitKey) -> Any? { + get { + return self[key.rawValue] + } + set { + self[key.rawValue] = newValue as? CKRecordValue + } + } +} diff --git a/Frameworks/Account/CloudKit/WebFeed+CloudKit.swift b/Frameworks/Account/CloudKit/WebFeed+CloudKit.swift new file mode 100644 index 000000000..f8a176678 --- /dev/null +++ b/Frameworks/Account/CloudKit/WebFeed+CloudKit.swift @@ -0,0 +1,53 @@ +// +// WebFeed+CloudKit.swift +// Account +// +// Created by Maurice Parker on 3/21/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import CloudKit + +extension WebFeed: CloudKitRecordConvertible { + + enum CloudKitKey: String { + case url + case homePageURL + case editedName + } + + static var cloudKitZoneID: CKRecordZone.ID { + return CloudKitAccountZone.zoneID + } + + var cloudKitPrimaryKey: String { + return externalID! + } + + var cloudKitRecord: CKRecord { + let record = CKRecord(recordType: Self.cloudKitRecordType) + record[.url] = url + record[.homePageURL] = homePageURL + record[.editedName] = editedName + return record + } + + func assignCloudKitPrimaryKeyIfNecessary() { + if externalID == nil { + externalID = UUID().uuidString + } + } + +} + +extension CKRecord { + subscript(key: WebFeed.CloudKitKey) -> Any? { + get { + return self[key.rawValue] + } + set { + self[key.rawValue] = newValue as? CKRecordValue + } + } +} diff --git a/Mac/Resources/Info.plist b/Mac/Resources/Info.plist index 29185b54b..a0ecd0ec5 100644 --- a/Mac/Resources/Info.plist +++ b/Mac/Resources/Info.plist @@ -65,5 +65,7 @@ https://ranchero.com/downloads/netnewswire-5.1-beta.xml UserAgent NetNewsWire (RSS Reader; https://ranchero.com/netnewswire/) + OrganizationIdentifier + $(ORGANIZATION_IDENTIFIER) diff --git a/iOS/IntentsExtension/Info.plist b/iOS/IntentsExtension/Info.plist index 5e81a246a..ecb576837 100644 --- a/iOS/IntentsExtension/Info.plist +++ b/iOS/IntentsExtension/Info.plist @@ -2,6 +2,8 @@ + OrganizationIdentifier + $(ORGANIZATION_IDENTIFIER) AppGroup group.$(ORGANIZATION_IDENTIFIER).NetNewsWire.iOS AppIdentifierPrefix diff --git a/iOS/Resources/Info.plist b/iOS/Resources/Info.plist index c2c6bdc76..3c6fd3c9a 100644 --- a/iOS/Resources/Info.plist +++ b/iOS/Resources/Info.plist @@ -2,6 +2,8 @@ + OrganizationIdentifier + $(ORGANIZATION_IDENTIFIER) AppGroup group.$(ORGANIZATION_IDENTIFIER).NetNewsWire.iOS AppIdentifierPrefix diff --git a/iOS/ShareExtension/Info.plist b/iOS/ShareExtension/Info.plist index fc1cd201d..ea15a492c 100644 --- a/iOS/ShareExtension/Info.plist +++ b/iOS/ShareExtension/Info.plist @@ -2,6 +2,8 @@ + OrganizationIdentifier + $(ORGANIZATION_IDENTIFIER) AppGroup group.$(ORGANIZATION_IDENTIFIER).NetNewsWire.iOS AppIdentifierPrefix