Reputation: 1313
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!
Upvotes: 29
Views: 14998
Reputation: 3441
Regarding question 3, here's how I do it. In the content script (the one you want to inject), I listen to a message from the background that tells me when the extension icon has been clicked. And I set a variable using that information, however, I also make sure to send back a response to the background script. So that listener in my injected content script looks something like this:
let extensionIsOn = false
const listener = (message: Message, sender: chrome.runtime.MessageSender, sendResponse: (resp: any) => void) => {
if (message.action === 'toggleExtension') {
extensionIsOn = true
sendResponse(true)
}
}
chrome.runtime.onMessage.addListener(listener)
Now in the background script, all I have to do is send the message when the extension is clicked, but then I add a catch clause for the error that may be thrown if the to be injected script has not been injected yet. This will be the clause where you'd want to inject your script programmatically. After injecting, I do well to send the background message to the injected script again.
chrome.action.onClicked.addListener((tab) => {
chrome.tabs.sendMessage(tab.id!, { action: 'toggleExtension' }).catch(async () => {
// if it failed, it may mean we need to load the script because
// it hasn't been injected before now
console.log('injecting script manually')
await chrome.scripting.executeScript({
target: { tabId: tab.id! },
files: ['js/index.js']
})
console.log('resending toggle signal')
// attempt to toggle it again
chrome.tabs.sendMessage(tab.id!, { action: 'toggleExtension' })
})
})
Please note that with this catch-clause approach, we may be catching errors that are thrown for other reasons that don't necessarily imply that the script hadn't been injected. In that case, you may end up attempting to inject the script for the wrong reason, however, in my case, this approach was good enough.
Upvotes: 0
Reputation: 348972
is this safe enough to naively call
chrome.tabs.executeScript
every time the extension icon got clicked? In other words, is this idempotent?
is there a similar method for
chrome.tabs.insertCSS
?
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?
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.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:
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
Upvotes: 25
Reputation: 20648
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.
Upvotes: 0
Reputation: 2654
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"});
}
});
Upvotes: 12
Reputation: 1313
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()
}
)
}
Upvotes: 1