browsing-context-helpers.sys.mjs (17604B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 export const WEBEXTENSION_FALLBACK_DOC_URL = 6 "chrome://devtools/content/shared/webextension-fallback.html"; 7 8 /** 9 * Retrieve the addon id corresponding to a given window global. 10 * This is usually extracted from the principal, but in case we are dealing 11 * with a DevTools webextension fallback window, the addon id will be available 12 * in the URL. 13 * 14 * @param {WindowGlobalChild|WindowGlobalParent} windowGlobal 15 * The WindowGlobal from which we want to extract the addonId. Either a 16 * WindowGlobalParent or a WindowGlobalChild depending on where this 17 * helper is used from. 18 * @return {string} Returns the addon id if any could found, null otherwise. 19 */ 20 export function getAddonIdForWindowGlobal(windowGlobal) { 21 const browsingContext = windowGlobal.browsingContext; 22 const isParent = CanonicalBrowsingContext.isInstance(browsingContext); 23 // documentPrincipal is only exposed on WindowGlobalParent, 24 // use a fallback for WindowGlobalChild. 25 const principal = isParent 26 ? windowGlobal.documentPrincipal 27 : browsingContext.window.document.nodePrincipal; 28 29 // On Android we can get parent process windows where `documentPrincipal` and 30 // `documentURI` are both unavailable. Bail out early. 31 if (!principal) { 32 return null; 33 } 34 35 // Most webextension documents are loaded from moz-extension://{addonId} and 36 // the principal provides the addon id. 37 if (principal.addonId) { 38 return principal.addonId; 39 } 40 41 // If no addon id was available on the principal, check if the window is the 42 // DevTools fallback window and extract the addon id from the URL. 43 const href = isParent 44 ? windowGlobal.documentURI?.displaySpec 45 : browsingContext.window.document.location.href; 46 47 if (href && href.startsWith(WEBEXTENSION_FALLBACK_DOC_URL)) { 48 const [, addonId] = href.split("#"); 49 return addonId; 50 } 51 52 return null; 53 } 54 55 /** 56 * Helper function to know if a given BrowsingContext should be debugged by scope 57 * described by the given session context. 58 * 59 * @param {BrowsingContext} browsingContext 60 * The browsing context we want to check if it is part of debugged context 61 * @param {object} sessionContext 62 * The Session Context to help know what is debugged. 63 * See devtools/server/actors/watcher/session-context.js 64 * @param {object} options 65 * Optional arguments passed via a dictionary. 66 * @param {boolean} options.forceAcceptTopLevelTarget 67 * If true, we will accept top level browsing context even when server target switching 68 * is disabled. In case of client side target switching, the top browsing context 69 * is debugged via a target actor that is being instantiated manually by the frontend. 70 * And this target actor isn't created, nor managed by the watcher actor. 71 * @param {boolean} options.acceptUncommitedInitialDocument 72 * By default, we ignore initial about:blank documents/WindowGlobals. 73 * But some code cares about all the WindowGlobals, this flag allows to also accept them. 74 * (Used by _validateWindowGlobal) 75 * @param {boolean} options.acceptNoWindowGlobal 76 * By default, we will reject BrowsingContext that don't have any WindowGlobal, 77 * either retrieved via BrowsingContext.currentWindowGlobal in the parent process, 78 * or via the options.windowGlobal argument. 79 * But in some case, we are processing BrowsingContext very early, before any 80 * WindowGlobal has been created for it. But they are still relevant BrowsingContexts 81 * to debug. 82 * @param {WindowGlobal} options.windowGlobal 83 * When we are in the content process, we can't easily retrieve the WindowGlobal 84 * for a given BrowsingContext. So allow to pass it via this argument. 85 * Also, there is some race conditions where browsingContext.currentWindowGlobal 86 * is null, while the callsite may have a reference to the WindowGlobal. 87 */ 88 // The goal of this method is to gather all checks done against BrowsingContext and WindowGlobal interfaces 89 // which leads it to be a lengthy method. So disable the complexity rule which is counter productive here. 90 // eslint-disable-next-line complexity 91 export function isBrowsingContextPartOfContext( 92 browsingContext, 93 sessionContext, 94 options = {} 95 ) { 96 let { 97 forceAcceptTopLevelTarget = false, 98 acceptNoWindowGlobal = false, 99 windowGlobal, 100 } = options; 101 102 // For now, reject debugging chrome BrowsingContext. 103 // This is for example top level chrome windows like browser.xhtml or webconsole/index.html (only the browser console) 104 // 105 // Tab and WebExtension debugging shouldn't target any such privileged document. 106 // All their document should be of type "content". 107 // 108 // This may only be an issue for the Browser Toolbox. 109 // For now, we expect the ParentProcessTargetActor to debug these. 110 // Note that we should probably revisit that, and have each WindowGlobal be debugged 111 // by one dedicated WindowGlobalTargetActor (bug 1685500). This requires some tweaks, at least in console-message 112 // resource watcher, which makes the ParentProcessTarget's console message resource watcher watch 113 // for all documents messages. It should probably only care about window-less messages and have one target per window global, 114 // each target fetching one window global messages. 115 // 116 // Such project would be about applying "EFT" to the browser toolbox and non-content documents 117 if ( 118 CanonicalBrowsingContext.isInstance(browsingContext) && 119 !browsingContext.isContent 120 ) { 121 return false; 122 } 123 124 if (!windowGlobal) { 125 // When we are in the parent process, WindowGlobal can be retrieved from the BrowsingContext, 126 // while in the content process, the callsites have to pass it manually as an argument 127 if (CanonicalBrowsingContext.isInstance(browsingContext)) { 128 windowGlobal = browsingContext.currentWindowGlobal; 129 } else if (!windowGlobal && !acceptNoWindowGlobal) { 130 throw new Error( 131 "isBrowsingContextPartOfContext expect a windowGlobal argument when called from the content process" 132 ); 133 } 134 } 135 // If we have a WindowGlobal, there is some additional checks we can do 136 if ( 137 windowGlobal && 138 !_validateWindowGlobal(windowGlobal, sessionContext, options) 139 ) { 140 return false; 141 } 142 // Loading or destroying BrowsingContext won't have any associated WindowGlobal. 143 // Ignore them by default. They should be either handled via DOMWindowCreated event or JSWindowActor destroy 144 if (!windowGlobal && !acceptNoWindowGlobal) { 145 return false; 146 } 147 148 // Now do the checks specific to each session context type 149 if (sessionContext.type == "all") { 150 return true; 151 } 152 if (sessionContext.type == "browser-element") { 153 // Check if the document is: 154 // - part of the Browser element, or, 155 // - a popup originating from the browser element (the popup being loaded in a distinct browser element) 156 const isMatchingTheBrowserElement = 157 browsingContext.browserId == sessionContext.browserId; 158 if ( 159 !isMatchingTheBrowserElement && 160 !isPopupToDebug(browsingContext, sessionContext) 161 ) { 162 return false; 163 } 164 165 // For client-side target switching, only mention the "remote frames". 166 // i.e. the frames which are in a distinct process compared to their parent document 167 // If there is no parent, this is most likely the top level document which we want to ignore. 168 // 169 // `forceAcceptTopLevelTarget` is set: 170 // * when navigating to and from pages in the bfcache, we ignore client side target 171 // and start emitting top level target from the server. 172 // * when the callsite care about all the debugged browsing contexts, 173 // no matter if their related targets are created by client or server. 174 const isClientSideTargetSwitching = 175 !sessionContext.isServerTargetSwitchingEnabled; 176 const isTopLevelBrowsingContext = !browsingContext.parent; 177 if ( 178 isClientSideTargetSwitching && 179 !forceAcceptTopLevelTarget && 180 isTopLevelBrowsingContext 181 ) { 182 return false; 183 } 184 return true; 185 } 186 187 if (sessionContext.type == "webextension") { 188 // Next and last check expects a WindowGlobal. 189 // As we have no way to really know if this BrowsingContext is related to this add-on, 190 // ignore it. Even if callsite accepts browsing context without a window global. 191 if (!windowGlobal) { 192 return false; 193 } 194 195 return getAddonIdForWindowGlobal(windowGlobal) == sessionContext.addonId; 196 } 197 throw new Error("Unsupported session context type: " + sessionContext.type); 198 } 199 200 /** 201 * Return true for popups to debug when debugging a browser-element. 202 * 203 * @param {BrowsingContext} browsingContext 204 * The browsing context we want to check if it is part of debugged context 205 * @param {object} sessionContext 206 * WatcherActor's session context. This helps know what is the overall debugged scope. 207 * See watcher actor constructor for more info. 208 */ 209 function isPopupToDebug(browsingContext, sessionContext) { 210 // If enabled, create targets for popups (i.e. window.open() calls). 211 // If the opener is the tab we are currently debugging, accept the WindowGlobal and create a target for it. 212 // 213 // Note that it is important to do this check *after* the isInitialDocument one. 214 // Popups end up involving three WindowGlobals: 215 // - a first WindowGlobal loading an initial about:blank document (so isInitialDocument is true) 216 // - a second WindowGlobal which looks exactly as the first one 217 // - a final WindowGlobal which loads the URL passed to window.open() (so isInitialDocument is false) 218 // 219 // For now, we only instantiate a target for the last WindowGlobal. 220 return ( 221 sessionContext.isPopupDebuggingEnabled && 222 browsingContext.opener && 223 browsingContext.opener.browserId == sessionContext.browserId 224 ); 225 } 226 227 /** 228 * Helper function of isBrowsingContextPartOfContext to execute all checks 229 * against WindowGlobal interface which aren't specific to a given SessionContext type 230 * 231 * @param {WindowGlobalParent|WindowGlobalChild} windowGlobal 232 * The WindowGlobal we want to check if it is part of debugged context 233 * @param {object} sessionContext 234 * The Session Context to help know what is debugged. 235 * See devtools/server/actors/watcher/session-context.js 236 * @param {object} options 237 * Optional arguments passed via a dictionary. 238 * See `isBrowsingContextPartOfContext` jsdoc. 239 * @param {boolean} options.acceptUncommitedInitialDocument 240 * By default, we ignore initial uncommitted about:blank documents/WindowGlobals 241 * as they might be transient while the initial load is ongoing. 242 * But some code cares about all the WindowGlobals. 243 */ 244 function _validateWindowGlobal( 245 windowGlobal, 246 sessionContext, 247 { acceptUncommitedInitialDocument } 248 ) { 249 // When creating a new DocShell and before loading anything, we immediately 250 // create the initial about:blank. This is expected by the spec. 251 // Occasionally, multiple such transient empty documents may be created. 252 // These documents start out in the uncommitted state, but if a navigation to 253 // `about:blank` occurs, they can become permanent by being committed to, and 254 // a load event will be fired. 255 // 256 // `Document.isUncommittedInitialDocument` helps identify these transient documents, 257 // which we want to ignore as they would instantiate very short lived targets which 258 // confuses many tests and triggers race conditions by spamming many targets. 259 // 260 // For example, when using `window.open()` with cross-process loads, we may create four 261 // such documents/WindowGlobals. We first get an initial about:blank document, 262 // a second one when moving to the right principal, a third one when moving to the correct 263 // process, and the fourth will is the actual document we expect to debug. 264 // The second and third documents are implementation artifacts that ideally 265 // wouldn't exist and aren't expected by the spec. These are implementation 266 // details that may have already changed or could change in the future. 267 // 268 // Note that `window.print` and print preview are using `window.open` and are going through this. 269 // 270 // WindowGlobalParent will have `isUncommittedInitialDocument` attribute, while we have to go 271 // through the Document for WindowGlobalChild. 272 const isUncommittedInitialDocument = 273 windowGlobal.isUncommittedInitialDocument || 274 windowGlobal.browsingContext.window?.document.isUncommittedInitialDocument; 275 if (isUncommittedInitialDocument && !acceptUncommitedInitialDocument) { 276 return false; 277 } 278 279 return true; 280 } 281 282 /** 283 * Helper function to know if a given WindowGlobal should be debugged by scope 284 * described by the given session context. This method could be called from any process 285 * as so accept either WindowGlobalParent or WindowGlobalChild instances. 286 * 287 * @param {WindowGlobalParent|WindowGlobalChild} windowGlobal 288 * The WindowGlobal we want to check if it is part of debugged context 289 * @param {object} sessionContext 290 * The Session Context to help know what is debugged. 291 * See devtools/server/actors/watcher/session-context.js 292 * @param {object} options 293 * Optional arguments passed via a dictionary. 294 * See `isBrowsingContextPartOfContext` jsdoc. 295 */ 296 export function isWindowGlobalPartOfContext( 297 windowGlobal, 298 sessionContext, 299 options 300 ) { 301 return isBrowsingContextPartOfContext( 302 windowGlobal.browsingContext, 303 sessionContext, 304 { 305 ...options, 306 windowGlobal, 307 } 308 ); 309 } 310 311 /** 312 * Get all the BrowsingContexts that should be debugged by the given session context. 313 * Consider using WatcherActor.getAllBrowsingContexts() which will automatically pass the right sessionContext. 314 * 315 * Really all of them: 316 * - For all the privileged windows (browser.xhtml, browser console, ...) 317 * - For all chrome *and* content contexts (privileged windows, as well as <browser> elements and their inner content documents) 318 * - For all nested browsing context. We fetch the contexts recursively. 319 * 320 * @param {object} sessionContext 321 * The Session Context to help know what is debugged. 322 * See devtools/server/actors/watcher/session-context.js 323 */ 324 export function getAllBrowsingContextsForContext(sessionContext) { 325 const browsingContexts = []; 326 327 // For a given BrowsingContext, add the `browsingContext` 328 // all of its children, that, recursively. 329 function walk(browsingContext) { 330 if (browsingContexts.includes(browsingContext)) { 331 return; 332 } 333 browsingContexts.push(browsingContext); 334 335 for (const child of browsingContext.children) { 336 walk(child); 337 } 338 339 if ( 340 (sessionContext.type == "all" || sessionContext.type == "webextension") && 341 browsingContext.window 342 ) { 343 // If the document is in the parent process, also iterate over each <browser>'s browsing context. 344 // BrowsingContext.children doesn't cross chrome to content boundaries, 345 // so we have to cross these boundaries by ourself. 346 // (This is also the reason why we aren't using BrowsingContext.getAllBrowsingContextsInSubtree()) 347 for (const browser of browsingContext.window.document.querySelectorAll( 348 `browser[type="content"]` 349 )) { 350 walk(browser.browsingContext); 351 } 352 } 353 } 354 355 // If target a single browser element, only walk through its BrowsingContext 356 if (sessionContext.type == "browser-element") { 357 const topBrowsingContext = BrowsingContext.getCurrentTopByBrowserId( 358 sessionContext.browserId 359 ); 360 // topBrowsingContext can be null if getCurrentTopByBrowserId is called for a tab that is unloaded. 361 if (topBrowsingContext?.embedderElement) { 362 // Unfortunately, getCurrentTopByBrowserId is subject to race conditions and may refer to a BrowsingContext 363 // that already navigated away. 364 // Query the current "live" BrowsingContext by going through the embedder element (i.e. the <browser>/<iframe> element) 365 // devtools/client/responsive/test/browser/browser_navigation.js covers this with fission enabled. 366 const realTopBrowsingContext = 367 topBrowsingContext.embedderElement.browsingContext; 368 walk(realTopBrowsingContext); 369 } 370 } else if ( 371 sessionContext.type == "all" || 372 sessionContext.type == "webextension" 373 ) { 374 // For the browser toolbox and web extension, retrieve all possible BrowsingContext. 375 // For WebExtension, we will then filter out the BrowsingContexts via `isBrowsingContextPartOfContext`. 376 // 377 // Fetch all top level window's browsing contexts 378 for (const window of Services.ww.getWindowEnumerator()) { 379 if (window.docShell.browsingContext) { 380 walk(window.docShell.browsingContext); 381 } 382 } 383 } else { 384 throw new Error("Unsupported session context type: " + sessionContext.type); 385 } 386 387 return browsingContexts.filter(bc => 388 // We force accepting the top level browsing context, otherwise 389 // it would only be returned if sessionContext.isServerSideTargetSwitching is enabled. 390 isBrowsingContextPartOfContext(bc, sessionContext, { 391 forceAcceptTopLevelTarget: true, 392 }) 393 ); 394 } 395 396 if (typeof module == "object") { 397 // eslint-disable-next-line no-undef 398 module.exports = { 399 isBrowsingContextPartOfContext, 400 isWindowGlobalPartOfContext, 401 getAddonIdForWindowGlobal, 402 getAllBrowsingContextsForContext, 403 }; 404 }