Chrome extension: Checking if content script has been injected or not - javascript

I'm developing a Chrome extension. Instead of using manifest.json to match content script for all URLs, I lazily inject the content script by calling chrome.tabs.executeScript when user do click the extension icon.
What I'm trying is to avoid executing the script more than once. So I have following code in my content script:
if (!window.ALREADY_INJECTED_FLAG) {
window.ALREADY_INJECTED_FLAG = true
init() // <- All side effects go here
}
Question #1, is this safe enough to naively call chrome.tabs.executeScript every time the extension icon got clicked? In other words, is this idempotent?
Question #2, is there a similar method for chrome.tabs.insertCSS?
It seems impossible to check the content script inject status in the backgroud script since it can not access the DOM of web page. I've tried a ping/pong method for checking any content script instance is alive. But this introduces an overhead and complexity of designing the ping-timeout.
Question #3, any better method for background script to check the inject status of content script, so I can just prevent calling chrome.tabs.executeScript every time when user clicked the icon?
Thanks in advance!

is this safe enough to naively call chrome.tabs.executeScript every
time the extension icon got clicked? In other words, is this
idempotent?
Yes, unless your content script modifies the page's DOM AND the extension is reloaded (either by reloading it via the settings page, via an update, etc.). In this scenario, your old content script will no longer run in the extension's context, so it cannot use extension APIs, nor communicate directly with your extension.
is there a similar method for chrome.tabs.insertCSS?
No, there is no kind of inclusion guard for chrome.tabs.insertCSS. But inserting the same stylesheet again does not change the appearance of the page because all rules have the same CSS specificity, and the last stylesheet takes precedence in this case. But if the stylesheet is tightly coupled with your extension, then you can simply inject the script using executeScript, check whether it was injected for the first time, and if so, insert the stylesheet (see below for an example).
any better method for background script to check the inject status of
content script, so I can just prevent calling
chrome.tabs.executeScript every time when user clicked the icon?
You could send a message to the tab (chrome.tabs.sendMessage), and if you don't get a reply, assume that there was no content script in the tab and insert the content script.
Code sample for 2
In your popup / background script:
chrome.tabs.executeScript(tabId, {
file: 'contentscript.js',
}, function(results) {
if (chrome.runtime.lastError || !results || !results.length) {
return; // Permission error, tab closed, etc.
}
if (results[0] !== true) {
// Not already inserted before, do your thing, e.g. add your CSS:
chrome.tabs.insertCSS(tabId, { file: 'yourstylesheet.css' });
}
});
With contentScript.js you have two solutions:
Using windows directly: not recommended, cause everyone can change that variables and Is there a spec that the id of elements should be made global variable?
Using chrome.storage API: That you can share with other windows the state of the contentScript ( you can see as downside, which is not downside at all, is that you need to request permissions on the Manifest.json. But this is ok, because is the proper way to go.
Option 1: contentscript.js:
// Wrapping in a function to not leak/modify variables if the script
// was already inserted before.
(function() {
if (window.hasRun === true)
return true; // Will ultimately be passed back to executeScript
window.hasRun = true;
// rest of code ...
// No return value here, so the return value is "undefined" (without quotes).
})(); // <-- Invoke function. The return value is passed back to executeScript
Note, it's important to check window.hasRun for the value explicitly (true in the example above), otherwise it can be an auto-created global variable for a DOM element with id="hasRun" attribute, see Is there a spec that the id of elements should be made global variable?
Option 2: contentscript.js (using chrome.storage.sync you could use chrome.storage.local as well)
// Wrapping in a function to not leak/modify variables if the script
// was already inserted before.
(chrome.storage.sync.get(['hasRun'], (hasRun)=>{
const updatedHasRun = checkHasRun(hasRun); // returns boolean
chrome.storage.sync.set({'hasRun': updatedHasRun});
))()
function checkHasRun(hasRun) {
if (hasRun === true)
return true; // Will ultimately be passed back to executeScript
hasRun = true;
// rest of code ...
// No return value here, so the return value is "undefined" (without quotes).
}; // <-- Invoke function. The return value is passed back to executeScript

Rob W's option 3 worked great for me. Basically the background script pings the content script and if there's no response it will add all the necessary files. I only do this when a tab is activated to avoid complications of having to add to every single open tab in the background:
background.js
chrome.tabs.onActivated.addListener(function(activeInfo){
tabId = activeInfo.tabId
chrome.tabs.sendMessage(tabId, {text: "are_you_there_content_script?"}, function(msg) {
msg = msg || {};
if (msg.status != 'yes') {
chrome.tabs.insertCSS(tabId, {file: "css/mystyle.css"});
chrome.tabs.executeScript(tabId, {file: "js/content.js"});
}
});
});
content.js
chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) {
if (msg.text === 'are_you_there_content_script?') {
sendResponse({status: "yes"});
}
});

