Source code
Revision control
Copy as Markdown
Other Tools
/* 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,
import { Preferences } from "chrome://global/content/preferences/Preferences.mjs";
import { SettingGroupManager } from "chrome://browser/content/preferences/config/SettingGroupManager.mjs";
import { DefaultBrowserHelper } from "chrome://browser/content/preferences/DefaultBrowserHelper.mjs";
const { XPCOMUtils } = ChromeUtils.importESModule(
"resource://gre/modules/XPCOMUtils.sys.mjs"
);
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
CustomIconManager:
"moz-src:///browser/components/shell/CustomIconManager.sys.mjs",
ICON_CATALOG: "moz-src:///browser/components/shell/CustomIconManager.sys.mjs",
resolvePreview:
"moz-src:///browser/components/shell/CustomIconManager.sys.mjs",
});
// The picker preview sits in this (chrome) document, so it should show the
// variant for the document's own color scheme; a theme-aware icon's preview
// follows that, while the applied icon follows the OS theme (resolved in
// CustomIconManager). Re-render on change so a theme switch updates previews.
const COLOR_SCHEME_QUERY = matchMedia("(prefers-color-scheme: dark)");
function currentScheme() {
return COLOR_SCHEME_QUERY.matches ? "dark" : "light";
}
function watchScheme(emitChange) {
COLOR_SCHEME_QUERY.addEventListener("change", emitChange);
return () => COLOR_SCHEME_QUERY.removeEventListener("change", emitChange);
}
XPCOMUtils.defineLazyServiceGetters(lazy, {
WinTaskbar: ["@mozilla.org/windows-taskbar;1", Ci.nsIWinTaskbar],
});
const PREF_ICON_ID = "browser.shell.customIcon.id";
// The default-browser and taskbar-pin state, resolved asynchronously. The Bonus
// icons are unlocked only when both are true; until then they stay disabled and
// the header promo offers a button for each missing action.
let gIsDefault = false;
let gIsPinned = false;
let gIconsUnlocked = false;
// Re-render callbacks for the elements that depend on this state (the Bonus
// picker, its header promo, and the promo's action buttons), so they update
// together when the asynchronous check resolves.
const gUnlockListeners = new Set();
// Unsubscribe handle for the shared DefaultBrowserHelper poll, held while any
// dependent element is alive so a single subscription is shared.
let gPollUnsubscribe = null;
/**
* Recompute the default-browser / taskbar-pin state and, if anything changed,
* notify every dependent element to re-render.
*/
async function refreshUnlockState() {
let isDefault = false;
let isPinned = false;
try {
isDefault = DefaultBrowserHelper.isBrowserDefault;
// Reach the shell service through getShellService() (rather than the raw
// XPCOM component) so tests can substitute it. Call the native pin check on
// .shellService directly: ShellService is a Proxy that would forward the
// method but invoke it with the wrong `this`.
isPinned = await window
.getShellService()
.shellService.isCurrentAppPinnedToTaskbar(lazy.WinTaskbar.defaultGroupId);
} catch (ex) {
isDefault = false;
isPinned = false;
}
let unlocked = isDefault && isPinned;
if (
isDefault !== gIsDefault ||
isPinned !== gIsPinned ||
unlocked !== gIconsUnlocked
) {
gIsDefault = isDefault;
gIsPinned = isPinned;
gIconsUnlocked = unlocked;
for (let notify of gUnlockListeners) {
notify();
}
}
}
/**
* setup() helper: register a re-render callback tied to the unlock state and
* kick off the (async) check. Returns the teardown function.
*
* While any dependent element is alive, subscribe (once) to the shared
* DefaultBrowserHelper poll so the unlock state also refreshes when the default
* browser changes outside this sub-pane (the OS, or the General pane's "Set as
* default"). The taskbar-pin half is re-checked as part of refreshUnlockState.
*
* @param {Function} emitChange Callback that triggers a re-render.
* @returns {() => void} Teardown that removes this listener and, once the last
* listener is gone, unsubscribes from the default-browser poller.
*/
function watchUnlockState(emitChange) {
if (!gUnlockListeners.size && DefaultBrowserHelper.canCheck) {
gPollUnsubscribe =
DefaultBrowserHelper.pollForDefaultChanges(refreshUnlockState);
}
gUnlockListeners.add(emitChange);
refreshUnlockState();
return () => {
gUnlockListeners.delete(emitChange);
if (!gUnlockListeners.size && gPollUnsubscribe) {
gPollUnsubscribe();
gPollUnsubscribe = null;
}
};
}
// Re-check the default/pinned state each time the user (re-)enters the Browser
// icon sub-pane, so a change made outside about:settings (setting default via
// the OS, pinning to the taskbar) is reflected without reloading the page. The
// first entry is already covered by the settings' setup(); this catches
// re-entry. "paneBrowserIcon" is the internal name of the "browserIcon" pane.
document.addEventListener("paneshown", event => {
if (event.detail?.category === "paneBrowserIcon") {
refreshUnlockState();
}
});
/**
* Build a moz-visual-picker option for a catalog icon.
*
* @param {string} id Catalog id (or "default").
* @param {string} l10nId Fluent id for the label.
* @param {string} preview chrome:// URI of the preview image.
* @returns {object}
*/
function iconOption(id, l10nId, preview) {
return {
value: id,
key: id,
l10nId,
controlAttrs: {
class: "browser-icon-item",
imagesrc: preview,
},
};
}
/**
* Returns the list of alternative browser icons from the catalog, as
* an option object that can be rendered by the moz-visual-picker.
*
* @param {boolean} isGated
* True if the gated browser icons should be returned, otherwise this
* will return the non-gated icons.
* @returns {object[]}
*/
function getOptions(isGated) {
let scheme = currentScheme();
let options = [];
for (let [id, entry] of Object.entries(lazy.ICON_CATALOG)) {
if (!!entry.gated == isGated) {
options.push(
iconOption(id, entry.l10nId, lazy.resolvePreview(entry, scheme))
);
}
}
return options;
}
// Re-resolve each option's preview for the current color scheme, so theme-aware
// icons show the right variant after a theme switch. Called from the pickers'
// getControlConfig so it runs on every (re-)render.
function resolveOptionPreviews(config) {
let scheme = currentScheme();
for (let option of config.options) {
let entry = lazy.ICON_CATALOG[option.value];
if (entry) {
option.controlAttrs = {
...option.controlAttrs,
imagesrc: lazy.resolvePreview(entry, scheme),
};
}
}
return config;
}
// Which ids each card owns. The active icon is a single value (the pref); each
// picker shows it only if it owns that id, otherwise it shows nothing selected.
function isBonusId(id) {
return !!lazy.ICON_CATALOG[id]?.gated;
}
function isBasicId(id) {
let entry = lazy.ICON_CATALOG[id];
return !!entry && !entry.gated;
}
/**
* Apply or revert the icon identified by a picker value. No pref on the picker
* settings: apply/revert own the side effects and update the pref themselves,
* which re-renders both pickers via the customIconIdPref dep.
*
* @param {string} val A catalog id, or "default" to revert.
*/
async function selectIcon(val) {
if (val === "default") {
await lazy.CustomIconManager.revert();
} else {
await lazy.CustomIconManager.apply(val);
}
}
Preferences.addAll([{ id: PREF_ICON_ID, type: "string" }]);
// Tracks the icon pref so the pickers re-render when the active icon changes
// (including from another window or the startup reconcile).
Preferences.addSetting({
id: "customIconIdPref",
pref: PREF_ICON_ID,
});
// Basic card picker: shows the active icon if it is a Basic id, else nothing.
Preferences.addSetting({
id: "customBrowserIconBasic",
deps: ["customIconIdPref"],
get(_, { customIconIdPref }) {
let id = customIconIdPref.value || "default";
return isBasicId(id) ? id : "";
},
set: selectIcon,
setup: watchScheme,
getControlConfig: resolveOptionPreviews,
});
// Bonus card picker: shows the active icon if it is a Bonus id, else nothing,
// and disables every option until the icons are unlocked.
Preferences.addSetting({
id: "customBrowserIconBonus",
deps: ["customIconIdPref"],
get(_, { customIconIdPref }) {
let id = customIconIdPref.value || "default";
return isBonusId(id) ? id : "";
},
set: selectIcon,
setup(emitChange) {
let stopUnlock = watchUnlockState(emitChange);
let stopScheme = watchScheme(emitChange);
return () => {
stopUnlock();
stopScheme();
};
},
getControlConfig(config) {
resolveOptionPreviews(config);
for (let option of config.options) {
option.disabled = !gIconsUnlocked;
}
return config;
},
});
// Header promo on the Bonus card, shown only while the icons are locked.
Preferences.addSetting({
id: "customBrowserIconRequirement",
setup: watchUnlockState,
visible: () => !gIconsUnlocked,
});
// Action buttons in the promo header. Each is shown only while its condition is
// unmet, performs that single action, then re-checks state so the UI (buttons,
// promo, Bonus picker) updates.
Preferences.addSetting({
id: "browserIconSetDefaultButton",
setup: watchUnlockState,
visible: () => !gIsDefault,
async onUserClick() {
await DefaultBrowserHelper.setDefaultBrowser();
refreshUnlockState();
},
});
Preferences.addSetting({
id: "browserIconPinButton",
setup: watchUnlockState,
visible: () => !gIsPinned,
async onUserClick() {
await window.getShellService().pinToTaskbar();
refreshUnlockState();
},
});
SettingGroupManager.registerGroups({
browserIconBasic: {
l10nId: "appearance-browser-icon-basic-group",
headingLevel: 2,
items: [
{
id: "customBrowserIconBasic",
control: "moz-visual-picker",
controlAttrs: { orientation: "vertical" },
options: getOptions(false /* isGated */),
},
],
},
browserIconBonus: {
l10nId: "appearance-browser-icon-bonus-group",
headingLevel: 2,
items: [
{
id: "customBrowserIconRequirement",
l10nId: "appearance-browser-icon-requirement",
control: "moz-promo",
controlAttrs: {
imagesrc: "chrome://global/skin/illustrations/kit-holding-lock.svg",
imagewidth: "small",
imagedisplay: "cover",
},
items: [
{
id: "browserIconSetDefaultButton",
l10nId: "appearance-browser-icon-set-default-button",
control: "moz-button",
slot: "actions",
},
{
id: "browserIconPinButton",
l10nId: "appearance-browser-icon-pin-button",
control: "moz-button",
slot: "actions",
},
],
},
{
id: "customBrowserIconBonus",
control: "moz-visual-picker",
controlAttrs: { orientation: "vertical" },
options: getOptions(true /* isGated */),
},
],
},
});