Store.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  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 getUser(baseUrl: String) -> User? {
  66. let fetchRequest = User.fetchRequest()
  67. let baseUrlPredicate = NSPredicate(format: "baseUrl = %@", baseUrl)
  68. fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [baseUrlPredicate])
  69. return try? context.fetch(fetchRequest).first
  70. }
  71. func delete(user: User) {
  72. context.delete(user)
  73. try? context.save()
  74. }
  75. func delete(subscription: Subscription) {
  76. context.delete(subscription)
  77. try? context.save()
  78. }
  79. func saveUser(baseUrl: String, username: String, password: String) {
  80. do {
  81. let user = getUser(baseUrl: baseUrl) ?? User(context: context)
  82. user.baseUrl = baseUrl
  83. user.username = username
  84. user.password = password
  85. try context.save()
  86. } catch let error {
  87. Log.w(Store.tag, "Cannot store user", error)
  88. rollbackAndRefresh()
  89. }
  90. }
  91. func save(notificationFromMessage message: Message, withSubscription subscription: Subscription) {
  92. do {
  93. let notification = Notification(context: context)
  94. notification.id = message.id
  95. notification.time = message.time
  96. notification.message = message.message ?? ""
  97. notification.title = message.title ?? ""
  98. notification.priority = (message.priority != nil && message.priority != 0) ? message.priority! : 3
  99. notification.tags = message.tags?.joined(separator: ",") ?? ""
  100. notification.actions = Actions.shared.encode(message.actions)
  101. notification.click = message.click ?? ""
  102. subscription.addToNotifications(notification)
  103. subscription.lastNotificationId = message.id
  104. try context.save()
  105. } catch let error {
  106. Log.w(Store.tag, "Cannot store notification (fromMessage)", error)
  107. rollbackAndRefresh()
  108. }
  109. }
  110. func delete(notification: Notification) {
  111. Log.d(Store.tag, "Deleting notification \(notification.id ?? "")")
  112. context.delete(notification)
  113. try? context.save()
  114. }
  115. func delete(notifications: Set<Notification>) {
  116. Log.d(Store.tag, "Deleting \(notifications.count) notification(s)")
  117. do {
  118. notifications.forEach { notification in
  119. context.delete(notification)
  120. }
  121. try context.save()
  122. } catch let error {
  123. Log.w(Store.tag, "Cannot delete notification(s)", error)
  124. rollbackAndRefresh()
  125. }
  126. }
  127. func delete(allNotificationsFor subscription: Subscription) {
  128. guard let notifications = subscription.notifications else { return }
  129. Log.d(Store.tag, "Deleting all \(notifications.count) notification(s) for subscription \(subscription.urlString())")
  130. do {
  131. notifications.forEach { notification in
  132. context.delete(notification as! Notification)
  133. }
  134. try context.save()
  135. } catch let error {
  136. Log.w(Store.tag, "Cannot delete notification(s)", error)
  137. rollbackAndRefresh()
  138. }
  139. }
  140. func rollbackAndRefresh() {
  141. // Hack: We refresh all objects, since failing to store a notification usually means
  142. // that the app extension stored the notification first. This is a way to update the
  143. // UI properly when it is in the foreground and the app extension stores a notification.
  144. context.rollback()
  145. hardRefresh()
  146. }
  147. func hardRefresh() {
  148. // `refreshAllObjects` only refreshes objects from which the cache is invalid. With a staleness intervall of -1 the cache never invalidates.
  149. // We set the `stalenessInterval` to 0 to make sure that changes in the app extension get processed correctly.
  150. // From: https://www.avanderlee.com/swift/core-data-app-extension-data-sharing/
  151. context.stalenessInterval = 0
  152. context.refreshAllObjects()
  153. context.stalenessInterval = -1
  154. }
  155. }
  156. extension Store {
  157. static let sampleMessages = [
  158. "stats": [
  159. // TODO: Message with action
  160. 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),
  161. 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),
  162. 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)
  163. ],
  164. "backups": [],
  165. "announcements": [],
  166. "alerts": [],
  167. "playground": []
  168. ]
  169. static var preview: Store = {
  170. let store = Store(inMemory: true)
  171. store.context.perform {
  172. // Subscriptions and notifications
  173. sampleMessages.forEach { topic, messages in
  174. store.makeSubscription(store.context, topic, messages)
  175. }
  176. // Users
  177. store.saveUser(baseUrl: "https://ntfy.sh", username: "testuser", password: "testuser")
  178. store.saveUser(baseUrl: "https://ntfy.example.com", username: "phil", password: "phil12")
  179. }
  180. return store
  181. }()
  182. static var previewEmpty: Store = {
  183. return Store(inMemory: true)
  184. }()
  185. @discardableResult
  186. func makeSubscription(_ context: NSManagedObjectContext, _ topic: String, _ messages: [Message]) -> Subscription {
  187. let notifications = messages.map { makeNotification(context, $0) }
  188. let subscription = Subscription(context: context)
  189. subscription.baseUrl = Config.appBaseUrl
  190. subscription.topic = topic
  191. subscription.notifications = NSSet(array: notifications)
  192. return subscription
  193. }
  194. @discardableResult
  195. func makeNotification(_ context: NSManagedObjectContext, _ message: Message) -> Notification {
  196. let notification = Notification(context: context)
  197. notification.id = message.id
  198. notification.time = message.time
  199. notification.message = message.message
  200. notification.title = message.title
  201. notification.priority = message.priority ?? 3
  202. notification.tags = message.tags?.joined(separator: ",") ?? ""
  203. return notification
  204. }
  205. }