610 lines
28 KiB
Swift
610 lines
28 KiB
Swift
//
|
|
// MastodonCachePersistentStore.swift
|
|
// Tusker
|
|
//
|
|
// Created by Shadowfacts on 4/11/20.
|
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import CoreData
|
|
import Pachyderm
|
|
import Combine
|
|
import OSLog
|
|
#if canImport(Sentry)
|
|
import Sentry
|
|
#endif
|
|
import CloudKit
|
|
import UserAccounts
|
|
|
|
fileprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentStore")
|
|
|
|
class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|
|
|
private let accountInfo: UserAccountInfo?
|
|
|
|
private static let managedObjectModel: NSManagedObjectModel = {
|
|
let url = Bundle.main.url(forResource: "Tusker", withExtension: "momd")!
|
|
return NSManagedObjectModel(contentsOf: url)!
|
|
}()
|
|
|
|
private(set) lazy var backgroundContext: NSManagedObjectContext = {
|
|
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
|
context.persistentStoreCoordinator = self.persistentStoreCoordinator
|
|
context.automaticallyMergesChangesFromParent = true
|
|
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
|
context.name = "Background"
|
|
return context
|
|
}()
|
|
|
|
// remote change processing happens on its own context, since it can sometimes take
|
|
// a really long time (upwards of a minute) and shouldn't block other things using the background context
|
|
private lazy var remoteChangesBackgroundContext: NSManagedObjectContext = {
|
|
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
|
context.persistentStoreCoordinator = self.persistentStoreCoordinator
|
|
context.automaticallyMergesChangesFromParent = true
|
|
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
|
context.name = "RemoteChanges"
|
|
return context
|
|
}()
|
|
|
|
// TODO: consider sending managed objects through this to avoid re-fetching things unnecessarily
|
|
// would need to audit existing uses to make sure everything happens on the main thread
|
|
// and when updating things on the background context would need to switch to main, refetch, and then publish
|
|
let statusSubject = PassthroughSubject<String, Never>()
|
|
let accountSubject = PassthroughSubject<String, Never>()
|
|
let relationshipSubject = PassthroughSubject<String, Never>()
|
|
|
|
init(for accountInfo: UserAccountInfo?, transient: Bool = false) {
|
|
self.accountInfo = accountInfo
|
|
|
|
let group = DispatchGroup()
|
|
var instancesToMigrate: [URL]? = nil
|
|
var hashtagsToMigrate: [Hashtag]? = nil
|
|
if transient {
|
|
super.init(name: "transient_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
|
|
|
|
let storeDescription = NSPersistentStoreDescription()
|
|
storeDescription.type = NSInMemoryStoreType
|
|
persistentStoreDescriptions = [storeDescription]
|
|
} else {
|
|
super.init(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
|
|
|
|
var localStoreLocation = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
|
localStoreLocation.appendPathComponent("\(accountInfo!.persistenceKey)_cache.sqlite", isDirectory: false)
|
|
let localStoreDescription = NSPersistentStoreDescription(url: localStoreLocation)
|
|
localStoreDescription.configuration = "Local"
|
|
localStoreDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
|
localStoreDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
|
|
|
var cloudStoreLocation = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
|
cloudStoreLocation.appendPathComponent("cloud.sqlite", isDirectory: false)
|
|
let cloudStoreDescription = NSPersistentStoreDescription(url: cloudStoreLocation)
|
|
cloudStoreDescription.configuration = "Cloud"
|
|
let options = NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.space.vaccor.Tusker")
|
|
options.databaseScope = .private
|
|
cloudStoreDescription.cloudKitContainerOptions = options
|
|
cloudStoreDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
|
cloudStoreDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
|
|
|
persistentStoreDescriptions = [
|
|
cloudStoreDescription,
|
|
localStoreDescription,
|
|
]
|
|
|
|
// workaround for migrating from using id in name to persistenceKey
|
|
// can be removed after a sufficient time has passed
|
|
if accountInfo!.id.contains("/") {
|
|
for desc in persistentStoreDescriptions {
|
|
guard let new = desc.url,
|
|
!FileManager.default.fileExists(atPath: new.path) else {
|
|
continue
|
|
}
|
|
do {
|
|
for ext in ["sqlite", "sqlite-shm", "sqlite-wal"] {
|
|
var old = new.deletingLastPathComponent()
|
|
let components = accountInfo!.id.split(separator: "/")
|
|
for dir in components.dropLast(1) {
|
|
old.appendPathComponent(String(dir), isDirectory: true)
|
|
}
|
|
old.appendPathComponent("\(components.last!)_cache", isDirectory: false)
|
|
old.appendPathExtension(ext)
|
|
if FileManager.default.fileExists(atPath: old.path) {
|
|
var expected = new.deletingLastPathComponent()
|
|
expected.appendPathComponent("\(accountInfo!.persistenceKey)_cache", isDirectory: false)
|
|
expected.appendPathExtension(ext)
|
|
try FileManager.default.moveItem(at: old, to: expected)
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
// migrate saved data from local store to cloud store
|
|
// this can be removed pre-app store release
|
|
if !FileManager.default.fileExists(atPath: cloudStoreLocation.path) {
|
|
group.enter()
|
|
var defaultPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
|
defaultPath.appendPathComponent("\(accountInfo!.persistenceKey)_cache.sqlite", isDirectory: false)
|
|
let defaultDesc = NSPersistentStoreDescription(url: defaultPath)
|
|
defaultDesc.configuration = "Default"
|
|
defaultDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
|
let defaultPSC = NSPersistentContainer(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
|
|
defaultPSC.persistentStoreDescriptions = [defaultDesc]
|
|
defaultPSC.loadPersistentStores { _, error in
|
|
guard error == nil else {
|
|
group.leave()
|
|
return
|
|
}
|
|
defaultPSC.performBackgroundTask { context in
|
|
if let instances = try? context.fetch(SavedInstance.fetchRequestWithoutAccountForMigrating()) {
|
|
instancesToMigrate = instances.map(\.url)
|
|
instances.forEach(context.delete(_:))
|
|
}
|
|
if let hashtags = try? context.fetch(SavedHashtag.fetchRequestWithoutAccountForMigrating()) {
|
|
hashtagsToMigrate = hashtags.map { Hashtag(name: $0.name, url: $0.url) }
|
|
hashtags.forEach(context.delete(_:))
|
|
}
|
|
if context.hasChanges {
|
|
try? context.save()
|
|
}
|
|
group.leave()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
group.wait()
|
|
loadPersistentStores { (description, error) in
|
|
if let error = error {
|
|
logger.error("Unable to load persistent store: \(String(describing: error), privacy: .public)")
|
|
fatalError("Unable to load persistent store: \(String(describing: error))")
|
|
}
|
|
|
|
if description.configuration == "Cloud" {
|
|
let context = self.backgroundContext
|
|
context.perform {
|
|
instancesToMigrate?.forEach({ url in
|
|
if !context.objectExists(for: SavedInstance.fetchRequest(url: url, account: accountInfo!)) {
|
|
_ = SavedInstance(url: url, account: accountInfo!, context: self.backgroundContext)
|
|
}
|
|
})
|
|
hashtagsToMigrate?.forEach({ hashtag in
|
|
if !context.objectExists(for: SavedHashtag.fetchRequest(name: hashtag.name, account: accountInfo!)) {
|
|
_ = SavedHashtag(hashtag: hashtag, account: accountInfo!, context: self.backgroundContext)
|
|
}
|
|
})
|
|
self.save(context: self.backgroundContext)
|
|
}
|
|
}
|
|
}
|
|
|
|
// changes to the Cloud CD model in development need this to be uncommented to update the CK schema
|
|
// #if DEBUG
|
|
// try! initializeCloudKitSchema(options: [])
|
|
// #endif
|
|
|
|
viewContext.automaticallyMergesChangesFromParent = true
|
|
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
|
viewContext.name = "View"
|
|
|
|
if accountInfo != nil {
|
|
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator)
|
|
}
|
|
}
|
|
|
|
func save(context: NSManagedObjectContext) {
|
|
guard context.hasChanges else {
|
|
return
|
|
}
|
|
do {
|
|
try context.save()
|
|
} catch let error as NSError {
|
|
logger.error("Unable to save managed object context: \(String(describing: error), privacy: .public)")
|
|
#if canImport(Sentry)
|
|
let crumb = Breadcrumb(level: .fatal, category: "PersistentStore")
|
|
// note: NSDetailedErrorsKey == "NSDetailedErrorsKey" != "NSDetailedErrors"
|
|
if let detailed = error.userInfo["NSDetailedErrors"] as? [NSError] {
|
|
crumb.data = [
|
|
"errors": detailed.compactMap { error -> [String: Any?]? in
|
|
guard let object = error.userInfo[NSValidationObjectErrorKey] as? NSManagedObject else {
|
|
return nil
|
|
}
|
|
return [
|
|
"entity": object.entity.name,
|
|
"key": error.userInfo[NSValidationKeyErrorKey],
|
|
"value": error.userInfo[NSValidationValueErrorKey],
|
|
"message": error.localizedDescription,
|
|
]
|
|
}
|
|
]
|
|
}
|
|
SentrySDK.addBreadcrumb(crumb)
|
|
#endif
|
|
fatalError("Unable to save managed object context: \(String(describing: error))")
|
|
}
|
|
}
|
|
|
|
func status(for id: String, in context: NSManagedObjectContext? = nil) -> StatusMO? {
|
|
let context = context ?? viewContext
|
|
let request: NSFetchRequest<StatusMO> = StatusMO.fetchRequest()
|
|
request.predicate = NSPredicate(format: "id = %@", id)
|
|
request.fetchLimit = 1
|
|
if let result = try? context.fetch(request), let status = result.first {
|
|
return status
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
private func upsert(status: Status, context: NSManagedObjectContext) -> StatusMO {
|
|
if let statusMO = self.status(for: status.id, in: context) {
|
|
statusMO.updateFrom(apiStatus: status, container: self)
|
|
return statusMO
|
|
} else {
|
|
return StatusMO(apiStatus: status, container: self, context: context)
|
|
}
|
|
}
|
|
|
|
func addOrUpdate(status: Status, context: NSManagedObjectContext? = nil, completion: ((StatusMO) -> Void)? = nil) {
|
|
let context = context ?? backgroundContext
|
|
context.perform {
|
|
let statusMO = self.upsert(status: status, context: context)
|
|
self.save(context: context)
|
|
completion?(statusMO)
|
|
self.statusSubject.send(status.id)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func addOrUpdateOnViewContext(status: Status) -> StatusMO {
|
|
let statusMO = self.upsert(status: status, context: viewContext)
|
|
self.save(context: viewContext)
|
|
statusSubject.send(status.id)
|
|
return statusMO
|
|
}
|
|
|
|
func addAll(statuses: [Status], in context: NSManagedObjectContext? = nil, completion: (() -> Void)? = nil) {
|
|
let context = context ?? backgroundContext
|
|
context.perform {
|
|
statuses.forEach { self.upsert(status: $0, context: context) }
|
|
self.save(context: context)
|
|
statuses.forEach { self.statusSubject.send($0.id) }
|
|
completion?()
|
|
}
|
|
}
|
|
|
|
func addAll(statuses: [Status], in context: NSManagedObjectContext? = nil) async {
|
|
return await withCheckedContinuation { continuation in
|
|
addAll(statuses: statuses, in: context) {
|
|
continuation.resume()
|
|
}
|
|
}
|
|
}
|
|
|
|
func account(for id: String, in context: NSManagedObjectContext? = nil) -> AccountMO? {
|
|
let context = context ?? viewContext
|
|
let request: NSFetchRequest<AccountMO> = AccountMO.fetchRequest()
|
|
request.predicate = NSPredicate(format: "id = %@", id)
|
|
request.fetchLimit = 1
|
|
if let result = try? context.fetch(request), let account = result.first {
|
|
return account
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
private func upsert(account: Account, in context: NSManagedObjectContext) -> AccountMO {
|
|
if let accountMO = self.account(for: account.id, in: context) {
|
|
accountMO.updateFrom(apiAccount: account, container: self)
|
|
return accountMO
|
|
} else {
|
|
return AccountMO(apiAccount: account, container: self, context: context)
|
|
}
|
|
}
|
|
|
|
func addOrUpdate(account: Account, in context: NSManagedObjectContext? = nil, completion: ((AccountMO) -> Void)? = nil) {
|
|
let context = context ?? backgroundContext
|
|
context.perform {
|
|
let accountMO = self.upsert(account: account, in: context)
|
|
self.save(context: context)
|
|
completion?(accountMO)
|
|
self.accountSubject.send(account.id)
|
|
}
|
|
}
|
|
|
|
/// The caller is responsible for calling this on a queue appropriate for `context`.
|
|
func addOrUpdateSynchronously(account: Account, in context: NSManagedObjectContext) -> AccountMO {
|
|
let accountMO = self.upsert(account: account, in: context)
|
|
self.save(context: context)
|
|
self.accountSubject.send(account.id)
|
|
return accountMO
|
|
}
|
|
|
|
func relationship(forAccount id: String, in context: NSManagedObjectContext? = nil) -> RelationshipMO? {
|
|
let context = context ?? viewContext
|
|
let request: NSFetchRequest<RelationshipMO> = RelationshipMO.fetchRequest()
|
|
request.predicate = NSPredicate(format: "accountID = %@", id)
|
|
request.fetchLimit = 1
|
|
if let result = try? context.fetch(request), let relationship = result.first {
|
|
return relationship
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
private func upsert(relationship: Relationship, in context: NSManagedObjectContext) -> RelationshipMO {
|
|
if let relationshipMO = self.relationship(forAccount: relationship.accountID, in: context) {
|
|
relationshipMO.updateFrom(apiRelationship: relationship, container: self)
|
|
return relationshipMO
|
|
} else {
|
|
let relationshipMO = RelationshipMO(apiRelationship: relationship, container: self, context: context)
|
|
return relationshipMO
|
|
}
|
|
}
|
|
|
|
func addOrUpdate(relationship: Relationship, in context: NSManagedObjectContext? = nil, completion: ((RelationshipMO) -> Void)? = nil) {
|
|
let context = context ?? backgroundContext
|
|
context.perform {
|
|
let relationshipMO = self.upsert(relationship: relationship, in: context)
|
|
self.save(context: context)
|
|
completion?(relationshipMO)
|
|
self.relationshipSubject.send(relationship.accountID)
|
|
}
|
|
}
|
|
|
|
func addAll(accounts: [Account], in context: NSManagedObjectContext? = nil, completion: (() -> Void)? = nil) {
|
|
let context = context ?? backgroundContext
|
|
context.perform {
|
|
accounts.forEach { self.upsert(account: $0, in: context) }
|
|
self.save(context: context)
|
|
completion?()
|
|
accounts.forEach { self.accountSubject.send($0.id) }
|
|
}
|
|
}
|
|
|
|
func addAll(accounts: [Account], in context: NSManagedObjectContext? = nil) async {
|
|
await withCheckedContinuation { continuation in
|
|
addAll(accounts: accounts, in: context) {
|
|
continuation.resume()
|
|
}
|
|
}
|
|
}
|
|
|
|
func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) {
|
|
backgroundContext.perform {
|
|
let statuses = notifications.compactMap { $0.status }
|
|
let accounts = notifications.map { $0.account }
|
|
statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
|
|
accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
|
|
self.save(context: self.backgroundContext)
|
|
completion?()
|
|
statuses.forEach { self.statusSubject.send($0.id) }
|
|
accounts.forEach { self.accountSubject.send($0.id) }
|
|
}
|
|
}
|
|
|
|
func performBatchUpdates(_ block: @escaping (_ context: NSManagedObjectContext, _ addAccounts: ([Account]) -> Void, _ addStatuses: ([Status]) -> Void) -> Void, completion: (() -> Void)? = nil) {
|
|
backgroundContext.perform {
|
|
var updatedAccounts = [String]()
|
|
var updatedStatuses = [String]()
|
|
|
|
block(self.backgroundContext, { (accounts) in
|
|
accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
|
|
updatedAccounts.append(contentsOf: accounts.map { $0.id })
|
|
}, { (statuses) in
|
|
statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
|
|
updatedStatuses.append(contentsOf: statuses.map { $0.id })
|
|
})
|
|
|
|
updatedAccounts.forEach(self.accountSubject.send)
|
|
updatedStatuses.forEach(self.statusSubject.send)
|
|
|
|
self.save(context: self.backgroundContext)
|
|
completion?()
|
|
}
|
|
}
|
|
|
|
func updateFollowedHashtags(_ hashtags: [Hashtag], completion: @escaping (Result<[FollowedHashtag], Error>) -> Void) {
|
|
viewContext.perform {
|
|
do {
|
|
var all = try self.viewContext.fetch(FollowedHashtag.fetchRequest())
|
|
|
|
let toDelete = all.filter { existing in !hashtags.contains(where: { $0.name == existing.name }) }.map(\.objectID)
|
|
if !toDelete.isEmpty {
|
|
try self.viewContext.execute(NSBatchDeleteRequest(objectIDs: toDelete))
|
|
}
|
|
|
|
for hashtag in hashtags where !all.contains(where: { $0.name == hashtag.name}) {
|
|
let mo = FollowedHashtag(hashtag: hashtag, context: self.viewContext)
|
|
all.append(mo)
|
|
}
|
|
|
|
self.save(context: self.viewContext)
|
|
completion(.success(all))
|
|
} catch {
|
|
completion(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
|
|
func hasFollowedHashtag(_ hashtag: Hashtag) -> Bool {
|
|
do {
|
|
let req = FollowedHashtag.fetchRequest(name: name)
|
|
return try viewContext.count(for: req) > 0
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
func updateFilters(_ filters: [AnyFilter], completion: @escaping (Result<[FilterMO], Error>) -> Void) {
|
|
viewContext.perform {
|
|
do {
|
|
var all = try self.viewContext.fetch(FilterMO.fetchRequest())
|
|
|
|
let toDelete = all.filter { existing in !filters.contains(where: { $0.id == existing.id }) }.map(\.objectID)
|
|
if !toDelete.isEmpty {
|
|
try self.viewContext.execute(NSBatchDeleteRequest(objectIDs: toDelete))
|
|
}
|
|
|
|
for filter in filters {
|
|
if let existing = all.first(where: { $0.id == filter.id }) {
|
|
existing.updateFrom(apiFilter: filter, context: self.viewContext)
|
|
} else {
|
|
let mo = FilterMO(context: self.viewContext)
|
|
mo.updateFrom(apiFilter: filter, context: self.viewContext)
|
|
all.append(mo)
|
|
}
|
|
}
|
|
|
|
self.save(context: self.viewContext)
|
|
completion(.success(all))
|
|
} catch {
|
|
completion(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
|
|
func getTimelinePosition(timeline: Timeline) -> TimelinePosition? {
|
|
guard let accountInfo else {
|
|
return nil
|
|
}
|
|
do {
|
|
let req = TimelinePosition.fetchRequest(timeline: timeline, account: accountInfo)
|
|
return try viewContext.fetch(req).first
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
@objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
|
|
let changes = hasChangedSavedHashtagsOrInstances(notification)
|
|
if changes.hashtags {
|
|
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
|
|
}
|
|
if changes.instances {
|
|
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
|
|
}
|
|
}
|
|
|
|
private func hasChangedSavedHashtagsOrInstances(_ notification: Foundation.Notification) -> (hashtags: Bool, instances: Bool) {
|
|
var changes: (hashtags: Bool, instances: Bool) = (false, false)
|
|
if let inserted = notification.userInfo?[NSInsertedObjectsKey] as? Set<NSManagedObject> {
|
|
for object in inserted {
|
|
if object is SavedHashtag {
|
|
changes.hashtags = true
|
|
} else if object is SavedInstance {
|
|
changes.instances = true
|
|
}
|
|
if changes.hashtags && changes.instances {
|
|
return changes
|
|
}
|
|
}
|
|
}
|
|
if let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject> {
|
|
for object in deleted {
|
|
if object is SavedHashtag {
|
|
changes.hashtags = true
|
|
} else if object is SavedInstance {
|
|
changes.instances = true
|
|
}
|
|
if changes.hashtags && changes.instances {
|
|
return changes
|
|
}
|
|
}
|
|
}
|
|
return changes
|
|
}
|
|
|
|
@objc private func remoteChanges(_ notification: Foundation.Notification) {
|
|
guard let accountInfo,
|
|
let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
|
|
return
|
|
}
|
|
PersistentHistoryTokenStore.token(for: accountInfo) { lastToken in
|
|
self.remoteChangesBackgroundContext.perform {
|
|
defer {
|
|
PersistentHistoryTokenStore.setToken(token, for: accountInfo)
|
|
}
|
|
let transactions: [NSPersistentHistoryTransaction]
|
|
do {
|
|
let req = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)
|
|
if let result = try self.remoteChangesBackgroundContext.execute(req) as? NSPersistentHistoryResult {
|
|
transactions = result.result as? [NSPersistentHistoryTransaction] ?? []
|
|
} else {
|
|
logger.error("Unexpectedly non-NSPersistentHistoryResult")
|
|
return
|
|
}
|
|
} catch {
|
|
logger.error("Unable to fetch persistent history results: \(String(describing: error), privacy: .public)")
|
|
return
|
|
}
|
|
if !transactions.isEmpty {
|
|
self.processPersistentHistoryTransactions(transactions)
|
|
}
|
|
|
|
// NB: We deliberately do not purge old persistent history.
|
|
// Doing so causes the CoreData+CloudKit integration to replay all of
|
|
// the server's changes on initialization, which takes a long time
|
|
// and produces a bunch of intermediate UI updates we don't want.
|
|
}
|
|
}
|
|
}
|
|
|
|
private func processPersistentHistoryTransactions(_ transactions: [NSPersistentHistoryTransaction]) {
|
|
logger.info("Processing \(transactions.count) persistent history transactions")
|
|
var changedHashtags = false
|
|
var changedInstances = false
|
|
var changedTimelinePositions = Set<NSManagedObjectID>()
|
|
var changedAccountPrefs = false
|
|
outer: for transaction in transactions {
|
|
logger.info("Processing \(transaction.changes?.count ?? 0) changes in transaction")
|
|
for change in transaction.changes ?? [] {
|
|
if change.changedObjectID.entity.name == "SavedHashtag" {
|
|
changedHashtags = true
|
|
} else if change.changedObjectID.entity.name == "SavedInstance" {
|
|
changedInstances = true
|
|
} else if change.changedObjectID.entity.name == "TimelinePosition" {
|
|
changedTimelinePositions.insert(change.changedObjectID)
|
|
} else if change.changedObjectID.entity.name == "AccountPreferences" {
|
|
changedAccountPrefs = true
|
|
}
|
|
}
|
|
}
|
|
// Can't capture vars in concurrently-executing closure
|
|
let hashtags = changedHashtags
|
|
let instances = changedInstances
|
|
let timelinePositions = changedTimelinePositions
|
|
let accountPrefs = changedAccountPrefs
|
|
DispatchQueue.main.async {
|
|
if hashtags {
|
|
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
|
|
}
|
|
if instances {
|
|
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
|
|
}
|
|
for id in timelinePositions {
|
|
guard let timelinePosition = try? self.viewContext.existingObject(with: id) as? TimelinePosition else {
|
|
continue
|
|
}
|
|
// the kvo observer that clears the LazilyDecoding cache doesn't always fire on remote changes, so do it manually
|
|
timelinePosition.changedRemotely()
|
|
NotificationCenter.default.post(name: .timelinePositionChanged, object: timelinePosition)
|
|
}
|
|
if accountPrefs {
|
|
NotificationCenter.default.post(name: .accountPreferencesChangedRemotely, object: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
extension Foundation.Notification.Name {
|
|
static let timelinePositionChanged = Notification.Name("timelinePositionChanged")
|
|
static let accountPreferencesChangedRemotely = Notification.Name("accountPreferencesChangedRemotely")
|
|
}
|