feat(extension): shared CDP session manager; refactor dialog/computer/network tools

- Add utils/cdp-session-manager with refcount + owner tracking
- Migrate dialog.ts to use session manager
- Migrate computer.ts to use session manager
- Migrate network-capture-debugger.ts to use session manager
- Lint + vue-tsc clean; wxt build passes
This commit is contained in:
hangwin
2025-10-09 04:11:50 +00:00
parent d4e2fb3c8d
commit 9fca59a674
4 changed files with 155 additions and 142 deletions
@@ -7,6 +7,7 @@ import { clickTool, fillTool } from './interaction';
import { keyboardTool } from './keyboard';
import { screenshotTool } from './screenshot';
import { screenshotContextManager, scaleCoordinates } from '@/utils/screenshot-context';
import { cdpSessionManager } from '@/utils/cdp-session-manager';
type MouseButton = 'left' | 'right' | 'middle';
@@ -48,49 +49,16 @@ interface ComputerParams {
// Minimal CDP helper encapsulated here to avoid scattering CDP code
class CDPHelper {
private static active = new Set<number>();
static async attach(tabId: number): Promise<void> {
// If already attached by us, skip
const targets = await chrome.debugger.getTargets();
const existing = targets.find((t) => t.tabId === tabId && t.attached);
if (existing) {
if (existing.extensionId === chrome.runtime.id) {
this.active.add(tabId);
return;
}
throw new Error(
`Debugger is already attached to tab ${tabId} by another tool (e.g., DevTools/extension)`,
);
}
await chrome.debugger.attach({ tabId }, '1.3');
this.active.add(tabId);
await cdpSessionManager.attach(tabId, 'computer');
}
static async detach(tabId: number): Promise<void> {
if (!this.active.has(tabId)) return;
try {
await chrome.debugger.detach({ tabId });
} finally {
this.active.delete(tabId);
}
await cdpSessionManager.detach(tabId, 'computer');
}
static async send(tabId: number, method: string, params?: object): Promise<any> {
try {
return await chrome.debugger.sendCommand({ tabId }, method, params);
} catch (e: any) {
// Try reattach once if lost
if (
String(e?.message || e)
.toLowerCase()
.includes('not attached')
) {
await this.attach(tabId);
return await chrome.debugger.sendCommand({ tabId }, method, params);
}
throw e;
}
return await cdpSessionManager.sendCommand(tabId, method, params);
}
static async dispatchMouseEvent(tabId: number, opts: any) {
@@ -259,18 +227,14 @@ class ComputerTool extends BaseBrowserToolExecutor {
// Prefer precise CDP emulation
await CDPHelper.attach(tab.id);
try {
await chrome.debugger.sendCommand(
{ tabId: tab.id },
'Emulation.setDeviceMetricsOverride',
{
width: Math.round(w),
height: Math.round(h),
deviceScaleFactor: 0,
mobile: false,
screenWidth: Math.round(w),
screenHeight: Math.round(h),
},
);
await CDPHelper.send(tab.id, 'Emulation.setDeviceMetricsOverride', {
width: Math.round(w),
height: Math.round(h),
deviceScaleFactor: 0,
mobile: false,
screenWidth: Math.round(w),
screenHeight: Math.round(h),
});
} finally {
await CDPHelper.detach(tab.id);
}
@@ -1,6 +1,7 @@
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { cdpSessionManager } from '@/utils/cdp-session-manager';
interface HandleDialogParams {
action: 'accept' | 'dismiss';
@@ -22,40 +23,16 @@ class HandleDialogTool extends BaseBrowserToolExecutor {
try {
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!activeTab?.id) return createErrorResponse('No active tab found');
const tabId = activeTab.id!;
// Attach debugger and try handling the dialog
try {
await chrome.debugger.attach({ tabId: activeTab.id }, '1.3');
} catch (e: any) {
if (String(e?.message || '').includes('attached')) {
// If already attached by us, proceed; otherwise fail with clear message
const targets = await chrome.debugger.getTargets();
const existing = targets.find((t) => t.tabId === activeTab.id && t.attached);
if (!existing || existing.extensionId !== chrome.runtime.id) {
return createErrorResponse(
`Debugger already attached to tab ${activeTab.id} by another client (e.g., DevTools). Close it and retry.`,
);
}
} else {
throw e;
}
}
try {
// Enable Page domain to be safe
await chrome.debugger.sendCommand({ tabId: activeTab.id }, 'Page.enable');
await chrome.debugger.sendCommand({ tabId: activeTab.id }, 'Page.handleJavaScriptDialog', {
// Use shared CDP session manager for safe attach/detach with refcount
await cdpSessionManager.withSession(tabId, 'dialog', async () => {
await cdpSessionManager.sendCommand(tabId, 'Page.enable');
await cdpSessionManager.sendCommand(tabId, 'Page.handleJavaScriptDialog', {
accept: action === 'accept',
promptText: action === 'accept' ? promptText : undefined,
});
} finally {
// Best-effort detach if we were the owners
try {
await chrome.debugger.detach({ tabId: activeTab.id });
} catch {
// ignore
}
}
});
return {
content: [
@@ -1,6 +1,7 @@
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { cdpSessionManager } from '@/utils/cdp-session-manager';
interface NetworkDebuggerStartToolParams {
url?: string; // URL to navigate to or focus. If not provided, uses active tab.
@@ -218,35 +219,15 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor {
// Get tab information
const tab = await chrome.tabs.get(tabId);
// Check if debugger is already attached
const targets = await chrome.debugger.getTargets();
const existingTarget = targets.find(
(t) => t.tabId === tabId && t.attached && t.type === 'page',
);
if (existingTarget && !existingTarget.extensionId) {
throw new Error(
`Debugger is already attached to tab ${tabId} by another tool (e.g., DevTools).`,
);
}
// Attach debugger
try {
await chrome.debugger.attach({ tabId }, DEBUGGER_PROTOCOL_VERSION);
} catch (error: any) {
if (error.message?.includes('Cannot attach to the target with an attached client')) {
throw new Error(
`Debugger is already attached to tab ${tabId}. This might be DevTools or another extension.`,
);
}
throw error;
}
// Attach via shared manager (handles conflicts and refcount)
await cdpSessionManager.attach(tabId, 'network-capture');
// Enable network tracking
try {
await chrome.debugger.sendCommand({ tabId }, 'Network.enable');
await cdpSessionManager.sendCommand(tabId, 'Network.enable');
} catch (error: any) {
await chrome.debugger
.detach({ tabId })
await cdpSessionManager
.detach(tabId, 'network-capture')
.catch((e) => console.warn('Error detaching after failed enable:', e));
throw error;
}
@@ -290,8 +271,8 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor {
// Clean up resources
if (this.captureData.has(tabId)) {
await chrome.debugger
.detach({ tabId })
await cdpSessionManager
.detach(tabId, 'network-capture')
.catch((e) => console.warn('Cleanup detach error:', e));
this.cleanupCapture(tabId);
}
@@ -673,14 +654,8 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor {
const responseBodyPromise = (async () => {
try {
// Check if debugger is still attached to this tabId
const attachedTabs = await chrome.debugger.getTargets();
if (!attachedTabs.some((target) => target.tabId === tabId && target.attached)) {
// console.warn(`NetworkDebuggerStartTool: Debugger not attached to tab ${tabId} when trying to get response body for ${requestId}.`);
throw new Error(`Debugger not attached to tab ${tabId}`);
}
const result = (await chrome.debugger.sendCommand({ tabId }, 'Network.getResponseBody', {
// Will attach temporarily if needed
const result = (await cdpSessionManager.sendCommand(tabId, 'Network.getResponseBody', {
requestId,
})) as { body: string; base64Encoded: boolean };
return result;
@@ -734,33 +709,21 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor {
);
try {
// Detach debugger first to prevent further events.
// Check if debugger is attached before trying to send commands or detach
const attachedTargets = await chrome.debugger.getTargets();
const isAttached = attachedTargets.some(
(target) => target.tabId === tabId && target.attached,
);
if (isAttached) {
try {
await chrome.debugger.sendCommand({ tabId }, 'Network.disable');
} catch (e) {
console.warn(
`NetworkDebuggerStartTool: Error disabling network for tab ${tabId} (possibly already detached):`,
e,
);
}
try {
await chrome.debugger.detach({ tabId });
} catch (e) {
console.warn(
`NetworkDebuggerStartTool: Error detaching debugger for tab ${tabId} (possibly already detached):`,
e,
);
}
} else {
console.log(
`NetworkDebuggerStartTool: Debugger was not attached to tab ${tabId} at stopCapture.`,
// Attempt to disable network and detach via manager; it will no-op if others own the session
try {
await cdpSessionManager.sendCommand(tabId, 'Network.disable');
} catch (e) {
console.warn(
`NetworkDebuggerStartTool: Error disabling network for tab ${tabId} (possibly already detached):`,
e,
);
}
try {
await cdpSessionManager.detach(tabId, 'network-capture');
} catch (e) {
console.warn(
`NetworkDebuggerStartTool: Error detaching debugger for tab ${tabId} (possibly already detached):`,
e,
);
}
} catch (error: any) {
@@ -1003,8 +966,8 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor {
// If a tabId was involved and debugger might be attached, try to clean up.
const tabIdToClean = tabToOperateOn?.id;
if (tabIdToClean && this.captureData.has(tabIdToClean)) {
await chrome.debugger
.detach({ tabId: tabIdToClean })
await cdpSessionManager
.detach(tabIdToClean, 'network-capture')
.catch((e) => console.warn('Cleanup detach error:', e));
this.cleanupCapture(tabIdToClean);
}
@@ -0,0 +1,109 @@
import { TOOL_NAMES } from 'chrome-mcp-shared';
type OwnerTag = string;
interface TabSessionState {
refCount: number;
owners: Set<OwnerTag>;
attachedByUs: boolean;
}
const DEBUGGER_PROTOCOL_VERSION = '1.3';
class CDPSessionManager {
private sessions = new Map<number, TabSessionState>();
private getState(tabId: number): TabSessionState | undefined {
return this.sessions.get(tabId);
}
private setState(tabId: number, state: TabSessionState) {
this.sessions.set(tabId, state);
}
async attach(tabId: number, owner: OwnerTag = 'unknown'): Promise<void> {
const state = this.getState(tabId);
if (state && state.attachedByUs) {
state.refCount += 1;
state.owners.add(owner);
return;
}
// Check existing attachments
const targets = await chrome.debugger.getTargets();
const existing = targets.find((t) => t.tabId === tabId && t.attached);
if (existing) {
if (existing.extensionId === chrome.runtime.id) {
// Already attached by us (e.g., previous tool). Adopt and refcount.
this.setState(tabId, {
refCount: state ? state.refCount + 1 : 1,
owners: new Set([...(state?.owners || []), owner]),
attachedByUs: true,
});
return;
}
// Another client (DevTools/other extension) is attached
throw new Error(
`Debugger is already attached to tab ${tabId} by another client (e.g., DevTools/extension)`,
);
}
// Attach freshly
await chrome.debugger.attach({ tabId }, DEBUGGER_PROTOCOL_VERSION);
this.setState(tabId, { refCount: 1, owners: new Set([owner]), attachedByUs: true });
}
async detach(tabId: number, owner: OwnerTag = 'unknown'): Promise<void> {
const state = this.getState(tabId);
if (!state) return; // Nothing to do
// Update ownership/refcount
if (state.owners.has(owner)) state.owners.delete(owner);
state.refCount = Math.max(0, state.refCount - 1);
if (state.refCount > 0) {
// Still in use by other owners
return;
}
// We are the last owner
try {
if (state.attachedByUs) {
await chrome.debugger.detach({ tabId });
}
} catch (e) {
// Best-effort detach; ignore
} finally {
this.sessions.delete(tabId);
}
}
/**
* Convenience wrapper: ensures attach before fn, and balanced detach after.
*/
async withSession<T>(tabId: number, owner: OwnerTag, fn: () => Promise<T>): Promise<T> {
await this.attach(tabId, owner);
try {
return await fn();
} finally {
await this.detach(tabId, owner);
}
}
/**
* Send a CDP command. Requires that this manager has attached to the tab.
* If not attached by us, will attempt a one-shot attach around the call.
*/
async sendCommand<T = any>(tabId: number, method: string, params?: object): Promise<T> {
const state = this.getState(tabId);
if (state && state.attachedByUs) {
return (await chrome.debugger.sendCommand({ tabId }, method, params)) as T;
}
// Fallback: temporary session
return await this.withSession<T>(tabId, `send:${method}`, async () => {
return (await chrome.debugger.sendCommand({ tabId }, method, params)) as T;
});
}
}
export const cdpSessionManager = new CDPSessionManager();