123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234 |
- import Foundation
- import CoreData
- import Combine
- /// Handles all persistence in the app by storing/loading subscriptions and notifications using Core Data.
- /// There are sadly a lot of hacks in here, because I don't quite understand this fully.
- class Store: ObservableObject {
- static let shared = Store()
- static let tag = "Store"
- static let appGroup = "group.io.heckel.ntfy" // Must match app group of ntfy = ntfyNSE targets
- static let modelName = "ntfy" // Must match .xdatamodeld folder
-
- private let container: NSPersistentContainer
- var context: NSManagedObjectContext {
- return container.viewContext
- }
- private var cancellables: Set<AnyCancellable> = []
- init(inMemory: Bool = false) {
- let storeUrl = (inMemory) ? URL(fileURLWithPath: "/dev/null") : FileManager.default
- .containerURL(forSecurityApplicationGroupIdentifier: Store.appGroup)!
- .appendingPathComponent("ntfy.sqlite")
- let description = NSPersistentStoreDescription(url: storeUrl)
- description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
- // Set up container and observe changes from app extension
- container = NSPersistentContainer(name: Store.modelName)
- container.persistentStoreDescriptions = [description]
- container.loadPersistentStores { description, error in
- if let error = error {
- Log.e(Store.tag, "Core Data failed to load: \(error.localizedDescription)", error)
- }
- }
-
- // Shortcut for context
- context.automaticallyMergesChangesFromParent = true
- context.mergePolicy = NSMergePolicy(merge: .mergeByPropertyStoreTrumpMergePolicyType) // https://stackoverflow.com/a/60362945/1440785
- context.transactionAuthor = Bundle.main.bundlePath.hasSuffix(".appex") ? "ntfy.appex" : "ntfy"
-
- // When a remote change comes in (= the app extension updated entities in Core Data),
- // we force refresh the view with horrible means. Please help me make this better!
- NotificationCenter.default
- .publisher(for: .NSPersistentStoreRemoteChange)
- .sink { value in
- Log.d(Store.tag, "Remote change detected, refreshing view", value)
- DispatchQueue.main.async {
- self.hardRefresh()
- }
- }
- .store(in: &cancellables)
- }
-
- func saveSubscription(baseUrl: String, topic: String) -> Subscription {
- let subscription = Subscription(context: context)
- subscription.baseUrl = baseUrl
- subscription.topic = topic
- DispatchQueue.main.sync {
- try? context.save()
- }
- return subscription
- }
-
- func getSubscription(baseUrl: String, topic: String) -> Subscription? {
- let fetchRequest = Subscription.fetchRequest()
- let baseUrlPredicate = NSPredicate(format: "baseUrl = %@", baseUrl)
- let topicPredicate = NSPredicate(format: "topic = %@", topic)
-
- fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [baseUrlPredicate, topicPredicate])
-
- return try? context.fetch(fetchRequest).first
- }
-
- func getSubscriptions() -> [Subscription]? {
- return try? context.fetch(Subscription.fetchRequest())
- }
-
- func getUser(baseUrl: String) -> User? {
- let fetchRequest = User.fetchRequest()
- let baseUrlPredicate = NSPredicate(format: "baseUrl = %@", baseUrl)
- fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [baseUrlPredicate])
- return try? context.fetch(fetchRequest).first
- }
-
- func delete(user: User) {
- context.delete(user)
- try? context.save()
- }
-
- func delete(subscription: Subscription) {
- context.delete(subscription)
- try? context.save()
- }
-
- func saveUser(baseUrl: String, username: String, password: String) {
- do {
- let user = getUser(baseUrl: baseUrl) ?? User(context: context)
- user.baseUrl = baseUrl
- user.username = username
- user.password = password
- try context.save()
- } catch let error {
- Log.w(Store.tag, "Cannot store user", error)
- rollbackAndRefresh()
- }
- }
-
- func save(notificationFromMessage message: Message, withSubscription subscription: Subscription) {
- do {
- let notification = Notification(context: context)
- notification.id = message.id
- notification.time = message.time
- notification.message = message.message ?? ""
- notification.title = message.title ?? ""
- notification.priority = (message.priority != nil && message.priority != 0) ? message.priority! : 3
- notification.tags = message.tags?.joined(separator: ",") ?? ""
- notification.actions = Actions.shared.encode(message.actions)
- notification.click = message.click ?? ""
- subscription.addToNotifications(notification)
- subscription.lastNotificationId = message.id
- try context.save()
- } catch let error {
- Log.w(Store.tag, "Cannot store notification (fromMessage)", error)
- rollbackAndRefresh()
- }
- }
-
- func delete(notification: Notification) {
- Log.d(Store.tag, "Deleting notification \(notification.id ?? "")")
- context.delete(notification)
- try? context.save()
- }
-
- func delete(notifications: Set<Notification>) {
- Log.d(Store.tag, "Deleting \(notifications.count) notification(s)")
- do {
- notifications.forEach { notification in
- context.delete(notification)
- }
- try context.save()
- } catch let error {
- Log.w(Store.tag, "Cannot delete notification(s)", error)
- rollbackAndRefresh()
- }
- }
-
- func delete(allNotificationsFor subscription: Subscription) {
- guard let notifications = subscription.notifications else { return }
- Log.d(Store.tag, "Deleting all \(notifications.count) notification(s) for subscription \(subscription.urlString())")
- do {
- notifications.forEach { notification in
- context.delete(notification as! Notification)
- }
- try context.save()
- } catch let error {
- Log.w(Store.tag, "Cannot delete notification(s)", error)
- rollbackAndRefresh()
- }
- }
-
- func rollbackAndRefresh() {
- // Hack: We refresh all objects, since failing to store a notification usually means
- // that the app extension stored the notification first. This is a way to update the
- // UI properly when it is in the foreground and the app extension stores a notification.
-
- context.rollback()
- hardRefresh()
- }
-
- func hardRefresh() {
- // `refreshAllObjects` only refreshes objects from which the cache is invalid. With a staleness intervall of -1 the cache never invalidates.
- // We set the `stalenessInterval` to 0 to make sure that changes in the app extension get processed correctly.
- // From: https://www.avanderlee.com/swift/core-data-app-extension-data-sharing/
-
- context.stalenessInterval = 0
- context.refreshAllObjects()
- context.stalenessInterval = -1
- }
- }
- extension Store {
- static let sampleMessages = [
- "stats": [
- // TODO: Message with action
- Message(id: "1", time: 1653048956, event: "message", topic: "stats", message: "In the last 24 hours, hyou had 5,000 users across 13 countries visit your website", title: "Record visitor numbers", priority: 4, tags: ["smile", "server123", "de"], actions: nil),
- Message(id: "2", time: 1653058956, event: "message", topic: "stats", message: "201 users/h\n80 IPs", title: "This is a title", priority: 1, tags: [], actions: nil),
- Message(id: "3", time: 1643058956, event: "message", topic: "stats", message: "This message does not have a title, but is instead super long. Like really really long. It can't be any longer I think. I mean, there is s 4,000 byte limit of the message, so I guess I have to make this 4,000 bytes long. Or do I? 😁 I don't know. It's quite tedious to come up with something so long, so I'll stop now. Bye!", title: nil, priority: 5, tags: ["facepalm"], actions: nil)
- ],
- "backups": [],
- "announcements": [],
- "alerts": [],
- "playground": []
- ]
-
- static var preview: Store = {
- let store = Store(inMemory: true)
- store.context.perform {
- // Subscriptions and notifications
- sampleMessages.forEach { topic, messages in
- store.makeSubscription(store.context, topic, messages)
- }
-
- // Users
- store.saveUser(baseUrl: "https://ntfy.sh", username: "testuser", password: "testuser")
- store.saveUser(baseUrl: "https://ntfy.example.com", username: "phil", password: "phil12")
- }
- return store
- }()
-
- static var previewEmpty: Store = {
- return Store(inMemory: true)
- }()
-
- @discardableResult
- func makeSubscription(_ context: NSManagedObjectContext, _ topic: String, _ messages: [Message]) -> Subscription {
- let notifications = messages.map { makeNotification(context, $0) }
- let subscription = Subscription(context: context)
- subscription.baseUrl = Config.appBaseUrl
- subscription.topic = topic
- subscription.notifications = NSSet(array: notifications)
- return subscription
- }
-
- @discardableResult
- func makeNotification(_ context: NSManagedObjectContext, _ message: Message) -> Notification {
- let notification = Notification(context: context)
- notification.id = message.id
- notification.time = message.time
- notification.message = message.message
- notification.title = message.title
- notification.priority = message.priority ?? 3
- notification.tags = message.tags?.joined(separator: ",") ?? ""
- return notification
- }
- }
|