I'm using a Chrome extension using MV3 to convert every frame to a shadow-root (to put inline their content), I need this because I cannot copy and paste iframe/embed/object
frames from the browser to OneNote, Word, etc when I select all the page, I have to select the frame content one by one, but this consumes a lot of time. So I decided to convert them, which allows me to copy them all at once with one ctrl-c
.
I solved iframes
, and the conversion works very well, I used chrome.webNavigation.getAllFrames
to inject every frame and its parent, then I postMessage
between them the content, and then I replace from the parent the subframe content because I can now identify the subframe by postMessage
the frameid
I got with getAllFrames
. but I could not solve the <embed>
frames:
elements (HTMLEmbedElement) do not have contentWindow or contentDocument so we cannot postmessage from the parent to its embed frame. link
So to get the content, I used this method: window.frames[]
does include windows from <embed>
, but it is not possible to tell whether an item in window.frames[]
belongs to a specific <embed>
, except in the same-origin
case, where frames[i].frameElement
can be compared with an <embed>
element. link
But this method will not always work because ShadowDOM
frames aren't exposed in the global window or frames, i mean window.frames
do not include iframe/embed
if they are inside a shadow roots. 1 2 3 4. And of course there is the cross-origin problem also.
Another solution is to compare the frame src
I get from getAllFrames
(or window.location.href
(sent from the embed to its parent)) with the frame.src
I get from the frame's parent. This allows me to identify from the parent the correct embed frame, which I have to convert to a shadow-root. But this method fails if the embed frame is redirected to a new url, the standard says it clearly:
element's src attribute does not get updated if the content navigable gets further navigated to other locations. link
There is also runtime.getFrameId()
which seems to solve all this problems, but Chrome do not want to implement it, Firefox implemented it, but I'm using chrome.
One solution I can use is the webrequest
api to intercept the redirections and save the old and new urls to identify the correct frame, but this means that I have to refresh the page, I want to avoid this, because I'm doing all this work to earn time when I copy/paste not to lose more time. Refreshing means I have to wait more time and I need to reopen any <details><summary>
... etc
I can run chrome with this flags --profile-directory="%Profile_name%" --disable-site-isolation-trials --disable-web-security
this disable the web security so it eliminates the same-origin policy restrictions, so cross-origin problems are solved, but i cannot run all the time with this dangerous flags, and running every time a new chrome with this flags just for copy/paste means losing more time...
So now to identify the embed frame from its parent frame, I cannot use frame.src
from the parent if it is redirected, and I cannot postmessage
from the parent to its child embed frame like I did in iframes
, and I cannot use window.frames
for cross-origin
frames or when the frame is inside a shadow-root
. Is there another solution?
If you a need a test case, here is an example of a cross-origin frame which will redirect to a new url
<div><embed src="https://iperasolutions.com" height="500" width="500"></div>
This did not work for cross-origin frames, it works fine for same-origin frames. there is an open bug about this:
cross origin iframes still can't be debugged via chrome.debugger https://issues.chromium.org/issues/40752731
chrome.debugger.attach({ tabId: tabId }, "1.3", function () {
// Enable auto-attach to subtargets
chrome.debugger.sendCommand({ tabId: tabId }, "Target.setAutoAttach", {
autoAttach: true,
waitForDebuggerOnStart: false,
flatten: true,
}, function () {
chrome.debugger.sendCommand({ tabId: tabId }, "DOMSnapshot.captureSnapshot", { computedStyles: [] }, function (snapshot) {
console.log("snapshot", snapshot);
});
});
DOM.getDocument
does not return cross-origin frames, and also this commands Page.getFrameTree
and Page.getResourceTree
const getDocument = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument", { depth: -1, pierce: true });
console.log("++++++++getDocument",getDocument);
const getFrameTree = await chrome.debugger.sendCommand({ tabId }, "Page.getFrameTree", {});
console.log("++++++++getFrameTree: ", getFrameTree);
const getResourceTree = await chrome.debugger.sendCommand({ tabId }, "Page.getResourceTree", {});
console.log("++++++++getResourceTree: ", getResourceTree);
when i run this in the protocol monitor in devtools, cross-origin frames are not returned too
{"command":"DOM.getDocument" ,"parameters":{"depth": -1, "pierce": true}}
if i disable web security by running chrome with --disable-site-isolation-trials
--disable-web-security
... all the above 3 commands returns cross-origin frames.
here is the full code:
const contextMap = new Map();
try {
chrome.debugger.onEvent.addListener(handleDebuggerEvents);
await chrome.debugger.attach({ tabId: tabId }, "1.3");
// When you call Runtime.enable, it sends executionContextCreated events for all existing execution contexts. So you don’t have to worry about connecting late. https://github.com/ChromeDevTools/devtools-protocol/issues/72
await chrome.debugger.sendCommand({ tabId: tabId }, "Runtime.enable");
console.log("contextMap",contextMap);
await injectScriptIntoAllFrames(tabId);
await chrome.debugger.detach({ tabId: tabId });
} catch (error) {
console.error(error);
}
async function injectScriptIntoAllFrames(tabId) {
const getDocument = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument", { depth: -1, pierce: true });
if (getDocument) {
console.log("++++++++getDocument",getDocument);
await traverseDOM(tabId, getDocument.root);
}
}
async function traverseDOM(tabId, node) {
if (node.frameId && (node.nodeName === 'IFRAME' || node.nodeName === 'EMBED' || node.nodeName === 'OBJECT')) {
console.log("frameid",node.frameId);
// const contextId = contextMap.get(node.frameId).id;
// uniqueContextId: is An alternative way to specify the execution context to evaluate in. Compared to contextId that may be reused across processes, this is guaranteed to be system-unique, so it can be used to prevent accidental evaluation of the expression in context different than intended (e.g. as a result of navigation across process boundaries). This is mutually exclusive with `contextId`.EXPERiMENTAL
const uniqueContextId = contextMap.get(node.frameId).uniqueId;
console.log("=========uniqueContextId",uniqueContextId);
await injectScriptIntoFrame(tabId, node.frameId, uniqueContextId);
}
if (node.children) {
for (const child of node.children) {
await traverseDOM(tabId, child);
}
}
}
async function injectScriptIntoFrame(tabId, frameId, uniqueContextId) {
const evaluateResult = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
expression: `(async () => {
console.log("111",window.badr);
await new Promise((resolve) => {setTimeout(resolve, 3000);});
return window.badr;
})()`
,awaitPromise: true
// ,contextId: contextId
,uniqueContextId: uniqueContextId
// ,silent: true
})
if (evaluateResult) {
if (evaluateResult.exceptionDetails) {
console.error(`An error happened or Failed to inject script into frame ${frameId}:`, evaluateResult.exceptionDetails);
} else {
console.log("Runtime.evaluate result",evaluateResult.result.value);
}
}
}
function handleDebuggerEvents (source, method, params) {
if (source.tabId !== tabId) return;
if (method === "Runtime.executionContextsCleared") {
contextMap.clear();
console.log("Contexts cleared");
}
if (method === "Runtime.executionContextCreated") {
const context = params.context;
const frameId = context.auxData && context.auxData.frameId;
// isDefault seems to be true only for main frame context,when i create a createIsolatedWorld or i inject a content script into the frame i see that isDefault is always false for those contexts. for main frame context the "name" seems to be always empty, and "type" is always type: 'default', type in general may be: 'default'|'isolated'|'worker'
if (frameId && context.auxData.isDefault) {
contextMap.set(frameId, context);
console.log(`Context saved: ${frameId}`, contextMap.get(frameId));
}
} else if (method === "Runtime.executionContextDestroyed") {
const executionContextId = params.executionContextId;
for (const [frameId, context] of contextMap) {
if (context.id === executionContextId) {
contextMap.delete(frameId);
console.log(`Context removed: ${frameId}`);
break;
}
}
}
}
this also does not return cross-origin frames.
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tabId = tabs[0].id;
chrome.debugger.attach({ tabId }, "1.3", () => {
chrome.debugger.sendCommand({ tabId }, "DOM.getDocument", {}, (result) => {
const rootNodeId = result.root.nodeId;
requestChildNodes(tabId, rootNodeId);
});
});
});
function requestChildNodes(tabId, nodeId) {
chrome.debugger.sendCommand({ tabId },"DOM.requestChildNodes",{ nodeId },(result) => {
console.log("Child nodes requested", result);
chrome.debugger.sendCommand({ tabId },"DOM.querySelectorAll",{ nodeId: nodeId, selector: '*' },(result) => {
console.log("All nodes:", result);
});
});
}
chrome.debugger.onEvent.addListener((source, method, params) => {
if (method === "DOM.setChildNodes") {
console.log("Child nodes set", params);
}
});
i found this which seems similar and applicable to the above commands, it is about Page.getFrameTree
CDP method Page.getFrameTree with chrome.debugger API doesn't return cross-domain iframes
Status: Won't Fix (Intended Behavior)
Page.getFrameTree only returns local frames and it is working as expected. To attach to out-of-process iframes (which are separate CDP targets and on which you can call Page.getFrameTree again to get local frame trees), in the latest Canary you can use CDP's auto-attach to automatically create sessions for all OOPIFs https://chromium-review.googlesource.com/c/chromium/src/+/5398119
so it seems it needs at least chrome v124+ and auto-attach, so no chance for win 7 and chrome v109 users like me :(
One way would be to replace all the <embed>
elements in the page with <iframe>
ones, so that you can communicate with it as you wish.
Injecting something like below should do.
[...document.querySelectorAll("embed")].forEach((embed) => {
const { src, width, height } = embed;
const frame = Object.assign(document.createElement("iframe"), { src, width, height })
embed.replaceWith(frame);
});
(This may cause some layout differences in the page).