Bug 1382388 - Make the Payments Dialog unprivileged & add "abort" support. r=mattn draft
authorJonathan Guillotte-Blouin <jguillotteblouin@mozilla.com>
Thu, 21 Sep 2017 13:49:41 -0700
changeset 668565 84366b50903805361a536a0ea81ec696820ac7dc
parent 667951 789e21a08030b00acd832a58268680dede2e7a1b
child 668734 d9c65a730a1e8838981099d2f145074e16c89e7c
child 668735 1fed00714a9b9f0d261ca349d5045957bdfb3212
child 668861 d65efac187f631e0097c300bf0e8fa6f9a78c540
push id81084
push usermozilla@noorenberghe.ca
push dateThu, 21 Sep 2017 20:50:00 +0000
reviewersmattn
bugs1382388
milestone57.0a1
Bug 1382388 - Make the Payments Dialog unprivileged & add "abort" support. r=mattn MozReview-Commit-ID: IXexL4ju2Fj
toolkit/components/payments/content/paymentDialog.js
toolkit/components/payments/content/paymentDialog.xhtml
toolkit/components/payments/content/paymentDialogFrameScript.js
toolkit/components/payments/content/paymentRequest.css
toolkit/components/payments/content/paymentRequest.js
toolkit/components/payments/content/paymentRequest.xhtml
toolkit/components/payments/jar.mn
toolkit/components/payments/paymentUIService.js
toolkit/components/payments/payments.manifest
toolkit/components/payments/res/paymentRequest.css
toolkit/components/payments/res/paymentRequest.js
toolkit/components/payments/res/paymentRequest.xhtml
toolkit/components/payments/test/browser/browser.ini
toolkit/components/payments/test/browser/browser_request_summary.js
toolkit/components/payments/test/browser/browser_show_dialog.js
toolkit/components/payments/test/browser/head.js
new file mode 100644
--- /dev/null
+++ b/toolkit/components/payments/content/paymentDialog.js
@@ -0,0 +1,89 @@
+/* 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/. */
+
+"use strict";
+
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+const paymentSrv = Cc["@mozilla.org/dom/payments/payment-request-service;1"]
+                     .getService(Ci.nsIPaymentRequestService);
+
+let PaymentDialog = {
+  componentsLoaded: new Map(),
+  frame: null,
+  mm: null,
+
+  init(frame) {
+    this.frame = frame;
+    this.mm = frame.frameLoader.messageManager;
+    this.mm.addMessageListener("paymentContentToChrome", this);
+    this.mm.loadFrameScript("chrome://payments/content/paymentDialogFrameScript.js", true);
+  },
+
+  createShowResponse({requestId, acceptStatus, methodName = "", data = null,
+                      payerName = "", payerEmail = "", payerPhone = ""}) {
+    let showResponse = this.createComponentInstance(Ci.nsIPaymentShowActionResponse);
+    let methodData = this.createComponentInstance(Ci.nsIGeneralResponseData);
+
+    showResponse.init(requestId,
+                      acceptStatus,
+                      methodName,
+                      methodData,
+                      payerName,
+                      payerEmail,
+                      payerPhone);
+    return showResponse;
+  },
+
+  createComponentInstance(componentInterface) {
+    let componentName;
+    switch (componentInterface) {
+      case Ci.nsIPaymentShowActionResponse: {
+        componentName = "@mozilla.org/dom/payments/payment-show-action-response;1";
+        break;
+      }
+      case Ci.nsIGeneralResponseData: {
+        componentName = "@mozilla.org/dom/payments/general-response-data;1";
+        break;
+      }
+    }
+    let component = this.componentsLoaded.get(componentName);
+
+    if (!component) {
+      component = Cc[componentName];
+      this.componentsLoaded.set(componentName, component);
+    }
+
+    return component.createInstance(componentInterface);
+  },
+
+  onPaymentCancel(requestId) {
+    const showResponse = this.createShowResponse({
+      requestId,
+      acceptStatus: Ci.nsIPaymentActionResponse.PAYMENT_REJECTED,
+    });
+    paymentSrv.respondPayment(showResponse);
+    window.close();
+  },
+
+  receiveMessage({data}) {
+    let {messageType, requestId} = data;
+
+    switch (messageType) {
+      case "initializeRequest": {
+        this.mm.sendAsyncMessage("paymentChromeToContent", {
+          messageType: "showPaymentRequest",
+          data: window.arguments[0],
+        });
+        break;
+      }
+      case "paymentCancel": {
+        this.onPaymentCancel(requestId);
+        break;
+      }
+    }
+  },
+};
+
+let frame = document.getElementById("paymentRequestFrame");
+PaymentDialog.init(frame);
new file mode 100644
--- /dev/null
+++ b/toolkit/components/payments/content/paymentDialog.xhtml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- 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/. -->
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+  <title></title>
+</head>
+<body>
+  <iframe type="content"
+          id="paymentRequestFrame"
+          mozbrowser="true"
+          remote="true"
+          name="paymentRequestFrame"
+          src="resource://payments/paymentRequest.xhtml"></iframe>
+  <script src="chrome://payments/content/paymentDialog.js"></script>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/toolkit/components/payments/content/paymentDialogFrameScript.js
@@ -0,0 +1,69 @@
+/* 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/. */
+
+"use strict";
+
+/* eslint-env mozilla/frame-script */
+
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+let PaymentFrameScript = {
+  init() {
+    this.defineLazyLogGetter(this, "frameScript");
+    addEventListener("paymentContentToChrome", this, false, true);
+
+    addMessageListener("paymentChromeToContent", this);
+  },
+
+  handleEvent(event) {
+    switch (event.type) {
+      case "paymentContentToChrome": {
+        this.sendToChrome(event);
+        break;
+      }
+      default: {
+        throw new Error("Unexpected event type");
+      }
+    }
+  },
+
+  receiveMessage({data: {messageType, data}}) {
+    this.sendToContent(messageType, data);
+  },
+
+  sendToChrome({detail}) {
+    let {messageType, requestId} = detail;
+    this.log.debug(`received message from content: ${messageType} ... ${requestId}`);
+    this.sendMessageToChrome(messageType, {
+      requestId,
+    });
+  },
+
+  defineLazyLogGetter(scope, logPrefix) {
+    XPCOMUtils.defineLazyGetter(scope, "log", () => {
+      let {ConsoleAPI} = Cu.import("resource://gre/modules/Console.jsm", {});
+      return new ConsoleAPI({
+        maxLogLevelPref: "dom.payments.loglevel",
+        prefix: logPrefix,
+      });
+    });
+  },
+
+  sendToContent(messageType, detail = {}) {
+    this.log.debug(`sendToContent (${messageType})`);
+    let response = Object.assign({messageType}, detail);
+    let event = new content.document.defaultView.CustomEvent("paymentChromeToContent", {
+      bubbles: true,
+      detail: Cu.cloneInto(response, content.document.defaultView),
+    });
+    content.document.dispatchEvent(event);
+  },
+
+  sendMessageToChrome(messageType, detail = {}) {
+    sendAsyncMessage("paymentContentToChrome", Object.assign(detail, {messageType}));
+  },
+};
+
+PaymentFrameScript.init();
--- a/toolkit/components/payments/jar.mn
+++ b/toolkit/components/payments/jar.mn
@@ -1,9 +1,11 @@
 # 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/.
 
 toolkit.jar:
 %   content payments %content/payments/
