aboutsummaryrefslogtreecommitdiff
path: root/ext/js/background/script-manager.js
diff options
context:
space:
mode:
Diffstat (limited to 'ext/js/background/script-manager.js')
-rw-r--r--ext/js/background/script-manager.js508
1 files changed, 114 insertions, 394 deletions
diff --git a/ext/js/background/script-manager.js b/ext/js/background/script-manager.js
index 98f67bb0..1142121f 100644
--- a/ext/js/background/script-manager.js
+++ b/ext/js/background/script-manager.js
@@ -16,419 +16,139 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-import {isObject} from '../core.js';
-
/**
- * This class is used to manage script injection into content tabs.
+ * Injects a stylesheet into a tab.
+ * @param {'file'|'code'} type The type of content to inject; either 'file' or 'code'.
+ * @param {string} content The content to inject.
+ * - If type is `'file'`, this argument should be a path to a file.
+ * - If type is `'code'`, this argument should be the CSS content.
+ * @param {number} tabId The id of the tab to inject into.
+ * @param {number|undefined} frameId The id of the frame to inject into.
+ * @param {boolean} allFrames Whether or not the stylesheet should be injected into all frames.
+ * @returns {Promise<void>}
*/
-export class ScriptManager {
- /**
- * Creates a new instance of the class.
- */
- constructor() {
- /** @type {Map<string, ?browser.contentScripts.RegisteredContentScript>} */
- this._contentScriptRegistrations = new Map();
- }
-
- /**
- * Injects a stylesheet into a tab.
- * @param {'file'|'code'} type The type of content to inject; either 'file' or 'code'.
- * @param {string} content The content to inject.
- * If type is 'file', this argument should be a path to a file.
- * If type is 'code', this argument should be the CSS content.
- * @param {number} tabId The id of the tab to inject into.
- * @param {number|undefined} frameId The id of the frame to inject into.
- * @param {boolean} allFrames Whether or not the stylesheet should be injected into all frames.
- * @returns {Promise<void>}
- */
- injectStylesheet(type, content, tabId, frameId, allFrames) {
- if (isObject(chrome.scripting) && typeof chrome.scripting.insertCSS === 'function') {
- return this._injectStylesheetMV3(type, content, tabId, frameId, allFrames);
- } else {
- return Promise.reject(new Error('Stylesheet injection not supported'));
- }
- }
-
- /**
- * Injects a script into a tab.
- * @param {string} file The path to a file to inject.
- * @param {number} tabId The id of the tab to inject into.
- * @param {number|undefined} frameId The id of the frame to inject into.
- * @param {boolean} allFrames Whether or not the script should be injected into all frames.
- * @returns {Promise<{frameId: number|undefined, result: unknown}>} The id of the frame and the result of the script injection.
- */
- injectScript(file, tabId, frameId, allFrames) {
- if (isObject(chrome.scripting) && typeof chrome.scripting.executeScript === 'function') {
- return this._injectScriptMV3(file, tabId, frameId, allFrames);
- } else {
- return Promise.reject(new Error('Script injection not supported'));
- }
- }
-
- /**
- * Checks whether or not a content script is registered.
- * @param {string} id The identifier used with a call to `registerContentScript`.
- * @returns {Promise<boolean>} `true` if a script is registered, `false` otherwise.
- */
- async isContentScriptRegistered(id) {
- if (this._contentScriptRegistrations.has(id)) {
- return true;
- }
- if (isObject(chrome.scripting) && typeof chrome.scripting.getRegisteredContentScripts === 'function') {
- const scripts = await new Promise((resolve, reject) => {
- chrome.scripting.getRegisteredContentScripts({ids: [id]}, (result) => {
- const e = chrome.runtime.lastError;
- if (e) {
- reject(new Error(e.message));
- } else {
- resolve(result);
- }
- });
- });
- for (const script of scripts) {
- if (script.id === id) {
- return true;
- }
+export function injectStylesheet(type, content, tabId, frameId, allFrames) {
+ return new Promise((resolve, reject) => {
+ /** @type {chrome.scripting.InjectionTarget} */
+ const target = {
+ tabId,
+ allFrames
+ };
+ /** @type {chrome.scripting.CSSInjection} */
+ const details = (
+ type === 'file' ?
+ {origin: 'AUTHOR', files: [content], target} :
+ {origin: 'USER', css: content, target}
+ );
+ if (!allFrames && typeof frameId === 'number') {
+ details.target.frameIds = [frameId];
+ }
+ chrome.scripting.insertCSS(details, () => {
+ const e = chrome.runtime.lastError;
+ if (e) {
+ reject(new Error(e.message));
+ } else {
+ resolve();
}
- }
- return false;
- }
-
- /**
- * Registers a dynamic content script.
- * Note: if the fallback handler is used and the 'webNavigation' permission isn't granted,
- * there is a possibility that the script can be injected more than once due to the events used.
- * Therefore, a reentrant check may need to be performed by the content script.
- * @param {string} id A unique identifier for the registration.
- * @param {import('script-manager').RegistrationDetails} details The script registration details.
- * @throws An error is thrown if the id is already in use.
- */
- async registerContentScript(id, details) {
- if (await this.isContentScriptRegistered(id)) {
- throw new Error('Registration already exists');
- }
-
- if (isObject(chrome.scripting) && typeof chrome.scripting.registerContentScripts === 'function') {
- const details2 = this._createContentScriptRegistrationOptionsChrome(details, id);
- await /** @type {Promise<void>} */ (new Promise((resolve, reject) => {
- chrome.scripting.registerContentScripts([details2], () => {
- const e = chrome.runtime.lastError;
- if (e) {
- reject(new Error(e.message));
- } else {
- resolve();
- }
- });
- }));
- this._contentScriptRegistrations.set(id, null);
- return;
- }
-
- // Fallback
- this._registerContentScriptFallback(id, details);
- }
+ });
+ });
+}
- /**
- * Unregisters a previously registered content script.
- * @param {string} id The identifier passed to a previous call to `registerContentScript`.
- * @returns {Promise<boolean>} `true` if the content script was unregistered, `false` otherwise.
- */
- async unregisterContentScript(id) {
- if (isObject(chrome.scripting) && typeof chrome.scripting.unregisterContentScripts === 'function') {
- this._contentScriptRegistrations.delete(id);
- try {
- await this._unregisterContentScriptMV3(id);
- return true;
- } catch (e) {
- return false;
+/**
+ * Checks whether or not a content script is registered.
+ * @param {string} id The identifier used with a call to `registerContentScript`.
+ * @returns {Promise<boolean>} `true` if a script is registered, `false` otherwise.
+ */
+export async function isContentScriptRegistered(id) {
+ const scripts = await new Promise((resolve, reject) => {
+ chrome.scripting.getRegisteredContentScripts({ids: [id]}, (result) => {
+ const e = chrome.runtime.lastError;
+ if (e) {
+ reject(new Error(e.message));
+ } else {
+ resolve(result);
}
+ });
+ });
+ for (const script of scripts) {
+ if (script.id === id) {
+ return true;
}
-
- // Fallback
- const registration = this._contentScriptRegistrations.get(id);
- if (typeof registration === 'undefined') { return false; }
- this._contentScriptRegistrations.delete(id);
- if (registration !== null && typeof registration.unregister === 'function') {
- await registration.unregister();
- }
- return true;
}
+ return false;
+}
- /**
- * Gets the optional permissions required to register a content script.
- * @returns {string[]} An array of the required permissions, which may be empty.
- */
- getRequiredContentScriptRegistrationPermissions() {
- if (isObject(chrome.scripting) && typeof chrome.scripting.registerContentScripts === 'function') {
- return [];
- }
-
- // Fallback
- return ['webNavigation'];
+/**
+ * Registers a dynamic content script.
+ * Note: if the fallback handler is used and the 'webNavigation' permission isn't granted,
+ * there is a possibility that the script can be injected more than once due to the events used.
+ * Therefore, a reentrant check may need to be performed by the content script.
+ * @param {string} id A unique identifier for the registration.
+ * @param {import('script-manager').RegistrationDetails} details The script registration details.
+ * @throws An error is thrown if the id is already in use.
+ */
+export async function registerContentScript(id, details) {
+ if (await isContentScriptRegistered(id)) {
+ throw new Error('Registration already exists');
}
- // Private
-
- /**
- * @param {'file'|'code'} type
- * @param {string} content
- * @param {number} tabId
- * @param {number|undefined} frameId
- * @param {boolean} allFrames
- * @returns {Promise<void>}
- */
- _injectStylesheetMV3(type, content, tabId, frameId, allFrames) {
- return new Promise((resolve, reject) => {
- /** @type {chrome.scripting.InjectionTarget} */
- const target = {
- tabId,
- allFrames
- };
- /** @type {chrome.scripting.CSSInjection} */
- const details = (
- type === 'file' ?
- {origin: 'AUTHOR', files: [content], target} :
- {origin: 'USER', css: content, target}
- );
- if (!allFrames && typeof frameId === 'number') {
- details.target.frameIds = [frameId];
+ const details2 = createContentScriptRegistrationOptions(details, id);
+ await /** @type {Promise<void>} */ (new Promise((resolve, reject) => {
+ chrome.scripting.registerContentScripts([details2], () => {
+ const e = chrome.runtime.lastError;
+ if (e) {
+ reject(new Error(e.message));
+ } else {
+ resolve();
}
- chrome.scripting.insertCSS(details, () => {
- const e = chrome.runtime.lastError;
- if (e) {
- reject(new Error(e.message));
- } else {
- resolve();
- }
- });
});
- }
+ }));
+}
- /**
- * @param {string} file
- * @param {number} tabId
- * @param {number|undefined} frameId
- * @param {boolean} allFrames
- * @returns {Promise<{frameId: number|undefined, result: unknown}>} The id of the frame and the result of the script injection.
- */
- _injectScriptMV3(file, tabId, frameId, allFrames) {
- return new Promise((resolve, reject) => {
- /** @type {chrome.scripting.ScriptInjection<unknown[], unknown>} */
- const details = {
- injectImmediately: true,
- files: [file],
- target: {tabId, allFrames}
- };
- if (!allFrames && typeof frameId === 'number') {
- details.target.frameIds = [frameId];
+/**
+ * Unregisters a previously registered content script.
+ * @param {string} id The identifier passed to a previous call to `registerContentScript`.
+ * @returns {Promise<void>}
+ */
+export async function unregisterContentScript(id) {
+ return new Promise((resolve, reject) => {
+ chrome.scripting.unregisterContentScripts({ids: [id]}, () => {
+ const e = chrome.runtime.lastError;
+ if (e) {
+ reject(new Error(e.message));
+ } else {
+ resolve();
}
- chrome.scripting.executeScript(details, (results) => {
- const e = chrome.runtime.lastError;
- if (e) {
- reject(new Error(e.message));
- } else {
- const {frameId: frameId2, result} = results[0];
- resolve({frameId: frameId2, result});
- }
- });
});
- }
-
- /**
- * @param {string} id
- * @returns {Promise<void>}
- */
- _unregisterContentScriptMV3(id) {
- return new Promise((resolve, reject) => {
- chrome.scripting.unregisterContentScripts({ids: [id]}, () => {
- const e = chrome.runtime.lastError;
- if (e) {
- reject(new Error(e.message));
- } else {
- resolve();
- }
- });
- });
- }
-
- /**
- * @param {import('script-manager').RegistrationDetails} details
- * @returns {browser.contentScripts.RegisteredContentScriptOptions}
- */
- _createContentScriptRegistrationOptionsFirefox(details) {
- const {css, js, matchAboutBlank} = details;
- /** @type {browser.contentScripts.RegisteredContentScriptOptions} */
- const options = {};
- if (typeof matchAboutBlank !== 'undefined') {
- options.matchAboutBlank = matchAboutBlank;
- }
- if (Array.isArray(css)) {
- options.css = css.map((file) => ({file}));
- }
- if (Array.isArray(js)) {
- options.js = js.map((file) => ({file}));
- }
- this._initializeContentScriptRegistrationOptionsGeneric(details, options);
- return options;
- }
-
- /**
- * @param {import('script-manager').RegistrationDetails} details
- * @param {string} id
- * @returns {chrome.scripting.RegisteredContentScript}
- */
- _createContentScriptRegistrationOptionsChrome(details, id) {
- const {css, js} = details;
- /** @type {chrome.scripting.RegisteredContentScript} */
- const options = {
- id: id,
- persistAcrossSessions: true
- };
- if (Array.isArray(css)) {
- options.css = [...css];
- }
- if (Array.isArray(js)) {
- options.js = [...js];
- }
- this._initializeContentScriptRegistrationOptionsGeneric(details, options);
- return options;
- }
+ });
+}
- /**
- * @param {import('script-manager').RegistrationDetails} details
- * @param {chrome.scripting.RegisteredContentScript|browser.contentScripts.RegisteredContentScriptOptions} options
- */
- _initializeContentScriptRegistrationOptionsGeneric(details, options) {
- const {allFrames, excludeMatches, matches, runAt} = details;
- if (typeof allFrames !== 'undefined') {
- options.allFrames = allFrames;
- }
- if (Array.isArray(excludeMatches)) {
- options.excludeMatches = [...excludeMatches];
- }
- if (Array.isArray(matches)) {
- options.matches = [...matches];
- }
- if (typeof runAt !== 'undefined') {
- options.runAt = runAt;
- }
+/**
+ * @param {import('script-manager').RegistrationDetails} details
+ * @param {string} id
+ * @returns {chrome.scripting.RegisteredContentScript}
+ */
+function createContentScriptRegistrationOptions(details, id) {
+ const {css, js, allFrames, matches, runAt} = details;
+ /** @type {chrome.scripting.RegisteredContentScript} */
+ const options = {
+ id: id,
+ persistAcrossSessions: true
+ };
+ if (Array.isArray(css)) {
+ options.css = [...css];
}
-
- /**
- * @param {string[]} array
- * @param {boolean} firefoxConvention
- * @returns {string[]|browser.extensionTypes.ExtensionFileOrCode[]}
- */
- _convertFileArray(array, firefoxConvention) {
- return firefoxConvention ? array.map((file) => ({file})) : [...array];
+ if (Array.isArray(js)) {
+ options.js = [...js];
}
-
- /**
- * @param {string} id
- * @param {import('script-manager').RegistrationDetails} details
- */
- _registerContentScriptFallback(id, details) {
- const {allFrames, css, js, matchAboutBlank, runAt, urlMatches} = details;
- /** @type {import('script-manager').ContentScriptInjectionDetails} */
- const details2 = {allFrames, css, js, matchAboutBlank, runAt, urlRegex: /** @type {?RegExp} */ (null)};
- /** @type {() => Promise<void>} */
- let unregister;
- const webNavigationEvent = this._getWebNavigationEvent(runAt);
- if (typeof webNavigationEvent === 'object' && webNavigationEvent !== null) {
- /**
- * @param {chrome.webNavigation.WebNavigationFramedCallbackDetails} details
- */
- const onTabCommitted = ({url, tabId, frameId}) => {
- this._injectContentScript(true, details2, null, url, tabId, frameId);
- };
- const filter = {url: [{urlMatches}]};
- webNavigationEvent.addListener(onTabCommitted, filter);
- unregister = async () => webNavigationEvent.removeListener(onTabCommitted);
- } else {
- /**
- * @param {number} tabId
- * @param {chrome.tabs.TabChangeInfo} changeInfo
- * @param {chrome.tabs.Tab} tab
- */
- const onTabUpdated = (tabId, {status}, {url}) => {
- if (typeof status === 'string' && typeof url === 'string') {
- this._injectContentScript(false, details2, status, url, tabId, void 0);
- }
- };
- try {
- // Firefox
- /** @type {browser.tabs.UpdateFilter} */
- const extraParameters = {urls: [urlMatches], properties: ['status']};
- browser.tabs.onUpdated.addListener(
- /** @type {(tabId: number, changeInfo: browser.tabs._OnUpdatedChangeInfo, tab: browser.tabs.Tab) => void} */ (onTabUpdated),
- extraParameters
- );
- } catch (e) {
- // Chrome
- details2.urlRegex = new RegExp(urlMatches);
- chrome.tabs.onUpdated.addListener(onTabUpdated);
- }
- unregister = async () => chrome.tabs.onUpdated.removeListener(onTabUpdated);
- }
- this._contentScriptRegistrations.set(id, {unregister});
+ if (typeof allFrames !== 'undefined') {
+ options.allFrames = allFrames;
}
-
- /**
- * @param {import('script-manager').RunAt} runAt
- * @returns {?(chrome.webNavigation.WebNavigationFramedEvent|chrome.webNavigation.WebNavigationTransitionalEvent)}
- */
- _getWebNavigationEvent(runAt) {
- const {webNavigation} = chrome;
- if (!isObject(webNavigation)) { return null; }
- switch (runAt) {
- case 'document_start':
- return webNavigation.onCommitted;
- case 'document_end':
- return webNavigation.onDOMContentLoaded;
- default: // 'document_idle':
- return webNavigation.onCompleted;
- }
+ if (Array.isArray(matches)) {
+ options.matches = [...matches];
}
-
- /**
- * @param {boolean} isWebNavigation
- * @param {import('script-manager').ContentScriptInjectionDetails} details
- * @param {?string} status
- * @param {string} url
- * @param {number} tabId
- * @param {number|undefined} frameId
- */
- async _injectContentScript(isWebNavigation, details, status, url, tabId, frameId) {
- const {urlRegex} = details;
- if (urlRegex !== null && !urlRegex.test(url)) { return; }
-
- let {allFrames, css, js, runAt} = details;
-
- if (isWebNavigation) {
- if (allFrames) {
- allFrames = false;
- } else {
- if (frameId !== 0) { return; }
- }
- } else {
- if (runAt === 'document_start') {
- if (status !== 'loading') { return; }
- } else { // 'document_end', 'document_idle'
- if (status !== 'complete') { return; }
- }
- }
-
- const promises = [];
- if (Array.isArray(css)) {
- for (const file of css) {
- promises.push(this.injectStylesheet('file', file, tabId, frameId, allFrames));
- }
- }
- if (Array.isArray(js)) {
- for (const file of js) {
- promises.push(this.injectScript(file, tabId, frameId, allFrames));
- }
- }
- await Promise.all(promises);
+ if (typeof runAt !== 'undefined') {
+ options.runAt = runAt;
}
+ return options;
}