diff --git a/docs/guide/essentials/entrypoints.md b/docs/guide/essentials/entrypoints.md index 4981b26f1..95783b81d 100644 --- a/docs/guide/essentials/entrypoints.md +++ b/docs/guide/essentials/entrypoints.md @@ -288,7 +288,7 @@ export default defineContentScript({ cssInjectionMode: undefined | "manifest" | "manual" | "ui", // Configure how/when content script will be registered - registration: undefined | "manifest" | "runtime", + registration: undefined | "manifest" | "runtime" | "optional", main(ctx: ContentScriptContext) { // Executed when content script is loaded, can be async diff --git a/docs/guide/essentials/scripting.md b/docs/guide/essentials/scripting.md index 468e2c770..7a81babcc 100644 --- a/docs/guide/essentials/scripting.md +++ b/docs/guide/essentials/scripting.md @@ -27,3 +27,9 @@ export default defineContentScript({ }, }); ``` + +## Optional Host Registration + +When using `registration: 'optional'`, WXT adds the script's `matches` to +`optional_host_permissions` instead of `host_permissions`. You must request host +access before registering/executing the script at runtime. diff --git a/packages/wxt/src/core/utils/__tests__/manifest.test.ts b/packages/wxt/src/core/utils/__tests__/manifest.test.ts index 250c8b45d..c4550924f 100644 --- a/packages/wxt/src/core/utils/__tests__/manifest.test.ts +++ b/packages/wxt/src/core/utils/__tests__/manifest.test.ts @@ -1281,6 +1281,47 @@ describe('Manifest Utils', () => { expect(actual.content_scripts).toEqual([]); expect(actual.host_permissions).toEqual(['*://google.com/*']); }); + + it('should add optional_host_permissions instead of content_scripts when registration=optional', async () => { + const cs: ContentScriptEntrypoint = { + type: 'content-script', + name: 'one', + inputPath: 'entrypoints/one.content.ts', + outputDir: contentScriptOutDir, + options: { + matches: ['*://google.com/*'], + registration: 'optional', + }, + skipped: false, + }; + const styles: OutputAsset = { + type: 'asset', + fileName: 'content-scripts/one.css', + }; + + const entrypoints = [cs]; + const buildOutput: Omit = { + publicAssets: [], + steps: [{ entrypoints: cs, chunks: [styles] }], + }; + setFakeWxt({ + config: { + manifestVersion: 3, + outDir, + command: 'build', + }, + }); + + const { manifest: actual } = await generateManifest( + entrypoints, + buildOutput, + ); + + expect(actual.content_scripts).toEqual([]); + expect(actual.optional_host_permissions).toEqual([ + '*://google.com/*', + ]); + }); }); }); @@ -1870,6 +1911,64 @@ describe('Manifest Utils', () => { }); }); + describe('optional_host_permissions', () => { + it('should keep optional_host_permissions as-is for MV3', async () => { + const expectedOptionalHostPermissions = ['https://google.com/*']; + const expectedOptionalPermissions: Browser.runtime.ManifestOptionalPermission[] = + ['cookies']; + setFakeWxt({ + config: { + manifest: { + optional_host_permissions: expectedOptionalHostPermissions, + optional_permissions: expectedOptionalPermissions, + }, + manifestVersion: 3, + command: 'build', + }, + }); + const output = fakeBuildOutput(); + + const { manifest: actual } = await generateManifest([], output); + + expect(actual.optional_permissions).toEqual( + expectedOptionalPermissions, + ); + expect(actual.optional_host_permissions).toEqual( + expectedOptionalHostPermissions, + ); + }); + + it('should move optional_host_permissions to optional_permissions for MV2, ignoring duplicates', async () => { + const expectedOptionalPermissions = [ + 'cookies', + 'https://google.com/*', + '*://*.youtube.com/*', + ]; + setFakeWxt({ + config: { + manifest: { + optional_host_permissions: [ + 'https://google.com/*', + 'https://google.com/*', + '*://*.youtube.com/*', + ], + optional_permissions: ['cookies'], + }, + manifestVersion: 2, + command: 'build', + }, + }); + const output = fakeBuildOutput(); + + const { manifest: actual } = await generateManifest([], output); + + expect(actual.optional_permissions).toEqual( + expectedOptionalPermissions, + ); + expect(actual.optional_host_permissions).toBeUndefined(); + }); + }); + describe('Dev mode', () => { it('should not add any code for production builds', async () => { setFakeWxt({ diff --git a/packages/wxt/src/core/utils/__tests__/validation.test.ts b/packages/wxt/src/core/utils/__tests__/validation.test.ts index 24f6fed71..2c51f5b61 100644 --- a/packages/wxt/src/core/utils/__tests__/validation.test.ts +++ b/packages/wxt/src/core/utils/__tests__/validation.test.ts @@ -85,7 +85,7 @@ describe('Validation Utils', () => { { type: 'error', message: - '`matches` is required for manifest registered content scripts', + '`matches` is required for content scripts that are not registered at runtime', value: null, entrypoint, }, @@ -117,5 +117,32 @@ describe('Validation Utils', () => { expect(actual).toEqual(expected); }); + + it('should return an error when "registration: optional" content scripts don\'t have matches', () => { + const entrypoint = fakeContentScriptEntrypoint({ + options: { + registration: 'optional', + // @ts-expect-error: Testing validation of invalid `optional` content script without `matches` + matches: null, + }, + }); + const expected = { + errors: [ + { + type: 'error', + message: + '`matches` is required for content scripts that are not registered at runtime', + value: null, + entrypoint, + }, + ], + errorCount: 1, + warningCount: 0, + }; + + const actual = validateEntrypoints([entrypoint]); + + expect(actual).toEqual(expected); + }); }); }); diff --git a/packages/wxt/src/core/utils/manifest.ts b/packages/wxt/src/core/utils/manifest.ts index 134c36c13..a7f37b190 100644 --- a/packages/wxt/src/core/utils/manifest.ts +++ b/packages/wxt/src/core/utils/manifest.ts @@ -145,6 +145,7 @@ export async function generateManifest( convertActionToMv2(manifest); convertCspToMv2(manifest); moveHostPermissionsToPermissions(manifest); + moveOptionalHostPermissionsToOptionalPermissions(manifest); } if (wxt.config.manifestVersion === 3) { @@ -395,13 +396,21 @@ function addEntrypoints( if (wxt.config.command === 'serve' && wxt.config.manifestVersion === 3) { contentScripts.forEach((script) => { script.options.matches?.forEach((matchPattern) => { - addHostPermission(manifest, matchPattern); + if (script.options.registration === 'optional') { + addOptionalHostPermission(manifest, matchPattern); + } else { + addHostPermission(manifest, matchPattern); + } }); }); } else { // Manifest scripts const hashToEntrypointsMap = contentScripts - .filter((cs) => cs.options.registration !== 'runtime') + .filter( + (cs) => + cs.options.registration !== 'runtime' && + cs.options.registration !== 'optional', + ) .reduce((map, script) => { const hash = hashContentScriptOptions(script.options); if (map.has(hash)) map.get(hash)?.push(script); @@ -433,6 +442,16 @@ function addEntrypoints( addHostPermission(manifest, matchPattern); }); }); + + // Optional runtime content scripts + const optionalContentScripts = contentScripts.filter( + (cs) => cs.options.registration === 'optional', + ); + optionalContentScripts.forEach((script) => { + script.options.matches?.forEach((matchPattern) => { + addOptionalHostPermission(manifest, matchPattern); + }); + }); } const contentScriptCssResources = getContentScriptCssWebAccessibleResources( @@ -616,6 +635,17 @@ function addPermission( manifest.permissions.push(permission); } +function addOptionalPermission( + manifest: Browser.runtime.Manifest, + permission: string, +): void { + manifest.optional_permissions ??= []; + // @ts-expect-error: Allow using strings for permissions for MV2 support + if (manifest.optional_permissions.includes(permission)) return; + // @ts-expect-error: Allow using strings for permissions for MV2 support + manifest.optional_permissions.push(permission); +} + function addHostPermission( manifest: Browser.runtime.Manifest, hostPermission: string, @@ -625,6 +655,15 @@ function addHostPermission( manifest.host_permissions.push(hostPermission); } +function addOptionalHostPermission( + manifest: Browser.runtime.Manifest, + hostPermission: string, +): void { + manifest.optional_host_permissions ??= []; + if (manifest.optional_host_permissions.includes(hostPermission)) return; + manifest.optional_host_permissions.push(hostPermission); +} + /** * - "" → "" * - "_://play.google.com/books/_" → "_://play.google.com/_" @@ -669,6 +708,17 @@ function moveHostPermissionsToPermissions( delete manifest.host_permissions; } +function moveOptionalHostPermissionsToOptionalPermissions( + manifest: Browser.runtime.Manifest, +): void { + if (!manifest.optional_host_permissions?.length) return; + + manifest.optional_host_permissions.forEach((permission: string) => + addOptionalPermission(manifest, permission), + ); + delete manifest.optional_host_permissions; +} + function convertActionToMv2(manifest: Browser.runtime.Manifest): void { if ( manifest.action == null || diff --git a/packages/wxt/src/core/utils/validation.ts b/packages/wxt/src/core/utils/validation.ts index 331fe80a6..b51327e03 100644 --- a/packages/wxt/src/core/utils/validation.ts +++ b/packages/wxt/src/core/utils/validation.ts @@ -36,7 +36,8 @@ function validateContentScriptEntrypoint( ) { errors.push({ type: 'error', - message: '`matches` is required for manifest registered content scripts', + message: + '`matches` is required for content scripts that are not registered at runtime', value: definition.options.matches, entrypoint: definition, }); diff --git a/packages/wxt/src/types.ts b/packages/wxt/src/types.ts index 46561536a..4dfc74f57 100644 --- a/packages/wxt/src/types.ts +++ b/packages/wxt/src/types.ts @@ -709,10 +709,14 @@ export interface BaseContentScriptEntrypointOptions extends BaseScriptEntrypoint * - `"runtime"`: The content script's `matches` is added to `host_permissions` * and you are responsible for using the scripting API to register/execute * the content script dynamically at runtime. + * - `"optional"`: The content script's `matches` is added to + * `optional_host_permissions` and you are responsible for requesting access + * and using the scripting API to register/execute the content script at + * runtime. * * @default 'manifest' */ - registration?: PerBrowserOption<'manifest' | 'runtime'>; + registration?: PerBrowserOption<'manifest' | 'runtime' | 'optional'>; } export interface MainWorldContentScriptEntrypointOptions extends BaseContentScriptEntrypointOptions { diff --git a/packages/wxt/src/utils/internal/dev-server-websocket.ts b/packages/wxt/src/utils/internal/dev-server-websocket.ts index 913f6b7e2..313b6a9e7 100644 --- a/packages/wxt/src/utils/internal/dev-server-websocket.ts +++ b/packages/wxt/src/utils/internal/dev-server-websocket.ts @@ -71,7 +71,7 @@ export function getDevServerWebSocket(): WxtWebSocket { } export interface ReloadContentScriptPayload { - registration?: 'manifest' | 'runtime'; + registration?: 'manifest' | 'runtime' | 'optional'; contentScript: { matches: string[]; js?: string[]; diff --git a/packages/wxt/src/virtual/utils/reload-content-scripts.ts b/packages/wxt/src/virtual/utils/reload-content-scripts.ts index 5d3b4363e..17025ca61 100644 --- a/packages/wxt/src/virtual/utils/reload-content-scripts.ts +++ b/packages/wxt/src/virtual/utils/reload-content-scripts.ts @@ -16,7 +16,7 @@ export async function reloadContentScriptMv3({ registration, contentScript, }: ReloadContentScriptPayload) { - if (registration === 'runtime') { + if (registration === 'runtime' || registration === 'optional') { await reloadRuntimeContentScriptMv3(contentScript); } else { await reloadManifestContentScriptMv3(contentScript);