diff options
Diffstat (limited to 'dom/push/PushComponents.sys.mjs')
-rw-r--r-- | dom/push/PushComponents.sys.mjs | 585 |
1 files changed, 585 insertions, 0 deletions
diff --git a/dom/push/PushComponents.sys.mjs b/dom/push/PushComponents.sys.mjs new file mode 100644 index 0000000000..0ad0505851 --- /dev/null +++ b/dom/push/PushComponents.sys.mjs @@ -0,0 +1,585 @@ +/* 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/. */ + +/** + * This file exports XPCOM components for C++ and chrome JavaScript callers to + * interact with the Push service. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +var isParent = + Services.appinfo.processType === Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT; + +const lazy = {}; + +// The default Push service implementation. +ChromeUtils.defineLazyGetter(lazy, "PushService", function () { + if (Services.prefs.getBoolPref("dom.push.enabled")) { + const { PushService } = ChromeUtils.importESModule( + "resource://gre/modules/PushService.sys.mjs" + ); + PushService.init(); + return PushService; + } + + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); +}); + +// Observer notification topics for push messages and subscription status +// changes. These are duplicated and used in `nsIPushNotifier`. They're exposed +// on `nsIPushService` so that JS callers only need to import this service. +const OBSERVER_TOPIC_PUSH = "push-message"; +const OBSERVER_TOPIC_SUBSCRIPTION_CHANGE = "push-subscription-change"; +const OBSERVER_TOPIC_SUBSCRIPTION_MODIFIED = "push-subscription-modified"; + +/** + * `PushServiceBase`, `PushServiceParent`, and `PushServiceContent` collectively + * implement the `nsIPushService` interface. This interface provides calls + * similar to the Push DOM API, but does not require service workers. + * + * Push service methods may be called from the parent or content process. The + * parent process implementation loads `PushService.jsm` at app startup, and + * calls its methods directly. The content implementation forwards calls to + * the parent Push service via IPC. + * + * The implementations share a class and contract ID. + */ +function PushServiceBase() { + this.wrappedJSObject = this; + this._addListeners(); +} + +PushServiceBase.prototype = { + classID: Components.ID("{daaa8d73-677e-4233-8acd-2c404bd01658}"), + contractID: "@mozilla.org/push/Service;1", + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + "nsIPushService", + "nsIPushQuotaManager", + "nsIPushErrorReporter", + ]), + + pushTopic: OBSERVER_TOPIC_PUSH, + subscriptionChangeTopic: OBSERVER_TOPIC_SUBSCRIPTION_CHANGE, + subscriptionModifiedTopic: OBSERVER_TOPIC_SUBSCRIPTION_MODIFIED, + + ensureReady() {}, + + _addListeners() { + for (let message of this._messages) { + this._mm.addMessageListener(message, this); + } + }, + + _isValidMessage(message) { + return this._messages.includes(message.name); + }, + + observe(subject, topic, data) { + if (topic === "android-push-service") { + // Load PushService immediately. + this.ensureReady(); + } + }, + + _deliverSubscription(request, props) { + if (!props) { + request.onPushSubscription(Cr.NS_OK, null); + return; + } + request.onPushSubscription(Cr.NS_OK, new PushSubscription(props)); + }, + + _deliverSubscriptionError(request, error) { + let result = + typeof error.result == "number" ? error.result : Cr.NS_ERROR_FAILURE; + request.onPushSubscription(result, null); + }, +}; + +/** + * The parent process implementation of `nsIPushService`. This version loads + * `PushService.jsm` at startup and calls its methods directly. It also + * receives and responds to requests from the content process. + */ +let parentInstance; +function PushServiceParent() { + if (parentInstance) { + return parentInstance; + } + parentInstance = this; + + PushServiceBase.call(this); +} + +PushServiceParent.prototype = Object.create(PushServiceBase.prototype); + +XPCOMUtils.defineLazyServiceGetter( + PushServiceParent.prototype, + "_mm", + "@mozilla.org/parentprocessmessagemanager;1", + "nsISupports" +); + +Object.assign(PushServiceParent.prototype, { + _messages: [ + "Push:Register", + "Push:Registration", + "Push:Unregister", + "Push:Clear", + "Push:NotificationForOriginShown", + "Push:NotificationForOriginClosed", + "Push:ReportError", + ], + + // nsIPushService methods + + subscribe(scope, principal, callback) { + this.subscribeWithKey(scope, principal, [], callback); + }, + + subscribeWithKey(scope, principal, key, callback) { + this._handleRequest("Push:Register", principal, { + scope, + appServerKey: key, + }) + .then( + result => { + this._deliverSubscription(callback, result); + }, + error => { + this._deliverSubscriptionError(callback, error); + } + ) + .catch(console.error); + }, + + unsubscribe(scope, principal, callback) { + this._handleRequest("Push:Unregister", principal, { + scope, + }) + .then( + result => { + callback.onUnsubscribe(Cr.NS_OK, result); + }, + error => { + callback.onUnsubscribe(Cr.NS_ERROR_FAILURE, false); + } + ) + .catch(console.error); + }, + + getSubscription(scope, principal, callback) { + return this._handleRequest("Push:Registration", principal, { + scope, + }) + .then( + result => { + this._deliverSubscription(callback, result); + }, + error => { + this._deliverSubscriptionError(callback, error); + } + ) + .catch(console.error); + }, + + clearForDomain(domain, callback) { + return this._handleRequest("Push:Clear", null, { + domain, + }) + .then( + result => { + callback.onClear(Cr.NS_OK); + }, + error => { + callback.onClear(Cr.NS_ERROR_FAILURE); + } + ) + .catch(console.error); + }, + + // nsIPushQuotaManager methods + + notificationForOriginShown(origin) { + this.service.notificationForOriginShown(origin); + }, + + notificationForOriginClosed(origin) { + this.service.notificationForOriginClosed(origin); + }, + + // nsIPushErrorReporter methods + + reportDeliveryError(messageId, reason) { + this.service.reportDeliveryError(messageId, reason); + }, + + receiveMessage(message) { + if (!this._isValidMessage(message)) { + return; + } + let { name, target, data } = message; + if (name === "Push:NotificationForOriginShown") { + this.notificationForOriginShown(data); + return; + } + if (name === "Push:NotificationForOriginClosed") { + this.notificationForOriginClosed(data); + return; + } + if (name === "Push:ReportError") { + this.reportDeliveryError(data.messageId, data.reason); + return; + } + this._handleRequest(name, data.principal, data) + .then( + result => { + target.sendAsyncMessage(this._getResponseName(name, "OK"), { + requestID: data.requestID, + result, + }); + }, + error => { + target.sendAsyncMessage(this._getResponseName(name, "KO"), { + requestID: data.requestID, + result: error.result, + }); + } + ) + .catch(console.error); + }, + + ensureReady() { + this.service.init(); + }, + + _toPageRecord(principal, data) { + if (!data.scope) { + throw new Error("Invalid page record: missing scope"); + } + if (!principal) { + throw new Error("Invalid page record: missing principal"); + } + if (principal.isNullPrincipal || principal.isExpandedPrincipal) { + throw new Error("Invalid page record: unsupported principal"); + } + + // System subscriptions can only be created by chrome callers, and are + // exempt from the background message quota and permission checks. They + // also do not fire service worker events. + data.systemRecord = principal.isSystemPrincipal; + + data.originAttributes = ChromeUtils.originAttributesToSuffix( + principal.originAttributes + ); + + return data; + }, + + async _handleRequest(name, principal, data) { + if (name == "Push:Clear") { + return this.service.clear(data); + } + + let pageRecord; + try { + pageRecord = this._toPageRecord(principal, data); + } catch (e) { + return Promise.reject(e); + } + + if (name === "Push:Register") { + return this.service.register(pageRecord); + } + if (name === "Push:Registration") { + return this.service.registration(pageRecord); + } + if (name === "Push:Unregister") { + return this.service.unregister(pageRecord); + } + + return Promise.reject(new Error("Invalid request: unknown name")); + }, + + _getResponseName(requestName, suffix) { + let name = requestName.slice("Push:".length); + return "PushService:" + name + ":" + suffix; + }, + + // Methods used for mocking in tests. + + replaceServiceBackend(options) { + return this.service.changeTestServer(options.serverURI, options); + }, + + restoreServiceBackend() { + var defaultServerURL = Services.prefs.getCharPref("dom.push.serverURL"); + return this.service.changeTestServer(defaultServerURL); + }, +}); + +// Used to replace the implementation with a mock. +Object.defineProperty(PushServiceParent.prototype, "service", { + get() { + return this._service || lazy.PushService; + }, + set(impl) { + this._service = impl; + }, +}); + +let contentInstance; +/** + * The content process implementation of `nsIPushService`. This version + * uses the child message manager to forward calls to the parent process. + * The parent Push service instance handles the request, and responds with a + * message containing the result. + */ +function PushServiceContent() { + if (contentInstance) { + return contentInstance; + } + contentInstance = this; + + PushServiceBase.apply(this, arguments); + this._requests = new Map(); + this._requestId = 0; +} + +PushServiceContent.prototype = Object.create(PushServiceBase.prototype); + +XPCOMUtils.defineLazyServiceGetter( + PushServiceContent.prototype, + "_mm", + "@mozilla.org/childprocessmessagemanager;1", + "nsISupports" +); + +Object.assign(PushServiceContent.prototype, { + _messages: [ + "PushService:Register:OK", + "PushService:Register:KO", + "PushService:Registration:OK", + "PushService:Registration:KO", + "PushService:Unregister:OK", + "PushService:Unregister:KO", + "PushService:Clear:OK", + "PushService:Clear:KO", + ], + + // nsIPushService methods + + subscribe(scope, principal, callback) { + this.subscribeWithKey(scope, principal, [], callback); + }, + + subscribeWithKey(scope, principal, key, callback) { + let requestID = this._addRequest(callback); + this._mm.sendAsyncMessage("Push:Register", { + scope, + appServerKey: key, + requestID, + principal, + }); + }, + + unsubscribe(scope, principal, callback) { + let requestID = this._addRequest(callback); + this._mm.sendAsyncMessage("Push:Unregister", { + scope, + requestID, + principal, + }); + }, + + getSubscription(scope, principal, callback) { + let requestID = this._addRequest(callback); + this._mm.sendAsyncMessage("Push:Registration", { + scope, + requestID, + principal, + }); + }, + + clearForDomain(domain, callback) { + let requestID = this._addRequest(callback); + this._mm.sendAsyncMessage("Push:Clear", { + domain, + requestID, + }); + }, + + // nsIPushQuotaManager methods + + notificationForOriginShown(origin) { + this._mm.sendAsyncMessage("Push:NotificationForOriginShown", origin); + }, + + notificationForOriginClosed(origin) { + this._mm.sendAsyncMessage("Push:NotificationForOriginClosed", origin); + }, + + // nsIPushErrorReporter methods + + reportDeliveryError(messageId, reason) { + this._mm.sendAsyncMessage("Push:ReportError", { + messageId, + reason, + }); + }, + + _addRequest(data) { + let id = ++this._requestId; + this._requests.set(id, data); + return id; + }, + + _takeRequest(requestId) { + let d = this._requests.get(requestId); + this._requests.delete(requestId); + return d; + }, + + receiveMessage(message) { + if (!this._isValidMessage(message)) { + return; + } + let { name, data } = message; + let request = this._takeRequest(data.requestID); + + if (!request) { + return; + } + + switch (name) { + case "PushService:Register:OK": + case "PushService:Registration:OK": + this._deliverSubscription(request, data.result); + break; + + case "PushService:Register:KO": + case "PushService:Registration:KO": + this._deliverSubscriptionError(request, data); + break; + + case "PushService:Unregister:OK": + if (typeof data.result === "boolean") { + request.onUnsubscribe(Cr.NS_OK, data.result); + } else { + request.onUnsubscribe(Cr.NS_ERROR_FAILURE, false); + } + break; + + case "PushService:Unregister:KO": + request.onUnsubscribe(Cr.NS_ERROR_FAILURE, false); + break; + + case "PushService:Clear:OK": + request.onClear(Cr.NS_OK); + break; + + case "PushService:Clear:KO": + request.onClear(Cr.NS_ERROR_FAILURE); + break; + + default: + break; + } + }, +}); + +/** `PushSubscription` instances are passed to all subscription callbacks. */ +function PushSubscription(props) { + this._props = props; +} + +PushSubscription.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIPushSubscription"]), + + /** The URL for sending messages to this subscription. */ + get endpoint() { + return this._props.endpoint; + }, + + /** The last time a message was sent to this subscription. */ + get lastPush() { + return this._props.lastPush; + }, + + /** The total number of messages sent to this subscription. */ + get pushCount() { + return this._props.pushCount; + }, + + /** The number of remaining background messages that can be sent to this + * subscription, or -1 of the subscription is exempt from the quota. + */ + get quota() { + return this._props.quota; + }, + + /** + * Indicates whether this subscription was created with the system principal. + * System subscriptions are exempt from the background message quota and + * permission checks. + */ + get isSystemSubscription() { + return !!this._props.systemRecord; + }, + + /** The private key used to decrypt incoming push messages, in JWK format */ + get p256dhPrivateKey() { + return this._props.p256dhPrivateKey; + }, + + /** + * Indicates whether this subscription is subject to the background message + * quota. + */ + quotaApplies() { + return this.quota >= 0; + }, + + /** + * Indicates whether this subscription exceeded the background message quota, + * or the user revoked the notification permission. The caller must request a + * new subscription to continue receiving push messages. + */ + isExpired() { + return this.quota === 0; + }, + + /** + * Returns a key for encrypting messages sent to this subscription. JS + * callers receive the key buffer as a return value, while C++ callers + * receive the key size and buffer as out parameters. + */ + getKey(name) { + switch (name) { + case "p256dh": + return this._getRawKey(this._props.p256dhKey); + + case "auth": + return this._getRawKey(this._props.authenticationSecret); + + case "appServer": + return this._getRawKey(this._props.appServerKey); + } + return []; + }, + + _getRawKey(key) { + if (!key) { + return []; + } + return new Uint8Array(key); + }, +}; + +// Export the correct implementation depending on whether we're running in +// the parent or content process. +export let Service = isParent ? PushServiceParent : PushServiceContent; |