mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 14:00:37 +00:00
✨ Build the MCP browser extension for Firefox
Build one extension per browser, in dist/chrome and dist/firefox, from the same source. A script makes each manifest. The code keeps the chrome namespace, which Firefox implements with promises in Manifest V3. The Firefox manifest runs the background as an event page, leaves out the debugger permission, and sets the add-on ID and Firefox 140 as the minimum version. Its extension page policy drops the default upgrade-insecure-requests, which would turn ws:// servers into wss://. Firefox has no debugger API, and it does not freeze background pages: it only slows their timers, which the plugin's heartbeat survives. The background therefore unfreezes in place only where the debugger API exists, and otherwise brings the tab forward and goes back, as a fallback that should rarely run. Firefox stops an event page that is idle for 30 s, even while its WebSocket is open, so the wake agent connection would drop and come back with each alarm. While it holds a connection, the background calls an extension API every 20 s, which Firefox counts as activity.
This commit is contained in:
parent
f9c5763345
commit
c13fb65e99
@ -1,6 +1,6 @@
|
||||
# Penpot MCP Wake
|
||||
|
||||
A Chrome extension that wakes a background Penpot tab when an MCP task needs it.
|
||||
A Chrome and Firefox extension that wakes a background Penpot tab when an MCP task needs it.
|
||||
|
||||
Chrome freezes background tabs, and a frozen tab cannot run the MCP plugin. When a task
|
||||
targets a frozen session, the MCP server asks this extension to activate the tab, waits
|
||||
@ -10,22 +10,27 @@ for the plugin to answer again, and then runs the task.
|
||||
|
||||
- The content script asks the Penpot page for its MCP session. The page answers with the
|
||||
session ID, MCP server URL, token and connection status.
|
||||
- The service worker maps each session to its tab and opens one WebSocket per MCP server
|
||||
and token, on the endpoint the plugin uses. It registers there as a wake agent.
|
||||
- When the server sends a wake request, the worker wakes the tab as described below.
|
||||
- The background (a service worker in Chrome, an event page in Firefox) maps each session to
|
||||
its tab and opens one WebSocket per MCP server and token, on the endpoint the plugin uses.
|
||||
It registers there as a wake agent.
|
||||
- When the server sends a wake request, the background wakes the tab as described below.
|
||||
|
||||
The popup turns waking on or off and lists the paired sessions and server connections.
|
||||
|
||||
## How a tab is woken
|
||||
|
||||
- The extension unfreezes the page in place: it attaches the debugger to the tab, sets the
|
||||
page lifecycle state to active, and detaches, without switching tabs. Chrome shows a
|
||||
debugging notice while the extension is attached. Chrome does not allow `debugger` as an
|
||||
optional permission, so the extension requires it, and Chrome warns about it at install.
|
||||
- **Fallback**, when the debugger fails (for instance with DevTools open on the tab): the
|
||||
extension brings the tab forward, waits until the page reports that it runs again, then
|
||||
returns to the tab and window that had the focus. Whether the window comes forward depends
|
||||
on the window manager.
|
||||
- **Chrome** unfreezes the page in place: the extension attaches the debugger to the tab,
|
||||
sets the page lifecycle state to active, and detaches, without switching tabs. Chrome
|
||||
shows a debugging notice while the extension is attached. Chrome does not allow
|
||||
`debugger` as an optional permission, so the extension requires it, and Chrome warns
|
||||
about it at install.
|
||||
- **Firefox** has no debugger API, but it does not freeze background pages either: it only
|
||||
slows their timers, which the plugin's heartbeat survives. The extension keeps tabs that
|
||||
hold a session from being unloaded.
|
||||
- **Fallback**, when the debugger fails in Chrome (for instance with DevTools open on the
|
||||
tab) and always in Firefox: the extension brings the tab forward, waits until the page
|
||||
reports that it runs again, then returns to the tab and window that had the focus.
|
||||
Whether the window comes forward depends on the window manager.
|
||||
|
||||
## Build and load
|
||||
|
||||
@ -37,13 +42,19 @@ pnpm --filter mcp-common run build
|
||||
pnpm --filter mcp-browser-extension run build
|
||||
```
|
||||
|
||||
Then open `chrome://extensions`, turn on developer mode, choose "Load unpacked" and pick
|
||||
`mcp/packages/browser-extension/dist`. Reload Penpot tabs opened before the extension was
|
||||
loaded, so the content script runs in them.
|
||||
The build writes one extension per browser, in `dist/chrome` and `dist/firefox`.
|
||||
|
||||
- **Chrome:** open `chrome://extensions`, turn on developer mode, choose "Load unpacked" and
|
||||
pick `mcp/packages/browser-extension/dist/chrome`.
|
||||
- **Firefox** (140 or later): open `about:debugging#/runtime/this-firefox`, choose "Load
|
||||
Temporary Add-on…" and pick `mcp/packages/browser-extension/dist/firefox/manifest.json`.
|
||||
Firefox removes temporary add-ons when it closes.
|
||||
|
||||
Reload Penpot tabs opened before the extension was loaded, so the content script runs in them.
|
||||
|
||||
The content script runs on `https://design.penpot.app` and on `localhost` at any port.
|
||||
|
||||
The service worker does not use the exception you grant a page for an untrusted
|
||||
In Chrome, the service worker does not use the exception you grant a page for an untrusted
|
||||
certificate. With the development environment's self-signed certificate it fails with
|
||||
`net::ERR_CERT_AUTHORITY_INVALID` until Chrome trusts the certificate. On Linux, copy it out
|
||||
of the devenv's main container and add it to Chrome's certificate store:
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Penpot MCP Wake",
|
||||
"version": "0.1.0",
|
||||
"description": "Wakes a background Penpot tab when an MCP task needs it.",
|
||||
"minimum_chrome_version": "116",
|
||||
"permissions": ["storage", "alarms", "debugger"],
|
||||
"action": {
|
||||
"default_title": "Penpot MCP Wake",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["https://design.penpot.app/*", "https://localhost/*", "http://localhost/*"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,11 +1,18 @@
|
||||
import { build } from "esbuild";
|
||||
import { cp, rm } from "node:fs/promises";
|
||||
import { cp, rm, writeFile } from "node:fs/promises";
|
||||
import { BROWSERS, createManifest } from "./manifest.mjs";
|
||||
|
||||
const options = { bundle: true, target: "chrome116", outdir: "dist", logLevel: "info" };
|
||||
const TARGETS = { chrome: "chrome116", firefox: "firefox140" };
|
||||
|
||||
await rm("dist", { recursive: true, force: true });
|
||||
// the service worker and the popup load as modules
|
||||
await build({ ...options, entryPoints: ["src/background.ts", "src/popup.ts"], format: "esm" });
|
||||
// content scripts load as classic scripts
|
||||
await build({ ...options, entryPoints: ["src/content.ts"], format: "iife" });
|
||||
await cp("public", "dist", { recursive: true });
|
||||
for (const browser of BROWSERS) {
|
||||
const outdir = `dist/${browser}`;
|
||||
const options = { bundle: true, target: TARGETS[browser], outdir, logLevel: "info" };
|
||||
// Chrome loads the service worker as a module, Firefox the event page as a classic script
|
||||
await build({ ...options, entryPoints: ["src/background.ts"], format: browser === "chrome" ? "esm" : "iife" });
|
||||
await build({ ...options, entryPoints: ["src/popup.ts"], format: "esm" });
|
||||
// content scripts load as classic scripts
|
||||
await build({ ...options, entryPoints: ["src/content.ts"], format: "iife" });
|
||||
await cp("public", outdir, { recursive: true });
|
||||
await writeFile(`${outdir}/manifest.json`, JSON.stringify(createManifest(browser), null, 4) + "\n");
|
||||
}
|
||||
|
||||
56
mcp/packages/browser-extension/scripts/manifest.mjs
Normal file
56
mcp/packages/browser-extension/scripts/manifest.mjs
Normal file
@ -0,0 +1,56 @@
|
||||
/** Browsers the extension is built for, one directory of `dist/` each. */
|
||||
export const BROWSERS = ["chrome", "firefox"];
|
||||
|
||||
const CONTENT_SCRIPTS = [
|
||||
{
|
||||
matches: ["https://design.penpot.app/*", "https://localhost/*", "http://localhost/*"],
|
||||
js: ["content.js"],
|
||||
run_at: "document_idle",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Creates the extension manifest for a browser.
|
||||
*
|
||||
* Chrome runs the background as a module service worker and needs `debugger` to unfreeze pages
|
||||
* in place. Firefox runs it as an event page, has no debugger API and needs an add-on ID.
|
||||
*
|
||||
* @param {"chrome" | "firefox"} browser - The browser to build for
|
||||
*/
|
||||
export function createManifest(browser) {
|
||||
const common = {
|
||||
manifest_version: 3,
|
||||
name: "Penpot MCP Wake",
|
||||
version: "0.1.0",
|
||||
description: "Wakes a background Penpot tab when an MCP task needs it.",
|
||||
action: {
|
||||
default_title: "Penpot MCP Wake",
|
||||
default_popup: "popup.html",
|
||||
},
|
||||
content_scripts: CONTENT_SCRIPTS,
|
||||
};
|
||||
if (browser === "chrome") {
|
||||
return {
|
||||
...common,
|
||||
minimum_chrome_version: "116",
|
||||
// Chrome does not allow debugger as an optional permission
|
||||
permissions: ["storage", "alarms", "debugger"],
|
||||
background: { service_worker: "background.js", type: "module" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
permissions: ["storage", "alarms"],
|
||||
background: { scripts: ["background.js"] },
|
||||
// the default policy adds upgrade-insecure-requests, which breaks ws:// servers
|
||||
content_security_policy: { extension_pages: "script-src 'self'" },
|
||||
browser_specific_settings: {
|
||||
gecko: {
|
||||
id: "mcp-wake@penpot.app",
|
||||
// the first version that knows data_collection_permissions
|
||||
strict_min_version: "140.0",
|
||||
data_collection_permissions: { required: ["none"] },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -95,7 +95,8 @@ export class ActivateAndReturnStrategy implements WakeStrategy {
|
||||
|
||||
/**
|
||||
* Makes the page active through the debugger, without switching tabs, and falls back to another
|
||||
* strategy when that fails, for instance when DevTools is open on the tab.
|
||||
* strategy when that fails, for instance when DevTools is open on the tab. Chrome only, since
|
||||
* Firefox has no debugger API.
|
||||
*/
|
||||
export class LifecycleStrategy implements WakeStrategy {
|
||||
constructor(
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
* It keeps the map from MCP sessions to the tabs holding them, holds a connection to each MCP
|
||||
* server those sessions use, and activates a session's tab when the server asks it to.
|
||||
*
|
||||
* Chrome may stop the worker at any time, so listeners are registered synchronously at start
|
||||
* and state lives in session storage.
|
||||
* Chrome may stop the worker, and Firefox its event page, at any time, so listeners are
|
||||
* registered synchronously at start and state lives in session storage.
|
||||
*/
|
||||
import {
|
||||
ChromeDiscardControl,
|
||||
@ -24,14 +24,17 @@ import { WakeAgentConnections } from "./WakeAgentConnections.ts";
|
||||
import { ActivateAndReturnStrategy, LifecycleStrategy, ResumeSignals } from "./WakeStrategies.ts";
|
||||
|
||||
const RESTART_ALARM = "restart-connections";
|
||||
const KEEP_ALIVE_MS = 20_000;
|
||||
|
||||
const sessions = new SessionMap(new ChromeSessionStore());
|
||||
const settings = new ChromeWakeSettings();
|
||||
const resumes = new ResumeSignals();
|
||||
const activateAndReturn = new ActivateAndReturnStrategy(new ChromeTabControl(), resumes);
|
||||
// Chrome unfreezes the page in place; Firefox has no debugger API and does not freeze pages
|
||||
const waker = new TabWaker(
|
||||
sessions,
|
||||
settings,
|
||||
new LifecycleStrategy(new ChromeLifecycleControl(), new ActivateAndReturnStrategy(new ChromeTabControl(), resumes))
|
||||
chrome.debugger ? new LifecycleStrategy(new ChromeLifecycleControl(), activateAndReturn) : activateAndReturn
|
||||
);
|
||||
const connections = new WakeAgentConnections(sessions, getAgentId(), (url) => new WebSocket(url), waker);
|
||||
sessions.observe(new DiscardGuard(new ChromeDiscardControl()));
|
||||
@ -76,4 +79,12 @@ chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
});
|
||||
void chrome.alarms.create(RESTART_ALARM, { periodInMinutes: 0.5 });
|
||||
|
||||
// Firefox stops an event page idle for 30s even while its WebSockets are open, and an extension
|
||||
// API call counts as activity
|
||||
setInterval(() => {
|
||||
if (connections.summaries().length > 0) {
|
||||
void chrome.runtime.getPlatformInfo();
|
||||
}
|
||||
}, KEEP_ALIVE_MS);
|
||||
|
||||
void connections.sync();
|
||||
|
||||
@ -1,31 +1,61 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { BROWSERS, createManifest } from "../scripts/manifest.mjs";
|
||||
|
||||
const manifest = JSON.parse(await readFile(new URL("../public/manifest.json", import.meta.url), "utf8"));
|
||||
const chrome = createManifest("chrome");
|
||||
const firefox = createManifest("firefox");
|
||||
|
||||
test("is a Manifest V3 extension whose service worker loads as a module", () => {
|
||||
assert.equal(manifest.manifest_version, 3);
|
||||
assert.deepEqual(manifest.background, { service_worker: "background.js", type: "module" });
|
||||
test("builds a Manifest V3 extension for Chrome and Firefox", () => {
|
||||
assert.deepEqual(BROWSERS, ["chrome", "firefox"]);
|
||||
assert.equal(chrome.manifest_version, 3);
|
||||
assert.equal(firefox.manifest_version, 3);
|
||||
});
|
||||
|
||||
test("requests storage, alarms and, to unfreeze pages in place, debugger", () => {
|
||||
// Chrome does not allow debugger as an optional permission
|
||||
assert.deepEqual(manifest.permissions, ["storage", "alarms", "debugger"]);
|
||||
assert.equal(manifest.optional_permissions, undefined);
|
||||
assert.equal(manifest.host_permissions, undefined);
|
||||
test("runs the background as a module service worker in Chrome and as an event page in Firefox", () => {
|
||||
assert.deepEqual(chrome.background, { service_worker: "background.js", type: "module" });
|
||||
assert.deepEqual(firefox.background, { scripts: ["background.js"] });
|
||||
});
|
||||
|
||||
test("requests storage, alarms and, in Chrome to unfreeze pages in place, debugger", () => {
|
||||
// Chrome does not allow debugger as an optional permission; Firefox has no debugger API
|
||||
assert.deepEqual(chrome.permissions, ["storage", "alarms", "debugger"]);
|
||||
assert.deepEqual(firefox.permissions, ["storage", "alarms"]);
|
||||
for (const manifest of [chrome, firefox]) {
|
||||
assert.equal(manifest.optional_permissions, undefined);
|
||||
assert.equal(manifest.host_permissions, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("gives the Firefox build an add-on ID and a minimum version", () => {
|
||||
assert.deepEqual(firefox.browser_specific_settings, {
|
||||
gecko: {
|
||||
id: "mcp-wake@penpot.app",
|
||||
strict_min_version: "140.0",
|
||||
data_collection_permissions: { required: ["none"] },
|
||||
},
|
||||
});
|
||||
assert.equal(chrome.browser_specific_settings, undefined);
|
||||
});
|
||||
|
||||
test("lets the Firefox build reach ws:// servers, as Chrome does", () => {
|
||||
assert.deepEqual(firefox.content_security_policy, { extension_pages: "script-src 'self'" });
|
||||
assert.equal(chrome.content_security_policy, undefined);
|
||||
});
|
||||
|
||||
test("runs the content script on Penpot and on local development instances", () => {
|
||||
assert.deepEqual(manifest.content_scripts, [
|
||||
{
|
||||
matches: ["https://design.penpot.app/*", "https://localhost/*", "http://localhost/*"],
|
||||
js: ["content.js"],
|
||||
run_at: "document_idle",
|
||||
},
|
||||
]);
|
||||
for (const manifest of [chrome, firefox]) {
|
||||
assert.deepEqual(manifest.content_scripts, [
|
||||
{
|
||||
matches: ["https://design.penpot.app/*", "https://localhost/*", "http://localhost/*"],
|
||||
js: ["content.js"],
|
||||
run_at: "document_idle",
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("opens the popup from the toolbar button", () => {
|
||||
assert.deepEqual(manifest.action, { default_title: "Penpot MCP Wake", default_popup: "popup.html" });
|
||||
for (const manifest of [chrome, firefox]) {
|
||||
assert.deepEqual(manifest.action, { default_title: "Penpot MCP Wake", default_popup: "popup.html" });
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user