Files

793 lines
26 KiB
JavaScript

/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const PREF_ICON_ID = "browser.shell.customIcon.id";
const PREF_ENABLED = "browser.shell.customIcon.enabled";
// True once we have ever created the shortcut.
const PREF_PER_USER_START_MENU_SHORTCUT_CREATED =
"browser.shell.customIcon.perUserStartMenuShortcutCreated";
/**
* Inlined catalog of selectable icons. Each entry has:
*
* iconResourceId: the Win32 resource ID of an icon embedded in firefox.exe
* at build time (declared in toolkit/xre/nsNativeAppSupportWin.h and
* browser/app/splash.rc). This is what gets applied: shortcuts reference
* it as firefox.exe,-<iconResourceId> and live windows load it directly.
* The catalog-id -> resource-id mapping is ABI: never remap or reuse an id
* once it has shipped, even if the icon is retired from the picker.
* preview: a chrome:// URI resolving to the same .ico shipped in omni.ja
* (see browser/components/shell/jar.mn). Used only to render a thumbnail in
* the about:settings picker; never used to apply the icon. PE resources are
* not addressable by a URL, hence the separate display asset.
* l10nId: Fluent id for the icon's about:settings label.
* gated: if true, the icon is a "Bonus" icon, offered only once the browser is
* both the default browser and pinned to the taskbar. This is purely an
* about:settings policy (the UI disables the option); CustomIconManager
* itself does not enforce it.
*
* Theme-aware icons (e.g. Minimal, a monochrome silhouette that's only legible
* against a matching background) instead carry a `variants` object keyed by
* color scheme ("dark"/"light"), each with its own iconResourceId + preview.
* `iconResourceId`/`preview` are read through resolveResourceId()/resolvePreview()
* so callers don't special-case theme-aware entries: the applied resource is
* chosen by the OS theme (the taskbar background), while the about:settings
* preview is chosen by the document's own color scheme (the surface it renders
* on). These differ only when the browser theme is overridden away from the OS.
*/
export const ICON_CATALOG = Object.freeze({
default: Object.freeze({
// The executable's own icon. "default" is the no-override state, selected by
// reverting (clearing the pref), so this resource id is never applied
// directly; the entry exists to give the picker a label and a preview. The
// preview is icon64 (rather than icon32) so it stays crisp on hi-dpi.
iconResourceId: 0,
preview: "chrome://branding/content/icon64.png",
l10nId: "appearance-browser-icon-default",
}),
retro2004: Object.freeze({
iconResourceId: 1100, // IDI_CUSTOM_RETRO2004
preview: "chrome://browser/content/icons/retro2004.ico",
l10nId: "appearance-browser-icon-retro2004",
}),
retro2017: Object.freeze({
iconResourceId: 1101, // IDI_CUSTOM_RETRO2017
preview: "chrome://browser/content/icons/retro2017.ico",
l10nId: "appearance-browser-icon-retro2017",
}),
pride: Object.freeze({
iconResourceId: 1106, // IDI_CUSTOM_PRIDE
preview: "chrome://browser/content/icons/pride.ico",
l10nId: "appearance-browser-icon-pride",
}),
minimal: Object.freeze({
l10nId: "appearance-browser-icon-minimal",
variants: Object.freeze({
dark: Object.freeze({
iconResourceId: 1102, // IDI_CUSTOM_MINIMAL_DARK
preview: "chrome://browser/content/icons/minimal-dark.ico",
}),
light: Object.freeze({
iconResourceId: 1103, // IDI_CUSTOM_MINIMAL_LIGHT
preview: "chrome://browser/content/icons/minimal-light.ico",
}),
}),
}),
kit: Object.freeze({
iconResourceId: 1107, // IDI_CUSTOM_KIT
preview: "chrome://browser/content/icons/kit.ico",
l10nId: "appearance-browser-icon-kit",
gated: true,
}),
pixelated: Object.freeze({
iconResourceId: 1104, // IDI_CUSTOM_PIXELATED
preview: "chrome://browser/content/icons/pixelated.ico",
l10nId: "appearance-browser-icon-pixelated",
gated: true,
}),
momo: Object.freeze({
iconResourceId: 1105, // IDI_CUSTOM_MOMO
preview: "chrome://browser/content/icons/momo.ico",
l10nId: "appearance-browser-icon-momo",
gated: true,
}),
});
/**
* Resolve a catalog entry's variant for a color scheme. Flat (theme-agnostic)
* entries are returned as-is; theme-aware entries return their dark/light
* variant.
*
* @param {object} entry A catalog entry.
* @param {"dark"|"light"} scheme
* @returns {object} An object with iconResourceId + preview.
*/
function resolveVariant(entry, scheme) {
return entry.variants ? entry.variants[scheme] : entry;
}
/**
* The embedded resource ID to apply for an entry, given the OS color scheme.
* Exported for tests; the manager itself resolves against the OS taskbar theme.
*
* @param {object} entry A catalog entry.
* @param {"dark"|"light"} scheme The OS color scheme.
* @returns {number}
*/
export function resolveResourceId(entry, scheme) {
return resolveVariant(entry, scheme).iconResourceId;
}
/**
* The preview URI to display for an entry, given a color scheme. Exported for
* the about:settings picker, which resolves against the document's own color
* scheme rather than the OS theme.
*
* @param {object} entry A catalog entry.
* @param {"dark"|"light"} scheme
* @returns {string}
*/
export function resolvePreview(entry, scheme) {
return resolveVariant(entry, scheme).preview;
}
const lazy = {};
XPCOMUtils.defineLazyServiceGetters(lazy, {
ShellService: [
"@mozilla.org/browser/shell-service;1",
Ci.nsIWindowsShellService,
],
WinTaskbar: ["@mozilla.org/windows-taskbar;1", Ci.nsIWinTaskbar],
});
ChromeUtils.defineESModuleGetters(lazy, {
SelectableProfileService:
"resource:///modules/profiles/SelectableProfileService.sys.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "logConsole", function () {
return console.createInstance({
prefix: "CustomIconManager",
maxLogLevel: Services.prefs.getBoolPref(
"browser.shell.customIcon.log",
false
)
? "Debug"
: "Warn",
});
});
/**
* Absolute path to the currently running browser executable. Custom icons are
* embedded as resources in this executable, so it is both the icon source for
* applying a custom icon and (with index 0) the default-icon source when
* reverting Windows shortcuts.
*
* @returns {string}
*/
function browserExePath() {
return Services.dirsvc.get("XREExeF", Ci.nsIFile).path;
}
/**
* Directory holding the user's pinned taskbar shortcuts. Mirrors the path
* EnumerateInstallShortcuts scans in nsWindowsShellService.cpp.
*
* @returns {string}
*/
function taskbarPinDir() {
return PathUtils.join(
Services.dirsvc.get("AppData", Ci.nsIFile).path,
"Microsoft",
"Internet Explorer",
"Quick Launch",
"User Pinned",
"TaskBar"
);
}
/**
* Locate the shortcuts this install owns, by AUMID.
*
* Only the two locations that can govern the taskbar icon are reported: the
* per-user Start Menu (which shadows the system-wide one) and the taskbar pin.
*
* @returns {Promise<?{inStartMenu: boolean, pinnedToTaskbar: boolean}>}
* Null if the shortcuts could not be enumerated.
*/
async function findInstallShortcuts() {
let shortcuts;
try {
shortcuts = await lazy.ShellService.enumerateInstallShortcuts(
lazy.WinTaskbar.defaultGroupId
);
} catch (ex) {
lazy.logConsole.error("enumerateInstallShortcuts failed", ex);
return null;
}
let anyUnder = dir => {
let prefix = dir.toLowerCase() + "\\";
return shortcuts.some(p => p.toLowerCase().startsWith(prefix));
};
return {
inStartMenu: anyUnder(Services.dirsvc.get("Progs", Ci.nsIFile).path),
pinnedToTaskbar: anyUnder(taskbarPinDir()),
};
}
/**
* Apply the given executable icon to every Windows .lnk file that this install
* owns (taskbar pin, per-user Desktop, per-user Start Menu).
*
* Per-shortcut failures are logged but do not abort the rest of the
* iteration; partial success is permitted.
*
* @param {string} iconPath Absolute path to the icon source (the executable).
* @param {number} iconResourceId Resource ID of the icon within iconPath (e.g.
* 1100 for IDI_CUSTOM_RETRO2004), or 0 for the executable's default icon.
* setShortcutsIcon owns the Win32 encoding of this reference.
* @returns {Promise<boolean>} True if at least one shortcut was updated.
*/
async function applyIconToWindowsShortcuts(iconPath, iconResourceId) {
let aumid = lazy.WinTaskbar.defaultGroupId;
let shortcuts = [];
try {
shortcuts = await lazy.ShellService.enumerateInstallShortcuts(aumid);
} catch (ex) {
lazy.logConsole.error("enumerateInstallShortcuts failed", ex);
return false;
}
lazy.logConsole.debug(
`enumerateInstallShortcuts(${aumid}) matched ${shortcuts.length} ` +
`shortcut(s): ${shortcuts.join(", ")}`
);
if (!shortcuts.length) {
lazy.logConsole.warn(
`No shortcuts matched this install (AUMID ${aumid}); nothing to update. ` +
`Only shortcuts created by Firefox carry this AUMID - hand-made ` +
`Explorer shortcuts are not modified.`
);
return false;
}
try {
await lazy.ShellService.setShortcutsIcon(
shortcuts,
iconPath,
iconResourceId
);
} catch (ex) {
if (ex.result == Cr.NS_ERROR_NOT_AVAILABLE) {
lazy.logConsole.error("Could not update any shortcut icons.");
return false;
}
lazy.logConsole.error(
"Fatal error while attempting to update short icons:",
ex
);
return false;
}
lazy.logConsole.debug(
`Set icon resource ${iconResourceId} from "${iconPath}" on ` +
`${shortcuts.length} shortcut(s).`
);
return true;
}
/**
* Push the runtime icon override to every top-level Windows window in this
* process (excepting private browsing windows and web application windows).
* A resource ID of 0 reverts windows to the default executable icon.
*
* @param {number} iconResourceId Win32 resource ID of an icon embedded in the
* executable, or 0 to clear.
*/
function applyRuntimeWindowsIcon(iconResourceId) {
try {
lazy.WinTaskbar.setAllWindowIcons(iconResourceId);
} catch (ex) {
lazy.logConsole.error("setAllWindowIcons failed", ex);
}
}
// Values of the Personalize\SystemUsesLightTheme registry value (the OS "Windows
// mode" that governs the taskbar). Exported so tests can drive osColorScheme().
export const OS_LIGHT = 1;
export const OS_DARK = 0;
/**
* The OS color scheme that governs the taskbar / Start Menu background, where
* applied shortcut icons are shown. Read from the Windows "system" theme
* setting (Personalize\SystemUsesLightTheme) so it reflects the OS regardless
* of any browser theme override - the taskbar follows the OS, not Firefox.
* There is no JS API for this (LookAndFeel is C++-only; nsIXULRuntime exposes
* only the browser-derived chrome scheme), so we read the registry directly,
* as Gecko's own nsLookAndFeel does.
*
* @returns {"dark"|"light"}
*/
function osColorScheme() {
let key = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
Ci.nsIWindowsRegKey
);
try {
key.open(
Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
Ci.nsIWindowsRegKey.ACCESS_READ
);
// Absent on older Windows, which only had the light theme.
if (key.hasValue("SystemUsesLightTheme")) {
return key.readIntValue("SystemUsesLightTheme") === OS_DARK
? "dark"
: "light";
}
} catch (ex) {
lazy.logConsole.warn("Could not read OS theme; assuming light.", ex);
} finally {
try {
key.close();
} catch (ex) {}
}
return "light";
}
// The OS scheme used the last time the active icon was applied, so a
// look-and-feel change that doesn't flip the taskbar theme (font/color changes
// also fire the notification) is ignored.
let gLastAppliedScheme = null;
let gObserversRegistered = false;
export const CustomIconManager = {
/**
* Whether the custom icon is supported on this install.
*
* @returns {boolean}
*/
get supported() {
return (
AppConstants.platform === "win" &&
!Services.sysinfo.getProperty("hasWinPackageId")
);
},
/**
* Make the icon identified by `id` the active custom icon for this
* install. On Windows this:
*
* 1. Updates every per-user .lnk this install owns to reference the
* embedded icon resource (firefox.exe,-<resource-id>).
* 2. Records the choice in a pref.
* 3. Pushes the icon to live windows via WM_SETICON.
*
* @param {string} id A key in ICON_CATALOG.
* @returns {Promise<void>}
* @throws {Error} If `id` is not in the catalog, the platform is not Windows,
* or this is an MSIX (packaged) build, where the feature is
* unsupported.
*/
async apply(id) {
if (AppConstants.platform !== "win") {
throw new Error("Custom icon is only supported on Windows.");
}
if (Services.sysinfo.getProperty("hasWinPackageId")) {
throw new Error(
"Custom browser icons are not supported on MSIX (packaged) builds."
);
}
let entry = ICON_CATALOG[id];
if (!entry) {
throw new Error(`Unknown icon id: ${id}`);
}
// Captured before the pref is written so re-applies of the already-active
// icon (the theme observer, the startup reconcile) can be distinguished
// from a genuine change to a different icon.
let previousId = this.currentId;
let scheme = osColorScheme();
let iconResourceId = resolveResourceId(entry, scheme);
let updated = await applyIconToWindowsShortcuts(
browserExePath(),
iconResourceId
);
if (!updated) {
lazy.logConsole.warn(
`apply("${id}"): no Windows shortcuts were updated. The running ` +
`window icon will change, but desktop/Start Menu/taskbar shortcuts ` +
`will not. See the log above for why.`
);
}
Services.prefs.setStringPref(PREF_ICON_ID, id);
applyRuntimeWindowsIcon(iconResourceId);
gLastAppliedScheme = scheme;
if (id !== previousId) {
Glean.customIcon.changed.record({ icon_id: id });
}
},
/**
* Revert all per-user shortcuts and the runtime icon for this process back
* to the default browser icon, and clear the pref.
*
* Safe to call when no custom icon is currently active.
*
* @returns {Promise<void>}
*/
async revert() {
if (AppConstants.platform !== "win") {
return;
}
let previousId = this.currentId;
await applyIconToWindowsShortcuts(browserExePath(), 0);
Services.prefs.clearUserPref(PREF_ICON_ID);
applyRuntimeWindowsIcon(0);
// Reverting is a change to the "default" icon, recorded only when a real
// (catalog-known, non-default) custom icon was active. This excludes both a
// revert() over the already-default state and the startup reconcile that
// reverts an unknown id (e.g. a retired or newer-build icon) - neither is a
// user changing their icon.
if (ICON_CATALOG[previousId] && previousId !== "default") {
Glean.customIcon.changed.record({ icon_id: "default" });
}
},
/**
* Eagerly register the runtime icon override before any browser windows
* are created, so that the first window picks up the custom icon at
* construction time rather than flashing the default icon.
*
* Synchronous and does no I/O: reads the pref, resolves it against the
* catalog, and pushes the resource ID to WinTaskbar. If the id is no longer
* in the catalog, ensureAppliedOrRevert() reconciles later.
*
* Intended to be called from a browser-before-ui-startup hook.
*/
applyRuntimeOverrideForStartup() {
if (!this.supported) {
return;
}
// Register before the no-icon early-return so icons chosen later in the
// session are still re-applied when the OS theme flips.
this.registerObservers();
let entry = ICON_CATALOG[this.currentId];
if (!entry) {
return;
}
applyRuntimeWindowsIcon(resolveResourceId(entry, osColorScheme()));
},
/**
* Adds various system observers in order to update icon state. This
* method is idempotent until observers are unregistered. Observers are
* automatically unregistered on xpcom-shutdown.
*/
registerObservers() {
if (gObserversRegistered) {
return;
}
lazy.logConsole.debug("Adding observers");
Services.obs.addObserver(this, "look-and-feel-changed");
Services.obs.addObserver(this, "xpcom-shutdown");
Services.obs.addObserver(this, "sps-profiles-updated");
gObserversRegistered = true;
lazy.logConsole.debug("Observers successfully added");
},
/**
* Unregisters observers added in registerObservers.
*/
unregisterObservers() {
if (!gObserversRegistered) {
return;
}
lazy.logConsole.debug("Removing observers");
Services.obs.removeObserver(this, "look-and-feel-changed");
Services.obs.removeObserver(this, "xpcom-shutdown");
Services.obs.removeObserver(this, "sps-profiles-updated");
gObserversRegistered = false;
lazy.logConsole.debug("Observers successfully removed");
},
observe(_subject, topic, data) {
switch (topic) {
case "xpcom-shutdown": {
this.unregisterObservers();
break;
}
case "look-and-feel-changed": {
let entry = ICON_CATALOG[this.currentId];
if (entry?.variants && osColorScheme() !== gLastAppliedScheme) {
this.apply(this.currentId).catch(ex =>
lazy.logConsole.error(
"Re-applying icon after theme change failed",
ex
)
);
}
break;
}
case "sps-profiles-updated": {
// The selectable profiles issued an update, which might mean that our icon
// needs to change. If the writer is remote, then re-evaluate which icon
// should be displayed.
lazy.logConsole.debug("Saw sps-profiles-updated: ", data);
if (data == "remote") {
this.ensureAppliedOrRevert(true /* remoteProfileUpdated */).catch(
ex =>
lazy.logConsole.error(
"Re-applying icon after a remote profile update failed",
ex
)
);
}
break;
}
}
},
/**
* Reconcile pref state with the shipped catalog at startup. If a custom icon
* is recorded in prefs and still exists in this build's catalog, push it to
* runtime windows. If the id is unknown (e.g. an older build that never
* shipped it, or an icon removed from the catalog), revert the .lnks to the
* default and clear the pref.
*
* Intended to be called from StartupOSIntegration once per process, or when
* we are notified that selectable profiles have been updated.
*
* @param {boolean} remoteProfileUpdated
* True if we're being called because a remote profile updated.
* @returns {Promise<void>}
*/
async ensureAppliedOrRevert(remoteProfileUpdated = false) {
if (!this.supported) {
return;
}
if (!remoteProfileUpdated) {
// At startup the shared custom-icon pref may still be loading from the
// selectable-profiles database, so we wait for that load to finish so we
// reconcile against the value synced from other profiles rather than this
// profile's stale copy (there's no sps-profiles-updated notification at
// startup to correct us afterward).
//
// SelectableProfileService.init() is idempotent, so this is a cheap journey
// through the microtask queue during the non-startup case.
await lazy.SelectableProfileService.init();
}
if (!Services.prefs.getBoolPref(PREF_ENABLED, false)) {
if (this.currentId) {
await this.revert();
}
return;
}
if (await this.shouldDisableForMissingShortcut()) {
lazy.logConsole.warn(
"The taskbar icon can no longer be overridden; disabling custom icons."
);
Services.prefs.setBoolPref(PREF_ENABLED, false);
await this.revert();
return;
}
let id = this.currentId;
// Record the active icon once per session. An unset pref is the default
// (no-override) icon.
Glean.customIcon.current.set(id || "default");
if (!id) {
if (remoteProfileUpdated) {
// It's possible that we previously had an ID, but don't any longer, since
// a remote profile cleared it. In that case, we can assume that the other
// profile did most of the reversion work, but we'll go ahead and update
// the icon we're setting for windows at runtime to the default.
applyRuntimeWindowsIcon(0);
}
return;
}
let entry = ICON_CATALOG[id];
if (!entry) {
lazy.logConsole.warn(`Custom icon id not in catalog, reverting: ${id}`);
await this.revert();
return;
}
if (entry.variants) {
// The OS theme may have flipped while the browser was closed, so re-apply
// the matching variant to shortcuts + runtime, and start watching for
// further changes.
await this.apply(id);
return;
}
applyRuntimeWindowsIcon(resolveResourceId(entry, osColorScheme()));
},
/**
* Whether the installer's system-wide (all-users) Start Menu shortcut for this
* install exists.
*
* @returns {Promise<boolean>} True if the all-users Start Menu shortcut exists.
*/
async hasSystemWideStartMenuShortcut() {
if (AppConstants.platform !== "win") {
return false;
}
let programData = Services.env.get("ProgramData");
if (!programData) {
return false;
}
let path = PathUtils.join(
programData,
"Microsoft",
"Windows",
"Start Menu",
"Programs",
AppConstants.MOZ_APP_DISPLAYNAME_DO_NOT_USE + ".lnk"
);
return IOUtils.exists(path);
},
/**
* Whether the custom icon feature should be turned off because we can no
* longer override the taskbar icon.
*
* @returns {Promise<boolean>} True if the feature can no longer take effect.
*/
async shouldDisableForMissingShortcut() {
// We're okay with creating a shortcut for the first time, do not force
// disable the feature just yet.
if (
!Services.prefs.getBoolPref(
PREF_PER_USER_START_MENU_SHORTCUT_CREATED,
false
)
) {
return false;
}
let shortcuts = await findInstallShortcuts();
if (!shortcuts || shortcuts.inStartMenu) {
return false;
}
return (
!shortcuts.pinnedToTaskbar &&
(await this.hasSystemWideStartMenuShortcut())
);
},
/**
* Create a per-user Start Menu shortcut for this install if required.
*
* The taskbar gets its icon from Start Menu shortcuts with a matching AUMID,
* and prioritizes the shortcut present in the user's Roaming folder over
* the system-wide Start Menu directory.
*
* No-op if shortcut already exists. Once created, we don't try again.
*
* Intended to run after ensureAppliedOrRevert() has finished.
*
* @returns {Promise<void>}
*/
async maybeCreatePerUserStartMenuShortcut() {
if (!this.supported) {
return;
}
if (
!Services.prefs.getBoolPref(PREF_ENABLED, false) ||
Services.prefs.getBoolPref(
PREF_PER_USER_START_MENU_SHORTCUT_CREATED,
false
)
) {
return;
}
// The taskbar icon falls back to the window icon if a system-wide
// start menu shortcut isn't present.
if (!(await this.hasSystemWideStartMenuShortcut())) {
return;
}
let shortcuts = await findInstallShortcuts();
if (!shortcuts) {
return;
}
if (shortcuts.inStartMenu) {
Services.prefs.setBoolPref(
PREF_PER_USER_START_MENU_SHORTCUT_CREATED,
true
);
return;
}
let exeFile = Services.dirsvc.get("XREExeF", Ci.nsIFile);
// The installer names shortcuts "${BrandShortName}.lnk"
// (from MOZ_APP_DISPLAYNAME in defines.nsi.in), so we use the
// same build-time constant to mirror that naming convention.
let name = AppConstants.MOZ_APP_DISPLAYNAME_DO_NOT_USE + ".lnk";
let strings = new Localization(
["branding/brand.ftl", "browser/browser.ftl"],
true
);
let [description] = await strings.formatValues([
"browser-shortcut-description",
]);
try {
await lazy.ShellService.createShortcut(
exeFile,
[],
description,
exeFile,
0,
lazy.WinTaskbar.defaultGroupId,
"Programs",
name
);
Services.prefs.setBoolPref(
PREF_PER_USER_START_MENU_SHORTCUT_CREATED,
true
);
} catch (ex) {
lazy.logConsole.error("Creating per-user install shortcut failed", ex);
}
},
/**
* Add and remove the taskbar buttons associated with the browser's AUMID.
* This forces the button to rebind to the newly created shortcut, so we
* could read from it without a restart.
*
* @returns {void}
*/
refreshTaskbarButtons() {
if (AppConstants.platform !== "win") {
return;
}
try {
lazy.WinTaskbar.refreshTaskbarButtons();
} catch (ex) {
lazy.logConsole.error("refreshTaskbarButtons failed", ex);
return;
}
// Cycling the taskbar buttons via DeleteTab/AddTab discards any overlay
// icon state (profile badge). Notify so consumers can re-apply it.
Services.obs.notifyObservers(null, "taskbar-buttons-refreshed");
},
};
XPCOMUtils.defineLazyPreferenceGetter(
CustomIconManager,
"currentId",
PREF_ICON_ID,
""
);