MobiDevTools Plugin Guide
Plugin API version 1 · MobiDevTools 4.1
MobiDevTools can be extended with new tabs. Every extra tab is a plugin, including the built-in Pharos (page audits), React, Vue, Angular and State tabs. Their source (plugins/*.js) is the reference implementation of everything described here.
There are two kinds of plugins:
| Extension plugins | Page plugins | |
|---|---|---|
| Lives in | a .js file inside the extension (plugins/) |
your own web page or app |
| Registered with | MDT_PLUGINS.register({...}) |
window.MobiDevTools.registerPanel({...}) |
| Runs in | the extension's content-script sandbox | the page, with the page's own privileges |
| Access to the page | full (DOM + JavaScript objects) via api.page |
it is the page |
| Access to MobiDevTools UI | builds its UI inside a MobiDevTools tab | renders into a container placed over the panel |
| Typical use | generic tools for every site (framework inspectors, auditors…) | app-specific debug panels (your store, feature flags, mocks…) |
Contents
- Quick start — extension plugin
- Quick start — page plugin
- Plugin definition
- Lifecycle
- The
apiobject - Working with page objects
- UI, styling and theming
- Mobile guidelines
- Performance guidelines
- Page plugins in depth
- Security model
- Packaging, ordering and distribution
- Testing your plugin
- Recipes
- Compatibility and versioning
1. Quick start — extension plugin
Create plugins/hello.js:
"use strict";
(function () {
if (!window.MDT_PLUGINS) return;
window.MDT_PLUGINS.register({
id: "hello",
title: "Hello",
icon: ["C12,12,9", "M8 14s1.5 2 4 2 4-2 4-2", "M9 9h.01", "M15 9h.01"],
order: 100,
mount(container, api) {
const btn = api.ui.button("Count links", () => {
const n = api.page.document.querySelectorAll("a").length;
api.console.log("This page has", n, "links");
api.toast(n + " links");
});
container.appendChild(btn);
}
});
})();
Add it to the injection list in background.js, before contentScript.js:
const MDT_MODULES = [
"styles.js", "icons-data.js", "beautify.js", "assistant.js",
"page-hooks.js", "inspector.js", "plugin-api.js",
"plugins/react.js", "plugins/vue.js",
"plugins/hello.js", // ← your plugin
"contentScript.js"
];
Load the extension as a temporary add-on (about:debugging → This Firefox → Load Temporary Add-on… → pick manifest.json), open any page, tap the MobiDevTools icon: there is a new Hello tab.
A complete, slightly bigger example is in examples/extension-plugin/perf-marks.js. It adds a "Marks" tab listing the page's performance.mark() / measure() entries.
2. Quick start — page plugin
Put this in your web app (development builds only, see §10):
const panel = {
id: "flags",
title: "Flags",
render(container, mdt) {
container.innerHTML = "<label><input type=checkbox id=dark> Dark mode</label>";
container.querySelector("#dark").onchange = (e) => {
document.body.classList.toggle("dark", e.target.checked);
mdt.log("dark mode:", e.target.checked);
};
}
};
if (window.MobiDevTools) window.MobiDevTools.registerPanel(panel);
else (window.__MOBIDEVTOOLS_PLUGINS__ = window.__MOBIDEVTOOLS_PLUGINS__ || []).push(panel);
Open MobiDevTools on your page: there is a Flags tab. The last two lines cover both orders of events. If MobiDevTools is already open, the panel is registered right away. Otherwise it is queued and picked up when MobiDevTools starts.
A complete example (an app store inspector with an action log) is in examples/page-plugin/store-panel.html.
3. Plugin definition
MDT_PLUGINS.register(definition) validates and registers a tab. It throws a TypeError with a clear message on invalid input.
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Unique id, /^[a-z][a-z0-9-]{1,31}$/. Reserved: elements console sources network perf storage ua guide settings. Registering an existing id replaces the previous definition. |
title |
string | yes | Tab label (max 24 chars). Keep it short: tabs scroll horizontally on phones. |
icon |
string[] | no | SVG shapes for a 24×24 viewBox (see below). Default: a circle. |
order |
number | no | Position among plugin tabs; lower is further left. Built-ins use 50 (Pharos), 60 (React), 61 (Vue), 61.5 (Angular), 62 (State). Default 100. |
detect |
function | no | detect(api) → boolean. If given, the tab stays hidden until it returns true. It is re-checked every 3 s while the panel is open and after SPA navigations. Keep it cheap. The built-in framework tabs use it to appear only on pages that use their framework. |
description |
string | no | Shown as the tab's tooltip. |
mount |
function | yes | mount(container, api), called the first time the tab is shown. May return a handle (see §4). |
Icon encoding (same as the built-in tabs, see icons-data.js):
| String | Becomes |
|---|---|
"C<cx>,<cy>,<r>" |
<circle> |
"R<x>,<y>,<w>,<h>[,<rx>]" |
<rect> |
| anything else | <path d="…"> |
Icons are drawn with stroke="currentColor", stroke-width="2", no fill. Lucide/Feather icons work as-is.
Other registry functions:
MDT_PLUGINS.list() // → array of registered definitions (sorted by order)
MDT_PLUGINS.get("react") // → definition or null
MDT_PLUGINS.onRegister(fn) // → unsubscribe function; fn(definition) for later registrations
MDT_PLUGINS.API_VERSION // → 1
4. Lifecycle
register() ──► tab button appears
first tap ──► mount(container, api) ──► returns handle
every show ──► handle.onShow()
leave tab ──► handle.onHide()
navigation ──► handle.onNavigate(url) (pushState/replaceState inside the same document)
element ──► handle.onElementSelected(node) (the user selected a node in Elements)
The object returned by mount may implement any of these hooks:
mount(container, api) {
let timer = null;
return {
onShow() { refresh(); timer = setInterval(refresh, 2000); },
onHide() { clearInterval(timer); },
onNavigate(url) { refresh(); },
onElementSelected(n) { /* n is the page node selected in Elements */ },
dispose() { clearInterval(timer); }
};
}
A note on navigations: a full navigation or reload creates a new document. MobiDevTools is injected again into it, and your plugin with it. mount then runs on the new page, so you never need to clean up after a reload. onNavigate is only for single-page-app URL changes.
mount is lazy. Nothing of your plugin runs until the user opens its tab, so registering many plugins costs nothing at startup.
If mount or a hook throws, MobiDevTools catches it, shows the error in the console as [plugin <id>] …, and keeps working.
5. The api object
Every plugin receives its own api object.
5.1 Identity
| Member | Value |
|---|---|
api.version |
MobiDevTools version, e.g. "4.0" |
api.apiVersion |
Plugin API version (1) |
api.id |
Your plugin id |
api.isMobile |
true on touch devices / Firefox for Android |
api.panelOpenedAt |
Epoch ms when MobiDevTools started in this document (useful to tell the page's activity from the panel's own) |
5.2 api.ui — building the interface
All helpers return plain DOM elements that live inside MobiDevTools' shadow root. Build the rest with normal DOM APIs (createElement, textContent). Do not use innerHTML with page-controlled strings (see §11).
| Helper | Returns | Notes |
|---|---|---|
el(tag, className?, text?) |
Element | text is set with textContent |
button(label, onClick?, className?) |
<button class="mdt-chip"> |
the standard small button |
toolbar() |
<div class="mdt-toolbar"> |
a horizontal bar for buttons/inputs |
input(placeholder?, onInput?) |
<input class="mdt-tbinput"> |
onInput(value) on every change; autocapitalize/spellcheck off |
split() |
{ root, left, right } |
side-by-side on desktop, stacked on phones; both sides scroll |
section(title) |
<div> with a section header |
|
empty(message) |
centered placeholder text | use \n for line breaks |
badge(text, color?) |
small pill | |
icon(paths) |
<svg> |
same encoding as the tab icon |
inspect(value, opts?) |
expandable value tree | the same inspector as the console: objects, arrays, Maps, DOM nodes (hover highlights, click reveals), getters on demand. opts.expanded opens the first level |
table(data, columns?) |
table | same output as console.table() |
onTap(element, fn) |
— | a click that never fires after a scroll gesture |
copy(text) |
— | copies to the clipboard |
addStyle(css) |
— | adds (or replaces) one stylesheet for your plugin inside the shadow root |
download(name, text, mime?) |
— | lets the user save a file (reports, exports) |
5.3 api.page — the inspected page
| Member | Description |
|---|---|
page.window |
The page's window with full visibility of page-defined objects (a waived object, see §6). |
page.document |
The page's document (standard DOM view). |
page.evaluate(code) |
Promise<value>: runs JavaScript in the page with the same engine as the console (it falls back to the isolated mode on strict-CSP pages). Rejects with the thrown error. |
page.highlight(node) / page.unhighlight() |
Draws (or removes) the blue overlay on a page element. |
page.reveal(node) |
Switches to Elements and selects node. |
page.selectedElement |
The node currently selected in Elements ($0), or null. |
page.pick() |
Promise<node \| null>: lets the user tap an element on the page and returns it (null if cancelled with Esc or the pick button). |
page.unwrap(value) |
Returns the waived (page-visible) view of a value (§6). |
page.listeners(target) |
[{ type, capture, passive, once, where }] — the addEventListener registrations of a node, document or window (where is the file:line that added it), or null if listener tracking is unavailable. |
page.listenerTracking |
true when listeners are being tracked. |
page.earlyCapture |
true when MobiDevTools' hooks were installed before the page's scripts ran (after a reload). Otherwise listeners added at start-up are missing. |
page.reload() |
Reloads the page. The panel, and your plugin, start again in the new document. Persist what you need with api.storage (Pharos uses this for "Reload & analyze"). |
5.4 api.console
api.console.log/info/warn/error(...values) writes to the MobiDevTools console. Values are rendered with the inspector, so you can log live page objects.
api.console.list() returns a snapshot of the console: [{ type, text, source, url, restored, count, time }]. type is one of log info warn error debug result input. restored marks messages kept from the previous page by "Preserve log".
5.5 api.network
api.network.list() returns a snapshot of every request captured for the tab. These are the same objects described under the "network" event below. Use it for one-shot analyses. Use the "network" event to follow requests live.
5.6 api.sources
api.sources.open(url, line) opens a file in the Sources tab at a given line.
5.7 api.storage
A small persistent key–value store, private to your plugin (prefixed with plugin:<id>: in the extension storage). It is shared by all sites.
const n = await api.storage.get("runs", 0);
await api.storage.set("runs", n + 1);
Values must be JSON-serialisable.
5.8 api.on(event, handler) — events
Returns an unsubscribe function.
| Event | Payload | When |
|---|---|---|
"navigate" |
url |
SPA navigation (pushState/replaceState) |
"elementSelected" |
page node | a node is selected in Elements |
"network" |
array of request objects | new or updated requests (see below) |
A request object (read-only):
{
id, url, method, type, // type: doc | script | css | img | font | media | xhr | ws | ping | other
status, statusText, mime,
reqHeaders: [{name, value}], resHeaders: [{name, value}],
reqBody: { kind: "raw"|"form", data } | null,
t0, tSend, tHeaders, tEnd, dur, // epoch milliseconds
size, fromCache, ip, error, pending,
originUrl, documentUrl, thirdParty, hasBody
}
5.9 api.toast(message)
Shows a short message at the bottom of the panel.
6. Working with page objects
Firefox runs extension code in a sandbox that sees the page through Xray vision. With Xrays, DOM objects show only their standard properties, and objects created by page scripts look empty. MobiDevTools gives plugins the waived view, which sees everything the page sees:
const W = api.page.window;
W.myApp.store.getState(); // ✔ page-defined objects are fully visible
W.React && W.React.version; // ✔
W.localStorage.getItem("token"); // ✔
Rules that keep you out of trouble:
- Reading page values is safe. Property reads may run page getters, so treat them like any page code.
- Calling page functions works:
W.store.dispatch(action). - Creating values for the page: objects created in your plugin belong to the sandbox, and the page cannot always use them. Build page-side values with the page's constructors:
```js const action = new W.Object(); // a page object action.type = "reset"; W.store.dispatch(action);
W.store.dispatch(W.JSON.parse('{"type":"add","text":"hi"}')); // also fine
``
4. **Passing a callback to the page** (event listener,.then, subscription) needsexportFunction`:
js
W.store.subscribe(exportFunction(() => refresh(), W));
5. Wrap risky reads in try/catch: pages can contain proxies, revoked objects and throwing getters. The built-in plugins use a small helper:
js
function safe(fn, dflt) { try { return fn(); } catch (e) { return dflt; } }
6. DOM nodes from page.document and from waived objects can be passed to page.highlight, page.reveal and ui.inspect as they are.
api.page.evaluate(code) is the simplest option when you just need the result of an expression in the page's own context:
const user = await api.page.evaluate("window.app && app.currentUser");
7. UI, styling and theming
Your tab is a flex column (display:flex; flex-direction:column). Give scrolling areas flex:1; overflow:auto; min-height:0, or use ui.split(), which already does this.
The panel lives in a closed shadow root, so page CSS never reaches your plugin and your CSS never reaches the page. Add CSS with api.ui.addStyle(css). Use a short prefix for your classes (.hello-…) so you don't clash with core classes.
Theme variables (always use them instead of hard-coded colours):
| Variable | Use |
|---|---|
--bg, --bg1, --bg2, --bg3 |
backgrounds, from darkest to lightest |
--bdr |
borders |
--wht |
main text |
--dim |
secondary text |
--acc |
accent (links, selection) |
--grn, --yel, --red, --org, --pur, --info |
status colours |
--mono, --ui |
font stacks (code / interface) |
--touch, --touch-sm |
minimum touch target sizes (bigger on phones) |
Reusable core classes: mdt-toolbar, mdt-chip (.on for active), mdt-tbinput, mdt-dsec (section header), mdt-drow + mdt-dk + mdt-dv (key/value rows), mdt-plugin-empty, mdt-plugin-badge.
api.ui.addStyle(`
.hello-list { padding: 6px 10px; font-family: var(--mono); }
.hello-item { color: var(--wht); border-bottom: 1px solid var(--bdr); padding: 4px 0; }
.hello-item.bad { color: var(--red); }
`);
8. Mobile guidelines
Most MobiDevTools users run it on Firefox for Android.
- Use
api.ui.onTap(el, fn)(or plainclick) for row activation, nevertouchend. Atouchendalso fires at the end of a scroll gesture, which was a real bug in 3.x. - Make interactive rows at least
var(--touch-sm)tall. - Prefer vertical layouts:
ui.split()stacks automatically on phones. - Inputs: use font-size ≥ 16px on mobile, otherwise Firefox zooms the page on focus.
ui.input()and the core styles already handle this. - Long-press is the mobile right-click. The core inspector uses ~550 ms.
- Keep toolbars short: they wrap on narrow screens.
9. Performance guidelines
- Do nothing in the registration code. Do the work in
mount, and the periodic work only betweenonShowandonHide. - Never poll faster than ~1 s. The built-in React/Vue tabs refresh every 1.2 s and re-render only when a structural signature changes.
- Cap what you render (the core console keeps ≤ 1000 rows in the DOM; the React tab caps at 4000 nodes). Render big lists lazily.
- Use
ui.inspect()for values: it builds children only when a node is expanded. - Clean up timers, observers and listeners in
onHide/dispose.
10. Page plugins in depth
Registration
window.MobiDevTools.registerPanel({
id: "store", // becomes the tab id "page-store"
title: "Store",
icon: ["R4,4,16,16,2"], // optional
render(container, mdt) { /* … */ }
});
window.MobiDevToolsexists only while MobiDevTools is injected in the page.- Before that, push definitions to
window.__MOBIDEVTOOLS_PLUGINS__(an array). MobiDevTools reads it once at startup. renderis called once, the first time the user opens the tab. After that the container is kept and shown or hidden as the user switches tabs.
The mdt object passed to render
| Member | Description |
|---|---|
mdt.version |
MobiDevTools version |
mdt.host |
the page's hostname |
mdt.log/info/warn/error(...values) |
write to the MobiDevTools console (live, inspectable values) |
The container
container is an ordinary <div> of your page, placed over the MobiDevTools panel area while your tab is active. It inherits nothing from the panel, so style it with your own CSS. It does inherit your page's global CSS, so scope your rules. Default colours: dark background #1e1e1e, text #d4d4d4.
Shipping only in development
Keep page plugins out of production bundles:
if (import.meta.env.DEV) import("./devtools/mobidevtools-panel.js"); // Vite
if (process.env.NODE_ENV !== "production") require("./mdt-panel"); // webpack
11. Security model
- Extension plugins run in the content-script sandbox, with the same privileges as MobiDevTools' panel code. They are reviewed together with the extension when it is published on addons.mozilla.org. Rules:
- No remote code: no
eval,new Function,<script src="https://…">or code downloaded at runtime. Code you run in the page throughapi.page.evaluate()must come from the user or from your plugin's own source. - Build UI with
textContent/createElement. Never put page-controlled strings intoinnerHTML. - Don't send page data anywhere. MobiDevTools declares
data_collection_permissions: none. - Page plugins run as the page and get nothing more than the page already has:
- They receive a container in the light DOM, outside MobiDevTools' closed shadow root. A page plugin cannot use its container (
getRootNode(),parentNode…) to read the panel. That matters because the Network tab can display HttpOnly cookies and authorization headers that page scripts must never see. mdt.log()only displays values. It gives no access to extension APIs.- MobiDevTools' own UI is never handed to page code, and events inside the panel do not propagate to the page.
12. Packaging, ordering and distribution
- Extension plugins are files of the extension. Add them to
MDT_MODULESinbackground.jsafterplugin-api.jsand beforecontentScript.js. Order among plugins does not matter: tab order comes fromorder. - To share a plugin with the community, open a pull request / patch with the file and the
MDT_MODULESline. Attach a screenshot and a test page. - Firefox only installs signed extensions. A fork with extra plugins must be signed through addons.mozilla.org (it can be "unlisted" for private use), or loaded as a temporary add-on for development.
13. Testing your plugin
- Desktop:
about:debugging→ Load Temporary Add-on. Edit, then press Reload. Content-script errors appear in the Browser Console (Ctrl+Shift+J). - Android: use
web-ext run -t firefox-android(needsadb) or install a signed build. - Plugin errors are also shown inside the MobiDevTools console as
[plugin <id>] …. - The source package includes a test harness (
test/, Node + Firefox WebDriver BiDi) used to verify every release. The React and Vue suites are good templates for plugin tests.
14. Recipes
List the selected element's dataset
mount(container, api) {
const box = api.ui.el("div");
container.appendChild(box);
function show(node) {
box.textContent = "";
if (!node) { box.appendChild(api.ui.empty("Select an element in Elements")); return; }
box.appendChild(api.ui.inspect(api.page.unwrap(node).dataset, { expanded: true }));
}
show(api.page.selectedElement);
return { onElementSelected: show };
}
Watch failing requests
mount(container, api) {
const list = api.ui.el("div");
container.appendChild(list);
api.on("network", (reqs) => reqs
.filter((r) => !r.pending && (r.error || r.status >= 400))
.forEach((r) => list.appendChild(api.ui.el("div", "mdt-drow", r.status + " " + r.method + " " + r.url))));
}
Let the user pick an element, then audit it
api.ui.button("Pick", async () => {
const node = await api.page.pick();
if (!node) return;
const cs = api.page.window.getComputedStyle(node);
api.console.log("contrast check for", node, cs.color, "on", cs.backgroundColor);
});
Talk to a framework devtools hook
const hook = api.page.window.__MY_FRAMEWORK_DEVTOOLS__;
if (hook) hook.on("update", exportFunction(() => refresh(), api.page.window));
15. Compatibility and versioning
api.apiVersionis1for the whole 4.x series. Additions are backwards compatible: new optional fields, events and helpers.- A breaking change would bump
apiVersion. Check it if you depend on something specific:
js
if (api.apiVersion < 1) { container.appendChild(api.ui.empty("Needs MobiDevTools 4+")); return; }
* Internal functions that are not on api (anything else you can reach on window.MDT_*) are not part of the API and may change in any release.
MobiDevTools — © 2025–2026 Francesco De Stefano — MIT License.