/* 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"; /* global windowTracker, EventManager, EventEmitter */ /* eslint-disable complexity */ ChromeUtils.defineESModuleGetters(this, { LightweightThemeManager: "resource://gre/modules/LightweightThemeManager.sys.mjs", }); const onUpdatedEmitter = new EventEmitter(); // Represents an empty theme for convenience of use const emptyTheme = { details: { colors: null, images: null, properties: null }, }; let defaultTheme = emptyTheme; // Map[windowId -> Theme instance] let windowOverrides = new Map(); /** * Class representing either a global theme affecting all windows or an override on a specific window. * Any extension updating the theme with a new global theme will replace the singleton defaultTheme. */ class Theme { /** * Creates a theme instance. * * @param {object} options * @param {string} options.extension Extension that created the theme. * @param {Integer} options.windowId The windowId where the theme is applied. * @param {object} options.details * @param {object} options.darkDetails * @param {object} options.experiment * @param {object} options.startupData */ constructor({ extension, details, darkDetails, windowId, experiment, startupData, }) { this.extension = extension; this.details = details; this.darkDetails = darkDetails; this.windowId = windowId; if (startupData && startupData.lwtData) { Object.assign(this, startupData); } else { // TODO(ntim): clean this in bug 1550090 this.lwtStyles = {}; this.lwtDarkStyles = null; if (darkDetails) { this.lwtDarkStyles = {}; } if (experiment) { if (extension.canUseThemeExperiment()) { this.lwtStyles.experimental = { colors: {}, images: {}, properties: {}, }; if (this.lwtDarkStyles) { this.lwtDarkStyles.experimental = { colors: {}, images: {}, properties: {}, }; } const { baseURI } = this.extension; if (experiment.stylesheet) { experiment.stylesheet = baseURI.resolve(experiment.stylesheet); } this.experiment = experiment; } else { const { logger } = this.extension; logger.warn("This extension is not allowed to run theme experiments"); return; } } } this.load(); } /** * Loads a theme by reading the properties from the extension's manifest. * This method will override any currently applied theme. */ load() { if (!this.lwtData) { this.loadDetails(this.details, this.lwtStyles); if (this.darkDetails) { this.loadDetails(this.darkDetails, this.lwtDarkStyles); } this.lwtData = { theme: this.lwtStyles, darkTheme: this.lwtDarkStyles, }; if (this.experiment) { this.lwtData.experiment = this.experiment; } this.extension.startupData = { lwtData: this.lwtData, lwtStyles: this.lwtStyles, lwtDarkStyles: this.lwtDarkStyles, experiment: this.experiment, }; this.extension.saveStartupData(); } if (this.windowId) { this.lwtData.window = windowTracker.getWindow( this.windowId ).docShell.outerWindowID; windowOverrides.set(this.windowId, this); } else { windowOverrides.clear(); defaultTheme = this; LightweightThemeManager.fallbackThemeData = this.lwtData; } onUpdatedEmitter.emit("theme-updated", this.details, this.windowId); Services.obs.notifyObservers( this.lwtData, "lightweight-theme-styling-update" ); } /** * @param {object} details Details * @param {object} styles Styles object in which to store the colors. */ loadDetails(details, styles) { if (details.colors) { this.loadColors(details.colors, styles); } if (details.images) { this.loadImages(details.images, styles); } if (details.properties) { this.loadProperties(details.properties, styles); } this.loadMetadata(this.extension, styles); } /** * Helper method for loading colors found in the extension's manifest. * * @param {object} colors Dictionary mapping color properties to values. * @param {object} styles Styles object in which to store the colors. */ loadColors(colors, styles) { for (let color of Object.keys(colors)) { let val = colors[color]; if (!val) { continue; } let cssColor = val; if (Array.isArray(val)) { cssColor = "rgb" + (val.length > 3 ? "a" : "") + "(" + val.join(",") + ")"; } switch (color) { case "frame": styles.accentcolor = cssColor; break; case "frame_inactive": styles.accentcolorInactive = cssColor; break; case "tab_background_text": styles.textcolor = cssColor; break; case "toolbar": styles.toolbarColor = cssColor; break; case "toolbar_text": case "bookmark_text": styles.toolbar_text = cssColor; break; case "icons": styles.icon_color = cssColor; break; case "icons_attention": styles.icon_attention_color = cssColor; break; case "tab_background_separator": case "tab_loading": case "tab_text": case "tab_line": case "tab_selected": case "toolbar_field": case "toolbar_field_text": case "toolbar_field_border": case "toolbar_field_focus": case "toolbar_field_text_focus": case "toolbar_field_border_focus": case "toolbar_top_separator": case "toolbar_bottom_separator": case "toolbar_vertical_separator": case "button_background_hover": case "button_background_active": case "popup": case "popup_text": case "popup_border": case "popup_highlight": case "popup_highlight_text": case "ntp_background": case "ntp_card_background": case "ntp_text": case "sidebar": case "sidebar_border": case "sidebar_text": case "sidebar_highlight": case "sidebar_highlight_text": case "toolbar_field_highlight": case "toolbar_field_highlight_text": styles[color] = cssColor; break; default: if ( this.experiment && this.experiment.colors && color in this.experiment.colors ) { styles.experimental.colors[color] = cssColor; } else { const { logger } = this.extension; logger.warn(`Unrecognized theme property found: colors.${color}`); } break; } } } /** * Helper method for loading images found in the extension's manifest. * * @param {object} images Dictionary mapping image properties to values. * @param {object} styles Styles object in which to store the colors. */ loadImages(images, styles) { const { baseURI, logger } = this.extension; for (let image of Object.keys(images)) { let val = images[image]; if (!val) { continue; } switch (image) { case "additional_backgrounds": { let backgroundImages = val.map(img => baseURI.resolve(img)); styles.additionalBackgrounds = backgroundImages; break; } case "theme_frame": { let resolvedURL = baseURI.resolve(val); styles.headerURL = resolvedURL; break; } default: { if ( this.experiment && this.experiment.images && image in this.experiment.images ) { styles.experimental.images[image] = baseURI.resolve(val); } else { logger.warn(`Unrecognized theme property found: images.${image}`); } break; } } } } /** * Helper method for preparing properties found in the extension's manifest. * Properties are commonly used to specify more advanced behavior of colors, * images or icons. * * @param {object} properties Dictionary mapping properties to values. * @param {object} styles Styles object in which to store the colors. */ loadProperties(properties, styles) { let additionalBackgroundsCount = (styles.additionalBackgrounds && styles.additionalBackgrounds.length) || 0; const assertValidAdditionalBackgrounds = (property, valueCount) => { const { logger } = this.extension; if (!additionalBackgroundsCount) { logger.warn( `The '${property}' property takes effect only when one ` + `or more additional background images are specified using the 'additional_backgrounds' property.` ); return false; } if (additionalBackgroundsCount !== valueCount) { logger.warn( `The amount of values specified for '${property}' ` + `(${valueCount}) is not equal to the amount of additional background ` + `images (${additionalBackgroundsCount}), which may lead to unexpected results.` ); } return true; }; for (let property of Object.getOwnPropertyNames(properties)) { let val = properties[property]; if (!val) { continue; } switch (property) { case "additional_backgrounds_alignment": { if (!assertValidAdditionalBackgrounds(property, val.length)) { break; } styles.backgroundsAlignment = val.join(","); break; } case "additional_backgrounds_tiling": { if (!assertValidAdditionalBackgrounds(property, val.length)) { break; } let tiling = []; for (let i = 0, l = styles.additionalBackgrounds.length; i < l; ++i) { tiling.push(val[i] || "no-repeat"); } styles.backgroundsTiling = tiling.join(","); break; } case "color_scheme": case "content_color_scheme": { styles[property] = val; break; } default: { if ( this.experiment && this.experiment.properties && property in this.experiment.properties ) { styles.experimental.properties[property] = val; } else { const { logger } = this.extension; logger.warn( `Unrecognized theme property found: properties.${property}` ); } break; } } } } /** * Helper method for loading extension metadata required by downstream * consumers. * * @param {object} extension Extension object. * @param {object} styles Styles object in which to store the colors. */ loadMetadata(extension, styles) { styles.id = extension.id; styles.version = extension.version; } static unload(windowId) { let lwtData = { theme: null, }; if (windowId) { lwtData.window = windowTracker.getWindow(windowId).docShell.outerWindowID; windowOverrides.delete(windowId); } else { windowOverrides.clear(); defaultTheme = emptyTheme; LightweightThemeManager.fallbackThemeData = null; } onUpdatedEmitter.emit("theme-updated", {}, windowId); Services.obs.notifyObservers(lwtData, "lightweight-theme-styling-update"); } } this.theme = class extends ExtensionAPIPersistent { PERSISTENT_EVENTS = { onUpdated({ fire, context }) { let callback = (event, theme, windowId) => { if (windowId) { // Force access validation for incognito mode by getting the window. if (windowTracker.getWindow(windowId, context, false)) { fire.async({ theme, windowId }); } } else { fire.async({ theme }); } }; onUpdatedEmitter.on("theme-updated", callback); return { unregister() { onUpdatedEmitter.off("theme-updated", callback); }, convert(_fire, _context) { fire = _fire; context = _context; }, }; }, }; onManifestEntry() { let { extension } = this; let { manifest } = extension; defaultTheme = new Theme({ extension, details: manifest.theme, darkDetails: manifest.dark_theme, experiment: manifest.theme_experiment, startupData: extension.startupData, }); } onShutdown(isAppShutdown) { if (isAppShutdown) { return; } let { extension } = this; for (let [windowId, theme] of windowOverrides) { if (theme.extension === extension) { Theme.unload(windowId); } } if (defaultTheme.extension === extension) { Theme.unload(); } } getAPI(context) { let { extension } = context; return { theme: { getCurrent: windowId => { // Take last focused window when no ID is supplied. if (!windowId) { windowId = windowTracker.getId(windowTracker.topWindow); } // Force access validation for incognito mode by getting the window. if (!windowTracker.getWindow(windowId, context)) { return Promise.reject(`Invalid window ID: ${windowId}`); } if (windowOverrides.has(windowId)) { return Promise.resolve(windowOverrides.get(windowId).details); } return Promise.resolve(defaultTheme.details); }, update: (windowId, details) => { if (windowId) { const browserWindow = windowTracker.getWindow(windowId, context); if (!browserWindow) { return Promise.reject(`Invalid window ID: ${windowId}`); } } new Theme({ extension, details, windowId, experiment: this.extension.manifest.theme_experiment, }); }, reset: windowId => { if (windowId) { const browserWindow = windowTracker.getWindow(windowId, context); if (!browserWindow) { return Promise.reject(`Invalid window ID: ${windowId}`); } let theme = windowOverrides.get(windowId) || defaultTheme; if (theme.extension !== extension) { return; } } else if (defaultTheme.extension !== extension) { return; } Theme.unload(windowId); }, onUpdated: new EventManager({ context, module: "theme", event: "onUpdated", extensionApi: this, }).api(), }, }; } };