feat: auto-discover Firefox theme_icons from public assets (#2242)
Co-authored-by: oferitz <oferitz@Ofers-MacBook-Pro.local> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Aaron <aaronklinker1@gmail.com>
This commit is contained in:
@@ -159,6 +159,30 @@ export default defineConfig({
|
||||
|
||||
Alternatively, you can use [`@wxt-dev/auto-icons`](https://www.npmjs.com/package/@wxt-dev/auto-icons) to let WXT generate your icon at the required sizes.
|
||||
|
||||
### Firefox `theme_icons`
|
||||
|
||||
Firefox supports a [`theme_icons`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/action#theme_icons) field on the toolbar action that swaps between a light and dark variant based on the current browser theme.
|
||||
|
||||
When [targeting Firefox](/guide/essentials/target-different-browsers.md), WXT auto-discovers paired light/dark icons in the `public/` directory and attaches them to `action` (MV3) or `browser_action` (MV2). You only need to drop both variants next to your regular icons:
|
||||
|
||||
```plaintext
|
||||
public/
|
||||
├─ icon-16.png # regular (default_icon)
|
||||
├─ icon-light-16.png # Firefox light theme
|
||||
├─ icon-dark-16.png # Firefox dark theme
|
||||
├─ icon-32.png
|
||||
├─ icon-light-32.png
|
||||
└─ icon-dark-32.png
|
||||
```
|
||||
|
||||
A size is only included in `theme_icons` if **both** a light and a dark file are present. The following filename patterns are discovered:
|
||||
|
||||
<<< @/../packages/wxt/src/core/utils/theme-icons.ts#snippet
|
||||
|
||||
Only `.png` files are discovered today, even though Firefox supports `.svg` - follow [#1120](https://github.com/wxt-dev/wxt/issues/1120) for updates.
|
||||
|
||||
If you set `manifest.action.theme_icons` (or `manifest.browser_action.theme_icons`) explicitly in `wxt.config.ts`, WXT will not overwrite it.
|
||||
|
||||
## Permissions
|
||||
|
||||
> [Chrome docs](https://developer.chrome.com/docs/extensions/reference/permissions/)
|
||||
|
||||
@@ -531,6 +531,294 @@ describe('Manifest Utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('theme_icons auto-discovery (Firefox)', () => {
|
||||
const firefoxPopup = () =>
|
||||
fakePopupEntrypoint({
|
||||
options: {
|
||||
// @ts-expect-error: Force undefined instead of the random value
|
||||
mv2Key: null,
|
||||
},
|
||||
outputDir: outDir,
|
||||
skipped: false,
|
||||
});
|
||||
|
||||
it('should auto-discover paired light/dark icons and attach them to action in mv3', async () => {
|
||||
const popup = firefoxPopup();
|
||||
const buildOutput = fakeBuildOutput({
|
||||
publicAssets: [
|
||||
{ type: 'asset', fileName: 'icon-light-16.png' },
|
||||
{ type: 'asset', fileName: 'icon-dark-16.png' },
|
||||
{ type: 'asset', fileName: 'icon-light-32.png' },
|
||||
{ type: 'asset', fileName: 'icon-dark-32.png' },
|
||||
],
|
||||
});
|
||||
setFakeWxt({
|
||||
config: {
|
||||
browser: 'firefox',
|
||||
manifestVersion: 3,
|
||||
outDir,
|
||||
},
|
||||
});
|
||||
|
||||
const { manifest: actual } = await generateManifest(
|
||||
[popup],
|
||||
buildOutput,
|
||||
);
|
||||
|
||||
expect((actual.action as any).theme_icons).toEqual([
|
||||
{ light: 'icon-light-16.png', dark: 'icon-dark-16.png', size: 16 },
|
||||
{ light: 'icon-light-32.png', dark: 'icon-dark-32.png', size: 32 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should auto-discover and attach theme_icons to browser_action in mv2', async () => {
|
||||
const popup = firefoxPopup();
|
||||
const buildOutput = fakeBuildOutput({
|
||||
publicAssets: [
|
||||
{ type: 'asset', fileName: 'icon/16-light.png' },
|
||||
{ type: 'asset', fileName: 'icon/16-dark.png' },
|
||||
],
|
||||
});
|
||||
setFakeWxt({
|
||||
config: {
|
||||
browser: 'firefox',
|
||||
manifestVersion: 2,
|
||||
outDir,
|
||||
},
|
||||
});
|
||||
|
||||
const { manifest: actual } = await generateManifest(
|
||||
[popup],
|
||||
buildOutput,
|
||||
);
|
||||
|
||||
expect((actual.browser_action as any).theme_icons).toEqual([
|
||||
{ light: 'icon/16-light.png', dark: 'icon/16-dark.png', size: 16 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should support every naming pattern (suffix, prefix, with/without folder, with/without WxH)', async () => {
|
||||
const popup = firefoxPopup();
|
||||
const buildOutput = fakeBuildOutput({
|
||||
publicAssets: [
|
||||
// suffix / no folder
|
||||
{ type: 'asset', fileName: 'icon-light-16.png' },
|
||||
{ type: 'asset', fileName: 'icon-dark-16.png' },
|
||||
// suffix / WxH / no folder
|
||||
{ type: 'asset', fileName: 'icon-light-24x24.png' },
|
||||
{ type: 'asset', fileName: 'icon-dark-24x24.png' },
|
||||
// prefix / no folder
|
||||
{ type: 'asset', fileName: 'icon-32-light.png' },
|
||||
{ type: 'asset', fileName: 'icon-32-dark.png' },
|
||||
// suffix / folder
|
||||
{ type: 'asset', fileName: 'icon/light-48.png' },
|
||||
{ type: 'asset', fileName: 'icon/dark-48.png' },
|
||||
// prefix / folder
|
||||
{ type: 'asset', fileName: 'icons/64-light.png' },
|
||||
{ type: 'asset', fileName: 'icons/64-dark.png' },
|
||||
// prefix / WxH / folder
|
||||
{ type: 'asset', fileName: 'icon/128x128-light.png' },
|
||||
{ type: 'asset', fileName: 'icon/128x128-dark.png' },
|
||||
],
|
||||
});
|
||||
setFakeWxt({
|
||||
config: {
|
||||
browser: 'firefox',
|
||||
manifestVersion: 3,
|
||||
outDir,
|
||||
},
|
||||
});
|
||||
|
||||
const { manifest: actual } = await generateManifest(
|
||||
[popup],
|
||||
buildOutput,
|
||||
);
|
||||
|
||||
expect((actual.action as any).theme_icons).toEqual([
|
||||
{ light: 'icon-light-16.png', dark: 'icon-dark-16.png', size: 16 },
|
||||
{
|
||||
light: 'icon-light-24x24.png',
|
||||
dark: 'icon-dark-24x24.png',
|
||||
size: 24,
|
||||
},
|
||||
{ light: 'icon-32-light.png', dark: 'icon-32-dark.png', size: 32 },
|
||||
{ light: 'icon/light-48.png', dark: 'icon/dark-48.png', size: 48 },
|
||||
{ light: 'icons/64-light.png', dark: 'icons/64-dark.png', size: 64 },
|
||||
{
|
||||
light: 'icon/128x128-light.png',
|
||||
dark: 'icon/128x128-dark.png',
|
||||
size: 128,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should skip sizes that are missing a light or dark pair and log a warning', async () => {
|
||||
const popup = firefoxPopup();
|
||||
const buildOutput = fakeBuildOutput({
|
||||
publicAssets: [
|
||||
// Complete pair — should be kept
|
||||
{ type: 'asset', fileName: 'icon-light-16.png' },
|
||||
{ type: 'asset', fileName: 'icon-dark-16.png' },
|
||||
// Only light, no dark — should be dropped + warn
|
||||
{ type: 'asset', fileName: 'icon-light-32.png' },
|
||||
// Only dark, no light — should be dropped + warn
|
||||
{ type: 'asset', fileName: 'icon-dark-48.png' },
|
||||
],
|
||||
});
|
||||
setFakeWxt({
|
||||
config: {
|
||||
browser: 'firefox',
|
||||
manifestVersion: 3,
|
||||
outDir,
|
||||
},
|
||||
});
|
||||
|
||||
const { manifest: actual } = await generateManifest(
|
||||
[popup],
|
||||
buildOutput,
|
||||
);
|
||||
|
||||
expect((actual.action as any).theme_icons).toEqual([
|
||||
{ light: 'icon-light-16.png', dark: 'icon-dark-16.png', size: 16 },
|
||||
]);
|
||||
expect(wxt.logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Skipping theme icon size 32: found light variant',
|
||||
),
|
||||
);
|
||||
expect(wxt.logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Skipping theme icon size 48: found dark variant',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn when different naming patterns resolve to the same (size, variant)', async () => {
|
||||
const popup = firefoxPopup();
|
||||
const buildOutput = fakeBuildOutput({
|
||||
publicAssets: [
|
||||
// Both of these match the light/16 slot via different regexes.
|
||||
// WXT should keep the first and warn about the second.
|
||||
{ type: 'asset', fileName: 'icon-light-16.png' },
|
||||
{ type: 'asset', fileName: 'icon/light-16.png' },
|
||||
{ type: 'asset', fileName: 'icon-dark-16.png' },
|
||||
],
|
||||
});
|
||||
setFakeWxt({
|
||||
config: {
|
||||
browser: 'firefox',
|
||||
manifestVersion: 3,
|
||||
outDir,
|
||||
},
|
||||
});
|
||||
|
||||
const { manifest: actual } = await generateManifest(
|
||||
[popup],
|
||||
buildOutput,
|
||||
);
|
||||
|
||||
expect((actual.action as any).theme_icons).toEqual([
|
||||
{ light: 'icon-light-16.png', dark: 'icon-dark-16.png', size: 16 },
|
||||
]);
|
||||
expect(wxt.logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Multiple theme icon files matched size 16 variant "light"',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not auto-discover theme_icons for non-Firefox browsers', async () => {
|
||||
const popup = firefoxPopup();
|
||||
const buildOutput = fakeBuildOutput({
|
||||
publicAssets: [
|
||||
{ type: 'asset', fileName: 'icon-light-16.png' },
|
||||
{ type: 'asset', fileName: 'icon-dark-16.png' },
|
||||
],
|
||||
});
|
||||
setFakeWxt({
|
||||
config: {
|
||||
browser: 'chrome',
|
||||
manifestVersion: 3,
|
||||
outDir,
|
||||
},
|
||||
});
|
||||
|
||||
const { manifest: actual } = await generateManifest(
|
||||
[popup],
|
||||
buildOutput,
|
||||
);
|
||||
|
||||
expect((actual.action as any).theme_icons).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should respect user-provided theme_icons and not overwrite them', async () => {
|
||||
const userThemeIcons = [
|
||||
{ light: 'custom-light.png', dark: 'custom-dark.png', size: 16 },
|
||||
];
|
||||
const popup = fakePopupEntrypoint({
|
||||
options: {
|
||||
// @ts-expect-error: Force undefined
|
||||
mv2Key: null,
|
||||
themeIcons: userThemeIcons,
|
||||
},
|
||||
outputDir: outDir,
|
||||
skipped: false,
|
||||
});
|
||||
const buildOutput = fakeBuildOutput({
|
||||
publicAssets: [
|
||||
{ type: 'asset', fileName: 'icon-light-16.png' },
|
||||
{ type: 'asset', fileName: 'icon-dark-16.png' },
|
||||
{ type: 'asset', fileName: 'icon-light-32.png' },
|
||||
{ type: 'asset', fileName: 'icon-dark-32.png' },
|
||||
],
|
||||
});
|
||||
setFakeWxt({
|
||||
config: {
|
||||
browser: 'firefox',
|
||||
manifestVersion: 3,
|
||||
outDir,
|
||||
},
|
||||
});
|
||||
|
||||
const { manifest: actual } = await generateManifest(
|
||||
[popup],
|
||||
buildOutput,
|
||||
);
|
||||
|
||||
expect((actual.action as any).theme_icons).toEqual(userThemeIcons);
|
||||
});
|
||||
|
||||
it('should not attach theme_icons when there is no action at all', async () => {
|
||||
// A background entrypoint with no popup means no action is
|
||||
// declared, so we have nothing to attach theme_icons to.
|
||||
const background = fakeBackgroundEntrypoint({
|
||||
outputDir: outDir,
|
||||
skipped: false,
|
||||
});
|
||||
const buildOutput = fakeBuildOutput({
|
||||
publicAssets: [
|
||||
{ type: 'asset', fileName: 'icon-light-16.png' },
|
||||
{ type: 'asset', fileName: 'icon-dark-16.png' },
|
||||
],
|
||||
});
|
||||
setFakeWxt({
|
||||
config: {
|
||||
browser: 'firefox',
|
||||
manifestVersion: 3,
|
||||
outDir,
|
||||
},
|
||||
});
|
||||
|
||||
const { manifest: actual } = await generateManifest(
|
||||
[background],
|
||||
buildOutput,
|
||||
);
|
||||
|
||||
expect(actual.action).toBeUndefined();
|
||||
expect(actual.browser_action).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('content_scripts', () => {
|
||||
it('should group content scripts and styles together based on their manifest properties', async () => {
|
||||
const cs1: ContentScriptEntrypoint = {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { normalizePath } from './paths';
|
||||
import { writeFileIfDifferent } from './fs';
|
||||
import defu from 'defu';
|
||||
import { wxt } from '../wxt';
|
||||
import { addDiscoveredThemeIcons } from './theme-icons';
|
||||
import { ManifestV3WebAccessibleResource } from './types';
|
||||
import type { Browser } from '@wxt-dev/browser';
|
||||
|
||||
@@ -116,6 +117,10 @@ export async function generateManifest(
|
||||
|
||||
addEntrypoints(manifest, entrypoints, buildOutput);
|
||||
|
||||
if (wxt.config.browser === 'firefox') {
|
||||
addDiscoveredThemeIcons(manifest, buildOutput);
|
||||
}
|
||||
|
||||
if (wxt.config.command === 'serve') addDevModeCsp(manifest);
|
||||
if (wxt.config.command === 'serve') addDevModePermissions(manifest);
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Browser } from '@wxt-dev/browser';
|
||||
import type { BuildOutput, ThemeIcon } from '../../types';
|
||||
import { normalizePath } from './paths';
|
||||
import { wxt } from '../wxt';
|
||||
|
||||
/**
|
||||
* Firefox only.
|
||||
*
|
||||
* If the manifest has an `action` (MV3) or `browser_action` (MV2) and the user
|
||||
* has not already set `theme_icons` on it, discover light/dark icon pairs from
|
||||
* the public assets and attach them.
|
||||
*/
|
||||
export function addDiscoveredThemeIcons(
|
||||
manifest: Browser.runtime.Manifest,
|
||||
buildOutput: Omit<BuildOutput, 'manifest'>,
|
||||
): void {
|
||||
const action = manifest.action ?? manifest.browser_action;
|
||||
if (action == null) return;
|
||||
|
||||
// Respect explicit user config or popup entrypoint option.
|
||||
if ((action as { theme_icons?: ThemeIcon[] }).theme_icons != null) return;
|
||||
|
||||
const themeIcons = discoverThemeIcons(buildOutput);
|
||||
if (themeIcons == null) return;
|
||||
|
||||
(action as { theme_icons?: ThemeIcon[] }).theme_icons = themeIcons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan `publicAssets` for paired `-light`/`-dark` icon files and return the
|
||||
* sizes where both variants exist, sorted ascending. Returns `undefined` if no
|
||||
* complete pairs are found so callers can short-circuit.
|
||||
*/
|
||||
export function discoverThemeIcons(
|
||||
buildOutput: Omit<BuildOutput, 'manifest'>,
|
||||
): ThemeIcon[] | undefined {
|
||||
const bySize = new Map<number, { light?: string; dark?: string }>();
|
||||
|
||||
for (const asset of buildOutput.publicAssets) {
|
||||
for (const regex of themeIconRegex) {
|
||||
const match = asset.fileName.match(regex);
|
||||
if (match?.groups == null) continue;
|
||||
|
||||
const size = Number(match.groups.size);
|
||||
const variant = match.groups.variant as 'light' | 'dark';
|
||||
const entry = bySize.get(size) ?? {};
|
||||
|
||||
const incoming = normalizePath(asset.fileName);
|
||||
const existing = entry[variant];
|
||||
if (existing != null && existing !== incoming) {
|
||||
wxt.logger.warn(
|
||||
`Multiple theme icon files matched size ${size} variant "${variant}": keeping "${existing}", ignoring "${incoming}". Use a single naming pattern per (size, variant).`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
entry[variant] = incoming;
|
||||
bySize.set(size, entry);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const pairs: ThemeIcon[] = [];
|
||||
for (const [size, entry] of bySize) {
|
||||
if (entry.light != null && entry.dark != null) {
|
||||
pairs.push({ light: entry.light, dark: entry.dark, size });
|
||||
continue;
|
||||
}
|
||||
const present = entry.light != null ? 'light' : 'dark';
|
||||
const missing = entry.light != null ? 'dark' : 'light';
|
||||
const file = entry.light ?? entry.dark;
|
||||
wxt.logger.warn(
|
||||
`Skipping theme icon size ${size}: found ${present} variant ("${file}") but no matching ${missing} variant. Add the missing file to include this size in theme_icons.`,
|
||||
);
|
||||
}
|
||||
pairs.sort((a, b) => a.size - b.size);
|
||||
|
||||
return pairs.length > 0 ? pairs : undefined;
|
||||
}
|
||||
|
||||
// prettier-ignore
|
||||
// #region snippet
|
||||
const themeIconRegex = [
|
||||
/^icon-(?<variant>light|dark)-(?<size>[0-9]+)\.png$/, // icon-light-16.png
|
||||
/^icon-(?<variant>light|dark)-(?<size>[0-9]+)x[0-9]+\.png$/, // icon-light-16x16.png
|
||||
/^icon-(?<size>[0-9]+)-(?<variant>light|dark)\.png$/, // icon-16-light.png
|
||||
/^icon-(?<size>[0-9]+)x[0-9]+-(?<variant>light|dark)\.png$/, // icon-16x16-light.png
|
||||
/^icons?[/\\](?<variant>light|dark)-(?<size>[0-9]+)\.png$/, // icon/light-16.png | icons/light-16.png
|
||||
/^icons?[/\\](?<variant>light|dark)-(?<size>[0-9]+)x[0-9]+\.png$/, // icon/light-16x16.png
|
||||
/^icons?[/\\](?<size>[0-9]+)-(?<variant>light|dark)\.png$/, // icon/16-light.png
|
||||
/^icons?[/\\](?<size>[0-9]+)x[0-9]+-(?<variant>light|dark)\.png$/, // icon/16x16-light.png
|
||||
];
|
||||
// #endregion snippet
|
||||
Reference in New Issue
Block a user