Simple Hardware Hacking: Auto "On Air" VC Indicator - Chapter 2

This is the second chapter in our journey to build an automatic On Air “sign” based on whether or not were on a call. If you haven’t read chapter 1 yet, you can check it out here: Chapter 1

Part 4: Chrome Browser Extension

What you’ll need:

  • a computer with Google Chrome on it

Up until now, we’ve just created a dumb API which allows turning a light on and off. Not very exciting. Now, we’ll start to add some awareness so we can automatically turn on and off our “On Air” LED.

Note: I recommend checking out the official Chrome Extension Getting Started Guide. It has a lot of debugging/development tips which I won’t attempt to recreate here but if you're going to follow along, that guide will provide a lot of useful information.

First, make a directory where your new extension will live. Next, you’ll need to add a manifest.json file with the following content:

{
  "name": "Meeting Status Notifier",
  "version": "1.0",
  "description": "notify people of the meeting status",
  "permissions": ["tabs", "storage"],
  "manifest_version": 2
}

The main interesting thing in this manifest is the permissions list. For this project, we just need to be able to see the URLs of the open tabs, and store a bit of metadata.

At this point you can go into chrome://extensions, enable developer mode, and upload your unpacked extension:

Figure 1: How to enable Chrome extensions developer mode

Figure 1: How to enable Chrome extensions developer mode

Now that we’ve installed our shell Chrome extension, we can actually start coding. To review the logic, when we start a VC call, we want to turn the on-air LED on, and when we leave the call, we want the light to turn off. As mentioned before, the majority of my VC’s are done over Google Hangouts. Helpfully, Hangouts uses a consistent prefix (https://hangouts.google.com/call/) for active video calls which we can look for when deciding whether I’m on a call.

There are a ton of APIs that are available to extensions. We could approach this problem in a bunch of different ways. I felt that using the tabs API was the best way to go.

In pseudocode, the logic is basically:

So the question becomes, how can we use the tabs API to enable this behavior? All the chrome APIs have various events that you can listen for. In our case, the most useful thing to listen for is changes to tabs using the onUpdated event. This will notify us of any changes to the URL in any of the tabs on the browser (e.g. if one of them navigates to a URL that begins with hangouts.google.com/call)

Thus far, our extension is only a manifest, so let’s update it to add a place for us to put our JavaScript implementation:

{
  "name": "Meeting Status Notifier",
  "version": "1.0",
  "description": "notify people of the meeting status",
  "permissions": ["tabs", "storage"],
  "background" : {
    "scripts": ["background.js"],
    "persistent": false
  },
  "manifest_version": 2
}

Now, create the background.js that we're referring to in the manifest above. As we mentioned before, we want to use an onUpdated event listener to see when we’re opening or closing Hangouts windows. One thing I like to do when I’m working with a new API is to write some print debugging to see how it behaves. Let’s create a dummy callback and see what happens. Enter the following into your new background.js file:

chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
  if (changeInfo.URL) {
    console.log(changeInfo.URL);
  }
});

If you go back into Chrome’s extension panel, locate your new extension and click the refresh button, it will pull in your latest changes:

Figure 2: How to update your extension after changing the code

Figure 2: How to update your extension after changing the code

Now, we can click on the background page link to inspect the behavior of our new code. The window it opens looks like a typical chrome inspector. You can do pretty much everything you’d expect for a bit of js running in a web page, set breakpoints, step through the code, read any console messages.

If you start opening new tabs and going to new sites, you’ll notice that our callback is printing out the URLs that we’re visiting. Cool, so now we have a way to know when we’ve arrived at a Hangouts URL. Let’s update the code to indicate if we’ve visited a Hangouts URL and turn the light on:

const HANGOUTS_PREFIX = "https://hangouts.google.com/call/";
const SIGN_ENDPOINT = "http://192.168.1.22:5000";
 
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
    if (changeInfo.url && changeInfo.url.startsWith(Hangouts_PREFIX)) {
fetch(`${SIGN_ENDPOINT}/on`, { mode: "no-cors" });
} });

To walk through this, we’re saying that, if a tab changes its URL and that URL starts with the Hangouts_PREFIX, we should send a request to our “SIGN_ENDPOINT” to turn the device on. We’re using the fetch API which gives us a nice, terse way to call our API endpoint, especially given that we don’t need to do anything with the response.

Note: We’re using mode: no-cors here because technically a request from an extension to anywhere outside of the extension's own files is a cross-origin request. Cross-origin requests require special handling from the server but allow you to do more powerful things that we don’t need. So we just specify no-cors and don’t have to worry about any of that.

This is getting close to what we wanted, but there are a couple of problems with this approach:

  • according to the onUpdated callback documentation, the changeInfo provided only has the new URL that was navigated to, not the previous one

  • a tab being closed does not trigger the update event.

We could maintain a list of the tabs that have visited the Hangouts call URL and then remove them from the list when we see that they've visited another URL or been closed, but that strategy is fairly delicate and prone to errors. For example, if our browser crashes and our script doesn't have a chance to remove that tab from the list so we'll end up with a zombie tab that doesn't exist anymore but is still in our list of Hangouts tabs.

Luckily there's another way! The Tabs API provides a method to query our open tabs by a bunch of different dimensions including the current URL. Now, we can query by our Hangouts prefix and know exactly how many Hangouts tabs there without having to do any finicky maintenance of our own state.

So our new pseudocode is:

  • when our tabs change

  • query for all tabs matching the Hangouts prefix

  • if there are any, turn the light on

  • if there are none, turn the light off