-    content/payments/paymentRequest.css                (content/paymentRequest.css)
-    content/payments/paymentRequest.js                 (content/paymentRequest.js)
-    content/payments/paymentRequest.xhtml              (content/paymentRequest.xhtml)
+    content/payments/paymentDialog.js                 (content/paymentDialog.js)
+    content/payments/paymentDialogFrameScript.js      (content/paymentDialogFrameScript.js)
+    content/payments/paymentDialog.xhtml              (content/paymentDialog.xhtml)
+%   resource payments %res/payments/
+    res/payments (res/paymentRequest.*)
--- a/toolkit/components/payments/paymentUIService.js
+++ b/toolkit/components/payments/paymentUIService.js
@@ -1,17 +1,17 @@
 /* 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/. */
 
 "use strict";
 
 const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
 // eslint-disable-next-line no-unused-vars
-const DIALOG_URL = "chrome://payments/content/paymentRequest.xhtml";
+const DIALOG_URL = "chrome://payments/content/paymentDialog.xhtml";
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 
 XPCOMUtils.defineLazyServiceGetter(this,
                                    "paymentSrv",
                                    "@mozilla.org/dom/payments/payment-request-service;1",
                                    "nsIPaymentRequestService");
@@ -22,56 +22,74 @@ function defineLazyLogGetter(scope, logP
     return new ConsoleAPI({
       maxLogLevelPref: "dom.payments.loglevel",
       prefix: logPrefix,
     });
   });
 }
 
 function PaymentUIService() {
+  this.wrappedJSObject = this;
   defineLazyLogGetter(this, "Payment UI Service");
-  paymentSrv.setTestingUIService(this);
   this.log.debug("constructor");
 }
 
 PaymentUIService.prototype = {
   classID: Components.ID("{01f8bd55-9017-438b-85ec-7c15d2b35cdc}"),
   QueryInterface: XPCOMUtils.generateQI([Ci.nsIPaymentUIService]),
   REQUEST_ID_PREFIX: "paymentRequest-",
 
   showPayment(requestId) {
     this.log.debug("showPayment");
     let chromeWindow = Services.wm.getMostRecentWindow("navigator:browser");
     chromeWindow.openDialog(DIALOG_URL,
                             `${this.REQUEST_ID_PREFIX}${requestId}`,
-                            "modal,dialog,centerscreen");
+                            "modal,dialog,centerscreen",
+                            {requestId});
   },
 
   abortPayment(requestId) {
     this.log.debug(`abortPayment: ${requestId}`);
     let abortResponse = Cc["@mozilla.org/dom/payments/payment-abort-action-response;1"]
                           .createInstance(Ci.nsIPaymentAbortActionResponse);
-    abortResponse.init(requestId, Ci.nsIPaymentActionResponse.ABORT_SUCCEEDED);
 
     let enu = Services.wm.getEnumerator(null);
     let win;
     while ((win = enu.getNext())) {
       if (win.name == `${this.REQUEST_ID_PREFIX}${requestId}`) {
         this.log.debug(`closing: ${win.name}`);
         win.close();
         break;
       }
     }
-    paymentSrv.respondPayment(abortResponse.QueryInterface(Ci.nsIPaymentActionResponse));
+
+    // if `win` is falsy, then we haven't found the dialog, so the abort fails
+    // otherwise, the abort is successful
+    let response = win ?
+      Ci.nsIPaymentActionResponse.ABORT_SUCCEEDED :
+      Ci.nsIPaymentActionResponse.ABORT_FAILED;
+
+    abortResponse.init(requestId, response);
+    paymentSrv.respondPayment(abortResponse);
   },
 
   completePayment(requestId) {
     let completeResponse = Cc["@mozilla.org/dom/payments/payment-complete-action-response;1"]
                              .createInstance(Ci.nsIPaymentCompleteActionResponse);
     completeResponse.init(requestId, Ci.nsIPaymentActionResponse.COMPLTETE_SUCCEEDED);
     paymentSrv.respondPayment(completeResponse.QueryInterface(Ci.nsIPaymentActionResponse));
   },
 
   updatePayment(requestId) {
   },
