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/. */
4
5"use strict";
6
7const { XPCOMUtils } = ChromeUtils.import(
8  "resource://gre/modules/XPCOMUtils.jsm"
9);
10const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
11const { DOMRequestIpcHelper } = ChromeUtils.import(
12  "resource://gre/modules/DOMRequestHelper.jsm"
13);
14
15XPCOMUtils.defineLazyGetter(this, "console", () => {
16  let { ConsoleAPI } = ChromeUtils.import("resource://gre/modules/Console.jsm");
17  return new ConsoleAPI({
18    maxLogLevelPref: "dom.push.loglevel",
19    prefix: "Push",
20  });
21});
22
23XPCOMUtils.defineLazyServiceGetter(
24  this,
25  "PushService",
26  "@mozilla.org/push/Service;1",
27  "nsIPushService"
28);
29
30const PUSH_CID = Components.ID("{cde1d019-fad8-4044-b141-65fb4fb7a245}");
31
32/**
33 * The Push component runs in the child process and exposes the Push API
34 * to the web application. The PushService running in the parent process is the
35 * one actually performing all operations.
36 */
37function Push() {
38  console.debug("Push()");
39}
40
41Push.prototype = {
42  __proto__: DOMRequestIpcHelper.prototype,
43
44  contractID: "@mozilla.org/push/PushManager;1",
45
46  classID: PUSH_CID,
47
48  QueryInterface: ChromeUtils.generateQI([
49    "nsIDOMGlobalPropertyInitializer",
50    "nsISupportsWeakReference",
51    "nsIObserver",
52  ]),
53
54  init(win) {
55    console.debug("init()");
56
57    this._window = win;
58
59    this.initDOMRequestHelper(win);
60
61    this._principal = win.document.nodePrincipal;
62
63    try {
64      this._topLevelPrincipal = win.top.document.nodePrincipal;
65    } catch (error) {
66      // Accessing the top-level document might fails if cross-origin
67      this._topLevelPrincipal = undefined;
68    }
69  },
70
71  __init(scope) {
72    this._scope = scope;
73  },
74
75  askPermission() {
76    console.debug("askPermission()");
77
78    let isHandlingUserInput = this._window.document
79      .hasValidTransientUserGestureActivation;
80
81    return this.createPromise((resolve, reject) => {
82      let permissionDenied = () => {
83        reject(
84          new this._window.DOMException(
85            "User denied permission to use the Push API.",
86            "NotAllowedError"
87          )
88        );
89      };
90
91      if (
92        Services.prefs.getBoolPref("dom.push.testing.ignorePermission", false)
93      ) {
94        resolve();
95        return;
96      }
97
98      this._requestPermission(isHandlingUserInput, resolve, permissionDenied);
99    });
100  },
101
102  subscribe(options) {
103    console.debug("subscribe()", this._scope);
104
105    return this.askPermission().then(() =>
106      this.createPromise((resolve, reject) => {
107        let callback = new PushSubscriptionCallback(this, resolve, reject);
108
109        if (!options || options.applicationServerKey === null) {
110          PushService.subscribe(this._scope, this._principal, callback);
111          return;
112        }
113
114        let keyView = this._normalizeAppServerKey(options.applicationServerKey);
115        if (keyView.byteLength === 0) {
116          callback._rejectWithError(Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR);
117          return;
118        }
119        PushService.subscribeWithKey(
120          this._scope,
121          this._principal,
122          keyView,
123          callback
124        );
125      })
126    );
127  },
128
129  _normalizeAppServerKey(appServerKey) {
130    let key;
131    if (typeof appServerKey == "string") {
132      try {
133        key = Cu.cloneInto(
134          ChromeUtils.base64URLDecode(appServerKey, {
135            padding: "reject",
136          }),
137          this._window
138        );
139      } catch (e) {
140        throw new this._window.DOMException(
141          "String contains an invalid character",
142          "InvalidCharacterError"
143        );
144      }
145    } else if (this._window.ArrayBuffer.isView(appServerKey)) {
146      key = appServerKey.buffer;
147    } else {
148      // `appServerKey` is an array buffer.
149      key = appServerKey;
150    }
151    return new this._window.Uint8Array(key);
152  },
153
154  getSubscription() {
155    console.debug("getSubscription()", this._scope);
156
157    return this.createPromise((resolve, reject) => {
158      let callback = new PushSubscriptionCallback(this, resolve, reject);
159      PushService.getSubscription(this._scope, this._principal, callback);
160    });
161  },
162
163  permissionState() {
164    console.debug("permissionState()", this._scope);
165
166    return this.createPromise((resolve, reject) => {
167      let permission = Ci.nsIPermissionManager.UNKNOWN_ACTION;
168
169      try {
170        permission = this._testPermission();
171      } catch (e) {
172        reject();
173        return;
174      }
175
176      let pushPermissionStatus = "prompt";
177      if (permission == Ci.nsIPermissionManager.ALLOW_ACTION) {
178        pushPermissionStatus = "granted";
179      } else if (permission == Ci.nsIPermissionManager.DENY_ACTION) {
180        pushPermissionStatus = "denied";
181      }
182      resolve(pushPermissionStatus);
183    });
184  },
185
186  _testPermission() {
187    let permission = Services.perms.testExactPermissionFromPrincipal(
188      this._principal,
189      "desktop-notification"
190    );
191    if (permission == Ci.nsIPermissionManager.ALLOW_ACTION) {
192      return permission;
193    }
194    try {
195      if (Services.prefs.getBoolPref("dom.push.testing.ignorePermission")) {
196        permission = Ci.nsIPermissionManager.ALLOW_ACTION;
197      }
198    } catch (e) {}
199    return permission;
200  },
201
202  _requestPermission(isHandlingUserInput, allowCallback, cancelCallback) {
203    // Create an array with a single nsIContentPermissionType element.
204    let type = {
205      type: "desktop-notification",
206      options: [],
207      QueryInterface: ChromeUtils.generateQI(["nsIContentPermissionType"]),
208    };
209    let typeArray = Cc["@mozilla.org/array;1"].createInstance(
210      Ci.nsIMutableArray
211    );
212    typeArray.appendElement(type);
213
214    // create a nsIContentPermissionRequest
215    let request = {
216      QueryInterface: ChromeUtils.generateQI(["nsIContentPermissionRequest"]),
217      types: typeArray,
218      principal: this._principal,
219      isHandlingUserInput,
220      topLevelPrincipal: this._topLevelPrincipal,
221      allow: allowCallback,
222      cancel: cancelCallback,
223      window: this._window,
224    };
225
226    // Using askPermission from nsIDOMWindowUtils that takes care of the
227    // remoting if needed.
228    let windowUtils = this._window.windowUtils;
229    windowUtils.askPermission(request);
230  },
231};
232
233function PushSubscriptionCallback(pushManager, resolve, reject) {
234  this.pushManager = pushManager;
235  this.resolve = resolve;
236  this.reject = reject;
237}
238
239PushSubscriptionCallback.prototype = {
240  QueryInterface: ChromeUtils.generateQI(["nsIPushSubscriptionCallback"]),
241
242  onPushSubscription(ok, subscription) {
243    let { pushManager } = this;
244    if (!Components.isSuccessCode(ok)) {
245      this._rejectWithError(ok);
246      return;
247    }
248
249    if (!subscription) {
250      this.resolve(null);
251      return;
252    }
253
254    let p256dhKey = this._getKey(subscription, "p256dh");
255    let authSecret = this._getKey(subscription, "auth");
256    let options = {
257      endpoint: subscription.endpoint,
258      scope: pushManager._scope,
259      p256dhKey,
260      authSecret,
261    };
262    let appServerKey = this._getKey(subscription, "appServer");
263    if (appServerKey) {
264      // Avoid passing null keys to work around bug 1256449.
265      options.appServerKey = appServerKey;
266    }
267    let sub = new pushManager._window.PushSubscription(options);
268    this.resolve(sub);
269  },
270
271  _getKey(subscription, name) {
272    let rawKey = Cu.cloneInto(
273      subscription.getKey(name),
274      this.pushManager._window
275    );
276    if (!rawKey.length) {
277      return null;
278    }
279
280    let key = new this.pushManager._window.ArrayBuffer(rawKey.length);
281    let keyView = new this.pushManager._window.Uint8Array(key);
282    keyView.set(rawKey);
283    return key;
284  },
285
286  _rejectWithError(result) {
287    let error;
288    switch (result) {
289      case Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR:
290        error = new this.pushManager._window.DOMException(
291          "Invalid raw ECDSA P-256 public key.",
292          "InvalidAccessError"
293        );
294        break;
295
296      case Cr.NS_ERROR_DOM_PUSH_MISMATCHED_KEY_ERR:
297        error = new this.pushManager._window.DOMException(
298          "A subscription with a different application server key already exists.",
299          "InvalidStateError"
300        );
301        break;
302
303      default:
304        error = new this.pushManager._window.DOMException(
305          "Error retrieving push subscription.",
306          "AbortError"
307        );
308    }
309    this.reject(error);
310  },
311};
312
313const EXPORTED_SYMBOLS = ["Push"];
314