Merge mozilla-central to autoland. a=merge CLOSED TREE
[gecko.git] / dom / push / PushComponents.sys.mjs
blob4a2bfc1279ee9bcd59ca733a6573d97245d965dc
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
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 /**
6  * This file exports XPCOM components for C++ and chrome JavaScript callers to
7  * interact with the Push service.
8  */
10 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
12 var isParent =
13   Services.appinfo.processType === Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT;
15 const lazy = {};
17 // The default Push service implementation.
18 ChromeUtils.defineLazyGetter(lazy, "PushService", function () {
19   if (Services.prefs.getBoolPref("dom.push.enabled")) {
20     const { PushService } = ChromeUtils.importESModule(
21       "resource://gre/modules/PushService.sys.mjs"
22     );
23     PushService.init();
24     return PushService;
25   }
27   throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE);
28 });
30 // Observer notification topics for push messages and subscription status
31 // changes. These are duplicated and used in `nsIPushNotifier`. They're exposed
32 // on `nsIPushService` so that JS callers only need to import this service.
33 const OBSERVER_TOPIC_PUSH = "push-message";
34 const OBSERVER_TOPIC_SUBSCRIPTION_CHANGE = "push-subscription-change";
35 const OBSERVER_TOPIC_SUBSCRIPTION_MODIFIED = "push-subscription-modified";
37 /**
38  * `PushServiceBase`, `PushServiceParent`, and `PushServiceContent` collectively
39  * implement the `nsIPushService` interface. This interface provides calls
40  * similar to the Push DOM API, but does not require service workers.
41  *
42  * Push service methods may be called from the parent or content process. The
43  * parent process implementation loads `PushService.sys.mjs` at app startup, and
44  * calls its methods directly. The content implementation forwards calls to
45  * the parent Push service via IPC.
46  *
47  * The implementations share a class and contract ID.
48  */
49 function PushServiceBase() {
50   this.wrappedJSObject = this;
51   this._addListeners();
54 PushServiceBase.prototype = {
55   classID: Components.ID("{daaa8d73-677e-4233-8acd-2c404bd01658}"),
56   contractID: "@mozilla.org/push/Service;1",
57   QueryInterface: ChromeUtils.generateQI([
58     "nsIObserver",
59     "nsISupportsWeakReference",
60     "nsIPushService",
61     "nsIPushQuotaManager",
62     "nsIPushErrorReporter",
63   ]),
65   pushTopic: OBSERVER_TOPIC_PUSH,
66   subscriptionChangeTopic: OBSERVER_TOPIC_SUBSCRIPTION_CHANGE,
67   subscriptionModifiedTopic: OBSERVER_TOPIC_SUBSCRIPTION_MODIFIED,
69   ensureReady() {},
71   _addListeners() {
72     for (let message of this._messages) {
73       this._mm.addMessageListener(message, this);
74     }
75   },
77   _isValidMessage(message) {
78     return this._messages.includes(message.name);
79   },
81   observe(subject, topic) {
82     if (topic === "android-push-service") {
83       // Load PushService immediately.
84       this.ensureReady();
85     }
86   },
88   _deliverSubscription(request, props) {
89     if (!props) {
90       request.onPushSubscription(Cr.NS_OK, null);
91       return;
92     }
93     request.onPushSubscription(Cr.NS_OK, new PushSubscription(props));
94   },
96   _deliverSubscriptionError(request, error) {
97     let result =
98       typeof error.result == "number" ? error.result : Cr.NS_ERROR_FAILURE;
99     request.onPushSubscription(result, null);
100   },
104  * The parent process implementation of `nsIPushService`. This version loads
105  * `PushService.sys.mjs` at startup and calls its methods directly. It also
106  * receives and responds to requests from the content process.
107  */
108 let parentInstance;
109 function PushServiceParent() {
110   if (parentInstance) {
111     return parentInstance;
112   }
113   parentInstance = this;
115   PushServiceBase.call(this);
118 PushServiceParent.prototype = Object.create(PushServiceBase.prototype);
120 XPCOMUtils.defineLazyServiceGetter(
121   PushServiceParent.prototype,
122   "_mm",
123   "@mozilla.org/parentprocessmessagemanager;1",
124   "nsISupports"
127 Object.assign(PushServiceParent.prototype, {
128   _messages: [
129     "Push:Register",
130     "Push:Registration",
131     "Push:Unregister",
132     "Push:Clear",
133     "Push:NotificationForOriginShown",
134     "Push:NotificationForOriginClosed",
135     "Push:ReportError",
136   ],
138   // nsIPushService methods
140   subscribe(scope, principal, callback) {
141     this.subscribeWithKey(scope, principal, [], callback);
142   },
144   subscribeWithKey(scope, principal, key, callback) {
145     this._handleRequest("Push:Register", principal, {
146       scope,
147       appServerKey: key,
148     })
149       .then(
150         result => {
151           this._deliverSubscription(callback, result);
152         },
153         error => {
154           this._deliverSubscriptionError(callback, error);
155         }
156       )
157       .catch(console.error);
158   },
160   unsubscribe(scope, principal, callback) {
161     this._handleRequest("Push:Unregister", principal, {
162       scope,
163     })
164       .then(
165         result => {
166           callback.onUnsubscribe(Cr.NS_OK, result);
167         },
168         () => {
169           callback.onUnsubscribe(Cr.NS_ERROR_FAILURE, false);
170         }
171       )
172       .catch(console.error);
173   },
175   getSubscription(scope, principal, callback) {
176     return this._handleRequest("Push:Registration", principal, {
177       scope,
178     })
179       .then(
180         result => {
181           this._deliverSubscription(callback, result);
182         },
183         error => {
184           this._deliverSubscriptionError(callback, error);
185         }
186       )
187       .catch(console.error);
188   },
190   clearForDomain(domain, callback) {
191     return this._handleRequest("Push:Clear", null, {
192       domain,
193     })
194       .then(
195         () => {
196           callback.onClear(Cr.NS_OK);
197         },
198         () => {
199           callback.onClear(Cr.NS_ERROR_FAILURE);
200         }
201       )
202       .catch(console.error);
203   },
205   // nsIPushQuotaManager methods
207   notificationForOriginShown(origin) {
208     this.service.notificationForOriginShown(origin);
209   },
211   notificationForOriginClosed(origin) {
212     this.service.notificationForOriginClosed(origin);
213   },
215   // nsIPushErrorReporter methods
217   reportDeliveryError(messageId, reason) {
218     this.service.reportDeliveryError(messageId, reason);
219   },
221   receiveMessage(message) {
222     if (!this._isValidMessage(message)) {
223       return;
224     }
225     let { name, target, data } = message;
226     if (name === "Push:NotificationForOriginShown") {
227       this.notificationForOriginShown(data);
228       return;
229     }
230     if (name === "Push:NotificationForOriginClosed") {
231       this.notificationForOriginClosed(data);
232       return;
233     }
234     if (name === "Push:ReportError") {
235       this.reportDeliveryError(data.messageId, data.reason);
236       return;
237     }
238     this._handleRequest(name, data.principal, data)
239       .then(
240         result => {
241           target.sendAsyncMessage(this._getResponseName(name, "OK"), {
242             requestID: data.requestID,
243             result,
244           });
245         },
246         error => {
247           target.sendAsyncMessage(this._getResponseName(name, "KO"), {
248             requestID: data.requestID,
249             result: error.result,
250           });
251         }
252       )
253       .catch(console.error);
254   },
256   ensureReady() {
257     this.service.init();
258   },
260   _toPageRecord(principal, data) {
261     if (!data.scope) {
262       throw new Error("Invalid page record: missing scope");
263     }
264     if (!principal) {
265       throw new Error("Invalid page record: missing principal");
266     }
267     if (principal.isNullPrincipal || principal.isExpandedPrincipal) {
268       throw new Error("Invalid page record: unsupported principal");
269     }
271     // System subscriptions can only be created by chrome callers, and are
272     // exempt from the background message quota and permission checks. They
273     // also do not fire service worker events.
274     data.systemRecord = principal.isSystemPrincipal;
276     data.originAttributes = ChromeUtils.originAttributesToSuffix(
277       principal.originAttributes
278     );
280     return data;
281   },
283   async _handleRequest(name, principal, data) {
284     if (name == "Push:Clear") {
285       return this.service.clear(data);
286     }
288     let pageRecord;
289     try {
290       pageRecord = this._toPageRecord(principal, data);
291     } catch (e) {
292       return Promise.reject(e);
293     }
295     if (name === "Push:Register") {
296       return this.service.register(pageRecord);
297     }
298     if (name === "Push:Registration") {
299       return this.service.registration(pageRecord);
300     }
301     if (name === "Push:Unregister") {
302       return this.service.unregister(pageRecord);
303     }
305     return Promise.reject(new Error("Invalid request: unknown name"));
306   },
308   _getResponseName(requestName, suffix) {
309     let name = requestName.slice("Push:".length);
310     return "PushService:" + name + ":" + suffix;
311   },
313   // Methods used for mocking in tests.
315   replaceServiceBackend(options) {
316     return this.service.changeTestServer(options.serverURI, options);
317   },
319   restoreServiceBackend() {
320     var defaultServerURL = Services.prefs.getCharPref("dom.push.serverURL");
321     return this.service.changeTestServer(defaultServerURL);
322   },
325 // Used to replace the implementation with a mock.
326 Object.defineProperty(PushServiceParent.prototype, "service", {
327   get() {
328     return this._service || lazy.PushService;
329   },
330   set(impl) {
331     this._service = impl;
332   },
335 let contentInstance;
337  * The content process implementation of `nsIPushService`. This version
338  * uses the child message manager to forward calls to the parent process.
339  * The parent Push service instance handles the request, and responds with a
340  * message containing the result.
341  */
342 function PushServiceContent() {
343   if (contentInstance) {
344     return contentInstance;
345   }
346   contentInstance = this;
348   PushServiceBase.apply(this, arguments);
349   this._requests = new Map();
350   this._requestId = 0;
353 PushServiceContent.prototype = Object.create(PushServiceBase.prototype);
355 XPCOMUtils.defineLazyServiceGetter(
356   PushServiceContent.prototype,
357   "_mm",
358   "@mozilla.org/childprocessmessagemanager;1",
359   "nsISupports"
362 Object.assign(PushServiceContent.prototype, {
363   _messages: [
364     "PushService:Register:OK",
365     "PushService:Register:KO",
366     "PushService:Registration:OK",
367     "PushService:Registration:KO",
368     "PushService:Unregister:OK",
369     "PushService:Unregister:KO",
370     "PushService:Clear:OK",
371     "PushService:Clear:KO",
372   ],
374   // nsIPushService methods
376   subscribe(scope, principal, callback) {
377     this.subscribeWithKey(scope, principal, [], callback);
378   },
380   subscribeWithKey(scope, principal, key, callback) {
381     let requestID = this._addRequest(callback);
382     this._mm.sendAsyncMessage("Push:Register", {
383       scope,
384       appServerKey: key,
385       requestID,
386       principal,
387     });
388   },
390   unsubscribe(scope, principal, callback) {
391     let requestID = this._addRequest(callback);
392     this._mm.sendAsyncMessage("Push:Unregister", {
393       scope,
394       requestID,
395       principal,
396     });
397   },
399   getSubscription(scope, principal, callback) {
400     let requestID = this._addRequest(callback);
401     this._mm.sendAsyncMessage("Push:Registration", {
402       scope,
403       requestID,
404       principal,
405     });
406   },
408   clearForDomain(domain, callback) {
409     let requestID = this._addRequest(callback);
410     this._mm.sendAsyncMessage("Push:Clear", {
411       domain,
412       requestID,
413     });
414   },
416   // nsIPushQuotaManager methods
418   notificationForOriginShown(origin) {
419     this._mm.sendAsyncMessage("Push:NotificationForOriginShown", origin);
420   },
422   notificationForOriginClosed(origin) {
423     this._mm.sendAsyncMessage("Push:NotificationForOriginClosed", origin);
424   },
426   // nsIPushErrorReporter methods
428   reportDeliveryError(messageId, reason) {
429     this._mm.sendAsyncMessage("Push:ReportError", {
430       messageId,
431       reason,
432     });
433   },
435   _addRequest(data) {
436     let id = ++this._requestId;
437     this._requests.set(id, data);
438     return id;
439   },
441   _takeRequest(requestId) {
442     let d = this._requests.get(requestId);
443     this._requests.delete(requestId);
444     return d;
445   },
447   receiveMessage(message) {
448     if (!this._isValidMessage(message)) {
449       return;
450     }
451     let { name, data } = message;
452     let request = this._takeRequest(data.requestID);
454     if (!request) {
455       return;
456     }
458     switch (name) {
459       case "PushService:Register:OK":
460       case "PushService:Registration:OK":
461         this._deliverSubscription(request, data.result);
462         break;
464       case "PushService:Register:KO":
465       case "PushService:Registration:KO":
466         this._deliverSubscriptionError(request, data);
467         break;
469       case "PushService:Unregister:OK":
470         if (typeof data.result === "boolean") {
471           request.onUnsubscribe(Cr.NS_OK, data.result);
472         } else {
473           request.onUnsubscribe(Cr.NS_ERROR_FAILURE, false);
474         }
475         break;
477       case "PushService:Unregister:KO":
478         request.onUnsubscribe(Cr.NS_ERROR_FAILURE, false);
479         break;
481       case "PushService:Clear:OK":
482         request.onClear(Cr.NS_OK);
483         break;
485       case "PushService:Clear:KO":
486         request.onClear(Cr.NS_ERROR_FAILURE);
487         break;
489       default:
490         break;
491     }
492   },
495 /** `PushSubscription` instances are passed to all subscription callbacks. */
496 function PushSubscription(props) {
497   this._props = props;
500 PushSubscription.prototype = {
501   QueryInterface: ChromeUtils.generateQI(["nsIPushSubscription"]),
503   /** The URL for sending messages to this subscription. */
504   get endpoint() {
505     return this._props.endpoint;
506   },
508   /** The last time a message was sent to this subscription. */
509   get lastPush() {
510     return this._props.lastPush;
511   },
513   /** The total number of messages sent to this subscription. */
514   get pushCount() {
515     return this._props.pushCount;
516   },
518   /** The number of remaining background messages that can be sent to this
519    * subscription, or -1 of the subscription is exempt from the quota.
520    */
521   get quota() {
522     return this._props.quota;
523   },
525   /**
526    * Indicates whether this subscription was created with the system principal.
527    * System subscriptions are exempt from the background message quota and
528    * permission checks.
529    */
530   get isSystemSubscription() {
531     return !!this._props.systemRecord;
532   },
534   /** The private key used to decrypt incoming push messages, in JWK format */
535   get p256dhPrivateKey() {
536     return this._props.p256dhPrivateKey;
537   },
539   /**
540    * Indicates whether this subscription is subject to the background message
541    * quota.
542    */
543   quotaApplies() {
544     return this.quota >= 0;
545   },
547   /**
548    * Indicates whether this subscription exceeded the background message quota,
549    * or the user revoked the notification permission. The caller must request a
550    * new subscription to continue receiving push messages.
551    */
552   isExpired() {
553     return this.quota === 0;
554   },
556   /**
557    * Returns a key for encrypting messages sent to this subscription. JS
558    * callers receive the key buffer as a return value, while C++ callers
559    * receive the key size and buffer as out parameters.
560    */
561   getKey(name) {
562     switch (name) {
563       case "p256dh":
564         return this._getRawKey(this._props.p256dhKey);
566       case "auth":
567         return this._getRawKey(this._props.authenticationSecret);
569       case "appServer":
570         return this._getRawKey(this._props.appServerKey);
571     }
572     return [];
573   },
575   _getRawKey(key) {
576     if (!key) {
577       return [];
578     }
579     return new Uint8Array(key);
580   },
583 // Export the correct implementation depending on whether we're running in
584 // the parent or content process.
585 export let Service = isParent ? PushServiceParent : PushServiceContent;