remove extension
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB |
@@ -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 |
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
})();
|
||||
@@ -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();
|
||||
@@ -1,8 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script src="devtools.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 |
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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` }
|
||||
}));
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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' ? '⬆' : '⬇'; // 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);
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user