+
+  // other helper methods
+
+  requestIdForWindow(window) {
+    let windowName = window.name;
+
+    return windowName.startsWith(this.REQUEST_ID_PREFIX) ?
+      windowName.replace(this.REQUEST_ID_PREFIX, "") : // returns suffix, which is the requestId
+      null;
+  },
 };
 
 this.NSGetFactory = XPCOMUtils.generateNSGetFactory([PaymentUIService]);
--- a/toolkit/components/payments/payments.manifest
+++ b/toolkit/components/payments/payments.manifest
@@ -1,3 +1,2 @@
 component {01f8bd55-9017-438b-85ec-7c15d2b35cdc} paymentUIService.js
 contract @mozilla.org/dom/payments/payment-ui-service;1 {01f8bd55-9017-438b-85ec-7c15d2b35cdc}
-category profile-after-change PaymentUIService @mozilla.org/dom/payments/payment-ui-service;1
rename from toolkit/components/payments/content/paymentRequest.css
rename to toolkit/components/payments/res/paymentRequest.css
rename from toolkit/components/payments/content/paymentRequest.js
rename to toolkit/components/payments/res/paymentRequest.js
--- a/toolkit/components/payments/content/paymentRequest.js
+++ b/toolkit/components/payments/res/paymentRequest.js
@@ -1,7 +1,90 @@
 /* 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/. */
 
 "use strict";
 
