Skip to content
1 change: 1 addition & 0 deletions cspell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ words:
- USSS
- verkit
- vitepress
- waterfox
- webext
- webextension
- webextrc
Expand Down
18 changes: 17 additions & 1 deletion docs/guide/essentials/target-different-browsers.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,28 @@ if (import.meta.env.FIREFOX) {

Read about [Built-in Environment Variables](/guide/essentials/config/environment-variables.html#built-in-environment-variables) for more details.

## Map Custom Browsers

WXT knows how a handful of labels behave: `chrome`, `edge`, `brave`, `opera` and `chromium` generate Chromium code, `firefox` generates Firefox code, and `safari` and `ios` generate Safari code.

When you build with a custom label, WXT falls back to Chromium, since it has no way to know what that label runs in. Use `targetBrowserMap` to tell it:

```ts
export default defineConfig({
targetBrowserMap: {
waterfox: 'firefox',
},
});
```

Now `wxt -b waterfox` generates the same manifest, and the same default manifest version, as `wxt -b firefox`. The build still outputs to `.output/waterfox-mv2`, and `import.meta.env.BROWSER` still reports `"waterfox"`, so output paths and runtime checks keep the label you built with.

## Target a Manifest Version

To target specific manifest versions, use the `--mv2` or `--mv3` CLI flags.

:::tip Default Manifest Version
By default, WXT will target MV2 for Safari and Firefox and MV3 for all other browsers.
By default, WXT will target MV2 for Safari and Firefox, including custom labels mapped to them, and MV3 for all other browsers.
:::

Similar to the browser, you can get the target manifest version at runtime using the [built-in environment variable](/guide/essentials/config/environment-variables.html#built-in-environment-variables):
Expand Down
58 changes: 58 additions & 0 deletions packages/wxt/e2e/tests/manifest-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,62 @@ describe('Manifest Content', () => {
expect(safariManifest.background.persistent).toBe(expected);
},
);

it('should generate the same manifest for an ios build as for a safari build', async () => {
const project = new TestProject();

project.addFile(
'entrypoints/background.ts',
`export default defineBackground({ main: () => {} })`,
);
project.addFile('entrypoints/popup.html', '<html></html>');
project.addFile(
'entrypoints/one.content/index.ts',
`import './style.css';

export default defineContentScript({
matches: ['*://google.com/*'],
cssInjectionMode: 'ui',
main: () => {},
})`,
);
project.addFile('entrypoints/one.content/style.css', `body { color: red }`);

await project.build({ browser: 'safari' });
const safariManifest = await project.getOutputManifest(
'.output/safari-mv2/manifest.json',
);

// The output directory and the default manifest version still use the raw
// `--browser` label, only the generated manifest follows the actual browser.
await project.build({ browser: 'ios' });
const iosManifest = await project.getOutputManifest(
'.output/ios-mv2/manifest.json',
);

expect(iosManifest).toEqual(safariManifest);
});

it('should apply a custom targetBrowserMap', async () => {
const project = new TestProject();
project.setConfigFileConfig({
targetBrowserMap: {
waterfox: 'firefox',
},
});

project.addFile(
'entrypoints/background.ts',
`export default defineBackground({ main: () => {} })`,
);

await project.build({ browser: 'waterfox' });
const manifest = await project.getOutputManifest(
'.output/waterfox-mv2/manifest.json',
);

// Firefox is the only browser that defaults to MV2, so an MV2 build of a
// custom label proves the mapping was applied.
expect(manifest.manifest_version).toBe(2);
});
});
37 changes: 37 additions & 0 deletions packages/wxt/src/internal-utils/target-browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { ActualBrowser, TargetBrowser, TargetBrowserMap } from '../types';

/**
* Default mapping from `--browser` values to the actual browser WXT generates
* code for. Custom `--browser` values are only mapped when the project adds an
* entry for them in its `targetBrowserMap` config.
*/
export const defaultTargetBrowserMap: TargetBrowserMap = {
chrome: 'chromium',
edge: 'chromium',
brave: 'chromium',
opera: 'chromium',
chromium: 'chromium',
firefox: 'firefox',
safari: 'safari',
ios: 'safari',
};

/**
* Resolve the actual browser to generate code for from a `--browser` value.
*
* Values missing from the map fall back to `chromium`, the code WXT generated
* for unrecognized labels before `targetBrowserMap` existed. Projects can opt a
* custom label into Firefox or Safari behavior by mapping it.
*/
export function resolveActualBrowser(
browser: TargetBrowser,
targetBrowserMap: TargetBrowserMap = defaultTargetBrowserMap,
): ActualBrowser {
// Check own properties, so `--browser constructor` doesn't resolve to
// `Object.prototype.constructor`.
const mapped = Object.hasOwn(targetBrowserMap, browser)
? targetBrowserMap[browser]
: undefined;

return mapped ?? 'chromium';
}
7 changes: 7 additions & 0 deletions packages/wxt/src/internal-utils/testing/fake-objects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import { mock } from 'vitest-mock-extended';
import { vi } from 'vitest';
import { setWxtForTesting } from '../../internal/wxt';
import { resolveActualBrowser } from '../target-browser';
import type { Browser } from '@wxt-dev/browser';

faker.seed(__TEST_SEED__);
Expand Down Expand Up @@ -232,6 +233,7 @@ export const fakeResolvedConfig = fakeObjectCreator<ResolvedConfig>(() => {

return {
browser,
actualBrowser: resolveActualBrowser(browser),
targetBrowsers: [],
command,
entrypointsDir: fakeDir(),
Expand Down Expand Up @@ -345,6 +347,11 @@ export const fakeWxtDevServer = fakeObjectCreator<WxtDevServer>(() => ({

export function setFakeWxt(overrides?: DeepPartial<Wxt>) {
const wxt = fakeWxt(overrides);
// Mirror resolveConfig: `actualBrowser` is resolved from `browser` unless
// the test overrides it directly.
if (overrides?.config?.actualBrowser == null) {
wxt.config.actualBrowser = resolveActualBrowser(wxt.config.browser);
}
setWxtForTesting(wxt);
return wxt;
}
Expand Down
197 changes: 197 additions & 0 deletions packages/wxt/src/internal/__tests__/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ import {
Entrypoint,
OutputAsset,
TargetManifestVersion,
ActualBrowser,
} from '../../types';
import { wxt } from '../wxt';
import { mock } from 'vitest-mock-extended';
import type { Browser } from '@wxt-dev/browser';
import { resolveActualBrowser } from '../../internal-utils/target-browser';

const outDir = '/output';
const contentScriptOutDir = '/output/content-scripts';
Expand Down Expand Up @@ -1380,6 +1382,52 @@ describe('Manifest Utils', () => {
]);
}
});

it('should key browser-specific checks off actualBrowser, not the raw browser label', async () => {
const cs: ContentScriptEntrypoint = {
type: 'content-script',
name: 'one',
inputPath: 'entrypoints/one.content.ts',
outputDir: contentScriptOutDir,
options: {
matches: ['*://google.com/*'],
cssInjectionMode: 'ui',
},
skipped: false,
};
const styles: OutputAsset = {
type: 'asset',
fileName: 'content-scripts/one.css',
};

const entrypoints = [cs];
const buildOutput: Omit<BuildOutput, 'manifest'> = {
publicAssets: [],
steps: [{ entrypoints: cs, chunks: [styles] }],
};

setFakeWxt({
config: {
outDir,
command: 'build',
manifestVersion: 3,
browser: 'ios',
actualBrowser: 'safari',
},
});

const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
);

expect(actual.web_accessible_resources).toEqual([
{
matches: ['*://google.com/*'],
resources: ['content-scripts/one.css'],
},
]);
});
});

describe('registration', () => {
Expand Down Expand Up @@ -2231,6 +2279,155 @@ describe('Manifest Utils', () => {
);
});
});

