tor-browser

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

PushService.sys.mjs (48715B)


      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 import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
      6 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
      7 import { ChromePushSubscription } from "./ChromePushSubscription.sys.mjs";
      8 
      9 const lazy = {};
     10 
     11 XPCOMUtils.defineLazyServiceGetter(
     12  lazy,
     13  "gPushNotifier",
     14  "@mozilla.org/push/Notifier;1",
     15  Ci.nsIPushNotifier
     16 );
     17 ChromeUtils.defineESModuleGetters(lazy, {
     18  PushCrypto: "resource://gre/modules/PushCrypto.sys.mjs",
     19  PushServiceWebSocket: "resource://gre/modules/PushServiceWebSocket.sys.mjs",
     20  pushBroadcastService: "resource://gre/modules/PushBroadcastService.sys.mjs",
     21 });
     22 
     23 ChromeUtils.defineLazyGetter(lazy, "console", () => {
     24  return console.createInstance({
     25    maxLogLevelPref: "dom.push.loglevel",
     26    prefix: "PushService",
     27  });
     28 });
     29 
     30 const prefs = Services.prefs.getBranch("dom.push.");
     31 
     32 const PUSH_SERVICE_UNINIT = 0;
     33 const PUSH_SERVICE_INIT = 1; // No serverURI
     34 const PUSH_SERVICE_ACTIVATING = 2; // activating db
     35 const PUSH_SERVICE_CONNECTION_DISABLE = 3;
     36 const PUSH_SERVICE_ACTIVE_OFFLINE = 4;
     37 const PUSH_SERVICE_RUNNING = 5;
     38 
     39 /**
     40 * State is change only in couple of functions:
     41 *   init - change state to PUSH_SERVICE_INIT if state was PUSH_SERVICE_UNINIT
     42 *   changeServerURL - change state to PUSH_SERVICE_ACTIVATING if serverURL
     43 *                     present or PUSH_SERVICE_INIT if not present.
     44 *   changeStateConnectionEnabledEvent - it is call on pref change or during
     45 *                                       the service activation and it can
     46 *                                       change state to
     47 *                                       PUSH_SERVICE_CONNECTION_DISABLE
     48 *   changeStateOfflineEvent - it is called when offline state changes or during
     49 *                             the service activation and it change state to
     50 *                             PUSH_SERVICE_ACTIVE_OFFLINE or
     51 *                             PUSH_SERVICE_RUNNING.
     52 *   uninit - change state to PUSH_SERVICE_UNINIT.
     53 */
     54 
     55 // This is for starting and stopping service.
     56 const STARTING_SERVICE_EVENT = 0;
     57 const CHANGING_SERVICE_EVENT = 1;
     58 const STOPPING_SERVICE_EVENT = 2;
     59 const UNINIT_EVENT = 3;
     60 
     61 // Returns the backend for the given server URI.
     62 function getServiceForServerURI(uri) {
     63  // Insecure server URLs are allowed for development and testing.
     64  let allowInsecure = prefs.getBoolPref(
     65    "testing.allowInsecureServerURL",
     66    false
     67  );
     68  if (uri.scheme == "wss" || (allowInsecure && uri.scheme == "ws")) {
     69    return lazy.PushServiceWebSocket;
     70  }
     71  return null;
     72 }
     73 
     74 /**
     75 * Annotates an error with an XPCOM result code. We use this helper
     76 * instead of `Components.Exception` because the latter can assert in
     77 * `nsXPCComponents_Exception::HasInstance` when inspected at shutdown.
     78 */
     79 function errorWithResult(message, result = Cr.NS_ERROR_FAILURE) {
     80  let error = new Error(message);
     81  error.result = result;
     82  return error;
     83 }
     84 
     85 /**
     86 * The implementation of the push system. It uses WebSockets
     87 * (PushServiceWebSocket) to communicate with the server and PushDB (IndexedDB)
     88 * for persistence.
     89 */
     90 export var PushService = {
     91  _service: null,
     92  _state: PUSH_SERVICE_UNINIT,
     93  _db: null,
     94  _options: null,
     95  _visibleNotifications: new Map(),
     96 
     97  // Callback that is called after attempting to
     98  // reduce the quota for a record. Used for testing purposes.
     99  _updateQuotaTestCallback: null,
    100 
    101  // Set of timeout ID of tasks to reduce quota.
    102  _updateQuotaTimeouts: new Set(),
    103 
    104  // When serverURI changes (this is used for testing), db is cleaned up and a
    105  // a new db is started. This events must be sequential.
    106  _stateChangeProcessQueue: null,
    107  _stateChangeProcessEnqueue(op) {
    108    if (!this._stateChangeProcessQueue) {
    109      this._stateChangeProcessQueue = Promise.resolve();
    110    }
    111 
    112    this._stateChangeProcessQueue = this._stateChangeProcessQueue
    113      .then(op)
    114      .catch(error => {
    115        lazy.console.error(
    116          "stateChangeProcessEnqueue: Error transitioning state",
    117          error
    118        );
    119        return this._shutdownService();
    120      })
    121      .catch(error => {
    122        lazy.console.error(
    123          "stateChangeProcessEnqueue: Error shutting down service",
    124          error
    125        );
    126      });
    127    return this._stateChangeProcessQueue;
    128  },
    129 
    130  // Pending request. If a worker try to register for the same scope again, do
    131  // not send a new registration request. Therefore we need queue of pending
    132  // register requests. This is the list of scopes which pending registration.
    133  _pendingRegisterRequest: {},
    134  _notifyActivated: null,
    135  _activated: null,
    136  _checkActivated() {
    137    if (this._state < PUSH_SERVICE_ACTIVATING) {
    138      return Promise.reject(new Error("Push service not active"));
    139    }
    140    if (this._state > PUSH_SERVICE_ACTIVATING) {
    141      return Promise.resolve();
    142    }
    143    if (!this._activated) {
    144      this._activated = new Promise((resolve, reject) => {
    145        this._notifyActivated = { resolve, reject };
    146      });
    147    }
    148    return this._activated;
    149  },
    150 
    151  _makePendingKey(aPageRecord) {
    152    return aPageRecord.scope + "|" + aPageRecord.originAttributes;
    153  },
    154 
    155  _lookupOrPutPendingRequest(aPageRecord) {
    156    let key = this._makePendingKey(aPageRecord);
    157    if (this._pendingRegisterRequest[key]) {
    158      return this._pendingRegisterRequest[key];
    159    }
    160 
    161    return (this._pendingRegisterRequest[key] =
    162      this._registerWithServer(aPageRecord));
    163  },
    164 
    165  _deletePendingRequest(aPageRecord) {
    166    let key = this._makePendingKey(aPageRecord);
    167    if (this._pendingRegisterRequest[key]) {
    168      delete this._pendingRegisterRequest[key];
    169    }
    170  },
    171 
    172  _setState(aNewState) {
    173    lazy.console.debug(
    174      "setState()",
    175      "new state",
    176      aNewState,
    177      "old state",
    178      this._state
    179    );
    180 
    181    if (this._state == aNewState) {
    182      return;
    183    }
    184 
    185    if (this._state == PUSH_SERVICE_ACTIVATING) {
    186      // It is not important what is the new state as soon as we leave
    187      // PUSH_SERVICE_ACTIVATING
    188      if (this._notifyActivated) {
    189        if (aNewState < PUSH_SERVICE_ACTIVATING) {
    190          this._notifyActivated.reject(new Error("Push service not active"));
    191        } else {
    192          this._notifyActivated.resolve();
    193        }
    194      }
    195      this._notifyActivated = null;
    196      this._activated = null;
    197    }
    198    this._state = aNewState;
    199  },
    200 
    201  async _changeStateOfflineEvent(offline, calledFromConnEnabledEvent) {
    202    lazy.console.debug("changeStateOfflineEvent()", offline);
    203 
    204    if (
    205      this._state < PUSH_SERVICE_ACTIVE_OFFLINE &&
    206      this._state != PUSH_SERVICE_ACTIVATING &&
    207      !calledFromConnEnabledEvent
    208    ) {
    209      return;
    210    }
    211 
    212    if (offline) {
    213      if (this._state == PUSH_SERVICE_RUNNING) {
    214        this._service.disconnect();
    215      }
    216      this._setState(PUSH_SERVICE_ACTIVE_OFFLINE);
    217      return;
    218    }
    219 
    220    if (this._state == PUSH_SERVICE_RUNNING) {
    221      // PushService was not in the offline state, but got notification to
    222      // go online (a offline notification has not been sent).
    223      // Disconnect first.
    224      this._service.disconnect();
    225    }
    226 
    227    let broadcastListeners = await lazy.pushBroadcastService.getListeners();
    228 
    229    // In principle, a listener could be added to the
    230    // pushBroadcastService here, after we have gotten listeners and
    231    // before we're RUNNING, but this can't happen in practice because
    232    // the only caller that can add listeners is PushBroadcastService,
    233    // and it waits on the same promise we are before it can add
    234    // listeners. If PushBroadcastService gets woken first, it will
    235    // update the value that is eventually returned from
    236    // getListeners.
    237    this._setState(PUSH_SERVICE_RUNNING);
    238 
    239    this._service.connect(broadcastListeners);
    240  },
    241 
    242  _changeStateConnectionEnabledEvent(enabled) {
    243    lazy.console.debug("changeStateConnectionEnabledEvent()", enabled);
    244 
    245    if (
    246      this._state < PUSH_SERVICE_CONNECTION_DISABLE &&
    247      this._state != PUSH_SERVICE_ACTIVATING
    248    ) {
    249      return Promise.resolve();
    250    }
    251 
    252    if (enabled) {
    253      return this._changeStateOfflineEvent(Services.io.offline, true);
    254    }
    255 
    256    if (this._state == PUSH_SERVICE_RUNNING) {
    257      this._service.disconnect();
    258    }
    259    this._setState(PUSH_SERVICE_CONNECTION_DISABLE);
    260    return Promise.resolve();
    261  },
    262 
    263  // Used for testing.
    264  changeTestServer(url, options = {}) {
    265    lazy.console.debug("changeTestServer()");
    266 
    267    return this._stateChangeProcessEnqueue(_ => {
    268      if (this._state < PUSH_SERVICE_ACTIVATING) {
    269        lazy.console.debug("changeTestServer: PushService not activated?");
    270        return Promise.resolve();
    271      }
    272 
    273      return this._changeServerURL(url, CHANGING_SERVICE_EVENT, options);
    274    });
    275  },
    276 
    277  observe: function observe(aSubject, aTopic, aData) {
    278    switch (aTopic) {
    279      /*
    280       * We need to call uninit() on shutdown to clean up things that modules
    281       * aren't very good at automatically cleaning up, so we don't get shutdown
    282       * leaks on browser shutdown.
    283       */
    284      case "quit-application":
    285        this.uninit();
    286        break;
    287      case "network:offline-status-changed":
    288        this._stateChangeProcessEnqueue(_ =>
    289          this._changeStateOfflineEvent(aData === "offline", false)
    290        );
    291        break;
    292 
    293      case "nsPref:changed":
    294        if (aData == "serverURL") {
    295          lazy.console.debug(
    296            "observe: dom.push.serverURL changed for websocket",
    297            prefs.getStringPref("serverURL")
    298          );
    299          this._stateChangeProcessEnqueue(_ =>
    300            this._changeServerURL(
    301              prefs.getStringPref("serverURL"),
    302              CHANGING_SERVICE_EVENT
    303            )
    304          );
    305        } else if (aData == "connection.enabled") {
    306          this._stateChangeProcessEnqueue(_ =>
    307            this._changeStateConnectionEnabledEvent(
    308              prefs.getBoolPref("connection.enabled")
    309            )
    310          );
    311        }
    312        break;
    313 
    314      case "idle-daily":
    315        this._dropExpiredRegistrations().catch(error => {
    316          lazy.console.error(
    317            "Failed to drop expired registrations on idle",
    318            error
    319          );
    320        });
    321        break;
    322 
    323      case "perm-changed":
    324        this._onPermissionChange(aSubject, aData).catch(error => {
    325          lazy.console.error(
    326            "onPermissionChange: Error updating registrations:",
    327            error
    328          );
    329        });
    330        break;
    331 
    332      case "clear-origin-attributes-data":
    333        this._clearOriginData(aData).catch(error => {
    334          lazy.console.error(
    335            "clearOriginData: Error clearing origin data:",
    336            error
    337          );
    338        });
    339        break;
    340    }
    341  },
    342 
    343  _clearOriginData(data) {
    344    lazy.console.log("clearOriginData()");
    345 
    346    if (!data) {
    347      return Promise.resolve();
    348    }
    349 
    350    let pattern = JSON.parse(data);
    351    return this._dropRegistrationsIf(record =>
    352      record.matchesOriginAttributes(pattern)
    353    );
    354  },
    355 
    356  /**
    357   * Sends an unregister request to the server in the background. If the
    358   * service is not connected, this function is a no-op.
    359   *
    360   * @param {PushRecord} record The record to unregister.
    361   * @param {number} reason An `nsIPushErrorReporter` unsubscribe reason,
    362   *  indicating why this record was removed.
    363   */
    364  _backgroundUnregister(record, reason) {
    365    lazy.console.debug("backgroundUnregister()");
    366 
    367    if (!this._service.isConnected() || !record) {
    368      return;
    369    }
    370 
    371    lazy.console.debug("backgroundUnregister: Notifying server", record);
    372    this._sendUnregister(record, reason)
    373      .then(() => {
    374        lazy.gPushNotifier.notifySubscriptionModified(
    375          record.scope,
    376          record.principal
    377        );
    378      })
    379      .catch(e => {
    380        lazy.console.error("backgroundUnregister: Error notifying server", e);
    381      });
    382  },
    383 
    384  _findService(serverURL) {
    385    lazy.console.debug("findService()");
    386 
    387    if (!serverURL) {
    388      lazy.console.warn("findService: No dom.push.serverURL found");
    389      return [];
    390    }
    391 
    392    let uri;
    393    try {
    394      uri = Services.io.newURI(serverURL);
    395    } catch (e) {
    396      lazy.console.warn(
    397        "findService: Error creating valid URI from",
    398        "dom.push.serverURL",
    399        serverURL
    400      );
    401      return [];
    402    }
    403 
    404    let service = getServiceForServerURI(uri);
    405    return [service, uri];
    406  },
    407 
    408  _changeServerURL(serverURI, event, options = {}) {
    409    lazy.console.debug("changeServerURL()");
    410 
    411    switch (event) {
    412      case UNINIT_EVENT:
    413        return this._stopService(event);
    414 
    415      case STARTING_SERVICE_EVENT: {
    416        let [service, uri] = this._findService(serverURI);
    417        if (!service) {
    418          this._setState(PUSH_SERVICE_INIT);
    419          return Promise.resolve();
    420        }
    421        return this._startService(service, uri, options).then(_ =>
    422          this._changeStateConnectionEnabledEvent(
    423            prefs.getBoolPref("connection.enabled")
    424          )
    425        );
    426      }
    427      case CHANGING_SERVICE_EVENT: {
    428        let [service, uri] = this._findService(serverURI);
    429        if (service) {
    430          if (this._state == PUSH_SERVICE_INIT) {
    431            this._setState(PUSH_SERVICE_ACTIVATING);
    432            // The service has not been running - start it.
    433            return this._startService(service, uri, options).then(_ =>
    434              this._changeStateConnectionEnabledEvent(
    435                prefs.getBoolPref("connection.enabled")
    436              )
    437            );
    438          }
    439          this._setState(PUSH_SERVICE_ACTIVATING);
    440          // If we already had running service - stop service, start the new
    441          // one and check connection.enabled and offline state(offline state
    442          // check is called in changeStateConnectionEnabledEvent function)
    443          return this._stopService(CHANGING_SERVICE_EVENT)
    444            .then(_ => this._startService(service, uri, options))
    445            .then(_ =>
    446              this._changeStateConnectionEnabledEvent(
    447                prefs.getBoolPref("connection.enabled")
    448              )
    449            );
    450        }
    451        if (this._state == PUSH_SERVICE_INIT) {
    452          return Promise.resolve();
    453        }
    454        // The new serverUri is empty or misconfigured - stop service.
    455        this._setState(PUSH_SERVICE_INIT);
    456        return this._stopService(STOPPING_SERVICE_EVENT);
    457      }
    458      default:
    459        lazy.console.error("Unexpected event in _changeServerURL", event);
    460        return Promise.reject(new Error(`Unexpected event ${event}`));
    461    }
    462  },
    463 
    464  /**
    465   * PushService initialization is divided into 4 parts:
    466   * init() - start listening for quit-application and serverURL changes.
    467   *          state is change to PUSH_SERVICE_INIT
    468   * startService() - if serverURL is present this function is called. It starts
    469   *                  listening for broadcasted messages, starts db and
    470   *                  PushService connection (WebSocket).
    471   *                  state is change to PUSH_SERVICE_ACTIVATING.
    472   * startObservers() - start other observers.
    473   * changeStateConnectionEnabledEvent  - checks prefs and offline state.
    474   *                                      It changes state to:
    475   *                                        PUSH_SERVICE_RUNNING,
    476   *                                        PUSH_SERVICE_ACTIVE_OFFLINE or
    477   *                                        PUSH_SERVICE_CONNECTION_DISABLE.
    478   */
    479  async init(options = {}) {
    480    lazy.console.debug("init()");
    481 
    482    if (this._state > PUSH_SERVICE_UNINIT) {
    483      return;
    484    }
    485 
    486    this._setState(PUSH_SERVICE_ACTIVATING);
    487 
    488    prefs.addObserver("serverURL", this);
    489    Services.obs.addObserver(this, "quit-application");
    490 
    491    if (options.serverURI) {
    492      // this is use for xpcshell test.
    493 
    494      await this._stateChangeProcessEnqueue(_ =>
    495        this._changeServerURL(
    496          options.serverURI,
    497          STARTING_SERVICE_EVENT,
    498          options
    499        )
    500      );
    501    } else {
    502      // This is only used for testing. Different tests require connecting to
    503      // slightly different URLs.
    504      await this._stateChangeProcessEnqueue(_ =>
    505        this._changeServerURL(
    506          prefs.getStringPref("serverURL"),
    507          STARTING_SERVICE_EVENT
    508        )
    509      );
    510    }
    511  },
    512 
    513  _startObservers() {
    514    lazy.console.debug("startObservers()");
    515 
    516    if (this._state != PUSH_SERVICE_ACTIVATING) {
    517      return;
    518    }
    519 
    520    Services.obs.addObserver(this, "clear-origin-attributes-data");
    521 
    522    // The offline-status-changed event is used to know
    523    // when to (dis)connect. It may not fire if the underlying OS changes
    524    // networks; in such a case we rely on timeout.
    525    Services.obs.addObserver(this, "network:offline-status-changed");
    526 
    527    // Used to monitor if the user wishes to disable Push.
    528    prefs.addObserver("connection.enabled", this);
    529 
    530    // Prunes expired registrations and notifies dormant service workers.
    531    Services.obs.addObserver(this, "idle-daily");
    532 
    533    // Prunes registrations for sites for which the user revokes push
    534    // permissions.
    535    Services.obs.addObserver(this, "perm-changed");
    536  },
    537 
    538  _startService(service, serverURI, options) {
    539    lazy.console.debug("startService()");
    540 
    541    if (this._state != PUSH_SERVICE_ACTIVATING) {
    542      return Promise.reject();
    543    }
    544 
    545    this._service = service;
    546 
    547    this._db = options.db;
    548    if (!this._db) {
    549      this._db = this._service.newPushDB();
    550    }
    551 
    552    return this._service.init(options, this, serverURI).then(() => {
    553      this._startObservers();
    554      return this._dropExpiredRegistrations();
    555    });
    556  },
    557 
    558  /**
    559   * PushService uninitialization is divided into 3 parts:
    560   * stopObservers() - stot observers started in startObservers.
    561   * stopService() - It stops listening for broadcasted messages, stops db and
    562   *                 PushService connection (WebSocket).
    563   *                 state is changed to PUSH_SERVICE_INIT.
    564   * uninit() - stop listening for quit-application and serverURL changes.
    565   *            state is change to PUSH_SERVICE_UNINIT
    566   */
    567  _stopService(event) {
    568    lazy.console.debug("stopService()");
    569 
    570    if (this._state < PUSH_SERVICE_ACTIVATING) {
    571      return Promise.resolve();
    572    }
    573 
    574    this._stopObservers();
    575 
    576    this._service.disconnect();
    577    this._service.uninit();
    578    this._service = null;
    579 
    580    this._updateQuotaTimeouts.forEach(timeoutID => clearTimeout(timeoutID));
    581    this._updateQuotaTimeouts.clear();
    582 
    583    if (!this._db) {
    584      return Promise.resolve();
    585    }
    586    if (event == UNINIT_EVENT) {
    587      // If it is uninitialized just close db.
    588      this._db.close();
    589      this._db = null;
    590      return Promise.resolve();
    591    }
    592 
    593    return this.dropUnexpiredRegistrations().then(
    594      _ => {
    595        this._db.close();
    596        this._db = null;
    597      },
    598      () => {
    599        this._db.close();
    600        this._db = null;
    601      }
    602    );
    603  },
    604 
    605  _stopObservers() {
    606    lazy.console.debug("stopObservers()");
    607 
    608    if (this._state < PUSH_SERVICE_ACTIVATING) {
    609      return;
    610    }
    611 
    612    prefs.removeObserver("connection.enabled", this);
    613 
    614    Services.obs.removeObserver(this, "network:offline-status-changed");
    615    Services.obs.removeObserver(this, "clear-origin-attributes-data");
    616    Services.obs.removeObserver(this, "idle-daily");
    617    Services.obs.removeObserver(this, "perm-changed");
    618  },
    619 
    620  _shutdownService() {
    621    let promiseChangeURL = this._changeServerURL("", UNINIT_EVENT);
    622    this._setState(PUSH_SERVICE_UNINIT);
    623    lazy.console.debug("shutdownService: shutdown complete!");
    624    return promiseChangeURL;
    625  },
    626 
    627  async uninit() {
    628    lazy.console.debug("uninit()");
    629 
    630    if (this._state == PUSH_SERVICE_UNINIT) {
    631      return;
    632    }
    633 
    634    prefs.removeObserver("serverURL", this);
    635    Services.obs.removeObserver(this, "quit-application");
    636 
    637    await this._stateChangeProcessEnqueue(_ => this._shutdownService());
    638  },
    639 
    640  /**
    641   * Drops all active registrations and notifies the associated service
    642   * workers. This function is called when the user switches Push servers,
    643   * or when the server invalidates all existing registrations.
    644   *
    645   * We ignore expired registrations because they're already handled in other
    646   * code paths. Registrations that expired after exceeding their quotas are
    647   * evicted at startup, or on the next `idle-daily` event. Registrations that
    648   * expired because the user revoked the notification permission are evicted
    649   * once the permission is reinstated.
    650   */
    651  dropUnexpiredRegistrations() {
    652    return this._db.clearIf(record => {
    653      if (record.isExpired()) {
    654        return false;
    655      }
    656      this._notifySubscriptionChangeObservers(record);
    657      return true;
    658    });
    659  },
    660 
    661  _notifySubscriptionChangeObservers(record) {
    662    if (!record) {
    663      return;
    664    }
    665    lazy.gPushNotifier.notifySubscriptionChange(
    666      record.scope,
    667      record.principal,
    668      new ChromePushSubscription(record.toSubscription())
    669    );
    670  },
    671 
    672  /**
    673   * Drops a registration and notifies the associated service worker. If the
    674   * registration does not exist, this function is a no-op.
    675   *
    676   * @param {string} keyID The registration ID to remove.
    677   * @returns {Promise} Resolves once the worker has been notified.
    678   */
    679  dropRegistrationAndNotifyApp(aKeyID) {
    680    return this._db
    681      .delete(aKeyID)
    682      .then(record => this._notifySubscriptionChangeObservers(record));
    683  },
    684 
    685  /**
    686   * Updates a registration and notifies the associated service worker.
    687   *
    688   * @param {string} keyID The registration ID to update.
    689   * @param {Function} updateFunc Returns the updated record.
    690   * @returns {Promise} Resolves with the updated record once the worker
    691   *  has been notified.
    692   */
    693  updateRecordAndNotifyApp(aKeyID, aUpdateFunc) {
    694    return this._db.update(aKeyID, aUpdateFunc).then(record => {
    695      this._notifySubscriptionChangeObservers(record);
    696      return record;
    697    });
    698  },
    699 
    700  ensureCrypto(record) {
    701    if (
    702      record.hasAuthenticationSecret() &&
    703      record.p256dhPublicKey &&
    704      record.p256dhPrivateKey
    705    ) {
    706      return Promise.resolve(record);
    707    }
    708 
    709    let keygen = Promise.resolve([]);
    710    if (!record.p256dhPublicKey || !record.p256dhPrivateKey) {
    711      keygen = lazy.PushCrypto.generateKeys();
    712    }
    713    // We do not have a encryption key. so we need to generate it. This
    714    // is only going to happen on db upgrade from version 4 to higher.
    715    return keygen.then(
    716      ([pubKey, privKey]) => {
    717        return this.updateRecordAndNotifyApp(record.keyID, recordToUpdate => {
    718          if (
    719            !recordToUpdate.p256dhPublicKey ||
    720            !recordToUpdate.p256dhPrivateKey
    721          ) {
    722            recordToUpdate.p256dhPublicKey = pubKey;
    723            recordToUpdate.p256dhPrivateKey = privKey;
    724          }
    725          if (!recordToUpdate.hasAuthenticationSecret()) {
    726            recordToUpdate.authenticationSecret =
    727              lazy.PushCrypto.generateAuthenticationSecret();
    728          }
    729          return recordToUpdate;
    730        });
    731      },
    732      error => {
    733        return this.dropRegistrationAndNotifyApp(record.keyID).then(() =>
    734          Promise.reject(error)
    735        );
    736      }
    737    );
    738  },
    739 
    740  /**
    741   * Dispatches an incoming message to a service worker, recalculating the
    742   * quota for the associated push registration. If the quota is exceeded,
    743   * the registration and message will be dropped, and the worker will not
    744   * be notified.
    745   *
    746   * @param {string} keyID The push registration ID.
    747   * @param {string} messageID The message ID, used to report service worker
    748   *  delivery failures. For Web Push messages, this is the version. If empty,
    749   *  failures will not be reported.
    750   * @param {object} headers The encryption headers.
    751   * @param {ArrayBuffer|Uint8Array} data The encrypted message data.
    752   * @param {Function} updateFunc A function that receives the existing
    753   *  registration record as its argument, and returns a new record. If the
    754   *  function returns `null` or `undefined`, the record will not be updated.
    755   *  `PushServiceWebSocket` uses this to drop incoming updates with older
    756   *  versions.
    757   * @returns {Promise} Resolves with an `nsIPushErrorReporter` ack status
    758   *  code, indicating whether the message was delivered successfully.
    759   */
    760  receivedPushMessage(keyID, messageID, headers, data, updateFunc) {
    761    lazy.console.debug("receivedPushMessage()");
    762 
    763    return this._updateRecordAfterPush(keyID, updateFunc)
    764      .then(record => {
    765        if (record.quotaApplies()) {
    766          // Update quota after the delay, at which point
    767          // we check for visible notifications.
    768          let timeoutID = setTimeout(_ => {
    769            this._updateQuota(keyID);
    770            if (!this._updateQuotaTimeouts.delete(timeoutID)) {
    771              lazy.console.debug(
    772                "receivedPushMessage: quota update timeout missing?"
    773              );
    774            }
    775          }, prefs.getIntPref("quotaUpdateDelay"));
    776          this._updateQuotaTimeouts.add(timeoutID);
    777        }
    778        return this._decryptAndNotifyApp(record, messageID, headers, data);
    779      })
    780      .catch(error => {
    781        lazy.console.error("receivedPushMessage: Error notifying app", error);
    782        return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED;
    783      });
    784  },
    785 
    786  /**
    787   * Dispatches a broadcast notification to the BroadcastService.
    788   *
    789   * @param {object} message The reply received by PushServiceWebSocket
    790   * @param {object} context Additional information about the context in which the
    791   *  notification was received.
    792   */
    793  receivedBroadcastMessage(message, context) {
    794    lazy.pushBroadcastService
    795      .receivedBroadcastMessage(message.broadcasts, context)
    796      .catch(e => {
    797        lazy.console.error(e);
    798      });
    799  },
    800 
    801  /**
    802   * Updates a registration record after receiving a push message.
    803   *
    804   * @param {string} keyID The push registration ID.
    805   * @param {Function} updateFunc The function passed to `receivedPushMessage`.
    806   * @returns {Promise} Resolves with the updated record, or rejects if the
    807   *  record was not updated.
    808   */
    809  _updateRecordAfterPush(keyID, updateFunc) {
    810    return this.getByKeyID(keyID)
    811      .then(record => {
    812        if (!record) {
    813          throw new Error("No record for key ID " + keyID);
    814        }
    815        return record
    816          .getLastVisit()
    817          .then(lastVisit => {
    818            // As a special case, don't notify the service worker if the user
    819            // cleared their history.
    820            if (!isFinite(lastVisit)) {
    821              throw new Error("Ignoring message sent to unvisited origin");
    822            }
    823            return lastVisit;
    824          })
    825          .then(lastVisit => {
    826            // Update the record, resetting the quota if the user has visited the
    827            // site since the last push.
    828            return this._db.update(keyID, recordToUpdate => {
    829              let newRecord = updateFunc(recordToUpdate);
    830              if (!newRecord) {
    831                return null;
    832              }
    833              // Because `unregister` is advisory only, we can still receive messages
    834              // for stale Simple Push registrations from the server. To work around
    835              // this, we check if the record has expired before *and* after updating
    836              // the quota.
    837              if (newRecord.isExpired()) {
    838                return null;
    839              }
    840              newRecord.receivedPush(lastVisit);
    841              return newRecord;
    842            });
    843          });
    844      })
    845      .then(record => {
    846        lazy.gPushNotifier.notifySubscriptionModified(
    847          record.scope,
    848          record.principal
    849        );
    850        return record;
    851      });
    852  },
    853 
    854  /**
    855   * Decrypts an incoming message and notifies the associated service worker.
    856   *
    857   * @param {PushRecord} record The receiving registration.
    858   * @param {string} messageID The message ID.
    859   * @param {object} headers The encryption headers.
    860   * @param {ArrayBuffer|Uint8Array} data The encrypted message data.
    861   * @returns {Promise} Resolves with an ack status code.
    862   */
    863  _decryptAndNotifyApp(record, messageID, headers, data) {
    864    return lazy.PushCrypto.decrypt(
    865      record.p256dhPrivateKey,
    866      record.p256dhPublicKey,
    867      record.authenticationSecret,
    868      headers,
    869      data
    870    ).then(
    871      message => this._notifyApp(record, messageID, message),
    872      error => {
    873        lazy.console.warn(
    874          "decryptAndNotifyApp: Error decrypting message",
    875          record.scope,
    876          messageID,
    877          error
    878        );
    879 
    880        let message = error.format(record.scope);
    881        lazy.gPushNotifier.notifyError(
    882          record.scope,
    883          record.principal,
    884          message,
    885          Ci.nsIScriptError.errorFlag
    886        );
    887        return Ci.nsIPushErrorReporter.ACK_DECRYPTION_ERROR;
    888      }
    889    );
    890  },
    891 
    892  _updateQuota(keyID) {
    893    lazy.console.debug("updateQuota()");
    894 
    895    this._db
    896      .update(keyID, record => {
    897        // Record may have expired from an earlier quota update.
    898        if (record.isExpired()) {
    899          lazy.console.debug(
    900            "updateQuota: Trying to update quota for expired record",
    901            record
    902          );
    903          return null;
    904        }
    905        // If there are visible notifications, don't apply the quota penalty
    906        // for the message.
    907        if (record.uri && !this._visibleNotifications.has(record.uri.prePath)) {
    908          record.reduceQuota();
    909        }
    910        return record;
    911      })
    912      .then(record => {
    913        if (record.isExpired()) {
    914          // Drop the registration in the background. If the user returns to the
    915          // site, the service worker will be notified on the next `idle-daily`
    916          // event.
    917          this._backgroundUnregister(
    918            record,
    919            Ci.nsIPushErrorReporter.UNSUBSCRIBE_QUOTA_EXCEEDED
    920          );
    921        } else {
    922          lazy.gPushNotifier.notifySubscriptionModified(
    923            record.scope,
    924            record.principal
    925          );
    926        }
    927        if (this._updateQuotaTestCallback) {
    928          // Callback so that test may be notified when the quota update is complete.
    929          this._updateQuotaTestCallback();
    930        }
    931      })
    932      .catch(error => {
    933        lazy.console.debug(
    934          "updateQuota: Error while trying to update quota",
    935          error
    936        );
    937      });
    938  },
    939 
    940  notificationForOriginShown(origin) {
    941    lazy.console.debug("notificationForOriginShown()", origin);
    942    let count;
    943    if (this._visibleNotifications.has(origin)) {
    944      count = this._visibleNotifications.get(origin);
    945    } else {
    946      count = 0;
    947    }
    948    this._visibleNotifications.set(origin, count + 1);
    949  },
    950 
    951  notificationForOriginClosed(origin) {
    952    lazy.console.debug("notificationForOriginClosed()", origin);
    953    let count;
    954    if (this._visibleNotifications.has(origin)) {
    955      count = this._visibleNotifications.get(origin);
    956    } else {
    957      lazy.console.debug(
    958        "notificationForOriginClosed: closing notification that has not been shown?"
    959      );
    960      return;
    961    }
    962    if (count > 1) {
    963      this._visibleNotifications.set(origin, count - 1);
    964    } else {
    965      this._visibleNotifications.delete(origin);
    966    }
    967  },
    968 
    969  reportDeliveryError(messageID, reason) {
    970    lazy.console.debug("reportDeliveryError()", messageID, reason);
    971    if (this._state == PUSH_SERVICE_RUNNING && this._service.isConnected()) {
    972      // Only report errors if we're initialized and connected.
    973      this._service.reportDeliveryError(messageID, reason);
    974    }
    975  },
    976 
    977  _notifyApp(aPushRecord, messageID, message) {
    978    if (
    979      !aPushRecord ||
    980      !aPushRecord.scope ||
    981      aPushRecord.originAttributes === undefined
    982    ) {
    983      lazy.console.error("notifyApp: Invalid record", aPushRecord);
    984      return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED;
    985    }
    986 
    987    lazy.console.debug("notifyApp()", aPushRecord.scope);
    988 
    989    // If permission has been revoked, trash the message.
    990    if (!aPushRecord.hasPermission()) {
    991      lazy.console.warn("notifyApp: Missing push permission", aPushRecord);
    992      return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED;
    993    }
    994 
    995    let payload = ArrayBuffer.isView(message)
    996      ? new Uint8Array(message.buffer)
    997      : message;
    998 
    999    if (aPushRecord.quotaApplies()) {
   1000      // Don't record telemetry for chrome push messages.
   1001      Glean.webPush.apiNotify.add(1);
   1002    }
   1003 
   1004    if (payload) {
   1005      lazy.gPushNotifier.notifyPushWithData(
   1006        aPushRecord.scope,
   1007        aPushRecord.principal,
   1008        messageID,
   1009        payload
   1010      );
   1011    } else {
   1012      lazy.gPushNotifier.notifyPush(
   1013        aPushRecord.scope,
   1014        aPushRecord.principal,
   1015        messageID
   1016      );
   1017    }
   1018 
   1019    return Ci.nsIPushErrorReporter.ACK_DELIVERED;
   1020  },
   1021 
   1022  getByKeyID(aKeyID) {
   1023    return this._db.getByKeyID(aKeyID);
   1024  },
   1025 
   1026  getAllUnexpired() {
   1027    return this._db.getAllUnexpired();
   1028  },
   1029 
   1030  _sendRequest(action, ...params) {
   1031    if (this._state == PUSH_SERVICE_CONNECTION_DISABLE) {
   1032      return Promise.reject(new Error("Push service disabled"));
   1033    }
   1034    if (this._state == PUSH_SERVICE_ACTIVE_OFFLINE) {
   1035      return Promise.reject(new Error("Push service offline"));
   1036    }
   1037    // Ensure the backend is ready. `getByPageRecord` already checks this, but
   1038    // we need to check again here in case the service was restarted in the
   1039    // meantime.
   1040    return this._checkActivated().then(_ => {
   1041      switch (action) {
   1042        case "register":
   1043          return this._service.register(...params);
   1044        case "unregister":
   1045          return this._service.unregister(...params);
   1046      }
   1047      return Promise.reject(new Error("Unknown request type: " + action));
   1048    });
   1049  },
   1050 
   1051  /**
   1052   * Called on message from the child process. aPageRecord is an object sent by
   1053   * the push manager, identifying the sending page and other fields.
   1054   */
   1055  _registerWithServer(aPageRecord) {
   1056    lazy.console.debug("registerWithServer()", aPageRecord);
   1057 
   1058    return this._sendRequest("register", aPageRecord)
   1059      .then(
   1060        record => this._onRegisterSuccess(record),
   1061        err => this._onRegisterError(err)
   1062      )
   1063      .then(
   1064        record => {
   1065          this._deletePendingRequest(aPageRecord);
   1066          lazy.gPushNotifier.notifySubscriptionModified(
   1067            record.scope,
   1068            record.principal
   1069          );
   1070          return record.toSubscription();
   1071        },
   1072        err => {
   1073          this._deletePendingRequest(aPageRecord);
   1074          throw err;
   1075        }
   1076      );
   1077  },
   1078 
   1079  _sendUnregister(aRecord, aReason) {
   1080    return this._sendRequest("unregister", aRecord, aReason);
   1081  },
   1082 
   1083  /**
   1084   * Exceptions thrown in _onRegisterSuccess are caught by the promise obtained
   1085   * from _service.request, causing the promise to be rejected instead.
   1086   */
   1087  _onRegisterSuccess(aRecord) {
   1088    lazy.console.debug("_onRegisterSuccess()");
   1089 
   1090    return this._db.put(aRecord).catch(error => {
   1091      // Unable to save. Destroy the subscription in the background.
   1092      this._backgroundUnregister(
   1093        aRecord,
   1094        Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL
   1095      );
   1096      throw error;
   1097    });
   1098  },
   1099 
   1100  /**
   1101   * Exceptions thrown in _onRegisterError are caught by the promise obtained
   1102   * from _service.request, causing the promise to be rejected instead.
   1103   */
   1104  _onRegisterError(reply) {
   1105    lazy.console.debug("_onRegisterError()");
   1106 
   1107    if (!reply.error) {
   1108      lazy.console.warn(
   1109        "onRegisterError: Called without valid error message!",
   1110        reply
   1111      );
   1112      throw new Error("Registration error");
   1113    }
   1114    throw reply.error;
   1115  },
   1116 
   1117  notificationsCleared() {
   1118    this._visibleNotifications.clear();
   1119  },
   1120 
   1121  _getByPageRecord(pageRecord) {
   1122    return this._checkActivated().then(_ =>
   1123      this._db.getByIdentifiers(pageRecord)
   1124    );
   1125  },
   1126 
   1127  register(aPageRecord) {
   1128    lazy.console.debug("register()", aPageRecord);
   1129 
   1130    let keyPromise;
   1131    if (aPageRecord.appServerKey && aPageRecord.appServerKey.length) {
   1132      let keyView = new Uint8Array(aPageRecord.appServerKey);
   1133      keyPromise = lazy.PushCrypto.validateAppServerKey(keyView).catch(() => {
   1134        // Normalize Web Crypto exceptions. `nsIPushService` will forward the
   1135        // error result to the DOM API implementation in `PushManager.cpp` or
   1136        // `Push.js`, which will convert it to the correct `DOMException`.
   1137        throw errorWithResult(
   1138          "Invalid app server key",
   1139          Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR
   1140        );
   1141      });
   1142    } else {
   1143      keyPromise = Promise.resolve(null);
   1144    }
   1145 
   1146    return Promise.all([keyPromise, this._getByPageRecord(aPageRecord)]).then(
   1147      ([appServerKey, record]) => {
   1148        aPageRecord.appServerKey = appServerKey;
   1149        if (!record) {
   1150          return this._lookupOrPutPendingRequest(aPageRecord);
   1151        }
   1152        if (!record.matchesAppServerKey(appServerKey)) {
   1153          throw errorWithResult(
   1154            "Mismatched app server key",
   1155            Cr.NS_ERROR_DOM_PUSH_MISMATCHED_KEY_ERR
   1156          );
   1157        }
   1158        if (record.isExpired()) {
   1159          return record
   1160            .quotaChanged()
   1161            .then(isChanged => {
   1162              if (isChanged) {
   1163                // If the user revisited the site, drop the expired push
   1164                // registration and re-register.
   1165                return this.dropRegistrationAndNotifyApp(record.keyID);
   1166              }
   1167              throw new Error("Push subscription expired");
   1168            })
   1169            .then(_ => this._lookupOrPutPendingRequest(aPageRecord));
   1170        }
   1171        return record.toSubscription();
   1172      }
   1173    );
   1174  },
   1175 
   1176  /*
   1177   * Called only by the PushBroadcastService on the receipt of a new
   1178   * subscription. Don't call this directly. Go through PushBroadcastService.
   1179   */
   1180  async subscribeBroadcast(broadcastId, version) {
   1181    if (this._state != PUSH_SERVICE_RUNNING) {
   1182      // Ignore any request to subscribe before we send a hello.
   1183      // We'll send all the broadcast listeners as part of the hello
   1184      // anyhow.
   1185      return;
   1186    }
   1187 
   1188    await this._service.sendSubscribeBroadcast(broadcastId, version);
   1189  },
   1190 
   1191  /**
   1192   * Called on message from the child process.
   1193   *
   1194   * Why is the record being deleted from the local database before the server
   1195   * is told?
   1196   *
   1197   * Unregistration is for the benefit of the app and the AppServer
   1198   * so that the AppServer does not keep pinging a channel the UserAgent isn't
   1199   * watching The important part of the transaction in this case is left to the
   1200   * app, to tell its server of the unregistration.  Even if the request to the
   1201   * PushServer were to fail, it would not affect correctness of the protocol,
   1202   * and the server GC would just clean up the channelID/subscription
   1203   * eventually.  Since the appserver doesn't ping it, no data is lost.
   1204   *
   1205   * If rather we were to unregister at the server and update the database only
   1206   * on success: If the server receives the unregister, and deletes the
   1207   * channelID/subscription, but the response is lost because of network
   1208   * failure, the application is never informed. In addition the application may
   1209   * retry the unregister when it fails due to timeout (websocket) or any other
   1210   * reason at which point the server will say it does not know of this
   1211   * unregistration.  We'll have to make the registration/unregistration phases
   1212   * have retries and attempts to resend messages from the server, and have the
   1213   * client acknowledge. On a server, data is cheap, reliable notification is
   1214   * not.
   1215   */
   1216  unregister(aPageRecord) {
   1217    lazy.console.debug("unregister()", aPageRecord);
   1218 
   1219    return this._getByPageRecord(aPageRecord).then(record => {
   1220      if (record === null) {
   1221        return false;
   1222      }
   1223 
   1224      let reason = Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL;
   1225      return Promise.all([
   1226        this._sendUnregister(record, reason),
   1227        this._db.delete(record.keyID).then(rec => {
   1228          if (rec) {
   1229            lazy.gPushNotifier.notifySubscriptionModified(
   1230              rec.scope,
   1231              rec.principal
   1232            );
   1233          }
   1234        }),
   1235      ]).then(([success]) => success);
   1236    });
   1237  },
   1238 
   1239  /**
   1240   * Clear subscriptions matching either a principal or a domain and
   1241   * OriginAttributesPattern. If domain="*" is passed all records will be
   1242   * deleted.
   1243   *
   1244   * @param {*} options
   1245   * @param {nsIPrincipal} [options.principal] - The principal to clear
   1246   * subscriptions for. This does an exact origin match.
   1247   * @param {string} [options.domain] - Clear all records matching the domain,
   1248   * including subdomains.
   1249   * @param {OriginAttributesPattern} [options.originAttributesPattern] -
   1250   * Additional OriginAttributes filter for clearing by domain. Ignored for
   1251   * domain == "*".
   1252   * @returns {Promise} - A Promise which resolves once the operation has
   1253   * completed.
   1254   */
   1255  clear({ principal, domain, originAttributesPattern }) {
   1256    return this._checkActivated()
   1257      .then(_ => {
   1258        return this._dropRegistrationsIf(record => {
   1259          // Drop all
   1260          if (domain == "*") {
   1261            return true;
   1262          }
   1263          // Can't match against record if it doesn't have a URI.
   1264          if (record.uri == null) {
   1265            return false;
   1266          }
   1267 
   1268          let originAttributes;
   1269          if (principal || originAttributesPattern) {
   1270            // Restore OriginAttributes from record.originAttributes which
   1271            // contains the OA suffix string. We need this to match against the
   1272            // principal or the OriginAttributesPattern.
   1273            try {
   1274              originAttributes =
   1275                ChromeUtils.CreateOriginAttributesFromOriginSuffix(
   1276                  record.originAttributes
   1277                );
   1278            } catch (e) {
   1279              console.warn("Error while parsing record OA suffix.", e);
   1280              return false;
   1281            }
   1282          }
   1283 
   1284          // Drop all matching principal.
   1285          if (principal) {
   1286            // Build a principal from the record metadata so we can compare it
   1287            // to the given principal.
   1288            let recordPrincipal =
   1289              Services.scriptSecurityManager.createContentPrincipal(
   1290                record.uri,
   1291                originAttributes
   1292              );
   1293            return recordPrincipal.equals(principal);
   1294          }
   1295 
   1296          if (!record.uri.host) {
   1297            return false;
   1298          }
   1299 
   1300          // Drop all matching domain + OA pattern.
   1301          return Services.clearData.hostMatchesSite(
   1302            record.uri.host,
   1303            originAttributes,
   1304            domain,
   1305            originAttributesPattern
   1306          );
   1307        });
   1308      })
   1309      .catch(e => {
   1310        lazy.console.warn(
   1311          "clear: Error dropping subscriptions for domain or principal",
   1312          domain,
   1313          e
   1314        );
   1315        return Promise.resolve();
   1316      });
   1317  },
   1318 
   1319  registration(aPageRecord) {
   1320    lazy.console.debug("registration()");
   1321 
   1322    return this._getByPageRecord(aPageRecord).then(record => {
   1323      if (!record) {
   1324        return null;
   1325      }
   1326      if (record.isExpired()) {
   1327        return record.quotaChanged().then(isChanged => {
   1328          if (isChanged) {
   1329            return this.dropRegistrationAndNotifyApp(record.keyID).then(
   1330              _ => null
   1331            );
   1332          }
   1333          return null;
   1334        });
   1335      }
   1336      return record.toSubscription();
   1337    });
   1338  },
   1339 
   1340  _dropExpiredRegistrations() {
   1341    lazy.console.debug("dropExpiredRegistrations()");
   1342 
   1343    return this._db.getAllExpired().then(records => {
   1344      return Promise.all(
   1345        records.map(record =>
   1346          record
   1347            .quotaChanged()
   1348            .then(isChanged => {
   1349              if (isChanged) {
   1350                // If the user revisited the site, drop the expired push
   1351                // registration and notify the associated service worker.
   1352                this.dropRegistrationAndNotifyApp(record.keyID);
   1353              }
   1354            })
   1355            .catch(error => {
   1356              lazy.console.error(
   1357                "dropExpiredRegistrations: Error dropping registration",
   1358                record.keyID,
   1359                error
   1360              );
   1361            })
   1362        )
   1363      );
   1364    });
   1365  },
   1366 
   1367  _onPermissionChange(subject, data) {
   1368    lazy.console.debug("onPermissionChange()");
   1369 
   1370    if (data == "cleared") {
   1371      return this._clearPermissions();
   1372    }
   1373 
   1374    let permission = subject.QueryInterface(Ci.nsIPermission);
   1375    if (permission.type != "desktop-notification") {
   1376      return Promise.resolve();
   1377    }
   1378 
   1379    return this._updatePermission(permission, data);
   1380  },
   1381 
   1382  _clearPermissions() {
   1383    lazy.console.debug("clearPermissions()");
   1384 
   1385    return this._db.clearIf(record => {
   1386      if (!record.quotaApplies()) {
   1387        // Only drop registrations that are subject to quota.
   1388        return false;
   1389      }
   1390      this._backgroundUnregister(
   1391        record,
   1392        Ci.nsIPushErrorReporter.UNSUBSCRIBE_PERMISSION_REVOKED
   1393      );
   1394      return true;
   1395    });
   1396  },
   1397 
   1398  _updatePermission(permission, type) {
   1399    lazy.console.debug("updatePermission()");
   1400 
   1401    let isAllow = permission.capability == Ci.nsIPermissionManager.ALLOW_ACTION;
   1402    let isChange = type == "added" || type == "changed";
   1403 
   1404    if (isAllow && isChange) {
   1405      // Permission set to "allow". Drop all expired registrations for this
   1406      // site, notify the associated service workers, and reset the quota
   1407      // for active registrations.
   1408      return this._forEachPrincipal(permission.principal, (record, cursor) =>
   1409        this._permissionAllowed(record, cursor)
   1410      );
   1411    } else if (isChange || (isAllow && type == "deleted")) {
   1412      // Permission set to "block" or "always ask," or "allow" permission
   1413      // removed. Expire all registrations for this site.
   1414      return this._forEachPrincipal(permission.principal, (record, cursor) =>
   1415        this._permissionDenied(record, cursor)
   1416      );
   1417    }
   1418 
   1419    return Promise.resolve();
   1420  },
   1421 
   1422  _forEachPrincipal(principal, callback) {
   1423    return this._db.forEachOrigin(
   1424      principal.URI.prePath,
   1425      ChromeUtils.originAttributesToSuffix(principal.originAttributes),
   1426      callback
   1427    );
   1428  },
   1429 
   1430  /**
   1431   * The update function called for each registration record if the push
   1432   * permission is revoked. We only expire the record so we can notify the
   1433   * service worker as soon as the permission is reinstated. If we just
   1434   * deleted the record, the worker wouldn't be notified until the next visit
   1435   * to the site.
   1436   *
   1437   * @param {PushRecord} record The record to expire.
   1438   * @param {IDBCursor} cursor The IndexedDB cursor.
   1439   */
   1440  _permissionDenied(record, cursor) {
   1441    lazy.console.debug("permissionDenied()");
   1442 
   1443    if (!record.quotaApplies() || record.isExpired()) {
   1444      // Ignore already-expired records.
   1445      return;
   1446    }
   1447    // Drop the registration in the background.
   1448    this._backgroundUnregister(
   1449      record,
   1450      Ci.nsIPushErrorReporter.UNSUBSCRIBE_PERMISSION_REVOKED
   1451    );
   1452    record.setQuota(0);
   1453    cursor.update(record);
   1454  },
   1455 
   1456  /**
   1457   * The update function called for each registration record if the push
   1458   * permission is granted. If the record has expired, it will be dropped;
   1459   * otherwise, its quota will be reset to the default value.
   1460   *
   1461   * @param {PushRecord} record The record to update.
   1462   * @param {IDBCursor} cursor The IndexedDB cursor.
   1463   */
   1464  _permissionAllowed(record, cursor) {
   1465    lazy.console.debug("permissionAllowed()");
   1466 
   1467    if (!record.quotaApplies()) {
   1468      return;
   1469    }
   1470    if (record.isExpired()) {
   1471      // If the registration has expired, drop and notify the worker
   1472      // unconditionally.
   1473      this._notifySubscriptionChangeObservers(record);
   1474      cursor.delete();
   1475      return;
   1476    }
   1477    record.resetQuota();
   1478    cursor.update(record);
   1479  },
   1480 
   1481  /**
   1482   * Drops all matching registrations from the database. Notifies the
   1483   * associated service workers if permission is granted, and removes
   1484   * unexpired registrations from the server.
   1485   *
   1486   * @param {Function} predicate A function called for each record.
   1487   * @returns {Promise} Resolves once the registrations have been dropped.
   1488   */
   1489  _dropRegistrationsIf(predicate) {
   1490    return this._db.clearIf(record => {
   1491      if (!predicate(record)) {
   1492        return false;
   1493      }
   1494      if (record.hasPermission()) {
   1495        // "Clear Recent History" and the Forget button remove permissions
   1496        // before clearing registrations, but it's possible for the worker to
   1497        // resubscribe if the "dom.push.testing.ignorePermission" pref is set.
   1498        this._notifySubscriptionChangeObservers(record);
   1499      }
   1500      if (!record.isExpired()) {
   1501        if (!record.systemRecord) {
   1502          Glean.webPush.unsubscribedByClearingData.add();
   1503        }
   1504        // Only unregister active registrations, since we already told the
   1505        // server about expired ones.
   1506        this._backgroundUnregister(
   1507          record,
   1508          Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL
   1509        );
   1510      }
   1511      return true;
   1512    });
   1513  },
   1514 };