-const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+let PaymentRequest = {
+  requestId: null,
+
+  init() {
+    // listen to content
+    window.addEventListener("paymentChromeToContent", this);
+
+    // listen to user events
+    window.addEventListener("DOMContentLoaded", this, {once: true});
+
+    // This scope is now ready to listen to the initialization data
+    this.sendMessageToChrome("initializeRequest");
+  },
+
+  handleEvent(event) {
+    switch (event.type) {
+      case "DOMContentLoaded": {
+        this.onPaymentRequestLoad();
+        break;
+      }
+      case "click": {
+        switch (event.target.id) {
+          case "cancel": {
+            this.onCancel();
+            break;
+          }
+        }
+        break;
+      }
+      case "unload": {
+        this.onPaymentRequestUnload();
+        break;
+      }
+      case "paymentChromeToContent": {
+        this.onChromeToContent(event);
+        break;
+      }
+      default: {
+        throw new Error("Unexpected event type");
+      }
+    }
+  },
+
+  sendMessageToChrome(messageType, detail = {}) {
+    let event = new CustomEvent("paymentContentToChrome", {
+      bubbles: true,
+      detail: Object.assign({
+        requestId: this.requestId,
+        messageType,
+      }, detail),
+    });
+    document.dispatchEvent(event);
+  },
+
+  onChromeToContent({detail}) {
+    let {messageType, requestId} = detail;
+
+    switch (messageType) {
+      case "showPaymentRequest": {
+        this.requestId = requestId;
+        break;
+      }
+    }
+  },
+
+  onPaymentRequestLoad(requestId) {
+    let cancelBtn = document.getElementById("cancel");
+    cancelBtn.addEventListener("click", this, {once: true});
+
+    window.addEventListener("unload", this, {once: true});
+    this.sendMessageToChrome("paymentDialogReady");
+  },
+
+  onCancel() {
+    this.sendMessageToChrome("paymentCancel");
+  },
+
+  onPaymentRequestUnload() {
+    // remove listeners that may be used multiple times here
+    window.removeEventListener("paymentChromeToContent", this);
+  },
+};
+
+PaymentRequest.init();
rename from toolkit/components/payments/content/paymentRequest.xhtml
rename to toolkit/components/payments/res/paymentRequest.xhtml
--- a/toolkit/components/payments/content/paymentRequest.xhtml
+++ b/toolkit/components/payments/res/paymentRequest.xhtml
@@ -1,17 +1,17 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <!-- 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/. -->
 <!DOCTYPE html>
 <html xmlns="http://www.w3.org/1999/xhtml">
 <head>
   <title></title>
