no bug - Bumping Firefox l10n changesets r=release a=l10n-bump DONTBUILD CLOSED TREE
[gecko.git] / dom / notification / new / NotificationDB.sys.mjs
blob146ddd2750ae572d2edf35907acfdcd02b0e9aee
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/. */
5 const DEBUG = false;
6 function debug(s) {
7   dump("-*- NotificationDB component: " + s + "\n");
10 const lazy = {};
12 ChromeUtils.defineESModuleGetters(lazy, {
13   AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
14   KeyValueService: "resource://gre/modules/kvstore.sys.mjs",
15 });
17 const kMessages = [
18   "Notification:Save",
19   "Notification:Delete",
20   "Notification:GetAll",
23 // Given its origin and ID, produce the key that uniquely identifies
24 // a notification.
25 function makeKey(origin, id) {
26   return origin.concat("\t", id);
29 var NotificationDB = {
30   // Ensure we won't call init() while xpcom-shutdown is performed
31   _shutdownInProgress: false,
33   // A handle to the kvstore, retrieved lazily when we load the data.
34   _store: null,
36   // A promise that resolves once the store has been loaded.
37   // The promise doesn't resolve to a value; it merely captures the state
38   // of the load via its resolution.
39   _loadPromise: null,
41   // A promise that resolves once the ongoing task queue has been drained.
42   // The value will be reset when the queue starts again.
43   _queueDrainedPromise: null,
44   _queueDrainedPromiseResolve: null,
46   init() {
47     if (this._shutdownInProgress) {
48       return;
49     }
51     this.tasks = []; // read/write operation queue
52     this.runningTask = null;
54     Services.obs.addObserver(this, "xpcom-shutdown");
55     this.registerListeners();
57     // This assumes that nothing will queue a new task at profile-change-teardown phase,
58     // potentially replacing the _queueDrainedPromise if there was no existing task run.
59     lazy.AsyncShutdown.profileChangeTeardown.addBlocker(
60       "NotificationDB: Need to make sure that all notification messages are processed",
61       () => this._queueDrainedPromise
62     );
63   },
65   registerListeners() {
66     for (let message of kMessages) {
67       Services.ppmm.addMessageListener(message, this);
68     }
69   },
71   unregisterListeners() {
72     for (let message of kMessages) {
73       Services.ppmm.removeMessageListener(message, this);
74     }
75   },
77   observe(aSubject, aTopic, aData) {
78     if (DEBUG) {
79       debug("Topic: " + aTopic);
80     }
81     if (aTopic == "xpcom-shutdown") {
82       this._shutdownInProgress = true;
83       Services.obs.removeObserver(this, "xpcom-shutdown");
84       this.unregisterListeners();
85     }
86   },
88   filterNonAppNotifications(notifications) {
89     for (let origin in notifications) {
90       let persistentNotificationCount = 0;
91       for (let id in notifications[origin]) {
92         if (notifications[origin][id].serviceWorkerRegistrationScope) {
93           persistentNotificationCount++;
94         } else {
95           delete notifications[origin][id];
96         }
97       }
98       if (persistentNotificationCount == 0) {
99         if (DEBUG) {
100           debug(
101             "Origin " + origin + " is not linked to an app manifest, deleting."
102           );
103         }
104         delete notifications[origin];
105       }
106     }
108     return notifications;
109   },
111   async maybeMigrateData() {
112     const oldStore = PathUtils.join(
113       Services.dirsvc.get("ProfD", Ci.nsIFile).path,
114       "notificationstore.json"
115     );
117     if (!(await IOUtils.exists(oldStore))) {
118       if (DEBUG) {
119         debug("Old store doesn't exist; not migrating data.");
120       }
121       return;
122     }
124     let data;
125     try {
126       data = await IOUtils.readUTF8(oldStore);
127     } catch (ex) {
128       // If read failed, we assume we have no notifications to migrate.
129       if (DEBUG) {
130         debug("Failed to read old store; not migrating data.");
131       }
132       return;
133     } finally {
134       // Finally, delete the old file so we don't try to migrate it again.
135       await IOUtils.remove(oldStore);
136     }
138     if (data.length) {
139       // Preprocessing phase intends to cleanly separate any migration-related
140       // tasks.
141       //
142       // NB: This code existed before we migrated the data to a kvstore,
143       // and the "migration-related tasks" it references are from an earlier
144       // migration.  We used to do it every time we read the JSON file;
145       // now we do it once, when migrating the JSON file to the kvstore.
146       const notifications = this.filterNonAppNotifications(JSON.parse(data));
148       // Copy the data from the JSON file to the kvstore.
149       // TODO: use a transaction to improve the performance of these operations
150       // once the kvstore API supports it (bug 1515096).
151       for (const origin in notifications) {
152         for (const id in notifications[origin]) {
153           await this._store.put(
154             makeKey(origin, id),
155             JSON.stringify(notifications[origin][id])
156           );
157         }
158       }
159     }
160   },
162   // Attempt to read notification file, if it's not there we will create it.
163   async load() {
164     // Get and cache a handle to the kvstore.
165     const dir = PathUtils.join(PathUtils.profileDir, "notificationstore");
166     await IOUtils.makeDirectory(dir, { ignoreExisting: true });
167     this._store = await lazy.KeyValueService.getOrCreate(dir, "notifications");
169     // Migrate data from the old JSON file to the new kvstore if the old file
170     // is present in the user's profile directory.
171     await this.maybeMigrateData();
172   },
174   // Helper function: promise will be resolved once file exists and/or is loaded.
175   ensureLoaded() {
176     if (!this._loadPromise) {
177       this._loadPromise = this.load();
178     }
179     return this._loadPromise;
180   },
182   receiveMessage(message) {
183     if (DEBUG) {
184       debug("Received message:" + message.name);
185     }
187     // sendAsyncMessage can fail if the child process exits during a
188     // notification storage operation, so always wrap it in a try/catch.
189     function returnMessage(name, data) {
190       try {
191         message.target.sendAsyncMessage(name, data);
192       } catch (e) {
193         if (DEBUG) {
194           debug("Return message failed, " + name);
195         }
196       }
197     }
199     switch (message.name) {
200       case "Notification:GetAll":
201         this.queueTask("getall", message.data)
202           .then(function (notifications) {
203             returnMessage("Notification:GetAll:Return:OK", {
204               requestID: message.data.requestID,
205               origin: message.data.origin,
206               notifications,
207             });
208           })
209           .catch(function (error) {
210             returnMessage("Notification:GetAll:Return:KO", {
211               requestID: message.data.requestID,
212               origin: message.data.origin,
213               errorMsg: error,
214             });
215           });
216         break;
218       case "Notification:Save":
219         this.queueTask("save", message.data)
220           .then(function () {
221             returnMessage("Notification:Save:Return:OK", {
222               requestID: message.data.requestID,
223             });
224           })
225           .catch(function (error) {
226             returnMessage("Notification:Save:Return:KO", {
227               requestID: message.data.requestID,
228               errorMsg: error,
229             });
230           });
231         break;
233       case "Notification:Delete":
234         this.queueTask("delete", message.data)
235           .then(function () {
236             returnMessage("Notification:Delete:Return:OK", {
237               requestID: message.data.requestID,
238             });
239           })
240           .catch(function (error) {
241             returnMessage("Notification:Delete:Return:KO", {
242               requestID: message.data.requestID,
243               errorMsg: error,
244             });
245           });
246         break;
248       default:
249         if (DEBUG) {
250           debug("Invalid message name" + message.name);
251         }
252     }
253   },
255   // We need to make sure any read/write operations are atomic,
256   // so use a queue to run each operation sequentially.
257   queueTask(operation, data) {
258     if (DEBUG) {
259       debug("Queueing task: " + operation);
260     }
262     var defer = {};
264     this.tasks.push({ operation, data, defer });
266     var promise = new Promise(function (resolve, reject) {
267       defer.resolve = resolve;
268       defer.reject = reject;
269     });
271     // Only run immediately if we aren't currently running another task.
272     if (!this.runningTask) {
273       if (DEBUG) {
274         debug("Task queue was not running, starting now...");
275       }
276       this.runNextTask();
277       this._queueDrainedPromise = new Promise(resolve => {
278         this._queueDrainedPromiseResolve = resolve;
279       });
280     }
282     return promise;
283   },
285   runNextTask() {
286     if (this.tasks.length === 0) {
287       if (DEBUG) {
288         debug("No more tasks to run, queue depleted");
289       }
290       this.runningTask = null;
291       if (this._queueDrainedPromiseResolve) {
292         this._queueDrainedPromiseResolve();
293       } else if (DEBUG) {
294         debug(
295           "_queueDrainedPromiseResolve was null somehow, no promise to resolve"
296         );
297       }
298       return;
299     }
300     this.runningTask = this.tasks.shift();
302     // Always make sure we are loaded before performing any read/write tasks.
303     this.ensureLoaded()
304       .then(() => {
305         var task = this.runningTask;
307         switch (task.operation) {
308           case "getall":
309             return this.taskGetAll(task.data);
311           case "save":
312             return this.taskSave(task.data);
314           case "delete":
315             return this.taskDelete(task.data);
316         }
318         throw new Error(`Unknown task operation: ${task.operation}`);
319       })
320       .then(payload => {
321         if (DEBUG) {
322           debug("Finishing task: " + this.runningTask.operation);
323         }
324         this.runningTask.defer.resolve(payload);
325       })
326       .catch(err => {
327         if (DEBUG) {
328           debug(
329             "Error while running " + this.runningTask.operation + ": " + err
330           );
331         }
332         this.runningTask.defer.reject(err);
333       })
334       .then(() => {
335         this.runNextTask();
336       });
337   },
339   enumerate(origin) {
340     // The "from" and "to" key parameters to nsIKeyValueStore.enumerate()
341     // are inclusive and exclusive, respectively, and keys are tuples
342     // of origin and ID joined by a tab (\t), which is character code 9;
343     // so enumerating ["origin", "origin\n"), where the line feed (\n)
344     // is character code 10, enumerates all pairs with the given origin.
345     return this._store.enumerate(origin, `${origin}\n`);
346   },
348   async taskGetAll(data) {
349     if (DEBUG) {
350       debug("Task, getting all");
351     }
352     var origin = data.origin;
353     var notifications = [];
355     for (const { value } of await this.enumerate(origin)) {
356       notifications.push(JSON.parse(value));
357     }
359     if (data.tag) {
360       notifications = notifications.filter(n => n.tag === data.tag);
361     }
363     return notifications;
364   },
366   async taskSave(data) {
367     if (DEBUG) {
368       debug("Task, saving");
369     }
370     var origin = data.origin;
371     var notification = data.notification;
373     // We might have existing notification with this tag,
374     // if so we need to remove it before saving the new one.
375     if (notification.tag) {
376       for (const { key, value } of await this.enumerate(origin)) {
377         const oldNotification = JSON.parse(value);
378         if (oldNotification.tag === notification.tag) {
379           await this._store.delete(key);
380         }
381       }
382     }
384     await this._store.put(
385       makeKey(origin, notification.id),
386       JSON.stringify(notification)
387     );
388   },
390   async taskDelete(data) {
391     if (DEBUG) {
392       debug("Task, deleting");
393     }
394     await this._store.delete(makeKey(data.origin, data.id));
395   },
398 NotificationDB.init();