Thursday, April 3, 2025

Restrict to TILL Files


We are currently popping up our sidepanel everywhere in Chrome. It seems that it should only be an option when there is a TILL file loaded into the current tab. How can we control that?

In the documentation, Google offers an example of how to control the sidepanel based on the URL. While this is not exactly what we want to do, the example makes it clear that you can control whether or not the sidepanel shows based on "the current tab". Let's see what we can do with that.

It says to add a listener to the tabs.onUpdated event source, like so:
chrome.tabs.onUpdated.addListener(async (tabId, info, tab) => {
    if (tab.url == "http://localhost:1399/") {
      await chrome.sidePanel.setOptions({
        tabId,
        path: 'html/sidepanel.html',
        enabled: true
      });
    } else {
      await chrome.sidePanel.setOptions({
        tabId,
        enabled: false
      });
    }
});

CDP_SIDEPANEL_URL:cdp-till/plugin/js/service-worker.js

But this appears to make no difference at all.

It turns out, upon reading the Mozilla documentation that this is concerned with any updates within a tab. So this will cover us when we use navigation within a tab, but not when we switch to different tabs.

It would seem for that, we need to call the sidePanel setOptions method directly. By default, we want to say that the sidepanel is "off" and then just turn it on for qualifying tabs.

So, at the top:
chrome.sidePanel
    .setPanelBehavior({ openPanelOnActionClick: true })
    .catch((error) => console.error(error));

chrome.sidePanel
    .setOptions({ enabled: false, path: "html/sidepanel.html" })
    .catch((error) => console.error(error));

CDP_SIDEPANEL_URL:cdp-till/plugin/js/service-worker.js

And then when we query the tabs and find a matching tab, turn it on:
chrome.tabs.query({ url: "http://localhost/*" }).then(tabs => {
    for (var tab of tabs) {
        if (tab.url == "http://localhost:1399/") {
            chrome.sidePanel
                .setOptions({ enabled: true, path: "html/sidepanel.html", tabId: tab.id })
                .catch((error) => console.error(error));
            chrome.debugger.attach({ tabId: tab.id }, "1.3");
            chrome.debugger.sendCommand({ tabId: tab.id }, "Debugger.enable");
        }
    }
});

CDP_SIDEPANEL_URL:cdp-till/plugin/js/service-worker.js

And we still have the listener if the user navigates to a TILL application within a tab.

What is a TILL application?

However, I'm not really happy with this solution. I can see how using a URL might be a good solution if you are deploying a specific website; but for a technology like this, I want to say "if the features I want are there". I don't want a long list of all possible websites.

So I want to look for something "on the page" that will tell me I'm dealing with a TILL app. The obvious thing, of course, is the till-runtime.js script. So I want to enable the extension if and only if the selected tab has a <script> tag whose src attribute ends with /till-runtime.js. It's not perfect, since a page may have a file with that name which is nothing to do with us, but choosing this approach does two didactic things for me:
  • It forces me to interrogate the actual page content without using the debugger;
  • It forces me to look at the DOM (before we come back and look at that in more detail next time).
As I understand it, in order to do this, we need to communicate with the page. Now, since we are actually writing the code on the page, we could add a message handler there, and, indeed, that might be the more reliable way to proceed. But from the point of view of trying something new, we are going to add a content script.

Content Scripts are pieces of JavaScript that, rather than running in the background or in the sidepanel are "attached to" the actual page. Imagine, if you will, that your extension has been able to add an extra <script> tag into the <head> portion of the HTML on the page. They run in the context of the page and have the ability to see what is going on there (with the usual caveat that these days almost everything is in a module so you have to be able to import anything you want to see, and even then you might see a duplicate, not the original).

But this is good enough for us to see if the runtime is embedded in the <head>.

So, let us write a content script that sends a message to the service worker when it loads, if the <head> has a script ending in /till-runtime.js:
var scripts = document.head.querySelectorAll("script");
for (var s of scripts) {
    if (s.src.endsWith("/till-runtime.js")) {
        chrome.runtime.sendMessage({ action: "haveTill" });
    }
}

CDP_SIDEPANEL_TILL:cdp-till/plugin/content/find-script.js

