/* 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"; var { components, Cc, Ci, Cu } = require("chrome"); var Services = require("Services"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/NetUtil.jsm"); Cu.import("resource://gre/modules/FileUtils.jsm"); Cu.import("resource://gre/modules/Task.jsm"); const promise = require("promise"); const events = require("sdk/event/core"); const protocol = require("devtools/server/protocol"); const {Arg, Option, method, RetVal, types} = protocol; const {LongStringActor, ShortLongString} = require("devtools/server/actors/string"); const {fetch} = require("devtools/shared/DevToolsUtils"); const {listenOnce} = require("devtools/shared/async-utils"); const {SourceMapConsumer} = require("source-map"); loader.lazyGetter(this, "CssLogic", () => require("devtools/shared/styleinspector/css-logic").CssLogic); const { getIndentationFromPrefs, getIndentationFromString } = require("devtools/shared/indentation"); var TRANSITION_CLASS = "moz-styleeditor-transitioning"; var TRANSITION_DURATION_MS = 500; var TRANSITION_BUFFER_MS = 1000; var TRANSITION_RULE_SELECTOR = ".moz-styleeditor-transitioning:root, .moz-styleeditor-transitioning:root *"; var TRANSITION_RULE = TRANSITION_RULE_SELECTOR + " {\ transition-duration: " + TRANSITION_DURATION_MS + "ms !important; \ transition-delay: 0ms !important;\ transition-timing-function: ease-out !important;\ transition-property: all !important;\ }"; var LOAD_ERROR = "error-load"; types.addActorType("stylesheet"); types.addActorType("originalsource"); // The possible kinds of style-applied events. // UPDATE_PRESERVING_RULES means that the update is guaranteed to // preserve the number and order of rules on the style sheet. // UPDATE_GENERAL covers any other kind of change to the style sheet. const UPDATE_PRESERVING_RULES = 0; exports.UPDATE_PRESERVING_RULES = UPDATE_PRESERVING_RULES; const UPDATE_GENERAL = 1; exports.UPDATE_GENERAL = UPDATE_GENERAL; // If the user edits a style sheet, we stash a copy of the edited text // here, keyed by the style sheet. This way, if the tools are closed // and then reopened, the edited text will be available. A weak map // is used so that navigation by the user will eventually cause the // edited text to be collected. let modifiedStyleSheets = new WeakMap(); /** * Creates a StyleSheetsActor. StyleSheetsActor provides remote access to the * stylesheets of a document. */ var StyleSheetsActor = exports.StyleSheetsActor = protocol.ActorClass({ typeName: "stylesheets", /** * The window we work with, taken from the parent actor. */ get window() { return this.parentActor.window; }, /** * The current content document of the window we work with. */ get document() { return this.window.document; }, form: function() { return { actor: this.actorID }; }, initialize: function (conn, tabActor) { protocol.Actor.prototype.initialize.call(this, null); this.parentActor = tabActor; }, /** * Protocol method for getting a list of StyleSheetActors representing * all the style sheets in this document. */ getStyleSheets: method(Task.async(function* () { // Iframe document can change during load (bug 1171919). Track their windows // instead. let windows = [this.window]; let actors = []; for (let win of windows) { let sheets = yield this._addStyleSheets(win); actors = actors.concat(sheets); // Recursively handle style sheets of the documents in iframes. for (let iframe of win.document.querySelectorAll("iframe, browser, frame")) { if (iframe.contentDocument && iframe.contentWindow) { // Sometimes, iframes don't have any document, like the // one that are over deeply nested (bug 285395) windows.push(iframe.contentWindow); } } } return actors; }), { request: {}, response: { styleSheets: RetVal("array:stylesheet") } }), /** * Check if we should be showing this stylesheet. * * @param {Document} doc * Document for which we're checking * @param {DOMCSSStyleSheet} sheet * Stylesheet we're interested in * * @return boolean * Whether the stylesheet should be listed. */ _shouldListSheet: function(doc, sheet) { // Special case about:PreferenceStyleSheet, as it is generated on the // fly and the URI is not registered with the about: handler. // https://bugzilla.mozilla.org/show_bug.cgi?id=935803#c37 if (sheet.href && sheet.href.toLowerCase() == "about:preferencestylesheet") { return false; } return true; }, /** * Add all the stylesheets for the document in this window to the map and * create an actor for each one if not already created. * * @param {Window} win * Window for which to add stylesheets * * @return {Promise} * Promise that resolves to an array of StyleSheetActors */ _addStyleSheets: function(win) { return Task.spawn(function*() { let doc = win.document; // readyState can be uninitialized if an iframe has just been created but // it has not started to load yet. if (doc.readyState === "loading" || doc.readyState === "uninitialized") { // Wait for the document to load first. yield listenOnce(win, "DOMContentLoaded", true); // Make sure we have the actual document for this window. If the // readyState was initially uninitialized, the initial dummy document // was replaced with the actual document (bug 1171919). doc = win.document; } let isChrome = Services.scriptSecurityManager.isSystemPrincipal(doc.nodePrincipal); let styleSheets = isChrome ? DOMUtils.getAllStyleSheets(doc) : doc.styleSheets; let actors = []; for (let i = 0; i < styleSheets.length; i++) { let sheet = styleSheets[i]; if (!this._shouldListSheet(doc, sheet)) { continue; } let actor = this.parentActor.createStyleSheetActor(sheet); actors.push(actor); // Get all sheets, including imported ones let imports = yield this._getImported(doc, actor); actors = actors.concat(imports); } return actors; }.bind(this)); }, /** * Get all the stylesheets @imported from a stylesheet. * * @param {Document} doc * The document including the stylesheet * @param {DOMStyleSheet} styleSheet * Style sheet to search * @return {Promise} * A promise that resolves with an array of StyleSheetActors */ _getImported: function(doc, styleSheet) { return Task.spawn(function*() { let rules = yield styleSheet.getCSSRules(); let imported = []; for (let i = 0; i < rules.length; i++) { let rule = rules[i]; if (rule.type == Ci.nsIDOMCSSRule.IMPORT_RULE) { // Associated styleSheet may be null if it has already been seen due // to duplicate @imports for the same URL. if (!rule.styleSheet || !this._shouldListSheet(doc, rule.styleSheet)) { continue; } let actor = this.parentActor.createStyleSheetActor(rule.styleSheet); imported.push(actor); // recurse imports in this stylesheet as well let children = yield this._getImported(doc, actor); imported = imported.concat(children); } else if (rule.type != Ci.nsIDOMCSSRule.CHARSET_RULE) { // @import rules must precede all others except @charset break; } } return imported; }.bind(this)); }, /** * Create a new style sheet in the document with the given text. * Return an actor for it. * * @param {object} request * Debugging protocol request object, with 'text property' * @return {object} * Object with 'styelSheet' property for form on new actor. */ addStyleSheet: method(function(text) { let parent = this.document.documentElement; let style = this.document.createElementNS("http://www.w3.org/1999/xhtml", "style"); style.setAttribute("type", "text/css"); if (text) { style.appendChild(this.document.createTextNode(text)); } parent.appendChild(style); let actor = this.parentActor.createStyleSheetActor(style.sheet); return actor; }, { request: { text: Arg(0, "string") }, response: { styleSheet: RetVal("stylesheet") } }) }); /** * The corresponding Front object for the StyleSheetsActor. */ var StyleSheetsFront = protocol.FrontClass(StyleSheetsActor, { initialize: function(client, tabForm) { protocol.Front.prototype.initialize.call(this, client); this.actorID = tabForm.styleSheetsActor; this.manage(this); } }); /** * A MediaRuleActor lives on the server and provides access to properties * of a DOM @media rule and emits events when it changes. */ var MediaRuleActor = protocol.ActorClass({ typeName: "mediarule", events: { "matches-change" : { type: "matchesChange", matches: Arg(0, "boolean"), } }, get window() { return this.parentActor.window; }, get document() { return this.window.document; }, get matches() { return this.mql ? this.mql.matches : null; }, initialize: function(aMediaRule, aParentActor) { protocol.Actor.prototype.initialize.call(this, null); this.rawRule = aMediaRule; this.parentActor = aParentActor; this.conn = this.parentActor.conn; this._matchesChange = this._matchesChange.bind(this); this.line = DOMUtils.getRuleLine(aMediaRule); this.column = DOMUtils.getRuleColumn(aMediaRule); try { this.mql = this.window.matchMedia(aMediaRule.media.mediaText); } catch(e) { } if (this.mql) { this.mql.addListener(this._matchesChange); } }, destroy: function() { if (this.mql) { this.mql.removeListener(this._matchesChange); } protocol.Actor.prototype.destroy.call(this); }, form: function(detail) { if (detail === "actorid") { return this.actorID; } let form = { actor: this.actorID, // actorID is set when this is added to a pool mediaText: this.rawRule.media.mediaText, conditionText: this.rawRule.conditionText, matches: this.matches, line: this.line, column: this.column, parentStyleSheet: this.parentActor.actorID }; return form; }, _matchesChange: function() { events.emit(this, "matches-change", this.matches); } }); /** * Cooresponding client-side front for a MediaRuleActor. */ var MediaRuleFront = protocol.FrontClass(MediaRuleActor, { initialize: function(client, form) { protocol.Front.prototype.initialize.call(this, client, form); this._onMatchesChange = this._onMatchesChange.bind(this); events.on(this, "matches-change", this._onMatchesChange); }, _onMatchesChange: function(matches) { this._form.matches = matches; }, form: function(form, detail) { if (detail === "actorid") { this.actorID = form; return; } this.actorID = form.actor; this._form = form; }, get mediaText() { return this._form.mediaText; }, get conditionText() { return this._form.conditionText; }, get matches() { return this._form.matches; }, get line() { return this._form.line || -1; }, get column() { return this._form.column || -1; }, get parentStyleSheet() { return this.conn.getActor(this._form.parentStyleSheet); } }); /** * A StyleSheetActor represents a stylesheet on the server. */ var StyleSheetActor = protocol.ActorClass({ typeName: "stylesheet", events: { "property-change" : { type: "propertyChange", property: Arg(0, "string"), value: Arg(1, "json") }, "style-applied" : { type: "styleApplied", kind: Arg(0, "number"), styleSheet: Arg(1, "stylesheet") }, "media-rules-changed" : { type: "mediaRulesChanged", rules: Arg(0, "array:mediarule") } }, /* List of original sources that generated this stylesheet */ _originalSources: null, toString: function() { return "[StyleSheetActor " + this.actorID + "]"; }, /** * Window of target */ get window() { return this._window || this.parentActor.window; }, /** * Document of target. */ get document() { return this.window.document; }, get ownerNode() { return this.rawSheet.ownerNode; }, /** * URL of underlying stylesheet. */ get href() { return this.rawSheet.href; }, /** * Retrieve the index (order) of stylesheet in the document. * * @return number */ get styleSheetIndex() { if (this._styleSheetIndex == -1) { for (let i = 0; i < this.document.styleSheets.length; i++) { if (this.document.styleSheets[i] == this.rawSheet) { this._styleSheetIndex = i; break; } } } return this._styleSheetIndex; }, initialize: function(aStyleSheet, aParentActor, aWindow) { protocol.Actor.prototype.initialize.call(this, null); this.rawSheet = aStyleSheet; this.parentActor = aParentActor; this.conn = this.parentActor.conn; this._window = aWindow; // text and index are unknown until source load this.text = null; this._styleSheetIndex = -1; this._transitionRefCount = 0; }, /** * Test whether all the rules in this sheet have associated source. * @return {Boolean} true if all the rules have source; false if * some rule was created via CSSOM. */ allRulesHaveSource: function() { let rules; try { rules = this.rawSheet.cssRules; } catch (e) { // sheet isn't loaded yet return true; } for (let i = 0; i < rules.length; i++) { let rule = rules[i]; if (DOMUtils.getRelativeRuleLine(rule) === 0) { return false; } } return true; }, /** * Get the raw stylesheet's cssRules once the sheet has been loaded. * * @return {Promise} * Promise that resolves with a CSSRuleList */ getCSSRules: function() { let rules; try { rules = this.rawSheet.cssRules; } catch (e) { // sheet isn't loaded yet } if (rules) { return promise.resolve(rules); } if (!this.ownerNode) { return promise.resolve([]); } if (this._cssRules) { return this._cssRules; } let deferred = promise.defer(); let onSheetLoaded = (event) => { this.ownerNode.removeEventListener("load", onSheetLoaded, false); deferred.resolve(this.rawSheet.cssRules); }; this.ownerNode.addEventListener("load", onSheetLoaded, false); // cache so we don't add many listeners if this is called multiple times. this._cssRules = deferred.promise; return this._cssRules; }, /** * Get the current state of the actor * * @return {object} * With properties of the underlying stylesheet, plus 'text', * 'styleSheetIndex' and 'parentActor' if it's @imported */ form: function(detail) { if (detail === "actorid") { return this.actorID; } let docHref; if (this.ownerNode) { if (this.ownerNode instanceof Ci.nsIDOMHTMLDocument) { docHref = this.ownerNode.location.href; } else if (this.ownerNode.ownerDocument && this.ownerNode.ownerDocument.location) { docHref = this.ownerNode.ownerDocument.location.href; } } let form = { actor: this.actorID, // actorID is set when this actor is added to a pool href: this.href, nodeHref: docHref, disabled: this.rawSheet.disabled, title: this.rawSheet.title, system: !CssLogic.isContentStylesheet(this.rawSheet), styleSheetIndex: this.styleSheetIndex } try { form.ruleCount = this.rawSheet.cssRules.length; } catch(e) { // stylesheet had an @import rule that wasn't loaded yet this.getCSSRules().then(() => { this._notifyPropertyChanged("ruleCount"); }); } return form; }, /** * Toggle the disabled property of the style sheet * * @return {object} * 'disabled' - the disabled state after toggling. */ toggleDisabled: method(function() { this.rawSheet.disabled = !this.rawSheet.disabled; this._notifyPropertyChanged("disabled"); return this.rawSheet.disabled; }, { response: { disabled: RetVal("boolean")} }), /** * Send an event notifying that a property of the stylesheet * has changed. * * @param {string} property * Name of the changed property */ _notifyPropertyChanged: function(property) { events.emit(this, "property-change", property, this.form()[property]); }, /** * Protocol method to get the text of this stylesheet. */ getText: method(function() { return this._getText().then((text) => { return new LongStringActor(this.conn, text || ""); }); }, { response: { text: RetVal("longstring") } }), /** * Fetch the text for this stylesheet from the cache or network. Return * cached text if it's already been fetched. * * @return {Promise} * Promise that resolves with a string text of the stylesheet. */ _getText: function() { if (typeof this.text === "string") { return promise.resolve(this.text); } let cssText = modifiedStyleSheets.get(this.rawSheet); if (cssText !== undefined) { this.text = cssText; return promise.resolve(cssText); } if (!this.href) { // this is an inline