import { load } from "../lib/settings.js"; import { buildChatGptUrl } from "../lib/build-url.js"; const MENU_ID = "ask-chatgpt-selection"; const COMMAND_ID = "ask-chatgpt"; // Reads the user's selection for the keyboard-shortcut path. The context menu // does not need this — it receives info.selectionText directly. // // A commands invocation confers activeTab (ext-commands.js grants it before // firing onCommand), so executeScript works with no host permission. // // Every frame keeps its own independent Selection, and a selection made in a // non-focused frame is not cleared when the user selects elsewhere. Without a // filter, a stale selection in an unfocused (possibly cross-origin) iframe // could win over — or be pulled alongside — the selection the user actually // made, with the winner among multiple non-empty results decided by an // executeScript ordering that isn't specified. The injected snippet is // therefore gated on document.hasFocus(), which is true for the focused // document and all of its ancestors: the top frame wins when the user // selected there, and an iframe's selection is only picked up when it is // itself the focused frame. // // Failure has three measured shapes, all handled here: a thrown // "Missing host permission for the tab", a thrown variant naming frames, and a // SILENT resolution to [null] on parent-process about: pages. Never index into // a result without a null check. async function readSelection() { let tabs; try { tabs = await browser.tabs.query({ active: true, currentWindow: true }); } catch { return ""; } // Only tab.id may be read. Reading tab.url or tab.title would require the // "tabs" permission, which does add an install-prompt warning line. const tabId = tabs?.[0]?.id; if (typeof tabId !== "number") { return ""; } let results; try { results = await browser.tabs.executeScript(tabId, { code: "document.hasFocus() ? window.getSelection().toString() : ''", allFrames: true, matchAboutBlank: true, }); } catch { return ""; } if (!Array.isArray(results)) { return ""; } for (const result of results) { if (typeof result === "string" && result.trim() !== "") { return result; } } return ""; } async function openChatGpt(query) { try { const settings = await load(); const url = buildChatGptUrl(settings, query); if (settings.openIn === "new-window") { try { await browser.windows.create({ url }); return; } catch { // Fall through to a tab rather than doing nothing. } } await browser.tabs.create({ url, active: settings.openIn !== "background-tab" }); } catch { // Nothing useful to say and nowhere useful to say it: the user expects a // tab or nothing at all. Both call sites invoke this without awaiting, so // without this catch a failure would log an unhandled rejection. } } // removeAll() first, so a background event page waking up and re-running this // module cannot throw on a duplicate menu id. async function registerMenu() { await browser.menus.removeAll(); browser.menus.create({ id: MENU_ID, title: 'Ask ChatGPT about "%s"', contexts: ["selection"], }); } browser.menus.onClicked.addListener((info) => { if (info.menuItemId !== MENU_ID) { return; } openChatGpt(info.selectionText ?? ""); }); // browser.commands does not exist on Firefox for Android. This extension is // desktop-only, but guard rather than throwing at startup. if (browser.commands) { browser.commands.onCommand.addListener(async (command) => { if (command !== COMMAND_ID) { return; } openChatGpt(await readSelection()); }); } registerMenu().catch(() => { // menus.removeAll() is promise-based and can reject; a failed re-registration // cannot be retried usefully from here. (menus.create() is callback-based and // cannot reject, so it needs no guard.) });