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, ScrollHorizontallyOptions,
ScrollOptions, ScrollOptions,
} from '../messaging/protocol' } from '../messaging/protocol'
import { rpcClient } from '../messaging/rpc' import { type RPCClient, createRPCClient } from '../messaging/rpc'
/** /**
* RemotePageController is a proxy that implements the PageController interface. * 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. * though events in the remote context are not currently bridged.
*/ */
export class RemotePageController extends EventTarget { 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 ======= // ======= State Queries =======
/** /**
* Get current page URL * Get current page URL
*/ */
async getCurrentUrl(): Promise<string> { async getCurrentUrl(): Promise<string> {
return rpcClient.getCurrentUrl() return this.rpc.getCurrentUrl()
} }
/** /**
* Get last tree update timestamp * Get last tree update timestamp
*/ */
async getLastUpdateTime(): Promise<number> { async getLastUpdateTime(): Promise<number> {
return rpcClient.getLastUpdateTime() return this.rpc.getLastUpdateTime()
} }
/** /**
* Get structured browser state for LLM consumption. * Get structured browser state for LLM consumption.
*/ */
async getBrowserState(): Promise<BrowserState> { async getBrowserState(): Promise<BrowserState> {
return rpcClient.getBrowserState() return this.rpc.getBrowserState()
} }
// ======= DOM Tree Operations ======= // ======= DOM Tree Operations =======
@@ -50,14 +62,14 @@ export class RemotePageController extends EventTarget {
* Update DOM tree, returns simplified HTML for LLM. * Update DOM tree, returns simplified HTML for LLM.
*/ */
async updateTree(): Promise<string> { async updateTree(): Promise<string> {
return rpcClient.updateTree() return this.rpc.updateTree()
} }
/** /**
* Clean up all element highlights * Clean up all element highlights
*/ */
async cleanUpHighlights(): Promise<void> { async cleanUpHighlights(): Promise<void> {
return rpcClient.cleanUpHighlights() return this.rpc.cleanUpHighlights()
} }
// ======= Element Actions ======= // ======= Element Actions =======
@@ -66,42 +78,42 @@ export class RemotePageController extends EventTarget {
* Click element by index * Click element by index
*/ */
async clickElement(index: number): Promise<ActionResult> { async clickElement(index: number): Promise<ActionResult> {
return rpcClient.clickElement(index) return this.rpc.clickElement(index)
} }
/** /**
* Input text into element by index * Input text into element by index
*/ */
async inputText(index: number, text: string): Promise<ActionResult> { 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 * Select dropdown option by index and option text
*/ */
async selectOption(index: number, optionText: string): Promise<ActionResult> { async selectOption(index: number, optionText: string): Promise<ActionResult> {
return rpcClient.selectOption(index, optionText) return this.rpc.selectOption(index, optionText)
} }
/** /**
* Scroll vertically * Scroll vertically
*/ */
async scroll(options: ScrollOptions): Promise<ActionResult> { async scroll(options: ScrollOptions): Promise<ActionResult> {
return rpcClient.scroll(options) return this.rpc.scroll(options)
} }
/** /**
* Scroll horizontally * Scroll horizontally
*/ */
async scrollHorizontally(options: ScrollHorizontallyOptions): Promise<ActionResult> { async scrollHorizontally(options: ScrollHorizontallyOptions): Promise<ActionResult> {
return rpcClient.scrollHorizontally(options) return this.rpc.scrollHorizontally(options)
} }
/** /**
* Execute arbitrary JavaScript on the page * Execute arbitrary JavaScript on the page
*/ */
async executeJavascript(script: string): Promise<ActionResult> { async executeJavascript(script: string): Promise<ActionResult> {
return rpcClient.executeJavascript(script) return this.rpc.executeJavascript(script)
} }
// ======= Mask Operations ======= // ======= Mask Operations =======
@@ -110,21 +122,21 @@ export class RemotePageController extends EventTarget {
* Show the visual mask overlay. * Show the visual mask overlay.
*/ */
async showMask(): Promise<void> { async showMask(): Promise<void> {
return rpcClient.showMask() return this.rpc.showMask()
} }
/** /**
* Hide the visual mask overlay. * Hide the visual mask overlay.
*/ */
async hideMask(): Promise<void> { async hideMask(): Promise<void> {
return rpcClient.hideMask() return this.rpc.hideMask()
} }
/** /**
* Dispose and clean up resources * Dispose and clean up resources
*/ */
dispose(): void { dispose(): void {
rpcClient.dispose().catch(() => { this.rpc.dispose().catch(() => {
// Ignore errors on dispose // Ignore errors on dispose
}) })
} }

View File

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