describe('actualBrowser', () => {
const background = fakeBackgroundEntrypoint({
outputDir: outDir,
options: { persistent: true, type: 'module' },
skipped: false,
});
const popup = fakePopupEntrypoint({
outputDir: outDir,
options: {
actionType: 'page_action',
defaultIcon: { '16': '/icon/16.png' },
defaultTitle: 'Default Title',
},
skipped: false,
});
const options = fakeOptionsEntrypoint({
outputDir: outDir,
options: {
openInTab: true,
chromeStyle: true,
browserStyle: true,
},
skipped: false,
});
const sidepanel = fakeSidepanelEntrypoint({
outputDir: outDir,
options: {
browserStyle: true,
defaultTitle: 'Sidepanel',
},
skipped: false,
});
const styles: OutputAsset = {
type: 'asset',
fileName: 'content-scripts/one.css',
};
const contentScript: ContentScriptEntrypoint = {
type: 'content-script',
name: 'one',
inputPath: 'entrypoints/one.content.ts',
outputDir: contentScriptOutDir,
options: {
matches: ['*://google.com/*'],
cssInjectionMode: 'ui',
},
skipped: false,
};
const entrypoints: Entrypoint[] = [
background,
popup,
options,
sidepanel,
contentScript,
];
const buildOutput: Omit<BuildOutput, 'manifest'> = {
publicAssets: [],
steps: [{ entrypoints: contentScript, chunks: [styles] }],
};

async function generateFor(params: {
browser: string;
actualBrowser: ActualBrowser;
manifestVersion: TargetManifestVersion;
}) {
setFakeWxt({
config: {
outDir,
command: 'build',
mode: 'production',
browser: params.browser,
actualBrowser: params.actualBrowser,
manifestVersion: params.manifestVersion,
manifest: {
name: 'wxt-test',
version: '1.2.3',
version_name: '1.2.3-beta',
},
},
});

return (await generateManifest(entrypoints, buildOutput)).manifest;
}

const getDynamicUrlFlags = (manifest: Browser.runtime.Manifest) => {
const resources = manifest.web_accessible_resources;
if (!Array.isArray(resources)) return [];

return resources.map((resource) =>
typeof resource === 'object' && 'use_dynamic_url' in resource
? Boolean(resource.use_dynamic_url)
: undefined,
);
};

it.each<[TargetManifestVersion]>([[2], [3]])(
'should generate the same manifest for an ios build as a safari build (MV%i)',
async (manifestVersion) => {
const safari = await generateFor({
browser: 'safari',
actualBrowser: 'safari',
manifestVersion,
});
const ios = await generateFor({
browser: 'ios',
actualBrowser: resolveActualBrowser('ios'),
manifestVersion,
});

expect(ios).toEqual(safari);
expect(ios.options_ui?.open_in_tab).toBeUndefined();
expect(getDynamicUrlFlags(ios)).toEqual([undefined]);
},
);

it('should keep Chrome-only manifest fields when the actual browser is chromium', async () => {
const chrome = await generateFor({
browser: 'chrome',
actualBrowser: 'chromium',
manifestVersion: 3,
});

expect(chrome.options_ui?.open_in_tab).toBe(true);
expect(getDynamicUrlFlags(chrome)).toEqual([true]);
});

it.each([
{ label: 'my-firefox', actualBrowser: 'firefox' },
{ label: 'my-safari', actualBrowser: 'safari' },
] as const)(
'should generate the $actualBrowser manifest for the custom label "$label"',
async ({ label, actualBrowser }) => {
const mapped = await generateFor({
browser: label,
actualBrowser: resolveActualBrowser(label, {
[label]: actualBrowser,
}),
manifestVersion: 2,
});
const native = await generateFor({
browser: actualBrowser,
actualBrowser,
manifestVersion: 2,
});

expect(mapped).toEqual(native);
},
);
});
});

describe('stripPathFromMatchPattern', () => {
Expand Down
Loading