diff --git a/src/background/commands.mjs b/src/background/commands.mjs index 1440d74d..cba9f7db 100644 --- a/src/background/commands.mjs +++ b/src/background/commands.mjs @@ -12,15 +12,58 @@ export function registerCommands() { if (command in menuConfig) { if (menuConfig[command].action) { - menuConfig[command].action(true, tab) + // The action may return a Promise (e.g. openSidePanel returns the + // chrome.sidePanel.open() Promise). Keep the call synchronous so the + // user-gesture context is preserved, but observe the Promise so a + // rejection does not become an unhandled rejection in the background. + // Also wrap in try/catch because Browser.commands.onCommand documents + // `tab` as optional, so an action that dereferences tab.* (e.g. the + // openSidePanel call) can throw synchronously. + let result + try { + result = menuConfig[command].action(true, tab) + } catch (error) { + console.error(`failed to run command action "${command}"`, error) + return + } + if (result && typeof result.catch === 'function') { + result.catch((error) => { + console.error(`failed to run command action "${command}"`, error) + }) + } } if (menuConfig[command].genPrompt) { - const currentTab = (await Browser.tabs.query({ active: true, currentWindow: true }))[0] - Browser.tabs.sendMessage(currentTab.id, { - type: 'CREATE_CHAT', - data: message, - }) + // Mirror the pattern in menus.mjs so no step here can leak an + // unhandled rejection in the background: + // - Browser.tabs.query() can reject (permission errors, etc.) — + // observe via try/catch. + // - tabs[0] may be undefined when no active tab exists — guard + // before dereferencing currentTab.id. + // - Browser.tabs.sendMessage() (via webextension-polyfill) rejects + // in normal extension usage (no content script listening, + // restricted pages like chrome://, stale content scripts after + // extension reload) — attach a .catch(). + let tabs + try { + tabs = await Browser.tabs.query({ active: true, currentWindow: true }) + } catch (error) { + console.error(`failed to query active tab for command "${command}"`, error) + return + } + const currentTab = tabs && tabs[0] + if (!currentTab) { + console.debug(`command "${command}" triggered but no active tab found, skipping`) + return + } + Browser.tabs + .sendMessage(currentTab.id, { + type: 'CREATE_CHAT', + data: message, + }) + .catch((error) => { + console.error(`failed to send CREATE_CHAT message for command "${command}"`, error) + }) } } }) diff --git a/src/background/menus.mjs b/src/background/menus.mjs index a92bd7ba..d3a61ee1 100644 --- a/src/background/menus.mjs +++ b/src/background/menus.mjs @@ -5,33 +5,107 @@ import { config as menuConfig } from '../content-script/menu-tools/index.mjs' const menuId = 'ChatGPTBox-Menu' const onClickMenu = (info, tab) => { - Browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => { - const currentTab = tabs[0] - const message = { - itemId: info.menuItemId.replace(menuId, ''), - selectionText: info.selectionText, - useMenuPosition: tab.id === currentTab.id, - } - console.debug('menu clicked', message) + const itemId = info.menuItemId.replace(menuId, '') - if (defaultConfig.selectionTools.includes(message.itemId)) { - Browser.tabs.sendMessage(currentTab.id, { - type: 'CREATE_CHAT', - data: message, + // sidePanel.open() must be called synchronously within the user gesture handler. + // Calling it inside a Promise callback (e.g. Browser.tabs.query().then()) breaks + // Chrome's user gesture requirement and causes the error: + // "sidePanel.open() may only be called in response to a user gesture." + if (itemId === 'openSidePanel' && menuConfig.openSidePanel?.action) { + // Keep the call synchronous to preserve the user-gesture requirement, + // but observe the returned Promise so a rejected sidePanel.open() does + // not become an unhandled rejection in the background script. + // Also wrap in try/catch because contextMenus.onClicked documents `tab` + // as optional ("If the click did not take place in a tab, this parameter + // will be missing"), so the openSidePanel action that dereferences + // tab.windowId/tab.id can throw synchronously. + let result + try { + result = menuConfig.openSidePanel.action(true, tab) + } catch (error) { + console.error('failed to open side panel', error) + return + } + if (result && typeof result.catch === 'function') { + result.catch((error) => { + console.error('failed to open side panel', error) }) - } else if (message.itemId in menuConfig) { - if (menuConfig[message.itemId].action) { - menuConfig[message.itemId].action(true, tab) + } + return + } + + Browser.tabs + .query({ active: true, currentWindow: true }) + .then((tabs) => { + const currentTab = tabs && tabs[0] + if (!currentTab) { + console.debug('menu clicked but no active tab found, skipping') + return } - if (menuConfig[message.itemId].genPrompt) { - Browser.tabs.sendMessage(currentTab.id, { - type: 'CREATE_CHAT', - data: message, - }) + // contextMenus.onClicked documents `tab` as optional ("If the click did + // not take place in a tab, this parameter will be missing"), so guard + // before dereferencing tab.id when computing useMenuPosition. + const message = { + itemId, + selectionText: info.selectionText, + useMenuPosition: tab ? tab.id === currentTab.id : false, } - } - }) + console.debug('menu clicked', message) + + if (defaultConfig.selectionTools.includes(message.itemId)) { + // Browser.tabs.sendMessage() (via webextension-polyfill) returns a + // Promise that commonly rejects (no content script listening, restricted + // pages such as chrome://, stale content scripts after extension reload) + // — observe it so we don't leak unhandled rejections in the background. + Browser.tabs + .sendMessage(currentTab.id, { + type: 'CREATE_CHAT', + data: message, + }) + .catch((error) => { + console.error(`failed to send CREATE_CHAT message for "${message.itemId}"`, error) + }) + } else if (message.itemId in menuConfig) { + if (menuConfig[message.itemId].action) { + // Several actions in menuConfig are async (e.g. tabs/windows calls) + // and can throw synchronously or return a rejected Promise. Mirror + // the handling already used for openSidePanel above and in + // commands.mjs so neither path leaks an unhandled rejection in the + // background script. + let actionResult + try { + actionResult = menuConfig[message.itemId].action(true, tab) + } catch (error) { + console.error(`failed to run menu action "${message.itemId}"`, error) + } + if (actionResult && typeof actionResult.catch === 'function') { + actionResult.catch((error) => { + console.error(`failed to run menu action "${message.itemId}"`, error) + }) + } + } + + if (menuConfig[message.itemId].genPrompt) { + // Same rationale as the sendMessage call above — observe the Promise + // so a rejected sendMessage (no content script, restricted page, etc.) + // doesn't surface as an unhandled rejection in the background. + Browser.tabs + .sendMessage(currentTab.id, { + type: 'CREATE_CHAT', + data: message, + }) + .catch((error) => { + console.error(`failed to send CREATE_CHAT message for "${message.itemId}"`, error) + }) + } + } + }) + .catch((error) => { + // Browser.tabs.query() can reject (e.g. on permission errors); make sure + // it does not become an unhandled promise rejection in the background. + console.error('failed to query active tab for menu click', error) + }) } export function refreshMenu() { if (Browser.contextMenus.onClicked.hasListener(onClickMenu)) diff --git a/src/content-script/menu-tools/index.mjs b/src/content-script/menu-tools/index.mjs index 0f46392e..f777b525 100644 --- a/src/content-script/menu-tools/index.mjs +++ b/src/content-script/menu-tools/index.mjs @@ -59,14 +59,29 @@ export const config = { }, openSidePanel: { label: 'Open Side Panel', - action: async (fromBackground, tab) => { + action: (fromBackground, tab) => { console.debug('action is from background', fromBackground) if (fromBackground) { // eslint-disable-next-line no-undef - chrome.sidePanel.open({ windowId: tab.windowId, tabId: tab.id }) - } else { - // side panel is not supported + if (typeof chrome === 'undefined' || !chrome.sidePanel?.open) { + // sidePanel API is not available in this browser (e.g. Firefox) + return Promise.reject(new Error('chrome.sidePanel API is not available')) + } + // contextMenus.onClicked / commands.onCommand document `tab` as + // optional, and even when present the tab may not have an id or + // windowId (e.g. clicks outside a normal browser tab). Guard here so + // callers do not have to wrap every invocation in try/catch just to + // avoid a TypeError from dereferencing tab.windowId / tab.id. + if (!tab || tab.windowId == null || tab.id == null) { + return Promise.reject( + new Error('chrome.sidePanel.open requires a tab with windowId and id'), + ) + } + // eslint-disable-next-line no-undef + return chrome.sidePanel.open({ windowId: tab.windowId, tabId: tab.id }) } + // side panel is not supported + return undefined }, }, closeAllChats: {