feat: add MCP Chrome extension (#325)
Instructions: 1. `git clone https://github.com/mxschmitt/playwright-mcp && git checkout extension-drafft` 2. `npm ci && npm run build` 3. `chrome://extensions` in your normal Chrome, "load unpacked" and select the extension folder. 4. `node cli.js --port=4242 --extension` - The URL it prints at the end you can put into the extension popup. 5. Put either this into Claude Desktop (it does not support SSE yet hence wrapping it or just put the URL into Cursor/VSCode) ```json { "mcpServers": { "playwright": { "command": "bash", "args": [ "-c", "source $HOME/.nvm/nvm.sh && nvm use --silent 22 && npx supergateway --streamableHttp http://127.0.0.1:4242/mcp" ] } } } ``` Things like `Take a snapshot of my browser.` should now work in your Prompt Chat. ---- - SSE only for now, since we already have a http server with a port there - Upstream "page tests" can be executed over this CDP relay via https://github.com/microsoft/playwright/pull/36286 - Limitations for now are everything what happens outside of the tab its session is shared with -> `window.open` / `target=_blank`. --------- Co-authored-by: Yury Semikhatsky <yurys@chromium.org>
This commit is contained in:
344
extension/background.js
Normal file
344
extension/background.js
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Simple Chrome Extension that pumps CDP messages between chrome.debugger and WebSocket
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
function debugLog(...args) {
|
||||
const enabled = false;
|
||||
if (enabled) {
|
||||
console.log('[Extension]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
class TabShareExtension {
|
||||
constructor() {
|
||||
this.activeConnections = new Map(); // tabId -> connection info
|
||||
|
||||
// Remove page action click handler since we now use popup
|
||||
chrome.tabs.onRemoved.addListener(this.onTabRemoved.bind(this));
|
||||
|
||||
// Handle messages from popup
|
||||
chrome.runtime.onMessage.addListener(this.onMessage.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages from popup
|
||||
* @param {any} message
|
||||
* @param {chrome.runtime.MessageSender} sender
|
||||
* @param {Function} sendResponse
|
||||
*/
|
||||
onMessage(message, sender, sendResponse) {
|
||||
switch (message.type) {
|
||||
case 'getStatus':
|
||||
this.getStatus(message.tabId, sendResponse);
|
||||
return true; // Will respond asynchronously
|
||||
|
||||
case 'connect':
|
||||
this.connectTab(message.tabId, message.bridgeUrl).then(
|
||||
() => sendResponse({ success: true }),
|
||||
(error) => sendResponse({ success: false, error: error.message })
|
||||
);
|
||||
return true; // Will respond asynchronously
|
||||
|
||||
case 'disconnect':
|
||||
this.disconnectTab(message.tabId).then(
|
||||
() => sendResponse({ success: true }),
|
||||
(error) => sendResponse({ success: false, error: error.message })
|
||||
);
|
||||
return true; // Will respond asynchronously
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection status for popup
|
||||
* @param {number} requestedTabId
|
||||
* @param {Function} sendResponse
|
||||
*/
|
||||
getStatus(requestedTabId, sendResponse) {
|
||||
const isConnected = this.activeConnections.size > 0;
|
||||
let activeTabId = null;
|
||||
let activeTabInfo = null;
|
||||
|
||||
if (isConnected) {
|
||||
const [tabId, connection] = this.activeConnections.entries().next().value;
|
||||
activeTabId = tabId;
|
||||
|
||||
// Get tab info
|
||||
chrome.tabs.get(tabId, (tab) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
sendResponse({
|
||||
isConnected: false,
|
||||
error: 'Active tab not found'
|
||||
});
|
||||
} else {
|
||||
sendResponse({
|
||||
isConnected: true,
|
||||
activeTabId,
|
||||
activeTabInfo: {
|
||||
title: tab.title,
|
||||
url: tab.url
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
sendResponse({
|
||||
isConnected: false,
|
||||
activeTabId: null,
|
||||
activeTabInfo: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a tab to the bridge server
|
||||
* @param {number} tabId
|
||||
* @param {string} bridgeUrl
|
||||
*/
|
||||
async connectTab(tabId, bridgeUrl) {
|
||||
try {
|
||||
debugLog(`Connecting tab ${tabId} to bridge at ${bridgeUrl}`);
|
||||
|
||||
// Attach chrome debugger
|
||||
const debuggee = { tabId };
|
||||
await chrome.debugger.attach(debuggee, '1.3');
|
||||
|
||||
if (chrome.runtime.lastError)
|
||||
throw new Error(chrome.runtime.lastError.message);
|
||||
const targetInfo = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo'));
|
||||
debugLog('Target info:', targetInfo);
|
||||
|
||||
// Connect to bridge server
|
||||
const socket = new WebSocket(bridgeUrl);
|
||||
|
||||
const connection = {
|
||||
debuggee,
|
||||
socket,
|
||||
tabId,
|
||||
sessionId: `pw-tab-${tabId}`
|
||||
};
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.onopen = () => {
|
||||
debugLog(`WebSocket connected for tab ${tabId}`);
|
||||
// Send initial connection info to bridge
|
||||
socket.send(JSON.stringify({
|
||||
type: 'connection_info',
|
||||
sessionId: connection.sessionId,
|
||||
targetInfo: targetInfo?.targetInfo
|
||||
}));
|
||||
resolve(undefined);
|
||||
};
|
||||
socket.onerror = reject;
|
||||
setTimeout(() => reject(new Error('Connection timeout')), 5000);
|
||||
});
|
||||
|
||||
// Set up message handling
|
||||
this.setupMessageHandling(connection);
|
||||
|
||||
// Store connection
|
||||
this.activeConnections.set(tabId, connection);
|
||||
|
||||
// Update UI
|
||||
chrome.action.setBadgeText({ tabId, text: '●' });
|
||||
chrome.action.setBadgeBackgroundColor({ tabId, color: '#4CAF50' });
|
||||
chrome.action.setTitle({ tabId, title: 'Disconnect from Playwright MCP' });
|
||||
|
||||
debugLog(`Tab ${tabId} connected successfully`);
|
||||
|
||||
} catch (error) {
|
||||
debugLog(`Failed to connect tab ${tabId}:`, error.message);
|
||||
await this.cleanupConnection(tabId);
|
||||
|
||||
// Show error to user
|
||||
chrome.action.setBadgeText({ tabId, text: '!' });
|
||||
chrome.action.setBadgeBackgroundColor({ tabId, color: '#F44336' });
|
||||
chrome.action.setTitle({ tabId, title: `Connection failed: ${error.message}` });
|
||||
|
||||
throw error; // Re-throw for popup to handle
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up bidirectional message handling between debugger and WebSocket
|
||||
* @param {Object} connection
|
||||
*/
|
||||
setupMessageHandling(connection) {
|
||||
const { debuggee, socket, tabId, sessionId: rootSessionId } = connection;
|
||||
|
||||
// WebSocket -> chrome.debugger
|
||||
socket.onmessage = async (event) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
} catch (error) {
|
||||
debugLog('Error parsing message:', error);
|
||||
socket.send(JSON.stringify({
|
||||
error: {
|
||||
code: -32700,
|
||||
message: `Error parsing message: ${error.message}`
|
||||
}
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
debugLog('Received from bridge:', message);
|
||||
|
||||
const debuggerSession = { ...debuggee };
|
||||
const sessionId = message.sessionId;
|
||||
// Pass session id, unless it's the root session.
|
||||
if (sessionId && sessionId !== rootSessionId)
|
||||
debuggerSession.sessionId = sessionId;
|
||||
|
||||
// Forward CDP command to chrome.debugger
|
||||
const result = await chrome.debugger.sendCommand(
|
||||
debuggerSession,
|
||||
message.method,
|
||||
message.params || {}
|
||||
);
|
||||
|
||||
// Send response back to bridge
|
||||
const response = {
|
||||
id: message.id,
|
||||
sessionId,
|
||||
result
|
||||
};
|
||||
|
||||
if (chrome.runtime.lastError) {
|
||||
response.error = {
|
||||
code: -32000,
|
||||
message: chrome.runtime.lastError.message,
|
||||
};
|
||||
}
|
||||
|
||||
socket.send(JSON.stringify(response));
|
||||
} catch (error) {
|
||||
debugLog('Error processing WebSocket message:', error);
|
||||
const response = {
|
||||
id: message.id,
|
||||
sessionId: message.sessionId,
|
||||
error: {
|
||||
code: -32000,
|
||||
message: error.message,
|
||||
},
|
||||
};
|
||||
socket.send(JSON.stringify(response));
|
||||
}
|
||||
};
|
||||
|
||||
// chrome.debugger events -> WebSocket
|
||||
const eventListener = (source, method, params) => {
|
||||
if (source.tabId === tabId && socket.readyState === WebSocket.OPEN) {
|
||||
// If the sessionId is not provided, use the root sessionId.
|
||||
const event = {
|
||||
sessionId: source.sessionId || rootSessionId,
|
||||
method,
|
||||
params,
|
||||
};
|
||||
debugLog('Forwarding CDP event:', event);
|
||||
socket.send(JSON.stringify(event));
|
||||
}
|
||||
};
|
||||
|
||||
const detachListener = (source, reason) => {
|
||||
if (source.tabId === tabId) {
|
||||
debugLog(`Debugger detached from tab ${tabId}, reason: ${reason}`);
|
||||
this.disconnectTab(tabId);
|
||||
}
|
||||
};
|
||||
|
||||
// Store listeners for cleanup
|
||||
connection.eventListener = eventListener;
|
||||
connection.detachListener = detachListener;
|
||||
|
||||
chrome.debugger.onEvent.addListener(eventListener);
|
||||
chrome.debugger.onDetach.addListener(detachListener);
|
||||
|
||||
// Handle WebSocket close
|
||||
socket.onclose = () => {
|
||||
debugLog(`WebSocket closed for tab ${tabId}`);
|
||||
this.disconnectTab(tabId);
|
||||
};
|
||||
|
||||
socket.onerror = (error) => {
|
||||
debugLog(`WebSocket error for tab ${tabId}:`, error);
|
||||
this.disconnectTab(tabId);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a tab from the bridge
|
||||
* @param {number} tabId
|
||||
*/
|
||||
async disconnectTab(tabId) {
|
||||
await this.cleanupConnection(tabId);
|
||||
|
||||
// Update UI
|
||||
chrome.action.setBadgeText({ tabId, text: '' });
|
||||
chrome.action.setTitle({ tabId, title: 'Share tab with Playwright MCP' });
|
||||
|
||||
debugLog(`Tab ${tabId} disconnected`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up connection resources
|
||||
* @param {number} tabId
|
||||
*/
|
||||
async cleanupConnection(tabId) {
|
||||
const connection = this.activeConnections.get(tabId);
|
||||
if (!connection) return;
|
||||
|
||||
// Remove listeners
|
||||
if (connection.eventListener) {
|
||||
chrome.debugger.onEvent.removeListener(connection.eventListener);
|
||||
}
|
||||
if (connection.detachListener) {
|
||||
chrome.debugger.onDetach.removeListener(connection.detachListener);
|
||||
}
|
||||
|
||||
// Close WebSocket
|
||||
if (connection.socket && connection.socket.readyState === WebSocket.OPEN) {
|
||||
connection.socket.close();
|
||||
}
|
||||
|
||||
// Detach debugger
|
||||
try {
|
||||
await chrome.debugger.detach(connection.debuggee);
|
||||
} catch (error) {
|
||||
// Ignore detach errors - might already be detached
|
||||
}
|
||||
|
||||
this.activeConnections.delete(tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tab removal
|
||||
* @param {number} tabId
|
||||
*/
|
||||
async onTabRemoved(tabId) {
|
||||
if (this.activeConnections.has(tabId)) {
|
||||
await this.cleanupConnection(tabId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
new TabShareExtension();
|
||||
BIN
extension/icons/icon-128.png
Normal file
BIN
extension/icons/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
BIN
extension/icons/icon-16.png
Normal file
BIN
extension/icons/icon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 571 B |
BIN
extension/icons/icon-32.png
Normal file
BIN
extension/icons/icon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
extension/icons/icon-48.png
Normal file
BIN
extension/icons/icon-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
40
extension/manifest.json
Normal file
40
extension/manifest.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Playwright MCP Bridge",
|
||||
"version": "1.0.0",
|
||||
"description": "Share browser tabs with Playwright MCP server through CDP bridge",
|
||||
|
||||
"permissions": [
|
||||
"debugger",
|
||||
"activeTab",
|
||||
"tabs",
|
||||
"storage"
|
||||
],
|
||||
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
|
||||
"background": {
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
},
|
||||
|
||||
"action": {
|
||||
"default_title": "Share tab with Playwright MCP",
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
}
|
||||
173
extension/popup.html
Normal file
173
extension/popup.html
Normal file
@@ -0,0 +1,173 @@
|
||||
<!--
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {
|
||||
width: 320px;
|
||||
padding: 16px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h3 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
input[type="url"] {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input[type="url"]:focus {
|
||||
outline: none;
|
||||
border-color: #4CAF50;
|
||||
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
|
||||
}
|
||||
|
||||
.button {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
background: #cccccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button.disconnect {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
.button.disconnect:hover {
|
||||
background: #da190b;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status.connected {
|
||||
background: #e8f5e8;
|
||||
color: #2e7d32;
|
||||
border: 1px solid #4caf50;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
border: 1px solid #f44336;
|
||||
}
|
||||
|
||||
.status.warning {
|
||||
background: #fff3e0;
|
||||
color: #ef6c00;
|
||||
border: 1px solid #ff9800;
|
||||
}
|
||||
|
||||
.tab-info {
|
||||
background: #f5f5f5;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.tab-url {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.focus-button {
|
||||
background: #2196F3;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.focus-button:hover {
|
||||
background: #1976D2;
|
||||
}
|
||||
|
||||
.small-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h3>Playwright MCP Bridge</h3>
|
||||
</div>
|
||||
|
||||
<div id="status-container"></div>
|
||||
|
||||
<div class="section">
|
||||
<label for="bridge-url">Bridge Server URL:</label>
|
||||
<input type="url" id="bridge-url" disabled placeholder="ws://localhost:9223/extension" />
|
||||
<div class="small-text">Enter the WebSocket URL of your MCP bridge server</div>
|
||||
</div>
|
||||
|
||||
<div id="action-container">
|
||||
<button id="connect-btn" class="button">Share This Tab</button>
|
||||
</div>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
228
extension/popup.js
Normal file
228
extension/popup.js
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Popup script for Playwright MCP Bridge extension
|
||||
*/
|
||||
|
||||
class PopupController {
|
||||
constructor() {
|
||||
this.currentTab = null;
|
||||
this.bridgeUrlInput = /** @type {HTMLInputElement} */ (document.getElementById('bridge-url'));
|
||||
this.connectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('connect-btn'));
|
||||
this.statusContainer = /** @type {HTMLElement} */ (document.getElementById('status-container'));
|
||||
this.actionContainer = /** @type {HTMLElement} */ (document.getElementById('action-container'));
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
// Get current tab
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
this.currentTab = tab;
|
||||
|
||||
// Load saved bridge URL
|
||||
const result = await chrome.storage.sync.get(['bridgeUrl']);
|
||||
const savedUrl = result.bridgeUrl || 'ws://localhost:9223/extension';
|
||||
this.bridgeUrlInput.value = savedUrl;
|
||||
this.bridgeUrlInput.disabled = false;
|
||||
|
||||
// Set up event listeners
|
||||
this.bridgeUrlInput.addEventListener('input', this.onUrlChange.bind(this));
|
||||
this.connectBtn.addEventListener('click', this.onConnectClick.bind(this));
|
||||
|
||||
// Update UI based on current state
|
||||
await this.updateUI();
|
||||
}
|
||||
|
||||
async updateUI() {
|
||||
if (!this.currentTab?.id) return;
|
||||
|
||||
// Get connection status from background script
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'getStatus',
|
||||
tabId: this.currentTab.id
|
||||
});
|
||||
|
||||
const { isConnected, activeTabId, activeTabInfo, error } = response;
|
||||
|
||||
if (!this.statusContainer || !this.actionContainer) return;
|
||||
|
||||
this.statusContainer.innerHTML = '';
|
||||
this.actionContainer.innerHTML = '';
|
||||
|
||||
if (error) {
|
||||
this.showStatus('error', `Error: ${error}`);
|
||||
this.showConnectButton();
|
||||
} else if (isConnected && activeTabId === this.currentTab.id) {
|
||||
// Current tab is connected
|
||||
this.showStatus('connected', 'This tab is currently shared with MCP server');
|
||||
this.showDisconnectButton();
|
||||
} else if (isConnected && activeTabId !== this.currentTab.id) {
|
||||
// Another tab is connected
|
||||
this.showStatus('warning', 'Another tab is already sharing the CDP session');
|
||||
this.showActiveTabInfo(activeTabInfo);
|
||||
this.showFocusButton(activeTabId);
|
||||
} else {
|
||||
// No connection
|
||||
this.showConnectButton();
|
||||
}
|
||||
}
|
||||
|
||||
showStatus(type, message) {
|
||||
const statusDiv = document.createElement('div');
|
||||
statusDiv.className = `status ${type}`;
|
||||
statusDiv.textContent = message;
|
||||
this.statusContainer.appendChild(statusDiv);
|
||||
}
|
||||
|
||||
showConnectButton() {
|
||||
if (!this.actionContainer) return;
|
||||
|
||||
this.actionContainer.innerHTML = `
|
||||
<button id="connect-btn" class="button">Share This Tab</button>
|
||||
`;
|
||||
|
||||
const connectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('connect-btn'));
|
||||
if (connectBtn) {
|
||||
connectBtn.addEventListener('click', this.onConnectClick.bind(this));
|
||||
|
||||
// Disable if URL is invalid
|
||||
const isValidUrl = this.bridgeUrlInput ? this.isValidWebSocketUrl(this.bridgeUrlInput.value) : false;
|
||||
connectBtn.disabled = !isValidUrl;
|
||||
}
|
||||
}
|
||||
|
||||
showDisconnectButton() {
|
||||
if (!this.actionContainer) return;
|
||||
|
||||
this.actionContainer.innerHTML = `
|
||||
<button id="disconnect-btn" class="button disconnect">Stop Sharing</button>
|
||||
`;
|
||||
|
||||
const disconnectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('disconnect-btn'));
|
||||
if (disconnectBtn) {
|
||||
disconnectBtn.addEventListener('click', this.onDisconnectClick.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
showActiveTabInfo(tabInfo) {
|
||||
if (!tabInfo) return;
|
||||
|
||||
const tabDiv = document.createElement('div');
|
||||
tabDiv.className = 'tab-info';
|
||||
tabDiv.innerHTML = `
|
||||
<div class="tab-title">${tabInfo.title || 'Unknown Tab'}</div>
|
||||
<div class="tab-url">${tabInfo.url || ''}</div>
|
||||
`;
|
||||
this.statusContainer.appendChild(tabDiv);
|
||||
}
|
||||
|
||||
showFocusButton(activeTabId) {
|
||||
if (!this.actionContainer) return;
|
||||
|
||||
this.actionContainer.innerHTML = `
|
||||
<button id="focus-btn" class="button focus-button">Switch to Shared Tab</button>
|
||||
`;
|
||||
|
||||
const focusBtn = /** @type {HTMLButtonElement} */ (document.getElementById('focus-btn'));
|
||||
if (focusBtn) {
|
||||
focusBtn.addEventListener('click', () => this.onFocusClick(activeTabId));
|
||||
}
|
||||
}
|
||||
|
||||
onUrlChange() {
|
||||
if (!this.bridgeUrlInput) return;
|
||||
|
||||
const isValid = this.isValidWebSocketUrl(this.bridgeUrlInput.value);
|
||||
const connectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('connect-btn'));
|
||||
if (connectBtn) {
|
||||
connectBtn.disabled = !isValid;
|
||||
}
|
||||
|
||||
// Save URL to storage
|
||||
if (isValid) {
|
||||
chrome.storage.sync.set({ bridgeUrl: this.bridgeUrlInput.value });
|
||||
}
|
||||
}
|
||||
|
||||
async onConnectClick() {
|
||||
if (!this.bridgeUrlInput || !this.currentTab?.id) return;
|
||||
|
||||
const url = this.bridgeUrlInput.value.trim();
|
||||
if (!this.isValidWebSocketUrl(url)) {
|
||||
this.showStatus('error', 'Please enter a valid WebSocket URL');
|
||||
return;
|
||||
}
|
||||
|
||||
// Save URL to storage
|
||||
await chrome.storage.sync.set({ bridgeUrl: url });
|
||||
|
||||
// Send connect message to background script
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'connect',
|
||||
tabId: this.currentTab.id,
|
||||
bridgeUrl: url
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
await this.updateUI();
|
||||
} else {
|
||||
this.showStatus('error', response.error || 'Failed to connect');
|
||||
}
|
||||
}
|
||||
|
||||
async onDisconnectClick() {
|
||||
if (!this.currentTab?.id) return;
|
||||
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'disconnect',
|
||||
tabId: this.currentTab.id
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
await this.updateUI();
|
||||
} else {
|
||||
this.showStatus('error', response.error || 'Failed to disconnect');
|
||||
}
|
||||
}
|
||||
|
||||
async onFocusClick(activeTabId) {
|
||||
try {
|
||||
await chrome.tabs.update(activeTabId, { active: true });
|
||||
window.close(); // Close popup after switching
|
||||
} catch (error) {
|
||||
this.showStatus('error', 'Failed to switch to tab');
|
||||
}
|
||||
}
|
||||
|
||||
isValidWebSocketUrl(url) {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'ws:' || parsed.protocol === 'wss:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize popup when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new PopupController();
|
||||
});
|
||||
Reference in New Issue
Block a user