Store.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  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. static let prefKeyDefaultBaseUrl = "defaultBaseUrl"
  12. private let container: NSPersistentContainer
  13. var context: NSManagedObjectContext {
  14. return container.viewContext
  15. }
  16. private var cancellables: Set<AnyCancellable> = []
  17. init(inMemory: Bool = false) {
  18. let storeUrl = (inMemory) ? URL(fileURLWithPath: "/dev/null") : FileManager.default
  19. .containerURL(forSecurityApplicationGroupIdentifier: Store.appGroup)!
  20. .appendingPathComponent("ntfy.sqlite")
  21. let description = NSPersistentStoreDescription(url: storeUrl)
  22. description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
  23. // Set up container and observe changes from app extension
  24. container = NSPersistentContainer(name: Store.modelName)
  25. container.persistentStoreDescriptions = [description]
  26. container.loadPersistentStores { description, error in
  27. if let error = error {
  28. Log.e(Store.tag, "Core Data failed to load: \(error.localizedDescription)", error)
  29. }
  30. }
  31. // Shortcut for context
  32. context.automaticallyMergesChangesFromParent = true
  33. context.mergePolicy = NSMergePolicy(merge: .mergeByPropertyStoreTrumpMergePolicyType) // https://stackoverflow.com/a/60362945/1440785
  34. context.transactionAuthor = Bundle.main.bundlePath.hasSuffix(".appex") ? "ntfy.appex" : "ntfy"
  35. // When a remote change comes in (= the app extension updated entities in Core Data),
  36. // we force refresh the view with horrible means. Please help me make this better!
  37. NotificationCenter.default
  38. .publisher(for: .NSPersistentStoreRemoteChange)
  39. .sink { value in
  40. Log.d(Store.tag, "Remote change detected, refreshing view", value)
  41. DispatchQueue.main.async {
  42. self.hardRefresh()
  43. }
  44. }
  45. .store(in: &cancellables)
  46. }
  47. func rollbackAndRefresh() {
  48. // Hack: We refresh all objects, since failing to store a notification usually means
  49. // that the app extension stored the notification first. This is a way to update the
  50. // UI properly when it is in the foreground and the app extension stores a notification.
  51. context.rollback()
  52. hardRefresh()
  53. }
  54. func hardRefresh() {
  55. // `refreshAllObjects` only refreshes objects from which the cache is invalid. With a staleness intervall of -1 the cache never invalidates.
  56. // We set the `stalenessInterval` to 0 to make sure that changes in the app extension get processed correctly.
  57. // From: https://www.avanderlee.com/swift/core-data-app-extension-data-sharing/
  58. context.stalenessInterval = 0
  59. context.refreshAllObjects()
  60. context.stalenessInterval = -1
  61. }
  62. // MARK: Subscriptions
  63. func saveSubscription(baseUrl: String, topic: String) -> Subscription {
  64. let subscription = Subscription(context: context)
  65. subscription.baseUrl = baseUrl
  66. subscription.topic = topic
  67. DispatchQueue.main.sync {
  68. try? context.save()
  69. }
  70. return subscription
  71. }
  72. func getSubscription(baseUrl: String, topic: String) -> Subscription? {
  73. let fetchRequest = Subscription.fetchRequest()
  74. let baseUrlPredicate = NSPredicate(format: "baseUrl = %@", baseUrl)
  75. let topicPredicate = NSPredicate(format: "topic = %@", topic)
  76. fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [baseUrlPredicate, topicPredicate])
  77. return try? context.fetch(fetchRequest).first
  78. }
  79. func getSubscriptions() -> [Subscription]? {
  80. return try? context.fetch(Subscription.fetchRequest())
  81. }
  82. func delete(subscription: Subscription) {
  83. context.delete(subscription)
  84. try? context.save()
  85. }
  86. // MARK: Notifications
  87. func save(notificationFromMessage message: Message, withSubscription subscription: Subscription) {
  88. do {
  89. let notification = Notification(context: context)
  90. notification.id = message.id
  91. notification.time = message.time
  92. notification.message = message.message ?? ""
  93. notification.title = message.title ?? ""
  94. notification.priority = (message.priority != nil && message.priority != 0) ? message.priority! : 3
  95. notification.tags = message.tags?.joined(separator: ",") ?? ""
  96. notification.actions = Actions.shared.encode(message.actions)
  97. notification.click = message.click ?? ""
  98. subscription.addToNotifications(notification)
  99. subscription.lastNotificationId = message.id
  100. try context.save()
  101. } catch let error {
  102. Log.w(Store.tag, "Cannot store notification (fromMessage)", error)
  103. rollbackAndRefresh()
  104. }
  105. }
  106. func delete(notification: Notification) {
  107. Log.d(Store.tag, "Deleting notification \(notification.id ?? "")")
  108. context.delete(notification)
  109. try? context.save()
  110. }
  111. func delete(notifications: Set<Notification>) {
  112. Log.d(Store.tag, "Deleting \(notifications.count) notification(s)")
  113. do {
  114. notifications.forEach { notification in
  115. context.delete(notification)
  116. }
  117. try context.save()
  118. } catch let error {
  119. Log.w(Store.tag, "Cannot delete notification(s)", error)
  120. rollbackAndRefresh()
  121. }
  122. }
  123. func delete(allNotificationsFor subscription: Subscription) {
  124. guard let notifications = subscription.notifications else { return }
  125. Log.d(Store.tag, "Deleting all \(notifications.count) notification(s) for subscription \(subscription.urlString())")
  126. do {
  127. notifications.forEach { notification in
  128. context.delete(notification as! Notification)
  129. }
  130. try context.save()
  131. } catch let error {
  132. Log.w(Store.tag, "Cannot delete notification(s)", error)
  133. rollbackAndRefresh()
  134. }
  135. }
  136. // MARK: Users
  137. func saveUser(baseUrl: String, username: String, password: String) {
  138. do {
  139. let user = getUser(baseUrl: baseUrl) ?? User(context: context)
  140. user.baseUrl = baseUrl
  141. user.username = username
  142. user.password = password
  143. try context.save()
  144. } catch let error {
  145. Log.w(Store.tag, "Cannot store user", error)
  146. rollbackAndRefresh()
  147. }
  148. }
  149. func getUser(baseUrl: String) -> User? {
  150. let request = User.fetchRequest()
  151. request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [NSPredicate(format: "baseUrl = %@", baseUrl)])
  152. return try? context.fetch(request).first
  153. }
  154. func delete(user: User) {
  155. context.delete(user)
  156. try? context.save()
  157. }
  158. // MARK: Preferences
  159. func saveDefaultBaseUrl(baseUrl: String?) {
  160. do {
  161. let pref = getPreference(key: Store.prefKeyDefaultBaseUrl) ?? Preference(context: context)
  162. pref.key = Store.prefKeyDefaultBaseUrl
  163. pref.value = baseUrl ?? Config.appBaseUrl
  164. try context.save()
  165. } catch let error {
  166. Log.w(Store.tag, "Cannot store preference", error)
  167. rollbackAndRefresh()
  168. }
  169. }
  170. func getDefaultBaseUrl() -> String {
  171. let baseUrl = getPreference(key: Store.prefKeyDefaultBaseUrl)?.value
  172. if baseUrl == nil || baseUrl?.isEmpty == true {
  173. return Config.appBaseUrl
  174. }
  175. return baseUrl!
  176. }
  177. private func getPreference(key: String) -> Preference? {
  178. let request = Preference.fetchRequest()
  179. request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [NSPredicate(format: "key = %@", key)])
  180. return try? context.fetch(request).first
  181. }
  182. }
  183. extension Store {
  184. static let sampleMessages = [
  185. "stats": [
  186. // TODO: Message with action
  187. 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),
  188. 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),
  189. 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)
  190. ],
  191. "backups": [],
  192. "announcements": [],
  193. "alerts": [],
  194. "playground": []
  195. ]
  196. static var preview: Store = {
  197. let store = Store(inMemory: true)
  198. store.context.perform {
  199. // Subscriptions and notifications
  200. sampleMessages.forEach { topic, messages in
  201. store.makeSubscription(store.context, topic, messages)
  202. }
  203. // Users
  204. store.saveUser(baseUrl: "https://ntfy.sh", username: "testuser", password: "testuser")
  205. store.saveUser(baseUrl: "https://ntfy.example.com", username: "phil", password: "phil12")
  206. }
  207. return store
  208. }()
  209. static var previewEmpty: Store = {
  210. return Store(inMemory: true)
  211. }()
  212. @discardableResult
  213. func makeSubscription(_ context: NSManagedObjectContext, _ topic: String, _ messages: [Message]) -> Subscription {
  214. let notifications = messages.map { makeNotification(context, $0) }
  215. let subscription = Subscription(context: context)
  216. subscription.baseUrl = Config.appBaseUrl
  217. subscription.topic = topic
  218. subscription.notifications = NSSet(array: notifications)
  219. return subscription
  220. }
  221. @discardableResult
  222. func makeNotification(_ context: NSManagedObjectContext, _ message: Message) -> Notification {
  223. let notification = Notification(context: context)
  224. notification.id = message.id
  225. notification.time = message.time
  226. notification.message = message.message
  227. notification.title = message.title
  228. notification.priority = message.priority ?? 3
  229. notification.tags = message.tags?.joined(separator: ",") ?? ""
  230. return notification
  231. }
  232. }