diff options
| -rw-r--r-- | extensions/chrome-profile-bridge/browser-extension/service_worker.js | 56 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/index.ts | 2 | ||||
| -rw-r--r-- | test-suite/unit/overlay.test.mjs | 33 |
3 files changed, 51 insertions, 40 deletions
diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js index de4f340..0ceabda 100644 --- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js +++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js @@ -714,34 +714,48 @@ async function overlayBlockedError(tabId) { ); } -// Injected into the page by overlay.clear. Removes other extensions' iframes (including inside -// open shadow roots), blurs the focused field, sends Escape and clicks the page root. +// Injected into the page by overlay.clear, in the extension's isolated world: only there can +// chrome.dom.openOrClosedShadowRoot read a closed shadow root, and password managers put their +// frame in one (1Password: a <com-1password-uso> element with the frame inside; there is no iframe +// in the page's own DOM). Removes every element whose shadow root holds another extension's +// iframe, plus loose foreign iframes, then blurs the focused field and sends Escape. function clearForeignOverlays(ownId) { const removed = []; - const visit = (root) => { + const isForeignFrame = (el) => el.tagName === "IFRAME" && /^chrome-extension:\/\//.test(el.src) && !el.src.startsWith(`chrome-extension://${ownId}/`); + const rootOf = (el) => { + try { + return (typeof chrome !== "undefined" && chrome.dom && chrome.dom.openOrClosedShadowRoot ? chrome.dom.openOrClosedShadowRoot(el) : null) || el.shadowRoot; + } catch { + return el.shadowRoot; + } + }; + // Returns the foreign frame URLs found inside `root`, looking through nested shadow roots. + const framesIn = (root) => { + const found = []; for (const el of Array.from(root.querySelectorAll("*"))) { - if (el.tagName === "IFRAME" && /^chrome-extension:\/\//.test(el.src) && !el.src.startsWith(`chrome-extension://${ownId}/`)) { - removed.push(el.src.replace(/[?#].*$/, "")); - el.remove(); - } else if (el.shadowRoot) { - visit(el.shadowRoot); - } + if (isForeignFrame(el)) found.push(el.src.replace(/[?#].*$/, "")); + const inner = rootOf(el); + if (inner) found.push(...framesIn(inner)); } + return found; }; - visit(document); + for (const el of Array.from(document.querySelectorAll("*"))) { + if (!el.isConnected) continue; + if (isForeignFrame(el)) { + removed.push(el.src.replace(/[?#].*$/, "")); + el.remove(); + continue; + } + const root = rootOf(el); + const frames = root ? framesIn(root) : []; + if (frames.length > 0) { + removed.push(`<${el.tagName.toLowerCase()}> holding ${frames.join(", ")}`); + el.remove(); + } + } const active = document.activeElement; if (active && active !== document.body && typeof active.blur === "function") active.blur(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true })); - // A click outside is what closes a password manager's inline menu (measured on 1Password: this, - // not Escape or blur, is what freed the tab). Same event shape as domClickFallback, which worked, - // sent to the page root so no page control is clicked. - const root = document.documentElement; - const init = { bubbles: true, cancelable: true, view: window, clientX: 900, clientY: 500, button: 0, buttons: 1 }; - root.dispatchEvent(new PointerEvent("pointerdown", { ...init, pointerId: 1, pointerType: "mouse", isPrimary: true })); - root.dispatchEvent(new MouseEvent("mousedown", init)); - root.dispatchEvent(new PointerEvent("pointerup", { ...init, pointerId: 1, pointerType: "mouse", isPrimary: true, buttons: 0 })); - root.dispatchEvent(new MouseEvent("mouseup", { ...init, buttons: 0 })); - root.click(); const custom = Array.from(document.querySelectorAll("*")).filter((el) => el.tagName.includes("-")).map((el) => el.tagName.toLowerCase()); return { removed, seen: { iframes: document.querySelectorAll("iframe").length, customElements: [...new Set(custom)].slice(0, 10), active: document.activeElement?.tagName ?? null } }; } @@ -750,7 +764,7 @@ async function clearOverlay(params) { const tab = await getTabByParams(params, { createOwnedTarget: false }); let injected = { removed: [] }; try { - const [first] = await executeScriptTimed({ target: { tabId: tab.id, frameIds: [0] }, world: "MAIN", func: clearForeignOverlays, args: [chrome.runtime.id] }, "chrome_clear_overlay"); + const [first] = await executeScriptTimed({ target: { tabId: tab.id, frameIds: [0] }, func: clearForeignOverlays, args: [chrome.runtime.id] }, "chrome_clear_overlay"); injected = first?.result ?? injected; } catch (error) { throw new Error(`Could not run the clean-up on tab ${tab.id}: ${error?.message || error}`); diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts index 4384967..aa86379 100644 --- a/extensions/chrome-profile-bridge/index.ts +++ b/extensions/chrome-profile-bridge/index.ts @@ -2286,7 +2286,7 @@ Usage rules: name: "chrome_clear_overlay", label: "Chrome Clear Overlay", description: - "Close another extension's prompt that is blocking a tab, usually a password manager's (1Password) inline sign-in or autofill prompt. While such a prompt is open Chrome refuses the debugger on that tab, so screenshots, clicks, typing and evaluate fail with \"Another extension ... has a prompt open on this tab\". Decide for yourself whether to use it: if the prompt is just in your way (it popped up on a field you are filling, or you do not need anything from it), clear it. If you need the user to sign in, authorize or approve something with it, or the user may be in the middle of using it, do not clear it: tell the user what you are waiting for. It removes the other extension's frame, blurs the focused field, sends Escape and clicks the page body (a click outside is what closes 1Password's inline menu), then reports whether the tab is usable again. It does not touch the page's own content, and it cannot close 1Password's browser-toolbar popup or the 1Password desktop app (if the result says the tab is still blocked, tell the user). Navigating the tab away also frees it.", + "Close another extension's prompt that is blocking a tab, usually a password manager's (1Password) inline sign-in or autofill prompt. While such a prompt is open Chrome refuses the debugger on that tab, so screenshots, clicks, typing and evaluate fail with \"Another extension ... has a prompt open on this tab\". Decide for yourself whether to use it: if the prompt is just in your way (it popped up on a field you are filling, or you do not need anything from it), clear it. If you need the user to sign in, authorize or approve something with it, or the user may be in the middle of using it, do not clear it: tell the user what you are waiting for. It removes the other extension's frame, blurs the focused field and sends Escape, then reports whether the tab is usable again. It does not touch the page's own content, and it cannot close 1Password's browser-toolbar popup or the 1Password desktop app (if the result says the tab is still blocked, tell the user). Navigating the tab away also frees it.", promptSnippet: "Close a password manager prompt that blocks a tab, unless you are waiting for the user to use it.", parameters: Type.Object({ targetId: Type.Optional(Type.String()), diff --git a/test-suite/unit/overlay.test.mjs b/test-suite/unit/overlay.test.mjs index 87c8fb9..b533ff8 100644 --- a/test-suite/unit/overlay.test.mjs +++ b/test-suite/unit/overlay.test.mjs @@ -52,7 +52,7 @@ test("overlay.clear removes the frame and reports the tab usable", async () => { assert.deepEqual(result.removedFrames, ["chrome-extension://aeblf/inline/universal-sign-on/x.html"]); assert.equal(result.debuggerAttachable, true); assert.equal(calls[0].options.args[0], "ours"); - assert.equal(calls[0].options.world, "MAIN"); + assert.equal(calls[0].options.world, undefined); }); test("overlay.clear says so when the tab is still blocked (toolbar popup, desktop app)", async () => { @@ -63,30 +63,27 @@ test("overlay.clear says so when the tab is still blocked (toolbar popup, deskto assert.deepEqual(result.remainingForeignTargets, ["chrome-extension://aeblf/inline/universal-sign-on/x.html"]); }); -test("the injected clean-up removes foreign extension iframes, not ours, and blurs the field", () => { +test("the injected clean-up removes a host whose closed shadow root holds a foreign frame, and loose foreign frames, but not ours or the page's", () => { const { w } = worker(); - const mk = (src) => ({ tagName: "IFRAME", src, removed: false, remove() { this.removed = true; } }); - const foreign = mk("chrome-extension://aeblf/inline/a.html?x=1"); - const ours = mk("chrome-extension://ours/panel.html"); - const page = mk("https://example.com/embed"); - const shadowFrame = mk("chrome-extension://aeblf/inline/b.html"); - const host = { tagName: "DIV", shadowRoot: { querySelectorAll: () => [shadowFrame] } }; + const frame = (src) => ({ tagName: "IFRAME", src, isConnected: true, removed: false, remove() { this.removed = true; } }); + const foreign = frame("chrome-extension://aeblf/inline/a.html?x=1"); + const ours = frame("chrome-extension://ours/panel.html"); + const page = frame("https://example.com/embed"); + const inClosedRoot = frame("chrome-extension://aeblf/inline/universal-sign-on.html?tabId=2"); + const host = { tagName: "COM-1PASSWORD-USO", isConnected: true, removed: false, remove() { this.removed = true; }, shadowRoot: null, closedRoot: { querySelectorAll: () => [inClosedRoot] } }; + const plain = { tagName: "DIV", isConnected: true, shadowRoot: null }; let blurred = false, escaped = false; - const bodyEvents = []; const sandbox = { - document: { querySelectorAll: () => [foreign, ours, page, host], activeElement: { blur: () => { blurred = true; } }, body: {}, documentElement: { dispatchEvent: (e) => { bodyEvents.push(e.type); }, click: () => { bodyEvents.push("click"); } }, dispatchEvent: (e) => { escaped = e.key === "Escape"; } }, + document: { querySelectorAll: (sel) => (sel === "iframe" ? [foreign, ours, page] : [foreign, ours, page, host, plain]), activeElement: { blur: () => { blurred = true; } }, body: {}, dispatchEvent: (e) => { escaped = e.key === "Escape"; } }, + chrome: { dom: { openOrClosedShadowRoot: (el) => el.closedRoot ?? null } }, KeyboardEvent: class { constructor(type, init) { this.type = type; Object.assign(this, init); } }, - MouseEvent: class { constructor(type) { this.type = type; } }, - PointerEvent: class { constructor(type) { this.type = type; } }, - window: {}, - Array, + Array, Set, }; const result = vm.runInNewContext(`(${w.clearForeignOverlays.toString()})("ours")`, sandbox); - assert.deepEqual([...result.removed], ["chrome-extension://aeblf/inline/a.html", "chrome-extension://aeblf/inline/b.html"]); - assert.equal(foreign.removed && shadowFrame.removed, true); - assert.equal(ours.removed || page.removed, false); + assert.deepEqual([...result.removed], ["chrome-extension://aeblf/inline/a.html", "<com-1password-uso> holding chrome-extension://aeblf/inline/universal-sign-on.html"]); + assert.ok(foreign.removed && host.removed); + assert.equal(ours.removed || page.removed || inClosedRoot.removed, false); assert.ok(blurred && escaped); - assert.deepEqual([...bodyEvents], ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]); }); test("the tool and the primer let the agent decide: clear it when in the way, leave it when waiting for the user", () => { |
