diff --git a/extension-playwright/src/background.ts b/extension-playwright/src/background.ts index a8f08d9..cb612b2 100644 --- a/extension-playwright/src/background.ts +++ b/extension-playwright/src/background.ts @@ -1,18 +1,17 @@ - -import { RelayConnection, debugLog } from './relayConnection'; -import { create } from 'zustand'; +import { RelayConnection, debugLog } from './relayConnection' +import { create } from 'zustand' // Relay URL - fixed port for MCP bridge -const RELAY_URL = 'ws://localhost:9988/extension'; +const RELAY_URL = 'ws://localhost:9988/extension' -type ConnectionState = 'disconnected' | 'reconnecting' | 'connected' | 'error'; +type ConnectionState = 'disconnected' | 'reconnecting' | 'connected' | 'error' interface ExtensionState { - connection: RelayConnection | undefined; - connectedTabs: Map; - connectionState: ConnectionState; - currentTabId: number | undefined; - errorText: string | undefined; + connection: RelayConnection | undefined + connectedTabs: Map + connectionState: ConnectionState + currentTabId: number | undefined + errorText: string | undefined } const useExtensionStore = create(() => ({ @@ -21,15 +20,15 @@ const useExtensionStore = create(() => ({ connectionState: 'disconnected', currentTabId: undefined, errorText: undefined, -})); +})) async function resetDebugger() { let targets = await chrome.debugger.getTargets() - targets = targets.filter(x => x.tabId && x.attached) + targets = targets.filter((x) => x.tabId && x.attached) console.log(`found ${targets.length} existing debugger targets. detaching them before background script starts`) console.log(targets) for (const target of targets) { - await chrome.debugger.detach({tabId: target.tabId}) + await chrome.debugger.detach({ tabId: target.tabId }) } } resetDebugger() @@ -40,363 +39,362 @@ const icons = { '16': '/icons/icon-green-16.png', '32': '/icons/icon-green-32.png', '48': '/icons/icon-green-48.png', - '128': '/icons/icon-green-128.png' + '128': '/icons/icon-green-128.png', }, title: 'Connected - Click to disconnect', badgeText: '', - badgeColor: undefined + badgeColor: undefined, }, connecting: { path: { '16': '/icons/icon-gray-16.png', '32': '/icons/icon-gray-32.png', '48': '/icons/icon-gray-48.png', - '128': '/icons/icon-gray-128.png' + '128': '/icons/icon-gray-128.png', }, title: 'Connecting...', badgeText: '...', - badgeColor: '#FF9800' + badgeColor: '#FF9800', }, disconnected: { path: { '16': '/icons/icon-gray-16.png', '32': '/icons/icon-gray-32.png', '48': '/icons/icon-gray-48.png', - '128': '/icons/icon-gray-48.png' + '128': '/icons/icon-gray-48.png', }, title: 'Click to attach debugger', badgeText: '', - badgeColor: undefined + badgeColor: undefined, }, error: { path: { '16': '/icons/icon-gray-16.png', '32': '/icons/icon-gray-32.png', '48': '/icons/icon-gray-48.png', - '128': '/icons/icon-gray-48.png' + '128': '/icons/icon-gray-48.png', }, title: 'Error', badgeText: '!', - badgeColor: '#f44336' - } -} as const; + badgeColor: '#f44336', + }, +} as const useExtensionStore.subscribe(async (state, prevState) => { console.log(state) - const { connectionState, connectedTabs, errorText } = state; + const { connectionState, connectedTabs, errorText } = state - const tabs = await chrome.tabs.query({}); - const allTabIds = [undefined, ...tabs.map(tab => tab.id).filter((id): id is number => id !== undefined)]; + const tabs = await chrome.tabs.query({}) + const allTabIds = [undefined, ...tabs.map((tab) => tab.id).filter((id): id is number => id !== undefined)] for (const tabId of allTabIds) { const iconConfig = (() => { if (connectionState === 'error') { - return icons.error; + return icons.error } if (connectionState === 'reconnecting') { - return icons.connecting; + return icons.connecting } if (tabId !== undefined && connectedTabs.has(tabId) && connectionState === 'connected') { - return icons.connected; + return icons.connected } - return icons.disconnected; - })(); - const title = connectionState === 'error' && errorText ? errorText : iconConfig.title; - void chrome.action.setIcon({ tabId, path: iconConfig.path }); - void chrome.action.setTitle({ tabId, title }); - if (iconConfig.badgeColor) void chrome.action.setBadgeBackgroundColor({ tabId, color: iconConfig.badgeColor }); - void chrome.action.setBadgeText({ tabId, text: iconConfig.badgeText }); + return icons.disconnected + })() + const title = connectionState === 'error' && errorText ? errorText : iconConfig.title + void chrome.action.setIcon({ tabId, path: iconConfig.path }) + void chrome.action.setTitle({ tabId, title }) + if (iconConfig.badgeColor) void chrome.action.setBadgeBackgroundColor({ tabId, color: iconConfig.badgeColor }) + void chrome.action.setBadgeText({ tabId, text: iconConfig.badgeText }) } -}); +}) async function ensureConnection(): Promise { - const { connection } = useExtensionStore.getState(); - if (connection) { - debugLog('Connection already exists, reusing'); - return; - } - - - debugLog('No existing connection, creating new relay connection'); - debugLog('Waiting for server at http://localhost:9988...'); - - useExtensionStore.setState({ connectionState: 'reconnecting' }); - while (true) { - try { - await fetch('http://localhost:9988', { method: 'HEAD' }); - debugLog('Server is available'); - break; - } catch (error: any) { - debugLog('Server not available, retrying in 1 second...'); - await new Promise(resolve => setTimeout(resolve, 1000)); - } - } - - debugLog('Server is ready, creating WebSocket connection to:', RELAY_URL); - const socket = new WebSocket(RELAY_URL); - debugLog('WebSocket created, initial readyState:', socket.readyState, '(0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED)'); - - await new Promise((resolve, reject) => { - let timeoutFired = false; - const timeout = setTimeout(() => { - timeoutFired = true; - debugLog('=== WebSocket connection TIMEOUT after 5 seconds ==='); - debugLog('Final WebSocket readyState:', socket.readyState); - debugLog('WebSocket URL:', socket.url); - debugLog('Socket protocol:', socket.protocol); - reject(new Error('Connection timeout')); - }, 5000); - - socket.onopen = () => { - if (timeoutFired) { - debugLog('WebSocket opened but timeout already fired!'); - return; - } - debugLog('WebSocket onopen fired! readyState:', socket.readyState); - clearTimeout(timeout); - resolve(); - }; - - socket.onerror = (error) => { - debugLog('WebSocket onerror during connection:', error); - debugLog('Error type:', error.type); - debugLog('Current readyState:', socket.readyState); - if (!timeoutFired) { - clearTimeout(timeout); - reject(new Error('WebSocket connection failed')); - } - }; - - socket.onclose = (event) => { - debugLog('WebSocket onclose during connection setup:', { - code: event.code, - reason: event.reason, - wasClean: event.wasClean, - readyState: socket.readyState - }); - if (!timeoutFired) { - clearTimeout(timeout); - reject(new Error(`WebSocket closed: ${event.reason || event.code}`)); - } - }; - - debugLog('Event handlers set, waiting for connection...'); - }); - - debugLog('WebSocket connected successfully, creating RelayConnection instance'); - const newConnection = new RelayConnection({ - ws: socket, - onClose: () => { - debugLog('=== Relay connection onClose callback triggered ==='); - const { connectedTabs } = useExtensionStore.getState(); - debugLog('Connected tabs before potential reconnection:', Array.from(connectedTabs.keys())); - useExtensionStore.setState({ connection: undefined, connectionState: 'disconnected' }); - - if (connectedTabs.size > 0) { - debugLog('Tabs still connected, triggering reconnection'); - void reconnect(); - } else { - debugLog('No tabs to reconnect'); - } - }, - onTabDetached: (tabId, reason) => { - debugLog('=== Manual tab detachment detected for tab:', tabId, '==='); - debugLog('User closed debugger via Chrome automation bar'); - - useExtensionStore.setState((state) => { - const newTabs = new Map(state.connectedTabs); - newTabs.delete(tabId); - return { connectedTabs: newTabs }; - }); - if (reason=== chrome.debugger.DetachReason.CANCELED_BY_USER) { - // if user cancels debugger. disconnect everything - useExtensionStore.setState({ connectionState: 'disconnected' }); - } - debugLog('Removed tab from _connectedTabs map'); - } - }); - - useExtensionStore.setState({ connection: newConnection }); - debugLog('Connection established, WebSocket open (caller should set connectionState)'); + const { connection } = useExtensionStore.getState() + if (connection) { + debugLog('Connection already exists, reusing') + return } + debugLog('No existing connection, creating new relay connection') + debugLog('Waiting for server at http://localhost:9988...') + + useExtensionStore.setState({ connectionState: 'reconnecting' }) + while (true) { + try { + await fetch('http://localhost:9988', { method: 'HEAD' }) + debugLog('Server is available') + break + } catch (error: any) { + debugLog('Server not available, retrying in 1 second...') + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + } + + debugLog('Server is ready, creating WebSocket connection to:', RELAY_URL) + const socket = new WebSocket(RELAY_URL) + debugLog('WebSocket created, initial readyState:', socket.readyState, '(0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED)') + + await new Promise((resolve, reject) => { + let timeoutFired = false + const timeout = setTimeout(() => { + timeoutFired = true + debugLog('=== WebSocket connection TIMEOUT after 5 seconds ===') + debugLog('Final WebSocket readyState:', socket.readyState) + debugLog('WebSocket URL:', socket.url) + debugLog('Socket protocol:', socket.protocol) + reject(new Error('Connection timeout')) + }, 5000) + + socket.onopen = () => { + if (timeoutFired) { + debugLog('WebSocket opened but timeout already fired!') + return + } + debugLog('WebSocket onopen fired! readyState:', socket.readyState) + clearTimeout(timeout) + resolve() + } + + socket.onerror = (error) => { + debugLog('WebSocket onerror during connection:', error) + debugLog('Error type:', error.type) + debugLog('Current readyState:', socket.readyState) + if (!timeoutFired) { + clearTimeout(timeout) + reject(new Error('WebSocket connection failed')) + } + } + + socket.onclose = (event) => { + debugLog('WebSocket onclose during connection setup:', { + code: event.code, + reason: event.reason, + wasClean: event.wasClean, + readyState: socket.readyState, + }) + if (!timeoutFired) { + clearTimeout(timeout) + reject(new Error(`WebSocket closed: ${event.reason || event.code}`)) + } + } + + debugLog('Event handlers set, waiting for connection...') + }) + + debugLog('WebSocket connected successfully, creating RelayConnection instance') + const newConnection = new RelayConnection({ + ws: socket, + onClose: () => { + debugLog('=== Relay connection onClose callback triggered ===') + const { connectedTabs } = useExtensionStore.getState() + debugLog('Connected tabs before potential reconnection:', Array.from(connectedTabs.keys())) + useExtensionStore.setState({ connection: undefined, connectionState: 'disconnected' }) + + if (connectedTabs.size > 0) { + debugLog('Tabs still connected, triggering reconnection') + void reconnect() + } else { + debugLog('No tabs to reconnect') + } + }, + onTabDetached: (tabId, reason) => { + debugLog('=== Manual tab detachment detected for tab:', tabId, '===') + debugLog('User closed debugger via Chrome automation bar') + + useExtensionStore.setState((state) => { + const newTabs = new Map(state.connectedTabs) + newTabs.delete(tabId) + return { connectedTabs: newTabs } + }) + if (reason === chrome.debugger.DetachReason.CANCELED_BY_USER) { + // if user cancels debugger. disconnect everything + useExtensionStore.setState({ connectionState: 'disconnected' }) + } + debugLog('Removed tab from _connectedTabs map') + }, + }) + + useExtensionStore.setState({ connection: newConnection }) + debugLog('Connection established, WebSocket open (caller should set connectionState)') +} + async function connectTab(tabId: number): Promise { - try { - debugLog(`=== Starting connection to tab ${tabId} ===`); + try { + debugLog(`=== Starting connection to tab ${tabId} ===`) - useExtensionStore.setState((state) => { - const newTabs = new Map(state.connectedTabs); - newTabs.set(tabId, ''); - return { connectedTabs: newTabs }; - }); + useExtensionStore.setState((state) => { + const newTabs = new Map(state.connectedTabs) + newTabs.set(tabId, '') + return { connectedTabs: newTabs } + }) - await ensureConnection(); + await ensureConnection() - debugLog('Calling attachTab for tab:', tabId); - const { connection } = useExtensionStore.getState(); - if (!connection) return - const targetInfo = await connection.attachTab(tabId); - debugLog('attachTab completed, updating targetId in connectedTabs map'); - useExtensionStore.setState((state) => { - const newTabs = new Map(state.connectedTabs); + debugLog('Calling attachTab for tab:', tabId) + const { connection } = useExtensionStore.getState() + if (!connection) return + const targetInfo = await connection.attachTab(tabId) + debugLog('attachTab completed, updating targetId in connectedTabs map') + useExtensionStore.setState((state) => { + const newTabs = new Map(state.connectedTabs) - newTabs.set(tabId, targetInfo?.targetId); - return { connectedTabs: newTabs, connectionState: 'connected' }; - }); + newTabs.set(tabId, targetInfo?.targetId) + return { connectedTabs: newTabs, connectionState: 'connected' } + }) - debugLog(`=== Successfully connected to tab ${tabId} ===`); - } catch (error: any) { - debugLog(`=== Failed to connect to tab ${tabId} ===`); - debugLog('Error details:', error); - debugLog('Error stack:', error.stack); + debugLog(`=== Successfully connected to tab ${tabId} ===`) + } catch (error: any) { + debugLog(`=== Failed to connect to tab ${tabId} ===`) + debugLog('Error details:', error) + debugLog('Error stack:', error.stack) - useExtensionStore.setState((state) => { - const newTabs = new Map(state.connectedTabs); - newTabs.delete(tabId); - return { connectedTabs: newTabs, connectionState: 'error', errorText: `Error: ${error.message}` }; - }); - } + useExtensionStore.setState((state) => { + const newTabs = new Map(state.connectedTabs) + newTabs.delete(tabId) + return { connectedTabs: newTabs, connectionState: 'error', errorText: `Error: ${error.message}` } + }) } +} async function disconnectTab(tabId: number): Promise { - debugLog(`=== Disconnecting tab ${tabId} ===`); + debugLog(`=== Disconnecting tab ${tabId} ===`) - const { connectedTabs, connection } = useExtensionStore.getState(); - if (!connectedTabs.has(tabId)) { - debugLog('Tab not in connectedTabs map, ignoring disconnect'); - return; - } - - debugLog('Calling detachTab on connection'); - connection?.detachTab(tabId); - useExtensionStore.setState((state) => { - const newTabs = new Map(state.connectedTabs); - newTabs.delete(tabId); - return { connectedTabs: newTabs }; - }); - debugLog('Tab removed from connectedTabs map'); - - const { connectedTabs: updatedTabs, connection: updatedConnection } = useExtensionStore.getState(); - debugLog('Connected tabs remaining:', updatedTabs.size); - if (updatedTabs.size === 0 && updatedConnection) { - debugLog('No tabs remaining, closing relay connection'); - updatedConnection.close('All tabs disconnected'); - useExtensionStore.setState({ connection: undefined, connectionState: 'disconnected' }); - } + const { connectedTabs, connection } = useExtensionStore.getState() + if (!connectedTabs.has(tabId)) { + debugLog('Tab not in connectedTabs map, ignoring disconnect') + return } + debugLog('Calling detachTab on connection') + connection?.detachTab(tabId) + useExtensionStore.setState((state) => { + const newTabs = new Map(state.connectedTabs) + newTabs.delete(tabId) + return { connectedTabs: newTabs } + }) + debugLog('Tab removed from connectedTabs map') + + const { connectedTabs: updatedTabs, connection: updatedConnection } = useExtensionStore.getState() + debugLog('Connected tabs remaining:', updatedTabs.size) + if (updatedTabs.size === 0 && updatedConnection) { + debugLog('No tabs remaining, closing relay connection') + updatedConnection.close('All tabs disconnected') + useExtensionStore.setState({ connection: undefined, connectionState: 'disconnected' }) + } +} + async function reconnect(): Promise { - debugLog('=== Starting reconnection ==='); - const { connectedTabs } = useExtensionStore.getState(); - debugLog('Tabs to reconnect:', Array.from(connectedTabs.keys())); + debugLog('=== Starting reconnection ===') + const { connectedTabs } = useExtensionStore.getState() + debugLog('Tabs to reconnect:', Array.from(connectedTabs.keys())) - try { - await ensureConnection(); + try { + await ensureConnection() - const tabsToReconnect = Array.from(connectedTabs.keys()); - debugLog('Re-attaching', tabsToReconnect.length, 'tabs'); + const tabsToReconnect = Array.from(connectedTabs.keys()) + debugLog('Re-attaching', tabsToReconnect.length, 'tabs') - for (const tabId of tabsToReconnect) { - const { connectedTabs: currentTabs } = useExtensionStore.getState(); - if (!currentTabs.has(tabId)) { - debugLog('Tab', tabId, 'was manually disconnected during reconnection, skipping'); - continue; - } - - try { - debugLog('Checking if tab', tabId, 'still exists'); - await chrome.tabs.get(tabId); - - debugLog('Re-attaching tab:', tabId); - const { connection } = useExtensionStore.getState(); - if (!connection) return; - const targetInfo = await connection.attachTab(tabId); - useExtensionStore.setState((state) => { - const newTabs = new Map(state.connectedTabs); - newTabs.set(tabId, targetInfo.targetId); - return { connectedTabs: newTabs }; - }); - debugLog('Successfully re-attached tab:', tabId); - } catch (error: any) { - debugLog('Failed to re-attach tab:', tabId, error.message); - useExtensionStore.setState((state) => { - const newTabs = new Map(state.connectedTabs); - newTabs.delete(tabId); - return { connectedTabs: newTabs }; - }); - } + for (const tabId of tabsToReconnect) { + const { connectedTabs: currentTabs } = useExtensionStore.getState() + if (!currentTabs.has(tabId)) { + debugLog('Tab', tabId, 'was manually disconnected during reconnection, skipping') + continue } - const { connectedTabs: finalTabs } = useExtensionStore.getState(); - debugLog('=== Reconnection complete ==='); - debugLog('Successfully reconnected tabs:', finalTabs.size); + try { + debugLog('Checking if tab', tabId, 'still exists') + await chrome.tabs.get(tabId) - if (finalTabs.size > 0) { - useExtensionStore.setState({ connectionState: 'connected' }); - debugLog('Set connectionState to connected'); - } else { - debugLog('No tabs successfully reconnected, staying in reconnecting state'); - useExtensionStore.setState({ connectionState: 'disconnected' }); + debugLog('Re-attaching tab:', tabId) + const { connection } = useExtensionStore.getState() + if (!connection) return + const targetInfo = await connection.attachTab(tabId) + useExtensionStore.setState((state) => { + const newTabs = new Map(state.connectedTabs) + newTabs.set(tabId, targetInfo.targetId) + return { connectedTabs: newTabs } + }) + debugLog('Successfully re-attached tab:', tabId) + } catch (error: any) { + debugLog('Failed to re-attach tab:', tabId, error.message) + useExtensionStore.setState((state) => { + const newTabs = new Map(state.connectedTabs) + newTabs.delete(tabId) + return { connectedTabs: newTabs } + }) } - } catch (error: any) { - debugLog('=== Reconnection failed ===', error); - - useExtensionStore.setState({ - connectedTabs: new Map(), - connectionState: 'error', - errorText: 'Reconnection failed - Click to retry' - }); } + + const { connectedTabs: finalTabs } = useExtensionStore.getState() + debugLog('=== Reconnection complete ===') + debugLog('Successfully reconnected tabs:', finalTabs.size) + + if (finalTabs.size > 0) { + useExtensionStore.setState({ connectionState: 'connected' }) + debugLog('Set connectionState to connected') + } else { + debugLog('No tabs successfully reconnected, staying in reconnecting state') + useExtensionStore.setState({ connectionState: 'disconnected' }) + } + } catch (error: any) { + debugLog('=== Reconnection failed ===', error) + + useExtensionStore.setState({ + connectedTabs: new Map(), + connectionState: 'error', + errorText: 'Reconnection failed - Click to retry', + }) } +} async function onTabRemoved(tabId: number): Promise { - const { connectedTabs } = useExtensionStore.getState(); - debugLog('Tab removed event for tab:', tabId, 'is connected:', connectedTabs.has(tabId)); - if (!connectedTabs.has(tabId)) return; + const { connectedTabs } = useExtensionStore.getState() + debugLog('Tab removed event for tab:', tabId, 'is connected:', connectedTabs.has(tabId)) + if (!connectedTabs.has(tabId)) return - debugLog(`Connected tab ${tabId} was closed, disconnecting`); - await disconnectTab(tabId); + debugLog(`Connected tab ${tabId} was closed, disconnecting`) + await disconnectTab(tabId) } async function onTabActivated(activeInfo: chrome.tabs.TabActiveInfo): Promise { - debugLog('Tab activated:', activeInfo.tabId); - useExtensionStore.setState({ currentTabId: activeInfo.tabId }); + debugLog('Tab activated:', activeInfo.tabId) + useExtensionStore.setState({ currentTabId: activeInfo.tabId }) } async function onActionClicked(tab: chrome.tabs.Tab): Promise { if (!tab.id) { - debugLog('No tab ID available'); - return; + debugLog('No tab ID available') + return } - const { connectedTabs, connectionState, connection } = useExtensionStore.getState(); + const { connectedTabs, connectionState, connection } = useExtensionStore.getState() if (connectionState === 'reconnecting' || connectionState === 'error') { - debugLog('User clicked during reconnection/error, canceling and disconnecting all tabs'); + debugLog('User clicked during reconnection/error, canceling and disconnecting all tabs') - const tabsToDisconnect = Array.from(connectedTabs.keys()); + const tabsToDisconnect = Array.from(connectedTabs.keys()) for (const tabId of tabsToDisconnect) { - connection?.detachTab(tabId); + connection?.detachTab(tabId) } - useExtensionStore.setState({ connectionState: 'disconnected', connectedTabs: new Map(), errorText: undefined }); + useExtensionStore.setState({ connectionState: 'disconnected', connectedTabs: new Map(), errorText: undefined }) if (connection) { - connection.close('User cancelled reconnection'); + connection.close('User cancelled reconnection') } - return; + return } if (connectedTabs.has(tab.id)) { - await disconnectTab(tab.id); + await disconnectTab(tab.id) } else { - await connectTab(tab.id); + await connectTab(tab.id) } } -debugLog(`Using relay URL: ${RELAY_URL}`); -chrome.tabs.onRemoved.addListener(onTabRemoved); -chrome.tabs.onActivated.addListener(onTabActivated); -chrome.action.onClicked.addListener(onActionClicked); +debugLog(`Using relay URL: ${RELAY_URL}`) +chrome.tabs.onRemoved.addListener(onTabRemoved) +chrome.tabs.onActivated.addListener(onTabActivated) +chrome.action.onClicked.addListener(onActionClicked)