Allegedly, we can get this to load by adding it to the manifest:
{
    "manifest_version": 3,
    "name": "Till Debugger",
    "version": "2025.04.02",
    "permissions": ["debugger", "sidePanel", "webRequest"],
    "side_panel": {
        "default_path": "html/sidepanel.html"
    },
    "action": {
        "default_title": "Click to open debugger"
    },
    "background": {
        "service_worker": "js/service-worker.js"
    },
    "content_scripts": [
        {
            "matches": ["<all_urls>"],
            "js": ["content/find-script.js"]
        }
    ]
}

CDP_SIDEPANEL_TILL:cdp-till/plugin/manifest.json

But when I update the plugin, nothing happens. If I try adding tracing, it doesn't come out. The content script seems to be being ignored. It doesn't seem I need any permissions, and I don't seem to be obviously doing anything wrong.

Fortunately, I came across this comment on StackOverflow, which made it clear what the problem was, although it probably should have been obvious to me: I need to reload the page as well as updating the plugin. Presumably content scripts are only pulled into the webpage when the page is loaded. Now, everything works and it sends its message.

OK, so it would be good if someone were listening on the other end. Of course, someone is listening already, they are just ignoring the message. So let's add another case to onMessage in the service worker:
    case "haveTill": {
        if (!tabsWithTill.includes(sender.tab.id)) {
            tabsWithTill.push(sender.tab.id);
            chrome.debugger.attach({ tabId: sender.tab.id }, "1.3");
            chrome.debugger.sendCommand({ tabId: sender.tab.id }, "Debugger.enable");
            chrome.sidePanel.setOptions({
                tabId: sender.tab.id,
                path: 'html/sidepanel.html',
                enabled: true
            });
        }
        break;
    }

CDP_SIDEPANEL_TILL:cdp-till/plugin/js/service-worker.js

The main thing this does is to update a list tabsWithTill (I added this as an empty array at the top of the script). This is the list we will check before allowing the sidepanel to open. Meanwhile, I have also (temporarily) moved the code to enable the debugger here and, of course, enabled the sidepanel.

All of the code that queried the tabs and enabled the debugger has now just been deleted - that depended on the URLs in its entirety. Meanwhile, I have updated the code we just added to check on tabs being updated to say that when a new URL is loaded, we want to disable the TILL sidepanel until such time as the newly loaded page asks us to enable it. I think there is probably a race condition here, but I have no idea how to handle it. What I did notice was that you receive multiple events, one of which is loading and one of which is complete and in my loose testing, the message came through between these two. Thus, I only removed the tab id when it was a loading event with a valid url.
chrome.tabs.onUpdated.addListener(async (tabId, info, tab) => {
    if (info.status != 'loading') {
        return;
    }
    if (!info.url) {
        return;
    }
    for (var i=0;i<tabsWithTill.length;i++) {
        if (tabsWithTill[i] == tabId) {
            tabsWithTill.splice(i, 1);
        }
    }
    await chrome.sidePanel.setOptions({
        tabId,
        enabled: false
    });
});

CDP_SIDEPANEL_TILL:cdp-till/plugin/js/service-worker.js

Waiting for the SidePanel

I feel that still isn't good enough for what I want.

I had to move the code to enable the debugger, but I wasn't happy with where I'd moved it. That code was intended to say that the sidepanel could open; I only want the debugger to function when the sidepanel is open. It seems to me that someone should receive an event when the sidepanel opens and then I can hook onto that to enable the debugger (and presumably disable it when the sidepanel closes).

Amazingly, it seems that there is not. It would seem it may be part of future work, but in the meantime this appears to be the best answer out there.

Experimenting with the plugin suggests that it is possible to see when the sidepanel opens simply by adding code to sidepanel.js: when the sidepanel opens, the HTML page loads and this loads and executes sidepanel.js. But there doesn't seem to be a corresponding close notification, so I'm not sure how much benefit this offers.

I can think of a number of hacks, but I'm not really that interested in hacking things here, so I'm just going to chalk this one up as "can't be done" and move on.

Conclusions

We managed to make it so that the sidepanel (and the debugger) are only operative on web pages that link in the till-runtime.js script.

To my surprise, we were not able to restrict the debugger to only run when the sidepanel was open, because we could not figure out when the sidepanel closed.

No comments:

Post a Comment