diff options
| author | Igor Soarez <igor@soarez.org> | 2026-11-08 11:58:33 +0000 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-11-08 11:58:33 +0000 |
| commit | e538c3e53e203fabb8a9fed94b6810c345ee6f80 (patch) | |
| tree | 394fd12fa9bb6829034e606c2bf0781c088af33d /extensions/chrome-profile-bridge/browser-extension | |
| parent | ff99a4432a3feedfac10d9514ce661a9cb95004c (diff) | |
navigate: answer a page's Leave-site prompt instead of hanging
A page with a beforeunload handler shows 'Leave site?' when the extension
navigates away. Nothing answered it, so chrome_navigate waited out its timeout
and the agent never learned why. The worker now hears Page.javascriptDialogOpening
and dismisses the dialog (stay/cancel); page.navigate fails at once and says so.
leaveIfAsked: true, for that call only, accepts a beforeunload prompt. Tool
description and primer tell agents when to use it and to ask the user when unsure.
Diffstat (limited to 'extensions/chrome-profile-bridge/browser-extension')
| -rw-r--r-- | extensions/chrome-profile-bridge/browser-extension/service_worker.js | 61 |
1 files changed, 57 insertions, 4 deletions
diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js index 1f178e4..c62aff9 100644 --- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js +++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js @@ -439,6 +439,8 @@ async function attachDebugger(tabId) { // Seed pointer in a plausible "just left the address bar" location. const entry = { detachAt: Date.now() + INPUT_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 }, debuggee: attachedDebuggee || { tabId } }; attachedTabs.set(tabId, entry); + // Page events are what tell us a page opened a dialog; without them nobody answers it. + await cdpRaw(tabId, "Page.enable", {}).catch(() => undefined); return entry; } @@ -1402,7 +1404,35 @@ function fileChooserTabIdForSource(source) { return undefined; } +// Dialogs a page opens (alert, confirm, prompt, and the "Leave site?" prompt of a beforeunload +// handler) block the tab until someone answers, and the agent cannot see them. We answer them: +// "stay"/cancel, unless page.navigate was told leaveIfAsked and it is a beforeunload prompt. +// page.navigate waits on dialogWaiters to report a dismissed one instead of timing out. +const dialogPolicy = new Map(); // tabId -> { leave: boolean }, only while page.navigate runs +const dialogWaiters = new Map(); // tabId -> resolve(dialog), only while page.navigate runs + +function describeDialog(dialog) { + if (dialog.type === "beforeunload") return 'a "Leave site? Changes you made may not be saved" prompt (the page has a beforeunload handler)'; + return `a JavaScript ${dialog.type} dialog saying "${dialog.message}"`; +} + +async function handleDialogOpening(tabId, params) { + const leave = params?.type === "beforeunload" && dialogPolicy.get(tabId)?.leave === true; + const dialog = { type: params?.type, message: params?.message ?? "", url: params?.url, accepted: leave }; + try { + await cdp(tabId, "Page.handleJavaScriptDialog", { accept: leave }); + } catch (error) { + console.warn(`[pi-chrome] could not answer a ${dialog.type} dialog on tab ${tabId}: ${error?.message || error}`); + } + if (!leave) dialogWaiters.get(tabId)?.(dialog); +} + function handleDebuggerEvent(source, method, params) { + if (method === "Page.javascriptDialogOpening") { + const dialogTabId = fileChooserTabIdForSource(source); + if (dialogTabId !== undefined) void handleDialogOpening(dialogTabId, params); + return; + } if (method !== "Page.fileChooserOpened") return; const tabId = fileChooserTabIdForSource(source); const entry = tabId === undefined ? undefined : pendingFileChoosers.get(tabId); @@ -1922,14 +1952,37 @@ async function dispatch(action, params) { // Register a one-shot document_start content script. We register, navigate, wait, then unregister. await registerInitScript(tab.id, params.initScript); } - const wait = params.waitUntilLoad !== false ? waitForTabComplete(tab.id, params.timeoutMs || 15000) : Promise.resolve(undefined); - const updated = await chrome.tabs.update(tab.id, { url: params.url }); + // Attach first: the debugger is how we hear about, and answer, a "Leave site?" prompt. + await attachDebugger(tab.id); + await cdp(tab.id, "Page.enable", {}).catch(() => undefined); + dialogPolicy.set(tab.id, { leave: params.leaveIfAsked === true }); + const dialogDismissed = new Promise((resolve) => dialogWaiters.set(tab.id, resolve)); + const waitsForLoad = params.waitUntilLoad !== false; + const wait = waitsForLoad ? waitForTabComplete(tab.id, params.timeoutMs || 15000) : Promise.resolve(undefined); + wait.catch(() => undefined); // if the dialog wins the race, a later load timeout is not an error try { - await wait; + const updated = await chrome.tabs.update(tab.id, { url: params.url }); + // Without waiting for the load, still give a prompt a moment to show up. + const outcome = await Promise.race([ + wait.then(() => ({ loaded: true })), + dialogDismissed.then((dialog) => ({ dialog })), + ...(waitsForLoad ? [] : [new Promise((resolve) => setTimeout(() => resolve({ loaded: true }), 700))]), + ]); + if (outcome.dialog) { + throw new Error( + `Navigation to ${params.url} did not happen: the page at ${outcome.dialog.url || tab.url} opened ${describeDialog(outcome.dialog)}, ` + + `and it was dismissed, so the tab stayed where it was. ` + + (outcome.dialog.type === "beforeunload" + ? "Nothing was lost, but the page may hold unsaved changes. If you know nothing unsaved matters here (you just saved or submitted, or this is a throwaway tab), call chrome_navigate again with leaveIfAsked: true. Otherwise ask the user." + : "Look at the page to see what it was asking."), + ); + } + return await formatTab(await chrome.tabs.get(updated.id)); } finally { + dialogPolicy.delete(tab.id); + dialogWaiters.delete(tab.id); if (params.initScript) await unregisterInitScript(tab.id).catch(() => undefined); } - return await formatTab(await chrome.tabs.get(updated.id)); } case "page.screenshot": return takeScreenshot(params); |
