mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 14:00:37 +00:00
✨ Unfreeze Penpot tabs in place from the MCP browser extension
Bringing the tab forward wakes a frozen page, but it takes the user away from what they are doing, and on some window managers the window does not come forward at all, so the page stays frozen. The extension now 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, and requires the debugger permission at install, since it cannot be optional. When the debugger fails, for instance with DevTools open on the tab, the extension brings the tab forward and goes back to the tab and window that had the focus once the page reports that it runs again, or after 5 s. It goes back only while the woken tab still has the focus, so it does not take away a tab the user chose meanwhile. The content script tells the service worker when its page resumes or becomes visible. The popup shows the last wake request and what the extension did, which the wake result also sends to the server's log.
This commit is contained in:
parent
f6d061cfef
commit
f9c5763345
@ -12,11 +12,21 @@ for the plugin to answer again, and then runs the task.
|
||||
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 activates the tab and focuses its
|
||||
window.
|
||||
- When the server sends a wake request, the worker 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.
|
||||
|
||||
## Build and load
|
||||
|
||||
From `mcp/`:
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Wakes a background Penpot tab when an MCP task needs it.",
|
||||
"minimum_chrome_version": "116",
|
||||
"permissions": ["storage", "alarms"],
|
||||
"permissions": ["storage", "alarms", "debugger"],
|
||||
"action": {
|
||||
"default_title": "Penpot MCP Wake",
|
||||
"default_popup": "popup.html"
|
||||
|
||||
@ -42,6 +42,11 @@ h2 {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.last-wake {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
|
||||
@ -11,6 +11,8 @@
|
||||
<input id="enabled" type="checkbox" />
|
||||
Wake Penpot tabs for MCP tasks
|
||||
</label>
|
||||
<h2>Last wake</h2>
|
||||
<p id="last-wake" class="last-wake"></p>
|
||||
<h2>Paired sessions</h2>
|
||||
<ul id="sessions"></ul>
|
||||
<h2>MCP servers</h2>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { beforeEach, test } from "node:test";
|
||||
import type { ContentMessage } from "./messages.ts";
|
||||
import { PageBridge, type PageWindow, type WorkerChannel } from "./PageBridge.ts";
|
||||
import { PageBridge, type PageDocument, type PageWindow, type WorkerChannel } from "./PageBridge.ts";
|
||||
|
||||
const ORIGIN = "https://design.penpot.app";
|
||||
|
||||
@ -29,6 +29,20 @@ class FakePage implements PageWindow {
|
||||
}
|
||||
}
|
||||
|
||||
/** Document whose lifecycle events the test dispatches. */
|
||||
class FakeDocument implements PageDocument {
|
||||
visibilityState: DocumentVisibilityState = "hidden";
|
||||
private readonly listeners = new Map<string, (() => void)[]>();
|
||||
|
||||
addEventListener(type: string, listener: () => void): void {
|
||||
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
||||
}
|
||||
|
||||
dispatch(type: string): void {
|
||||
for (const listener of this.listeners.get(type) ?? []) listener();
|
||||
}
|
||||
}
|
||||
|
||||
/** Worker channel recording what the bridge sends. */
|
||||
class FakeWorker implements WorkerChannel {
|
||||
readonly received: ContentMessage[] = [];
|
||||
@ -48,12 +62,14 @@ const announcement = {
|
||||
};
|
||||
|
||||
let page: FakePage;
|
||||
let doc: FakeDocument;
|
||||
let worker: FakeWorker;
|
||||
|
||||
beforeEach(() => {
|
||||
page = new FakePage();
|
||||
doc = new FakeDocument();
|
||||
worker = new FakeWorker();
|
||||
new PageBridge(page, worker).start();
|
||||
new PageBridge(page, doc, worker).start();
|
||||
});
|
||||
|
||||
test("asks the page for its session on start, restricted to the page's origin", () => {
|
||||
@ -95,12 +111,22 @@ test("reports a hidden page and asks again when it returns from the back/forward
|
||||
|
||||
test("keeps working when the worker cannot be reached", () => {
|
||||
const failing = new FakePage();
|
||||
new PageBridge(failing, {
|
||||
new PageBridge(failing, new FakeDocument(), {
|
||||
sendMessage: () => {
|
||||
throw new Error("Extension context invalidated.");
|
||||
},
|
||||
}).start();
|
||||
failing.post(announcement);
|
||||
new PageBridge(failing, { sendMessage: () => Promise.reject(new Error("No receiver")) }).start();
|
||||
new PageBridge(failing, new FakeDocument(), {
|
||||
sendMessage: () => Promise.reject(new Error("No receiver")),
|
||||
}).start();
|
||||
failing.post(announcement);
|
||||
});
|
||||
|
||||
test("tells the worker when the page resumes or becomes visible", () => {
|
||||
doc.dispatch("resume");
|
||||
doc.dispatch("visibilitychange");
|
||||
doc.visibilityState = "visible";
|
||||
doc.dispatch("visibilitychange");
|
||||
assert.deepEqual(worker.received, [{ type: "page-resumed" }, { type: "page-resumed" }]);
|
||||
});
|
||||
|
||||
@ -8,6 +8,12 @@ export interface PageWindow {
|
||||
addEventListener(type: string, listener: (event: any) => void): void;
|
||||
}
|
||||
|
||||
/** Document operations the bridge uses; the content script passes its document. */
|
||||
export interface PageDocument {
|
||||
readonly visibilityState: DocumentVisibilityState;
|
||||
addEventListener(type: string, listener: () => void): void;
|
||||
}
|
||||
|
||||
/** Channel from the content script to the service worker. */
|
||||
export interface WorkerChannel {
|
||||
sendMessage(message: ContentMessage): Promise<unknown>;
|
||||
@ -17,12 +23,14 @@ export interface WorkerChannel {
|
||||
* Relays the MCP session announced by a Penpot page to the extension's service worker.
|
||||
*
|
||||
* The bridge asks the page for its session on load, when the page reports it has started
|
||||
* listening, and when the page returns from the back/forward cache. It accepts only messages the page posted on its own window from its own
|
||||
* origin.
|
||||
* listening, and when the page returns from the back/forward cache. It accepts only messages the
|
||||
* page posted on its own window from its own origin. It also tells the worker when the page runs
|
||||
* again after the browser froze or hid it.
|
||||
*/
|
||||
export class PageBridge {
|
||||
constructor(
|
||||
private readonly page: PageWindow,
|
||||
private readonly document: PageDocument,
|
||||
private readonly worker: WorkerChannel
|
||||
) {}
|
||||
|
||||
@ -35,6 +43,12 @@ export class PageBridge {
|
||||
this.sayHello();
|
||||
}
|
||||
});
|
||||
this.document.addEventListener("resume", () => this.send({ type: "page-resumed" }));
|
||||
this.document.addEventListener("visibilitychange", () => {
|
||||
if (this.document.visibilityState === "visible") {
|
||||
this.send({ type: "page-resumed" });
|
||||
}
|
||||
});
|
||||
this.sayHello();
|
||||
}
|
||||
|
||||
|
||||
@ -16,9 +16,10 @@ test("lists the paired sessions and connections without the user tokens", async
|
||||
}
|
||||
);
|
||||
const connections = [{ serverUrl: "wss://penpot/mcp/ws", status: "connected" as const, sessionIds: ["tab"] }];
|
||||
const state = getPopupState(false, sessions, connections);
|
||||
const state = getPopupState(false, null, sessions, connections);
|
||||
assert.deepEqual(state, {
|
||||
enabled: false,
|
||||
lastWake: null,
|
||||
sessions: [{ sessionId: "tab", tabId: 7, serverUrl: "wss://penpot/mcp/ws" }],
|
||||
connections,
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { SessionMap } from "./SessionMap.ts";
|
||||
import type { WakeRecord } from "./TabWaker.ts";
|
||||
import type { ConnectionSummary } from "./WakeAgentConnections.ts";
|
||||
|
||||
/** Request of the popup for the extension's state. */
|
||||
@ -9,6 +10,7 @@ export interface PopupStateRequest {
|
||||
/** State of the extension shown in the popup. */
|
||||
export interface PopupState {
|
||||
enabled: boolean;
|
||||
lastWake: WakeRecord | null;
|
||||
sessions: { sessionId: string; tabId: number; serverUrl: string }[];
|
||||
connections: ConnectionSummary[];
|
||||
}
|
||||
@ -17,12 +19,19 @@ export interface PopupState {
|
||||
* Collects the state shown in the popup. It leaves out the user tokens.
|
||||
*
|
||||
* @param enabled - Whether waking is turned on
|
||||
* @param lastWake - The last wake request, if any
|
||||
* @param sessions - The map of sessions to tabs
|
||||
* @param connections - The summaries of the server connections
|
||||
*/
|
||||
export function getPopupState(enabled: boolean, sessions: SessionMap, connections: ConnectionSummary[]): PopupState {
|
||||
export function getPopupState(
|
||||
enabled: boolean,
|
||||
lastWake: WakeRecord | null,
|
||||
sessions: SessionMap,
|
||||
connections: ConnectionSummary[]
|
||||
): PopupState {
|
||||
return {
|
||||
enabled,
|
||||
lastWake,
|
||||
sessions: sessions.all().map(([sessionId, entry]) => ({
|
||||
sessionId,
|
||||
tabId: entry.tabId,
|
||||
|
||||
@ -1,33 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { beforeEach, test } from "node:test";
|
||||
import { SessionMap } from "./SessionMap.ts";
|
||||
import { TabWaker, type TabControl, type WakeSwitch } from "./TabWaker.ts";
|
||||
import { SessionMap, type SessionEntry } from "./SessionMap.ts";
|
||||
import { TabWaker, type WakeAttempt, type WakeSettings, type WakeStrategy } from "./TabWaker.ts";
|
||||
|
||||
/** Strategy recording the tabs it wakes. */
|
||||
class RecordingStrategy implements WakeStrategy {
|
||||
readonly woken: number[] = [];
|
||||
|
||||
constructor(private readonly openTabs: Set<number>) {}
|
||||
|
||||
async wake(entry: SessionEntry): Promise<WakeAttempt> {
|
||||
if (!this.openTabs.has(entry.tabId)) return { tabExists: false, details: "Gone." };
|
||||
this.woken.push(entry.tabId);
|
||||
return { tabExists: true, details: "Woken." };
|
||||
}
|
||||
}
|
||||
|
||||
let sessions: SessionMap;
|
||||
let activated: number[];
|
||||
let openTabs: Set<number>;
|
||||
let enabled: boolean;
|
||||
let strategy: RecordingStrategy;
|
||||
let waker: TabWaker;
|
||||
|
||||
beforeEach(async () => {
|
||||
sessions = new SessionMap({ load: async () => undefined, save: async () => {} });
|
||||
activated = [];
|
||||
openTabs = new Set([7]);
|
||||
enabled = true;
|
||||
const tabs: TabControl = {
|
||||
activate: async (tabId) => {
|
||||
if (!openTabs.has(tabId)) return false;
|
||||
activated.push(tabId);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const wakeSwitch: WakeSwitch = {
|
||||
const settings: WakeSettings = {
|
||||
isEnabled: async () => enabled,
|
||||
setEnabled: async (value) => {
|
||||
enabled = value;
|
||||
},
|
||||
};
|
||||
waker = new TabWaker(sessions, tabs, wakeSwitch);
|
||||
strategy = new RecordingStrategy(openTabs);
|
||||
waker = new TabWaker(sessions, settings, strategy);
|
||||
await sessions.announce(
|
||||
{ tabId: 7, windowId: 1 },
|
||||
{
|
||||
@ -40,20 +46,23 @@ beforeEach(async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("activates the tab holding the session", async () => {
|
||||
assert.equal((await waker.wake("tab")).outcome, "activated");
|
||||
assert.deepEqual(activated, [7]);
|
||||
test("wakes the tab holding the session and records the attempt", async () => {
|
||||
assert.deepEqual(await waker.wake("tab"), { outcome: "activated", details: "Woken." });
|
||||
assert.deepEqual(strategy.woken, [7]);
|
||||
const { at, ...record } = waker.lastWake!;
|
||||
assert.ok(at);
|
||||
assert.deepEqual(record, { outcome: "activated", details: "Woken." });
|
||||
});
|
||||
|
||||
test("does nothing while the user has turned waking off", async () => {
|
||||
enabled = false;
|
||||
assert.equal((await waker.wake("tab")).outcome, "disabled");
|
||||
assert.deepEqual(activated, []);
|
||||
assert.deepEqual(await waker.wake("tab"), { outcome: "disabled", details: "Waking is turned off." });
|
||||
assert.deepEqual(strategy.woken, []);
|
||||
});
|
||||
|
||||
test("reports a session no tab holds", async () => {
|
||||
assert.equal((await waker.wake("other")).outcome, "unknown-session");
|
||||
assert.deepEqual(activated, []);
|
||||
assert.deepEqual(strategy.woken, []);
|
||||
});
|
||||
|
||||
test("reports a closed tab and forgets its session", async () => {
|
||||
|
||||
@ -1,46 +1,69 @@
|
||||
import type { SessionMap } from "./SessionMap.ts";
|
||||
import type { WakeOutcome } from "@penpot/mcp-common";
|
||||
import type { SessionEntry, SessionMap } from "./SessionMap.ts";
|
||||
import type { WakeHandler, WakeReport } from "./WakeAgentConnection.ts";
|
||||
|
||||
/** Browser control over tab activation. */
|
||||
export interface TabControl {
|
||||
/**
|
||||
* Activates a tab and focuses its window.
|
||||
*
|
||||
* @returns Whether the tab still exists
|
||||
*/
|
||||
activate(tabId: number): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** The user's switch that pauses waking without removing the extension. */
|
||||
export interface WakeSwitch {
|
||||
/** The user's wake settings. */
|
||||
export interface WakeSettings {
|
||||
/** Whether waking is turned on; the user can pause it without removing the extension. */
|
||||
isEnabled(): Promise<boolean>;
|
||||
setEnabled(enabled: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wakes the tab holding a session by activating it and focusing its window, which makes the
|
||||
* browser resume a frozen page.
|
||||
*/
|
||||
/** Result of an attempt to wake a tab. */
|
||||
export interface WakeAttempt {
|
||||
/** whether the tab still exists. */
|
||||
tabExists: boolean;
|
||||
/** what the strategy did, shown in the popup and the server's log. */
|
||||
details: string;
|
||||
}
|
||||
|
||||
/** Record of the last wake request, shown in the popup. */
|
||||
export interface WakeRecord {
|
||||
at: string;
|
||||
outcome: WakeOutcome;
|
||||
details: string;
|
||||
}
|
||||
|
||||
/** Way of waking the tab holding a session, so that the browser resumes its frozen page. */
|
||||
export interface WakeStrategy {
|
||||
wake(entry: SessionEntry): Promise<WakeAttempt>;
|
||||
}
|
||||
|
||||
/** Wakes the tab holding a session, unless the user has turned waking off. */
|
||||
export class TabWaker implements WakeHandler {
|
||||
private last: WakeRecord | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly sessions: SessionMap,
|
||||
private readonly tabs: TabControl,
|
||||
private readonly wakeSwitch: WakeSwitch
|
||||
private readonly settings: WakeSettings,
|
||||
private readonly strategy: WakeStrategy
|
||||
) {}
|
||||
|
||||
/** The last wake request, if any since the service worker started. */
|
||||
get lastWake(): WakeRecord | null {
|
||||
return this.last;
|
||||
}
|
||||
|
||||
async wake(sessionId: string): Promise<WakeReport> {
|
||||
if (!(await this.wakeSwitch.isEnabled())) {
|
||||
return { outcome: "disabled" };
|
||||
if (!(await this.settings.isEnabled())) {
|
||||
return this.record("disabled", "Waking is turned off.");
|
||||
}
|
||||
await this.sessions.ready();
|
||||
const entry = this.sessions.get(sessionId);
|
||||
if (!entry) {
|
||||
return { outcome: "unknown-session" };
|
||||
return this.record("unknown-session", `No tab holds session ${sessionId}.`);
|
||||
}
|
||||
if (!(await this.tabs.activate(entry.tabId))) {
|
||||
const attempt = await this.strategy.wake(entry);
|
||||
if (!attempt.tabExists) {
|
||||
await this.sessions.removeTab(entry.tabId);
|
||||
return { outcome: "tab-closed" };
|
||||
return this.record("tab-closed", attempt.details);
|
||||
}
|
||||
return { outcome: "activated" };
|
||||
return this.record("activated", attempt.details);
|
||||
}
|
||||
|
||||
private record(outcome: WakeOutcome, details: string): WakeReport {
|
||||
this.last = { at: new Date().toISOString(), outcome, details };
|
||||
console.info("Penpot MCP wake:", this.last);
|
||||
return { outcome, details };
|
||||
}
|
||||
}
|
||||
|
||||
129
mcp/packages/browser-extension/src/WakeStrategies.test.ts
Normal file
129
mcp/packages/browser-extension/src/WakeStrategies.test.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { beforeEach, test } from "node:test";
|
||||
import type { SessionEntry } from "./SessionMap.ts";
|
||||
import {
|
||||
ActivateAndReturnStrategy,
|
||||
LifecycleStrategy,
|
||||
ResumeSignals,
|
||||
type Focus,
|
||||
type TabControl,
|
||||
} from "./WakeStrategies.ts";
|
||||
|
||||
const ENTRY: SessionEntry = { tabId: 7, windowId: 1, serverUrl: "wss://penpot/mcp/ws", token: null };
|
||||
|
||||
/** Tab control recording activations and focus changes. */
|
||||
class FakeTabs implements TabControl {
|
||||
readonly calls: string[] = [];
|
||||
openTabs = new Set([7]);
|
||||
focus: Focus | null = { windowId: 2, tabId: 3, focused: true };
|
||||
|
||||
async activate(tabId: number): Promise<boolean> {
|
||||
if (!this.openTabs.has(tabId)) return false;
|
||||
this.calls.push(`activate ${tabId}`);
|
||||
this.focus = { windowId: 1, tabId, focused: true };
|
||||
return true;
|
||||
}
|
||||
|
||||
async getFocus(): Promise<Focus | null> {
|
||||
return this.focus;
|
||||
}
|
||||
|
||||
async restoreFocus(focus: Focus): Promise<void> {
|
||||
this.calls.push(`restore ${focus.tabId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function until(condition: () => boolean): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const check = () => (condition() ? resolve() : setTimeout(check, 1));
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
let tabs: FakeTabs;
|
||||
let resumes: ResumeSignals;
|
||||
|
||||
beforeEach(() => {
|
||||
tabs = new FakeTabs();
|
||||
resumes = new ResumeSignals();
|
||||
});
|
||||
|
||||
test("activate-and-return goes back to the previous tab once the page runs again", async () => {
|
||||
assert.deepEqual(await new ActivateAndReturnStrategy(tabs, resumes).wake(ENTRY), {
|
||||
tabExists: true,
|
||||
details: "Brought the tab forward; going back to tab 3 once the page runs.",
|
||||
});
|
||||
assert.deepEqual(tabs.calls, ["activate 7"]);
|
||||
resumes.notify(7);
|
||||
await until(() => tabs.calls.length === 2);
|
||||
assert.deepEqual(tabs.calls, ["activate 7", "restore 3"]);
|
||||
});
|
||||
|
||||
test("activate-and-return goes back after the timeout when the page does not report", async () => {
|
||||
await new ActivateAndReturnStrategy(tabs, resumes, 10).wake(ENTRY);
|
||||
await until(() => tabs.calls.length === 2);
|
||||
assert.equal(tabs.calls[1], "restore 3");
|
||||
});
|
||||
|
||||
test("activate-and-return stays when the user has moved to another tab before going back", async () => {
|
||||
await new ActivateAndReturnStrategy(tabs, resumes, 10).wake(ENTRY);
|
||||
tabs.focus = { windowId: 1, tabId: 9, focused: true };
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.deepEqual(tabs.calls, ["activate 7"]);
|
||||
});
|
||||
|
||||
test("activate-and-return stays when the tab already had the focus", async () => {
|
||||
tabs.focus = { windowId: 1, tabId: 7, focused: true };
|
||||
await new ActivateAndReturnStrategy(tabs, resumes, 10).wake(ENTRY);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.deepEqual(tabs.calls, ["activate 7"]);
|
||||
});
|
||||
|
||||
test("activate-and-return does not go back from a closed tab", async () => {
|
||||
tabs.openTabs.clear();
|
||||
assert.equal((await new ActivateAndReturnStrategy(tabs, resumes, 10).wake(ENTRY)).tabExists, false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.deepEqual(tabs.calls, []);
|
||||
});
|
||||
|
||||
test("the lifecycle strategy unfreezes the page without switching tabs", async () => {
|
||||
const unfrozen: number[] = [];
|
||||
const strategy = new LifecycleStrategy(
|
||||
{
|
||||
setActive: async (tabId) => {
|
||||
unfrozen.push(tabId);
|
||||
},
|
||||
},
|
||||
new ActivateAndReturnStrategy(tabs, resumes, 10)
|
||||
);
|
||||
assert.deepEqual(await strategy.wake(ENTRY), { tabExists: true, details: "Unfroze the page in place." });
|
||||
assert.deepEqual(unfrozen, [7]);
|
||||
assert.deepEqual(tabs.calls, []);
|
||||
});
|
||||
|
||||
test("the lifecycle strategy falls back to bringing the tab forward and back when the debugger fails", async () => {
|
||||
const strategy = new LifecycleStrategy(
|
||||
{
|
||||
setActive: async () => {
|
||||
throw new Error("Could not attach: Another debugger is already attached.");
|
||||
},
|
||||
},
|
||||
new ActivateAndReturnStrategy(tabs, resumes, 10)
|
||||
);
|
||||
assert.deepEqual(await strategy.wake(ENTRY), {
|
||||
tabExists: true,
|
||||
details:
|
||||
"Debugger failed: Could not attach: Another debugger is already attached. " +
|
||||
"Brought the tab forward; going back to tab 3 once the page runs.",
|
||||
});
|
||||
await until(() => tabs.calls.length === 2);
|
||||
assert.deepEqual(tabs.calls, ["activate 7", "restore 3"]);
|
||||
});
|
||||
|
||||
test("resume signals only answer waits for the resumed tab", async () => {
|
||||
const seven = resumes.waitForResume(7, 1000);
|
||||
const eight = resumes.waitForResume(8, 20);
|
||||
resumes.notify(7);
|
||||
assert.equal(await seven, true);
|
||||
assert.equal(await eight, false);
|
||||
});
|
||||
143
mcp/packages/browser-extension/src/WakeStrategies.ts
Normal file
143
mcp/packages/browser-extension/src/WakeStrategies.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import type { SessionEntry } from "./SessionMap.ts";
|
||||
import type { WakeAttempt, WakeStrategy } from "./TabWaker.ts";
|
||||
|
||||
/** The focused window and its active tab, to go back to after waking another tab. */
|
||||
export interface Focus {
|
||||
windowId: number;
|
||||
tabId: number;
|
||||
/** whether the browser window had the focus of the desktop. */
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
/** Browser control over tab activation. */
|
||||
export interface TabControl {
|
||||
/**
|
||||
* Activates a tab and focuses its window.
|
||||
*
|
||||
* @returns Whether the tab still exists
|
||||
*/
|
||||
activate(tabId: number): Promise<boolean>;
|
||||
|
||||
/** Returns the last focused window and its active tab, if any. */
|
||||
getFocus(): Promise<Focus | null>;
|
||||
|
||||
/** Activates the tab of a focus again, and focuses its window if it had the focus. */
|
||||
restoreFocus(focus: Focus): Promise<void>;
|
||||
}
|
||||
|
||||
/** Browser control over the lifecycle state of a page. */
|
||||
export interface LifecycleControl {
|
||||
/**
|
||||
* Makes the page of a tab active, which resumes it if frozen.
|
||||
*
|
||||
* @throws Error explaining why the page could not be made active
|
||||
*/
|
||||
setActive(tabId: number): Promise<void>;
|
||||
}
|
||||
|
||||
const TAB_CLOSED: WakeAttempt = { tabExists: false, details: "The tab no longer exists." };
|
||||
|
||||
/** Notice of tabs whose page runs again after being frozen or hidden. */
|
||||
export interface ResumeWatcher {
|
||||
/**
|
||||
* Waits for the page of a tab to report it runs again.
|
||||
*
|
||||
* @returns Whether the page reported it before the timeout
|
||||
*/
|
||||
waitForResume(tabId: number, timeoutMs: number): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings the tab forward, which makes the browser resume its page, then goes back to the tab and
|
||||
* window that had the focus once the page runs again. The page then stays hidden but running long
|
||||
* enough for the task. It stays on the woken tab if the user has moved on from it meanwhile.
|
||||
*/
|
||||
export class ActivateAndReturnStrategy implements WakeStrategy {
|
||||
/**
|
||||
* @param tabs - The tab activation control
|
||||
* @param resumes - The notice of resumed pages
|
||||
* @param resumeTimeoutMs - Maximum time, in ms, to wait for the page before going back anyway
|
||||
*/
|
||||
constructor(
|
||||
private readonly tabs: TabControl,
|
||||
private readonly resumes: ResumeWatcher,
|
||||
private readonly resumeTimeoutMs = 5_000
|
||||
) {}
|
||||
|
||||
async wake(entry: SessionEntry): Promise<WakeAttempt> {
|
||||
const previous = await this.tabs.getFocus();
|
||||
// start waiting before the activation, so that a fast resume is not missed
|
||||
const resumed = this.resumes.waitForResume(entry.tabId, this.resumeTimeoutMs);
|
||||
if (!(await this.tabs.activate(entry.tabId))) {
|
||||
return TAB_CLOSED;
|
||||
}
|
||||
if (!previous || previous.tabId === entry.tabId) {
|
||||
return { tabExists: true, details: "Brought the tab forward; it already had the focus." };
|
||||
}
|
||||
// the server waits for the plugin, not for the focus to go back
|
||||
void resumed
|
||||
.then(async (reported) => {
|
||||
const current = await this.tabs.getFocus();
|
||||
if (current?.tabId !== entry.tabId) {
|
||||
console.info("Penpot MCP wake: the focus left the woken tab; not going back");
|
||||
return;
|
||||
}
|
||||
console.info(`Penpot MCP wake: going back to tab ${previous.tabId}`, { pageReported: reported });
|
||||
await this.tabs.restoreFocus(previous);
|
||||
})
|
||||
.catch(() => {});
|
||||
return {
|
||||
tabExists: true,
|
||||
details: `Brought the tab forward; going back to tab ${previous.tabId} once the page runs.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export class LifecycleStrategy implements WakeStrategy {
|
||||
constructor(
|
||||
private readonly lifecycle: LifecycleControl,
|
||||
private readonly fallback: WakeStrategy
|
||||
) {}
|
||||
|
||||
async wake(entry: SessionEntry): Promise<WakeAttempt> {
|
||||
try {
|
||||
await this.lifecycle.setActive(entry.tabId);
|
||||
return { tabExists: true, details: "Unfroze the page in place." };
|
||||
} catch (error) {
|
||||
const attempt = await this.fallback.wake(entry);
|
||||
return { ...attempt, details: `Debugger failed: ${(error as Error).message} ${attempt.details}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resume watcher fed by the content scripts' resume messages. */
|
||||
export class ResumeSignals implements ResumeWatcher {
|
||||
private readonly waiters = new Map<number, Set<() => void>>();
|
||||
|
||||
waitForResume(tabId: number, timeoutMs: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const done = (resumed: boolean) => {
|
||||
clearTimeout(timeout);
|
||||
this.waiters.get(tabId)?.delete(notify);
|
||||
resolve(resumed);
|
||||
};
|
||||
const notify = () => done(true);
|
||||
const timeout = setTimeout(() => done(false), timeoutMs);
|
||||
const waiters = this.waiters.get(tabId) ?? new Set();
|
||||
waiters.add(notify);
|
||||
this.waiters.set(tabId, waiters);
|
||||
});
|
||||
}
|
||||
|
||||
/** Reports that the page of a tab runs again. */
|
||||
notify(tabId: number): void {
|
||||
for (const notify of [...(this.waiters.get(tabId) ?? [])]) {
|
||||
notify();
|
||||
}
|
||||
this.waiters.delete(tabId);
|
||||
}
|
||||
}
|
||||
@ -7,19 +7,32 @@
|
||||
* Chrome may stop the worker at any time, so listeners are registered synchronously at start
|
||||
* and state lives in session storage.
|
||||
*/
|
||||
import { ChromeDiscardControl, ChromeSessionStore, ChromeTabControl, ChromeWakeSwitch, getAgentId } from "./chrome.ts";
|
||||
import {
|
||||
ChromeDiscardControl,
|
||||
ChromeLifecycleControl,
|
||||
ChromeSessionStore,
|
||||
ChromeTabControl,
|
||||
ChromeWakeSettings,
|
||||
getAgentId,
|
||||
} from "./chrome.ts";
|
||||
import { DiscardGuard } from "./DiscardGuard.ts";
|
||||
import { parseAnnouncement, type ContentMessage } from "./messages.ts";
|
||||
import { getPopupState, type PopupStateRequest } from "./PopupState.ts";
|
||||
import { SessionMap } from "./SessionMap.ts";
|
||||
import { TabWaker } from "./TabWaker.ts";
|
||||
import { WakeAgentConnections } from "./WakeAgentConnections.ts";
|
||||
import { ActivateAndReturnStrategy, LifecycleStrategy, ResumeSignals } from "./WakeStrategies.ts";
|
||||
|
||||
const RESTART_ALARM = "restart-connections";
|
||||
|
||||
const sessions = new SessionMap(new ChromeSessionStore());
|
||||
const wakeSwitch = new ChromeWakeSwitch();
|
||||
const waker = new TabWaker(sessions, new ChromeTabControl(), wakeSwitch);
|
||||
const settings = new ChromeWakeSettings();
|
||||
const resumes = new ResumeSignals();
|
||||
const waker = new TabWaker(
|
||||
sessions,
|
||||
settings,
|
||||
new LifecycleStrategy(new ChromeLifecycleControl(), new ActivateAndReturnStrategy(new ChromeTabControl(), resumes))
|
||||
);
|
||||
const connections = new WakeAgentConnections(sessions, getAgentId(), (url) => new WebSocket(url), waker);
|
||||
sessions.observe(new DiscardGuard(new ChromeDiscardControl()));
|
||||
sessions.observe(connections);
|
||||
@ -30,8 +43,8 @@ chrome.runtime.onMessage.addListener((message: ContentMessage | PopupStateReques
|
||||
}
|
||||
const tab = sender.tab;
|
||||
if (message?.type === "popup:get-state" && !tab) {
|
||||
void Promise.all([wakeSwitch.isEnabled(), sessions.ready()]).then(([enabled]) =>
|
||||
sendResponse(getPopupState(enabled, sessions, connections.summaries()))
|
||||
void Promise.all([settings.isEnabled(), sessions.ready()]).then(([enabled]) =>
|
||||
sendResponse(getPopupState(enabled, waker.lastWake, sessions, connections.summaries()))
|
||||
);
|
||||
// the response is sent asynchronously
|
||||
return true;
|
||||
@ -39,7 +52,9 @@ chrome.runtime.onMessage.addListener((message: ContentMessage | PopupStateReques
|
||||
if (tab?.id === undefined) {
|
||||
return;
|
||||
}
|
||||
if (message?.type === "page-hidden") {
|
||||
if (message?.type === "page-resumed") {
|
||||
resumes.notify(tab.id);
|
||||
} else if (message?.type === "page-hidden") {
|
||||
void sessions.removeTab(tab.id);
|
||||
} else if (message?.type === "session") {
|
||||
const announcement = parseAnnouncement(message.announcement);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { DiscardControl } from "./DiscardGuard.ts";
|
||||
import type { SessionEntry, SessionStore } from "./SessionMap.ts";
|
||||
import type { TabControl, WakeSwitch } from "./TabWaker.ts";
|
||||
import type { WakeSettings } from "./TabWaker.ts";
|
||||
import type { Focus, LifecycleControl, TabControl } from "./WakeStrategies.ts";
|
||||
|
||||
const SESSIONS_KEY = "sessions";
|
||||
const AGENT_ID_KEY = "agentId";
|
||||
@ -61,10 +62,56 @@ export class ChromeTabControl implements TabControl {
|
||||
await chrome.windows.update(tab.windowId, { focused: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
async getFocus(): Promise<Focus | null> {
|
||||
try {
|
||||
const window = await chrome.windows.getLastFocused();
|
||||
const [tab] = await chrome.tabs.query({ active: true, windowId: window.id });
|
||||
if (window.id === undefined || tab?.id === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { windowId: window.id, tabId: tab.id, focused: window.focused };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async restoreFocus(focus: Focus): Promise<void> {
|
||||
await chrome.tabs.update(focus.tabId, { active: true });
|
||||
if (focus.focused) {
|
||||
await chrome.windows.update(focus.windowId, { focused: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Wake switch kept in `chrome.storage.local`; waking is enabled until the user turns it off. */
|
||||
export class ChromeWakeSwitch implements WakeSwitch {
|
||||
/**
|
||||
* Lifecycle control through the Chrome debugger API. Chrome shows a debugging notice while the
|
||||
* extension is attached.
|
||||
*/
|
||||
export class ChromeLifecycleControl implements LifecycleControl {
|
||||
async setActive(tabId: number): Promise<void> {
|
||||
if (!chrome.debugger) {
|
||||
throw new Error("The debugger API is not available.");
|
||||
}
|
||||
const target = { tabId };
|
||||
try {
|
||||
await chrome.debugger.attach(target, "1.3");
|
||||
} catch (error) {
|
||||
// the tab is gone, or DevTools or another debugger is attached to it
|
||||
throw new Error(`Could not attach: ${(error as Error).message}.`);
|
||||
}
|
||||
try {
|
||||
await chrome.debugger.sendCommand(target, "Page.setWebLifecycleState", { state: "active" });
|
||||
} catch (error) {
|
||||
throw new Error(`Page.setWebLifecycleState failed: ${(error as Error).message}.`);
|
||||
} finally {
|
||||
await chrome.debugger.detach(target).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Wake settings kept in `chrome.storage.local`; waking is on by default. */
|
||||
export class ChromeWakeSettings implements WakeSettings {
|
||||
async isEnabled(): Promise<boolean> {
|
||||
const stored = await chrome.storage.local.get(WAKE_ENABLED_KEY);
|
||||
return stored[WAKE_ENABLED_KEY] !== false;
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
import { PageBridge } from "./PageBridge.ts";
|
||||
|
||||
new PageBridge(window, chrome.runtime).start();
|
||||
new PageBridge(window, document, chrome.runtime).start();
|
||||
|
||||
@ -9,8 +9,10 @@ test("is a Manifest V3 extension whose service worker loads as a module", () =>
|
||||
assert.deepEqual(manifest.background, { service_worker: "background.js", type: "module" });
|
||||
});
|
||||
|
||||
test("requests no permission beyond storage and alarms", () => {
|
||||
assert.deepEqual(manifest.permissions, ["storage", "alarms"]);
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@ -5,8 +5,10 @@ import type { McpConnectionStatus, SessionAnnouncement } from "@penpot/mcp-commo
|
||||
*
|
||||
* - `session`: the page announced its MCP session
|
||||
* - `page-hidden`: the page is being unloaded or stored in the back/forward cache
|
||||
* - `page-resumed`: the page runs again after being frozen or hidden
|
||||
*/
|
||||
export type ContentMessage = { type: "session"; announcement: SessionAnnouncement } | { type: "page-hidden" };
|
||||
export type ContentMessage =
|
||||
{ type: "session"; announcement: SessionAnnouncement } | { type: "page-hidden" } | { type: "page-resumed" };
|
||||
|
||||
const STATUSES: readonly McpConnectionStatus[] = ["idle", "connecting", "connected", "disconnected", "error"];
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ChromeWakeSwitch } from "./chrome.ts";
|
||||
import { ChromeWakeSettings } from "./chrome.ts";
|
||||
import type { PopupState, PopupStateRequest } from "./PopupState.ts";
|
||||
|
||||
const REFRESH_INTERVAL_MS = 1_000;
|
||||
@ -11,8 +11,9 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
closed: "Closed",
|
||||
};
|
||||
|
||||
const wakeSwitch = new ChromeWakeSwitch();
|
||||
const settings = new ChromeWakeSettings();
|
||||
const enabledInput = document.getElementById("enabled") as HTMLInputElement;
|
||||
const lastWake = document.getElementById("last-wake") as HTMLParagraphElement;
|
||||
const sessionsList = document.getElementById("sessions") as HTMLUListElement;
|
||||
const connectionsList = document.getElementById("connections") as HTMLUListElement;
|
||||
|
||||
@ -30,6 +31,9 @@ function renderList(list: HTMLUListElement, items: string[], placeholder: string
|
||||
|
||||
function render(state: PopupState): void {
|
||||
enabledInput.checked = state.enabled;
|
||||
lastWake.textContent = state.lastWake
|
||||
? `${new Date(state.lastWake.at).toLocaleTimeString()}: ${state.lastWake.outcome}. ${state.lastWake.details}`
|
||||
: "None since the extension started.";
|
||||
renderList(
|
||||
sessionsList,
|
||||
state.sessions.map((session) => `${session.sessionId} (tab ${session.tabId})`),
|
||||
@ -55,7 +59,7 @@ async function refresh(): Promise<void> {
|
||||
}
|
||||
|
||||
enabledInput.addEventListener("change", async () => {
|
||||
await wakeSwitch.setEnabled(enabledInput.checked);
|
||||
await settings.setEnabled(enabledInput.checked);
|
||||
await refresh();
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user