search-with-chatgpt-powered.../background/background.js

121 lines
3.9 KiB
JavaScript

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.)
});