93 lines
3.3 KiB
JavaScript
93 lines
3.3 KiB
JavaScript
import { load } from "../lib/settings.js";
|
|
import { prepareAction } from "../lib/prepare-action.js";
|
|
import { openChat } from "./open-chat.js";
|
|
import { showActionDialog } from "./dialogs.js";
|
|
import { initializeMenus } from "./menus.js";
|
|
|
|
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. Exclude ancestors whose active element is a 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() && !['IFRAME', 'FRAME'].includes(document.activeElement?.tagName) ? 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 runAction(request) {
|
|
let preparation = prepareAction(request);
|
|
if (preparation.kind === "ready") {
|
|
try {
|
|
await openChat(browser, preparation.url, request.settings.openIn);
|
|
return;
|
|
} catch {
|
|
// Keep the ready payload so the dialog's Continue button retries it.
|
|
}
|
|
}
|
|
try {
|
|
await showActionDialog(browser, { request, preparation });
|
|
} catch {
|
|
console.error("Could not open the action dialog. Please try the action again.");
|
|
}
|
|
}
|
|
|
|
void initializeMenus({ browser, load, onAction: runAction }).catch(console.error);
|
|
if (browser.commands) {
|
|
browser.commands.onCommand.addListener(async command => {
|
|
if (command !== COMMAND_ID) return;
|
|
// Read the selection before another asynchronous API loses activeTab context.
|
|
const source = await readSelection();
|
|
await runAction({ actionId: "ask", source, settings: await load() });
|
|
});
|
|
}
|