import { createStore } from 'zustand/vanilla' import type { ExtensionState, ConnectionState, TabState, TabInfo } from './types' import type { CDPEvent, Protocol } from 'playwriter/src/cdp-types' import type { ExtensionCommandMessage, ExtensionResponseMessage } from 'playwriter/src/extension/protocol' const RELAY_URL = 'ws://localhost:19988/extension' function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } let ws: WebSocket | null = null let childSessions: Map = new Map() let nextSessionId = 1 let playwriterGroupId: number | null = null let syncTabGroupQueue: Promise = Promise.resolve() let replacedRetryInterval: ReturnType | null = null const store = createStore(() => ({ tabs: new Map(), connectionState: 'disconnected', currentTabId: undefined, errorText: undefined, })) // @ts-ignore globalThis.toggleExtensionForActiveTab = toggleExtensionForActiveTab // @ts-ignore globalThis.disconnectEverything = disconnectEverything // @ts-ignore globalThis.getExtensionState = () => store.getState() declare global { var toggleExtensionForActiveTab: () => Promise<{ isConnected: boolean; state: ExtensionState }> var getExtensionState: () => ExtensionState var disconnectEverything: () => Promise } function safeSerialize(arg: any): string { if (arg === undefined) return 'undefined' if (arg === null) return 'null' if (typeof arg === 'function') return `[Function: ${arg.name || 'anonymous'}]` if (typeof arg === 'symbol') return String(arg) if (arg instanceof Error) return arg.stack || arg.message || String(arg) if (typeof arg === 'object') { try { const seen = new WeakSet() return JSON.stringify(arg, (key, value) => { if (typeof value === 'object' && value !== null) { if (seen.has(value)) return '[Circular]' seen.add(value) if (value instanceof Map) return { dataType: 'Map', value: Array.from(value.entries()) } if (value instanceof Set) return { dataType: 'Set', value: Array.from(value.values()) } } return value }) } catch { return String(arg) } } return String(arg) } function sendLog(level: string, args: any[]) { sendMessage({ method: 'log', params: { level, args: args.map(safeSerialize) }, }) } const logger = { log: (...args: any[]) => { console.log(...args) sendLog('log', args) }, debug: (...args: any[]) => { console.debug(...args) sendLog('debug', args) }, info: (...args: any[]) => { console.info(...args) sendLog('info', args) }, warn: (...args: any[]) => { console.warn(...args) sendLog('warn', args) }, error: (...args: any[]) => { console.error(...args) sendLog('error', args) }, } function sendMessage(message: any): void { if (ws?.readyState === WebSocket.OPEN) { try { ws.send(JSON.stringify(message)) } catch (error: any) { console.debug('ERROR sending message:', error, 'message type:', message.method || 'response') } } } async function syncTabGroup(): Promise { try { const connectedTabIds = Array.from(store.getState().tabs.entries()) .filter(([_, info]) => info.state === 'connected') .map(([tabId]) => tabId) const existingGroups = await chrome.tabGroups.query({ title: 'playwriter' }) if (existingGroups.length > 1) { const [keep, ...duplicates] = existingGroups for (const group of duplicates) { const tabsInDupe = await chrome.tabs.query({ groupId: group.id }) for (const tab of tabsInDupe) { if (tab.id) { await chrome.tabs.ungroup(tab.id) } } logger.debug('Removed duplicate playwriter group:', group.id) } playwriterGroupId = keep.id } else if (existingGroups.length === 1) { playwriterGroupId = existingGroups[0].id } if (connectedTabIds.length === 0) { for (const group of existingGroups) { const tabsInGroup = await chrome.tabs.query({ groupId: group.id }) for (const tab of tabsInGroup) { if (tab.id) { await chrome.tabs.ungroup(tab.id) } } logger.debug('Cleared playwriter group:', group.id) } playwriterGroupId = null return } const allTabs = await chrome.tabs.query({}) const tabsInGroup = allTabs.filter((t) => t.groupId === playwriterGroupId && t.id !== undefined) const tabIdsInGroup = new Set(tabsInGroup.map((t) => t.id!)) const tabsToAdd = connectedTabIds.filter((id) => !tabIdsInGroup.has(id)) const tabsToRemove = Array.from(tabIdsInGroup).filter((id) => !connectedTabIds.includes(id)) for (const tabId of tabsToRemove) { try { await chrome.tabs.ungroup(tabId) logger.debug('Removed tab from group:', tabId) } catch (e: any) { logger.debug('Failed to ungroup tab:', tabId, e.message) } } if (tabsToAdd.length > 0) { if (playwriterGroupId === null) { playwriterGroupId = await chrome.tabs.group({ tabIds: tabsToAdd }) await chrome.tabGroups.update(playwriterGroupId, { title: 'playwriter', color: 'green' }) logger.debug('Created tab group:', playwriterGroupId, 'with tabs:', tabsToAdd) } else { await chrome.tabs.group({ tabIds: tabsToAdd, groupId: playwriterGroupId }) logger.debug('Added tabs to existing group:', tabsToAdd) } } } catch (error: any) { logger.debug('Failed to sync tab group:', error.message) } } function getTabBySessionId(sessionId: string): { tabId: number; tab: TabInfo } | undefined { for (const [tabId, tab] of store.getState().tabs) { if (tab.sessionId === sessionId) { return { tabId, tab } } } return undefined } function getTabByTargetId(targetId: string): { tabId: number; tab: TabInfo } | undefined { for (const [tabId, tab] of store.getState().tabs) { if (tab.targetId === targetId) { return { tabId, tab } } } return undefined } async function handleCommand(msg: ExtensionCommandMessage): Promise { if (msg.method !== 'forwardCDPCommand') return let targetTabId: number | undefined let targetTab: TabInfo | undefined if (msg.params.sessionId) { const found = getTabBySessionId(msg.params.sessionId) if (found) { targetTabId = found.tabId targetTab = found.tab } } if (!targetTab && msg.params.sessionId) { const parentTabId = childSessions.get(msg.params.sessionId) if (parentTabId) { targetTabId = parentTabId targetTab = store.getState().tabs.get(parentTabId) logger.debug('Found parent tab for child session:', msg.params.sessionId, 'tabId:', parentTabId) } } if (!targetTab && msg.params.params && 'targetId' in msg.params.params && msg.params.params.targetId) { const found = getTabByTargetId(msg.params.params.targetId as string) if (found) { targetTabId = found.tabId targetTab = found.tab logger.debug('Found tab for targetId:', msg.params.params.targetId, 'tabId:', targetTabId) } } const debuggee = targetTabId ? { tabId: targetTabId } : undefined switch (msg.params.method) { case 'Runtime.enable': { if (!debuggee) { throw new Error(`No debuggee found for Runtime.enable (sessionId: ${msg.params.sessionId})`) } try { await chrome.debugger.sendCommand(debuggee, 'Runtime.disable') await sleep(200) } catch (e) { logger.debug('Error disabling Runtime (ignoring):', e) } return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', msg.params.params) } case 'Target.createTarget': { const url = msg.params.params?.url || 'about:blank' logger.debug('Creating new tab with URL:', url) const tab = await chrome.tabs.create({ url, active: false }) if (!tab.id) throw new Error('Failed to create tab') logger.debug('Created tab:', tab.id, 'waiting for it to load...') await sleep(100) const targetInfo = await attachTab(tab.id) return { targetId: targetInfo.targetId } satisfies Protocol.Target.CreateTargetResponse } case 'Target.closeTarget': { if (!targetTabId) { logger.log(`Target not found: ${msg.params.params?.targetId}`) return { success: false } satisfies Protocol.Target.CloseTargetResponse } await chrome.tabs.remove(targetTabId) return { success: true } satisfies Protocol.Target.CloseTargetResponse } } if (!debuggee || !targetTab) { throw new Error( `No tab found for method ${msg.params.method} sessionId: ${msg.params.sessionId} params: ${JSON.stringify(msg.params.params || null)}`, ) } logger.debug('CDP command:', msg.params.method, 'for tab:', targetTabId) const debuggerSession: chrome.debugger.DebuggerSession = { ...debuggee, sessionId: msg.params.sessionId !== targetTab.sessionId ? msg.params.sessionId : undefined, } return await chrome.debugger.sendCommand(debuggerSession, msg.params.method, msg.params.params) } function onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void { const tab = source.tabId ? store.getState().tabs.get(source.tabId) : undefined if (!tab) return logger.debug('Forwarding CDP event:', method, 'from tab:', source.tabId) if (method === 'Target.attachedToTarget' && params?.sessionId) { logger.debug('Child target attached:', params.sessionId, 'for tab:', source.tabId) childSessions.set(params.sessionId, source.tabId!) } if (method === 'Target.detachedFromTarget' && params?.sessionId) { const mainTab = getTabBySessionId(params.sessionId) if (mainTab) { logger.debug('Main tab detached via CDP event:', mainTab.tabId, 'sessionId:', params.sessionId) store.setState((state) => { const newTabs = new Map(state.tabs) newTabs.delete(mainTab.tabId) return { tabs: newTabs } }) } else { logger.debug('Child target detached:', params.sessionId) childSessions.delete(params.sessionId) } } sendMessage({ method: 'forwardCDPEvent', params: { sessionId: source.sessionId || tab.sessionId, method, params, }, }) } function onDebuggerDetach(source: chrome.debugger.Debuggee, reason: `${chrome.debugger.DetachReason}`): void { const tabId = source.tabId if (!tabId || !store.getState().tabs.has(tabId)) { logger.debug('Ignoring debugger detach event for untracked tab:', tabId) return } logger.debug(`Manual debugger detachment detected for tab ${tabId}: ${reason}`) const tab = store.getState().tabs.get(tabId) if (tab) { sendMessage({ method: 'forwardCDPEvent', params: { method: 'Target.detachedFromTarget', params: { sessionId: tab.sessionId, targetId: tab.targetId }, }, }) } for (const [childSessionId, parentTabId] of childSessions.entries()) { if (parentTabId === tabId) { logger.debug('Cleaning up child session:', childSessionId, 'for tab:', tabId) childSessions.delete(childSessionId) } } store.setState((state) => { const newTabs = new Map(state.tabs) newTabs.delete(tabId) return { tabs: newTabs } }) if (reason === chrome.debugger.DetachReason.CANCELED_BY_USER) { // Chrome's debugger info bar cancellation detaches ALL debugger sessions, not just one tab store.setState({ connectionState: 'disconnected', errorText: undefined }) } } async function attachTab(tabId: number): Promise { const debuggee = { tabId } logger.debug('Attaching debugger to tab:', tabId) await chrome.debugger.attach(debuggee, '1.3') logger.debug('Debugger attached successfully to tab:', tabId) await chrome.debugger.sendCommand(debuggee, 'Page.enable') await sleep(400) const result = (await chrome.debugger.sendCommand( debuggee, 'Target.getTargetInfo', )) as Protocol.Target.GetTargetInfoResponse const targetInfo = result.targetInfo const sessionId = `pw-tab-${nextSessionId++}` store.setState((state) => { const newTabs = new Map(state.tabs) newTabs.set(tabId, { sessionId, targetId: targetInfo.targetId, state: 'connected', }) return { tabs: newTabs, connectionState: 'connected', errorText: undefined } }) sendMessage({ method: 'forwardCDPEvent', params: { method: 'Target.attachedToTarget', params: { sessionId, targetInfo: { ...targetInfo, attached: true }, waitingForDebugger: false, }, }, }) logger.debug('Tab attached successfully:', tabId, 'sessionId:', sessionId, 'targetId:', targetInfo.targetId) return targetInfo } function detachTab(tabId: number, shouldDetachDebugger: boolean): void { const tab = store.getState().tabs.get(tabId) if (!tab) { logger.debug('detachTab: tab not found in map:', tabId) return } logger.debug('Detaching tab:', tabId, 'sessionId:', tab.sessionId, 'shouldDetach:', shouldDetachDebugger) sendMessage({ method: 'forwardCDPEvent', params: { method: 'Target.detachedFromTarget', params: { sessionId: tab.sessionId, targetId: tab.targetId }, }, }) store.setState((state) => { const newTabs = new Map(state.tabs) newTabs.delete(tabId) return { tabs: newTabs } }) for (const [childSessionId, parentTabId] of childSessions.entries()) { if (parentTabId === tabId) { logger.debug('Cleaning up child session:', childSessionId, 'for tab:', tabId) childSessions.delete(childSessionId) } } if (shouldDetachDebugger) { chrome.debugger.detach({ tabId }).catch((err) => { logger.debug('Error detaching debugger from tab:', tabId, err.message) }) } } function closeConnection(reason: string): void { logger.debug('Closing connection, reason:', reason) chrome.debugger.onEvent.removeListener(onDebuggerEvent) chrome.debugger.onDetach.removeListener(onDebuggerDetach) for (const [tabId] of store.getState().tabs) { chrome.debugger.detach({ tabId }).catch((err) => { logger.debug('Error detaching from tab:', tabId, err.message) }) } store.setState({ tabs: new Map(), connectionState: 'disconnected', errorText: undefined }) childSessions.clear() if (ws) { ws.close(1000, reason) ws = null } } function startReplacedRetryLoop(): void { if (replacedRetryInterval) return logger.debug('Starting replaced retry loop (every 3 seconds)') replacedRetryInterval = setInterval(async () => { if (ws?.readyState === WebSocket.OPEN || store.getState().connectionState !== 'error') { clearInterval(replacedRetryInterval!) replacedRetryInterval = null logger.debug('Stopped replaced retry loop') return } try { const response = await fetch('http://localhost:19988/extension/status') const data = await response.json() if (!data.connected) { store.setState({ connectionState: 'disconnected', errorText: undefined }) logger.debug('Extension slot is free, cleared error state') } else { logger.debug('Extension slot still taken, will retry...') } } catch { logger.debug('Server not available, will retry...') } }, 3000) } function handleConnectionClose(reason: string, code: number): void { logger.debug('Connection closed:', { reason, code }) chrome.debugger.onEvent.removeListener(onDebuggerEvent) chrome.debugger.onDetach.removeListener(onDebuggerDetach) const { tabs } = store.getState() for (const [tabId] of tabs) { chrome.debugger.detach({ tabId }).catch((err) => { logger.debug('Error detaching from tab:', tabId, err.message) }) } childSessions.clear() ws = null if (reason === 'Extension Replaced' || code === 4001) { logger.debug('Connection replaced by another extension instance') store.setState({ tabs: new Map(), connectionState: 'error', errorText: 'Disconnected: Replaced by another extension', }) startReplacedRetryLoop() return } store.setState({ connectionState: 'disconnected', errorText: undefined }) if (tabs.size > 0) { logger.debug('Tabs still connected, triggering reconnection') void reconnect() } } async function ensureConnection(): Promise { if (ws?.readyState === WebSocket.OPEN) { logger.debug('Connection already exists, reusing') return } logger.debug('Waiting for server at http://localhost:19988...') store.setState({ connectionState: 'reconnecting' }) while (true) { try { await fetch('http://localhost:19988', { method: 'HEAD' }) logger.debug('Server is available') break } catch { logger.debug('Server not available, retrying in 1 second...') await sleep(1000) } } logger.debug('Creating WebSocket connection to:', RELAY_URL) const socket = new WebSocket(RELAY_URL) await new Promise((resolve, reject) => { let timeoutFired = false const timeout = setTimeout(() => { timeoutFired = true logger.debug('WebSocket connection TIMEOUT after 5 seconds') reject(new Error('Connection timeout')) }, 5000) socket.onopen = () => { if (timeoutFired) return logger.debug('WebSocket connected') clearTimeout(timeout) resolve() } socket.onerror = (error) => { logger.debug('WebSocket error during connection:', error) if (!timeoutFired) { clearTimeout(timeout) reject(new Error('WebSocket connection failed')) } } socket.onclose = (event) => { logger.debug('WebSocket closed during connection:', { code: event.code, reason: event.reason }) if (!timeoutFired) { clearTimeout(timeout) reject(new Error(`WebSocket closed: ${event.reason || event.code}`)) } } }) ws = socket ws.onmessage = async (event: MessageEvent) => { let message: ExtensionCommandMessage try { message = JSON.parse(event.data) } catch (error: any) { logger.debug('Error parsing message:', error) sendMessage({ error: { code: -32700, message: `Error parsing message: ${error.message}` } }) return } const response: ExtensionResponseMessage = { id: message.id } try { response.result = await handleCommand(message) } catch (error: any) { logger.debug('Error handling command:', error) response.error = error.message } logger.debug('Sending response:', response) sendMessage(response) } ws.onclose = (event: CloseEvent) => { handleConnectionClose(event.reason, event.code) } ws.onerror = (event: Event) => { logger.debug('WebSocket error:', event) } chrome.debugger.onEvent.addListener(onDebuggerEvent) chrome.debugger.onDetach.addListener(onDebuggerDetach) logger.debug('Connection established') } async function connectTab(tabId: number): Promise { try { logger.debug(`Starting connection to tab ${tabId}`) store.setState((state) => { const newTabs = new Map(state.tabs) newTabs.set(tabId, { state: 'connecting' }) return { tabs: newTabs } }) await ensureConnection() await attachTab(tabId) logger.debug(`Successfully connected to tab ${tabId}`) } catch (error: any) { logger.debug(`Failed to connect to tab ${tabId}:`, error) store.setState((state) => { const newTabs = new Map(state.tabs) newTabs.set(tabId, { state: 'error', errorText: `Error: ${error.message}` }) const nextConnectionState = state.connectionState === 'reconnecting' ? 'disconnected' : state.connectionState return { tabs: newTabs, connectionState: nextConnectionState } }) } } async function disconnectTab(tabId: number): Promise { logger.debug(`Disconnecting tab ${tabId}`) const { tabs } = store.getState() if (!tabs.has(tabId)) { logger.debug('Tab not in tabs map, ignoring disconnect') return } detachTab(tabId, true) const { tabs: updatedTabs } = store.getState() if (updatedTabs.size === 0 && ws) { logger.debug('No tabs remaining, closing connection') closeConnection('All tabs disconnected') } } async function reconnect(): Promise { logger.debug('Starting reconnection') const { tabs } = store.getState() const tabsToReconnect = Array.from(tabs.keys()) logger.debug('Tabs to reconnect:', tabsToReconnect) try { await ensureConnection() for (const tabId of tabsToReconnect) { if (!store.getState().tabs.has(tabId)) { logger.debug('Tab', tabId, 'was manually disconnected during reconnection, skipping') continue } try { await chrome.tabs.get(tabId) await attachTab(tabId) logger.debug('Successfully re-attached tab:', tabId) } catch (error: any) { logger.debug('Failed to re-attach tab:', tabId, error.message) store.setState((state) => { const newTabs = new Map(state.tabs) newTabs.delete(tabId) return { tabs: newTabs } }) } } const { tabs: finalTabs } = store.getState() if (finalTabs.size > 0) { store.setState({ connectionState: 'connected', errorText: undefined }) } else { store.setState({ connectionState: 'disconnected', errorText: undefined }) } } catch (error: any) { logger.debug('Reconnection failed:', error) store.setState({ tabs: new Map(), connectionState: 'error', errorText: 'Reconnection failed - Click to retry', }) } } async function toggleExtensionForActiveTab(): Promise<{ isConnected: boolean; state: ExtensionState }> { const tabs = await chrome.tabs.query({ active: true, currentWindow: true }) const tab = tabs[0] if (!tab?.id) throw new Error('No active tab found') await onActionClicked(tab) await new Promise((resolve) => { const check = () => { const state = store.getState() const tabInfo = state.tabs.get(tab.id!) if (tabInfo?.state === 'connecting' || state.connectionState === 'reconnecting') { setTimeout(check, 100) return } resolve() } check() }) const state = store.getState() const isConnected = state.tabs.has(tab.id) && state.tabs.get(tab.id)?.state === 'connected' return { isConnected, state } } async function disconnectEverything(): Promise { const { tabs } = store.getState() for (const tabId of tabs.keys()) { await disconnectTab(tabId) } if (ws) { closeConnection('Manual full disconnect') store.setState({ connectionState: 'disconnected', tabs: new Map(), errorText: undefined, }) } } async function resetDebugger(): Promise { let targets = await chrome.debugger.getTargets() targets = targets.filter((x) => x.tabId && x.attached) logger.log(`found ${targets.length} existing debugger targets. detaching them before background script starts`) for (const target of targets) { await chrome.debugger.detach({ tabId: target.tabId }) } } function isRestrictedUrl(url: string | undefined): boolean { if (!url) return false const restrictedPrefixes = ['chrome://', 'chrome-extension://', 'devtools://', 'edge://'] return restrictedPrefixes.some((prefix) => url.startsWith(prefix)) } const icons = { connected: { path: { '16': '/icons/icon-green-16.png', '32': '/icons/icon-green-32.png', '48': '/icons/icon-green-48.png', '128': '/icons/icon-green-128.png', }, title: 'Connected - Click to disconnect', badgeText: '', badgeColor: [0, 0, 0, 0] as [number, number, number, number], }, 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', }, title: 'Connecting...', badgeText: '...', badgeColor: [0, 0, 0, 0] as [number, number, number, number], }, disconnected: { path: { '16': '/icons/icon-black-16.png', '32': '/icons/icon-black-32.png', '48': '/icons/icon-black-48.png', '128': '/icons/icon-black-128.png', }, title: 'Click to attach debugger', badgeText: '', badgeColor: [0, 0, 0, 0] as [number, number, number, number], }, restricted: { 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', }, title: 'Cannot attach to this page', badgeText: '', badgeColor: [0, 0, 0, 0] as [number, number, number, number], }, error: { 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', }, title: 'Error', badgeText: '!', badgeColor: [220, 38, 38, 255] as [number, number, number, number], }, } as const async function updateIcons(): Promise { const state = store.getState() const { connectionState, tabs, errorText } = state const connectedCount = Array.from(tabs.values()).filter((t) => t.state === 'connected').length const allTabs = await chrome.tabs.query({}) const tabUrlMap = new Map(allTabs.map((tab) => [tab.id, tab.url])) const allTabIds = [undefined, ...allTabs.map((tab) => tab.id).filter((id): id is number => id !== undefined)] for (const tabId of allTabIds) { const tabInfo = tabId !== undefined ? tabs.get(tabId) : undefined const tabUrl = tabId !== undefined ? tabUrlMap.get(tabId) : undefined const iconConfig = (() => { if (connectionState === 'error') return icons.error if (connectionState === 'reconnecting') return icons.connecting if (tabInfo?.state === 'error') return icons.error if (tabInfo?.state === 'connecting') return icons.connecting if (tabInfo?.state === 'connected') return icons.connected if (tabId !== undefined && isRestrictedUrl(tabUrl)) return icons.restricted return icons.disconnected })() const title = (() => { if (connectionState === 'error' && errorText) return errorText if (tabInfo?.errorText) return tabInfo.errorText return iconConfig.title })() const badgeText = (() => { if (iconConfig === icons.connected || iconConfig === icons.disconnected || iconConfig === icons.restricted) { return connectedCount > 0 ? String(connectedCount) : '' } return iconConfig.badgeText })() 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: badgeText }) } } async function onTabRemoved(tabId: number): Promise { const { tabs } = store.getState() if (!tabs.has(tabId)) return logger.debug(`Connected tab ${tabId} was closed, disconnecting`) await disconnectTab(tabId) } async function onTabActivated(activeInfo: chrome.tabs.TabActiveInfo): Promise { store.setState({ currentTabId: activeInfo.tabId }) } async function onActionClicked(tab: chrome.tabs.Tab): Promise { if (!tab.id) { logger.debug('No tab ID available') return } if (isRestrictedUrl(tab.url)) { logger.debug('Cannot attach to restricted URL:', tab.url) return } const { tabs, connectionState } = store.getState() const tabInfo = tabs.get(tab.id) if (connectionState === 'error') { logger.debug('Global error state - retrying reconnection') await reconnect() return } if (connectionState === 'reconnecting') { logger.debug('User clicked during reconnection, canceling and disconnecting all tabs') for (const tabId of tabs.keys()) { detachTab(tabId, true) } store.setState({ connectionState: 'disconnected', tabs: new Map(), errorText: undefined }) if (ws) { ws.close(1000, 'User cancelled reconnection') ws = null } return } if (tabInfo?.state === 'error') { logger.debug('Tab has error - disconnecting to clear state') await disconnectTab(tab.id) return } if (tabInfo?.state === 'connecting') { logger.debug('Tab is already connecting, ignoring click') return } if (tabInfo?.state === 'connected') { await disconnectTab(tab.id) } else { await connectTab(tab.id) } } resetDebugger() chrome.runtime.onInstalled.addListener((details) => { if (import.meta.env.TESTING) return if (details.reason === 'install') { void chrome.tabs.create({ url: 'welcome.html' }) } }) function serializeTabs(tabs: Map): string { return JSON.stringify(Array.from(tabs.entries())) } store.subscribe((state, prevState) => { logger.log(state) void updateIcons() const tabsChanged = serializeTabs(state.tabs) !== serializeTabs(prevState.tabs) if (tabsChanged) { syncTabGroupQueue = syncTabGroupQueue.then(syncTabGroup).catch((e) => { logger.debug('syncTabGroup error:', e) }) } }) logger.debug(`Using relay URL: ${RELAY_URL}`) chrome.tabs.onRemoved.addListener(onTabRemoved) chrome.tabs.onActivated.addListener(onTabActivated) chrome.action.onClicked.addListener(onActionClicked) chrome.tabs.onUpdated.addListener(() => { void updateIcons() })