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
This commit is contained in:
toasted-nutbread 2021-11-23 16:16:13 -05:00 committed by GitHub
parent cedf6b25c4
commit ecc994a8bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 303 additions and 34 deletions

View File

@ -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": ["<all_urls>"], "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": ["<all_urls>"]}], "after": "web_accessible_resources_old"},
{"action": "move", "path": ["web_accessible_resources_old"], "newPath": ["web_accessible_resources", 0, "resources"]}

View File

@ -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) {

View File

@ -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<void>}
*/
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<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 {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<boolean>} `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);
}
}

View File

@ -97,7 +97,8 @@
],
"optional_permissions": [
"clipboardRead",
"nativeMessaging"
"nativeMessaging",
"webNavigation"
],
"commands": {
"toggleTextScanning": {

View File

@ -123,6 +123,18 @@
<label class="toggle"><input type="checkbox" class="permissions-toggle" data-required-permissions="nativeMessaging"><span class="toggle-body"><span class="toggle-track"></span><span class="toggle-knob"></span></span></label>
</div>
</div></div>
<div class="settings-item" data-hide-for-manifest-version="3"><div class="settings-item-inner">
<div class="settings-item-left">
<div class="settings-item-label"><code>webNavigation</code> <span class="light">(optional)</span></div>
<div class="settings-item-description">
Yomichan may require this permission to inject content scripts for certain browsers
if Google Docs accessibility mode is enabled.
</div>
</div>
<div class="settings-item-right">
<label class="toggle"><input type="checkbox" class="permissions-toggle" data-required-permissions="webNavigation"><span class="toggle-body"><span class="toggle-track"></span><span class="toggle-knob"></span></span></label>
</div>
</div></div>
<div class="settings-item"><div class="settings-item-inner">
<div class="settings-item-left">
<div class="settings-item-label">Allow in private windows <span class="light">(optional)</span></div>