-  <link rel="stylesheet" href="chrome://payments/content/paymentRequest.css" />
-  <script src="chrome://payments/content/paymentRequest.js"></script>
+  <link rel="stylesheet" href="resource://payments/paymentRequest.css" />
+  <script src="resource://payments/paymentRequest.js"></script>
 </head>
 <body>
   <div id="controls-container">
-    <button id="cancel" onclick="window.close()">Cancel payment</button>
+    <button id="cancel">Cancel payment</button>
   </div>
 </body>
 </html>
--- a/toolkit/components/payments/test/browser/browser.ini
+++ b/toolkit/components/payments/test/browser/browser.ini
@@ -1,10 +1,9 @@
 [DEFAULT]
 head = head.js
-
 support-files =
   blank_page.html
 
 [browser_request_summary.js]
 [browser_show_dialog.js]
 # Bug 1365964 - Payment Request isn't implemented for non-e10s
 skip-if = !e10s
--- a/toolkit/components/payments/test/browser/browser_request_summary.js
+++ b/toolkit/components/payments/test/browser/browser_request_summary.js
@@ -1,11 +1,11 @@
 "use strict";
 
 add_task(async function test_summary() {
   await BrowserTestUtils.withNewTab({
     gBrowser,
-    url: "chrome://payments/content/paymentRequest.xhtml",
+    url: "resource://payments/paymentRequest.xhtml",
   }, async browser => {
     // eslint-disable-next-line mozilla/no-cpows-in-tests
     ok(browser.contentDocument.getElementById("cancel"), "Cancel button exists");
   });
 });
--- a/toolkit/components/payments/test/browser/browser_show_dialog.js
+++ b/toolkit/components/payments/test/browser/browser_show_dialog.js
@@ -14,18 +14,45 @@ add_task(async function test_show_abort_
   await BrowserTestUtils.withNewTab({
     gBrowser,
     url: BLANK_PAGE_URL,
   }, async browser => {
     // start by creating a PaymentRequest, and show it
     await ContentTask.spawn(browser, {methodData, details}, ContentTasks.createAndShowRequest);
 
     // get a reference to the UI dialog and the requestId
-    let win = await getDialogWindow();
-    let requestId = requestIdForWindow(win);
+    let win = await getPaymentWidget();
+    let requestId = paymentUISrv.requestIdForWindow(win);
     ok(requestId, "requestId should be defined");
-    ok(!win.closed, "dialog should not be closed");
+    is(win.closed, false, "dialog should not be closed");
 
     // abort the payment request
-    await ContentTask.spawn(browser, null, async() => content.rq.abort());
-    ok(win.closed, "dialog should be closed");
+    ContentTask.spawn(browser, null, async() => content.rq.abort());
+    await BrowserTestUtils.waitForCondition(() => win.closed, "dialog should be closed");
   });
 });
+
+add_task(async function test_show_manualAbort_dialog() {
+  await BrowserTestUtils.withNewTab({
+    gBrowser,
+    url: BLANK_PAGE_URL,
+  }, async browser => {
+    let dialogReadyPromise = waitForWidgetReady();
+    // start by creating a PaymentRequest, and show it
+    await ContentTask.spawn(browser, {methodData, details}, ContentTasks.createAndShowRequest);
+
+    // get a reference to the UI dialog and the requestId
+    let [win] = await Promise.all([getPaymentWidget(), dialogReadyPromise]);
+    ok(win, "Got payment widget");
+    let requestId = paymentUISrv.requestIdForWindow(win);
+    ok(requestId, "requestId should be defined");
+    is(win.closed, false, "dialog should not be closed");
+
+    // abort the payment request manually
+    let frame = await getPaymentFrame(win);
+    ok(frame, "Got payment frame");
+    await dialogReadyPromise;
+    info("dialog ready");
+    spawnPaymentDialogTask(frame, ContentTasks.manuallyClickCancel);
+
+    await BrowserTestUtils.waitForCondition(() => win.closed, "dialog should be closed");
+  });
+});
--- a/toolkit/components/payments/test/browser/head.js
+++ b/toolkit/components/payments/test/browser/head.js
@@ -3,47 +3,88 @@
 /* eslint
   "no-unused-vars": ["error", {
     vars: "local",
     args: "none",
     varsIgnorePattern: "^(Cc|Ci|Cr|Cu|EXPORTED_SYMBOLS)$",
   }],
 */
 
