Sync saved hashtags and instances over iCloud

Closes #160
This commit is contained in:
Shadowfacts 2022-12-19 10:58:14 -05:00
parent e0d97cd2a8
commit d13b517128
13 changed files with 186 additions and 72 deletions

View File

@ -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<String, Never>()
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

View File

@ -14,24 +14,32 @@ import WebURLFoundationExtras
@objc(SavedHashtag)
public final class SavedHashtag: NSManagedObject {
@nonobjc public class func fetchRequest() -> NSFetchRequest<SavedHashtag> {
@nonobjc class func fetchRequestWithoutAccountForMigrating() -> NSFetchRequest<SavedHashtag> {
return NSFetchRequest<SavedHashtag>(entityName: "SavedHashtag")
}
@nonobjc public class func fetchRequest(name: String) -> NSFetchRequest<SavedHashtag> {
@nonobjc class func fetchRequest(account: LocalData.UserAccountInfo) -> NSFetchRequest<SavedHashtag> {
let req = NSFetchRequest<SavedHashtag>(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<SavedHashtag> {
let req = NSFetchRequest<SavedHashtag>(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)!
}

View File

@ -12,23 +12,31 @@ import CoreData
@objc(SavedInstance)
public final class SavedInstance: NSManagedObject {
@nonobjc public class func fetchRequest() -> NSFetchRequest<SavedInstance> {
@nonobjc class func fetchRequestWithoutAccountForMigrating() -> NSFetchRequest<SavedInstance> {
return NSFetchRequest<SavedInstance>(entityName: "SavedInstance")
}
@nonobjc public class func fetchRequest(url: URL) -> NSFetchRequest<SavedInstance> {
let req = fetchRequest()
req.predicate = NSPredicate(format: "url = %@", url as NSURL)
@nonobjc class func fetchRequest(account: LocalData.UserAccountInfo) -> NSFetchRequest<SavedInstance> {
let req = NSFetchRequest<SavedInstance>(entityName: "SavedInstance")
req.predicate = NSPredicate(format: "accountID = %@", account.id)
return req
}
@nonobjc class func fetchRequest(url: URL, account: LocalData.UserAccountInfo) -> NSFetchRequest<SavedInstance> {
let req = NSFetchRequest<SavedInstance>(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
}
}

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21512" systemVersion="22A380" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22A380" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="AccountMO" syncable="YES">
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" optional="YES" attributeType="URI"/>
@ -60,21 +60,13 @@
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="relationship" inverseEntity="Account"/>
</entity>
<entity name="SavedHashtag" representedClassName="SavedHashtag" syncable="YES">
<attribute name="name" attributeType="String"/>
<attribute name="url" attributeType="URI"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="name"/>
</uniquenessConstraint>
</uniquenessConstraints>
<attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="url" optional="YES" attributeType="URI"/>
</entity>
<entity name="SavedInstance" representedClassName="SavedInstance" syncable="YES">
<attribute name="url" attributeType="URI"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="url"/>
</uniquenessConstraint>
</uniquenessConstraints>
<attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="url" optional="YES" attributeType="URI"/>
</entity>
<entity name="Status" representedClassName="StatusMO" syncable="YES">
<attribute name="applicationName" optional="YES" attributeType="String"/>
@ -117,4 +109,17 @@
<attribute name="timelineKind" attributeType="String" valueTransformerName="pachydermTimeline" customClassName="Tusker.TimelineContainer"/>
<relationship name="statuses" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="Status" inverseName="timelines" inverseEntity="Status"/>
</entity>
<configuration name="Cloud" usedWithCloudKit="YES">
<memberEntity name="SavedHashtag"/>
<memberEntity name="SavedInstance"/>
</configuration>
<configuration name="Local">
<memberEntity name="Account"/>
<memberEntity name="Filter"/>
<memberEntity name="FilterKeyword"/>
<memberEntity name="FollowedHashtag"/>
<memberEntity name="Relationship"/>
<memberEntity name="Status"/>
<memberEntity name="TimelineState"/>
</configuration>
</model>

View File

@ -2,37 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SentryDSN</key>
<string>$(SENTRY_DSN)</string>
<key>OSLogPreferences</key>
<dict>
<key>space.vaccor.Tusker</key>
<dict>
<key>DEFAULT-OPTIONS</key>
<dict>
<key>TTL</key>
<dict>
<key>Fault</key>
<integer>30</integer>
<key>Error</key>
<integer>30</integer>
<key>Debug</key>
<integer>15</integer>
<key>Info</key>
<integer>30</integer>
<key>Default</key>
<integer>30</integer>
</dict>
<key>Level</key>
<dict>
<key>Persist</key>
<string>Debug</string>
<key>Enable</key>
<string>Debug</string>
</dict>
</dict>
</dict>
</dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
@ -87,7 +56,7 @@
<key>NSMicrophoneUsageDescription</key>
<string>Post videos from the camera.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Save photos directly from other people&apos;s posts.</string>
<string>Save photos directly from other people's posts.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Post photos from the photo library.</string>
<key>NSUserActivityTypes</key>
@ -102,6 +71,37 @@
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-profile</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.main-scene</string>
</array>
<key>OSLogPreferences</key>
<dict>
<key>space.vaccor.Tusker</key>
<dict>
<key>DEFAULT-OPTIONS</key>
<dict>
<key>Level</key>
<dict>
<key>Enable</key>
<string>Debug</string>
<key>Persist</key>
<string>Debug</string>
</dict>
<key>TTL</key>
<dict>
<key>Debug</key>
<integer>15</integer>
<key>Default</key>
<integer>30</integer>
<key>Error</key>
<integer>30</integer>
<key>Fault</key>
<integer>30</integer>
<key>Info</key>
<integer>30</integer>
</dict>
</dict>
</dict>
</dict>
<key>SentryDSN</key>
<string>$(SENTRY_DSN)</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
@ -140,6 +140,7 @@
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>remote-notification</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,18 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.space.vaccor.Tusker</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>