diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index b1f5fe0..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/browser-tools-mcp/mcp-server.ts b/browser-tools-mcp/mcp-server.ts index a7a1272..1edf18c 100644 --- a/browser-tools-mcp/mcp-server.ts +++ b/browser-tools-mcp/mcp-server.ts @@ -264,14 +264,49 @@ server.tool( const result = await response.json(); if (response.ok) { - return { - content: [ - { - type: "text", - text: "Successfully saved screenshot", - }, - ], - }; + // If we have a file path, read the image directly from disk + if (result.path && typeof result.path === 'string') { + try { + const fs = await import('fs'); + const imageBuffer = fs.readFileSync(result.path); + const base64Data = imageBuffer.toString('base64'); + + return { + content: [ + { + type: "image", + data: base64Data, + mimeType: "image/png", + }, + { + type: "text", + text: `Screenshot captured successfully and saved to: ${result.path}`, + }, + ], + }; + } catch (fileError) { + // If we can't read the file, fall back to text response + console.error("Failed to read screenshot file:", fileError); + return { + content: [ + { + type: "text", + text: `Screenshot saved to: ${result.path} (but couldn't load image data)`, + }, + ], + }; + } + } else { + // Fallback to text response if no path + return { + content: [ + { + type: "text", + text: "Successfully saved screenshot", + }, + ], + }; + } } else { return { content: [ @@ -584,7 +619,7 @@ server.tool("runNextJSAudit", {}, async () => ({ text: ` You are an expert in SEO and web development with NextJS. Given the following procedures for analyzing my codebase, please perform a comprehensive - page by page analysis of our NextJS application to identify any issues or areas of improvement for SEO. - After each iteration of changes, reinvoke this tool to re-fetch our SEO audit procedures and then scan our codebase again to identify additional areas of improvement. + After each iteration of changes, reinvoke this tool to re-fetch our SEO audit procedures and then scan our codebase again to identify additional areas of improvement. When no more areas of improvement are found, return "No more areas of improvement found, your NextJS application is optimized for SEO!". @@ -706,7 +741,7 @@ server.tool("runNextJSAudit", {}, async () => ({ initialScale: 1, themeColor: "#ffffff" }; - + export const metadata: Metadata = { metadataBase: new URL("https://dminhvu.com"), openGraph: { @@ -835,25 +870,25 @@ server.tool("runNextJSAudit", {}, async () => ({ type Params = { slug: string; }; - + type Props = { params: Params; searchParams: { [key: string]: string | string[] | undefined }; }; - + export async function generateMetadata( { params, searchParams }: Props, parent: ResolvingMetadata ): Promise { const { slug } = params; - + const post: Post = await fetch("YOUR_ENDPOINT", { method: "GET", next: { revalidate: 60 * 60 * 24 } }).then((res) => res.json()); - + return { title: "{post.title} | dminhvu", authors: [ @@ -903,7 +938,7 @@ server.tool("runNextJSAudit", {}, async () => ({ }; } - + 2. JSON-LD Schema JSON-LD is a format for structured data that can be used by search engines to understand your content. For example, you can use it to describe a person, an event, an organization, a movie, a book, a recipe, and many other types of entities. @@ -912,7 +947,7 @@ server.tool("runNextJSAudit", {}, async () => ({ export default async function Page({ params }) { const { id } = await params const product = await getProduct(id) - + const jsonLd = { '@context': 'https://schema.org', '@type': 'Product', @@ -920,7 +955,7 @@ server.tool("runNextJSAudit", {}, async () => ({ image: product.image, description: product.description, } - + return (
{/* Add JSON-LD to your page */} @@ -932,12 +967,12 @@ server.tool("runNextJSAudit", {}, async () => ({
) } - + You can type your JSON-LD with TypeScript using community packages like schema-dts: import { Product, WithContext } from 'schema-dts' - + const jsonLd: WithContext = { '@context': 'https://schema.org', '@type': 'Product', @@ -981,7 +1016,7 @@ server.tool("runNextJSAudit", {}, async () => ({ getAllPostSlugsWithModifyTime } from "@/utils/getData"; import { MetadataRoute } from "next"; - + export default async function sitemap(): Promise { const defaultPages = [ { @@ -1004,10 +1039,10 @@ server.tool("runNextJSAudit", {}, async () => ({ } // other pages ]; - + const postSlugs = await getAllPostSlugsWithModifyTime(); const categorySlugs = await getAllCategories(); - + const sitemap = [ ...defaultPages, ...postSlugs.map((e: any) => ({ @@ -1023,7 +1058,7 @@ server.tool("runNextJSAudit", {}, async () => ({ priority: 0.7 })) ]; - + return sitemap; } With this sitemap.ts file created, you can access the sitemap at https://dminhvu.com/sitemap.xml. @@ -1062,7 +1097,7 @@ server.tool("runNextJSAudit", {}, async () => ({ app/robots.ts import { MetadataRoute } from "next"; - + export default function robots(): MetadataRoute.Robots { return { rules: { @@ -1080,7 +1115,7 @@ server.tool("runNextJSAudit", {}, async () => ({ Allow: / Disallow: /search?q= Disallow: /admin - + Sitemap: https://dminhvu.com/sitemap.xml 5. Link tags Link Tags for Next.js Pages Router @@ -1089,7 +1124,7 @@ server.tool("runNextJSAudit", {}, async () => ({ pages/_app.tsx import Head from "next/head"; - + export default function Page() { return ( @@ -1116,7 +1151,7 @@ server.tool("runNextJSAudit", {}, async () => ({ pages/[slug].tsx import Head from "next/head"; - + export default function Page() { return ( @@ -1191,7 +1226,7 @@ server.tool("runNextJSAudit", {}, async () => ({ import Head from "next/head"; import Script from "next/script"; - + export default function Page() { return ( @@ -1229,7 +1264,7 @@ server.tool("runNextJSAudit", {}, async () => ({ import { GoogleTagManager } from "@next/third-parties/google"; import { GoogleAnalytics } from "@next/third-parties/google"; import Head from "next/head"; - + export default function Page() { return ( @@ -1258,7 +1293,7 @@ server.tool("runNextJSAudit", {}, async () => ({ import Image from "next/image"; - + export default function Page() { return ( controller.abort(), timeout); + return controller.signal; +} + +// Listen for messages from the devtools panel +browserAPI.runtime.onMessage.addListener((message, sender, sendResponse) => { + // Handle console messages from content scripts (Firefox compatibility) + if (message.type === "CONSOLE_MESSAGE_FROM_CONTENT") { + console.log("Background: Received console message from content script:", message.data); + + // Send directly to browser connector server + const serverUrl = `http://localhost:3025/extension-log`; + + const payload = { + data: { + ...message.data, + timestamp: Date.now(), + }, + settings: { + logLimit: 50, + queryLimit: 30000, + showRequestHeaders: false, + showResponseHeaders: false, + }, + }; + + fetch(serverUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + .then((response) => response.json()) + .then((data) => { + console.log("Background: Console log sent to server successfully:", data); + }) + .catch((error) => { + console.error("Background: Error sending console log to server:", error); + }); + + return true; + } + + if (message.type === "GET_CURRENT_URL" && message.tabId) { + getCurrentTabUrl(message.tabId) + .then((url) => { + sendResponse({ success: true, url: url }); + }) + .catch((error) => { + sendResponse({ success: false, error: error.message }); + }); + return true; // Required to use sendResponse asynchronously + } + + // Handle explicit request to update the server with the URL + if (message.type === "UPDATE_SERVER_URL" && message.tabId && message.url) { + console.log( + `Background: Received request to update server with URL for tab ${message.tabId}: ${message.url}` + ); + updateServerWithUrl( + message.tabId, + message.url, + message.source || "explicit_update" + ) + .then(() => { + if (sendResponse) sendResponse({ success: true }); + }) + .catch((error) => { + console.error("Background: Error updating server with URL:", error); + if (sendResponse) + sendResponse({ success: false, error: error.message }); + }); + return true; // Required to use sendResponse asynchronously + } + + if (message.type === "CAPTURE_SCREENSHOT" && message.tabId) { + // Handle screenshot capture and return data to DevTools (for Firefox compatibility) + captureScreenshotForDevTools(message, sendResponse); + return true; // Required to use sendResponse asynchronously + } +}); + +// Validate server identity +async function validateServerIdentity(host, port) { + try { + const response = await fetch(`http://${host}:${port}/.identity`, { + signal: createTimeoutSignal(3000), // 3 second timeout + }); + + if (!response.ok) { + console.error(`Invalid server response: ${response.status}`); + return false; + } + + const identity = await response.json(); + + // Validate the server signature + if (identity.signature !== "mcp-browser-connector-24x7") { + console.error("Invalid server signature - not the browser tools server"); + return false; + } + + return true; + } catch (error) { + console.error("Error validating server identity:", error); + return false; + } +} + +// Helper function to process the tab and run the audit +function processTabForAudit(tab, tabId) { + const url = tab.url; + + if (!url) { + console.error(`No URL available for tab ${tabId}`); + return; + } + + // Update our cache and the server with this URL + tabUrls.set(tabId, url); + updateServerWithUrl(tabId, url); +} + +// Track URLs for each tab +const tabUrls = new Map(); + +// Function to get the current URL for a tab +async function getCurrentTabUrl(tabId) { + try { + console.log("Background: Getting URL for tab", tabId); + + // First check if we have it cached + if (tabUrls.has(tabId)) { + const cachedUrl = tabUrls.get(tabId); + console.log("Background: Found cached URL:", cachedUrl); + return cachedUrl; + } + + // Otherwise get it from the tab + try { + const tab = await browserAPI.tabs.get(tabId); + if (tab && tab.url) { + // Cache the URL + tabUrls.set(tabId, tab.url); + console.log("Background: Got URL from tab:", tab.url); + return tab.url; + } else { + console.log("Background: Tab exists but no URL found"); + } + } catch (tabError) { + console.error("Background: Error getting tab:", tabError); + } + + // If we can't get the tab directly, try querying for active tabs + try { + const tabs = await browserAPI.tabs.query({ + active: true, + currentWindow: true, + }); + if (tabs && tabs.length > 0 && tabs[0].url) { + const activeUrl = tabs[0].url; + console.log("Background: Got URL from active tab:", activeUrl); + // Cache this URL as well + tabUrls.set(tabId, activeUrl); + return activeUrl; + } + } catch (queryError) { + console.error("Background: Error querying tabs:", queryError); + } + + console.log("Background: Could not find URL for tab", tabId); + return null; + } catch (error) { + console.error("Background: Error getting tab URL:", error); + return null; + } +} + +// Listen for tab updates to detect page refreshes and URL changes +browserAPI.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + // Track URL changes + if (changeInfo.url) { + console.log(`URL changed in tab ${tabId} to ${changeInfo.url}`); + tabUrls.set(tabId, changeInfo.url); + + // Send URL update to server if possible + updateServerWithUrl(tabId, changeInfo.url, "tab_url_change"); + } + + // Check if this is a page refresh (status becoming "complete") + if (changeInfo.status === "complete") { + // Update URL in our cache + if (tab.url) { + tabUrls.set(tabId, tab.url); + // Send URL update to server if possible + updateServerWithUrl(tabId, tab.url, "page_complete"); + } + + retestConnectionOnRefresh(tabId); + } +}); + +// Listen for tab activation (switching between tabs) +browserAPI.tabs.onActivated.addListener((activeInfo) => { + const tabId = activeInfo.tabId; + console.log(`Tab activated: ${tabId}`); + + // Get the URL of the newly activated tab + browserAPI.tabs.get(tabId, (tab) => { + if (browserAPI.runtime.lastError) { + console.error("Error getting tab info:", browserAPI.runtime.lastError); + return; + } + + if (tab && tab.url) { + console.log(`Active tab changed to ${tab.url}`); + + // Update our cache + tabUrls.set(tabId, tab.url); + + // Send URL update to server + updateServerWithUrl(tabId, tab.url, "tab_activated"); + } + }); +}); + +// Function to update the server with the current URL +async function updateServerWithUrl(tabId, url, source = "background_update") { + if (!url) { + console.error("Cannot update server with empty URL"); + return; + } + + console.log(`Updating server with URL for tab ${tabId}: ${url}`); + + // Get the saved settings + try { + const result = await browserAPI.storage.local.get(["browserConnectorSettings"]); + const settings = result.browserConnectorSettings || { + serverHost: "localhost", + serverPort: 3025, + }; + + // Maximum number of retry attempts + const maxRetries = 3; + let retryCount = 0; + let success = false; + + while (retryCount < maxRetries && !success) { + try { + // Send the URL to the server + const serverUrl = `http://${settings.serverHost}:${settings.serverPort}/current-url`; + console.log( + `Attempt ${ + retryCount + 1 + }/${maxRetries} to update server with URL: ${url}` + ); + + const response = await fetch(serverUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + url: url, + tabId: tabId, + timestamp: Date.now(), + source: source, + }), + // Add a timeout to prevent hanging requests + signal: createTimeoutSignal(5000), + }); + + if (response.ok) { + const responseData = await response.json(); + console.log( + `Successfully updated server with URL: ${url}`, + responseData + ); + success = true; + } else { + console.error( + `Server returned error: ${response.status} ${response.statusText}` + ); + retryCount++; + // Wait before retrying + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } catch (error) { + console.error(`Error updating server with URL: ${error.message}`); + retryCount++; + // Wait before retrying + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + + if (!success) { + console.error( + `Failed to update server with URL after ${maxRetries} attempts` + ); + } + } catch (error) { + console.error("Error getting settings for URL update:", error); + } +} + +// Clean up when tabs are closed +browserAPI.tabs.onRemoved.addListener((tabId) => { + tabUrls.delete(tabId); +}); + +// Function to retest connection when a page is refreshed +async function retestConnectionOnRefresh(tabId) { + console.log(`Page refreshed in tab ${tabId}, retesting connection...`); + + // Get the saved settings + try { + const result = await browserAPI.storage.local.get(["browserConnectorSettings"]); + const settings = result.browserConnectorSettings || { + serverHost: "localhost", + serverPort: 3025, + }; + + // Test the connection with the last known host and port + const isConnected = await validateServerIdentity( + settings.serverHost, + settings.serverPort + ); + + // Notify all devtools instances about the connection status + browserAPI.runtime.sendMessage({ + type: "CONNECTION_STATUS_UPDATE", + isConnected: isConnected, + tabId: tabId, + }); + + // Always notify for page refresh, whether connected or not + // This ensures any ongoing discovery is cancelled and restarted + browserAPI.runtime.sendMessage({ + type: "INITIATE_AUTO_DISCOVERY", + reason: "page_refresh", + tabId: tabId, + forceRestart: true, // Add a flag to indicate this should force restart any ongoing processes + }); + + if (!isConnected) { + console.log( + "Connection test failed after page refresh, initiating auto-discovery..." + ); + } else { + console.log("Connection test successful after page refresh"); + } + } catch (error) { + console.error("Error getting settings for connection retest:", error); + } +} + +// Function to capture screenshot and return data to DevTools (Firefox compatibility) +function captureScreenshotForDevTools(message, sendResponse) { + // Get the inspected window's tab + browserAPI.tabs.get(message.tabId, (tab) => { + if (browserAPI.runtime.lastError) { + console.error("Error getting tab:", browserAPI.runtime.lastError); + sendResponse({ + success: false, + error: browserAPI.runtime.lastError.message, + }); + return; + } + + // Get all windows to find the one containing our tab + browserAPI.windows.getAll({ populate: true }, (windows) => { + const targetWindow = windows.find((w) => + w.tabs.some((t) => t.id === message.tabId) + ); + + if (!targetWindow) { + console.error("Could not find window containing the inspected tab"); + sendResponse({ + success: false, + error: "Could not find window containing the inspected tab", + }); + return; + } + + // Capture screenshot of the window containing our tab + browserAPI.tabs.captureVisibleTab( + targetWindow.id, + { format: "png" }, + (dataUrl) => { + // Ignore DevTools panel capture error if it occurs + if ( + browserAPI.runtime.lastError && + !browserAPI.runtime.lastError.message.includes("devtools://") + ) { + console.error( + "Error capturing screenshot:", + browserAPI.runtime.lastError + ); + sendResponse({ + success: false, + error: browserAPI.runtime.lastError.message, + }); + return; + } + + console.log("Screenshot captured successfully for DevTools"); + // Return screenshot data to DevTools script + sendResponse({ + success: true, + dataUrl: dataUrl, + title: tab.title || "Current Tab", + }); + } + ); + }); + }); +} + +// Function to capture and send screenshot +function captureAndSendScreenshot(message, settings, sendResponse) { + // Get the inspected window's tab + browserAPI.tabs.get(message.tabId, (tab) => { + if (browserAPI.runtime.lastError) { + console.error("Error getting tab:", browserAPI.runtime.lastError); + sendResponse({ + success: false, + error: browserAPI.runtime.lastError.message, + }); + return; + } + + // Get all windows to find the one containing our tab + browserAPI.windows.getAll({ populate: true }, (windows) => { + const targetWindow = windows.find((w) => + w.tabs.some((t) => t.id === message.tabId) + ); + + if (!targetWindow) { + console.error("Could not find window containing the inspected tab"); + sendResponse({ + success: false, + error: "Could not find window containing the inspected tab", + }); + return; + } + + // Capture screenshot of the window containing our tab + browserAPI.tabs.captureVisibleTab( + targetWindow.id, + { format: "png" }, + (dataUrl) => { + // Ignore DevTools panel capture error if it occurs + if ( + browserAPI.runtime.lastError && + !browserAPI.runtime.lastError.message.includes("devtools://") + ) { + console.error( + "Error capturing screenshot:", + browserAPI.runtime.lastError + ); + sendResponse({ + success: false, + error: browserAPI.runtime.lastError.message, + }); + return; + } + + // Send screenshot data to browser connector using configured settings + const serverUrl = `http://${settings.serverHost}:${settings.serverPort}/screenshot`; + console.log(`Sending screenshot to ${serverUrl}`); + + fetch(serverUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + data: dataUrl, + path: message.screenshotPath, + }), + }) + .then((response) => response.json()) + .then((result) => { + if (result.error) { + console.error("Error from server:", result.error); + sendResponse({ success: false, error: result.error }); + } else { + console.log("Screenshot saved successfully:", result.path); + // Send success response even if DevTools capture failed + sendResponse({ + success: true, + path: result.path, + title: tab.title || "Current Tab", + }); + } + }) + .catch((error) => { + console.error("Error sending screenshot data:", error); + sendResponse({ + success: false, + error: error.message || "Failed to save screenshot", + }); + }); + } + ); + }); + }); +} diff --git a/firefox-extension/console-capture.js b/firefox-extension/console-capture.js new file mode 100644 index 0000000..72d261b --- /dev/null +++ b/firefox-extension/console-capture.js @@ -0,0 +1,118 @@ +// Firefox Console Capture Content Script +// This script runs in the page context to capture console logs + +(function() { + 'use strict'; + + // Only run once per page + if (window.browserToolsConsoleCapture) { + return; + } + window.browserToolsConsoleCapture = true; + + console.log('Firefox Console Capture: Initializing console interception'); + + // Store original console methods + const originalConsole = { + log: console.log.bind(console), + error: console.error.bind(console), + warn: console.warn.bind(console), + info: console.info.bind(console), + debug: console.debug.bind(console) + }; + + // Function to send console messages to the extension + function sendToExtension(level, args) { + try { + // Skip our own debug messages to prevent infinite loops + const message = args.map(arg => { + if (typeof arg === 'object') { + try { + return JSON.stringify(arg, null, 2); + } catch (e) { + return '[Object]'; + } + } + return String(arg); + }).join(' '); + + // Skip messages from our own content script to prevent infinite loops + if (message.includes('Content Script:') || message.includes('Firefox Console Capture:')) { + return; + } + + const messageData = { + type: level === 'error' ? 'console-error' : 'console-log', + level: level, + message: message, + timestamp: Date.now() + }; + + // Try multiple methods to send the message (without logging to prevent loops) + + // Send directly to background script which will relay to DevTools + if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.sendMessage) { + chrome.runtime.sendMessage({ + type: 'CONSOLE_MESSAGE_FROM_CONTENT', + data: messageData, + tabId: null // Background script will determine the tab + }); + } else if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.sendMessage) { + browser.runtime.sendMessage({ + type: 'CONSOLE_MESSAGE_FROM_CONTENT', + data: messageData, + tabId: null + }); + } + } catch (error) { + // Fail silently to avoid infinite loops + } + } + + // Fallback method using custom events + function sendViaCustomEvent(messageData) { + // Don't log here to prevent infinite loops + const event = new CustomEvent('browserToolsConsoleMessage', { + detail: messageData + }); + document.dispatchEvent(event); + } + + // Intercept console methods + function interceptConsole(method, level) { + console[method] = function(...args) { + // Call original method first + originalConsole[method](...args); + + // Send to extension + sendToExtension(level, args); + }; + } + + // Set up interception for all console methods + interceptConsole('log', 'log'); + interceptConsole('error', 'error'); + interceptConsole('warn', 'warn'); + interceptConsole('info', 'info'); + interceptConsole('debug', 'debug'); + + // Capture unhandled errors + window.addEventListener('error', function(event) { + const message = event.error ? + `${event.error.name}: ${event.error.message}\n${event.error.stack}` : + `${event.message} at ${event.filename}:${event.lineno}:${event.colno}`; + + sendToExtension('error', [message]); + }); + + // Capture unhandled promise rejections + window.addEventListener('unhandledrejection', function(event) { + const message = event.reason ? + `Unhandled Promise Rejection: ${event.reason}` : + 'Unhandled Promise Rejection'; + + sendToExtension('error', [message]); + }); + + console.log('Firefox Console Capture: Console interception active'); +})(); diff --git a/firefox-extension/devtools.html b/firefox-extension/devtools.html new file mode 100644 index 0000000..5432248 --- /dev/null +++ b/firefox-extension/devtools.html @@ -0,0 +1,13 @@ + + + + + BrowserTools MCP + + + + + + + + diff --git a/firefox-extension/devtools.js b/firefox-extension/devtools.js new file mode 100644 index 0000000..73c9c0d --- /dev/null +++ b/firefox-extension/devtools.js @@ -0,0 +1,1269 @@ +// devtools.js + +// Cross-browser compatibility +const browserAPI = typeof browser !== 'undefined' ? browser : chrome; + +// Cross-browser AbortSignal.timeout polyfill +function createTimeoutSignal(timeout) { + if (typeof AbortSignal !== 'undefined' && AbortSignal.timeout) { + return AbortSignal.timeout(timeout); + } + + // Fallback for older browsers + const controller = new AbortController(); + setTimeout(() => controller.abort(), timeout); + return controller.signal; +} + +// Store settings with defaults +let settings = { + logLimit: 50, + queryLimit: 30000, + stringSizeLimit: 500, + maxLogSize: 20000, + showRequestHeaders: false, + showResponseHeaders: false, + screenshotPath: "", // Add new setting for screenshot path + serverHost: "localhost", // Default server host + serverPort: 3025, // Default server port + allowAutoPaste: false, // Default auto-paste setting +}; + +// Keep track of debugger state +let isDebuggerAttached = false; +let attachDebuggerRetries = 0; +const currentTabId = browserAPI.devtools?.inspectedWindow?.tabId; +const MAX_ATTACH_RETRIES = 3; +const ATTACH_RETRY_DELAY = 1000; // 1 second + +// WebSocket connection management (declare early to avoid initialization errors) +let ws = null; +let wsReconnectTimeout = null; +let heartbeatInterval = null; +const WS_RECONNECT_DELAY = 5000; // 5 seconds +const HEARTBEAT_INTERVAL = 30000; // 30 seconds +// Add a flag to track if we need to reconnect after identity validation +let reconnectAfterValidation = false; +// Track if we're intentionally closing the connection +let intentionalClosure = false; + +// Load saved settings on startup +browserAPI.storage.local.get(["browserConnectorSettings"]).then((result) => { + if (result.browserConnectorSettings) { + settings = { ...settings, ...result.browserConnectorSettings }; + } +}).catch((error) => { + console.error("Error loading settings:", error); +}); + +// Listen for settings updates +browserAPI.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type === "SETTINGS_UPDATED") { + settings = message.settings; + + // If server settings changed and we have a WebSocket, reconnect + if ( + ws && + (message.settings.serverHost !== settings.serverHost || + message.settings.serverPort !== settings.serverPort) + ) { + console.log("Server settings changed, reconnecting WebSocket..."); + setupWebSocket(); + } + } + + // Handle connection status updates from page refreshes + if (message.type === "CONNECTION_STATUS_UPDATE") { + console.log( + `DevTools received connection status update: ${ + message.isConnected ? "Connected" : "Disconnected" + }` + ); + + // If connection is lost, try to reestablish WebSocket only if we had a previous connection + if (!message.isConnected && ws) { + console.log( + "Connection lost after page refresh, will attempt to reconnect WebSocket" + ); + + // Only reconnect if we actually have a WebSocket that might be stale + if ( + ws && + (ws.readyState === WebSocket.CLOSED || + ws.readyState === WebSocket.CLOSING) + ) { + console.log("WebSocket is already closed or closing, will reconnect"); + setupWebSocket(); + } + } + } + + // Handle auto-discovery requests after page refreshes + if (message.type === "INITIATE_AUTO_DISCOVERY") { + console.log( + `DevTools initiating WebSocket reconnect after page refresh (reason: ${message.reason})` + ); + + // For page refreshes with forceRestart, we should always reconnect if our current connection is not working + if ( + (message.reason === "page_refresh" || message.forceRestart === true) && + (!ws || ws.readyState !== WebSocket.OPEN) + ) { + console.log( + "Page refreshed and WebSocket not open - forcing reconnection" + ); + + // Close existing WebSocket if any + if (ws) { + console.log("Closing existing WebSocket due to page refresh"); + intentionalClosure = true; // Mark as intentional to prevent auto-reconnect + try { + ws.close(); + } catch (e) { + console.error("Error closing WebSocket:", e); + } + ws = null; + intentionalClosure = false; // Reset flag + } + + // Clear any pending reconnect timeouts + if (wsReconnectTimeout) { + clearTimeout(wsReconnectTimeout); + wsReconnectTimeout = null; + } + + // Try to reestablish the WebSocket connection + setupWebSocket(); + } + } +}); + +// Utility to recursively truncate strings in any data structure +function truncateStringsInData(data, maxLength, depth = 0, path = "") { + // Add depth limit to prevent circular references + if (depth > 100) { + console.warn("Max depth exceeded at path:", path); + return "[MAX_DEPTH_EXCEEDED]"; + } + + console.log(`Processing at path: ${path}, type:`, typeof data); + + if (typeof data === "string") { + if (data.length > maxLength) { + console.log( + `Truncating string at path ${path} from ${data.length} to ${maxLength}` + ); + return data.substring(0, maxLength) + "... (truncated)"; + } + return data; + } + + if (Array.isArray(data)) { + console.log(`Processing array at path ${path} with length:`, data.length); + return data.map((item, index) => + truncateStringsInData(item, maxLength, depth + 1, `${path}[${index}]`) + ); + } + + if (typeof data === "object" && data !== null) { + console.log( + `Processing object at path ${path} with keys:`, + Object.keys(data) + ); + const result = {}; + for (const [key, value] of Object.entries(data)) { + try { + result[key] = truncateStringsInData( + value, + maxLength, + depth + 1, + path ? `${path}.${key}` : key + ); + } catch (e) { + console.error(`Error processing key ${key} at path ${path}:`, e); + result[key] = "[ERROR_PROCESSING]"; + } + } + return result; + } + + return data; +} + +// Helper to calculate the size of an object +function calculateObjectSize(obj) { + return JSON.stringify(obj).length; +} + +// Helper to process array of objects with size limit +function processArrayWithSizeLimit(array, maxTotalSize, processFunc) { + let currentSize = 0; + const result = []; + + for (const item of array) { + // Process the item first + const processedItem = processFunc(item); + const itemSize = calculateObjectSize(processedItem); + + // Check if adding this item would exceed the limit + if (currentSize + itemSize > maxTotalSize) { + console.log( + `Reached size limit (${currentSize}/${maxTotalSize}), truncating array` + ); + break; + } + + // Add item and update size + result.push(processedItem); + currentSize += itemSize; + console.log( + `Added item of size ${itemSize}, total size now: ${currentSize}` + ); + } + + return result; +} + +// Modified processJsonString to handle arrays with size limit +function processJsonString(jsonString, maxLength) { + console.log("Processing string of length:", jsonString?.length); + try { + let parsed; + try { + parsed = JSON.parse(jsonString); + console.log( + "Successfully parsed as JSON, structure:", + JSON.stringify(Object.keys(parsed)) + ); + } catch (e) { + console.log("Not valid JSON, treating as string"); + return truncateStringsInData(jsonString, maxLength, 0, "root"); + } + + // If it's an array, process with size limit + if (Array.isArray(parsed)) { + console.log("Processing array of objects with size limit"); + const processed = processArrayWithSizeLimit( + parsed, + settings.maxLogSize, + (item) => truncateStringsInData(item, maxLength, 0, "root") + ); + const result = JSON.stringify(processed); + console.log( + `Processed array: ${parsed.length} -> ${processed.length} items` + ); + return result; + } + + // Otherwise process as before + const processed = truncateStringsInData(parsed, maxLength, 0, "root"); + const result = JSON.stringify(processed); + console.log("Processed JSON string length:", result.length); + return result; + } catch (e) { + console.error("Error in processJsonString:", e); + return jsonString.substring(0, maxLength) + "... (truncated)"; + } +} + +// Helper to send logs to browser-connector +async function sendToBrowserConnector(logData) { + if (!logData) { + console.error("No log data provided to sendToBrowserConnector"); + return; + } + + // First, ensure we're connecting to the right server + if (!(await validateServerIdentity())) { + console.error( + "Cannot send logs: Not connected to a valid browser tools server" + ); + return; + } + + console.log("Sending log data to browser connector:", { + type: logData.type, + timestamp: logData.timestamp, + }); + + // Process any string fields that might contain JSON + const processedData = { ...logData }; + + if (logData.type === "network-request") { + console.log("Processing network request"); + if (processedData.requestBody) { + console.log( + "Request body size before:", + processedData.requestBody.length + ); + processedData.requestBody = processJsonString( + processedData.requestBody, + settings.stringSizeLimit + ); + console.log("Request body size after:", processedData.requestBody.length); + } + if (processedData.responseBody) { + console.log( + "Response body size before:", + processedData.responseBody.length + ); + processedData.responseBody = processJsonString( + processedData.responseBody, + settings.stringSizeLimit + ); + console.log( + "Response body size after:", + processedData.responseBody.length + ); + } + } else if ( + logData.type === "console-log" || + logData.type === "console-error" + ) { + console.log("Processing console message"); + if (processedData.message) { + console.log("Message size before:", processedData.message.length); + processedData.message = processJsonString( + processedData.message, + settings.stringSizeLimit + ); + console.log("Message size after:", processedData.message.length); + } + } + + // Add settings to the request + const payload = { + data: { + ...processedData, + timestamp: Date.now(), + }, + settings: { + logLimit: settings.logLimit, + queryLimit: settings.queryLimit, + showRequestHeaders: settings.showRequestHeaders, + showResponseHeaders: settings.showResponseHeaders, + }, + }; + + const finalPayloadSize = JSON.stringify(payload).length; + console.log("Final payload size:", finalPayloadSize); + + if (finalPayloadSize > 1000000) { + console.warn("Warning: Large payload detected:", finalPayloadSize); + console.warn( + "Payload preview:", + JSON.stringify(payload).substring(0, 1000) + "..." + ); + } + + const serverUrl = `http://${settings.serverHost}:${settings.serverPort}/extension-log`; + console.log(`Sending log to ${serverUrl}`); + + fetch(serverUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP error ${response.status}`); + } + return response.json(); + }) + .then((data) => { + console.log("Log sent successfully:", data); + }) + .catch((error) => { + console.error("Error sending log:", error); + }); +} + +// Validate server identity +async function validateServerIdentity() { + try { + console.log( + `Validating server identity at ${settings.serverHost}:${settings.serverPort}...` + ); + + // Use fetch with a timeout to prevent long-hanging requests + const response = await fetch( + `http://${settings.serverHost}:${settings.serverPort}/.identity`, + { + signal: createTimeoutSignal(3000), // 3 second timeout + } + ); + + if (!response.ok) { + console.error( + `Server identity validation failed: HTTP ${response.status}` + ); + + // Notify about the connection failure + browserAPI.runtime.sendMessage({ + type: "SERVER_VALIDATION_FAILED", + reason: "http_error", + status: response.status, + serverHost: settings.serverHost, + serverPort: settings.serverPort, + }); + + return false; + } + + const identity = await response.json(); + + // Validate signature + if (identity.signature !== "mcp-browser-connector-24x7") { + console.error("Server identity validation failed: Invalid signature"); + + // Notify about the invalid signature + browserAPI.runtime.sendMessage({ + type: "SERVER_VALIDATION_FAILED", + reason: "invalid_signature", + serverHost: settings.serverHost, + serverPort: settings.serverPort, + }); + + return false; + } + + console.log( + `Server identity confirmed: ${identity.name} v${identity.version}` + ); + + // Notify about successful validation + browserAPI.runtime.sendMessage({ + type: "SERVER_VALIDATION_SUCCESS", + serverInfo: identity, + serverHost: settings.serverHost, + serverPort: settings.serverPort, + }); + + return true; + } catch (error) { + console.error("Server identity validation failed:", error); + + // Notify about the connection error + browserAPI.runtime.sendMessage({ + type: "SERVER_VALIDATION_FAILED", + reason: "connection_error", + error: error.message, + serverHost: settings.serverHost, + serverPort: settings.serverPort, + }); + + return false; + } +} + +// Function to clear logs on the server +function wipeLogs() { + console.log("Wiping all logs..."); + + const serverUrl = `http://${settings.serverHost}:${settings.serverPort}/wipelogs`; + console.log(`Sending wipe request to ${serverUrl}`); + + fetch(serverUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }) + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP error ${response.status}`); + } + return response.json(); + }) + .then((data) => { + console.log("Logs wiped successfully:", data); + }) + .catch((error) => { + console.error("Error wiping logs:", error); + }); +} + +// Listen for page refreshes +browserAPI.devtools.network.onNavigated.addListener((url) => { + console.log("Page navigated/refreshed - wiping logs"); + wipeLogs(); + + // Send the new URL to the server + if (ws && ws.readyState === WebSocket.OPEN && url) { + console.log( + "Chrome Extension: Sending page-navigated event with URL:", + url + ); + ws.send( + JSON.stringify({ + type: "page-navigated", + url: url, + tabId: browserAPI.devtools.inspectedWindow.tabId, + timestamp: Date.now(), + }) + ); + } +}); + +// 1) Listen for network requests +browserAPI.devtools.network.onRequestFinished.addListener((request) => { + if (request._resourceType === "xhr" || request._resourceType === "fetch") { + // Convert getContent to promise-based for cross-browser compatibility + const getContentPromise = new Promise((resolve) => { + try { + request.getContent(resolve); + } catch (error) { + // If callback version fails, try promise version + if (request.getContent && typeof request.getContent().then === 'function') { + request.getContent().then(resolve).catch(() => resolve("")); + } else { + resolve(""); + } + } + }); + + getContentPromise.then((responseBody) => { + const entry = { + type: "network-request", + url: request.request.url, + method: request.request.method, + status: request.response.status, + requestHeaders: request.request.headers, + responseHeaders: request.response.headers, + requestBody: request.request.postData?.text ?? "", + responseBody: responseBody ?? "", + }; + sendToBrowserConnector(entry); + }).catch((error) => { + console.error("Error getting network request content:", error); + // Send entry without response body if getContent fails + const entry = { + type: "network-request", + url: request.request.url, + method: request.request.method, + status: request.response.status, + requestHeaders: request.request.headers, + responseHeaders: request.response.headers, + requestBody: request.request.postData?.text ?? "", + responseBody: "", + }; + sendToBrowserConnector(entry); + }); + } +}); + +// Helper function to attach debugger +async function attachDebugger() { + console.log("Chrome Extension: Attempting to attach debugger to tab:", currentTabId); + + // Check if debugger API is available (Firefox may not have it) + if (!browserAPI.debugger) { + console.warn("Debugger API not available in this browser - using alternative console capture"); + isDebuggerAttached = false; + + // Try Firefox-specific console capture using content scripts + setupFirefoxConsoleCapture(); + return; + } + + // First check if we're already attached to this tab + browserAPI.debugger.getTargets((targets) => { + const isAlreadyAttached = targets.some( + (target) => target.tabId === currentTabId && target.attached + ); + + if (isAlreadyAttached) { + console.log("Found existing debugger attachment, detaching first..."); + // Force detach first to ensure clean state + browserAPI.debugger.detach({ tabId: currentTabId }, () => { + // Ignore any errors during detach + if (browserAPI.runtime.lastError) { + console.log("Error during forced detach:", browserAPI.runtime.lastError); + } + // Now proceed with fresh attachment + performAttach(); + }); + } else { + // No existing attachment, proceed directly + performAttach(); + } + }); +} + +function performAttach() { + console.log("Performing debugger attachment to tab:", currentTabId); + browserAPI.debugger.attach({ tabId: currentTabId }, "1.3", () => { + if (browserAPI.runtime.lastError) { + console.error("Failed to attach debugger:", browserAPI.runtime.lastError); + console.error("Error details:", browserAPI.runtime.lastError.message); + isDebuggerAttached = false; + return; + } + + isDebuggerAttached = true; + console.log("Debugger successfully attached to tab:", currentTabId); + + // Add the event listener when attaching + browserAPI.debugger.onEvent.addListener(consoleMessageListener); + + browserAPI.debugger.sendCommand( + { tabId: currentTabId }, + "Runtime.enable", + {}, + () => { + if (browserAPI.runtime.lastError) { + console.error("Failed to enable runtime:", browserAPI.runtime.lastError); + return; + } + console.log("Runtime API successfully enabled"); + } + ); + }); +} + +// Firefox-specific console capture using content script +function setupFirefoxConsoleCapture() { + console.log("Firefox detected - console capture via content script and background relay"); + + // The content script will send messages directly to the background script + // which will then relay them to the server. No need for complex DevTools communication. + isDebuggerAttached = true; // Mark as "attached" for Firefox + + console.log("Firefox console capture set up - messages will be relayed via background script"); +} + +// Helper function to detach debugger +function detachDebugger() { + // Check if debugger API is available + if (!browserAPI.debugger) { + console.log("Debugger API not available - nothing to detach"); + isDebuggerAttached = false; + return; + } + + // Remove the event listener first + browserAPI.debugger.onEvent.removeListener(consoleMessageListener); + + // Check if debugger is actually attached before trying to detach + browserAPI.debugger.getTargets((targets) => { + const isStillAttached = targets.some( + (target) => target.tabId === currentTabId && target.attached + ); + + if (!isStillAttached) { + console.log("Debugger already detached"); + isDebuggerAttached = false; + return; + } + + browserAPI.debugger.detach({ tabId: currentTabId }, () => { + if (browserAPI.runtime.lastError) { + console.warn( + "Warning during debugger detach:", + browserAPI.runtime.lastError + ); + } + isDebuggerAttached = false; + console.log("Debugger detached"); + }); + }); +} + +// Move the console message listener outside the panel creation +const consoleMessageListener = (source, method, params) => { + console.log("Console message listener called:", { source, method, params }); + + // Only process events for our tab + if (source.tabId !== currentTabId) { + console.log("Ignoring event for different tab:", source.tabId, "vs", currentTabId); + return; + } + + if (method === "Runtime.exceptionThrown") { + const entry = { + type: "console-error", + message: + params.exceptionDetails.exception?.description || + JSON.stringify(params.exceptionDetails), + level: "error", + timestamp: Date.now(), + }; + console.log("Sending runtime exception:", entry); + sendToBrowserConnector(entry); + } + + if (method === "Runtime.consoleAPICalled") { + // Process all arguments from the console call + let formattedMessage = ""; + const args = params.args || []; + + // Extract all arguments and combine them + if (args.length > 0) { + // Try to build a meaningful representation of all arguments + try { + formattedMessage = args + .map((arg) => { + // Handle different types of arguments + if (arg.type === "string") { + return arg.value; + } else if (arg.type === "object" && arg.preview) { + // For objects, include their preview or description + return JSON.stringify(arg.preview); + } else if (arg.description) { + // Some objects have descriptions + return arg.description; + } else { + // Fallback for other types + return arg.value || arg.description || JSON.stringify(arg); + } + }) + .join(" "); + } catch (e) { + // Fallback if processing fails + console.error("Failed to process console arguments:", e); + formattedMessage = + args[0]?.value || "Unable to process console arguments"; + } + } + + const entry = { + type: params.type === "error" ? "console-error" : "console-log", + level: params.type, + message: formattedMessage, + timestamp: Date.now(), + }; + console.log("Sending console entry:", entry); + sendToBrowserConnector(entry); + } +}; + +// 2) Use DevTools Protocol to capture console logs +browserAPI.devtools.panels.create("BrowserToolsMCP", "", "panel.html").then((panel) => { + // Initial attach - we'll keep the debugger attached as long as DevTools is open + attachDebugger(); + + // Handle panel showing + panel.onShown.addListener((panelWindow) => { + if (!isDebuggerAttached) { + attachDebugger(); + } + }); +}).catch((error) => { + console.error("Error creating DevTools panel:", error); +}); + +// Clean up when DevTools closes +window.addEventListener("unload", () => { + // Detach debugger + detachDebugger(); + + // Set intentional closure flag before closing + intentionalClosure = true; + + if (ws) { + try { + ws.close(); + } catch (e) { + console.error("Error closing WebSocket during unload:", e); + } + ws = null; + } + + if (wsReconnectTimeout) { + clearTimeout(wsReconnectTimeout); + wsReconnectTimeout = null; + } + + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } +}); + +// Function to capture and send element data +function captureAndSendElement() { + browserAPI.devtools.inspectedWindow.eval( + `(function() { + const el = $0; // $0 is the currently selected element in DevTools + if (!el) return null; + + const rect = el.getBoundingClientRect(); + + return { + tagName: el.tagName, + id: el.id, + className: el.className, + textContent: el.textContent?.substring(0, 100), + attributes: Array.from(el.attributes).map(attr => ({ + name: attr.name, + value: attr.value + })), + dimensions: { + width: rect.width, + height: rect.height, + top: rect.top, + left: rect.left + }, + innerHTML: el.innerHTML.substring(0, 500) + }; + })()`, + (result, isException) => { + if (isException || !result) return; + + console.log("Element selected:", result); + + // Send to browser connector + sendToBrowserConnector({ + type: "selected-element", + timestamp: Date.now(), + element: result, + }); + } + ); +} + +// Listen for element selection in the Elements panel +browserAPI.devtools.panels.elements.onSelectionChanged.addListener(() => { + captureAndSendElement(); +}); + +// WebSocket connection management variables are declared at the top of the file + +// Function to send a heartbeat to keep the WebSocket connection alive +function sendHeartbeat() { + if (ws && ws.readyState === WebSocket.OPEN) { + console.log("Chrome Extension: Sending WebSocket heartbeat"); + ws.send(JSON.stringify({ type: "heartbeat" })); + } +} + +async function setupWebSocket() { + // Clear any pending timeouts + if (wsReconnectTimeout) { + clearTimeout(wsReconnectTimeout); + wsReconnectTimeout = null; + } + + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + + // Close existing WebSocket if any + if (ws) { + // Set flag to indicate this is an intentional closure + intentionalClosure = true; + try { + ws.close(); + } catch (e) { + console.error("Error closing existing WebSocket:", e); + } + ws = null; + intentionalClosure = false; // Reset flag + } + + // Validate server identity before connecting + console.log("Validating server identity before WebSocket connection..."); + const isValid = await validateServerIdentity(); + + if (!isValid) { + console.error( + "Cannot establish WebSocket: Not connected to a valid browser tools server" + ); + // Set flag to indicate we need to reconnect after a page refresh check + reconnectAfterValidation = true; + + // Try again after delay + wsReconnectTimeout = setTimeout(() => { + console.log("Attempting to reconnect WebSocket after validation failure"); + setupWebSocket(); + }, WS_RECONNECT_DELAY); + return; + } + + // Reset reconnect flag since validation succeeded + reconnectAfterValidation = false; + + const wsUrl = `ws://${settings.serverHost}:${settings.serverPort}/extension-ws`; + console.log(`Connecting to WebSocket at ${wsUrl}`); + + try { + ws = new WebSocket(wsUrl); + + ws.onopen = () => { + console.log(`Chrome Extension: WebSocket connected to ${wsUrl}`); + + // Start heartbeat to keep connection alive + heartbeatInterval = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL); + + // Notify that connection is successful + browserAPI.runtime.sendMessage({ + type: "WEBSOCKET_CONNECTED", + serverHost: settings.serverHost, + serverPort: settings.serverPort, + }); + + // Send the current URL to the server right after connection + // This ensures the server has the URL even if no navigation occurs + browserAPI.runtime.sendMessage( + { + type: "GET_CURRENT_URL", + tabId: browserAPI.devtools.inspectedWindow.tabId, + }, + (response) => { + if (browserAPI.runtime.lastError) { + console.error( + "Chrome Extension: Error getting URL from background on connection:", + browserAPI.runtime.lastError + ); + + // If normal method fails, try fallback to browserAPI.tabs API directly + tryFallbackGetUrl(); + return; + } + + if (response && response.url) { + console.log( + "Chrome Extension: Sending initial URL to server:", + response.url + ); + + // Send the URL to the server via the background script + browserAPI.runtime.sendMessage({ + type: "UPDATE_SERVER_URL", + tabId: browserAPI.devtools.inspectedWindow.tabId, + url: response.url, + source: "initial_connection", + }); + } else { + // If response exists but no URL, try fallback + tryFallbackGetUrl(); + } + } + ); + + // Fallback method to get URL directly + function tryFallbackGetUrl() { + console.log("Chrome Extension: Trying fallback method to get URL"); + + // Try to get the URL directly using the tabs API + browserAPI.tabs.query({ active: true, currentWindow: true }, (tabs) => { + if (browserAPI.runtime.lastError) { + console.error( + "Chrome Extension: Fallback URL retrieval failed:", + browserAPI.runtime.lastError + ); + return; + } + + if (tabs && tabs.length > 0 && tabs[0].url) { + console.log( + "Chrome Extension: Got URL via fallback method:", + tabs[0].url + ); + + // Send the URL to the server + browserAPI.runtime.sendMessage({ + type: "UPDATE_SERVER_URL", + tabId: browserAPI.devtools.inspectedWindow.tabId, + url: tabs[0].url, + source: "fallback_method", + }); + } else { + console.warn( + "Chrome Extension: Could not retrieve URL through fallback method" + ); + } + }); + } + }; + + ws.onerror = (error) => { + console.error(`Chrome Extension: WebSocket error for ${wsUrl}:`, error); + }; + + ws.onclose = (event) => { + console.log(`Chrome Extension: WebSocket closed for ${wsUrl}:`, event); + + // Stop heartbeat + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + + // Don't reconnect if this was an intentional closure + if (intentionalClosure) { + console.log( + "Chrome Extension: Intentional WebSocket closure, not reconnecting" + ); + return; + } + + // Only attempt to reconnect if the closure wasn't intentional + // Code 1000 (Normal Closure) and 1001 (Going Away) are normal closures + // Code 1005 often happens with clean closures in Chrome + const isAbnormalClosure = !(event.code === 1000 || event.code === 1001); + + // Check if this was an abnormal closure or if we need to reconnect after validation + if (isAbnormalClosure || reconnectAfterValidation) { + console.log( + `Chrome Extension: Will attempt to reconnect WebSocket (closure code: ${event.code})` + ); + + // Try to reconnect after delay + wsReconnectTimeout = setTimeout(() => { + console.log( + `Chrome Extension: Attempting to reconnect WebSocket to ${wsUrl}` + ); + setupWebSocket(); + }, WS_RECONNECT_DELAY); + } else { + console.log( + `Chrome Extension: Normal WebSocket closure, not reconnecting automatically` + ); + } + }; + + ws.onmessage = async (event) => { + try { + const message = JSON.parse(event.data); + + // Don't log heartbeat responses to reduce noise + if (message.type !== "heartbeat-response") { + console.log("Chrome Extension: Received WebSocket message:", message); + + if (message.type === "server-shutdown") { + console.log("Chrome Extension: Received server shutdown signal"); + // Clear any reconnection attempts + if (wsReconnectTimeout) { + clearTimeout(wsReconnectTimeout); + wsReconnectTimeout = null; + } + // Close the connection gracefully + ws.close(1000, "Server shutting down"); + return; + } + } + + if (message.type === "heartbeat-response") { + // Just a heartbeat response, no action needed + // Uncomment the next line for debug purposes only + // console.log("Chrome Extension: Received heartbeat response"); + } else if (message.type === "take-screenshot") { + console.log("Chrome Extension: Taking screenshot..."); + + // Check if tabs API is available (Chrome DevTools context) + let hasTabs = false; + let hasCaptureVisibleTab = false; + + try { + hasTabs = !!browserAPI.tabs; + hasCaptureVisibleTab = !!(browserAPI.tabs && browserAPI.tabs.captureVisibleTab); + } catch (e) { + console.log("Chrome Extension: Error checking tabs API:", e.message); + } + + console.log("Chrome Extension: Checking tabs API availability:", { + hasTabs, + hasCaptureVisibleTab + }); + + if (hasCaptureVisibleTab) { + // Chrome: Use tabs API directly in DevTools context + try { + browserAPI.tabs.captureVisibleTab(null, { format: "png" }, (dataUrl) => { + if (browserAPI.runtime.lastError) { + console.error( + "Chrome Extension: Screenshot capture failed:", + browserAPI.runtime.lastError + ); + ws.send( + JSON.stringify({ + type: "screenshot-error", + error: browserAPI.runtime.lastError.message, + requestId: message.requestId, + }) + ); + return; + } + + console.log("Chrome Extension: Screenshot captured successfully"); + // Just send the screenshot data, let the server handle paths + const response = { + type: "screenshot-data", + data: dataUrl, + requestId: message.requestId, + // Only include path if it's configured in settings + ...(settings.screenshotPath && { path: settings.screenshotPath }), + // Include auto-paste setting + autoPaste: settings.allowAutoPaste, + }; + + console.log("Chrome Extension: Sending screenshot data response", { + ...response, + data: "[base64 data]", + }); + + ws.send(JSON.stringify(response)); + }); + } catch (error) { + console.error("Chrome Extension: Screenshot capture error:", error); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: "screenshot-error", + error: error.message, + requestId: message.requestId, + })); + } + } + } else { + // Firefox: Relay screenshot request to background script + console.log("Chrome Extension: Relaying screenshot request to background script"); + browserAPI.runtime.sendMessage({ + type: "CAPTURE_SCREENSHOT", + tabId: currentTabId, + screenshotPath: settings.screenshotPath, + requestId: message.requestId, + }).then((response) => { + if (response && response.success) { + const wsResponse = { + type: "screenshot-data", + data: response.dataUrl, + requestId: message.requestId, + ...(settings.screenshotPath && { path: settings.screenshotPath }), + autoPaste: settings.allowAutoPaste, + }; + ws.send(JSON.stringify(wsResponse)); + } else { + ws.send(JSON.stringify({ + type: "screenshot-error", + error: response ? response.error : "Failed to capture screenshot", + requestId: message.requestId, + })); + } + }).catch((error) => { + console.error("Chrome Extension: Error relaying screenshot request:", error); + ws.send(JSON.stringify({ + type: "screenshot-error", + error: error.message, + requestId: message.requestId, + })); + }); + } + } else if (message.type === "get-current-url") { + console.log("Chrome Extension: Received request for current URL"); + + // Get the current URL from the background script instead of inspectedWindow.eval + let retryCount = 0; + const maxRetries = 2; + + const requestCurrentUrl = () => { + browserAPI.runtime.sendMessage( + { + type: "GET_CURRENT_URL", + tabId: browserAPI.devtools.inspectedWindow.tabId, + }, + (response) => { + if (browserAPI.runtime.lastError) { + console.error( + "Chrome Extension: Error getting URL from background:", + browserAPI.runtime.lastError + ); + + // Retry logic + if (retryCount < maxRetries) { + retryCount++; + console.log( + `Retrying URL request (${retryCount}/${maxRetries})...` + ); + setTimeout(requestCurrentUrl, 500); // Wait 500ms before retrying + return; + } + + ws.send( + JSON.stringify({ + type: "current-url-response", + url: null, + tabId: browserAPI.devtools.inspectedWindow.tabId, + error: + "Failed to get URL from background: " + + browserAPI.runtime.lastError.message, + requestId: message.requestId, + }) + ); + return; + } + + if (response && response.success && response.url) { + console.log( + "Chrome Extension: Got URL from background:", + response.url + ); + ws.send( + JSON.stringify({ + type: "current-url-response", + url: response.url, + tabId: browserAPI.devtools.inspectedWindow.tabId, + requestId: message.requestId, + }) + ); + } else { + console.error( + "Chrome Extension: Invalid URL response from background:", + response + ); + + // Last resort - try to get URL directly from the tab + browserAPI.tabs.query( + { active: true, currentWindow: true }, + (tabs) => { + const url = tabs && tabs[0] && tabs[0].url; + console.log( + "Chrome Extension: Got URL directly from tab:", + url + ); + + ws.send( + JSON.stringify({ + type: "current-url-response", + url: url || null, + tabId: browserAPI.devtools.inspectedWindow.tabId, + error: + response?.error || + "Failed to get URL from background", + requestId: message.requestId, + }) + ); + } + ); + } + } + ); + }; + + requestCurrentUrl(); + } + } catch (error) { + console.error( + "Chrome Extension: Error processing WebSocket message:", + error + ); + } + }; + } catch (error) { + console.error("Error creating WebSocket:", error); + // Try again after delay + wsReconnectTimeout = setTimeout(setupWebSocket, WS_RECONNECT_DELAY); + } +} + +// Initialize WebSocket connection when DevTools opens +setupWebSocket(); + +// Clean up WebSocket when DevTools closes +window.addEventListener("unload", () => { + if (ws) { + ws.close(); + } + if (wsReconnectTimeout) { + clearTimeout(wsReconnectTimeout); + } +}); diff --git a/firefox-extension/manifest.json b/firefox-extension/manifest.json new file mode 100644 index 0000000..48fd21e --- /dev/null +++ b/firefox-extension/manifest.json @@ -0,0 +1,32 @@ +{ + "name": "BrowserTools MCP", + "version": "1.2.0", + "description": "MCP tool for AI code editors to capture data from a browser such as console logs, network requests, screenshots and more", + "manifest_version": 2, + "devtools_page": "devtools.html", + "permissions": [ + "activeTab", + "storage", + "tabs", + "", + "debugger" + ], + "content_scripts": [ + { + "matches": [""], + "js": ["console-capture.js"], + "run_at": "document_start", + "all_frames": true + } + ], + "background": { + "scripts": ["webextension-polyfill.js", "background.js"], + "persistent": true + }, + "browser_specific_settings": { + "gecko": { + "id": "browsertools-mcp@example.com", + "strict_min_version": "109.0" + } + } +} diff --git a/firefox-extension/panel.html b/firefox-extension/panel.html new file mode 100644 index 0000000..9ffbe53 --- /dev/null +++ b/firefox-extension/panel.html @@ -0,0 +1,218 @@ + + + + + + +
+

Quick Actions

+
+ + +
+
+ +
+
+ +
+

Screenshot Settings

+
+ + +
+
+ +
+

Server Connection Settings

+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+

Advanced Settings

+ + + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+
+
+ + + + + + \ No newline at end of file diff --git a/firefox-extension/panel.js b/firefox-extension/panel.js new file mode 100644 index 0000000..ac8da84 --- /dev/null +++ b/firefox-extension/panel.js @@ -0,0 +1,1024 @@ +// Cross-browser compatibility +const browserAPI = typeof browser !== 'undefined' ? browser : chrome; + +// Cross-browser AbortSignal.timeout polyfill +function createTimeoutSignal(timeout) { + if (typeof AbortSignal !== 'undefined' && AbortSignal.timeout) { + return AbortSignal.timeout(timeout); + } + + // Fallback for older browsers + const controller = new AbortController(); + setTimeout(() => controller.abort(), timeout); + return controller.signal; +} + +// Cross-browser AbortSignal.any polyfill +function createCombinedSignal(signals) { + if (typeof AbortSignal !== 'undefined' && AbortSignal.any) { + return AbortSignal.any(signals); + } + + // Fallback for older browsers + const controller = new AbortController(); + + signals.forEach(signal => { + if (signal.aborted) { + controller.abort(); + return; + } + signal.addEventListener('abort', () => controller.abort()); + }); + + return controller.signal; +} + +// Store settings +let settings = { + logLimit: 50, + queryLimit: 30000, + stringSizeLimit: 500, + showRequestHeaders: false, + showResponseHeaders: false, + maxLogSize: 20000, + screenshotPath: "", + // Add server connection settings + serverHost: "localhost", + serverPort: 3025, + allowAutoPaste: false, // Default auto-paste setting +}; + +// Track connection status +let serverConnected = false; +let reconnectAttemptTimeout = null; +// Add a flag to track ongoing discovery operations +let isDiscoveryInProgress = false; +// Add an AbortController to cancel fetch operations +let discoveryController = null; + +// Load saved settings on startup +browserAPI.storage.local.get(["browserConnectorSettings"]).then((result) => { + if (result.browserConnectorSettings) { + settings = { ...settings, ...result.browserConnectorSettings }; + updateUIFromSettings(); + } + + // Create connection status banner at the top + createConnectionBanner(); + + // Automatically discover server on panel load with quiet mode enabled + discoverServer(true); +}).catch((error) => { + console.error("Error loading settings:", error); + + // Still create UI even if settings fail to load + createConnectionBanner(); + discoverServer(true); +}); + +// Add listener for connection status updates from background script (page refresh events) +browserAPI.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type === "CONNECTION_STATUS_UPDATE") { + console.log( + `Received connection status update: ${ + message.isConnected ? "Connected" : "Disconnected" + }` + ); + + // Update UI based on connection status + if (message.isConnected) { + // If already connected, just maintain the current state + if (!serverConnected) { + // Connection was re-established, update UI + serverConnected = true; + updateConnectionBanner(true, { + name: "Browser Tools Server", + version: "reconnected", + host: settings.serverHost, + port: settings.serverPort, + }); + } + } else { + // Connection lost, update UI to show disconnected + serverConnected = false; + updateConnectionBanner(false, null); + } + } + + if (message.type === "INITIATE_AUTO_DISCOVERY") { + console.log( + `Initiating auto-discovery after page refresh (reason: ${message.reason})` + ); + + // For page refreshes or if forceRestart is set to true, always cancel any ongoing discovery and restart + if (message.reason === "page_refresh" || message.forceRestart === true) { + // Cancel any ongoing discovery operation + cancelOngoingDiscovery(); + + // Update UI to indicate we're starting a fresh scan + if (connectionStatusDiv) { + connectionStatusDiv.style.display = "block"; + if (statusIcon) statusIcon.className = "status-indicator"; + if (statusText) + statusText.textContent = + "Page refreshed. Restarting server discovery..."; + } + + // Always update the connection banner when a page refresh occurs + updateConnectionBanner(false, null); + + // Start a new discovery process with quiet mode + console.log("Starting fresh discovery after page refresh"); + discoverServer(true); + } + // For other types of auto-discovery requests, only start if not already in progress + else if (!isDiscoveryInProgress) { + // Use quiet mode for auto-discovery to minimize UI changes + discoverServer(true); + } + } + + // Handle successful server validation + if (message.type === "SERVER_VALIDATION_SUCCESS") { + console.log( + `Server validation successful: ${message.serverHost}:${message.serverPort}` + ); + + // Update the connection status banner + serverConnected = true; + updateConnectionBanner(true, message.serverInfo); + + // If we were showing the connection status dialog, we can hide it now + if (connectionStatusDiv && connectionStatusDiv.style.display === "block") { + connectionStatusDiv.style.display = "none"; + } + } + + // Handle failed server validation + if (message.type === "SERVER_VALIDATION_FAILED") { + console.log( + `Server validation failed: ${message.reason} - ${message.serverHost}:${message.serverPort}` + ); + + // Update the connection status + serverConnected = false; + updateConnectionBanner(false, null); + + // Start auto-discovery if this was a page refresh validation + if ( + message.reason === "connection_error" || + message.reason === "http_error" + ) { + // If we're not already trying to discover the server, start the process + if (!isDiscoveryInProgress) { + console.log("Starting auto-discovery after validation failure"); + discoverServer(true); + } + } + } + + // Handle successful WebSocket connection + if (message.type === "WEBSOCKET_CONNECTED") { + console.log( + `WebSocket connected to ${message.serverHost}:${message.serverPort}` + ); + + // Update connection status if it wasn't already connected + if (!serverConnected) { + serverConnected = true; + updateConnectionBanner(true, { + name: "Browser Tools Server", + version: "connected via WebSocket", + host: message.serverHost, + port: message.serverPort, + }); + } + } +}); + +// Create connection status banner +function createConnectionBanner() { + // Check if banner already exists + if (document.getElementById("connection-banner")) { + return; + } + + // Create the banner + const banner = document.createElement("div"); + banner.id = "connection-banner"; + banner.style.cssText = ` + padding: 6px 0px; + margin-bottom: 4px; + width: 40%; + display: flex; + flex-direction: column; + align-items: flex-start; + background-color:rgba(0,0,0,0); + border-radius: 11px; + font-size: 11px; + font-weight: 500; + color: #ffffff; + `; + + // Create reconnect button (now placed at the top) + const reconnectButton = document.createElement("button"); + reconnectButton.id = "banner-reconnect-btn"; + reconnectButton.textContent = "Reconnect"; + reconnectButton.style.cssText = ` + background-color: #333333; + color: #ffffff; + border: 1px solid #444444; + border-radius: 3px; + padding: 2px 8px; + font-size: 10px; + cursor: pointer; + margin-bottom: 6px; + align-self: flex-start; + display: none; + transition: background-color 0.2s; + `; + reconnectButton.addEventListener("mouseover", () => { + reconnectButton.style.backgroundColor = "#444444"; + }); + reconnectButton.addEventListener("mouseout", () => { + reconnectButton.style.backgroundColor = "#333333"; + }); + reconnectButton.addEventListener("click", () => { + // Hide the button while reconnecting + reconnectButton.style.display = "none"; + reconnectButton.textContent = "Reconnecting..."; + + // Update UI to show searching state + updateConnectionBanner(false, null); + + // Try to discover server + discoverServer(false); + }); + + // Create a container for the status indicator and text + const statusContainer = document.createElement("div"); + statusContainer.style.cssText = ` + display: flex; + align-items: center; + width: 100%; + `; + + // Create status indicator + const indicator = document.createElement("div"); + indicator.id = "banner-status-indicator"; + indicator.style.cssText = ` + width: 6px; + height: 6px; + position: relative; + top: 1px; + border-radius: 50%; + background-color: #ccc; + margin-right: 8px; + flex-shrink: 0; + transition: background-color 0.3s ease; + `; + + // Create status text + const statusText = document.createElement("div"); + statusText.id = "banner-status-text"; + statusText.textContent = "Searching for server..."; + statusText.style.cssText = + "flex-grow: 1; font-weight: 400; letter-spacing: 0.1px; font-size: 11px;"; + + // Add elements to statusContainer + statusContainer.appendChild(indicator); + statusContainer.appendChild(statusText); + + // Add elements to banner - reconnect button first, then status container + banner.appendChild(reconnectButton); + banner.appendChild(statusContainer); + + // Add banner to the beginning of the document body + // This ensures it's the very first element + document.body.prepend(banner); + + // Set initial state + updateConnectionBanner(false, null); +} + +// Update the connection banner with current status +function updateConnectionBanner(connected, serverInfo) { + const indicator = document.getElementById("banner-status-indicator"); + const statusText = document.getElementById("banner-status-text"); + const banner = document.getElementById("connection-banner"); + const reconnectButton = document.getElementById("banner-reconnect-btn"); + + if (!indicator || !statusText || !banner || !reconnectButton) return; + + if (connected && serverInfo) { + // Connected state with server info + indicator.style.backgroundColor = "#4CAF50"; // Green indicator + statusText.style.color = "#ffffff"; // White text for contrast on black + statusText.textContent = `Connected to ${serverInfo.name} v${serverInfo.version} at ${settings.serverHost}:${settings.serverPort}`; + + // Hide reconnect button when connected + reconnectButton.style.display = "none"; + } else if (connected) { + // Connected without server info + indicator.style.backgroundColor = "#4CAF50"; // Green indicator + statusText.style.color = "#ffffff"; // White text for contrast on black + statusText.textContent = `Connected to server at ${settings.serverHost}:${settings.serverPort}`; + + // Hide reconnect button when connected + reconnectButton.style.display = "none"; + } else { + // Disconnected state + indicator.style.backgroundColor = "#F44336"; // Red indicator + statusText.style.color = "#ffffff"; // White text for contrast on black + + // Only show "searching" message if discovery is in progress + if (isDiscoveryInProgress) { + statusText.textContent = "Not connected to server. Searching..."; + // Hide reconnect button while actively searching + reconnectButton.style.display = "none"; + } else { + statusText.textContent = "Not connected to server."; + // Show reconnect button above status message when disconnected and not searching + reconnectButton.style.display = "block"; + reconnectButton.textContent = "Reconnect"; + } + } +} + +// Initialize UI elements +const logLimitInput = document.getElementById("log-limit"); +const queryLimitInput = document.getElementById("query-limit"); +const stringSizeLimitInput = document.getElementById("string-size-limit"); +const showRequestHeadersCheckbox = document.getElementById( + "show-request-headers" +); +const showResponseHeadersCheckbox = document.getElementById( + "show-response-headers" +); +const maxLogSizeInput = document.getElementById("max-log-size"); +const screenshotPathInput = document.getElementById("screenshot-path"); +const captureScreenshotButton = document.getElementById("capture-screenshot"); + +// Server connection UI elements +const serverHostInput = document.getElementById("server-host"); +const serverPortInput = document.getElementById("server-port"); +const discoverServerButton = document.getElementById("discover-server"); +const testConnectionButton = document.getElementById("test-connection"); +const connectionStatusDiv = document.getElementById("connection-status"); +const statusIcon = document.getElementById("status-icon"); +const statusText = document.getElementById("status-text"); + +// Initialize collapsible advanced settings +const advancedSettingsHeader = document.getElementById( + "advanced-settings-header" +); +const advancedSettingsContent = document.getElementById( + "advanced-settings-content" +); +const chevronIcon = advancedSettingsHeader.querySelector(".chevron"); + +advancedSettingsHeader.addEventListener("click", () => { + advancedSettingsContent.classList.toggle("visible"); + chevronIcon.classList.toggle("open"); +}); + +// Get all inputs by ID +const allowAutoPasteCheckbox = document.getElementById("allow-auto-paste"); + +// Update UI from settings +function updateUIFromSettings() { + logLimitInput.value = settings.logLimit; + queryLimitInput.value = settings.queryLimit; + stringSizeLimitInput.value = settings.stringSizeLimit; + showRequestHeadersCheckbox.checked = settings.showRequestHeaders; + showResponseHeadersCheckbox.checked = settings.showResponseHeaders; + maxLogSizeInput.value = settings.maxLogSize; + screenshotPathInput.value = settings.screenshotPath; + serverHostInput.value = settings.serverHost; + serverPortInput.value = settings.serverPort; + allowAutoPasteCheckbox.checked = settings.allowAutoPaste; +} + +// Save settings +function saveSettings() { + browserAPI.storage.local.set({ browserConnectorSettings: settings }).catch((error) => { + console.error("Error saving settings:", error); + }); + // Notify devtools.js about settings change + browserAPI.runtime.sendMessage({ + type: "SETTINGS_UPDATED", + settings, + }); +} + +// Add event listeners for all inputs +logLimitInput.addEventListener("change", (e) => { + settings.logLimit = parseInt(e.target.value, 10); + saveSettings(); +}); + +queryLimitInput.addEventListener("change", (e) => { + settings.queryLimit = parseInt(e.target.value, 10); + saveSettings(); +}); + +stringSizeLimitInput.addEventListener("change", (e) => { + settings.stringSizeLimit = parseInt(e.target.value, 10); + saveSettings(); +}); + +showRequestHeadersCheckbox.addEventListener("change", (e) => { + settings.showRequestHeaders = e.target.checked; + saveSettings(); +}); + +showResponseHeadersCheckbox.addEventListener("change", (e) => { + settings.showResponseHeaders = e.target.checked; + saveSettings(); +}); + +maxLogSizeInput.addEventListener("change", (e) => { + settings.maxLogSize = parseInt(e.target.value, 10); + saveSettings(); +}); + +screenshotPathInput.addEventListener("change", (e) => { + settings.screenshotPath = e.target.value; + saveSettings(); +}); + +// Add event listeners for server settings +serverHostInput.addEventListener("change", (e) => { + settings.serverHost = e.target.value; + saveSettings(); + // Automatically test connection when host is changed + testConnection(settings.serverHost, settings.serverPort); +}); + +serverPortInput.addEventListener("change", (e) => { + settings.serverPort = parseInt(e.target.value, 10); + saveSettings(); + // Automatically test connection when port is changed + testConnection(settings.serverHost, settings.serverPort); +}); + +// Add event listener for auto-paste checkbox +allowAutoPasteCheckbox.addEventListener("change", (e) => { + settings.allowAutoPaste = e.target.checked; + saveSettings(); +}); + +// Function to cancel any ongoing discovery operations +function cancelOngoingDiscovery() { + if (isDiscoveryInProgress) { + console.log("Cancelling ongoing discovery operation"); + + // Abort any fetch requests in progress + if (discoveryController) { + try { + discoveryController.abort(); + } catch (error) { + console.error("Error aborting discovery controller:", error); + } + discoveryController = null; + } + + // Reset the discovery status + isDiscoveryInProgress = false; + + // Update UI to indicate the operation was cancelled + if ( + statusText && + connectionStatusDiv && + connectionStatusDiv.style.display === "block" + ) { + statusText.textContent = "Server discovery operation cancelled"; + } + + // Clear any pending network timeouts that might be part of the discovery process + clearTimeout(reconnectAttemptTimeout); + reconnectAttemptTimeout = null; + + console.log("Discovery operation cancelled successfully"); + } +} + +// Test server connection +testConnectionButton.addEventListener("click", async () => { + // Cancel any ongoing discovery operations before testing + cancelOngoingDiscovery(); + await testConnection(settings.serverHost, settings.serverPort); +}); + +// Function to test server connection +async function testConnection(host, port) { + // Cancel any ongoing discovery operations + cancelOngoingDiscovery(); + + connectionStatusDiv.style.display = "block"; + statusIcon.className = "status-indicator"; + statusText.textContent = "Testing connection..."; + + try { + // Use the identity endpoint instead of .port for more reliable validation + const response = await fetch(`http://${host}:${port}/.identity`, { + signal: createTimeoutSignal(5000), // 5 second timeout + }); + + if (response.ok) { + const identity = await response.json(); + + // Verify this is actually our server by checking the signature + if (identity.signature !== "mcp-browser-connector-24x7") { + statusIcon.className = "status-indicator status-disconnected"; + statusText.textContent = `Connection failed: Found a server at ${host}:${port} but it's not the Browser Tools server`; + serverConnected = false; + updateConnectionBanner(false, null); + scheduleReconnectAttempt(); + return false; + } + + statusIcon.className = "status-indicator status-connected"; + statusText.textContent = `Connected successfully to ${identity.name} v${identity.version} at ${host}:${port}`; + serverConnected = true; + updateConnectionBanner(true, identity); + + // Clear any scheduled reconnect attempts + if (reconnectAttemptTimeout) { + clearTimeout(reconnectAttemptTimeout); + reconnectAttemptTimeout = null; + } + + // Update settings if different port was discovered + if (parseInt(identity.port, 10) !== port) { + console.log(`Detected different port: ${identity.port}`); + settings.serverPort = parseInt(identity.port, 10); + serverPortInput.value = settings.serverPort; + saveSettings(); + } + + return true; + } else { + statusIcon.className = "status-indicator status-disconnected"; + statusText.textContent = `Connection failed: Server returned ${response.status}`; + serverConnected = false; + + // Make sure isDiscoveryInProgress is false so the reconnect button will show + isDiscoveryInProgress = false; + + // Now update the connection banner to show the reconnect button + updateConnectionBanner(false, null); + scheduleReconnectAttempt(); + return false; + } + } catch (error) { + statusIcon.className = "status-indicator status-disconnected"; + statusText.textContent = `Connection failed: ${error.message}`; + serverConnected = false; + + // Make sure isDiscoveryInProgress is false so the reconnect button will show + isDiscoveryInProgress = false; + + // Now update the connection banner to show the reconnect button + updateConnectionBanner(false, null); + scheduleReconnectAttempt(); + return false; + } +} + +// Schedule a reconnect attempt if server isn't found +function scheduleReconnectAttempt() { + // Clear any existing reconnect timeout + if (reconnectAttemptTimeout) { + clearTimeout(reconnectAttemptTimeout); + } + + // Schedule a reconnect attempt in 30 seconds + reconnectAttemptTimeout = setTimeout(() => { + console.log("Attempting to reconnect to server..."); + // Only show minimal UI during auto-reconnect + discoverServer(true); + }, 30000); // 30 seconds +} + +// Helper function to try connecting to a server +async function tryServerConnection(host, port) { + try { + // Check if the discovery process was cancelled + if (!isDiscoveryInProgress) { + return false; + } + + // Create a local timeout that won't abort the entire discovery process + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, 500); // 500ms timeout for each connection attempt + + try { + // Use identity endpoint for validation + const response = await fetch(`http://${host}:${port}/.identity`, { + // Use a local controller for this specific request timeout + // but also respect the global discovery cancellation + signal: discoveryController + ? createCombinedSignal([controller.signal, discoveryController.signal]) + : controller.signal, + }); + + clearTimeout(timeoutId); + + // Check again if discovery was cancelled during the fetch + if (!isDiscoveryInProgress) { + return false; + } + + if (response.ok) { + const identity = await response.json(); + + // Verify this is actually our server by checking the signature + if (identity.signature !== "mcp-browser-connector-24x7") { + console.log( + `Found a server at ${host}:${port} but it's not the Browser Tools server` + ); + return false; + } + + console.log(`Successfully found server at ${host}:${port}`); + + // Update settings with discovered server + settings.serverHost = host; + settings.serverPort = parseInt(identity.port, 10); + serverHostInput.value = settings.serverHost; + serverPortInput.value = settings.serverPort; + saveSettings(); + + statusIcon.className = "status-indicator status-connected"; + statusText.textContent = `Discovered ${identity.name} v${identity.version} at ${host}:${identity.port}`; + + // Update connection banner with server info + updateConnectionBanner(true, identity); + + // Update connection status + serverConnected = true; + + // Clear any scheduled reconnect attempts + if (reconnectAttemptTimeout) { + clearTimeout(reconnectAttemptTimeout); + reconnectAttemptTimeout = null; + } + + // End the discovery process + isDiscoveryInProgress = false; + + // Successfully found server + return true; + } + + return false; + } finally { + clearTimeout(timeoutId); + } + } catch (error) { + // Ignore connection errors during discovery + // But check if it was an abort (cancellation) + if (error.name === "AbortError") { + // Check if this was due to the global discovery cancellation + if (discoveryController && discoveryController.signal.aborted) { + console.log("Connection attempt aborted by global cancellation"); + return "aborted"; + } + // Otherwise it was just a timeout for this specific connection attempt + return false; + } + console.log(`Connection error for ${host}:${port}: ${error.message}`); + return false; + } +} + +// Server discovery function (extracted to be reusable) +async function discoverServer(quietMode = false) { + // Cancel any ongoing discovery operations before starting a new one + cancelOngoingDiscovery(); + + // Create a new AbortController for this discovery process + discoveryController = new AbortController(); + isDiscoveryInProgress = true; + + // In quiet mode, we don't show the connection status until we either succeed or fail completely + if (!quietMode) { + connectionStatusDiv.style.display = "block"; + statusIcon.className = "status-indicator"; + statusText.textContent = "Discovering server..."; + } + + // Always update the connection banner + updateConnectionBanner(false, null); + + try { + console.log("Starting server discovery process"); + + // Add an early cancellation listener that will respond to page navigation/refresh + discoveryController.signal.addEventListener("abort", () => { + console.log("Discovery aborted via AbortController signal"); + isDiscoveryInProgress = false; + }); + + // Common IPs to try (in order of likelihood) + const hosts = ["localhost", "127.0.0.1"]; + + // Add the current configured host if it's not already in the list + if ( + !hosts.includes(settings.serverHost) && + settings.serverHost !== "0.0.0.0" + ) { + hosts.unshift(settings.serverHost); // Put at the beginning for priority + } + + // Add common local network IPs + const commonLocalIps = ["192.168.0.", "192.168.1.", "10.0.0.", "10.0.1."]; + for (const prefix of commonLocalIps) { + for (let i = 1; i <= 5; i++) { + // Reduced from 10 to 5 for efficiency + hosts.push(`${prefix}${i}`); + } + } + + // Build port list in a smart order: + // 1. Start with current configured port + // 2. Add default port (3025) + // 3. Add sequential ports around the default (for fallback detection) + const ports = []; + + // Current configured port gets highest priority + const configuredPort = parseInt(settings.serverPort, 10); + ports.push(configuredPort); + + // Add default port if it's not the same as configured + if (configuredPort !== 3025) { + ports.push(3025); + } + + // Add sequential fallback ports (from default up to default+10) + for (let p = 3026; p <= 3035; p++) { + if (p !== configuredPort) { + // Avoid duplicates + ports.push(p); + } + } + + // Remove duplicates + const uniquePorts = [...new Set(ports)]; + console.log("Will check ports:", uniquePorts); + + // Create a progress indicator + let progress = 0; + let totalChecked = 0; + + // Phase 1: Try the most likely combinations first (current host:port and localhost variants) + console.log("Starting Phase 1: Quick check of high-priority hosts/ports"); + const priorityHosts = hosts.slice(0, 2); // First two hosts are highest priority + for (const host of priorityHosts) { + // Check if discovery was cancelled + if (!isDiscoveryInProgress) { + console.log("Discovery process was cancelled during Phase 1"); + return false; + } + + // Try configured port first + totalChecked++; + if (!quietMode) { + statusText.textContent = `Checking ${host}:${uniquePorts[0]}...`; + } + console.log(`Checking ${host}:${uniquePorts[0]}...`); + const result = await tryServerConnection(host, uniquePorts[0]); + + // Check for cancellation or success + if (result === "aborted" || !isDiscoveryInProgress) { + console.log("Discovery process was cancelled"); + return false; + } else if (result === true) { + console.log("Server found in priority check"); + if (quietMode) { + // In quiet mode, only show the connection banner but hide the status box + connectionStatusDiv.style.display = "none"; + } + return true; // Successfully found server + } + + // Then try default port if different + if (uniquePorts.length > 1) { + // Check if discovery was cancelled + if (!isDiscoveryInProgress) { + console.log("Discovery process was cancelled"); + return false; + } + + totalChecked++; + if (!quietMode) { + statusText.textContent = `Checking ${host}:${uniquePorts[1]}...`; + } + console.log(`Checking ${host}:${uniquePorts[1]}...`); + const result = await tryServerConnection(host, uniquePorts[1]); + + // Check for cancellation or success + if (result === "aborted" || !isDiscoveryInProgress) { + console.log("Discovery process was cancelled"); + return false; + } else if (result === true) { + console.log("Server found in priority check"); + if (quietMode) { + // In quiet mode, only show the connection banner but hide the status box + connectionStatusDiv.style.display = "none"; + } + return true; // Successfully found server + } + } + } + + // If we're in quiet mode and the quick checks failed, show the status now + // as we move into more intensive scanning + if (quietMode) { + connectionStatusDiv.style.display = "block"; + statusIcon.className = "status-indicator"; + statusText.textContent = "Searching for server..."; + } + + // Phase 2: Systematic scan of all combinations + const totalAttempts = hosts.length * uniquePorts.length; + console.log( + `Starting Phase 2: Full scan (${totalAttempts} total combinations)` + ); + statusText.textContent = `Quick check failed. Starting full scan (${totalChecked}/${totalAttempts})...`; + + // First, scan through all ports on localhost/127.0.0.1 to find fallback ports quickly + const localHosts = ["localhost", "127.0.0.1"]; + for (const host of localHosts) { + // Skip the first two ports on localhost if we already checked them in Phase 1 + const portsToCheck = uniquePorts.slice( + localHosts.includes(host) && priorityHosts.includes(host) ? 2 : 0 + ); + + for (const port of portsToCheck) { + // Check if discovery was cancelled + if (!isDiscoveryInProgress) { + console.log("Discovery process was cancelled during local port scan"); + return false; + } + + // Update progress + progress++; + totalChecked++; + statusText.textContent = `Scanning local ports... (${totalChecked}/${totalAttempts}) - Trying ${host}:${port}`; + console.log(`Checking ${host}:${port}...`); + + const result = await tryServerConnection(host, port); + + // Check for cancellation or success + if (result === "aborted" || !isDiscoveryInProgress) { + console.log("Discovery process was cancelled"); + return false; + } else if (result === true) { + console.log(`Server found at ${host}:${port}`); + return true; // Successfully found server + } + } + } + + // Then scan all the remaining host/port combinations + for (const host of hosts) { + // Skip hosts we already checked + if (localHosts.includes(host)) { + continue; + } + + for (const port of uniquePorts) { + // Check if discovery was cancelled + if (!isDiscoveryInProgress) { + console.log("Discovery process was cancelled during remote scan"); + return false; + } + + // Update progress + progress++; + totalChecked++; + statusText.textContent = `Scanning remote hosts... (${totalChecked}/${totalAttempts}) - Trying ${host}:${port}`; + console.log(`Checking ${host}:${port}...`); + + const result = await tryServerConnection(host, port); + + // Check for cancellation or success + if (result === "aborted" || !isDiscoveryInProgress) { + console.log("Discovery process was cancelled"); + return false; + } else if (result === true) { + console.log(`Server found at ${host}:${port}`); + return true; // Successfully found server + } + } + } + + console.log( + `Discovery process completed, checked ${totalChecked} combinations, no server found` + ); + // If we get here, no server was found + statusIcon.className = "status-indicator status-disconnected"; + statusText.textContent = + "No server found. Please check server is running and try again."; + + serverConnected = false; + + // End the discovery process first before updating the banner + isDiscoveryInProgress = false; + + // Update the connection banner to show the reconnect button + updateConnectionBanner(false, null); + + // Schedule a reconnect attempt + scheduleReconnectAttempt(); + + return false; + } catch (error) { + console.error("Error during server discovery:", error); + statusIcon.className = "status-indicator status-disconnected"; + statusText.textContent = `Error discovering server: ${error.message}`; + + serverConnected = false; + + // End the discovery process first before updating the banner + isDiscoveryInProgress = false; + + // Update the connection banner to show the reconnect button + updateConnectionBanner(false, null); + + // Schedule a reconnect attempt + scheduleReconnectAttempt(); + + return false; + } finally { + console.log("Discovery process finished"); + // Always clean up, even if there was an error + if (discoveryController) { + discoveryController = null; + } + } +} + +// Bind discover server button to the extracted function +discoverServerButton.addEventListener("click", () => discoverServer(false)); + +// Screenshot capture functionality +captureScreenshotButton.addEventListener("click", () => { + captureScreenshotButton.textContent = "Capturing..."; + + // Send message to background script to capture screenshot + browserAPI.runtime.sendMessage( + { + type: "CAPTURE_SCREENSHOT", + tabId: browserAPI.devtools.inspectedWindow.tabId, + screenshotPath: settings.screenshotPath, + }, + (response) => { + console.log("Screenshot capture response:", response); + if (!response) { + captureScreenshotButton.textContent = "Failed to capture!"; + console.error("Screenshot capture failed: No response received"); + } else if (!response.success) { + captureScreenshotButton.textContent = "Failed to capture!"; + console.error("Screenshot capture failed:", response.error); + } else { + captureScreenshotButton.textContent = `Captured: ${response.title}`; + console.log("Screenshot captured successfully:", response.path); + } + setTimeout(() => { + captureScreenshotButton.textContent = "Capture Screenshot"; + }, 2000); + } + ); +}); + +// Add wipe logs functionality +const wipeLogsButton = document.getElementById("wipe-logs"); +wipeLogsButton.addEventListener("click", () => { + const serverUrl = `http://${settings.serverHost}:${settings.serverPort}/wipelogs`; + console.log(`Sending wipe request to ${serverUrl}`); + + fetch(serverUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }) + .then((response) => response.json()) + .then((result) => { + console.log("Logs wiped successfully:", result.message); + wipeLogsButton.textContent = "Logs Wiped!"; + setTimeout(() => { + wipeLogsButton.textContent = "Wipe All Logs"; + }, 2000); + }) + .catch((error) => { + console.error("Failed to wipe logs:", error); + wipeLogsButton.textContent = "Failed to Wipe Logs"; + setTimeout(() => { + wipeLogsButton.textContent = "Wipe All Logs"; + }, 2000); + }); +}); diff --git a/firefox-extension/webextension-polyfill.js b/firefox-extension/webextension-polyfill.js new file mode 100644 index 0000000..37f6ee9 --- /dev/null +++ b/firefox-extension/webextension-polyfill.js @@ -0,0 +1,8 @@ +(function(a,b){if("function"==typeof define&&define.amd)define("webextension-polyfill",["module"],b);else if("undefined"!=typeof exports)b(module);else{var c={exports:{}};b(c),a.browser=c.exports}})("undefined"==typeof globalThis?"undefined"==typeof self?this:self:globalThis,function(a){"use strict";if(!globalThis.chrome?.runtime?.id)throw new Error("This script should only be loaded in a browser extension.");if("undefined"==typeof globalThis.browser||Object.getPrototypeOf(globalThis.browser)!==Object.prototype){a.exports=(a=>{const b={alarms:{clear:{minArgs:0,maxArgs:1},clearAll:{minArgs:0,maxArgs:0},get:{minArgs:0,maxArgs:1},getAll:{minArgs:0,maxArgs:0}},bookmarks:{create:{minArgs:1,maxArgs:1},get:{minArgs:1,maxArgs:1},getChildren:{minArgs:1,maxArgs:1},getRecent:{minArgs:1,maxArgs:1},getSubTree:{minArgs:1,maxArgs:1},getTree:{minArgs:0,maxArgs:0},move:{minArgs:2,maxArgs:2},remove:{minArgs:1,maxArgs:1},removeTree:{minArgs:1,maxArgs:1},search:{minArgs:1,maxArgs:1},update:{minArgs:2,maxArgs:2}},browserAction:{disable:{minArgs:0,maxArgs:1,fallbackToNoCallback:!0},enable:{minArgs:0,maxArgs:1,fallbackToNoCallback:!0},getBadgeBackgroundColor:{minArgs:1,maxArgs:1},getBadgeText:{minArgs:1,maxArgs:1},getPopup:{minArgs:1,maxArgs:1},getTitle:{minArgs:1,maxArgs:1},openPopup:{minArgs:0,maxArgs:0},setBadgeBackgroundColor:{minArgs:1,maxArgs:1,fallbackToNoCallback:!0},setBadgeText:{minArgs:1,maxArgs:1,fallbackToNoCallback:!0},setIcon:{minArgs:1,maxArgs:1},setPopup:{minArgs:1,maxArgs:1,fallbackToNoCallback:!0},setTitle:{minArgs:1,maxArgs:1,fallbackToNoCallback:!0}},browsingData:{remove:{minArgs:2,maxArgs:2},removeCache:{minArgs:1,maxArgs:1},removeCookies:{minArgs:1,maxArgs:1},removeDownloads:{minArgs:1,maxArgs:1},removeFormData:{minArgs:1,maxArgs:1},removeHistory:{minArgs:1,maxArgs:1},removeLocalStorage:{minArgs:1,maxArgs:1},removePasswords:{minArgs:1,maxArgs:1},removePluginData:{minArgs:1,maxArgs:1},settings:{minArgs:0,maxArgs:0}},commands:{getAll:{minArgs:0,maxArgs:0}},contextMenus:{remove:{minArgs:1,maxArgs:1},removeAll:{minArgs:0,maxArgs:0},update:{minArgs:2,maxArgs:2}},cookies:{get:{minArgs:1,maxArgs:1},getAll:{minArgs:1,maxArgs:1},getAllCookieStores:{minArgs:0,maxArgs:0},remove:{minArgs:1,maxArgs:1},set:{minArgs:1,maxArgs:1}},devtools:{inspectedWindow:{eval:{minArgs:1,maxArgs:2,singleCallbackArg:!1}},panels:{create:{minArgs:3,maxArgs:3,singleCallbackArg:!0},elements:{createSidebarPane:{minArgs:1,maxArgs:1}}}},downloads:{cancel:{minArgs:1,maxArgs:1},download:{minArgs:1,maxArgs:1},erase:{minArgs:1,maxArgs:1},getFileIcon:{minArgs:1,maxArgs:2},open:{minArgs:1,maxArgs:1,fallbackToNoCallback:!0},pause:{minArgs:1,maxArgs:1},removeFile:{minArgs:1,maxArgs:1},resume:{minArgs:1,maxArgs:1},search:{minArgs:1,maxArgs:1},show:{minArgs:1,maxArgs:1,fallbackToNoCallback:!0}},extension:{isAllowedFileSchemeAccess:{minArgs:0,maxArgs:0},isAllowedIncognitoAccess:{minArgs:0,maxArgs:0}},history:{addUrl:{minArgs:1,maxArgs:1},deleteAll:{minArgs:0,maxArgs:0},deleteRange:{minArgs:1,maxArgs:1},deleteUrl:{minArgs:1,maxArgs:1},getVisits:{minArgs:1,maxArgs:1},search:{minArgs:1,maxArgs:1}},i18n:{detectLanguage:{minArgs:1,maxArgs:1},getAcceptLanguages:{minArgs:0,maxArgs:0}},identity:{launchWebAuthFlow:{minArgs:1,maxArgs:1}},idle:{queryState:{minArgs:1,maxArgs:1}},management:{get:{minArgs:1,maxArgs:1},getAll:{minArgs:0,maxArgs:0},getSelf:{minArgs:0,maxArgs:0},setEnabled:{minArgs:2,maxArgs:2},uninstallSelf:{minArgs:0,maxArgs:1}},notifications:{clear:{minArgs:1,maxArgs:1},create:{minArgs:1,maxArgs:2},getAll:{minArgs:0,maxArgs:0},getPermissionLevel:{minArgs:0,maxArgs:0},update:{minArgs:2,maxArgs:2}},pageAction:{getPopup:{minArgs:1,maxArgs:1},getTitle:{minArgs:1,maxArgs:1},hide:{minArgs:1,maxArgs:1,fallbackToNoCallback:!0},setIcon:{minArgs:1,maxArgs:1},setPopup:{minArgs:1,maxArgs:1,fallbackToNoCallback:!0},setTitle:{minArgs:1,maxArgs:1,fallbackToNoCallback:!0},show:{minArgs:1,maxArgs:1,fallbackToNoCallback:!0}},permissions:{contains:{minArgs:1,maxArgs:1},getAll:{minArgs:0,maxArgs:0},remove:{minArgs:1,maxArgs:1},request:{minArgs:1,maxArgs:1}},runtime:{getBackgroundPage:{minArgs:0,maxArgs:0},getPlatformInfo:{minArgs:0,maxArgs:0},openOptionsPage:{minArgs:0,maxArgs:0},requestUpdateCheck:{minArgs:0,maxArgs:0},sendMessage:{minArgs:1,maxArgs:3},sendNativeMessage:{minArgs:2,maxArgs:2},setUninstallURL:{minArgs:1,maxArgs:1}},sessions:{getDevices:{minArgs:0,maxArgs:1},getRecentlyClosed:{minArgs:0,maxArgs:1},restore:{minArgs:0,maxArgs:1}},storage:{local:{clear:{minArgs:0,maxArgs:0},get:{minArgs:0,maxArgs:1},getBytesInUse:{minArgs:0,maxArgs:1},remove:{minArgs:1,maxArgs:1},set:{minArgs:1,maxArgs:1}},managed:{get:{minArgs:0,maxArgs:1},getBytesInUse:{minArgs:0,maxArgs:1}},sync:{clear:{minArgs:0,maxArgs:0},get:{minArgs:0,maxArgs:1},getBytesInUse:{minArgs:0,maxArgs:1},remove:{minArgs:1,maxArgs:1},set:{minArgs:1,maxArgs:1}}},tabs:{captureVisibleTab:{minArgs:0,maxArgs:2},create:{minArgs:1,maxArgs:1},detectLanguage:{minArgs:0,maxArgs:1},discard:{minArgs:0,maxArgs:1},duplicate:{minArgs:1,maxArgs:1},executeScript:{minArgs:1,maxArgs:2},get:{minArgs:1,maxArgs:1},getCurrent:{minArgs:0,maxArgs:0},getZoom:{minArgs:0,maxArgs:1},getZoomSettings:{minArgs:0,maxArgs:1},goBack:{minArgs:0,maxArgs:1},goForward:{minArgs:0,maxArgs:1},highlight:{minArgs:1,maxArgs:1},insertCSS:{minArgs:1,maxArgs:2},move:{minArgs:2,maxArgs:2},query:{minArgs:1,maxArgs:1},reload:{minArgs:0,maxArgs:2},remove:{minArgs:1,maxArgs:1},removeCSS:{minArgs:1,maxArgs:2},sendMessage:{minArgs:2,maxArgs:3},setZoom:{minArgs:1,maxArgs:2},setZoomSettings:{minArgs:1,maxArgs:2},update:{minArgs:1,maxArgs:2}},topSites:{get:{minArgs:0,maxArgs:0}},webNavigation:{getAllFrames:{minArgs:1,maxArgs:1},getFrame:{minArgs:1,maxArgs:1}},webRequest:{handlerBehaviorChanged:{minArgs:0,maxArgs:0}},windows:{create:{minArgs:0,maxArgs:1},get:{minArgs:1,maxArgs:2},getAll:{minArgs:0,maxArgs:1},getCurrent:{minArgs:0,maxArgs:1},getLastFocused:{minArgs:0,maxArgs:1},remove:{minArgs:1,maxArgs:1},update:{minArgs:2,maxArgs:2}}};if(0===Object.keys(b).length)throw new Error("api-metadata.json has not been included in browser-polyfill");class c extends WeakMap{constructor(a,b=void 0){super(b),this.createItem=a}get(a){return this.has(a)||this.set(a,this.createItem(a)),super.get(a)}}const d=a=>a&&"object"==typeof a&&"function"==typeof a.then,e=(b,c)=>(...d)=>{a.runtime.lastError?b.reject(new Error(a.runtime.lastError.message)):c.singleCallbackArg||1>=d.length&&!1!==c.singleCallbackArg?b.resolve(d[0]):b.resolve(d)},f=a=>1==a?"argument":"arguments",g=(a,b)=>function(c,...d){if(d.lengthb.maxArgs)throw new Error(`Expected at most ${b.maxArgs} ${f(b.maxArgs)} for ${a}(), got ${d.length}`);return new Promise((f,g)=>{if(b.fallbackToNoCallback)try{c[a](...d,e({resolve:f,reject:g},b))}catch(e){console.warn(`${a} API method doesn't seem to support the callback parameter, `+"falling back to call it without a callback: ",e),c[a](...d),b.fallbackToNoCallback=!1,b.noCallback=!0,f()}else b.noCallback?(c[a](...d),f()):c[a](...d,e({resolve:f,reject:g},b))})},h=(a,b,c)=>new Proxy(b,{apply(b,d,e){return c.call(d,a,...e)}});let i=Function.call.bind(Object.prototype.hasOwnProperty);const j=(a,b={},c={})=>{let d=Object.create(null),e=Object.create(a);return new Proxy(e,{has(b,c){return c in a||c in d},get(e,f){if(f in d)return d[f];if(!(f in a))return;let k=a[f];if("function"==typeof k){if("function"==typeof b[f])k=h(a,a[f],b[f]);else if(i(c,f)){let b=g(f,c[f]);k=h(a,a[f],b)}else k=k.bind(a);}else if("object"==typeof k&&null!==k&&(i(b,f)||i(c,f)))k=j(k,b[f],c[f]);else if(i(c,"*"))k=j(k,b[f],c["*"]);else return Object.defineProperty(d,f,{configurable:!0,enumerable:!0,get(){return a[f]},set(b){a[f]=b}}),k;return d[f]=k,k},set(b,c,e){return c in d?d[c]=e:a[c]=e,!0},defineProperty(a,b,c){return Reflect.defineProperty(d,b,c)},deleteProperty(a,b){return Reflect.deleteProperty(d,b)}})},k=a=>({addListener(b,c,...d){b.addListener(a.get(c),...d)},hasListener(b,c){return b.hasListener(a.get(c))},removeListener(b,c){b.removeListener(a.get(c))}}),l=new c(a=>"function"==typeof a?function(b){const c=j(b,{},{getContent:{minArgs:0,maxArgs:0}});a(c)}:a),m=new c(a=>"function"==typeof a?function(b,c,e){let f,g,h=!1,i=new Promise(a=>{f=function(b){h=!0,a(b)}});try{g=a(b,c,f)}catch(a){g=Promise.reject(a)}const j=!0!==g&&d(g);if(!0!==g&&!j&&!h)return!1;const k=a=>{a.then(a=>{e(a)},a=>{let b;b=a&&(a instanceof Error||"string"==typeof a.message)?a.message:"An unexpected error occurred",e({__mozWebExtensionPolyfillReject__:!0,message:b})}).catch(a=>{console.error("Failed to send onMessage rejected reply",a)})};return j?k(g):k(i),!0}:a),n=({reject:b,resolve:c},d)=>{a.runtime.lastError?a.runtime.lastError.message==="The message port closed before a response was received."?c():b(new Error(a.runtime.lastError.message)):d&&d.__mozWebExtensionPolyfillReject__?b(new Error(d.message)):c(d)},o=(a,b,c,...d)=>{if(d.lengthb.maxArgs)throw new Error(`Expected at most ${b.maxArgs} ${f(b.maxArgs)} for ${a}(), got ${d.length}`);return new Promise((a,b)=>{const e=n.bind(null,{resolve:a,reject:b});d.push(e),c.sendMessage(...d)})},p={devtools:{network:{onRequestFinished:k(l)}},runtime:{onMessage:k(m),onMessageExternal:k(m),sendMessage:o.bind(null,"sendMessage",{minArgs:1,maxArgs:3})},tabs:{sendMessage:o.bind(null,"sendMessage",{minArgs:2,maxArgs:3})}},q={clear:{minArgs:1,maxArgs:1},get:{minArgs:1,maxArgs:1},set:{minArgs:1,maxArgs:1}};return b.privacy={network:{"*":q},services:{"*":q},websites:{"*":q}},j(a,p,b)})(chrome)}else a.exports=globalThis.browser}); +//# sourceMappingURL=browser-polyfill.min.js.map + +// webextension-polyfill v.0.10.0 (https://github.com/mozilla/webextension-polyfill) + +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */