diff --git a/package.json b/package.json index a1e8214..7557b68 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ }, "author": "Neek Sandhu ", "scripts": { - "build": "tsup src/index.ts src/background.ts src/content-script.ts src/devtools.ts src/options.ts src/popup.ts src/window.ts --format esm,cjs --dts", + "build": "tsup src/index.ts src/background.ts src/content-script.ts src/devtools.ts src/options.ts src/popup.ts src/window.ts src/sidepanel.ts --format esm,cjs --dts", "watch": "npm run build -- --watch", "release": "bumpp --commit --push --tag && npm run build && npm publish" }, @@ -55,6 +55,10 @@ "./window": { "import": "./dist/window.js", "require": "./dist/window.cjs" + }, + "./sidepanel": { + "import": "./dist/sidepanel.js", + "require": "./dist/sidepanel.cjs" } }, "typesVersions": { @@ -79,6 +83,9 @@ ], "window": [ "dist/window.d.ts" + ], + "sidepanel": [ + "dist/sidepanel.d.ts" ] } }, diff --git a/src/background.ts b/src/background.ts index de77f84..8a1e528 100644 --- a/src/background.ts +++ b/src/background.ts @@ -240,6 +240,23 @@ browser.runtime.onConnect.addListener((incomingPort) => { frameId: incomingPort.sender.frameId, }) + // For sidepanel, popup, options and devtools, we need the tab ID of the current tab + if (['sidepanel', 'popup', 'options', 'devtools'].includes(parseEndpoint(connArgs.endpointName).context)) { + browser.tabs.query({ active: true, currentWindow: true }).then(tabs => { + if (tabs[0]) { + const activeTabId = tabs[0].id + // Update the endpoint name with the active tab ID for sidepanel + if (parseEndpoint(connArgs.endpointName).context === 'sidepanel' && + parseEndpoint(connArgs.endpointName).tabId == null) { + connArgs.endpointName = formatEndpoint({ + context: 'sidepanel', + tabId: activeTabId, + }) + } + } + }) + } + // literal tab id in case of content script, however tab id of inspected page in case of devtools context const { tabId: linkedTabId, frameId: linkedFrameId } = parseEndpoint( connArgs.endpointName, diff --git a/src/internal/endpoint.ts b/src/internal/endpoint.ts index 0c271f2..86ebd95 100644 --- a/src/internal/endpoint.ts +++ b/src/internal/endpoint.ts @@ -1,6 +1,6 @@ import type { Endpoint, RuntimeContext } from '../types' -const ENDPOINT_RE = /^((?:background$)|devtools|popup|options|content-script|window)(?:@(\d+)(?:\.(\d+))?)?$/ +const ENDPOINT_RE = /^((?:background$)|devtools|popup|options|content-script|window|sidepanel)(?:@(\d+)(?:\.(\d+))?)?$/ export const parseEndpoint = (endpoint: string): Endpoint => { const [, context, tabId, frameId] = endpoint.match(ENDPOINT_RE) || [] @@ -13,7 +13,7 @@ export const parseEndpoint = (endpoint: string): Endpoint => { } export const formatEndpoint = ({ context, tabId, frameId }: Endpoint): string => { - if (['background', 'popup', 'options'].includes(context)) + if (['background', 'popup', 'options', 'sidepanel'].includes(context)) return context return `${context}@${tabId}${frameId ? `.${frameId}` : ''}` diff --git a/src/internal/is-internal-endpoint.ts b/src/internal/is-internal-endpoint.ts index 5e6ab4c..712639f 100644 --- a/src/internal/is-internal-endpoint.ts +++ b/src/internal/is-internal-endpoint.ts @@ -1,5 +1,5 @@ import type { Endpoint, RuntimeContext } from '../types' -const internalEndpoints: RuntimeContext[] = ['background', 'devtools', 'content-script', 'options', 'popup'] +const internalEndpoints: RuntimeContext[] = ['background', 'devtools', 'content-script', 'options', 'popup', 'sidepanel'] export const isInternalEndpoint = ({ context: ctx }: Endpoint): boolean => internalEndpoints.includes(ctx) diff --git a/src/sidepanel.ts b/src/sidepanel.ts new file mode 100644 index 0000000..8b85990 --- /dev/null +++ b/src/sidepanel.ts @@ -0,0 +1,108 @@ +import { createEndpointRuntime } from './internal/endpoint-runtime' +import { createStreamWirings } from './internal/stream' +import { createPersistentPort } from './internal/persistent-port' +import browser from 'webextension-polyfill' + +// Chrome API types for sidepanel +declare global { + interface Chrome { + sidePanel?: { + setPanelBehavior: (options: { openPanelOnActionClick: boolean }) => void + setOptions: (options: { path?: string }) => void + onShown: { + addListener: (callback: () => void) => void + removeListener: (callback: () => void) => void + hasListener: (callback: () => void) => boolean + } + onHidden: { + addListener: (callback: () => void) => void + removeListener: (callback: () => void) => void + hasListener: (callback: () => void) => boolean + } + // V3 还支持指定页面的侧边栏配置 + getOptions: (options: { tabId?: number }) => Promise<{ path?: string }> + } + } + var chrome: Chrome | undefined +} + +const port = createPersistentPort('sidepanel') +const endpointRuntime = createEndpointRuntime( + 'sidepanel', + message => port.postMessage(message), +) + +port.onMessage(endpointRuntime.handleMessage) + +/** + * Set up Chrome's sidepanel API for Manifest V3 extensions + * + * This function initializes the Chrome sidepanel API and configures its behavior. + * Use this in your sidepanel entry point to ensure the sidepanel works correctly. + * + * Example usage in your sidepanel script: + * + * ```ts + * import { setupSidepanel, sendMessage } from 'webext-bridge/sidepanel' + * + * // Initialize the sidepanel + * setupSidepanel({ defaultPath: 'sidepanel.html' }) + * + * // Send a message to background + * sendMessage('get-data', { key: 'value' }, 'background') + * .then(response => console.log(response)) + * + * // Listen for messages from other contexts + * onMessage('update-sidebar', (message) => { + * console.log(message.data) + * // Update sidebar UI + * }) + * ``` + * + * @param options Configuration options for the sidepanel + * @param options.defaultPath Default HTML path for the sidepanel + */ +export function setupSidepanel(options: { defaultPath?: string } = {}) { + if (typeof chrome !== 'undefined' && chrome.sidePanel) { + // Chrome specific sidepanel API + chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }) + + if (options.defaultPath) { + chrome.sidePanel.setOptions({ path: options.defaultPath }) + } + } +} + +/** + * 注册侧边栏显示事件的回调函数 + * @param callback 当侧边栏显示时要执行的回调函数 + * @returns 用于移除事件监听器的函数 + */ +export function onSidepanelShown(callback: () => void): () => void { + if (typeof chrome !== 'undefined' && chrome.sidePanel && chrome.sidePanel.onShown) { + chrome.sidePanel.onShown.addListener(callback) + return () => chrome.sidePanel.onShown.removeListener(callback) + } + return () => {} +} + +/** + * 注册侧边栏隐藏事件的回调函数 + * @param callback 当侧边栏隐藏时要执行的回调函数 + * @returns 用于移除事件监听器的函数 + */ +export function onSidepanelHidden(callback: () => void): () => void { + if (typeof chrome !== 'undefined' && chrome.sidePanel && chrome.sidePanel.onHidden) { + chrome.sidePanel.onHidden.addListener(callback) + return () => chrome.sidePanel.onHidden.removeListener(callback) + } + return () => {} +} + +export function isSidepanelSupported(): boolean { + return !!chrome.sidePanel +} + + +export const { sendMessage, onMessage } = endpointRuntime +export const { openStream, onOpenStreamChannel } = createStreamWirings(endpointRuntime) diff --git a/src/types.ts b/src/types.ts index d184a5c..235d2e1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,7 @@ export type RuntimeContext = | 'options' | 'content-script' | 'window' + | 'sidepanel' export interface Endpoint { context: RuntimeContext