Store.swift 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. import Foundation
  2. import CoreData
  3. import Combine
  4. /// Handles all persistence in the app by storing/loading subscriptions and notifications using Core Data.
  5. /// There are sadly a lot of hacks in here, because I don't quite understand this fully.
  6. class Store: ObservableObject {
  7. static let shared = Store()
  8. static let tag = "Store"
  9. static let appGroup = "group.io.heckel.ntfy" // Must match app group of ntfy = ntfyNSE targets
  10. static let modelName = "ntfy" // Must match .xdatamodeld folder
  11. private let container: NSPersistentContainer
  12. var context: NSManagedObjectContext {
  13. return container.viewContext
  14. }
  15. private var cancellables: Set<AnyCancellable> = []
  16. init(inMemory: Bool = false) {
  17. let storeUrl = (inMemory) ? URL(fileURLWithPath: "/dev/null") : FileManager.default
  18. .containerURL(forSecurityApplicationGroupIdentifier: Store.appGroup)!
  19. .appendingPathComponent("ntfy.sqlite")
  20. let description = NSPersistentStoreDescription(url: storeUrl)
  21. description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
  22. // Set up container and observe changes from app extension
  23. container = NSPersistentContainer(name: Store.modelName)
  24. container.persistentStoreDescriptions = [description]
  25. container.loadPersistentStores { description, error in
  26. if let error = error {
  27. Log.e(Store.tag, "Core Data failed to load: \(error.localizedDescription)", error)
  28. }
  29. }
  30. // Shortcut for context
  31. context.automaticallyMergesChangesFromParent = true
  32. context.mergePolicy = NSMergePolicy(merge: .mergeByPropertyStoreTrumpMergePolicyType) // https://stackoverflow.com/a/60362945/1440785
  33. context.transactionAuthor = Bundle.main.bundlePath.hasSuffix(".appex") ? "ntfy.appex" : "ntfy"
  34. // When a remote change comes in (= the app extension updated entities in Core Data),
  35. // we force refresh the view with horrible means. Please help me make this better!
  36. NotificationCenter.default
  37. .publisher(for: .NSPersistentStoreRemoteChange)
  38. .sink { value in
  39. Log.d(Store.tag, "Remote change detected, refreshing view", value)
  40. DispatchQueue.main.async {
  41. self.hardRefresh()
  42. }
  43. }
  44. .store(in: &cancellables)
  45. }
  46. func saveSubscription(baseUrl: String, topic: String) -> Subscription {
  47. let subscription = Subscription(context: context)
  48. subscription.baseUrl = baseUrl
  49. subscription.topic = topic
  50. DispatchQueue.main.sync {
  51. try? context.save()
  52. }
  53. return subscription
  54. }
  55. func getSubscription(baseUrl: String, topic: String) -> Subscription? {
  56. let fetchRequest = Subscription.fetchRequest()
  57. let baseUrlPredicate = NSPredicate(format: "baseUrl = %@", baseUrl)
  58. let topicPredicate = NSPredicate(format: "topic = %@", topic)
  59. fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [baseUrlPredicate, topicPredicate])
  60. return try? context.fetch(fetchRequest).first
  61. }
  62. func getSubscriptions() -> [Subscription]? {
  63. return try? context.fetch(Subscription.fetchRequest())
  64. }
  65. func delete(subscription: Subscription) {
  66. context.delete(subscription)
  67. try? context.save()
  68. }
  69. func save(notificationFromMessage message: Message, withSubscription subscription: Subscription) {
  70. do {
  71. let notification = Notification(context: context)
  72. notification.id = message.id
  73. notification.time = message.time
  74. notification.message = message.message ?? ""
  75. notification.title = message.title ?? ""
  76. notification.priority = (message.priority != nil && message.priority != 0) ? message.priority! : 3
  77. notification.tags = message.tags?.joined(separator: ",") ?? ""
  78. notification.actions = Actions.shared.encode(message.actions)
  79. notification.click = message.click ?? ""
  80. subscription.addToNotifications(notification)
  81. subscription.lastNotificationId = message.id
  82. try context.save()
  83. } catch let error {
  84. Log.w(Store.tag, "Cannot store notification (fromMessage)", error)
  85. rollbackAndRefresh()
  86. }
  87. }
  88. func delete(notification: Notification) {
  89. Log.d(Store.tag, "Deleting notification \(notification.id ?? "")")
  90. context.delete(notification)
  91. try? context.save()
  92. }
  93. func delete(notifications: Set<Notification>) {
  94. Log.d(Store.tag, "Deleting \(notifications.count) notification(s)")
  95. do {
  96. notifications.forEach { notification in
  97. context.delete(notification)
  98. }
  99. try context.save()
  100. } catch let error {
  101. Log.w(Store.tag, "Cannot delete notification(s)", error)
  102. rollbackAndRefresh()
  103. }
  104. }
  105. func delete(allNotificationsFor subscription: Subscription) {
  106. guard let notifications = subscription.notifications else { return }
  107. Log.d(Store.tag, "Deleting all \(notifications.count) notification(s) for subscription \(subscription.urlString())")
  108. do {
  109. notifications.forEach { notification in
  110. context.delete(notification as! Notification)
  111. }
  112. try context.save()
  113. } catch let error {
  114. Log.w(Store.tag, "Cannot delete notification(s)", error)
  115. rollbackAndRefresh()
  116. }
  117. }
  118. func rollbackAndRefresh() {
  119. // Hack: We refresh all objects, since failing to store a notification usually means
  120. // that the app extension stored the notification first. This is a way to update the
  121. // UI properly when it is in the foreground and the app extension stores a notification.
  122. context.rollback()
  123. hardRefresh()
  124. }
  125. func hardRefresh() {
  126. // `refreshAllObjects` only refreshes objects from which the cache is invalid. With a staleness intervall of -1 the cache never invalidates.
  127. // We set the `stalenessInterval` to 0 to make sure that changes in the app extension get processed correctly.
  128. // From: https://www.avanderlee.com/swift/core-data-app-extension-data-sharing/
  129. context.stalenessInterval = 0
  130. context.refreshAllObjects()
  131. context.stalenessInterval = -1
  132. }
  133. }
  134. extension Store {
  135. static let sampleData = [
  136. "stats": [
  137. // TODO: Message with action
  138. 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),
  139. 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),
  140. 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)
  141. ],
  142. "backups": [],
  143. "announcements": [],
  144. "alerts": [],
  145. "plaground": []
  146. ]
  147. static var preview: Store = {
  148. let store = Store(inMemory: true)
  149. store.context.perform {
  150. sampleData.forEach { topic, messages in
  151. store.makeSubscription(store.context, topic, messages)
  152. }
  153. }
  154. return store
  155. }()
  156. static var previewEmpty: Store = {
  157. return Store(inMemory: true)
  158. }()
  159. @discardableResult
  160. func makeSubscription(_ context: NSManagedObjectContext, _ topic: String, _ messages: [Message]) -> Subscription {
  161. let notifications = messages.map { makeNotification(context, $0) }
  162. let subscription = Subscription(context: context)
  163. subscription.baseUrl = Config.appBaseUrl
  164. subscription.topic = topic
  165. subscription.notifications = NSSet(array: notifications)
  166. return subscription
  167. }
  168. @discardableResult
  169. func makeNotification(_ context: NSManagedObjectContext, _ message: Message) -> Notification {
  170. let notification = Notification(context: context)
  171. notification.id = message.id
  172. notification.time = message.time
  173. notification.message = message.message
  174. notification.title = message.title
  175. notification.priority = message.priority ?? 3
  176. notification.tags = message.tags?.joined(separator: ",") ?? ""
  177. return notification
  178. }
  179. }