/* * Copyright (C) 2023 Yomitan Authors * Copyright (C) 2021-2022 Yomichan Authors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * 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. */ 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; } } } 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; } } // 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; } /** * 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']; } // 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]; } 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]; } 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 {string[]} array * @param {boolean} firefoxConvention * @returns {string[]|browser.extensionTypes.ExtensionFileOrCode[]} */ _convertFileArray(array, firefoxConvention) { return firefoxConvention ? array.map((file) => ({file})) : [...array]; } /** * @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}); } /** * @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; } } /** * @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); } }