fix(ext): fix multi-thread logic; extensive logging and error handling
This commit is contained in:
@@ -74,6 +74,11 @@ export class MultiPageAgent extends PageAgentCore {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onBeforeStep: async (agent) => {
|
||||||
|
// make sure the current tab is loaded before the step starts
|
||||||
|
await tabsController.waitUntilTabLoaded(tabsController.currentTabId!)
|
||||||
|
},
|
||||||
|
|
||||||
onDispose: () => {
|
onDispose: () => {
|
||||||
if (heartBeatInterval) {
|
if (heartBeatInterval) {
|
||||||
window.clearInterval(heartBeatInterval)
|
window.clearInterval(heartBeatInterval)
|
||||||
|
|||||||
@@ -8,13 +8,21 @@ export function handlePageControlMessage(
|
|||||||
sender: chrome.runtime.MessageSender,
|
sender: chrome.runtime.MessageSender,
|
||||||
sendResponse: (response: unknown) => void
|
sendResponse: (response: unknown) => void
|
||||||
): true | undefined {
|
): true | undefined {
|
||||||
|
const PREFIX = '[RemotePageController.background]'
|
||||||
|
|
||||||
|
function debug(...messages: any[]) {
|
||||||
|
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
|
||||||
|
}
|
||||||
|
|
||||||
const { action, payload, targetTabId } = message
|
const { action, payload, targetTabId } = message
|
||||||
|
|
||||||
if (action === 'get_my_tab_id') {
|
if (action === 'get_my_tab_id') {
|
||||||
|
debug('get_my_tab_id', sender.tab?.id)
|
||||||
sendResponse({ tabId: sender.tab?.id || null })
|
sendResponse({ tabId: sender.tab?.id || null })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// proxy to content script
|
||||||
chrome.tabs
|
chrome.tabs
|
||||||
.sendMessage(targetTabId, {
|
.sendMessage(targetTabId, {
|
||||||
type: 'PAGE_CONTROL',
|
type: 'PAGE_CONTROL',
|
||||||
@@ -25,6 +33,7 @@ export function handlePageControlMessage(
|
|||||||
sendResponse(result)
|
sendResponse(result)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
console.error(PREFIX, error)
|
||||||
sendResponse({
|
sendResponse({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
|||||||
@@ -12,10 +12,14 @@ export function initPageController() {
|
|||||||
.then((response) => {
|
.then((response) => {
|
||||||
return (response as { tabId: number | null }).tabId
|
return (response as { tabId: number | null }).tabId
|
||||||
})
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[RemotePageController.ContentScript]: Failed to get my tab id', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
function getPC(): PageController {
|
function getPC(): PageController {
|
||||||
if (!pageController) {
|
if (!pageController) {
|
||||||
pageController = new PageController({ enableMask: false })
|
pageController = new PageController({ enableMask: false, viewportExpansion: 400 })
|
||||||
}
|
}
|
||||||
return pageController
|
return pageController
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,31 @@ import type { BrowserState } from '@page-agent/page-controller'
|
|||||||
|
|
||||||
import type { TabsController } from './TabsController'
|
import type { TabsController } from './TabsController'
|
||||||
|
|
||||||
|
const PREFIX = '[RemotePageController]'
|
||||||
|
|
||||||
|
function debug(...messages: any[]) {
|
||||||
|
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMessage(message: {
|
||||||
|
type: 'PAGE_CONTROL'
|
||||||
|
action: string
|
||||||
|
targetTabId: number
|
||||||
|
payload?: any
|
||||||
|
}): Promise<any> {
|
||||||
|
return chrome.runtime.sendMessage(message).catch((error) => {
|
||||||
|
console.error(PREFIX, message.action, error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent side page controller.
|
* Agent side page controller.
|
||||||
* - live in the agent env (extension page or content script)
|
* - live in the agent env (extension page or content script)
|
||||||
* - communicates with remote PageController via sw
|
* - communicates with remote PageController via sw
|
||||||
*/
|
*/
|
||||||
export class RemotePageController {
|
export class RemotePageController {
|
||||||
private tabsController: TabsController
|
tabsController: TabsController
|
||||||
|
|
||||||
constructor(tabsController: TabsController) {
|
constructor(tabsController: TabsController) {
|
||||||
this.tabsController = tabsController
|
this.tabsController = tabsController
|
||||||
@@ -18,46 +36,46 @@ export class RemotePageController {
|
|||||||
return this.tabsController.currentTabId
|
return this.tabsController.currentTabId
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCurrentUrl(): Promise<string> {
|
private async getCurrentUrl(): Promise<string> {
|
||||||
if (!this.currentTabId) return ''
|
if (!this.currentTabId) return ''
|
||||||
const { url } = await this.tabsController.getTabInfo(this.currentTabId)
|
const { url } = await this.tabsController.getTabInfo(this.currentTabId)
|
||||||
return url || ''
|
return url || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCurrentTitle(): Promise<string> {
|
private async getCurrentTitle(): Promise<string> {
|
||||||
if (!this.currentTabId) return ''
|
if (!this.currentTabId) return ''
|
||||||
const { title } = await this.tabsController.getTabInfo(this.currentTabId)
|
const { title } = await this.tabsController.getTabInfo(this.currentTabId)
|
||||||
return title || ''
|
return title || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
get currentTabTitle(): Promise<string> {
|
|
||||||
return this.getCurrentTitle()
|
|
||||||
}
|
|
||||||
|
|
||||||
async getLastUpdateTime(): Promise<number> {
|
async getLastUpdateTime(): Promise<number> {
|
||||||
if (!this.currentTabId) throw new Error('tabsController not initialized.')
|
if (!this.currentTabId) throw new Error('tabsController not initialized.')
|
||||||
|
return sendMessage({
|
||||||
return await chrome.runtime.sendMessage({
|
|
||||||
type: 'PAGE_CONTROL',
|
type: 'PAGE_CONTROL',
|
||||||
action: 'get_last_update_time',
|
action: 'get_last_update_time',
|
||||||
targetTabId: this.currentTabId,
|
targetTabId: this.currentTabId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// getBrowserState
|
|
||||||
async getBrowserState(): Promise<BrowserState> {
|
async getBrowserState(): Promise<BrowserState> {
|
||||||
let browserState = {} as BrowserState
|
if (!this.currentTabId) throw new Error('tabsController not initialized.')
|
||||||
|
|
||||||
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) {
|
let browserState = {} as BrowserState
|
||||||
|
debug('getBrowserState', this.currentTabId)
|
||||||
|
|
||||||
|
const currentUrl = await this.getCurrentUrl()
|
||||||
|
const currentTitle = await this.getCurrentTitle()
|
||||||
|
|
||||||
|
if (!this.currentTabId || !isContentScriptAllowed(currentUrl)) {
|
||||||
browserState = {
|
browserState = {
|
||||||
url: await this.currentTabUrl,
|
url: currentUrl,
|
||||||
title: await this.currentTabTitle,
|
title: currentTitle,
|
||||||
header: '',
|
header: '',
|
||||||
content: '(empty page)',
|
content: '(empty page. either current page is not readable or not loaded yet.)',
|
||||||
footer: '',
|
footer: '',
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
browserState = await chrome.runtime.sendMessage({
|
browserState = await sendMessage({
|
||||||
type: 'PAGE_CONTROL',
|
type: 'PAGE_CONTROL',
|
||||||
action: 'get_browser_state',
|
action: 'get_browser_state',
|
||||||
targetTabId: this.currentTabId,
|
targetTabId: this.currentTabId,
|
||||||
@@ -67,61 +85,58 @@ export class RemotePageController {
|
|||||||
const sum = await this.tabsController.summarizeTabs()
|
const sum = await this.tabsController.summarizeTabs()
|
||||||
browserState.header = sum + '\n\n' + (browserState.header || '')
|
browserState.header = sum + '\n\n' + (browserState.header || '')
|
||||||
|
|
||||||
|
debug('getBrowserState: success', this.currentTabId, browserState)
|
||||||
|
|
||||||
return browserState
|
return browserState
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateTree
|
|
||||||
async updateTree(): Promise<void> {
|
async updateTree(): Promise<void> {
|
||||||
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) {
|
if (!this.currentTabId || !isContentScriptAllowed(await this.getCurrentUrl())) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await chrome.runtime.sendMessage({
|
await sendMessage({
|
||||||
type: 'PAGE_CONTROL',
|
type: 'PAGE_CONTROL',
|
||||||
action: 'update_tree',
|
action: 'update_tree',
|
||||||
targetTabId: this.currentTabId,
|
targetTabId: this.currentTabId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanUpHighlights
|
|
||||||
async cleanUpHighlights(): Promise<void> {
|
async cleanUpHighlights(): Promise<void> {
|
||||||
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) {
|
if (!this.currentTabId || !isContentScriptAllowed(await this.getCurrentUrl())) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await chrome.runtime.sendMessage({
|
await sendMessage({
|
||||||
type: 'PAGE_CONTROL',
|
type: 'PAGE_CONTROL',
|
||||||
action: 'clean_up_highlights',
|
action: 'clean_up_highlights',
|
||||||
targetTabId: this.currentTabId,
|
targetTabId: this.currentTabId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// clickElement
|
|
||||||
async clickElement(...args: any[]): Promise<DomActionReturn> {
|
async clickElement(...args: any[]): Promise<DomActionReturn> {
|
||||||
return this.remoteCallDomAction('click_element', args)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// inputText
|
|
||||||
async inputText(...args: any[]): Promise<DomActionReturn> {
|
async inputText(...args: any[]): Promise<DomActionReturn> {
|
||||||
return this.remoteCallDomAction('input_text', args)
|
return this.remoteCallDomAction('input_text', args)
|
||||||
}
|
}
|
||||||
|
|
||||||
// selectOption
|
|
||||||
async selectOption(...args: any[]): Promise<DomActionReturn> {
|
async selectOption(...args: any[]): Promise<DomActionReturn> {
|
||||||
return this.remoteCallDomAction('select_option', args)
|
return this.remoteCallDomAction('select_option', args)
|
||||||
}
|
}
|
||||||
|
|
||||||
// scroll
|
|
||||||
async scroll(...args: any[]): Promise<DomActionReturn> {
|
async scroll(...args: any[]): Promise<DomActionReturn> {
|
||||||
return this.remoteCallDomAction('scroll', args)
|
return this.remoteCallDomAction('scroll', args)
|
||||||
}
|
}
|
||||||
|
|
||||||
// scrollHorizontally
|
|
||||||
async scrollHorizontally(...args: any[]): Promise<DomActionReturn> {
|
async scrollHorizontally(...args: any[]): Promise<DomActionReturn> {
|
||||||
return this.remoteCallDomAction('scroll_horizontally', args)
|
return this.remoteCallDomAction('scroll_horizontally', args)
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeJavascript
|
|
||||||
async executeJavascript(...args: any[]): Promise<DomActionReturn> {
|
async executeJavascript(...args: any[]): Promise<DomActionReturn> {
|
||||||
return this.remoteCallDomAction('execute_javascript', args)
|
return this.remoteCallDomAction('execute_javascript', args)
|
||||||
}
|
}
|
||||||
@@ -133,35 +148,26 @@ export class RemotePageController {
|
|||||||
/** @note Managed by content script via storage polling. */
|
/** @note Managed by content script via storage polling. */
|
||||||
dispose(): void {}
|
dispose(): void {}
|
||||||
|
|
||||||
private async preCheck() {
|
|
||||||
if (!this.currentTabId) {
|
|
||||||
return 'RemotePageController not initialized.'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isContentScriptAllowed(await this.currentTabUrl)) {
|
|
||||||
return 'Operation not allowed on this page. Use open_new_tab to navigate to a web page first.'
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private async remoteCallDomAction(action: string, payload: any[]): Promise<DomActionReturn> {
|
private async remoteCallDomAction(action: string, payload: any[]): Promise<DomActionReturn> {
|
||||||
const preCheckError = await this.preCheck()
|
if (!this.currentTabId) {
|
||||||
if (preCheckError) {
|
return { success: false, message: 'RemotePageController not initialized.' }
|
||||||
return { success: false, message: preCheckError }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await chrome.runtime.sendMessage({
|
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',
|
type: 'PAGE_CONTROL',
|
||||||
action: action,
|
action: action,
|
||||||
targetTabId: this.currentTabId!,
|
targetTabId: this.currentTabId!,
|
||||||
payload,
|
payload,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private get currentTabUrl(): Promise<string> {
|
|
||||||
return this.getCurrentUrl()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DomActionReturn {
|
interface DomActionReturn {
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
*/
|
*/
|
||||||
import type { TabAction } from './TabsController'
|
import type { TabAction } from './TabsController'
|
||||||
|
|
||||||
|
const PREFIX = '[TabsController.background]'
|
||||||
|
|
||||||
|
function debug(...messages: any[]) {
|
||||||
|
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
|
||||||
|
}
|
||||||
|
|
||||||
export function handleTabControlMessage(
|
export function handleTabControlMessage(
|
||||||
message: { type: 'TAB_CONTROL'; action: TabAction; payload: any },
|
message: { type: 'TAB_CONTROL'; action: TabAction; payload: any },
|
||||||
sender: chrome.runtime.MessageSender,
|
sender: chrome.runtime.MessageSender,
|
||||||
@@ -12,10 +18,12 @@ export function handleTabControlMessage(
|
|||||||
|
|
||||||
switch (action as TabAction) {
|
switch (action as TabAction) {
|
||||||
case 'get_active_tab': {
|
case 'get_active_tab': {
|
||||||
|
debug('get_active_tab')
|
||||||
chrome.tabs
|
chrome.tabs
|
||||||
.query({ active: true, currentWindow: true })
|
.query({ active: true, currentWindow: true })
|
||||||
.then((tabs) => {
|
.then((tabs) => {
|
||||||
const tabId = tabs.length > 0 ? tabs[0].id || null : null
|
const tabId = tabs.length > 0 ? tabs[0].id || null : null
|
||||||
|
debug('get_active_tab: success', tabId)
|
||||||
sendResponse({ success: true, tabId })
|
sendResponse({ success: true, tabId })
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -25,11 +33,12 @@ export function handleTabControlMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'get_tab_info': {
|
case 'get_tab_info': {
|
||||||
|
debug('get_tab_info', payload)
|
||||||
chrome.tabs
|
chrome.tabs
|
||||||
.get(payload.tabId)
|
.get(payload.tabId)
|
||||||
.then((tab) => {
|
.then((tab) => {
|
||||||
const result = { title: tab.title || '', url: tab.url || '' }
|
debug('get_tab_info: success', tab)
|
||||||
sendResponse(result)
|
sendResponse(tab)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||||
@@ -38,10 +47,11 @@ export function handleTabControlMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'open_new_tab': {
|
case 'open_new_tab': {
|
||||||
|
debug('open_new_tab', payload)
|
||||||
chrome.tabs
|
chrome.tabs
|
||||||
.create({ url: payload.url, active: false })
|
.create({ url: payload.url, active: false })
|
||||||
.then((newTab) => {
|
.then((newTab) => {
|
||||||
// @todo: wait for tab to be fully loaded
|
debug('open_new_tab: success', newTab)
|
||||||
sendResponse({ success: true, tabId: newTab.id, windowId: newTab.windowId })
|
sendResponse({ success: true, tabId: newTab.id, windowId: newTab.windowId })
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -51,20 +61,22 @@ export function handleTabControlMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'create_tab_group': {
|
case 'create_tab_group': {
|
||||||
|
debug('create_tab_group', payload)
|
||||||
chrome.tabs
|
chrome.tabs
|
||||||
.group({ tabIds: payload.tabIds, createProperties: { windowId: payload.windowId } })
|
.group({ tabIds: payload.tabIds, createProperties: { windowId: payload.windowId } })
|
||||||
.then((groupId) => {
|
.then((groupId) => {
|
||||||
console.log('Created tab group', groupId)
|
debug('create_tab_group: success', groupId)
|
||||||
sendResponse({ success: true, groupId })
|
sendResponse({ success: true, groupId })
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to create tab group', error)
|
console.error(PREFIX, 'Failed to create tab group', error)
|
||||||
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||||
})
|
})
|
||||||
return true // async response
|
return true // async response
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'update_tab_group': {
|
case 'update_tab_group': {
|
||||||
|
debug('update_tab_group', payload)
|
||||||
chrome.tabGroups
|
chrome.tabGroups
|
||||||
.update(payload.groupId, payload.properties)
|
.update(payload.groupId, payload.properties)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -77,6 +89,7 @@ export function handleTabControlMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'add_tab_to_group': {
|
case 'add_tab_to_group': {
|
||||||
|
debug('add_tab_to_group', payload)
|
||||||
chrome.tabs
|
chrome.tabs
|
||||||
.group({ tabIds: payload.tabId, groupId: payload.groupId })
|
.group({ tabIds: payload.tabId, groupId: payload.groupId })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -89,6 +102,7 @@ export function handleTabControlMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'close_tab': {
|
case 'close_tab': {
|
||||||
|
debug('close_tab', payload)
|
||||||
chrome.tabs
|
chrome.tabs
|
||||||
.remove(payload.tabId)
|
.remove(payload.tabId)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -107,17 +121,40 @@ export function handleTabControlMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setupTabChangeEvents() {
|
export function setupTabChangeEvents() {
|
||||||
|
console.log('[TabsController.background] setupTabChangeEvents')
|
||||||
|
|
||||||
chrome.tabs.onCreated.addListener((tab) => {
|
chrome.tabs.onCreated.addListener((tab) => {
|
||||||
console.debug('[Background] Tab created', tab)
|
debug('onCreated', tab)
|
||||||
chrome.runtime.sendMessage({ type: 'TAB_CHANGE', action: 'created', payload: { tab } })
|
chrome.runtime
|
||||||
|
.sendMessage({ type: 'TAB_CHANGE', action: 'created', payload: { tab } })
|
||||||
|
.catch((error) => {
|
||||||
|
debug('onCreated error:', error)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
|
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
|
||||||
console.debug('[Background] Tab removed', tabId, removeInfo)
|
debug('onRemoved', tabId, removeInfo)
|
||||||
chrome.runtime.sendMessage({
|
chrome.runtime
|
||||||
type: 'TAB_CHANGE',
|
.sendMessage({
|
||||||
action: 'removed',
|
type: 'TAB_CHANGE',
|
||||||
payload: { tabId, removeInfo },
|
action: 'removed',
|
||||||
})
|
payload: { tabId, removeInfo },
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
debug('onRemoved error:', error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
debug('onUpdated error:', error)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,20 @@
|
|||||||
|
const PREFIX = '[TabsController]'
|
||||||
|
|
||||||
|
function debug(...messages: any[]) {
|
||||||
|
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMessage(message: {
|
||||||
|
type: 'TAB_CONTROL'
|
||||||
|
action: TabAction
|
||||||
|
payload?: any
|
||||||
|
}): Promise<any> {
|
||||||
|
return chrome.runtime.sendMessage(message).catch((error) => {
|
||||||
|
console.error(PREFIX, message.action, error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controller for managing browser tabs.
|
* Controller for managing browser tabs.
|
||||||
* - live in the agent env (extension page or content script)
|
* - live in the agent env (extension page or content script)
|
||||||
@@ -13,6 +30,8 @@ export class TabsController extends EventTarget {
|
|||||||
private windowId: number | null = null
|
private windowId: number | null = null
|
||||||
|
|
||||||
async init(task: string, includeInitialTab: boolean = true) {
|
async init(task: string, includeInitialTab: boolean = true) {
|
||||||
|
debug('init', task, includeInitialTab)
|
||||||
|
|
||||||
this.task = task
|
this.task = task
|
||||||
this.tabs = []
|
this.tabs = []
|
||||||
this.currentTabId = null
|
this.currentTabId = null
|
||||||
@@ -20,7 +39,7 @@ export class TabsController extends EventTarget {
|
|||||||
this.initialTabId = null
|
this.initialTabId = null
|
||||||
this.windowId = null
|
this.windowId = null
|
||||||
|
|
||||||
const result = await chrome.runtime.sendMessage({
|
const result = await sendMessage({
|
||||||
type: 'TAB_CONTROL',
|
type: 'TAB_CONTROL',
|
||||||
action: 'get_active_tab',
|
action: 'get_active_tab',
|
||||||
})
|
})
|
||||||
@@ -33,9 +52,20 @@ export class TabsController extends EventTarget {
|
|||||||
|
|
||||||
if (includeInitialTab) {
|
if (includeInitialTab) {
|
||||||
this.currentTabId = this.initialTabId
|
this.currentTabId = this.initialTabId
|
||||||
|
|
||||||
|
// update tab status immediately
|
||||||
|
const info = await sendMessage({
|
||||||
|
type: 'TAB_CONTROL',
|
||||||
|
action: 'get_tab_info',
|
||||||
|
payload: { tabId: this.initialTabId },
|
||||||
|
})
|
||||||
|
|
||||||
this.tabs.push({
|
this.tabs.push({
|
||||||
id: result.tabId,
|
id: result.tabId,
|
||||||
isInitial: true,
|
isInitial: true,
|
||||||
|
url: info.url,
|
||||||
|
title: info.title,
|
||||||
|
status: info.status,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +100,14 @@ export class TabsController extends EventTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,8 +118,10 @@ export class TabsController extends EventTarget {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async openNewTab(url: string): Promise<{ success: boolean; tabId: number; message: string }> {
|
async openNewTab(url: string): Promise<string> {
|
||||||
const result = await chrome.runtime.sendMessage({
|
debug('openNewTab', url)
|
||||||
|
|
||||||
|
const result = await sendMessage({
|
||||||
type: 'TAB_CONTROL',
|
type: 'TAB_CONTROL',
|
||||||
action: 'open_new_tab',
|
action: 'open_new_tab',
|
||||||
payload: { url },
|
payload: { url },
|
||||||
@@ -104,7 +144,7 @@ export class TabsController extends EventTarget {
|
|||||||
await this.switchToTab(tabId)
|
await this.switchToTab(tabId)
|
||||||
|
|
||||||
if (!this.tabGroupId) {
|
if (!this.tabGroupId) {
|
||||||
const result = await chrome.runtime.sendMessage({
|
const result = await sendMessage({
|
||||||
type: 'TAB_CONTROL',
|
type: 'TAB_CONTROL',
|
||||||
action: 'create_tab_group',
|
action: 'create_tab_group',
|
||||||
payload: { tabIds: [tabId], windowId: this.windowId },
|
payload: { tabIds: [tabId], windowId: this.windowId },
|
||||||
@@ -118,7 +158,7 @@ export class TabsController extends EventTarget {
|
|||||||
|
|
||||||
this.tabGroupId = groupId
|
this.tabGroupId = groupId
|
||||||
|
|
||||||
await chrome.runtime.sendMessage({
|
await sendMessage({
|
||||||
type: 'TAB_CONTROL',
|
type: 'TAB_CONTROL',
|
||||||
action: 'update_tab_group',
|
action: 'update_tab_group',
|
||||||
payload: {
|
payload: {
|
||||||
@@ -131,57 +171,43 @@ export class TabsController extends EventTarget {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
await chrome.runtime.sendMessage({
|
await sendMessage({
|
||||||
type: 'TAB_CONTROL',
|
type: 'TAB_CONTROL',
|
||||||
action: 'add_tab_to_group',
|
action: 'add_tab_to_group',
|
||||||
payload: { tabId: result.tabId, groupId: this.tabGroupId },
|
payload: { tabId: result.tabId, groupId: this.tabGroupId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// wait for the new tab to be fully loaded
|
await this.waitUntilTabLoaded(tabId)
|
||||||
// @todo
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
||||||
|
|
||||||
return {
|
return `✅ Opened new tab ID ${tabId} with URL ${url}`
|
||||||
success: true,
|
|
||||||
tabId,
|
|
||||||
message: `Opened new tab ID ${tabId} with URL ${url}`,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async switchToTab(tabId: number): Promise<{ success: boolean; message: string }> {
|
async switchToTab(tabId: number): Promise<string> {
|
||||||
|
debug('switchToTab', tabId)
|
||||||
|
|
||||||
const targetTab = this.tabs.find((t) => t.id === tabId)
|
const targetTab = this.tabs.find((t) => t.id === tabId)
|
||||||
if (!targetTab) {
|
if (!targetTab) {
|
||||||
return {
|
throw new Error(`Tab ID ${tabId} not found in tab list.`)
|
||||||
success: false,
|
|
||||||
message: `Tab ID ${tabId} not found in tab list.`,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.updateCurrentTabId(tabId)
|
await this.updateCurrentTabId(tabId)
|
||||||
|
|
||||||
return {
|
return `✅ Switched to tab ID ${tabId}.`
|
||||||
success: true,
|
|
||||||
message: `Switched to tab ID ${tabId}.`,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async closeTab(tabId: number): Promise<{ success: boolean; message: string }> {
|
async closeTab(tabId: number): Promise<string> {
|
||||||
|
debug('closeTab', tabId)
|
||||||
|
|
||||||
const targetTab = this.tabs.find((t) => t.id === tabId)
|
const targetTab = this.tabs.find((t) => t.id === tabId)
|
||||||
if (!targetTab) {
|
if (!targetTab) {
|
||||||
return {
|
throw new Error(`Tab ID ${tabId} not found in tab list.`)
|
||||||
success: false,
|
|
||||||
message: `Tab ID ${tabId} not found in tab list.`,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (targetTab.isInitial) {
|
if (targetTab.isInitial) {
|
||||||
return {
|
throw new Error(`Cannot close the initial tab ID ${tabId}.`)
|
||||||
success: false,
|
|
||||||
message: `Cannot close the initial tab ID ${tabId}.`,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await chrome.runtime.sendMessage({
|
const result = await sendMessage({
|
||||||
type: 'TAB_CONTROL',
|
type: 'TAB_CONTROL',
|
||||||
action: 'close_tab',
|
action: 'close_tab',
|
||||||
payload: { tabId },
|
payload: { tabId },
|
||||||
@@ -198,29 +224,39 @@ export class TabsController extends EventTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return `✅ Closed tab ID ${tabId}.`
|
||||||
success: true,
|
|
||||||
message: `Closed tab ID ${tabId}.`,
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return {
|
throw new Error(`Failed to close tab ID ${tabId}: ${result.error}`)
|
||||||
success: false,
|
|
||||||
message: `Failed to close tab ID ${tabId}: ${result.error}`,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateCurrentTabId(tabId: number | null) {
|
async updateCurrentTabId(tabId: number | null) {
|
||||||
|
debug('updateCurrentTabId', tabId)
|
||||||
|
|
||||||
this.currentTabId = tabId
|
this.currentTabId = tabId
|
||||||
await chrome.storage.local.set({ currentTabId: tabId })
|
await chrome.storage.local.set({ currentTabId: tabId })
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTabInfo(tabId: number): Promise<{ title: string; url: string }> {
|
async getTabInfo(tabId: number): Promise<{ title: string; url: string }> {
|
||||||
const result = await chrome.runtime.sendMessage({
|
// use cached tab info if available
|
||||||
|
const tabMeta = this.tabs.find((t) => t.id === tabId)
|
||||||
|
if (tabMeta && tabMeta.url && tabMeta.title) {
|
||||||
|
return { title: tabMeta.title, url: tabMeta.url }
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise, pull the latest tab info from the background script
|
||||||
|
debug('getTabInfo: pulling from background script', tabId)
|
||||||
|
const result = await sendMessage({
|
||||||
type: 'TAB_CONTROL',
|
type: 'TAB_CONTROL',
|
||||||
action: 'get_tab_info',
|
action: 'get_tab_info',
|
||||||
payload: { tabId },
|
payload: { tabId },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (tabMeta) {
|
||||||
|
tabMeta.url = result.url
|
||||||
|
tabMeta.title = result.title
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +275,17 @@ export class TabsController extends EventTarget {
|
|||||||
return summaries.join('\n')
|
return summaries.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async waitUntilTabLoaded(tabId: number): Promise<void> {
|
||||||
|
const tab = this.tabs.find((t) => t.id === tabId)
|
||||||
|
if (!tab) throw new Error(`Tab ID ${tabId} not found in tab list.`)
|
||||||
|
|
||||||
|
if (tab.status === 'unloaded') throw new Error(`Tab ID ${tabId} is unloaded.`)
|
||||||
|
if (tab.status === 'complete') return
|
||||||
|
|
||||||
|
debug('waitUntilTabLoaded', tabId)
|
||||||
|
await waitUntil(() => tab.status === 'complete', 4_000)
|
||||||
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
this.dispatchEvent(new Event('dispose'))
|
this.dispatchEvent(new Event('dispose'))
|
||||||
}
|
}
|
||||||
@@ -257,6 +304,9 @@ export type TabAction =
|
|||||||
interface TabMeta {
|
interface TabMeta {
|
||||||
id: number
|
id: number
|
||||||
isInitial: boolean
|
isInitial: boolean
|
||||||
|
url?: string
|
||||||
|
title?: string
|
||||||
|
status?: 'loading' | 'unloaded' | 'complete'
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAB_GROUP_COLORS = [
|
const TAB_GROUP_COLORS = [
|
||||||
@@ -275,3 +325,33 @@ type TabGroupColor = (typeof TAB_GROUP_COLORS)[number]
|
|||||||
function randomColor(): TabGroupColor {
|
function randomColor(): TabGroupColor {
|
||||||
return TAB_GROUP_COLORS[Math.floor(Math.random() * TAB_GROUP_COLORS.length)]
|
return TAB_GROUP_COLORS[Math.floor(Math.random() * TAB_GROUP_COLORS.length)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until condition becomes true
|
||||||
|
* @returns Returns when condition becomes true, throws otherwise
|
||||||
|
* @param timeoutMS Timeout in milliseconds, default 1 minutes, throws error on timeout
|
||||||
|
* @param error Error object to reject on timeout. If not provided, will resolve with false
|
||||||
|
*/
|
||||||
|
export async function waitUntil(
|
||||||
|
check: () => boolean | Promise<boolean>,
|
||||||
|
timeoutMS = 60_000,
|
||||||
|
error?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (await check()) return true
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const start = Date.now()
|
||||||
|
const poll = async () => {
|
||||||
|
if (await check()) return resolve(true)
|
||||||
|
if (Date.now() - start > timeoutMS) {
|
||||||
|
if (error) {
|
||||||
|
return reject(new Error(error))
|
||||||
|
} else {
|
||||||
|
return resolve(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(poll, 100)
|
||||||
|
}
|
||||||
|
setTimeout(poll, 100)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,8 +31,11 @@ export function createTabTools(tabsController: TabsController): Record<string, T
|
|||||||
}),
|
}),
|
||||||
execute: async (input: unknown) => {
|
execute: async (input: unknown) => {
|
||||||
const { url } = input as { url: string }
|
const { url } = input as { url: string }
|
||||||
const result = await tabsController.openNewTab(url)
|
try {
|
||||||
return result.message
|
return await tabsController.openNewTab(url)
|
||||||
|
} catch (error) {
|
||||||
|
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -44,7 +47,11 @@ export function createTabTools(tabsController: TabsController): Record<string, T
|
|||||||
}),
|
}),
|
||||||
execute: async (input: unknown) => {
|
execute: async (input: unknown) => {
|
||||||
const { tab_id } = input as { tab_id: number }
|
const { tab_id } = input as { tab_id: number }
|
||||||
return (await tabsController.switchToTab(tab_id)).message
|
try {
|
||||||
|
return await tabsController.switchToTab(tab_id)
|
||||||
|
} catch (error) {
|
||||||
|
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -56,7 +63,11 @@ export function createTabTools(tabsController: TabsController): Record<string, T
|
|||||||
}),
|
}),
|
||||||
execute: async (input: unknown) => {
|
execute: async (input: unknown) => {
|
||||||
const { tab_id } = input as { tab_id: number }
|
const { tab_id } = input as { tab_id: number }
|
||||||
return (await tabsController.closeTab(tab_id)).message
|
try {
|
||||||
|
return await tabsController.closeTab(tab_id)
|
||||||
|
} catch (error) {
|
||||||
|
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { handleTabControlMessage, setupTabChangeEvents } from '@/agent/TabsContr
|
|||||||
export default defineBackground(() => {
|
export default defineBackground(() => {
|
||||||
console.log('[Background] Service Worker started')
|
console.log('[Background] Service Worker started')
|
||||||
|
|
||||||
|
// tab change events
|
||||||
|
|
||||||
|
setupTabChangeEvents()
|
||||||
|
|
||||||
// generate user auth token
|
// generate user auth token
|
||||||
|
|
||||||
chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => {
|
chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => {
|
||||||
@@ -13,10 +17,6 @@ export default defineBackground(() => {
|
|||||||
chrome.storage.local.set({ PageAgentExtUserAuthToken: userAuthToken })
|
chrome.storage.local.set({ PageAgentExtUserAuthToken: userAuthToken })
|
||||||
})
|
})
|
||||||
|
|
||||||
// setup
|
|
||||||
|
|
||||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
|
|
||||||
|
|
||||||
// message proxy
|
// message proxy
|
||||||
|
|
||||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse): true | undefined => {
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse): true | undefined => {
|
||||||
@@ -30,7 +30,7 @@ export default defineBackground(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// tab change events
|
// setup
|
||||||
|
|
||||||
setupTabChangeEvents()
|
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const DEBUG_PREFIX = '[Content]'
|
|||||||
|
|
||||||
export default defineContentScript({
|
export default defineContentScript({
|
||||||
matches: ['<all_urls>'],
|
matches: ['<all_urls>'],
|
||||||
runAt: 'document_idle',
|
runAt: 'document_end',
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
console.debug(`${DEBUG_PREFIX} Loaded on ${window.location.href}`)
|
console.debug(`${DEBUG_PREFIX} Loaded on ${window.location.href}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user