Just a side note to the great answer from Rob.
I've found the Chrome extension from Pocket is using a similar method. In their dynamic injected script:
if (window.thePKT_BM)
window.thePKT_BM.save();
else {
var PKT_BM_OVERLAY = function(a) {
// ... tons of code
},
$(document).ready(function() {
if (!window.thePKT_BM) {
var a = new PKT_BM;
window.thePKT_BM = a,
a.init()
}
window.thePKT_BM.save()
}
)
}

For MV3 Chrome extension, I use this code, no chrome.runtime.lastError "leaking" as well:
In Background/Extension page (Popup for example)
private async injectIfNotAsync(tabId: number) {
let injected = false;
try {
injected = await new Promise((r, rej) => {
chrome.tabs.sendMessage(tabId, { op: "confirm" }, (res: boolean) => {
const err = chrome.runtime.lastError;
if (err) {
rej(err);
}
r(res);
});
});
} catch {
injected = false;
}
if (injected) { return tabId; }
await chrome.scripting.executeScript({
target: {
tabId
},
files: ["/js/InjectScript.js"]
});
return tabId;
}
NOTE that currently in Chrome/Edge 96, chrome.tabs.sendMessage does NOT return a Promise that waits for sendResponse although the documentation says so.
In content script:
const extId = chrome.runtime.id;
class InjectionScript{
init() {
chrome.runtime.onMessage.addListener((...params) => this.onMessage(...params));
}
onMessage(msg: any, sender: ChrSender, sendRes: SendRes) {
if (sender.id != extId || !msg?.op) { return; }
switch (msg.op) {
case "confirm":
console.debug("Already injected");
return void sendRes(true);
// Other ops
default:
console.error("Unknown OP: " + msg.op);
}
}
}
new InjectionScript().init();
What it does:
When user opens the extension popup for example, attempt to ask the current tab to "confirm".
If the script isn't injected yet, no response would be found and chrome.runtime.lastError would have value, rejecting the promise.
If the script was already injected, a true response would result in the background script not performing it again.

Related

Injected script doesn't run correctly unless cache is cleared

