From ecc994a8bbd52a426434a549f8e3e68eba6e786e Mon Sep 17 00:00:00 2001 From: toasted-nutbread Date: Tue, 23 Nov 2021 16:16:13 -0500 Subject: [PATCH] ScriptManager updates (#2022) * Fix spacing * Add more parameters to injectStylesheet * Add more parameters to injectScript * Update ScriptManager to support content script registration * Add webNavigation as an optional permission --- dev/data/manifest-variants.json | 5 +- ext/js/background/backend.js | 4 +- ext/js/background/script-manager.js | 313 +++++++++++++++++++++++++--- ext/manifest.json | 3 +- ext/permissions.html | 12 ++ 5 files changed, 303 insertions(+), 34 deletions(-) diff --git a/dev/data/manifest-variants.json b/dev/data/manifest-variants.json index 86dbaecf..004cec01 100644 --- a/dev/data/manifest-variants.json +++ b/dev/data/manifest-variants.json @@ -98,7 +98,8 @@ ], "optional_permissions": [ "clipboardRead", - "nativeMessaging" + "nativeMessaging", + "webNavigation" ], "commands": { "toggleTextScanning": { @@ -172,6 +173,7 @@ "fileName": "yomichan-chrome-mv3.zip", "modifications": [ {"action": "set", "path": ["manifest_version"], "value": 3}, + {"action": "set", "path": ["minimum_chrome_version"], "value": "96.0.0.0"}, {"action": "move", "path": ["browser_action"], "newPath": ["action"]}, {"action": "delete", "path": ["background", "page"]}, {"action": "delete", "path": ["background", "persistent"]}, @@ -185,6 +187,7 @@ {"action": "remove", "path": ["permissions"], "item": "webRequestBlocking"}, {"action": "add", "path": ["permissions"], "items": ["declarativeNetRequest", "scripting"]}, {"action": "set", "path": ["host_permissions"], "value": [""], "after": "optional_permissions"}, + {"action": "remove", "path": ["optional_permissions"], "item": "webNavigation"}, {"action": "move", "path": ["web_accessible_resources"], "newPath": ["web_accessible_resources_old"]}, {"action": "set", "path": ["web_accessible_resources"], "value": [{"resources": [], "matches": [""]}], "after": "web_accessible_resources_old"}, {"action": "move", "path": ["web_accessible_resources_old"], "newPath": ["web_accessible_resources", 0, "resources"]} diff --git a/ext/js/background/backend.js b/ext/js/background/backend.js index f48a87f8..db43ec57 100644 --- a/ext/js/background/backend.js +++ b/ext/js/background/backend.js @@ -564,7 +564,7 @@ class Backend { async _onApiInjectStylesheet({type, value}, sender) { const {frameId, tab} = sender; if (!isObject(tab)) { throw new Error('Invalid tab'); } - return await this._scriptManager.injectStylesheet(type, value, tab.id, frameId); + return await this._scriptManager.injectStylesheet(type, value, tab.id, frameId, false, true, 'document_start'); } async _onApiGetStylesheetContent({url}) { @@ -2156,7 +2156,7 @@ class Backend { if (file === null) { return; } - await this._scriptManager.injectScript(file, tabId, frameId); + await this._scriptManager.injectScript(file, tabId, frameId, false, true, 'document_start'); } async _getNormalizedDictionaryDatabaseMedia(targets) { diff --git a/ext/js/background/script-manager.js b/ext/js/background/script-manager.js index a5dbe0d2..c6bdc0bb 100644 --- a/ext/js/background/script-manager.js +++ b/ext/js/background/script-manager.js @@ -20,60 +20,186 @@ */ class ScriptManager { /** - * Injects a stylesheet into a specific tab and frame. + * Creates a new instance of the class. + */ + constructor() { + this._contentScriptRegistrations = new Map(); + } + + /** + * Injects a stylesheet into a tab. * @param {string} 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} frameId The id of the frame to inject into. + * @param {number} [frameId] The id of the frame to inject into. + * @param {boolean} [allFrames] Whether or not the stylesheet should be injected into all frames. + * @param {boolean} [matchAboutBlank] Whether or not the stylesheet should be injected into about:blank frames. + * @param {string} [runAt] The time to inject the stylesheet at. * @returns {Promise} */ - injectStylesheet(type, content, tabId, frameId) { + injectStylesheet(type, content, tabId, frameId, allFrames, matchAboutBlank, runAt) { if (isObject(chrome.tabs) && typeof chrome.tabs.insertCSS === 'function') { - return this._injectStylesheetMV2(type, content, tabId, frameId); + return this._injectStylesheetMV2(type, content, tabId, frameId, allFrames, matchAboutBlank, runAt); } else if (isObject(chrome.scripting) && typeof chrome.scripting.insertCSS === 'function') { - return this._injectStylesheetMV3(type, content, tabId, frameId); + return this._injectStylesheetMV3(type, content, tabId, frameId, allFrames); } else { return Promise.reject(new Error('Stylesheet injection not supported')); } } + /** - * Injects a script into a specific tab and frame. + * 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} frameId The id of the frame to inject into. + * @param {number} [frameId] The id of the frame to inject into. + * @param {boolean} [allFrames] Whether or not the script should be injected into all frames. + * @param {boolean} [matchAboutBlank] Whether or not the script should be injected into about:blank frames. + * @param {string} [runAt] The time to inject the script at. * @returns {Promise<{frameId: number, result: object}>} The id of the frame and the result of the script injection. */ - injectScript(file, tabId, frameId) { + injectScript(file, tabId, frameId, allFrames, matchAboutBlank, runAt) { if (isObject(chrome.tabs) && typeof chrome.tabs.executeScript === 'function') { - return this._injectScriptMV2(file, tabId, frameId); + return this._injectScriptMV2(file, tabId, frameId, allFrames, matchAboutBlank, runAt); } else if (isObject(chrome.scripting) && typeof chrome.scripting.executeScript === 'function') { - return this._injectScriptMV3(file, tabId, frameId); + 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} `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 {object} details The script registration details. + * @param {boolean} [details.allFrames] Same as `all_frames` in the `content_scripts` manifest key. + * @param {string[]} [details.css] + * @param {string[]} [details.excludeMatches] Same as `exclude_matches` in the `content_scripts` manifest key. + * @param {string[]} [details.js] + * @param {boolean} [details.matchAboutBlank] Same as `match_about_blank` in the `content_scripts` manifest key. + * @param {string[]} details.matches Same as `matches` in the `content_scripts` manifest key. + * @param {string} [details.urlMatches] Regex match pattern to use as a fallback + * when native content script registration isn't supported. Should be equivalent to `matches`. + * @param {string} [details.runAt] Same as `run_at` in the `content_scripts` manifest key. + * @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'); + } + + // Firefox + if ( + typeof browser === 'object' && browser !== null && + isObject(browser.contentScripts) && + typeof browser.contentScripts.register === 'function' + ) { + const details2 = this._convertContentScriptRegistrationDetails(details, id, true); + const registration = await browser.contentScripts.register(details2); + this._contentScriptRegistrations.set(id, registration); + return; + } + + // Chrome + if (isObject(chrome.scripting) && typeof chrome.scripting.registerContentScripts === 'function') { + const details2 = this._convertContentScriptRegistrationDetails(details, id, false); + await 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} `true` if the content script was unregistered, `false` otherwise. + */ + async unregisterContentScript(id) { + // Chrome + if (isObject(chrome.scripting) && typeof chrome.scripting.unregisterContentScripts === 'function') { + this._contentScriptRegistrations.delete(id); + try { + await this._unregisterContentScriptChrome(id); + return true; + } catch (e) { + return false; + } + } + + // Firefox or fallback + const registration = this._contentScriptRegistrations.get(id); + if (typeof registration === 'undefined') { return false; } + this._contentScriptRegistrations.delete(id); + if (isObject(registration) && typeof registration.unregister === 'function') { + await registration.unregister(); + } + return true; + } + // Private - _injectStylesheetMV2(type, content, tabId, frameId) { + _injectStylesheetMV2(type, content, tabId, frameId, allFrames, matchAboutBlank, runAt) { return new Promise((resolve, reject) => { const details = ( type === 'file' ? { file: content, - runAt: 'document_start', + runAt, cssOrigin: 'author', - allFrames: false, - matchAboutBlank: true + allFrames, + matchAboutBlank } : { code: content, - runAt: 'document_start', + runAt, cssOrigin: 'user', - allFrames: false, - matchAboutBlank: true + allFrames, + matchAboutBlank } ); if (typeof frameId === 'number') { @@ -90,7 +216,7 @@ class ScriptManager { }); } - _injectStylesheetMV3(type, content, tabId, frameId) { + _injectStylesheetMV3(type, content, tabId, frameId, allFrames) { return new Promise((resolve, reject) => { const details = ( type === 'file' ? @@ -99,9 +225,9 @@ class ScriptManager { ); details.target = { tabId, - allFrames: false + allFrames }; - if (typeof frameId === 'number') { + if (!allFrames && typeof frameId === 'number') { details.target.frameIds = [frameId]; } chrome.scripting.insertCSS(details, () => { @@ -115,14 +241,14 @@ class ScriptManager { }); } - _injectScriptMV2(file, tabId, frameId) { + _injectScriptMV2(file, tabId, frameId, allFrames, matchAboutBlank, runAt) { return new Promise((resolve, reject) => { const details = { - allFrames: false, + allFrames, frameId, file, - matchAboutBlank: true, - runAt: 'document_start' + matchAboutBlank, + runAt }; chrome.tabs.executeScript(tabId, details, (results) => { const e = chrome.runtime.lastError; @@ -136,16 +262,15 @@ class ScriptManager { }); } - _injectScriptMV3(file, tabId, frameId) { + _injectScriptMV3(file, tabId, frameId, allFrames) { return new Promise((resolve, reject) => { const details = { files: [file], - target: { - allFrames: false, - frameIds: [frameId], - tabId - } + target: {tabId, allFrames} }; + if (!allFrames && typeof frameId === 'number') { + details.target.frameIds = [frameId]; + } chrome.scripting.executeScript(details, (results) => { const e = chrome.runtime.lastError; if (e) { @@ -157,4 +282,132 @@ class ScriptManager { }); }); } + + _unregisterContentScriptChrome(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(); + } + }); + }); + } + + _convertContentScriptRegistrationDetails(details, id, firefoxConvention) { + const {allFrames, css, excludeMatches, js, matchAboutBlank, matches, runAt} = details; + const details2 = {}; + if (!firefoxConvention) { + details2.id = id; + details2.persistAcrossSessions = true; + } + if (typeof allFrames !== 'undefined') { + details2.allFrames = allFrames; + } + if (Array.isArray(excludeMatches)) { + details2.excludeMatches = [...excludeMatches]; + } + if (Array.isArray(matches)) { + details2.matches = [...matches]; + } + if (typeof runAt !== 'undefined') { + details2.runAt = runAt; + } + if (firefoxConvention && typeof matchAboutBlank !== 'undefined') { + details2.matchAboutBlank = matchAboutBlank; + } + if (Array.isArray(css)) { + details2.css = this._convertFileArray(css, firefoxConvention); + } + if (Array.isArray(js)) { + details2.js = this._convertFileArray(js, firefoxConvention); + } + return details2; + } + + _convertFileArray(array, firefoxConvention) { + return firefoxConvention ? array.map((file) => ({file})) : [...array]; + } + + _registerContentScriptFallback(id, details) { + const {allFrames, css, js, matchAboutBlank, runAt, urlMatches} = details; + const urlRegex = new RegExp(urlMatches); + const details2 = {allFrames, css, js, matchAboutBlank, runAt, urlRegex}; + let unregister; + const webNavigationEvent = this._getWebNavigationEvent(runAt); + if (isObject(webNavigationEvent)) { + const onTabCommitted = ({url, tabId, frameId}) => { + this._injectContentScript(true, details2, null, url, tabId, frameId); + }; + const filter = {url: [{urlMatches}]}; + webNavigationEvent.addListener(onTabCommitted, filter); + unregister = () => webNavigationEvent.removeListener(onTabCommitted); + } else { + const onTabUpdated = (tabId, {status}, {url}) => { + if (typeof status === 'string' && typeof url === 'string') { + this._injectContentScript(false, details2, status, url, tabId, void 0); + } + }; + const extraParameters = {url: [urlMatches], properties: ['status']}; + try { + // Firefox + chrome.tabs.onUpdated.addListener(onTabUpdated, extraParameters); + } catch (e) { + // Chrome + chrome.tabs.onUpdated.addListener(onTabUpdated); + } + unregister = () => chrome.tabs.onUpdated.removeListener(onTabUpdated); + } + this._contentScriptRegistrations.set(id, {unregister}); + } + + _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; + } + } + + async _injectContentScript(isWebNavigation, details, status, url, tabId, frameId) { + const {urlRegex} = details; + if (typeof urlRegex !== 'undefined' && !urlRegex.test(url)) { return; } + + let {allFrames, css, js, matchAboutBlank, 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)) { + const runAtCss = (typeof runAt === 'string' ? runAt : 'document_start'); + for (const file of css) { + promises.push(this.injectStylesheet('file', file, tabId, frameId, allFrames, matchAboutBlank, runAtCss)); + } + } + if (Array.isArray(js)) { + for (const file of js) { + promises.push(this.injectScript(file, tabId, frameId, allFrames, matchAboutBlank, runAt)); + } + } + await Promise.all(promises); + } } diff --git a/ext/manifest.json b/ext/manifest.json index b6a9cf41..cd75e216 100644 --- a/ext/manifest.json +++ b/ext/manifest.json @@ -97,7 +97,8 @@ ], "optional_permissions": [ "clipboardRead", - "nativeMessaging" + "nativeMessaging", + "webNavigation" ], "commands": { "toggleTextScanning": { diff --git a/ext/permissions.html b/ext/permissions.html index 04d1d5fa..afb16300 100644 --- a/ext/permissions.html +++ b/ext/permissions.html @@ -123,6 +123,18 @@ +
+
+
webNavigation (optional)
+
+ Yomichan may require this permission to inject content scripts for certain browsers + if Google Docs accessibility mode is enabled. +
+
+
+ +
+
Allow in private windows (optional)