diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index a98112c2..a144f7e0 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -259,6 +259,7 @@ D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C10E25B62D2400298D0F /* DiskCache.swift */; }; D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11425B62E9700298D0F /* CacheExpiry.swift */; }; D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */; }; + D6A8D7A52C14DB280007B285 /* PersistentHistoryTokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */; }; D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */; }; D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */; }; D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */; }; @@ -687,6 +688,7 @@ D6A6C10E25B62D2400298D0F /* DiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskCache.swift; sourceTree = ""; }; D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = ""; }; D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = ""; }; + D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentHistoryTokenStore.swift; sourceTree = ""; }; D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = MatchedGeometryPresentation; sourceTree = ""; }; D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; sourceTree = ""; }; D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionCollectionViewCell.swift; sourceTree = ""; }; @@ -1051,6 +1053,7 @@ D608470E2A245D1F00C17380 /* ActiveInstance.swift */, D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */, D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */, + D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */, ); path = CoreData; sourceTree = ""; @@ -2412,6 +2415,7 @@ D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */, D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */, D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */, + D6A8D7A52C14DB280007B285 /* PersistentHistoryTokenStore.swift in Sources */, D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */, D646DCDC2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift in Sources */, ); diff --git a/Tusker/CoreData/MastodonCachePersistentStore.swift b/Tusker/CoreData/MastodonCachePersistentStore.swift index 837f0834..a062a193 100644 --- a/Tusker/CoreData/MastodonCachePersistentStore.swift +++ b/Tusker/CoreData/MastodonCachePersistentStore.swift @@ -48,8 +48,6 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer { return context }() - private var lastRemoteChangeToken: NSPersistentHistoryToken? - // 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 @@ -190,8 +188,10 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer { viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump viewContext.name = "View" - NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext) - NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator) + 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) { @@ -521,58 +521,67 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer { } @objc private func remoteChanges(_ notification: Foundation.Notification) { - guard let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else { + guard let accountInfo, + let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else { return } - remoteChangesBackgroundContext.perform { - defer { - self.lastRemoteChangeToken = token + PersistentHistoryTokenStore.token(for: accountInfo) { lastToken in + self.remoteChangesBackgroundContext.perform { + defer { + PersistentHistoryTokenStore.setToken(token, for: accountInfo) + } + let req = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken) + if let result = try? self.remoteChangesBackgroundContext.execute(req) as? NSPersistentHistoryResult, + let transactions = result.result as? [NSPersistentHistoryTransaction], + !transactions.isEmpty { + self.processPersistentHistoryTransactions(transactions) + } } - let req = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastRemoteChangeToken) - if let result = try? self.remoteChangesBackgroundContext.execute(req) as? NSPersistentHistoryResult, - let transactions = result.result as? [NSPersistentHistoryTransaction], - !transactions.isEmpty { - var changedHashtags = false - var changedInstances = false - var changedTimelinePositions = Set() - var changedAccountPrefs = false - outer: for transaction in transactions { - 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 - } - } + } + } + + private func processPersistentHistoryTransactions(_ transactions: [NSPersistentHistoryTransaction]) { + logger.info("Processing \(transactions.count) persistent history transactions") + var changedHashtags = false + var changedInstances = false + var changedTimelinePositions = Set() + 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) - } + } + } + // 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) } } } diff --git a/Tusker/CoreData/PersistentHistoryTokenStore.swift b/Tusker/CoreData/PersistentHistoryTokenStore.swift new file mode 100644 index 00000000..58c4c151 --- /dev/null +++ b/Tusker/CoreData/PersistentHistoryTokenStore.swift @@ -0,0 +1,47 @@ +// +// PersistentHistoryTokenStore.swift +// Tusker +// +// Created by Shadowfacts on 6/8/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import Foundation +import CoreData +import UserAccounts + +struct PersistentHistoryTokenStore { + private static let queue = DispatchQueue(label: "PersistentHistoryTokenStore") + + private static var tokens: [String: NSPersistentHistoryToken] = (try? load()) ?? [:] + + private static let applicationSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + private static let storeURL = applicationSupportDirectory.appendingPathComponent("PersistentHistoryTokenStore.plist") + + private static func load() throws -> [String: NSPersistentHistoryToken]? { + let data = try Data(contentsOf: storeURL) + let unarchived = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSDictionary.self, NSString.self, NSPersistentHistoryToken.self], from: data) + return unarchived as? [String: NSPersistentHistoryToken] + } + + private static func save() throws { + let data = try NSKeyedArchiver.archivedData(withRootObject: tokens as [NSString: NSPersistentHistoryToken], requiringSecureCoding: true) + try data.write(to: PersistentHistoryTokenStore.storeURL) + } + + static func token(for account: UserAccountInfo, completion: @escaping (NSPersistentHistoryToken?) -> Void) { + queue.async { + completion(tokens[account.id]) + } + } + + static func setToken(_ token: NSPersistentHistoryToken, for account: UserAccountInfo) { + queue.async { + tokens[account.id] = token + try? save() + } + } + + private init() {} + +}