Same code, same transport, same behaviour as the Chrome extension — including automatic download capture, which Firefox fully supports, and HLS/DASH stream sniffing.
Firefox has its own background.js in this directory; everything else is
shared with the Chrome extension:
| File | Owner |
|---|---|
manifest.json, background.js, README.md |
Firefox's own, tracked here |
core.js, content.js, popup.*, welcome.*, icons/ |
copies of extensions/chrome/*, regenerated by the sync below |
core.js is the browser-neutral part of the extension — transport,
capture gates, stream sniffing, menus, the popup's messages — and it binds
whichever namespace exists (browser on Firefox, chrome on Chromium).
background.js is the one thing the browsers do not agree on: how a
download is captured. The manifest loads core.js first, so background.js
reaches the shared functions as plain globals.
scripts/sync-extension-resources.sh firefoxEdit extensions/chrome/* for shared behaviour and
extensions/firefox/background.js for capture — the copies are
overwritten on every sync.
- Manifest V3 with an event page (
background.scripts) instead of a service worker — Firefox's MV3 background is a non-persistent event page, and the shared code runs unchanged on both. The blockingwebRequestlistener is registered synchronously at the top level ofbackground.js, which is what lets Firefox persist it across event-page shutdowns and wake the script for a response rather than letting it through. - The manifest overrides the default MV3 CSP. Firefox's default for
extension pages is
script-src 'self'; upgrade-insecure-requests;, and that second directive rewrites the extension'sws://127.0.0.1:6799towss://, which the app does not speak. With it in place the WebSocket never connected: every request fell through to spawninghydra-host, and the toolbar showed the app as down even while it was running. The manifest'scontent_security_policy.extension_pageskeeps the script restriction and drops the upgrade; the socket never leaves the machine. - Capture happens at the response, not at the download. Firefox has no
onDeterminingFilename, and a download it has already created cannot be handed to Hydra without leaving a "Canceled" row in the browser's own download list —downloads.erase()drops the extension's view of it, not the browser's memory. So Gecko decides one event earlier, in a blockingwebRequest.onHeadersReceived: a response bound for Hydra is cancelled before a download exists, and one left to the browser is never touched. That is whatwebRequestBlockingis in the manifest for; Chromium has no such permission under MV3 and does not need one, because it can park a download withdownloads.pause()and hand it back intact.downloads.onCreatedstays registered underneath as the net for transfers that arrive without a response we were shown. - Stable add-on id (
hydra@ja7ad.github.io, inbrowser_specific_settings.gecko) — native messaging allow-lists Firefox add-ons by id (allowed_extensions), not by an extension-origin URL the way Chromium does. - The proxy setting is readable, but only per browser.
proxy.settingsanswers with Firefox's own object (proxyType,http/ssl/ftp,socks+socksVersion,passthrough) rather than Chromium's, and the shared core reads whichever it is handed. Only Manual proxy configuration names an address; "Use system proxy settings" and an automatic configuration URL do not, and are passed on as nothing. Reading the setting needs no private-browsing access — onlyset()does, and this extension never sets anything. - Site access can be switched off. Firefox grants
host_permissionsat install (127+), but a user can revoke them fromabout:addons→ Hydra → Permissions; if cookies stop being attached, that is where to look.
-
Register the native host. The Hydra app does this itself on every start — it writes Firefox's manifest into
~/Library/Application Support/Mozilla/NativeMessagingHostson macOS,~/.mozilla/native-messaging-hostson Linux, and pointsHKCU\Software\Mozilla\NativeMessagingHostsat it on Windows — so normally there is nothing to do here. From a source checkout, where the app may not have been launched yet, the script does the same thing:scripts/install-native-host.sh
-
Assemble the extension directory:
scripts/sync-extension-resources.sh firefox
-
Load it:
about:debugging#/runtime/this-firefox→ Load Temporary Add-on → pickextensions/firefox/manifest.json.
scripts/build-firefox-xpi.sh # Firefox only
scripts/build-extensions.sh # Firefox .xpi + Chromium .zip + INSTALL.txtBoth write target/extensions/hydra-firefox-<version>.xpi (and verify that
manifest.json sits at the archive root, which Firefox requires) alongside
target/extensions/firefox/, the unpacked copy the archive was made from.
Load either — Load Temporary Add-on accepts an .xpi directly, or the
directory's manifest.json.
build-extensions.sh --out DIR packs into DIR instead; that is how the DMG,
PKG, deb, rpm and Windows installers put the extension inside the installed
application, next to an INSTALL.txt written with that machine's paths.
Temporary add-ons are cleared when Firefox restarts. For a permanent install the package must be signed by Mozilla:
export WEB_EXT_API_KEY=user:12345678:123
export WEB_EXT_API_SECRET=...
scripts/build-firefox-xpi.sh --sign(keys from https://addons.mozilla.org/developers/addon/api/key/; the
unlisted channel means self-distribution, not a public listing). The
alternative is Developer Edition / Nightly / ESR with
xpinstall.signatures.required=false.
Identical to Chrome: a persistent WebSocket to ws://127.0.0.1:6799
(fallback 16799), authenticated by the moz-extension://… origin, with the
native host as the fallback that can launch the app. See the
protocol table.