adding examples

This commit is contained in:
Tommy D. Rossi
2025-11-13 19:42:44 +01:00
parent ac5322e580
commit ec02a90812
57 changed files with 8226 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+87
View File
@@ -0,0 +1,87 @@
# Privacy Policy for Kapture Browser Automation
**Last Updated: December 2024**
## Overview
Kapture Browser Automation ("the Extension") is designed to enable local browser automation through the Model Context Protocol (MCP). This privacy policy explains how the Extension handles user data.
## Data Collection and Usage
### What We Access
The Extension may access:
- Current tab URL and title
- Page content (HTML/DOM) when requested
- Browser console logs when requested
- Screenshots of the current tab when requested
- Form field values when automating interactions
### What We DON'T Do
- We do NOT collect or store any personal information
- We do NOT send any data to external servers
- We do NOT track your browsing history
- We do NOT use analytics or tracking services
- We do NOT sell or share any data
### Local Operation Only
All Extension operations are strictly local:
- Communication occurs only with localhost (127.0.0.1) on port 61822
- Data is transmitted only to the MCP server running on your local machine
- No internet connection is required for Extension operation
- No data leaves your computer
## Data Transmission
When you use the Extension:
1. Commands are received from the local MCP server via WebSocket
2. The Extension executes the requested action on the current web page
3. Results are sent back to the local MCP server
4. All communication uses localhost networking only
## Data Storage
The Extension stores:
- Your connection status (connected/disconnected)
- A temporary session Tab ID for the current browser tab
- Command history for the current session (cleared on disconnect)
No persistent data is stored between browser sessions.
## Permissions
The Extension requires certain Chrome permissions:
- `tabs`: To access tab information and execute scripts
- `debugger`: To capture screenshots
- `<all_urls>`: To interact with any website you choose to automate
These permissions are used solely for providing automation functionality and are never used for tracking or data collection.
## Security
- All communication uses WebSocket protocol over localhost
- No authentication tokens or passwords are stored
- Each tab receives a unique session ID that expires on disconnect
## User Control
You have complete control:
- Connect/disconnect at any time via the DevTools panel
- Close the DevTools panel to stop all Extension activity
- Disable or uninstall the Extension at any time via Chrome settings
## Children's Privacy
This Extension is not directed at children under 13 and does not knowingly collect information from children.
## Changes to This Policy
We may update this privacy policy from time to time. Any changes will be reflected in the "Last Updated" date above.
## Contact
For questions about this privacy policy or the Extension, please visit:
https://github.com/williamkapke/kapture
## Consent
By using the Kapture Browser Automation Extension, you consent to this privacy policy.
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+239
View File
@@ -0,0 +1,239 @@
// Background service worker - manages WebSocket connections
import { TabManager } from './modules/tab-manager.js';
import {ConsoleLogEntry} from "./modules/models.js";
// Single source of truth for all tab state
const tabManager = new TabManager();
// Helper function to send connection state to content script
function sendConnectionStateToTab(tabId, connectionState) {
chrome.tabs.sendMessage(tabId, {
command: '_connectionStateChanged',
params: {
status: connectionState.status,
connected: connectionState.connected
}
}).catch(err => {
// Content script might not be injected yet, ignore error
console.debug('Could not send connection state to tab:', err);
});
}
// Listen for tab state changes
tabManager.addListener((tabId, event, tabState, data) => {
switch (event) {
case 'stateChanged':
// Broadcast to all ports for this tab
tabState.broadcastToPorts({
type: 'state',
tabId,
...tabState.getConnectionState()
});
// Send connection state to content script
const connectionState = tabState.getConnectionState();
sendConnectionStateToTab(tabId, connectionState);
// Update action badge for active tab
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0]?.id === tabId) {
updateActionBadge(tabState.getConnectionState());
}
});
break;
case 'messageReceived':
case 'messageSent':
// Broadcast updated messages to all ports
tabState.broadcastToPorts({
type: 'messages',
tabId,
messages: tabState.getMessages()
});
break;
case 'consoleLogAdded':
// Broadcast updated console count
tabState.broadcastToPorts({
type: 'consoleCount',
tabId,
count: tabState.getConsoleLogCount()
});
break;
case 'messagesCleared':
tabState.broadcastToPorts({
type: 'messages',
tabId,
messages: []
});
break;
case 'consoleLogsCleared':
tabState.broadcastToPorts({
type: 'consoleCount',
tabId,
count: 0
});
break;
}
});
// Update action badge based on connection status
function updateActionBadge(state) {
switch (state.status) {
case 'connected':
chrome.action.setBadgeText({ text: '✓' });
chrome.action.setBadgeBackgroundColor({ color: '#4caf50' });
break;
case 'retrying':
chrome.action.setBadgeText({ text: '↻' });
chrome.action.setBadgeBackgroundColor({ color: '#ff9800' });
break;
case 'disconnected':
default:
chrome.action.setBadgeText({ text: '' });
break;
}
}
// Handle UI connections
chrome.runtime.onConnect.addListener((port) => {
port.onMessage.addListener((msg) => {
if (msg.type === 'subscribe' && msg.tabId) {
tabManager.addPort(msg.tabId, port);
} else if (msg.type === 'clearMessages' && msg.tabId) {
tabManager.clearMessages(msg.tabId);
} else if (msg.type === 'clearConsoleLogs' && msg.tabId) {
tabManager.clearConsoleLogs(msg.tabId);
}
});
port.onDisconnect.addListener(() => {
// Remove port from all tabs
tabManager.getAllTabs().forEach(tabState => {
tabState.removePort(port);
});
});
});
// Handle all messages
chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => {
// Handle content script messages
if (sender.tab) {
if (request.type === 'contentScriptReady') {
console.log(`Content script ready in tab ${sender.tab.id}`);
// Send current connection state to the newly ready content script
const tabState = tabManager.getTab(sender.tab.id);
if (tabState) {
const connectionState = tabState.getConnectionState();
sendConnectionStateToTab(sender.tab.id, connectionState);
}
sendResponse({ acknowledged: true });
return false;
}
if (request.type === 'connect') {
const result = await tabManager.connect(sender.tab.id);
sendResponse(result);
return true; // Keep message channel open for async response
}
if (request.type === 'disconnect') {
const result = tabManager.disconnect(sender.tab.id);
sendResponse(result);
return false;
}
if (request.type === 'openPopup') {
chrome.action.openPopup();
sendResponse({ ok: true });
return false;
}
if (request.type === 'mousePosition') {
// Store mouse position for the tab
const tabState = tabManager.getTab(sender.tab.id);
if (tabState) {
tabState.setMousePosition({ x: request.x, y: request.y });
}
return false;
}
if (request.type === 'consoleLog') {
// Handle console log from content script
const tabState = tabManager.getTab(sender.tab.id);
if (tabState) {
if (request.level === 'clear') {
// Clear console logs
tabManager.clearConsoleLogs(sender.tab.id);
} else {
// Add console log
tabManager.addConsoleLog(sender.tab.id, new ConsoleLogEntry(
request.level,
request.args,
request.stackTrace
));
}
}
return false;
}
}
// Handle messages from popup/panel (not from content scripts)
if (!sender.tab) {
if (request.type === 'connect' && request.tabId) {
const result = await tabManager.connect(request.tabId);
sendResponse(result);
return true; // Keep message channel open for async response
}
if (request.type === 'disconnect' && request.tabId) {
const result = tabManager.disconnect(request.tabId);
sendResponse(result);
return false;
}
if (request.type === 'getState' && request.tabId) {
const tabState = tabManager.getTab(request.tabId);
sendResponse(tabState ? tabState.getConnectionState() : { connected: false, status: 'disconnected' });
return false;
}
}
});
// Update badge when active tab changes
chrome.tabs.onActivated.addListener((activeInfo) => {
const tabState = tabManager.getTab(activeInfo.tabId);
if (tabState) {
updateActionBadge(tabState.getConnectionState());
} else {
updateActionBadge({ status: 'disconnected' });
}
});
// Clean up when tabs are closed
chrome.tabs.onRemoved.addListener((tabId) => {
tabManager.removeTab(tabId);
});
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
const destination = { url: 'https://to.kap.co/kapture-welcome' };
// Navigate the current active tab instead of creating a new one
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0]) {
chrome.tabs.update(tabs[0].id, destination);
}
else {
// Fallback: create a new tab if no active tab is found
chrome.tabs.create(destination);
}
});
}
});
+88
View File
@@ -0,0 +1,88 @@
(function() {
const isOverridden = !console.log.toString().includes('[native code]');
// Check if already injected
if (isOverridden) {
return;
}
// Store original console methods
const originalConsole = {
log: console.log,
error: console.error,
warn: console.warn,
info: console.info,
debug: console.debug,
trace: console.trace,
table: console.table,
group: console.group,
groupCollapsed: console.groupCollapsed,
groupEnd: console.groupEnd,
clear: console.clear
};
// Helper to serialize arguments
function serializeArgs(args) {
return Array.from(args).map(arg => {
try {
if (arg === undefined) return 'undefined';
if (arg === null) return 'null';
if (typeof arg === 'function') return arg.toString();
if (typeof arg === 'object') {
// Handle circular references
const seen = new WeakSet();
return JSON.stringify(arg, function(key, value) {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) return '[Circular]';
seen.add(value);
}
if (typeof value === 'function') return value.toString();
return value;
});
}
return String(arg);
} catch (e) {
return String(arg);
}
});
}
// Override console methods (all except clear)
['log', 'error', 'warn', 'info', 'debug', 'trace', 'table', 'group', 'groupCollapsed', 'groupEnd'].forEach(level => {
console[level] = function(...args) {
// Create log entry
const detail = {
level: level,
args: serializeArgs(args),
timestamp: new Date().toISOString()
};
// Only include stack trace for errors and warnings
if (level === 'error' || level === 'warn' || level === 'trace') {
detail.stack = new Error().stack;
}
const event = new CustomEvent('kapture-console', { detail });
// Dispatch event for content script to capture
window.dispatchEvent(event);
// Call original method
originalConsole[level].apply(console, args);
};
});
// Override console.clear
console.clear = function() {
// Dispatch clear event
const event = new CustomEvent('kapture-console', { detail: { level: 'clear' }});
originalConsole.log('[Kapture] Dispatching console clear event');
window.dispatchEvent(event);
// Call original method
originalConsole.clear.apply(console);
};
// Log that injection is complete
originalConsole.log('[Kapture] Console listener attached');
})();
+30
View File
@@ -0,0 +1,30 @@
// Proxy messages from webpage to extension
window.addEventListener('kapture-message', (event) => {
chrome.runtime.sendMessage(event.detail);
});
// Listen for console log events from the injected code
window.addEventListener('kapture-console', (event) => {
// Send console log to background script
chrome.runtime.sendMessage({
type: 'consoleLog',
...event.detail
});
});
function ready() {
// Notify background script that content script is ready
chrome.runtime.sendMessage({ type: 'contentScriptReady' });
document.body.classList.add('kapture-loaded');
window.dispatchEvent(new CustomEvent('kapture-loaded'));
// Check for auto-connect querystring parameter
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('kapture-connect') === 'true') {
chrome.runtime.sendMessage({ type: 'connect' });
chrome.runtime.sendMessage({ type: 'openPopup' });
}
}
// Notify webpage that Kapture is loaded after DOMContentLoaded
document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', ready) : ready();
+8
View File
@@ -0,0 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<script src="devtools.js"></script>
</head>
<body>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
// Create DevTools panel
chrome.devtools.panels.create(
"Kapture",
null,
"panel.html"
);
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+44
View File
@@ -0,0 +1,44 @@
{
"manifest_version": 3,
"name": "Kapture MCP Browser Automation",
"short_name": "Kapture",
"version": "0.14.0",
"description": "Remote browser automation via MCP - DevTools Extension",
"author": "William Kapke",
"homepage_url": "https://github.com/williamkapke/kapture",
"permissions": ["activeTab", "debugger"],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "background.js",
"type": "module"
},
"devtools_page": "devtools.html",
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["console-listener.js"],
"run_at": "document_start",
"all_frames": false,
"world": "MAIN"
},
{
"matches": ["<all_urls>"],
"js": ["page-helpers.js", "content-script.js"],
"run_at": "document_start",
"all_frames": false
}
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
@@ -0,0 +1,122 @@
// Import helper functions from background-commands
import { getFromContentScript, respondWith, respondWithError, attachDebugger, getElement } from './background-commands.js';
export async function click({tabId, mousePosition}, { selector, xpath }) {
return await hover({ tabId, mousePosition }, { selector, xpath }, true);
}
export async function hover(tab, { selector, xpath }, click = false) {
const { tabId, mousePosition } = tab;
// Validate that either selector or xpath is provided
if (!selector && !xpath) {
return respondWithError(tabId, 'SELECTOR_OR_XPATH_REQUIRED', 'Either selector or xpath is required');
}
// Get element and validate it exists and is visible
const elementResult = await getElement(tabId, selector, xpath, true);
if (elementResult.error) return elementResult;
// Get current mouse position
const currentPosition = mousePosition || { x: 0, y: 0 };
// Calculate target position (center of element)
const targetX = elementResult.element.bounds.x + elementResult.element.bounds.width / 2;
const targetY = elementResult.element.bounds.y + elementResult.element.bounds.height / 2;
try {
// Show cursor
await getFromContentScript(tabId, '_cursor', { show: true });
// Set initial cursor position
await getFromContentScript(tabId, '_moveMouseSVG', { x: currentPosition.x, y: currentPosition.y });
// Animation configuration
const pixelsPerSecond = 1000; // Adjust for desired speed
const frameInterval = 16; // ~60fps
await attachDebugger(tabId, async () => {
const sendCmd = (cmd, params) => chrome.debugger.sendCommand({ tabId }, cmd, params);
const dispatchMouseEvent = (params) => sendCmd('Input.dispatchMouseEvent', params);
// Enable Input domain for mouse events
// await sendCmd('Input.enable');
// Function to animate to a position
const animateToPosition = async (fromX, fromY, toX, toY) => {
const distance = Math.sqrt(Math.pow(toX - fromX, 2) + Math.pow(toY - fromY, 2));
const duration = Math.min(1000, (distance / pixelsPerSecond) * 1000);
const animSteps = Math.max(1, Math.ceil(duration / frameInterval));
const dx = (toX - fromX) / animSteps;
const dy = (toY - fromY) / animSteps;
for (let i = 1; i <= animSteps; i++) {
const x = fromX + (dx * i);
const y = fromY + (dy * i);
// Move visual cursor
await getFromContentScript(tabId, '_moveMouseSVG', { x, y });
// Send mouse move event
await dispatchMouseEvent({ type: 'mouseMoved', x, y });
// Wait for next frame
await new Promise(resolve => setTimeout(resolve, frameInterval));
}
return { x: toX, y: toY };
};
// Initial animation to target
let finalPosition = await animateToPosition(currentPosition.x, currentPosition.y, targetX, targetY);
// Try up to 5 times to ensure we're over the target element
const maxAttempts = 5;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const finalCheck = await getElement(tabId, selector, xpath, true);
if (!finalCheck.error && finalCheck.element) {
const bounds = finalCheck.element.bounds;
// Check if cursor is actually over the element
const isOverElement = finalPosition.x >= bounds.x &&
finalPosition.x <= bounds.x + bounds.width &&
finalPosition.y >= bounds.y &&
finalPosition.y <= bounds.y + bounds.height;
if (!isOverElement) {
// Calculate new target center and animate to it
const actualTargetX = bounds.x + bounds.width / 2;
const actualTargetY = bounds.y + bounds.height / 2;
finalPosition = await animateToPosition(finalPosition.x, finalPosition.y, actualTargetX, actualTargetY);
} else {
// Cursor is over the element, we're done
break;
}
} else {
// Element not found, stop trying
break;
}
}
const bounds = await getFromContentScript(tabId, '_elementPosition', { id: elementResult.element.id });
const actualTargetX = bounds.x + bounds.width / 2;
const actualTargetY = bounds.y + bounds.height / 2;
await getFromContentScript(tabId, '_moveMouseSVG', { x: actualTargetX, y: actualTargetY });
if (click) {
await dispatchMouseEvent({type: 'mousePressed', x: actualTargetX, y: actualTargetY, button: 'left', clickCount: 1});
await dispatchMouseEvent({type: 'mouseReleased', x: actualTargetX, y: actualTargetY, button: 'left', clickCount: 1});
}
});
// Hide cursor after 1 second
setTimeout(async () => {
await getFromContentScript(tabId, '_cursor', { show: false });
}, 1000);
return respondWith(tabId, click ? { clicked: true } : { hovered: true }, selector, xpath);
} catch (error) {
// Make sure cursor is hidden on error
await getFromContentScript(tabId, '_cursor', { show: false });
return respondWithError(tabId, 'CLICK_FAILED', error.message, selector, xpath);
}
}
@@ -0,0 +1,88 @@
import { keypress } from './background-keypress.js';
import { click, hover } from './background-click.js';
import { navigate, back, forward, close, reload, show } from './background-navigate.js';
import { screenshot } from './background-screenshot.js';
import { getLogs } from './background-console.js';
export const getFromContentScript = async (tabId, command, params, ) => {
return await chrome.tabs.sendMessage(tabId, { command, params });
}
// Detect browser type from user agent
export const detectBrowser = () => {
const userAgent = navigator.userAgent.toLowerCase();
// Check for Chromium-based browsers that support Chrome extensions
if (userAgent.includes('edg/')) {
return 'edge';
} else if (userAgent.includes('opr/') || userAgent.includes('opera/')) {
return 'opera';
} else if (userAgent.includes('vivaldi/')) {
return 'vivaldi';
} else if (userAgent.includes('brave/')) {
return 'brave';
} else if (userAgent.includes('chrome/')) {
// Additional check for Brave which doesn't always include 'brave' in UA
if (navigator.brave && navigator.brave.isBrave) {
return 'brave';
}
return 'chrome';
} else {
// Default to chromium for any other Chromium-based browser
return 'chromium';
}
};
export const getTabInfo = async(tabId) => await getFromContentScript(tabId, 'getTabInfo');
export const getElement = async (tabId, selector, xpath, visible) => {
return await getFromContentScript(tabId, 'element', { selector, xpath, visible });
}
export const respondWith = async (tabId, obj, selector, xpath) => {
const info = await getTabInfo(tabId);
return {
success: !obj.error,
selector,
xpath: !selector ? xpath : undefined,
...info,
...obj
};
}
export const respondWithError = async (tabId, code, message, selector, xpath) => {
return respondWith(tabId,{ error: { code, message } }, selector, xpath);
}
export async function attachDebugger(tabId, action) {
let debuggerAttached = false;
try {
// Attach debugger to capture screenshot without making tab active
await chrome.debugger.attach({tabId}, '1.3');
debuggerAttached = true;
// Enable the Page domain
await chrome.debugger.sendCommand({tabId}, 'Page.enable');
return await action();
}
finally {
// Always detach debugger if attached
try {
if (debuggerAttached) await chrome.debugger.detach({tabId: tabId});
}
catch (e) { }
}
}
export const backgroundCommands = {
navigate,
back,
forward,
close,
reload,
show,
click,
hover,
keypress,
screenshot,
getLogs
}
@@ -0,0 +1,21 @@
// Import helper functions from background-commands
import { respondWith, respondWithError } from './background-commands.js';
export async function getLogs(tabState, { before, limit = 100, level }) {
try {
// Get the logs from the tab state using its built-in method
const logs = tabState.getConsoleLogs(limit, level, before);
// Check if there are more logs available
const totalFilteredCount = tabState.getConsoleLogs(null, level, before).length;
const hasMore = totalFilteredCount > limit;
return respondWith(tabState.tabId, {
logs: logs,
hasMore: hasMore,
totalCount: tabState.getConsoleLogCount()
});
} catch (error) {
return respondWithError(tabState.tabId, 'CONSOLE_LOG_ERROR', error.message);
}
}
@@ -0,0 +1,214 @@
// Import helper functions from background-commands
import { getFromContentScript, respondWith, respondWithError, attachDebugger } from './background-commands.js';
// Additional helper functions
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// Send keypress event
export async function keypress({tabId}, params) {
const { key, selector, xpath, delay = 50 } = params;
if (!key) {
return respondWithError(tabId, 'KEY_REQUIRED', 'Key parameter is required');
}
// Validate delay is within reasonable bounds (0-60000ms = 0-60 seconds)
const keypressDelay = Math.max(0, Math.min(60000, delay));
try {
// First, focus the target element if selector/xpath provided
if (selector || xpath) {
const focusResult = await getFromContentScript(tabId, 'focus', { selector, xpath });
if (focusResult.error) {
return focusResult;
}
}
// Parse key combination to extract key and modifiers
const keyData = parseKeyCombination(key);
// Use attachDebugger helper to manage debugger lifecycle
return await attachDebugger(tabId, async () => {
// Helper function to send key event
const sendKeyEvent = async (type, autoRepeat = false, text = null) => {
const eventData = {
type,
...keyData,
windowsVirtualKeyCode: keyData.keyCode,
nativeVirtualKeyCode: keyData.keyCode
};
if (type === 'keyDown' || type === 'keyUp') {
eventData.autoRepeat = autoRepeat;
}
if (text) {
eventData.text = text;
}
await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', eventData);
};
// Send keydown event
await sendKeyEvent('keyDown', false);
// If delay > 500ms, simulate key repeat
if (keypressDelay > 500) {
// Calculate number of repeat events based on delay
// Typical repeat rate is ~30ms between events
const repeatInterval = 30;
const repeatCount = Math.floor((keypressDelay - 100) / repeatInterval);
// Wait a bit before starting repeat (typical OS behavior)
await wait(50);
// Send repeated keydown events
for (let i = 0; i < repeatCount; i++) {
await sendKeyEvent('keyDown', true);
// Wait between repeat events
if (i < repeatCount - 1) {
await wait(repeatInterval);
}
}
} else if (keypressDelay > 0) {
// For delays <= 500ms, just wait the specified time
await wait(keypressDelay);
}
// Send keyup event
await sendKeyEvent('keyUp');
// Wait a bit for the page to process the key events
await wait(50);
// Keys that typically cause scrolling need extra time to settle
const scrollKeys = ['PageUp', 'PageDown', 'Space', ' ', 'ArrowUp', 'ArrowDown', 'Home', 'End'];
if (!selector && !xpath && scrollKeys.includes(keyData.key)) {
// Additional delay for scroll to complete when key is pressed globally (not on a specific element)
await wait(100);
}
// Get result including element info if selector was provided
const resultData = {
keyPressed: true,
key: key,
delay: keypressDelay
};
// If this was an auto-repeat key press, include that info
if (keypressDelay > 500) {
resultData.autoRepeat = true;
resultData.repeatCount = Math.floor((keypressDelay - 100) / 30);
}
return await respondWith(tabId, resultData, selector, xpath);
});
} catch (error) {
return respondWithError(tabId, 'KEYPRESS_FAILED', error.message, selector, xpath);
}
}
// Parse key combination and return CDP-compatible key data
function parseKeyCombination(keyCombination) {
let modifiers = 0;
let key = '';
// CDP modifier flags
const CDP_MODIFIERS = {
alt: 1,
ctrl: 2,
meta: 4,
shift: 8
};
// Special key mappings
const KEY_MAPPINGS = {
'enter': { key: 'Enter', code: 'Enter', keyCode: 13 },
'return': { key: 'Enter', code: 'Enter', keyCode: 13 },
'tab': { key: 'Tab', code: 'Tab', keyCode: 9 },
'delete': { key: 'Delete', code: 'Delete', keyCode: 46 },
'backspace': { key: 'Backspace', code: 'Backspace', keyCode: 8 },
'escape': { key: 'Escape', code: 'Escape', keyCode: 27 },
'esc': { key: 'Escape', code: 'Escape', keyCode: 27 },
'space': { key: ' ', code: 'Space', keyCode: 32, text: ' ' },
' ': { key: ' ', code: 'Space', keyCode: 32, text: ' ' },
'arrowup': { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
'arrowdown': { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
'arrowleft': { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
'arrowright': { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
'pageup': { key: 'PageUp', code: 'PageUp', keyCode: 33 },
'pagedown': { key: 'PageDown', code: 'PageDown', keyCode: 34 },
'home': { key: 'Home', code: 'Home', keyCode: 36 },
'end': { key: 'End', code: 'End', keyCode: 35 },
'insert': { key: 'Insert', code: 'Insert', keyCode: 45 },
'f1': { key: 'F1', code: 'F1', keyCode: 112 },
'f2': { key: 'F2', code: 'F2', keyCode: 113 },
'f3': { key: 'F3', code: 'F3', keyCode: 114 },
'f4': { key: 'F4', code: 'F4', keyCode: 115 },
'f5': { key: 'F5', code: 'F5', keyCode: 116 },
'f6': { key: 'F6', code: 'F6', keyCode: 117 },
'f7': { key: 'F7', code: 'F7', keyCode: 118 },
'f8': { key: 'F8', code: 'F8', keyCode: 119 },
'f9': { key: 'F9', code: 'F9', keyCode: 120 },
'f10': { key: 'F10', code: 'F10', keyCode: 121 },
'f11': { key: 'F11', code: 'F11', keyCode: 122 },
'f12': { key: 'F12', code: 'F12', keyCode: 123 }
};
// Parse modifiers from combination
let remaining = keyCombination;
const modifierPatterns = [
{ pattern: /^(Control|Ctrl)\+/i, modifier: 'ctrl' },
{ pattern: /^Shift\+/i, modifier: 'shift' },
{ pattern: /^Alt|Option\+/i, modifier: 'alt' },
{ pattern: /^(Meta|Cmd|Command)\+/i, modifier: 'meta' }
];
// Extract modifiers
let foundModifier = true;
while (foundModifier && remaining) {
foundModifier = false;
for (const { pattern, modifier } of modifierPatterns) {
if (pattern.test(remaining)) {
modifiers |= CDP_MODIFIERS[modifier];
remaining = remaining.replace(pattern, '');
foundModifier = true;
break;
}
}
}
// What's left is the key
key = remaining;
// Look up special key mapping
const lowerKey = key.toLowerCase();
if (KEY_MAPPINGS[lowerKey]) {
return {
...KEY_MAPPINGS[lowerKey],
modifiers,
text: KEY_MAPPINGS[lowerKey].text || undefined
};
}
// For single character keys
if (key.length === 1) {
const keyCode = key.toUpperCase().charCodeAt(0);
return {
key: key,
code: 'Key' + key.toUpperCase(),
keyCode: keyCode,
modifiers,
text: modifiers === 0 ? key : undefined // Only send text for unmodified keys
};
}
// For other keys, try to generate a reasonable code
return {
key: key,
code: key,
keyCode: 0, // Unknown keycode
modifiers
};
}
@@ -0,0 +1,82 @@
// Import helper functions from background-commands
import { getFromContentScript, respondWith, respondWithError } from './background-commands.js';
// Check if URL is allowed for extension
const isAllowedUrl = (url) => !!url && (url.startsWith('http://') || url.startsWith('https://'));
// Wait for content script to be ready
async function waitForContentScriptReady(tabId, timeout = 5000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
chrome.runtime.onMessage.removeListener(listener);
reject(new Error('Timeout waiting for content script'));
}, timeout);
const listener = (request, sender) => {
if (request.type === 'contentScriptReady' && sender.tab?.id === tabId) {
clearTimeout(timer);
chrome.runtime.onMessage.removeListener(listener);
resolve();
}
};
chrome.runtime.onMessage.addListener(listener);
});
}
// Execute navigation and wait for content script
async function executeNavigation(tabId, navigationFn) {
try {
await navigationFn();
await waitForContentScriptReady(tabId);
return await respondWith(tabId, {});
} catch (error) {
return respondWithError(tabId, 'NAVIGATION_FAILED', error.message);
}
}
// Navigation commands
export async function navigate({tabId}, { url }) {
if (!isAllowedUrl(url)) {
return respondWithError(tabId, 'NAVIGATION_BLOCKED', `Navigation to ${url} is not allowed`);
}
return executeNavigation(tabId, async () => getFromContentScript(tabId, '_navigate', { url }));
}
export async function back({tabId}) {
return executeNavigation(tabId, () => chrome.tabs.goBack(tabId));
}
export async function forward({tabId}) {
return executeNavigation(tabId, () => chrome.tabs.goForward(tabId));
}
export async function close({tabId}) {
try {
await chrome.tabs.remove(tabId);
return { success: true, closed: true };
} catch (error) {
return { success: false, error: { code: 'CLOSE_FAILED', message: error.message } };
}
}
export async function reload({tabId}) {
return executeNavigation(tabId, () => chrome.tabs.reload(tabId));
}
export async function show({tabId}) {
try {
// Get the tab to find its window ID
const tab = await chrome.tabs.get(tabId);
// Bring the window to the front
await chrome.windows.update(tab.windowId, { focused: true });
// Make the tab active in the window
await chrome.tabs.update(tabId, { active: true });
return await respondWith(tabId, { shown: true });
} catch (error) {
return respondWithError(tabId, 'SHOW_FAILED', error.message);
}
}
@@ -0,0 +1,54 @@
// Import helper functions from background-commands
import { getElement, getTabInfo, respondWithError, attachDebugger } from './background-commands.js';
export async function screenshot({tabId}, { scale = 0.5, quality = 0.5, format = 'webp', selector, xpath }) {
let elementResult;
if (selector || xpath) {
elementResult = await getElement(tabId, selector, xpath, true);
if (elementResult.error) return elementResult;
}
else {
elementResult = await getTabInfo(tabId)
elementResult.element = {
bounds: {
x: elementResult.scrollPosition.x,
y: elementResult.scrollPosition.y,
width: elementResult.viewportDimensions.width,
height: elementResult.viewportDimensions.height
}
};
}
const clip = { ...elementResult.element.bounds };
// For fixed positioned elements, we need viewport-relative coordinates
// For non-fixed elements, we need document-relative coordinates
if (elementResult.element.position !== 'fixed') {
// Add scroll position to convert from viewport to document coordinates
clip.x += elementResult.scrollPosition.x;
clip.y += elementResult.scrollPosition.y;
}
if (scale) {
clip.scale = scale;
}
return attachDebugger(tabId, async () => {
const screenshot = await chrome.debugger.sendCommand({ tabId }, 'Page.captureScreenshot', {
format,
quality: Math.round(quality * 100), // Chrome needs an integer percentage,
clip
});
return {
...elementResult,
element: undefined,
selector: elementResult.element?.selector || undefined,
mimeType: `image/${format}`,
data: screenshot.data,
};
})
.catch((err) => {
return respondWithError(tabId,'SCREENSHOT_ERROR', err.message, null, null);
});
}
+33
View File
@@ -0,0 +1,33 @@
// Connection state enum
export const ConnectionStatus = {
DISCONNECTED: 'disconnected',
CONNECTING: 'connecting',
CONNECTED: 'connected',
RETRYING: 'retrying',
ERROR: 'error'
};
// Message class
export class Message {
constructor(direction, data) {
this.id = crypto.randomUUID();
this.direction = direction; // 'incoming' | 'outgoing'
this.data = data;
this.timestamp = new Date();
}
}
// Console log entry class
export class ConsoleLogEntry {
constructor(level, args, stackTrace = null) {
this.id = crypto.randomUUID();
this.level = level; // 'log' | 'info' | 'warn' | 'error'
this.args = args;
if (stackTrace) {
this.stackTrace = stackTrace;
}
this.timestamp = new Date();
}
}
+342
View File
@@ -0,0 +1,342 @@
import { TabState } from './tab-state.js';
import { backgroundCommands, getTabInfo, detectBrowser } from './background-commands.js';
export class TabManager {
constructor() {
this.tabs = new Map(); // tabId -> TabState
this.listeners = new Set(); // State change listeners
}
// Tab lifecycle
getOrCreateTab(tabId) {
if (!this.tabs.has(tabId)) {
const tabState = new TabState(tabId);
this.tabs.set(tabId, tabState);
this.notifyListeners(tabId, 'created', tabState);
}
return this.tabs.get(tabId);
}
getTab(tabId) {
return this.tabs.get(tabId);
}
hasTab(tabId) {
return this.tabs.has(tabId);
}
removeTab(tabId) {
const tabState = this.tabs.get(tabId);
if (tabState) {
tabState.cleanup();
this.tabs.delete(tabId);
this.notifyListeners(tabId, 'removed', null);
}
}
// WebSocket connection management
async connect(tabId) {
const tabState = this.getOrCreateTab(tabId);
if (tabState.websocket && tabState.websocket.readyState === WebSocket.OPEN) {
return { ok: true, message: 'Already connected' };
}
// Get tab info from content script
const tabInfo = await getTabInfo(tabId);
tabState.updatePageMetadata(tabInfo);
// Set up connection
tabState.connectionInfo.userDisconnected = false;
await this._createConnection(tabState);
return { ok: true };
}
disconnect(tabId) {
const tabState = this.getTab(tabId);
if (!tabState) {
return { ok: false, error: 'Tab not found' };
}
tabState.connectionInfo.setDisconnected(true);
// Clear keepalive interval
if (tabState.keepaliveInterval) {
clearInterval(tabState.keepaliveInterval);
tabState.keepaliveInterval = null;
}
if (tabState.websocket) {
tabState.websocket.close();
tabState.clearWebSocket();
}
// Clear any pending reconnect
if (tabState.connectionInfo.reconnectTimer) {
clearTimeout(tabState.connectionInfo.reconnectTimer);
tabState.connectionInfo.reconnectTimer = null;
}
this.notifyListeners(tabId, 'stateChanged', tabState);
return { ok: true };
}
// Private connection methods
async _createConnection(tabState) {
// First check if tab still exists
try {
await chrome.tabs.get(tabState.tabId);
} catch (error) {
console.log(`Tab ${tabState.tabId} no longer exists, aborting connection`);
this.removeTab(tabState.tabId);
return;
}
const ws = new WebSocket(tabState.connectionInfo.url);
tabState.setWebSocket(ws);
// Clear any existing keepalive interval
if (tabState.keepaliveInterval) {
clearInterval(tabState.keepaliveInterval);
tabState.keepaliveInterval = null;
}
ws.onopen = () => {
tabState.connectionInfo.setConnected();
this.notifyListeners(tabState.tabId, 'stateChanged', tabState);
// Send registration message with Chrome tab ID and metadata
const registerMessage = {
type: 'register',
requestedTabId: tabState.tabId.toString(), // Chrome tab ID
browser: detectBrowser(), // Add browser type
...tabState.pageMetadata
};
this.sendMessage(tabState.tabId, registerMessage);
// Set up keepalive ping every 30 seconds
tabState.keepaliveInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
} catch (e) {
console.error('Failed to send keepalive ping:', e);
}
}
}, 30000);
};
ws.onclose = () => {
// Clear keepalive interval
if (tabState.keepaliveInterval) {
clearInterval(tabState.keepaliveInterval);
tabState.keepaliveInterval = null;
}
tabState.clearWebSocket();
if (!tabState.connectionInfo.userDisconnected) {
// Schedule reconnect
this._scheduleReconnect(tabState);
} else {
tabState.connectionInfo.setDisconnected(true);
}
this.notifyListeners(tabState.tabId, 'stateChanged', tabState);
};
ws.onerror = (error) => {
console.error(`WebSocket error for tab ${tabState.tabId}:`, error);
tabState.connectionInfo.setError(error);
// Don't clear WebSocket here - let onclose handle cleanup and reconnection
this.notifyListeners(tabState.tabId, 'stateChanged', tabState);
};
ws.onmessage = async (event) => {
try {
const data = JSON.parse(event.data);
// Don't track pong messages
if (data.type !== 'pong') {
const message = tabState.addMessage('incoming', data);
this.notifyListeners(tabState.tabId, 'messageReceived', tabState, message);
}
// Handle commands
if (data.type === 'command' && data.command && data.id) {
await this._handleCommand(tabState, data);
} else if (data.type === 'pong') {
// Pong received - connection is healthy
tabState.lastPongTime = Date.now();
}
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
}
_scheduleReconnect(tabState) {
// Clear any existing reconnect timer
if (tabState.connectionInfo.reconnectTimer) {
clearTimeout(tabState.connectionInfo.reconnectTimer);
tabState.connectionInfo.reconnectTimer = null;
}
const attemptNumber = tabState.connectionInfo.reconnectAttempts;
const backoffMs = this._getBackoffMs(attemptNumber);
console.log(`Scheduling reconnect for tab ${tabState.tabId} in ${backoffMs}ms (attempt ${attemptNumber + 1})`);
tabState.connectionInfo.setRetrying(attemptNumber + 1, backoffMs);
this.notifyListeners(tabState.tabId, 'stateChanged', tabState);
tabState.connectionInfo.reconnectTimer = setTimeout(async () => {
if (!tabState.connectionInfo.userDisconnected) {
// Check if tab still exists before attempting reconnect
try {
await chrome.tabs.get(tabState.tabId);
console.log(`Attempting reconnect for tab ${tabState.tabId}`);
this._createConnection(tabState);
} catch (error) {
console.log(`Tab ${tabState.tabId} no longer exists, removing from manager`);
this.removeTab(tabState.tabId);
}
}
}, backoffMs);
}
_getBackoffMs(attemptNumber) {
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, then cap at 60s
return Math.min(1000 * Math.pow(2, attemptNumber), 60000);
}
async _handleCommand(tabState, {command, params, id}) {
try {
let result;
// some need to run with the background context
if (backgroundCommands[command]) {
result = await backgroundCommands[command](tabState, params);
}
// others we execute in the page context
else {
result = await chrome.tabs.sendMessage(tabState.tabId, {command, params});
}
// `success: true` means we didn't throw an error. TODO: rename or remove it
const response = {id, type: 'response', success: true, result};
this.sendMessage(tabState.tabId, response);
}
catch (error) {
const errorResponse = { id, type: 'response', success: false, error: { message: error.message, code: 'COMMAND_FAILED' }};
this.sendMessage(tabState.tabId, errorResponse);
}
}
// Message sending
sendMessage(tabId, data) {
const tabState = this.getTab(tabId);
if (!tabState || !tabState.websocket || tabState.websocket.readyState !== WebSocket.OPEN) {
return { ok: false, error: 'Not connected' };
}
try {
const message = tabState.addMessage('outgoing', data);
tabState.websocket.send(JSON.stringify(data));
this.notifyListeners(tabId, 'messageSent', tabState, message);
return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
}
// Message management
clearMessages(tabId) {
const tabState = this.getTab(tabId);
if (tabState) {
tabState.clearMessages();
this.notifyListeners(tabId, 'messagesCleared', tabState);
}
}
// Console log management
addConsoleLog(tabId, log) {
const tabState = this.getTab(tabId);
if (tabState) {
tabState.addConsoleLog(log);
this.notifyListeners(tabId, 'consoleLogAdded', tabState, log);
}
}
clearConsoleLogs(tabId) {
const tabState = this.getTab(tabId);
if (tabState) {
tabState.clearConsoleLogs();
this.notifyListeners(tabId, 'consoleLogsCleared', tabState);
}
}
// Port management
addPort(tabId, port) {
const tabState = this.getOrCreateTab(tabId);
tabState.addPort(port);
// Send initial state
port.postMessage({
type: 'state',
tabId,
...tabState.getConnectionState()
});
// Send current messages
port.postMessage({
type: 'messages',
tabId,
messages: tabState.getMessages()
});
// Send console count
port.postMessage({
type: 'consoleCount',
tabId,
count: tabState.getConsoleLogCount()
});
}
removePort(tabId, port) {
const tabState = this.getTab(tabId);
if (tabState) {
tabState.removePort(port);
}
}
// Event listeners
addListener(callback) {
this.listeners.add(callback);
}
removeListener(callback) {
this.listeners.delete(callback);
}
notifyListeners(tabId, event, tabState, data = null) {
this.listeners.forEach(callback => {
try {
callback(tabId, event, tabState, data);
} catch (e) {
console.error('Error in TabManager listener:', e);
}
});
}
// Utility
getAllTabs() {
return Array.from(this.tabs.values());
}
getActiveTabCount() {
return this.tabs.size;
}
}
+177
View File
@@ -0,0 +1,177 @@
// WebSocket connection info
import { ConnectionStatus, Message } from "./models.js";
export class ConnectionInfo {
constructor(url) {
this.url = url;
this.status = ConnectionStatus.DISCONNECTED;
this.connected = false;
this.userDisconnected = false;
this.reconnectAttempts = 0;
this.reconnectTimer = null;
this.nextRetryIn = null;
}
setConnected() {
this.status = ConnectionStatus.CONNECTED;
this.connected = true;
this.reconnectAttempts = 0;
this.nextRetryIn = null;
}
setDisconnected(userInitiated = false) {
this.status = ConnectionStatus.DISCONNECTED;
this.connected = false;
this.userDisconnected = userInitiated;
if (userInitiated) {
this.reconnectAttempts = 0;
this.nextRetryIn = null;
}
}
setRetrying(attemptNumber, nextRetryMs) {
this.status = ConnectionStatus.RETRYING;
this.connected = false;
this.reconnectAttempts = attemptNumber;
this.nextRetryIn = nextRetryMs;
}
setError(error) {
this.status = ConnectionStatus.ERROR;
this.connected = false;
}
}
// Main tab state class
export class TabState {
constructor(tabId) {
this.tabId = tabId;
this.websocket = null;
this.connectionInfo = new ConnectionInfo('ws://localhost:61822');
this.messages = [];
this.consoleLogs = [];
this.ports = new Set(); // Connected DevTools panels/popups
this.pageMetadata = {};
this.mousePosition = { x: 0, y: 0 }; // Track current mouse position
}
// WebSocket management
setWebSocket(ws) {
this.websocket = ws;
}
clearWebSocket() {
this.websocket = null;
}
// Message management
addMessage(direction, data) {
const message = new Message(direction, data);
this.messages.push(message);
return message;
}
clearMessages() {
this.messages = [];
}
getMessages(limit = null) {
if (limit === null) {
return [...this.messages];
}
return this.messages.slice(-limit);
}
// Console log management
addConsoleLog(log) {
this.consoleLogs.unshift(log);
}
clearConsoleLogs() {
this.consoleLogs = [];
}
getConsoleLogs(limit = null, level = null, before = null) {
let logs = this.consoleLogs;
if (level) {
logs = logs.filter(log => log.level === level);
}
if (before) {
const beforeTimestamp = new Date(before).getTime();
logs = logs.filter(log =>
new Date(log.timestamp).getTime() < beforeTimestamp
);
}
if (limit === null) {
return [...logs];
}
return logs.slice(-limit);
}
getConsoleLogCount() {
return this.consoleLogs.length;
}
// Port management
addPort(port) {
this.ports.add(port);
}
removePort(port) {
this.ports.delete(port);
}
broadcastToPorts(message) {
this.ports.forEach(port => {
try {
port.postMessage(message);
} catch (e) {
// Port might be disconnected
this.ports.delete(port);
}
});
}
// State management
getConnectionState() {
return {
connected: this.connectionInfo.connected,
status: this.connectionInfo.status,
reconnectAttempt: this.connectionInfo.reconnectAttempts,
nextRetryIn: this.connectionInfo.nextRetryIn
};
}
updatePageMetadata(metadata) {
this.pageMetadata = metadata;
}
// Mouse position tracking
setMousePosition(position) {
this.mousePosition = { ...position };
}
// Cleanup
cleanup() {
// Clear reconnect timer
if (this.connectionInfo.reconnectTimer) {
clearTimeout(this.connectionInfo.reconnectTimer);
this.connectionInfo.reconnectTimer = null;
}
// Close WebSocket
if (this.websocket) {
this.websocket.close();
this.websocket = null;
}
// Clear all data
this.messages = [];
this.consoleLogs = [];
this.ports.clear();
}
}
+683
View File
@@ -0,0 +1,683 @@
// page-helpers.js - Content script that provides helper functions
let kaptureIdCounter = 0;
function getUniqueSelector(element) {
if (!element || !(element instanceof Element)) return null;
// Special handling for html, head, and body elements - their tagName is unique
const tagName = element.tagName.toLowerCase();
if (tagName === 'html' || tagName === 'head' || tagName === 'body') {
return tagName;
}
// If element has an ID, use it (unless it's empty or contains special chars)
if (element.id && /^[a-zA-Z][\w-]*$/.test(element.id)) {
// Check if ID is truly unique
if (document.querySelectorAll('#' + CSS.escape(element.id)).length === 1) {
return '#' + CSS.escape(element.id);
}
}
const uniqueId = 'kapture-' + (++kaptureIdCounter)
if (!element.id) {
element.id = uniqueId;
return '#' + uniqueId;
}
element.classList.add(uniqueId);
return '.' + uniqueId
}
function findScrollableParent(element) {
function isScrollable(element) {
const hasScrollableContent = element.scrollHeight > element.clientHeight ||
element.scrollWidth > element.clientWidth;
if (!hasScrollableContent) return false;
const style = getComputedStyle(element);
return /(auto|scroll)/.test(style.overflow + style.overflowY + style.overflowX);
}
let parent = element.parentElement;
while (parent && parent !== document.body) {
if (isScrollable(parent)) return parent;
parent = parent.parentElement;
}
return document.documentElement;
}
function serializeValue(value, depth = 0, maxDepth = 3, seen = new WeakSet()) {
// Handle primitive types
if (value === null || value === undefined) return value;
if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
return value;
}
if (typeof value === 'function') return '[Function: ' + (value.name || 'anonymous') + ']';
if (typeof value === 'symbol') return value.toString();
if (value instanceof Date) return value.toISOString();
if (value instanceof RegExp) return value.toString();
// Handle errors
if (value instanceof Error) {
return {
name: value.name,
message: value.message,
stack: value.stack
};
}
// Handle DOM elements
if (value instanceof Element) {
const selector = getUniqueSelector(value);
return {
nodeType: 'ELEMENT_NODE',
selector: selector,
tagName: value.tagName,
id: value.id || undefined,
className: value.className || undefined,
attributes: Array.from(value.attributes).reduce((acc, attr) => {
acc[attr.name] = attr.value;
return acc;
}, {})
};
}
// Prevent infinite recursion
if (depth >= maxDepth) {
return '[Max depth reached]';
}
// Handle circular references
if (typeof value === 'object' && seen.has(value)) {
return '[Circular reference]';
}
// Mark object as seen
if (typeof value === 'object') {
seen.add(value);
}
// Handle arrays
if (Array.isArray(value)) {
return value.map(item => serializeValue(item, depth + 1, maxDepth, seen));
}
// Handle NodeList and HTMLCollection
if (value instanceof NodeList || value instanceof HTMLCollection) {
return {
nodeType: value instanceof NodeList ? 'NodeList' : 'HTMLCollection',
length: value.length,
items: Array.from(value).map(item => serializeValue(item, depth + 1, maxDepth, seen))
};
}
// Handle typed arrays
if (ArrayBuffer.isView(value)) {
return {
type: value.constructor.name,
length: value.length,
data: '[Binary data]'
};
}
// Handle other objects
if (typeof value === 'object') {
const result = {};
const keys = Object.keys(value);
// Limit number of keys to prevent huge objects
const maxKeys = 100;
const limitedKeys = keys.slice(0, maxKeys);
for (const key of limitedKeys) {
try {
const serialized = this.serializeValue(value[key], depth + 1, maxDepth, seen);
if (serialized === undefined || serialized === null) continue; // Skip undefined values
result[key] = serialized;
} catch (e) {
result[key] = '[Error accessing property]';
}
}
if (keys.length > maxKeys) {
result['...'] = `${keys.length - maxKeys} more properties`;
}
return result;
}
// Fallback for unknown types
return String(value);
}
function getTabInfo() {
const de = document.documentElement;
return {
url: window.location.href,
title: document.title,
domSize: de.outerHTML.length,
fullPageDimensions: { width: de.scrollWidth, height: de.scrollHeight },
viewportDimensions: { width: window.innerWidth, height: window.innerHeight },
scrollPosition: { x: window.pageXOffset || de.scrollLeft, y: window.pageYOffset || de.scrollTop },
pageVisibility: { visible: !document.hidden, visibilityState: document.visibilityState }
};
}
function findAllElements(selector, xpath) {
if (selector) {
try {
return Array.from(document.querySelectorAll(selector));
} catch (e) {
throw new Error(`Invalid selector: ${e.message}`);
}
}
try {
const result = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
return Array.from({length: result.snapshotLength}, (_, i) => result.snapshotItem(i));
}
catch (e) {
throw new Error(`Invalid XPath: ${e.message}`);
}
}
function getElementData(element) {
const rect = element.getBoundingClientRect();
const computedStyle = window.getComputedStyle(element);
// Get the selector (which may add an ID to the element)
const selector = getUniqueSelector(element);
// Comprehensive visibility check
const visible = isElementVisible(element, rect, computedStyle);
const data = {
tagName: element.tagName.toLowerCase(),
id: element.id || undefined,
className: element.className || undefined,
selector: selector,
bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
visible: visible,
focused: element === document.activeElement,
position: computedStyle.position
};
// Conditionally add attributes
["href", "src", "value", "name"].forEach(attr => element[attr] && (data[attr] = element[attr]));
// If it's a select element, add the options
if (data.tagName === 'select') {
data.options = Array.from(element.options).map((option, optionIndex) => ({
index: optionIndex,
value: option.value,
text: option.text,
selected: option.selected,
disabled: option.disabled
}));
}
// Add scrollable parent if exists
const scrollParent = findScrollableParent(element);
data.scrollParent = getUniqueSelector(scrollParent);
return data;
}
function isElementVisible(element, rect, computedStyle) {
// If rect and computedStyle not provided, calculate them
if (!rect) rect = element.getBoundingClientRect();
if (!computedStyle) computedStyle = window.getComputedStyle(element);
// Check if element has dimensions
if (rect.width <= 0 || rect.height <= 0) return false;
// Check CSS visibility properties
if (computedStyle.display === 'none' || computedStyle.visibility === 'hidden' || computedStyle.opacity === '0') {
return false;
}
// Check if element is in viewport
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
// Check if any part of the element is within the viewport
const inViewport = rect.bottom > 0 &&
rect.right > 0 &&
rect.top < viewportHeight &&
rect.left < viewportWidth;
if (!inViewport) return false;
// Get element's center point (used multiple times below)
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
// Helper function to check if element is visible at a point
const isElementAtPoint = (x, y) => {
const elementAtPoint = document.elementFromPoint(x, y);
if (!elementAtPoint) return false;
return elementAtPoint === element || element.contains(elementAtPoint) || elementAtPoint.contains(element);
};
// Check if element is visible at center point - this is the most reliable check
if (isElementAtPoint(centerX, centerY)) return true;
// Check if element is hidden by ancestor's properties
let parent = element.parentElement;
while (parent && parent !== document.body) {
const parentStyle = window.getComputedStyle(parent);
if (parentStyle.display === 'none' || parentStyle.visibility === 'hidden' || parentStyle.opacity === '0') {
return false;
}
// Check for overflow hidden that might hide the element
if (parentStyle.overflow === 'hidden' || parentStyle.overflowX === 'hidden' || parentStyle.overflowY === 'hidden') {
const parentRect = parent.getBoundingClientRect();
// Check if element is outside parent's visible area
if (rect.bottom < parentRect.top || rect.top > parentRect.bottom || rect.right < parentRect.left || rect.left > parentRect.right) {
// Before returning false, check if the element is actually visible using elementsFromPoint
const elementsAtPoint = document.elementsFromPoint(centerX, centerY);
// If the element is in the elements chain at its center point, it's visible
if (elementsAtPoint.includes(element)) {
continue; // Skip this parent check and continue checking other parents
}
return false;
}
}
parent = parent.parentElement;
}
// Element might be partially covered, check multiple points
const points = [
{ x: rect.left + rect.width * 0.1, y: rect.top + rect.height * 0.1 },
{ x: rect.right - rect.width * 0.1, y: rect.top + rect.height * 0.1 },
{ x: rect.left + rect.width * 0.1, y: rect.bottom - rect.height * 0.1 },
{ x: rect.right - rect.width * 0.1, y: rect.bottom - rect.height * 0.1 }
];
// Check if any of the points hit our element
return points.some(point => isElementAtPoint(point.x, point.y));
}
function respondWith(obj, selector, xpath) {
return {
success: !obj.error,
selector,
xpath: !selector ? xpath : undefined,
...getTabInfo(),
...obj
};
}
function respondWithError(code, message, selector, xpath) {
return respondWith({ error: { code, message } }, selector, xpath);
}
function elementNotFound(selector, xpath) {
return respondWithError('ELEMENT_NOT_FOUND', 'Element not found', selector, xpath);
}
function requireSelectorOrXpath(selector, xpath) {
return respondWithError('SELECTOR_OR_XPATH_REQUIRED', 'Selector or XPath parameter required', selector, xpath);
}
const helpers = {
//called by the background script
_navigate: ({ url }) => {
window.location.href = url;
},
_elementPosition: ({ id }) => {
const element = document.getElementById(id);
return element.getBoundingClientRect();
},
_connectionStateChanged: ({ status, connected }) => {
// Remove existing connection classes
document.body.classList.remove('kapture-connected', 'kapture-connecting');
// Add appropriate class based on status
if (status === 'connected') {
document.body.classList.add('kapture-connected');
} else if (status === 'retrying') {
document.body.classList.add('kapture-connecting');
}
// No class for disconnected state
return { success: true };
},
// tool calls
getTabInfo,
/**
* Limits the nesting depth of HTML by replacing deeply nested elements with comments
* indicating how many more layers exist.
*/
limitDOMNesting: (htmlString, maxNesting) => {
if (typeof maxNesting !== 'number' || maxNesting < 0) {
return htmlString;
}
// Parse the HTML string
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
// Get the root element (could be body or a specific element)
const root = doc.body.firstElementChild || doc.body;
/**
* Counts the maximum nesting depth from a given element
*/
function countDepth(element) {
if (!element || !element.children || element.children.length === 0) {
return 0;
}
let maxChildDepth = 0;
for (const child of element.children) {
maxChildDepth = Math.max(maxChildDepth, countDepth(child));
}
return 1 + maxChildDepth;
}
/**
* Recursively processes elements and limits nesting depth
*/
function processElement(element, currentDepth) {
if (!element || !element.children) return;
// If we've reached the max nesting depth
if (currentDepth >= maxNesting) {
// Count how many more layers exist
const remainingDepth = countDepth(element);
if (remainingDepth > 0) {
// Replace all children with a comment
const comment = doc.createComment(` ... ${remainingDepth} more layer${remainingDepth === 1 ? '' : 's'} of nesting `);
// Remove all children
while (element.firstChild) {
element.removeChild(element.firstChild);
}
// Add the comment
element.appendChild(comment);
}
return;
}
// Process children recursively
const children = Array.from(element.children);
for (const child of children) {
processElement(child, currentDepth + 1);
}
}
// Start processing from depth 0
processElement(root, 0);
// Return the serialized HTML
return root.outerHTML;
},
dom: ({selector, xpath, maxNesting}) => {
let html;
if (!selector && !xpath) {
html = document.body.outerHTML;
} else {
const element = findAllElements(selector, xpath)[0];
if (!element) return elementNotFound(selector, xpath);
html = element.outerHTML;
}
// Apply nesting limit (default is 5)
const nestingLimit = typeof maxNesting === 'number' && maxNesting >= 0 ? maxNesting : 5;
html = window.pageHelpers.limitDOMNesting(html, nestingLimit);
return respondWith({ html }, selector, xpath);
},
elementsFromPoint: ({x, y}) => {
if (typeof x !== 'number' || typeof y !== 'number') {
return respondWithError('XY_REQUIRED', 'Both x and y coordinates are required');
}
const elements = document.elementsFromPoint(x, y);
return respondWith({ x, y, elements: elements.map(getElementData) });
},
elements: ({selector, xpath, visible = 'all'}) => {
if (!selector && !xpath) return requireSelectorOrXpath();
let elements;
try {
elements = findAllElements(selector, xpath).map(getElementData);
} catch (e) {
const errorCode = selector ? 'INVALID_SELECTOR' : 'INVALID_XPATH';
return respondWithError(errorCode, e.message, selector, xpath);
}
// Apply visibility filter
if (visible !== 'all') {
const filterVisible = String(visible) === 'true';
elements = elements.filter(el => el.visible === filterVisible);
}
return respondWith({elements: elements, visible: visible !== 'all' ? visible : undefined}, selector, xpath);
},
element: ({selector, xpath, visible = 'all'}) => {
const result = helpers.elements({selector, xpath, visible});
if (result.error) return result;
if (!result.elements.length) return elementNotFound(selector, xpath);
result.element = result.elements[0];
delete result.elements;
return result;
},
focus: ({selector, xpath}) => {
if (!selector && !xpath) return requireSelectorOrXpath();
let element;
try {
element = findAllElements(selector, xpath)[0];
} catch (e) {
const errorCode = selector ? 'INVALID_SELECTOR' : 'INVALID_XPATH';
return respondWithError(errorCode, e.message, selector, xpath);
}
if (!element) return elementNotFound(selector, xpath);
// Focus the element
element.focus();
// Check if element is actually focusable
const focusableElements = ['input', 'textarea', 'select', 'button', 'a'];
const tagName = element.tagName.toLowerCase();
const isFocusable = focusableElements.includes(tagName) ||
element.hasAttribute('tabindex') ||
element.isContentEditable;
if (!isFocusable) {
// Still return success but with a warning
return respondWith({
focused: true,
warning: 'Element may not be focusable'
}, selector, xpath);
}
return respondWith({ focused: true }, selector, xpath);
},
fill: ({selector, xpath, value}) => {
if (!selector && !xpath) return requireSelectorOrXpath();
const element = findAllElements(selector, xpath)[0];
if (!element) return elementNotFound(selector, xpath);
// Check if it's an input element
const tagName = element.tagName.toLowerCase();
const inputTypes = ['input', 'textarea'];
if (!inputTypes.includes(tagName) && !element.isContentEditable) {
return respondWithError('INVALID_ELEMENT', 'Element is not fillable: ' + tagName, selector, xpath);
}
// Focus the element
element.focus();
// Clear existing value
if (element.value !== undefined) {
element.value = '';
} else if (element.isContentEditable) {
element.textContent = '';
}
// Set new value
if (element.value !== undefined) {
element.value = value;
} else if (element.isContentEditable) {
element.textContent = value;
}
// Trigger input and change events
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
// Blur to trigger any blur handlers
element.blur();
return respondWith({ filled: true }, selector, xpath);
},
select: ({selector, xpath, value}) => {
if (!selector && !xpath) return requireSelectorOrXpath();
const element = findAllElements(selector, xpath)[0];
if (!element) return elementNotFound(selector, xpath);
if (element.tagName !== 'SELECT') {
return respondWithError('INVALID_ELEMENT', 'Element is not fillable: ' + element.name, selector, xpath);
}
// Find option by value
const option = Array.from(element.options).find(opt => opt.value === value);
if (!option) {
return respondWithError('OPTION_NOT_FOUND', 'Option not found with value: ' + value, selector, xpath);
}
// Select the option
element.value = value;
option.selected = true;
// Trigger change event
element.dispatchEvent(new Event('change', { bubbles: true }));
return respondWith({ selected: true }, selector, xpath);
},
blur: ({selector, xpath}) => {
if (!selector && !xpath) return requireSelectorOrXpath();
let element;
try {
element = findAllElements(selector, xpath)[0];
} catch (e) {
const errorCode = selector ? 'INVALID_SELECTOR' : 'INVALID_XPATH';
return respondWithError(errorCode, e.message, selector, xpath);
}
if (!element) return elementNotFound(selector, xpath);
// Blur the element
element.blur();
// Also remove focus from document.activeElement if it's different
if (document.activeElement && document.activeElement !== element) {
document.activeElement.blur();
}
return respondWith({ blurred: true }, selector, xpath);
},
_cursor: ({show}) => {
const cursorId = 'kapture-cursor';
let cursor = document.getElementById(cursorId);
try {
if (show === false) {
// Hide cursor
if (cursor) {
cursor.style.display = 'none';
}
return respondWith({ visible: false });
}
// Show cursor - create if doesn't exist
if (!cursor) {
cursor = document.createElement('div');
cursor.id = cursorId;
// Create cursor SVG
cursor.innerHTML = `
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0 L0 16 L4.5 12.5 L7.5 20 L10 19 L7 11.5 L12 11 Z"
fill="white"
stroke="black"
stroke-width="1"/>
</svg>
`;
// Style the cursor container
cursor.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 20px;
height: 20px;
z-index: 2147483647;
pointer-events: none;
transform: translate(-2px, -2px);
transition: none;
will-change: transform;
`;
document.body.appendChild(cursor);
}
cursor.style.display = 'block';
return respondWith({ visible: true });
} catch (e) {
return respondWithError('CURSOR_ERROR', e.message);
}
},
_moveMouseSVG: ({x, y}) => {
if (typeof x !== 'number' || typeof y !== 'number') {
return respondWithError('XY_REQUIRED', 'Both x and y coordinates are required');
}
try {
const cursor = document.getElementById('kapture-cursor');
if (!cursor) {
return respondWithError('CURSOR_NOT_FOUND', 'Cursor element not found. Call _cursor with show=true first');
}
cursor.style.transform = `translate(${x - 2}px, ${y - 2}px)`;
return respondWith({ moved: true, x, y });
} catch (e) {
return respondWithError('MOVE_MOUSE_SVG_ERROR', e.message);
}
}
};
// Mouse position tracking with throttling
let lastMouseSendTime = 0;
const MOUSE_THROTTLE_MS = 50; // Throttle to 20 updates per second
document.addEventListener('mousemove', (event) => {
const now = Date.now();
if (now - lastMouseSendTime < MOUSE_THROTTLE_MS) return;
lastMouseSendTime = now;
chrome.runtime.sendMessage({
type: 'mousePosition',
x: event.clientX,
y: event.clientY
});
});
// Listen for requests from the extension
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (!request.command) return;
if (helpers[request.command]) {
const result = helpers[request.command](request.params);
Promise.resolve(result).then(sendResponse);
return true; // Keep channel open for async response
}
else {
sendResponse(respondWith({
error: { code: 'UNKNOWN_COMMAND', message: `Command '${request.command}' not found` }
}));
}
});
+377
View File
@@ -0,0 +1,377 @@
:root {
--bg-primary: #1e1e1e;
--bg-secondary: #252526;
--bg-tertiary: #2d2d30;
--text-primary: #cccccc;
--text-secondary: #969696;
--border-color: #464647;
--accent-blue: #3794ff;
--accent-green: #4ec9b0;
--accent-orange: #ff9800;
--accent-red: #f44336;
--hover-bg: #2a2a2a;
--selection-bg: #094771;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
font-size: 13px;
background: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
/* Connection Toggle */
.connection-toggle {
display: flex;
align-items: center;
gap: 12px;
}
/* Status */
.status {
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.status.disconnected {
color: #8ab4f8;
}
.status.retrying {
color: #fdd663;
}
.status.connected {
color: #81c995;
}
/* Toggle switch container */
.toggle-container {
position: relative;
width: 48px;
height: 24px;
flex-shrink: 0;
}
/* Hidden checkbox */
.toggle-input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
/* Toggle switch track */
.toggle-track {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #5f6368;
transition: all 0.3s ease;
border-radius: 24px;
}
/* Toggle switch thumb */
.toggle-track::before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: #e8eaed;
transition: all 0.3s ease;
border-radius: 50%;
}
/* Checked state */
.toggle-input:checked + .toggle-track {
background-color: #81c995;
}
.toggle-input:checked + .toggle-track::before {
transform: translateX(24px);
}
/* Retrying state - must come after checked state to override */
.retrying .toggle-input:checked + .toggle-track {
background-color: #5f5034;
}
.retrying .toggle-input:checked + .toggle-track::before {
background-color: #fdd663;
}
/* Spinner */
.spinner {
width: 12px;
height: 12px;
border: 2px solid rgba(253, 214, 99, 0.3);
border-top-color: #fdd663;
border-radius: 50%;
animation: spin 1s linear infinite;
display: none;
}
.status.retrying .spinner {
display: inline-block;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.tab-info {
color: var(--text-secondary);
font-size: 12px;
}
/* Messages Container */
.messages-container {
flex: 1;
display: flex;
flex-direction: column;
height: calc(100vh - 120px);
}
.messages-header {
display: flex;
padding: 8px 12px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
font-size: 12px;
color: var(--text-secondary);
}
.messages-header-data {
flex: 1;
}
.messages-header-time {
width: 80px;
text-align: right;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.clear-messages-button {
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s;
}
.clear-messages-button:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
}
/* Messages List */
.messages-list {
flex: 1;
overflow-y: auto;
background: var(--bg-primary);
}
.message {
display: flex;
padding: 6px 12px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: background 0.1s;
}
.message:hover {
background: var(--hover-bg);
}
.message.selected {
background: var(--selection-bg);
}
.message-data {
flex: 1;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.message-arrow {
margin-right: 8px;
font-weight: bold;
}
.message-arrow.outgoing {
color: var(--accent-blue);
}
.message-arrow.incoming {
color: var(--accent-green);
}
.message-time {
width: 80px;
text-align: right;
color: var(--text-secondary);
font-size: 11px;
}
.empty-state {
align-items: center;
justify-content: center;
height: 200px;
color: var(--text-secondary);
font-style: italic;
}
/* Show/hide based on parent class */
.messages-container .empty-state {
display: flex;
}
.messages-container .messages-list {
display: none;
}
.messages-container.has-messages .empty-state {
display: none;
}
.messages-container.has-messages .messages-list {
display: block;
}
/* Detail View */
.detail-container {
height: 200px;
display: none;
flex-direction: column;
border-top: 1px solid var(--border-color);
}
.detail-container.visible {
display: flex;
}
.resize-handle {
height: 4px;
background: var(--border-color);
cursor: ns-resize;
user-select: none;
}
.resize-handle:hover {
background: var(--accent-blue);
}
.detail-content {
flex: 1;
padding: 12px;
overflow: auto;
background: var(--bg-secondary);
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
font-size: 12px;
}
.detail-content pre {
margin: 0;
white-space: pre;
}
.detail-content img {
max-width: 100%;
height: auto;
}
/* Footer */
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 8px 12px;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
}
.console-count {
color: var(--text-secondary);
font-size: 12px;
}
.clear-button {
padding: 4px 12px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.clear-button:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
/* Layout */
.panel-container {
display: flex;
flex-direction: column;
height: 100vh;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="panel.css">
</head>
<body>
<div class="panel-container">
<!-- Header -->
<div class="header">
<span class="tab-info" id="tab-info">Tab: Not connected</span>
<div class="connection-toggle">
<div class="status disconnected" id="status">
<span class="spinner"></span>
<span class="status-text">Disconnected</span>
</div>
<label class="toggle-container">
<input type="checkbox" class="toggle-input" id="toggle">
<span class="toggle-track"></span>
</label>
</div>
</div>
<!-- Messages Container -->
<div class="messages-container">
<div class="messages-header">
<div class="messages-header-data">Date</div>
<div class="messages-header-time">
Time
<button class="clear-messages-button" id="clear-messages" title="Clear messages">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14zM10 11v6M14 11v6"/>
</svg>
</button>
</div>
</div>
<div class="empty-state" id="empty-state">No messages yet</div>
<div class="messages-list" id="messages-list"></div>
<!-- Detail View -->
<div class="detail-container" id="detail-container">
<div class="resize-handle" id="resize-handle"></div>
<div class="detail-content" id="detail-content"></div>
</div>
</div>
<!-- Footer -->
<div class="footer">
<span class="console-count" id="console-count">Console: 0</span>
<button class="clear-button" id="clear-logs">Clear Logs</button>
</div>
</div>
<script src="panel.js"></script>
</body>
</html>
+304
View File
@@ -0,0 +1,304 @@
// DevTools Panel UI with Real Connection and Data
// Debug what you're actually getting
console.log('inspectedWindow.tabId:', chrome.devtools.inspectedWindow.tabId);
// Let's also check what chrome.tabs.query gives us for comparison
chrome.tabs.query({}, (tabs) => {
console.log('All tabs:', tabs);
// Find the tab with the same ID
const inspectedTab = tabs.find(tab => tab.id === chrome.devtools.inspectedWindow.tabId);
console.log('Inspected tab details:', inspectedTab);
});
// Also check the inspected window URL
console.log('inspectedWindow.url:', chrome.devtools.inspectedWindow.url);
const tabId = chrome.devtools.inspectedWindow.tabId;
let selectedMessageIndex = -1;
let messages = [];
let consoleLogCount = 0;
let port = null;
// Initialize UI
function initializeUI() {
// Connect to background script
port = chrome.runtime.connect({ name: 'panel' });
// Listen for state updates
port.onMessage.addListener((msg) => {
if (msg.type === 'state' && msg.tabId === tabId) {
updateUI(msg.connected, msg.status);
} else if (msg.type === 'messages' && msg.tabId === tabId) {
// Update messages from background
messages = msg.messages || [];
renderMessages();
} else if (msg.type === 'consoleCount' && msg.tabId === tabId) {
// Update console count from background
consoleLogCount = msg.count || 0;
updateConsoleCount();
}
});
// Subscribe to state updates for this tab
port.postMessage({ type: 'subscribe', tabId });
// Event listeners
document.getElementById('toggle').addEventListener('change', handleToggleChange);
document.getElementById('clear-logs').addEventListener('click', handleClearLogs);
document.getElementById('clear-messages').addEventListener('click', handleClearMessages);
document.getElementById('messages-list').addEventListener('click', handleMessageClick);
document.addEventListener('keydown', handleKeyDown);
// Resize handle
initializeResizeHandle();
}
// Update UI based on connection state
function updateUI(connected, status = 'disconnected') {
const toggle = document.getElementById('toggle');
const toggleContainer = toggle.parentElement;
const statusEl = document.getElementById('status');
const statusText = statusEl.querySelector('.status-text');
const tabInfo = document.getElementById('tab-info');
// Remove existing classes
statusEl.classList.remove('connected', 'disconnected', 'retrying');
toggleContainer.classList.remove('connected', 'disconnected', 'retrying');
switch (status) {
case 'connected':
toggle.checked = true;
toggle.disabled = false;
statusEl.classList.add('connected');
toggleContainer.classList.add('connected');
statusText.textContent = 'Connected';
tabInfo.textContent = `Tab: ${tabId} - Connected`;
break;
case 'retrying':
toggle.checked = true;
toggle.disabled = false;
statusEl.classList.add('retrying');
toggleContainer.classList.add('retrying');
statusText.textContent = 'Retrying...';
tabInfo.textContent = `Tab: ${tabId} - Reconnecting`;
break;
case 'disconnected':
default:
toggle.checked = false;
toggle.disabled = false;
statusEl.classList.add('disconnected');
toggleContainer.classList.add('disconnected');
statusText.textContent = 'Disconnected';
tabInfo.textContent = 'Tab: Not connected';
break;
}
}
// Render messages
function renderMessages() {
const messagesList = document.getElementById('messages-list');
const messagesContainer = document.querySelector('.messages-container');
// Toggle class based on whether we have messages
messagesContainer.classList.toggle('has-messages', messages.length > 0);
if (messages.length === 0) {
return;
}
messagesList.innerHTML = '';
messages.forEach((msg, index) => {
const messageEl = document.createElement('div');
messageEl.className = 'message';
if (index === selectedMessageIndex) {
messageEl.classList.add('selected');
}
const arrow = msg.direction === 'outgoing' ? '&#x2B06' : '&#x2B07'; // Up arrow for outgoing, down arrow for incoming
const arrowClass = msg.direction === 'outgoing' ? 'outgoing' : 'incoming';
// Show the raw JSON data
const dataText = JSON.stringify(msg.data);
messageEl.innerHTML = `
<div class="message-data">
<span class="message-arrow ${arrowClass}">${arrow}</span>
${dataText}
</div>
<div class="message-time">${formatTime(msg.timestamp)}</div>
`;
messageEl.dataset.index = index;
messagesList.appendChild(messageEl);
});
// Scroll to bottom
messagesList.scrollTop = messagesList.scrollHeight;
}
// Format timestamp
function formatTime(date) {
// Convert string to Date if needed
const dateObj = typeof date === 'string' ? new Date(date) : date;
return dateObj.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
// Handle message click
function handleMessageClick(e) {
const messageEl = e.target.closest('.message');
if (!messageEl) return;
const index = parseInt(messageEl.dataset.index);
selectMessage(index);
}
// Select message
function selectMessage(index) {
selectedMessageIndex = index;
// Update selected state
document.querySelectorAll('.message').forEach((el, i) => {
el.classList.toggle('selected', i === index);
});
// Show detail view
const detailContainer = document.getElementById('detail-container');
const detailContent = document.getElementById('detail-content');
if (index >= 0 && index < messages.length) {
detailContainer.classList.add('visible');
const message = messages[index];
const data = message.data;
// Check if this is a screenshot response
if (data.type === 'response' && data.success && data.result && data.result.data &&
data.result.mimeType && data.result.mimeType.startsWith('image/')) {
// Create image preview for screenshot
const img = document.createElement('img');
img.src = `data:${data.result.mimeType};base64,${data.result.data}`;
img.style.cssText = 'max-width: 100%; height: auto; cursor: pointer; display: block; border: 1px solid #e0e0e0; border-radius: 4px;';
img.title = 'Click to open in new tab';
// Open in new tab on click
img.addEventListener('click', () => {
window.open(img.src, '_blank');
});
// Show image and data
detailContent.innerHTML = '';
detailContent.appendChild(img);
// Add the rest of the data below the image
const dataInfo = document.createElement('pre');
dataInfo.textContent = JSON.stringify(data.result, null, 2);
dataInfo.style.cssText = 'margin-top: 10px; font-size: 12px; overflow: auto;';
detailContent.appendChild(dataInfo);
} else {
// Show only the actual message data, not the wrapper
detailContent.innerHTML = '';
const pre = document.createElement('pre');
pre.style.cssText = 'margin: 0; font-size: 12px; overflow: auto;';
pre.textContent = JSON.stringify(message.data, null, 2);
detailContent.appendChild(pre);
}
} else {
detailContainer.classList.remove('visible');
}
}
// Handle keyboard navigation
function handleKeyDown(e) {
if (e.key === 'ArrowUp' && selectedMessageIndex > 0) {
selectMessage(selectedMessageIndex - 1);
e.preventDefault();
} else if (e.key === 'ArrowDown' && selectedMessageIndex < messages.length - 1) {
selectMessage(selectedMessageIndex + 1);
e.preventDefault();
} else if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
// Clear messages
handleClearMessages();
e.preventDefault();
}
}
// Handle toggle change
function handleToggleChange(e) {
const checked = e.target.checked;
chrome.runtime.sendMessage(
{
type: checked ? 'connect' : 'disconnect',
tabId: tabId
},
(response) => {
if (response?.error) {
console.error('Toggle error:', response.error);
}
}
);
}
// Handle clear logs
function handleClearLogs() {
// Request background to clear console logs
port.postMessage({ type: 'clearConsoleLogs', tabId });
}
// Handle clear messages
function handleClearMessages() {
// Request background to clear messages
port.postMessage({ type: 'clearMessages', tabId });
// Clear the detail view
const detailContainer = document.getElementById('detail-container');
detailContainer.classList.remove('visible');
selectedMessageIndex = -1;
}
// Update console count
function updateConsoleCount() {
document.getElementById('console-count').textContent = `Console: ${consoleLogCount}`;
}
// Initialize resize handle
function initializeResizeHandle() {
const resizeHandle = document.getElementById('resize-handle');
const detailContainer = document.getElementById('detail-container');
let isResizing = false;
let startY = 0;
let startHeight = 0;
resizeHandle.addEventListener('mousedown', (e) => {
isResizing = true;
startY = e.clientY;
startHeight = detailContainer.offsetHeight;
document.body.style.cursor = 'ns-resize';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const deltaY = startY - e.clientY;
const newHeight = Math.min(Math.max(100, startHeight + deltaY), 500);
detailContainer.style.height = `${newHeight}px`;
});
document.addEventListener('mouseup', () => {
isResizing = false;
document.body.style.cursor = '';
});
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initializeUI);
+130
View File
@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
margin: 0;
padding: 16px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
width: 180px;
background-color: #202124;
display: flex;
align-items: center;
gap: 12px;
}
/* Toggle switch container */
.toggle-container {
position: relative;
width: 48px;
height: 24px;
flex-shrink: 0;
}
/* Hidden checkbox */
.toggle-input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
/* Toggle switch track */
.toggle-track {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #5f6368;
transition: all 0.3s ease;
border-radius: 24px;
}
/* Toggle switch thumb */
.toggle-track::before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: #e8eaed;
transition: all 0.3s ease;
border-radius: 50%;
}
/* Checked state */
.toggle-input:checked + .toggle-track {
background-color: #81c995;
}
.toggle-input:checked + .toggle-track::before {
transform: translateX(24px);
}
/* Retrying state - must come after checked state to override */
.retrying .toggle-input:checked + .toggle-track {
background-color: #5f5034;
}
.retrying .toggle-input:checked + .toggle-track::before {
background-color: #fdd663;
}
/* Status text */
.status {
color: #e8eaed;
font-size: 14px;
font-weight: 500;
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
.status.disconnected {
color: #8ab4f8;
}
.status.retrying {
color: #fdd663;
}
.status.connected {
color: #81c995;
}
/* Spinner */
.spinner {
width: 12px;
height: 12px;
border: 2px solid rgba(253, 214, 99, 0.3);
border-top-color: #fdd663;
border-radius: 50%;
animation: spin 1s linear infinite;
display: none;
}
.status.retrying .spinner {
display: inline-block;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="status disconnected" id="status">
<span class="spinner"></span>
<span class="status-text">Disconnected</span>
</div>
<label class="toggle-container">
<input type="checkbox" class="toggle-input" id="toggle">
<span class="toggle-track"></span>
</label>
<script src="popup.js"></script>
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
// Popup UI
let tabId;
let port;
let isUpdatingUI = false;
// Get current tab and set up
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
tabId = tabs[0].id;
// Connect to background
port = chrome.runtime.connect();
port.postMessage({ type: 'subscribe', tabId });
// Listen for state updates
port.onMessage.addListener((msg) => {
if (msg.type === 'state' && msg.tabId === tabId) {
updateUI(msg.connected, msg.status);
}
});
// Get initial state
chrome.runtime.sendMessage({ type: 'getState', tabId }, (state) => {
updateUI(state.connected, state.status);
});
});
// Handle toggle switch
document.getElementById('toggle').addEventListener('change', (e) => {
if (isUpdatingUI) return; // Prevent feedback loop
if (e.target.checked) {
chrome.runtime.sendMessage({ type: 'connect', tabId });
} else {
chrome.runtime.sendMessage({ type: 'disconnect', tabId });
}
});
// Update UI
function updateUI(connected, status = 'disconnected') {
isUpdatingUI = true;
const toggle = document.getElementById('toggle');
const toggleContainer = toggle.parentElement;
const statusEl = document.getElementById('status');
const statusText = statusEl.querySelector('.status-text');
// Remove all state classes
statusEl.classList.remove('connected', 'disconnected', 'retrying');
toggleContainer.classList.remove('connected', 'disconnected', 'retrying');
switch (status) {
case 'connected':
toggle.checked = true;
toggle.disabled = false;
statusEl.classList.add('connected');
toggleContainer.classList.add('connected');
statusText.textContent = 'Connected';
break;
case 'retrying':
toggle.checked = true;
toggle.disabled = false;
statusEl.classList.add('retrying');
toggleContainer.classList.add('retrying');
statusText.textContent = 'Connecting';
break;
case 'disconnected':
default:
toggle.checked = false;
toggle.disabled = false;
statusEl.classList.add('disconnected');
toggleContainer.classList.add('disconnected');
statusText.textContent = 'Disconnected';
break;
}
setTimeout(() => { isUpdatingUI = false; }, 100);
}
+48
View File
@@ -0,0 +1,48 @@
# Playwright MCP Chrome Extension
## Introduction
The Playwright MCP Chrome Extension allows you to connect to pages in your existing browser and leverage the state of your default user profile. This means the AI assistant can interact with websites where you're already logged in, using your existing cookies, sessions, and browser state, providing a seamless experience without requiring separate authentication or setup.
## Prerequisites
- Chrome/Edge/Chromium browser
## Installation Steps
### Download the Extension
Download the latest Chrome extension from GitHub:
- **Download link**: https://github.com/microsoft/playwright-mcp/releases
### Load Chrome Extension
1. Open Chrome and navigate to `chrome://extensions/`
2. Enable "Developer mode" (toggle in the top right corner)
3. Click "Load unpacked" and select the extension directory
### Configure Playwright MCP server
Configure Playwright MCP server to connect to the browser using the extension by passing the `--extension` option when running the MCP server:
```json
{
"mcpServers": {
"playwright-extension": {
"command": "npx",
"args": [
"@playwright/mcp@latest",
"--extension"
]
}
}
}
```
## Usage
### Browser Tab Selection
When the LLM interacts with the browser for the first time, it will load a page where you can select which browser tab the LLM will connect to. This allows you to control which specific page the AI assistant will interact with during the session.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+35
View File
@@ -0,0 +1,35 @@
{
"manifest_version": 3,
"name": "Playwright MCP Bridge",
"version": "0.0.46",
"description": "Share browser tabs with Playwright MCP server",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB",
"permissions": [
"debugger",
"activeTab",
"tabs",
"storage"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "lib/background.mjs",
"type": "module"
},
"action": {
"default_title": "Playwright MCP Bridge",
"default_icon": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"icons": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
}
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@playwright/mcp-extension",
"version": "0.0.46",
"description": "Playwright MCP Browser Extension",
"private": true,
"repository": {
"type": "git",
"url": "git+https://github.com/microsoft/playwright-mcp.git"
},
"homepage": "https://playwright.dev",
"engines": {
"node": ">=18"
},
"author": {
"name": "Microsoft Corporation"
},
"license": "Apache-2.0",
"scripts": {
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build && vite build --config vite.sw.config.mts",
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch & vite build --watch --config vite.sw.config.mts",
"test": "playwright test",
"clean": "rm -rf dist"
},
"devDependencies": {
"@types/chrome": "^0.0.315",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.8.2",
"vite": "^5.4.21",
"vite-plugin-static-copy": "^3.1.1"
}
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { defineConfig } from '@playwright/test';
import type { TestOptions } from '../tests/fixtures';
export default defineConfig<TestOptions>({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
projects: [
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
],
});
+222
View File
@@ -0,0 +1,222 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RelayConnection, debugLog } from './relayConnection';
type PageMessage = {
type: 'connectToMCPRelay';
mcpRelayUrl: string;
} | {
type: 'getTabs';
} | {
type: 'connectToTab';
tabId?: number;
windowId?: number;
mcpRelayUrl: string;
} | {
type: 'getConnectionStatus';
} | {
type: 'disconnect';
};
class TabShareExtension {
private _activeConnection: RelayConnection | undefined;
private _connectedTabId: number | null = null;
private _pendingTabSelection = new Map<number, { connection: RelayConnection, timerId?: number }>();
constructor() {
chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this));
chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this));
chrome.tabs.onActivated.addListener(this._onTabActivated.bind(this));
chrome.runtime.onMessage.addListener(this._onMessage.bind(this));
chrome.action.onClicked.addListener(this._onActionClicked.bind(this));
}
// Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
switch (message.type) {
case 'connectToMCPRelay':
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl).then(
() => sendResponse({ success: true }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true;
case 'getTabs':
this._getTabs().then(
tabs => sendResponse({ success: true, tabs, currentTabId: sender.tab?.id }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true;
case 'connectToTab':
const tabId = message.tabId || sender.tab?.id!;
const windowId = message.windowId || sender.tab?.windowId!;
this._connectTab(sender.tab!.id!, tabId, windowId, message.mcpRelayUrl!).then(
() => sendResponse({ success: true }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true; // Return true to indicate that the response will be sent asynchronously
case 'getConnectionStatus':
sendResponse({
connectedTabId: this._connectedTabId
});
return false;
case 'disconnect':
this._disconnect().then(
() => sendResponse({ success: true }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true;
}
return false;
}
private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string): Promise<void> {
try {
debugLog(`Connecting to relay at ${mcpRelayUrl}`);
const socket = new WebSocket(mcpRelayUrl);
await new Promise<void>((resolve, reject) => {
socket.onopen = () => resolve();
socket.onerror = () => reject(new Error('WebSocket error'));
setTimeout(() => reject(new Error('Connection timeout')), 5000);
});
const connection = new RelayConnection(socket);
connection.onclose = () => {
debugLog('Connection closed');
this._pendingTabSelection.delete(selectorTabId);
// TODO: show error in the selector tab?
};
this._pendingTabSelection.set(selectorTabId, { connection });
debugLog(`Connected to MCP relay`);
} catch (error: any) {
const message = `Failed to connect to MCP relay: ${error.message}`;
debugLog(message);
throw new Error(message);
}
}
private async _connectTab(selectorTabId: number, tabId: number, windowId: number, mcpRelayUrl: string): Promise<void> {
try {
debugLog(`Connecting tab ${tabId} to relay at ${mcpRelayUrl}`);
try {
this._activeConnection?.close('Another connection is requested');
} catch (error: any) {
debugLog(`Error closing active connection:`, error);
}
await this._setConnectedTabId(null);
this._activeConnection = this._pendingTabSelection.get(selectorTabId)?.connection;
if (!this._activeConnection)
throw new Error('No active MCP relay connection');
this._pendingTabSelection.delete(selectorTabId);
this._activeConnection.setTabId(tabId);
this._activeConnection.onclose = () => {
debugLog('MCP connection closed');
this._activeConnection = undefined;
void this._setConnectedTabId(null);
};
await Promise.all([
this._setConnectedTabId(tabId),
chrome.tabs.update(tabId, { active: true }),
chrome.windows.update(windowId, { focused: true }),
]);
debugLog(`Connected to MCP bridge`);
} catch (error: any) {
await this._setConnectedTabId(null);
debugLog(`Failed to connect tab ${tabId}:`, error.message);
throw error;
}
}
private async _setConnectedTabId(tabId: number | null): Promise<void> {
const oldTabId = this._connectedTabId;
this._connectedTabId = tabId;
if (oldTabId && oldTabId !== tabId)
await this._updateBadge(oldTabId, { text: '' });
if (tabId)
await this._updateBadge(tabId, { text: '✓', color: '#4CAF50', title: 'Connected to MCP client' });
}
private async _updateBadge(tabId: number, { text, color, title }: { text: string; color?: string, title?: string }): Promise<void> {
try {
await chrome.action.setBadgeText({ tabId, text });
await chrome.action.setTitle({ tabId, title: title || '' });
if (color)
await chrome.action.setBadgeBackgroundColor({ tabId, color });
} catch (error: any) {
// Ignore errors as the tab may be closed already.
}
}
private async _onTabRemoved(tabId: number): Promise<void> {
const pendingConnection = this._pendingTabSelection.get(tabId)?.connection;
if (pendingConnection) {
this._pendingTabSelection.delete(tabId);
pendingConnection.close('Browser tab closed');
return;
}
if (this._connectedTabId !== tabId)
return;
this._activeConnection?.close('Browser tab closed');
this._activeConnection = undefined;
this._connectedTabId = null;
}
private _onTabActivated(activeInfo: chrome.tabs.TabActiveInfo) {
for (const [tabId, pending] of this._pendingTabSelection) {
if (tabId === activeInfo.tabId) {
if (pending.timerId) {
clearTimeout(pending.timerId);
pending.timerId = undefined;
}
continue;
}
if (!pending.timerId) {
pending.timerId = setTimeout(() => {
const existed = this._pendingTabSelection.delete(tabId);
if (existed) {
pending.connection.close('Tab has been inactive for 5 seconds');
chrome.tabs.sendMessage(tabId, { type: 'connectionTimeout' });
}
}, 5000);
return;
}
}
}
private _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) {
if (this._connectedTabId === tabId)
void this._setConnectedTabId(tabId);
}
private async _getTabs(): Promise<chrome.tabs.Tab[]> {
const tabs = await chrome.tabs.query({});
return tabs.filter(tab => tab.url && !['chrome:', 'edge:', 'devtools:'].some(scheme => tab.url!.startsWith(scheme)));
}
private async _onActionClicked(): Promise<void> {
await chrome.tabs.create({
url: chrome.runtime.getURL('status.html'),
active: true
});
}
private async _disconnect(): Promise<void> {
this._activeConnection?.close('User disconnected');
this._activeConnection = undefined;
await this._setConnectedTabId(null);
}
}
new TabShareExtension();
+178
View File
@@ -0,0 +1,178 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export function debugLog(...args: unknown[]): void {
const enabled = true;
if (enabled) {
// eslint-disable-next-line no-console
console.log('[Extension]', ...args);
}
}
type ProtocolCommand = {
id: number;
method: string;
params?: any;
};
type ProtocolResponse = {
id?: number;
method?: string;
params?: any;
result?: any;
error?: string;
};
export class RelayConnection {
private _debuggee: chrome.debugger.Debuggee;
private _ws: WebSocket;
private _eventListener: (source: chrome.debugger.DebuggerSession, method: string, params: any) => void;
private _detachListener: (source: chrome.debugger.Debuggee, reason: string) => void;
private _tabPromise: Promise<void>;
private _tabPromiseResolve!: () => void;
private _closed = false;
onclose?: () => void;
constructor(ws: WebSocket) {
this._debuggee = { };
this._tabPromise = new Promise(resolve => this._tabPromiseResolve = resolve);
this._ws = ws;
this._ws.onmessage = this._onMessage.bind(this);
this._ws.onclose = () => this._onClose();
// Store listeners for cleanup
this._eventListener = this._onDebuggerEvent.bind(this);
this._detachListener = this._onDebuggerDetach.bind(this);
chrome.debugger.onEvent.addListener(this._eventListener);
chrome.debugger.onDetach.addListener(this._detachListener);
}
// Either setTabId or close is called after creating the connection.
setTabId(tabId: number): void {
this._debuggee = { tabId };
this._tabPromiseResolve();
}
close(message: string): void {
this._ws.close(1000, message);
// ws.onclose is called asynchronously, so we call it here to avoid forwarding
// CDP events to the closed connection.
this._onClose();
}
private _onClose() {
if (this._closed)
return;
this._closed = true;
chrome.debugger.onEvent.removeListener(this._eventListener);
chrome.debugger.onDetach.removeListener(this._detachListener);
chrome.debugger.detach(this._debuggee).catch(() => {});
this.onclose?.();
}
private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void {
if (source.tabId !== this._debuggee.tabId)
return;
debugLog('Forwarding CDP event:', method, params);
const sessionId = source.sessionId;
this._sendMessage({
method: 'forwardCDPEvent',
params: {
sessionId,
method,
params,
},
});
}
private _onDebuggerDetach(source: chrome.debugger.Debuggee, reason: string): void {
if (source.tabId !== this._debuggee.tabId)
return;
this.close(`Debugger detached: ${reason}`);
this._debuggee = { };
}
private _onMessage(event: MessageEvent): void {
this._onMessageAsync(event).catch(e => debugLog('Error handling message:', e));
}
private async _onMessageAsync(event: MessageEvent): Promise<void> {
let message: ProtocolCommand;
try {
message = JSON.parse(event.data);
} catch (error: any) {
debugLog('Error parsing message:', error);
this._sendError(-32700, `Error parsing message: ${error.message}`);
return;
}
debugLog('Received message:', message);
const response: ProtocolResponse = {
id: message.id,
};
try {
response.result = await this._handleCommand(message);
} catch (error: any) {
debugLog('Error handling command:', error);
response.error = error.message;
}
debugLog('Sending response:', response);
this._sendMessage(response);
}
private async _handleCommand(message: ProtocolCommand): Promise<any> {
if (message.method === 'attachToTab') {
await this._tabPromise;
debugLog('Attaching debugger to tab:', this._debuggee);
await chrome.debugger.attach(this._debuggee, '1.3');
const result: any = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo');
return {
targetInfo: result?.targetInfo,
};
}
if (!this._debuggee.tabId)
throw new Error('No tab is connected. Please go to the Playwright MCP extension and select the tab you want to connect to.');
if (message.method === 'forwardCDPCommand') {
const { sessionId, method, params } = message.params;
debugLog('CDP command:', method, params);
const debuggerSession: chrome.debugger.DebuggerSession = {
...this._debuggee,
sessionId,
};
// Forward CDP command to chrome.debugger
return await chrome.debugger.sendCommand(
debuggerSession,
method,
params
);
}
}
private _sendError(code: number, message: string): void {
this._sendMessage({
error: {
code,
message,
},
});
}
private _sendMessage(message: any): void {
if (this._ws.readyState === WebSocket.OPEN)
this._ws.send(JSON.stringify(message));
}
}
+142
View File
@@ -0,0 +1,142 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.auth-token-section {
margin: 16px 0;
padding: 16px;
background-color: #f6f8fa;
border-radius: 6px;
}
.auth-token-description {
font-size: 12px;
color: #656d76;
margin-bottom: 12px;
}
.auth-token-container {
display: flex;
align-items: center;
gap: 8px;
background-color: #ffffff;
padding: 8px;
}
.auth-token-code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
color: #1f2328;
border: none;
flex: 1;
padding: 0;
word-break: break-all;
}
.auth-token-refresh {
flex: none;
height: 24px;
width: 24px;
border: none;
outline: none;
color: var(--color-fg-muted);
background: transparent;
padding: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.auth-token-refresh svg {
margin: 0;
}
.auth-token-refresh:not(:disabled):hover {
background-color: var(--color-btn-selected-bg);
}
.auth-token-example-section {
margin-top: 16px;
}
.auth-token-example-toggle {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
padding: 8px 0;
font-size: 12px;
color: #656d76;
cursor: pointer;
outline: none;
text-align: left;
width: 100%;
}
.auth-token-example-toggle:hover {
color: #1f2328;
}
.auth-token-chevron {
display: inline-flex;
align-items: center;
justify-content: center;
transform: rotate(-90deg);
flex-shrink: 0;
}
.auth-token-chevron.expanded {
transform: rotate(0deg);
}
.auth-token-chevron svg {
width: 12px;
height: 12px;
}
.auth-token-chevron .octicon {
margin: 0px;
}
.auth-token-example-content {
margin-top: 12px;
padding: 12px 0;
}
.auth-token-example-description {
font-size: 12px;
color: #656d76;
margin-bottom: 12px;
}
.auth-token-example-config {
display: flex;
align-items: flex-start;
gap: 8px;
background-color: #ffffff;
padding: 12px;
}
.auth-token-example-code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 11px;
color: #1f2328;
white-space: pre;
flex: 1;
line-height: 1.4;
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useCallback, useState } from 'react';
import { CopyToClipboard } from './copyToClipboard';
import * as icons from './icons';
import './authToken.css';
export const AuthTokenSection: React.FC<{}> = ({}) => {
const [authToken, setAuthToken] = useState<string>(getOrCreateAuthToken);
const [isExampleExpanded, setIsExampleExpanded] = useState<boolean>(false);
const onRegenerateToken = useCallback(() => {
const newToken = generateAuthToken();
localStorage.setItem('auth-token', newToken);
setAuthToken(newToken);
}, []);
const toggleExample = useCallback(() => {
setIsExampleExpanded(!isExampleExpanded);
}, [isExampleExpanded]);
return (
<div className='auth-token-section'>
<div className='auth-token-description'>
Set this environment variable to bypass the connection dialog:
</div>
<div className='auth-token-container'>
<code className='auth-token-code'>{authTokenCode(authToken)}</code>
<button className='auth-token-refresh' title='Generate new token' aria-label='Generate new token'onClick={onRegenerateToken}>{icons.refresh()}</button>
<CopyToClipboard value={authTokenCode(authToken)} />
</div>
<div className='auth-token-example-section'>
<button
className='auth-token-example-toggle'
onClick={toggleExample}
aria-expanded={isExampleExpanded}
title={isExampleExpanded ? 'Hide example config' : 'Show example config'}
>
<span className={`auth-token-chevron ${isExampleExpanded ? 'expanded' : ''}`}>
{icons.chevronDown()}
</span>
Example MCP server configuration
</button>
{isExampleExpanded && (
<div className='auth-token-example-content'>
<div className='auth-token-example-description'>
Add this configuration to your MCP client (e.g., VS Code) to connect to the Playwright MCP Bridge:
</div>
<div className='auth-token-example-config'>
<code className='auth-token-example-code'>{exampleConfig(authToken)}</code>
<CopyToClipboard value={exampleConfig(authToken)} />
</div>
</div>
)}
</div>
</div>
);
};
function authTokenCode(authToken: string) {
return `PLAYWRIGHT_MCP_EXTENSION_TOKEN=${authToken}`;
}
function exampleConfig(authToken: string) {
return `{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--extension"],
"env": {
"PLAYWRIGHT_MCP_EXTENSION_TOKEN":
"${authToken}"
}
}
}
}`;
}
function generateAuthToken(): string {
// Generate a cryptographically secure random token
const array = new Uint8Array(32);
crypto.getRandomValues(array);
// Convert to base64 and make it URL-safe
return btoa(String.fromCharCode.apply(null, Array.from(array)))
.replace(/[+/=]/g, match => {
switch (match) {
case '+': return '-';
case '/': return '_';
case '=': return '';
default: return match;
}
});
}
export const getOrCreateAuthToken = (): string => {
let token = localStorage.getItem('auth-token');
if (!token) {
token = generateAuthToken();
localStorage.setItem('auth-token', token);
}
return token;
}
+891
View File
@@ -0,0 +1,891 @@
/* The MIT License (MIT)
Copyright (c) 2021 GitHub Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. */
:root {
--color-canvas-default-transparent: rgba(255,255,255,0);
--color-marketing-icon-primary: #218bff;
--color-marketing-icon-secondary: #54aeff;
--color-diff-blob-addition-num-text: #24292f;
--color-diff-blob-addition-fg: #24292f;
--color-diff-blob-addition-num-bg: #CCFFD8;
--color-diff-blob-addition-line-bg: #E6FFEC;
--color-diff-blob-addition-word-bg: #ABF2BC;
--color-diff-blob-deletion-num-text: #24292f;
--color-diff-blob-deletion-fg: #24292f;
--color-diff-blob-deletion-num-bg: #FFD7D5;
--color-diff-blob-deletion-line-bg: #FFEBE9;
--color-diff-blob-deletion-word-bg: rgba(255,129,130,0.4);
--color-diff-blob-hunk-num-bg: rgba(84,174,255,0.4);
--color-diff-blob-expander-icon: #57606a;
--color-diff-blob-selected-line-highlight-mix-blend-mode: multiply;
--color-diffstat-deletion-border: rgba(27,31,36,0.15);
--color-diffstat-addition-border: rgba(27,31,36,0.15);
--color-diffstat-addition-bg: #2da44e;
--color-search-keyword-hl: #fff8c5;
--color-prettylights-syntax-comment: #6e7781;
--color-prettylights-syntax-constant: #0550ae;
--color-prettylights-syntax-entity: #8250df;
--color-prettylights-syntax-storage-modifier-import: #24292f;
--color-prettylights-syntax-entity-tag: #116329;
--color-prettylights-syntax-keyword: #cf222e;
--color-prettylights-syntax-string: #0a3069;
--color-prettylights-syntax-variable: #953800;
--color-prettylights-syntax-brackethighlighter-unmatched: #82071e;
--color-prettylights-syntax-invalid-illegal-text: #f6f8fa;
--color-prettylights-syntax-invalid-illegal-bg: #82071e;
--color-prettylights-syntax-carriage-return-text: #f6f8fa;
--color-prettylights-syntax-carriage-return-bg: #cf222e;
--color-prettylights-syntax-string-regexp: #116329;
--color-prettylights-syntax-markup-list: #3b2300;
--color-prettylights-syntax-markup-heading: #0550ae;
--color-prettylights-syntax-markup-italic: #24292f;
--color-prettylights-syntax-markup-bold: #24292f;
--color-prettylights-syntax-markup-deleted-text: #82071e;
--color-prettylights-syntax-markup-deleted-bg: #FFEBE9;
--color-prettylights-syntax-markup-inserted-text: #116329;
--color-prettylights-syntax-markup-inserted-bg: #dafbe1;
--color-prettylights-syntax-markup-changed-text: #953800;
--color-prettylights-syntax-markup-changed-bg: #ffd8b5;
--color-prettylights-syntax-markup-ignored-text: #eaeef2;
--color-prettylights-syntax-markup-ignored-bg: #0550ae;
--color-prettylights-syntax-meta-diff-range: #8250df;
--color-prettylights-syntax-brackethighlighter-angle: #57606a;
--color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f;
--color-prettylights-syntax-constant-other-reference-link: #0a3069;
--color-codemirror-text: #24292f;
--color-codemirror-bg: #ffffff;
--color-codemirror-gutters-bg: #ffffff;
--color-codemirror-guttermarker-text: #ffffff;
--color-codemirror-guttermarker-subtle-text: #6e7781;
--color-codemirror-linenumber-text: #57606a;
--color-codemirror-cursor: #24292f;
--color-codemirror-selection-bg: rgba(84,174,255,0.4);
--color-codemirror-activeline-bg: rgba(234,238,242,0.5);
--color-codemirror-matchingbracket-text: #24292f;
--color-codemirror-lines-bg: #ffffff;
--color-codemirror-syntax-comment: #24292f;
--color-codemirror-syntax-constant: #0550ae;
--color-codemirror-syntax-entity: #8250df;
--color-codemirror-syntax-keyword: #cf222e;
--color-codemirror-syntax-storage: #cf222e;
--color-codemirror-syntax-string: #0a3069;
--color-codemirror-syntax-support: #0550ae;
--color-codemirror-syntax-variable: #953800;
--color-checks-bg: #24292f;
--color-checks-run-border-width: 0px;
--color-checks-container-border-width: 0px;
--color-checks-text-primary: #f6f8fa;
--color-checks-text-secondary: #8c959f;
--color-checks-text-link: #54aeff;
--color-checks-btn-icon: #afb8c1;
--color-checks-btn-hover-icon: #f6f8fa;
--color-checks-btn-hover-bg: rgba(255,255,255,0.125);
--color-checks-input-text: #eaeef2;
--color-checks-input-placeholder-text: #8c959f;
--color-checks-input-focus-text: #8c959f;
--color-checks-input-bg: #32383f;
--color-checks-input-shadow: none;
--color-checks-donut-error: #fa4549;
--color-checks-donut-pending: #bf8700;
--color-checks-donut-success: #2da44e;
--color-checks-donut-neutral: #afb8c1;
--color-checks-dropdown-text: #afb8c1;
--color-checks-dropdown-bg: #32383f;
--color-checks-dropdown-border: #424a53;
--color-checks-dropdown-shadow: rgba(27,31,36,0.3);
--color-checks-dropdown-hover-text: #f6f8fa;
--color-checks-dropdown-hover-bg: #424a53;
--color-checks-dropdown-btn-hover-text: #f6f8fa;
--color-checks-dropdown-btn-hover-bg: #32383f;
--color-checks-scrollbar-thumb-bg: #57606a;
--color-checks-header-label-text: #d0d7de;
--color-checks-header-label-open-text: #f6f8fa;
--color-checks-header-border: #32383f;
--color-checks-header-icon: #8c959f;
--color-checks-line-text: #d0d7de;
--color-checks-line-num-text: rgba(140,149,159,0.75);
--color-checks-line-timestamp-text: #8c959f;
--color-checks-line-hover-bg: #32383f;
--color-checks-line-selected-bg: rgba(33,139,255,0.15);
--color-checks-line-selected-num-text: #54aeff;
--color-checks-line-dt-fm-text: #24292f;
--color-checks-line-dt-fm-bg: #9a6700;
--color-checks-gate-bg: rgba(125,78,0,0.15);
--color-checks-gate-text: #d0d7de;
--color-checks-gate-waiting-text: #afb8c1;
--color-checks-step-header-open-bg: #32383f;
--color-checks-step-error-text: #ff8182;
--color-checks-step-warning-text: #d4a72c;
--color-checks-logline-text: #8c959f;
--color-checks-logline-num-text: rgba(140,149,159,0.75);
--color-checks-logline-debug-text: #c297ff;
--color-checks-logline-error-text: #d0d7de;
--color-checks-logline-error-num-text: #ff8182;
--color-checks-logline-error-bg: rgba(164,14,38,0.15);
--color-checks-logline-warning-text: #d0d7de;
--color-checks-logline-warning-num-text: #d4a72c;
--color-checks-logline-warning-bg: rgba(125,78,0,0.15);
--color-checks-logline-command-text: #54aeff;
--color-checks-logline-section-text: #4ac26b;
--color-checks-ansi-black: #24292f;
--color-checks-ansi-black-bright: #32383f;
--color-checks-ansi-white: #d0d7de;
--color-checks-ansi-white-bright: #d0d7de;
--color-checks-ansi-gray: #8c959f;
--color-checks-ansi-red: #ff8182;
--color-checks-ansi-red-bright: #ffaba8;
--color-checks-ansi-green: #4ac26b;
--color-checks-ansi-green-bright: #6fdd8b;
--color-checks-ansi-yellow: #d4a72c;
--color-checks-ansi-yellow-bright: #eac54f;
--color-checks-ansi-blue: #54aeff;
--color-checks-ansi-blue-bright: #80ccff;
--color-checks-ansi-magenta: #c297ff;
--color-checks-ansi-magenta-bright: #d8b9ff;
--color-checks-ansi-cyan: #76e3ea;
--color-checks-ansi-cyan-bright: #b3f0ff;
--color-project-header-bg: #24292f;
--color-project-sidebar-bg: #ffffff;
--color-project-gradient-in: #ffffff;
--color-project-gradient-out: rgba(255,255,255,0);
--color-mktg-success: rgba(36,146,67,1);
--color-mktg-info: rgba(19,119,234,1);
--color-mktg-bg-shade-gradient-top: rgba(27,31,36,0.065);
--color-mktg-bg-shade-gradient-bottom: rgba(27,31,36,0);
--color-mktg-btn-bg-top: hsla(228,82%,66%,1);
--color-mktg-btn-bg-bottom: #4969ed;
--color-mktg-btn-bg-overlay-top: hsla(228,74%,59%,1);
--color-mktg-btn-bg-overlay-bottom: #3355e0;
--color-mktg-btn-text: #ffffff;
--color-mktg-btn-primary-bg-top: hsla(137,56%,46%,1);
--color-mktg-btn-primary-bg-bottom: #2ea44f;
--color-mktg-btn-primary-bg-overlay-top: hsla(134,60%,38%,1);
--color-mktg-btn-primary-bg-overlay-bottom: #22863a;
--color-mktg-btn-primary-text: #ffffff;
--color-mktg-btn-enterprise-bg-top: hsla(249,100%,72%,1);
--color-mktg-btn-enterprise-bg-bottom: #6f57ff;
--color-mktg-btn-enterprise-bg-overlay-top: hsla(248,65%,63%,1);
--color-mktg-btn-enterprise-bg-overlay-bottom: #614eda;
--color-mktg-btn-enterprise-text: #ffffff;
--color-mktg-btn-outline-text: #4969ed;
--color-mktg-btn-outline-border: rgba(73,105,237,0.3);
--color-mktg-btn-outline-hover-text: #3355e0;
--color-mktg-btn-outline-hover-border: rgba(51,85,224,0.5);
--color-mktg-btn-outline-focus-border: #4969ed;
--color-mktg-btn-outline-focus-border-inset: rgba(73,105,237,0.5);
--color-mktg-btn-dark-text: #ffffff;
--color-mktg-btn-dark-border: rgba(255,255,255,0.3);
--color-mktg-btn-dark-hover-text: #ffffff;
--color-mktg-btn-dark-hover-border: rgba(255,255,255,0.5);
--color-mktg-btn-dark-focus-border: #ffffff;
--color-mktg-btn-dark-focus-border-inset: rgba(255,255,255,0.5);
--color-avatar-bg: #ffffff;
--color-avatar-border: rgba(27,31,36,0.15);
--color-avatar-stack-fade: #afb8c1;
--color-avatar-stack-fade-more: #d0d7de;
--color-avatar-child-shadow: -2px -2px 0 rgba(255,255,255,0.8);
--color-topic-tag-border: rgba(0,0,0,0);
--color-select-menu-backdrop-border: rgba(0,0,0,0);
--color-select-menu-tap-highlight: rgba(175,184,193,0.5);
--color-select-menu-tap-focus-bg: #b6e3ff;
--color-overlay-shadow: 0 1px 3px rgba(27,31,36,0.12), 0 8px 24px rgba(66,74,83,0.12);
--color-header-text: rgba(255,255,255,0.7);
--color-header-bg: #24292f;
--color-header-logo: #ffffff;
--color-header-search-bg: #24292f;
--color-header-search-border: #57606a;
--color-sidenav-selected-bg: #ffffff;
--color-menu-bg-active: rgba(0,0,0,0);
--color-control-transparent-bg-hover: #818b981a;
--color-input-disabled-bg: rgba(175,184,193,0.2);
--color-timeline-badge-bg: #eaeef2;
--color-ansi-black: #24292f;
--color-ansi-black-bright: #57606a;
--color-ansi-white: #6e7781;
--color-ansi-white-bright: #8c959f;
--color-ansi-gray: #6e7781;
--color-ansi-red: #cf222e;
--color-ansi-red-bright: #a40e26;
--color-ansi-green: #116329;
--color-ansi-green-bright: #1a7f37;
--color-ansi-yellow: #4d2d00;
--color-ansi-yellow-bright: #633c01;
--color-ansi-blue: #0969da;
--color-ansi-blue-bright: #218bff;
--color-ansi-magenta: #8250df;
--color-ansi-magenta-bright: #a475f9;
--color-ansi-cyan: #1b7c83;
--color-ansi-cyan-bright: #3192aa;
--color-btn-text: #24292f;
--color-btn-bg: #f6f8fa;
--color-btn-border: rgba(27,31,36,0.15);
--color-btn-shadow: 0 1px 0 rgba(27,31,36,0.04);
--color-btn-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.25);
--color-btn-hover-bg: #f3f4f6;
--color-btn-hover-border: rgba(27,31,36,0.15);
--color-btn-active-bg: hsla(220,14%,93%,1);
--color-btn-active-border: rgba(27,31,36,0.15);
--color-btn-selected-bg: hsla(220,14%,94%,1);
--color-btn-focus-bg: #f6f8fa;
--color-btn-focus-border: rgba(27,31,36,0.15);
--color-btn-focus-shadow: 0 0 0 3px rgba(9,105,218,0.3);
--color-btn-shadow-active: inset 0 0.15em 0.3em rgba(27,31,36,0.15);
--color-btn-shadow-input-focus: 0 0 0 0.2em rgba(9,105,218,0.3);
--color-btn-counter-bg: rgba(27,31,36,0.08);
--color-btn-primary-text: #ffffff;
--color-btn-primary-bg: #2da44e;
--color-btn-primary-border: rgba(27,31,36,0.15);
--color-btn-primary-shadow: 0 1px 0 rgba(27,31,36,0.1);
--color-btn-primary-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
--color-btn-primary-hover-bg: #2c974b;
--color-btn-primary-hover-border: rgba(27,31,36,0.15);
--color-btn-primary-selected-bg: hsla(137,55%,36%,1);
--color-btn-primary-selected-shadow: inset 0 1px 0 rgba(0,45,17,0.2);
--color-btn-primary-disabled-text: rgba(255,255,255,0.8);
--color-btn-primary-disabled-bg: #94d3a2;
--color-btn-primary-disabled-border: rgba(27,31,36,0.15);
--color-btn-primary-focus-bg: #2da44e;
--color-btn-primary-focus-border: rgba(27,31,36,0.15);
--color-btn-primary-focus-shadow: 0 0 0 3px rgba(45,164,78,0.4);
--color-btn-primary-icon: rgba(255,255,255,0.8);
--color-btn-primary-counter-bg: rgba(255,255,255,0.2);
--color-btn-outline-text: #0969da;
--color-btn-outline-hover-text: #ffffff;
--color-btn-outline-hover-bg: #0969da;
--color-btn-outline-hover-border: rgba(27,31,36,0.15);
--color-btn-outline-hover-shadow: 0 1px 0 rgba(27,31,36,0.1);
--color-btn-outline-hover-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
--color-btn-outline-hover-counter-bg: rgba(255,255,255,0.2);
--color-btn-outline-selected-text: #ffffff;
--color-btn-outline-selected-bg: hsla(212,92%,42%,1);
--color-btn-outline-selected-border: rgba(27,31,36,0.15);
--color-btn-outline-selected-shadow: inset 0 1px 0 rgba(0,33,85,0.2);
--color-btn-outline-disabled-text: rgba(9,105,218,0.5);
--color-btn-outline-disabled-bg: #f6f8fa;
--color-btn-outline-disabled-counter-bg: rgba(9,105,218,0.05);
--color-btn-outline-focus-border: rgba(27,31,36,0.15);
--color-btn-outline-focus-shadow: 0 0 0 3px rgba(5,80,174,0.4);
--color-btn-outline-counter-bg: rgba(9,105,218,0.1);
--color-btn-danger-text: #cf222e;
--color-btn-danger-hover-text: #ffffff;
--color-btn-danger-hover-bg: #a40e26;
--color-btn-danger-hover-border: rgba(27,31,36,0.15);
--color-btn-danger-hover-shadow: 0 1px 0 rgba(27,31,36,0.1);
--color-btn-danger-hover-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
--color-btn-danger-hover-counter-bg: rgba(255,255,255,0.2);
--color-btn-danger-selected-text: #ffffff;
--color-btn-danger-selected-bg: hsla(356,72%,44%,1);
--color-btn-danger-selected-border: rgba(27,31,36,0.15);
--color-btn-danger-selected-shadow: inset 0 1px 0 rgba(76,0,20,0.2);
--color-btn-danger-disabled-text: rgba(207,34,46,0.5);
--color-btn-danger-disabled-bg: #f6f8fa;
--color-btn-danger-disabled-counter-bg: rgba(207,34,46,0.05);
--color-btn-danger-focus-border: rgba(27,31,36,0.15);
--color-btn-danger-focus-shadow: 0 0 0 3px rgba(164,14,38,0.4);
--color-btn-danger-counter-bg: rgba(207,34,46,0.1);
--color-btn-danger-icon: #cf222e;
--color-btn-danger-hover-icon: #ffffff;
--color-underlinenav-icon: #6e7781;
--color-underlinenav-border-hover: rgba(175,184,193,0.2);
--color-fg-default: #24292f;
--color-fg-muted: #57606a;
--color-fg-subtle: #6e7781;
--color-fg-on-emphasis: #ffffff;
--color-canvas-default: #ffffff;
--color-canvas-overlay: #ffffff;
--color-canvas-inset: #f6f8fa;
--color-canvas-subtle: #f6f8fa;
--color-border-default: #d0d7de;
--color-border-muted: hsla(210,18%,87%,1);
--color-border-subtle: rgba(27,31,36,0.15);
--color-shadow-small: 0 1px 0 rgba(27,31,36,0.04);
--color-shadow-medium: 0 3px 6px rgba(140,149,159,0.15);
--color-shadow-large: 0 8px 24px rgba(140,149,159,0.2);
--color-shadow-extra-large: 0 12px 28px rgba(140,149,159,0.3);
--color-neutral-emphasis-plus: #24292f;
--color-neutral-emphasis: #6e7781;
--color-neutral-muted: rgba(175,184,193,0.2);
--color-neutral-subtle: rgba(234,238,242,0.5);
--color-accent-fg: #0969da;
--color-accent-emphasis: #0969da;
--color-accent-muted: rgba(84,174,255,0.4);
--color-accent-subtle: #ddf4ff;
--color-success-fg: #1a7f37;
--color-success-emphasis: #2da44e;
--color-success-muted: rgba(74,194,107,0.4);
--color-success-subtle: #dafbe1;
--color-attention-fg: #9a6700;
--color-attention-emphasis: #bf8700;
--color-attention-muted: rgba(212,167,44,0.4);
--color-attention-subtle: #fff8c5;
--color-severe-fg: #bc4c00;
--color-severe-emphasis: #bc4c00;
--color-severe-muted: rgba(251,143,68,0.4);
--color-severe-subtle: #fff1e5;
--color-danger-fg: #cf222e;
--color-danger-emphasis: #cf222e;
--color-danger-muted: rgba(255,129,130,0.4);
--color-danger-subtle: #FFEBE9;
--color-done-fg: #8250df;
--color-done-emphasis: #8250df;
--color-done-muted: rgba(194,151,255,0.4);
--color-done-subtle: #fbefff;
--color-sponsors-fg: #bf3989;
--color-sponsors-emphasis: #bf3989;
--color-sponsors-muted: rgba(255,128,200,0.4);
--color-sponsors-subtle: #ffeff7;
--color-primer-canvas-backdrop: rgba(27,31,36,0.5);
--color-primer-canvas-sticky: rgba(255,255,255,0.95);
--color-primer-border-active: #FD8C73;
--color-primer-border-contrast: rgba(27,31,36,0.1);
--color-primer-shadow-highlight: inset 0 1px 0 rgba(255,255,255,0.25);
--color-primer-shadow-inset: inset 0 1px 0 rgba(208,215,222,0.2);
--color-primer-shadow-focus: 0 0 0 3px rgba(9,105,218,0.3);
--color-scale-black: #1b1f24;
--color-scale-white: #ffffff;
--color-scale-gray-0: #f6f8fa;
--color-scale-gray-1: #eaeef2;
--color-scale-gray-2: #d0d7de;
--color-scale-gray-3: #afb8c1;
--color-scale-gray-4: #8c959f;
--color-scale-gray-5: #6e7781;
--color-scale-gray-6: #57606a;
--color-scale-gray-7: #424a53;
--color-scale-gray-8: #32383f;
--color-scale-gray-9: #24292f;
--color-scale-blue-0: #ddf4ff;
--color-scale-blue-1: #b6e3ff;
--color-scale-blue-2: #80ccff;
--color-scale-blue-3: #54aeff;
--color-scale-blue-4: #218bff;
--color-scale-blue-5: #0969da;
--color-scale-blue-6: #0550ae;
--color-scale-blue-7: #033d8b;
--color-scale-blue-8: #0a3069;
--color-scale-blue-9: #002155;
--color-scale-green-0: #dafbe1;
--color-scale-green-1: #aceebb;
--color-scale-green-2: #6fdd8b;
--color-scale-green-3: #4ac26b;
--color-scale-green-4: #2da44e;
--color-scale-green-5: #1a7f37;
--color-scale-green-6: #116329;
--color-scale-green-7: #044f1e;
--color-scale-green-8: #003d16;
--color-scale-green-9: #002d11;
--color-scale-yellow-0: #fff8c5;
--color-scale-yellow-1: #fae17d;
--color-scale-yellow-2: #eac54f;
--color-scale-yellow-3: #d4a72c;
--color-scale-yellow-4: #bf8700;
--color-scale-yellow-5: #9a6700;
--color-scale-yellow-6: #7d4e00;
--color-scale-yellow-7: #633c01;
--color-scale-yellow-8: #4d2d00;
--color-scale-yellow-9: #3b2300;
--color-scale-orange-0: #fff1e5;
--color-scale-orange-1: #ffd8b5;
--color-scale-orange-2: #ffb77c;
--color-scale-orange-3: #fb8f44;
--color-scale-orange-4: #e16f24;
--color-scale-orange-5: #bc4c00;
--color-scale-orange-6: #953800;
--color-scale-orange-7: #762c00;
--color-scale-orange-8: #5c2200;
--color-scale-orange-9: #471700;
--color-scale-red-0: #FFEBE9;
--color-scale-red-1: #ffcecb;
--color-scale-red-2: #ffaba8;
--color-scale-red-3: #ff8182;
--color-scale-red-4: #fa4549;
--color-scale-red-5: #cf222e;
--color-scale-red-6: #a40e26;
--color-scale-red-7: #82071e;
--color-scale-red-8: #660018;
--color-scale-red-9: #4c0014;
--color-scale-purple-0: #fbefff;
--color-scale-purple-1: #ecd8ff;
--color-scale-purple-2: #d8b9ff;
--color-scale-purple-3: #c297ff;
--color-scale-purple-4: #a475f9;
--color-scale-purple-5: #8250df;
--color-scale-purple-6: #6639ba;
--color-scale-purple-7: #512a97;
--color-scale-purple-8: #3e1f79;
--color-scale-purple-9: #2e1461;
--color-scale-pink-0: #ffeff7;
--color-scale-pink-1: #ffd3eb;
--color-scale-pink-2: #ffadda;
--color-scale-pink-3: #ff80c8;
--color-scale-pink-4: #e85aad;
--color-scale-pink-5: #bf3989;
--color-scale-pink-6: #99286e;
--color-scale-pink-7: #772057;
--color-scale-pink-8: #611347;
--color-scale-pink-9: #4d0336;
--color-scale-coral-0: #FFF0EB;
--color-scale-coral-1: #FFD6CC;
--color-scale-coral-2: #FFB4A1;
--color-scale-coral-3: #FD8C73;
--color-scale-coral-4: #EC6547;
--color-scale-coral-5: #C4432B;
--color-scale-coral-6: #9E2F1C;
--color-scale-coral-7: #801F0F;
--color-scale-coral-8: #691105;
--color-scale-coral-9: #510901
}
@media(prefers-color-scheme: dark) {
:root {
--color-canvas-default-transparent: rgba(13,17,23,0);
--color-marketing-icon-primary: #79c0ff;
--color-marketing-icon-secondary: #1f6feb;
--color-diff-blob-addition-num-text: #c9d1d9;
--color-diff-blob-addition-fg: #c9d1d9;
--color-diff-blob-addition-num-bg: rgba(63,185,80,0.3);
--color-diff-blob-addition-line-bg: rgba(46,160,67,0.15);
--color-diff-blob-addition-word-bg: rgba(46,160,67,0.4);
--color-diff-blob-deletion-num-text: #c9d1d9;
--color-diff-blob-deletion-fg: #c9d1d9;
--color-diff-blob-deletion-num-bg: rgba(248,81,73,0.3);
--color-diff-blob-deletion-line-bg: rgba(248,81,73,0.15);
--color-diff-blob-deletion-word-bg: rgba(248,81,73,0.4);
--color-diff-blob-hunk-num-bg: rgba(56,139,253,0.4);
--color-diff-blob-expander-icon: #8b949e;
--color-diff-blob-selected-line-highlight-mix-blend-mode: screen;
--color-diffstat-deletion-border: rgba(240,246,252,0.1);
--color-diffstat-addition-border: rgba(240,246,252,0.1);
--color-diffstat-addition-bg: #3fb950;
--color-search-keyword-hl: rgba(210,153,34,0.4);
--color-prettylights-syntax-comment: #8b949e;
--color-prettylights-syntax-constant: #79c0ff;
--color-prettylights-syntax-entity: #d2a8ff;
--color-prettylights-syntax-storage-modifier-import: #c9d1d9;
--color-prettylights-syntax-entity-tag: #7ee787;
--color-prettylights-syntax-keyword: #ff7b72;
--color-prettylights-syntax-string: #a5d6ff;
--color-prettylights-syntax-variable: #ffa657;
--color-prettylights-syntax-brackethighlighter-unmatched: #f85149;
--color-prettylights-syntax-invalid-illegal-text: #f0f6fc;
--color-prettylights-syntax-invalid-illegal-bg: #8e1519;
--color-prettylights-syntax-carriage-return-text: #f0f6fc;
--color-prettylights-syntax-carriage-return-bg: #b62324;
--color-prettylights-syntax-string-regexp: #7ee787;
--color-prettylights-syntax-markup-list: #f2cc60;
--color-prettylights-syntax-markup-heading: #1f6feb;
--color-prettylights-syntax-markup-italic: #c9d1d9;
--color-prettylights-syntax-markup-bold: #c9d1d9;
--color-prettylights-syntax-markup-deleted-text: #ffdcd7;
--color-prettylights-syntax-markup-deleted-bg: #67060c;
--color-prettylights-syntax-markup-inserted-text: #aff5b4;
--color-prettylights-syntax-markup-inserted-bg: #033a16;
--color-prettylights-syntax-markup-changed-text: #ffdfb6;
--color-prettylights-syntax-markup-changed-bg: #5a1e02;
--color-prettylights-syntax-markup-ignored-text: #c9d1d9;
--color-prettylights-syntax-markup-ignored-bg: #1158c7;
--color-prettylights-syntax-meta-diff-range: #d2a8ff;
--color-prettylights-syntax-brackethighlighter-angle: #8b949e;
--color-prettylights-syntax-sublimelinter-gutter-mark: #484f58;
--color-prettylights-syntax-constant-other-reference-link: #a5d6ff;
--color-codemirror-text: #c9d1d9;
--color-codemirror-bg: #0d1117;
--color-codemirror-gutters-bg: #0d1117;
--color-codemirror-guttermarker-text: #0d1117;
--color-codemirror-guttermarker-subtle-text: #484f58;
--color-codemirror-linenumber-text: #8b949e;
--color-codemirror-cursor: #c9d1d9;
--color-codemirror-selection-bg: rgba(56,139,253,0.4);
--color-codemirror-activeline-bg: rgba(110,118,129,0.1);
--color-codemirror-matchingbracket-text: #c9d1d9;
--color-codemirror-lines-bg: #0d1117;
--color-codemirror-syntax-comment: #8b949e;
--color-codemirror-syntax-constant: #79c0ff;
--color-codemirror-syntax-entity: #d2a8ff;
--color-codemirror-syntax-keyword: #ff7b72;
--color-codemirror-syntax-storage: #ff7b72;
--color-codemirror-syntax-string: #a5d6ff;
--color-codemirror-syntax-support: #79c0ff;
--color-codemirror-syntax-variable: #ffa657;
--color-checks-bg: #010409;
--color-checks-run-border-width: 1px;
--color-checks-container-border-width: 1px;
--color-checks-text-primary: #c9d1d9;
--color-checks-text-secondary: #8b949e;
--color-checks-text-link: #58a6ff;
--color-checks-btn-icon: #8b949e;
--color-checks-btn-hover-icon: #c9d1d9;
--color-checks-btn-hover-bg: rgba(110,118,129,0.1);
--color-checks-input-text: #8b949e;
--color-checks-input-placeholder-text: #484f58;
--color-checks-input-focus-text: #c9d1d9;
--color-checks-input-bg: #161b22;
--color-checks-input-shadow: none;
--color-checks-donut-error: #f85149;
--color-checks-donut-pending: #d29922;
--color-checks-donut-success: #2ea043;
--color-checks-donut-neutral: #8b949e;
--color-checks-dropdown-text: #c9d1d9;
--color-checks-dropdown-bg: #161b22;
--color-checks-dropdown-border: #30363d;
--color-checks-dropdown-shadow: rgba(1,4,9,0.3);
--color-checks-dropdown-hover-text: #c9d1d9;
--color-checks-dropdown-hover-bg: rgba(110,118,129,0.1);
--color-checks-dropdown-btn-hover-text: #c9d1d9;
--color-checks-dropdown-btn-hover-bg: rgba(110,118,129,0.1);
--color-checks-scrollbar-thumb-bg: rgba(110,118,129,0.4);
--color-checks-header-label-text: #8b949e;
--color-checks-header-label-open-text: #c9d1d9;
--color-checks-header-border: #21262d;
--color-checks-header-icon: #8b949e;
--color-checks-line-text: #8b949e;
--color-checks-line-num-text: #484f58;
--color-checks-line-timestamp-text: #484f58;
--color-checks-line-hover-bg: rgba(110,118,129,0.1);
--color-checks-line-selected-bg: rgba(56,139,253,0.15);
--color-checks-line-selected-num-text: #58a6ff;
--color-checks-line-dt-fm-text: #f0f6fc;
--color-checks-line-dt-fm-bg: #9e6a03;
--color-checks-gate-bg: rgba(187,128,9,0.15);
--color-checks-gate-text: #8b949e;
--color-checks-gate-waiting-text: #d29922;
--color-checks-step-header-open-bg: #161b22;
--color-checks-step-error-text: #f85149;
--color-checks-step-warning-text: #d29922;
--color-checks-logline-text: #8b949e;
--color-checks-logline-num-text: #484f58;
--color-checks-logline-debug-text: #a371f7;
--color-checks-logline-error-text: #8b949e;
--color-checks-logline-error-num-text: #484f58;
--color-checks-logline-error-bg: rgba(248,81,73,0.15);
--color-checks-logline-warning-text: #8b949e;
--color-checks-logline-warning-num-text: #d29922;
--color-checks-logline-warning-bg: rgba(187,128,9,0.15);
--color-checks-logline-command-text: #58a6ff;
--color-checks-logline-section-text: #3fb950;
--color-checks-ansi-black: #0d1117;
--color-checks-ansi-black-bright: #161b22;
--color-checks-ansi-white: #b1bac4;
--color-checks-ansi-white-bright: #b1bac4;
--color-checks-ansi-gray: #6e7681;
--color-checks-ansi-red: #ff7b72;
--color-checks-ansi-red-bright: #ffa198;
--color-checks-ansi-green: #3fb950;
--color-checks-ansi-green-bright: #56d364;
--color-checks-ansi-yellow: #d29922;
--color-checks-ansi-yellow-bright: #e3b341;
--color-checks-ansi-blue: #58a6ff;
--color-checks-ansi-blue-bright: #79c0ff;
--color-checks-ansi-magenta: #bc8cff;
--color-checks-ansi-magenta-bright: #d2a8ff;
--color-checks-ansi-cyan: #76e3ea;
--color-checks-ansi-cyan-bright: #b3f0ff;
--color-project-header-bg: #0d1117;
--color-project-sidebar-bg: #161b22;
--color-project-gradient-in: #161b22;
--color-project-gradient-out: rgba(22,27,34,0);
--color-mktg-success: rgba(41,147,61,1);
--color-mktg-info: rgba(42,123,243,1);
--color-mktg-bg-shade-gradient-top: rgba(1,4,9,0.065);
--color-mktg-bg-shade-gradient-bottom: rgba(1,4,9,0);
--color-mktg-btn-bg-top: hsla(228,82%,66%,1);
--color-mktg-btn-bg-bottom: #4969ed;
--color-mktg-btn-bg-overlay-top: hsla(228,74%,59%,1);
--color-mktg-btn-bg-overlay-bottom: #3355e0;
--color-mktg-btn-text: #f0f6fc;
--color-mktg-btn-primary-bg-top: hsla(137,56%,46%,1);
--color-mktg-btn-primary-bg-bottom: #2ea44f;
--color-mktg-btn-primary-bg-overlay-top: hsla(134,60%,38%,1);
--color-mktg-btn-primary-bg-overlay-bottom: #22863a;
--color-mktg-btn-primary-text: #f0f6fc;
--color-mktg-btn-enterprise-bg-top: hsla(249,100%,72%,1);
--color-mktg-btn-enterprise-bg-bottom: #6f57ff;
--color-mktg-btn-enterprise-bg-overlay-top: hsla(248,65%,63%,1);
--color-mktg-btn-enterprise-bg-overlay-bottom: #614eda;
--color-mktg-btn-enterprise-text: #f0f6fc;
--color-mktg-btn-outline-text: #f0f6fc;
--color-mktg-btn-outline-border: rgba(240,246,252,0.3);
--color-mktg-btn-outline-hover-text: #f0f6fc;
--color-mktg-btn-outline-hover-border: rgba(240,246,252,0.5);
--color-mktg-btn-outline-focus-border: #f0f6fc;
--color-mktg-btn-outline-focus-border-inset: rgba(240,246,252,0.5);
--color-mktg-btn-dark-text: #f0f6fc;
--color-mktg-btn-dark-border: rgba(240,246,252,0.3);
--color-mktg-btn-dark-hover-text: #f0f6fc;
--color-mktg-btn-dark-hover-border: rgba(240,246,252,0.5);
--color-mktg-btn-dark-focus-border: #f0f6fc;
--color-mktg-btn-dark-focus-border-inset: rgba(240,246,252,0.5);
--color-avatar-bg: rgba(240,246,252,0.1);
--color-avatar-border: rgba(240,246,252,0.1);
--color-avatar-stack-fade: #30363d;
--color-avatar-stack-fade-more: #21262d;
--color-avatar-child-shadow: -2px -2px 0 #0d1117;
--color-topic-tag-border: rgba(0,0,0,0);
--color-select-menu-backdrop-border: #484f58;
--color-select-menu-tap-highlight: rgba(48,54,61,0.5);
--color-select-menu-tap-focus-bg: #0c2d6b;
--color-overlay-shadow: 0 0 0 1px #30363d, 0 16px 32px rgba(1,4,9,0.85);
--color-header-text: rgba(240,246,252,0.7);
--color-header-bg: #161b22;
--color-header-logo: #f0f6fc;
--color-header-search-bg: #0d1117;
--color-header-search-border: #30363d;
--color-sidenav-selected-bg: #21262d;
--color-menu-bg-active: #161b22;
--color-control-transparent-bg-hover: #656c7633;
--color-input-disabled-bg: rgba(110,118,129,0);
--color-timeline-badge-bg: #21262d;
--color-ansi-black: #484f58;
--color-ansi-black-bright: #6e7681;
--color-ansi-white: #b1bac4;
--color-ansi-white-bright: #f0f6fc;
--color-ansi-gray: #6e7681;
--color-ansi-red: #ff7b72;
--color-ansi-red-bright: #ffa198;
--color-ansi-green: #3fb950;
--color-ansi-green-bright: #56d364;
--color-ansi-yellow: #d29922;
--color-ansi-yellow-bright: #e3b341;
--color-ansi-blue: #58a6ff;
--color-ansi-blue-bright: #79c0ff;
--color-ansi-magenta: #bc8cff;
--color-ansi-magenta-bright: #d2a8ff;
--color-ansi-cyan: #39c5cf;
--color-ansi-cyan-bright: #56d4dd;
--color-btn-text: #c9d1d9;
--color-btn-bg: #21262d;
--color-btn-border: rgba(240,246,252,0.1);
--color-btn-shadow: 0 0 transparent;
--color-btn-inset-shadow: 0 0 transparent;
--color-btn-hover-bg: #30363d;
--color-btn-hover-border: #8b949e;
--color-btn-active-bg: hsla(212,12%,18%,1);
--color-btn-active-border: #6e7681;
--color-btn-selected-bg: #161b22;
--color-btn-focus-bg: #21262d;
--color-btn-focus-border: #8b949e;
--color-btn-focus-shadow: 0 0 0 3px rgba(139,148,158,0.3);
--color-btn-shadow-active: inset 0 0.15em 0.3em rgba(1,4,9,0.15);
--color-btn-shadow-input-focus: 0 0 0 0.2em rgba(31,111,235,0.3);
--color-btn-counter-bg: #30363d;
--color-btn-primary-text: #ffffff;
--color-btn-primary-bg: #238636;
--color-btn-primary-border: rgba(240,246,252,0.1);
--color-btn-primary-shadow: 0 0 transparent;
--color-btn-primary-inset-shadow: 0 0 transparent;
--color-btn-primary-hover-bg: #2ea043;
--color-btn-primary-hover-border: rgba(240,246,252,0.1);
--color-btn-primary-selected-bg: #238636;
--color-btn-primary-selected-shadow: 0 0 transparent;
--color-btn-primary-disabled-text: rgba(240,246,252,0.5);
--color-btn-primary-disabled-bg: rgba(35,134,54,0.6);
--color-btn-primary-disabled-border: rgba(240,246,252,0.1);
--color-btn-primary-focus-bg: #238636;
--color-btn-primary-focus-border: rgba(240,246,252,0.1);
--color-btn-primary-focus-shadow: 0 0 0 3px rgba(46,164,79,0.4);
--color-btn-primary-icon: #f0f6fc;
--color-btn-primary-counter-bg: rgba(240,246,252,0.2);
--color-btn-outline-text: #58a6ff;
--color-btn-outline-hover-text: #58a6ff;
--color-btn-outline-hover-bg: #30363d;
--color-btn-outline-hover-border: rgba(240,246,252,0.1);
--color-btn-outline-hover-shadow: 0 1px 0 rgba(1,4,9,0.1);
--color-btn-outline-hover-inset-shadow: inset 0 1px 0 rgba(240,246,252,0.03);
--color-btn-outline-hover-counter-bg: rgba(240,246,252,0.2);
--color-btn-outline-selected-text: #f0f6fc;
--color-btn-outline-selected-bg: #0d419d;
--color-btn-outline-selected-border: rgba(240,246,252,0.1);
--color-btn-outline-selected-shadow: 0 0 transparent;
--color-btn-outline-disabled-text: rgba(88,166,255,0.5);
--color-btn-outline-disabled-bg: #0d1117;
--color-btn-outline-disabled-counter-bg: rgba(31,111,235,0.05);
--color-btn-outline-focus-border: rgba(240,246,252,0.1);
--color-btn-outline-focus-shadow: 0 0 0 3px rgba(17,88,199,0.4);
--color-btn-outline-counter-bg: rgba(31,111,235,0.1);
--color-btn-danger-text: #f85149;
--color-btn-danger-hover-text: #f0f6fc;
--color-btn-danger-hover-bg: #da3633;
--color-btn-danger-hover-border: #f85149;
--color-btn-danger-hover-shadow: 0 0 transparent;
--color-btn-danger-hover-inset-shadow: 0 0 transparent;
--color-btn-danger-hover-icon: #f0f6fc;
--color-btn-danger-hover-counter-bg: rgba(255,255,255,0.2);
--color-btn-danger-selected-text: #ffffff;
--color-btn-danger-selected-bg: #b62324;
--color-btn-danger-selected-border: #ff7b72;
--color-btn-danger-selected-shadow: 0 0 transparent;
--color-btn-danger-disabled-text: rgba(248,81,73,0.5);
--color-btn-danger-disabled-bg: #0d1117;
--color-btn-danger-disabled-counter-bg: rgba(218,54,51,0.05);
--color-btn-danger-focus-border: #f85149;
--color-btn-danger-focus-shadow: 0 0 0 3px rgba(248,81,73,0.4);
--color-btn-danger-counter-bg: rgba(218,54,51,0.1);
--color-btn-danger-icon: #f85149;
--color-underlinenav-icon: #484f58;
--color-underlinenav-border-hover: rgba(110,118,129,0.4);
--color-fg-default: #c9d1d9;
--color-fg-muted: #8b949e;
--color-fg-subtle: #484f58;
--color-fg-on-emphasis: #f0f6fc;
--color-canvas-default: #0d1117;
--color-canvas-overlay: #161b22;
--color-canvas-inset: #010409;
--color-canvas-subtle: #161b22;
--color-border-default: #30363d;
--color-border-muted: #21262d;
--color-border-subtle: rgba(240,246,252,0.1);
--color-shadow-small: 0 0 transparent;
--color-shadow-medium: 0 3px 6px #010409;
--color-shadow-large: 0 8px 24px #010409;
--color-shadow-extra-large: 0 12px 48px #010409;
--color-neutral-emphasis-plus: #6e7681;
--color-neutral-emphasis: #6e7681;
--color-neutral-muted: rgba(110,118,129,0.4);
--color-neutral-subtle: rgba(110,118,129,0.1);
--color-accent-fg: #58a6ff;
--color-accent-emphasis: #1f6feb;
--color-accent-muted: rgba(56,139,253,0.4);
--color-accent-subtle: rgba(56,139,253,0.15);
--color-success-fg: #3fb950;
--color-success-emphasis: #238636;
--color-success-muted: rgba(46,160,67,0.4);
--color-success-subtle: rgba(46,160,67,0.15);
--color-attention-fg: #d29922;
--color-attention-emphasis: #9e6a03;
--color-attention-muted: rgba(187,128,9,0.4);
--color-attention-subtle: rgba(187,128,9,0.15);
--color-severe-fg: #db6d28;
--color-severe-emphasis: #bd561d;
--color-severe-muted: rgba(219,109,40,0.4);
--color-severe-subtle: rgba(219,109,40,0.15);
--color-danger-fg: #f85149;
--color-danger-emphasis: #da3633;
--color-danger-muted: rgba(248,81,73,0.4);
--color-danger-subtle: rgba(248,81,73,0.15);
--color-done-fg: #a371f7;
--color-done-emphasis: #8957e5;
--color-done-muted: rgba(163,113,247,0.4);
--color-done-subtle: rgba(163,113,247,0.15);
--color-sponsors-fg: #db61a2;
--color-sponsors-emphasis: #bf4b8a;
--color-sponsors-muted: rgba(219,97,162,0.4);
--color-sponsors-subtle: rgba(219,97,162,0.15);
--color-primer-canvas-backdrop: rgba(1,4,9,0.8);
--color-primer-canvas-sticky: rgba(13,17,23,0.95);
--color-primer-border-active: #F78166;
--color-primer-border-contrast: rgba(240,246,252,0.2);
--color-primer-shadow-highlight: 0 0 transparent;
--color-primer-shadow-inset: 0 0 transparent;
--color-primer-shadow-focus: 0 0 0 3px #0c2d6b;
--color-scale-black: #010409;
--color-scale-white: #f0f6fc;
--color-scale-gray-0: #f0f6fc;
--color-scale-gray-1: #c9d1d9;
--color-scale-gray-2: #b1bac4;
--color-scale-gray-3: #8b949e;
--color-scale-gray-4: #6e7681;
--color-scale-gray-5: #484f58;
--color-scale-gray-6: #30363d;
--color-scale-gray-7: #21262d;
--color-scale-gray-8: #161b22;
--color-scale-gray-9: #0d1117;
--color-scale-blue-0: #cae8ff;
--color-scale-blue-1: #a5d6ff;
--color-scale-blue-2: #79c0ff;
--color-scale-blue-3: #58a6ff;
--color-scale-blue-4: #388bfd;
--color-scale-blue-5: #1f6feb;
--color-scale-blue-6: #1158c7;
--color-scale-blue-7: #0d419d;
--color-scale-blue-8: #0c2d6b;
--color-scale-blue-9: #051d4d;
--color-scale-green-0: #aff5b4;
--color-scale-green-1: #7ee787;
--color-scale-green-2: #56d364;
--color-scale-green-3: #3fb950;
--color-scale-green-4: #2ea043;
--color-scale-green-5: #238636;
--color-scale-green-6: #196c2e;
--color-scale-green-7: #0f5323;
--color-scale-green-8: #033a16;
--color-scale-green-9: #04260f;
--color-scale-yellow-0: #f8e3a1;
--color-scale-yellow-1: #f2cc60;
--color-scale-yellow-2: #e3b341;
--color-scale-yellow-3: #d29922;
--color-scale-yellow-4: #bb8009;
--color-scale-yellow-5: #9e6a03;
--color-scale-yellow-6: #845306;
--color-scale-yellow-7: #693e00;
--color-scale-yellow-8: #4b2900;
--color-scale-yellow-9: #341a00;
--color-scale-orange-0: #ffdfb6;
--color-scale-orange-1: #ffc680;
--color-scale-orange-2: #ffa657;
--color-scale-orange-3: #f0883e;
--color-scale-orange-4: #db6d28;
--color-scale-orange-5: #bd561d;
--color-scale-orange-6: #9b4215;
--color-scale-orange-7: #762d0a;
--color-scale-orange-8: #5a1e02;
--color-scale-orange-9: #3d1300;
--color-scale-red-0: #ffdcd7;
--color-scale-red-1: #ffc1ba;
--color-scale-red-2: #ffa198;
--color-scale-red-3: #ff7b72;
--color-scale-red-4: #f85149;
--color-scale-red-5: #da3633;
--color-scale-red-6: #b62324;
--color-scale-red-7: #8e1519;
--color-scale-red-8: #67060c;
--color-scale-red-9: #490202;
--color-scale-purple-0: #eddeff;
--color-scale-purple-1: #e2c5ff;
--color-scale-purple-2: #d2a8ff;
--color-scale-purple-3: #bc8cff;
--color-scale-purple-4: #a371f7;
--color-scale-purple-5: #8957e5;
--color-scale-purple-6: #6e40c9;
--color-scale-purple-7: #553098;
--color-scale-purple-8: #3c1e70;
--color-scale-purple-9: #271052;
--color-scale-pink-0: #ffdaec;
--color-scale-pink-1: #ffbedd;
--color-scale-pink-2: #ff9bce;
--color-scale-pink-3: #f778ba;
--color-scale-pink-4: #db61a2;
--color-scale-pink-5: #bf4b8a;
--color-scale-pink-6: #9e3670;
--color-scale-pink-7: #7d2457;
--color-scale-pink-8: #5e103e;
--color-scale-pink-9: #42062a;
--color-scale-coral-0: #FFDDD2;
--color-scale-coral-1: #FFC2B2;
--color-scale-coral-2: #FFA28B;
--color-scale-coral-3: #F78166;
--color-scale-coral-4: #EA6045;
--color-scale-coral-5: #CF462D;
--color-scale-coral-6: #AC3220;
--color-scale-coral-7: #872012;
--color-scale-coral-8: #640D04;
--color-scale-coral-9: #460701
}
}
+262
View File
@@ -0,0 +1,262 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
body {
margin: 0;
padding: 0;
}
/* Base styles */
.app-container {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
background-color: #ffffff;
color: #1f2328;
margin: 0;
padding: 16px;
min-height: 100vh;
font-size: 14px;
}
.content-wrapper {
max-width: 600px;
margin: 0 auto;
}
/* Status Banner */
.status-container {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding-right: 12px;
}
.status-banner {
padding: 12px;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.status-banner.connected {
color: #1f2328;
}
.status-banner.connected::before {
content: "\2705";
margin-right: 8px;
}
.status-banner.error {
color: #1f2328;
}
.status-banner.error::before {
content: "\274C";
margin-right: 8px;
}
/* Buttons */
.button-container {
margin-bottom: 16px;
display: flex;
justify-content: flex-end;
padding-right: 12px;
}
.button {
padding: 8px 16px;
border-radius: 6px;
border: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
margin-right: 8px;
min-width: 90px;
}
.button.primary {
background-color: #f8f9fa;
color: #3c4043;
border: 1px solid #dadce0;
}
.button.primary:hover {
background-color: #f1f3f4;
border-color: #dadce0;
box-shadow: 0 1px 2px 0 rgba(60,64,67,.1);
}
.button.default {
background-color: #f6f8fa;
color: #24292f;
}
.button.default:hover {
background-color: #f3f4f6;
}
.button.reject {
background-color: #da3633;
color: #ffffff;
border: 1px solid #da3633;
}
.button.reject:hover {
background-color: #c73836;
border-color: #c73836;
}
/* Tab selection */
.tab-section-title {
padding-left: 12px;
font-size: 12px;
font-weight: 400;
margin-bottom: 12px;
color: #656d76;
}
.tab-item {
display: flex;
align-items: center;
padding: 12px;
margin-bottom: 8px;
background-color: #ffffff;
cursor: pointer;
border-radius: 6px;
transition: background-color 0.2s ease;
}
.tab-item:hover {
background-color: #f8f9fa;
}
.tab-item.selected {
background-color: #f6f8fa;
}
.tab-item.disabled {
cursor: not-allowed;
opacity: 0.5;
}
.tab-radio {
margin-right: 12px;
flex-shrink: 0;
}
.tab-favicon {
width: 16px;
height: 16px;
margin-right: 8px;
flex-shrink: 0;
}
.tab-content {
flex: 1;
min-width: 0;
}
.tab-title {
font-weight: 500;
color: #1f2328;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-url {
font-size: 12px;
color: #656d76;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Link-style button */
.link-button {
background: none;
border: none;
color: #0066cc;
text-decoration: underline;
cursor: pointer;
padding: 0;
font: inherit;
}
/* Auth token section */
.auth-token-section {
margin: 16px 0;
padding: 16px;
background-color: #f6f8fa;
border-radius: 6px;
}
.auth-token-description {
font-size: 12px;
color: #656d76;
margin-bottom: 12px;
}
.auth-token-container {
display: flex;
align-items: center;
gap: 8px;
background-color: #ffffff;
padding: 8px;
}
.auth-token-code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
color: #1f2328;
border: none;
flex: 1;
padding: 0;
word-break: break-all;
}
.auth-token-refresh {
flex: none;
height: 24px;
width: 24px;
border: none;
outline: none;
color: var(--color-fg-muted);
background: transparent;
padding: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.auth-token-refresh svg {
margin: 0;
}
.auth-token-refresh:not(:disabled):hover {
background-color: var(--color-btn-selected-bg);
}
+29
View File
@@ -0,0 +1,29 @@
<!--
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE html>
<html>
<head>
<title>Playwright MCP extension</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" sizes="32x32" href="../../icons/icon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="../../icons/icon-16.png">
<link rel="stylesheet" href="connect.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="connect.tsx"></script>
</body>
</html>
+253
View File
@@ -0,0 +1,253 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { Button, TabItem } from './tabItem';
import { AuthTokenSection, getOrCreateAuthToken } from './authToken';
import type { TabInfo } from './tabItem';
type Status =
| { type: 'connecting'; message: string }
| { type: 'connected'; message: string }
| { type: 'error'; message: string }
| { type: 'error'; versionMismatch: { extensionVersion: string; } };
const SUPPORTED_PROTOCOL_VERSION = 1;
const ConnectApp: React.FC = () => {
const [tabs, setTabs] = useState<TabInfo[]>([]);
const [status, setStatus] = useState<Status | null>(null);
const [showButtons, setShowButtons] = useState(true);
const [showTabList, setShowTabList] = useState(true);
const [clientInfo, setClientInfo] = useState('unknown');
const [mcpRelayUrl, setMcpRelayUrl] = useState('');
const [newTab, setNewTab] = useState<boolean>(false);
useEffect(() => {
const runAsync = async () => {
const params = new URLSearchParams(window.location.search);
const relayUrl = params.get('mcpRelayUrl');
if (!relayUrl) {
setShowButtons(false);
setStatus({ type: 'error', message: 'Missing mcpRelayUrl parameter in URL.' });
return;
}
setMcpRelayUrl(relayUrl);
try {
const client = JSON.parse(params.get('client') || '{}');
const info = `${client.name}/${client.version}`;
setClientInfo(info);
setStatus({
type: 'connecting',
message: `🎭 Playwright MCP started from "${info}" is trying to connect. Do you want to continue?`
});
} catch (e) {
setStatus({ type: 'error', message: 'Failed to parse client version.' });
return;
}
const parsedVersion = parseInt(params.get('protocolVersion') ?? '', 10);
const requiredVersion = isNaN(parsedVersion) ? 1 : parsedVersion;
if (requiredVersion > SUPPORTED_PROTOCOL_VERSION) {
const extensionVersion = chrome.runtime.getManifest().version;
setShowButtons(false);
setShowTabList(false);
setStatus({
type: 'error',
versionMismatch: {
extensionVersion,
}
});
return;
}
const expectedToken = getOrCreateAuthToken();
const token = params.get('token');
if (token === expectedToken) {
await connectToMCPRelay(relayUrl);
await handleConnectToTab();
return;
}
if (token) {
handleReject('Invalid token provided.');
return;
}
await connectToMCPRelay(relayUrl);
// If this is a browser_navigate command, hide the tab list and show simple allow/reject
if (params.get('newTab') === 'true') {
setNewTab(true);
setShowTabList(false);
} else {
await loadTabs();
}
};
void runAsync();
}, []);
const handleReject = useCallback((message: string) => {
setShowButtons(false);
setShowTabList(false);
setStatus({ type: 'error', message });
}, []);
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => {
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
if (!response.success)
handleReject(response.error);
}, [handleReject]);
const loadTabs = useCallback(async () => {
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
if (response.success)
setTabs(response.tabs);
else
setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error });
}, []);
const handleConnectToTab = useCallback(async (tab?: TabInfo) => {
setShowButtons(false);
setShowTabList(false);
try {
const response = await chrome.runtime.sendMessage({
type: 'connectToTab',
mcpRelayUrl,
tabId: tab?.id,
windowId: tab?.windowId,
});
if (response?.success) {
setStatus({ type: 'connected', message: `MCP client "${clientInfo}" connected.` });
} else {
setStatus({
type: 'error',
message: response?.error || `MCP client "${clientInfo}" failed to connect.`
});
}
} catch (e) {
setStatus({
type: 'error',
message: `MCP client "${clientInfo}" failed to connect: ${e}`
});
}
}, [clientInfo, mcpRelayUrl]);
useEffect(() => {
const listener = (message: any) => {
if (message.type === 'connectionTimeout')
handleReject('Connection timed out.');
};
chrome.runtime.onMessage.addListener(listener);
return () => {
chrome.runtime.onMessage.removeListener(listener);
};
}, [handleReject]);
return (
<div className='app-container'>
<div className='content-wrapper'>
{status && (
<div className='status-container'>
<StatusBanner status={status} />
{showButtons && (
<div className='button-container'>
{newTab ? (
<>
<Button variant='primary' onClick={() => handleConnectToTab()}>
Allow
</Button>
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
Reject
</Button>
</>
) : (
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
Reject
</Button>
)}
</div>
)}
</div>
)}
{status?.type === 'connecting' && (
<AuthTokenSection />
)}
{showTabList && (
<div>
<div className='tab-section-title'>
Select page to expose to MCP server:
</div>
<div>
{tabs.map(tab => (
<TabItem
key={tab.id}
tab={tab}
button={
<Button variant='primary' onClick={() => handleConnectToTab(tab)}>
Connect
</Button>
}
/>
))}
</div>
</div>
)}
</div>
</div>
);
};
const VersionMismatchError: React.FC<{ extensionVersion: string }> = ({ extensionVersion }) => {
const readmeUrl = 'https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md';
const latestReleaseUrl = 'https://github.com/microsoft/playwright-mcp/releases/latest';
return (
<div>
Playwright MCP version trying to connect requires newer extension version (current version: {extensionVersion}).{' '}
<a href={latestReleaseUrl}>Click here</a> to download latest version of the extension, then drag and drop it into the Chrome Extensions page.{' '}
See <a href={readmeUrl} target='_blank' rel='noopener noreferrer'>installation instructions</a> for more details.
</div>
);
};
const StatusBanner: React.FC<{ status: Status }> = ({ status }) => {
return (
<div className={`status-banner ${status.type}`}>
{'versionMismatch' in status ? (
<VersionMismatchError
extensionVersion={status.versionMismatch.extensionVersion}
/>
) : (
status.message
)}
</div>
);
};
// Initialize the React app
const container = document.getElementById('root');
if (container) {
const root = createRoot(container);
root.render(<ConnectApp />);
}
@@ -0,0 +1,39 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.copy-icon {
flex: none;
height: 24px;
width: 24px;
border: none;
outline: none;
color: var(--color-fg-muted);
background: transparent;
padding: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.copy-icon svg {
margin: 0;
}
.copy-icon:not(:disabled):hover {
background-color: var(--color-btn-selected-bg);
}
@@ -0,0 +1,54 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as React from 'react';
import * as icons from './icons';
import './copyToClipboard.css';
type CopyToClipboardProps = {
value: string;
};
/**
* A copy to clipboard button.
*/
export const CopyToClipboard: React.FunctionComponent<CopyToClipboardProps> = ({ value }) => {
type IconType = 'copy' | 'check' | 'cross';
const [icon, setIcon] = React.useState<IconType>('copy');
React.useEffect(() => {
setIcon('copy');
}, [value]);
React.useEffect(() => {
if (icon === 'check') {
const timeout = setTimeout(() => {
setIcon('copy');
}, 3000);
return () => clearTimeout(timeout);
}
}, [icon]);
const handleCopy = React.useCallback(() => {
navigator.clipboard.writeText(value).then(() => {
setIcon('check');
}, () => {
setIcon('cross');
});
}, [value]);
const iconElement = icon === 'check' ? icons.check() : icon === 'cross' ? icons.cross() : icons.copy();
return <button className='copy-icon' title='Copy to clipboard' aria-label='Copy to clipboard' onClick={handleCopy}>{iconElement}</button>;
};
+32
View File
@@ -0,0 +1,32 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.octicon {
display: inline-block;
overflow: visible !important;
vertical-align: text-bottom;
fill: currentColor;
margin-right: 7px;
flex: none;
}
.color-icon-success {
color: var(--color-success-fg) !important;
}
.color-text-danger {
color: var(--color-danger-fg) !important;
}
+49
View File
@@ -0,0 +1,49 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import './icons.css';
import './colors.css';
export const cross = () => {
return <svg className='octicon color-text-danger' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'>
<path fillRule='evenodd' d='M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z'></path>
</svg>;
};
export const check = () => {
return <svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon color-icon-success'>
<path fillRule='evenodd' d='M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z'></path>
</svg>;
};
export const copy = () => {
return <svg className='octicon' viewBox='0 0 16 16' width='16' height='16' aria-hidden='true'>
<path d='M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z'></path>
<path d='M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z'></path>
</svg>;
};
export const refresh = () => {
return <svg className='octicon' viewBox="0 0 16 16" width="16" height="16" aria-hidden='true'>
<path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z"></path>
</svg>;
};
export const chevronDown = () => {
return <svg className='octicon' viewBox="0 0 16 16" width="16" height="16" aria-hidden='true'>
<path d="M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z"></path>
</svg>;
};
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Playwright MCP Bridge Status</title>
<link rel="stylesheet" href="connect.css">
</head>
<body>
<div id="root"></div>
<script src="status.tsx" type="module"></script>
</body>
</html>
+112
View File
@@ -0,0 +1,112 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import { Button, TabItem } from './tabItem';
import type { TabInfo } from './tabItem';
import { AuthTokenSection } from './authToken';
interface ConnectionStatus {
isConnected: boolean;
connectedTabId: number | null;
connectedTab?: TabInfo;
}
const StatusApp: React.FC = () => {
const [status, setStatus] = useState<ConnectionStatus>({
isConnected: false,
connectedTabId: null
});
useEffect(() => {
void loadStatus();
}, []);
const loadStatus = async () => {
// Get current connection status from background script
const { connectedTabId } = await chrome.runtime.sendMessage({ type: 'getConnectionStatus' });
if (connectedTabId) {
const tab = await chrome.tabs.get(connectedTabId);
setStatus({
isConnected: true,
connectedTabId,
connectedTab: {
id: tab.id!,
windowId: tab.windowId!,
title: tab.title!,
url: tab.url!,
favIconUrl: tab.favIconUrl
}
});
} else {
setStatus({
isConnected: false,
connectedTabId: null
});
}
};
const openConnectedTab = async () => {
if (!status.connectedTabId)
return;
await chrome.tabs.update(status.connectedTabId, { active: true });
window.close();
};
const disconnect = async () => {
await chrome.runtime.sendMessage({ type: 'disconnect' });
window.close();
};
return (
<div className='app-container'>
<div className='content-wrapper'>
{status.isConnected && status.connectedTab ? (
<div>
<div className='tab-section-title'>
Page with connected MCP client:
</div>
<div>
<TabItem
tab={status.connectedTab}
button={
<Button variant='primary' onClick={disconnect}>
Disconnect
</Button>
}
onClick={openConnectedTab}
/>
</div>
</div>
) : (
<div className='status-banner'>
No MCP clients are currently connected.
</div>
)}
<AuthTokenSection />
</div>
</div>
);
};
// Initialize the React app
const container = document.getElementById('root');
if (container) {
const root = createRoot(container);
root.render(<StatusApp />);
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
export interface TabInfo {
id: number;
windowId: number;
title: string;
url: string;
favIconUrl?: string;
}
export const Button: React.FC<{ variant: 'primary' | 'default' | 'reject'; onClick: () => void; children: React.ReactNode }> = ({
variant,
onClick,
children
}) => {
return (
<button className={`button ${variant}`} onClick={onClick}>
{children}
</button>
);
};
export interface TabItemProps {
tab: TabInfo;
onClick?: () => void;
button?: React.ReactNode;
}
export const TabItem: React.FC<TabItemProps> = ({
tab,
onClick,
button
}) => {
return (
<div className='tab-item' onClick={onClick} style={onClick ? { cursor: 'pointer' } : undefined}>
<img
src={tab.favIconUrl || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect width="16" height="16" fill="%23f6f8fa"/></svg>'}
alt=''
className='tab-favicon'
/>
<div className='tab-content'>
<div className='tab-title'>
{tab.title || 'Untitled'}
</div>
<div className='tab-url'>{tab.url}</div>
</div>
{button}
</div>
);
};
@@ -0,0 +1,4 @@
// Help VSCode to find right tsconfig file.
{
"extends": "../../tsconfig.ui.json"
}
@@ -0,0 +1,336 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs';
import path from 'path';
import { chromium } from 'playwright';
import { test as base, expect } from '../../tests/fixtures';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import type { BrowserContext } from 'playwright';
import type { StartClient } from '../../tests/fixtures';
type BrowserWithExtension = {
userDataDir: string;
launch: (mode?: 'disable-extension') => Promise<BrowserContext>;
};
type TestFixtures = {
browserWithExtension: BrowserWithExtension,
pathToExtension: string,
useShortConnectionTimeout: (timeoutMs: number) => void
overrideProtocolVersion: (version: number) => void
};
const test = base.extend<TestFixtures>({
pathToExtension: async ({}, use) => {
await use(path.resolve(__dirname, '../dist'));
},
browserWithExtension: async ({ mcpBrowser, pathToExtension }, use, testInfo) => {
// The flags no longer work in Chrome since
// https://chromium.googlesource.com/chromium/src/+/290ed8046692651ce76088914750cb659b65fb17%5E%21/chrome/browser/extensions/extension_service.cc?pli=1#
test.skip('chromium' !== mcpBrowser, '--load-extension is not supported for official builds of Chromium');
let browserContext: BrowserContext | undefined;
const userDataDir = testInfo.outputPath('extension-user-data-dir');
await use({
userDataDir,
launch: async (mode?: 'disable-extension') => {
browserContext = await chromium.launchPersistentContext(userDataDir, {
channel: mcpBrowser,
// Opening the browser singleton only works in headed.
headless: false,
// Automation disables singleton browser process behavior, which is necessary for the extension.
ignoreDefaultArgs: ['--enable-automation'],
args: mode === 'disable-extension' ? [] : [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
],
});
// for manifest v3:
let [serviceWorker] = browserContext.serviceWorkers();
if (!serviceWorker)
serviceWorker = await browserContext.waitForEvent('serviceworker');
return browserContext;
}
});
await browserContext?.close();
},
useShortConnectionTimeout: async ({}, use) => {
await use((timeoutMs: number) => {
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = timeoutMs.toString();
});
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined;
},
overrideProtocolVersion: async ({}, use) => {
await use((version: number) => {
process.env.PWMCP_TEST_PROTOCOL_VERSION = version.toString();
});
process.env.PWMCP_TEST_PROTOCOL_VERSION = undefined;
}
});
async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
const { client } = await startClient({
args: [`--connect-tool`],
config: {
browser: {
userDataDir: browserWithExtension.userDataDir,
}
},
});
expect(await client.callTool({
name: 'browser_connect',
arguments: {
name: 'extension'
}
})).toHaveResponse({
result: 'Successfully changed connection method.',
});
return client;
}
async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
const { client } = await startClient({
args: [`--extension`],
config: {
browser: {
userDataDir: browserWithExtension.userDataDir,
}
},
});
return client;
}
const testWithOldExtensionVersion = test.extend({
pathToExtension: async ({}, use, testInfo) => {
const extensionDir = testInfo.outputPath('extension');
const oldPath = path.resolve(__dirname, '../dist');
await fs.promises.cp(oldPath, extensionDir, { recursive: true });
const manifestPath = path.join(extensionDir, 'manifest.json');
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
manifest.version = '0.0.1';
await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
await use(extensionDir);
},
});
for (const [mode, startClientMethod] of [
['connect-tool', startAndCallConnectTool],
['extension-flag', startWithExtensionFlag],
] as const) {
test(`navigate with extension (${mode})`, async ({ browserWithExtension, startClient, server }) => {
const browserContext = await browserWithExtension.launch();
const client = await startClientMethod(browserWithExtension, startClient);
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
const navigateResponse = client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const selectorPage = await confirmationPagePromise;
// For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector
await selectorPage.getByRole('button', { name: 'Allow' }).click();
expect(await navigateResponse).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
});
test(`snapshot of an existing page (${mode})`, async ({ browserWithExtension, startClient, server }) => {
const browserContext = await browserWithExtension.launch();
const page = await browserContext.newPage();
await page.goto(server.HELLO_WORLD);
// Another empty page.
await browserContext.newPage();
expect(browserContext.pages()).toHaveLength(3);
const client = await startClientMethod(browserWithExtension, startClient);
expect(browserContext.pages()).toHaveLength(3);
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
const navigateResponse = client.callTool({
name: 'browser_snapshot',
arguments: { },
});
const selectorPage = await confirmationPagePromise;
expect(browserContext.pages()).toHaveLength(4);
await selectorPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click();
expect(await navigateResponse).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
expect(browserContext.pages()).toHaveLength(4);
});
test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
useShortConnectionTimeout(100);
const browserContext = await browserWithExtension.launch();
const client = await startClientMethod(browserWithExtension, startClient);
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toHaveResponse({
result: expect.stringContaining('Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed.'),
isError: true,
});
await confirmationPagePromise;
});
testWithOldExtensionVersion(`works with old extension version (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
useShortConnectionTimeout(500);
// Prelaunch the browser, so that it is properly closed after the test.
const browserContext = await browserWithExtension.launch();
const client = await startClientMethod(browserWithExtension, startClient);
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
const navigateResponse = client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const selectorPage = await confirmationPagePromise;
// For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector
await selectorPage.getByRole('button', { name: 'Allow' }).click();
expect(await navigateResponse).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
});
test(`extension needs update (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout, overrideProtocolVersion }) => {
useShortConnectionTimeout(500);
overrideProtocolVersion(1000);
// Prelaunch the browser, so that it is properly closed after the test.
const browserContext = await browserWithExtension.launch();
const client = await startClientMethod(browserWithExtension, startClient);
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
const navigateResponse = client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const confirmationPage = await confirmationPagePromise;
await expect(confirmationPage.locator('.status-banner')).toContainText(`Playwright MCP version trying to connect requires newer extension version`);
expect(await navigateResponse).toHaveResponse({
result: expect.stringContaining('Extension connection timeout.'),
isError: true,
});
});
}
test(`custom executablePath`, async ({ startClient, server, useShortConnectionTimeout }) => {
useShortConnectionTimeout(1000);
const executablePath = test.info().outputPath('echo.sh');
await fs.promises.writeFile(executablePath, '#!/bin/bash\necho "Custom exec args: $@" > "$(dirname "$0")/output.txt"', { mode: 0o755 });
const { client } = await startClient({
args: [`--extension`],
config: {
browser: {
launchOptions: {
executablePath,
},
}
},
});
const navigateResponse = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
timeout: 1000,
});
expect(await navigateResponse).toHaveResponse({
result: expect.stringContaining('Extension connection timeout.'),
isError: true,
});
expect(await fs.promises.readFile(test.info().outputPath('output.txt'), 'utf8')).toContain('Custom exec args: chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html?');
});
test(`bypass connection dialog with token`, async ({ browserWithExtension, startClient, server }) => {
const browserContext = await browserWithExtension.launch();
const page = await browserContext.newPage();
await page.goto('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/status.html');
const token = await page.locator('.auth-token-code').textContent();
const [name, value] = token?.split('=') || [];
const { client } = await startClient({
args: [`--extension`],
extensionToken: value,
config: {
browser: {
userDataDir: browserWithExtension.userDataDir,
}
},
});
const navigateResponse = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
expect(await navigateResponse).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
});
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ESNext",
"esModuleInterop": true,
"moduleResolution": "node",
"strict": true,
"module": "ESNext",
"rootDir": "src",
"outDir": "./dist/lib",
"resolveJsonModule": true,
"types": ["chrome"],
"jsx": "react-jsx",
"jsxImportSource": "react",
"noEmit": true
},
"include": [
"src",
],
"exclude": [
"src/ui",
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"esModuleInterop": true,
"moduleResolution": "node",
"strict": true,
"module": "ESNext",
"rootDir": "src",
"outDir": "./lib",
"resolveJsonModule": true,
"types": ["chrome"],
"jsx": "react-jsx",
"jsxImportSource": "react",
"noEmit": true,
},
"include": [
"src/ui",
],
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { resolve } from 'path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { viteStaticCopy } from 'vite-plugin-static-copy';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
viteStaticCopy({
targets: [
{
src: '../../icons/*',
dest: 'icons'
},
{
src: '../../manifest.json',
dest: '.'
}
]
})
],
root: resolve(__dirname, 'src/ui'),
build: {
outDir: resolve(__dirname, 'dist/'),
emptyOutDir: false,
minify: false,
rollupOptions: {
input: ['src/ui/connect.html', 'src/ui/status.html'],
output: {
manualChunks: undefined,
entryFileNames: 'lib/ui/[name].js',
chunkFileNames: 'lib/ui/[name].js',
assetFileNames: 'lib/ui/[name].[ext]'
}
}
}
});
+31
View File
@@ -0,0 +1,31 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { resolve } from 'path';
import { defineConfig } from 'vite';
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/background.ts'),
fileName: 'lib/background',
formats: ['es']
},
outDir: 'dist',
emptyOutDir: false,
minify: false
}
});