Store.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  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 save() {
  48. do {
  49. try context.save()
  50. } catch let error {
  51. Log.w(Store.tag, "Cannot save context", error)
  52. rollbackAndRefresh()
  53. }
  54. }
  55. func rollbackAndRefresh() {
  56. // Hack: We refresh all objects, since failing to store a notification usually means
  57. // that the app extension stored the notification first. This is a way to update the
  58. // UI properly when it is in the foreground and the app extension stores a notification.
  59. Log.w(Store.tag, "Rolling back context")
  60. context.rollback()
  61. hardRefresh()
  62. }
  63. func hardRefresh() {
  64. // `refreshAllObjects` only refreshes objects from which the cache is invalid. With a staleness intervall of -1 the cache never invalidates.
  65. // We set the `stalenessInterval` to 0 to make sure that changes in the app extension get processed correctly.
  66. // From: https://www.avanderlee.com/swift/core-data-app-extension-data-sharing/
  67. context.stalenessInterval = 0
  68. context.refreshAllObjects()
  69. context.stalenessInterval = -1
  70. }
  71. // MARK: Subscriptions
  72. func saveSubscription(baseUrl: String, topic: String) -> Subscription {
  73. let subscription = Subscription(context: context)
  74. subscription.baseUrl = baseUrl
  75. subscription.topic = topic
  76. DispatchQueue.main.sync {
  77. try? context.save()
  78. }
  79. return subscription
  80. }
  81. func getSubscription(baseUrl: String, topic: String) -> Subscription? {
  82. let fetchRequest = Subscription.fetchRequest()
  83. let baseUrlPredicate = NSPredicate(format: "baseUrl = %@", baseUrl)
  84. let topicPredicate = NSPredicate(format: "topic = %@", topic)
  85. fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [baseUrlPredicate, topicPredicate])
  86. return try? context.fetch(fetchRequest).first
  87. }
  88. func getSubscriptions() -> [Subscription]? {
  89. return try? context.fetch(Subscription.fetchRequest())
  90. }
  91. func delete(subscription: Subscription) {
  92. context.delete(subscription)
  93. try? context.save()
  94. }
  95. // MARK: Notifications
  96. func saveNotification(fromMessage message: Message, withSubscription subscription: Subscription) {
  97. do {
  98. let notification = Notification(context: context)
  99. notification.id = message.id
  100. notification.time = message.time
  101. notification.message = message.message ?? ""
  102. notification.title = message.title ?? ""
  103. notification.priority = (message.priority != nil && message.priority != 0) ? message.priority! : 3
  104. notification.tags = message.tags?.joined(separator: ",") ?? ""
  105. notification.actions = Actions.shared.encode(message.actions)
  106. notification.click = message.click ?? ""
  107. if let att = message.attachment {
  108. let attachment = Attachment(context: context)
  109. attachment.name = att.name
  110. attachment.url = att.url
  111. attachment.type = att.type
  112. attachment.size = att.size ?? 0
  113. attachment.expires = att.expires ?? 0
  114. attachment.contentUrl = att.contentUrl
  115. notification.attachment = attachment
  116. }
  117. subscription.addToNotifications(notification)
  118. subscription.lastNotificationId = message.id
  119. try context.save()
  120. } catch let error {
  121. Log.w(Store.tag, "Cannot store notification (fromMessage)", error)
  122. rollbackAndRefresh()
  123. }
  124. }
  125. func delete(notification: Notification) {
  126. Log.d(Store.tag, "Deleting notification \(notification.id ?? "")")
  127. context.delete(notification)
  128. try? context.save()
  129. }
  130. func delete(notifications: Set<Notification>) {
  131. Log.d(Store.tag, "Deleting \(notifications.count) notification(s)")
  132. do {
  133. notifications.forEach { notification in
  134. context.delete(notification)
  135. }
  136. try context.save()
  137. } catch let error {
  138. Log.w(Store.tag, "Cannot delete notification(s)", error)
  139. rollbackAndRefresh()
  140. }
  141. }
  142. func delete(allNotificationsFor subscription: Subscription) {
  143. guard let notifications = subscription.notifications else { return }
  144. Log.d(Store.tag, "Deleting all \(notifications.count) notification(s) for subscription \(subscription.urlString())")
  145. do {
  146. notifications.forEach { notification in
  147. context.delete(notification as! Notification)
  148. }
  149. try context.save()
  150. } catch let error {
  151. Log.w(Store.tag, "Cannot delete notification(s)", error)
  152. rollbackAndRefresh()
  153. }
  154. }
  155. // MARK: Users
  156. func saveUser(baseUrl: String, username: String, password: String) {
  157. do {
  158. let user = getUser(baseUrl: baseUrl) ?? User(context: context)
  159. user.baseUrl = baseUrl
  160. user.username = username
  161. user.password = password
  162. try context.save()
  163. } catch let error {
  164. Log.w(Store.tag, "Cannot store user", error)
  165. rollbackAndRefresh()
  166. }
  167. }
  168. func getUser(baseUrl: String) -> User? {
  169. let request = User.fetchRequest()
  170. request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [NSPredicate(format: "baseUrl = %@", baseUrl)])
  171. return try? context.fetch(request).first
  172. }
  173. func delete(user: User) {
  174. context.delete(user)
  175. try? context.save()
  176. }
  177. // MARK: Preferences
  178. func saveDefaultBaseUrl(baseUrl: String?) {
  179. do {
  180. let pref = getPreference(key: Store.prefKeyDefaultBaseUrl) ?? Preference(context: context)
  181. pref.key = Store.prefKeyDefaultBaseUrl
  182. pref.value = baseUrl ?? Config.appBaseUrl
  183. try context.save()
  184. } catch let error {
  185. Log.w(Store.tag, "Cannot store preference", error)
  186. rollbackAndRefresh()
  187. }
  188. }
  189. func getDefaultBaseUrl() -> String {
  190. let baseUrl = getPreference(key: Store.prefKeyDefaultBaseUrl)?.value
  191. if baseUrl == nil || baseUrl?.isEmpty == true {
  192. return Config.appBaseUrl
  193. }
  194. return baseUrl!
  195. }
  196. private func getPreference(key: String) -> Preference? {
  197. let request = Preference.fetchRequest()
  198. request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [NSPredicate(format: "key = %@", key)])
  199. return try? context.fetch(request).first
  200. }
  201. }
  202. extension Store {
  203. static let sampleMessages = [
  204. "stats": [
  205. Message(
  206. id: "1",
  207. time: 1653048956,
  208. event: "message",
  209. topic: "stats",
  210. message: "In the last 24 hours, hyou had 5,000 users across 13 countries visit your website",
  211. title: "Record visitor numbers",
  212. priority: 4,
  213. tags: ["smile", "server123", "de"],
  214. actions: [
  215. MessageAction(id: "3344", action: "view", label: "Show panel", url: "https://xyz.com", method: nil, headers: nil, body: nil, clear: nil),
  216. MessageAction(id: "3344", action: "http", label: "POST it", url: "https://xyz.com", method: nil, headers: nil, body: nil, clear: nil)
  217. ]
  218. ),
  219. Message(
  220. id: "2",
  221. time: 1653058956,
  222. event: "message",
  223. topic: "stats",
  224. message: "201 users/h\n80 IPs",
  225. title: "This is a title",
  226. priority: 1,
  227. tags: [],
  228. actions: nil,
  229. attachment: MessageAttachment(name: "image.jpg", url: "https://bla.com/flower.jpg", type: nil, size: nil, expires: nil, contentUrl: nil)
  230. ),
  231. 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)
  232. ],
  233. "backups": [],
  234. "announcements": [],
  235. "alerts": [],
  236. "playground": []
  237. ]
  238. static var preview: Store = {
  239. let store = Store(inMemory: true)
  240. store.context.perform {
  241. // Subscriptions and notifications
  242. sampleMessages.forEach { topic, messages in
  243. store.makeSubscription(store.context, topic, messages)
  244. }
  245. // Users
  246. store.saveUser(baseUrl: "https://ntfy.sh", username: "testuser", password: "testuser")
  247. store.saveUser(baseUrl: "https://ntfy.example.com", username: "phil", password: "phil12")
  248. }
  249. return store
  250. }()
  251. static var previewEmpty: Store = {
  252. return Store(inMemory: true)
  253. }()
  254. @discardableResult
  255. func makeSubscription(_ context: NSManagedObjectContext, _ topic: String, _ messages: [Message]) -> Subscription {
  256. let notifications = messages.map { makeNotification(context, $0) }
  257. let subscription = Subscription(context: context)
  258. subscription.baseUrl = Config.appBaseUrl
  259. subscription.topic = topic
  260. subscription.notifications = NSSet(array: notifications)
  261. return subscription
  262. }
  263. @discardableResult
  264. func makeNotification(_ context: NSManagedObjectContext, _ message: Message) -> Notification {
  265. let notification = Notification(context: context)
  266. notification.id = message.id
  267. notification.time = message.time
  268. notification.message = message.message
  269. notification.title = message.title
  270. notification.priority = message.priority ?? 3
  271. notification.tags = message.tags?.joined(separator: ",") ?? ""
  272. return notification
  273. }
  274. }