-const REQUEST_ID_PREFIX = "paymentRequest-";
 const BLANK_PAGE_URL = "https://example.com/browser/toolkit/components/" +
                        "payments/test/browser/blank_page.html";
 const PREF_PAYMENT_ENABLED = "dom.payments.request.enabled";
+const paymentUISrv = Cc["@mozilla.org/dom/payments/payment-ui-service;1"]
+                     .getService().wrappedJSObject;
 
 
-async function getDialogWindow() {
+/**
+ * Return the container (e.g. dialog or overlay) that the payment request contents are shown in.
+ * This abstracts away the details of the widget used so that this can more earily transition from a
+ * dialog to another kind of overlay.
+ * Consumers shouldn't rely on a dialog window being returned.
+ * @returns {Promise}
+ */
+async function getPaymentWidget() {
   let win;
   await BrowserTestUtils.waitForCondition(() => {
     win = Services.wm.getMostRecentWindow(null);
-    return win.name.startsWith(REQUEST_ID_PREFIX);
+    return win.name.startsWith(paymentUISrv.REQUEST_ID_PREFIX);
   }, "payment dialog should be the most recent");
 
   return win;
 }
 
-function requestIdForWindow(window) {
-  let windowName = window.name;
+async function getPaymentFrame(widget) {
+  return widget.document.getElementById("paymentRequestFrame");
+}
 
-  return windowName.startsWith(REQUEST_ID_PREFIX) ?
-    windowName.replace(REQUEST_ID_PREFIX, "") : // returns suffix, which is the requestId
-    null;
+async function waitForMessageFromWidget(messageType, widget = null) {
+  info("waitForMessageFromWidget: " + messageType);
+  return new Promise(resolve => {
+    Services.mm.addMessageListener("paymentContentToChrome", function onMessage({data, target}) {
+      if (data.messageType != messageType) {
+        return;
+      }
+      if (widget && widget != target) {
+        return;
+      }
+      resolve();
+      info(`Got ${messageType} from widget`);
+      Services.mm.removeMessageListener("paymentContentToChrome", onMessage);
+    });
+  });
+}
+
+async function waitForWidgetReady(widget = null) {
+  return waitForMessageFromWidget("paymentDialogReady", widget);
+}
+
+function spawnPaymentDialogTask(paymentDialogFrame, taskFn, args = null) {
+  return ContentTask.spawn(paymentDialogFrame.frameLoader, args, taskFn);
 }
 
 /**
  * Common content tasks functions to be used with ContentTask.spawn.
  */
 let ContentTasks = {
   createAndShowRequest: async ({methodData, details, options}) => {
     let rq = new content.PaymentRequest(methodData, details, options);
     content.rq = rq; // assign it so we can retrieve it later
     rq.show();
   },
+
+  /**
+   * Click the cancel button
+   *
+   * Don't await on this task since the cancel can close the dialog before
+   * ContentTask can resolve the promise.
+   *
+   * @returns {undefined}
+   */
+  manuallyClickCancel: () => {
+    content.document.getElementById("cancel").click();
+  },
 };
 
 
 add_task(async function setup_head() {
   await SpecialPowers.pushPrefEnv({set: [[PREF_PAYMENT_ENABLED, true]]});
 });