Let's give that a shot:

const HANGOUTS_PATTERN = 'https://hangouts.google.com/call/*';
const SIGN_ENDPOINT = 'http://192.168.1.22:5000';

chrome.tabs.onUpdated.addListener(() => {
  chrome.tabs.query({ URL: Hangouts_PATTERN }, tabs => {
    const path = tabs.length > 0 ? 'on' : 'off';
    fetch(`/`, {
      mode: 'no-cors',
    })
  });
});

A couple things to note:

  • we've changed our HANGOUTS_PREFIX variable to a HANGOUTS_PATTERN, by adding a * at the end. This follows the URL pattern scheme that the query method expects.

  • we've removed the arguments from the callback method since we're not using any of the parameters passed to us


This is very close to what we're looking for. One issue that remains, as described before, is that if we leave the Hangouts call by closing the tab, this callback won't be called. If we want really snappy performance, we should also execute this code when a tab is closed. For that we'll use the onRemoved event:

const HANGOUTS_PATTERN = 'https://Hangouts.google.com/call/*';
const SIGN_ENDPOINT = 'http://192.168.1.22:5000';

function checkTabsAndUpdateStatusIfNecessary() {
  chrome.tabs.query({ URL: Hangouts_PATTERN }, tabs => {
    const path = tabs.length > 0 ? 'on' : 'off';
    fetch(`${SIGN_ENDPOINT}/${path}`, {
      mode: 'no-cors',
    })
  });
}

chrome.tabs.onUpdated.addListener(() => {
  checkTabsAndUpdateStatusIfNecessary();
});

chrome.tabs.onRemoved.addListener(() => {   checkTabsAndUpdateStatusIfNecessary(); });

Now, we've extracted the body of our previous onUpdated callback into a helper method and we're calling that helper method both when a tab has changed or has been closed.

From a functional standpoint, this solution works great! It's decently robust to the browser crashing, and it will update the status immediately when the Hangouts tab / window is closed. 

One problem that it does suffer from is unnecessary traffic. With the current solution, we'll be calling the endpoint every time we navigate to a new URL, even if our busy status hasn't changed! This is clearly suboptimal. So what can we do about it?

Enter the Chrome extension storage APIs:

This API will allow us to keep track of the prior stored response and only make our fetch request in the case that our current situation is different from the previously registered situation.

Here's what our new checkTabsAndUpdateStatusIfNecessary method looks like:

function checkTabsAndUpdateStatusIfNecessary() {
  
chrome.storage.local.get(null, value => { // committedStatus represents the status set by the last // successful fetch request let committedStatus = null; if (value && value.committedStatus === 'boolean') { committedStatus = value.committedStatus; }
chrome.tabs.query({ URL: Hangouts_PATTERN }, tabs => {
const currentStatus = tabs.length > 0; if (currentStatus != committedStatus) {
const path = currentStatus ? 'on' : 'off'; fetch(`${SIGN_ENDPOINT}/${path}`, { mode: 'no-cors',
}).then(() => { chrome.storage.local.set({ committedStatus: currentStatus }); });
} }); }); }

In this version, we've wrapped the previous body of our checkTabsAndUpdateStatusIfNecessary function in a call to get the stored data for our extension, if there is any. Next, we check if we've set the value to anything previously, by checking if it's a boolean value, in which case we'll use our stored value as the "committedStatus". Finally, in our tab query, we check whether or not the stored value matches the current value. If not, we make a new fetch to our REST API. If / when that request completes, we write the new status into our local store.

So here is the code for our chrome extension in all of its glory:

const Hangouts_PATTERN = 'https://hangouts.google.com/call/*';
const SIGN_ENDPOINT = 'http://192.168.1.22:5000';
 
function checkTabsAndUpdateStatusIfNecessary() {
  chrome.storage.local.get(null, value => {
    // committedStatus represents the status set by the last
    // successful fetch request
    let committedStatus = null;
    if (value && value.committedStatus === 'boolean') {
      committedStatus = value.committedStatus;
    }
 
    chrome.tabs.query({ URL: Hangouts_PATTERN }, tabs => {
      const currentStatus = tabs.length > 0;
      if (currentStatus != committedStatus) {
        const path = currentStatus ? 'on' : 'off';
        fetch(`${SIGN_ENDPOINT}/${path}`, {
          mode: 'no-cors',
        }).then(() => {
          chrome.storage.local.set({ committedStatus: currentStatus });
        });
      }
    });
  });
}
 
chrome.tabs.onUpdated.addListener(() => {
  checkTabsAndUpdateStatusIfNecessary();
});
 
chrome.tabs.onRemoved.addListener(() => {
  checkTabsAndUpdateStatusIfNecessary();
});

If you want the most, up-to-date version of the code checkout the GitHub repo.

To recap, this is what the final version of our extension is doing:

  • Listening for tab updates or removals

  • Getting the status of the previous successful request

  • Querying all of the tabs to count how many, if any, are currently at Hangouts call URLs

  • If the current status does not match the previously recorded status, making a new request to update the sign via the corresponding path (i.e. "/on" if we have a Hangouts tab, "/off" if we don't)

  • Storing the new committed status

Final Result

Figure 3: The working On Air “Sign”

Figure 3: The working On Air “Sign”

There you have it, our automatic, VC detector sign! Obviously there's a lot of improvements we can still make but this completes the spirit of the initial vision! Keep an eye out for future updates where I will be improving / extending this in different and wonderful ways!

Matthew KellerComment