Darren Mothersele

Software Developer

Warning: You are viewing old, legacy content. Kept for posterity. Information is out of date. Code samples probably don't work. My opinions have probably changed. Browse at your own risk.

GitDone, MozFest 2015, and Learning Firefox WebExtensions

Nov 10, 2015

web-dev

At Mozfest this last weekend I joined Matt Thompson @OpenMatt and Phillip Smith @phillipadsmith hacking on an idea make GitHub more accessible for non-technical users.

This was my first time playing around with WebExtensions in FireFox. It’s still all quite new, and there’s minimal documentation, but during the weekend at MozFest, we pieced together a working Firefox extension GitDone.

The approach we took was to rewrite bits of the GitHub interface using a Firefox Addon. Hiding bits, moving bits from one page to another, or rewriting bits of the interface. To do this we used an extension to inject CSS and JS into the page. This is not usually a good idea, it’s very fragile. By doing this we are coding against private details internal to GitHub’s implementation and not a public published API. Building against APIs is good, because it’s like a contract, and changes are managable, but with this approach GitHub could change things at any point and break our code. A better approach would be to build a new UI and then integrate with GitHub via their API.

But, fragility doesn’t matter here, as the priority is getting something working so we can test out ideas. I don’t see the technology as an issue in this case, it’s more about understanding what would make GitHub issues easier and more efficient for project managers.

We got a first very basic prototype hacked together during Mozfest, and I hope we’ll continue to develop it as we get feedback from users, but the most interesting thing for me was that it introduced me to the new Firefox addon mechanism, based on WebExtensions (so similar to Chrome and Opera).

Limitation and learning

At first it seemed so easy. I imagined a very trivial, almost vacuous, addon. WebExtensions has support for Content Scripts that “runs in the context of a particular web page”. We can define a CSS file and a URL pattern, such that the CSS file will be injected into pages that match the URL pattern. You can see this in action in our first experiment (commit #bf18807). Great! We can simplify the interface by hiding something things. So, now we move on to moving things and rewriting things? Well, unfortunately not. It turns out it’s not this easy.

GitHub uses pjax to deliver a faster browsing experience. It combines ajax loading and pushState to respond to clicks and load in new content while still maintaining permalinks, page titles, and browser back button functionality.

This breaks our simple content script based CSS injection, because it only gets loaded in on the first GitHub page we access. This causes issues when we want to do different rewritings of the UI on different pages. We need to react to the pjax events that pull in new content.

Isolated worlds

It took a bit of randomly experimenting and randomly searching documentation to work out why this wasn’t working. Being new to Firefox addons, I was quickly having to learn where to find information, how to debug, etc. The frustration was that I could inject code via the console, react to pjax events and see it all working, but I couldn’t do the same from a Content Script loaded via the WebExtension.

The answer came when I realised that the Content Script and the web page run in “isolated worlds”. They have the same copy of the DOM, but outside of this the Javascript environments don’t overlap. The pjax events were happening in the page context, and not visible to the Content Script.

Get out of jail card

I would later find out that regular DOM Events (non-jQuery) bubble up fine and can trigger event handlers in either of the “worlds”. But, for the first working version, it was enough to use the DOM to “inject” our Javascript into the DOM so it could run in the same “world” as the GitHub page Javascript, and thus do our rewritings of the UI based off the pjax events fired there. To do this, we use a Content Script, injector.js that just injects our script into page, via the DOM:

var s = document.createElement('script');
s.src = chrome.extension.getURL('scripts/bootstrap.js');
s.onload = function() {
    this.parentNode.removeChild(this);
};
(document.head || document.documentElement).appendChild(s);

For v 0.1 it was enough to inject the main JS, and do the rewriting in the “world” of the GitHub page. This is isolated from the Content Script that injects it. Unfortunately, in the GitHub world, the JS is not an extension (addon) anymore, so it doesn’t have access to the WebExtension APIs of Firefox. The Content Script does, and can communicate with a “background script” to persist data in local storage, create notifications, etc.

Isolated worlds

In a WebExtension the background script has full access to the APIs for local storage, generating notifications, etc. The Content Script has limited access but can use the features by communicating with the background script. This is done by sending messages and listening for message events.

If our injected JS running in the GitHub page needs to access these features, it first needs a way to communicate back to the Content Script that injected it. The two scripts are in separate “isolated worlds” but they share the DOM of the page, so we can do this using regular DOM events (jQuery events didn’t seem to work in my tests).

For example, in the injected JS we can react to a jQuery event, and turn it into a DOM event:

// in injected JS
// Add out listener to pjax events
$(document).ready(function () {
  $(document).on("pjax:end", function (e) {
    var applyModsEvent = document.createEvent('CustomEvent');
    applyModsEvent.initCustomEvent('gitdone:apply', true, true, {
      activeRoute: document.URL
    });
    document.dispatchEvent(applyModsEvent);
  });
});

And, in the injector Content Script listen for the event:

// in content script
window.addEventListener("gitdone:apply", applyModsEventHandler);
function applyModsEventHandler(e) {
  console.log('Apply modifications for ' + e.detail.activeRoute);
}

The injected JS can’t communicate with the background script, but the content script can. In the content script you send a message event:

// in content script
chrome.runtime.sendMessage({msg: "init"});

Which you can react to in the background script:

// in background.js
chrome.runtime.onMessage.addListener(function (message, sender) {

});

And, in the background script, send messages back to the Content Script:

// in background.js
chrome.tabs.sendMessage(sender.tab.id, state.gitdone);

Which the content script can receive:

// in content script
chrome.runtime.onMessage.addListener(function (message, sender) {
}

Conclusion

So that solves the issue of communicating across the three scripts. We have the injected script, running in the Github page catching pjax events. The content script, sharing the DOM with the Github page but separate JS world. The content script injects the separate js script into the page via a script tag on the DOM. Communication across this barrier is via DOM events.

We also have the background script, having access to all the WebExtension APIs like local storage, notifications, etc. The content script and background script communicate via message events on chrome.runtime.

Useful learnings, and hopefully GitDone will develop into something useful too.