NotificationDB.sys.mjs (10123B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const lazy = {}; 6 7 ChromeUtils.defineESModuleGetters(lazy, { 8 AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", 9 }); 10 11 ChromeUtils.defineLazyGetter(lazy, "console", () => { 12 return console.createInstance({ 13 prefix: "NotificationDB", 14 maxLogLevelPref: "dom.webnotifications.loglevel", 15 }); 16 }); 17 18 export class NotificationDB { 19 // Ensure we won't call init() while xpcom-shutdown is performed 20 #shutdownInProgress = false; 21 22 // A promise that resolves once the ongoing task queue has been drained. 23 // The value will be reset when the queue starts again. 24 #queueDrainedPromise = null; 25 #queueDrainedPromiseResolve = null; 26 27 #byTag = Object.create(null); 28 #notifications = Object.create(null); 29 #loaded = false; 30 #tasks = []; 31 #runningTask = null; 32 33 #storagePath = null; 34 35 constructor() { 36 if (this.#shutdownInProgress) { 37 return; 38 } 39 40 this.#notifications = Object.create(null); 41 this.#byTag = Object.create(null); 42 this.#loaded = false; 43 44 this.#tasks = []; // read/write operation queue 45 this.#runningTask = null; 46 47 // This assumes that nothing will queue a new task at profile-change-teardown phase, 48 // potentially replacing the #queueDrainedPromise if there was no existing task run. 49 lazy.AsyncShutdown.profileChangeTeardown.addBlocker( 50 "NotificationDB: Need to make sure that all notification messages are processed", 51 () => this.#queueDrainedPromise 52 ); 53 } 54 55 filterNonAppNotifications(notifications) { 56 let result = Object.create(null); 57 for (let origin in notifications) { 58 result[origin] = Object.create(null); 59 let persistentNotificationCount = 0; 60 for (let id in notifications[origin]) { 61 if (notifications[origin][id].serviceWorkerRegistrationScope) { 62 persistentNotificationCount++; 63 result[origin][id] = notifications[origin][id]; 64 } 65 } 66 if (persistentNotificationCount == 0) { 67 lazy.console.debug( 68 `Origin ${origin} is not linked to an app manifest, deleting.` 69 ); 70 delete result[origin]; 71 } 72 } 73 74 return result; 75 } 76 77 // Attempt to read notification file, if it's not there we will create it. 78 load() { 79 const NOTIFICATION_STORE_DIR = PathUtils.profileDir; 80 this.#storagePath = PathUtils.join( 81 NOTIFICATION_STORE_DIR, 82 "notificationstore.json" 83 ); 84 var promise = IOUtils.readUTF8(this.#storagePath); 85 return promise.then( 86 data => { 87 if (data.length) { 88 // Preprocessing phase intends to cleanly separate any migration-related 89 // tasks. 90 this.#notifications = this.filterNonAppNotifications( 91 JSON.parse(data) 92 ); 93 } 94 95 // populate the list of notifications by tag 96 if (this.#notifications) { 97 for (var origin in this.#notifications) { 98 this.#byTag[origin] = Object.create(null); 99 for (var id in this.#notifications[origin]) { 100 var curNotification = this.#notifications[origin][id]; 101 if (curNotification.tag) { 102 this.#byTag[origin][curNotification.tag] = curNotification; 103 } 104 } 105 } 106 } 107 108 this.#loaded = true; 109 }, 110 111 // If read failed, we assume we have no notifications to load. 112 () => { 113 this.#loaded = true; 114 return this.#createStore(NOTIFICATION_STORE_DIR); 115 } 116 ); 117 } 118 119 // Creates the notification directory. 120 #createStore(directory) { 121 var promise = IOUtils.makeDirectory(directory, { 122 ignoreExisting: true, 123 }); 124 return promise.then(this.createFile()); 125 } 126 127 // Creates the notification file once the directory is created. 128 createFile() { 129 return IOUtils.writeUTF8(this.#storagePath, "", { 130 tmpPath: this.#storagePath + ".tmp", 131 }); 132 } 133 134 // Save current notifications to the file. 135 save() { 136 var data = JSON.stringify(this.#notifications); 137 return IOUtils.writeUTF8(this.#storagePath, data, { 138 tmpPath: this.#storagePath + ".tmp", 139 }); 140 } 141 142 testGetRawMap() { 143 return { 144 notifications: this.#notifications, 145 byTag: this.#byTag, 146 }; 147 } 148 149 // Helper function: promise will be resolved once file exists and/or is loaded. 150 #ensureLoaded() { 151 if (!this.#loaded) { 152 return this.load(); 153 } 154 return Promise.resolve(); 155 } 156 157 // We need to make sure any read/write operations are atomic, 158 // so use a queue to run each operation sequentially. 159 queueTask(operation, data) { 160 lazy.console.debug(`Queueing task: ${operation}`); 161 162 var defer = {}; 163 164 this.#tasks.push({ 165 operation, 166 data, 167 defer, 168 }); 169 170 var promise = new Promise((resolve, reject) => { 171 defer.resolve = resolve; 172 defer.reject = reject; 173 }); 174 175 // Only run immediately if we aren't currently running another task. 176 if (!this.#runningTask) { 177 lazy.console.debug("Task queue was not running, starting now..."); 178 this.runNextTask(); 179 this.#queueDrainedPromise = new Promise(resolve => { 180 this.#queueDrainedPromiseResolve = resolve; 181 }); 182 } 183 184 return promise; 185 } 186 187 runNextTask() { 188 if (this.#tasks.length === 0) { 189 lazy.console.debug("No more tasks to run, queue depleted"); 190 this.#runningTask = null; 191 if (this.#queueDrainedPromiseResolve) { 192 this.#queueDrainedPromiseResolve(); 193 } else { 194 lazy.console.debug( 195 "#queueDrainedPromiseResolve was null somehow, no promise to resolve" 196 ); 197 } 198 return; 199 } 200 this.#runningTask = this.#tasks.shift(); 201 202 // Always make sure we are loaded before performing any read/write tasks. 203 this.#ensureLoaded() 204 .then(() => { 205 var task = this.#runningTask; 206 207 switch (task.operation) { 208 case "getall": 209 return this.taskGetAll(task.data); 210 211 case "get": 212 return this.taskGet(task.data); 213 214 case "save": 215 return this.taskSave(task.data); 216 217 case "delete": 218 return this.taskDelete(task.data); 219 220 case "deleteAllExcept": 221 return this.taskDeleteAllExcept(task.data); 222 223 default: 224 return Promise.reject( 225 new Error(`Found a task with unknown operation ${task.operation}`) 226 ); 227 } 228 }) 229 .then(payload => { 230 lazy.console.debug(`Finishing task: ${this.#runningTask.operation}`); 231 this.#runningTask.defer.resolve(payload); 232 }) 233 .catch(err => { 234 lazy.console.debug( 235 `Error while running ${this.#runningTask.operation}: ${err}` 236 ); 237 this.#runningTask.defer.reject(err); 238 }) 239 .then(() => { 240 this.runNextTask(); 241 }); 242 } 243 244 removeOriginIfEmpty(origin) { 245 if (!Object.keys(this.#notifications[origin]).length) { 246 delete this.#notifications[origin]; 247 delete this.#byTag[origin]; 248 } 249 } 250 251 taskGetAll(data) { 252 let { origin, scope } = data; 253 lazy.console.debug( 254 `Task, getting all for the origin ${origin} and SWR scope ${scope}` 255 ); 256 257 // Grab only the notifications for specified origin. 258 if (!this.#notifications[origin]) { 259 return []; 260 } 261 262 // XXX(krosylight): same-tagged notifications from different SWRs can collide. 263 // See bug 1950159. 264 if (data.tag) { 265 let n = this.#byTag[origin][data.tag]; 266 if (n && n.serviceWorkerRegistrationScope === data.scope) { 267 return [n]; 268 } 269 return []; 270 } 271 272 let notifications = Object.values(this.#notifications[origin]).filter( 273 n => n.serviceWorkerRegistrationScope === data.scope 274 ); 275 return notifications; 276 } 277 278 taskGet(data) { 279 let { origin, id } = data; 280 lazy.console.debug(`Task, getting for the origin ${origin} and ID ${id}`); 281 return this.#notifications[origin]?.[id]; 282 } 283 284 taskSave(data) { 285 lazy.console.debug("Task, saving"); 286 var origin = data.origin; 287 var notification = data.notification; 288 if (!this.#notifications[origin]) { 289 this.#notifications[origin] = Object.create(null); 290 this.#byTag[origin] = Object.create(null); 291 } 292 293 // We might have existing notification with this tag, 294 // if so we need to remove it before saving the new one. 295 if (notification.tag) { 296 var oldNotification = this.#byTag[origin][notification.tag]; 297 if (oldNotification) { 298 delete this.#notifications[origin][oldNotification.id]; 299 } 300 this.#byTag[origin][notification.tag] = notification; 301 } 302 303 this.#notifications[origin][notification.id] = notification; 304 return this.save(); 305 } 306 307 taskDelete(data) { 308 lazy.console.debug("Task, deleting"); 309 var origin = data.origin; 310 var id = data.id; 311 if (!this.#notifications[origin]) { 312 lazy.console.debug(`No notifications found for origin: ${origin}`); 313 return Promise.resolve(); 314 } 315 316 // Make sure we can find the notification to delete. 317 var oldNotification = this.#notifications[origin][id]; 318 if (!oldNotification) { 319 lazy.console.debug(`No notification found with id: ${id}`); 320 return Promise.resolve(); 321 } 322 323 if (oldNotification.tag) { 324 delete this.#byTag[origin][oldNotification.tag]; 325 } 326 delete this.#notifications[origin][id]; 327 this.removeOriginIfEmpty(origin); 328 return this.save(); 329 } 330 331 taskDeleteAllExcept({ ids }) { 332 lazy.console.debug("Task, deleting all"); 333 334 const entries = Object.entries(this.#notifications); 335 for (const [origin, data] of entries) { 336 const originEntries = Object.entries(data).filter( 337 ([id]) => !ids.includes(id) 338 ); 339 for (const [id, oldNotification] of originEntries) { 340 delete data[id]; 341 if (oldNotification.tag) { 342 delete this.#byTag[origin][oldNotification.tag]; 343 } 344 } 345 this.removeOriginIfEmpty(origin); 346 } 347 348 return this.save(); 349 } 350 } 351 352 export const db = new NotificationDB();