forked from mirrors/gecko-dev
		
	 fffa5382c1
			
		
	
	
		fffa5382c1
		
	
	
	
	
		
			
			Differential Revision: https://phabricator.services.mozilla.com/D45608 --HG-- extra : moz-landing-system : lando
		
			
				
	
	
		
			1493 lines
		
	
	
	
		
			46 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			1493 lines
		
	
	
	
		
			46 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /* This Source Code Form is subject to the terms of the Mozilla Public
 | |
|  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 | |
|  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 | |
| 
 | |
| "use strict";
 | |
| 
 | |
| const { AppConstants } = ChromeUtils.import(
 | |
|   "resource://gre/modules/AppConstants.jsm"
 | |
| );
 | |
| const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
 | |
| const { clearTimeout, setTimeout } = ChromeUtils.import(
 | |
|   "resource://gre/modules/Timer.jsm"
 | |
| );
 | |
| const { XPCOMUtils } = ChromeUtils.import(
 | |
|   "resource://gre/modules/XPCOMUtils.jsm"
 | |
| );
 | |
| 
 | |
| var PushServiceWebSocket, PushServiceHttp2;
 | |
| 
 | |
| XPCOMUtils.defineLazyServiceGetter(
 | |
|   this,
 | |
|   "gPushNotifier",
 | |
|   "@mozilla.org/push/Notifier;1",
 | |
|   "nsIPushNotifier"
 | |
| );
 | |
| XPCOMUtils.defineLazyServiceGetter(
 | |
|   this,
 | |
|   "eTLDService",
 | |
|   "@mozilla.org/network/effective-tld-service;1",
 | |
|   "nsIEffectiveTLDService"
 | |
| );
 | |
| ChromeUtils.defineModuleGetter(
 | |
|   this,
 | |
|   "pushBroadcastService",
 | |
|   "resource://gre/modules/PushBroadcastService.jsm"
 | |
| );
 | |
| ChromeUtils.defineModuleGetter(
 | |
|   this,
 | |
|   "PushCrypto",
 | |
|   "resource://gre/modules/PushCrypto.jsm"
 | |
| );
 | |
| ChromeUtils.defineModuleGetter(
 | |
|   this,
 | |
|   "PushServiceAndroidGCM",
 | |
|   "resource://gre/modules/PushServiceAndroidGCM.jsm"
 | |
| );
 | |
| 
 | |
| const CONNECTION_PROTOCOLS = (function() {
 | |
|   if ("android" != AppConstants.MOZ_WIDGET_TOOLKIT) {
 | |
|     ({ PushServiceWebSocket } = ChromeUtils.import(
 | |
|       "resource://gre/modules/PushServiceWebSocket.jsm"
 | |
|     ));
 | |
|     ({ PushServiceHttp2 } = ChromeUtils.import(
 | |
|       "resource://gre/modules/PushServiceHttp2.jsm"
 | |
|     ));
 | |
|     return [PushServiceWebSocket, PushServiceHttp2];
 | |
|   }
 | |
|   return [PushServiceAndroidGCM];
 | |
| })();
 | |
| 
 | |
| const EXPORTED_SYMBOLS = ["PushService"];
 | |
| 
 | |
| XPCOMUtils.defineLazyGetter(this, "console", () => {
 | |
|   let { ConsoleAPI } = ChromeUtils.import("resource://gre/modules/Console.jsm");
 | |
|   return new ConsoleAPI({
 | |
|     maxLogLevelPref: "dom.push.loglevel",
 | |
|     prefix: "PushService",
 | |
|   });
 | |
| });
 | |
| 
 | |
| const prefs = Services.prefs.getBranch("dom.push.");
 | |
| 
 | |
| const PUSH_SERVICE_UNINIT = 0;
 | |
| const PUSH_SERVICE_INIT = 1; // No serverURI
 | |
| const PUSH_SERVICE_ACTIVATING = 2; // activating db
 | |
| const PUSH_SERVICE_CONNECTION_DISABLE = 3;
 | |
| const PUSH_SERVICE_ACTIVE_OFFLINE = 4;
 | |
| const PUSH_SERVICE_RUNNING = 5;
 | |
| 
 | |
| /**
 | |
|  * State is change only in couple of functions:
 | |
|  *   init - change state to PUSH_SERVICE_INIT if state was PUSH_SERVICE_UNINIT
 | |
|  *   changeServerURL - change state to PUSH_SERVICE_ACTIVATING if serverURL
 | |
|  *                     present or PUSH_SERVICE_INIT if not present.
 | |
|  *   changeStateConnectionEnabledEvent - it is call on pref change or during
 | |
|  *                                       the service activation and it can
 | |
|  *                                       change state to
 | |
|  *                                       PUSH_SERVICE_CONNECTION_DISABLE
 | |
|  *   changeStateOfflineEvent - it is called when offline state changes or during
 | |
|  *                             the service activation and it change state to
 | |
|  *                             PUSH_SERVICE_ACTIVE_OFFLINE or
 | |
|  *                             PUSH_SERVICE_RUNNING.
 | |
|  *   uninit - change state to PUSH_SERVICE_UNINIT.
 | |
|  **/
 | |
| 
 | |
| // This is for starting and stopping service.
 | |
| const STARTING_SERVICE_EVENT = 0;
 | |
| const CHANGING_SERVICE_EVENT = 1;
 | |
| const STOPPING_SERVICE_EVENT = 2;
 | |
| const UNINIT_EVENT = 3;
 | |
| 
 | |
| // Returns the backend for the given server URI.
 | |