I'm working on rewriting a Chrome extension to manifest v3 (damn you, Google monopoly!)
It's targeted towards a certain website, adding a bunch of cool features. This means I have to inject script that can access the page context. The website uses PageSpeed Insights (presumably an older version because I doubt they use eval nowadays), which fetches stringified code and evaluates it as such:
<script src="https://cdn.tanktrouble.com/RELEASE-2021-07-06-01/js/red_infiltration.js+messages.js.pagespeed.jc.vNQfCE2Wzx.js"></script>
<!-- ^ Contains the variables `mod_pagespeed_nEcHBi$9_H = 'function foo(){...}'` and `mod_pagespeed_gxZ81E5yX8 = 'function bar(){...}'` and the scripts below evaluate them -->
<script>eval(mod_pagespeed_nEcHBi$9_H);</script>
<script>eval(mod_pagespeed_gxZ81E5yX8);</script>
Now, I've come up with a method where I override the native eval function, take the string that it being evaluated, I generate a hash and compare it with some stored hashes. If they match, I stop the script from being evaluated and inject mine instead. Works good in theory, and acts as a failsafe if the site updates any code.
The problem lies in the fact that a good 50% of the time, the evaluation happens before I override eval, and it seems to relate to cache. If I do a hard reset (Ctrl+Shift+R) it works most of the time and injects my scripts instead as intended. However, a normal reload/site load, and it won't work.
manifest.json
...
"content_scripts": [{
"run_at": "document_start",
"js": ["js/content.js"],
"matches": [ "*://*.tanktrouble.com/*" ]
}
], ...
content.js (content script)
class GameLoader {
constructor() {
// Preload scripts, why not?
this.hasherScript = Object.assign(document.createElement('script'), {
'src': chrome.runtime.getURL('js/hasher.js'),
'type': 'module' // So we can import some utils and data later on
});
// These custom elements are a hacky method to get my chrome runtime URL into the site with element datasets.
this.extensionURL = document.createElement('tanktroubleaddons-url');
this.extensionURL.dataset.url = chrome.runtime.getURL('');
}
observe() {
return new Promise(resolve => {
const observer = new MutationObserver((mutations, observer) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.tagName === 'HEAD') {
node.insertBefore(this.extensionURL, node.firstChild); // extensionURL is used in hasherScript.js.
node.insertBefore(this.hasherScript, node.firstChild); // Inject the hash module
resolve(), observer.disconnect();
}
}
}
})
.observe(document.documentElement, { childList: true, subtree: true });
});
}
}
const loader = new GameLoader();
loader.observe();
hasher.js (this.hasherScript)
import ScriptHashes from '/config/ScriptHashes.js'; // The script hashes previously mentioned. The format is { '7pqp95akl2s': 'game/gamemanager.js' ... }
import Hasher from '/js/utils/HashAlgorithm.js'; // Quick hash algorithm
/**
Here we implement the aforementioned hacky method for the extension URL.
*/
const nodeData = document.querySelector('tanktroubleaddons-url'),
extensionURL = nodeData.dataset.url;
window.t_url = function(url) {
return extensionURL + url;
}
// Change the native eval function and generate a hash of the script being evaluated.
const proxied = eval;
const hashLength = ScriptHashes.length;
window.eval = function(code) {
if (typeof code === 'string') {
const codeHash = Hasher(code),
match = ScriptHashes.hashes[codeHash]; // Check the newly generated hash against my list of hashes. If match, it should return a path to use be used in script fetching.
if (match) {
// Synchronous script fetching with jQuery. When done, return null so original script won't be evaluated.
$.getScript(t_url('js/injects/' + match), () => {
return null;
});
}
}
return proxied.apply(this, arguments);
}
Weird behaviour
I have noticed weird behaviour when changing hasherScript to a regular script rather than a module. If I exclude the type paramter and paste the imports directly into hasher.js, things load and work perfectly. Scripts get evaluated every time et cetera. It makes me wonder if it's a synchronous/asynchronous issue.
I haven't been able to find anything on this, unfortunately.
Thanks in advance! :)
I'd try waiting for the load state of DOM, if that's not enough and there's scripts rendering the page further you can wait for a specific element/attribute before full insertion but this may not be enough so added CheckState.
When you insert it the way you do it's mostly done before DOM has fully loaded so perhaps your issue.
/*
The DOM state should be loaded before injecting code, however this depends on the site. Calling this makes sure that all HTML/JavaScript/CSS is loaded.
*/
window.onload = async() => {
console.log("HTML/JavaScript/CSS are considered to be loaded now");
console.log("Page may be running scripts, wait for finish!");
CheckState();
// Above is optional or may be needed.
};
let fullyloaded;
function CheckState() {
console.log("checksite");
fullyloaded = setTimeout(() => {
/*
Sometimes the site is loaded 100% but nothing is on the page, possibly loading the rest of the site through script. In this case you can run a setTimeout
*/
if (document.querySelector('.SelectionThatTellsUsPageIsLoadedProperly')) {
InjectCode();
} else {
CheckState();
}
}, 10000);
}
function InjectCode() {
console.log("We're loaded, let's insert the code now no issues");
}

Code in $(document).ready, sometimes works sometimes doesnt

