1107 lines
35 KiB
TypeScript
1107 lines
35 KiB
TypeScript
declare const process: { env: { PLAYWRITER_PORT: string } }
|
|
|
|
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/protocol'
|
|
|
|
const RELAY_PORT = process.env.PLAYWRITER_PORT
|
|
const RELAY_URL = `ws://localhost:${RELAY_PORT}/extension`
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|
|
|
|
let ws: WebSocket | null = null
|
|
let childSessions: Map<string, number> = new Map()
|
|
let nextSessionId = 1
|
|
let playwriterGroupId: number | null = null
|
|
let syncTabGroupQueue: Promise<void> = Promise.resolve()
|
|
let replacedRetryInterval: ReturnType<typeof setInterval> | null = null
|
|
|
|
const store = createStore<ExtensionState>(() => ({
|
|
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<void>
|
|
}
|
|
|
|
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 getCallStack(): string {
|
|
const stack = new Error().stack || ''
|
|
return stack.split('\n').slice(2, 6).join(' <- ').replace(/\s+/g, ' ')
|
|
}
|
|
|
|
self.addEventListener('error', (event) => {
|
|
const error = event.error
|
|
const stack = error?.stack || `${event.message} at ${event.filename}:${event.lineno}:${event.colno}`
|
|
logger.error('Uncaught error:', stack)
|
|
})
|
|
|
|
self.addEventListener('unhandledrejection', (event) => {
|
|
const reason = event.reason
|
|
const stack = reason?.stack || String(reason)
|
|
logger.error('Unhandled promise rejection:', stack)
|
|
})
|
|
|
|
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<void> {
|
|
try {
|
|
// Only tabs with state 'connected' are in the group.
|
|
// Tabs in 'connecting' or 'error' state are removed from the group.
|
|
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<any> {
|
|
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})`)
|
|
}
|
|
// When multiple Playwright clients connect to the same tab, each calls Runtime.enable.
|
|
// If Runtime is already enabled, the enable call succeeds but Chrome doesn't re-send
|
|
// Runtime.executionContextCreated events - those were already sent to the first client.
|
|
// By disabling first, we force Chrome to re-send all execution context events when we
|
|
// re-enable, ensuring the new client receives them. The relay server waits for the
|
|
// executionContextCreated events before returning. See cdp-timing.md for details.
|
|
try {
|
|
await chrome.debugger.sendCommand(debuggee, 'Runtime.disable')
|
|
await sleep(50)
|
|
} 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.warn(`DISCONNECT: onDebuggerDetach tabId=${tabId} reason=${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<Protocol.Target.TargetInfo> {
|
|
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')
|
|
|
|
const contextMenuScript = `
|
|
document.addEventListener('contextmenu', (e) => {
|
|
window.__playwriter_lastRightClicked = e.target;
|
|
}, true);
|
|
`
|
|
await chrome.debugger.sendCommand(debuggee, 'Page.addScriptToEvaluateOnNewDocument', { source: contextMenuScript })
|
|
await chrome.debugger.sendCommand(debuggee, 'Runtime.evaluate', { expression: contextMenuScript })
|
|
|
|
const result = (await chrome.debugger.sendCommand(
|
|
debuggee,
|
|
'Target.getTargetInfo',
|
|
)) as Protocol.Target.GetTargetInfoResponse
|
|
|
|
const targetInfo = result.targetInfo
|
|
const attachOrder = nextSessionId
|
|
const sessionId = `pw-tab-${nextSessionId++}`
|
|
|
|
store.setState((state) => {
|
|
const newTabs = new Map(state.tabs)
|
|
newTabs.set(tabId, {
|
|
sessionId,
|
|
targetId: targetInfo.targetId,
|
|
state: 'connected',
|
|
attachOrder,
|
|
})
|
|
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.warn(`DISCONNECT: detachTab tabId=${tabId} shouldDetach=${shouldDetachDebugger} stack=${getCallStack()}`)
|
|
|
|
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.warn(`DISCONNECT: closeConnection reason=${reason} stack=${getCallStack()}`)
|
|
|
|
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:${RELAY_PORT}/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.warn(`DISCONNECT: WS closed code=${code} reason=${reason || 'none'} stack=${getCallStack()}`)
|
|
|
|
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
|
|
}
|
|
|
|
if (tabs.size > 0) {
|
|
logger.debug('Tabs still connected, triggering reconnection')
|
|
store.setState((state) => {
|
|
const newTabs = new Map(state.tabs)
|
|
for (const [tabId, tab] of newTabs) {
|
|
newTabs.set(tabId, { ...tab, state: 'connecting' })
|
|
}
|
|
return { tabs: newTabs }
|
|
})
|
|
void reconnect()
|
|
} else {
|
|
store.setState({ connectionState: 'disconnected', errorText: undefined })
|
|
}
|
|
}
|
|
|
|
async function ensureConnection(): Promise<void> {
|
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
logger.debug('Connection already exists, reusing')
|
|
return
|
|
}
|
|
|
|
logger.debug(`Waiting for server at http://localhost:${RELAY_PORT}...`)
|
|
store.setState({ connectionState: 'reconnecting' })
|
|
|
|
while (true) {
|
|
try {
|
|
await fetch(`http://localhost:${RELAY_PORT}`, { 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<void>((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: any
|
|
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
|
|
}
|
|
|
|
// Handle ping from server - respond with pong to keep service worker alive
|
|
if (message.method === 'ping') {
|
|
sendMessage({ method: 'pong' })
|
|
return
|
|
}
|
|
|
|
const response: ExtensionResponseMessage = { id: message.id }
|
|
try {
|
|
response.result = await handleCommand(message as ExtensionCommandMessage)
|
|
} 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
logger.debug('Starting reconnection')
|
|
store.setState({ connectionState: 'reconnecting', errorText: undefined })
|
|
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<void>((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<void> {
|
|
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<void> {
|
|
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 })
|
|
}
|
|
}
|
|
|
|
// undefined URL is for about:blank pages (not restricted) and chrome:// URLs (restricted).
|
|
// We can't distinguish them without the `tabs` permission, so we just let attachment fail.
|
|
function isRestrictedUrl(url: string | undefined): boolean {
|
|
if (!url) return false
|
|
const restrictedPrefixes = ['chrome://', 'chrome-extension://', 'devtools://', 'edge://', 'https://chrome.google.com/', 'https://chromewebstore.google.com/']
|
|
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: [64, 64, 64, 255] 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: 'Waiting for MCP WS server...',
|
|
badgeText: '...',
|
|
badgeColor: [64, 64, 64, 255] 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: [64, 64, 64, 255] 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: [64, 64, 64, 255] 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<void> {
|
|
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 (tabId !== undefined && isRestrictedUrl(tabUrl)) return icons.restricted
|
|
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
|
|
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<void> {
|
|
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<void> {
|
|
store.setState({ currentTabId: activeInfo.tabId })
|
|
}
|
|
|
|
async function onActionClicked(tab: chrome.tabs.Tab): Promise<void> {
|
|
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.contextMenus.remove('playwriter-pin-element').catch(() => {}).finally(() => {
|
|
chrome.contextMenus.create({
|
|
id: 'playwriter-pin-element',
|
|
title: 'Copy Playwriter Element Reference',
|
|
contexts: ['all'],
|
|
visible: false,
|
|
})
|
|
})
|
|
|
|
function updateContextMenuVisibility(): void {
|
|
const { currentTabId, tabs } = store.getState()
|
|
const isConnected = currentTabId !== undefined && tabs.get(currentTabId)?.state === 'connected'
|
|
chrome.contextMenus.update('playwriter-pin-element', { visible: isConnected })
|
|
}
|
|
|
|
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<number, TabInfo>): string {
|
|
return JSON.stringify(Array.from(tabs.entries()))
|
|
}
|
|
|
|
store.subscribe((state, prevState) => {
|
|
logger.log(state)
|
|
void updateIcons()
|
|
updateContextMenuVisibility()
|
|
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((tabId, changeInfo, tab) => {
|
|
void updateIcons()
|
|
if (changeInfo.groupId !== undefined && playwriterGroupId !== null) {
|
|
const { tabs } = store.getState()
|
|
if (changeInfo.groupId === playwriterGroupId) {
|
|
if (!tabs.has(tabId) && !isRestrictedUrl(tab.url)) {
|
|
logger.debug('Tab manually added to playwriter group:', tabId)
|
|
void connectTab(tabId)
|
|
}
|
|
} else if (tabs.has(tabId)) {
|
|
const tabInfo = tabs.get(tabId)
|
|
if (tabInfo?.state === 'connecting') {
|
|
logger.debug('Tab removed from group while connecting, ignoring:', tabId)
|
|
return
|
|
}
|
|
logger.debug('Tab manually removed from playwriter group:', tabId)
|
|
void disconnectTab(tabId)
|
|
}
|
|
}
|
|
})
|
|
|
|
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
|
if (info.menuItemId !== 'playwriter-pin-element' || !tab?.id) return
|
|
|
|
const tabInfo = store.getState().tabs.get(tab.id)
|
|
if (!tabInfo || tabInfo.state !== 'connected') {
|
|
logger.debug('Tab not connected, ignoring')
|
|
return
|
|
}
|
|
|
|
const debuggee = { tabId: tab.id }
|
|
const count = (tabInfo.pinnedCount || 0) + 1
|
|
|
|
store.setState((state) => {
|
|
const newTabs = new Map(state.tabs)
|
|
const existing = newTabs.get(tab.id!)
|
|
if (existing) {
|
|
newTabs.set(tab.id!, { ...existing, pinnedCount: count })
|
|
}
|
|
return { tabs: newTabs }
|
|
})
|
|
|
|
const name = `playwriterPinnedElem${count}`
|
|
|
|
const connectedTabs = Array.from(store.getState().tabs.entries())
|
|
.filter(([_, t]) => t.state === 'connected')
|
|
.sort((a, b) => (a[1].attachOrder ?? 0) - (b[1].attachOrder ?? 0))
|
|
const pageIndex = connectedTabs.findIndex(([id]) => id === tab.id)
|
|
const hasMultiplePages = connectedTabs.length > 1
|
|
|
|
try {
|
|
const result = (await chrome.debugger.sendCommand(debuggee, 'Runtime.evaluate', {
|
|
expression: `
|
|
if (window.__playwriter_lastRightClicked) {
|
|
window.${name} = window.__playwriter_lastRightClicked;
|
|
'${name}';
|
|
} else {
|
|
throw new Error('No element was right-clicked');
|
|
}
|
|
`,
|
|
returnByValue: true,
|
|
})) as { result?: { value?: string }; exceptionDetails?: { text: string } }
|
|
|
|
if (result.exceptionDetails) {
|
|
logger.error('Failed to pin element:', result.exceptionDetails.text)
|
|
return
|
|
}
|
|
|
|
const clipboardText = hasMultiplePages
|
|
? `globalThis.${name} (page ${pageIndex}, ${tab.url || 'unknown url'})`
|
|
: `globalThis.${name}`
|
|
|
|
await chrome.debugger.sendCommand(debuggee, 'Runtime.evaluate', {
|
|
expression: `
|
|
(() => {
|
|
const el = window.${name};
|
|
if (!el) return;
|
|
const orig = el.getAttribute('style') || '';
|
|
el.setAttribute('style', orig + '; outline: 3px solid #22c55e !important; outline-offset: 2px !important; box-shadow: 0 0 0 3px #22c55e !important;');
|
|
setTimeout(() => el.setAttribute('style', orig), 300);
|
|
return navigator.clipboard.writeText(${JSON.stringify(clipboardText)});
|
|
})()
|
|
`,
|
|
awaitPromise: true,
|
|
})
|
|
|
|
logger.debug('Pinned element as:', name)
|
|
} catch (error: any) {
|
|
logger.error('Failed to pin element:', error.message)
|
|
}
|
|
})
|
|
|
|
// Sync icons on first load
|
|
void updateIcons()
|