diff --git a/mcp/packages/browser-extension/README.md b/mcp/packages/browser-extension/README.md index ad0a784fd3..b476b11656 100644 --- a/mcp/packages/browser-extension/README.md +++ b/mcp/packages/browser-extension/README.md @@ -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/`: diff --git a/mcp/packages/browser-extension/public/manifest.json b/mcp/packages/browser-extension/public/manifest.json index 3efcc9e3fc..c7377dddb4 100644 --- a/mcp/packages/browser-extension/public/manifest.json +++ b/mcp/packages/browser-extension/public/manifest.json @@ -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" diff --git a/mcp/packages/browser-extension/public/popup.css b/mcp/packages/browser-extension/public/popup.css index 383ef77692..548ba73fe3 100644 --- a/mcp/packages/browser-extension/public/popup.css +++ b/mcp/packages/browser-extension/public/popup.css @@ -42,6 +42,11 @@ h2 { align-items: center; } +.last-wake { + margin: 0; + overflow-wrap: anywhere; +} + ul { margin: 0; padding-left: 18px; diff --git a/mcp/packages/browser-extension/public/popup.html b/mcp/packages/browser-extension/public/popup.html index 15967ddc56..609d9ae2a1 100644 --- a/mcp/packages/browser-extension/public/popup.html +++ b/mcp/packages/browser-extension/public/popup.html @@ -11,6 +11,8 @@ Wake Penpot tabs for MCP tasks +

Last wake

+

Paired sessions

MCP servers

diff --git a/mcp/packages/browser-extension/src/PageBridge.test.ts b/mcp/packages/browser-extension/src/PageBridge.test.ts index 021aa0c0cf..3765ecece3 100644 --- a/mcp/packages/browser-extension/src/PageBridge.test.ts +++ b/mcp/packages/browser-extension/src/PageBridge.test.ts @@ -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 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" }]); +}); diff --git a/mcp/packages/browser-extension/src/PageBridge.ts b/mcp/packages/browser-extension/src/PageBridge.ts index e6a6ddb4d1..f7c4e96fb6 100644 --- a/mcp/packages/browser-extension/src/PageBridge.ts +++ b/mcp/packages/browser-extension/src/PageBridge.ts @@ -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; @@ -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(); } diff --git a/mcp/packages/browser-extension/src/PopupState.test.ts b/mcp/packages/browser-extension/src/PopupState.test.ts index df4f77aa3f..c873678523 100644 --- a/mcp/packages/browser-extension/src/PopupState.test.ts +++ b/mcp/packages/browser-extension/src/PopupState.test.ts @@ -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, }); diff --git a/mcp/packages/browser-extension/src/PopupState.ts b/mcp/packages/browser-extension/src/PopupState.ts index c1d29600d8..9fe672b579 100644 --- a/mcp/packages/browser-extension/src/PopupState.ts +++ b/mcp/packages/browser-extension/src/PopupState.ts @@ -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, diff --git a/mcp/packages/browser-extension/src/TabWaker.test.ts b/mcp/packages/browser-extension/src/TabWaker.test.ts index b386ae486f..aa136b32a0 100644 --- a/mcp/packages/browser-extension/src/TabWaker.test.ts +++ b/mcp/packages/browser-extension/src/TabWaker.test.ts @@ -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) {} + + async wake(entry: SessionEntry): Promise { + 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; 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 () => { diff --git a/mcp/packages/browser-extension/src/TabWaker.ts b/mcp/packages/browser-extension/src/TabWaker.ts index f3e74f1846..b07185e722 100644 --- a/mcp/packages/browser-extension/src/TabWaker.ts +++ b/mcp/packages/browser-extension/src/TabWaker.ts @@ -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; -} - -/** 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; setEnabled(enabled: boolean): Promise; } -/** - * 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; +} + +/** 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 { - 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 }; } } diff --git a/mcp/packages/browser-extension/src/WakeStrategies.test.ts b/mcp/packages/browser-extension/src/WakeStrategies.test.ts new file mode 100644 index 0000000000..171054c010 --- /dev/null +++ b/mcp/packages/browser-extension/src/WakeStrategies.test.ts @@ -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 { + if (!this.openTabs.has(tabId)) return false; + this.calls.push(`activate ${tabId}`); + this.focus = { windowId: 1, tabId, focused: true }; + return true; + } + + async getFocus(): Promise { + return this.focus; + } + + async restoreFocus(focus: Focus): Promise { + this.calls.push(`restore ${focus.tabId}`); + } +} + +function until(condition: () => boolean): Promise { + 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); +}); diff --git a/mcp/packages/browser-extension/src/WakeStrategies.ts b/mcp/packages/browser-extension/src/WakeStrategies.ts new file mode 100644 index 0000000000..b4f1e64147 --- /dev/null +++ b/mcp/packages/browser-extension/src/WakeStrategies.ts @@ -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; + + /** Returns the last focused window and its active tab, if any. */ + getFocus(): Promise; + + /** Activates the tab of a focus again, and focuses its window if it had the focus. */ + restoreFocus(focus: Focus): Promise; +} + +/** 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; +} + +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; +} + +/** + * 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 { + 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 { + 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 void>>(); + + waitForResume(tabId: number, timeoutMs: number): Promise { + 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); + } +} diff --git a/mcp/packages/browser-extension/src/background.ts b/mcp/packages/browser-extension/src/background.ts index d60f11daad..8c56b03f32 100644 --- a/mcp/packages/browser-extension/src/background.ts +++ b/mcp/packages/browser-extension/src/background.ts @@ -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); diff --git a/mcp/packages/browser-extension/src/chrome.ts b/mcp/packages/browser-extension/src/chrome.ts index 0bed1f1777..36c07a9b25 100644 --- a/mcp/packages/browser-extension/src/chrome.ts +++ b/mcp/packages/browser-extension/src/chrome.ts @@ -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 { + 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 { + 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 { + 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 { const stored = await chrome.storage.local.get(WAKE_ENABLED_KEY); return stored[WAKE_ENABLED_KEY] !== false; diff --git a/mcp/packages/browser-extension/src/content.ts b/mcp/packages/browser-extension/src/content.ts index 1e115228a4..60c8908752 100644 --- a/mcp/packages/browser-extension/src/content.ts +++ b/mcp/packages/browser-extension/src/content.ts @@ -1,3 +1,3 @@ import { PageBridge } from "./PageBridge.ts"; -new PageBridge(window, chrome.runtime).start(); +new PageBridge(window, document, chrome.runtime).start(); diff --git a/mcp/packages/browser-extension/src/manifest.test.ts b/mcp/packages/browser-extension/src/manifest.test.ts index 5a209436c3..b2abe144f1 100644 --- a/mcp/packages/browser-extension/src/manifest.test.ts +++ b/mcp/packages/browser-extension/src/manifest.test.ts @@ -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); }); diff --git a/mcp/packages/browser-extension/src/messages.ts b/mcp/packages/browser-extension/src/messages.ts index a8a6a16206..1538b5be9b 100644 --- a/mcp/packages/browser-extension/src/messages.ts +++ b/mcp/packages/browser-extension/src/messages.ts @@ -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"]; diff --git a/mcp/packages/browser-extension/src/popup.ts b/mcp/packages/browser-extension/src/popup.ts index 2e16a4c2e1..74f84ad3a8 100644 --- a/mcp/packages/browser-extension/src/popup.ts +++ b/mcp/packages/browser-extension/src/popup.ts @@ -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 = { 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 { } enabledInput.addEventListener("change", async () => { - await wakeSwitch.setEnabled(enabledInput.checked); + await settings.setEnabled(enabledInput.checked); await refresh(); });