Skip to content
Merged
20 changes: 19 additions & 1 deletion src/background/commands.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,25 @@ 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)
})
}
Comment on lines +29 to +33
}

if (menuConfig[command].genPrompt) {
Expand Down
118 changes: 96 additions & 22 deletions src/background/menus.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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))
Expand Down
23 changes: 19 additions & 4 deletions src/content-script/menu-tools/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
},
closeAllChats: {
Expand Down