Merge branch 'master' into accent-color-experimental
This commit is contained in:
commit
e08efa55a1
|
@ -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
|
||||
|
|
|
@ -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 = "<group>"; };
|
||||
51E490352288C37100C791F0 /* FeedbinDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinDate.swift; sourceTree = "<group>"; };
|
||||
51E4DB2B242632DC0091EB5B /* CloudKitErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitErrorHandler.swift; sourceTree = "<group>"; };
|
||||
51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitZone.swift; sourceTree = "<group>"; };
|
||||
51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAccountZone.swift; sourceTree = "<group>"; };
|
||||
51E4DB3124264B470091EB5B /* WebFeed+CloudKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebFeed+CloudKit.swift"; sourceTree = "<group>"; };
|
||||
51E4DB3324264CD50091EB5B /* Folder+CloudKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Folder+CloudKit.swift"; sourceTree = "<group>"; };
|
||||
51E4DB352426693F0091EB5B /* CloudKitRecordConvertable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitRecordConvertable.swift; sourceTree = "<group>"; };
|
||||
51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinUnreadEntry.swift; sourceTree = "<group>"; };
|
||||
51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinStarredEntry.swift; sourceTree = "<group>"; };
|
||||
552032ED229D5D5A009559E0 /* ReaderAPIEntry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPIEntry.swift; sourceTree = "<group>"; };
|
||||
|
@ -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 = "<group>";
|
||||
|
@ -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 */,
|
||||
|
|
|
@ -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, Error>) -> Void) {
|
||||
refresher.refreshFeeds(account.flattenedWebFeeds()) {
|
||||
account.metadata.lastArticleFetchEndTime = Date()
|
||||
|
|
|
@ -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)
|
||||
// }
|
||||
|
||||
}
|
|
@ -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)"
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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, Error>) -> 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, Error>) -> 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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -182,6 +182,19 @@
|
|||
<action selector="toggleArticleExtractor:" target="B8D-0N-5wS" id="ZKQ-SK-5YJ"/>
|
||||
</connections>
|
||||
</toolbarItem>
|
||||
<toolbarItem implicitItemIdentifier="ACB5604B-4543-4985-BA1A-54ADA9DF5845" label="Clean UP" paletteLabel="Clean Up" toolTip="Clean Up" image="cleanUp" sizingBehavior="auto" id="SsT-iS-pKE">
|
||||
<button key="view" verticalHuggingPriority="750" id="9At-yP-WNY">
|
||||
<rect key="frame" x="7" y="14" width="42" height="25"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
|
||||
<buttonCell key="cell" type="roundTextured" bezelStyle="texturedRounded" image="cleanUp" imagePosition="only" alignment="center" lineBreakMode="truncatingTail" state="on" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="Zwg-74-ZkZ">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
</buttonCell>
|
||||
</button>
|
||||
<connections>
|
||||
<action selector="cleanUp:" target="Oky-zY-oP4" id="UCH-DG-yk4"/>
|
||||
</connections>
|
||||
</toolbarItem>
|
||||
</allowedToolbarItems>
|
||||
<defaultToolbarItems>
|
||||
<toolbarItem reference="Skp-5r-70Q"/>
|
||||
|
@ -607,6 +620,7 @@
|
|||
<image name="NSAddTemplate" width="11" height="11"/>
|
||||
<image name="NSRefreshTemplate" width="11" height="15"/>
|
||||
<image name="NSShareTemplate" width="11" height="16"/>
|
||||
<image name="cleanUp" width="149" height="113"/>
|
||||
<image name="filterInactive" width="100" height="101"/>
|
||||
<image name="markAllRead" width="22" height="19"/>
|
||||
<image name="markRead" width="19" height="19"/>
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "cleanUp.pdf"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -65,5 +65,7 @@
|
|||
<string>https://ranchero.com/downloads/netnewswire-5.1-beta.xml</string>
|
||||
<key>UserAgent</key>
|
||||
<string>NetNewsWire (RSS Reader; https://ranchero.com/netnewswire/)</string>
|
||||
<key>OrganizationIdentifier</key>
|
||||
<string>$(ORGANIZATION_IDENTIFIER)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>OrganizationIdentifier</key>
|
||||
<string>$(ORGANIZATION_IDENTIFIER)</string>
|
||||
<key>AppGroup</key>
|
||||
<string>group.$(ORGANIZATION_IDENTIFIER).NetNewsWire.iOS</string>
|
||||
<key>AppIdentifierPrefix</key>
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>OrganizationIdentifier</key>
|
||||
<string>$(ORGANIZATION_IDENTIFIER)</string>
|
||||
<key>AppGroup</key>
|
||||
<string>group.$(ORGANIZATION_IDENTIFIER).NetNewsWire.iOS</string>
|
||||
<key>AppIdentifierPrefix</key>
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>OrganizationIdentifier</key>
|
||||
<string>$(ORGANIZATION_IDENTIFIER)</string>
|
||||
<key>AppGroup</key>
|
||||
<string>group.$(ORGANIZATION_IDENTIFIER).NetNewsWire.iOS</string>
|
||||
<key>AppIdentifierPrefix</key>
|
||||
|
|
Loading…
Reference in New Issue