From 8105756ccbb60070ecaabc3296ff13e04b9bb2d9 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sun, 22 Mar 2020 16:35:03 -0500 Subject: [PATCH 1/2] 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 From 09733f0d8784e28d8096d0eca222ffec5acbdf63 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sun, 22 Mar 2020 17:53:17 -0500 Subject: [PATCH 2/2] Add button for Clean Up function. --- Mac/Base.lproj/MainWindow.storyboard | 14 ++++++++++++++ .../cleanUp.imageset/Contents.json | 15 +++++++++++++++ .../Assets.xcassets/cleanUp.imageset/cleanUp.pdf | Bin 0 -> 4745 bytes 3 files changed, 29 insertions(+) create mode 100644 Mac/Resources/Assets.xcassets/cleanUp.imageset/Contents.json create mode 100644 Mac/Resources/Assets.xcassets/cleanUp.imageset/cleanUp.pdf diff --git a/Mac/Base.lproj/MainWindow.storyboard b/Mac/Base.lproj/MainWindow.storyboard index 48694b502..2250dff7b 100644 --- a/Mac/Base.lproj/MainWindow.storyboard +++ b/Mac/Base.lproj/MainWindow.storyboard @@ -182,6 +182,19 @@ + + + + + + @@ -607,6 +620,7 @@ + diff --git a/Mac/Resources/Assets.xcassets/cleanUp.imageset/Contents.json b/Mac/Resources/Assets.xcassets/cleanUp.imageset/Contents.json new file mode 100644 index 000000000..1a8719b21 --- /dev/null +++ b/Mac/Resources/Assets.xcassets/cleanUp.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "cleanUp.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template" + } +} \ No newline at end of file diff --git a/Mac/Resources/Assets.xcassets/cleanUp.imageset/cleanUp.pdf b/Mac/Resources/Assets.xcassets/cleanUp.imageset/cleanUp.pdf new file mode 100644 index 0000000000000000000000000000000000000000..69969dcf27f78c56948840f3dc62bf114bd18312 GIT binary patch literal 4745 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 R7a9hYmId