Implement web feed sync between devices.

This commit is contained in:
Maurice Parker 2020-03-29 17:12:34 -05:00
parent c0e1fbfff3
commit 4f425c9c86
8 changed files with 138 additions and 62 deletions

View File

@ -9,6 +9,14 @@
import Foundation
import CloudKit
extension CKRecord {
var externalID: String {
return recordID.externalID
}
}
extension CKRecord.ID {
var externalID: String {

View File

@ -49,13 +49,21 @@ final class CloudKitAccountDelegate: AccountDelegate {
accountZone = CloudKitAccountZone(container: container)
let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3")
database = SyncDatabase(databaseFilePath: databaseFilePath)
accountZone.refreshProgress = refreshProgress
}
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
refresher.refreshFeeds(account.flattenedWebFeeds()) {
accountZone.fetchChangesInZone() { result in
switch result {
case .success:
self.refresher.refreshFeeds(account.flattenedWebFeeds()) {
account.metadata.lastArticleFetchEndTime = Date()
completion(.success(()))
}
case .failure(let error):
completion(.failure(error))
}
}
}
func sendArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void)) {
@ -119,8 +127,7 @@ final class CloudKitAccountDelegate: AccountDelegate {
switch result {
case .success(let feedSpecifiers):
guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers),
let url = URL(string: bestFeedSpecifier.urlString) else {
guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers), let url = URL(string: bestFeedSpecifier.urlString) else {
self.refreshProgress.completeTask()
completion(.failure(AccountError.createErrorNotFound))
return
@ -132,7 +139,7 @@ final class CloudKitAccountDelegate: AccountDelegate {
return
}
self.accountZone.createWebFeed(url: urlString, editedName: name) { result in
self.accountZone.createWebFeed(url: bestFeedSpecifier.urlString, editedName: name) { result in
switch result {
case .success(let externalID):
@ -231,10 +238,12 @@ final class CloudKitAccountDelegate: AccountDelegate {
}
func accountDidInitialize(_ account: Account) {
accountZone.delegate = CloudKitAcountZoneDelegate(account: account)
accountZone.delegate = CloudKitAcountZoneDelegate(account: account, refreshProgress: refreshProgress)
accountZone.resumeLongLivedOperationIfPossible()
}
func accountWillBeDeleted(_ account: Account) {
accountZone.resetChangeToken()
}
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL? = nil, completion: (Result<Credentials?, Error>) -> Void) {

View File

@ -7,6 +7,8 @@
//
import Foundation
import os.log
import RSWeb
import CloudKit
final class CloudKitAccountZone: CloudKitZone {
@ -15,8 +17,11 @@ final class CloudKitAccountZone: CloudKitZone {
return CKRecordZone.ID(zoneName: "Account", ownerName: CKCurrentUserDefaultName)
}
let container: CKContainer
let database: CKDatabase
var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit")
weak var container: CKContainer?
weak var database: CKDatabase?
weak var refreshProgress: DownloadProgress?
var delegate: CloudKitZoneDelegate? = nil
struct CloudKitWebFeed {
@ -43,7 +48,7 @@ final class CloudKitAccountZone: CloudKitZone {
save(record: record) { result in
switch result {
case .success:
completion(.success(record.recordID.externalID))
completion(.success(record.externalID))
case .failure(let error):
completion(.failure(error))
}

View File

@ -7,20 +7,26 @@
//
import Foundation
import os.log
import RSWeb
import CloudKit
class CloudKitAcountZoneDelegate: CloudKitZoneDelegate {
weak var account: Account?
private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit")
init(account: Account) {
weak var account: Account?
weak var refreshProgress: DownloadProgress?
init(account: Account, refreshProgress: DownloadProgress) {
self.account = account
self.refreshProgress = refreshProgress
}
func cloudKitDidChange(record: CKRecord) {
switch record.recordType {
case CloudKitAccountZone.CloudKitWebFeed.recordType:
addWebFeed(record)
addOrUpdateWebFeed(record)
default:
assertionFailure("Unknown record type: \(record.recordType)")
}
@ -35,11 +41,47 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate {
}
}
func addWebFeed(_ record: CKRecord) {
func addOrUpdateWebFeed(_ record: CKRecord) {
guard let account = account else { return }
let editedName = record[CloudKitAccountZone.CloudKitWebFeed.Fields.editedName] as? String
if let webFeed = account.existingWebFeed(withExternalID: record.externalID) {
webFeed.editedName = editedName
} else {
if let urlString = record[CloudKitAccountZone.CloudKitWebFeed.Fields.url] as? String, let url = URL(string: urlString) {
downloadAndAddWebFeed(url: url, editedName: editedName, externalID: record.externalID)
} else {
os_log(.error, log: self.log, "Failed to add or update web feed.")
}
}
}
func removeWebFeed(_ externalID: String) {
if let webFeed = account?.existingWebFeed(withExternalID: externalID) {
account?.removeWebFeed(webFeed)
}
}
}
private extension CloudKitAcountZoneDelegate {
func downloadAndAddWebFeed(url: URL, editedName: String?, externalID: String) {
guard let account = account else { return }
let webFeed = account.createWebFeed(with: editedName, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil)
webFeed.editedName = editedName
webFeed.externalID = externalID
account.addWebFeed(webFeed)
refreshProgress?.addToNumberOfTasksAndRemaining(1)
InitialFeedDownloader.download(url) { parsedFeed in
self.refreshProgress?.completeTask()
if let parsedFeed = parsedFeed {
account.update(webFeed, with: parsedFeed, {_ in })
}
}
}

View File

@ -7,6 +7,8 @@
//
import CloudKit
import os.log
import RSWeb
enum CloudKitZoneError: Error {
case userDeletedZone
@ -23,39 +25,33 @@ protocol CloudKitZone: class {
static var zoneID: CKRecordZone.ID { get }
var container: CKContainer { get }
var database: CKDatabase { get }
var log: OSLog { get }
var container: CKContainer? { get }
var database: CKDatabase? { get }
var refreshProgress: DownloadProgress? { get set }
var delegate: CloudKitZoneDelegate? { get set }
// 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 resetChangeToken() {
changeToken = nil
}
func generateRecordID() -> CKRecord.ID {
return CKRecord.ID(recordName: UUID().uuidString, zoneID: Self.zoneID)
}
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 }
guard let container = container else { return }
container.fetchAllLongLivedOperationIDs { (opIDs, error) in
guard let opIDs = opIDs else { return }
for opID in opIDs {
container.fetchLongLivedOperation(withID: opID, completionHandler: { (ope, error) in
if let modifyOp = ope as? CKModifyRecordsOperation {
self.container.add(modifyOp)
container.add(modifyOp)
}
})
}
@ -134,10 +130,13 @@ extension CloudKitZone {
}
}
database.add(op)
database?.add(op)
}
func fetchChangesInZones(completion: @escaping (Result<Void, Error>) -> Void) {
func fetchChangesInZone(completion: @escaping (Result<Void, Error>) -> Void) {
refreshProgress?.addToNumberOfTasksAndRemaining(1)
let zoneConfig = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
zoneConfig.previousServerChangeToken = changeToken
let op = CKFetchRecordZoneChangesOperation(recordZoneIDs: [Self.zoneID], configurationsByRecordZoneID: [Self.zoneID: zoneConfig])
@ -145,43 +144,54 @@ extension CloudKitZone {
op.recordZoneChangeTokensUpdatedBlock = { [weak self] zoneId, token, _ in
guard let self = self else { return }
DispatchQueue.main.async {
self.changeToken = token
}
}
op.recordChangedBlock = { [weak self] record in
guard let self = self else { return }
DispatchQueue.main.async {
self.delegate?.cloudKitDidChange(record: record)
}
}
op.recordWithIDWasDeletedBlock = { [weak self] recordId, recordType in
guard let self = self else { return }
DispatchQueue.main.async {
self.delegate?.cloudKitDidDelete(recordType: recordType, recordID: recordId)
}
}
op.recordZoneFetchCompletionBlock = { [weak self](zoneId ,token, _, _, error) in
op.recordZoneFetchCompletionBlock = { [weak self] zoneId ,token, _, _, error in
guard let self = self else { return }
switch CloudKitZoneResult.resolve(error) {
case .success:
DispatchQueue.main.async {
self.changeToken = token
}
case .retry(let timeToWait):
self.retryOperationIfPossible(retryAfter: timeToWait) {
self.fetchChangesInZones(completion: completion)
self.fetchChangesInZone(completion: completion)
}
default:
return
os_log(.error, log: self.log, "%@ zone fetch changes error: %@.", zoneId.zoneName, error?.localizedDescription ?? "Unknown")
}
}
op.fetchRecordZoneChangesCompletionBlock = { error in
op.fetchRecordZoneChangesCompletionBlock = { [weak self] error in
DispatchQueue.main.async {
self?.refreshProgress?.completeTask()
if let error = error {
completion(.failure(error))
} else {
completion(.success(()))
}
}
}
database.add(op)
database?.add(op)
}
}
@ -213,7 +223,7 @@ private extension CloudKitZone {
}
func createZoneRecord(completion: @escaping (Result<Void, Error>) -> Void) {
database.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in
database?.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in
if let error = error {
DispatchQueue.main.async {
completion(.failure(error))

View File

@ -35,7 +35,8 @@ class AccountsAddCloudKitWindowController: NSWindowController {
}
@IBAction func create(_ sender: Any) {
_ = AccountManager.shared.createAccount(type: .cloudKit)
let account = AccountManager.shared.createAccount(type: .cloudKit)
account.refreshAll(completion: { _ in })
hostWindow!.endSheet(window!, returnCode: NSApplication.ModalResponse.OK)
}

View File

@ -25,7 +25,8 @@ class CloudKitAccountViewController: UITableViewController {
}
@IBAction func add(_ sender: Any) {
_ = AccountManager.shared.createAccount(type: .cloudKit)
let account = AccountManager.shared.createAccount(type: .cloudKit)
account.refreshAll(completion: { _ in })
dismiss(animated: true, completion: nil)
delegate?.dismiss()
}

View File

@ -47,7 +47,7 @@ class AddAccountViewController: UITableViewController, AddAccountDismissDelegate
cell.accountNameLabel?.text = Account.defaultLocalAccountName
cell.accountImage?.image = AppAssets.image(for: .onMyMac)
case .cloudKit:
cell.accountNameLabel?.text = NSLocalizedString("CloudKit", comment: "CloudKit")
cell.accountNameLabel?.text = NSLocalizedString("iCloud", comment: "iCloud")
cell.accountImage?.image = AppAssets.accountCloudKitImage
case .feedbin:
cell.accountNameLabel?.text = NSLocalizedString("Feedbin", comment: "Feedbin")