26

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!

KF Lin
  • 1,273
  • 2
  • 15
  • 18

4 Answers4

22

is this safe enough to naively call chrome.tabs.executeScript every time the extension icon got clicked? In other words, is this idempotent?

  1. 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?

  1. 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?

  1. 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:

  1. 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?
  2. 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
  • 341,306
  • 83
  • 791
  • 678
  • Thank you Rob! I like your solution for the second question. For #3, it'll introduces the timeout design complexity as I said in the question. For example, if there is a response, good. But if there is no response, I can't determine whether it's because no content script is running or just timing or other problems... Please correct me if I'm wrong. Thanks again. – KF Lin Dec 30 '15 at 12:23
  • @KFLin With the inclusion guard, injecting more than once doesn't matter. So in the worst case you're just unnecessarily injecting scripts when a timing issue occurs. When you use #3, in theory there are two options: 1) You get a response back and know that the script exists or 2) the callback is called without response (and in most cases with [`chrome.runtime.lastError`](https://developer.chrome.com/extensions/runtime#property-lastError) being set). (to be continued) – Rob W Dec 30 '15 at 13:40
  • @FKLin In practice there is another case: The callback is not called. This happens when the tab is killed (https://crbug.com/439780), or when the page redirects to 204 (https://crbug.com/533863). These are edge cases and not likely to occur in your situation (and I've committed to fixing them in the future). – Rob W Dec 30 '15 at 13:42
  • Rob, well explanation! I appreciate it an awful lot! Now I got another issue because `browserify` in my build flow. The content script was built into http://pastebin.com/bQLyFVEq by browserify. So the return value from the inclusion guard just missed somewhere within the closure. In the background script I got the result `null` from `chrome.tabs.executeScript`. Sort of out of topic, but what would you suggest to do in this situation? Thanks again. – KF Lin Dec 30 '15 at 14:18
  • @KFLin Modify your build process such that you can change the return value. For example, prepend the inclusion guard: `window.hasRun ? true : (window.hasRun=true), (function(){ ... browserify output here that does not return true ... })();` (There are dozens of other variants, all you need to do is to change the value of the last expression. – Rob W Dec 30 '15 at 14:37
  • Thanks again. I don't think I understand what you mean on 'change the value of the last expression'. I've tried both prepend and append the inclusion guard to the function call, but still get a null return value from `chrome.tabs.executeScript`. This seems off topic, I've started a new question post on http://stackoverflow.com/questions/34540906 – KF Lin Dec 31 '15 at 04:21
  • @RobW Could you please modify your solution for Manifest V3? – SkyRar Feb 13 '23 at 04:39
  • Getting error `Uncaught TypeError: chrome.storage.sync.get(...) is not a function` Probably due to wrapping within IIFE. The chrome api is not available. – SkyRar Feb 17 '23 at 09:28
11

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"});
    }
});
GivP
  • 2,634
  • 6
  • 32
  • 34
  • 4
    Throws ``Unchecked runtime.lastError: Could not establish connection. Receiving end does not exist.`` if content.js doesn't exist. – EssenceBlue May 04 '19 at 09:37
  • 1
    It does throw the exception mentioned by @EssenceBlue, which you can't try/catch. But adding "if(chrome.runtime.lastError) {}" does prevent Chrome from saying that there are errors with your extension. Also see: https://stackoverflow.com/a/28432087/906308 – BigJ May 06 '19 at 12:46
  • You also need to check chrome.tabs.onUpdated because the user may reload the page that has the script and onActivated does not get invoked in that case. – bvs Feb 26 '20 at 21:10
1

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()
    }
    )
}
KF Lin
  • 1,273
  • 2
  • 15
  • 18
0

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.

Luke Vo
  • 17,859
  • 21
  • 105
  • 181