gecko-dev/dom/push/PushComponents.sys.mjs

586 lines
15 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/. */
/**
* 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.sys.mjs` 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) {
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.sys.mjs` 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);
},
() => {
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(
() => {
callback.onClear(Cr.NS_OK);
},
() => {
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;