| function getServiceForServerURI(uri) {
 | |
|   // Insecure server URLs are allowed for development and testing.
 | |
|   let allowInsecure = prefs.getBoolPref(
 | |
|     "testing.allowInsecureServerURL",
 | |
|     false
 | |
|   );
 | |
|   if (AppConstants.MOZ_WIDGET_TOOLKIT == "android") {
 | |
|     if (uri.scheme == "https" || (allowInsecure && uri.scheme == "http")) {
 | |
|       return CONNECTION_PROTOCOLS;
 | |
|     }
 | |
|     return null;
 | |
|   }
 | |
|   if (uri.scheme == "wss" || (allowInsecure && uri.scheme == "ws")) {
 | |
|     return PushServiceWebSocket;
 | |
|   }
 | |
|   if (uri.scheme == "https" || (allowInsecure && uri.scheme == "http")) {
 | |
|     return PushServiceHttp2;
 | |
|   }
 | |
|   return null;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Annotates an error with an XPCOM result code. We use this helper
 | |
|  * instead of `Components.Exception` because the latter can assert in
 | |
|  * `nsXPCComponents_Exception::HasInstance` when inspected at shutdown.
 | |
|  */
 | |
| function errorWithResult(message, result = Cr.NS_ERROR_FAILURE) {
 | |
|   let error = new Error(message);
 | |
|   error.result = result;
 | |
|   return error;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * The implementation of the push system. It uses WebSockets
 | |
|  * (PushServiceWebSocket) to communicate with the server and PushDB (IndexedDB)
 | |
|  * for persistence.
 | |
|  */
 | |
| var PushService = {
 | |
|   _service: null,
 | |
|   _state: PUSH_SERVICE_UNINIT,
 | |
|   _db: null,
 | |
|   _options: null,
 | |
|   _visibleNotifications: new Map(),
 | |
| 
 | |
|   // Callback that is called after attempting to
 | |
|   // reduce the quota for a record. Used for testing purposes.
 | |
|   _updateQuotaTestCallback: null,
 | |
| 
 | |
|   // Set of timeout ID of tasks to reduce quota.
 | |
|   _updateQuotaTimeouts: new Set(),
 | |
| 
 | |
|   // When serverURI changes (this is used for testing), db is cleaned up and a
 | |
|   // a new db is started. This events must be sequential.
 | |
|   _stateChangeProcessQueue: null,
 | |
|   _stateChangeProcessEnqueue(op) {
 | |
|     if (!this._stateChangeProcessQueue) {
 | |
|       this._stateChangeProcessQueue = Promise.resolve();
 | |
|     }
 | |
| 
 | |
|     this._stateChangeProcessQueue = this._stateChangeProcessQueue
 | |
|       .then(op)
 | |
|       .catch(error => {
 | |
|         console.error(
 | |
|           "stateChangeProcessEnqueue: Error transitioning state",
 | |
|           error
 | |
|         );
 | |
|         return this._shutdownService();
 | |
|       })
 | |
|       .catch(error => {
 | |
|         console.error(
 | |
|           "stateChangeProcessEnqueue: Error shutting down service",
 | |
|           error
 | |
|         );
 | |
|       });
 | |
|     return this._stateChangeProcessQueue;
 | |
|   },
 | |
| 
 | |
|   // Pending request. If a worker try to register for the same scope again, do
 | |
|   // not send a new registration request. Therefore we need queue of pending
 | |
|   // register requests. This is the list of scopes which pending registration.
 | |
|   _pendingRegisterRequest: {},
 | |
|   _notifyActivated: null,
 | |
|   _activated: null,
 | |
|   _checkActivated() {
 | |
|     if (this._state < PUSH_SERVICE_ACTIVATING) {
 | |
|       return Promise.reject(new Error("Push service not active"));
 | |
|     }
 | |
|     if (this._state > PUSH_SERVICE_ACTIVATING) {
 | |
|       return Promise.resolve();
 | |
|     }
 | |
|     if (!this._activated) {
 | |
|       this._activated = new Promise((resolve, reject) => {
 | |
|         this._notifyActivated = { resolve, reject };
 | |
|       });
 | |
|     }
 | |
|     return this._activated;
 | |
|   },
 | |
| 
 | |
|   _makePendingKey(aPageRecord) {
 | |
|     return aPageRecord.scope + "|" + aPageRecord.originAttributes;
 | |
|   },
 | |
| 
 | |
|   _lookupOrPutPendingRequest(aPageRecord) {
 | |
|     let key = this._makePendingKey(aPageRecord);
 | |
|     if (this._pendingRegisterRequest[key]) {
 | |
|       return this._pendingRegisterRequest[key];
 | |
|     }
 | |
| 
 | |
|     return (this._pendingRegisterRequest[key] = this._registerWithServer(
 | |
|       aPageRecord
 | |
|     ));
 | |
|   },
 | |
| 
 | |
|   _deletePendingRequest(aPageRecord) {
 | |
|     let key = this._makePendingKey(aPageRecord);
 | |
|     if (this._pendingRegisterRequest[key]) {
 | |
|       delete this._pendingRegisterRequest[key];
 | |
|     }
 | |
|   },
 | |
| 
 | |
|   _setState(aNewState) {
 | |
|     console.debug(
 | |
|       "setState()",
 | |
|       "new state",
 | |
|       aNewState,
 | |
|       "old state",
 | |
|       this._state
 | |
|     );
 | |
| 
 | |
|     if (this._state == aNewState) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (this._state == PUSH_SERVICE_ACTIVATING) {
 | |
|       // It is not important what is the new state as soon as we leave
 | |
|       // PUSH_SERVICE_ACTIVATING
 | |
|       if (this._notifyActivated) {
 | |
|         if (aNewState < PUSH_SERVICE_ACTIVATING) {
 | |
|           this._notifyActivated.reject(new Error("Push service not active"));
 | |
|         } else {
 | |
|           this._notifyActivated.resolve();
 | |
|         }
 | |
|       }
 | |
|       this._notifyActivated = null;
 | |
|       this._activated = null;
 | |
|     }
 | |
|     this._state = aNewState;
 | |
|   },
 | |
| 
 | |
|   async _changeStateOfflineEvent(offline, calledFromConnEnabledEvent) {
 | |
|     console.debug("changeStateOfflineEvent()", offline);
 | |
| 
 | |
|     if (
 | |
|       this._state < PUSH_SERVICE_ACTIVE_OFFLINE &&
 | |
|       this._state != PUSH_SERVICE_ACTIVATING &&
 | |
|       !calledFromConnEnabledEvent
 | |
|     ) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (offline) {
 | |
|       if (this._state == PUSH_SERVICE_RUNNING) {
 | |
|         this._service.disconnect();
 | |
|       }
 | |
|       this._setState(PUSH_SERVICE_ACTIVE_OFFLINE);
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (this._state == PUSH_SERVICE_RUNNING) {
 | |
|       // PushService was not in the offline state, but got notification to
 | |
|       // go online (a offline notification has not been sent).
 | |
|       // Disconnect first.
 | |
|       this._service.disconnect();
 | |
|     }
 | |
| 
 | |
|     let broadcastListeners = await pushBroadcastService.getListeners();
 | |
| 
 | |
|     // In principle, a listener could be added to the
 | |
|     // pushBroadcastService here, after we have gotten listeners and
 | |
|     // before we're RUNNING, but this can't happen in practice because
 | |
|     // the only caller that can add listeners is PushBroadcastService,
 | |
|     // and it waits on the same promise we are before it can add
 | |
|     // listeners. If PushBroadcastService gets woken first, it will
 | |
|     // update the value that is eventually returned from
 | |
|     // getListeners.
 | |
|     this._setState(PUSH_SERVICE_RUNNING);
 | |
| 
 | |
|     this._service.connect(broadcastListeners);
 | |
|   },
 | |
| 
 | |
|   _changeStateConnectionEnabledEvent(enabled) {
 | |
|     console.debug("changeStateConnectionEnabledEvent()", enabled);
 | |
| 
 | |
|     if (
 | |
|       this._state < PUSH_SERVICE_CONNECTION_DISABLE &&
 | |
|       this._state != PUSH_SERVICE_ACTIVATING
 | |
|     ) {
 | |
|       return Promise.resolve();
 | |
|     }
 | |
| 
 | |
|     if (enabled) {
 | |
|       return this._changeStateOfflineEvent(Services.io.offline, true);
 | |
|     }
 | |
| 
 | |
|     if (this._state == PUSH_SERVICE_RUNNING) {
 | |
|       this._service.disconnect();
 | |
|     }
 | |
|     this._setState(PUSH_SERVICE_CONNECTION_DISABLE);
 | |
|     return Promise.resolve();
 | |
|   },
 | |
| 
 | |
|   // Used for testing.
 | |
|   changeTestServer(url, options = {}) {
 | |
|     console.debug("changeTestServer()");
 | |
| 
 | |
|     return this._stateChangeProcessEnqueue(_ => {
 | |
|       if (this._state < PUSH_SERVICE_ACTIVATING) {
 | |
|         console.debug("changeTestServer: PushService not activated?");
 | |
|         return Promise.resolve();
 | |
|       }
 | |
| 
 | |
|       return this._changeServerURL(url, CHANGING_SERVICE_EVENT, options);
 | |
|     });
 | |
|   },
 | |
| 
 | |
|   observe: function observe(aSubject, aTopic, aData) {
 | |
|     switch (aTopic) {
 | |
|       /*
 | |
|        * We need to call uninit() on shutdown to clean up things that modules
 | |
|        * aren't very good at automatically cleaning up, so we don't get shutdown
 | |
|        * leaks on browser shutdown.
 | |
|        */
 | |
|       case "quit-application":
 | |
|         this.uninit();
 | |
|         break;
 | |
|       case "network:offline-status-changed":
 | |
|         this._stateChangeProcessEnqueue(_ =>
 | |
|           this._changeStateOfflineEvent(aData === "offline", false)
 | |
|         );
 | |
|         break;
 | |
| 
 | |
|       case "nsPref:changed":
 | |
|         if (aData == "serverURL") {
 | |
|           console.debug(
 | |
|             "observe: dom.push.serverURL changed for websocket",
 | |
|             prefs.getStringPref("serverURL")
 | |
|           );
 | |
|           this._stateChangeProcessEnqueue(_ =>
 | |
|             this._changeServerURL(
 | |
|               prefs.getStringPref("serverURL"),
 | |
|               CHANGING_SERVICE_EVENT
 | |
|             )
 | |
|           );
 | |
|         } else if (aData == "connection.enabled") {
 | |
|           this._stateChangeProcessEnqueue(_ =>
 | |
|             this._changeStateConnectionEnabledEvent(
 | |
|               prefs.getBoolPref("connection.enabled")
 | |
|             )
 | |
|           );
 | |
|         }
 | |
|         break;
 | |
| 
 | |
|       case "idle-daily":
 | |
|         this._dropExpiredRegistrations().catch(error => {
 | |
|           console.error("Failed to drop expired registrations on idle", error);
 | |
|         });
 | |
|         break;
 | |
| 
 | |
|       case "perm-changed":
 | |
|         this._onPermissionChange(aSubject, aData).catch(error => {
 | |
|           console.error(
 | |
|             "onPermissionChange: Error updating registrations:",
 | |
|             error
 | |
|           );
 | |
|         });
 | |
|         break;
 | |
| 
 | |
|       case "clear-origin-attributes-data":
 | |
|         this._clearOriginData(aData).catch(error => {
 | |
|           console.error("clearOriginData: Error clearing origin data:", error);
 | |
|         });
 | |
|         break;
 | |
|     }
 | |
|   },
 | |
| 
 | |
|   _clearOriginData(data) {
 | |
|     console.log("clearOriginData()");
 | |
| 
 | |
|     if (!data) {
 | |
|       return Promise.resolve();
 | |
|     }
 | |
| 
 | |
|     let pattern = JSON.parse(data);
 | |
|     return this._dropRegistrationsIf(record =>
 | |
|       record.matchesOriginAttributes(pattern)
 | |
|     );
 | |
|   },
 | |
| 
 | |
|   /**
 | |
|    * Sends an unregister request to the server in the background. If the
 | |
|    * service is not connected, this function is a no-op.
 | |
|    *
 | |
|    * @param {PushRecord} record The record to unregister.
 | |
|    * @param {Number} reason An `nsIPushErrorReporter` unsubscribe reason,
 | |
|    *  indicating why this record was removed.
 | |
|    */
 | |
|   _backgroundUnregister(record, reason) {
 | |
|     console.debug("backgroundUnregister()");
 | |
| 
 | |
|     if (!this._service.isConnected() || !record) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     console.debug("backgroundUnregister: Notifying server", record);
 | |
|     this._sendUnregister(record, reason)
 | |
|       .then(() => {
 | |
|         gPushNotifier.notifySubscriptionModified(
 | |
|           record.scope,
 | |
|           record.principal
 | |
|         );
 | |
|       })
 | |
|       .catch(e => {
 | |
|         console.error("backgroundUnregister: Error notifying server", e);
 | |
|       });
 | |
|   },
 | |
| 
 | |
|   _findService(serverURL) {
 | |
|     console.debug("findService()");
 | |
| 
 | |
|     if (!serverURL) {
 | |
|       console.warn("findService: No dom.push.serverURL found");
 | |
|       return [];
 | |
|     }
 | |
| 
 | |
|     let uri;
 | |
|     try {
 | |
|       uri = Services.io.newURI(serverURL);
 | |
|     } catch (e) {
 | |
|       console.warn(
 | |
|         "findService: Error creating valid URI from",
 | |
|         "dom.push.serverURL",
 | |
|         serverURL
 | |
|       );
 | |
|       return [];
 | |
|     }
 | |
| 
 | |
|     let service = getServiceForServerURI(uri);
 | |
|     return [service, uri];
 | |
|   },
 | |
| 
 | |
|   _changeServerURL(serverURI, event, options = {}) {
 | |
|     console.debug("changeServerURL()");
 | |
| 
 | |
|     switch (event) {
 | |
|       case UNINIT_EVENT:
 | |
|         return this._stopService(event);
 | |
| 
 | |
|       case STARTING_SERVICE_EVENT: {
 | |
|         let [service, uri] = this._findService(serverURI);
 | |
|         if (!service) {
 | |
|           this._setState(PUSH_SERVICE_INIT);
 | |
|           return Promise.resolve();
 | |
|         }
 | |
|         return this._startService(service, uri, options).then(_ =>
 | |
|           this._changeStateConnectionEnabledEvent(
 | |
|             prefs.getBoolPref("connection.enabled")
 | |
|           )
 | |
|         );
 | |
|       }
 | |
|       case CHANGING_SERVICE_EVENT:
 | |
|         let [service, uri] = this._findService(serverURI);
 | |
|         if (service) {
 | |
|           if (this._state == PUSH_SERVICE_INIT) {
 | |
|             this._setState(PUSH_SERVICE_ACTIVATING);
 | |
|             // The service has not been running - start it.
 | |
|             return this._startService(service, uri, options).then(_ =>
 | |
|               this._changeStateConnectionEnabledEvent(
 | |
|                 prefs.getBoolPref("connection.enabled")
 | |
|               )
 | |
|             );
 | |
|           }
 | |
|           this._setState(PUSH_SERVICE_ACTIVATING);
 | |
|           // If we already had running service - stop service, start the new
 | |
|           // one and check connection.enabled and offline state(offline state
 | |
|           // check is called in changeStateConnectionEnabledEvent function)
 | |
|           return this._stopService(CHANGING_SERVICE_EVENT)
 | |
|             .then(_ => this._startService(service, uri, options))
 | |
|             .then(_ =>
 | |
|               this._changeStateConnectionEnabledEvent(
 | |
|                 prefs.getBoolPref("connection.enabled")
 | |
|               )
 | |
|             );
 | |
|         }
 | |
|         if (this._state == PUSH_SERVICE_INIT) {
 | |
|           return Promise.resolve();
 | |
|         }
 | |
|         // The new serverUri is empty or misconfigured - stop service.
 | |
|         this._setState(PUSH_SERVICE_INIT);
 | |
|         return this._stopService(STOPPING_SERVICE_EVENT);
 | |
| 
 | |
|       default:
 | |
|         console.error("Unexpected event in _changeServerURL", event);
 | |
|         return Promise.reject(new Error(`Unexpected event ${event}`));
 | |
|     }
 | |
|   },
 | |
| 
 | |
|   /**
 | |
|    * PushService initialization is divided into 4 parts:
 | |
|    * init() - start listening for quit-application and serverURL changes.
 | |
|    *          state is change to PUSH_SERVICE_INIT
 | |
|    * startService() - if serverURL is present this function is called. It starts
 | |
|    *                  listening for broadcasted messages, starts db and
 | |
|    *                  PushService connection (WebSocket).
 | |
|    *                  state is change to PUSH_SERVICE_ACTIVATING.
 | |
|    * startObservers() - start other observers.
 | |
|    * changeStateConnectionEnabledEvent  - checks prefs and offline state.
 | |
|    *                                      It changes state to:
 | |
|    *                                        PUSH_SERVICE_RUNNING,
 | |
|    *                                        PUSH_SERVICE_ACTIVE_OFFLINE or
 | |
|    *                                        PUSH_SERVICE_CONNECTION_DISABLE.
 | |
|    */
 | |
|   async init(options = {}) {
 | |
|     console.debug("init()");
 | |
| 
 | |
|     if (this._state > PUSH_SERVICE_UNINIT) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     this._setState(PUSH_SERVICE_ACTIVATING);
 | |
| 
 | |
|     prefs.addObserver("serverURL", this);
 | |
|     Services.obs.addObserver(this, "quit-application");
 | |
| 
 | |
|     if (options.serverURI) {
 | |
|       // this is use for xpcshell test.
 | |
| 
 | |
|       await this._stateChangeProcessEnqueue(_ =>
 | |
|         this._changeServerURL(
 | |
|           options.serverURI,
 | |
|           STARTING_SERVICE_EVENT,
 | |
|           options
 | |
|         )
 | |
|       );
 | |
|     } else {
 | |
|       // This is only used for testing. Different tests require connecting to
 | |
|       // slightly different URLs.
 | |
|       await this._stateChangeProcessEnqueue(_ =>
 | |
|         this._changeServerURL(
 | |
|           prefs.getStringPref("serverURL"),
 | |
|           STARTING_SERVICE_EVENT
 | |
|         )
 | |
|       );
 | |
|     }
 | |
|   },
 | |
| 
 | |
|   _startObservers() {
 | |
|     console.debug("startObservers()");
 | |
| 
 | |
|     if (this._state != PUSH_SERVICE_ACTIVATING) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     Services.obs.addObserver(this, "clear-origin-attributes-data");
 | |
| 
 | |
|     // The offline-status-changed event is used to know
 | |
|     // when to (dis)connect. It may not fire if the underlying OS changes
 | |
|     // networks; in such a case we rely on timeout.
 | |
|     Services.obs.addObserver(this, "network:offline-status-changed");
 | |
| 
 | |
|     // Used to monitor if the user wishes to disable Push.
 | |
|     prefs.addObserver("connection.enabled", this);
 | |
| 
 | |
|     // Prunes expired registrations and notifies dormant service workers.
 | |
|     Services.obs.addObserver(this, "idle-daily");
 | |
| 
 | |
|     // Prunes registrations for sites for which the user revokes push
 | |
|     // permissions.
 | |
|     Services.obs.addObserver(this, "perm-changed");
 | |
|   },
 | |
| 
 | |
|   _startService(service, serverURI, options) {
 | |
|     console.debug("startService()");
 | |
| 
 | |
|     if (this._state != PUSH_SERVICE_ACTIVATING) {
 | |
|       return Promise.reject();
 | |
|     }
 | |
| 
 | |
|     this._service = service;
 | |
| 
 | |
|     this._db = options.db;
 | |
|     if (!this._db) {
 | |
|       this._db = this._service.newPushDB();
 | |
|     }
 | |
| 
 | |
|     return this._service.init(options, this, serverURI).then(() => {
 | |
|       this._startObservers();
 | |
|       return this._dropExpiredRegistrations();
 | |
|     });
 | |
|   },
 | |
| 
 | |
|   /**
 | |
|    * PushService uninitialization is divided into 3 parts:
 | |
|    * stopObservers() - stot observers started in startObservers.
 | |
|    * stopService() - It stops listening for broadcasted messages, stops db and
 | |
|    *                 PushService connection (WebSocket).
 | |
|    *                 state is changed to PUSH_SERVICE_INIT.
 | |
|    * uninit() - stop listening for quit-application and serverURL changes.
 | |
|    *            state is change to PUSH_SERVICE_UNINIT
 | |
|    */
 | |
|   _stopService(event) {
 | |
|     console.debug("stopService()");
 | |
| 
 | |
|     if (this._state < PUSH_SERVICE_ACTIVATING) {
 | |
|       return Promise.resolve();
 | |
|     }
 | |
| 
 | |
|     this._stopObservers();
 | |
| 
 | |
|     this._service.disconnect();
 | |
|     this._service.uninit();
 | |
|     this._service = null;
 | |
| 
 | |
|     this._updateQuotaTimeouts.forEach(timeoutID => clearTimeout(timeoutID));
 | |
|     this._updateQuotaTimeouts.clear();
 | |
| 
 | |
|     if (!this._db) {
 | |
|       return Promise.resolve();
 | |
|     }
 | |
|     if (event == UNINIT_EVENT) {
 | |
|       // If it is uninitialized just close db.
 | |
|       this._db.close();
 | |
|       this._db = null;
 | |
|       return Promise.resolve();
 | |
|     }
 | |
| 
 | |
|     return this.dropUnexpiredRegistrations().then(
 | |
|       _ => {
 | |
|         this._db.close();
 | |
|         this._db = null;
 | |
|       },
 | |
|       err => {
 | |
|         this._db.close();
 | |
|         this._db = null;
 | |
|       }
 | |
|     );
 | |
|   },
 | |
| 
 | |
|   _stopObservers() {
 | |
|     console.debug("stopObservers()");
 | |
| 
 | |
|     if (this._state < PUSH_SERVICE_ACTIVATING) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     prefs.removeObserver("connection.enabled", this);
 | |
| 
 | |
|     Services.obs.removeObserver(this, "network:offline-status-changed");
 | |
|     Services.obs.removeObserver(this, "clear-origin-attributes-data");
 | |
|     Services.obs.removeObserver(this, "idle-daily");
 | |
|     Services.obs.removeObserver(this, "perm-changed");
 | |
|   },
 | |
| 
 | |
|   _shutdownService() {
 | |
|     let promiseChangeURL = this._changeServerURL("", UNINIT_EVENT);
 | |
|     this._setState(PUSH_SERVICE_UNINIT);
 | |
|     console.debug("shutdownService: shutdown complete!");
 | |
|     return promiseChangeURL;
 | |
|   },
 | |
| 
 | |
|   async uninit() {
 | |
|     console.debug("uninit()");
 | |
| 
 | |
|     if (this._state == PUSH_SERVICE_UNINIT) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     prefs.removeObserver("serverURL", this);
 | |
|     Services.obs.removeObserver(this, "quit-application");
 | |
| 
 | |
|     await this._stateChangeProcessEnqueue(_ => this._shutdownService());
 | |
|   },
 | |
| 
 | |
|   /**
 | |
|    * Drops all active registrations and notifies the associated service
 | |
|    * workers. This function is called when the user switches Push servers,
 | |
|    * or when the server invalidates all existing registrations.
 | |
|    *
 | |
|    * We ignore expired registrations because they're already handled in other
 | |
|    * code paths. Registrations that expired after exceeding their quotas are
 | |
|    * evicted at startup, or on the next `idle-daily` event. Registrations that
 | |
|    * expired because the user revoked the notification permission are evicted
 | |
|    * once the permission is reinstated.
 | |
|    */
 | |
|   dropUnexpiredRegistrations() {
 | |
|     return this._db.clearIf(record => {
 | |
|       if (record.isExpired()) {
 | |
|         return false;
 | |
|       }
 | |
|       this._notifySubscriptionChangeObservers(record);
 | |
|       return true;
 | |
|     });
 | |
|   },
 | |
| 
 | |
|   _notifySubscriptionChangeObservers(record) {
 | |
|     if (!record) {
 | |
|       return;
 | |
|     }
 | |
|     gPushNotifier.notifySubscriptionChange(record.scope, record.principal);
 | |
|   },
 | |
| 
 | |
|   /**
 | |
|    * Drops a registration and notifies the associated service worker. If the
 | |
|    * registration does not exist, this function is a no-op.
 | |
|    *
 | |
|    * @param {String} keyID The registration ID to remove.
 | |
|    * @returns {Promise} Resolves once the worker has been notified.
 | |
|    */
 | |
|   dropRegistrationAndNotifyApp(aKeyID) {
 | |
|     return this._db
 | |
|       .delete(aKeyID)
 | |
|       .then(record => this._notifySubscriptionChangeObservers(record));
 | |
|   },
 | |
| 
 | |
|   /**
 | |
|    * Replaces an existing registration and notifies the associated service
 | |
|    * worker.
 | |
|    *
 | |
|    * @param {String} aOldKey The registration ID to replace.
 | |
|    * @param {PushRecord} aNewRecord The new record.
 | |
|    * @returns {Promise} Resolves once the worker has been notified.
 | |
|    */
 | |
|   updateRegistrationAndNotifyApp(aOldKey, aNewRecord) {
 | |
|     return this.updateRecordAndNotifyApp(aOldKey, _ => aNewRecord);
 | |
|   },
 | |
|   /**
 | |
|    * Updates a registration and notifies the associated service worker.
 | |
|    *
 | |
|    * @param {String} keyID The registration ID to update.
 | |
|    * @param {Function} updateFunc Returns the updated record.
 | |
|    * @returns {Promise} Resolves with the updated record once the worker
 | |
|    *  has been notified.
 | |
|    */
 | |
|   updateRecordAndNotifyApp(aKeyID, aUpdateFunc) {
 | |
|     return this._db.update(aKeyID, aUpdateFunc).then(record => {
 | |
|       this._notifySubscriptionChangeObservers(record);
 | |
|       return record;
 | |
|     });
 | |
|   },
 | |
| 
 | |
|   ensureCrypto(record) {
 | |
|     if (
 | |
|       record.hasAuthenticationSecret() &&
 | |
|       record.p256dhPublicKey &&
 | |
|       record.p256dhPrivateKey
 | |
|     ) {
 | |
|       return Promise.resolve(record);
 | |
|     }
 | |
| 
 | |
|     let keygen = Promise.resolve([]);
 | |
|     if (!record.p256dhPublicKey || !record.p256dhPrivateKey) {
 | |
|       keygen = PushCrypto.generateKeys();
 | |
|     }
 | |
|     // We do not have a encryption key. so we need to generate it. This
 | |
|     // is only going to happen on db upgrade from version 4 to higher.
 | |
|     return keygen.then(
 | |
|       ([pubKey, privKey]) => {
 | |
|         return this.updateRecordAndNotifyApp(record.keyID, record => {
 | |
|           if (!record.p256dhPublicKey || !record.p256dhPrivateKey) {
 | |
|             record.p256dhPublicKey = pubKey;
 | |
|             record.p256dhPrivateKey = privKey;
 | |
|           }
 | |
|           if (!record.hasAuthenticationSecret()) {
 | |
|             record.authenticationSecret = PushCrypto.generateAuthenticationSecret();
 | |
|           }
 | |
|           return record;
 | |
|         });
 | |
|       },
 | |
|       error => {
 | |
|         return this.dropRegistrationAndNotifyApp(record.keyID).then(() =>
 | |
|           Promise.reject(error)
 | |
|         );
 | |
|       }
 | |
|     );
 | |
|   },
 | |
| 
 | |
|   /**
 | |
|    * Dispatches an incoming message to a service worker, recalculating the
 | |
|    * quota for the associated push registration. If the quota is exceeded,
 | |
|    * the registration and message will be dropped, and the worker will not
 | |
|    * be notified.
 | |
|    *
 | |
|    * @param {String} keyID The push registration ID.
 | |
|    * @param {String} messageID The message ID, used to report service worker
 | |
|    *  delivery failures. For Web Push messages, this is the version. If empty,
 | |
|    *  failures will not be reported.
 | |
|    * @param {Object} headers The encryption headers.
 | |
|    * @param {ArrayBuffer|Uint8Array} data The encrypted message data.
 | |
|    * @param {Function} updateFunc A function that receives the existing
 | |
|    *  registration record as its argument, and returns a new record. If the
 | |
|    *  function returns `null` or `undefined`, the record will not be updated.
 | |
|    *  `PushServiceWebSocket` uses this to drop incoming updates with older
 | |
|    *  versions.
 | |
|    * @returns {Promise} Resolves with an `nsIPushErrorReporter` ack status
 | |
|    *  code, indicating whether the message was delivered successfully.
 | |
|    */
 | |
|   receivedPushMessage(keyID, messageID, headers, data, updateFunc) {
 | |
|     console.debug("receivedPushMessage()");
 | |
| 
 | |
|     return this._updateRecordAfterPush(keyID, updateFunc)
 | |
|       .then(record => {
 | |
|         if (record.quotaApplies()) {
 | |
|           // Update quota after the delay, at which point
 | |
|           // we check for visible notifications.
 | |
|           let timeoutID = setTimeout(_ => {
 | |
|             this._updateQuota(keyID);
 | |
|             if (!this._updateQuotaTimeouts.delete(timeoutID)) {
 | |
|               console.debug(
 | |
|                 "receivedPushMessage: quota update timeout missing?"
 | |
|               );
 | |
|             }
 | |
|           }, prefs.getIntPref("quotaUpdateDelay"));
 | |
|           this._updateQuotaTimeouts.add(timeoutID);
 | |
|         }
 | |
|         return this._decryptAndNotifyApp(record, messageID, headers, data);
 | |
|       })
 | |
|       .catch(error => {
 | |
|         console.error("receivedPushMessage: Error notifying app", error);
 | |
|         return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED;
 | |
|       });
 | |
|   },
 | |
| 
 | |
|   /**
 | |
|    * Dispatches a broadcast notification to the BroadcastService.
 | |
|    *
 | |
|    * @param {Object} message The reply received by PushServiceWebSocket
 | |
|    * @param {Object} context Additional information about the context in which the
 | |
|    *  notification was received.
 | |
|    */
 | |
|   receivedBroadcastMessage(message, context) {
 | |
|     pushBroadcastService
 | |
|       .receivedBroadcastMessage(message.broadcasts, context)
 | |
|       .catch(e => {
 | |
|         console.error(e);
 | |
|       });
 | |
|   },
 | |
| 
 | |
|   /**
 | |
|    * Updates a registration record after receiving a push message.
 | |
|    *
 | |
|    * @param {String} keyID The push registration ID.
 | |
|    * @param {Function} updateFunc The function passed to `receivedPushMessage`.
 | |
|    * @returns {Promise} Resolves with the updated record, or rejects if the
 | |
|    *  record was not updated.
 | |
|    */
 | |
|   _updateRecordAfterPush(keyID, updateFunc) {
 | |
|     return this.getByKeyID(keyID)
 | |
|       .then(record => {
 | |
|         if (!record) {
 | |
|           throw new Error("No record for key ID " + keyID);
 | |
|         }
 | |
|         return record
 | |
|           .getLastVisit()
 | |
|           .then(lastVisit => {
 | |
|             // As a special case, don't notify the service worker if the user
 | |
|             // cleared their history.
 | |
|             if (!isFinite(lastVisit)) {
 | |
|               throw new Error("Ignoring message sent to unvisited origin");
 | |
|             }
 | |
|             return lastVisit;
 | |
|           })
 | |
|           .then(lastVisit => {
 | |
|             // Update the record, resetting the quota if the user has visited the
 | |
|             // site since the last push.
 | |
|             return this._db.update(keyID, record => {
 | |
|               let newRecord = updateFunc(record);
 | |
|               if (!newRecord) {
 | |
|                 return null;
 | |
|               }
 | |
|               // Because `unregister` is advisory only, we can still receive messages
 | |
|               // for stale Simple Push registrations from the server. To work around
 | |
|               // this, we check if the record has expired before *and* after updating
 | |
|               // the quota.
 | |
|               if (newRecord.isExpired()) {
 | |
|                 return null;
 | |
|               }
 | |
|               newRecord.receivedPush(lastVisit);
 | |
|               return newRecord;
 | |
|             });
 | |
|           });
 | |
|       })
 | |
|       .then(record => {
 | |
|         gPushNotifier.notifySubscriptionModified(
 | |
|           record.scope,
 | |
|           record.principal
 | |
|         );
 | |
|         return record;
 | |
|       });
 | |
|   },
 | |
| 
 | |
|   /**
 | |
|    * Decrypts an incoming message and notifies the associated service worker.
 | |
|    *
 | |
|    * @param {PushRecord} record The receiving registration.
 | |
|    * @param {String} messageID The message ID.
 | |
|    * @param {Object} headers The encryption headers.
 | |
|    * @param {ArrayBuffer|Uint8Array} data The encrypted message data.
 | |
|    * @returns {Promise} Resolves with an ack status code.
 | |
|    */
 | |
|   _decryptAndNotifyApp(record, messageID, headers, data) {
 | |
|     return PushCrypto.decrypt(
 | |
|       record.p256dhPrivateKey,
 | |
|       record.p256dhPublicKey,
 | |
|       record.authenticationSecret,
 | |
|       headers,
 | |
|       data
 | |
|     ).then(
 | |
|       message => this._notifyApp(record, messageID, message),
 | |
|       error => {
 | |
|         console.warn(
 | |
|           "decryptAndNotifyApp: Error decrypting message",
 | |
|           record.scope,
 | |
|           messageID,
 | |
|           error
 | |
|         );
 | |
| 
 | |
|         let message = error.format(record.scope);
 | |
|         gPushNotifier.notifyError(
 | |
|           record.scope,
 | |
|           record.principal,
 | |
|           message,
 | |
|           Ci.nsIScriptError.errorFlag
 | |
|         );
 | |
|         return Ci.nsIPushErrorReporter.ACK_DECRYPTION_ERROR;
 | |
|       }
 | |
|     );
 | |
|   },
 | |
| 
 | |
|   _updateQuota(keyID) {
 | |
|     console.debug("updateQuota()");
 | |
| 
 | |
|     this._db
 | |
|       .update(keyID, record => {
 | |
|         // Record may have expired from an earlier quota update.
 | |
|         if (record.isExpired()) {
 | |
|           console.debug(
 | |
|             "updateQuota: Trying to update quota for expired record",
 | |
|             record
 | |
|           );
 | |
|           return null;
 | |
|         }
 | |
|         // If there are visible notifications, don't apply the quota penalty
 | |
|         // for the message.
 | |
|         if (record.uri && !this._visibleNotifications.has(record.uri.prePath)) {
 | |
|           record.reduceQuota();
 | |
|         }
 | |
|         return record;
 | |
|       })
 | |
|       .then(record => {
 | |
|         if (record.isExpired()) {
 | |
|           // Drop the registration in the background. If the user returns to the
 | |
|           // site, the service worker will be notified on the next `idle-daily`
 | |
|           // event.
 | |
|           this._backgroundUnregister(
 | |
|             record,
 | |
|             Ci.nsIPushErrorReporter.UNSUBSCRIBE_QUOTA_EXCEEDED
 | |
|           );
 | |
|         } else {
 | |
|           gPushNotifier.notifySubscriptionModified(
 | |
|             record.scope,
 | |
|             record.principal
 | |
|           );
 | |
|         }
 | |
|         if (this._updateQuotaTestCallback) {
 | |
|           // Callback so that test may be notified when the quota update is complete.
 | |
|           this._updateQuotaTestCallback();
 | |
|         }
 | |
|       })
 | |
|       .catch(error => {
 | |
|         console.debug("updateQuota: Error while trying to update quota", error);
 | |
|       });
 | |
|   },
 | |
| 
 | |
|   notificationForOriginShown(origin) {
 | |
|     console.debug("notificationForOriginShown()", origin);
 | |
|     let count;
 | |
|     if (this._visibleNotifications.has(origin)) {
 | |
|       count = this._visibleNotifications.get(origin);
 | |
|     } else {
 | |
|       count = 0;
 | |
|     }
 | |
|     this._visibleNotifications.set(origin, count + 1);
 | |
|   },
 | |
| 
 | |
|   notificationForOriginClosed(origin) {
 | |
|     console.debug("notificationForOriginClosed()", origin);
 | |
|     let count;
 | |
|     if (this._visibleNotifications.has(origin)) {
 | |
|       count = this._visibleNotifications.get(origin);
 | |
|     } else {
 | |
|       console.debug(
 | |
|         "notificationForOriginClosed: closing notification that has not been shown?"
 | |
|       );
 | |
|       return;
 | |
|     }
 | |
|     if (count > 1) {
 | |
|       this._visibleNotifications.set(origin, count - 1);
 | |
|     } else {
 | |
|       this._visibleNotifications.delete(origin);
 | |
|     }
 | |
|   },
 | |
| 
 | |
|   reportDeliveryError(messageID, reason) {
 | |
|     console.debug("reportDeliveryError()", messageID, reason);
 | |
|     if (this._state == PUSH_SERVICE_RUNNING && this._service.isConnected()) {
 | |
|       // Only report errors if we're initialized and connected.
 | |
|       this._service.reportDeliveryError(messageID, reason);
 | |
|     }
 | |
|   },
 | |
| 
 | |
|   _notifyApp(aPushRecord, messageID, message) {
 | |
|     if (
 | |
|       !aPushRecord ||
 | |
|       !aPushRecord.scope ||
 | |
|       aPushRecord.originAttributes === undefined
 | |
|     ) {
 | |
|       console.error("notifyApp: Invalid record", aPushRecord);
 | |
|       return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED;
 | |
|     }
 | |
| 
 | |
|     console.debug("notifyApp()", aPushRecord.scope);
 | |
| 
 | |
|     // If permission has been revoked, trash the message.
 | |
|     if (!aPushRecord.hasPermission()) {
 | |
|       console.warn("notifyApp: Missing push permission", aPushRecord);
 | |
|       return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED;
 | |
|     }
 | |
| 
 | |
|     let payload = ArrayBuffer.isView(message)
 | |
|       ? new Uint8Array(message.buffer)
 | |
|       : message;
 | |
| 
 | |
|     if (aPushRecord.quotaApplies()) {
 | |
|       // Don't record telemetry for chrome push messages.
 | |
|       Services.telemetry.getHistogramById("PUSH_API_NOTIFY").add();
 | |
|     }
 | |
| 
 | |
|     if (payload) {
 | |
|       gPushNotifier.notifyPushWithData(
 | |
|         aPushRecord.scope,
 | |
|         aPushRecord.principal,
 | |
|         messageID,
 | |
|         payload
 | |
|       );
 | |
|     } else {
 | |
|       gPushNotifier.notifyPush(
 | |
|         aPushRecord.scope,
 | |
|         aPushRecord.principal,
 | |
|         messageID
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     return Ci.nsIPushErrorReporter.ACK_DELIVERED;
 | |
|   },
 | |
| 
 | |
|   getByKeyID(aKeyID) {
 | |
|     return this._db.getByKeyID(aKeyID);
 | |
|   },
 | |
| 
 | |
|   getAllUnexpired() {
 | |
|     return this._db.getAllUnexpired();
 | |
|   },
 | |
| 
 | |
|   _sendRequest(action, ...params) {
 | |
|     if (this._state == PUSH_SERVICE_CONNECTION_DISABLE) {
 | |
|       return Promise.reject(new Error("Push service disabled"));
 | |
|     }
 | |
|     if (this._state == PUSH_SERVICE_ACTIVE_OFFLINE) {
 | |
|       return Promise.reject(new Error("Push service offline"));
 | |
|     }
 | |
|     // Ensure the backend is ready. `getByPageRecord` already checks this, but
 | |
|     // we need to check again here in case the service was restarted in the
 | |
|     // meantime.
 | |
|     return this._checkActivated().then(_ => {
 | |
|       switch (action) {
 | |
|         case "register":
 | |
|           return this._service.register(...params);
 | |
|         case "unregister":
 | |
|           return this._service.unregister(...params);
 | |
|       }
 | |
|       return Promise.reject(new Error("Unknown request type: " + action));
 | |
|     });
 | |
|   },
 | |
| 
 | |
|   /**
 | |
|    * Called on message from the child process. aPageRecord is an object sent by
 | |
|    * the push manager, identifying the sending page and other fields.
 | |
|    */
 | |
|   _registerWithServer(aPageRecord) {
 | |
|     console.debug("registerWithServer()", aPageRecord);
 | |
| 
 | |
|     return this._sendRequest("register", aPageRecord)
 | |
|       .then(
 | |
|         record => this._onRegisterSuccess(record),
 | |
|         err => this._onRegisterError(err)
 | |
|       )
 | |
|       .then(
 | |
|         record => {
 | |
|           this._deletePendingRequest(aPageRecord);
 | |
|           gPushNotifier.notifySubscriptionModified(
 | |
|             record.scope,
 | |
|             record.principal
 | |
|           );
 | |
|           return record.toSubscription();
 | |
|         },
 | |
|         err => {
 | |
|           this._deletePendingRequest(aPageRecord);
 | |
|           throw err;
 | |
|         }
 | |
|       );
 | |
|   },
 | |
| 
 | |
|   _sendUnregister(aRecord, aReason) {
 | |
|     return this._sendRequest("unregister", aRecord, aReason);
 | |
|   },
 | |
| 
 | |
|   /**
 | |
|    * Exceptions thrown in _onRegisterSuccess are caught by the promise obtained
 | |
|    * from _service.request, causing the promise to be rejected instead.
 | |
|    */
 | |
|   _onRegisterSuccess(aRecord) {
 | |
|     console.debug("_onRegisterSuccess()");
 | |
| 
 | |
|     return this._db.put(aRecord).catch(error => {
 | |
|       // Unable to save. Destroy the subscription in the background.
 | |
|       this._backgroundUnregister(
 | |
|         aRecord,
 | |
|         Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL
 | |
|       );
 | |
|       throw error;
 | |
|     });
 | |
|   },
 | |
| 
 | |
|   /**
 | |
|    * Exceptions thrown in _onRegisterError are caught by the promise obtained
 | |
|    * from _service.request, causing the promise to be rejected instead.
 | |
|    */
 | |
|   _onRegisterError(reply) {
 | |
|     console.debug("_onRegisterError()");
 | |
| 
 | |
|     if (!reply.error) {
 | |
|       console.warn(
 | |
|         "onRegisterError: Called without valid error message!",
 | |
|         reply
 | |
|       );
 | |
|       throw new Error("Registration error");
 | |
|     }
 | |
|     throw reply.error;
 | |
|   },
 | |
| 
 | |
|   notificationsCleared() {
 | |
|     this._visibleNotifications.clear();
 | |
|   },
 | |
| 
 | |
|   _getByPageRecord(pageRecord) {
 | |
|     return this._checkActivated().then(_ =>
 | |
|       this._db.getByIdentifiers(pageRecord)
 | |
|     );
 | |
|   },
 | |
| 
 | |
|   register(aPageRecord) {
 | |
|     console.debug("register()", aPageRecord);
 | |
| 
 | |
|     let keyPromise;
 | |
|     if (aPageRecord.appServerKey && aPageRecord.appServerKey.length != 0) {
 | |
|       let keyView = new Uint8Array(aPageRecord.appServerKey);
 | |
|       keyPromise = PushCrypto.validateAppServerKey(keyView).catch(error => {
 | |
|         // Normalize Web Crypto exceptions. `nsIPushService` will forward the
 | |
|         // error result to the DOM API implementation in `PushManager.cpp` or
 | |
|         // `Push.js`, which will convert it to the correct `DOMException`.
 | |
|         throw errorWithResult(
 | |
|           "Invalid app server key",
 | |
|           Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR
 | |
|         );
 | |
|       });
 | |
|     } else {
 | |
|       keyPromise = Promise.resolve(null);
 | |
|     }
 | |
| 
 | |
|     return Promise.all([keyPromise, this._getByPageRecord(aPageRecord)]).then(
 | |
|       ([appServerKey, record]) => {
 | |
|         aPageRecord.appServerKey = appServerKey;
 | |
|         if (!record) {
 | |
|           return this._lookupOrPutPendingRequest(aPageRecord);
 | |
|         }
 | |
|         if (!record.matchesAppServerKey(appServerKey)) {
 | |
|           throw errorWithResult(
 | |
|             "Mismatched app server key",
 | |
|             Cr.NS_ERROR_DOM_PUSH_MISMATCHED_KEY_ERR
 | |
|           );
 | |
|         }
 | |
|         if (record.isExpired()) {
 | |
|           return record
 | |
|             .quotaChanged()
 | |
|             .then(isChanged => {
 | |
|               if (isChanged) {
 | |
|                 // If the user revisited the site, drop the expired push
 | |
|                 // registration and re-register.
 | |
|                 return this.dropRegistrationAndNotifyApp(record.keyID);
 | |
|               }
 | |
|               throw new Error("Push subscription expired");
 | |
|             })
 | |
|             .then(_ => this._lookupOrPutPendingRequest(aPageRecord));
 | |
|         }
 | |
|         return record.toSubscription();
 | |
|       }
 | |
|     );
 | |
|   },
 | |
| 
 | |
|   /*
 | |
|    * Called only by the PushBroadcastService on the receipt of a new
 | |
|    * subscription. Don't call this directly. Go through PushBroadcastService.
 | |
|    */
 | |
|   async subscribeBroadcast(broadcastId, version) {
 | |
|     if (this._state != PUSH_SERVICE_RUNNING) {
 | |
|       // Ignore any request to subscribe before we send a hello.
 | |
|       // We'll send all the broadcast listeners as part of the hello
 | |
|       // anyhow.
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     await this._service.sendSubscribeBroadcast(broadcastId, version);
 | |
|   },
 | |
| 
 | |
|   /**
 | |
|    * Called on message from the child process.
 | |
|    *
 | |
|    * Why is the record being deleted from the local database before the server
 | |
|    * is told?
 | |
|    *
 | |
|    * Unregistration is for the benefit of the app and the AppServer
 | |
|    * so that the AppServer does not keep pinging a channel the UserAgent isn't
 | |
|    * watching The important part of the transaction in this case is left to the
 | |
|    * app, to tell its server of the unregistration.  Even if the request to the
 | |
|    * PushServer were to fail, it would not affect correctness of the protocol,
 | |
|    * and the server GC would just clean up the channelID/subscription
 | |
|    * eventually.  Since the appserver doesn't ping it, no data is lost.
 | |
|    *
 | |
|    * If rather we were to unregister at the server and update the database only
 | |
|    * on success: If the server receives the unregister, and deletes the
 | |
|    * channelID/subscription, but the response is lost because of network
 | |
|    * failure, the application is never informed. In addition the application may
 | |
|    * retry the unregister when it fails due to timeout (websocket) or any other
 | |
|    * reason at which point the server will say it does not know of this
 | |
|    * unregistration.  We'll have to make the registration/unregistration phases
 | |
|    * have retries and attempts to resend messages from the server, and have the
 | |
|    * client acknowledge. On a server, data is cheap, reliable notification is
 | |
|    * not.
 | |
|    */
 | |
|   unregister(aPageRecord) {
 | |
|     console.debug("unregister()", aPageRecord);
 | |
| 
 | |
|     return this._getByPageRecord(aPageRecord).then(record => {
 | |
|       if (record === null) {
 | |
|         return false;
 | |
|       }
 | |
| 
 | |
|       let reason = Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL;
 | |
|       return Promise.all([
 | |
|         this._sendUnregister(record, reason),
 | |
|         this._db.delete(record.keyID).then(rec => {
 | |
|           if (rec) {
 | |
|             gPushNotifier.notifySubscriptionModified(rec.scope, rec.principal);
 | |
|           }
 | |
|         }),
 | |
|       ]).then(([success]) => success);
 | |
|     });
 | |
|   },
 | |
| 
 | |
|   clear(info) {
 | |
|     return this._checkActivated()
 | |
|       .then(_ => {
 | |
|         return this._dropRegistrationsIf(
 | |
|           record =>
 | |
|             info.domain == "*" ||
 | |
|             (record.uri &&
 | |
|               eTLDService.hasRootDomain(record.uri.prePath, info.domain))
 | |
|         );
 | |
|       })
 | |
|       .catch(e => {
 | |
|         console.warn(
 | |
|           "clear: Error dropping subscriptions for domain",
 | |
|           info.domain,
 | |
|           e
 | |
|         );
 | |
|         return Promise.resolve();
 | |
|       });
 | |
|   },
 | |
| 
 | |
|   registration(aPageRecord) {
 | |
|     console.debug("registration()");
 | |
| 
 | |
|     return this._getByPageRecord(aPageRecord).then(record => {
 | |
|       if (!record) {
 | |
|         return null;
 | |
|       }
 | |
|       if (record.isExpired()) {
 | |
|         return record.quotaChanged().then(isChanged => {
 | |
|           if (isChanged) {
 | |
|             return this.dropRegistrationAndNotifyApp(record.keyID).then(
 | |
|               _ => null
 | |
|             );
 | |
|           }
 | |
|           return null;
 | |
|         });
 | |
|       }
 | |
|       return record.toSubscription();
 | |
|     });
 | |
|   },
 | |
| 
 | |
|   _dropExpiredRegistrations() {
 | |
|     console.debug("dropExpiredRegistrations()");
 | |
| 
 | |
|     return this._db.getAllExpired().then(records => {
 | |
|       return Promise.all(
 | |
|         records.map(record =>
 | |
|           record
 | |
|             .quotaChanged()
 | |
|             .then(isChanged => {
 | |
|               if (isChanged) {
 | |
|                 // If the user revisited the site, drop the expired push
 | |
|                 // registration and notify the associated service worker.
 | |
|                 this.dropRegistrationAndNotifyApp(record.keyID);
 | |
|               }
 | |
|             })
 | |
|             .catch(error => {
 | |
|               console.error(
 | |
|                 "dropExpiredRegistrations: Error dropping registration",
 | |
|                 record.keyID,
 | |
|                 error
 | |
|               );
 | |
|             })
 | |
|         )
 | |
|       );
 | |
|     });
 | |
|   },
 | |
| 
 | |
|   _onPermissionChange(subject, data) {
 | |
|     console.debug("onPermissionChange()");
 | |
| 
 | |
|     if (data == "cleared") {
 | |
|       return this._clearPermissions();
 | |
|     }
 | |
| 
 | |
|     let permission = subject.QueryInterface(Ci.nsIPermission);
 | |
|     if (permission.type != "desktop-notification") {
 | |
|       return Promise.resolve();
 | |
|     }
 | |
| 
 | |
|     return this._updatePermission(permission, data);
 | |
|   },
 | |
| 
 | |
|   _clearPermissions() {
 | |
|     console.debug("clearPermissions()");
 | |
| 
 | |
|     return this._db.clearIf(record => {
 | |
|       if (!record.quotaApplies()) {
 | |
|         // Only drop registrations that are subject to quota.
 | |
|         return false;
 | |
|       }
 | |
|       this._backgroundUnregister(
 | |
|         record,
 | |
|         Ci.nsIPushErrorReporter.UNSUBSCRIBE_PERMISSION_REVOKED
 | |
|       );
 | |
|       return true;
 | |
|     });
 | |
|   },
 | |
| 
 | |
|   _updatePermission(permission, type) {
 | |
|     console.debug("updatePermission()");
 | |
| 
 | |
|     let isAllow = permission.capability == Ci.nsIPermissionManager.ALLOW_ACTION;
 | |
|     let isChange = type == "added" || type == "changed";
 | |
| 
 | |
|     if (isAllow && isChange) {
 | |
|       // Permission set to "allow". Drop all expired registrations for this
 | |
|       // site, notify the associated service workers, and reset the quota
 | |
|       // for active registrations.
 | |
|       return this._forEachPrincipal(permission.principal, (record, cursor) =>
 | |
|         this._permissionAllowed(record, cursor)
 | |
|       );
 | |
|     } else if (isChange || (isAllow && type == "deleted")) {
 | |
|       // Permission set to "block" or "always ask," or "allow" permission
 | |
|       // removed. Expire all registrations for this site.
 | |
|       return this._forEachPrincipal(permission.principal, (record, cursor) =>
 | |
|         this._permissionDenied(record, cursor)
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     return Promise.resolve();
 | |
|   },
 | |
| 
 | |
|   _forEachPrincipal(principal, callback) {
 | |
|     return this._db.forEachOrigin(
 | |
|       principal.URI.prePath,
 | |
|       ChromeUtils.originAttributesToSuffix(principal.originAttributes),
 | |
|       callback
 | |
|     );
 | |
|   },
 | |
| 
 | |
|   /**
 | |
|    * The update function called for each registration record if the push
 | |
|    * permission is revoked. We only expire the record so we can notify the
 | |
|    * service worker as soon as the permission is reinstated. If we just
 | |
|    * deleted the record, the worker wouldn't be notified until the next visit
 | |
|    * to the site.
 | |
|    *
 | |
|    * @param {PushRecord} record The record to expire.
 | |
|    * @param {IDBCursor} cursor The IndexedDB cursor.
 | |
|    */
 | |
|   _permissionDenied(record, cursor) {
 | |
|     console.debug("permissionDenied()");
 | |
| 
 | |
|     if (!record.quotaApplies() || record.isExpired()) {
 | |
|       // Ignore already-expired records.
 | |
|       return;
 | |
|     }
 | |
|     // Drop the registration in the background.
 | |
|     this._backgroundUnregister(
 | |
|       record,
 | |
|       Ci.nsIPushErrorReporter.UNSUBSCRIBE_PERMISSION_REVOKED
 | |
|     );
 | |
|     record.setQuota(0);
 | |
|     cursor.update(record);
 | |
|   },
 | |
| 
 | |
|   /**
 | |
|    * The update function called for each registration record if the push
 | |
|    * permission is granted. If the record has expired, it will be dropped;
 | |
|    * otherwise, its quota will be reset to the default value.
 | |
|    *
 | |
|    * @param {PushRecord} record The record to update.
 | |
|    * @param {IDBCursor} cursor The IndexedDB cursor.
 | |
|    */
 | |
|   _permissionAllowed(record, cursor) {
 | |
|     console.debug("permissionAllowed()");
 | |
| 
 | |
|     if (!record.quotaApplies()) {
 | |
|       return;
 | |
|     }
 | |
|     if (record.isExpired()) {
 | |
|       // If the registration has expired, drop and notify the worker
 | |
|       // unconditionally.
 | |
|       this._notifySubscriptionChangeObservers(record);
 | |
|       cursor.delete();
 | |
|       return;
 | |
|     }
 | |
|     record.resetQuota();
 | |
|     cursor.update(record);
 | |
|   },
 | |
| 
 | |
|   /**
 | |
|    * Drops all matching registrations from the database. Notifies the
 | |
|    * associated service workers if permission is granted, and removes
 | |
|    * unexpired registrations from the server.
 | |
|    *
 | |
|    * @param {Function} predicate A function called for each record.
 | |
|    * @returns {Promise} Resolves once the registrations have been dropped.
 | |
|    */
 | |
|   _dropRegistrationsIf(predicate) {
 | |
|     return this._db.clearIf(record => {
 | |
|       if (!predicate(record)) {
 | |
|         return false;
 | |
|       }
 | |
|       if (record.hasPermission()) {
 | |
|         // "Clear Recent History" and the Forget button remove permissions
 | |
|         // before clearing registrations, but it's possible for the worker to
 | |
|         // resubscribe if the "dom.push.testing.ignorePermission" pref is set.
 | |
|         this._notifySubscriptionChangeObservers(record);
 | |
|       }
 | |
|       if (!record.isExpired()) {
 | |
|         // Only unregister active registrations, since we already told the
 | |
|         // server about expired ones.
 | |
|         this._backgroundUnregister(
 | |
|           record,
 | |
|           Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL
 | |
|         );
 | |
|       }
 | |
|       return true;
 | |
|     });
 | |
|   },
 | |
| };
 |