Bug 1941046 - Part 4: Send a callback request for impression and clicks of MARS Top...
[gecko.git] / dom / push / Push.sys.mjs
blob1e30c2eb8fd2f573ded16f68da3d71410a3086f4
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3  * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
7 const lazy = {};
9 ChromeUtils.defineLazyGetter(lazy, "console", () => {
10   return console.createInstance({
11     maxLogLevelPref: "dom.push.loglevel",
12     prefix: "Push",
13   });
14 });
16 XPCOMUtils.defineLazyServiceGetter(
17   lazy,
18   "PushService",
19   "@mozilla.org/push/Service;1",
20   "nsIPushService"
23 /**
24  * The Push component runs in the child process and exposes the Push API
25  * to the web application. The PushService running in the parent process is the
26  * one actually performing all operations.
27  */
28 export class Push {
29   constructor() {
30     lazy.console.debug("Push()");
31   }
33   get contractID() {
34     return "@mozilla.org/push/PushManager;1";
35   }
37   get classID() {
38     return Components.ID("{cde1d019-fad8-4044-b141-65fb4fb7a245}");
39   }
41   get QueryInterface() {
42     return ChromeUtils.generateQI([
43       "nsIDOMGlobalPropertyInitializer",
44       "nsISupportsWeakReference",
45       "nsIObserver",
46     ]);
47   }
49   init(win) {
50     lazy.console.debug("init()");
52     this._window = win;
54     // Get the client principal from the window. This won't be null because the
55     // service worker should be available when accessing the push manager.
56     this._principal = win.clientPrincipal;
58     if (!this._principal) {
59       throw new Error(" The client principal of the window is not available");
60     }
62     try {
63       this._topLevelPrincipal = win.top.document.nodePrincipal;
64     } catch (error) {
65       // Accessing the top-level document might fails if cross-origin
66       this._topLevelPrincipal = undefined;
67     }
68   }
70   __init(scope) {
71     this._scope = scope;
72   }
74   askPermission() {
75     lazy.console.debug("askPermission()");
77     let hasValidTransientUserGestureActivation =
78       this._window.document.hasValidTransientUserGestureActivation;
80     return new this._window.Promise((resolve, reject) => {
81       // Test permission before requesting to support GeckoView:
82       // * GeckoViewPermissionChild wants to return early when requested without user activation
83       //   before doing actual permission check:
84       //   https://searchfox.org/mozilla-central/rev/0ba4632ee85679a1ccaf652df79c971fa7e9b9f7/mobile/android/actors/GeckoViewPermissionChild.sys.mjs#46-56
85       //   which is partly because:
86       // * GeckoView test runner has no real permission check but just returns VALUE_ALLOW.
87       //   https://searchfox.org/mozilla-central/rev/6e5b9a5a1edab13a1b2e2e90944b6e06b4d8149c/mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/TestRunnerActivity.java#108-123
88       if (this.#testPermission() === Ci.nsIPermissionManager.ALLOW_ACTION) {
89         resolve();
90         return;
91       }
93       let permissionDenied = () => {
94         reject(
95           new this._window.DOMException(
96             "User denied permission to use the Push API.",
97             "NotAllowedError"
98           )
99         );
100       };
102       if (
103         Services.prefs.getBoolPref("dom.push.testing.ignorePermission", false)
104       ) {
105         resolve();
106         return;
107       }
109       this.#requestPermission(
110         hasValidTransientUserGestureActivation,
111         resolve,
112         permissionDenied
113       );
114     });
115   }
117   subscribe(options) {
118     lazy.console.debug("subscribe()", this._scope);
120     return this.askPermission().then(
121       () =>
122         new this._window.Promise((resolve, reject) => {
123           let callback = new PushSubscriptionCallback(this, resolve, reject);
125           if (!options || options.applicationServerKey === null) {
126             lazy.PushService.subscribe(this._scope, this._principal, callback);
127             return;
128           }
130           let keyView = this.#normalizeAppServerKey(
131             options.applicationServerKey
132           );
133           if (keyView.byteLength === 0) {
134             callback.rejectWithError(Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR);
135             return;
136           }
137           lazy.PushService.subscribeWithKey(
138             this._scope,
139             this._principal,
140             keyView,
141             callback
142           );
143         })
144     );
145   }
147   #normalizeAppServerKey(appServerKey) {
148     let key;
149     if (typeof appServerKey == "string") {
150       try {
151         key = Cu.cloneInto(
152           ChromeUtils.base64URLDecode(appServerKey, {
153             padding: "reject",
154           }),
155           this._window
156         );
157       } catch (e) {
158         throw new this._window.DOMException(
159           "String contains an invalid character",
160           "InvalidCharacterError"
161         );
162       }
163     } else if (this._window.ArrayBuffer.isView(appServerKey)) {
164       key = appServerKey.buffer;
165     } else {
166       // `appServerKey` is an array buffer.
167       key = appServerKey;
168     }
169     return new this._window.Uint8Array(key);
170   }
172   getSubscription() {
173     lazy.console.debug("getSubscription()", this._scope);
175     return new this._window.Promise((resolve, reject) => {
176       let callback = new PushSubscriptionCallback(this, resolve, reject);
177       lazy.PushService.getSubscription(this._scope, this._principal, callback);
178     });
179   }
181   permissionState() {
182     lazy.console.debug("permissionState()", this._scope);
184     return new this._window.Promise((resolve, reject) => {
185       let permission = Ci.nsIPermissionManager.UNKNOWN_ACTION;
187       try {
188         permission = this.#testPermission();
189       } catch (e) {
190         reject();
191         return;
192       }
194       let pushPermissionStatus = "prompt";
195       if (permission == Ci.nsIPermissionManager.ALLOW_ACTION) {
196         pushPermissionStatus = "granted";
197       } else if (permission == Ci.nsIPermissionManager.DENY_ACTION) {
198         pushPermissionStatus = "denied";
199       }
200       resolve(pushPermissionStatus);
201     });
202   }
204   #testPermission() {
205     let permission = Services.perms.testExactPermissionFromPrincipal(
206       this._principal,
207       "desktop-notification"
208     );
209     if (permission == Ci.nsIPermissionManager.ALLOW_ACTION) {
210       return permission;
211     }
212     try {
213       if (Services.prefs.getBoolPref("dom.push.testing.ignorePermission")) {
214         permission = Ci.nsIPermissionManager.ALLOW_ACTION;
215       }
216     } catch (e) {}
217     return permission;
218   }
220   #requestPermission(
221     hasValidTransientUserGestureActivation,
222     allowCallback,
223     cancelCallback
224   ) {
225     // Create an array with a single nsIContentPermissionType element.
226     let type = {
227       type: "desktop-notification",
228       options: [],
229       QueryInterface: ChromeUtils.generateQI(["nsIContentPermissionType"]),
230     };
231     let typeArray = Cc["@mozilla.org/array;1"].createInstance(
232       Ci.nsIMutableArray
233     );
234     typeArray.appendElement(type);
236     // create a nsIContentPermissionRequest
237     let request = {
238       QueryInterface: ChromeUtils.generateQI(["nsIContentPermissionRequest"]),
239       types: typeArray,
240       principal: this._principal,
241       hasValidTransientUserGestureActivation,
242       topLevelPrincipal: this._topLevelPrincipal,
243       allow: allowCallback,
244       cancel: cancelCallback,
245       window: this._window,
246     };
248     // Using askPermission from nsIDOMWindowUtils that takes care of the
249     // remoting if needed.
250     let windowUtils = this._window.windowUtils;
251     windowUtils.askPermission(request);
252   }
255 class PushSubscriptionCallback {
256   constructor(pushManager, resolve, reject) {
257     this.pushManager = pushManager;
258     this.resolve = resolve;
259     this.reject = reject;
260   }
262   get QueryInterface() {
263     return ChromeUtils.generateQI(["nsIPushSubscriptionCallback"]);
264   }
266   onPushSubscription(ok, subscription) {
267     let { pushManager } = this;
268     if (!Components.isSuccessCode(ok)) {
269       this.rejectWithError(ok);
270       return;
271     }
273     if (!subscription) {
274       this.resolve(null);
275       return;
276     }
278     let p256dhKey = this.#getKey(subscription, "p256dh");
279     let authSecret = this.#getKey(subscription, "auth");
280     let options = {
281       endpoint: subscription.endpoint,
282       scope: pushManager._scope,
283       p256dhKey,
284       authSecret,
285     };
286     let appServerKey = this.#getKey(subscription, "appServer");
287     if (appServerKey) {
288       // Avoid passing null keys to work around bug 1256449.
289       options.appServerKey = appServerKey;
290     }
291     let sub = new pushManager._window.PushSubscription(options);
292     this.resolve(sub);
293   }
295   #getKey(subscription, name) {
296     let rawKey = Cu.cloneInto(
297       subscription.getKey(name),
298       this.pushManager._window
299     );
300     if (!rawKey.length) {
301       return null;
302     }
304     let key = new this.pushManager._window.ArrayBuffer(rawKey.length);
305     let keyView = new this.pushManager._window.Uint8Array(key);
306     keyView.set(rawKey);
307     return key;
308   }
310   rejectWithError(result) {
311     let error;
312     switch (result) {
313       case Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR:
314         error = new this.pushManager._window.DOMException(
315           "Invalid raw ECDSA P-256 public key.",
316           "InvalidAccessError"
317         );
318         break;
320       case Cr.NS_ERROR_DOM_PUSH_MISMATCHED_KEY_ERR:
321         error = new this.pushManager._window.DOMException(
322           "A subscription with a different application server key already exists.",
323           "InvalidStateError"
324         );
325         break;
327       default:
328         error = new this.pushManager._window.DOMException(
329           "Error retrieving push subscription.",
330           "AbortError"
331         );
332     }
333     this.reject(error);
334   }