UNPKG

@ajayyy/webext-content-scripts

Version:

Utility functions to inject content scripts in WebExtensions, for Manifest v2 and v3

208 lines (207 loc) 7.51 kB
import chromeP from 'webext-polyfill-kinda'; import { patternToRegex } from 'webext-patterns'; const gotScripting = Boolean(globalThis.chrome?.scripting); function castTarget(target) { return typeof target === 'object' ? target : { tabId: target, frameId: 0, }; } function castAllFramesTarget(target) { if (typeof target === 'object') { return { ...target, allFrames: false }; } return { tabId: target, frameId: undefined, allFrames: true, }; } function castArray(possibleArray) { if (Array.isArray(possibleArray)) { return possibleArray; } return [possibleArray]; } const nativeFunction = /^function \w+\(\) {[\n\s]+\[native code][\n\s]+}/; export async function executeFunction(target, function_, ...args) { if (nativeFunction.test(String(function_))) { throw new TypeError('Native functions need to be wrapped first, like `executeFunction(1, () => alert(1))`'); } const { frameId, tabId } = castTarget(target); if (gotScripting) { const [injection] = await chrome.scripting.executeScript({ target: { tabId, frameIds: [frameId], }, func: function_, args, }); return injection?.result; } const [result] = await chromeP.tabs.executeScript(tabId, { code: `(${function_.toString()})(...${JSON.stringify(args)})`, frameId, }); return result; } function arrayOrUndefined(value) { return value === undefined ? undefined : [value]; } // eslint-disable-next-line @typescript-eslint/naming-convention -- It follows the native naming export async function insertCSS({ tabId, frameId, files, allFrames, matchAboutBlank, runAt, }, { ignoreTargetErrors } = {}) { const everyInsertion = Promise.all(files.map(async (content) => { if (typeof content === 'string') { content = { file: content }; } if (gotScripting) { return chrome.scripting.insertCSS({ target: { tabId, frameIds: arrayOrUndefined(frameId), allFrames: frameId === undefined ? allFrames : undefined, }, files: 'file' in content ? [content.file] : undefined, css: 'code' in content ? content.code : undefined, }); } return chromeP.tabs.insertCSS(tabId, { ...content, matchAboutBlank, allFrames, frameId, runAt: runAt ?? 'document_start', // CSS should prefer `document_start` when unspecified }); })); if (ignoreTargetErrors) { await catchTargetInjectionErrors(everyInsertion); } else { await everyInsertion; } } function assertNoCode(files) { if (files.some(content => 'code' in content)) { throw new Error('chrome.scripting does not support injecting strings of `code`'); } } export async function executeScript({ tabId, frameId, files, allFrames, matchAboutBlank, runAt, }, { ignoreTargetErrors } = {}) { const normalizedFiles = files.map(file => typeof file === 'string' ? { file } : file); if (gotScripting) { assertNoCode(normalizedFiles); const injection = chrome.scripting.executeScript({ target: { tabId, frameIds: arrayOrUndefined(frameId), allFrames: frameId === undefined ? allFrames : undefined, }, files: normalizedFiles.map(({ file }) => file), }); if (ignoreTargetErrors) { await catchTargetInjectionErrors(injection); } else { await injection; } // Don't return `injection`; the "return value" of a file is generally not useful return; } // Don't use .map(), `code` injections can't be "parallel" const executions = []; for (const content of normalizedFiles) { // Files are executed in order, but `code` isn’t, so it must await the last script before injecting more if ('code' in content) { // eslint-disable-next-line no-await-in-loop -- On purpose, see above await executions.at(-1); } executions.push(chromeP.tabs.executeScript(tabId, { ...content, matchAboutBlank, allFrames, frameId, runAt, })); } if (ignoreTargetErrors) { await catchTargetInjectionErrors(Promise.all(executions)); } else { await Promise.all(executions); } } export async function getTabsByUrl(matches, excludeMatches) { if (matches.length === 0) { return []; } const exclude = excludeMatches ? patternToRegex(...excludeMatches) : undefined; const tabs = await chromeP.tabs.query({ url: matches }); return tabs .filter(tab => tab.id && tab.url && (exclude ? !exclude.test(tab.url) : true)) .map(tab => tab.id); } export async function injectContentScript(where, scripts, options = {}) { const targets = castArray(where); await Promise.all(targets.map(async (target) => injectContentScriptInSpecificTarget(castAllFramesTarget(target), scripts, options))); } async function injectContentScriptInSpecificTarget({ frameId, tabId, allFrames }, scripts, options = {}) { const injections = castArray(scripts).flatMap(script => [ insertCSS({ tabId, frameId, allFrames, files: script.css ?? [], matchAboutBlank: script.matchAboutBlank ?? script.match_about_blank, runAt: script.runAt ?? script.run_at, }, options), executeScript({ tabId, frameId, allFrames, files: script.js ?? [], matchAboutBlank: script.matchAboutBlank ?? script.match_about_blank, runAt: script.runAt ?? script.run_at, }, options), ]); await Promise.all(injections); } // Sourced from: // https://source.chromium.org/chromium/chromium/src/+/main:extensions/common/extension_urls.cc;drc=6b42116fe3b3d93a77750bdcc07948e98a728405;l=29 // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Content_scripts const blockedPrefixes = [ 'chrome.google.com/webstore', 'accounts-static.cdn.mozilla.net', 'accounts.firefox.com', 'addons.cdn.mozilla.net', 'addons.mozilla.org', 'api.accounts.firefox.com', 'content.cdn.mozilla.net', 'discovery.addons.mozilla.org', 'input.mozilla.org', 'install.mozilla.org', 'oauth.accounts.firefox.com', 'profile.accounts.firefox.com', 'support.mozilla.org', 'sync.services.mozilla.com', 'testpilot.firefox.com', ]; export function isScriptableUrl(url) { if (!url.startsWith('http')) { return false; } const cleanUrl = url.replace(/^https?:\/\//, ''); return blockedPrefixes.every(blocked => !cleanUrl.startsWith(blocked)); } const targetErrors = /^No frame with id \d+ in tab \d+.$|^No tab with id: \d+.$|^The tab was closed.$|^The frame was removed.$/; async function catchTargetInjectionErrors(promise) { try { await promise; } catch (error) { // @ts-expect-error Optional chaining is good enough // eslint-disable-next-line @typescript-eslint/no-unsafe-argument if (!targetErrors.test(error?.message)) { throw error; } } }