adding examples
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
// Import helper functions from background-commands
|
||||
import { getFromContentScript, respondWith, respondWithError, attachDebugger, getElement } from './background-commands.js';
|
||||
|
||||
export async function click({tabId, mousePosition}, { selector, xpath }) {
|
||||
return await hover({ tabId, mousePosition }, { selector, xpath }, true);
|
||||
}
|
||||
|
||||
export async function hover(tab, { selector, xpath }, click = false) {
|
||||
const { tabId, mousePosition } = tab;
|
||||
// Validate that either selector or xpath is provided
|
||||
if (!selector && !xpath) {
|
||||
return respondWithError(tabId, 'SELECTOR_OR_XPATH_REQUIRED', 'Either selector or xpath is required');
|
||||
}
|
||||
|
||||
// Get element and validate it exists and is visible
|
||||
const elementResult = await getElement(tabId, selector, xpath, true);
|
||||
if (elementResult.error) return elementResult;
|
||||
|
||||
// Get current mouse position
|
||||
const currentPosition = mousePosition || { x: 0, y: 0 };
|
||||
|
||||
// Calculate target position (center of element)
|
||||
const targetX = elementResult.element.bounds.x + elementResult.element.bounds.width / 2;
|
||||
const targetY = elementResult.element.bounds.y + elementResult.element.bounds.height / 2;
|
||||
|
||||
try {
|
||||
// Show cursor
|
||||
await getFromContentScript(tabId, '_cursor', { show: true });
|
||||
|
||||
// Set initial cursor position
|
||||
await getFromContentScript(tabId, '_moveMouseSVG', { x: currentPosition.x, y: currentPosition.y });
|
||||
|
||||
// Animation configuration
|
||||
const pixelsPerSecond = 1000; // Adjust for desired speed
|
||||
const frameInterval = 16; // ~60fps
|
||||
|
||||
await attachDebugger(tabId, async () => {
|
||||
const sendCmd = (cmd, params) => chrome.debugger.sendCommand({ tabId }, cmd, params);
|
||||
const dispatchMouseEvent = (params) => sendCmd('Input.dispatchMouseEvent', params);
|
||||
|
||||
// Enable Input domain for mouse events
|
||||
// await sendCmd('Input.enable');
|
||||
|
||||
// Function to animate to a position
|
||||
const animateToPosition = async (fromX, fromY, toX, toY) => {
|
||||
const distance = Math.sqrt(Math.pow(toX - fromX, 2) + Math.pow(toY - fromY, 2));
|
||||
const duration = Math.min(1000, (distance / pixelsPerSecond) * 1000);
|
||||
const animSteps = Math.max(1, Math.ceil(duration / frameInterval));
|
||||
const dx = (toX - fromX) / animSteps;
|
||||
const dy = (toY - fromY) / animSteps;
|
||||
|
||||
for (let i = 1; i <= animSteps; i++) {
|
||||
const x = fromX + (dx * i);
|
||||
const y = fromY + (dy * i);
|
||||
|
||||
// Move visual cursor
|
||||
await getFromContentScript(tabId, '_moveMouseSVG', { x, y });
|
||||
|
||||
// Send mouse move event
|
||||
await dispatchMouseEvent({ type: 'mouseMoved', x, y });
|
||||
|
||||
// Wait for next frame
|
||||
await new Promise(resolve => setTimeout(resolve, frameInterval));
|
||||
}
|
||||
|
||||
return { x: toX, y: toY };
|
||||
};
|
||||
|
||||
// Initial animation to target
|
||||
let finalPosition = await animateToPosition(currentPosition.x, currentPosition.y, targetX, targetY);
|
||||
|
||||
// Try up to 5 times to ensure we're over the target element
|
||||
const maxAttempts = 5;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
const finalCheck = await getElement(tabId, selector, xpath, true);
|
||||
if (!finalCheck.error && finalCheck.element) {
|
||||
const bounds = finalCheck.element.bounds;
|
||||
|
||||
// Check if cursor is actually over the element
|
||||
const isOverElement = finalPosition.x >= bounds.x &&
|
||||
finalPosition.x <= bounds.x + bounds.width &&
|
||||
finalPosition.y >= bounds.y &&
|
||||
finalPosition.y <= bounds.y + bounds.height;
|
||||
|
||||
if (!isOverElement) {
|
||||
// Calculate new target center and animate to it
|
||||
const actualTargetX = bounds.x + bounds.width / 2;
|
||||
const actualTargetY = bounds.y + bounds.height / 2;
|
||||
finalPosition = await animateToPosition(finalPosition.x, finalPosition.y, actualTargetX, actualTargetY);
|
||||
} else {
|
||||
// Cursor is over the element, we're done
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Element not found, stop trying
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const bounds = await getFromContentScript(tabId, '_elementPosition', { id: elementResult.element.id });
|
||||
const actualTargetX = bounds.x + bounds.width / 2;
|
||||
const actualTargetY = bounds.y + bounds.height / 2;
|
||||
await getFromContentScript(tabId, '_moveMouseSVG', { x: actualTargetX, y: actualTargetY });
|
||||
|
||||
if (click) {
|
||||
await dispatchMouseEvent({type: 'mousePressed', x: actualTargetX, y: actualTargetY, button: 'left', clickCount: 1});
|
||||
await dispatchMouseEvent({type: 'mouseReleased', x: actualTargetX, y: actualTargetY, button: 'left', clickCount: 1});
|
||||
}
|
||||
});
|
||||
|
||||
// Hide cursor after 1 second
|
||||
setTimeout(async () => {
|
||||
await getFromContentScript(tabId, '_cursor', { show: false });
|
||||
}, 1000);
|
||||
|
||||
return respondWith(tabId, click ? { clicked: true } : { hovered: true }, selector, xpath);
|
||||
} catch (error) {
|
||||
// Make sure cursor is hidden on error
|
||||
await getFromContentScript(tabId, '_cursor', { show: false });
|
||||
return respondWithError(tabId, 'CLICK_FAILED', error.message, selector, xpath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { keypress } from './background-keypress.js';
|
||||
import { click, hover } from './background-click.js';
|
||||
import { navigate, back, forward, close, reload, show } from './background-navigate.js';
|
||||
import { screenshot } from './background-screenshot.js';
|
||||
import { getLogs } from './background-console.js';
|
||||
|
||||
export const getFromContentScript = async (tabId, command, params, ) => {
|
||||
return await chrome.tabs.sendMessage(tabId, { command, params });
|
||||
}
|
||||
|
||||
// Detect browser type from user agent
|
||||
export const detectBrowser = () => {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
|
||||
// Check for Chromium-based browsers that support Chrome extensions
|
||||
if (userAgent.includes('edg/')) {
|
||||
return 'edge';
|
||||
} else if (userAgent.includes('opr/') || userAgent.includes('opera/')) {
|
||||
return 'opera';
|
||||
} else if (userAgent.includes('vivaldi/')) {
|
||||
return 'vivaldi';
|
||||
} else if (userAgent.includes('brave/')) {
|
||||
return 'brave';
|
||||
} else if (userAgent.includes('chrome/')) {
|
||||
// Additional check for Brave which doesn't always include 'brave' in UA
|
||||
if (navigator.brave && navigator.brave.isBrave) {
|
||||
return 'brave';
|
||||
}
|
||||
return 'chrome';
|
||||
} else {
|
||||
// Default to chromium for any other Chromium-based browser
|
||||
return 'chromium';
|
||||
}
|
||||
};
|
||||
|
||||
export const getTabInfo = async(tabId) => await getFromContentScript(tabId, 'getTabInfo');
|
||||
export const getElement = async (tabId, selector, xpath, visible) => {
|
||||
return await getFromContentScript(tabId, 'element', { selector, xpath, visible });
|
||||
}
|
||||
|
||||
export const respondWith = async (tabId, obj, selector, xpath) => {
|
||||
const info = await getTabInfo(tabId);
|
||||
return {
|
||||
success: !obj.error,
|
||||
selector,
|
||||
xpath: !selector ? xpath : undefined,
|
||||
...info,
|
||||
...obj
|
||||
};
|
||||
}
|
||||
export const respondWithError = async (tabId, code, message, selector, xpath) => {
|
||||
return respondWith(tabId,{ error: { code, message } }, selector, xpath);
|
||||
}
|
||||
export async function attachDebugger(tabId, action) {
|
||||
let debuggerAttached = false;
|
||||
try {
|
||||
// Attach debugger to capture screenshot without making tab active
|
||||
await chrome.debugger.attach({tabId}, '1.3');
|
||||
debuggerAttached = true;
|
||||
|
||||
// Enable the Page domain
|
||||
await chrome.debugger.sendCommand({tabId}, 'Page.enable');
|
||||
|
||||
return await action();
|
||||
}
|
||||
finally {
|
||||
// Always detach debugger if attached
|
||||
try {
|
||||
if (debuggerAttached) await chrome.debugger.detach({tabId: tabId});
|
||||
}
|
||||
catch (e) { }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const backgroundCommands = {
|
||||
navigate,
|
||||
back,
|
||||
forward,
|
||||
close,
|
||||
reload,
|
||||
show,
|
||||
click,
|
||||
hover,
|
||||
keypress,
|
||||
screenshot,
|
||||
getLogs
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Import helper functions from background-commands
|
||||
import { respondWith, respondWithError } from './background-commands.js';
|
||||
|
||||
export async function getLogs(tabState, { before, limit = 100, level }) {
|
||||
try {
|
||||
// Get the logs from the tab state using its built-in method
|
||||
const logs = tabState.getConsoleLogs(limit, level, before);
|
||||
|
||||
// Check if there are more logs available
|
||||
const totalFilteredCount = tabState.getConsoleLogs(null, level, before).length;
|
||||
const hasMore = totalFilteredCount > limit;
|
||||
|
||||
return respondWith(tabState.tabId, {
|
||||
logs: logs,
|
||||
hasMore: hasMore,
|
||||
totalCount: tabState.getConsoleLogCount()
|
||||
});
|
||||
} catch (error) {
|
||||
return respondWithError(tabState.tabId, 'CONSOLE_LOG_ERROR', error.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// Import helper functions from background-commands
|
||||
import { getFromContentScript, respondWith, respondWithError, attachDebugger } from './background-commands.js';
|
||||
|
||||
// Additional helper functions
|
||||
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// Send keypress event
|
||||
export async function keypress({tabId}, params) {
|
||||
const { key, selector, xpath, delay = 50 } = params;
|
||||
|
||||
if (!key) {
|
||||
return respondWithError(tabId, 'KEY_REQUIRED', 'Key parameter is required');
|
||||
}
|
||||
|
||||
// Validate delay is within reasonable bounds (0-60000ms = 0-60 seconds)
|
||||
const keypressDelay = Math.max(0, Math.min(60000, delay));
|
||||
|
||||
try {
|
||||
// First, focus the target element if selector/xpath provided
|
||||
if (selector || xpath) {
|
||||
const focusResult = await getFromContentScript(tabId, 'focus', { selector, xpath });
|
||||
if (focusResult.error) {
|
||||
return focusResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse key combination to extract key and modifiers
|
||||
const keyData = parseKeyCombination(key);
|
||||
|
||||
// Use attachDebugger helper to manage debugger lifecycle
|
||||
return await attachDebugger(tabId, async () => {
|
||||
// Helper function to send key event
|
||||
const sendKeyEvent = async (type, autoRepeat = false, text = null) => {
|
||||
const eventData = {
|
||||
type,
|
||||
...keyData,
|
||||
windowsVirtualKeyCode: keyData.keyCode,
|
||||
nativeVirtualKeyCode: keyData.keyCode
|
||||
};
|
||||
|
||||
if (type === 'keyDown' || type === 'keyUp') {
|
||||
eventData.autoRepeat = autoRepeat;
|
||||
}
|
||||
|
||||
if (text) {
|
||||
eventData.text = text;
|
||||
}
|
||||
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', eventData);
|
||||
};
|
||||
|
||||
// Send keydown event
|
||||
await sendKeyEvent('keyDown', false);
|
||||
|
||||
// If delay > 500ms, simulate key repeat
|
||||
if (keypressDelay > 500) {
|
||||
// Calculate number of repeat events based on delay
|
||||
// Typical repeat rate is ~30ms between events
|
||||
const repeatInterval = 30;
|
||||
const repeatCount = Math.floor((keypressDelay - 100) / repeatInterval);
|
||||
|
||||
// Wait a bit before starting repeat (typical OS behavior)
|
||||
await wait(50);
|
||||
|
||||
// Send repeated keydown events
|
||||
for (let i = 0; i < repeatCount; i++) {
|
||||
await sendKeyEvent('keyDown', true);
|
||||
|
||||
// Wait between repeat events
|
||||
if (i < repeatCount - 1) {
|
||||
await wait(repeatInterval);
|
||||
}
|
||||
}
|
||||
} else if (keypressDelay > 0) {
|
||||
// For delays <= 500ms, just wait the specified time
|
||||
await wait(keypressDelay);
|
||||
}
|
||||
|
||||
// Send keyup event
|
||||
await sendKeyEvent('keyUp');
|
||||
|
||||
// Wait a bit for the page to process the key events
|
||||
await wait(50);
|
||||
|
||||
// Keys that typically cause scrolling need extra time to settle
|
||||
const scrollKeys = ['PageUp', 'PageDown', 'Space', ' ', 'ArrowUp', 'ArrowDown', 'Home', 'End'];
|
||||
if (!selector && !xpath && scrollKeys.includes(keyData.key)) {
|
||||
// Additional delay for scroll to complete when key is pressed globally (not on a specific element)
|
||||
await wait(100);
|
||||
}
|
||||
|
||||
// Get result including element info if selector was provided
|
||||
const resultData = {
|
||||
keyPressed: true,
|
||||
key: key,
|
||||
delay: keypressDelay
|
||||
};
|
||||
|
||||
// If this was an auto-repeat key press, include that info
|
||||
if (keypressDelay > 500) {
|
||||
resultData.autoRepeat = true;
|
||||
resultData.repeatCount = Math.floor((keypressDelay - 100) / 30);
|
||||
}
|
||||
|
||||
return await respondWith(tabId, resultData, selector, xpath);
|
||||
});
|
||||
} catch (error) {
|
||||
return respondWithError(tabId, 'KEYPRESS_FAILED', error.message, selector, xpath);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse key combination and return CDP-compatible key data
|
||||
function parseKeyCombination(keyCombination) {
|
||||
let modifiers = 0;
|
||||
let key = '';
|
||||
|
||||
// CDP modifier flags
|
||||
const CDP_MODIFIERS = {
|
||||
alt: 1,
|
||||
ctrl: 2,
|
||||
meta: 4,
|
||||
shift: 8
|
||||
};
|
||||
|
||||
// Special key mappings
|
||||
const KEY_MAPPINGS = {
|
||||
'enter': { key: 'Enter', code: 'Enter', keyCode: 13 },
|
||||
'return': { key: 'Enter', code: 'Enter', keyCode: 13 },
|
||||
'tab': { key: 'Tab', code: 'Tab', keyCode: 9 },
|
||||
'delete': { key: 'Delete', code: 'Delete', keyCode: 46 },
|
||||
'backspace': { key: 'Backspace', code: 'Backspace', keyCode: 8 },
|
||||
'escape': { key: 'Escape', code: 'Escape', keyCode: 27 },
|
||||
'esc': { key: 'Escape', code: 'Escape', keyCode: 27 },
|
||||
'space': { key: ' ', code: 'Space', keyCode: 32, text: ' ' },
|
||||
' ': { key: ' ', code: 'Space', keyCode: 32, text: ' ' },
|
||||
'arrowup': { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
|
||||
'arrowdown': { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
|
||||
'arrowleft': { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
|
||||
'arrowright': { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
|
||||
'pageup': { key: 'PageUp', code: 'PageUp', keyCode: 33 },
|
||||
'pagedown': { key: 'PageDown', code: 'PageDown', keyCode: 34 },
|
||||
'home': { key: 'Home', code: 'Home', keyCode: 36 },
|
||||
'end': { key: 'End', code: 'End', keyCode: 35 },
|
||||
'insert': { key: 'Insert', code: 'Insert', keyCode: 45 },
|
||||
'f1': { key: 'F1', code: 'F1', keyCode: 112 },
|
||||
'f2': { key: 'F2', code: 'F2', keyCode: 113 },
|
||||
'f3': { key: 'F3', code: 'F3', keyCode: 114 },
|
||||
'f4': { key: 'F4', code: 'F4', keyCode: 115 },
|
||||
'f5': { key: 'F5', code: 'F5', keyCode: 116 },
|
||||
'f6': { key: 'F6', code: 'F6', keyCode: 117 },
|
||||
'f7': { key: 'F7', code: 'F7', keyCode: 118 },
|
||||
'f8': { key: 'F8', code: 'F8', keyCode: 119 },
|
||||
'f9': { key: 'F9', code: 'F9', keyCode: 120 },
|
||||
'f10': { key: 'F10', code: 'F10', keyCode: 121 },
|
||||
'f11': { key: 'F11', code: 'F11', keyCode: 122 },
|
||||
'f12': { key: 'F12', code: 'F12', keyCode: 123 }
|
||||
};
|
||||
|
||||
// Parse modifiers from combination
|
||||
let remaining = keyCombination;
|
||||
const modifierPatterns = [
|
||||
{ pattern: /^(Control|Ctrl)\+/i, modifier: 'ctrl' },
|
||||
{ pattern: /^Shift\+/i, modifier: 'shift' },
|
||||
{ pattern: /^Alt|Option\+/i, modifier: 'alt' },
|
||||
{ pattern: /^(Meta|Cmd|Command)\+/i, modifier: 'meta' }
|
||||
];
|
||||
|
||||
// Extract modifiers
|
||||
let foundModifier = true;
|
||||
while (foundModifier && remaining) {
|
||||
foundModifier = false;
|
||||
for (const { pattern, modifier } of modifierPatterns) {
|
||||
if (pattern.test(remaining)) {
|
||||
modifiers |= CDP_MODIFIERS[modifier];
|
||||
remaining = remaining.replace(pattern, '');
|
||||
foundModifier = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What's left is the key
|
||||
key = remaining;
|
||||
|
||||
// Look up special key mapping
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (KEY_MAPPINGS[lowerKey]) {
|
||||
return {
|
||||
...KEY_MAPPINGS[lowerKey],
|
||||
modifiers,
|
||||
text: KEY_MAPPINGS[lowerKey].text || undefined
|
||||
};
|
||||
}
|
||||
|
||||
// For single character keys
|
||||
if (key.length === 1) {
|
||||
const keyCode = key.toUpperCase().charCodeAt(0);
|
||||
return {
|
||||
key: key,
|
||||
code: 'Key' + key.toUpperCase(),
|
||||
keyCode: keyCode,
|
||||
modifiers,
|
||||
text: modifiers === 0 ? key : undefined // Only send text for unmodified keys
|
||||
};
|
||||
}
|
||||
|
||||
// For other keys, try to generate a reasonable code
|
||||
return {
|
||||
key: key,
|
||||
code: key,
|
||||
keyCode: 0, // Unknown keycode
|
||||
modifiers
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Import helper functions from background-commands
|
||||
import { getFromContentScript, respondWith, respondWithError } from './background-commands.js';
|
||||
|
||||
// Check if URL is allowed for extension
|
||||
const isAllowedUrl = (url) => !!url && (url.startsWith('http://') || url.startsWith('https://'));
|
||||
|
||||
// Wait for content script to be ready
|
||||
async function waitForContentScriptReady(tabId, timeout = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
chrome.runtime.onMessage.removeListener(listener);
|
||||
reject(new Error('Timeout waiting for content script'));
|
||||
}, timeout);
|
||||
|
||||
const listener = (request, sender) => {
|
||||
if (request.type === 'contentScriptReady' && sender.tab?.id === tabId) {
|
||||
clearTimeout(timer);
|
||||
chrome.runtime.onMessage.removeListener(listener);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
chrome.runtime.onMessage.addListener(listener);
|
||||
});
|
||||
}
|
||||
|
||||
// Execute navigation and wait for content script
|
||||
async function executeNavigation(tabId, navigationFn) {
|
||||
try {
|
||||
await navigationFn();
|
||||
await waitForContentScriptReady(tabId);
|
||||
return await respondWith(tabId, {});
|
||||
} catch (error) {
|
||||
return respondWithError(tabId, 'NAVIGATION_FAILED', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation commands
|
||||
export async function navigate({tabId}, { url }) {
|
||||
if (!isAllowedUrl(url)) {
|
||||
return respondWithError(tabId, 'NAVIGATION_BLOCKED', `Navigation to ${url} is not allowed`);
|
||||
}
|
||||
return executeNavigation(tabId, async () => getFromContentScript(tabId, '_navigate', { url }));
|
||||
}
|
||||
|
||||
export async function back({tabId}) {
|
||||
return executeNavigation(tabId, () => chrome.tabs.goBack(tabId));
|
||||
}
|
||||
|
||||
export async function forward({tabId}) {
|
||||
return executeNavigation(tabId, () => chrome.tabs.goForward(tabId));
|
||||
}
|
||||
|
||||
export async function close({tabId}) {
|
||||
try {
|
||||
await chrome.tabs.remove(tabId);
|
||||
return { success: true, closed: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: { code: 'CLOSE_FAILED', message: error.message } };
|
||||
}
|
||||
}
|
||||
|
||||
export async function reload({tabId}) {
|
||||
return executeNavigation(tabId, () => chrome.tabs.reload(tabId));
|
||||
}
|
||||
|
||||
export async function show({tabId}) {
|
||||
try {
|
||||
// Get the tab to find its window ID
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
|
||||
// Bring the window to the front
|
||||
await chrome.windows.update(tab.windowId, { focused: true });
|
||||
|
||||
// Make the tab active in the window
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
|
||||
return await respondWith(tabId, { shown: true });
|
||||
} catch (error) {
|
||||
return respondWithError(tabId, 'SHOW_FAILED', error.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Import helper functions from background-commands
|
||||
import { getElement, getTabInfo, respondWithError, attachDebugger } from './background-commands.js';
|
||||
|
||||
export async function screenshot({tabId}, { scale = 0.5, quality = 0.5, format = 'webp', selector, xpath }) {
|
||||
let elementResult;
|
||||
if (selector || xpath) {
|
||||
elementResult = await getElement(tabId, selector, xpath, true);
|
||||
if (elementResult.error) return elementResult;
|
||||
}
|
||||
else {
|
||||
elementResult = await getTabInfo(tabId)
|
||||
elementResult.element = {
|
||||
bounds: {
|
||||
x: elementResult.scrollPosition.x,
|
||||
y: elementResult.scrollPosition.y,
|
||||
width: elementResult.viewportDimensions.width,
|
||||
height: elementResult.viewportDimensions.height
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const clip = { ...elementResult.element.bounds };
|
||||
|
||||
// For fixed positioned elements, we need viewport-relative coordinates
|
||||
// For non-fixed elements, we need document-relative coordinates
|
||||
if (elementResult.element.position !== 'fixed') {
|
||||
// Add scroll position to convert from viewport to document coordinates
|
||||
clip.x += elementResult.scrollPosition.x;
|
||||
clip.y += elementResult.scrollPosition.y;
|
||||
}
|
||||
|
||||
if (scale) {
|
||||
clip.scale = scale;
|
||||
}
|
||||
|
||||
return attachDebugger(tabId, async () => {
|
||||
const screenshot = await chrome.debugger.sendCommand({ tabId }, 'Page.captureScreenshot', {
|
||||
format,
|
||||
quality: Math.round(quality * 100), // Chrome needs an integer percentage,
|
||||
clip
|
||||
});
|
||||
|
||||
return {
|
||||
...elementResult,
|
||||
element: undefined,
|
||||
selector: elementResult.element?.selector || undefined,
|
||||
mimeType: `image/${format}`,
|
||||
data: screenshot.data,
|
||||
};
|
||||
})
|
||||
.catch((err) => {
|
||||
return respondWithError(tabId,'SCREENSHOT_ERROR', err.message, null, null);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
|
||||
// Connection state enum
|
||||
export const ConnectionStatus = {
|
||||
DISCONNECTED: 'disconnected',
|
||||
CONNECTING: 'connecting',
|
||||
CONNECTED: 'connected',
|
||||
RETRYING: 'retrying',
|
||||
ERROR: 'error'
|
||||
};
|
||||
|
||||
// Message class
|
||||
export class Message {
|
||||
constructor(direction, data) {
|
||||
this.id = crypto.randomUUID();
|
||||
this.direction = direction; // 'incoming' | 'outgoing'
|
||||
this.data = data;
|
||||
this.timestamp = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
// Console log entry class
|
||||
export class ConsoleLogEntry {
|
||||
constructor(level, args, stackTrace = null) {
|
||||
this.id = crypto.randomUUID();
|
||||
this.level = level; // 'log' | 'info' | 'warn' | 'error'
|
||||
this.args = args;
|
||||
if (stackTrace) {
|
||||
this.stackTrace = stackTrace;
|
||||
}
|
||||
this.timestamp = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { TabState } from './tab-state.js';
|
||||
import { backgroundCommands, getTabInfo, detectBrowser } from './background-commands.js';
|
||||
|
||||
export class TabManager {
|
||||
constructor() {
|
||||
this.tabs = new Map(); // tabId -> TabState
|
||||
this.listeners = new Set(); // State change listeners
|
||||
}
|
||||
|
||||
// Tab lifecycle
|
||||
getOrCreateTab(tabId) {
|
||||
if (!this.tabs.has(tabId)) {
|
||||
const tabState = new TabState(tabId);
|
||||
this.tabs.set(tabId, tabState);
|
||||
this.notifyListeners(tabId, 'created', tabState);
|
||||
}
|
||||
return this.tabs.get(tabId);
|
||||
}
|
||||
|
||||
getTab(tabId) {
|
||||
return this.tabs.get(tabId);
|
||||
}
|
||||
|
||||
hasTab(tabId) {
|
||||
return this.tabs.has(tabId);
|
||||
}
|
||||
|
||||
removeTab(tabId) {
|
||||
const tabState = this.tabs.get(tabId);
|
||||
if (tabState) {
|
||||
tabState.cleanup();
|
||||
this.tabs.delete(tabId);
|
||||
this.notifyListeners(tabId, 'removed', null);
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket connection management
|
||||
async connect(tabId) {
|
||||
const tabState = this.getOrCreateTab(tabId);
|
||||
|
||||
if (tabState.websocket && tabState.websocket.readyState === WebSocket.OPEN) {
|
||||
return { ok: true, message: 'Already connected' };
|
||||
}
|
||||
|
||||
// Get tab info from content script
|
||||
const tabInfo = await getTabInfo(tabId);
|
||||
tabState.updatePageMetadata(tabInfo);
|
||||
|
||||
// Set up connection
|
||||
tabState.connectionInfo.userDisconnected = false;
|
||||
|
||||
await this._createConnection(tabState);
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
disconnect(tabId) {
|
||||
const tabState = this.getTab(tabId);
|
||||
if (!tabState) {
|
||||
return { ok: false, error: 'Tab not found' };
|
||||
}
|
||||
|
||||
tabState.connectionInfo.setDisconnected(true);
|
||||
|
||||
// Clear keepalive interval
|
||||
if (tabState.keepaliveInterval) {
|
||||
clearInterval(tabState.keepaliveInterval);
|
||||
tabState.keepaliveInterval = null;
|
||||
}
|
||||
|
||||
if (tabState.websocket) {
|
||||
tabState.websocket.close();
|
||||
tabState.clearWebSocket();
|
||||
}
|
||||
|
||||
// Clear any pending reconnect
|
||||
if (tabState.connectionInfo.reconnectTimer) {
|
||||
clearTimeout(tabState.connectionInfo.reconnectTimer);
|
||||
tabState.connectionInfo.reconnectTimer = null;
|
||||
}
|
||||
|
||||
this.notifyListeners(tabId, 'stateChanged', tabState);
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Private connection methods
|
||||
async _createConnection(tabState) {
|
||||
// First check if tab still exists
|
||||
try {
|
||||
await chrome.tabs.get(tabState.tabId);
|
||||
} catch (error) {
|
||||
console.log(`Tab ${tabState.tabId} no longer exists, aborting connection`);
|
||||
this.removeTab(tabState.tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(tabState.connectionInfo.url);
|
||||
tabState.setWebSocket(ws);
|
||||
|
||||
// Clear any existing keepalive interval
|
||||
if (tabState.keepaliveInterval) {
|
||||
clearInterval(tabState.keepaliveInterval);
|
||||
tabState.keepaliveInterval = null;
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
tabState.connectionInfo.setConnected();
|
||||
this.notifyListeners(tabState.tabId, 'stateChanged', tabState);
|
||||
|
||||
// Send registration message with Chrome tab ID and metadata
|
||||
const registerMessage = {
|
||||
type: 'register',
|
||||
requestedTabId: tabState.tabId.toString(), // Chrome tab ID
|
||||
browser: detectBrowser(), // Add browser type
|
||||
...tabState.pageMetadata
|
||||
};
|
||||
|
||||
this.sendMessage(tabState.tabId, registerMessage);
|
||||
|
||||
// Set up keepalive ping every 30 seconds
|
||||
tabState.keepaliveInterval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
|
||||
} catch (e) {
|
||||
console.error('Failed to send keepalive ping:', e);
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
// Clear keepalive interval
|
||||
if (tabState.keepaliveInterval) {
|
||||
clearInterval(tabState.keepaliveInterval);
|
||||
tabState.keepaliveInterval = null;
|
||||
}
|
||||
|
||||
tabState.clearWebSocket();
|
||||
|
||||
if (!tabState.connectionInfo.userDisconnected) {
|
||||
// Schedule reconnect
|
||||
this._scheduleReconnect(tabState);
|
||||
} else {
|
||||
tabState.connectionInfo.setDisconnected(true);
|
||||
}
|
||||
|
||||
this.notifyListeners(tabState.tabId, 'stateChanged', tabState);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error(`WebSocket error for tab ${tabState.tabId}:`, error);
|
||||
tabState.connectionInfo.setError(error);
|
||||
// Don't clear WebSocket here - let onclose handle cleanup and reconnection
|
||||
this.notifyListeners(tabState.tabId, 'stateChanged', tabState);
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Don't track pong messages
|
||||
if (data.type !== 'pong') {
|
||||
const message = tabState.addMessage('incoming', data);
|
||||
this.notifyListeners(tabState.tabId, 'messageReceived', tabState, message);
|
||||
}
|
||||
|
||||
// Handle commands
|
||||
if (data.type === 'command' && data.command && data.id) {
|
||||
await this._handleCommand(tabState, data);
|
||||
} else if (data.type === 'pong') {
|
||||
// Pong received - connection is healthy
|
||||
tabState.lastPongTime = Date.now();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse WebSocket message:', e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
_scheduleReconnect(tabState) {
|
||||
// Clear any existing reconnect timer
|
||||
if (tabState.connectionInfo.reconnectTimer) {
|
||||
clearTimeout(tabState.connectionInfo.reconnectTimer);
|
||||
tabState.connectionInfo.reconnectTimer = null;
|
||||
}
|
||||
|
||||
const attemptNumber = tabState.connectionInfo.reconnectAttempts;
|
||||
const backoffMs = this._getBackoffMs(attemptNumber);
|
||||
|
||||
console.log(`Scheduling reconnect for tab ${tabState.tabId} in ${backoffMs}ms (attempt ${attemptNumber + 1})`);
|
||||
|
||||
tabState.connectionInfo.setRetrying(attemptNumber + 1, backoffMs);
|
||||
this.notifyListeners(tabState.tabId, 'stateChanged', tabState);
|
||||
|
||||
tabState.connectionInfo.reconnectTimer = setTimeout(async () => {
|
||||
if (!tabState.connectionInfo.userDisconnected) {
|
||||
// Check if tab still exists before attempting reconnect
|
||||
try {
|
||||
await chrome.tabs.get(tabState.tabId);
|
||||
console.log(`Attempting reconnect for tab ${tabState.tabId}`);
|
||||
this._createConnection(tabState);
|
||||
} catch (error) {
|
||||
console.log(`Tab ${tabState.tabId} no longer exists, removing from manager`);
|
||||
this.removeTab(tabState.tabId);
|
||||
}
|
||||
}
|
||||
}, backoffMs);
|
||||
}
|
||||
|
||||
_getBackoffMs(attemptNumber) {
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, then cap at 60s
|
||||
return Math.min(1000 * Math.pow(2, attemptNumber), 60000);
|
||||
}
|
||||
|
||||
async _handleCommand(tabState, {command, params, id}) {
|
||||
try {
|
||||
let result;
|
||||
// some need to run with the background context
|
||||
if (backgroundCommands[command]) {
|
||||
result = await backgroundCommands[command](tabState, params);
|
||||
}
|
||||
// others we execute in the page context
|
||||
else {
|
||||
result = await chrome.tabs.sendMessage(tabState.tabId, {command, params});
|
||||
}
|
||||
// `success: true` means we didn't throw an error. TODO: rename or remove it
|
||||
const response = {id, type: 'response', success: true, result};
|
||||
this.sendMessage(tabState.tabId, response);
|
||||
}
|
||||
catch (error) {
|
||||
const errorResponse = { id, type: 'response', success: false, error: { message: error.message, code: 'COMMAND_FAILED' }};
|
||||
this.sendMessage(tabState.tabId, errorResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// Message sending
|
||||
sendMessage(tabId, data) {
|
||||
const tabState = this.getTab(tabId);
|
||||
if (!tabState || !tabState.websocket || tabState.websocket.readyState !== WebSocket.OPEN) {
|
||||
return { ok: false, error: 'Not connected' };
|
||||
}
|
||||
|
||||
try {
|
||||
const message = tabState.addMessage('outgoing', data);
|
||||
tabState.websocket.send(JSON.stringify(data));
|
||||
this.notifyListeners(tabId, 'messageSent', tabState, message);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Message management
|
||||
clearMessages(tabId) {
|
||||
const tabState = this.getTab(tabId);
|
||||
if (tabState) {
|
||||
tabState.clearMessages();
|
||||
this.notifyListeners(tabId, 'messagesCleared', tabState);
|
||||
}
|
||||
}
|
||||
|
||||
// Console log management
|
||||
addConsoleLog(tabId, log) {
|
||||
const tabState = this.getTab(tabId);
|
||||
if (tabState) {
|
||||
tabState.addConsoleLog(log);
|
||||
this.notifyListeners(tabId, 'consoleLogAdded', tabState, log);
|
||||
}
|
||||
}
|
||||
|
||||
clearConsoleLogs(tabId) {
|
||||
const tabState = this.getTab(tabId);
|
||||
if (tabState) {
|
||||
tabState.clearConsoleLogs();
|
||||
this.notifyListeners(tabId, 'consoleLogsCleared', tabState);
|
||||
}
|
||||
}
|
||||
|
||||
// Port management
|
||||
addPort(tabId, port) {
|
||||
const tabState = this.getOrCreateTab(tabId);
|
||||
tabState.addPort(port);
|
||||
|
||||
// Send initial state
|
||||
port.postMessage({
|
||||
type: 'state',
|
||||
tabId,
|
||||
...tabState.getConnectionState()
|
||||
});
|
||||
|
||||
// Send current messages
|
||||
port.postMessage({
|
||||
type: 'messages',
|
||||
tabId,
|
||||
messages: tabState.getMessages()
|
||||
});
|
||||
|
||||
// Send console count
|
||||
port.postMessage({
|
||||
type: 'consoleCount',
|
||||
tabId,
|
||||
count: tabState.getConsoleLogCount()
|
||||
});
|
||||
}
|
||||
|
||||
removePort(tabId, port) {
|
||||
const tabState = this.getTab(tabId);
|
||||
if (tabState) {
|
||||
tabState.removePort(port);
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
addListener(callback) {
|
||||
this.listeners.add(callback);
|
||||
}
|
||||
|
||||
removeListener(callback) {
|
||||
this.listeners.delete(callback);
|
||||
}
|
||||
|
||||
notifyListeners(tabId, event, tabState, data = null) {
|
||||
this.listeners.forEach(callback => {
|
||||
try {
|
||||
callback(tabId, event, tabState, data);
|
||||
} catch (e) {
|
||||
console.error('Error in TabManager listener:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Utility
|
||||
getAllTabs() {
|
||||
return Array.from(this.tabs.values());
|
||||
}
|
||||
|
||||
getActiveTabCount() {
|
||||
return this.tabs.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
|
||||
// WebSocket connection info
|
||||
import { ConnectionStatus, Message } from "./models.js";
|
||||
|
||||
export class ConnectionInfo {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.status = ConnectionStatus.DISCONNECTED;
|
||||
this.connected = false;
|
||||
this.userDisconnected = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.reconnectTimer = null;
|
||||
this.nextRetryIn = null;
|
||||
}
|
||||
|
||||
setConnected() {
|
||||
this.status = ConnectionStatus.CONNECTED;
|
||||
this.connected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.nextRetryIn = null;
|
||||
}
|
||||
|
||||
setDisconnected(userInitiated = false) {
|
||||
this.status = ConnectionStatus.DISCONNECTED;
|
||||
this.connected = false;
|
||||
this.userDisconnected = userInitiated;
|
||||
if (userInitiated) {
|
||||
this.reconnectAttempts = 0;
|
||||
this.nextRetryIn = null;
|
||||
}
|
||||
}
|
||||
|
||||
setRetrying(attemptNumber, nextRetryMs) {
|
||||
this.status = ConnectionStatus.RETRYING;
|
||||
this.connected = false;
|
||||
this.reconnectAttempts = attemptNumber;
|
||||
this.nextRetryIn = nextRetryMs;
|
||||
}
|
||||
|
||||
setError(error) {
|
||||
this.status = ConnectionStatus.ERROR;
|
||||
this.connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Main tab state class
|
||||
export class TabState {
|
||||
constructor(tabId) {
|
||||
this.tabId = tabId;
|
||||
this.websocket = null;
|
||||
this.connectionInfo = new ConnectionInfo('ws://localhost:61822');
|
||||
this.messages = [];
|
||||
this.consoleLogs = [];
|
||||
this.ports = new Set(); // Connected DevTools panels/popups
|
||||
this.pageMetadata = {};
|
||||
this.mousePosition = { x: 0, y: 0 }; // Track current mouse position
|
||||
}
|
||||
|
||||
// WebSocket management
|
||||
setWebSocket(ws) {
|
||||
this.websocket = ws;
|
||||
}
|
||||
|
||||
clearWebSocket() {
|
||||
this.websocket = null;
|
||||
}
|
||||
|
||||
// Message management
|
||||
addMessage(direction, data) {
|
||||
const message = new Message(direction, data);
|
||||
this.messages.push(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
clearMessages() {
|
||||
this.messages = [];
|
||||
}
|
||||
|
||||
getMessages(limit = null) {
|
||||
if (limit === null) {
|
||||
return [...this.messages];
|
||||
}
|
||||
return this.messages.slice(-limit);
|
||||
}
|
||||
|
||||
// Console log management
|
||||
addConsoleLog(log) {
|
||||
this.consoleLogs.unshift(log);
|
||||
}
|
||||
|
||||
clearConsoleLogs() {
|
||||
this.consoleLogs = [];
|
||||
}
|
||||
|
||||
getConsoleLogs(limit = null, level = null, before = null) {
|
||||
let logs = this.consoleLogs;
|
||||
|
||||
if (level) {
|
||||
logs = logs.filter(log => log.level === level);
|
||||
}
|
||||
|
||||
if (before) {
|
||||
const beforeTimestamp = new Date(before).getTime();
|
||||
logs = logs.filter(log =>
|
||||
new Date(log.timestamp).getTime() < beforeTimestamp
|
||||
);
|
||||
}
|
||||
|
||||
if (limit === null) {
|
||||
return [...logs];
|
||||
}
|
||||
return logs.slice(-limit);
|
||||
}
|
||||
|
||||
getConsoleLogCount() {
|
||||
return this.consoleLogs.length;
|
||||
}
|
||||
|
||||
// Port management
|
||||
addPort(port) {
|
||||
this.ports.add(port);
|
||||
}
|
||||
|
||||
removePort(port) {
|
||||
this.ports.delete(port);
|
||||
}
|
||||
|
||||
broadcastToPorts(message) {
|
||||
this.ports.forEach(port => {
|
||||
try {
|
||||
port.postMessage(message);
|
||||
} catch (e) {
|
||||
// Port might be disconnected
|
||||
this.ports.delete(port);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// State management
|
||||
getConnectionState() {
|
||||
return {
|
||||
connected: this.connectionInfo.connected,
|
||||
status: this.connectionInfo.status,
|
||||
reconnectAttempt: this.connectionInfo.reconnectAttempts,
|
||||
nextRetryIn: this.connectionInfo.nextRetryIn
|
||||
};
|
||||
}
|
||||
|
||||
updatePageMetadata(metadata) {
|
||||
this.pageMetadata = metadata;
|
||||
}
|
||||
|
||||
// Mouse position tracking
|
||||
setMousePosition(position) {
|
||||
this.mousePosition = { ...position };
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
cleanup() {
|
||||
// Clear reconnect timer
|
||||
if (this.connectionInfo.reconnectTimer) {
|
||||
clearTimeout(this.connectionInfo.reconnectTimer);
|
||||
this.connectionInfo.reconnectTimer = null;
|
||||
}
|
||||
|
||||
// Close WebSocket
|
||||
if (this.websocket) {
|
||||
this.websocket.close();
|
||||
this.websocket = null;
|
||||
}
|
||||
|
||||
// Clear all data
|
||||
this.messages = [];
|
||||
this.consoleLogs = [];
|
||||
this.ports.clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user