summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/resources/stylesheets.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/resources/stylesheets.js')
-rw-r--r--devtools/server/actors/resources/stylesheets.js703
1 files changed, 703 insertions, 0 deletions
diff --git a/devtools/server/actors/resources/stylesheets.js b/devtools/server/actors/resources/stylesheets.js
new file mode 100644
index 0000000000..40d3bf33f1
--- /dev/null
+++ b/devtools/server/actors/resources/stylesheets.js
@@ -0,0 +1,703 @@
+/* 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 { fetch } = require("devtools/shared/DevToolsUtils");
+const InspectorUtils = require("InspectorUtils");
+const {
+ getSourcemapBaseURL,
+} = require("devtools/server/actors/utils/source-map-utils");
+
+const {
+ TYPES: { STYLESHEET },
+} = require("devtools/server/actors/resources/index");
+
+loader.lazyRequireGetter(
+ this,
+ "CssLogic",
+ "devtools/shared/inspector/css-logic"
+);
+
+loader.lazyRequireGetter(
+ this,
+ ["addPseudoClassLock", "removePseudoClassLock"],
+ "devtools/server/actors/highlighters/utils/markup",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "loadSheet",
+ "devtools/shared/layout/utils",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["UPDATE_GENERAL", "UPDATE_PRESERVING_RULES"],
+ "devtools/server/actors/style-sheet",
+ true
+);
+
+const TRANSITION_PSEUDO_CLASS = ":-moz-styleeditor-transitioning";
+const TRANSITION_DURATION_MS = 500;
+const TRANSITION_BUFFER_MS = 1000;
+const TRANSITION_RULE_SELECTOR = `:root${TRANSITION_PSEUDO_CLASS}, :root${TRANSITION_PSEUDO_CLASS} *`;
+const TRANSITION_SHEET =
+ "data:text/css;charset=utf-8," +
+ encodeURIComponent(`
+ ${TRANSITION_RULE_SELECTOR} {
+ transition-duration: ${TRANSITION_DURATION_MS}ms !important;
+ transition-delay: 0ms !important;
+ transition-timing-function: ease-out !important;
+ transition-property: all !important;
+ }
+`);
+
+// If the user edits a stylesheet, we stash a copy of the edited text
+// here, keyed by the stylesheet. 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.
+const modifiedStyleSheets = new WeakMap();
+
+class StyleSheetWatcher {
+ constructor() {
+ this._resourceCount = 0;
+ // The _styleSheetMap maps resourceId and following value.
+ // {
+ // styleSheet: Raw StyleSheet object.
+ // }
+ this._styleSheetMap = new Map();
+ // List of all watched media queries. Change listeners are being registered from _getMediaRules.
+ this._mqlList = [];
+
+ this._onApplicableStateChanged = this._onApplicableStateChanged.bind(this);
+ }
+
+ /**
+ * Start watching for all stylesheets related to a given Target Actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe css changes.
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(targetActor, { onAvailable, onUpdated }) {
+ this._targetActor = targetActor;
+ this._onAvailable = onAvailable;
+ this._onUpdated = onUpdated;
+
+ // Listen for new stylesheet being added via StyleSheetApplicableStateChanged
+ this._targetActor.chromeEventHandler.addEventListener(
+ "StyleSheetApplicableStateChanged",
+ this._onApplicableStateChanged,
+ true
+ );
+
+ const styleSheets = [];
+
+ for (const window of this._targetActor.windows) {
+ // We have to set this flag in order to get the
+ // StyleSheetApplicableStateChanged events. See Document.webidl.
+ window.document.styleSheetChangeEventsEnabled = true;
+
+ styleSheets.push(...(await this._getStyleSheets(window)));
+ }
+
+ await this._notifyResourcesAvailable(styleSheets);
+ }
+
+ /**
+ * Create a new style sheet in the document with the given text.
+ *
+ * @param {Document} document
+ * Document that the new style sheet belong to.
+ * @param {string} text
+ * Content of style sheet.
+ * @param {string} fileName
+ * If the stylesheet adding is from file, `fileName` indicates the path.
+ */
+ async addStyleSheet(document, text, fileName) {
+ const parent = document.documentElement;
+ const style = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "style"
+ );
+ style.setAttribute("type", "text/css");
+
+ if (text) {
+ style.appendChild(document.createTextNode(text));
+ }
+
+ // This triggers StyleSheetApplicableStateChanged event.
+ parent.appendChild(style);
+
+ // This promise will be resolved when the resource for this stylesheet is available.
+ let resolve = null;
+ const promise = new Promise(r => {
+ resolve = r;
+ });
+
+ if (!this._stylesheetCreationData) {
+ this._stylesheetCreationData = new WeakMap();
+ }
+ this._stylesheetCreationData.set(style.sheet, {
+ isCreatedByDevTools: true,
+ fileName,
+ resolve,
+ });
+
+ await promise;
+
+ return style.sheet;
+ }
+
+ async ensureResourceAvailable(styleSheet) {
+ if (this.getResourceId(styleSheet)) {
+ return;
+ }
+
+ await this._notifyResourcesAvailable([styleSheet]);
+ }
+
+ /**
+ * Return resourceId of the given style sheet.
+ */
+ getResourceId(styleSheet) {
+ for (const [resourceId, value] of this._styleSheetMap.entries()) {
+ if (styleSheet === value.styleSheet) {
+ return resourceId;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Return owner node of the style sheet of the given resource id.
+ */
+ getOwnerNode(resourceId) {
+ const { styleSheet } = this._styleSheetMap.get(resourceId);
+ return styleSheet.ownerNode;
+ }
+
+ /**
+ * Return the style sheet of the given resource id.
+ */
+ getStyleSheet(resourceId) {
+ const { styleSheet } = this._styleSheetMap.get(resourceId);
+ return styleSheet;
+ }
+
+ /**
+ * Return the index of given stylesheet of the given resource id.
+ */
+ getStyleSheetIndex(resourceId) {
+ const { styleSheet } = this._styleSheetMap.get(resourceId);
+ return this._getStyleSheetIndex(styleSheet);
+ }
+
+ /**
+ * Protocol method to get the text of stylesheet of resourceId.
+ */
+ async getText(resourceId) {
+ const { styleSheet } = this._styleSheetMap.get(resourceId);
+
+ const modifiedText = modifiedStyleSheets.get(styleSheet);
+
+ // modifiedText is the content of the stylesheet updated by update function.
+ // In case not updating, this is undefined.
+ if (modifiedText !== undefined) {
+ return modifiedText;
+ }
+
+ if (!styleSheet.href) {
+ // this is an inline <style> sheet
+ return styleSheet.ownerNode.textContent;
+ }
+
+ return this._fetchStylesheet(styleSheet);
+ }
+
+ /**
+ * Toggle the disabled property of the stylesheet
+ *
+ * @return {Boolean} the disabled state after toggling.
+ */
+ toggleDisabled(resourceId) {
+ const { styleSheet } = this._styleSheetMap.get(resourceId);
+ styleSheet.disabled = !styleSheet.disabled;
+
+ this._notifyPropertyChanged(resourceId, "disabled", styleSheet.disabled);
+
+ return styleSheet.disabled;
+ }
+
+ /**
+ * Update the style sheet in place with new text.
+ *
+ * @param {String} resourceId
+ * @param {String} text
+ * New text.
+ * @param {Boolean} transition
+ * Whether to do CSS transition for change.
+ * @param {Number} kind
+ * Either UPDATE_PRESERVING_RULES or UPDATE_GENERAL
+ * @param {String} cause
+ * Indicates the cause of this update (e.g. "styleeditor") if this was called
+ * from the stylesheet to be edited by the user from the StyleEditor.
+ */
+ async update(
+ resourceId,
+ text,
+ transition,
+ kind = UPDATE_GENERAL,
+ cause = ""
+ ) {
+ const { styleSheet } = this._styleSheetMap.get(resourceId);
+ InspectorUtils.parseStyleSheet(styleSheet, text);
+ modifiedStyleSheets.set(styleSheet, text);
+
+ if (kind !== UPDATE_PRESERVING_RULES) {
+ this._notifyPropertyChanged(
+ resourceId,
+ "ruleCount",
+ styleSheet.cssRules.length
+ );
+ }
+
+ if (transition) {
+ this._startTransition(resourceId, kind, cause);
+ } else {
+ this._notifyResourceUpdated(resourceId, "style-applied", {
+ event: { kind, cause },
+ });
+ }
+
+ // Remove event handler from all media query list we set to.
+ for (const mql of this._mqlList) {
+ mql.onchange = null;
+ }
+
+ const mediaRules = await this._getMediaRules(resourceId, styleSheet);
+ this._notifyResourceUpdated(resourceId, "media-rules-changed", {
+ resourceUpdates: { mediaRules },
+ });
+ }
+
+ _startTransition(resourceId, kind, cause) {
+ const { styleSheet } = this._styleSheetMap.get(resourceId);
+ const document = styleSheet.ownerNode.ownerDocument;
+ const window = styleSheet.ownerNode.ownerGlobal;
+
+ if (!this._transitionSheetLoaded) {
+ this._transitionSheetLoaded = true;
+ // We don't remove this sheet. It uses an internal selector that
+ // we only apply via locks, so there's no need to load and unload
+ // it all the time.
+ loadSheet(window, TRANSITION_SHEET);
+ }
+
+ addPseudoClassLock(document.documentElement, TRANSITION_PSEUDO_CLASS);
+
+ // Set up clean up and commit after transition duration (+buffer)
+ // @see _onTransitionEnd
+ window.clearTimeout(this._transitionTimeout);
+ this._transitionTimeout = window.setTimeout(
+ this._onTransitionEnd.bind(this, resourceId, kind, cause),
+ TRANSITION_DURATION_MS + TRANSITION_BUFFER_MS
+ );
+ }
+
+ _onTransitionEnd(resourceId, kind, cause) {
+ const { styleSheet } = this._styleSheetMap.get(resourceId);
+ const document = styleSheet.ownerNode.ownerDocument;
+
+ this._transitionTimeout = null;
+ removePseudoClassLock(document.documentElement, TRANSITION_PSEUDO_CLASS);
+ this._notifyResourceUpdated(resourceId, "style-applied", {
+ event: { kind, cause },
+ });
+ }
+
+ async _fetchStylesheet(styleSheet) {
+ const href = styleSheet.href;
+
+ const options = {
+ loadFromCache: true,
+ policy: Ci.nsIContentPolicy.TYPE_INTERNAL_STYLESHEET,
+ charset: this._getCSSCharset(styleSheet),
+ };
+
+ // Bug 1282660 - We use the system principal to load the default internal
+ // stylesheets instead of the content principal since such stylesheets
+ // require system principal to load. At meanwhile, we strip the loadGroup
+ // for preventing the assertion of the userContextId mismatching.
+
+ // chrome|file|resource|moz-extension protocols rely on the system principal.
+ const excludedProtocolsRe = /^(chrome|file|resource|moz-extension):\/\//;
+ if (!excludedProtocolsRe.test(href)) {
+ // Stylesheets using other protocols should use the content principal.
+ if (styleSheet.ownerNode) {
+ // eslint-disable-next-line mozilla/use-ownerGlobal
+ options.window = styleSheet.ownerNode.ownerDocument.defaultView;
+ options.principal = styleSheet.ownerNode.ownerDocument.nodePrincipal;
+ }
+ }
+
+ let result;
+
+ try {
+ result = await fetch(href, options);
+ } catch (e) {
+ // The list of excluded protocols can be missing some protocols, try to use the
+ // system principal if the first fetch failed.
+ console.error(
+ `stylesheets: fetch failed for ${href},` +
+ ` using system principal instead.`
+ );
+ options.window = undefined;
+ options.principal = undefined;
+ result = await fetch(href, options);
+ }
+
+ return result.content;
+ }
+
+ _getCSSCharset(styleSheet) {
+ if (styleSheet) {
+ // charset attribute of <link> or <style> element, if it exists
+ if (styleSheet.ownerNode?.getAttribute) {
+ const linkCharset = styleSheet.ownerNode.getAttribute("charset");
+ if (linkCharset != null) {
+ return linkCharset;
+ }
+ }
+
+ // charset of referring document.
+ if (styleSheet.ownerNode?.ownerDocument.characterSet) {
+ return styleSheet.ownerNode.ownerDocument.characterSet;
+ }
+ }
+
+ return "UTF-8";
+ }
+
+ _getCSSRules(styleSheet) {
+ try {
+ return styleSheet.cssRules;
+ } catch (e) {
+ // sheet isn't loaded yet
+ }
+
+ if (!styleSheet.ownerNode) {
+ return Promise.resolve([]);
+ }
+
+ return new Promise(resolve => {
+ styleSheet.ownerNode.addEventListener(
+ "load",
+ () => resolve(styleSheet.cssRules),
+ { once: true }
+ );
+ });
+ }
+
+ async _getImportedStyleSheets(document, styleSheet) {
+ const importedStyleSheets = [];
+
+ for (const rule of await this._getCSSRules(styleSheet)) {
+ if (rule.type == CSSRule.IMPORT_RULE) {
+ // With the Gecko style system, the associated styleSheet may be null
+ // if it has already been seen because an import cycle for the same
+ // URL. With Stylo, the styleSheet will exist (which is correct per
+ // the latest CSSOM spec), so we also need to check ancestors for the
+ // same URL to avoid cycles.
+ if (
+ !rule.styleSheet ||
+ this._haveAncestorWithSameURL(rule.styleSheet) ||
+ !this._shouldListSheet(rule.styleSheet)
+ ) {
+ continue;
+ }
+
+ importedStyleSheets.push(rule.styleSheet);
+
+ // recurse imports in this stylesheet as well
+ const children = await this._getImportedStyleSheets(
+ document,
+ rule.styleSheet
+ );
+ importedStyleSheets.push(...children);
+ } else if (rule.type != CSSRule.CHARSET_RULE) {
+ // @import rules must precede all others except @charset
+ break;
+ }
+ }
+
+ return importedStyleSheets;
+ }
+
+ async _getMediaRules(resourceId, styleSheet) {
+ this._mqlList = [];
+
+ const mediaRules = Array.from(await this._getCSSRules(styleSheet)).filter(
+ rule => rule.type === CSSRule.MEDIA_RULE
+ );
+
+ return mediaRules.map((rule, index) => {
+ let matches = false;
+
+ try {
+ const window = styleSheet.ownerNode.ownerGlobal;
+ const mql = window.matchMedia(rule.media.mediaText);
+ matches = mql.matches;
+ mql.onchange = this._onMatchesChange.bind(this, resourceId, index);
+ this._mqlList.push(mql);
+ } catch (e) {
+ // Ignored
+ }
+
+ return {
+ mediaText: rule.media.mediaText,
+ conditionText: rule.conditionText,
+ matches,
+ line: InspectorUtils.getRuleLine(rule),
+ column: InspectorUtils.getRuleColumn(rule),
+ };
+ });
+ }
+
+ _onMatchesChange(resourceId, index, mql) {
+ this._notifyResourceUpdated(resourceId, "matches-change", {
+ nestedResourceUpdates: [
+ {
+ path: ["mediaRules", index, "matches"],
+ value: mql.matches,
+ },
+ ],
+ });
+ }
+
+ _getNodeHref(styleSheet) {
+ const { ownerNode } = styleSheet;
+ if (!ownerNode) {
+ return null;
+ }
+
+ if (ownerNode.nodeType == ownerNode.DOCUMENT_NODE) {
+ return ownerNode.location.href;
+ } else if (ownerNode.ownerDocument?.location) {
+ return ownerNode.ownerDocument.location.href;
+ }
+
+ return null;
+ }
+
+ _getSourcemapBaseURL(styleSheet) {
+ // When the style is imported, `styleSheet.ownerNode` is null,
+ // so retrieve the topmost parent style sheet which has an ownerNode
+ let parentStyleSheet = styleSheet;
+ while (parentStyleSheet.parentStyleSheet) {
+ parentStyleSheet = parentStyleSheet.parentStyleSheet;
+ }
+
+ // When the style is injected via nsIDOMWindowUtils.loadSheet, even
+ // the parent style sheet has no owner, so default back to target actor
+ // document
+ const ownerDocument = parentStyleSheet.ownerNode
+ ? parentStyleSheet.ownerNode.ownerDocument
+ : this._targetActor.window;
+
+ return getSourcemapBaseURL(
+ // Technically resolveSourceURL should be used here alongside
+ // "this.rawSheet.sourceURL", but the style inspector does not support
+ // /*# sourceURL=*/ in CSS, so we're omitting it here (bug 880831).
+ styleSheet.href || this._getNodeHref(styleSheet),
+ ownerDocument
+ );
+ }
+
+ _getStyleSheetIndex(styleSheet) {
+ const styleSheets = InspectorUtils.getAllStyleSheets(
+ this._targetActor.window.document,
+ true
+ );
+ return styleSheets.indexOf(styleSheet);
+ }
+
+ async _getStyleSheets({ document }) {
+ const documentOnly = !document.nodePrincipal.isSystemPrincipal;
+
+ const styleSheets = [];
+
+ for (const styleSheet of InspectorUtils.getAllStyleSheets(
+ document,
+ documentOnly
+ )) {
+ if (!this._shouldListSheet(styleSheet)) {
+ continue;
+ }
+
+ styleSheets.push(styleSheet);
+
+ // Get all sheets, including imported ones
+ const importedStyleSheets = await this._getImportedStyleSheets(
+ document,
+ styleSheet
+ );
+ styleSheets.push(...importedStyleSheets);
+ }
+
+ return styleSheets;
+ }
+
+ _haveAncestorWithSameURL(styleSheet) {
+ const href = styleSheet.href;
+ while (styleSheet.parentStyleSheet) {
+ if (styleSheet.parentStyleSheet.href == href) {
+ return true;
+ }
+ styleSheet = styleSheet.parentStyleSheet;
+ }
+ return false;
+ }
+
+ _notifyPropertyChanged(resourceId, property, value) {
+ this._notifyResourceUpdated(resourceId, "property-change", {
+ resourceUpdates: { [property]: value },
+ });
+ }
+
+ /**
+ * Event handler that is called when the state of applicable of style sheet is changed.
+ *
+ * For now, StyleSheetApplicableStateChanged event will be called at following timings.
+ * - Append <link> of stylesheet to document
+ * - Append <style> to document
+ * - Change disable attribute of stylesheet object
+ * - Change disable attribute of <link> to false
+ * When appending <link>, <style> or changing `disable` attribute to false, `applicable`
+ * is passed as true. The other hand, when changing `disable` to true, this will be
+ * false.
+ * NOTE: For now, StyleSheetApplicableStateChanged will not be called when removing the
+ * link and style element.
+ *
+ * @param {StyleSheetApplicableStateChanged}
+ * The triggering event.
+ */
+ async _onApplicableStateChanged({ applicable, stylesheet: styleSheet }) {
+ for (const existing of this._styleSheetMap.values()) {
+ if (existing.styleSheet === styleSheet) {
+ return;
+ }
+ }
+
+ if (
+ // Have interest in applicable stylesheet only.
+ applicable &&
+ // No ownerNode means that this stylesheet is *not* associated to a DOM Element.
+ styleSheet.ownerNode &&
+ this._shouldListSheet(styleSheet) &&
+ !this._haveAncestorWithSameURL(styleSheet)
+ ) {
+ await this._notifyResourcesAvailable([styleSheet]);
+ }
+ }
+
+ _shouldListSheet(styleSheet) {
+ // 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 (styleSheet.href?.toLowerCase() === "about:preferencestylesheet") {
+ return false;
+ }
+
+ return true;
+ }
+
+ async _toResource(
+ styleSheet,
+ { isCreatedByDevTools = false, fileName = null } = {}
+ ) {
+ const resourceId = `stylesheet:${this._resourceCount++}`;
+
+ const resource = {
+ resourceId,
+ resourceType: STYLESHEET,
+ disabled: styleSheet.disabled,
+ fileName,
+ href: styleSheet.href,
+ isNew: isCreatedByDevTools,
+ mediaRules: await this._getMediaRules(resourceId, styleSheet),
+ nodeHref: this._getNodeHref(styleSheet),
+ ruleCount: styleSheet.cssRules.length,
+ sourceMapBaseURL: this._getSourcemapBaseURL(styleSheet),
+ sourceMapURL: styleSheet.sourceMapURL,
+ styleSheetIndex: this._getStyleSheetIndex(styleSheet),
+ system: CssLogic.isAgentStylesheet(styleSheet),
+ title: styleSheet.title,
+ };
+
+ return resource;
+ }
+
+ async _notifyResourcesAvailable(styleSheets) {
+ const resources = await Promise.all(
+ styleSheets.map(async styleSheet => {
+ const creationData = this._stylesheetCreationData?.get(styleSheet);
+
+ const resource = await this._toResource(styleSheet, {
+ isCreatedByDevTools: creationData?.isCreatedByDevTools,
+ fileName: creationData?.fileName,
+ });
+
+ this._styleSheetMap.set(resource.resourceId, { styleSheet });
+ return resource;
+ })
+ );
+
+ await this._onAvailable(resources);
+
+ for (const styleSheet of styleSheets) {
+ const creationData = this._stylesheetCreationData?.get(styleSheet);
+ creationData?.resolve();
+ this._stylesheetCreationData?.delete(styleSheet);
+ }
+ }
+
+ _notifyResourceUpdated(
+ resourceId,
+ updateType,
+ { resourceUpdates, nestedResourceUpdates, event }
+ ) {
+ this._onUpdated([
+ {
+ resourceType: STYLESHEET,
+ resourceId,
+ updateType,
+ resourceUpdates,
+ nestedResourceUpdates,
+ event,
+ },
+ ]);
+ }
+
+ destroy() {
+ if (!this._targetActor.docShell) {
+ return;
+ }
+
+ this._targetActor.chromeEventHandler.removeEventListener(
+ "StyleSheetApplicableStateChanged",
+ this._onApplicableStateChanged,
+ true
+ );
+ }
+}
+
+module.exports = StyleSheetWatcher;