"use strict"; /* global windowTracker */ Cu.import("resource://gre/modules/Services.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "LightweightThemeManager", "resource://gre/modules/LightweightThemeManager.jsm"); XPCOMUtils.defineLazyGetter(this, "gThemesEnabled", () => { return Services.prefs.getBoolPref("extensions.webextensions.themes.enabled"); }); var { getWinUtils, } = ExtensionUtils; const ICONS = Services.prefs.getStringPref("extensions.webextensions.themes.icons.buttons", "").split(","); /** Class representing a theme. */ class Theme { /** * Creates a theme instance. * * @param {string} baseURI The base URI of the extension, used to * resolve relative filepaths. * @param {Object} logger Reference to the (console) logger that will be used * to show manifest warnings to the theme author. */ constructor(baseURI, logger) { // A dictionary of light weight theme styles. this.lwtStyles = { icons: {}, }; this.baseURI = baseURI; this.logger = logger; } /** * Loads a theme by reading the properties from the extension's manifest. * This method will override any currently applied theme. * * @param {Object} details Theme part of the manifest. Supported * properties can be found in the schema under ThemeType. * @param {Object} targetWindow The window to apply the theme to. Omitting * this parameter will apply the theme globally. */ load(details, targetWindow) { if (targetWindow) { this.lwtStyles.window = getWinUtils(targetWindow).outerWindowID; } if (details.colors) { this.loadColors(details.colors); } if (details.images) { this.loadImages(details.images); } if (details.icons) { this.loadIcons(details.icons); } if (details.properties) { this.loadProperties(details.properties); } // Lightweight themes require all properties to be defined. if (this.lwtStyles.headerURL && this.lwtStyles.accentcolor && this.lwtStyles.textcolor) { LightweightThemeManager.fallbackThemeData = this.lwtStyles; Services.obs.notifyObservers(null, "lightweight-theme-styling-update", JSON.stringify(this.lwtStyles)); } else { this.logger.warn("Your theme doesn't include one of the following required " + "properties: 'headerURL', 'accentcolor' or 'textcolor'"); } } /** * Helper method for loading colors found in the extension's manifest. * * @param {Object} colors Dictionary mapping color properties to values. */ loadColors(colors) { 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 "accentcolor": case "frame": this.lwtStyles.accentcolor = cssColor; break; case "textcolor": case "tab_text": this.lwtStyles.textcolor = cssColor; break; case "toolbar": this.lwtStyles.toolbarColor = cssColor; break; case "toolbar_text": this.lwtStyles.toolbar_text = cssColor; break; case "toolbar_field": this.lwtStyles.toolbar_field = cssColor; break; case "toolbar_field_text": this.lwtStyles.toolbar_field_text = cssColor; break; } } } /** * Helper method for loading images found in the extension's manifest. * * @param {Object} images Dictionary mapping image properties to values. */ loadImages(images) { for (let image of Object.keys(images)) { let val = images[image]; if (!val) { continue; } switch (image) { case "additional_backgrounds": { let backgroundImages = val.map(img => this.baseURI.resolve(img)); this.lwtStyles.additionalBackgrounds = backgroundImages; break; } case "headerURL": case "theme_frame": { let resolvedURL = this.baseURI.resolve(val); this.lwtStyles.headerURL = resolvedURL; break; } } } } /** * Helper method for loading icons found in the extension's manifest. * * @param {Object} icons Dictionary mapping icon properties to extension URLs. */ loadIcons(icons) { if (!Services.prefs.getBoolPref("extensions.webextensions.themes.icons.enabled")) { // Return early if icons are disabled. return; } for (let icon of Object.getOwnPropertyNames(icons)) { let val = icons[icon]; // We also have to compare against the baseURI spec because // `val` might have been resolved already. Resolving "" against // the baseURI just produces that URI, so check for equality. if (!val || val == this.baseURI.spec || !ICONS.includes(icon)) { continue; } let variableName = `--${icon}-icon`; let resolvedURL = this.baseURI.resolve(val); this.lwtStyles.icons[variableName] = resolvedURL; } } /** * 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. */ loadProperties(properties) { let additionalBackgroundsCount = (this.lwtStyles.additionalBackgrounds && this.lwtStyles.additionalBackgrounds.length) || 0; const assertValidAdditionalBackgrounds = (property, valueCount) => { if (!additionalBackgroundsCount) { this.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) { this.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; } let alignment = []; if (this.lwtStyles.headerURL) { alignment.push("right top"); } this.lwtStyles.backgroundsAlignment = alignment.concat(val).join(","); break; } case "additional_backgrounds_tiling": { if (!assertValidAdditionalBackgrounds(property, val.length)) { break; } let tiling = []; if (this.lwtStyles.headerURL) { tiling.push("no-repeat"); } for (let i = 0, l = this.lwtStyles.additionalBackgrounds.length; i < l; ++i) { tiling.push(val[i] || "no-repeat"); } this.lwtStyles.backgroundsTiling = tiling.join(","); break; } } } } /** * Unloads the currently applied theme. * @param {Object} targetWindow The window the theme should be unloaded from */ unload(targetWindow) { let lwtStyles = { headerURL: "", accentcolor: "", additionalBackgrounds: "", backgroundsAlignment: "", backgroundsTiling: "", textcolor: "", icons: {}, }; if (targetWindow) { lwtStyles.window = getWinUtils(targetWindow).outerWindowID; } for (let icon of ICONS) { lwtStyles.icons[`--${icon}--icon`] = ""; } LightweightThemeManager.fallbackThemeData = null; Services.obs.notifyObservers(null, "lightweight-theme-styling-update", JSON.stringify(lwtStyles)); } } this.theme = class extends ExtensionAPI { onManifestEntry(entryName) { if (!gThemesEnabled) { // Return early if themes are disabled. return; } let {extension} = this; let {manifest} = extension; if (!gThemesEnabled) { // Return early if themes are disabled. return; } this.theme = new Theme(extension.baseURI, extension.logger); this.theme.load(manifest.theme); } onShutdown() { if (this.theme) { this.theme.unload(); } } getAPI(context) { let {extension} = context; return { theme: { update: (windowId, details) => { if (!gThemesEnabled) { // Return early if themes are disabled. return; } if (!this.theme) { // WebExtensions using the Theme API will not have a theme defined // in the manifest. Therefore, we need to initialize the theme the // first time browser.theme.update is called. this.theme = new Theme(extension.baseURI, extension.logger); } let browserWindow; if (windowId !== null) { browserWindow = windowTracker.getWindow(windowId, context); } this.theme.load(details, browserWindow); }, reset: (windowId) => { if (!gThemesEnabled) { // Return early if themes are disabled. return; } if (!this.theme) { // If no theme has been initialized, nothing to do. return; } let browserWindow; if (windowId !== null) { browserWindow = windowTracker.getWindow(windowId, context); } this.theme.unload(browserWindow); }, }, }; } };