Bug 1197611 - Allow GCLI screenshot to save anywhere. r=pbro,jwalker draft
authorJ. Ryan Stinnett <jryans@gmail.com>
Mon, 22 Feb 2016 17:07:42 -0600
changeset 335617 1822da6eba4894a71f99d30ff4a704b23b56b4fc
parent 335599 446932a345fe1631718eb0b6bcb3e098c2958265
child 515180 aa72276c023504e6531620971ebb9303b535ecc6
push id11835
push userbmo:jryans@gmail.com
push dateTue, 01 Mar 2016 00:52:38 +0000
reviewerspbro, jwalker
bugs1197611
milestone47.0a1
Bug 1197611 - Allow GCLI screenshot to save anywhere. r=pbro,jwalker In addition to this, we now have access to the final path the file is saved to, so we can correct the image onclick handler to correctly reveal the new file. MozReview-Commit-ID: HLgm94VBcqE
devtools/shared/gcli/commands/screenshot.js
devtools/shared/gcli/source/lib/gcli/util/fileparser.js
--- a/devtools/shared/gcli/commands/screenshot.js
+++ b/devtools/shared/gcli/commands/screenshot.js
@@ -1,22 +1,26 @@
 /* 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, Cu } = require("chrome");
+const { Cc, Ci, Cr } = require("chrome");
 const l10n = require("gcli/l10n");
 const Services = require("Services");
 const { getRect } = require("devtools/shared/layout/utils");
+const promise = require("promise");
 
 loader.lazyImporter(this, "Downloads", "resource://gre/modules/Downloads.jsm");
 loader.lazyImporter(this, "Task", "resource://gre/modules/Task.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");
 
 const BRAND_SHORT_NAME = Cc["@mozilla.org/intl/stringbundle;1"]
                            .getService(Ci.nsIStringBundleService)
                            .createBundle("chrome://branding/locale/brand.properties")
                            .GetStringFromName("brandShortName");
 
 // String used as an indication to generate default file name in the following
 // format: "Screen Shot yyyy-mm-dd at HH.MM.SS.png"
@@ -30,17 +34,21 @@ const FILENAME_DEFAULT_VALUE = " ";
  * command when the --chrome flag is *not* used.
  */
 
 /**
  * Both commands have the same initial filename parameter
  */
 const filenameParam = {
   name: "filename",
-  type: "string",
+  type: {
+    name: "file",
+    filetype: "file",
+    existing: "maybe",
+  },
   defaultValue: FILENAME_DEFAULT_VALUE,
   description: l10n.lookup("screenshotFilenameDesc"),
   manual: l10n.lookup("screenshotFilenameManual")
 };
 
 /**
  * Both commands have the same set of standard optional parameters
  */
@@ -425,35 +433,156 @@ function uploadToImgur(reply) {
 
         resolve();
       }
     };
   });
 }
 
 /**
- * Save the screenshot data to disk, returning a promise which
- * is resolved on completion
+ * 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 actually 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) {
+  function makeClosure(name) {
+    return function() {
+      transfer[name].apply(transfer, arguments);
+    };
+  }
+
+  this.window = win;
+  this.transfer = transfer;
+
+  // For most method calls, forward to the transfer object.
+  for (let name in transfer) {
+    if (name != "QueryInterface" &&
+        name != "onStateChange") {
+      this[name] = makeClosure(name);
+    }
+  }
+
+  // Allow saveToFile to await completion for error handling
+  this._completedDeferred = promise.defer();
+  this.completed = this._completedDeferred.promise;
+}
+
+DownloadListener.prototype = {
+  QueryInterface: function(iid) {
+    if (iid.equals(Ci.nsIInterfaceRequestor) ||
+        iid.equals(Ci.nsIWebProgressListener) ||
+        iid.equals(Ci.nsIWebProgressListener2) ||
+        iid.equals(Ci.nsISupports)) {
+      return this;
+    }
+    throw Cr.NS_ERROR_NO_INTERFACE;
+  },
+
+  getInterface: function(iid) {
+    if (iid.equals(Ci.nsIAuthPrompt) ||
+        iid.equals(Ci.nsIAuthPrompt2)) {
+      let 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.
  */
 function saveToFile(context, reply) {
   return Task.spawn(function*() {
-    try {
-      let document = context.environment.chromeDocument;
-      let window = context.environment.chromeWindow;
+    let document = context.environment.chromeDocument;
+    let window = context.environment.chromeWindow;
+
+    // Check there is a .png extension to filename
+    if (!reply.filename.match(/.png$/i)) {
+      reply.filename += ".png";
+    }
 
-      let filename = reply.filename;
-      // Check there is a .png extension to filename
-      if (!filename.match(/.png$/i)) {
-        filename += ".png";
-      }
+    let downloadsDir = yield Downloads.getPreferredDownloadsDirectory();
+    let 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);
+    }
+
+    let sourceURI = Services.io.newURI(reply.data, null, null);
+    let targetFile = new FileUtils.File(reply.filename);
+    let targetFileURI = Services.io.newFileURI(targetFile);
 
-      window.saveURL(reply.data, filename, null,
-                     true /* aShouldBypassCache */, true /* aSkipPrompt */,
-                     document.documentURIObject, document);
+    // 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;
+    let isPrivate =
+      PrivateBrowsingUtils.isContentWindowPrivate(document.defaultView);
+    let persist = Cc["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"]
+                    .createInstance(Ci.nsIWebBrowserPersist);
+    persist.persistFlags = flags;
+    let tr = Cc["@mozilla.org/transfer;1"].createInstance(Ci.nsITransfer);
+    tr.init(sourceURI,
+            targetFileURI,
+            "",
+            null,
+            null,
+            null,
+            persist,
+            isPrivate);
+    let listener = new DownloadListener(window, tr);
+    persist.progressListener = listener;
+    persist.savePrivacyAwareURI(sourceURI,
+                                null,
+                                document.documentURIObject,
+                                Ci.nsIHttpChannel
+                                  .REFERRER_POLICY_NO_REFERRER_WHEN_DOWNGRADE,
+                                null,
+                                null,
+                                targetFileURI,
+                                isPrivate);
 
-      reply.destinations.push(l10n.lookup("screenshotSavedToFile") + " \"" + filename + "\"");
-    }
-    catch (ex) {
+    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") + " " + filename);
+      reply.destinations.push(l10n.lookup("screenshotErrorSavingToFile") + " " +
+                              reply.filename);
     }
   });
 }
--- a/devtools/shared/gcli/source/lib/gcli/util/fileparser.js
+++ b/devtools/shared/gcli/source/lib/gcli/util/fileparser.js
@@ -100,17 +100,17 @@ exports.parse = function(context, typed,
   });
 };
 
 var RANK_OPTIONS = { noSort: true, prefixZero: true };
 
 /**
  * We want to be able to turn predictions off in Firefox
  */
-exports.supportsPredictions = true;
+exports.supportsPredictions = false;
 
 /**
  * Get a function which creates predictions of files that match the given
  * path
  */
 function getPredictor(typed, options) {
   if (!exports.supportsPredictions) {
     return undefined;