remove extension

This commit is contained in:
Tommy D. Rossi
2025-11-14 13:33:52 +01:00
parent 75d458b27c
commit b4f6ea8237
28 changed files with 1 additions and 3265 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

-87
View File
@@ -1,87 +0,0 @@
# 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.

Before

Width:  |  Height:  |  Size: 53 KiB

-239
View File
@@ -1,239 +0,0 @@
// 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
@@ -1,88 +0,0 @@
(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
@@ -1,30 +0,0 @@
// 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
@@ -1,8 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<script src="devtools.js"></script>
</head>
<body>
</body>
</html>
-6
View File
@@ -1,6 +0,0 @@
// Create DevTools panel
chrome.devtools.panels.create(
"Kapture",
null,
"panel.html"
);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 744 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

-44
View File
@@ -1,44 +0,0 @@
{
"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"
}
}
@@ -1,122 +0,0 @@
// 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);
}
}
@@ -1,88 +0,0 @@
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
}
@@ -1,21 +0,0 @@
// 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);
}
}
@@ -1,214 +0,0 @@
// 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
};
}
@@ -1,82 +0,0 @@
// 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);
}
}
@@ -1,54 +0,0 @@
// 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
@@ -1,33 +0,0 @@
// 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
@@ -1,342 +0,0 @@
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
@@ -1,177 +0,0 @@
// 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
@@ -1,683 +0,0 @@
// 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
@@ -1,377 +0,0 @@
: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
@@ -1,56 +0,0 @@
<!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
@@ -1,304 +0,0 @@
// 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
@@ -1,130 +0,0 @@
<!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
@@ -1,80 +0,0 @@
// 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);
}
+1
View File
@@ -0,0 +1 @@
now see where attachToTab is sent and received. see that we basically attach to the tab as soon as the playwright instance connects. I want to change this: instead attach to the tab when we click on the extension icon, meaning inside onClicked. when this happens we should also send a