Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ Configure Playwright MCP server to connect to the browser using the extension by

When the LLM interacts with the browser for the first time, it will load a page where you can select which browser tab the LLM will connect to. This allows you to control which specific page the AI assistant will interact with during the session.

### Multiple Clients

Several clients can be connected at the same time. Each one gets its own tab group, named after the client and colored apart from the other groups, and only sees the tabs in that group — a tab can belong to a single client at a time. Drag tabs in and out of a group to change what a client can reach, and use the extension's status page to see every connection and disconnect them individually.

### Bypassing the Connection Approval Dialog

By default, you'll need to approve each connection when the MCP server tries to connect to your browser. To bypass this approval dialog and allow automatic connections, you can use an authentication token.
Expand Down
84 changes: 47 additions & 37 deletions packages/extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import { debugLog } from './relayConnection';
import { PendingConnections } from './pendingConnection';
import { ConnectedTabGroup, cleanupStalePlaywrightGroups, isNonDebuggableUrl } from './connectedTabGroup';
import { ConnectedTabGroup, cleanupStalePlaywrightGroups, isNonDebuggableUrl, ungroupTabs, uniqueGroupStyle } from './connectedTabGroup';

type PageMessage = {
type: 'connectionRequested';
Expand All @@ -33,13 +33,14 @@ type PageMessage = {
type: 'getConnectionStatus';
} | {
type: 'disconnect';
connectionId: number;
} | {
type: 'keepalive';
};

class PlaywrightExtension {
private _activeGroup: ConnectedTabGroup | undefined;
private _activeClientName: string | undefined;
private _connections = new Map<number, ConnectedTabGroup>();
private _lastConnectionId = 0;
private _pendingConnections = new PendingConnections();
// Service worker restarts lose all connection state, so any existing
// Playwright groups are stale. Connections wait on this before reconciling.
Expand All @@ -54,12 +55,16 @@ class PlaywrightExtension {
// Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
switch (message.type) {
case 'connectionRequested':
this._pendingConnections.create(sender.tab!.id!, message.mcpRelayUrl);
sendResponse({ success: true });
return false;
case 'connectionRequested': {
const selectorTabId = sender.tab!.id!;
this._releaseConnectPage(selectorTabId).then(() => {
this._pendingConnections.create(selectorTabId, message.mcpRelayUrl);
sendResponse({ success: true });
});
return true;
}
case 'getTabs':
this._getTabs().then(
this._getTabs(sender.tab?.id).then(
tabs => sendResponse({ success: true, tabs, currentTabId: sender.tab?.id }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true;
Expand All @@ -76,18 +81,17 @@ class PlaywrightExtension {
}
case 'getConnectionStatus':
sendResponse({
connectedTabIds: this._activeGroup?.connectedTabIds() ?? [],
clientName: this._activeClientName,
connections: [...this._connections].map(([id, group]) => ({
id,
clientName: group.clientName,
connectedTabIds: group.connectedTabIds(),
})),
});
return false;
case 'disconnect':
try {
this._disconnect('User disconnected');
sendResponse({ success: true });
} catch (error: any) {
sendResponse({ success: false, error: error.message });
}
return true;
this._connections.get(message.connectionId)?.close('User disconnected');
sendResponse({ success: true });
return false;
case 'keepalive':
// Connect page pings us every ~20s so receiving this message resets
// the MV3 service worker idle timer and keeps the relay WebSocket alive.
Expand All @@ -98,21 +102,19 @@ class PlaywrightExtension {
private async _connectTab(selectorTabId: number, tab: chrome.tabs.Tab & { id: number }, clientName: string | undefined): Promise<void> {
try {
await this._cleanupPromise;
this._disconnect('Another connection is requested');
this._releaseTab(selectorTabId);
if (tab.id !== selectorTabId && this._connectedTabIds().has(tab.id))
throw new Error('This tab is already connected to another client');

const connection = await this._pendingConnections.take(selectorTabId);
if (!connection)
throw new Error('Pending client connection closed');

const group = new ConnectedTabGroup(connection, tab);
group.onclose = () => {
if (this._activeGroup === group) {
this._activeGroup = undefined;
this._activeClientName = undefined;
}
};
this._activeGroup = group;
this._activeClientName = clientName;
const id = ++this._lastConnectionId;
const taken = [...this._connections.values()].map(group => group.groupStyle);
const group = new ConnectedTabGroup(connection, tab, clientName, uniqueGroupStyle(clientName, taken), tabId => this._pendingConnections.has(tabId));
group.onclose = () => this._connections.delete(id);
this._connections.set(id, group);

await Promise.all([
chrome.tabs.update(tab.id, { active: true }),
Expand All @@ -127,9 +129,25 @@ class PlaywrightExtension {
}
}

private async _getTabs(): Promise<chrome.tabs.Tab[]> {
// Chrome may create the connect page inside the active client's group.
private async _releaseConnectPage(tabId: number): Promise<void> {
this._releaseTab(tabId);
await ungroupTabs([tabId]);
}

private _releaseTab(tabId: number): void {
for (const group of this._connections.values())
group.releaseTab(tabId);
}

private async _getTabs(selectorTabId: number | undefined): Promise<chrome.tabs.Tab[]> {
const tabs = await chrome.tabs.query({});
return tabs.filter(tab => !isNonDebuggableUrl(tab.url));
const connectedTabIds = this._connectedTabIds();
return tabs.filter(tab => !isNonDebuggableUrl(tab.url) && (tab.id === selectorTabId || !connectedTabIds.has(tab.id!)));
}

private _connectedTabIds(): Set<number> {
return new Set([...this._connections.values()].flatMap(group => group.connectedTabIds()));
}

private async _onActionClicked(): Promise<void> {
Expand All @@ -138,14 +156,6 @@ class PlaywrightExtension {
active: true
});
}

// Closes the active group's connection if any. ConnectedTabGroup's onclose
// handles state cleanup (connectedTabIds, badges, reconcile).
private _disconnect(reason: string) {
this._activeGroup?.close(reason);
this._activeGroup = undefined;
this._activeClientName = undefined;
}
}

new PlaywrightExtension();
106 changes: 77 additions & 29 deletions packages/extension/src/connectedTabGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,45 @@
import { RelayConnection, debugLog } from './relayConnection';

const PLAYWRIGHT_GROUP_TITLE = 'Playwright';
const PLAYWRIGHT_GROUP_COLOR = 'green';
const PLAYWRIGHT_GROUP_TITLE_PREFIX = `${PLAYWRIGHT_GROUP_TITLE} · `;
// Green first, so a lone connection keeps the familiar look.
const PLAYWRIGHT_GROUP_COLORS: GroupColor[] = ['green', 'blue', 'purple', 'orange', 'pink', 'cyan', 'yellow', 'red'];
const NON_DEBUGGABLE_SCHEMES = ['chrome:', 'edge:', 'devtools:'];
const CONNECTED_BADGE = { text: '✓', color: '#4CAF50', title: 'Connected to Playwright client' };

export function isNonDebuggableUrl(url: string | undefined): boolean {
return !!url && NON_DEBUGGABLE_SCHEMES.some(s => url.startsWith(s));
}

type GroupColor = `${chrome.tabGroups.Color}`;

export type GroupStyle = {
title: string;
color: GroupColor;
};

export function uniqueGroupStyle(clientName: string | undefined, taken: readonly GroupStyle[]): GroupStyle {
const titles = new Set(taken.map(style => style.title));
const base = PLAYWRIGHT_GROUP_TITLE_PREFIX + (clientName || 'unknown');
let title = base;
for (let i = 2; titles.has(title); i++)
title = `${base} (${i})`;

const colors = new Set(taken.map(style => style.color));
const color = PLAYWRIGHT_GROUP_COLORS.find(candidate => !colors.has(candidate)) ?? PLAYWRIGHT_GROUP_COLORS[0];
return { title, color };
}

// Ungroups any Playwright-titled groups left behind by a prior service worker.
export async function cleanupStalePlaywrightGroups(): Promise<void> {
try {
const groups = await chrome.tabGroups.query({ title: PLAYWRIGHT_GROUP_TITLE });
const tabsPerGroup = await Promise.all(groups.map(g => chrome.tabs.query({ groupId: g.id })));
const groups = await chrome.tabGroups.query({});
// The bare title comes from versions that predate per-client groups.
const stale = groups.filter(g => g.title === PLAYWRIGHT_GROUP_TITLE || g.title?.startsWith(PLAYWRIGHT_GROUP_TITLE_PREFIX));
const tabsPerGroup = await Promise.all(stale.map(g => chrome.tabs.query({ groupId: g.id })));
const tabIds = tabsPerGroup.flat().map(t => t.id).filter((id): id is number => id !== undefined);
if (tabIds.length)
await chrome.tabs.ungroup(tabIds);
await ungroupTabs(tabIds);
} catch (error: any) {
debugLog('Error cleaning up stale groups:', error);
}
Expand All @@ -47,15 +70,21 @@ export async function cleanupStalePlaywrightGroups(): Promise<void> {
// `_groupTabIds` caches group membership from Chrome events so hot-path checks
// in `_onTabUpdated` stay synchronous.
export class ConnectedTabGroup {
readonly clientName: string | undefined;
readonly groupStyle: GroupStyle;
private _connection: RelayConnection;
private _isTabReserved: (tabId: number) => boolean;
private _groupId: number | null = null;
private _groupTabIds: Set<number> = new Set();
private _onTabUpdatedListener: (tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => void;
private _onTabRemovedListener: (tabId: number) => void;

onclose?: () => void;

constructor(connection: RelayConnection, selectedTab: chrome.tabs.Tab) {
constructor(connection: RelayConnection, selectedTab: chrome.tabs.Tab, clientName: string | undefined, groupStyle: GroupStyle, isTabReserved: (tabId: number) => boolean) {
this.clientName = clientName;
this.groupStyle = groupStyle;
this._isTabReserved = isTabReserved;
this._connection = connection;
this._connection.onclose = () => this._onConnectionClose();
this._connection.ontabattached = (tabId: number) => this._onTabAttached(tabId);
Expand All @@ -80,6 +109,13 @@ export class ConnectedTabGroup {
this._connection.close(reason);
}

releaseTab(tabId: number): void {
if (!this._groupTabIds.has(tabId))
return;
this._groupTabIds.delete(tabId);
this._connection.detachTab(tabId);
}

private _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab): void {
if (changeInfo.groupId !== undefined)
this._onTabGroupChanged(tabId, tab);
Expand All @@ -102,6 +138,12 @@ export class ConnectedTabGroup {
if (inOurGroup === wasInGroup)
return;
if (inOurGroup) {
// Chrome may drop the connect page of a client that is still connecting
// into our group; that tab is spoken for.
if (this._isTabReserved(tabId)) {
void ungroupTabs([tabId]);
return;
}
this._groupTabIds.add(tabId);
if (!isNonDebuggableUrl(tab.url))
this._connection.attachTab(tab);
Expand Down Expand Up @@ -133,11 +175,8 @@ export class ConnectedTabGroup {
chrome.tabs.onRemoved.removeListener(this._onTabRemovedListener);
const groupTabs = [...this._groupTabIds];
this._groupTabIds.clear();
if (groupTabs.length) {
this._retryOnDrag(() => chrome.tabs.ungroup(groupTabs)).catch(error => {
debugLog('Error ungrouping tabs on close:', error);
});
}
if (groupTabs.length)
void ungroupTabs(groupTabs);
this.onclose?.();
}

Expand All @@ -161,10 +200,10 @@ export class ConnectedTabGroup {
if (this._groupTabIds.has(tabId))
return;
try {
await this._retryOnDrag(async () => {
await retryOnDrag(async () => {
if (this._groupId === null) {
this._groupId = await chrome.tabs.group({ tabIds: [tabId] });
await chrome.tabGroups.update(this._groupId, { color: PLAYWRIGHT_GROUP_COLOR, title: PLAYWRIGHT_GROUP_TITLE });
await chrome.tabGroups.update(this._groupId, this.groupStyle);
} else {
await chrome.tabs.group({ groupId: this._groupId, tabIds: [tabId] });
}
Expand All @@ -175,23 +214,32 @@ export class ConnectedTabGroup {
}
}

// Chrome throws "user may be dragging a tab" while a drag is in progress.
// Retry with backoff until it clears (or we give up).
private async _retryOnDrag(fn: () => Promise<void>): Promise<void> {
const delays = [0, 100, 200, 400, 800];
let lastError: unknown;
for (const delay of delays) {
if (delay)
await new Promise(resolve => setTimeout(resolve, delay));
try {
await fn();
return;
} catch (error: any) {
if (!error?.message?.includes('user may be dragging a tab'))
throw error;
lastError = error;
}
}

export async function ungroupTabs(tabIds: number[]): Promise<void> {
try {
await retryOnDrag(() => chrome.tabs.ungroup(tabIds));
} catch (error: any) {
debugLog('Error ungrouping tabs:', error);
}
}

// Chrome throws "user may be dragging a tab" while a drag is in progress.
// Retry with backoff until it clears (or we give up).
async function retryOnDrag(fn: () => Promise<void>): Promise<void> {
const delays = [0, 100, 200, 400, 800];
let lastError: unknown;
for (const delay of delays) {
if (delay)
await new Promise(resolve => setTimeout(resolve, delay));
try {
await fn();
return;
} catch (error: any) {
if (!error?.message?.includes('user may be dragging a tab'))
throw error;
lastError = error;
}
throw lastError;
}
throw lastError;
}
5 changes: 5 additions & 0 deletions packages/extension/src/pendingConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export class PendingConnections {
this._map.set(selectorTabId, mcpRelayUrl);
}

// A connect page awaiting approval; no connection may claim its tab.
has(selectorTabId: number): boolean {
return this._map.has(selectorTabId);
}

async take(selectorTabId: number): Promise<RelayConnection | undefined> {
const mcpRelayUrl = this._map.get(selectorTabId);
if (mcpRelayUrl === undefined)
Expand Down
7 changes: 7 additions & 0 deletions packages/extension/src/ui/connect.css
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,13 @@ body {
border-color: #c73836;
}

/* Connections */
.connection + .connection {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #d1d9e0;
}

/* Connection header */
.connection-header {
display: flex;
Expand Down
Loading
Loading