I am trying to dynamically map data from one object to another in an $(document).ready() function, the problem is that it works whenever it wants, the console.logs throughout the code are sometimes working, sometimes not.
var newBtnsObj = {};
$(document).ready(() => {
robots.forEach((robot) => {
newBtnsObj[robot.Name] = {};
newBtnsObj[robot.Name].Buttons = {};
console.log(newBtnsObj);
Object.keys(robot.ConfigData).forEach((dataType) => {
if (dataType === DataTypes.IO) {
Object.values(robot.ConfigData[`${dataType}`]).forEach((IOData) => {
if (IOData.length > 0) {
IOData.forEach((s) => {
console.log("HI");
newBtnsObj[robot.Name].Buttons[s.HtmlID] = {
DataType: dataType,
ID: s.ID,
Val: null,
};
});
}
});
} else {
Object.values(robot.ConfigData[`${dataType}`]).forEach((data) => {
console.log("doublehi");
newBtnsObj[robot.Name].Buttons[data.HtmlID] = {
DataType: dataType,
ID: data.ID,
Val: null,
};
});
}
});
});
});
robots is an array that is generated inside a socket.io event whenever someone connects to the page, I am using NodeJS on the backend to emit the "config" data that is used in the creation of the object and it is the first thing that is emitted when someone opens the page. I am opening the page locally and hosting the page and the server locally.
socket.on("config", (config) => {
config.forEach((robot) => {
robots.push(new Robot(robot.Name, robot.configData, robot.Events));
});
The order in which I load the files in the HTML is first the socket file and then the file with the $(document).ready code.
The problem is that newBtnsObj is sometimes created, sometimes not, if I add any new code and refresh the page it works only on the first refresh and then it is again an empty object. But even that doesnt work everytime, I can't even reproduce it 100%.
Now just for completeness I have added the full code, but even if I remove the newBtnsObj creation and leave it only with the console.log("hi")'s I have the same issue, so the issue is not in the object creation itself (if I copy and paste the code in the browser console it works perfectly). So even if its left to this it still has the same issue:
$(document).ready(() => {
robots.forEach((robot) => {
Object.keys(robot.ConfigData).forEach((dataType) => {
if (dataType === DataTypes.IO) {
Object.values(robot.ConfigData[`${dataType}`]).forEach((IOData) => {
if (IOData.length > 0) {
IOData.forEach((s) => {
console.log("HI");
});
}
});
} else {
Object.values(robot.ConfigData[`${dataType}`]).forEach((data) => {
console.log("doublehi");
});
}
});
});
});
Additionally, I thought that the problem may be that, I am trying to use the robots array before it is created from the config received in the initial socket event, so I have tried placing the code within the event itself, so that it executes right after the event is received, and it worked. Obviously that is the issue, how can I solve it? Is the only way to keep the code in the socket event, and if I want to extract it, I have to make another internal event to let the code know that the robots array is ready?
It seems like you programmed yourself a race-condition there. So your jQuery is executed whenever the page is loaded, regardless if the robots object is there or not. But the generation of the object is related to connections and network timing too. So your script only runs fine, if the generation is faster as your page loading.
And now you might ask why it would not work, if you copy the code below the generation itself. That is a race-condition too. Code inside a jQuery ready state will only be executed after page load. If you paste the code inside another script, like the object generation, the ready state may be registered at a time, where the page was already loaded. So it will never be executed, because you registered it to late.
So what you need to do, is to ensure that the code is only executed after the generation is finished. Maybe by using a callback, an event or something else. But for now, you have two related scripts what run whenever they want and nobody waits for each other. ;)
By the way, just a hint: Using $(document).ready(() => {}) is not best practice and out-dated too. There is a shorthand for that. And you should use jQuery instead of $ for calling it, so your scripts will work in noConflict mode too. So insted use jQuery(($) => {}).

Correct way to hook Chrome extension background script message handler

I have a custom dev-tools panel extension with the background script that connects panel and page together. It worked great with only one tab but when I opened a second or third tab, I got duplicated messages because the runtime.onMessage handler was duplicated. To fix this I modified the code to check if the handler was already hooked but I've still got problems.
background.js
(function () {
var tabCount = 0;
var devPanelPorts = {};
let chromeMessageHandler = function (request, sender, sendResponse) {
console.log('background message listener', sender.tab.id, sender, request);
if (sender.tab) {
var tabId = sender.tab.id;
if (tabId in devPanelPorts) {
devPanelPorts[tabId].postMessage(request); //definition removed here for simplicity
}
return true;
}
if (!chrome.runtime.onMessage.hasListener(chromeMessageHandler)) { //On the second call this is still true
console.log("hook background message listener");
chrome.runtime.onMessage.addListener(chromeMessageHandler);
tabCount++;
console.log(chrome.runtime.onMessage.hasListener(chromeMessageHandler)); //returns true
}
...
})();
chromeMessageHandler is hooked correctly the first time, but the second time chrome.runtime.onMessage.hasListener(chromeMessageHandler) is still false. If I move chromeMessageHandler to a var outside of the IFFE, it looks like it works. I'd prefer to have it in the IFFE but what I don't get is tabCount increments correctly, remembering the correct value for the second and third call. Why does tabCount refer to the same variable but chromeMessageHandler doesn't seem to?
What is the correct way to write this part of the extension background script?

How can I run a script in another (newly-opened) tab?

I am trying to run a script in a new tab. The code I use is this:
$ = jQuery;
function openAndPush(url, id) {
var win = window.open('https://example.com' + url + '?view=map');
var element = $('<script type="text/javascript">console.log("Starting magic...");var region_id='+id+';$=jQuery;var p=$(\'div["se:map:paths"]\').attr(\'se:map:paths\');if(p){console.log("Found! pushing..."); $.get(\'https://localhost:9443/addPolygon\', {id: region_id, polygon: p}, function(){console.log("Done!")})}else{console.log("Not found!");}</script>').get(0);
setTimeout(function(){ win.document.body.appendChild(element);
console.log('New script appended!') }, 10000);
}
Considering the following:
I was inspired in this answer, but used jQuery instead.
I run this code in an inspector/console, from another page in https://example.com (yes, the actual domain is not example.com - but the target url is always in the same domain with respect to the original tab) to avoid CORS errors.
When I run the function (say, openAndPush('/target', 1)) and then inspect the code in another inspector, one for the new window, the console message Starting magic... is not shown (I wait the 10 seconds and perhaps more). However the new DOM element (this script I am creating) is shown in the Elements tab (and, in the first console/inspector, I can see the New script appended! message).
(In both cases jQuery is present, but not occupying the $ identifier, which seems to be undefined - so I manually occupy it)
What I conclude is that my script is not being executed in the new window.
What am I missing? How can I ensure the code is being executed?
Instead of embedding script element in the document, do this.
wrap the code that you want to run in another tab, into a function.
bind that wrapped function to the new tab's window
Call that function
Here's the code that I ran in my console and it worked for me i.e another tab was opened and alert was displayed.
function openAndPush(url, id) {
var win = window.open('https://www.google.com');
win.test = function () {
win.alert("Starting magic...");
}
win.test();
setTimeout(function () {
win.document.body.appendChild(element);
console.log('New script appended!')
}, 10000);
}
Found that my error consisted on the origin document being referenced when creating a new script node, instead of the target document (i.e. win.document). What I needed is to change the code to reference the new document and create the node directly, no jQuery in the middle at that point. So I changed my code like this:
function openAndPush(url, id) {
var win = window.open('https://streeteasy.com' + url + '?view=map');
var element = win.document.createElement('script');
element.type='text/javascript';
element.innerHTML = 'console.log("Starting magic...");var region_id='+id+';$=jQuery;var p=$(\'div[se\\\\:map\\\\:paths]\').attr(\'se:map:paths\');if(p){console.log("Found! pushing..."); $.get(\'https://localhost:9443/addPolygon\', {id: region_id, polygon: p}, function(){console.log("Done!")})}else{console.log("Not found! searched in: ", document);}'
setTimeout(function(){ win.document.body.appendChild(element); console.log('New script appended!') }, 10000);
}
With this code something is essentially happening: The JS code is being parsed (and its node created) in the context of the new document. Older alternatives involved the origin console (since the origin document was implicitly referenced).
It's bad practice to send scripts to another webpage. You can pass some query params using a complicated URL and handle it by a source code from another webpage, it's much better:
function doMagicAtUrlByRegionId (url, regionId) {
window.open(`https://example.com${url}?view=map&magic=true&region_id=${regionId}`);
}

Wait until js script is loaded on the page

I'm trying to add js file to the page. How to check if that file is loaded?
javascript ga.js file:
this.event = function (value) {
this.value = value;
this.method = function() {
return "sometext";
};
};
Dart code:
ScriptElement ga = new ScriptElement()
..src = "/ga.js"
..async = true;
querySelector('body').append(ga);
bool exist = context.hasProperty('event');
JsObject event = null;
if (exist) {
print("event exist");
} else {
print("there is no event yet");
}
You can just add an onload event to any script element in HTML.
ga.onload = function(ev) { alert('loaded!') };
Since this is native HTMLScriptElement behaviour it should combine fine with Dart.
The way I found for now it's to use Future.
void checkEvent() {
bool exist = context.hasProperty('event');
JsObject event = null;
if (exist) {
print("event exist");
} else {
print("there is no event yet");
var future = new Future.delayed(const Duration(milliseconds: 10), checkEvent);
}
}
But it's if I now already that 'event' exist in that javascript file. Maybe there is some way to check when javascript loaded in html?
Most browsers with Developer tools like Chrome, Firefox, and even IE have a tab called Network. In these panels/tabs you can search for the file in question. If it has loaded it will be in that view.
As long as the JavaScript recognizes/attempts to load the .js file, the console will usually tell you if it couldn't load the file.
If you're planning on doing something on your page after the file has loaded then you would want to do something more along the lines of what Niels Keurentjes suggested.
More Information:
Chrome's Network Panel
Firefox's Network Panel
Internet Explorer's Network Panel
OnLoad Event

Categories