tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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();