Bug 1464461 - Implement screenshot command in console panel; r=nchevobbe,ochameau a=ritu
authoryulia <ystartsev@mozilla.com>
Mon, 04 Jun 2018 17:46:48 +0200
changeset 478194 ba2081206f9f
parent 478193 2107f137f325
child 478195 f7804a512504
push id9569
push usercsabou@mozilla.com
push dateThu, 02 Aug 2018 11:13:39 +0000
treeherdermozilla-beta@42473b21092f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnchevobbe, ochameau, ritu
bugs1464461
milestone62.0
Bug 1464461 - Implement screenshot command in console panel; r=nchevobbe,ochameau a=ritu MozReview-Commit-ID: 8MDZglPqTz9
devtools/client/webconsole/components/JSTerm.js
devtools/server/actors/webconsole.js
devtools/server/actors/webconsole/moz.build
devtools/server/actors/webconsole/screenshot.js
devtools/server/actors/webconsole/utils.js
devtools/shared/locales/en-US/screenshot.properties
devtools/shared/webconsole/moz.build
devtools/shared/webconsole/screenshot-helper.js
--- a/devtools/client/webconsole/components/JSTerm.js
+++ b/devtools/client/webconsole/components/JSTerm.js
@@ -14,16 +14,17 @@ loader.lazyRequireGetter(this, "defer", 
 loader.lazyRequireGetter(this, "Debugger", "Debugger");
 loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter");
 loader.lazyRequireGetter(this, "AutocompletePopup", "devtools/client/shared/autocomplete-popup");
 loader.lazyRequireGetter(this, "PropTypes", "devtools/client/shared/vendor/react-prop-types");
 loader.lazyRequireGetter(this, "gDevTools", "devtools/client/framework/devtools", true);
 loader.lazyRequireGetter(this, "KeyCodes", "devtools/client/shared/keycodes", true);
 loader.lazyRequireGetter(this, "Editor", "devtools/client/sourceeditor/editor");
 loader.lazyRequireGetter(this, "Telemetry", "devtools/client/shared/telemetry");
+loader.lazyRequireGetter(this, "processScreenshot", "devtools/shared/webconsole/screenshot-helper");
 
 const l10n = require("devtools/client/webconsole/webconsole-l10n");
 
 const HELP_URL = "https://developer.mozilla.org/docs/Tools/Web_Console/Helpers";
 
 function gSequenceId() {
   return gSequenceId.n++;
 }
@@ -262,17 +263,17 @@ class JSTerm extends Component {
    *
    * @private
    * @param function [callback]
    *        Optional function to invoke when the evaluation result is added to
    *        the output.
    * @param object response
    *        The message received from the server.
    */
-  _executeResultCallback(callback, response) {
+  async _executeResultCallback(callback, response) {
     if (!this.hud) {
       return;
     }
     if (response.error) {
       console.error("Evaluation error " + response.error + ": " + response.message);
       return;
     }
     let errorMessage = response.exceptionMessage;
@@ -304,16 +305,22 @@ class JSTerm extends Component {
           }
           break;
         case "help":
           this.hud.owner.openLink(HELP_URL);
           break;
         case "copyValueToClipboard":
           clipboardHelper.copyString(helperResult.value);
           break;
+        case "screenshotOutput":
+          const { args, value } = helperResult;
+          const results = await processScreenshot(this.hud.window, args, value);
+          this.screenshotNotify(results);
+          // early return as screenshot notify has dispatched all necessary messages
+          return;
       }
     }
 
     // Hide undefined results coming from JSTerm helper functions.
     if (!errorMessage && result && typeof result == "object" &&
       result.type == "undefined" &&
       helperResult && !helperHasRawOutput) {
       callback && callback();
@@ -330,16 +337,21 @@ class JSTerm extends Component {
       helperResult: {
         type: "inspectObject",
         object: objectActor
       }
     }, true);
     return this.hud.consoleOutput;
   }
 
+  screenshotNotify(results) {
+    const wrappedResults = results.map(result => ({ result }));
+    this.hud.consoleOutput.dispatchMessagesAdd(wrappedResults);
+  }
+
   /**
    * Execute a string. Execution happens asynchronously in the content process.
    *
    * @param string [executeString]
    *        The string you want to execute. If this is not provided, the current
    *        user input is used - taken from |this.getInputValue()|.
    * @param function [callback]
    *        Optional function to invoke when the result is displayed.
--- a/devtools/server/actors/webconsole.js
+++ b/devtools/server/actors/webconsole.js
@@ -870,16 +870,17 @@ WebConsoleActor.prototype =
     };
   },
 
   /**
    * Handler for the "evaluateJSAsync" request. This method evaluates the given
    * JavaScript string and sends back a packet with a unique ID.
    * The result will be returned later as an unsolicited `evaluationResult`,
    * that can be associated back to this request via the `resultID` field.
+   * Cannot be async, see Comment two on Bug #1452920
    *
    * @param object request
    *        The JSON request object received from the Web Console client.
    * @return object
    *         The response packet to send to with the unique id in the
    *         `resultID` field.
    */
   evaluateJSAsync: function(request) {
@@ -892,16 +893,49 @@ WebConsoleActor.prototype =
       from: this.actorID,
       resultID: resultID
     });
 
     // Then, execute the script that may pause.
     const response = this.evaluateJS(request);
     response.resultID = resultID;
 
+    this._waitForHelperResultAndSend(response).catch(e =>
+      DevToolsUtils.reportException(
+        "evaluateJSAsync",
+        Error(`Encountered error while waiting for Helper Result: ${e}`)
+      )
+    );
+  },
+
+  /**
+   * In order to have asynchronous commands such as screenshot, we have to be
+   * able to handle promises in the helper result. This method handles waiting
+   * for the promise, and then dispatching the result
+   *
+   *
+   * @private
+   * @param object response
+   *         The response packet to send to with the unique id in the
+   *         `resultID` field, and potentially a promise in the helperResult
+   *         field.
+   *
+   * @return object
+   *         The response packet to send to with the unique id in the
+   *         `resultID` field, with a sanitized helperResult field.
+   */
+  _waitForHelperResultAndSend: async function(response) {
+    // Wait for asynchronous command completion before sending back the response
+    if (
+      response.helperResult &&
+      typeof response.helperResult.then == "function"
+    ) {
+      response.helperResult = await response.helperResult;
+    }
+
     // Finally, send an unsolicited evaluationResult packet with
     // the normal return value
     this.conn.sendActorEvent(this.actorID, "evaluationResult", response);
   },
 
   /**
    * Handler for the "evaluateJS" request. This method evaluates the given
    * JavaScript string and sends back the result.
--- a/devtools/server/actors/webconsole/moz.build
+++ b/devtools/server/actors/webconsole/moz.build
@@ -2,11 +2,12 @@
 # vim: set filetype=python:
 # 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/.
 
 DevToolsModules(
     'content-process-forward.js',
     'listeners.js',
+    'screenshot.js',
     'utils.js',
     'worker-listeners.js',
 )
copy from devtools/shared/gcli/commands/screenshot.js
copy to devtools/server/actors/webconsole/screenshot.js
--- a/devtools/shared/gcli/commands/screenshot.js
+++ b/devtools/server/actors/webconsole/screenshot.js
@@ -1,240 +1,37 @@
 /* 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 { Cc, Ci, Cr, Cu } = require("chrome");
-const ChromeUtils = require("ChromeUtils");
-const l10n = require("gcli/l10n");
-const Services = require("Services");
-const { NetUtil } = require("resource://gre/modules/NetUtil.jsm");
+const { Ci, Cu } = require("chrome");
 const { getRect } = require("devtools/shared/layout/utils");
-const defer = require("devtools/shared/defer");
-const { Task } = require("devtools/shared/task");
-
-loader.lazyRequireGetter(this, "openContentLink", "devtools/client/shared/link", true);
-
-loader.lazyImporter(this, "Downloads", "resource://gre/modules/Downloads.jsm");
-loader.lazyImporter(this, "OS", "resource://gre/modules/osfile.jsm");
-loader.lazyImporter(this, "FileUtils", "resource://gre/modules/FileUtils.jsm");
-loader.lazyImporter(this, "PrivateBrowsingUtils",
-                          "resource://gre/modules/PrivateBrowsingUtils.jsm");
-
-// String used as an indication to generate default file name in the following
-// format: "Screen Shot yyyy-mm-dd at HH.MM.SS.png"
-const FILENAME_DEFAULT_VALUE = " ";
-const CONTAINER_FLASHING_DURATION = 500;
-
-/*
- * There are 2 commands and 1 converter here. The 2 commands are nearly
- * identical except that one runs on the client and one in the server.
- *
- * The server command is hidden, and is designed to be called from the client
- * command.
- */
-
-/**
- * Both commands have the same initial filename parameter
- */
-const filenameParam = {
-  name: "filename",
-  type: "string",
-  defaultValue: FILENAME_DEFAULT_VALUE,
-  description: l10n.lookup("screenshotFilenameDesc"),
-  manual: l10n.lookup("screenshotFilenameManual")
-};
+const { LocalizationHelper } = require("devtools/shared/L10N");
 
