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) {
throw new Error('No active tab found')
}
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 // State queries
async getCurrentUrl(): Promise<string> { async getCurrentUrl(): Promise<string> {
const tabId = await getActiveTabId() const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:getCurrentUrl', undefined, tabId) return pageControllerRPC.sendMessage('rpc:getCurrentUrl', undefined, tabId)
}, },
async getLastUpdateTime(): Promise<number> { async getLastUpdateTime(): Promise<number> {
const tabId = await getActiveTabId() const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:getLastUpdateTime', undefined, tabId) return pageControllerRPC.sendMessage('rpc:getLastUpdateTime', undefined, tabId)
}, },
async getBrowserState(): Promise<BrowserState> { async getBrowserState(): Promise<BrowserState> {
const tabId = await getActiveTabId() const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:getBrowserState', undefined, tabId) return pageControllerRPC.sendMessage('rpc:getBrowserState', undefined, tabId)
}, },
// DOM operations // DOM operations
async updateTree(): Promise<string> { async updateTree(): Promise<string> {
const tabId = await getActiveTabId() const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:updateTree', undefined, tabId) return pageControllerRPC.sendMessage('rpc:updateTree', undefined, tabId)
}, },
async cleanUpHighlights(): Promise<void> { async cleanUpHighlights(): Promise<void> {
const tabId = await getActiveTabId() const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:cleanUpHighlights', undefined, tabId) return pageControllerRPC.sendMessage('rpc:cleanUpHighlights', undefined, tabId)
}, },
// Element actions // Element actions
async clickElement(index: number): Promise<ActionResult> { async clickElement(index: number): Promise<ActionResult> {
const tabId = await getActiveTabId() const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:clickElement', index, tabId) return pageControllerRPC.sendMessage('rpc:clickElement', index, tabId)
}, },
async inputText(index: number, text: string): Promise<ActionResult> { async inputText(index: number, text: string): Promise<ActionResult> {
const tabId = await getActiveTabId() const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:inputText', { index, text }, tabId) return pageControllerRPC.sendMessage('rpc:inputText', { index, text }, tabId)
}, },
async selectOption(index: number, optionText: string): Promise<ActionResult> { async selectOption(index: number, optionText: string): Promise<ActionResult> {
const tabId = await getActiveTabId() const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:selectOption', { index, optionText }, tabId) return pageControllerRPC.sendMessage('rpc:selectOption', { index, optionText }, tabId)
}, },
async scroll(options: ScrollOptions): Promise<ActionResult> { async scroll(options: ScrollOptions): Promise<ActionResult> {
const tabId = await getActiveTabId() const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:scroll', options, tabId) return pageControllerRPC.sendMessage('rpc:scroll', options, tabId)
}, },
async scrollHorizontally(options: ScrollHorizontallyOptions): Promise<ActionResult> { async scrollHorizontally(options: ScrollHorizontallyOptions): Promise<ActionResult> {
const tabId = await getActiveTabId() const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:scrollHorizontally', options, tabId) return pageControllerRPC.sendMessage('rpc:scrollHorizontally', options, tabId)
}, },
async executeJavascript(script: string): Promise<ActionResult> { async executeJavascript(script: string): Promise<ActionResult> {
const tabId = await getActiveTabId() const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:executeJavascript', script, tabId) return pageControllerRPC.sendMessage('rpc:executeJavascript', script, tabId)
}, },
// Mask operations // Mask operations
async showMask(): Promise<void> { async showMask(): Promise<void> {
const tabId = await getActiveTabId() const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:showMask', undefined, tabId) return pageControllerRPC.sendMessage('rpc:showMask', undefined, tabId)
}, },
async hideMask(): Promise<void> { async hideMask(): Promise<void> {
const tabId = await getActiveTabId() const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:hideMask', undefined, tabId) return pageControllerRPC.sendMessage('rpc:hideMask', undefined, tabId)
}, },
// Lifecycle // Lifecycle
async dispose(): Promise<void> { async dispose(): Promise<void> {
const tabId = await getActiveTabId() const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:dispose', undefined, tabId) return pageControllerRPC.sendMessage('rpc:dispose', undefined, tabId)
}, },
} }
}
export type RPCClient = typeof rpcClient 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>
}