From 49b137981cf9f395895e72465003083aadbe3cbf Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:40:16 +0800 Subject: [PATCH] fix(ext): tab events do not work for content scripts --- .../src/agent/TabsController.background.ts | 57 ++++---- .../extension/src/agent/TabsController.ts | 132 +++++++++++------- .../extension/src/entrypoints/background.ts | 4 +- 3 files changed, 112 insertions(+), 81 deletions(-) diff --git a/packages/extension/src/agent/TabsController.background.ts b/packages/extension/src/agent/TabsController.background.ts index 79705d1..5b4baff 100644 --- a/packages/extension/src/agent/TabsController.background.ts +++ b/packages/extension/src/agent/TabsController.background.ts @@ -6,7 +6,6 @@ import type { TabAction } from './TabsController' const PREFIX = '[TabsController.background]' const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`) -const debugError = console.error.bind(console, `\x1b[91m${PREFIX}\x1b[0m`) export function handleTabControlMessage( message: { type: 'TAB_CONTROL'; action: TabAction; payload: any }, @@ -131,42 +130,40 @@ export function handleTabControlMessage( } } -export function setupTabChangeEvents() { - // @note It's normal to catch errors here before `TabsController.init()` - console.log('[TabsController.background] setupTabChangeEvents') +const tabEventPorts = new Set() + +function broadcastTabEvent(message: object) { + for (const port of tabEventPorts) { + port.postMessage(message) + } +} + +/** + * Port-based tab events: agents connect via `chrome.runtime.connect({ name: 'tab-events' })` + * and receive tab change events through the port. Works for both extension pages and content scripts. + */ +export function setupTabEventsPort() { + chrome.runtime.onConnect.addListener((port) => { + if (port.name !== 'tab-events') return + + debug('port connected', port.sender?.tab?.id ?? port.sender?.url) + tabEventPorts.add(port) + + port.onDisconnect.addListener(() => { + debug('port disconnected') + tabEventPorts.delete(port) + }) + }) chrome.tabs.onCreated.addListener((tab) => { - debug('onCreated', tab) - chrome.runtime - .sendMessage({ type: 'TAB_CHANGE', action: 'created', payload: { tab } }) - .catch((error) => { - debugError('onCreated error:', error) - }) + broadcastTabEvent({ action: 'created', payload: { tab } }) }) chrome.tabs.onRemoved.addListener((tabId, removeInfo) => { - debug('onRemoved', tabId, removeInfo) - chrome.runtime - .sendMessage({ - type: 'TAB_CHANGE', - action: 'removed', - payload: { tabId, removeInfo }, - }) - .catch((error) => { - debugError('onRemoved error:', error) - }) + broadcastTabEvent({ action: 'removed', payload: { tabId, removeInfo } }) }) chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { - debug('onUpdated', tabId, changeInfo) - chrome.runtime - .sendMessage({ - type: 'TAB_CHANGE', - action: 'updated', - payload: { tabId, changeInfo, tab }, - }) - .catch((error) => { - debugError('onUpdated error:', error) - }) + broadcastTabEvent({ action: 'updated', payload: { tabId, changeInfo, tab } }) }) } diff --git a/packages/extension/src/agent/TabsController.ts b/packages/extension/src/agent/TabsController.ts index f8f04ea..b21e7af 100644 --- a/packages/extension/src/agent/TabsController.ts +++ b/packages/extension/src/agent/TabsController.ts @@ -20,9 +20,13 @@ function sendMessage(message: { * - live in the agent env (extension page or content script) * - no chrome apis. call sw for tab operations */ -export class TabsController extends EventTarget { +export class TabsController { currentTabId: number | null = null + private disposed = false + private port: chrome.runtime.Port | null = null + private portRetries = 0 + private windowId: number | null = null private tabs: TabMeta[] = [] private initialTabId: number | null = null @@ -34,13 +38,21 @@ export class TabsController extends EventTarget { const { includeInitialTab = true, experimentalIncludeAllTabs = false } = options debug('init', task, options) - this.task = task + if (this.disposed) { + throw new Error('TabsController already disposed') + } + + this.currentTabId = null + this.disposed = false + this.port = null + this.portRetries = 0 + this.windowId = null this.tabs = [] - this.currentTabId = null this.tabGroupId = null this.initialTabId = null this.experimentalIncludeAllTabs = experimentalIncludeAllTabs + this.task = task const activeTabResult = await sendMessage({ type: 'TAB_CONTROL', @@ -58,6 +70,8 @@ export class TabsController extends EventTarget { } } + this.connectTabEvents() + if (experimentalIncludeAllTabs) { const allTabs = await sendMessage({ type: 'TAB_CONTROL', @@ -102,51 +116,6 @@ export class TabsController extends EventTarget { } await this.updateCurrentTabId(this.currentTabId) - - const tabChangeHandler = (message: any): void => { - if (message.type !== 'TAB_CHANGE') { - return - } - - if (message.action === 'created') { - const tab = message.payload.tab as chrome.tabs.Tab - const shouldTrack = this.experimentalIncludeAllTabs || tab.groupId === this.tabGroupId - if (shouldTrack && tab.id != null) { - if (!this.tabs.find((t) => t.id === tab.id)) { - this.tabs.push({ id: tab.id, isInitial: false }) - } - this.switchToTab(tab.id) - } - } else if (message.action === 'removed') { - const { tabId } = message.payload as { tabId: number } - const targetTab = this.tabs.find((t) => t.id === tabId) - if (targetTab) { - this.tabs = this.tabs.filter((t) => t.id !== tabId) - if (this.currentTabId === tabId) { - const newCurrentTab = this.tabs[this.tabs.length - 1] || null - if (newCurrentTab) { - this.switchToTab(newCurrentTab.id) - } else { - this.updateCurrentTabId(null) - } - } - } - } else if (message.action === 'updated') { - const { tabId, tab } = message.payload as { tabId: number; tab: chrome.tabs.Tab } - const targetTab = this.tabs.find((t) => t.id === tabId) - if (targetTab) { - targetTab.url = tab.url - targetTab.title = tab.title - targetTab.status = tab.status - } - } - } - - chrome.runtime.onMessage.addListener(tabChangeHandler) - - this.addEventListener('dispose', () => { - chrome.runtime.onMessage.removeListener(tabChangeHandler) - }) } async openNewTab(url: string): Promise { @@ -316,8 +285,73 @@ export class TabsController extends EventTarget { await waitUntil(() => tab.status === 'complete', 4_000) } + /** + * Connect to background SW via port to receive tab change events. + * + * @note Port is 1:1 (runtime.connect → background SW has no frames), + * so onDisconnect fires exactly once and we can safely reconnect. + * Reconnection may miss events during the gap. + * TODO: refresh this.tabs from background after reconnect to stay consistent. + */ + private connectTabEvents() { + this.port = chrome.runtime.connect({ name: 'tab-events' }) + + this.port.onMessage.addListener((message: any) => { + if (this.disposed) return + this.portRetries = 0 + + if (message.action === 'created') { + const tab = message.payload.tab as chrome.tabs.Tab + const shouldTrack = this.experimentalIncludeAllTabs || tab.groupId === this.tabGroupId + if (shouldTrack && tab.id != null) { + if (!this.tabs.find((t) => t.id === tab.id)) { + this.tabs.push({ id: tab.id, isInitial: false }) + } + this.switchToTab(tab.id) + } + } else if (message.action === 'removed') { + const { tabId } = message.payload as { tabId: number } + const targetTab = this.tabs.find((t) => t.id === tabId) + if (targetTab) { + this.tabs = this.tabs.filter((t) => t.id !== tabId) + if (this.currentTabId === tabId) { + const newCurrentTab = this.tabs[this.tabs.length - 1] || null + if (newCurrentTab) { + this.switchToTab(newCurrentTab.id) + } else { + this.updateCurrentTabId(null) + } + } + } + } else if (message.action === 'updated') { + const { tabId, tab } = message.payload as { tabId: number; tab: chrome.tabs.Tab } + const targetTab = this.tabs.find((t) => t.id === tabId) + if (targetTab) { + targetTab.url = tab.url + targetTab.title = tab.title + targetTab.status = tab.status + } + } + }) + + this.port.onDisconnect.addListener(() => { + this.port = null + if (this.disposed) return + if (this.portRetries >= 7) { + console.error(PREFIX, 'tab events port failed after 3 retries, giving up') + return + } + debug('port disconnected, reconnecting...') + this.portRetries++ + this.connectTabEvents() + }) + } + dispose() { - this.dispatchEvent(new Event('dispose')) + debug('dispose') + this.disposed = true + this.port?.disconnect() + this.port = null } } diff --git a/packages/extension/src/entrypoints/background.ts b/packages/extension/src/entrypoints/background.ts index 9c26f96..a83fde3 100644 --- a/packages/extension/src/entrypoints/background.ts +++ b/packages/extension/src/entrypoints/background.ts @@ -1,12 +1,12 @@ import { handlePageControlMessage } from '@/agent/RemotePageController.background' -import { handleTabControlMessage, setupTabChangeEvents } from '@/agent/TabsController.background' +import { handleTabControlMessage, setupTabEventsPort } from '@/agent/TabsController.background' export default defineBackground(() => { console.log('[Background] Service Worker started') // tab change events - setupTabChangeEvents() + setupTabEventsPort() // generate user auth token