KH4 Companion is a small extension I built: it counts down to Kingdom Hearts IV, puts the days remaining on the toolbar badge, pulls series news and trailers from public feeds, carries a lore compendium, and hides a three-lane rhythm minigame in the popup. It has been on the Chrome Web Store since 19 August. As of this week it is also on addons.mozilla.org, which makes it my first Mozilla listing.
I had been putting the port off, because "port" sounds like work. It was not. Same build, same version number, same feature set — what changed was four keys in manifest.json. This is the writeup I wanted to find before I started.
The thing nobody tells you first
The blocker is not your code. It is that AMO rejects the package before it ever shows you a listing form. So the order of operations is: fix the manifest, get the linter to zero errors, then worry about icons and screenshots and copy. Assets built against a package that cannot upload are wasted.
npx addons-linter@latest <extension-dir> is the gate. Run it before you touch anything else.
1. Firefox needs an explicit add-on ID
Chrome derives an extension ID for you. Firefox does not — in MV3 you must state it:
"browser_specific_settings": {
"gecko": { "id": "kh4-companion@dhseadev.online" }
}
The email-ish form or a {8-4-4-4-12} GUID both work. Pick carefully: this ID is your update identity forever. Changing it later means a new listing, not an update.
2. There are no extension service workers in Firefox
This is the real difference, and it is smaller than it sounds. Firefox runs an event page where Chrome runs a service worker. background.service_worker is simply ignored, with a BACKGROUND_SERVICE_WORKER_IGNORED warning. The cross-browser answer is the dual key:
"background": {
"scripts": ["core/lib.js", "background.js"],
"service_worker": "background.js"
}
Chrome reads service_worker. Firefox reads scripts. One file, both browsers.
Two traps live in here, and both pass a manifest review and fail at runtime:
importScripts does not exist in an event page. If your service worker starts with importScripts('core/lib.js'), that is a ReferenceError on Firefox. The fix is to list the dependency in background.scripts before the entry file and guard the call:
if (typeof importScripts === 'function' && typeof KH_CORE === 'undefined') {
importScripts('core/lib.js');
}
The second half of that condition is not paranoia — it stops a double-load if some future Firefox does provide importScripts.
Script order is load order. background.scripts executes top to bottom in one shared global. A dependency listed after its consumer is undefined at module top level. Chrome's import graph forgave you; an array does not.
While you are in there: no window, no document, no self.clients in the background. Write to the intersection of both runtimes.
3. Declare what you collect
Since November 2025, every new AMO extension has to declare its data collection in the manifest. It is machine-readable and Firefox shows it in the install prompt.
"browser_specific_settings": {
"gecko": {
"data_collection_permissions": { "required": ["none"] }
}
}
["none"] is the whole declaration when nothing leaves the device — which for this extension is the truth: no analytics, no account, no telemetry, everything in local extension storage. If data does leave, you list types (browsingActivity, personallyIdentifyingInfo, and friends) and they had better match your privacy policy and your description word for word. A reviewer reading three different stories about your data handling is not a style problem, it is a rejection.
This was the key I most enjoyed filling in. Answering an honest none took about four seconds and is the payoff for having built the thing without a tracker in it.
4. strict_min_version is arithmetic, not a guess
Three floors stack up, and you take the highest one that applies to you:
| Constraint | Floor |
|---|---|
| Certificate expiry (updates break below) | 115 ESR / 128 |
| Dual-key background actually starts | 121 |
data_collection_permissions supported |
140 desktop / 142 Android |
Below 121, Firefox refused to start the background page at all when service_worker was present. And declaring the data-collection key under a lower minimum earns you KEY_FIREFOX_UNSUPPORTED_BY_MIN_VERSION warnings. If you are shipping a new MV3 add-on with the data declaration, 140 is the value that lints clean with no version warnings. Go lower only if pre-140 reach is worth carrying the noise.
Also worth knowing: omit gecko_android and you are desktop-only, which may be exactly what you want.
Lint clean is not loaded
The linter is static. Load the thing for real: about:debugging → This Firefox → Load Temporary Add-on → pick manifest.json. Open the popup, exercise the background path, and watch the Browser Console for a ReferenceError. A green lint plus an add-on that never boots is the precise failure the dual-key trap produces.
A few runtime differences that survive a clean lint, in case they bite you where they did not bite me: host permissions are user-grantable and revocable rather than install-time; Firefox maintains a quarantined-domains list where an extension is inert until the user opts it in; web_accessible_resources supports resources/matches/extension_ids but not use_dynamic_url; and chrome.* works in Firefox as an alias, so you do not need the polyfill unless you want promises on old Chrome.
What actually made it cheap
The port took the time it did because of decisions made months earlier, none of which were about Firefox.
The extension has no bundler and no framework. That means the code AMO receives is the code I wrote, which means no source-code submission — the requirement that kicks in when minification or bundling makes the shipped file hard to read, and that ends with a reviewer rebuilding your project and diffing the result. Staying unbundled skipped that gate entirely.
The logic modules are pure: no DOM, no clock of their own, time passed in as an argument. They run identically in Node tests and in the extension, which also means they run identically in a service worker and an event page. There was nothing browser-specific hiding in the parts that matter, because the parts that matter never touched a browser API.
The background is a relay. It holds no durable state, so the service-worker-versus-event-page distinction — which is mostly a distinction about lifetime and what survives a restart — had nothing to disagree with.
I did not do any of that so I could ship on Firefox. I did it because it made the thing testable. The second store was the dividend.
KH4 Companion is an unofficial fan project. It is not affiliated with, endorsed by, or connected to Square Enix or Disney, and it ships no game assets, no logos and no copyrighted text — all art and all compendium prose are original.
Top comments (0)