1284 lines
41 KiB
TypeScript
1284 lines
41 KiB
TypeScript
/**
|
|
* Remote Server Client for Chrome Extension
|
|
* Connects to the remote MCP server via WebSocket
|
|
*/
|
|
|
|
import { TIMEOUTS } from '@/common/constants';
|
|
import { handleCallTool } from '@/entrypoints/background/tools';
|
|
import { DEFAULT_CONNECTION_CONFIG } from '@/common/env-config';
|
|
|
|
export interface RemoteServerConfig {
|
|
serverUrl: string;
|
|
reconnectInterval: number;
|
|
maxReconnectAttempts: number;
|
|
}
|
|
|
|
export interface SessionInfo {
|
|
userId: string;
|
|
sessionId: string;
|
|
connectionId: string;
|
|
}
|
|
|
|
export interface RemoteServerStatus {
|
|
connected: boolean;
|
|
connecting: boolean;
|
|
connectionTime?: number;
|
|
reconnectAttempts: number;
|
|
error?: string;
|
|
serverUrl: string;
|
|
sessionInfo?: SessionInfo;
|
|
}
|
|
|
|
export class RemoteServerClient {
|
|
private ws: WebSocket | null = null;
|
|
private config: RemoteServerConfig;
|
|
private reconnectAttempts = 0;
|
|
private isConnecting = false;
|
|
private messageHandlers = new Map<string, (data: any) => void>();
|
|
private connectionTime: number | null = null;
|
|
private statusUpdateCallback: ((status: any) => void) | null = null;
|
|
private persistentConnectionEnabled = true; // Enable persistent connections by default
|
|
private connectionStateKey = 'remoteServerConnectionState';
|
|
private keepAliveInterval: NodeJS.Timeout | null = null;
|
|
private sessionInfo: SessionInfo | null = null;
|
|
|
|
constructor(config: Partial<RemoteServerConfig> = {}) {
|
|
this.config = {
|
|
serverUrl: config.serverUrl || DEFAULT_CONNECTION_CONFIG.serverUrl,
|
|
reconnectInterval: config.reconnectInterval || DEFAULT_CONNECTION_CONFIG.reconnectInterval,
|
|
maxReconnectAttempts: config.maxReconnectAttempts || 999999, // Effectively unlimited for persistent connections
|
|
};
|
|
|
|
// Do not auto-connect on initialization - only connect when user explicitly requests it
|
|
console.log(
|
|
'RemoteServerClient: Initialized without auto-connection. Use connect() method to establish connection.',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Set up keep-alive mechanism to prevent connection timeouts
|
|
*/
|
|
private setupKeepAlive(): void {
|
|
// Clear any existing keep-alive interval
|
|
if (this.keepAliveInterval) {
|
|
clearInterval(this.keepAliveInterval);
|
|
}
|
|
|
|
// Send a ping every 25 seconds to keep connection alive
|
|
this.keepAliveInterval = setInterval(() => {
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
// Send a simple ping message with proper ID to avoid "unknown request ID" warnings
|
|
try {
|
|
const pingId = `ping_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
this.ws.send(
|
|
JSON.stringify({
|
|
id: pingId,
|
|
type: 'ping',
|
|
timestamp: Date.now(),
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
console.warn('RemoteServerClient: Failed to send keep-alive ping:', error);
|
|
}
|
|
}
|
|
}, 25000); // 25 seconds - slightly less than server's 30-second ping interval
|
|
}
|
|
|
|
/**
|
|
* Clear keep-alive mechanism
|
|
*/
|
|
private clearKeepAlive(): void {
|
|
if (this.keepAliveInterval) {
|
|
clearInterval(this.keepAliveInterval);
|
|
this.keepAliveInterval = null;
|
|
}
|
|
}
|
|
|
|
setStatusUpdateCallback(callback: (status: any) => void) {
|
|
this.statusUpdateCallback = callback;
|
|
}
|
|
|
|
/**
|
|
* Save connection state to chrome storage for persistence
|
|
*/
|
|
private async saveConnectionState(): Promise<void> {
|
|
try {
|
|
const state = {
|
|
wasConnected: this.isConnected(),
|
|
connectionTime: this.connectionTime,
|
|
serverUrl: this.config.serverUrl,
|
|
lastSaveTime: Date.now(),
|
|
};
|
|
await chrome.storage.local.set({ [this.connectionStateKey]: state });
|
|
console.log('RemoteServerClient: Connection state saved', state);
|
|
} catch (error) {
|
|
console.warn('RemoteServerClient: Failed to save connection state:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load connection state from chrome storage (for manual restoration only)
|
|
*/
|
|
private async loadConnectionState(): Promise<any> {
|
|
try {
|
|
const result = await chrome.storage.local.get(this.connectionStateKey);
|
|
const state = result[this.connectionStateKey];
|
|
|
|
if (state && state.wasConnected) {
|
|
console.log(
|
|
'RemoteServerClient: Found previous connection state (manual restoration available)',
|
|
state,
|
|
);
|
|
return state;
|
|
}
|
|
return null;
|
|
} catch (error) {
|
|
console.warn('RemoteServerClient: Failed to load connection state:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Manually restore connection from saved state (only when user requests it)
|
|
*/
|
|
async restoreConnectionFromState(): Promise<boolean> {
|
|
const state = await this.loadConnectionState();
|
|
if (!state) {
|
|
console.log('RemoteServerClient: No previous connection state to restore');
|
|
return false;
|
|
}
|
|
|
|
const timeSinceLastSave = Date.now() - (state.lastSaveTime || 0);
|
|
const maxRestoreAge = 24 * 60 * 60 * 1000; // 24 hours
|
|
|
|
if (timeSinceLastSave < maxRestoreAge) {
|
|
console.log('RemoteServerClient: Attempting to restore connection from saved state...');
|
|
try {
|
|
await this.connect();
|
|
return true;
|
|
} catch (error) {
|
|
console.log('RemoteServerClient: Failed to restore previous connection:', error);
|
|
return false;
|
|
}
|
|
} else {
|
|
console.log('RemoteServerClient: Previous connection state too old, clearing...');
|
|
await this.clearConnectionState();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear saved connection state
|
|
*/
|
|
private async clearConnectionState(): Promise<void> {
|
|
try {
|
|
await chrome.storage.local.remove(this.connectionStateKey);
|
|
console.log('RemoteServerClient: Connection state cleared');
|
|
} catch (error) {
|
|
console.warn('RemoteServerClient: Failed to clear connection state:', error);
|
|
}
|
|
}
|
|
|
|
private notifyStatusUpdate(status: any) {
|
|
if (this.statusUpdateCallback) {
|
|
this.statusUpdateCallback(status);
|
|
}
|
|
|
|
// Also send to popup if available
|
|
try {
|
|
chrome.runtime.sendMessage(
|
|
{
|
|
type: 'remoteServerStatusUpdate',
|
|
payload: status,
|
|
},
|
|
(response) => {
|
|
// Handle response or check for errors
|
|
if (chrome.runtime.lastError) {
|
|
// Silently ignore "Receiving end does not exist" errors
|
|
// This happens when popup is not open
|
|
}
|
|
},
|
|
);
|
|
} catch (error) {
|
|
// Ignore errors if popup is not available
|
|
}
|
|
}
|
|
|
|
getStatus(): RemoteServerStatus {
|
|
return {
|
|
connected: this.isConnected(),
|
|
connecting: this.isConnecting,
|
|
reconnectAttempts: this.reconnectAttempts,
|
|
connectionTime: this.connectionTime,
|
|
serverUrl: this.config.serverUrl,
|
|
sessionInfo: this.sessionInfo,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get current session information
|
|
*/
|
|
getSessionInfo(): SessionInfo | null {
|
|
return this.sessionInfo;
|
|
}
|
|
|
|
/**
|
|
* Check if session is active
|
|
*/
|
|
hasActiveSession(): boolean {
|
|
return this.sessionInfo !== null && this.isConnected();
|
|
}
|
|
|
|
/**
|
|
* Generate or retrieve persistent user ID for this Chrome extension
|
|
*/
|
|
private async generateOrRetrieveUserId(): Promise<string> {
|
|
const storageKey = 'chrome_extension_user_id';
|
|
|
|
try {
|
|
// Try to get existing user ID from storage first
|
|
const result = await chrome.storage.local.get([storageKey]);
|
|
if (result[storageKey]) {
|
|
console.log(`Chrome Extension: Retrieved existing user ID: ${result[storageKey]}`);
|
|
return result[storageKey];
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to retrieve user ID from storage:', error);
|
|
}
|
|
|
|
// Generate new user ID if none exists
|
|
const timestamp = Date.now();
|
|
const randomSuffix = Math.random().toString(36).substring(2, 15);
|
|
const userId = `user_${timestamp}_${randomSuffix}`;
|
|
|
|
try {
|
|
// Save the new user ID to storage for persistence
|
|
await chrome.storage.local.set({ [storageKey]: userId });
|
|
console.log(`Chrome Extension: Generated and saved new user ID: ${userId}`);
|
|
} catch (error) {
|
|
console.warn('Failed to save user ID to storage:', error);
|
|
}
|
|
|
|
return userId;
|
|
}
|
|
|
|
/**
|
|
* Get the current user ID (public method)
|
|
*/
|
|
public async getCurrentUserId(): Promise<string | null> {
|
|
const storageKey = 'chrome_extension_user_id';
|
|
try {
|
|
const result = await chrome.storage.local.get([storageKey]);
|
|
return result[storageKey] || null;
|
|
} catch (error) {
|
|
console.warn('Failed to get current user ID:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async connect(): Promise<void> {
|
|
// Check if already connected or connecting
|
|
if (this.isConnecting) {
|
|
console.log('Connection attempt already in progress');
|
|
return;
|
|
}
|
|
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
console.log('Already connected to remote server');
|
|
return;
|
|
}
|
|
|
|
// Clean up any existing connection in bad state
|
|
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
|
|
console.log('Cleaning up existing connection in bad state');
|
|
this.ws.close();
|
|
this.ws = null;
|
|
}
|
|
|
|
this.isConnecting = true;
|
|
this.notifyStatusUpdate({ connecting: true, error: undefined });
|
|
|
|
try {
|
|
console.log(`Attempting to connect to: ${this.config.serverUrl}`);
|
|
this.ws = new WebSocket(this.config.serverUrl);
|
|
|
|
this.ws.onopen = async () => {
|
|
console.log('Connected to remote MCP server');
|
|
this.isConnecting = false;
|
|
this.reconnectAttempts = 0;
|
|
this.connectionTime = Date.now();
|
|
|
|
// Generate or retrieve persistent user ID for this Chrome extension
|
|
const userId = await this.generateOrRetrieveUserId();
|
|
|
|
// Send connection info to server for session management with user ID
|
|
const connectionInfo = {
|
|
type: 'connection_info',
|
|
userId: userId, // Include the Chrome extension's user ID
|
|
userAgent: navigator.userAgent,
|
|
timestamp: Date.now(),
|
|
extensionId: chrome.runtime.id,
|
|
};
|
|
|
|
console.log(`Chrome Extension: Connecting with user ID: ${userId}`);
|
|
this.ws?.send(JSON.stringify(connectionInfo));
|
|
|
|
this.notifyStatusUpdate({
|
|
connected: true,
|
|
connecting: false,
|
|
connectionTime: this.connectionTime,
|
|
reconnectAttempts: this.reconnectAttempts,
|
|
});
|
|
|
|
// Start keep-alive mechanism
|
|
this.setupKeepAlive();
|
|
|
|
// Save connection state for persistence
|
|
this.saveConnectionState();
|
|
};
|
|
|
|
this.ws.onmessage = (event) => {
|
|
try {
|
|
// Handle ping/pong for connection keep-alive
|
|
if (event.data === 'ping') {
|
|
this.ws?.send('pong');
|
|
return;
|
|
}
|
|
|
|
const data = JSON.parse(event.data);
|
|
|
|
// Check if this is a session initialization message
|
|
if (data.type === 'session_info' && data.sessionInfo) {
|
|
this.sessionInfo = data.sessionInfo;
|
|
console.log('Session info received:', this.sessionInfo);
|
|
this.notifyStatusUpdate({
|
|
connected: true,
|
|
connecting: false,
|
|
connectionTime: this.connectionTime,
|
|
reconnectAttempts: this.reconnectAttempts,
|
|
sessionInfo: this.sessionInfo,
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.handleMessage(data);
|
|
} catch (error) {
|
|
console.error('Error parsing message from remote server:', error);
|
|
}
|
|
};
|
|
|
|
this.ws.onclose = (event) => {
|
|
const wasConnected = this.connectionTime !== null;
|
|
console.log(
|
|
`Disconnected from remote MCP server (code: ${event.code}, reason: ${event.reason})`,
|
|
);
|
|
|
|
this.isConnecting = false;
|
|
this.connectionTime = null;
|
|
|
|
// Stop keep-alive mechanism
|
|
this.clearKeepAlive();
|
|
|
|
// Determine if this was an unexpected disconnection
|
|
const wasUnexpected = wasConnected && event.code !== 1000; // 1000 = normal closure
|
|
|
|
this.notifyStatusUpdate({
|
|
connected: false,
|
|
connecting: false,
|
|
connectionTime: null,
|
|
error: wasUnexpected ? `Connection lost (code: ${event.code})` : undefined,
|
|
});
|
|
|
|
// Only schedule reconnect for unexpected disconnections
|
|
if (wasUnexpected) {
|
|
this.scheduleReconnect();
|
|
}
|
|
};
|
|
|
|
this.ws.onerror = (error) => {
|
|
console.error('WebSocket error:', error);
|
|
this.isConnecting = false;
|
|
|
|
// Provide more specific error messages based on the error
|
|
let errorMessage = 'Connection error';
|
|
if (this.ws?.readyState === WebSocket.CONNECTING) {
|
|
errorMessage = 'Failed to connect to server';
|
|
} else if (this.ws?.readyState === WebSocket.OPEN) {
|
|
errorMessage = 'Connection error during communication';
|
|
}
|
|
|
|
this.notifyStatusUpdate({
|
|
connected: false,
|
|
connecting: false,
|
|
error: errorMessage,
|
|
});
|
|
};
|
|
|
|
// Wait for connection to open with enhanced error handling
|
|
await new Promise<void>((resolve, reject) => {
|
|
if (!this.ws) {
|
|
reject(new Error('WebSocket not initialized'));
|
|
return;
|
|
}
|
|
|
|
const timeout = setTimeout(() => {
|
|
this.isConnecting = false;
|
|
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
|
|
this.ws.close();
|
|
}
|
|
this.notifyStatusUpdate({
|
|
connected: false,
|
|
connecting: false,
|
|
error: 'Connection timeout - Server may be unreachable',
|
|
});
|
|
reject(new Error('Connection timeout - Server may be unreachable'));
|
|
}, TIMEOUTS.REMOTE_SERVER_CONNECTION);
|
|
|
|
// Override the onopen handler for the promise
|
|
const originalOnOpen = this.ws.onopen;
|
|
this.ws.onopen = (event) => {
|
|
clearTimeout(timeout);
|
|
// Call the original handler
|
|
if (originalOnOpen) {
|
|
originalOnOpen.call(this.ws, event);
|
|
}
|
|
resolve();
|
|
};
|
|
|
|
// Override the onerror handler for the promise
|
|
const originalOnError = this.ws.onerror;
|
|
this.ws.onerror = (event) => {
|
|
clearTimeout(timeout);
|
|
this.isConnecting = false;
|
|
|
|
// Determine error type based on readyState
|
|
let errorMessage = 'Connection failed';
|
|
if (this.ws?.readyState === WebSocket.CONNECTING) {
|
|
errorMessage = 'Failed to connect - Server may be offline';
|
|
}
|
|
|
|
this.notifyStatusUpdate({
|
|
connected: false,
|
|
connecting: false,
|
|
error: errorMessage,
|
|
});
|
|
|
|
// Call the original handler
|
|
if (originalOnError) {
|
|
originalOnError.call(this.ws, event);
|
|
}
|
|
|
|
reject(new Error(errorMessage));
|
|
};
|
|
});
|
|
} catch (error) {
|
|
this.isConnecting = false;
|
|
this.notifyStatusUpdate({
|
|
connected: false,
|
|
connecting: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private scheduleReconnect(): void {
|
|
// For persistent connections, never give up - keep trying indefinitely
|
|
const maxAttempts = this.persistentConnectionEnabled ? Infinity : 10;
|
|
|
|
// Only stop if persistent connections are disabled and max attempts reached
|
|
if (!this.persistentConnectionEnabled && this.reconnectAttempts >= maxAttempts) {
|
|
console.error(`Max reconnection attempts reached (${maxAttempts})`);
|
|
this.notifyStatusUpdate({
|
|
connected: false,
|
|
connecting: false,
|
|
reconnectAttempts: this.reconnectAttempts,
|
|
error: `Connection failed after ${maxAttempts} attempts`,
|
|
});
|
|
|
|
this.clearConnectionState();
|
|
return;
|
|
}
|
|
|
|
this.reconnectAttempts++;
|
|
|
|
// Use exponential backoff for reconnection delays, but cap at reasonable intervals
|
|
const baseDelay = this.config.reconnectInterval;
|
|
const maxDelay = this.persistentConnectionEnabled ? 30000 : 30000; // Cap at 30s for both
|
|
const exponentialDelay = Math.min(
|
|
baseDelay * Math.pow(1.5, Math.min(this.reconnectAttempts - 1, 10)), // Slower growth, cap exponential part
|
|
maxDelay,
|
|
);
|
|
|
|
const attemptsDisplay = this.persistentConnectionEnabled
|
|
? this.reconnectAttempts
|
|
: `${this.reconnectAttempts}/${maxAttempts}`;
|
|
console.log(
|
|
`Scheduling reconnection attempt ${attemptsDisplay} in ${exponentialDelay}ms (persistent: ${this.persistentConnectionEnabled})`,
|
|
);
|
|
|
|
this.notifyStatusUpdate({
|
|
connected: false,
|
|
connecting: false,
|
|
reconnectAttempts: this.reconnectAttempts,
|
|
error: `Reconnecting... (attempt ${attemptsDisplay})`,
|
|
});
|
|
|
|
setTimeout(() => {
|
|
// Check if we should still attempt reconnection
|
|
if (this.reconnectAttempts <= maxAttempts && !this.isConnected()) {
|
|
console.log(`Executing reconnection attempt ${this.reconnectAttempts}`);
|
|
this.connect().catch((error) => {
|
|
console.error(`Reconnection attempt ${this.reconnectAttempts} failed:`, error);
|
|
// The error will trigger another reconnection attempt via onclose handler
|
|
});
|
|
} else {
|
|
console.log('Skipping reconnection attempt - already connected or max attempts reached');
|
|
}
|
|
}, exponentialDelay);
|
|
}
|
|
|
|
private handleMessage(data: any): void {
|
|
console.log('🟡 [Chrome Extension] Received message from remote server:', {
|
|
action: data.action,
|
|
id: data.id,
|
|
hasParams: !!data.params,
|
|
fullMessage: data,
|
|
});
|
|
|
|
if (data.id && this.messageHandlers.has(data.id)) {
|
|
const handler = this.messageHandlers.get(data.id);
|
|
if (handler) {
|
|
console.log(
|
|
'🟡 [Chrome Extension] Handling message with existing handler for ID:',
|
|
data.id,
|
|
);
|
|
handler(data);
|
|
this.messageHandlers.delete(data.id);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle different types of messages
|
|
switch (data.action) {
|
|
// General tool call handler - routes to the extension's tool system
|
|
case 'callTool':
|
|
console.log('🟡 [Chrome Extension] Handling callTool action:', data.params);
|
|
this.handleToolCall(data);
|
|
break;
|
|
|
|
// Legacy actions for backward compatibility
|
|
case 'navigate':
|
|
this.handleNavigate(data);
|
|
break;
|
|
case 'getContent':
|
|
this.handleGetContent(data);
|
|
break;
|
|
case 'click':
|
|
this.handleClick(data);
|
|
break;
|
|
case 'fillInput':
|
|
this.handleFillInput(data);
|
|
break;
|
|
case 'screenshot':
|
|
this.handleScreenshot(data);
|
|
break;
|
|
case 'executeScript':
|
|
this.handleExecuteScript(data);
|
|
break;
|
|
case 'getCurrentTab':
|
|
this.handleGetCurrentTab(data);
|
|
break;
|
|
case 'getAllTabs':
|
|
this.handleGetAllTabs(data);
|
|
break;
|
|
case 'switchTab':
|
|
this.handleSwitchTab(data);
|
|
break;
|
|
case 'createTab':
|
|
this.handleCreateTab(data);
|
|
break;
|
|
case 'closeTab':
|
|
this.handleCloseTab(data);
|
|
break;
|
|
|
|
// Browser automation tools matching native server
|
|
case 'get_windows_and_tabs':
|
|
this.handleGetWindowsAndTabs(data);
|
|
break;
|
|
case 'search_tabs_content':
|
|
this.handleSearchTabsContent(data);
|
|
break;
|
|
case 'chrome_navigate':
|
|
this.handleChromeNavigate(data);
|
|
break;
|
|
case 'chrome_screenshot':
|
|
this.handleChromeScreenshot(data);
|
|
break;
|
|
case 'chrome_close_tabs':
|
|
this.handleChromeCloseTabs(data);
|
|
break;
|
|
case 'chrome_go_back_or_forward':
|
|
this.handleChromeGoBackOrForward(data);
|
|
break;
|
|
case 'chrome_get_web_content':
|
|
this.handleChromeGetWebContent(data);
|
|
break;
|
|
case 'chrome_click_element':
|
|
this.handleChromeClickElement(data);
|
|
break;
|
|
case 'chrome_fill_or_select':
|
|
this.handleChromeFillOrSelect(data);
|
|
break;
|
|
case 'chrome_get_interactive_elements':
|
|
this.handleChromeGetInteractiveElements(data);
|
|
break;
|
|
case 'chrome_network_capture_start':
|
|
this.handleChromeNetworkCaptureStart(data);
|
|
break;
|
|
case 'chrome_network_capture_stop':
|
|
this.handleChromeNetworkCaptureStop(data);
|
|
break;
|
|
case 'chrome_network_request':
|
|
this.handleChromeNetworkRequest(data);
|
|
break;
|
|
case 'chrome_network_debugger_start':
|
|
this.handleChromeNetworkDebuggerStart(data);
|
|
break;
|
|
case 'chrome_network_debugger_stop':
|
|
this.handleChromeNetworkDebuggerStop(data);
|
|
break;
|
|
case 'chrome_keyboard':
|
|
this.handleChromeKeyboard(data);
|
|
break;
|
|
case 'chrome_history':
|
|
this.handleChromeHistory(data);
|
|
break;
|
|
case 'chrome_bookmark_search':
|
|
this.handleChromeBookmarkSearch(data);
|
|
break;
|
|
case 'chrome_bookmark_add':
|
|
this.handleChromeBookmarkAdd(data);
|
|
break;
|
|
case 'chrome_bookmark_delete':
|
|
this.handleChromeBookmarkDelete(data);
|
|
break;
|
|
case 'chrome_inject_script':
|
|
this.handleChromeInjectScript(data);
|
|
break;
|
|
case 'chrome_send_command_to_inject_script':
|
|
this.handleChromeSendCommandToInjectScript(data);
|
|
break;
|
|
case 'chrome_console':
|
|
this.handleChromeConsole(data);
|
|
break;
|
|
default:
|
|
console.warn('Unknown action:', data.action);
|
|
this.sendResponse(data.id, { error: 'Unknown action' });
|
|
}
|
|
}
|
|
|
|
private sendResponse(id: string, result: any): void {
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
try {
|
|
console.log(`📤 [Chrome Extension] Sending response for ID ${id}:`, {
|
|
messageId: id,
|
|
hasResult: !!result,
|
|
isError: result?.isError,
|
|
result,
|
|
});
|
|
this.ws.send(JSON.stringify({ id, result }));
|
|
} catch (error) {
|
|
console.error(`📤 [Chrome Extension] Failed to send response for ID ${id}:`, error);
|
|
}
|
|
} else {
|
|
console.error(
|
|
`📤 [Chrome Extension] Cannot send response for ID ${id}: WebSocket not open (readyState: ${this.ws?.readyState})`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private sendError(id: string, error: string): void {
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
try {
|
|
console.log(`📤 [Chrome Extension] Sending error for ID ${id}:`, error);
|
|
this.ws.send(JSON.stringify({ id, error }));
|
|
} catch (sendError) {
|
|
console.error(`📤 [Chrome Extension] Failed to send error for ID ${id}:`, sendError);
|
|
}
|
|
} else {
|
|
console.error(
|
|
`📤 [Chrome Extension] Cannot send error for ID ${id}: WebSocket not open (readyState: ${this.ws?.readyState})`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Chrome API handlers
|
|
private async handleNavigate(data: any): Promise<void> {
|
|
try {
|
|
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
if (tabs[0]) {
|
|
await chrome.tabs.update(tabs[0].id, { url: data.params.url });
|
|
this.sendResponse(data.id, { success: true, url: data.params.url });
|
|
} else {
|
|
this.sendError(data.id, 'No active tab found');
|
|
}
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Navigation failed');
|
|
}
|
|
}
|
|
|
|
private async handleGetContent(data: any): Promise<void> {
|
|
try {
|
|
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
if (tabs[0]) {
|
|
const results = await chrome.scripting.executeScript({
|
|
target: { tabId: tabs[0].id! },
|
|
func: (selector?: string) => {
|
|
if (selector) {
|
|
const element = document.querySelector(selector);
|
|
return element ? element.textContent : null;
|
|
}
|
|
return document.body.textContent;
|
|
},
|
|
args: [data.params.selector],
|
|
});
|
|
|
|
this.sendResponse(data.id, { content: results[0].result });
|
|
} else {
|
|
this.sendError(data.id, 'No active tab found');
|
|
}
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Failed to get content');
|
|
}
|
|
}
|
|
|
|
private async handleClick(data: any): Promise<void> {
|
|
try {
|
|
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
if (tabs[0]) {
|
|
const results = await chrome.scripting.executeScript({
|
|
target: { tabId: tabs[0].id! },
|
|
func: (selector: string) => {
|
|
const element = document.querySelector(selector) as HTMLElement;
|
|
if (element) {
|
|
element.click();
|
|
return { success: true };
|
|
}
|
|
return { success: false, error: 'Element not found' };
|
|
},
|
|
args: [data.params.selector],
|
|
});
|
|
|
|
this.sendResponse(data.id, results[0].result);
|
|
} else {
|
|
this.sendError(data.id, 'No active tab found');
|
|
}
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Click failed');
|
|
}
|
|
}
|
|
|
|
private async handleFillInput(data: any): Promise<void> {
|
|
try {
|
|
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
if (tabs[0]) {
|
|
const results = await chrome.scripting.executeScript({
|
|
target: { tabId: tabs[0].id! },
|
|
func: (selector: string, value: string) => {
|
|
const element = document.querySelector(selector) as HTMLInputElement;
|
|
if (element) {
|
|
element.value = value;
|
|
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
return { success: true };
|
|
}
|
|
return { success: false, error: 'Element not found' };
|
|
},
|
|
args: [data.params.selector, data.params.value],
|
|
});
|
|
|
|
this.sendResponse(data.id, results[0].result);
|
|
} else {
|
|
this.sendError(data.id, 'No active tab found');
|
|
}
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Fill input failed');
|
|
}
|
|
}
|
|
|
|
private async handleScreenshot(data: any): Promise<void> {
|
|
try {
|
|
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
if (tabs[0]) {
|
|
const dataUrl = await chrome.tabs.captureVisibleTab(undefined, {
|
|
format: 'png',
|
|
quality: 90,
|
|
});
|
|
|
|
this.sendResponse(data.id, { screenshot: dataUrl });
|
|
} else {
|
|
this.sendError(data.id, 'No active tab found');
|
|
}
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Screenshot failed');
|
|
}
|
|
}
|
|
|
|
private async handleExecuteScript(data: any): Promise<void> {
|
|
try {
|
|
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
if (tabs[0]) {
|
|
const results = await chrome.scripting.executeScript({
|
|
target: { tabId: tabs[0].id! },
|
|
func: new Function('return ' + data.params.script)(),
|
|
});
|
|
|
|
this.sendResponse(data.id, { result: results[0].result });
|
|
} else {
|
|
this.sendError(data.id, 'No active tab found');
|
|
}
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Script execution failed');
|
|
}
|
|
}
|
|
|
|
private async handleGetCurrentTab(data: any): Promise<void> {
|
|
try {
|
|
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
this.sendResponse(data.id, { tab: tabs[0] || null });
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Failed to get current tab');
|
|
}
|
|
}
|
|
|
|
private async handleGetAllTabs(data: any): Promise<void> {
|
|
try {
|
|
const tabs = await chrome.tabs.query({});
|
|
this.sendResponse(data.id, { tabs });
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Failed to get all tabs');
|
|
}
|
|
}
|
|
|
|
private async handleSwitchTab(data: any): Promise<void> {
|
|
try {
|
|
await chrome.tabs.update(data.params.tabId, { active: true });
|
|
this.sendResponse(data.id, { success: true });
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Failed to switch tab');
|
|
}
|
|
}
|
|
|
|
private async handleCreateTab(data: any): Promise<void> {
|
|
try {
|
|
const tab = await chrome.tabs.create({ url: data.params.url });
|
|
this.sendResponse(data.id, { tab });
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Failed to create tab');
|
|
}
|
|
}
|
|
|
|
private async handleCloseTab(data: any): Promise<void> {
|
|
try {
|
|
const tabId = data.params.tabId;
|
|
if (tabId) {
|
|
await chrome.tabs.remove(tabId);
|
|
} else {
|
|
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
if (tabs[0]) {
|
|
await chrome.tabs.remove(tabs[0].id!);
|
|
}
|
|
}
|
|
this.sendResponse(data.id, { success: true });
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Failed to close tab');
|
|
}
|
|
}
|
|
|
|
// Browser automation tool handlers matching native server functionality
|
|
|
|
private async handleGetWindowsAndTabs(data: any): Promise<void> {
|
|
try {
|
|
const result = await handleCallTool({ name: 'get_windows_and_tabs', args: data.params });
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(
|
|
data.id,
|
|
error instanceof Error ? error.message : 'Failed to get windows and tabs',
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handleSearchTabsContent(data: any): Promise<void> {
|
|
try {
|
|
// Import handleCallTool directly to avoid message passing issues
|
|
const { handleCallTool } = await import('../entrypoints/background/tools');
|
|
const result = await handleCallTool({
|
|
name: 'search_tabs_content',
|
|
args: data.params,
|
|
});
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(
|
|
data.id,
|
|
error instanceof Error ? error.message : 'Failed to search tabs content',
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handleChromeNavigate(data: any): Promise<void> {
|
|
try {
|
|
console.log('🔧 [Chrome Extension] handleChromeNavigate called with:', data.params);
|
|
console.log(
|
|
'🔧 [Chrome Extension] handleCallTool function available:',
|
|
typeof handleCallTool,
|
|
);
|
|
|
|
const result = await handleCallTool({ name: 'chrome_navigate', args: data.params });
|
|
|
|
console.log('🔧 [Chrome Extension] handleCallTool result:', result);
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
console.error('🔧 [Chrome Extension] handleChromeNavigate error:', error);
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Failed to navigate');
|
|
}
|
|
}
|
|
|
|
private async handleChromeScreenshot(data: any): Promise<void> {
|
|
try {
|
|
const result = await handleCallTool({ name: 'chrome_screenshot', args: data.params });
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Failed to take screenshot');
|
|
}
|
|
}
|
|
|
|
private async handleChromeCloseTabs(data: any): Promise<void> {
|
|
try {
|
|
const result = await this.callToolHandler('chrome_close_tabs', data.params);
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Failed to close tabs');
|
|
}
|
|
}
|
|
|
|
private async handleChromeGoBackOrForward(data: any): Promise<void> {
|
|
try {
|
|
const result = await this.callToolHandler('chrome_go_back_or_forward', data.params);
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(
|
|
data.id,
|
|
error instanceof Error ? error.message : 'Failed to go back or forward',
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handleChromeGetWebContent(data: any): Promise<void> {
|
|
try {
|
|
const result = await this.callToolHandler('chrome_get_web_content', data.params);
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Failed to get web content');
|
|
}
|
|
}
|
|
|
|
private async handleChromeClickElement(data: any): Promise<void> {
|
|
try {
|
|
const result = await this.callToolHandler('chrome_click_element', data.params);
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Failed to click element');
|
|
}
|
|
}
|
|
|
|
private async handleChromeFillOrSelect(data: any): Promise<void> {
|
|
try {
|
|
const result = await this.callToolHandler('chrome_fill_or_select', data.params);
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Failed to fill or select');
|
|
}
|
|
}
|
|
|
|
private async handleChromeGetInteractiveElements(data: any): Promise<void> {
|
|
try {
|
|
// Import handleCallTool directly to avoid message passing issues
|
|
const { handleCallTool } = await import('../entrypoints/background/tools');
|
|
const result = await handleCallTool({
|
|
name: 'chrome_get_interactive_elements',
|
|
args: data.params,
|
|
});
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(
|
|
data.id,
|
|
error instanceof Error ? error.message : 'Failed to get interactive elements',
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handleChromeNetworkCaptureStart(data: any): Promise<void> {
|
|
try {
|
|
const result = await this.callToolHandler('chrome_network_capture_start', data.params);
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(
|
|
data.id,
|
|
error instanceof Error ? error.message : 'Failed to start network capture',
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handleChromeNetworkCaptureStop(data: any): Promise<void> {
|
|
try {
|
|
const result = await this.callToolHandler('chrome_network_capture_stop', data.params);
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(
|
|
data.id,
|
|
error instanceof Error ? error.message : 'Failed to stop network capture',
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handleChromeNetworkRequest(data: any): Promise<void> {
|
|
try {
|
|
const result = await this.callToolHandler('chrome_network_request', data.params);
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(
|
|
data.id,
|
|
error instanceof Error ? error.message : 'Failed to make network request',
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handleChromeNetworkDebuggerStart(data: any): Promise<void> {
|
|
try {
|
|
const result = await this.callToolHandler('chrome_network_debugger_start', data.params);
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(
|
|
data.id,
|
|
error instanceof Error ? error.message : 'Failed to start network debugger',
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handleChromeNetworkDebuggerStop(data: any): Promise<void> {
|
|
try {
|
|
const result = await this.callToolHandler('chrome_network_debugger_stop', data.params);
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(
|
|
data.id,
|
|
error instanceof Error ? error.message : 'Failed to stop network debugger',
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handleChromeKeyboard(data: any): Promise<void> {
|
|
try {
|
|
const result = await this.callToolHandler('chrome_keyboard', data.params);
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(
|
|
data.id,
|
|
error instanceof Error ? error.message : 'Failed to simulate keyboard',
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handleChromeHistory(data: any): Promise<void> {
|
|
try {
|
|
const result = await this.callToolHandler('chrome_history', data.params);
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Failed to get history');
|
|
}
|
|
}
|
|
|
|
private async handleChromeBookmarkSearch(data: any): Promise<void> {
|
|
try {
|
|
const result = await this.callToolHandler('chrome_bookmark_search', data.params);
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(
|
|
data.id,
|
|
error instanceof Error ? error.message : 'Failed to search bookmarks',
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handleChromeBookmarkAdd(data: any): Promise<void> {
|
|
try {
|
|
const result = await this.callToolHandler('chrome_bookmark_add', data.params);
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Failed to add bookmark');
|
|
}
|
|
}
|
|
|
|
private async handleChromeBookmarkDelete(data: any): Promise<void> {
|
|
try {
|
|
const result = await this.callToolHandler('chrome_bookmark_delete', data.params);
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Failed to delete bookmark');
|
|
}
|
|
}
|
|
|
|
private async handleChromeInjectScript(data: any): Promise<void> {
|
|
try {
|
|
const result = await this.callToolHandler('chrome_inject_script', data.params);
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Failed to inject script');
|
|
}
|
|
}
|
|
|
|
private async handleChromeSendCommandToInjectScript(data: any): Promise<void> {
|
|
try {
|
|
const result = await this.callToolHandler(
|
|
'chrome_send_command_to_inject_script',
|
|
data.params,
|
|
);
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(
|
|
data.id,
|
|
error instanceof Error ? error.message : 'Failed to send command to inject script',
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handleChromeConsole(data: any): Promise<void> {
|
|
try {
|
|
const result = await this.callToolHandler('chrome_console', data.params);
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Failed to get console');
|
|
}
|
|
}
|
|
|
|
// Helper method to call the extension's tool handler directly (avoiding message passing issues)
|
|
private async callToolHandler(toolName: string, params: any): Promise<any> {
|
|
try {
|
|
// Import handleCallTool directly to avoid message passing issues
|
|
const { handleCallTool } = await import('../entrypoints/background/tools');
|
|
return await handleCallTool({
|
|
name: toolName,
|
|
args: params,
|
|
});
|
|
} catch (error) {
|
|
throw new Error(error instanceof Error ? error.message : 'Tool execution failed');
|
|
}
|
|
}
|
|
|
|
// Handle general tool calls from remote server
|
|
private async handleToolCall(data: any): Promise<void> {
|
|
try {
|
|
console.log('🔧 [Chrome Extension] Handling tool call:', {
|
|
toolName: data.params?.name,
|
|
hasArgs: !!(data.params?.arguments || data.params?.args),
|
|
messageId: data.id,
|
|
fullParams: data.params,
|
|
});
|
|
|
|
const result = await handleCallTool({
|
|
name: data.params.name,
|
|
args: data.params.arguments || data.params.args,
|
|
});
|
|
|
|
console.log('🔧 [Chrome Extension] Tool call completed, sending response:', {
|
|
messageId: data.id,
|
|
hasResult: !!result,
|
|
isError: result?.isError,
|
|
result,
|
|
});
|
|
|
|
this.sendResponse(data.id, result);
|
|
} catch (error) {
|
|
console.error('🔧 [Chrome Extension] Tool call failed:', error);
|
|
this.sendError(data.id, error instanceof Error ? error.message : 'Tool execution failed');
|
|
}
|
|
}
|
|
|
|
disconnect(): void {
|
|
console.log('Disconnecting from remote server...');
|
|
|
|
// Stop any ongoing connection attempts
|
|
this.isConnecting = false;
|
|
|
|
// Stop keep-alive mechanism
|
|
this.clearKeepAlive();
|
|
|
|
if (this.ws) {
|
|
const currentState = this.ws.readyState;
|
|
console.log(`Closing WebSocket connection (current state: ${currentState})`);
|
|
|
|
// Only close if not already closed/closing
|
|
if (currentState === WebSocket.OPEN || currentState === WebSocket.CONNECTING) {
|
|
try {
|
|
this.ws.close(1000, 'User initiated disconnect'); // Normal closure
|
|
} catch (error) {
|
|
console.error('Error closing WebSocket:', error);
|
|
}
|
|
}
|
|
|
|
this.ws = null;
|
|
}
|
|
|
|
this.connectionTime = null;
|
|
this.reconnectAttempts = 0;
|
|
|
|
this.notifyStatusUpdate({
|
|
connected: false,
|
|
connecting: false,
|
|
connectionTime: null,
|
|
reconnectAttempts: 0,
|
|
error: undefined, // Clear any previous errors
|
|
});
|
|
|
|
// Clear saved connection state since this is a manual disconnect
|
|
this.clearConnectionState();
|
|
|
|
console.log('Successfully disconnected from remote server');
|
|
}
|
|
|
|
isConnected(): boolean {
|
|
const connected = this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
|
|
|
// Only log if there's a state change or for debugging
|
|
if (this.ws) {
|
|
const stateNames = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'];
|
|
const stateName = stateNames[this.ws.readyState] || 'UNKNOWN';
|
|
console.log(`RemoteServerClient.isConnected(): ${connected} (state: ${stateName})`);
|
|
}
|
|
|
|
return connected;
|
|
}
|
|
|
|
/**
|
|
* Enable or disable persistent connection behavior
|
|
*/
|
|
setPersistentConnection(enabled: boolean): void {
|
|
this.persistentConnectionEnabled = enabled;
|
|
console.log(`RemoteServerClient: Persistent connection ${enabled ? 'enabled' : 'disabled'}`);
|
|
|
|
if (!enabled) {
|
|
// Clear saved state if disabling persistence
|
|
this.clearConnectionState();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current persistent connection setting
|
|
*/
|
|
isPersistentConnectionEnabled(): boolean {
|
|
return this.persistentConnectionEnabled;
|
|
}
|
|
}
|