summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/utils/stylesheets-manager.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/utils/stylesheets-manager.js')
-rw-r--r--devtools/server/actors/utils/stylesheets-manager.js948
1 files changed, 948 insertions, 0 deletions
diff --git a/devtools/server/actors/utils/stylesheets-manager.js b/devtools/server/actors/utils/stylesheets-manager.js
new file mode 100644
index 0000000000..a9e548d205
--- /dev/null
+++ b/devtools/server/actors/utils/stylesheets-manager.js
@@ -0,0 +1,948 @@
+/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const { fetch } = require("resource://devtools/shared/DevToolsUtils.js");
+const InspectorUtils = require("InspectorUtils");
+const {
+ getSourcemapBaseURL,
+} = require("resource://devtools/server/actors/utils/source-map-utils.js");
+const {
+ TYPES,
+} = require("resource://devtools/server/actors/resources/index.js");
+
+loader.lazyRequireGetter(
+ this,
+ ["addPseudoClassLock", "removePseudoClassLock"],
+ "resource://devtools/server/actors/highlighters/utils/markup.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "loadSheet",
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["getSheetOwnerNode", "UPDATE_GENERAL", "UPDATE_PRESERVING_RULES"],
+ "resource://devtools/server/actors/style-sheet.js",
+ 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} *:not(:-moz-native-anonymous)`;
+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();
+
+/**
+ * Manage stylesheets related to a given Target Actor.
+ *
+ * @emits applicable-stylesheet-added: emitted when an applicable stylesheet is added to the document.
+ * First arg is an object with the following properties:
+ * - resourceId {String}: The id that was assigned to the stylesheet
+ * - styleSheet {StyleSheet}: The actual stylesheet
+ * - creationData {Object}: An object with:
+ * - isCreatedByDevTools {Boolean}: Was the stylesheet created by DevTools (e.g.
+ * by the user clicking the new stylesheet button in the styleeditor)
+ * - fileName {String}
+ * @emits stylesheet-updated: emitted when there was changes in a stylesheet
+ * First arg is an object with the following properties:
+ * - resourceId {String}: The id that was assigned to the stylesheet
+ * - updateKind {String}: Which kind of update it is ("style-applied",
+ * "at-rules-changed", "matches-change", "property-change")
+ * - updates {Object}: The update data
+ */
+class StyleSheetsManager extends EventEmitter {
+ _styleSheetCount = 0;
+ _styleSheetMap = new Map();
+ // List of all watched media queries. Change listeners are being registered from getAtRules.
+ _mqlList = [];
+
+ /**
+ * @param TargetActor targetActor
+ * The target actor from which we should observe stylesheet changes.
+ */
+ constructor(targetActor) {
+ super();
+
+ this._targetActor = targetActor;
+ this._onApplicableStateChanged = this._onApplicableStateChanged.bind(this);
+ this._onTargetActorWindowReady = this._onTargetActorWindowReady.bind(this);
+ }
+
+ /**
+ * Calling this function will make the StyleSheetsManager start the event listeners needed
+ * to watch for stylesheet additions and modifications.
+ * It will also trigger applicable-stylesheet-added events for the existing stylesheets.
+ * This function resolves once it notified about existing stylesheets.
+ */
+ async startWatching() {
+ // Process existing stylesheets
+ const promises = [];
+ for (const window of this._targetActor.windows) {
+ promises.push(this._getStyleSheetsForWindow(window));
+ }
+
+ // Listen for new stylesheet being added via StyleSheetApplicableStateChanged
+ this._targetActor.chromeEventHandler.addEventListener(
+ "StyleSheetApplicableStateChanged",
+ this._onApplicableStateChanged,
+ true
+ );
+ this._watchStyleSheetChangeEvents();
+ this._targetActor.on("window-ready", this._onTargetActorWindowReady);
+
+ // Finally, notify about existing stylesheets
+ let styleSheets = await Promise.all(promises);
+ styleSheets = styleSheets.flat();
+ for (const styleSheet of styleSheets) {
+ this._registerStyleSheet(styleSheet);
+ }
+ }
+
+ _watchStyleSheetChangeEvents() {
+ for (const window of this._targetActor.windows) {
+ this._watchStyleSheetChangeEventsForWindow(window);
+ }
+ }
+
+ _onTargetActorWindowReady({ window }) {
+ this._watchStyleSheetChangeEventsForWindow(window);
+ }
+
+ _watchStyleSheetChangeEventsForWindow(window) {
+ // We have to set this flag in order to get the
+ // StyleSheetApplicableStateChanged events. See Document.webidl.
+ window.document.styleSheetChangeEventsEnabled = true;
+ }
+
+ _unwatchStyleSheetChangeEvents() {
+ for (const window of this._targetActor.windows) {
+ window.document.styleSheetChangeEventsEnabled = false;
+ }
+ }
+
+ /**
+ * 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");
+ style.setDevtoolsAsTriggeringPrincipal();
+
+ 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;
+ }
+
+ /**
+ * Return resourceId of the given style sheet or create one if the stylesheet wasn't
+ * registered yet.
+ *
+ * @params {StyleSheet} styleSheet
+ * @returns {String} resourceId
+ */
+ getStyleSheetResourceId(styleSheet) {
+ const existingResourceId = this._findStyleSheetResourceId(styleSheet);
+ if (existingResourceId) {
+ return existingResourceId;
+ }
+
+ // If we couldn't find an associated resourceId, that means the stylesheet isn't
+ // registered yet. Calling _registerStyleSheet will register it and return the
+ // associated resourceId it computed for it.
+ return this._registerStyleSheet(styleSheet);
+ }
+
+ /**
+ * Return the associated resourceId of the given registered style sheet, or null if the
+ * stylesheet wasn't registered yet.
+ *
+ * @params {StyleSheet} styleSheet
+ * @returns {String} resourceId
+ */
+ _findStyleSheetResourceId(styleSheet) {
+ for (const [
+ resourceId,
+ existingStyleSheet,
+ ] of this._styleSheetMap.entries()) {
+ if (styleSheet === existingStyleSheet) {
+ return resourceId;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Return owner node of the style sheet of the given resource id.
+ *
+ * @params {String} resourceId
+ * The id associated with the stylesheet
+ * @returns {Element|null}
+ */
+ getOwnerNode(resourceId) {
+ const styleSheet = this._styleSheetMap.get(resourceId);
+ return styleSheet.ownerNode;
+ }
+
+ /**
+ * Return the index of given stylesheet of the given resource id.
+ *
+ * @params {String} resourceId
+ * The id associated with the stylesheet
+ * @returns {Number}
+ */
+ getStyleSheetIndex(resourceId) {
+ const styleSheet = this._styleSheetMap.get(resourceId);
+ return this._getStyleSheetIndex(styleSheet);
+ }
+
+ /**
+ * Get the text of a stylesheet given its resourceId.
+ *
+ * @params {String} resourceId
+ * The id associated with the stylesheet
+ * @returns {String}
+ */
+ 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) {
+ if (styleSheet.ownerNode) {
+ // this is an inline <style> sheet
+ return styleSheet.ownerNode.textContent;
+ }
+ // Constructed stylesheet.
+ // TODO(bug 176993): Maybe preserve authored text?
+ return "";
+ }
+
+ return this._fetchStyleSheet(styleSheet);
+ }
+
+ /**
+ * Toggle the disabled property of the stylesheet
+ *
+ * @params {String} resourceId
+ * The id associated with 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 {Object} options
+ * @param {Boolean} options.transition
+ * Whether to do CSS transition for change. Defaults to false.
+ * @param {Number} options.kind
+ * Either UPDATE_PRESERVING_RULES or UPDATE_GENERAL. Defaults to UPDATE_GENERAL.
+ * @param {String} options.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 setStyleSheetText(
+ resourceId,
+ text,
+ { transition = false, 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.emit("stylesheet-updated", {
+ resourceId,
+ updateKind: "style-applied",
+ updates: {
+ event: { kind, cause },
+ },
+ });
+ }
+
+ // Remove event handler from all media query list we set to. We are going to re-set
+ // those handler properly from getAtRules.
+ for (const mql of this._mqlList) {
+ mql.onchange = null;
+ }
+
+ const atRules = await this.getAtRules(styleSheet);
+ this.emit("stylesheet-updated", {
+ resourceId,
+ updateKind: "at-rules-changed",
+ updates: {
+ resourceUpdates: { atRules },
+ },
+ });
+ }
+
+ /**
+ * Applies a transition to the stylesheet document so any change made by the user in the
+ * client will be animated so it's more visible.
+ *
+ * @param {String} resourceId
+ * The id associated with the stylesheet
+ * @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.
+ */
+ _startTransition(resourceId, kind, cause) {
+ const styleSheet = this._styleSheetMap.get(resourceId);
+ const document = styleSheet.associatedDocument;
+ const window = document.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
+ );
+ }
+
+ /**
+ * @param {String} resourceId
+ * The id associated with the stylesheet
+ * @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.
+ */
+ _onTransitionEnd(resourceId, kind, cause) {
+ const styleSheet = this._styleSheetMap.get(resourceId);
+ const document = styleSheet.associatedDocument;
+
+ this._transitionTimeout = null;
+ removePseudoClassLock(document.documentElement, TRANSITION_PSEUDO_CLASS);
+
+ this.emit("stylesheet-updated", {
+ resourceId,
+ updateKind: "style-applied",
+ updates: {
+ event: { kind, cause },
+ },
+ });
+ }
+
+ /**
+ * Retrieve the content of a given stylesheet
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {String}
+ */
+ 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.
+ const ownerNode = getSheetOwnerNode(styleSheet);
+ if (ownerNode) {
+ // eslint-disable-next-line mozilla/use-ownerGlobal
+ options.window = ownerNode.ownerDocument.defaultView;
+ options.principal = 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;
+ }
+
+ /**
+ * Get charset of a given stylesheet
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {String}
+ */
+ _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";
+ }
+
+ /**
+ * Retrieve the CSSRuleList of a given stylesheet
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {CSSRuleList}
+ */
+ _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 }
+ );
+ });
+ }
+
+ /**
+ * Get the stylesheets imported by a given stylesheet (via @import)
+ *
+ * @param {Document} document
+ * @param {StyleSheet} styleSheet
+ * @returns Array<StyleSheet>
+ */
+ 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;
+ }
+
+ /**
+ * Retrieve the at-rules of a given stylesheet
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {Array<Object>} An array of object of the following shape:
+ * - type {String}
+ * - mediaText {String}
+ * - conditionText {String}
+ * - matches {Boolean}: true if the media rule matches the current state of the document
+ * - line {Number}
+ * - column {Number}
+ */
+ async getAtRules(styleSheet) {
+ const resourceId = this._findStyleSheetResourceId(styleSheet);
+ if (!resourceId) {
+ return [];
+ }
+
+ this._mqlList = [];
+
+ const styleSheetRules = await this._getCSSRules(styleSheet);
+ const document = styleSheet.associatedDocument;
+ const win = document?.ownerGlobal;
+ const CSSGroupingRule = win?.CSSGroupingRule;
+
+ // We need to go through nested rules to extract all the rules we're interested in
+ const rules = [];
+ const traverseRules = ruleList => {
+ for (const rule of ruleList) {
+ // Don't go further if the rule can't hold other rules (e.g. not a @media, @supports, …)
+ if (!CSSGroupingRule || !CSSGroupingRule.isInstance(rule)) {
+ continue;
+ }
+
+ const line = InspectorUtils.getRelativeRuleLine(rule);
+ const column = InspectorUtils.getRuleColumn(rule);
+
+ const className = ChromeUtils.getClassName(rule);
+ if (className === "CSSMediaRule") {
+ let matches = false;
+
+ try {
+ const mql = win.matchMedia(rule.media.mediaText);
+ matches = mql.matches;
+ mql.onchange = this._onMatchesChange.bind(
+ this,
+ resourceId,
+ rules.length
+ );
+ this._mqlList.push(mql);
+ } catch (e) {
+ // Ignored
+ }
+
+ rules.push({
+ type: "media",
+ mediaText: rule.media.mediaText,
+ conditionText: rule.conditionText,
+ matches,
+ line,
+ column,
+ });
+ } else if (className === "CSSContainerRule") {
+ rules.push({
+ type: "container",
+ conditionText: rule.conditionText,
+ line,
+ column,
+ });
+ } else if (className === "CSSSupportsRule") {
+ rules.push({
+ type: "support",
+ conditionText: rule.conditionText,
+ line,
+ column,
+ });
+ } else if (className === "CSSLayerBlockRule") {
+ rules.push({
+ type: "layer",
+ layerName: rule.name,
+ line,
+ column,
+ });
+ }
+
+ if (rule.cssRules) {
+ traverseRules(rule.cssRules);
+ }
+ }
+ };
+ traverseRules(styleSheetRules);
+ return rules;
+ }
+
+ /**
+ * Called when the status of a media query support changes (i.e. it now matches, or it
+ * was matching but isn't anymore)
+ *
+ * @param {String} resourceId
+ * The id associated with the stylesheet
+ * @param {Number} index
+ * The index of the media rule relatively to all the other at-rules of the stylesheet
+ * @param {MediaQueryList} mql
+ * The result of matchMedia for the given media rule
+ */
+ _onMatchesChange(resourceId, index, mql) {
+ this.emit("stylesheet-updated", {
+ resourceId,
+ updateKind: "matches-change",
+ updates: {
+ nestedResourceUpdates: [
+ {
+ path: ["atRules", index, "matches"],
+ value: mql.matches,
+ },
+ ],
+ },
+ });
+ }
+
+ /**
+ * Get the node href of a given stylesheet
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {String}
+ */
+ _getNodeHref(styleSheet) {
+ const { ownerNode } = styleSheet;
+ if (!ownerNode) {
+ return null;
+ }
+
+ if (ownerNode.nodeType == ownerNode.DOCUMENT_NODE) {
+ return ownerNode.location.href;
+ }
+
+ if (ownerNode.ownerDocument?.location) {
+ return ownerNode.ownerDocument.location.href;
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the sourcemap base url of a given stylesheet
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {String}
+ */
+ _getSourcemapBaseURL(styleSheet) {
+ // When the style is injected via nsIDOMWindowUtils.loadSheet, even
+ // the parent style sheet has no owner, so default back to target actor
+ // document
+ const ownerNode = getSheetOwnerNode(styleSheet);
+ const ownerDocument = ownerNode
+ ? 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
+ );
+ }
+
+ /**
+ * Get the index of a given stylesheet in the document it lives in
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {Number}
+ */
+ _getStyleSheetIndex(styleSheet) {
+ const styleSheets = InspectorUtils.getAllStyleSheets(
+ this._targetActor.window.document,
+ true
+ );
+ return styleSheets.indexOf(styleSheet);
+ }
+
+ /**
+ * Get all the stylesheets for a given window
+ *
+ * @param {Window} window
+ * @returns {Array<StyleSheet>}
+ */
+ async _getStyleSheetsForWindow(window) {
+ const { document } = window;
+ 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;
+ }
+
+ /**
+ * Returns true if a given stylesheet has an ancestor with the same url it has
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {Boolean}
+ */
+ _haveAncestorWithSameURL(styleSheet) {
+ const href = styleSheet.href;
+ while (styleSheet.parentStyleSheet) {
+ if (styleSheet.parentStyleSheet.href == href) {
+ return true;
+ }
+ styleSheet = styleSheet.parentStyleSheet;
+ }
+ return false;
+ }
+
+ /**
+ * Helper function called when a property changed in a given stylesheet
+ *
+ * @param {String} resourceId
+ * The id of the stylesheet the change occured in
+ * @param {String} property
+ * The property that was changed
+ * @param {String} value
+ * The value of the property
+ */
+ _notifyPropertyChanged(resourceId, property, value) {
+ this.emit("stylesheet-updated", {
+ resourceId,
+ updateKind: "property-change",
+ updates: { 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
+ * - Stylesheet is constructed.
+ * When appending <link>, <style> or changing `disabled` attribute to false,
+ * `applicable` is passed as true. The other hand, when changing `disabled`
+ * 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.
+ */
+ _onApplicableStateChanged({ applicable, stylesheet: styleSheet }) {
+ if (
+ // Have interest in applicable stylesheet only.
+ applicable &&
+ styleSheet.associatedDocument &&
+ (!this._targetActor.ignoreSubFrames ||
+ styleSheet.associatedDocument.ownerGlobal ===
+ this._targetActor.window) &&
+ this._shouldListSheet(styleSheet) &&
+ !this._haveAncestorWithSameURL(styleSheet)
+ ) {
+ this._registerStyleSheet(styleSheet);
+ }
+ }
+
+ /**
+ * If the stylesheet isn't registered yet, this function will generate an associated
+ * resourceId and will emit an "applicable-stylesheet-added" event.
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {String} the associated resourceId
+ */
+ _registerStyleSheet(styleSheet) {
+ const existingResourceId = this._findStyleSheetResourceId(styleSheet);
+ // If the stylesheet is already registered, there's no need to notify about it again.
+ if (existingResourceId) {
+ return existingResourceId;
+ }
+
+ // It's important to prefix the resourceId with the target actorID so we can't have
+ // duplicated resource ids when the client connects to multiple targets.
+ const resourceId = `${this._targetActor.actorID}:stylesheet:${this
+ ._styleSheetCount++}`;
+ this._styleSheetMap.set(resourceId, styleSheet);
+
+ const creationData = this._styleSheetCreationData?.get(styleSheet);
+ this._styleSheetCreationData?.delete(styleSheet);
+
+ // We need to use emitAsync and await on it so the watcher can sends the resource to
+ // the client before we resolve a potential creationData promise.
+ const onEventHandlerDone = this.emitAsync("applicable-stylesheet-added", {
+ resourceId,
+ styleSheet,
+ creationData,
+ });
+
+ // creationData exists if this stylesheet was created via `addStyleSheet`.
+ if (creationData) {
+ // We resolve the promise once the handler of applicable-stylesheet-added are settled,
+ // (e.g. the watcher sent the resources to the client) so `addStyleSheet` calls can
+ // be fullfilled.
+ onEventHandlerDone.then(() => creationData?.resolve());
+ }
+ return resourceId;
+ }
+
+ /**
+ * Returns true if the passed styleSheet should be handled.
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {Boolean}
+ */
+ _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;
+ }
+
+ /**
+ * The StyleSheetManager instance is managed by the target, so this will be called when
+ * the target gets destroyed.
+ */
+ destroy() {
+ // Cleanup
+ this._targetActor.off("window-ready", this._watchStyleSheetChangeEvents);
+
+ try {
+ this._targetActor.chromeEventHandler.removeEventListener(
+ "StyleSheetApplicableStateChanged",
+ this._onApplicableStateChanged,
+ true
+ );
+ this._unwatchStyleSheetChangeEvents();
+ } catch (e) {
+ console.error(
+ "Error when destroying StyleSheet manager for",
+ this._targetActor,
+ ": ",
+ e
+ );
+ }
+
+ this._styleSheetMap.clear();
+ this._styleSheetMap = null;
+ this._targetActor = null;
+ this._styleSheetCreationData = null;
+ this._mqlList = null;
+ }
+}
+
+function hasStyleSheetWatcherSupportForTarget(targetActor) {
+ return (
+ targetActor.sessionContext.supportedResources?.[TYPES.STYLESHEET] || false
+ );
+}
+
+module.exports = {
+ StyleSheetsManager,
+ hasStyleSheetWatcherSupportForTarget,
+};