From 5f799ca23ea10c70bf59f646aeaddac1948748a7 Mon Sep 17 00:00:00 2001 From: Ofer Itzhaki Date: Sat, 11 Apr 2026 23:37:50 +0300 Subject: [PATCH] feat: auto-discover Firefox `theme_icons` from public assets (#2242) Co-authored-by: oferitz Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Aaron --- docs/guide/essentials/config/manifest.md | 24 ++ .../src/core/utils/__tests__/manifest.test.ts | 288 ++++++++++++++++++ packages/wxt/src/core/utils/manifest.ts | 5 + packages/wxt/src/core/utils/theme-icons.ts | 93 ++++++ 4 files changed, 410 insertions(+) create mode 100644 packages/wxt/src/core/utils/theme-icons.ts diff --git a/docs/guide/essentials/config/manifest.md b/docs/guide/essentials/config/manifest.md index 6af5f93e..73811151 100644 --- a/docs/guide/essentials/config/manifest.md +++ b/docs/guide/essentials/config/manifest.md @@ -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/) diff --git a/packages/wxt/src/core/utils/__tests__/manifest.test.ts b/packages/wxt/src/core/utils/__tests__/manifest.test.ts index 5775be31..72b37d65 100644 --- a/packages/wxt/src/core/utils/__tests__/manifest.test.ts +++ b/packages/wxt/src/core/utils/__tests__/manifest.test.ts @@ -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 = { diff --git a/packages/wxt/src/core/utils/manifest.ts b/packages/wxt/src/core/utils/manifest.ts index 5ce6fdd7..cad1bbdb 100644 --- a/packages/wxt/src/core/utils/manifest.ts +++ b/packages/wxt/src/core/utils/manifest.ts @@ -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); diff --git a/packages/wxt/src/core/utils/theme-icons.ts b/packages/wxt/src/core/utils/theme-icons.ts new file mode 100644 index 00000000..55dc6934 --- /dev/null +++ b/packages/wxt/src/core/utils/theme-icons.ts @@ -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, +): 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, +): ThemeIcon[] | undefined { + const bySize = new Map(); + + 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-(?light|dark)-(?[0-9]+)\.png$/, // icon-light-16.png + /^icon-(?light|dark)-(?[0-9]+)x[0-9]+\.png$/, // icon-light-16x16.png + /^icon-(?[0-9]+)-(?light|dark)\.png$/, // icon-16-light.png + /^icon-(?[0-9]+)x[0-9]+-(?light|dark)\.png$/, // icon-16x16-light.png + /^icons?[/\\](?light|dark)-(?[0-9]+)\.png$/, // icon/light-16.png | icons/light-16.png + /^icons?[/\\](?light|dark)-(?[0-9]+)x[0-9]+\.png$/, // icon/light-16x16.png + /^icons?[/\\](?[0-9]+)-(?light|dark)\.png$/, // icon/16-light.png + /^icons?[/\\](?[0-9]+)x[0-9]+-(?light|dark)\.png$/, // icon/16x16-light.png +]; +// #endregion snippet