Store.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  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. // TODO: this could probably broadcast the name of the channel
  41. // so that only relevant views can update.
  42. Log.d(Store.tag, "Remote change detected, refreshing views", value)
  43. DispatchQueue.main.async {
  44. self.hardRefresh()
  45. }
  46. }
  47. .store(in: &cancellables)
  48. }
  49. func rollbackAndRefresh() {
  50. // Hack: We refresh all objects, since failing to store a notification usually means
  51. // that the app extension stored the notification first. This is a way to update the
  52. // UI properly when it is in the foreground and the app extension stores a notification.
  53. context.rollback()
  54. hardRefresh()
  55. }
  56. func hardRefresh() {
  57. // `refreshAllObjects` only refreshes objects from which the cache is invalid. With a staleness intervall of -1 the cache never invalidates.
  58. // We set the `stalenessInterval` to 0 to make sure that changes in the app extension get processed correctly.
  59. // From: https://www.avanderlee.com/swift/core-data-app-extension-data-sharing/
  60. context.stalenessInterval = 0
  61. context.refreshAllObjects()
  62. context.stalenessInterval = -1
  63. }
  64. // MARK: Subscriptions
  65. func saveSubscription(baseUrl: String, topic: String) -> Subscription {
  66. let subscription = Subscription(context: context)
  67. subscription.baseUrl = baseUrl
  68. subscription.topic = topic
  69. DispatchQueue.main.sync {
  70. Log.d(Store.tag, "Storing subscription baseUrl=\(baseUrl), topic=\(topic)")
  71. try? context.save()
  72. }
  73. return subscription
  74. }
  75. func getSubscription(baseUrl: String, topic: String) -> Subscription? {
  76. let fetchRequest = Subscription.fetchRequest()
  77. let baseUrlPredicate = NSPredicate(format: "baseUrl = %@", baseUrl)
  78. let topicPredicate = NSPredicate(format: "topic = %@", topic)
  79. fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [baseUrlPredicate, topicPredicate])
  80. return try? context.fetch(fetchRequest).first
  81. }
  82. func getSubscriptions() -> [Subscription]? {
  83. return try? context.fetch(Subscription.fetchRequest())
  84. }
  85. func delete(subscription: Subscription) {
  86. context.delete(subscription)
  87. try? context.save()
  88. }
  89. // MARK: Notifications
  90. func save(notificationFromMessage message: Message, withSubscription subscription: Subscription) {
  91. do {
  92. let notification = Notification(context: context)
  93. notification.id = message.id
  94. notification.time = message.time
  95. notification.message = message.message ?? ""
  96. notification.title = message.title ?? ""
  97. notification.priority = (message.priority != nil && message.priority != 0) ? message.priority! : 3
  98. notification.tags = message.tags?.joined(separator: ",") ?? ""
  99. notification.actions = Actions.shared.encode(message.actions)
  100. notification.click = message.click ?? ""
  101. notification.subscription = subscription
  102. subscription.addToNotifications(notification)
  103. subscription.lastNotificationId = message.id
  104. Log.d(Store.tag, "Storing notification with ID \(notification.id ?? "<unknown>")")
  105. try context.save()
  106. } catch let error {
  107. Log.w(Store.tag, "Cannot store notification (fromMessage)", error)
  108. rollbackAndRefresh()
  109. }
  110. }
  111. func delete(notification: Notification) {
  112. Log.d(Store.tag, "Deleting notification \(notification.id ?? "")")
  113. context.delete(notification)
  114. try? context.save()
  115. }
  116. func delete(notifications: Set<Notification>) {
  117. Log.d(Store.tag, "Deleting \(notifications.count) notification(s)")
  118. do {
  119. notifications.forEach { notification in
  120. context.delete(notification)
  121. }
  122. try context.save()
  123. } catch let error {
  124. Log.w(Store.tag, "Cannot delete notification(s)", error)
  125. rollbackAndRefresh()
  126. }
  127. }
  128. func delete(allNotificationsFor subscription: Subscription) {
  129. guard let notifications = subscription.notifications else { return }
  130. Log.d(Store.tag, "Deleting all \(notifications.count) notification(s) for subscription \(subscription.urlString())")
  131. do {
  132. notifications.forEach { notification in
  133. context.delete(notification as! Notification)
  134. }
  135. try context.save()
  136. } catch let error {
  137. Log.w(Store.tag, "Cannot delete notification(s)", error)
  138. rollbackAndRefresh()
  139. }
  140. }
  141. // MARK: Users
  142. func saveUser(baseUrl: String, username: String, password: String) {
  143. do {
  144. let user = getUser(baseUrl: baseUrl) ?? User(context: context)
  145. user.baseUrl = baseUrl
  146. user.username = username
  147. user.password = password
  148. try context.save()
  149. } catch let error {
  150. Log.w(Store.tag, "Cannot store user", error)
  151. rollbackAndRefresh()
  152. }
  153. }
  154. func getUser(baseUrl: String) -> User? {
  155. let request = User.fetchRequest()
  156. request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [NSPredicate(format: "baseUrl = %@", baseUrl)])
  157. return try? context.fetch(request).first
  158. }
  159. func delete(user: User) {
  160. context.delete(user)
  161. try? context.save()
  162. }
  163. // MARK: Preferences
  164. func saveDefaultBaseUrl(baseUrl: String?) {
  165. do {
  166. let pref = getPreference(key: Store.prefKeyDefaultBaseUrl) ?? Preference(context: context)
  167. pref.key = Store.prefKeyDefaultBaseUrl
  168. pref.value = baseUrl ?? Config.appBaseUrl
  169. try context.save()
  170. } catch let error {
  171. Log.w(Store.tag, "Cannot store preference", error)
  172. rollbackAndRefresh()
  173. }
  174. }
  175. func getDefaultBaseUrl() -> String {
  176. let baseUrl = getPreference(key: Store.prefKeyDefaultBaseUrl)?.value
  177. if baseUrl == nil || baseUrl?.isEmpty == true {
  178. return Config.appBaseUrl
  179. }
  180. return baseUrl!
  181. }
  182. private func getPreference(key: String) -> Preference? {
  183. let request = Preference.fetchRequest()
  184. request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [NSPredicate(format: "key = %@", key)])
  185. return try? context.fetch(request).first
  186. }
  187. }
  188. extension Store {
  189. static let sampleMessages = [
  190. "stats": [
  191. // TODO: Message with action
  192. 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),
  193. 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),
  194. 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)
  195. ],
  196. "backups": [],
  197. "announcements": [],
  198. "alerts": [],
  199. "playground": []
  200. ]
  201. static var preview: Store = {
  202. let store = Store(inMemory: true)
  203. store.context.perform {
  204. // Subscriptions and notifications
  205. sampleMessages.forEach { topic, messages in
  206. store.makeSubscription(store.context, topic, messages)
  207. }
  208. // Users
  209. store.saveUser(baseUrl: "https://ntfy.sh", username: "testuser", password: "testuser")
  210. store.saveUser(baseUrl: "https://ntfy.example.com", username: "phil", password: "phil12")
  211. }
  212. return store
  213. }()
  214. static var previewEmpty: Store = {
  215. return Store(inMemory: true)
  216. }()
  217. @discardableResult
  218. func makeSubscription(_ context: NSManagedObjectContext, _ topic: String, _ messages: [Message]) -> Subscription {
  219. let notifications = messages.map { makeNotification(context, $0) }
  220. let subscription = Subscription(context: context)
  221. subscription.baseUrl = Config.appBaseUrl
  222. subscription.topic = topic
  223. subscription.notifications = NSSet(array: notifications)
  224. return subscription
  225. }
  226. @discardableResult
  227. func makeNotification(_ context: NSManagedObjectContext, _ message: Message) -> Notification {
  228. let notification = Notification(context: context)
  229. notification.id = message.id
  230. notification.time = message.time
  231. notification.message = message.message
  232. notification.title = message.title
  233. notification.priority = message.priority ?? 3
  234. notification.tags = message.tags?.joined(separator: ",") ?? ""
  235. return notification
  236. }
  237. }