/* 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/. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { AddonManager: "resource://gre/modules/AddonManager.sys.mjs", BuiltInThemeConfig: "resource:///modules/BuiltInThemeConfig.sys.mjs", }); const ColorwayL10n = new Localization(["browser/colorways.ftl"], true); const kActiveThemePref = "extensions.activeThemeID"; const kRetainedThemesPref = "browser.theme.retainedExpiredThemes"; const ColorwayIntensityIdPostfixToL10nMap = [ ["-soft-colorway@mozilla.org", "colorway-intensity-soft"], ["-balanced-colorway@mozilla.org", "colorway-intensity-balanced"], ["-bold-colorway@mozilla.org", "colorway-intensity-bold"], ]; XPCOMUtils.defineLazyPreferenceGetter( lazy, "retainedThemes", kRetainedThemesPref, null, null, val => { if (!val) { return []; } let parsedVal; try { parsedVal = JSON.parse(val); } catch (ex) { console.log(`${kRetainedThemesPref} has invalid value.`); return []; } return parsedVal; } ); class _BuiltInThemes { /** * The list of themes to be installed. This is exposed on the class so tests * can set custom config files. */ builtInThemeMap = lazy.BuiltInThemeConfig; /** * @param {string} id An addon's id string. * @returns {string} * If `id` refers to a built-in theme, returns a path pointing to the * theme's preview image. Null otherwise. */ previewForBuiltInThemeId(id) { let theme = this.builtInThemeMap.get(id); if (theme) { return `${theme.path}preview.svg`; } return null; } /** * If the active theme is built-in, this function calls * AddonManager.maybeInstallBuiltinAddon for that theme. */ maybeInstallActiveBuiltInTheme() { const activeThemeID = Services.prefs.getStringPref( kActiveThemePref, "default-theme@mozilla.org" ); let activeBuiltInTheme = this.builtInThemeMap.get(activeThemeID); if (activeBuiltInTheme) { lazy.AddonManager.maybeInstallBuiltinAddon( activeThemeID, activeBuiltInTheme.version, `resource://builtin-themes/${activeBuiltInTheme.path}` ); } } /** * Ensures that all built-in themes are installed and expired themes are * uninstalled. */ async ensureBuiltInThemes() { let installPromises = []; installPromises.push(this._uninstallExpiredThemes()); const now = new Date(); for (let [id, themeInfo] of this.builtInThemeMap.entries()) { if ( !themeInfo.expiry || lazy.retainedThemes.includes(id) || new Date(themeInfo.expiry) > now ) { installPromises.push( lazy.AddonManager.maybeInstallBuiltinAddon( id, themeInfo.version, themeInfo.path ) ); } } await Promise.all(installPromises); } /** * This looks up the id in a Map rather than accessing a property on * the addon itself. That makes calls to this function O(m) where m is the * total number of built-in themes offered now or in the past. Since we * are using a Map, calls are O(1) in the average case. * * @param {string} id * A theme's ID. * @returns {boolean} * Returns true if the theme is expired. False otherwise. */ themeIsExpired(id) { let themeInfo = this.builtInThemeMap.get(id); return themeInfo?.expiry && new Date(themeInfo.expiry) < new Date(); } /** * @param {string} id * The theme's id. * @returns {boolean} * True if the theme with id `id` is both expired and retained. That is, * the user has the ability to use it after its expiry date. */ isRetainedExpiredTheme(id) { return lazy.retainedThemes.includes(id) && this.themeIsExpired(id); } /** * @param {string} id * The theme's id. * @returns {boolean} * True if the theme with id `id` is from the currently active theme. */ isActiveTheme(id) { return ( id === Services.prefs.getStringPref( kActiveThemePref, "default-theme@mozilla.org" ) ); } /** * Uninstalls themes after they expire. If the expired theme is active, then * it is not uninstalled. Instead, it is saved so that the user can use it * indefinitely. */ async _uninstallExpiredThemes() { const activeThemeID = Services.prefs.getStringPref( kActiveThemePref, "default-theme@mozilla.org" ); const now = new Date(); const expiredThemes = Array.from(this.builtInThemeMap.entries()).filter( ([id, themeInfo]) => !!themeInfo.expiry && !lazy.retainedThemes.includes(id) && new Date(themeInfo.expiry) <= now ); for (let [id] of expiredThemes) { if (id == activeThemeID) { let shouldRetain = true; try { let addon = await lazy.AddonManager.getAddonByID(id); if (addon) { // Only add the id to the retain themes pref if it is // also a built-in themes (and don't if it was migrated // xpi files installed in the user profile). shouldRetain = addon.isBuiltinColorwayTheme; } } catch (e) { console.error( `Failed to retrieve active theme AddonWrapper ${id}`, e ); } if (shouldRetain) { this._retainLimitedTimeTheme(id); } } else { try { let addon = await lazy.AddonManager.getAddonByID(id); // Only uninstall the expired colorways theme if they are not // migrated builtins (because on migrated to xpi files // installed in the user profile they are also removed // from the retainedExpiredThemes pref). if (addon?.isBuiltinColorwayTheme) { await addon.uninstall(); } } catch (e) { console.error(`Failed to uninstall expired theme ${id}`, e); } } } } /** * Set a pref to ensure that the user can continue to use a specified theme * past its expiry date. * * @param {string} id * The ID of the theme to retain. */ _retainLimitedTimeTheme(id) { if (!lazy.retainedThemes.includes(id)) { lazy.retainedThemes.push(id); Services.prefs.setStringPref( kRetainedThemesPref, JSON.stringify(lazy.retainedThemes) ); } } /** * Removes from the retained expired theme list colorways themes that have been * migrated from the one installed in the built-in XPIProvider location * to an AMO hosted xpi installed in the user profile XPIProvider location. * * @param {string} id * The ID of the theme to remove from the retained themes list. */ unretainMigratedColorwayTheme(id) { if (lazy.retainedThemes.includes(id)) { const retainedThemes = lazy.retainedThemes.filter( retainedThemeId => retainedThemeId !== id ); Services.prefs.setStringPref( kRetainedThemesPref, JSON.stringify(retainedThemes) ); } } /** * Colorway collections are usually divided into and presented as "groups". * A group either contains closely related colorways, e.g. stemming from the * same base color but with different intensities (soft, balanced, and bold), * or if the current collection doesn't have intensities, each colorway is * their own group. Group name localization is optional. * * @param {string} colorwayId * The ID of the colorway add-on. * @returns {string} * Localized colorway group name. null if there's no such name, in which * case the caller should fall back on getting a name from the add-on API. */ getLocalizedColorwayGroupName(colorwayId) { return this._getColorwayString(colorwayId, "groupName"); } /** * @param {string} colorwayId * The ID of the colorway add-on. * @returns {string} * L10nId for intensity value of the colorway with the provided id, null if * there's none. */ getColorwayIntensityL10nId(colorwayId) { const result = ColorwayIntensityIdPostfixToL10nMap.find( ([postfix, l10nId]) => colorwayId.endsWith(postfix) ); return result ? result[1] : null; } /** * @param {string} colorwayId * The ID of the colorway add-on. * @returns {string} * Localized description of the colorway with the provided id, null if * there's none. */ getLocalizedColorwayDescription(colorwayId) { return this._getColorwayString(colorwayId, "description"); } _getColorwayString(colorwayId, stringType) { let l10nId = this.builtInThemeMap.get(colorwayId)?.l10nId?.[stringType]; let s; if (l10nId) { [s] = ColorwayL10n.formatMessagesSync([ { id: l10nId, }, ]); } return s?.value || null; } } export var BuiltInThemes = new _BuiltInThemes();