diff options
| -rw-r--r-- | ext/js/accessibility/accessibility-controller.js | 17 | ||||
| -rw-r--r-- | ext/js/background/backend.js | 8 | ||||
| -rw-r--r-- | ext/js/background/script-manager.js | 508 | ||||
| -rw-r--r-- | types/ext/script-manager.d.ts | 14 | 
4 files changed, 122 insertions, 425 deletions
| diff --git a/ext/js/accessibility/accessibility-controller.js b/ext/js/accessibility/accessibility-controller.js index 8250b369..2b352948 100644 --- a/ext/js/accessibility/accessibility-controller.js +++ b/ext/js/accessibility/accessibility-controller.js @@ -16,19 +16,14 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +import {isContentScriptRegistered, registerContentScript, unregisterContentScript} from '../background/script-manager.js';  import {log} from '../core.js';  /**   * This class controls the registration of accessibility handlers.   */  export class AccessibilityController { -    /** -     * Creates a new instance. -     * @param {import('../background/script-manager.js').ScriptManager} scriptManager An instance of the `ScriptManager` class. -     */ -    constructor(scriptManager) { -        /** @type {import('../background/script-manager.js').ScriptManager} */ -        this._scriptManager = scriptManager; +    constructor() {          /** @type {?import('core').TokenObject} */          this._updateGoogleDocsAccessibilityToken = null;          /** @type {?Promise<void>} */ @@ -90,19 +85,17 @@ export class AccessibilityController {          const id = 'googleDocsAccessibility';          try {              if (forceGoogleDocsHtmlRenderingAny) { -                if (await this._scriptManager.isContentScriptRegistered(id)) { return; } +                if (await isContentScriptRegistered(id)) { return; }                  /** @type {import('script-manager').RegistrationDetails} */                  const details = {                      allFrames: true, -                    matchAboutBlank: true,                      matches: ['*://docs.google.com/*'], -                    urlMatches: '^[^:]*://docs\\.google\\.com/[\\w\\W]*$',                      runAt: 'document_start',                      js: ['js/accessibility/google-docs.js']                  }; -                await this._scriptManager.registerContentScript(id, details); +                await registerContentScript(id, details);              } else { -                await this._scriptManager.unregisterContentScript(id); +                await unregisterContentScript(id);              }          } catch (e) {              log.error(e); diff --git a/ext/js/background/backend.js b/ext/js/background/backend.js index 5ef3c3be..0604fe8b 100644 --- a/ext/js/background/backend.js +++ b/ext/js/background/backend.js @@ -40,7 +40,7 @@ import {MediaUtil} from '../media/media-util.js';  import {ClipboardReaderProxy, DictionaryDatabaseProxy, OffscreenProxy, TranslatorProxy} from './offscreen-proxy.js';  import {ProfileConditionsUtil} from './profile-conditions-util.js';  import {RequestBuilder} from './request-builder.js'; -import {ScriptManager} from './script-manager.js'; +import {injectStylesheet} from './script-manager.js';  /**   * This class controls the core logic of the extension, including API calls @@ -110,10 +110,8 @@ export class Backend {          });          /** @type {OptionsUtil} */          this._optionsUtil = new OptionsUtil(); -        /** @type {ScriptManager} */ -        this._scriptManager = new ScriptManager();          /** @type {AccessibilityController} */ -        this._accessibilityController = new AccessibilityController(this._scriptManager); +        this._accessibilityController = new AccessibilityController();          /** @type {?number} */          this._searchPopupTabId = null; @@ -650,7 +648,7 @@ export class Backend {      async _onApiInjectStylesheet({type, value}, sender) {          const {frameId, tab} = sender;          if (typeof tab !== 'object' || tab === null || typeof tab.id !== 'number') { throw new Error('Invalid tab'); } -        return await this._scriptManager.injectStylesheet(type, value, tab.id, frameId, false); +        return await injectStylesheet(type, value, tab.id, frameId, false);      }      /** @type {import('api').ApiHandler<'getStylesheetContent'>} */ 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;  } diff --git a/types/ext/script-manager.d.ts b/types/ext/script-manager.d.ts index 57b9ee06..66a5c20f 100644 --- a/types/ext/script-manager.d.ts +++ b/types/ext/script-manager.d.ts @@ -21,26 +21,12 @@ export type RunAt = 'document_start' | 'document_end' | 'document_idle';  export type RegistrationDetails = {      /** Same as `matches` in the `content_scripts` manifest key. */      matches: string[]; - -    /** Regex match pattern to use as a fallback when native content script registration isn't supported. */ -    /** Should be equivalent to `matches`. */ -    urlMatches: string; -      /** Same as `run_at` in the `content_scripts` manifest key. */      runAt: RunAt; - -    /** Same as `exclude_matches` in the `content_scripts` manifest key. */ -    excludeMatches?: string[]; - -    /** Same as `match_about_blank` in the `content_scripts` manifest key. */ -    matchAboutBlank: boolean; -      /** Same as `all_frames` in the `content_scripts` manifest key. */      allFrames: boolean; -      /** List of CSS paths. */      css?: string[]; -      /** List of script paths. */      js?: string[];  }; |