Merge branch 'master' into accent-color-experimental

This commit is contained in:
Maurice Parker 2020-03-22 17:54:01 -05:00
commit e08efa55a1
16 changed files with 673 additions and 5 deletions

View File

@ -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

View File

@ -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 */,

View File

@ -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()

View File

@ -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)
// }
}

View File

@ -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)"
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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"/>

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "cleanUp.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
},
"properties" : {
"template-rendering-intent" : "template"
}
}

Binary file not shown.

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>