-/**
- * Both commands have almost the same set of standard optional parameters, except for the
- * type of the --selector option, which can be a node only on the server.
- */
-const getScreenshotCommandParams = function(isClient) {
-  return {
-    group: l10n.lookup("screenshotGroupOptions"),
-    params: [
-      {
-        name: "clipboard",
-        type: "boolean",
-        description: l10n.lookup("screenshotClipboardDesc"),
-        manual: l10n.lookup("screenshotClipboardManual")
-      },
-      {
-        name: "imgur",
-        type: "boolean",
-        description: l10n.lookup("screenshotImgurDesc"),
-        manual: l10n.lookup("screenshotImgurManual")
-      },
-      {
-        name: "delay",
-        type: { name: "number", min: 0 },
-        defaultValue: 0,
-        description: l10n.lookup("screenshotDelayDesc"),
-        manual: l10n.lookup("screenshotDelayManual")
-      },
-      {
-        name: "dpr",
-        type: { name: "number", min: 0, allowFloat: true },
-        defaultValue: 0,
-        description: l10n.lookup("screenshotDPRDesc"),
-        manual: l10n.lookup("screenshotDPRManual")
-      },
-      {
-        name: "fullpage",
-        type: "boolean",
-        description: l10n.lookup("screenshotFullPageDesc"),
-        manual: l10n.lookup("screenshotFullPageManual")
-      },
-      {
-        name: "selector",
-        // On the client side, don't try to parse the selector as a node as it will
-        // trigger an unsafe CPOW.
-        type: isClient ? "string" : "node",
-        defaultValue: null,
-        description: l10n.lookup("inspectNodeDesc"),
-        manual: l10n.lookup("inspectNodeManual")
-      },
-      {
-        name: "file",
-        type: "boolean",
-        description: l10n.lookup("screenshotFileDesc"),
-        manual: l10n.lookup("screenshotFileManual"),
-      },
-    ]
-  };
-};
-
-const clientScreenshotParams = getScreenshotCommandParams(true);
-const serverScreenshotParams = getScreenshotCommandParams(false);
+const CONTAINER_FLASHING_DURATION = 500;
+const STRINGS_URI = "devtools/shared/locales/screenshot.properties";
+const L10N = new LocalizationHelper(STRINGS_URI);
 
