343 lines
9.6 KiB
JavaScript
343 lines
9.6 KiB
JavaScript
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;
|
|
}
|
|
}
|