diff --git a/mcp/packages/browser-extension/README.md b/mcp/packages/browser-extension/README.md index b476b11656..c9d4a0943d 100644 --- a/mcp/packages/browser-extension/README.md +++ b/mcp/packages/browser-extension/README.md @@ -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: diff --git a/mcp/packages/browser-extension/public/manifest.json b/mcp/packages/browser-extension/public/manifest.json deleted file mode 100644 index c7377dddb4..0000000000 --- a/mcp/packages/browser-extension/public/manifest.json +++ /dev/null @@ -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" - } - ] -} diff --git a/mcp/packages/browser-extension/scripts/build.mjs b/mcp/packages/browser-extension/scripts/build.mjs index 84aeb6af9b..6d7b6b3069 100644 --- a/mcp/packages/browser-extension/scripts/build.mjs +++ b/mcp/packages/browser-extension/scripts/build.mjs @@ -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"); +} diff --git a/mcp/packages/browser-extension/scripts/manifest.mjs b/mcp/packages/browser-extension/scripts/manifest.mjs new file mode 100644 index 0000000000..6fab3ff8bd --- /dev/null +++ b/mcp/packages/browser-extension/scripts/manifest.mjs @@ -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"] }, + }, + }, + }; +} diff --git a/mcp/packages/browser-extension/src/WakeStrategies.ts b/mcp/packages/browser-extension/src/WakeStrategies.ts index b4f1e64147..8365af4087 100644 --- a/mcp/packages/browser-extension/src/WakeStrategies.ts +++ b/mcp/packages/browser-extension/src/WakeStrategies.ts @@ -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( diff --git a/mcp/packages/browser-extension/src/background.ts b/mcp/packages/browser-extension/src/background.ts index 8c56b03f32..a7a6b4165b 100644 --- a/mcp/packages/browser-extension/src/background.ts +++ b/mcp/packages/browser-extension/src/background.ts @@ -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(); diff --git a/mcp/packages/browser-extension/src/manifest.test.ts b/mcp/packages/browser-extension/src/manifest.test.ts index b2abe144f1..82f2c04fc3 100644 --- a/mcp/packages/browser-extension/src/manifest.test.ts +++ b/mcp/packages/browser-extension/src/manifest.test.ts @@ -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" }); + } });