fix: lost control of tab when window inactive

This commit is contained in:
Simon
2026-01-20 19:03:15 +08:00
parent 37332cbb9d
commit 1a0c533cb4
2 changed files with 121 additions and 103 deletions

View File

@@ -11,7 +11,7 @@ import type {
ScrollHorizontallyOptions,
ScrollOptions,
} from '../messaging/protocol'
import { rpcClient } from '../messaging/rpc'
import { type RPCClient, createRPCClient } from '../messaging/rpc'
/**
* RemotePageController is a proxy that implements the PageController interface.
@@ -21,27 +21,39 @@ import { rpcClient } from '../messaging/rpc'
* though events in the remote context are not currently bridged.
*/
export class RemotePageController extends EventTarget {
private rpc: RPCClient
constructor() {
super()
// Capture the active tab ID at construction time to avoid issues when tab loses focus
const tabIdPromise = chrome.tabs.query({ active: true, currentWindow: true }).then(([tab]) => {
if (!tab?.id) throw new Error('No active tab found')
return tab.id
})
this.rpc = createRPCClient(tabIdPromise)
}
// ======= State Queries =======
/**
* Get current page URL
*/
async getCurrentUrl(): Promise<string> {
return rpcClient.getCurrentUrl()
return this.rpc.getCurrentUrl()
}
/**
* Get last tree update timestamp
*/
async getLastUpdateTime(): Promise<number> {
return rpcClient.getLastUpdateTime()
return this.rpc.getLastUpdateTime()
}
/**
* Get structured browser state for LLM consumption.
*/
async getBrowserState(): Promise<BrowserState> {
return rpcClient.getBrowserState()
return this.rpc.getBrowserState()
}
// ======= DOM Tree Operations =======
@@ -50,14 +62,14 @@ export class RemotePageController extends EventTarget {
* Update DOM tree, returns simplified HTML for LLM.
*/
async updateTree(): Promise<string> {
return rpcClient.updateTree()
return this.rpc.updateTree()
}
/**
* Clean up all element highlights
*/
async cleanUpHighlights(): Promise<void> {
return rpcClient.cleanUpHighlights()
return this.rpc.cleanUpHighlights()
}
// ======= Element Actions =======
@@ -66,42 +78,42 @@ export class RemotePageController extends EventTarget {
* Click element by index
*/
async clickElement(index: number): Promise<ActionResult> {
return rpcClient.clickElement(index)
return this.rpc.clickElement(index)
}
/**
* Input text into element by index
*/
async inputText(index: number, text: string): Promise<ActionResult> {
return rpcClient.inputText(index, text)
return this.rpc.inputText(index, text)
}
/**
* Select dropdown option by index and option text
*/
async selectOption(index: number, optionText: string): Promise<ActionResult> {
return rpcClient.selectOption(index, optionText)
return this.rpc.selectOption(index, optionText)
}
/**
* Scroll vertically
*/
async scroll(options: ScrollOptions): Promise<ActionResult> {
return rpcClient.scroll(options)
return this.rpc.scroll(options)
}
/**
* Scroll horizontally
*/
async scrollHorizontally(options: ScrollHorizontallyOptions): Promise<ActionResult> {
return rpcClient.scrollHorizontally(options)
return this.rpc.scrollHorizontally(options)
}
/**
* Execute arbitrary JavaScript on the page
*/
async executeJavascript(script: string): Promise<ActionResult> {
return rpcClient.executeJavascript(script)
return this.rpc.executeJavascript(script)
}
// ======= Mask Operations =======
@@ -110,21 +122,21 @@ export class RemotePageController extends EventTarget {
* Show the visual mask overlay.
*/
async showMask(): Promise<void> {
return rpcClient.showMask()
return this.rpc.showMask()
}
/**
* Hide the visual mask overlay.
*/
async hideMask(): Promise<void> {
return rpcClient.hideMask()
return this.rpc.hideMask()
}
/**
* Dispose and clean up resources
*/
dispose(): void {
rpcClient.dispose().catch(() => {
this.rpc.dispose().catch(() => {
// Ignore errors on dispose
})
}

View File

@@ -13,96 +13,102 @@ import type {
} from './protocol'
/**
* Get the active tab ID for the current sidepanel context.
* In MV3, we need to explicitly target the tab.
* Create an RPC client bound to a specific tab.
* The tabId is captured at creation time to ensure messages are sent to the correct tab
* even if the user switches tabs or the page loses focus.
*/
async function getActiveTabId(): Promise<number> {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
if (!tab?.id) {
throw new Error('No active tab found')
export function createRPCClient(tabIdPromise: Promise<number>): RPCClient {
return {
// State queries
async getCurrentUrl(): Promise<string> {
const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:getCurrentUrl', undefined, tabId)
},
async getLastUpdateTime(): Promise<number> {
const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:getLastUpdateTime', undefined, tabId)
},
async getBrowserState(): Promise<BrowserState> {
const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:getBrowserState', undefined, tabId)
},
// DOM operations
async updateTree(): Promise<string> {
const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:updateTree', undefined, tabId)
},
async cleanUpHighlights(): Promise<void> {
const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:cleanUpHighlights', undefined, tabId)
},
// Element actions
async clickElement(index: number): Promise<ActionResult> {
const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:clickElement', index, tabId)
},
async inputText(index: number, text: string): Promise<ActionResult> {
const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:inputText', { index, text }, tabId)
},
async selectOption(index: number, optionText: string): Promise<ActionResult> {
const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:selectOption', { index, optionText }, tabId)
},
async scroll(options: ScrollOptions): Promise<ActionResult> {
const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:scroll', options, tabId)
},
async scrollHorizontally(options: ScrollHorizontallyOptions): Promise<ActionResult> {
const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:scrollHorizontally', options, tabId)
},
async executeJavascript(script: string): Promise<ActionResult> {
const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:executeJavascript', script, tabId)
},
// Mask operations
async showMask(): Promise<void> {
const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:showMask', undefined, tabId)
},
async hideMask(): Promise<void> {
const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:hideMask', undefined, tabId)
},
// Lifecycle
async dispose(): Promise<void> {
const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:dispose', undefined, tabId)
},
}
return tab.id
}
/**
* RPC client for calling PageController methods in ContentScript.
* Each method sends a message and waits for the response.
*/
export const rpcClient = {
// State queries
async getCurrentUrl(): Promise<string> {
const tabId = await getActiveTabId()
return pageControllerRPC.sendMessage('rpc:getCurrentUrl', undefined, tabId)
},
async getLastUpdateTime(): Promise<number> {
const tabId = await getActiveTabId()
return pageControllerRPC.sendMessage('rpc:getLastUpdateTime', undefined, tabId)
},
async getBrowserState(): Promise<BrowserState> {
const tabId = await getActiveTabId()
return pageControllerRPC.sendMessage('rpc:getBrowserState', undefined, tabId)
},
// DOM operations
async updateTree(): Promise<string> {
const tabId = await getActiveTabId()
return pageControllerRPC.sendMessage('rpc:updateTree', undefined, tabId)
},
async cleanUpHighlights(): Promise<void> {
const tabId = await getActiveTabId()
return pageControllerRPC.sendMessage('rpc:cleanUpHighlights', undefined, tabId)
},
// Element actions
async clickElement(index: number): Promise<ActionResult> {
const tabId = await getActiveTabId()
return pageControllerRPC.sendMessage('rpc:clickElement', index, tabId)
},
async inputText(index: number, text: string): Promise<ActionResult> {
const tabId = await getActiveTabId()
return pageControllerRPC.sendMessage('rpc:inputText', { index, text }, tabId)
},
async selectOption(index: number, optionText: string): Promise<ActionResult> {
const tabId = await getActiveTabId()
return pageControllerRPC.sendMessage('rpc:selectOption', { index, optionText }, tabId)
},
async scroll(options: ScrollOptions): Promise<ActionResult> {
const tabId = await getActiveTabId()
return pageControllerRPC.sendMessage('rpc:scroll', options, tabId)
},
async scrollHorizontally(options: ScrollHorizontallyOptions): Promise<ActionResult> {
const tabId = await getActiveTabId()
return pageControllerRPC.sendMessage('rpc:scrollHorizontally', options, tabId)
},
async executeJavascript(script: string): Promise<ActionResult> {
const tabId = await getActiveTabId()
return pageControllerRPC.sendMessage('rpc:executeJavascript', script, tabId)
},
// Mask operations
async showMask(): Promise<void> {
const tabId = await getActiveTabId()
return pageControllerRPC.sendMessage('rpc:showMask', undefined, tabId)
},
async hideMask(): Promise<void> {
const tabId = await getActiveTabId()
return pageControllerRPC.sendMessage('rpc:hideMask', undefined, tabId)
},
// Lifecycle
async dispose(): Promise<void> {
const tabId = await getActiveTabId()
return pageControllerRPC.sendMessage('rpc:dispose', undefined, tabId)
},
export interface RPCClient {
getCurrentUrl(): Promise<string>
getLastUpdateTime(): Promise<number>
getBrowserState(): Promise<BrowserState>
updateTree(): Promise<string>
cleanUpHighlights(): Promise<void>
clickElement(index: number): Promise<ActionResult>
inputText(index: number, text: string): Promise<ActionResult>
selectOption(index: number, optionText: string): Promise<ActionResult>
scroll(options: ScrollOptions): Promise<ActionResult>
scrollHorizontally(options: ScrollHorizontallyOptions): Promise<ActionResult>
executeJavascript(script: string): Promise<ActionResult>
showMask(): Promise<void>
hideMask(): Promise<void>
dispose(): Promise<void>
}
export type RPCClient = typeof rpcClient