adding examples

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