-exports.items = [
-  {
-    /**
-     * Format an 'imageSummary' (as output by the screenshot command).
-     * An 'imageSummary' is a simple JSON object that looks like this:
-     *
-     * {
-     *   destinations: [ "..." ], // Required array of descriptions of the
-     *                            // locations of the result image (the command
-     *                            // can have multiple outputs)
-     *   data: "...",             // Optional Base64 encoded image data
-     *   width:1024, height:768,  // Dimensions of the image data, required
-     *                            // if data != null
-     *   filename: "...",         // If set, clicking the image will open the
-     *                            // folder containing the given file
-     *   href: "...",             // If set, clicking the image will open the
-     *                            // link in a new tab
-     * }
-     */
-    item: "converter",
-    from: "imageSummary",
-    to: "dom",
-    exec: function(imageSummary, context) {
-      const document = context.document;
-      const root = document.createElement("div");
-
-      // Add a line to the result for each destination
-      imageSummary.destinations.forEach(destination => {
-        const title = document.createElement("div");
-        title.textContent = destination;
-        root.appendChild(title);
-      });
-
-      // Add the thumbnail image
-      if (imageSummary.data != null) {
-        const image = context.document.createElement("div");
-        const previewHeight = parseInt(256 * imageSummary.height / imageSummary.width,
-                                       10);
-        const style = "" +
-            "width: 256px;" +
-            "height: " + previewHeight + "px;" +
-            "max-height: 256px;" +
-            "background-image: url('" + imageSummary.data + "');" +
-            "background-size: 256px " + previewHeight + "px;" +
-            "margin: 4px;" +
-            "display: block;";
-        image.setAttribute("style", style);
-        root.appendChild(image);
-      }
-
-      // Click handler
-      if (imageSummary.href || imageSummary.filename) {
-        root.style.cursor = "pointer";
-        root.addEventListener("click", () => {
-          if (imageSummary.href) {
-            openContentLink(imageSummary.href);
-          } else if (imageSummary.filename) {
-            const file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
-            file.initWithPath(imageSummary.filename);
-            file.reveal();
-          }
-        });
-      }
-
-      return root;
-    }
-  },
-  {
-    item: "command",
-    runAt: "client",
-    name: "screenshot",
-    description: l10n.lookup("screenshotDesc"),
-    manual: l10n.lookup("screenshotManual"),
-    returnType: "imageSummary",
-    buttonId: "command-button-screenshot",
-    buttonClass: "command-button",
-    tooltipText: l10n.lookup("screenshotTooltipPage"),
-    params: [
-      filenameParam,
-      clientScreenshotParams,
-    ],
-    exec: function(args, context) {
-      // Re-execute the command on the server
-      const command = context.typed.replace(/^screenshot/, "screenshot_server");
-      const capture = context.updateExec(command).then(output => {
-        return output.error ? Promise.reject(output.data) : output.data;
-      });
-
-      simulateCameraEffect(context.environment.chromeDocument, "shutter");
-      return capture.then(saveScreenshot.bind(null, args, context));
-    },
-  },
-  {
-    item: "command",
-    runAt: "server",
-    name: "screenshot_server",
-    hidden: true,
-    returnType: "imageSummary",
-    params: [
-      filenameParam,
-      serverScreenshotParams,
-    ],
-    exec: function(args, context) {
-      return captureScreenshot(args, context.environment.document);
-    },
+exports.screenshot = function takeAsyncScreenshot(owner, args = {}) {
+  if (args.help) {
+    // Early return as help will be handled on the client side.
+    return null;
   }
-];
+  return captureScreenshot(args, owner.window.document);
+};
 
 /**
  * This function is called to simulate camera effects
  */
-function simulateCameraEffect(document, effect) {
+function simulateCameraFlash(document) {
   const window = document.defaultView;
-  if (effect === "shutter") {
-    if (Services.prefs.getBoolPref("devtools.screenshot.audio.enabled")) {
-      const audioCamera = new window.Audio("resource://devtools/client/themes/audio/shutter.wav");
-      audioCamera.play();
-    }
-  }
-  if (effect == "flash") {
-    const frames = Cu.cloneInto({ opacity: [ 0, 1 ] }, window);
-    document.documentElement.animate(frames, CONTAINER_FLASHING_DURATION);
-  }
+  const frames = Cu.cloneInto({ opacity: [ 0, 1 ] }, window);
+  document.documentElement.animate(frames, CONTAINER_FLASHING_DURATION);
 }
 
 /**
  * This function simply handles the --delay argument before calling
  * createScreenshotData
  */
 function captureScreenshot(args, document) {
   if (args.delay > 0) {
@@ -243,36 +40,16 @@ function captureScreenshot(args, documen
         createScreenshotData(document, args).then(resolve, reject);
       }, args.delay * 1000);
     });
   }
   return createScreenshotData(document, args);
 }
 
 /**
- * There are several possible destinations for the screenshot, SKIP is used
- * in saveScreenshot() whenever one of them is not used
- */
-const SKIP = Promise.resolve();
-
-/**
- * Save the captured screenshot to one of several destinations.
- */
-function saveScreenshot(args, context, reply) {
-  const fileNeeded = args.filename != FILENAME_DEFAULT_VALUE ||
-    (!args.imgur && !args.clipboard) || args.file;
-
-  return Promise.all([
-    args.clipboard ? saveToClipboard(context, reply) : SKIP,
-    args.imgur ? uploadToImgur(reply) : SKIP,
-    fileNeeded ? saveToFile(context, reply) : SKIP,
-  ]).then(() => reply);
-}
-
-/**
  * This does the dirty work of creating a base64 string out of an
  * area of the browser window
  */
 function createScreenshotData(document, args) {
   const window = document.defaultView;
   let left = 0;
   let top = 0;
   let width;
@@ -285,17 +62,18 @@ function createScreenshotData(document, 
   if (args.fullpage) {
     // Bug 961832: GCLI screenshot shows fixed position element in wrong
     // position if we don't scroll to top
     window.scrollTo(0, 0);
     width = window.innerWidth + window.scrollMaxX - window.scrollMinX;
     height = window.innerHeight + window.scrollMaxY - window.scrollMinY;
     filename = filename.replace(".png", "-fullpage.png");
   } else if (args.selector) {
-    ({ top, left, width, height } = getRect(window, args.selector, window));
+    const node = window.document.querySelector(args.selector);
+    ({ top, left, width, height } = getRect(window, node, window));
   } else {
     left = window.scrollX;
     top = window.scrollY;
     width = window.innerWidth;
     height = window.innerHeight;
   }
 
   // Only adjust for scrollbars when considering the full window
@@ -318,278 +96,46 @@ function createScreenshotData(document, 
   ctx.drawWindow(window, left, top, width, height, "#fff");
   const data = canvas.toDataURL("image/png", "");
 
   // See comment above on bug 961832
   if (args.fullpage) {
     window.scrollTo(currentX, currentY);
   }
 
-  simulateCameraEffect(document, "flash");
+  simulateCameraFlash(document);
 
   return Promise.resolve({
     destinations: [],
     data: data,
     height: height,
     width: width,
     filename: filename,
   });
 }
 
 /**
  * We may have a filename specified in args, or we might have to generate
  * one.
  */
 function getFilename(defaultName) {
   // Create a name for the file if not present
-  if (defaultName != FILENAME_DEFAULT_VALUE) {
+  if (defaultName) {
     return defaultName;
   }
 
   const date = new Date();
   let dateString = date.getFullYear() + "-" + (date.getMonth() + 1) +
                   "-" + date.getDate();
   dateString = dateString.split("-").map(function(part) {
     if (part.length == 1) {
       part = "0" + part;
     }
     return part;
   }).join("-");
 
   const timeString = date.toTimeString().replace(/:/g, ".").split(" ")[0];
-  return l10n.lookupFormat("screenshotGeneratedFilename",
-                           [ dateString, timeString ]) + ".png";
-}
-
-/**
- * Save the image data to the clipboard. This returns a promise, so it can
- * be treated exactly like imgur / file processing, but it's really sync
- * for now.
- */
-function saveToClipboard(context, reply) {
-  return new Promise(resolve => {
-    try {
-      const channel = NetUtil.newChannel({
-        uri: reply.data,
-        loadUsingSystemPrincipal: true,
-        contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE
-      });
-      const input = channel.open2();
-
-      const loadContext = context.environment.chromeWindow
-                                 .QueryInterface(Ci.nsIInterfaceRequestor)
-                                 .getInterface(Ci.nsIWebNavigation)
-                                 .QueryInterface(Ci.nsILoadContext);
-
-      const callback = {
-        onImageReady(container, status) {
-          if (!container) {
-            console.error("imgTools.decodeImageAsync failed");
-            reply.destinations.push(l10n.lookup("screenshotErrorCopying"));
-            resolve();
-            return;
-          }
-
-          try {
-            const wrapped = Cc["@mozilla.org/supports-interface-pointer;1"]
-                              .createInstance(Ci.nsISupportsInterfacePointer);
-            wrapped.data = container;
-
-            const trans = Cc["@mozilla.org/widget/transferable;1"]
-                            .createInstance(Ci.nsITransferable);
-            trans.init(loadContext);
-            trans.addDataFlavor(channel.contentType);
-            trans.setTransferData(channel.contentType, wrapped, -1);
-
-            Services.clipboard.setData(trans, null, Ci.nsIClipboard.kGlobalClipboard);
-
-            reply.destinations.push(l10n.lookup("screenshotCopied"));
-          } catch (ex) {
-            console.error(ex);
-            reply.destinations.push(l10n.lookup("screenshotErrorCopying"));
-          }
-          resolve();
-        }
-      };
-
-      const threadManager = Cc["@mozilla.org/thread-manager;1"].getService();
-      const imgTools = Cc["@mozilla.org/image/tools;1"]
-                          .getService(Ci.imgITools);
-      imgTools.decodeImageAsync(input, channel.contentType, callback,
-                                threadManager.currentThread);
-    } catch (ex) {
-      console.error(ex);
-      reply.destinations.push(l10n.lookup("screenshotErrorCopying"));
-      resolve();
-    }
-  });
-}
-
-/**
- * Upload screenshot data to Imgur, returning a promise of a URL (as a string)
- */
-function uploadToImgur(reply) {
-  return new Promise((resolve, reject) => {
-    const xhr = new XMLHttpRequest();
-    const fd = new FormData();
-    fd.append("image", reply.data.split(",")[1]);
-    fd.append("type", "base64");
-    fd.append("title", reply.filename);
-
-    const postURL = Services.prefs.getCharPref("devtools.gcli.imgurUploadURL");
-    const clientID = "Client-ID " +
-                     Services.prefs.getCharPref("devtools.gcli.imgurClientID");
-
-    xhr.open("POST", postURL);
-    xhr.setRequestHeader("Authorization", clientID);
-    xhr.send(fd);
-    xhr.responseType = "json";
-
-    xhr.onreadystatechange = function() {
-      if (xhr.readyState == 4) {
-        if (xhr.status == 200) {
-          reply.href = xhr.response.data.link;
-          reply.destinations.push(l10n.lookupFormat("screenshotImgurUploaded",
-                                                    [ reply.href ]));
-        } else {
-          reply.destinations.push(l10n.lookup("screenshotImgurError"));
-        }
-
-        resolve();
-      }
-    };
-  });
+  return L10N.getFormatStr(
+    "screenshotGeneratedFilename",
+    dateString,
+    timeString
+  ) + ".png";
 }
-
-/**
- * Progress listener that forwards calls to a transfer object.
- *
- * This is used below in saveToFile to forward progress updates from the
- * nsIWebBrowserPersist object that does the actual saving to the nsITransfer
- * which just represents the operation for the Download Manager.  This keeps the
- * Download Manager updated on saving progress and completion, so that it gives
- * visual feedback from the downloads toolbar button when the save is done.
- *
- * It also allows the browser window to show auth prompts if needed (should not
- * be needed for saving screenshots).
- *
- * This code is borrowed directly from contentAreaUtils.js.
- */
-function DownloadListener(win, transfer) {
-  this.window = win;
-  this.transfer = transfer;
-
-  // For most method calls, forward to the transfer object.
-  for (const name in transfer) {
-    if (name != "QueryInterface" &&
-        name != "onStateChange") {
-      this[name] = (...args) => transfer[name].apply(transfer, args);
-    }
-  }
-
-  // Allow saveToFile to await completion for error handling
-  this._completedDeferred = defer();
-  this.completed = this._completedDeferred.promise;
-}
-
-DownloadListener.prototype = {
-  QueryInterface: ChromeUtils.generateQI(["nsIInterfaceRequestor",
-                                          "nsIWebProgressListener",
-                                          "nsIWebProgressListener2"]),
-
-  getInterface: function(iid) {
-    if (iid.equals(Ci.nsIAuthPrompt) ||
-        iid.equals(Ci.nsIAuthPrompt2)) {
-      const ww = Cc["@mozilla.org/embedcomp/window-watcher;1"]
-                 .getService(Ci.nsIPromptFactory);
-      return ww.getPrompt(this.window, iid);
-    }
-
-    throw Cr.NS_ERROR_NO_INTERFACE;
-  },
-
-  onStateChange: function(webProgress, request, state, status) {
-    // Check if the download has completed
-    if ((state & Ci.nsIWebProgressListener.STATE_STOP) &&
-        (state & Ci.nsIWebProgressListener.STATE_IS_NETWORK)) {
-      if (status == Cr.NS_OK) {
-        this._completedDeferred.resolve();
-      } else {
-        this._completedDeferred.reject();
-      }
-    }
-
-    this.transfer.onStateChange.apply(this.transfer, arguments);
-  }
-};
-
-/**
- * Save the screenshot data to disk, returning a promise which is resolved on
- * completion.
- */
-var saveToFile = Task.async(function* (context, reply) {
-  const document = context.environment.chromeDocument;
-  const window = context.environment.chromeWindow;
-
-  // Check there is a .png extension to filename
-  if (!reply.filename.match(/.png$/i)) {
-    reply.filename += ".png";
-  }
-
-  const downloadsDir = yield Downloads.getPreferredDownloadsDirectory();
-  const downloadsDirExists = yield OS.File.exists(downloadsDir);
-  if (downloadsDirExists) {
-    // If filename is absolute, it will override the downloads directory and
-    // still be applied as expected.
-    reply.filename = OS.Path.join(downloadsDir, reply.filename);
-  }
-
-  const sourceURI = Services.io.newURI(reply.data);
-  const targetFile = new FileUtils.File(reply.filename);
-  const targetFileURI = Services.io.newFileURI(targetFile);
-
-  // Create download and track its progress.
-  // This is adapted from saveURL in contentAreaUtils.js, but simplified greatly
-  // and modified to allow saving to arbitrary paths on disk.  Using these
-  // objects as opposed to just writing with OS.File allows us to tie into the
-  // download manager to record a download entry and to get visual feedback from
-  // the downloads toolbar button when the save is done.
-  const nsIWBP = Ci.nsIWebBrowserPersist;
-  const flags = nsIWBP.PERSIST_FLAGS_REPLACE_EXISTING_FILES |
-                nsIWBP.PERSIST_FLAGS_FORCE_ALLOW_COOKIES |
-                nsIWBP.PERSIST_FLAGS_BYPASS_CACHE |
-                nsIWBP.PERSIST_FLAGS_AUTODETECT_APPLY_CONVERSION;
-  const isPrivate =
-    PrivateBrowsingUtils.isContentWindowPrivate(document.defaultView);
-  const persist = Cc["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"]
-                  .createInstance(Ci.nsIWebBrowserPersist);
-  persist.persistFlags = flags;
-  const tr = Cc["@mozilla.org/transfer;1"].createInstance(Ci.nsITransfer);
-  tr.init(sourceURI,
-          targetFileURI,
-          "",
-          null,
-          null,
-          null,
-          persist,
-          isPrivate);
-  const listener = new DownloadListener(window, tr);
-  persist.progressListener = listener;
-  persist.savePrivacyAwareURI(sourceURI,
-                              0,
-                              document.documentURIObject,
-                              Ci.nsIHttpChannel.REFERRER_POLICY_UNSET,
-                              null,
-                              null,
-                              targetFileURI,
-                              isPrivate);
-
-  try {
-    // Await successful completion of the save via the listener
-    yield listener.completed;
-    reply.destinations.push(l10n.lookup("screenshotSavedToFile") +
-                            ` "${reply.filename}"`);
-  } catch (ex) {
-    console.error(ex);
-    reply.destinations.push(l10n.lookup("screenshotErrorSavingToFile") + " " +
-                            reply.filename);
-  }
-});
--- a/devtools/server/actors/webconsole/utils.js
+++ b/devtools/server/actors/webconsole/utils.js
@@ -1,18 +1,19 @@
 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
-/* vim: set ft= javascript ts=2 et sw=2 tw=80: */
 /* 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 {Ci, Cu} = require("chrome");
 
+loader.lazyRequireGetter(this, "screenshot", "devtools/server/actors/webconsole/screenshot", true);
+
 // Note that this is only used in WebConsoleCommands, see $0 and pprint().
 if (!isWorker) {
   loader.lazyImporter(this, "VariablesView", "resource://devtools/client/shared/widgets/VariablesView.jsm");
 }
 
 const CONSOLE_WORKER_IDS = exports.CONSOLE_WORKER_IDS = [
   "SharedWorker",
   "ServiceWorker",
@@ -588,16 +589,38 @@ WebConsoleCommands._registerOriginal("co
   }
   owner.helperResult = {
     type: "copyValueToClipboard",
     value: payload,
   };
 });
 
 /**
+ * Take a screenshot of a page.
+ *
+ * @param object args
+ *               The arguments to be passed to the screenshot
+ * @return void
+ */
+WebConsoleCommands._registerOriginal("screenshot", function(owner, args) {
+  owner.helperResult = (async () => {
+    // creates data for saving the screenshot
+    const value = await screenshot(owner, args);
+    return {
+      type: "screenshotOutput",
+      value,
+      // pass args through to the client, so that the client can take care of copying
+      // and saving the screenshot data on the client machine instead of on the
+      // remote machine
+      args
+    };
+  })();
+});
+
+/**
  * (Internal only) Add the bindings to |owner.sandbox|.
  * This is intended to be used by the WebConsole actor only.
   *
   * @param object owner
   *        The owning object.
   */
 function addWebConsoleCommands(owner) {
   // Not supporting extra commands in workers yet.  This should be possible to
new file mode 100644
--- /dev/null
+++ b/devtools/shared/locales/en-US/screenshot.properties
@@ -0,0 +1,116 @@
+# 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/.
+
+# LOCALIZATION NOTE These strings are used inside Web Console commands.
+# The Web Console command line is available from the Web Developer sub-menu
+# -> 'Web Console'.
+#
+# The correct localization of this file might be to keep it in
+# English, or another language commonly spoken among web developers.
+# You want to make that choice consistent across the developer tools.
+# A good criteria is the language in which you'd find the best
+# documentation on web development on the web.
+
+# LOCALIZATION NOTE (screenshotDesc) A very short description of the
+# 'screenshot' command. Displayed when the --help flag is passed to
+# the screenshot command.
+screenshotDesc=Save an image of the page
+
+# LOCALIZATION NOTE (screenshotFilenameDesc) A very short string to describe
+# the 'filename' parameter to the 'screenshot' command. Displayed when the
+# --help flag is passed to the screenshot command.
+screenshotFilenameDesc=Destination filename
+
+# LOCALIZATION NOTE (screenshotFilenameManual) A fuller description of the
+# 'filename' parameter to the 'screenshot' command.
+screenshotFilenameManual=The name of the file (should have a ‘.png’ extension) to which we write the screenshot.
+
+# LOCALIZATION NOTE (screenshotClipboardDesc) A very short string to describe
+# the 'clipboard' parameter to the 'screenshot' command. Displayed when the
+# --help flag is passed to the screenshot command.
+screenshotClipboardDesc=Copy screenshot to clipboard? (true/false)
+
+# LOCALIZATION NOTE (screenshotClipboardManual) A fuller description of the
+# 'clipboard' parameter to the 'screenshot' command. Displayed when the
+# --help flag is passed to the screenshot command.
+screenshotClipboardManual=True if you want to copy the screenshot instead of saving it to a file.
+
+# LOCALIZATION NOTE (screenshotGroupOptions) A label for the optional options of
+# the screenshot command. Displayed when the --help flag is passed to the
+# screenshot command.
+screenshotGroupOptions=Options
+
+# LOCALIZATION NOTE (screenshotDelayDesc) A very short string to describe
+# the 'delay' parameter to the 'screenshot' command. Displayed when the
+# --help flag is passed to the screenshot command.
+screenshotDelayDesc=Delay (seconds)
+
+# LOCALIZATION NOTE (screenshotDelayManual) A fuller description of the
+# 'delay' parameter to the 'screenshot' command. Displayed when the
+# --help flag is passed to the screenshot command.
+screenshotDelayManual=The time to wait (in seconds) before the screenshot is taken
+
+# LOCALIZATION NOTE (screenshotDPRDesc) A very short string to describe
+# the 'dpr' parameter to the 'screenshot' command. Displayed when the
+# --help flag is passed to the `screenshot command.
+screenshotDPRDesc=Device pixel ratio
+
+# LOCALIZATION NOTE (screenshotDPRManual) A fuller description of the
+# 'dpr' parameter to the 'screenshot' command. Displayed when the
+# --help flag is passed to the `screenshot command.
+screenshotDPRManual=The device pixel ratio to use when taking the screenshot
+
+# LOCALIZATION NOTE (screenshotFullPageDesc) A very short string to describe
+# the 'fullpage' parameter to the 'screenshot' command. Displayed when the
+# --help flag is passed to the `screenshot command.
+screenshotFullPageDesc=Entire webpage? (true/false)
+
+# LOCALIZATION NOTE (screenshotFullPageManual) A fuller description of the
+# 'fullpage' parameter to the 'screenshot' command. Displayed when the
+# --help flag is passed to the `screenshot command.
+screenshotFullPageManual=True if the screenshot should also include parts of the webpage which are outside the current scrolled bounds.
+
+# LOCALIZATION NOTE (screenshotFileDesc) A very short string to describe
+# the 'file' parameter to the 'screenshot' command. Displayed when the
+# --help flag is passed to the `screenshot command.
+screenshotFileDesc=Save to file? (true/false)
+
+# LOCALIZATION NOTE (screenshotFileManual) A fuller description of the
+# 'file' parameter to the 'screenshot' command. Displayed when the
+# --help flag is passed to the `screenshot command.
+screenshotFileManual=True if the screenshot should save the file even when other options are enabled (eg. clipboard).
+
+# LOCALIZATION NOTE (screenshotGeneratedFilename) The auto generated filename
+# when no file name is provided. The first argument (%1$S) is the date string
+# in yyyy-mm-dd format and the second argument (%2$S) is the time string
+# in HH.MM.SS format. Please don't add the extension here.
+screenshotGeneratedFilename=Screen Shot %1$S at %2$S
+
+# LOCALIZATION NOTE (screenshotErrorSavingToFile) Text displayed to user upon
+# encountering error while saving the screenshot to the file specified.
+# The argument (%1$S) is the filename.
+screenshotErrorSavingToFile=Error saving to %1$S
+
+# LOCALIZATION NOTE (screenshotSavedToFile) Text displayed to user when the
+# screenshot is successfully saved to the file specified.
+# The argument (%1$S) is the filename.
+screenshotSavedToFile=Saved to %1$S
+
+# LOCALIZATION NOTE (screenshotErrorCopying) Text displayed to user upon
+# encountering error while copying the screenshot to clipboard.
+screenshotErrorCopying=Error occurred while copying screenshot to clipboard.
+
+# LOCALIZATION NOTE (screenshotCopied) Text displayed to user when the
+# screenshot is successfully copied to the clipboard.
+screenshotCopied=Screenshot copied to clipboard.
+
+# LOCALIZATION NOTE (inspectNodeDesc) A very short string to describe the
+# 'node' parameter to the 'inspect' command. Displayed when the
+# --help flag is passed to the `screenshot command.
+inspectNodeDesc=CSS selector
+
+# LOCALIZATION NOTE (inspectNodeManual) A fuller description of the 'node'
+# parameter to the 'inspect' command. Displayed when the --help flag is
+# passed to the `screenshot command.
+inspectNodeManual=A CSS selector for use with document.querySelector which identifies a single element
--- a/devtools/shared/webconsole/moz.build
+++ b/devtools/shared/webconsole/moz.build
@@ -8,10 +8,11 @@ if CONFIG['OS_TARGET'] != 'Android':
     MOCHITEST_CHROME_MANIFESTS += ['test/chrome.ini']
     XPCSHELL_TESTS_MANIFESTS += ['test/unit/xpcshell.ini']
 
 DevToolsModules(
     'client.js',
     'js-property-provider.js',
     'network-helper.js',
     'network-monitor.js',
+    'screenshot-helper.js',
     'throttle.js',
 )
copy from devtools/shared/gcli/commands/screenshot.js
copy to devtools/shared/webconsole/screenshot-helper.js
--- a/devtools/shared/gcli/commands/screenshot.js
+++ b/devtools/shared/webconsole/screenshot-helper.js
@@ -1,498 +1,290 @@
 /* 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 { Cc, Ci, Cr, Cu } = require("chrome");
+const { Cc, Ci, Cr } = require("chrome");
 const ChromeUtils = require("ChromeUtils");
-const l10n = require("gcli/l10n");
+const { LocalizationHelper } = require("devtools/shared/l10n");
 const Services = require("Services");
 const { NetUtil } = require("resource://gre/modules/NetUtil.jsm");
-const { getRect } = require("devtools/shared/layout/utils");
-const defer = require("devtools/shared/defer");
-const { Task } = require("devtools/shared/task");
-
-loader.lazyRequireGetter(this, "openContentLink", "devtools/client/shared/link", true);
 
 loader.lazyImporter(this, "Downloads", "resource://gre/modules/Downloads.jsm");
 loader.lazyImporter(this, "OS", "resource://gre/modules/osfile.jsm");
 loader.lazyImporter(this, "FileUtils", "resource://gre/modules/FileUtils.jsm");
 loader.lazyImporter(this, "PrivateBrowsingUtils",
                           "resource://gre/modules/PrivateBrowsingUtils.jsm");
 
-// String used as an indication to generate default file name in the following
-// format: "Screen Shot yyyy-mm-dd at HH.MM.SS.png"
-const FILENAME_DEFAULT_VALUE = " ";
-const CONTAINER_FLASHING_DURATION = 500;
-
-/*
- * There are 2 commands and 1 converter here. The 2 commands are nearly
- * identical except that one runs on the client and one in the server.
- *
- * The server command is hidden, and is designed to be called from the client
- * command.
- */
-
-/**
- * Both commands have the same initial filename parameter
- */
-const filenameParam = {
-  name: "filename",
-  type: "string",
-  defaultValue: FILENAME_DEFAULT_VALUE,
-  description: l10n.lookup("screenshotFilenameDesc"),
-  manual: l10n.lookup("screenshotFilenameManual")
-};
+const STRINGS_URI = "devtools/shared/locales/screenshot.properties";
+const L10N = new LocalizationHelper(STRINGS_URI);
 
-/**
- * Both commands have almost the same set of standard optional parameters, except for the
- * type of the --selector option, which can be a node only on the server.
- */
-const getScreenshotCommandParams = function(isClient) {
-  return {
-    group: l10n.lookup("screenshotGroupOptions"),
-    params: [
-      {
-        name: "clipboard",
-        type: "boolean",
-        description: l10n.lookup("screenshotClipboardDesc"),
-        manual: l10n.lookup("screenshotClipboardManual")
-      },
-      {
-        name: "imgur",
-        type: "boolean",
-        description: l10n.lookup("screenshotImgurDesc"),
-        manual: l10n.lookup("screenshotImgurManual")
-      },
-      {
-        name: "delay",
-        type: { name: "number", min: 0 },
-        defaultValue: 0,
-        description: l10n.lookup("screenshotDelayDesc"),
-        manual: l10n.lookup("screenshotDelayManual")
-      },
-      {
-        name: "dpr",
-        type: { name: "number", min: 0, allowFloat: true },
-        defaultValue: 0,
-        description: l10n.lookup("screenshotDPRDesc"),
-        manual: l10n.lookup("screenshotDPRManual")
-      },
-      {
-        name: "fullpage",
-        type: "boolean",
-        description: l10n.lookup("screenshotFullPageDesc"),
-        manual: l10n.lookup("screenshotFullPageManual")
-      },
-      {
-        name: "selector",
-        // On the client side, don't try to parse the selector as a node as it will
-        // trigger an unsafe CPOW.
-        type: isClient ? "string" : "node",
-        defaultValue: null,
-        description: l10n.lookup("inspectNodeDesc"),
-        manual: l10n.lookup("inspectNodeManual")
-      },
-      {
-        name: "file",
-        type: "boolean",
-        description: l10n.lookup("screenshotFileDesc"),
-        manual: l10n.lookup("screenshotFileManual"),
-      },
-    ]
-  };
-};
-
-const clientScreenshotParams = getScreenshotCommandParams(true);
-const serverScreenshotParams = getScreenshotCommandParams(false);
-
-exports.items = [
+const screenshotDescription = L10N.getStr("screenshotDesc");
+const screenshotGroupOptions = L10N.getStr("screenshotGroupOptions");
+const screenshotCommandParams = [
   {
-    /**
-     * Format an 'imageSummary' (as output by the screenshot command).
-     * An 'imageSummary' is a simple JSON object that looks like this:
-     *
-     * {
-     *   destinations: [ "..." ], // Required array of descriptions of the
-     *                            // locations of the result image (the command
-     *                            // can have multiple outputs)
-     *   data: "...",             // Optional Base64 encoded image data
-     *   width:1024, height:768,  // Dimensions of the image data, required
-     *                            // if data != null
-     *   filename: "...",         // If set, clicking the image will open the
-     *                            // folder containing the given file
-     *   href: "...",             // If set, clicking the image will open the
-     *                            // link in a new tab
-     * }
-     */
-    item: "converter",
-    from: "imageSummary",
-    to: "dom",
-    exec: function(imageSummary, context) {
-      const document = context.document;
-      const root = document.createElement("div");
-
-      // Add a line to the result for each destination
-      imageSummary.destinations.forEach(destination => {
-        const title = document.createElement("div");
-        title.textContent = destination;
-        root.appendChild(title);
-      });
-
-      // Add the thumbnail image
-      if (imageSummary.data != null) {
-        const image = context.document.createElement("div");
-        const previewHeight = parseInt(256 * imageSummary.height / imageSummary.width,
-                                       10);
-        const style = "" +
-            "width: 256px;" +
-            "height: " + previewHeight + "px;" +
-            "max-height: 256px;" +
-            "background-image: url('" + imageSummary.data + "');" +
-            "background-size: 256px " + previewHeight + "px;" +
-            "margin: 4px;" +
-            "display: block;";
-        image.setAttribute("style", style);
-        root.appendChild(image);
-      }
-
-      // Click handler
-      if (imageSummary.href || imageSummary.filename) {
-        root.style.cursor = "pointer";
-        root.addEventListener("click", () => {
-          if (imageSummary.href) {
-            openContentLink(imageSummary.href);
-          } else if (imageSummary.filename) {
-            const file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
-            file.initWithPath(imageSummary.filename);
-            file.reveal();
-          }
-        });
-      }
-
-      return root;
-    }
+    name: "clipboard",
+    type: "boolean",
+    description: L10N.getStr("screenshotClipboardDesc"),
+    manual: L10N.getStr("screenshotClipboardManual")
+  },
+  {
+    name: "delay",
+    type: "number",
+    description: L10N.getStr("screenshotDelayDesc"),
+    manual: L10N.getStr("screenshotDelayManual")
   },
   {
-    item: "command",
-    runAt: "client",
-    name: "screenshot",
-    description: l10n.lookup("screenshotDesc"),
-    manual: l10n.lookup("screenshotManual"),
-    returnType: "imageSummary",
-    buttonId: "command-button-screenshot",
-    buttonClass: "command-button",
-    tooltipText: l10n.lookup("screenshotTooltipPage"),
-    params: [
-      filenameParam,
-      clientScreenshotParams,
-    ],
-    exec: function(args, context) {
-      // Re-execute the command on the server
-      const command = context.typed.replace(/^screenshot/, "screenshot_server");
-      const capture = context.updateExec(command).then(output => {
-        return output.error ? Promise.reject(output.data) : output.data;
-      });
-
-      simulateCameraEffect(context.environment.chromeDocument, "shutter");
-      return capture.then(saveScreenshot.bind(null, args, context));
-    },
+    name: "dpr",
+    type: "number",
+    description: L10N.getStr("screenshotDPRDesc"),
+    manual: L10N.getStr("screenshotDPRManual")
+  },
+  {
+    name: "fullpage",
+    type: "boolean",
+    description: L10N.getStr("screenshotFullPageDesc"),
+    manual: L10N.getStr("screenshotFullPageManual")
   },
   {
-    item: "command",
-    runAt: "server",
-    name: "screenshot_server",
-    hidden: true,
-    returnType: "imageSummary",
-    params: [
-      filenameParam,
-      serverScreenshotParams,
-    ],
-    exec: function(args, context) {
-      return captureScreenshot(args, context.environment.document);
-    },
+    name: "selector",
+    type: "string",
+    description: L10N.getStr("inspectNodeDesc"),
+    manual: L10N.getStr("inspectNodeManual")
+  },
+  {
+    name: "file",
+    type: "boolean",
+    description: L10N.getStr("screenshotFileDesc"),
+    manual: L10N.getStr("screenshotFileManual"),
+  },
+  {
+    name: "filename",
+    type: "string",
+    description: L10N.getStr("screenshotFilenameDesc"),
+    manual: L10N.getStr("screenshotFilenameManual")
   }
 ];
 
 /**
- * This function is called to simulate camera effects
+ * Creates a string from an object for use when screenshot is passed the `--help` argument
+ *
+ * @param object param
+ *        The param object to be formatted.
+ * @return string
+ *         The formatted information from the param object as a string
+ */
+function formatHelpField(param) {
+  const padding = " ".repeat(5);
+  return Object.entries(param).map(([key, value]) => {
+    if (key === "name") {
+      const name = `${padding}--${value}`;
+      return name;
+    }
+    return `${padding.repeat(2)}${key}: ${value}`;
+  }).join("\n");
+}
+
+/**
+ * Creates a string response from the screenshot options for use when
+ * screenshot is passed the `--help` argument
+ *
+ * @return string
+ *         The formatted information from the param object as a string
  */
-function simulateCameraEffect(document, effect) {
+function getFormattedHelpData() {
+  const formattedParams = screenshotCommandParams
+    .map(formatHelpField)
+    .join("\n\n");
+
+  return `${screenshotDescription}\n${screenshotGroupOptions}\n\n${formattedParams}`;
+}
+
+/**
+ * Main entry point in this file; Takes the original arguments that `:screenshot` was
+ * called with and the image value from the server, and uses the client window to save
+ * the screenshot to the remote debugging machine's memory or clipboard.
+ *
+ * @param object window
+ *        The Debugger Client window.
+ *
+ * @param object args
+ *        The original args with which the screenshot
+ *        was called.
+ * @param object value
+ *        an object with a image value and file name
+ *
+ * @return string[]
+ *         Response messages from processing the screenshot
+ */
+function processScreenshot(window, args = {}, value) {
+  if (args.help) {
+    const message = getFormattedHelpData();
+    // Wrap meesage in an array so that the return value is consistant with saveScreenshot
+    return [message];
+  }
+  simulateCameraShutter(window.document);
+  return saveScreenshot(window, args, value);
+}
+
+/**
+ * This function is called to simulate camera effects
+ *
+ * @param object document
+ *        The Debugger Client document.
+ */
+function simulateCameraShutter(document) {
   const window = document.defaultView;
-  if (effect === "shutter") {
-    if (Services.prefs.getBoolPref("devtools.screenshot.audio.enabled")) {
-      const audioCamera = new window.Audio("resource://devtools/client/themes/audio/shutter.wav");
-      audioCamera.play();
-    }
-  }
-  if (effect == "flash") {
-    const frames = Cu.cloneInto({ opacity: [ 0, 1 ] }, window);
-    document.documentElement.animate(frames, CONTAINER_FLASHING_DURATION);
+  if (Services.prefs.getBoolPref("devtools.screenshot.audio.enabled")) {
+    const audioCamera = new window.Audio("resource://devtools/client/themes/audio/shutter.wav");
+    audioCamera.play();
   }
 }
 
 /**
- * This function simply handles the --delay argument before calling
- * createScreenshotData
+ * Save the captured screenshot to one of several destinations.
+ *
+ * @param object window
+ *        The Debugger Client window.
+ *
+ * @param object args
+ *        The original args with which the screenshot was called.
+ *
+ * @param object image
+ *        The image object that was sent from the server.
+ *
+ * @return string[]
+ *         Response messages from processing the screenshot.
  */
-function captureScreenshot(args, document) {
-  if (args.delay > 0) {
-    return new Promise((resolve, reject) => {
-      document.defaultView.setTimeout(() => {
-        createScreenshotData(document, args).then(resolve, reject);
-      }, args.delay * 1000);
-    });
-  }
-  return createScreenshotData(document, args);
-}
-
-/**
- * There are several possible destinations for the screenshot, SKIP is used
- * in saveScreenshot() whenever one of them is not used
- */
-const SKIP = Promise.resolve();
-
-/**
- * Save the captured screenshot to one of several destinations.
- */
-function saveScreenshot(args, context, reply) {
-  const fileNeeded = args.filename != FILENAME_DEFAULT_VALUE ||
-    (!args.imgur && !args.clipboard) || args.file;
+async function saveScreenshot(window, args, image) {
+  const fileNeeded = args.filename ||
+    !args.clipboard || args.file;
+  const results = [];
 
-  return Promise.all([
-    args.clipboard ? saveToClipboard(context, reply) : SKIP,
-    args.imgur ? uploadToImgur(reply) : SKIP,
-    fileNeeded ? saveToFile(context, reply) : SKIP,
-  ]).then(() => reply);
-}
-
-/**
- * This does the dirty work of creating a base64 string out of an
- * area of the browser window
- */
-function createScreenshotData(document, args) {
-  const window = document.defaultView;
-  let left = 0;
-  let top = 0;
-  let width;
-  let height;
-  const currentX = window.scrollX;
-  const currentY = window.scrollY;
-
-  let filename = getFilename(args.filename);
-
-  if (args.fullpage) {
-    // Bug 961832: GCLI screenshot shows fixed position element in wrong
-    // position if we don't scroll to top
-    window.scrollTo(0, 0);
-    width = window.innerWidth + window.scrollMaxX - window.scrollMinX;
-    height = window.innerHeight + window.scrollMaxY - window.scrollMinY;
-    filename = filename.replace(".png", "-fullpage.png");
-  } else if (args.selector) {
-    ({ top, left, width, height } = getRect(window, args.selector, window));
-  } else {
-    left = window.scrollX;
-    top = window.scrollY;
-    width = window.innerWidth;
-    height = window.innerHeight;
+  if (args.clipboard) {
+    const result = await saveToClipboard(window, image.data);
+    results.push(result);
   }
 
-  // Only adjust for scrollbars when considering the full window
-  if (!args.selector) {
-    const winUtils = window.QueryInterface(Ci.nsIInterfaceRequestor)
-                         .getInterface(Ci.nsIDOMWindowUtils);
-    const scrollbarHeight = {};
-    const scrollbarWidth = {};
-    winUtils.getScrollbarSize(false, scrollbarWidth, scrollbarHeight);
-    width -= scrollbarWidth.value;
-    height -= scrollbarHeight.value;
-  }
-
-  const canvas = document.createElementNS("http://www.w3.org/1999/xhtml", "canvas");
-  const ctx = canvas.getContext("2d");
-  const ratio = args.dpr ? args.dpr : window.devicePixelRatio;
-  canvas.width = width * ratio;
-  canvas.height = height * ratio;
-  ctx.scale(ratio, ratio);
-  ctx.drawWindow(window, left, top, width, height, "#fff");
-  const data = canvas.toDataURL("image/png", "");
-
-  // See comment above on bug 961832
-  if (args.fullpage) {
-    window.scrollTo(currentX, currentY);
+  if (fileNeeded) {
+    const result = await saveToFile(window, image);
+    results.push(result);
   }
-
-  simulateCameraEffect(document, "flash");
-
-  return Promise.resolve({
-    destinations: [],
-    data: data,
-    height: height,
-    width: width,
-    filename: filename,
-  });
-}
-
-/**
- * We may have a filename specified in args, or we might have to generate
- * one.
- */
-function getFilename(defaultName) {
-  // Create a name for the file if not present
-  if (defaultName != FILENAME_DEFAULT_VALUE) {
-    return defaultName;
-  }
-
-  const date = new Date();
-  let dateString = date.getFullYear() + "-" + (date.getMonth() + 1) +
-                  "-" + date.getDate();
-  dateString = dateString.split("-").map(function(part) {
-    if (part.length == 1) {
-      part = "0" + part;
-    }
-    return part;
-  }).join("-");
-
-  const timeString = date.toTimeString().replace(/:/g, ".").split(" ")[0];
-  return l10n.lookupFormat("screenshotGeneratedFilename",
-                           [ dateString, timeString ]) + ".png";
+  return results;
 }
 
 /**
  * Save the image data to the clipboard. This returns a promise, so it can
- * be treated exactly like imgur / file processing, but it's really sync
- * for now.
+ * be treated exactly like file processing.
+ *
+ * @param object window
+ *        The Debugger Client window.
+ *
+ * @param string data
+ *        The image data encoded in base64 that was sent from the server.
+ *
+ * @return string
+ *         Response message from processing the screenshot.
  */
-function saveToClipboard(context, reply) {
+function saveToClipboard(window, data) {
   return new Promise(resolve => {
     try {
       const channel = NetUtil.newChannel({
-        uri: reply.data,
+        uri: data,
         loadUsingSystemPrincipal: true,
         contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE
       });
       const input = channel.open2();
 
-      const loadContext = context.environment.chromeWindow
-                                 .QueryInterface(Ci.nsIInterfaceRequestor)
-                                 .getInterface(Ci.nsIWebNavigation)
-                                 .QueryInterface(Ci.nsILoadContext);
+      const loadContext = window.QueryInterface(Ci.nsIInterfaceRequestor)
+                                .getInterface(Ci.nsIWebNavigation)
+                                .QueryInterface(Ci.nsILoadContext);
 
       const callback = {
         onImageReady(container, status) {
           if (!container) {
             console.error("imgTools.decodeImageAsync failed");
-            reply.destinations.push(l10n.lookup("screenshotErrorCopying"));
-            resolve();
+            resolve(L10N.getStr("screenshotErrorCopying"));
             return;
           }
 
           try {
             const wrapped = Cc["@mozilla.org/supports-interface-pointer;1"]
                               .createInstance(Ci.nsISupportsInterfacePointer);
             wrapped.data = container;
 
             const trans = Cc["@mozilla.org/widget/transferable;1"]
                             .createInstance(Ci.nsITransferable);
             trans.init(loadContext);
             trans.addDataFlavor(channel.contentType);
             trans.setTransferData(channel.contentType, wrapped, -1);
 
             Services.clipboard.setData(trans, null, Ci.nsIClipboard.kGlobalClipboard);
 
-            reply.destinations.push(l10n.lookup("screenshotCopied"));
+            resolve(L10N.getStr("screenshotCopied"));
           } catch (ex) {
             console.error(ex);
-            reply.destinations.push(l10n.lookup("screenshotErrorCopying"));
+            resolve(L10N.getStr("screenshotErrorCopying"));
           }
-          resolve();
         }
       };
 
       const threadManager = Cc["@mozilla.org/thread-manager;1"].getService();
       const imgTools = Cc["@mozilla.org/image/tools;1"]
                           .getService(Ci.imgITools);
       imgTools.decodeImageAsync(input, channel.contentType, callback,
                                 threadManager.currentThread);
     } catch (ex) {
       console.error(ex);
-      reply.destinations.push(l10n.lookup("screenshotErrorCopying"));
-      resolve();
+      resolve(L10N.getStr("screenshotErrorCopying"));
     }
   });
 }
 
 /**
- * Upload screenshot data to Imgur, returning a promise of a URL (as a string)
- */
-function uploadToImgur(reply) {
-  return new Promise((resolve, reject) => {
-    const xhr = new XMLHttpRequest();
-    const fd = new FormData();
-    fd.append("image", reply.data.split(",")[1]);
-    fd.append("type", "base64");
-    fd.append("title", reply.filename);
-
-    const postURL = Services.prefs.getCharPref("devtools.gcli.imgurUploadURL");
-    const clientID = "Client-ID " +
-                     Services.prefs.getCharPref("devtools.gcli.imgurClientID");
-
-    xhr.open("POST", postURL);
-    xhr.setRequestHeader("Authorization", clientID);
-    xhr.send(fd);
-    xhr.responseType = "json";
-
-    xhr.onreadystatechange = function() {
-      if (xhr.readyState == 4) {
-        if (xhr.status == 200) {
-          reply.href = xhr.response.data.link;
-          reply.destinations.push(l10n.lookupFormat("screenshotImgurUploaded",
-                                                    [ reply.href ]));
-        } else {
-          reply.destinations.push(l10n.lookup("screenshotImgurError"));
-        }
-
-        resolve();
-      }
-    };
-  });
-}
-
-/**
  * Progress listener that forwards calls to a transfer object.
  *
  * This is used below in saveToFile to forward progress updates from the
  * nsIWebBrowserPersist object that does the actual saving to the nsITransfer
- * which just represents the operation for the Download Manager.  This keeps the
+ * which just represents the operation for the Download Manager. This keeps the
  * Download Manager updated on saving progress and completion, so that it gives
  * visual feedback from the downloads toolbar button when the save is done.
  *
  * It also allows the browser window to show auth prompts if needed (should not
  * be needed for saving screenshots).
  *
  * This code is borrowed directly from contentAreaUtils.js.
+ *
+ * @param object win
+ *        The Debugger Client window.
+ *
+ * @param object transfer
+ *        The transfer object.
+ *
  */
 function DownloadListener(win, transfer) {
   this.window = win;
   this.transfer = transfer;
 
   // For most method calls, forward to the transfer object.
   for (const name in transfer) {
     if (name != "QueryInterface" &&
         name != "onStateChange") {
       this[name] = (...args) => transfer[name].apply(transfer, args);
     }
   }
 
   // Allow saveToFile to await completion for error handling
-  this._completedDeferred = defer();
-  this.completed = this._completedDeferred.promise;
+  this._completedDeferred = {};
+  this.completed = new Promise((resolve, reject) => {
+    this._completedDeferred.resolve = resolve;
+    this._completedDeferred.reject = reject;
+  });
 }
 
 DownloadListener.prototype = {
   QueryInterface: ChromeUtils.generateQI(["nsIInterfaceRequestor",
                                           "nsIWebProgressListener",
                                           "nsIWebProgressListener2"]),
 
   getInterface: function(iid) {
@@ -519,41 +311,50 @@ DownloadListener.prototype = {
 
     this.transfer.onStateChange.apply(this.transfer, arguments);
   }
 };
 
 /**
  * Save the screenshot data to disk, returning a promise which is resolved on
  * completion.
+ *
+ * @param object window
+ *        The Debugger Client window.
+ *
+ * @param object image
+ *        The image object that was sent from the server.
+ *
+ * @return string
+ *         Response message from processing the screenshot.
  */
-var saveToFile = Task.async(function* (context, reply) {
-  const document = context.environment.chromeDocument;
-  const window = context.environment.chromeWindow;
+async function saveToFile(window, image) {
+  const document = window.document;
+  let filename = image.filename;
 
   // Check there is a .png extension to filename
-  if (!reply.filename.match(/.png$/i)) {
-    reply.filename += ".png";
+  if (!filename.match(/.png$/i)) {
+    filename += ".png";
   }
 
-  const downloadsDir = yield Downloads.getPreferredDownloadsDirectory();
-  const downloadsDirExists = yield OS.File.exists(downloadsDir);
+  const downloadsDir = await Downloads.getPreferredDownloadsDirectory();
+  const downloadsDirExists = await OS.File.exists(downloadsDir);
   if (downloadsDirExists) {
     // If filename is absolute, it will override the downloads directory and
     // still be applied as expected.
-    reply.filename = OS.Path.join(downloadsDir, reply.filename);
+    filename = OS.Path.join(downloadsDir, filename);
   }
 
-  const sourceURI = Services.io.newURI(reply.data);
-  const targetFile = new FileUtils.File(reply.filename);
+  const sourceURI = Services.io.newURI(image.data);
+  const targetFile = new FileUtils.File(filename);
   const targetFileURI = Services.io.newFileURI(targetFile);
 
   // Create download and track its progress.
   // This is adapted from saveURL in contentAreaUtils.js, but simplified greatly
-  // and modified to allow saving to arbitrary paths on disk.  Using these
+  // and modified to allow saving to arbitrary paths on disk. Using these
   // objects as opposed to just writing with OS.File allows us to tie into the
   // download manager to record a download entry and to get visual feedback from
   // the downloads toolbar button when the save is done.
   const nsIWBP = Ci.nsIWebBrowserPersist;
   const flags = nsIWBP.PERSIST_FLAGS_REPLACE_EXISTING_FILES |
                 nsIWBP.PERSIST_FLAGS_FORCE_ALLOW_COOKIES |
                 nsIWBP.PERSIST_FLAGS_BYPASS_CACHE |
                 nsIWBP.PERSIST_FLAGS_AUTODETECT_APPLY_CONVERSION;
@@ -579,17 +380,17 @@ var saveToFile = Task.async(function* (c
                               Ci.nsIHttpChannel.REFERRER_POLICY_UNSET,
                               null,
                               null,
                               targetFileURI,
                               isPrivate);
 
   try {
     // Await successful completion of the save via the listener
-    yield listener.completed;
-    reply.destinations.push(l10n.lookup("screenshotSavedToFile") +
-                            ` "${reply.filename}"`);
+    await listener.completed;
+    return L10N.getFormatStr("screenshotSavedToFile", filename);
   } catch (ex) {
     console.error(ex);
-    reply.destinations.push(l10n.lookup("screenshotErrorSavingToFile") + " " +
-                            reply.filename);
+    return L10N.getFormatStr("screenshotErrorSavingToFile", filename);
   }
-});
+}
+
+module.exports = processScreenshot;