import type { BrowserState } from '@page-agent/page-controller' import type { TabsController } from './TabsController' const PREFIX = '[RemotePageController]' const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`) function sendMessage(message: { type: 'PAGE_CONTROL' action: string targetTabId: number payload?: any }): Promise { return chrome.runtime.sendMessage(message).catch((error) => { console.error(PREFIX, message.action, error) return null }) } /** * Agent side page controller. * - live in the agent env (extension page or content script) * - communicates with remote PageController via sw */ export class RemotePageController { tabsController: TabsController constructor(tabsController: TabsController) { this.tabsController = tabsController } get currentTabId(): number | null { return this.tabsController.currentTabId } private async getCurrentUrl(): Promise { if (!this.currentTabId) return '' const { url } = await this.tabsController.getTabInfo(this.currentTabId) return url || '' } private async getCurrentTitle(): Promise { if (!this.currentTabId) return '' const { title } = await this.tabsController.getTabInfo(this.currentTabId) return title || '' } async getLastUpdateTime(): Promise { if (!this.currentTabId) throw new Error('tabsController not initialized.') return sendMessage({ type: 'PAGE_CONTROL', action: 'get_last_update_time', targetTabId: this.currentTabId, }) } async getBrowserState(): Promise { let browserState: BrowserState debug('getBrowserState', this.currentTabId) const currentUrl = await this.getCurrentUrl() const currentTitle = await this.getCurrentTitle() if (!this.currentTabId || !isContentScriptAllowed(currentUrl)) { browserState = { url: currentUrl, title: currentTitle, header: '', content: '(empty page. either current page is not readable or not loaded yet.)', footer: '', } } else { browserState = await sendMessage({ type: 'PAGE_CONTROL', action: 'get_browser_state', targetTabId: this.currentTabId, }) } const sum = await this.tabsController.summarizeTabs() browserState.header = sum + '\n\n' + (browserState.header || '') debug('getBrowserState: success', this.currentTabId, browserState) return browserState } async updateTree(): Promise { if (!this.currentTabId || !isContentScriptAllowed(await this.getCurrentUrl())) { return } await sendMessage({ type: 'PAGE_CONTROL', action: 'update_tree', targetTabId: this.currentTabId, }) } async cleanUpHighlights(): Promise { if (!this.currentTabId || !isContentScriptAllowed(await this.getCurrentUrl())) { return } await sendMessage({ type: 'PAGE_CONTROL', action: 'clean_up_highlights', targetTabId: this.currentTabId, }) } async clickElement(...args: any[]): Promise { const res = await this.remoteCallDomAction('click_element', args) // @note may cause page navigation, wait for 1 second to ensure the page loading started await new Promise((resolve) => setTimeout(resolve, 1000)) return res } async inputText(...args: any[]): Promise { return this.remoteCallDomAction('input_text', args) } async selectOption(...args: any[]): Promise { return this.remoteCallDomAction('select_option', args) } async scroll(...args: any[]): Promise { return this.remoteCallDomAction('scroll', args) } async scrollHorizontally(...args: any[]): Promise { return this.remoteCallDomAction('scroll_horizontally', args) } // `execute_javascript` is intentionally not implemented: AbortSignal cannot cross context /** @note Managed by content script via storage polling. */ async showMask(): Promise {} /** @note Managed by content script via storage polling. */ async hideMask(): Promise {} /** @note Managed by content script via storage polling. */ dispose(): void {} private async remoteCallDomAction(action: string, payload: any[]): Promise { if (!this.currentTabId) { return { success: false, message: 'RemotePageController not initialized.' } } if (!isContentScriptAllowed(await this.getCurrentUrl())) { return { success: false, message: 'Operation not allowed on this page. Use open_new_tab to navigate to a web page first.', } } return sendMessage({ type: 'PAGE_CONTROL', action: action, targetTabId: this.currentTabId!, payload, }) } } interface DomActionReturn { success: boolean message: string } /** * Check if a URL can run content scripts. */ export function isContentScriptAllowed(url: string | undefined): boolean { if (!url) return false const restrictedPatterns = [ /^chrome:\/\//, /^chrome-extension:\/\//, /^about:/, /^edge:\/\//, /^brave:\/\//, /^opera:\/\//, /^vivaldi:\/\//, /^file:\/\//, /^view-source:/, /^devtools:\/\//, ] return !restrictedPatterns.some((pattern) => pattern.test(url)) }