--- a/toolkit/components/extensions/parent/ext-theme.js
+++ b/toolkit/components/extensions/parent/ext-theme.js
@@ -31,25 +31,45 @@ let windowOverrides = new Map();
*/
class Theme {
/**
* Creates a theme instance.
*
* @param {string} extension Extension that created the theme.
* @param {Integer} windowId The windowId where the theme is applied.
*/
- constructor({extension, details, windowId}) {
+ constructor({extension, details, windowId, experiment}) {
this.extension = extension;
this.details = details;
this.windowId = windowId;
this.lwtStyles = {
icons: {},
};
+ if (experiment) {
+ const canRunExperiment = AppConstants.MOZ_ALLOW_LEGACY_EXTENSIONS &&
+ Services.prefs.getBoolPref("extensions.legacy.enabled");
+ if (canRunExperiment) {
+ this.lwtStyles.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.
*
* @param {Object} details Theme part of the manifest. Supported
@@ -83,16 +103,19 @@ class Theme {
getWinUtils(windowTracker.getWindow(this.windowId)).outerWindowID;
windowOverrides.set(this.windowId, this);
} else {
windowOverrides.clear();
defaultTheme = this;
}
onUpdatedEmitter.emit("theme-updated", this.details, this.windowId);
+ if (this.experiment) {
+ lwtData.experiment = this.experiment;
+ }
LightweightThemeManager.fallbackThemeData = this.lwtStyles;
Services.obs.notifyObservers(null,
"lightweight-theme-styling-update",
JSON.stringify(lwtData));
}
/**
* Helper method for loading colors found in the extension's manifest.
@@ -158,27 +181,35 @@ class Theme {
case "popup_text":
case "popup_border":
case "popup_highlight":
case "popup_highlight_text":
case "ntp_background":
case "ntp_text":
this.lwtStyles[color] = cssColor;
break;
+ default:
+ if (this.experiment && this.experiment.colors && color in this.experiment.colors) {
+ this.lwtStyles.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.
*/
loadImages(images) {
- const {baseURI} = this.extension;
+ const {baseURI, logger} = this.extension;
for (let image of Object.keys(images)) {
let val = images[image];
if (!val) {
continue;
}
@@ -189,16 +220,24 @@ class Theme {
break;
}
case "headerURL":
case "theme_frame": {
let resolvedURL = baseURI.resolve(val);
this.lwtStyles.headerURL = resolvedURL;
break;
}
+ default: {
+ if (this.experiment && this.experiment.images && image in this.experiment.images) {
+ this.lwtStyles.experimental.images[image] = baseURI.resolve(val);
+ } else {
+ logger.warn(`Unrecognized theme property found: images.${image}`);
+ }
+ break;
+ }
}
}
}
/**
* Helper method for loading icons found in the extension's manifest.
*
* @param {Object} icons Dictionary mapping icon properties to extension URLs.
@@ -280,16 +319,25 @@ class Theme {
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;
}
+ default: {
+ if (this.experiment && this.experiment.properties && property in this.experiment.properties) {
+ this.lwtStyles.experimental.properties[property] = val;
+ } else {
+ const {logger} = this.extension;
+ logger.warn(`Unrecognized theme property found: properties.${property}`);
+ }
+ break;
+ }
}
}
}
static unload(windowId) {
let lwtData = {
theme: null,
};
@@ -309,20 +357,22 @@ class Theme {
JSON.stringify(lwtData));
}
}
this.theme = class extends ExtensionAPI {
onManifestEntry(entryName) {
let {extension} = this;
let {manifest} = extension;
+ let {theme, theme_experiment} = manifest;
defaultTheme = new Theme({
extension,
- details: manifest.theme,
+ details: theme,
+ experiment: theme_experiment,
});
}
onShutdown(reason) {
if (reason === "APP_SHUTDOWN") {
return;
}
@@ -361,16 +411,17 @@ this.theme = class extends ExtensionAPI
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}`);
}
--- a/toolkit/components/extensions/schemas/theme.json
+++ b/toolkit/components/extensions/schemas/theme.json
@@ -37,16 +37,47 @@
"maxItems": 4,
"items": {
"type": "number"
}
}
]
},
{
+ "id": "ThemeExperiment",
+ "type": "object",
+ "properties": {
+ "stylesheet": {
+ "optional": true,
+ "$ref": "ExtensionURL"
+ },
+ "images": {
+ "type": "object",
+ "optional": true,
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "colors": {
+ "type": "object",
+ "optional": true,
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "properties": {
+ "type": "object",
+ "optional": true,
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ {
"id": "ThemeType",
"type": "object",
"properties": {
"images": {
"type": "object",
"optional": true,
"properties": {
"additional_backgrounds": {
@@ -59,17 +90,17 @@
"$ref": "ImageDataOrExtensionURL",
"optional": true
},
"theme_frame": {
"$ref": "ImageDataOrExtensionURL",
"optional": true
}
},
- "additionalProperties": { "$ref": "UnrecognizedProperty" }
+ "additionalProperties": { "$ref": "ImageDataOrExtensionURL" }
},
"colors": {
"type": "object",
"optional": true,
"properties": {
"tab_selected": {
"$ref": "ThemeColor",
"optional": true
@@ -202,17 +233,17 @@
"$ref": "ThemeColor",
"optional": true
},
"ntp_text": {
"$ref": "ThemeColor",
"optional": true
}
},
- "additionalProperties": { "$ref": "UnrecognizedProperty" }
+ "additionalProperties": { "$ref": "ThemeColor" }
},
"icons": {
"type": "object",
"optional": true,
"properties": {
"back": {
"$ref": "ExtensionURL",
"optional": true
@@ -559,42 +590,55 @@
"items": {
"type": "string",
"enum": ["no-repeat", "repeat", "repeat-x", "repeat-y"]
},
"maxItems": 15,
"optional": true
}
},
- "additionalProperties": { "$ref": "UnrecognizedProperty" }
+ "additionalProperties": { "type": "string" }
}
},
"additionalProperties": { "$ref": "UnrecognizedProperty" }
},
{
"id": "ThemeManifest",
"type": "object",
"description": "Contents of manifest.json for a static theme",
"$import": "manifest.ManifestBase",
"properties": {
"theme": {
"$ref": "ThemeType"
},
"default_locale": {
"type": "string",
- "optional": "true"
+ "optional": true
+ },
+ "theme_experiment": {
+ "$ref": "ThemeExperiment",
+ "optional": true
},
"icons": {
"type": "object",
"optional": true,
"patternProperties": {
"^[1-9]\\d*$": { "type": "string" }
}
}
}
+ },
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "theme_experiment": {
+ "$ref": "ThemeExperiment",
+ "optional": true
+ }
+ }
}
]
},
{
"namespace": "theme",
"description": "The theme API allows customizing of visual elements of the browser.",
"types": [
{
--- a/toolkit/components/extensions/test/browser/browser.ini
+++ b/toolkit/components/extensions/test/browser/browser.ini
@@ -4,16 +4,17 @@ support-files =
[browser_ext_management_themes.js]
skip-if = verify
[browser_ext_themes_alpha_accentcolor.js]
[browser_ext_themes_chromeparity.js]
[browser_ext_themes_dynamic_getCurrent.js]
[browser_ext_themes_dynamic_onUpdated.js]
[browser_ext_themes_dynamic_updates.js]
+[browser_ext_themes_experiment.js]
[browser_ext_themes_getCurrent_differentExt.js]
[browser_ext_themes_lwtsupport.js]
[browser_ext_themes_multiple_backgrounds.js]
[browser_ext_themes_ntp_colors.js]
[browser_ext_themes_ntp_colors_perwindow.js]
[browser_ext_themes_persistence.js]
[browser_ext_themes_separators.js]
[browser_ext_themes_static_onUpdated.js]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_experiment.js
@@ -0,0 +1,258 @@
+"use strict";
+
+// This test checks whether the theme experiments work
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.legacy.enabled", true]],
+ });
+});
+
+add_task(async function test_experiment_static_theme() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ some_color_property: "#ff00ff",
+ },
+ images: {
+ some_image_property: "background.jpg",
+ },
+ properties: {
+ some_random_property: "no-repeat",
+ },
+ },
+ theme_experiment: {
+ colors: {
+ some_color_property: "--some-color-property",
+ },
+ images: {
+ some_image_property: "--some-image-property",
+ },
+ properties: {
+ some_random_property: "--some-random-property",
+ },
+ },
+ },
+ });
+
+ const root = window.document.documentElement;
+
+ is(root.style.getPropertyValue("--some-color-property"), "",
+ "Color property should be unset");
+ is(root.style.getPropertyValue("--some-image-property"), "",
+ "Image property should be unset");
+ is(root.style.getPropertyValue("--some-random-property"), "",
+ "Generic Property should be unset.");
+
+ await extension.startup();
+
+ if (AppConstants.MOZ_ALLOW_LEGACY_EXTENSIONS) {
+ is(root.style.getPropertyValue("--some-color-property"), hexToCSS("#ff00ff"),
+ "Color property should be parsed and set.");
+ ok(root.style.getPropertyValue("--some-image-property").startsWith("url("),
+ "Image property should be parsed.");
+ ok(root.style.getPropertyValue("--some-image-property").endsWith("background.jpg)"),
+ "Image property should be set.");
+ is(root.style.getPropertyValue("--some-random-property"), "no-repeat",
+ "Generic Property should be set.");
+ } else {
+ is(root.style.getPropertyValue("--some-color-property"), "",
+ "Color property should be unset");
+ is(root.style.getPropertyValue("--some-image-property"), "",
+ "Image property should be unset");
+ is(root.style.getPropertyValue("--some-random-property"), "",
+ "Generic Property should be unset.");
+ }
+
+ await extension.unload();
+
+ is(root.style.getPropertyValue("--some-color-property"), "",
+ "Color property should be unset");
+ is(root.style.getPropertyValue("--some-image-property"), "",
+ "Image property should be unset");
+ is(root.style.getPropertyValue("--some-random-property"), "",
+ "Generic Property should be unset.");
+});
+
+add_task(async function test_experiment_dynamic_theme() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["theme"],
+ theme_experiment: {
+ colors: {
+ some_color_property: "--some-color-property",
+ },
+ images: {
+ some_image_property: "--some-image-property",
+ },
+ properties: {
+ some_random_property: "--some-random-property",
+ },
+ },
+ },
+ background() {
+ const theme = {
+ colors: {
+ some_color_property: "#ff00ff",
+ },
+ images: {
+ some_image_property: "background.jpg",
+ },
+ properties: {
+ some_random_property: "no-repeat",
+ },
+ };
+ browser.test.onMessage.addListener((msg) => {
+ if (msg === "update-theme") {
+ browser.theme.update(theme).then(() => {
+ browser.test.sendMessage("theme-updated");
+ });
+ } else {
+ browser.theme.reset().then(() => {
+ browser.test.sendMessage("theme-reset");
+ });
+ }
+ });
+ },
+ });
+
+ await extension.startup();
+
+ const root = window.document.documentElement;
+
+ is(root.style.getPropertyValue("--some-color-property"), "",
+ "Color property should be unset");
+ is(root.style.getPropertyValue("--some-image-property"), "",
+ "Image property should be unset");
+ is(root.style.getPropertyValue("--some-random-property"), "",
+ "Generic Property should be unset.");
+
+ extension.sendMessage("update-theme");
+ await extension.awaitMessage("theme-updated");
+
+ if (AppConstants.MOZ_ALLOW_LEGACY_EXTENSIONS) {
+ is(root.style.getPropertyValue("--some-color-property"), hexToCSS("#ff00ff"),
+ "Color property should be parsed and set.");
+ ok(root.style.getPropertyValue("--some-image-property").startsWith("url("),
+ "Image property should be parsed.");
+ ok(root.style.getPropertyValue("--some-image-property").endsWith("background.jpg)"),
+ "Image property should be set.");
+ is(root.style.getPropertyValue("--some-random-property"), "no-repeat",
+ "Generic Property should be set.");
+ } else {
+ is(root.style.getPropertyValue("--some-color-property"), "",
+ "Color property should be unset");
+ is(root.style.getPropertyValue("--some-image-property"), "",
+ "Image property should be unset");
+ is(root.style.getPropertyValue("--some-random-property"), "",
+ "Generic Property should be unset.");
+ }
+
+ extension.sendMessage("reset-theme");
+ await extension.awaitMessage("theme-reset");
+
+ is(root.style.getPropertyValue("--some-color-property"), "",
+ "Color property should be unset");
+ is(root.style.getPropertyValue("--some-image-property"), "",
+ "Image property should be unset");
+ is(root.style.getPropertyValue("--some-random-property"), "",
+ "Generic Property should be unset.");
+
+ extension.sendMessage("update-theme");
+ await extension.awaitMessage("theme-updated");
+
+ if (AppConstants.MOZ_ALLOW_LEGACY_EXTENSIONS) {
+ is(root.style.getPropertyValue("--some-color-property"), hexToCSS("#ff00ff"),
+ "Color property should be parsed and set.");
+ ok(root.style.getPropertyValue("--some-image-property").startsWith("url("),
+ "Image property should be parsed.");
+ ok(root.style.getPropertyValue("--some-image-property").endsWith("background.jpg)"),
+ "Image property should be set.");
+ is(root.style.getPropertyValue("--some-random-property"), "no-repeat",
+ "Generic Property should be set.");
+ } else {
+ is(root.style.getPropertyValue("--some-color-property"), "",
+ "Color property should be unset");
+ is(root.style.getPropertyValue("--some-image-property"), "",
+ "Image property should be unset");
+ is(root.style.getPropertyValue("--some-random-property"), "",
+ "Generic Property should be unset.");
+ }
+
+ await extension.unload();
+
+ is(root.style.getPropertyValue("--some-color-property"), "",
+ "Color property should be unset");
+ is(root.style.getPropertyValue("--some-image-property"), "",
+ "Image property should be unset");
+ is(root.style.getPropertyValue("--some-random-property"), "",
+ "Generic Property should be unset.");
+});
+
+add_task(async function test_experiment_stylesheet() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ menu_button_background: "#ff00ff",
+ },
+ },
+ theme_experiment: {
+ stylesheet: "experiment.css",
+ colors: {
+ menu_button_background: "--menu-button-background",
+ },
+ },
+ },
+ files: {
+ "experiment.css": `#PanelUI-menu-button {
+ background-color: var(--menu-button-background);
+ fill: white;
+ }`,
+ },
+ });
+
+ const root = window.document.documentElement;
+ const menuButton = document.getElementById("PanelUI-menu-button");
+ const computedStyle = window.getComputedStyle(menuButton);
+ const expectedColor = hexToCSS("#ff00ff");
+ const expectedFill = hexToCSS("#ffffff");
+
+ is(root.style.getPropertyValue("--menu-button-background"), "",
+ "Variable should be unset");
+ isnot(computedStyle.backgroundColor, expectedColor,
+ "Menu button should not have custom background");
+ isnot(computedStyle.fill, expectedFill,
+ "Menu button should not have stylesheet fill");
+
+ await extension.startup();
+
+ if (AppConstants.MOZ_ALLOW_LEGACY_EXTENSIONS) {
+ // Wait for stylesheet load.
+ await BrowserTestUtils.waitForCondition(() => computedStyle.fill === expectedFill);
+
+ is(root.style.getPropertyValue("--menu-button-background"), expectedColor,
+ "Variable should be parsed and set.");
+ is(computedStyle.backgroundColor, expectedColor,
+ "Menu button should be have correct background");
+ is(computedStyle.fill, expectedFill,
+ "Menu button should be have correct fill");
+ } else {
+ is(root.style.getPropertyValue("--menu-button-background"), "",
+ "Variable should be unset");
+ isnot(computedStyle.backgroundColor, expectedColor,
+ "Menu button should not have custom background");
+ isnot(computedStyle.fill, expectedFill,
+ "Menu button should not have stylesheet fill");
+ }
+
+ await extension.unload();
+
+ is(root.style.getPropertyValue("--menu-button-background"), "",
+ "Variable should be unset");
+ isnot(computedStyle.backgroundColor, expectedColor,
+ "Menu button should not have custom background");
+ isnot(computedStyle.fill, expectedFill,
+ "Menu button should not have stylesheet fill");
+});
--- a/toolkit/modules/LightweightThemeConsumer.jsm
+++ b/toolkit/modules/LightweightThemeConsumer.jsm
@@ -132,24 +132,24 @@ LightweightThemeConsumer.prototype = {
_active: false,
observe(aSubject, aTopic, aData) {
if (aTopic != "lightweight-theme-styling-update")
return;
let parsedData = JSON.parse(aData);
if (!parsedData) {
- parsedData = { theme: null };
+ parsedData = { theme: null, experiment: null };
}
if (parsedData.window && parsedData.window !== this._winId) {
return;
}
- this._update(parsedData.theme);
+ this._update(parsedData.theme, parsedData.experiment);
},
handleEvent(aEvent) {
switch (aEvent.type) {
case "resolutionchange":
if (this._active) {
this._update(this._lastData);
}
@@ -158,67 +158,120 @@ LightweightThemeConsumer.prototype = {
Services.obs.removeObserver(this, "lightweight-theme-styling-update");
Services.ppmm.sharedData.delete(`theme/${this._winId}`);
this._win.removeEventListener("resolutionchange", this);
this._win = this._doc = null;
break;
}
},
- _update(aData) {
- this._lastData = aData;
- if (aData) {
- aData = LightweightThemeImageOptimizer.optimize(aData, this._win.screen);
+ _update(theme, experiment) {
+ this._lastData = theme;
+ if (theme) {
+ theme = LightweightThemeImageOptimizer.optimize(theme, this._win.screen);
}
- let active = this._active = aData && aData.id !== DEFAULT_THEME_ID;
+ let active = this._active = theme && theme.id !== DEFAULT_THEME_ID;
- if (!aData) {
- aData = {};
+ if (!theme) {
+ theme = {};
}
let root = this._doc.documentElement;
- if (active && aData.headerURL) {
+ if (active && theme.headerURL) {
root.setAttribute("lwtheme-image", "true");
} else {
root.removeAttribute("lwtheme-image");
}
- if (active && aData.icons) {
- let activeIcons = Object.keys(aData.icons).join(" ");
+ if (active && theme.icons) {
+ let activeIcons = Object.keys(theme.icons).join(" ");
root.setAttribute("lwthemeicons", activeIcons);
} else {
root.removeAttribute("lwthemeicons");
}
for (let icon of ICONS) {
- let value = aData.icons ? aData.icons[`--${icon}-icon`] : null;
+ let value = theme.icons ? theme.icons[`--${icon}-icon`] : null;
_setImage(root, active, `--${icon}-icon`, value);
}
- _setImage(root, active, "--lwt-header-image", aData.headerURL);
- _setImage(root, active, "--lwt-footer-image", aData.footerURL);
- _setImage(root, active, "--lwt-additional-images", aData.additionalBackgrounds);
- _setProperties(root, active, aData);
+ this._setExperiment(active, experiment, theme.experimental);
+ _setImage(root, active, "--lwt-header-image", theme.headerURL);
+ _setImage(root, active, "--lwt-footer-image", theme.footerURL);
+ _setImage(root, active, "--lwt-additional-images", theme.additionalBackgrounds);
+ _setProperties(root, active, theme);
if (active) {
root.setAttribute("lwtheme", "true");
} else {
root.removeAttribute("lwtheme");
root.removeAttribute("lwthemetextcolor");
}
- if (active && aData.footerURL)
+ if (active && theme.footerURL)
root.setAttribute("lwthemefooter", "true");
else
root.removeAttribute("lwthemefooter");
- let contentThemeData = _getContentProperties(this._doc, active, aData);
+ let contentThemeData = _getContentProperties(this._doc, active, theme);
Services.ppmm.sharedData.set(`theme/${this._winId}`, contentThemeData);
+ },
+
+ _setExperiment(active, experiment, properties) {
+ const root = this._doc.documentElement;
+ if (this._lastExperimentData) {
+ const { stylesheet, usedVariables } = this._lastExperimentData;
+ if (stylesheet) {
+ stylesheet.remove();
+ }
+ if (usedVariables) {
+ for (const variable of usedVariables) {
+ _setProperty(root, false, variable);
+ }
+ }
+ }
+ if (active && experiment) {
+ this._lastExperimentData = {};
+ if (experiment.stylesheet) {
+ /* Stylesheet URLs are validated using WebExtension schemas */
+ let stylesheetAttr = `href="${experiment.stylesheet}" type="text/css"`;
+ let stylesheet = this._doc.createProcessingInstruction("xml-stylesheet",
+ stylesheetAttr);
+ this._doc.insertBefore(stylesheet, root);
+ this._lastExperimentData.stylesheet = stylesheet;
+ }
+ let usedVariables = [];
+ if (properties.colors) {
+ for (const property in properties.colors) {
+ const cssVariable = experiment.colors[property];
+ const value = _sanitizeCSSColor(root.ownerDocument, properties.colors[property]);
+ _setProperty(root, active, cssVariable, value);
+ usedVariables.push(cssVariable);
+ }
+ }
+ if (properties.images) {
+ for (const property in properties.images) {
+ const cssVariable = experiment.images[property];
+ _setProperty(root, active, cssVariable, `url(${properties.images[property]})`);
+ usedVariables.push(cssVariable);
+ }
+ }
+ if (properties.properties) {
+ for (const property in properties.properties) {
+ const cssVariable = experiment.properties[property];
+ _setProperty(root, active, cssVariable, properties.properties[property]);
+ usedVariables.push(cssVariable);
+ }
+ }
+ this._lastExperimentData.usedVariables = usedVariables;
+ } else {
+ this._lastExperimentData = null;
+ }
}
};
function _getContentProperties(doc, active, data) {
if (!active) {
return {};
}
let properties = {};