feat: multi tabs control
This commit is contained in:
@@ -4,7 +4,11 @@
|
||||
* This class implements the same interface as PageController but forwards
|
||||
* all method calls via RPC to the real PageController running in ContentScript.
|
||||
* This allows PageAgentCore to work transparently with remote DOM operations.
|
||||
*
|
||||
* Tab targeting is managed externally by TabsManager via setTargetTab().
|
||||
*/
|
||||
import type { PageController } from '@page-agent/page-controller'
|
||||
|
||||
import type {
|
||||
ActionResult,
|
||||
BrowserState,
|
||||
@@ -13,6 +17,32 @@ import type {
|
||||
} from '../messaging/protocol'
|
||||
import { type RPCClient, createRPCClient } from '../messaging/rpc'
|
||||
|
||||
const DEBUG_PREFIX = '[RemotePageController]'
|
||||
|
||||
/**
|
||||
* Check if a URL can run content scripts.
|
||||
* Chrome extensions cannot inject content scripts into certain pages.
|
||||
*/
|
||||
export function isContentScriptAllowed(url: string | undefined): boolean {
|
||||
if (!url) return false
|
||||
|
||||
// Restricted URL patterns
|
||||
const restrictedPatterns = [
|
||||
/^chrome:\/\//,
|
||||
/^chrome-extension:\/\//,
|
||||
/^about:/,
|
||||
/^edge:\/\//,
|
||||
/^brave:\/\//,
|
||||
/^opera:\/\//,
|
||||
/^vivaldi:\/\//,
|
||||
/^file:\/\//,
|
||||
/^view-source:/,
|
||||
/^devtools:\/\//,
|
||||
]
|
||||
|
||||
return !restrictedPatterns.some((pattern) => pattern.test(url))
|
||||
}
|
||||
|
||||
/**
|
||||
* RemotePageController is a proxy that implements the PageController interface.
|
||||
* All methods are async and forward to ContentScript via RPC.
|
||||
@@ -20,30 +50,133 @@ import { type RPCClient, createRPCClient } from '../messaging/rpc'
|
||||
* This class extends EventTarget to maintain API compatibility with PageController,
|
||||
* though events in the remote context are not currently bridged.
|
||||
*/
|
||||
export class RemotePageController extends EventTarget {
|
||||
private rpc: RPCClient
|
||||
private _tabId: number | null = null
|
||||
private _tabIdPromise: Promise<number>
|
||||
export class RemotePageController {
|
||||
private rpc: RPCClient | null = null
|
||||
private _currentTabId: number | null = null
|
||||
private _currentTabUrl: string | undefined = undefined
|
||||
private _previousTabId: number | null = null
|
||||
|
||||
/** Get the target tab ID (null if not yet resolved) */
|
||||
get tabId(): number | null {
|
||||
return this._tabId
|
||||
/** Get the current target tab ID */
|
||||
get currentTabId(): number | null {
|
||||
return this._currentTabId
|
||||
}
|
||||
|
||||
/** Get the promise that resolves to the target tab ID */
|
||||
get tabIdPromise(): Promise<number> {
|
||||
return this._tabIdPromise
|
||||
/** Get the current target tab URL */
|
||||
get currentTabUrl(): string | undefined {
|
||||
return this._currentTabUrl
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
// Capture the active tab ID at construction time to avoid issues when tab loses focus
|
||||
this._tabIdPromise = chrome.tabs.query({ active: true, currentWindow: true }).then(([tab]) => {
|
||||
if (!tab?.id) throw new Error('No active tab found')
|
||||
this._tabId = tab.id
|
||||
return tab.id
|
||||
})
|
||||
this.rpc = createRPCClient(this._tabIdPromise)
|
||||
/** Check if current tab supports content scripts */
|
||||
get isCurrentTabAccessible(): boolean {
|
||||
return isContentScriptAllowed(this._currentTabUrl)
|
||||
}
|
||||
|
||||
// Tab ID is now set externally via setTargetTab()
|
||||
|
||||
/**
|
||||
* Set the target tab for all RPC operations.
|
||||
* Called by TabsManager when switching tabs.
|
||||
* Handles cleanup on old tab and mask show on new tab.
|
||||
*/
|
||||
async setTargetTab(tabId: number): Promise<void> {
|
||||
const previousTabId = this._currentTabId
|
||||
const previousRpc = this.rpc
|
||||
|
||||
console.debug(`${DEBUG_PREFIX} setTargetTab: ${previousTabId} → ${tabId}`)
|
||||
|
||||
// Clean up old tab completely (highlights + mask)
|
||||
if (previousTabId && previousTabId !== tabId && previousRpc) {
|
||||
console.debug(`${DEBUG_PREFIX} Cleaning up previous tab ${previousTabId}`)
|
||||
try {
|
||||
// Clean up highlights first - this is important for visual cleanup
|
||||
await previousRpc.cleanUpHighlights()
|
||||
} catch (e) {
|
||||
console.debug(
|
||||
`${DEBUG_PREFIX} cleanUpHighlights on tab ${previousTabId} failed (ignored):`,
|
||||
e
|
||||
)
|
||||
}
|
||||
try {
|
||||
await previousRpc.hideMask()
|
||||
} catch (e) {
|
||||
console.debug(`${DEBUG_PREFIX} hideMask on tab ${previousTabId} failed (ignored):`, e)
|
||||
}
|
||||
}
|
||||
|
||||
// Get tab info to check URL
|
||||
const tab = await chrome.tabs.get(tabId)
|
||||
const tabUrl = tab.url
|
||||
|
||||
// Update state
|
||||
this._previousTabId = previousTabId
|
||||
this._currentTabId = tabId
|
||||
this._currentTabUrl = tabUrl
|
||||
|
||||
// Check if this tab can run content scripts
|
||||
if (!isContentScriptAllowed(tabUrl)) {
|
||||
console.debug(`${DEBUG_PREFIX} Tab ${tabId} cannot run content scripts: ${tabUrl}`)
|
||||
// Clear RPC - operations will return restricted page state
|
||||
this.rpc = null
|
||||
return
|
||||
}
|
||||
|
||||
// Create new RPC client for the new tab
|
||||
this.rpc = createRPCClient(tabId)
|
||||
|
||||
// Verify content script is ready by making a test call
|
||||
// This uses the retry mechanism to wait for content script initialization
|
||||
try {
|
||||
await this.rpc.getLastUpdateTime()
|
||||
console.debug(`${DEBUG_PREFIX} Content script ready on tab ${tabId}`)
|
||||
} catch (error) {
|
||||
console.error(`${DEBUG_PREFIX} Content script not ready on tab ${tabId}:`, error)
|
||||
// Don't clear rpc - subsequent calls will retry and may succeed
|
||||
}
|
||||
|
||||
// Show mask on new tab
|
||||
try {
|
||||
await this.rpc.showMask()
|
||||
console.debug(`${DEBUG_PREFIX} Mask shown on tab ${tabId}`)
|
||||
} catch (error) {
|
||||
console.error(`${DEBUG_PREFIX} Failed to show mask on tab ${tabId}:`, error)
|
||||
// Continue anyway - mask is optional
|
||||
}
|
||||
|
||||
console.debug(`${DEBUG_PREFIX} Target tab set to ${tabId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure RPC client is initialized
|
||||
* @throws Error if setTargetTab() has not been called
|
||||
*/
|
||||
private ensureInitialized(): void {
|
||||
if (!this._currentTabId) {
|
||||
throw new Error('RemotePageController not initialized. Call setTargetTab() first.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a browser state for restricted pages that cannot run content scripts.
|
||||
* Treats restricted pages as empty pages rather than errors.
|
||||
*/
|
||||
private createRestrictedPageState(): BrowserState {
|
||||
return {
|
||||
url: this._currentTabUrl || '',
|
||||
title: '',
|
||||
header: '',
|
||||
content: '(empty page)',
|
||||
footer: '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a no-op action result for restricted pages
|
||||
*/
|
||||
private createRestrictedActionResult(action: string): ActionResult {
|
||||
return {
|
||||
success: false,
|
||||
message: `Cannot ${action} on this page. Use open_new_tab to navigate to a web page first.`,
|
||||
}
|
||||
}
|
||||
|
||||
// ======= State Queries =======
|
||||
@@ -52,13 +185,15 @@ export class RemotePageController extends EventTarget {
|
||||
* Get current page URL
|
||||
*/
|
||||
async getCurrentUrl(): Promise<string> {
|
||||
return this.rpc.getCurrentUrl()
|
||||
// Can return URL even for restricted pages
|
||||
return this._currentTabUrl || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last tree update timestamp
|
||||
*/
|
||||
async getLastUpdateTime(): Promise<number> {
|
||||
if (!this.rpc) return Date.now()
|
||||
return this.rpc.getLastUpdateTime()
|
||||
}
|
||||
|
||||
@@ -66,6 +201,10 @@ export class RemotePageController extends EventTarget {
|
||||
* Get structured browser state for LLM consumption.
|
||||
*/
|
||||
async getBrowserState(): Promise<BrowserState> {
|
||||
// Return restricted page state if content scripts cannot run
|
||||
if (!this.rpc) {
|
||||
return this.createRestrictedPageState()
|
||||
}
|
||||
return this.rpc.getBrowserState()
|
||||
}
|
||||
|
||||
@@ -75,6 +214,8 @@ export class RemotePageController extends EventTarget {
|
||||
* Update DOM tree, returns simplified HTML for LLM.
|
||||
*/
|
||||
async updateTree(): Promise<string> {
|
||||
this.ensureInitialized()
|
||||
if (!this.rpc) return '(empty page)'
|
||||
return this.rpc.updateTree()
|
||||
}
|
||||
|
||||
@@ -82,6 +223,7 @@ export class RemotePageController extends EventTarget {
|
||||
* Clean up all element highlights
|
||||
*/
|
||||
async cleanUpHighlights(): Promise<void> {
|
||||
if (!this.rpc) return
|
||||
return this.rpc.cleanUpHighlights()
|
||||
}
|
||||
|
||||
@@ -91,6 +233,8 @@ export class RemotePageController extends EventTarget {
|
||||
* Click element by index
|
||||
*/
|
||||
async clickElement(index: number): Promise<ActionResult> {
|
||||
this.ensureInitialized()
|
||||
if (!this.rpc) return this.createRestrictedActionResult('click')
|
||||
return this.rpc.clickElement(index)
|
||||
}
|
||||
|
||||
@@ -98,6 +242,8 @@ export class RemotePageController extends EventTarget {
|
||||
* Input text into element by index
|
||||
*/
|
||||
async inputText(index: number, text: string): Promise<ActionResult> {
|
||||
this.ensureInitialized()
|
||||
if (!this.rpc) return this.createRestrictedActionResult('input text')
|
||||
return this.rpc.inputText(index, text)
|
||||
}
|
||||
|
||||
@@ -105,6 +251,8 @@ export class RemotePageController extends EventTarget {
|
||||
* Select dropdown option by index and option text
|
||||
*/
|
||||
async selectOption(index: number, optionText: string): Promise<ActionResult> {
|
||||
this.ensureInitialized()
|
||||
if (!this.rpc) return this.createRestrictedActionResult('select option')
|
||||
return this.rpc.selectOption(index, optionText)
|
||||
}
|
||||
|
||||
@@ -112,6 +260,8 @@ export class RemotePageController extends EventTarget {
|
||||
* Scroll vertically
|
||||
*/
|
||||
async scroll(options: ScrollOptions): Promise<ActionResult> {
|
||||
this.ensureInitialized()
|
||||
if (!this.rpc) return this.createRestrictedActionResult('scroll')
|
||||
return this.rpc.scroll(options)
|
||||
}
|
||||
|
||||
@@ -119,6 +269,8 @@ export class RemotePageController extends EventTarget {
|
||||
* Scroll horizontally
|
||||
*/
|
||||
async scrollHorizontally(options: ScrollHorizontallyOptions): Promise<ActionResult> {
|
||||
this.ensureInitialized()
|
||||
if (!this.rpc) return this.createRestrictedActionResult('scroll')
|
||||
return this.rpc.scrollHorizontally(options)
|
||||
}
|
||||
|
||||
@@ -126,6 +278,8 @@ export class RemotePageController extends EventTarget {
|
||||
* Execute arbitrary JavaScript on the page
|
||||
*/
|
||||
async executeJavascript(script: string): Promise<ActionResult> {
|
||||
this.ensureInitialized()
|
||||
if (!this.rpc) return this.createRestrictedActionResult('execute script')
|
||||
return this.rpc.executeJavascript(script)
|
||||
}
|
||||
|
||||
@@ -135,6 +289,7 @@ export class RemotePageController extends EventTarget {
|
||||
* Show the visual mask overlay.
|
||||
*/
|
||||
async showMask(): Promise<void> {
|
||||
if (!this.rpc) return
|
||||
return this.rpc.showMask()
|
||||
}
|
||||
|
||||
@@ -142,15 +297,38 @@ export class RemotePageController extends EventTarget {
|
||||
* Hide the visual mask overlay.
|
||||
*/
|
||||
async hideMask(): Promise<void> {
|
||||
if (!this.rpc) return
|
||||
return this.rpc.hideMask()
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose and clean up resources
|
||||
* Dispose and clean up resources on current tab
|
||||
*/
|
||||
dispose(): void {
|
||||
this.rpc.dispose().catch(() => {
|
||||
// Ignore errors on dispose
|
||||
})
|
||||
console.debug(`${DEBUG_PREFIX} dispose() called, current tab: ${this._currentTabId}`)
|
||||
if (this.rpc) {
|
||||
this.rpc.dispose().catch((e) => {
|
||||
console.debug(`${DEBUG_PREFIX} dispose RPC failed (ignored):`, e)
|
||||
})
|
||||
}
|
||||
this._currentTabId = null
|
||||
this._previousTabId = null
|
||||
this.rpc = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose PageController on a specific tab (cleanup for multi-tab scenarios)
|
||||
*/
|
||||
async disposeTab(tabId: number): Promise<void> {
|
||||
console.debug(`${DEBUG_PREFIX} disposeTab(${tabId})`)
|
||||
try {
|
||||
const rpc = createRPCClient(tabId)
|
||||
await rpc.cleanUpHighlights()
|
||||
await rpc.hideMask()
|
||||
await rpc.dispose()
|
||||
console.debug(`${DEBUG_PREFIX} Tab ${tabId} disposed successfully`)
|
||||
} catch (e) {
|
||||
console.debug(`${DEBUG_PREFIX} disposeTab(${tabId}) failed (ignored):`, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user