From d13b5171280dbd6f8ef240df6a17ced2fd2e8c00 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 19 Dec 2022 10:58:14 -0500 Subject: [PATCH] Sync saved hashtags and instances over iCloud Closes #160 --- .../MastodonCachePersistentStore.swift | 74 ++++++++++++++++++- Tusker/CoreData/SavedHashtag.swift | 16 +++- Tusker/CoreData/SavedInstance.swift | 18 +++-- .../Tusker.xcdatamodel/contents | 33 +++++---- Tusker/Info.plist | 65 ++++++++-------- .../Compose/ComposeAutocompleteView.swift | 3 +- .../Explore/ExploreViewController.swift | 11 ++- .../Main/MainSidebarViewController.swift | 5 +- .../Onboarding/OnboardingViewController.swift | 2 +- .../HashtagTimelineViewController.swift | 7 +- .../InstanceTimelineViewController.swift | 8 +- Tusker/Screens/Utilities/Previewing.swift | 4 +- Tusker/Tusker.entitlements | 12 +++ 13 files changed, 186 insertions(+), 72 deletions(-) diff --git a/Tusker/CoreData/MastodonCachePersistentStore.swift b/Tusker/CoreData/MastodonCachePersistentStore.swift index 668bc80f..1b8c030a 100644 --- a/Tusker/CoreData/MastodonCachePersistentStore.swift +++ b/Tusker/CoreData/MastodonCachePersistentStore.swift @@ -12,10 +12,11 @@ import Pachyderm import Combine import OSLog import Sentry +import CloudKit fileprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentStore") -class MastodonCachePersistentStore: NSPersistentContainer { +class MastodonCachePersistentStore: NSPersistentCloudKitContainer { private static let managedObjectModel: NSManagedObjectModel = { let url = Bundle.main.url(forResource: "Tusker", withExtension: "momd")! @@ -46,6 +47,9 @@ class MastodonCachePersistentStore: NSPersistentContainer { let relationshipSubject = PassthroughSubject() init(for accountInfo: LocalData.UserAccountInfo?, transient: Bool = false) { + let group = DispatchGroup() + var instancesToMigrate: [URL]? = nil + var hashtagsToMigrate: [Hashtag]? = nil if transient { super.init(name: "transient_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel) @@ -55,6 +59,24 @@ class MastodonCachePersistentStore: NSPersistentContainer { } 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" + + 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 + + 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("/") { @@ -82,15 +104,65 @@ class MastodonCachePersistentStore: NSPersistentContainer { } catch {} } } + + // migrate saved data from local store to cloud store + // this can be removed pre-app store release + var defaultPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + defaultPath.appendPathComponent("\(accountInfo!.persistenceKey)_cache.sqlite", isDirectory: false) + if FileManager.default.fileExists(atPath: defaultPath.path) { + group.enter() + let defaultDesc = NSPersistentStoreDescription(url: defaultPath) + defaultDesc.configuration = "Default" + 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") } + + if description.configuration == "Cloud" { + self.backgroundContext.perform { + instancesToMigrate?.forEach({ url in + _ = SavedInstance(url: url, account: accountInfo!, context: self.backgroundContext) + }) + hashtagsToMigrate?.forEach({ hashtag in + _ = 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 diff --git a/Tusker/CoreData/SavedHashtag.swift b/Tusker/CoreData/SavedHashtag.swift index eda2e8ef..265513b9 100644 --- a/Tusker/CoreData/SavedHashtag.swift +++ b/Tusker/CoreData/SavedHashtag.swift @@ -14,24 +14,32 @@ import WebURLFoundationExtras @objc(SavedHashtag) public final class SavedHashtag: NSManagedObject { - @nonobjc public class func fetchRequest() -> NSFetchRequest { + @nonobjc class func fetchRequestWithoutAccountForMigrating() -> NSFetchRequest { return NSFetchRequest(entityName: "SavedHashtag") } - @nonobjc public class func fetchRequest(name: String) -> NSFetchRequest { + @nonobjc class func fetchRequest(account: LocalData.UserAccountInfo) -> NSFetchRequest { let req = NSFetchRequest(entityName: "SavedHashtag") - req.predicate = NSPredicate(format: "name LIKE[cd] %@", name) + req.predicate = NSPredicate(format: "accountID = %@", account.id) return req } + @nonobjc class func fetchRequest(name: String, account: LocalData.UserAccountInfo) -> NSFetchRequest { + let req = NSFetchRequest(entityName: "SavedHashtag") + req.predicate = NSPredicate(format: "name LIKE[cd] %@ AND accountID = %@", name, account.id) + return req + } + + @NSManaged public var accountID: String @NSManaged public var name: String @NSManaged public var url: URL } extension SavedHashtag { - convenience init(hashtag: Hashtag, context: NSManagedObjectContext) { + convenience init(hashtag: Hashtag, account: LocalData.UserAccountInfo, context: NSManagedObjectContext) { self.init(context: context) + self.accountID = account.id self.name = hashtag.name self.url = URL(hashtag.url)! } diff --git a/Tusker/CoreData/SavedInstance.swift b/Tusker/CoreData/SavedInstance.swift index 80c7c603..f2c612e8 100644 --- a/Tusker/CoreData/SavedInstance.swift +++ b/Tusker/CoreData/SavedInstance.swift @@ -12,23 +12,31 @@ import CoreData @objc(SavedInstance) public final class SavedInstance: NSManagedObject { - @nonobjc public class func fetchRequest() -> NSFetchRequest { + @nonobjc class func fetchRequestWithoutAccountForMigrating() -> NSFetchRequest { return NSFetchRequest(entityName: "SavedInstance") } - @nonobjc public class func fetchRequest(url: URL) -> NSFetchRequest { - let req = fetchRequest() - req.predicate = NSPredicate(format: "url = %@", url as NSURL) + @nonobjc class func fetchRequest(account: LocalData.UserAccountInfo) -> NSFetchRequest { + let req = NSFetchRequest(entityName: "SavedInstance") + req.predicate = NSPredicate(format: "accountID = %@", account.id) return req } + @nonobjc class func fetchRequest(url: URL, account: LocalData.UserAccountInfo) -> NSFetchRequest { + let req = NSFetchRequest(entityName: "SavedInstance") + req.predicate = NSPredicate(format: "url = %@ AND accountID = %@", url as NSURL, account.id) + return req + } + + @NSManaged public var accountID: String @NSManaged public var url: URL } extension SavedInstance { - convenience init(url: URL, context: NSManagedObjectContext) { + convenience init(url: URL, account: LocalData.UserAccountInfo, context: NSManagedObjectContext) { self.init(context: context) + self.accountID = account.id self.url = url } } diff --git a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents index 241a1d57..46d02c58 100644 --- a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents +++ b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -60,21 +60,13 @@ - - - - - - - + + + - - - - - - + + @@ -117,4 +109,17 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/Tusker/Info.plist b/Tusker/Info.plist index e1b3082f..bc6abf8f 100644 --- a/Tusker/Info.plist +++ b/Tusker/Info.plist @@ -2,37 +2,6 @@ - SentryDSN - $(SENTRY_DSN) - OSLogPreferences - - space.vaccor.Tusker - - DEFAULT-OPTIONS - - TTL - - Fault - 30 - Error - 30 - Debug - 15 - Info - 30 - Default - 30 - - Level - - Persist - Debug - Enable - Debug - - - - CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -87,7 +56,7 @@ NSMicrophoneUsageDescription Post videos from the camera. NSPhotoLibraryAddUsageDescription - Save photos directly from other people's posts. + Save photos directly from other people's posts. NSPhotoLibraryUsageDescription Post photos from the photo library. NSUserActivityTypes @@ -102,6 +71,37 @@ $(PRODUCT_BUNDLE_IDENTIFIER).activity.show-profile $(PRODUCT_BUNDLE_IDENTIFIER).activity.main-scene + OSLogPreferences + + space.vaccor.Tusker + + DEFAULT-OPTIONS + + Level + + Enable + Debug + Persist + Debug + + TTL + + Debug + 15 + Default + 30 + Error + 30 + Fault + 30 + Info + 30 + + + + + SentryDSN + $(SENTRY_DSN) UIApplicationSceneManifest UIApplicationSupportsMultipleScenes @@ -140,6 +140,7 @@ UIBackgroundModes audio + remote-notification UILaunchStoryboardName LaunchScreen diff --git a/Tusker/Screens/Compose/ComposeAutocompleteView.swift b/Tusker/Screens/Compose/ComposeAutocompleteView.swift index e7bf4582..05d5ee6e 100644 --- a/Tusker/Screens/Compose/ComposeAutocompleteView.swift +++ b/Tusker/Screens/Compose/ComposeAutocompleteView.swift @@ -400,7 +400,8 @@ struct ComposeAutocompleteHashtagsView: View { } private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], query: String) { - let savedTags = ((try? mastodonController.persistentContainer.viewContext.fetch(SavedHashtag.fetchRequest())) ?? []) + let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!) + let savedTags = ((try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? []) .map { Hashtag(name: $0.name, url: $0.url) } hashtags = (searchResults + savedTags + trendingTags) diff --git a/Tusker/Screens/Explore/ExploreViewController.swift b/Tusker/Screens/Explore/ExploreViewController.swift index 4bdceaeb..ebfd12c5 100644 --- a/Tusker/Screens/Explore/ExploreViewController.swift +++ b/Tusker/Screens/Explore/ExploreViewController.swift @@ -206,7 +206,8 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate { @MainActor private func fetchHashtagItems(followed: [FollowedHashtag]) -> [Item] { - let saved = (try? mastodonController.persistentContainer.viewContext.fetch(SavedHashtag.fetchRequest())) ?? [] + let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!) + let saved = (try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? [] var items = saved.map { Item.savedHashtag(Hashtag(name: $0.name, url: $0.url)) } @@ -219,7 +220,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate { @MainActor private func fetchSavedInstances() -> [SavedInstance] { - let req = SavedInstance.fetchRequest() + let req = SavedInstance.fetchRequest(account: mastodonController.accountInfo!) req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)] do { return try mastodonController.persistentContainer.viewContext.fetch(req) @@ -278,7 +279,8 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate { func removeSavedHashtag(_ hashtag: Hashtag) { let context = mastodonController.persistentContainer.viewContext - if let hashtag = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first { + let req = SavedHashtag.fetchRequest(name: hashtag.name, account: mastodonController.accountInfo!) + if let hashtag = try? context.fetch(req).first { context.delete(hashtag) try! context.save() } @@ -286,7 +288,8 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate { func removeSavedInstance(_ instanceURL: URL) { let context = mastodonController.persistentContainer.viewContext - if let instance = try? context.fetch(SavedInstance.fetchRequest(url: instanceURL)).first { + let req = SavedInstance.fetchRequest(url: instanceURL, account: mastodonController.accountInfo!) + if let instance = try? context.fetch(req).first { context.delete(instance) try! context.save() } diff --git a/Tusker/Screens/Main/MainSidebarViewController.swift b/Tusker/Screens/Main/MainSidebarViewController.swift index 18888afe..8ed0a829 100644 --- a/Tusker/Screens/Main/MainSidebarViewController.swift +++ b/Tusker/Screens/Main/MainSidebarViewController.swift @@ -232,7 +232,8 @@ class MainSidebarViewController: UIViewController { @MainActor private func fetchHashtagItems(followed: [FollowedHashtag]) -> [Item] { - let saved = (try? mastodonController.persistentContainer.viewContext.fetch(SavedHashtag.fetchRequest())) ?? [] + let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!) + let saved = (try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? [] var items = saved.map { Item.savedHashtag(Hashtag(name: $0.name, url: $0.url)) } @@ -245,7 +246,7 @@ class MainSidebarViewController: UIViewController { @MainActor private func fetchSavedInstances() -> [SavedInstance] { - let req = SavedInstance.fetchRequest() + let req = SavedInstance.fetchRequest(account: mastodonController.accountInfo!) req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)] do { return try mastodonController.persistentContainer.viewContext.fetch(req) diff --git a/Tusker/Screens/Onboarding/OnboardingViewController.swift b/Tusker/Screens/Onboarding/OnboardingViewController.swift index 19c27c8b..c3c4208a 100644 --- a/Tusker/Screens/Onboarding/OnboardingViewController.swift +++ b/Tusker/Screens/Onboarding/OnboardingViewController.swift @@ -39,7 +39,7 @@ class OnboardingViewController: UINavigationController { @MainActor private func tryLoginTo(instanceURL: URL) async throws { - let mastodonController = MastodonController(instanceURL: instanceURL) + let mastodonController = MastodonController(instanceURL: instanceURL, transient: true) let clientID: String let clientSecret: String do { diff --git a/Tusker/Screens/Timeline/HashtagTimelineViewController.swift b/Tusker/Screens/Timeline/HashtagTimelineViewController.swift index cacd4c2d..9c404db8 100644 --- a/Tusker/Screens/Timeline/HashtagTimelineViewController.swift +++ b/Tusker/Screens/Timeline/HashtagTimelineViewController.swift @@ -16,7 +16,8 @@ class HashtagTimelineViewController: TimelineViewController { var toggleSaveButton: UIBarButtonItem! private var isHashtagSaved: Bool { - mastodonController.persistentContainer.viewContext.objectExists(for: SavedHashtag.fetchRequest(name: hashtag.name)) + let req = SavedHashtag.fetchRequest(name: hashtag.name, account: mastodonController.accountInfo!) + return mastodonController.persistentContainer.viewContext.objectExists(for: req) } private var isHashtagFollowed: Bool { @@ -47,10 +48,10 @@ class HashtagTimelineViewController: TimelineViewController { private func toggleSave() { let context = mastodonController.persistentContainer.viewContext - if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first { + if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name, account: mastodonController.accountInfo!)).first { context.delete(existing) } else { - _ = SavedHashtag(hashtag: hashtag, context: context) + _ = SavedHashtag(hashtag: hashtag, account: mastodonController.accountInfo!, context: context) } mastodonController.persistentContainer.save(context: context) } diff --git a/Tusker/Screens/Timeline/InstanceTimelineViewController.swift b/Tusker/Screens/Timeline/InstanceTimelineViewController.swift index 0b46b9ab..d164a0f3 100644 --- a/Tusker/Screens/Timeline/InstanceTimelineViewController.swift +++ b/Tusker/Screens/Timeline/InstanceTimelineViewController.swift @@ -26,7 +26,8 @@ class InstanceTimelineViewController: TimelineViewController { private var toggleSaveButton: UIBarButtonItem! private var isInstanceSaved: Bool { - parentMastodonController!.persistentContainer.viewContext.objectExists(for: SavedInstance.fetchRequest(url: instanceURL)) + let req = SavedInstance.fetchRequest(url: instanceURL, account: parentMastodonController!.accountInfo!) + return parentMastodonController!.persistentContainer.viewContext.objectExists(for: req) } private var toggleSaveButtonTitle: String { if isInstanceSaved { @@ -83,12 +84,13 @@ class InstanceTimelineViewController: TimelineViewController { // MARK: - Interaction @objc func toggleSaveButtonPressed() { let context = parentMastodonController!.persistentContainer.viewContext - let existing = try? context.fetch(SavedInstance.fetchRequest(url: instanceURL)).first + let req = SavedInstance.fetchRequest(url: instanceURL, account: parentMastodonController!.accountInfo!) + let existing = try? context.fetch(req).first if let existing = existing { context.delete(existing) delegate?.didUnsaveInstance(url: instanceURL) } else { - _ = SavedInstance(url: instanceURL, context: context) + _ = SavedInstance(url: instanceURL, account: parentMastodonController!.accountInfo!, context: context) delegate?.didSaveInstance(url: instanceURL) } mastodonController.persistentContainer.save(context: context) diff --git a/Tusker/Screens/Utilities/Previewing.swift b/Tusker/Screens/Utilities/Previewing.swift index 85e9ffb5..6c31fbd3 100644 --- a/Tusker/Screens/Utilities/Previewing.swift +++ b/Tusker/Screens/Utilities/Previewing.swift @@ -111,7 +111,7 @@ extension MenuActionProvider { mastodonController.loggedIn { let name = hashtag.name.lowercased() let context = mastodonController.persistentContainer.viewContext - let existing = try? context.fetch(SavedHashtag.fetchRequest(name: name)).first + let existing = try? context.fetch(SavedHashtag.fetchRequest(name: name, account: mastodonController.accountInfo!)).first let saveSubtitle = "Saved hashtags appear in the Explore section of Tusker" let saveImage = UIImage(systemName: existing != nil ? "minus" : "plus") actionsSection = [ @@ -119,7 +119,7 @@ extension MenuActionProvider { if let existing = existing { context.delete(existing) } else { - _ = SavedHashtag(hashtag: hashtag, context: context) + _ = SavedHashtag(hashtag: hashtag, account: mastodonController.accountInfo!, context: context) } mastodonController.persistentContainer.save(context: context) }) diff --git a/Tusker/Tusker.entitlements b/Tusker/Tusker.entitlements index ad0d7a74..c5342041 100644 --- a/Tusker/Tusker.entitlements +++ b/Tusker/Tusker.entitlements @@ -2,6 +2,18 @@ + aps-environment + development + com.apple.developer.aps-environment + development + com.apple.developer.icloud-container-identifiers + + iCloud.space.vaccor.Tusker + + com.apple.developer.icloud-services + + CloudKit + com.apple.security.app-sandbox com.apple.security.application-groups