MobiDevTools

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

  1. Quick start — extension plugin
  2. Quick start — page plugin
  3. Plugin definition
  4. Lifecycle
  5. The api object
  6. Working with page objects
  7. UI, styling and theming
  8. Mobile guidelines
  9. Performance guidelines
  10. Page plugins in depth
  11. Security model
  12. Packaging, ordering and distribution
  13. Testing your plugin
  14. Recipes
  15. 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:

  1. Reading page values is safe. Property reads may run page getters, so treat them like any page code.
  2. Calling page functions works: W.store.dispatch(action).
  3. 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.

9. Performance guidelines


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) { /* … */ }
});

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

12. Packaging, ordering and distribution

13. Testing your plugin

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

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.