diff options
Diffstat (limited to 'devtools/shared/gcli/commands')
23 files changed, 4020 insertions, 0 deletions
diff --git a/devtools/shared/gcli/commands/addon.js b/devtools/shared/gcli/commands/addon.js new file mode 100644 index 000000000..9a38142a3 --- /dev/null +++ b/devtools/shared/gcli/commands/addon.js @@ -0,0 +1,320 @@ +/* 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"; + +/** + * You can't require the AddonManager in a child process, but GCLI wants to + * check for 'items' in all processes, so we return empty array if the + * AddonManager is not available + */ +function getAddonManager() { + try { + return { + AddonManager: require("resource://gre/modules/AddonManager.jsm").AddonManager, + addonManagerActive: true + }; + } + catch (ex) { + // Fake up an AddonManager just enough to let the file load + return { + AddonManager: { + getAllAddons() {}, + getAddonsByTypes() {} + }, + addonManagerActive: false + }; + } +} + +const { Cc, Ci, Cu } = require("chrome"); +const { AddonManager, addonManagerActive } = getAddonManager(); +const l10n = require("gcli/l10n"); + +const BRAND_SHORT_NAME = Cc["@mozilla.org/intl/stringbundle;1"] + .getService(Ci.nsIStringBundleService) + .createBundle("chrome://branding/locale/brand.properties") + .GetStringFromName("brandShortName"); + +/** + * Takes a function that uses a callback as its last parameter, and returns a + * new function that returns a promise instead. + * This should probably live in async-util + */ +const promiseify = function(scope, functionWithLastParamCallback) { + return (...args) => { + return new Promise(resolve => { + args.push((...results) => { + resolve(results.length > 1 ? results : results[0]); + }); + functionWithLastParamCallback.apply(scope, args); + }); + } +}; + +// Convert callback based functions to promise based ones +const getAllAddons = promiseify(AddonManager, AddonManager.getAllAddons); +const getAddonsByTypes = promiseify(AddonManager, AddonManager.getAddonsByTypes); + +/** + * Return a string array containing the pending operations on an addon + */ +function pendingOperations(addon) { + let allOperations = [ + "PENDING_ENABLE", "PENDING_DISABLE", "PENDING_UNINSTALL", + "PENDING_INSTALL", "PENDING_UPGRADE" + ]; + return allOperations.reduce(function(operations, opName) { + return addon.pendingOperations & AddonManager[opName] ? + operations.concat(opName) : + operations; + }, []); +} + +var items = [ + { + item: "type", + name: "addon", + parent: "selection", + stringifyProperty: "name", + cacheable: true, + constructor: function() { + // Tell GCLI to clear the cache of addons when one is added or removed + let listener = { + onInstalled: addon => { this.clearCache(); }, + onUninstalled: addon => { this.clearCache(); }, + }; + AddonManager.addAddonListener(listener); + }, + lookup: function() { + return getAllAddons().then(addons => { + return addons.map(addon => { + let name = addon.name + " " + addon.version; + name = name.trim().replace(/\s/g, "_"); + return { name: name, value: addon }; + }); + }); + } + }, + { + name: "addon", + description: l10n.lookup("addonDesc") + }, + { + name: "addon list", + description: l10n.lookup("addonListDesc"), + returnType: "addonsInfo", + params: [{ + name: "type", + type: { + name: "selection", + data: [ "dictionary", "extension", "locale", "plugin", "theme", "all" ] + }, + defaultValue: "all", + description: l10n.lookup("addonListTypeDesc") + }], + exec: function(args, context) { + let types = (args.type === "all") ? null : [ args.type ]; + return getAddonsByTypes(types).then(addons => { + addons = addons.map(function(addon) { + return { + name: addon.name, + version: addon.version, + isActive: addon.isActive, + pendingOperations: pendingOperations(addon) + }; + }); + return { addons: addons, type: args.type }; + }); + } + }, + { + item: "converter", + from: "addonsInfo", + to: "view", + exec: function(addonsInfo, context) { + if (!addonsInfo.addons.length) { + return context.createView({ + html: "<p>${message}</p>", + data: { message: l10n.lookup("addonNoneOfType") } + }); + } + + let headerLookups = { + "dictionary": "addonListDictionaryHeading", + "extension": "addonListExtensionHeading", + "locale": "addonListLocaleHeading", + "plugin": "addonListPluginHeading", + "theme": "addonListThemeHeading", + "all": "addonListAllHeading" + }; + let header = l10n.lookup(headerLookups[addonsInfo.type] || + "addonListUnknownHeading"); + + let operationLookups = { + "PENDING_ENABLE": "addonPendingEnable", + "PENDING_DISABLE": "addonPendingDisable", + "PENDING_UNINSTALL": "addonPendingUninstall", + "PENDING_INSTALL": "addonPendingInstall", + "PENDING_UPGRADE": "addonPendingUpgrade" + }; + function lookupOperation(opName) { + let lookupName = operationLookups[opName]; + return lookupName ? l10n.lookup(lookupName) : opName; + } + + function arrangeAddons(addons) { + let enabledAddons = []; + let disabledAddons = []; + addons.forEach(function(addon) { + if (addon.isActive) { + enabledAddons.push(addon); + } else { + disabledAddons.push(addon); + } + }); + + function compareAddonNames(nameA, nameB) { + return String.localeCompare(nameA.name, nameB.name); + } + enabledAddons.sort(compareAddonNames); + disabledAddons.sort(compareAddonNames); + + return enabledAddons.concat(disabledAddons); + } + + function isActiveForToggle(addon) { + return (addon.isActive && ~~addon.pendingOperations.indexOf("PENDING_DISABLE")); + } + + return context.createView({ + html: + "<table>" + + " <caption>${header}</caption>" + + " <tbody>" + + " <tr foreach='addon in ${addons}'" + + " class=\"gcli-addon-${addon.status}\">" + + " <td>${addon.name} ${addon.version}</td>" + + " <td>${addon.pendingOperations}</td>" + + " <td>" + + " <span class='gcli-out-shortcut'" + + " data-command='addon ${addon.toggleActionName} ${addon.label}'" + + " onclick='${onclick}' ondblclick='${ondblclick}'" + + " >${addon.toggleActionMessage}</span>" + + " </td>" + + " </tr>" + + " </tbody>" + + "</table>", + data: { + header: header, + addons: arrangeAddons(addonsInfo.addons).map(function(addon) { + return { + name: addon.name, + label: addon.name.replace(/\s/g, "_") + + (addon.version ? "_" + addon.version : ""), + status: addon.isActive ? "enabled" : "disabled", + version: addon.version, + pendingOperations: addon.pendingOperations.length ? + (" (" + l10n.lookup("addonPending") + ": " + + addon.pendingOperations.map(lookupOperation).join(", ") + + ")") : + "", + toggleActionName: isActiveForToggle(addon) ? "disable": "enable", + toggleActionMessage: isActiveForToggle(addon) ? + l10n.lookup("addonListOutDisable") : + l10n.lookup("addonListOutEnable") + }; + }), + onclick: context.update, + ondblclick: context.updateExec + } + }); + } + }, + { + item: "command", + runAt: "client", + name: "addon enable", + description: l10n.lookup("addonEnableDesc"), + params: [ + { + name: "addon", + type: "addon", + description: l10n.lookup("addonNameDesc") + } + ], + exec: function(args, context) { + let name = (args.addon.name + " " + args.addon.version).trim(); + if (args.addon.userDisabled) { + args.addon.userDisabled = false; + return l10n.lookupFormat("addonEnabled", [ name ]); + } + + return l10n.lookupFormat("addonAlreadyEnabled", [ name ]); + } + }, + { + item: "command", + runAt: "client", + name: "addon disable", + description: l10n.lookup("addonDisableDesc"), + params: [ + { + name: "addon", + type: "addon", + description: l10n.lookup("addonNameDesc") + } + ], + exec: function(args, context) { + // If the addon is not disabled or is set to "click to play" then + // disable it. Otherwise display the message "Add-on is already + // disabled." + let name = (args.addon.name + " " + args.addon.version).trim(); + if (!args.addon.userDisabled || + args.addon.userDisabled === AddonManager.STATE_ASK_TO_ACTIVATE) { + args.addon.userDisabled = true; + return l10n.lookupFormat("addonDisabled", [ name ]); + } + + return l10n.lookupFormat("addonAlreadyDisabled", [ name ]); + } + }, + { + item: "command", + runAt: "client", + name: "addon ctp", + description: l10n.lookup("addonCtpDesc"), + params: [ + { + name: "addon", + type: "addon", + description: l10n.lookup("addonNameDesc") + } + ], + exec: function(args, context) { + let name = (args.addon.name + " " + args.addon.version).trim(); + if (args.addon.type !== "plugin") { + return l10n.lookupFormat("addonCantCtp", [ name ]); + } + + if (!args.addon.userDisabled || + args.addon.userDisabled === true) { + args.addon.userDisabled = AddonManager.STATE_ASK_TO_ACTIVATE; + + if (args.addon.userDisabled !== AddonManager.STATE_ASK_TO_ACTIVATE) { + // Some plugins (e.g. OpenH264 shipped with Firefox) cannot be set to + // click-to-play. Handle this. + + return l10n.lookupFormat("addonNoCtp", [ name ]); + } + + return l10n.lookupFormat("addonCtp", [ name ]); + } + + return l10n.lookupFormat("addonAlreadyCtp", [ name ]); + } + } +]; + +exports.items = addonManagerActive ? items : []; diff --git a/devtools/shared/gcli/commands/appcache.js b/devtools/shared/gcli/commands/appcache.js new file mode 100644 index 000000000..0789fb5a0 --- /dev/null +++ b/devtools/shared/gcli/commands/appcache.js @@ -0,0 +1,186 @@ +/* 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 l10n = require("gcli/l10n"); + +loader.lazyImporter(this, "AppCacheUtils", "resource://devtools/client/shared/AppCacheUtils.jsm"); + +exports.items = [ + { + item: "command", + name: "appcache", + description: l10n.lookup("appCacheDesc") + }, + { + item: "command", + runAt: "server", + name: "appcache validate", + description: l10n.lookup("appCacheValidateDesc"), + manual: l10n.lookup("appCacheValidateManual"), + returnType: "appcacheerrors", + params: [{ + group: "options", + params: [ + { + type: "string", + name: "uri", + description: l10n.lookup("appCacheValidateUriDesc"), + defaultValue: null, + } + ] + }], + exec: function(args, context) { + let utils; + let deferred = context.defer(); + + if (args.uri) { + utils = new AppCacheUtils(args.uri); + } else { + utils = new AppCacheUtils(context.environment.document); + } + + utils.validateManifest().then(function(errors) { + deferred.resolve([errors, utils.manifestURI || "-"]); + }); + + return deferred.promise; + } + }, + { + item: "converter", + from: "appcacheerrors", + to: "view", + exec: function([errors, manifestURI], context) { + if (errors.length == 0) { + return context.createView({ + html: "<span>" + l10n.lookup("appCacheValidatedSuccessfully") + "</span>" + }); + } + + return context.createView({ + html: + "<div>" + + " <h4>Manifest URI: ${manifestURI}</h4>" + + " <ol>" + + " <li foreach='error in ${errors}'>${error.msg}</li>" + + " </ol>" + + "</div>", + data: { + errors: errors, + manifestURI: manifestURI + } + }); + } + }, + { + item: "command", + runAt: "server", + name: "appcache clear", + description: l10n.lookup("appCacheClearDesc"), + manual: l10n.lookup("appCacheClearManual"), + exec: function(args, context) { + let utils = new AppCacheUtils(args.uri); + utils.clearAll(); + + return l10n.lookup("appCacheClearCleared"); + } + }, + { + item: "command", + runAt: "server", + name: "appcache list", + description: l10n.lookup("appCacheListDesc"), + manual: l10n.lookup("appCacheListManual"), + returnType: "appcacheentries", + params: [{ + group: "options", + params: [ + { + type: "string", + name: "search", + description: l10n.lookup("appCacheListSearchDesc"), + defaultValue: null, + }, + ] + }], + exec: function(args, context) { + let utils = new AppCacheUtils(); + return utils.listEntries(args.search); + } + }, + { + item: "converter", + from: "appcacheentries", + to: "view", + exec: function(entries, context) { + return context.createView({ + html: "" + + "<ul class='gcli-appcache-list'>" + + " <li foreach='entry in ${entries}'>" + + " <table class='gcli-appcache-detail'>" + + " <tr>" + + " <td>" + l10n.lookup("appCacheListKey") + "</td>" + + " <td>${entry.key}</td>" + + " </tr>" + + " <tr>" + + " <td>" + l10n.lookup("appCacheListFetchCount") + "</td>" + + " <td>${entry.fetchCount}</td>" + + " </tr>" + + " <tr>" + + " <td>" + l10n.lookup("appCacheListLastFetched") + "</td>" + + " <td>${entry.lastFetched}</td>" + + " </tr>" + + " <tr>" + + " <td>" + l10n.lookup("appCacheListLastModified") + "</td>" + + " <td>${entry.lastModified}</td>" + + " </tr>" + + " <tr>" + + " <td>" + l10n.lookup("appCacheListExpirationTime") + "</td>" + + " <td>${entry.expirationTime}</td>" + + " </tr>" + + " <tr>" + + " <td>" + l10n.lookup("appCacheListDataSize") + "</td>" + + " <td>${entry.dataSize}</td>" + + " </tr>" + + " <tr>" + + " <td>" + l10n.lookup("appCacheListDeviceID") + "</td>" + + " <td>${entry.deviceID} <span class='gcli-out-shortcut' " + + "onclick='${onclick}' ondblclick='${ondblclick}' " + + "data-command='appcache viewentry ${entry.key}'" + + ">" + l10n.lookup("appCacheListViewEntry") + "</span>" + + " </td>" + + " </tr>" + + " </table>" + + " </li>" + + "</ul>", + data: { + entries: entries, + onclick: context.update, + ondblclick: context.updateExec + } + }); + } + }, + { + item: "command", + runAt: "server", + name: "appcache viewentry", + description: l10n.lookup("appCacheViewEntryDesc"), + manual: l10n.lookup("appCacheViewEntryManual"), + params: [ + { + type: "string", + name: "key", + description: l10n.lookup("appCacheViewEntryKey"), + defaultValue: null, + } + ], + exec: function(args, context) { + let utils = new AppCacheUtils(); + return utils.viewEntry(args.key); + } + } +]; diff --git a/devtools/shared/gcli/commands/calllog.js b/devtools/shared/gcli/commands/calllog.js new file mode 100644 index 000000000..c0f21aeab --- /dev/null +++ b/devtools/shared/gcli/commands/calllog.js @@ -0,0 +1,219 @@ +/* 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 { Cu } = require("chrome"); +const l10n = require("gcli/l10n"); +const gcli = require("gcli/index"); +const Debugger = require("Debugger"); + +loader.lazyRequireGetter(this, "gDevTools", "devtools/client/framework/devtools", true); +loader.lazyRequireGetter(this, "TargetFactory", "devtools/client/framework/target", true); + +var debuggers = []; +var chromeDebuggers = []; +var sandboxes = []; + +exports.items = [ + { + name: "calllog", + description: l10n.lookup("calllogDesc") + }, + { + item: "command", + runAt: "client", + name: "calllog start", + description: l10n.lookup("calllogStartDesc"), + + exec: function(args, context) { + let contentWindow = context.environment.window; + + let dbg = new Debugger(contentWindow); + dbg.onEnterFrame = function(frame) { + // BUG 773652 - Make the output from the GCLI calllog command nicer + contentWindow.console.log("Method call: " + this.callDescription(frame)); + }.bind(this); + + debuggers.push(dbg); + + let gBrowser = context.environment.chromeDocument.defaultView.gBrowser; + let target = TargetFactory.forTab(gBrowser.selectedTab); + gDevTools.showToolbox(target, "webconsole"); + + return l10n.lookup("calllogStartReply"); + }, + + callDescription: function(frame) { + let name = "<anonymous>"; + if (frame.callee.name) { + name = frame.callee.name; + } + else { + let desc = frame.callee.getOwnPropertyDescriptor("displayName"); + if (desc && desc.value && typeof desc.value == "string") { + name = desc.value; + } + } + + let args = frame.arguments.map(this.valueToString).join(", "); + return name + "(" + args + ")"; + }, + + valueToString: function(value) { + if (typeof value !== "object" || value === null) { + return uneval(value); + } + return "[object " + value.class + "]"; + } + }, + { + item: "command", + runAt: "client", + name: "calllog stop", + description: l10n.lookup("calllogStopDesc"), + + exec: function(args, context) { + let numDebuggers = debuggers.length; + if (numDebuggers == 0) { + return l10n.lookup("calllogStopNoLogging"); + } + + for (let dbg of debuggers) { + dbg.onEnterFrame = undefined; + } + debuggers = []; + + return l10n.lookupFormat("calllogStopReply", [ numDebuggers ]); + } + }, + { + item: "command", + runAt: "client", + name: "calllog chromestart", + description: l10n.lookup("calllogChromeStartDesc"), + get hidden() { + return gcli.hiddenByChromePref(); + }, + params: [ + { + name: "sourceType", + type: { + name: "selection", + data: ["content-variable", "chrome-variable", "jsm", "javascript"] + } + }, + { + name: "source", + type: "string", + description: l10n.lookup("calllogChromeSourceTypeDesc"), + manual: l10n.lookup("calllogChromeSourceTypeManual"), + } + ], + exec: function(args, context) { + let globalObj; + let contentWindow = context.environment.window; + + if (args.sourceType == "jsm") { + try { + globalObj = Cu.import(args.source, {}); + } catch (e) { + return l10n.lookup("callLogChromeInvalidJSM"); + } + } else if (args.sourceType == "content-variable") { + if (args.source in contentWindow) { + globalObj = Cu.getGlobalForObject(contentWindow[args.source]); + } else { + throw new Error(l10n.lookup("callLogChromeVarNotFoundContent")); + } + } else if (args.sourceType == "chrome-variable") { + let chromeWin = context.environment.chromeDocument.defaultView; + if (args.source in chromeWin) { + globalObj = Cu.getGlobalForObject(chromeWin[args.source]); + } else { + return l10n.lookup("callLogChromeVarNotFoundChrome"); + } + } else { + let chromeWin = context.environment.chromeDocument.defaultView; + let sandbox = new Cu.Sandbox(chromeWin, + { + sandboxPrototype: chromeWin, + wantXrays: false, + sandboxName: "gcli-cmd-calllog-chrome" + }); + let returnVal; + try { + returnVal = Cu.evalInSandbox(args.source, sandbox, "ECMAv5"); + sandboxes.push(sandbox); + } catch(e) { + // We need to save the message before cleaning up else e contains a dead + // object. + let msg = l10n.lookup("callLogChromeEvalException") + ": " + e; + Cu.nukeSandbox(sandbox); + return msg; + } + + if (typeof returnVal == "undefined") { + return l10n.lookup("callLogChromeEvalNeedsObject"); + } + + globalObj = Cu.getGlobalForObject(returnVal); + } + + let dbg = new Debugger(globalObj); + chromeDebuggers.push(dbg); + + dbg.onEnterFrame = function(frame) { + // BUG 773652 - Make the output from the GCLI calllog command nicer + contentWindow.console.log(l10n.lookup("callLogChromeMethodCall") + + ": " + this.callDescription(frame)); + }.bind(this); + + let gBrowser = context.environment.chromeDocument.defaultView.gBrowser; + let target = TargetFactory.forTab(gBrowser.selectedTab); + gDevTools.showToolbox(target, "webconsole"); + + return l10n.lookup("calllogChromeStartReply"); + }, + + valueToString: function(value) { + if (typeof value !== "object" || value === null) + return uneval(value); + return "[object " + value.class + "]"; + }, + + callDescription: function(frame) { + let name = frame.callee.name || l10n.lookup("callLogChromeAnonFunction"); + let args = frame.arguments.map(this.valueToString).join(", "); + return name + "(" + args + ")"; + } + }, + { + item: "command", + runAt: "client", + name: "calllog chromestop", + description: l10n.lookup("calllogChromeStopDesc"), + get hidden() { + return gcli.hiddenByChromePref(); + }, + exec: function(args, context) { + let numDebuggers = chromeDebuggers.length; + if (numDebuggers == 0) { + return l10n.lookup("calllogChromeStopNoLogging"); + } + + for (let dbg of chromeDebuggers) { + dbg.onEnterFrame = undefined; + dbg.enabled = false; + } + for (let sandbox of sandboxes) { + Cu.nukeSandbox(sandbox); + } + chromeDebuggers = []; + sandboxes = []; + + return l10n.lookupFormat("calllogChromeStopReply", [ numDebuggers ]); + } + } +]; diff --git a/devtools/shared/gcli/commands/cmd.js b/devtools/shared/gcli/commands/cmd.js new file mode 100644 index 000000000..1777ed960 --- /dev/null +++ b/devtools/shared/gcli/commands/cmd.js @@ -0,0 +1,178 @@ +/* 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 { OS } = Cu.import("resource://gre/modules/osfile.jsm", {}); +const { TextEncoder, TextDecoder } = Cu.import('resource://gre/modules/commonjs/toolkit/loader.js', {}); +const gcli = require("gcli/index"); +const l10n = require("gcli/l10n"); + +loader.lazyGetter(this, "prefBranch", function() { + let prefService = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefService); + return prefService.getBranch(null).QueryInterface(Ci.nsIPrefBranch2); +}); + +loader.lazyGetter(this, "supportsString", function() { + return Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString); +}); + +loader.lazyImporter(this, "NetUtil", "resource://gre/modules/NetUtil.jsm"); + +const PREF_DIR = "devtools.commands.dir"; + +/** + * Load all the .mozcmd files in the directory pointed to by PREF_DIR + * @return A promise of an array of items suitable for gcli.addItems or + * using in gcli.addItemsByModule + */ +function loadItemsFromMozDir() { + let dirName = prefBranch.getComplexValue(PREF_DIR, + Ci.nsISupportsString).data.trim(); + if (dirName == "") { + return Promise.resolve([]); + } + + // replaces ~ with the home directory path in unix and windows + if (dirName.indexOf("~") == 0) { + let dirService = Cc["@mozilla.org/file/directory_service;1"] + .getService(Ci.nsIProperties); + let homeDirFile = dirService.get("Home", Ci.nsIFile); + let homeDir = homeDirFile.path; + dirName = dirName.substr(1); + dirName = homeDir + dirName; + } + + // statPromise resolves to nothing if dirName is a directory, or it + // rejects with an error message otherwise + let statPromise = OS.File.stat(dirName); + statPromise = statPromise.then( + function onSuccess(stat) { + if (!stat.isDir) { + throw new Error("'" + dirName + "' is not a directory."); + } + }, + function onFailure(reason) { + if (reason instanceof OS.File.Error && reason.becauseNoSuchFile) { + throw new Error("'" + dirName + "' does not exist."); + } else { + throw reason; + } + } + ); + + // We need to return (a promise of) an array of items from the *.mozcmd + // files in dirName (which we can assume to be a valid directory now) + return statPromise.then(() => { + let itemPromises = []; + + let iterator = new OS.File.DirectoryIterator(dirName); + let iterPromise = iterator.forEach(entry => { + if (entry.name.match(/.*\.mozcmd$/) && !entry.isDir) { + itemPromises.push(loadCommandFile(entry)); + } + }); + + return iterPromise.then(() => { + iterator.close(); + return Promise.all(itemPromises).then((itemsArray) => { + return itemsArray.reduce((prev, curr) => { + return prev.concat(curr); + }, []); + }); + }, reason => { iterator.close(); throw reason; }); + }); +} + +exports.mozDirLoader = function(name) { + return loadItemsFromMozDir().then(items => { + return { items: items }; + }); +}; + +/** + * Load the commands from a single file + * @param OS.File.DirectoryIterator.Entry entry The DirectoryIterator + * Entry of the file containing the commands that we should read + */ +function loadCommandFile(entry) { + let readPromise = OS.File.read(entry.path); + return readPromise = readPromise.then(array => { + let decoder = new TextDecoder(); + let source = decoder.decode(array); + var principal = Cc["@mozilla.org/systemprincipal;1"] + .createInstance(Ci.nsIPrincipal); + + let sandbox = new Cu.Sandbox(principal, { + sandboxName: entry.path + }); + let data = Cu.evalInSandbox(source, sandbox, "1.8", entry.name, 1); + + if (!Array.isArray(data)) { + console.error("Command file '" + entry.name + "' does not have top level array."); + return; + } + + return data; + }); +} + +exports.items = [ + { + name: "cmd", + get hidden() { + return !prefBranch.prefHasUserValue(PREF_DIR); + }, + description: l10n.lookup("cmdDesc") + }, + { + item: "command", + runAt: "client", + name: "cmd refresh", + description: l10n.lookup("cmdRefreshDesc"), + get hidden() { + return !prefBranch.prefHasUserValue(PREF_DIR); + }, + exec: function(args, context) { + gcli.load(); + + let dirName = prefBranch.getComplexValue(PREF_DIR, + Ci.nsISupportsString).data.trim(); + return l10n.lookupFormat("cmdStatus3", [ dirName ]); + } + }, + { + item: "command", + runAt: "client", + name: "cmd setdir", + description: l10n.lookup("cmdSetdirDesc"), + manual: l10n.lookup("cmdSetdirManual3"), + params: [ + { + name: "directory", + description: l10n.lookup("cmdSetdirDirectoryDesc"), + type: { + name: "file", + filetype: "directory", + existing: "yes" + }, + defaultValue: null + } + ], + returnType: "string", + get hidden() { + return true; // !prefBranch.prefHasUserValue(PREF_DIR); + }, + exec: function(args, context) { + supportsString.data = args.directory; + prefBranch.setComplexValue(PREF_DIR, Ci.nsISupportsString, supportsString); + + gcli.load(); + + return l10n.lookupFormat("cmdStatus3", [ args.directory ]); + } + } +]; diff --git a/devtools/shared/gcli/commands/cookie.js b/devtools/shared/gcli/commands/cookie.js new file mode 100644 index 000000000..f1680042f --- /dev/null +++ b/devtools/shared/gcli/commands/cookie.js @@ -0,0 +1,300 @@ +/* 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"; + +/** + * XXX: bug 1221488 is required to make these commands run on the server. + * If we want these commands to run on remote devices/connections, they need to + * run on the server (runAt=server). Unfortunately, cookie commands not only + * need to run on the server, they also need to access to the parent process to + * retrieve and manipulate cookies via nsICookieManager2. + * However, server-running commands have no way of accessing the parent process + * for now. + * + * So, because these cookie commands, as of today, only run in the developer + * toolbar (the gcli command bar), and because this toolbar is only available on + * a local Firefox desktop tab (not in webide or the browser toolbox), we can + * make the commands run on the client. + * This way, they'll always run in the parent process. + */ + +const { Ci, Cc } = require("chrome"); +const l10n = require("gcli/l10n"); +const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyGetter(this, "cookieMgr", function() { + return Cc["@mozilla.org/cookiemanager;1"].getService(Ci.nsICookieManager2); +}); + +/** + * Check host value and remove port part as it is not used + * for storing cookies. + * + * Parameter will usually be `new URL(context.environment.target.url).host` + */ +function sanitizeHost(host) { + if (host == null || host == "") { + throw new Error(l10n.lookup("cookieListOutNonePage")); + } + return host.split(":")[0]; +} + +/** + * The cookie 'expires' value needs converting into something more readable. + * + * And the unit of expires is sec, the unit that in argument of Date() needs + * millisecond. + */ +function translateExpires(expires) { + if (expires == 0) { + return l10n.lookup("cookieListOutSession"); + } + + let expires_msec = expires * 1000; + + return (new Date(expires_msec)).toLocaleString(); +} + +/** + * Check if a given cookie matches a given host + */ +function isCookieAtHost(cookie, host) { + if (cookie.host == null) { + return host == null; + } + if (cookie.host.startsWith(".")) { + return ("." + host).endsWith(cookie.host); + } + if (cookie.host === "") { + return host.startsWith("file://" + cookie.path); + } + return cookie.host == host; +} + +exports.items = [ + { + name: "cookie", + description: l10n.lookup("cookieDesc"), + manual: l10n.lookup("cookieManual") + }, + { + item: "command", + runAt: "client", + name: "cookie list", + description: l10n.lookup("cookieListDesc"), + manual: l10n.lookup("cookieListManual"), + returnType: "cookies", + exec: function(args, context) { + if (context.environment.target.isRemote) { + throw new Error("The cookie gcli commands only work in a local tab, " + + "see bug 1221488"); + } + let host = new URL(context.environment.target.url).host; + let contentWindow = context.environment.window; + host = sanitizeHost(host); + let enm = cookieMgr.getCookiesFromHost(host, contentWindow.document. + nodePrincipal. + originAttributes); + + let cookies = []; + while (enm.hasMoreElements()) { + let cookie = enm.getNext().QueryInterface(Ci.nsICookie); + if (isCookieAtHost(cookie, host)) { + cookies.push({ + host: cookie.host, + name: cookie.name, + value: cookie.value, + path: cookie.path, + expires: cookie.expires, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + sameDomain: cookie.sameDomain + }); + } + } + + return cookies; + } + }, + { + item: "command", + runAt: "client", + name: "cookie remove", + description: l10n.lookup("cookieRemoveDesc"), + manual: l10n.lookup("cookieRemoveManual"), + params: [ + { + name: "name", + type: "string", + description: l10n.lookup("cookieRemoveKeyDesc"), + } + ], + exec: function(args, context) { + if (context.environment.target.isRemote) { + throw new Error("The cookie gcli commands only work in a local tab, " + + "see bug 1221488"); + } + let host = new URL(context.environment.target.url).host; + let contentWindow = context.environment.window; + host = sanitizeHost(host); + let enm = cookieMgr.getCookiesFromHost(host, contentWindow.document. + nodePrincipal. + originAttributes); + + while (enm.hasMoreElements()) { + let cookie = enm.getNext().QueryInterface(Ci.nsICookie); + if (isCookieAtHost(cookie, host)) { + if (cookie.name == args.name) { + cookieMgr.remove(cookie.host, cookie.name, cookie.path, + false, cookie.originAttributes); + } + } + } + } + }, + { + item: "converter", + from: "cookies", + to: "view", + exec: function(cookies, context) { + if (cookies.length == 0) { + let host = new URL(context.environment.target.url).host; + host = sanitizeHost(host); + let msg = l10n.lookupFormat("cookieListOutNoneHost", [ host ]); + return context.createView({ html: "<span>" + msg + "</span>" }); + } + + for (let cookie of cookies) { + cookie.expires = translateExpires(cookie.expires); + + let noAttrs = !cookie.secure && !cookie.httpOnly && !cookie.sameDomain; + cookie.attrs = (cookie.secure ? "secure" : " ") + + (cookie.httpOnly ? "httpOnly" : " ") + + (cookie.sameDomain ? "sameDomain" : " ") + + (noAttrs ? l10n.lookup("cookieListOutNone") : " "); + } + + return context.createView({ + html: + "<ul class='gcli-cookielist-list'>" + + " <li foreach='cookie in ${cookies}'>" + + " <div>${cookie.name}=${cookie.value}</div>" + + " <table class='gcli-cookielist-detail'>" + + " <tr>" + + " <td>" + l10n.lookup("cookieListOutHost") + "</td>" + + " <td>${cookie.host}</td>" + + " </tr>" + + " <tr>" + + " <td>" + l10n.lookup("cookieListOutPath") + "</td>" + + " <td>${cookie.path}</td>" + + " </tr>" + + " <tr>" + + " <td>" + l10n.lookup("cookieListOutExpires") + "</td>" + + " <td>${cookie.expires}</td>" + + " </tr>" + + " <tr>" + + " <td>" + l10n.lookup("cookieListOutAttributes") + "</td>" + + " <td>${cookie.attrs}</td>" + + " </tr>" + + " <tr><td colspan='2'>" + + " <span class='gcli-out-shortcut' onclick='${onclick}'" + + " data-command='cookie set ${cookie.name} '" + + " >" + l10n.lookup("cookieListOutEdit") + "</span>" + + " <span class='gcli-out-shortcut'" + + " onclick='${onclick}' ondblclick='${ondblclick}'" + + " data-command='cookie remove ${cookie.name}'" + + " >" + l10n.lookup("cookieListOutRemove") + "</span>" + + " </td></tr>" + + " </table>" + + " </li>" + + "</ul>", + data: { + options: { allowEval: true }, + cookies: cookies, + onclick: context.update, + ondblclick: context.updateExec + } + }); + } + }, + { + item: "command", + runAt: "client", + name: "cookie set", + description: l10n.lookup("cookieSetDesc"), + manual: l10n.lookup("cookieSetManual"), + params: [ + { + name: "name", + type: "string", + description: l10n.lookup("cookieSetKeyDesc") + }, + { + name: "value", + type: "string", + description: l10n.lookup("cookieSetValueDesc") + }, + { + group: l10n.lookup("cookieSetOptionsDesc"), + params: [ + { + name: "path", + type: { name: "string", allowBlank: true }, + defaultValue: "/", + description: l10n.lookup("cookieSetPathDesc") + }, + { + name: "domain", + type: "string", + defaultValue: null, + description: l10n.lookup("cookieSetDomainDesc") + }, + { + name: "secure", + type: "boolean", + description: l10n.lookup("cookieSetSecureDesc") + }, + { + name: "httpOnly", + type: "boolean", + description: l10n.lookup("cookieSetHttpOnlyDesc") + }, + { + name: "session", + type: "boolean", + description: l10n.lookup("cookieSetSessionDesc") + }, + { + name: "expires", + type: "string", + defaultValue: "Jan 17, 2038", + description: l10n.lookup("cookieSetExpiresDesc") + }, + ] + } + ], + exec: function(args, context) { + if (context.environment.target.isRemote) { + throw new Error("The cookie gcli commands only work in a local tab, " + + "see bug 1221488"); + } + let host = new URL(context.environment.target.url).host; + host = sanitizeHost(host); + let time = Date.parse(args.expires) / 1000; + let contentWindow = context.environment.window; + cookieMgr.add(args.domain ? "." + args.domain : host, + args.path ? args.path : "/", + args.name, + args.value, + args.secure, + args.httpOnly, + args.session, + time, + contentWindow.document. + nodePrincipal. + originAttributes); + } + } +]; diff --git a/devtools/shared/gcli/commands/csscoverage.js b/devtools/shared/gcli/commands/csscoverage.js new file mode 100644 index 000000000..ebbf0baca --- /dev/null +++ b/devtools/shared/gcli/commands/csscoverage.js @@ -0,0 +1,201 @@ +/* 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 } = require("chrome"); + +const domtemplate = require("gcli/util/domtemplate"); +const csscoverage = require("devtools/shared/fronts/csscoverage"); +const l10n = csscoverage.l10n; + +loader.lazyRequireGetter(this, "gDevTools", "devtools/client/framework/devtools", true); + +loader.lazyImporter(this, "Chart", "resource://devtools/client/shared/widgets/Chart.jsm"); + +/** + * The commands/converters for GCLI + */ +exports.items = [ + { + name: "csscoverage", + hidden: true, + description: l10n.lookup("csscoverageDesc"), + }, + { + item: "command", + runAt: "client", + name: "csscoverage start", + hidden: true, + description: l10n.lookup("csscoverageStartDesc2"), + params: [ + { + name: "noreload", + type: "boolean", + description: l10n.lookup("csscoverageStartNoReloadDesc"), + manual: l10n.lookup("csscoverageStartNoReloadManual") + } + ], + exec: function*(args, context) { + let usage = yield csscoverage.getUsage(context.environment.target); + if (usage == null) { + throw new Error(l10n.lookup("csscoverageNoRemoteError")); + } + yield usage.start(context.environment.chromeWindow, + context.environment.target, args.noreload); + } + }, + { + item: "command", + runAt: "client", + name: "csscoverage stop", + hidden: true, + description: l10n.lookup("csscoverageStopDesc2"), + exec: function*(args, context) { + let target = context.environment.target; + let usage = yield csscoverage.getUsage(target); + if (usage == null) { + throw new Error(l10n.lookup("csscoverageNoRemoteError")); + } + yield usage.stop(); + yield gDevTools.showToolbox(target, "styleeditor"); + } + }, + { + item: "command", + runAt: "client", + name: "csscoverage oneshot", + hidden: true, + description: l10n.lookup("csscoverageOneShotDesc2"), + exec: function*(args, context) { + let target = context.environment.target; + let usage = yield csscoverage.getUsage(target); + if (usage == null) { + throw new Error(l10n.lookup("csscoverageNoRemoteError")); + } + yield usage.oneshot(); + yield gDevTools.showToolbox(target, "styleeditor"); + } + }, + { + item: "command", + runAt: "client", + name: "csscoverage toggle", + hidden: true, + description: l10n.lookup("csscoverageToggleDesc2"), + state: { + isChecked: function(target) { + return csscoverage.getUsage(target).then(usage => { + return usage.isRunning(); + }); + }, + onChange: function(target, handler) { + csscoverage.getUsage(target).then(usage => { + this.handler = ev => { handler("state-change", ev); }; + usage.on("state-change", this.handler); + }); + }, + offChange: function(target, handler) { + csscoverage.getUsage(target).then(usage => { + usage.off("state-change", this.handler); + this.handler = undefined; + }); + }, + }, + exec: function*(args, context) { + let target = context.environment.target; + let usage = yield csscoverage.getUsage(target); + if (usage == null) { + throw new Error(l10n.lookup("csscoverageNoRemoteError")); + } + + yield usage.toggle(context.environment.chromeWindow, + context.environment.target); + } + }, + { + item: "command", + runAt: "client", + name: "csscoverage report", + hidden: true, + description: l10n.lookup("csscoverageReportDesc2"), + exec: function*(args, context) { + let usage = yield csscoverage.getUsage(context.environment.target); + if (usage == null) { + throw new Error(l10n.lookup("csscoverageNoRemoteError")); + } + + return { + isTypedData: true, + type: "csscoveragePageReport", + data: yield usage.createPageReport() + }; + } + }, + { + item: "converter", + from: "csscoveragePageReport", + to: "dom", + exec: function*(csscoveragePageReport, context) { + let target = context.environment.target; + + let toolbox = yield gDevTools.showToolbox(target, "styleeditor"); + let panel = toolbox.getCurrentPanel(); + + let host = panel._panelDoc.querySelector(".csscoverage-report"); + let templ = panel._panelDoc.querySelector(".csscoverage-template"); + + templ = templ.cloneNode(true); + templ.hidden = false; + + let data = { + preload: csscoveragePageReport.preload, + unused: csscoveragePageReport.unused, + summary: csscoveragePageReport.summary, + onback: () => { + // The back button clears and hides .csscoverage-report + while (host.hasChildNodes()) { + host.removeChild(host.firstChild); + } + host.hidden = true; + } + }; + + let addOnClick = rule => { + rule.onclick = () => { + panel.selectStyleSheet(rule.url, rule.start.line); + }; + }; + + data.preload.forEach(page => { + page.rules.forEach(addOnClick); + }); + data.unused.forEach(page => { + page.rules.forEach(addOnClick); + }); + + let options = { allowEval: true, stack: "styleeditor.xul" }; + domtemplate.template(templ, data, options); + + while (templ.hasChildNodes()) { + host.appendChild(templ.firstChild); + } + + // Create a new chart. + let container = host.querySelector(".csscoverage-report-chart"); + let chart = Chart.PieTable(panel._panelDoc, { + diameter: 200, // px + title: "CSS Usage", + data: [ + { size: data.summary.preload, label: "Used Preload" }, + { size: data.summary.used, label: "Used" }, + { size: data.summary.unused, label: "Unused" } + ] + }); + container.appendChild(chart.node); + + host.hidden = false; + } + } +]; diff --git a/devtools/shared/gcli/commands/folder.js b/devtools/shared/gcli/commands/folder.js new file mode 100644 index 000000000..22a51420d --- /dev/null +++ b/devtools/shared/gcli/commands/folder.js @@ -0,0 +1,77 @@ +/* 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, CC } = require("chrome"); +const Services = require("Services"); +const l10n = require("gcli/l10n"); +const dirService = Cc["@mozilla.org/file/directory_service;1"] + .getService(Ci.nsIProperties); + +function showFolder(aPath) { + let nsLocalFile = CC("@mozilla.org/file/local;1", "nsILocalFile", + "initWithPath"); + + try { + let file = new nsLocalFile(aPath); + + if (file.exists()) { + file.reveal(); + return l10n.lookupFormat("folderOpenDirResult", [aPath]); + } else { + return l10n.lookup("folderInvalidPath"); + } + } catch (e) { + return l10n.lookup("folderInvalidPath"); + } +} + +exports.items = [ + { + name: "folder", + description: l10n.lookup("folderDesc") + }, + { + item: "command", + runAt: "client", + name: "folder open", + description: l10n.lookup("folderOpenDesc"), + params: [ + { + name: "path", + type: { name: "string", allowBlank: true }, + defaultValue: "~", + description: l10n.lookup("folderOpenDir") + } + ], + returnType: "string", + exec: function(args, context) { + let dirName = args.path; + + // replaces ~ with the home directory path in unix and windows + if (dirName.indexOf("~") == 0) { + let homeDirFile = dirService.get("Home", Ci.nsIFile); + let homeDir = homeDirFile.path; + dirName = dirName.substr(1); + dirName = homeDir + dirName; + } + + return showFolder(dirName); + } + }, + { + item: "command", + runAt: "client", + name: "folder openprofile", + description: l10n.lookup("folderOpenProfileDesc"), + returnType: "string", + exec: function(args, context) { + // Get the profile directory. + let currProfD = Services.dirsvc.get("ProfD", Ci.nsIFile); + let profileDir = currProfD.path; + return showFolder(profileDir); + } + } +]; diff --git a/devtools/shared/gcli/commands/highlight.js b/devtools/shared/gcli/commands/highlight.js new file mode 100644 index 000000000..cc2353b3b --- /dev/null +++ b/devtools/shared/gcli/commands/highlight.js @@ -0,0 +1,158 @@ +/* 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 l10n = require("gcli/l10n"); +require("devtools/server/actors/inspector"); +const { + BoxModelHighlighter, + HighlighterEnvironment +} = require("devtools/server/actors/highlighters"); + +const {PluralForm} = require("devtools/shared/plural-form"); +const {LocalizationHelper} = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper("devtools/shared/locales/gclicommands.properties"); + +// How many maximum nodes can be highlighted in parallel +const MAX_HIGHLIGHTED_ELEMENTS = 100; + +// Store the environment object used to create highlighters so it can be +// destroyed later. +var highlighterEnv; + +// Stores the highlighters instances so they can be destroyed later. +// also export them so tests can access those and assert they got created +// correctly. +exports.highlighters = []; + +/** + * Destroy all existing highlighters + */ +function unhighlightAll() { + for (let highlighter of exports.highlighters) { + highlighter.destroy(); + } + exports.highlighters.length = 0; + + if (highlighterEnv) { + highlighterEnv.destroy(); + highlighterEnv = null; + } +} + +exports.items = [ + { + item: "command", + runAt: "server", + name: "highlight", + description: l10n.lookup("highlightDesc"), + manual: l10n.lookup("highlightManual"), + params: [ + { + name: "selector", + type: "nodelist", + description: l10n.lookup("highlightSelectorDesc"), + manual: l10n.lookup("highlightSelectorManual") + }, + { + group: l10n.lookup("highlightOptionsDesc"), + params: [ + { + name: "hideguides", + type: "boolean", + description: l10n.lookup("highlightHideGuidesDesc"), + manual: l10n.lookup("highlightHideGuidesManual") + }, + { + name: "showinfobar", + type: "boolean", + description: l10n.lookup("highlightShowInfoBarDesc"), + manual: l10n.lookup("highlightShowInfoBarManual") + }, + { + name: "showall", + type: "boolean", + description: l10n.lookup("highlightShowAllDesc"), + manual: l10n.lookup("highlightShowAllManual") + }, + { + name: "region", + type: { + name: "selection", + data: ["content", "padding", "border", "margin"] + }, + description: l10n.lookup("highlightRegionDesc"), + manual: l10n.lookup("highlightRegionManual"), + defaultValue: "border" + }, + { + name: "fill", + type: "string", + description: l10n.lookup("highlightFillDesc"), + manual: l10n.lookup("highlightFillManual"), + defaultValue: null + }, + { + name: "keep", + type: "boolean", + description: l10n.lookup("highlightKeepDesc"), + manual: l10n.lookup("highlightKeepManual") + } + ] + } + ], + exec: function(args, context) { + // Remove all existing highlighters unless told otherwise + if (!args.keep) { + unhighlightAll(); + } + + let env = context.environment; + highlighterEnv = new HighlighterEnvironment(); + highlighterEnv.initFromWindow(env.window); + + // Unhighlight on navigate + highlighterEnv.once("will-navigate", unhighlightAll); + + let i = 0; + for (let node of args.selector) { + if (!args.showall && i >= MAX_HIGHLIGHTED_ELEMENTS) { + break; + } + + let highlighter = new BoxModelHighlighter(highlighterEnv); + if (args.fill) { + highlighter.regionFill[args.region] = args.fill; + } + highlighter.show(node, { + region: args.region, + hideInfoBar: !args.showinfobar, + hideGuides: args.hideguides, + showOnly: args.region + }); + exports.highlighters.push(highlighter); + i++; + } + + let highlightText = L10N.getStr("highlightOutputConfirm2"); + let output = PluralForm.get(args.selector.length, highlightText) + .replace("%1$S", args.selector.length); + if (args.selector.length > i) { + output = l10n.lookupFormat("highlightOutputMaxReached", + ["" + args.selector.length, "" + i]); + } + + return output; + } + }, + { + item: "command", + runAt: "server", + name: "unhighlight", + description: l10n.lookup("unhighlightDesc"), + manual: l10n.lookup("unhighlightManual"), + exec: unhighlightAll + } +]; diff --git a/devtools/shared/gcli/commands/index.js b/devtools/shared/gcli/commands/index.js new file mode 100644 index 000000000..8fe77482e --- /dev/null +++ b/devtools/shared/gcli/commands/index.js @@ -0,0 +1,179 @@ +/* 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 { createSystem, connectFront, disconnectFront } = require("gcli/system"); +const { GcliFront } = require("devtools/shared/fronts/gcli"); + +/** + * This is the basic list of modules that should be loaded into each + * requisition instance whether server side or client side + */ +exports.baseModules = [ + "gcli/types/delegate", + "gcli/types/selection", + "gcli/types/array", + + "gcli/types/boolean", + "gcli/types/command", + "gcli/types/date", + "gcli/types/file", + "gcli/types/javascript", + "gcli/types/node", + "gcli/types/number", + "gcli/types/resource", + "gcli/types/setting", + "gcli/types/string", + "gcli/types/union", + "gcli/types/url", + + "gcli/fields/fields", + "gcli/fields/delegate", + "gcli/fields/selection", + + "gcli/ui/focus", + "gcli/ui/intro", + + "gcli/converters/converters", + "gcli/converters/basic", + "gcli/converters/terminal", + + "gcli/languages/command", + "gcli/languages/javascript", + + "gcli/commands/clear", + "gcli/commands/context", + "gcli/commands/help", + "gcli/commands/pref", +]; + +/** + * Some commands belong to a tool (see getToolModules). This is a list of the + * modules that are *not* owned by a tool. + */ +exports.devtoolsModules = [ + "devtools/shared/gcli/commands/addon", + "devtools/shared/gcli/commands/appcache", + "devtools/shared/gcli/commands/calllog", + "devtools/shared/gcli/commands/cmd", + "devtools/shared/gcli/commands/cookie", + "devtools/shared/gcli/commands/csscoverage", + "devtools/shared/gcli/commands/folder", + "devtools/shared/gcli/commands/highlight", + "devtools/shared/gcli/commands/inject", + "devtools/shared/gcli/commands/jsb", + "devtools/shared/gcli/commands/listen", + "devtools/shared/gcli/commands/mdn", + "devtools/shared/gcli/commands/measure", + "devtools/shared/gcli/commands/media", + "devtools/shared/gcli/commands/pagemod", + "devtools/shared/gcli/commands/paintflashing", + "devtools/shared/gcli/commands/qsa", + "devtools/shared/gcli/commands/restart", + "devtools/shared/gcli/commands/rulers", + "devtools/shared/gcli/commands/screenshot", + "devtools/shared/gcli/commands/security", +]; + +/** + * Register commands from tools with 'command: [ "some/module" ]' definitions. + * The map/reduce incantation squashes the array of arrays to a single array. + */ +try { + const { defaultTools } = require("devtools/client/definitions"); + exports.devtoolsToolModules = defaultTools.map(def => def.commands || []) + .reduce((prev, curr) => prev.concat(curr), []); +} catch (e) { + // "devtools/client/definitions" is only accessible from Firefox + exports.devtoolsToolModules = []; +} + +/** + * Register commands from toolbox buttons with 'command: [ "some/module" ]' + * definitions. The map/reduce incantation squashes the array of arrays to a + * single array. + */ +try { + const { ToolboxButtons } = require("devtools/client/definitions"); + exports.devtoolsButtonModules = ToolboxButtons.map(def => def.commands || []) + .reduce((prev, curr) => prev.concat(curr), []); +} catch (e) { + // "devtools/client/definitions" is only accessible from Firefox + exports.devtoolsButtonModules = []; +} + +/** + * Add modules to a system for use in a content process (but don't call load) + */ +exports.addAllItemsByModule = function(system) { + system.addItemsByModule(exports.baseModules, { delayedLoad: true }); + system.addItemsByModule(exports.devtoolsModules, { delayedLoad: true }); + system.addItemsByModule(exports.devtoolsToolModules, { delayedLoad: true }); + system.addItemsByModule(exports.devtoolsButtonModules, { delayedLoad: true }); + + const { mozDirLoader } = require("devtools/shared/gcli/commands/cmd"); + system.addItemsByModule("mozcmd", { delayedLoad: true, loader: mozDirLoader }); +}; + +/** + * This is WeakMap<Target, Links> where Links is an object that looks like + * { refs: number, promise: Promise<System>, front: GcliFront } + */ +var linksForTarget = new WeakMap(); + +/** + * The toolbox uses the following properties on a command to allow it to be + * added to the toolbox toolbar + */ +var customProperties = [ "buttonId", "buttonClass", "tooltipText" ]; + +/** + * Create a system which connects to a GCLI in a remote target + * @return Promise<System> for the given target + */ +exports.getSystem = function(target) { + const existingLinks = linksForTarget.get(target); + if (existingLinks != null) { + existingLinks.refs++; + return existingLinks.promise; + } + + const system = createSystem({ location: "client" }); + + exports.addAllItemsByModule(system); + + // Load the client system + const links = { + refs: 1, + system, + promise: system.load().then(() => { + return GcliFront.create(target).then(front => { + links.front = front; + return connectFront(system, front, customProperties).then(() => system); + }); + }) + }; + + linksForTarget.set(target, links); + return links.promise; +}; + +/** + * Someone that called getSystem doesn't need it any more, so decrement the + * count of users of the system for that target, and destroy if needed + */ +exports.releaseSystem = function(target) { + const links = linksForTarget.get(target); + if (links == null) { + throw new Error("releaseSystem called for unknown target"); + } + + links.refs--; + if (links.refs === 0) { + disconnectFront(links.system, links.front); + links.system.destroy(); + linksForTarget.delete(target); + } +}; diff --git a/devtools/shared/gcli/commands/inject.js b/devtools/shared/gcli/commands/inject.js new file mode 100644 index 000000000..85e995eed --- /dev/null +++ b/devtools/shared/gcli/commands/inject.js @@ -0,0 +1,86 @@ +/* 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 Services = require("Services"); +const { listenOnce } = require("devtools/shared/async-utils"); +const l10n = require("gcli/l10n"); + +exports.items = [ + { + item: "command", + runAt: "server", + name: "inject", + description: l10n.lookup("injectDesc"), + manual: l10n.lookup("injectManual2"), + params: [{ + name: "library", + type: { + name: "union", + alternatives: [ + { + name: "selection", + lookup: [ + { + name: "jQuery", + value: { + name: "jQuery", + src: Services.prefs.getCharPref("devtools.gcli.jquerySrc") + } + }, + { + name: "lodash", + value: { + name: "lodash", + src: Services.prefs.getCharPref("devtools.gcli.lodashSrc") + } + }, + { + name: "underscore", + value: { + name: "underscore", + src: Services.prefs.getCharPref("devtools.gcli.underscoreSrc") + } + } + ] + }, + { + name: "url" + } + ] + }, + description: l10n.lookup("injectLibraryDesc") + }], + exec: function*(args, context) { + let document = context.environment.document; + let library = args.library; + let name = (library.type === "selection") ? + library.selection.name : library.url; + let src = (library.type === "selection") ? + library.selection.src : library.url; + + if (context.environment.window.location.protocol == "https:") { + src = src.replace(/^http:/, "https:"); + } + + try { + // Check if URI is valid + Services.io.newURI(src, null, null); + } catch(e) { + return l10n.lookupFormat("injectFailed", [name]); + } + + let newSource = document.createElement("script"); + newSource.setAttribute("src", src); + + let loadPromise = listenOnce(newSource, "load"); + document.head.appendChild(newSource); + + yield loadPromise; + + return l10n.lookupFormat("injectLoaded", [name]); + } + } +]; diff --git a/devtools/shared/gcli/commands/jsb.js b/devtools/shared/gcli/commands/jsb.js new file mode 100644 index 000000000..b56e079d2 --- /dev/null +++ b/devtools/shared/gcli/commands/jsb.js @@ -0,0 +1,134 @@ +/* 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 l10n = require("gcli/l10n"); +const XMLHttpRequest = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]; + +loader.lazyImporter(this, "Preferences", "resource://gre/modules/Preferences.jsm"); +loader.lazyImporter(this, "ScratchpadManager", "resource://devtools/client/scratchpad/scratchpad-manager.jsm"); + +loader.lazyRequireGetter(this, "beautify", "devtools/shared/jsbeautify/beautify"); + +exports.items = [ + { + item: "command", + runAt: "client", + name: "jsb", + description: l10n.lookup("jsbDesc"), + returnValue:"string", + params: [ + { + name: "url", + type: "string", + description: l10n.lookup("jsbUrlDesc") + }, + { + group: l10n.lookup("jsbOptionsDesc"), + params: [ + { + name: "indentSize", + type: "number", + description: l10n.lookup("jsbIndentSizeDesc"), + manual: l10n.lookup("jsbIndentSizeManual"), + defaultValue: Preferences.get("devtools.editor.tabsize", 2), + }, + { + name: "indentChar", + type: { + name: "selection", + lookup: [ + { name: "space", value: " " }, + { name: "tab", value: "\t" } + ] + }, + description: l10n.lookup("jsbIndentCharDesc"), + manual: l10n.lookup("jsbIndentCharManual"), + defaultValue: " ", + }, + { + name: "doNotPreserveNewlines", + type: "boolean", + description: l10n.lookup("jsbDoNotPreserveNewlinesDesc") + }, + { + name: "preserveMaxNewlines", + type: "number", + description: l10n.lookup("jsbPreserveMaxNewlinesDesc"), + manual: l10n.lookup("jsbPreserveMaxNewlinesManual"), + defaultValue: -1 + }, + { + name: "jslintHappy", + type: "boolean", + description: l10n.lookup("jsbJslintHappyDesc"), + manual: l10n.lookup("jsbJslintHappyManual") + }, + { + name: "braceStyle", + type: { + name: "selection", + data: ["collapse", "expand", "end-expand", "expand-strict"] + }, + description: l10n.lookup("jsbBraceStyleDesc2"), + manual: l10n.lookup("jsbBraceStyleManual2"), + defaultValue: "collapse" + }, + { + name: "noSpaceBeforeConditional", + type: "boolean", + description: l10n.lookup("jsbNoSpaceBeforeConditionalDesc") + }, + { + name: "unescapeStrings", + type: "boolean", + description: l10n.lookup("jsbUnescapeStringsDesc"), + manual: l10n.lookup("jsbUnescapeStringsManual") + } + ] + } + ], + exec: function(args, context) { + let opts = { + indent_size: args.indentSize, + indent_char: args.indentChar, + preserve_newlines: !args.doNotPreserveNewlines, + max_preserve_newlines: args.preserveMaxNewlines == -1 ? + undefined : args.preserveMaxNewlines, + jslint_happy: args.jslintHappy, + brace_style: args.braceStyle, + space_before_conditional: !args.noSpaceBeforeConditional, + unescape_strings: args.unescapeStrings + }; + + let xhr = new XMLHttpRequest(); + + let deferred = context.defer(); + + xhr.onreadystatechange = function() { + if (xhr.readyState == 4) { + if (xhr.status == 200 || xhr.status == 0) { + let result = beautify.js(xhr.responseText, opts); + + ScratchpadManager.openScratchpad({text: result}); + + deferred.resolve(); + } else { + deferred.reject("Unable to load page to beautify: " + args.url + " " + + xhr.status + " " + xhr.statusText); + } + }; + } + try { + xhr.open("GET", args.url, true); + xhr.send(null); + } catch(e) { + return l10n.lookup("jsbInvalidURL"); + } + return deferred.promise; + } + } +]; diff --git a/devtools/shared/gcli/commands/listen.js b/devtools/shared/gcli/commands/listen.js new file mode 100644 index 000000000..7878577fb --- /dev/null +++ b/devtools/shared/gcli/commands/listen.js @@ -0,0 +1,106 @@ +/* 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 } = require("chrome"); +const Services = require("Services"); +const l10n = require("gcli/l10n"); +const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "DevToolsLoader", + "resource://devtools/shared/Loader.jsm"); + +const BRAND_SHORT_NAME = Cc["@mozilla.org/intl/stringbundle;1"] + .getService(Ci.nsIStringBundleService) + .createBundle("chrome://branding/locale/brand.properties") + .GetStringFromName("brandShortName"); + +XPCOMUtils.defineLazyGetter(this, "debuggerServer", () => { + // Create a separate loader instance, so that we can be sure to receive + // a separate instance of the DebuggingServer from the rest of the + // devtools. This allows us to safely use the tools against even the + // actors and DebuggingServer itself, especially since we can mark + // serverLoader as invisible to the debugger (unlike the usual loader + // settings). + let serverLoader = new DevToolsLoader(); + serverLoader.invisibleToDebugger = true; + let { DebuggerServer: debuggerServer } = serverLoader.require("devtools/server/main"); + debuggerServer.init(); + debuggerServer.addBrowserActors(); + debuggerServer.allowChromeProcess = !l10n.hiddenByChromePref(); + return debuggerServer; +}); + +exports.items = [ + { + item: "command", + runAt: "client", + name: "listen", + description: l10n.lookup("listenDesc"), + manual: l10n.lookupFormat("listenManual2", [ BRAND_SHORT_NAME ]), + params: [ + { + name: "port", + type: "number", + get defaultValue() { + return Services.prefs.getIntPref("devtools.debugger.remote-port"); + }, + description: l10n.lookup("listenPortDesc"), + }, + { + name: "protocol", + get defaultValue() { + let webSocket = Services.prefs + .getBoolPref("devtools.debugger.remote-websocket"); + let protocol; + if (webSocket === true) { + protocol = "websocket"; + } else { + protocol = "mozilla-rdp"; + } + return protocol; + }, + type: { + name: "selection", + data: [ "mozilla-rdp", "websocket"], + }, + description: l10n.lookup("listenProtocolDesc"), + }, + ], + exec: function (args, context) { + var listener = debuggerServer.createListener(); + if (!listener) { + throw new Error(l10n.lookup("listenDisabledOutput")); + } + + let webSocket = false; + if (args.protocol === "websocket") { + webSocket = true; + } else if (args.protocol === "mozilla-rdp") { + webSocket = false; + } + + listener.portOrPath = args.port; + listener.webSocket = webSocket; + listener.open(); + + if (debuggerServer.initialized) { + return l10n.lookupFormat("listenInitOutput", [ "" + args.port ]); + } + + return l10n.lookup("listenNoInitOutput"); + }, + }, + { + item: "command", + runAt: "client", + name: "unlisten", + description: l10n.lookup("unlistenDesc"), + manual: l10n.lookup("unlistenManual"), + exec: function (args, context) { + debuggerServer.closeAllListeners(); + return l10n.lookup("unlistenOutput"); + } + } +]; diff --git a/devtools/shared/gcli/commands/mdn.js b/devtools/shared/gcli/commands/mdn.js new file mode 100644 index 000000000..57e582e40 --- /dev/null +++ b/devtools/shared/gcli/commands/mdn.js @@ -0,0 +1,83 @@ +/* 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 l10n = require("gcli/l10n"); + +var MdnDocsWidget; +try { + MdnDocsWidget = require("devtools/client/shared/widgets/MdnDocsWidget"); +} catch (e) { + // DevTools MdnDocsWidget only available in Firefox Desktop +} + +exports.items = [{ + name: "mdn", + description: l10n.lookup("mdnDesc") +}, { + item: "command", + runAt: "client", + name: "mdn css", + description: l10n.lookup("mdnCssDesc"), + returnType: "cssPropertyOutput", + params: [{ + name: "property", + type: { name: "string" }, + defaultValue: null, + description: l10n.lookup("mdnCssProp") + }], + exec: function(args) { + if (!MdnDocsWidget) { + return null; + } + + return MdnDocsWidget.getCssDocs(args.property).then(result => { + return { + data: result, + url: MdnDocsWidget.PAGE_LINK_URL + args.property, + property: args.property + }; + }, error => { + return { error, property: args.property }; + }); + } +}, { + item: "converter", + from: "cssPropertyOutput", + to: "dom", + exec: function(result, context) { + let propertyName = result.property; + + let document = context.document; + let root = document.createElement("div"); + + if (result.error) { + // The css property specified doesn't exist. + root.appendChild(document.createTextNode( + l10n.lookupFormat("mdnCssPropertyNotFound", [ propertyName ]) + + " (" + result.error + ")")); + } else { + let title = document.createElement("h2"); + title.textContent = propertyName; + root.appendChild(title); + + let link = document.createElement("p"); + link.classList.add("gcli-mdn-url"); + link.textContent = l10n.lookup("mdnCssVisitPage"); + root.appendChild(link); + + link.addEventListener("click", () => { + let mainWindow = context.environment.chromeWindow; + mainWindow.openUILinkIn(result.url, "tab"); + }); + + let summary = document.createElement("p"); + summary.textContent = result.data.summary; + root.appendChild(summary); + } + + return root; + } +}]; diff --git a/devtools/shared/gcli/commands/measure.js b/devtools/shared/gcli/commands/measure.js new file mode 100644 index 000000000..7f6233a95 --- /dev/null +++ b/devtools/shared/gcli/commands/measure.js @@ -0,0 +1,112 @@ +/* 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/. */ + /* globals getOuterId, getBrowserForTab */ + +"use strict"; + +const EventEmitter = require("devtools/shared/event-emitter"); +const eventEmitter = new EventEmitter(); +const events = require("sdk/event/core"); + +loader.lazyRequireGetter(this, "getOuterId", "sdk/window/utils", true); +loader.lazyRequireGetter(this, "getBrowserForTab", "sdk/tabs/utils", true); + +const l10n = require("gcli/l10n"); +require("devtools/server/actors/inspector"); +const { MeasuringToolHighlighter, HighlighterEnvironment } = + require("devtools/server/actors/highlighters"); + +const highlighters = new WeakMap(); +const visibleHighlighters = new Set(); + +const isCheckedFor = (tab) => + tab ? visibleHighlighters.has(getBrowserForTab(tab).outerWindowID) : false; + +exports.items = [ + // The client measure command is used to maintain the toolbar button state + // only and redirects to the server command to actually toggle the measuring + // tool (see `measure_server` below). + { + name: "measure", + runAt: "client", + description: l10n.lookup("measureDesc"), + manual: l10n.lookup("measureManual"), + buttonId: "command-button-measure", + buttonClass: "command-button command-button-invertable", + tooltipText: l10n.lookup("measureTooltip"), + state: { + isChecked: ({_tab}) => isCheckedFor(_tab), + onChange: (target, handler) => eventEmitter.on("changed", handler), + offChange: (target, handler) => eventEmitter.off("changed", handler) + }, + exec: function*(args, context) { + let { target } = context.environment; + + // Pipe the call to the server command. + let response = yield context.updateExec("measure_server"); + let { visible, id } = response.data; + + if (visible) { + visibleHighlighters.add(id); + } else { + visibleHighlighters.delete(id); + } + + eventEmitter.emit("changed", { target }); + + // Toggle off the button when the page navigates because the measuring + // tool is removed automatically by the MeasuringToolHighlighter on the + // server then. + let onNavigate = () => { + visibleHighlighters.delete(id); + eventEmitter.emit("changed", { target }); + }; + target.off("will-navigate", onNavigate); + target.once("will-navigate", onNavigate); + } + }, + // The server measure command is hidden by default, it's just used by the + // client command. + { + name: "measure_server", + runAt: "server", + hidden: true, + returnType: "highlighterVisibility", + exec: function(args, context) { + let env = context.environment; + let { document } = env; + let id = getOuterId(env.window); + + // Calling the command again after the measuring tool has been shown once, + // hides it. + if (highlighters.has(document)) { + let { highlighter } = highlighters.get(document); + highlighter.destroy(); + return {visible: false, id}; + } + + // Otherwise, display the measuring tool. + let environment = new HighlighterEnvironment(); + environment.initFromWindow(env.window); + let highlighter = new MeasuringToolHighlighter(environment); + + // Store the instance of the measuring tool highlighter for this document + // so we can hide it later. + highlighters.set(document, { highlighter, environment }); + + // Listen to the highlighter's destroy event which may happen if the + // window is refreshed or closed with the measuring tool shown. + events.once(highlighter, "destroy", () => { + if (highlighters.has(document)) { + let { environment } = highlighters.get(document); + environment.destroy(); + highlighters.delete(document); + } + }); + + highlighter.show(); + return {visible: true, id}; + } + } +]; diff --git a/devtools/shared/gcli/commands/media.js b/devtools/shared/gcli/commands/media.js new file mode 100644 index 000000000..908e9eb5e --- /dev/null +++ b/devtools/shared/gcli/commands/media.js @@ -0,0 +1,56 @@ +/* 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} = require("chrome"); +const l10n = require("gcli/l10n"); + +function getContentViewer(context) { + let {window} = context.environment; + return window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell) + .contentViewer; +} + +exports.items = [ + { + name: "media", + description: l10n.lookup("mediaDesc") + }, + { + item: "command", + runAt: "server", + name: "media emulate", + description: l10n.lookup("mediaEmulateDesc"), + manual: l10n.lookup("mediaEmulateManual"), + params: [ + { + name: "type", + description: l10n.lookup("mediaEmulateType"), + type: { + name: "selection", + data: [ + "braille", "embossed", "handheld", "print", "projection", + "screen", "speech", "tty", "tv" + ] + } + } + ], + exec: function(args, context) { + let contentViewer = getContentViewer(context); + contentViewer.emulateMedium(args.type); + } + }, + { + item: "command", + runAt: "server", + name: "media reset", + description: l10n.lookup("mediaResetDesc"), + exec: function(args, context) { + let contentViewer = getContentViewer(context); + contentViewer.stopEmulatingMedium(); + } + } +]; diff --git a/devtools/shared/gcli/commands/moz.build b/devtools/shared/gcli/commands/moz.build new file mode 100644 index 000000000..e5f3fe91c --- /dev/null +++ b/devtools/shared/gcli/commands/moz.build @@ -0,0 +1,30 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# 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( + 'addon.js', + 'appcache.js', + 'calllog.js', + 'cmd.js', + 'cookie.js', + 'csscoverage.js', + 'folder.js', + 'highlight.js', + 'index.js', + 'inject.js', + 'jsb.js', + 'listen.js', + 'mdn.js', + 'measure.js', + 'media.js', + 'pagemod.js', + 'paintflashing.js', + 'qsa.js', + 'restart.js', + 'rulers.js', + 'screenshot.js', + 'security.js', +) diff --git a/devtools/shared/gcli/commands/pagemod.js b/devtools/shared/gcli/commands/pagemod.js new file mode 100644 index 000000000..184ab1ea3 --- /dev/null +++ b/devtools/shared/gcli/commands/pagemod.js @@ -0,0 +1,276 @@ +/* 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 l10n = require("gcli/l10n"); + +exports.items = [ + { + name: "pagemod", + description: l10n.lookup("pagemodDesc"), + }, + { + item: "command", + runAt: "server", + name: "pagemod replace", + description: l10n.lookup("pagemodReplaceDesc"), + params: [ + { + name: "search", + type: "string", + description: l10n.lookup("pagemodReplaceSearchDesc"), + }, + { + name: "replace", + type: "string", + description: l10n.lookup("pagemodReplaceReplaceDesc"), + }, + { + name: "ignoreCase", + type: "boolean", + description: l10n.lookup("pagemodReplaceIgnoreCaseDesc"), + }, + { + name: "selector", + type: "string", + description: l10n.lookup("pagemodReplaceSelectorDesc"), + defaultValue: "*:not(script):not(style):not(embed):not(object):not(frame):not(iframe):not(frameset)", + }, + { + name: "root", + type: "node", + description: l10n.lookup("pagemodReplaceRootDesc"), + defaultValue: null, + }, + { + name: "attrOnly", + type: "boolean", + description: l10n.lookup("pagemodReplaceAttrOnlyDesc"), + }, + { + name: "contentOnly", + type: "boolean", + description: l10n.lookup("pagemodReplaceContentOnlyDesc"), + }, + { + name: "attributes", + type: "string", + description: l10n.lookup("pagemodReplaceAttributesDesc"), + defaultValue: null, + }, + ], + // Make a given string safe to use in a regular expression. + escapeRegex: function(aString) { + return aString.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); + }, + exec: function(args, context) { + let searchTextNodes = !args.attrOnly; + let searchAttributes = !args.contentOnly; + let regexOptions = args.ignoreCase ? "ig" : "g"; + let search = new RegExp(this.escapeRegex(args.search), regexOptions); + let attributeRegex = null; + if (args.attributes) { + attributeRegex = new RegExp(args.attributes, regexOptions); + } + + let root = args.root || context.environment.document; + let elements = root.querySelectorAll(args.selector); + elements = Array.prototype.slice.call(elements); + + let replacedTextNodes = 0; + let replacedAttributes = 0; + + function replaceAttribute() { + replacedAttributes++; + return args.replace; + } + function replaceTextNode() { + replacedTextNodes++; + return args.replace; + } + + for (let i = 0; i < elements.length; i++) { + let element = elements[i]; + if (searchTextNodes) { + for (let y = 0; y < element.childNodes.length; y++) { + let node = element.childNodes[y]; + if (node.nodeType == node.TEXT_NODE) { + node.textContent = node.textContent.replace(search, replaceTextNode); + } + } + } + + if (searchAttributes) { + if (!element.attributes) { + continue; + } + for (let y = 0; y < element.attributes.length; y++) { + let attr = element.attributes[y]; + if (!attributeRegex || attributeRegex.test(attr.name)) { + attr.value = attr.value.replace(search, replaceAttribute); + } + } + } + } + + return l10n.lookupFormat("pagemodReplaceResult", + [elements.length, replacedTextNodes, + replacedAttributes]); + } + }, + { + name: "pagemod remove", + description: l10n.lookup("pagemodRemoveDesc"), + }, + { + item: "command", + runAt: "server", + name: "pagemod remove element", + description: l10n.lookup("pagemodRemoveElementDesc"), + params: [ + { + name: "search", + type: "string", + description: l10n.lookup("pagemodRemoveElementSearchDesc"), + }, + { + name: "root", + type: "node", + description: l10n.lookup("pagemodRemoveElementRootDesc"), + defaultValue: null, + }, + { + name: "stripOnly", + type: "boolean", + description: l10n.lookup("pagemodRemoveElementStripOnlyDesc"), + }, + { + name: "ifEmptyOnly", + type: "boolean", + description: l10n.lookup("pagemodRemoveElementIfEmptyOnlyDesc"), + }, + ], + exec: function(args, context) { + let root = args.root || context.environment.document; + let elements = Array.prototype.slice.call(root.querySelectorAll(args.search)); + + let removed = 0; + for (let i = 0; i < elements.length; i++) { + let element = elements[i]; + let parentNode = element.parentNode; + if (!parentNode || !element.removeChild) { + continue; + } + if (args.stripOnly) { + while (element.hasChildNodes()) { + parentNode.insertBefore(element.childNodes[0], element); + } + } + if (!args.ifEmptyOnly || !element.hasChildNodes()) { + element.parentNode.removeChild(element); + removed++; + } + } + + return l10n.lookupFormat("pagemodRemoveElementResultMatchedAndRemovedElements", + [elements.length, removed]); + } + }, + { + item: "command", + runAt: "server", + name: "pagemod remove attribute", + description: l10n.lookup("pagemodRemoveAttributeDesc"), + params: [ + { + name: "searchAttributes", + type: "string", + description: l10n.lookup("pagemodRemoveAttributeSearchAttributesDesc"), + }, + { + name: "searchElements", + type: "string", + description: l10n.lookup("pagemodRemoveAttributeSearchElementsDesc"), + }, + { + name: "root", + type: "node", + description: l10n.lookup("pagemodRemoveAttributeRootDesc"), + defaultValue: null, + }, + { + name: "ignoreCase", + type: "boolean", + description: l10n.lookup("pagemodRemoveAttributeIgnoreCaseDesc"), + }, + ], + exec: function(args, context) { + let root = args.root || context.environment.document; + let regexOptions = args.ignoreCase ? "ig" : "g"; + let attributeRegex = new RegExp(args.searchAttributes, regexOptions); + let elements = root.querySelectorAll(args.searchElements); + elements = Array.prototype.slice.call(elements); + + let removed = 0; + for (let i = 0; i < elements.length; i++) { + let element = elements[i]; + if (!element.attributes) { + continue; + } + + var attrs = Array.prototype.slice.call(element.attributes); + for (let y = 0; y < attrs.length; y++) { + let attr = attrs[y]; + if (attributeRegex.test(attr.name)) { + element.removeAttribute(attr.name); + removed++; + } + } + } + + return l10n.lookupFormat("pagemodRemoveAttributeResult", + [elements.length, removed]); + } + }, + // This command allows the user to export the page to HTML after DOM changes + { + name: "export", + description: l10n.lookup("exportDesc"), + }, + { + item: "command", + runAt: "server", + name: "export html", + description: l10n.lookup("exportHtmlDesc"), + params: [ + { + name: "destination", + type: { + name: "selection", + data: [ "window", "stdout", "clipboard" ] + }, + defaultValue: "window" + } + ], + exec: function(args, context) { + let html = context.environment.document.documentElement.outerHTML; + if (args.destination === "stdout") { + return html; + } + + if (args.desination === "clipboard") { + let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"] + .getService(Ci.nsIClipboardHelper); + clipboard.copyString(url); + return ''; + } + + let url = "data:text/plain;charset=utf8," + encodeURIComponent(html); + context.environment.window.open(url); + return ''; + } + } +]; diff --git a/devtools/shared/gcli/commands/paintflashing.js b/devtools/shared/gcli/commands/paintflashing.js new file mode 100644 index 000000000..7e21911a7 --- /dev/null +++ b/devtools/shared/gcli/commands/paintflashing.js @@ -0,0 +1,201 @@ +/* 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 } = require("chrome"); +loader.lazyRequireGetter(this, "getOuterId", "sdk/window/utils", true); +loader.lazyRequireGetter(this, "getBrowserForTab", "sdk/tabs/utils", true); + +var telemetry; +try { + const Telemetry = require("devtools/client/shared/telemetry"); + telemetry = new Telemetry(); +} catch(e) { + // DevTools Telemetry module only available in Firefox +} + +const EventEmitter = require("devtools/shared/event-emitter"); +const eventEmitter = new EventEmitter(); + +const gcli = require("gcli/index"); +const l10n = require("gcli/l10n"); + +const enabledPaintFlashing = new Set(); + +const isCheckedFor = (tab) => + tab ? enabledPaintFlashing.has(getBrowserForTab(tab).outerWindowID) : false; + +/** + * Fire events and telemetry when paintFlashing happens + */ +function onPaintFlashingChanged(target, state) { + const { flashing, id } = state; + + if (flashing) { + enabledPaintFlashing.add(id); + } else { + enabledPaintFlashing.delete(id); + } + + eventEmitter.emit("changed", { target: target }); + function fireChange() { + eventEmitter.emit("changed", { target: target }); + } + + target.off("navigate", fireChange); + target.once("navigate", fireChange); + + if (!telemetry) { + return; + } + if (flashing) { + telemetry.toolOpened("paintflashing"); + } else { + telemetry.toolClosed("paintflashing"); + } +} + +/** + * Alter the paintFlashing state of a window and report on the new value. + * This works with chrome or content windows. + * + * This is a bizarre method that you could argue should be broken up into + * separate getter and setter functions, however keeping it as one helps + * to simplify the commands below. + * + * @param state {string} One of: + * - "on" which does window.paintFlashing = true + * - "off" which does window.paintFlashing = false + * - "toggle" which does window.paintFlashing = !window.paintFlashing + * - "query" which does nothing + * @return The new value of the window.paintFlashing flag + */ +function setPaintFlashing(window, state) { + const winUtils = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + + if (!["on", "off", "toggle", "query"].includes(state)) { + throw new Error(`Unsupported state: ${state}`); + } + + if (state === "on") { + winUtils.paintFlashing = true; + } else if (state === "off") { + winUtils.paintFlashing = false; + } else if (state === "toggle") { + winUtils.paintFlashing = !winUtils.paintFlashing; + } + + return winUtils.paintFlashing; +} + +exports.items = [ + { + name: "paintflashing", + description: l10n.lookup("paintflashingDesc") + }, + { + item: "command", + runAt: "client", + name: "paintflashing on", + description: l10n.lookup("paintflashingOnDesc"), + manual: l10n.lookup("paintflashingManual"), + params: [{ + group: "options", + params: [ + { + type: "boolean", + name: "chrome", + get hidden() { + return gcli.hiddenByChromePref(); + }, + description: l10n.lookup("paintflashingChromeDesc"), + } + ] + }], + exec: function*(args, context) { + if (!args.chrome) { + const output = yield context.updateExec("paintflashing_server --state on"); + + onPaintFlashingChanged(context.environment.target, output.data); + } else { + setPaintFlashing(context.environment.chromeWindow, "on"); + } + } + }, + { + item: "command", + runAt: "client", + name: "paintflashing off", + description: l10n.lookup("paintflashingOffDesc"), + manual: l10n.lookup("paintflashingManual"), + params: [{ + group: "options", + params: [ + { + type: "boolean", + name: "chrome", + get hidden() { + return gcli.hiddenByChromePref(); + }, + description: l10n.lookup("paintflashingChromeDesc"), + } + ] + }], + exec: function*(args, context) { + if (!args.chrome) { + const output = yield context.updateExec("paintflashing_server --state off"); + + onPaintFlashingChanged(context.environment.target, output.data); + } else { + setPaintFlashing(context.environment.chromeWindow, "off"); + } + } + }, + { + item: "command", + runAt: "client", + name: "paintflashing toggle", + hidden: true, + buttonId: "command-button-paintflashing", + buttonClass: "command-button command-button-invertable", + state: { + isChecked: ({_tab}) => isCheckedFor(_tab), + onChange: (_, handler) => eventEmitter.on("changed", handler), + offChange: (_, handler) => eventEmitter.off("changed", handler), + }, + tooltipText: l10n.lookup("paintflashingTooltip"), + description: l10n.lookup("paintflashingToggleDesc"), + manual: l10n.lookup("paintflashingManual"), + exec: function*(args, context) { + const output = yield context.updateExec("paintflashing_server --state toggle"); + + onPaintFlashingChanged(context.environment.target, output.data); + } + }, + { + item: "command", + runAt: "server", + name: "paintflashing_server", + hidden: true, + params: [ + { + name: "state", + type: { + name: "selection", + data: [ "on", "off", "toggle", "query" ] + } + }, + ], + returnType: "paintFlashingState", + exec: function(args, context) { + let { window } = context.environment; + let id = getOuterId(window); + let flashing = setPaintFlashing(window, args.state); + + return { flashing, id }; + } + } +]; diff --git a/devtools/shared/gcli/commands/qsa.js b/devtools/shared/gcli/commands/qsa.js new file mode 100644 index 000000000..939991f18 --- /dev/null +++ b/devtools/shared/gcli/commands/qsa.js @@ -0,0 +1,24 @@ +/* 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 l10n = require("gcli/l10n");
+
+exports.items = [
+ {
+ item: "command",
+ runAt: "server",
+ name: "qsa",
+ description: l10n.lookup("qsaDesc"),
+ params: [{
+ name: "query",
+ type: "nodelist",
+ description: l10n.lookup("qsaQueryDesc")
+ }],
+ exec: function(args, context) {
+ return args.query.length;
+ }
+ }
+];
diff --git a/devtools/shared/gcli/commands/restart.js b/devtools/shared/gcli/commands/restart.js new file mode 100644 index 000000000..cf0e688d3 --- /dev/null +++ b/devtools/shared/gcli/commands/restart.js @@ -0,0 +1,77 @@ +/* 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 l10n = require("gcli/l10n"); +const Services = require("Services"); + +const BRAND_SHORT_NAME = Cc["@mozilla.org/intl/stringbundle;1"] + .getService(Ci.nsIStringBundleService) + .createBundle("chrome://branding/locale/brand.properties") + .GetStringFromName("brandShortName"); + +/** + * Restart command + * + * @param boolean nocache + * Disables loading content from cache upon restart. + * + * Examples : + * >> restart + * - restarts browser immediately + * >> restart --nocache + * - restarts immediately and starts Firefox without using cache + */ +exports.items = [ + { + item: "command", + runAt: "client", + name: "restart", + description: l10n.lookupFormat("restartBrowserDesc", [ BRAND_SHORT_NAME ]), + params: [{ + group: l10n.lookup("restartBrowserGroupOptions"), + params: [ + { + name: "nocache", + type: "boolean", + description: l10n.lookup("restartBrowserNocacheDesc") + }, + { + name: "safemode", + type: "boolean", + description: l10n.lookup("restartBrowserSafemodeDesc") + } + ] + }], + returnType: "string", + exec: function Restart(args, context) { + let canceled = Cc["@mozilla.org/supports-PRBool;1"] + .createInstance(Ci.nsISupportsPRBool); + Services.obs.notifyObservers(canceled, "quit-application-requested", "restart"); + if (canceled.data) { + return l10n.lookup("restartBrowserRequestCancelled"); + } + + // disable loading content from cache. + if (args.nocache) { + Services.appinfo.invalidateCachesOnRestart(); + } + + const appStartup = Cc["@mozilla.org/toolkit/app-startup;1"] + .getService(Ci.nsIAppStartup); + + if (args.safemode) { + // restart in safemode + appStartup.restartInSafeMode(Ci.nsIAppStartup.eAttemptQuit); + } else { + // restart normally + appStartup.quit(Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart); + } + + return l10n.lookupFormat("restartBrowserRestarting", [ BRAND_SHORT_NAME ]); + } + } +]; diff --git a/devtools/shared/gcli/commands/rulers.js b/devtools/shared/gcli/commands/rulers.js new file mode 100644 index 000000000..121e975bc --- /dev/null +++ b/devtools/shared/gcli/commands/rulers.js @@ -0,0 +1,110 @@ +/* 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/. */ +/* globals getBrowserForTab */ + +"use strict"; + +const EventEmitter = require("devtools/shared/event-emitter"); +const eventEmitter = new EventEmitter(); +const events = require("sdk/event/core"); +loader.lazyRequireGetter(this, "getOuterId", "sdk/window/utils", true); +loader.lazyRequireGetter(this, "getBrowserForTab", "sdk/tabs/utils", true); + +const l10n = require("gcli/l10n"); +require("devtools/server/actors/inspector"); +const { RulersHighlighter, HighlighterEnvironment } = + require("devtools/server/actors/highlighters"); + +const highlighters = new WeakMap(); +const visibleHighlighters = new Set(); + +const isCheckedFor = (tab) => + tab ? visibleHighlighters.has(getBrowserForTab(tab).outerWindowID) : false; + +exports.items = [ + // The client rulers command is used to maintain the toolbar button state only + // and redirects to the server command to actually toggle the rulers (see + // rulers_server below). + { + name: "rulers", + runAt: "client", + description: l10n.lookup("rulersDesc"), + manual: l10n.lookup("rulersManual"), + buttonId: "command-button-rulers", + buttonClass: "command-button command-button-invertable", + tooltipText: l10n.lookup("rulersTooltip"), + state: { + isChecked: ({_tab}) => isCheckedFor(_tab), + onChange: (target, handler) => eventEmitter.on("changed", handler), + offChange: (target, handler) => eventEmitter.off("changed", handler) + }, + exec: function*(args, context) { + let { target } = context.environment; + + // Pipe the call to the server command. + let response = yield context.updateExec("rulers_server"); + let { visible, id } = response.data; + + if (visible) { + visibleHighlighters.add(id); + } else { + visibleHighlighters.delete(id); + } + + eventEmitter.emit("changed", { target }); + + // Toggle off the button when the page navigates because the rulers are + // removed automatically by the RulersHighlighter on the server then. + let onNavigate = () => { + visibleHighlighters.delete(id); + eventEmitter.emit("changed", { target }); + }; + target.off("will-navigate", onNavigate); + target.once("will-navigate", onNavigate); + } + }, + // The server rulers command is hidden by default, it's just used by the + // client command. + { + name: "rulers_server", + runAt: "server", + hidden: true, + returnType: "highlighterVisibility", + exec: function(args, context) { + let env = context.environment; + let { document } = env; + let id = getOuterId(env.window); + + // Calling the command again after the rulers have been shown once hides + // them. + if (highlighters.has(document)) { + let { highlighter } = highlighters.get(document); + highlighter.destroy(); + return {visible: false, id}; + } + + // Otherwise, display the rulers. + let environment = new HighlighterEnvironment(); + environment.initFromWindow(env.window); + let highlighter = new RulersHighlighter(environment); + + // Store the instance of the rulers highlighter for this document so we + // can hide it later. + highlighters.set(document, { highlighter, environment }); + + // Listen to the highlighter's destroy event which may happen if the + // window is refreshed or closed with the rulers shown. + events.once(highlighter, "destroy", () => { + if (highlighters.has(document)) { + let { environment } = highlighters.get(document); + environment.destroy(); + highlighters.delete(document); + } + }); + + highlighter.show(); + return {visible: true, id}; + } + } +]; diff --git a/devtools/shared/gcli/commands/screenshot.js b/devtools/shared/gcli/commands/screenshot.js new file mode 100644 index 000000000..e2f38b6d9 --- /dev/null +++ b/devtools/shared/gcli/commands/screenshot.js @@ -0,0 +1,579 @@ +/* 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 l10n = require("gcli/l10n"); +const Services = require("Services"); +const { NetUtil } = require("resource://gre/modules/NetUtil.jsm"); +const { getRect } = require("devtools/shared/layout/utils"); +const promise = require("promise"); +const defer = require("devtools/shared/defer"); +const { Task } = require("devtools/shared/task"); + +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"); + +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" +const FILENAME_DEFAULT_VALUE = " "; + +/* + * 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: { + 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 + */ +const standardParams = { + 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", + type: "node", + defaultValue: null, + description: l10n.lookup("inspectNodeDesc"), + manual: l10n.lookup("inspectNodeManual") + } + ] +}; + +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); + 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) { + let mainWindow = context.environment.chromeWindow; + mainWindow.openUILinkIn(imageSummary.href, "tab"); + } else if (imageSummary.filename) { + const file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile); + 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 command-button-invertable", + tooltipText: l10n.lookup("screenshotTooltipPage"), + params: [ + filenameParam, + standardParams, + ], + exec: function (args, context) { + // Re-execute the command on the server + const command = context.typed.replace(/^screenshot/, "screenshot_server"); + let 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, standardParams ], + exec: function (args, context) { + return captureScreenshot(args, context.environment.document); + }, + } +]; + +/** + * This function is called to simulate camera effects + */ +function simulateCameraEffect(document, effect) { + let window = document.defaultView; + if (effect === "shutter") { + 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, 500); + } +} + +/** + * This function simply handles the --delay argument before calling + * createScreenshotData + */ +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); + }); + } + else { + 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); + + 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; + } + + // 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); + } + + 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"; +} + +/** + * 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) { + 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 imgTools = Cc["@mozilla.org/image/tools;1"] + .getService(Ci.imgITools); + + const container = {}; + imgTools.decodeImageData(input, channel.contentType, container); + + const wrapped = Cc["@mozilla.org/supports-interface-pointer;1"] + .createInstance(Ci.nsISupportsInterfacePointer); + wrapped.data = container.value; + + const trans = Cc["@mozilla.org/widget/transferable;1"] + .createInstance(Ci.nsITransferable); + trans.init(loadContext); + trans.addDataFlavor(channel.contentType); + trans.setTransferData(channel.contentType, wrapped, -1); + + const clip = Cc["@mozilla.org/widget/clipboard;1"] + .getService(Ci.nsIClipboard); + clip.setData(trans, null, Ci.nsIClipboard.kGlobalClipboard); + + reply.destinations.push(l10n.lookup("screenshotCopied")); + } + catch (ex) { + console.error(ex); + reply.destinations.push(l10n.lookup("screenshotErrorCopying")); + } + + return Promise.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 = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"] + .createInstance(Ci.nsIXMLHttpRequest); + const fd = Cc["@mozilla.org/files/formdata;1"] + .createInstance(Ci.nsIDOMFormData); + 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 + * 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 (let 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: 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. + */ +var saveToFile = Task.async(function*(context, reply) { + 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 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); + + // 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); + + 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); + } +}); diff --git a/devtools/shared/gcli/commands/security.js b/devtools/shared/gcli/commands/security.js new file mode 100644 index 000000000..eeed03c61 --- /dev/null +++ b/devtools/shared/gcli/commands/security.js @@ -0,0 +1,328 @@ +/* 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/. */ + +/** + * The Security devtool supports the following arguments: + * * Security CSP + * Provides feedback about the current CSP + * + * * Security referrer + * Provides information about the current referrer policy + */ + +"use strict"; + +const { Cc, Ci, Cu, CC } = require("chrome"); +const l10n = require("gcli/l10n"); +const CSP = Cc["@mozilla.org/cspcontext;1"].getService(Ci.nsIContentSecurityPolicy); + +const GOOD_IMG_SRC = "chrome://browser/content/gcli_sec_good.svg"; +const MOD_IMG_SRC = "chrome://browser/content/gcli_sec_moderate.svg"; +const BAD_IMG_SRC = "chrome://browser/content/gcli_sec_bad.svg"; + + +// special handling within policy +const POLICY_REPORT_ONLY = "report-only" + +// special handling of directives +const DIR_UPGRADE_INSECURE = "upgrade-insecure-requests"; +const DIR_BLOCK_ALL_MIXED_CONTENT = "block-all-mixed-content"; + +// special handling of sources +const SRC_UNSAFE_INLINE = "'unsafe-inline'"; +const SRC_UNSAFE_EVAL = "'unsafe-eval'"; + +const WILDCARD_MSG = l10n.lookup("securityCSPRemWildCard"); +const XSS_WARNING_MSG = l10n.lookup("securityCSPPotentialXSS"); +const NO_CSP_ON_PAGE_MSG = l10n.lookup("securityCSPNoCSPOnPage"); +const CONTENT_SECURITY_POLICY_MSG = l10n.lookup("securityCSPHeaderOnPage"); +const CONTENT_SECURITY_POLICY_REPORT_ONLY_MSG = l10n.lookup("securityCSPROHeaderOnPage"); + +const NEXT_URI_HEADER = l10n.lookup("securityReferrerNextURI"); +const CALCULATED_REFERRER_HEADER = l10n.lookup("securityReferrerCalculatedReferrer"); +/* The official names from the W3C Referrer Policy Draft http://www.w3.org/TR/referrer-policy/ */ +const REFERRER_POLICY_NAMES = [ "None When Downgrade (default)", "None", "Origin Only", "Origin When Cross-Origin", "Unsafe URL" ]; + +exports.items = [ + { + // --- General Security information + name: "security", + description: l10n.lookup("securityPrivacyDesc"), + manual: l10n.lookup("securityManual") + }, + { + // --- CSP specific Security information + item: "command", + runAt: "server", + name: "security csp", + description: l10n.lookup("securityCSPDesc"), + manual: l10n.lookup("securityCSPManual"), + returnType: "securityCSPInfo", + exec: function(args, context) { + + var cspJSON = context.environment.document.nodePrincipal.cspJSON; + var cspOBJ = JSON.parse(cspJSON); + + var outPolicies = []; + + var policies = cspOBJ["csp-policies"]; + + // loop over all the different policies + for (var csp in policies) { + var curPolicy = policies[csp]; + + // loop over all the directive-values within that policy + var outDirectives = []; + var outHeader = CONTENT_SECURITY_POLICY_MSG; + for (var dir in curPolicy) { + var curDir = curPolicy[dir]; + + // when iterating properties within the obj we might also + // encounter the 'report-only' flag, which is not a csp directive. + if (dir === POLICY_REPORT_ONLY) { + outHeader = curPolicy[POLICY_REPORT_ONLY] === true ? + CONTENT_SECURITY_POLICY_REPORT_ONLY_MSG : + CONTENT_SECURITY_POLICY_MSG; + continue; + } + + // loop over all the directive-sources within that directive + var outSrcs = []; + + // special case handling for the directives + // upgrade-insecure-requests and block-all-mixed-content + // which do not include any srcs + if (dir === DIR_UPGRADE_INSECURE || + dir === DIR_BLOCK_ALL_MIXED_CONTENT) { + outSrcs.push({ + icon: GOOD_IMG_SRC, + src: "", // no src + desc: "" // no description + }); + } + + for (var src in curDir) { + var curSrc = curDir[src]; + + // the default icon and descritpion of the directive-src + var outIcon = GOOD_IMG_SRC; + var outDesc = ""; + + if (curSrc.indexOf("*") > -1) { + outIcon = MOD_IMG_SRC; + outDesc = WILDCARD_MSG; + } + if (curSrc == SRC_UNSAFE_INLINE || curSrc == SRC_UNSAFE_EVAL) { + outIcon = BAD_IMG_SRC; + outDesc = XSS_WARNING_MSG; + } + outSrcs.push({ + icon: outIcon, + src: curSrc, + desc: outDesc + }); + } + // append info about that directive to the directives array + outDirectives.push({ + dirValue: dir, + dirSrc: outSrcs + }); + } + // append info about the policy to the policies array + outPolicies.push({ + header: outHeader, + directives: outDirectives + }); + } + return outPolicies; + } + }, + { + item: "converter", + from: "securityCSPInfo", + to: "view", + exec: function(cspInfo, context) { + var url = context.environment.target.url; + + if (cspInfo.length == 0) { + return context.createView({ + html: + "<table class='gcli-csp-detail' cellspacing='10' valign='top'>" + + " <tr>" + + " <td> <img src='chrome://browser/content/gcli_sec_bad.svg' width='20px' /> </td> " + + " <td>" + NO_CSP_ON_PAGE_MSG + " <b>" + url + "</b></td>" + + " </tr>" + + "</table>"}); + } + + return context.createView({ + html: + "<table class='gcli-csp-detail' cellspacing='10' valign='top'>" + + // iterate all policies + " <tr foreach='csp in ${cspinfo}' >" + + " <td> ${csp.header} <b>" + url + "</b><br/><br/>" + + " <table class='gcli-csp-dir-detail' valign='top'>" + + // >> iterate all directives + " <tr foreach='dir in ${csp.directives}' >" + + " <td valign='top'> ${dir.dirValue} </td>" + + " <td valign='top'>" + + " <table class='gcli-csp-src-detail' valign='top'>" + + // >> >> iterate all srs + " <tr foreach='src in ${dir.dirSrc}' >" + + " <td valign='center' width='20px'> <img src= \"${src.icon}\" width='20px' /> </td> " + + " <td valign='center' width='200px'> ${src.src} </td>" + + " <td valign='center'> ${src.desc} </td>" + + " </tr>" + + " </table>" + + " </td>" + + " </tr>" + + " </table>" + + " </td>" + + " </tr>" + + "</table>", + data: { + cspinfo: cspInfo, + } + }); + } + }, + { + // --- Referrer Policy specific Security information + item: "command", + runAt: "server", + name: "security referrer", + description: l10n.lookup("securityReferrerPolicyDesc"), + manual: l10n.lookup("securityReferrerPolicyManual"), + returnType: "securityReferrerPolicyInfo", + exec: function(args, context) { + var doc = context.environment.document; + + var referrerPolicy = doc.referrerPolicy; + + var pageURI = doc.documentURIObject; + var sameDomainReferrer = ""; + var otherDomainReferrer = ""; + var downgradeReferrer = ""; + var otherDowngradeReferrer = ""; + var origin = pageURI.prePath; + + switch (referrerPolicy) { + case Ci.nsIHttpChannel.REFERRER_POLICY_NO_REFERRER: + // sends no referrer + sameDomainReferrer + = otherDomainReferrer + = downgradeReferrer + = otherDowngradeReferrer + = "(no referrer)"; + break; + case Ci.nsIHttpChannel.REFERRER_POLICY_ORIGIN: + // only sends the origin of the referring URL + sameDomainReferrer + = otherDomainReferrer + = downgradeReferrer + = otherDowngradeReferrer + = origin; + break; + case Ci.nsIHttpChannel.REFERRER_POLICY_ORIGIN_WHEN_XORIGIN: + // same as default, but reduced to ORIGIN when cross-origin. + sameDomainReferrer = pageURI.spec; + otherDomainReferrer + = downgradeReferrer + = otherDowngradeReferrer + = origin; + break; + case Ci.nsIHttpChannel.REFERRER_POLICY_UNSAFE_URL: + // always sends the referrer, even on downgrade. + sameDomainReferrer + = otherDomainReferrer + = downgradeReferrer + = otherDowngradeReferrer + = pageURI.spec; + break; + case Ci.nsIHttpChannel.REFERRER_POLICY_NO_REFERRER_WHEN_DOWNGRADE: + // default state, doesn't send referrer from https->http + sameDomainReferrer = otherDomainReferrer = pageURI.spec; + downgradeReferrer = otherDowngradeReferrer = "(no referrer)"; + break; + default: + // this is a new referrer policy which we do not know about + sameDomainReferrer + = otherDomainReferrer + = downgradeReferrer + = otherDowngradeReferrer + = "(unknown Referrer Policy)"; + break; + } + + var sameDomainUri = origin + "/*"; + + var referrerUrls = [ + // add the referrer uri 'referrer' we would send when visiting 'uri' + { + uri: pageURI.scheme+'://example.com/', + referrer: otherDomainReferrer, + description: l10n.lookup('securityReferrerPolicyOtherDomain')}, + { + uri: sameDomainUri, + referrer: sameDomainReferrer, + description: l10n.lookup('securityReferrerPolicySameDomain')} + ]; + + if (pageURI.schemeIs('https')) { + // add the referrer we would send on downgrading http->https + if (sameDomainReferrer != downgradeReferrer) { + referrerUrls.push({ + uri: "http://"+pageURI.hostPort+"/*", + referrer: downgradeReferrer, + description: + l10n.lookup('securityReferrerPolicySameDomainDowngrade') + }); + } + if (otherDomainReferrer != otherDowngradeReferrer) { + referrerUrls.push({ + uri: "http://example.com/", + referrer: otherDowngradeReferrer, + description: + l10n.lookup('securityReferrerPolicyOtherDomainDowngrade') + }); + } + } + + return { + header: l10n.lookupFormat("securityReferrerPolicyReportHeader", + [pageURI.spec]), + policyName: REFERRER_POLICY_NAMES[referrerPolicy], + urls: referrerUrls + } + } + }, + { + item: "converter", + from: "securityReferrerPolicyInfo", + to: "view", + exec: function(referrerPolicyInfo, context) { + return context.createView({ + html: + "<div class='gcli-referrer-policy'>" + + " <strong> ${rpi.header} </strong> <br />" + + " ${rpi.policyName} <br />" + + " <table class='gcli-referrer-policy-detail' cellspacing='10' >" + + " <tr>" + + " <th> " + NEXT_URI_HEADER + " </th>" + + " <th> " + CALCULATED_REFERRER_HEADER + " </th>" + + " </tr>" + + // iterate all policies + " <tr foreach='nextURI in ${rpi.urls}' >" + + " <td> ${nextURI.description} (e.g., ${nextURI.uri}) </td>" + + " <td> ${nextURI.referrer} </td>" + + " </tr>" + + " </table>" + + "</div>", + data: { + rpi: referrerPolicyInfo, + } + }); + } + } +]; |