Making a browser tab lie, on purpose
I keep streams running in a background tab while I work. Twitch notices, and drops the quality to save bandwidth. When I switch back, the picture is soft for a while. I wanted to turn that off, so I wrote a small Firefox extension called Twitch Quality Lock.
I am a sysadmin, not a web developer, so most of what follows was new to me. The interesting part turned out to be less about Twitch and more about where an extension's code actually runs.
How the page knows it is hidden
The browser tells every page whether it is visible, through the Page Visibility API. The page
can read document.hidden and document.visibilityState, and it can listen for a
visibilitychange event that fires when you switch tabs.
Twitch's player uses that signal. When the tab goes hidden, it switches to a low quality mode. The extension's answer is to make the page's answer always the same:
Object.defineProperty(Document.prototype, "hidden", {
configurable: true,
enumerable: true,
get() { return enabled ? false : original.get.call(this); },
});
document.visibilityState is pinned to "visible" the same way, and hasFocus() is wrapped
to return true. The visibilitychange event is stopped with a capturing listener on window,
which runs before any handler Twitch registered. The player never gets told the tab went away.
Why it has to run in the page's own world
Extensions normally run their content scripts in an isolated world. The script sees the same
DOM as the page, but has its own copy of the JavaScript environment. If my script redefined
document.hidden there, Twitch's code would never see it, because Twitch is looking at its own
copy.
So the two scripts that do the lying, visibility.js and quality.js, are declared to run in
the MAIN world, which is the page's real JavaScript environment. They also run at
document_start, before the page's own scripts. If Twitch's player loaded first, it could read
the real values before I replaced them. The manifest says:
"run_at": "document_start",
"world": "MAIN",
Per the README, running in MAIN at document_start needs Firefox 142 or later, and the
manifest enforces that with strict_min_version.
The bridge
There is a catch. Scripts in the page's world are page scripts. They cannot use the extension
APIs, so they cannot read browser.storage, which is where the popup saves your settings.
The fix is a third script, bridge.js, which stays in the isolated world where the extension
API works. It reads the settings from extension storage and copies them into the page's
localStorage under the key tql-settings, then dispatches a window event with the same name.
The page-world scripts read that key and listen for the event.
So the settings flow is: popup writes to extension storage, bridge copies to page localStorage, page-world scripts read it. It is a little roundabout, but it is the only channel both worlds can reach.
The quality setting
Stopping the drop is half of it. The other half is making streams start at the quality I want.
Twitch remembers your choice in localStorage under video-quality, so quality.js writes
that key before the player reads it:
localStorage.setItem("video-quality", JSON.stringify({ default: quality }));
Twitch calls the Source rendition chunked, so that is the default. Twitch is also a
single-page app, so switching channels does not reload the page. The script re-applies the
value on popstate and wraps history.pushState and replaceState to catch navigation.
Checking that it works
The README has three steps:
- Open a live stream and check in the console that
document.hiddenisfalse. - Turn on Video Stats in the player's advanced settings and note the resolution.
- Switch tabs for two minutes or more, come back, and confirm the resolution has not changed.
Step one is the one I like. It checks the lie directly, not just the outcome.
Is it signed
Yes, going by the files. The web-ext-artifacts folder holds a version 1.1.0 package, and
listing it shows a META-INF directory with mozilla.rsa, mozilla.sf and cose.sig. That is
the signature Mozilla adds when it signs an add-on. I have not checked whether it is listed
publicly or distributed only to me, so I will not claim either. The README still describes the
temporary install route and says permanent installs go through signing with web-ext sign.
The security angle
An extension that changes what a page believes about the browser is powerful. This one only
touches Twitch, and the manifest limits it to three hosts: www.twitch.tv, m.twitch.tv and
player.twitch.tv. Its only permission is storage, and it declares that it collects no data.
The two worlds are also a containment decision. The part with access to the extension API
(bridge.js) never runs inside the page, and the parts running inside the page (the two that do
the lying) have no access to the extension API. The page can read the settings key from
localStorage, but it cannot reach anything else the extension holds.
Worth knowing too that the lie is global to the page. While it is on, every script on those pages sees the tab as visible, not just Twitch's player. For this use, that is the point.