mabel.ink

Making a browser tab lie, on purpose

October 7, 2026

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:

  1. Open a live stream and check in the console that document.hidden is false.
  2. Turn on Video Stats in the player's advanced settings and note the resolution.
  3. 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.