1/* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */ 2/* This Source Code Form is subject to the terms of the Mozilla Public 3 * License, v. 2.0. If a copy of the MPL was not distributed with this 4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 5 6"use strict"; 7 8var EXPORTED_SYMBOLS = ["BrowserUtils"]; 9 10const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); 11ChromeUtils.defineModuleGetter( 12 this, 13 "PlacesUtils", 14 "resource://gre/modules/PlacesUtils.jsm" 15); 16 17var BrowserUtils = { 18 /** 19 * Prints arguments separated by a space and appends a new line. 20 */ 21 dumpLn(...args) { 22 for (let a of args) { 23 dump(a + " "); 24 } 25 dump("\n"); 26 }, 27 28 /** 29 * restartApplication: Restarts the application, keeping it in 30 * safe mode if it is already in safe mode. 31 */ 32 restartApplication() { 33 let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance( 34 Ci.nsISupportsPRBool 35 ); 36 Services.obs.notifyObservers( 37 cancelQuit, 38 "quit-application-requested", 39 "restart" 40 ); 41 if (cancelQuit.data) { 42 // The quit request has been canceled. 43 return false; 44 } 45 // if already in safe mode restart in safe mode 46 if (Services.appinfo.inSafeMode) { 47 Services.startup.restartInSafeMode( 48 Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart 49 ); 50 return undefined; 51 } 52 Services.startup.quit( 53 Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart 54 ); 55 return undefined; 56 }, 57 58 /** 59 * Check whether a page can be considered as 'empty', that its URI 60 * reflects its origin, and that if it's loaded in a tab, that tab 61 * could be considered 'empty' (e.g. like the result of opening 62 * a 'blank' new tab). 63 * 64 * We have to do more than just check the URI, because especially 65 * for things like about:blank, it is possible that the opener or 66 * some other page has control over the contents of the page. 67 * 68 * @param {Browser} browser 69 * The browser whose page we're checking. 70 * @param {nsIURI} [uri] 71 * The URI against which we're checking (the browser's currentURI 72 * if omitted). 73 * 74 * @return {boolean} false if the page was opened by or is controlled by 75 * arbitrary web content, unless that content corresponds with the URI. 76 * true if the page is blank and controlled by a principal matching 77 * that URI (or the system principal if the principal has no URI) 78 */ 79 checkEmptyPageOrigin(browser, uri = browser.currentURI) { 80 // If another page opened this page with e.g. window.open, this page might 81 // be controlled by its opener. 82 if (browser.hasContentOpener) { 83 return false; 84 } 85 let contentPrincipal = browser.contentPrincipal; 86 // Not all principals have URIs... 87 if (contentPrincipal.URI) { 88 // There are two special-cases involving about:blank. One is where 89 // the user has manually loaded it and it got created with a null 90 // principal. The other involves the case where we load 91 // some other empty page in a browser and the current page is the 92 // initial about:blank page (which has that as its principal, not 93 // just URI in which case it could be web-based). Especially in 94 // e10s, we need to tackle that case specifically to avoid race 95 // conditions when updating the URL bar. 96 // 97 // Note that we check the documentURI here, since the currentURI on 98 // the browser might have been set by SessionStore in order to 99 // support switch-to-tab without having actually loaded the content 100 // yet. 101 let uriToCheck = browser.documentURI || uri; 102 if ( 103 (uriToCheck.spec == "about:blank" && 104 contentPrincipal.isNullPrincipal) || 105 contentPrincipal.URI.spec == "about:blank" 106 ) { 107 return true; 108 } 109 return contentPrincipal.URI.equals(uri); 110 } 111 // ... so for those that don't have them, enforce that the page has the 112 // system principal (this matches e.g. on about:newtab). 113 return contentPrincipal.isSystemPrincipal; 114 }, 115 116 /** 117 * urlSecurityCheck: JavaScript wrapper for checkLoadURIWithPrincipal 118 * and checkLoadURIStrWithPrincipal. 119 * If |aPrincipal| is not allowed to link to |aURL|, this function throws with 120 * an error message. 121 * 122 * @param aURL 123 * The URL a page has linked to. This could be passed either as a string 124 * or as a nsIURI object. 125 * @param aPrincipal 126 * The principal of the document from which aURL came. 127 * @param aFlags 128 * Flags to be passed to checkLoadURIStr. If undefined, 129 * nsIScriptSecurityManager.STANDARD will be passed. 130 */ 131 urlSecurityCheck(aURL, aPrincipal, aFlags) { 132 var secMan = Services.scriptSecurityManager; 133 if (aFlags === undefined) { 134 aFlags = secMan.STANDARD; 135 } 136 137 try { 138 if (aURL instanceof Ci.nsIURI) { 139 secMan.checkLoadURIWithPrincipal(aPrincipal, aURL, aFlags); 140 } else { 141 secMan.checkLoadURIStrWithPrincipal(aPrincipal, aURL, aFlags); 142 } 143 } catch (e) { 144 let principalStr = ""; 145 try { 146 principalStr = " from " + aPrincipal.URI.spec; 147 } catch (e2) {} 148 149 throw new Error(`Load of ${aURL + principalStr} denied.`); 150 } 151 }, 152 153 /** 154 * Return or create a principal with the content of one, and the originAttributes 155 * of an existing principal (e.g. on a docshell, where the originAttributes ought 156 * not to change, that is, we should keep the userContextId, privateBrowsingId, 157 * etc. the same when changing the principal). 158 * 159 * @param principal 160 * The principal whose content/null/system-ness we want. 161 * @param existingPrincipal 162 * The principal whose originAttributes we want, usually the current 163 * principal of a docshell. 164 * @return an nsIPrincipal that matches the content/null/system-ness of the first 165 * param, and the originAttributes of the second. 166 */ 167 principalWithMatchingOA(principal, existingPrincipal) { 168 // Don't care about system principals: 169 if (principal.isSystemPrincipal) { 170 return principal; 171 } 172 173 // If the originAttributes already match, just return the principal as-is. 174 if (existingPrincipal.originSuffix == principal.originSuffix) { 175 return principal; 176 } 177 178 let secMan = Services.scriptSecurityManager; 179 if (principal.isContentPrincipal) { 180 return secMan.createContentPrincipal( 181 principal.URI, 182 existingPrincipal.originAttributes 183 ); 184 } 185 186 if (principal.isNullPrincipal) { 187 return secMan.createNullPrincipal(existingPrincipal.originAttributes); 188 } 189 throw new Error( 190 "Can't change the originAttributes of an expanded principal!" 191 ); 192 }, 193 194 /** 195 * Constructs a new URI, using nsIIOService. 196 * @param aURL The URI spec. 197 * @param aOriginCharset The charset of the URI. 198 * @param aBaseURI Base URI to resolve aURL, or null. 199 * @return an nsIURI object based on aURL. 200 * 201 * @deprecated Use Services.io.newURI directly instead. 202 */ 203 makeURI(aURL, aOriginCharset, aBaseURI) { 204 return Services.io.newURI(aURL, aOriginCharset, aBaseURI); 205 }, 206 207 /** 208 * @deprecated Use Services.io.newFileURI directly instead. 209 */ 210 makeFileURI(aFile) { 211 return Services.io.newFileURI(aFile); 212 }, 213 214 /** 215 * For a given DOM element, returns its position in "screen" 216 * coordinates. In a content process, the coordinates returned will 217 * be relative to the left/top of the tab. In the chrome process, 218 * the coordinates are relative to the user's screen. 219 */ 220 getElementBoundingScreenRect(aElement) { 221 return this.getElementBoundingRect(aElement, true); 222 }, 223 224 /** 225 * For a given DOM element, returns its position as an offset from the topmost 226 * window. In a content process, the coordinates returned will be relative to 227 * the left/top of the topmost content area. If aInScreenCoords is true, 228 * screen coordinates will be returned instead. 229 */ 230 getElementBoundingRect(aElement, aInScreenCoords) { 231 let rect = aElement.getBoundingClientRect(); 232 let win = aElement.ownerGlobal; 233 234 let x = rect.left, 235 y = rect.top; 236 237 // We need to compensate for any iframes that might shift things 238 // over. We also need to compensate for zooming. 239 let parentFrame = win.frameElement; 240 while (parentFrame) { 241 win = parentFrame.ownerGlobal; 242 let cstyle = win.getComputedStyle(parentFrame); 243 244 let framerect = parentFrame.getBoundingClientRect(); 245 x += 246 framerect.left + 247 parseFloat(cstyle.borderLeftWidth) + 248 parseFloat(cstyle.paddingLeft); 249 y += 250 framerect.top + 251 parseFloat(cstyle.borderTopWidth) + 252 parseFloat(cstyle.paddingTop); 253 254 parentFrame = win.frameElement; 255 } 256 257 if (aInScreenCoords) { 258 x += win.mozInnerScreenX; 259 y += win.mozInnerScreenY; 260 } 261 262 let fullZoom = win.windowUtils.fullZoom; 263 rect = { 264 left: x * fullZoom, 265 top: y * fullZoom, 266 width: rect.width * fullZoom, 267 height: rect.height * fullZoom, 268 }; 269 270 return rect; 271 }, 272 273 onBeforeLinkTraversal(originalTarget, linkURI, linkNode, isAppTab) { 274 // Don't modify non-default targets or targets that aren't in top-level app 275 // tab docshells (isAppTab will be false for app tab subframes). 276 if (originalTarget != "" || !isAppTab) { 277 return originalTarget; 278 } 279 280 // External links from within app tabs should always open in new tabs 281 // instead of replacing the app tab's page (Bug 575561) 282 let linkHost; 283 let docHost; 284 try { 285 linkHost = linkURI.host; 286 docHost = linkNode.ownerDocument.documentURIObject.host; 287 } catch (e) { 288 // nsIURI.host can throw for non-nsStandardURL nsIURIs. 289 // If we fail to get either host, just return originalTarget. 290 return originalTarget; 291 } 292 293 if (docHost == linkHost) { 294 return originalTarget; 295 } 296 297 // Special case: ignore "www" prefix if it is part of host string 298 let [longHost, shortHost] = 299 linkHost.length > docHost.length 300 ? [linkHost, docHost] 301 : [docHost, linkHost]; 302 if (longHost == "www." + shortHost) { 303 return originalTarget; 304 } 305 306 return "_blank"; 307 }, 308 309 /** 310 * Map the plugin's name to a filtered version more suitable for UI. 311 * 312 * @param aName The full-length name string of the plugin. 313 * @return the simplified name string. 314 */ 315 makeNicePluginName(aName) { 316 if (aName == "Shockwave Flash") { 317 return "Adobe Flash"; 318 } 319 // Regex checks if aName begins with "Java" + non-letter char 320 if (/^Java\W/.exec(aName)) { 321 return "Java"; 322 } 323 324 // Clean up the plugin name by stripping off parenthetical clauses, 325 // trailing version numbers or "plugin". 326 // EG, "Foo Bar (Linux) Plugin 1.23_02" --> "Foo Bar" 327 // Do this by first stripping the numbers, etc. off the end, and then 328 // removing "Plugin" (and then trimming to get rid of any whitespace). 329 // (Otherwise, something like "Java(TM) Plug-in 1.7.0_07" gets mangled) 330 let newName = aName 331 .replace(/\(.*?\)/g, "") 332 .replace(/[\s\d\.\-\_\(\)]+$/, "") 333 .replace(/\bplug-?in\b/i, "") 334 .trim(); 335 return newName; 336 }, 337 338 /** 339 * Returns true if |mimeType| is text-based, or false otherwise. 340 * 341 * @param mimeType 342 * The MIME type to check. 343 */ 344 mimeTypeIsTextBased(mimeType) { 345 return ( 346 mimeType.startsWith("text/") || 347 mimeType.endsWith("+xml") || 348 mimeType == "application/x-javascript" || 349 mimeType == "application/javascript" || 350 mimeType == "application/json" || 351 mimeType == "application/xml" 352 ); 353 }, 354 355 /** 356 * Returns true if we can show a find bar, including FAYT, for the specified 357 * document location. The location must not be in a blacklist of specific 358 * "about:" pages for which find is disabled. 359 * 360 * This can be called from the parent process or from content processes. 361 */ 362 canFindInPage(location) { 363 return ( 364 !location.startsWith("about:addons") && 365 !location.startsWith( 366 "chrome://mozapps/content/extensions/aboutaddons.html" 367 ) && 368 !location.startsWith("about:preferences") 369 ); 370 }, 371 372 _visibleToolbarsMap: new WeakMap(), 373 374 /** 375 * Return true if any or a specific toolbar that interacts with the content 376 * document is visible. 377 * 378 * @param {nsIDocShell} docShell The docShell instance that a toolbar should 379 * be interacting with 380 * @param {String} which Identifier of a specific toolbar 381 * @return {Boolean} 382 */ 383 isToolbarVisible(docShell, which) { 384 let window = this.getRootWindow(docShell); 385 if (!this._visibleToolbarsMap.has(window)) { 386 return false; 387 } 388 let toolbars = this._visibleToolbarsMap.get(window); 389 return !!toolbars && toolbars.has(which); 390 }, 391 392 /** 393 * Sets the --toolbarbutton-button-height CSS property on the closest 394 * toolbar to the provided element. Useful if you need to vertically 395 * center a position:absolute element within a toolbar that uses 396 * -moz-pack-align:stretch, and thus a height which is dependant on 397 * the font-size. 398 * 399 * @param element An element within the toolbar whose height is desired. 400 */ 401 async setToolbarButtonHeightProperty(element) { 402 let window = element.ownerGlobal; 403 let dwu = window.windowUtils; 404 let toolbarItem = element; 405 let urlBarContainer = element.closest("#urlbar-container"); 406 if (urlBarContainer) { 407 // The stop-reload-button, which is contained in #urlbar-container, 408 // needs to use #urlbar-container to calculate the bounds. 409 toolbarItem = urlBarContainer; 410 } 411 if (!toolbarItem) { 412 return; 413 } 414 let bounds = dwu.getBoundsWithoutFlushing(toolbarItem); 415 if (!bounds.height) { 416 await window.promiseDocumentFlushed(() => { 417 bounds = dwu.getBoundsWithoutFlushing(toolbarItem); 418 }); 419 } 420 if (bounds.height) { 421 toolbarItem.style.setProperty( 422 "--toolbarbutton-height", 423 bounds.height + "px" 424 ); 425 } 426 }, 427 428 /** 429 * Track whether a toolbar is visible for a given a docShell. 430 * 431 * @param {nsIDocShell} docShell The docShell instance that a toolbar should 432 * be interacting with 433 * @param {String} which Identifier of a specific toolbar 434 * @param {Boolean} [visible] Whether the toolbar is visible. Optional, 435 * defaults to `true`. 436 */ 437 trackToolbarVisibility(docShell, which, visible = true) { 438 // We have to get the root window object, because XPConnect WrappedNatives 439 // can't be used as WeakMap keys. 440 let window = this.getRootWindow(docShell); 441 let toolbars = this._visibleToolbarsMap.get(window); 442 if (!toolbars) { 443 toolbars = new Set(); 444 this._visibleToolbarsMap.set(window, toolbars); 445 } 446 if (!visible) { 447 toolbars.delete(which); 448 } else { 449 toolbars.add(which); 450 } 451 }, 452 453 /** 454 * Retrieve the root window object (i.e. the top-most content global) for a 455 * specific docShell object. 456 * 457 * @param {nsIDocShell} docShell 458 * @return {nsIDOMWindow} 459 */ 460 getRootWindow(docShell) { 461 return docShell.browsingContext.top.window; 462 }, 463 464 /** 465 * Trim the selection text to a reasonable size and sanitize it to make it 466 * safe for search query input. 467 * 468 * @param aSelection 469 * The selection text to trim. 470 * @param aMaxLen 471 * The maximum string length, defaults to a reasonable size if undefined. 472 * @return The trimmed selection text. 473 */ 474 trimSelection(aSelection, aMaxLen) { 475 // Selections of more than 150 characters aren't useful. 476 const maxLen = Math.min(aMaxLen || 150, aSelection.length); 477 478 if (aSelection.length > maxLen) { 479 // only use the first maxLen important chars. see bug 221361 480 let pattern = new RegExp("^(?:\\s*.){0," + maxLen + "}"); 481 pattern.test(aSelection); 482 aSelection = RegExp.lastMatch; 483 } 484 485 aSelection = aSelection.trim().replace(/\s+/g, " "); 486 487 if (aSelection.length > maxLen) { 488 aSelection = aSelection.substr(0, maxLen); 489 } 490 491 return aSelection; 492 }, 493 494 /** 495 * Retrieve the text selection details for the given window. 496 * 497 * @param aTopWindow 498 * The top window of the element containing the selection. 499 * @param aCharLen 500 * The maximum string length for the selection text. 501 * @return The selection details containing the full and trimmed selection text 502 * and link details for link selections. 503 */ 504 getSelectionDetails(aTopWindow, aCharLen) { 505 let focusedWindow = {}; 506 let focusedElement = Services.focus.getFocusedElementForWindow( 507 aTopWindow, 508 true, 509 focusedWindow 510 ); 511 focusedWindow = focusedWindow.value; 512 513 let selection = focusedWindow.getSelection(); 514 let selectionStr = selection.toString(); 515 let fullText; 516 517 let url; 518 let linkText; 519 520 // try getting a selected text in text input. 521 if (!selectionStr && focusedElement) { 522 // Don't get the selection for password fields. See bug 565717. 523 if ( 524 ChromeUtils.getClassName(focusedElement) === "HTMLTextAreaElement" || 525 (ChromeUtils.getClassName(focusedElement) === "HTMLInputElement" && 526 focusedElement.mozIsTextField(true)) 527 ) { 528 selection = focusedElement.editor.selection; 529 selectionStr = selection.toString(); 530 } 531 } 532 533 let collapsed = selection.isCollapsed; 534 535 if (selectionStr) { 536 // Have some text, let's figure out if it looks like a URL that isn't 537 // actually a link. 538 linkText = selectionStr.trim(); 539 if (/^(?:https?|ftp):/i.test(linkText)) { 540 try { 541 url = this.makeURI(linkText); 542 } catch (ex) {} 543 } else if (/^(?:[a-z\d-]+\.)+[a-z]+$/i.test(linkText)) { 544 // Check if this could be a valid url, just missing the protocol. 545 // Now let's see if this is an intentional link selection. Our guess is 546 // based on whether the selection begins/ends with whitespace or is 547 // preceded/followed by a non-word character. 548 549 // selection.toString() trims trailing whitespace, so we look for 550 // that explicitly in the first and last ranges. 551 let beginRange = selection.getRangeAt(0); 552 let delimitedAtStart = /^\s/.test(beginRange); 553 if (!delimitedAtStart) { 554 let container = beginRange.startContainer; 555 let offset = beginRange.startOffset; 556 if (container.nodeType == container.TEXT_NODE && offset > 0) { 557 delimitedAtStart = /\W/.test(container.textContent[offset - 1]); 558 } else { 559 delimitedAtStart = true; 560 } 561 } 562 563 let delimitedAtEnd = false; 564 if (delimitedAtStart) { 565 let endRange = selection.getRangeAt(selection.rangeCount - 1); 566 delimitedAtEnd = /\s$/.test(endRange); 567 if (!delimitedAtEnd) { 568 let container = endRange.endContainer; 569 let offset = endRange.endOffset; 570 if ( 571 container.nodeType == container.TEXT_NODE && 572 offset < container.textContent.length 573 ) { 574 delimitedAtEnd = /\W/.test(container.textContent[offset]); 575 } else { 576 delimitedAtEnd = true; 577 } 578 } 579 } 580 581 if (delimitedAtStart && delimitedAtEnd) { 582 try { 583 url = Services.uriFixup.createFixupURI( 584 linkText, 585 Services.uriFixup.FIXUP_FLAG_NONE 586 ); 587 } catch (ex) {} 588 } 589 } 590 } 591 592 if (selectionStr) { 593 // Pass up to 16K through unmolested. If an add-on needs more, they will 594 // have to use a content script. 595 fullText = selectionStr.substr(0, 16384); 596 selectionStr = this.trimSelection(selectionStr, aCharLen); 597 } 598 599 if (url && !url.host) { 600 url = null; 601 } 602 603 return { 604 text: selectionStr, 605 docSelectionIsCollapsed: collapsed, 606 fullText, 607 linkURL: url ? url.spec : null, 608 linkText: url ? linkText : "", 609 }; 610 }, 611 612 // Iterates through every docshell in the window and calls PermitUnload. 613 canCloseWindow(window) { 614 let docShell = window.docShell; 615 for (let i = 0; i < docShell.childCount; ++i) { 616 let childShell = docShell.getChildAt(i).QueryInterface(Ci.nsIDocShell); 617 let contentViewer = childShell.contentViewer; 618 if (contentViewer && !contentViewer.permitUnload()) { 619 return false; 620 } 621 } 622 623 return true; 624 }, 625 626 /** 627 * Replaces %s or %S in the provided url or postData with the given parameter, 628 * acccording to the best charset for the given url. 629 * 630 * @return [url, postData] 631 * @throws if nor url nor postData accept a param, but a param was provided. 632 */ 633 async parseUrlAndPostData(url, postData, param) { 634 let hasGETParam = /%s/i.test(url); 635 let decodedPostData = postData ? unescape(postData) : ""; 636 let hasPOSTParam = /%s/i.test(decodedPostData); 637 638 if (!hasGETParam && !hasPOSTParam) { 639 if (param) { 640 // If nor the url, nor postData contain parameters, but a parameter was 641 // provided, return the original input. 642 throw new Error( 643 "A param was provided but there's nothing to bind it to" 644 ); 645 } 646 return [url, postData]; 647 } 648 649 let charset = ""; 650 const re = /^(.*)\&mozcharset=([a-zA-Z][_\-a-zA-Z0-9]+)\s*$/; 651 let matches = url.match(re); 652 if (matches) { 653 [, url, charset] = matches; 654 } else { 655 // Try to fetch a charset from History. 656 try { 657 // Will return an empty string if character-set is not found. 658 let pageInfo = await PlacesUtils.history.fetch(url, { 659 includeAnnotations: true, 660 }); 661 if (pageInfo && pageInfo.annotations.has(PlacesUtils.CHARSET_ANNO)) { 662 charset = pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO); 663 } 664 } catch (ex) { 665 // makeURI() throws if url is invalid. 666 Cu.reportError(ex); 667 } 668 } 669 670 // encodeURIComponent produces UTF-8, and cannot be used for other charsets. 671 // escape() works in those cases, but it doesn't uri-encode +, @, and /. 672 // Therefore we need to manually replace these ASCII characters by their 673 // encodeURIComponent result, to match the behavior of nsEscape() with 674 // url_XPAlphas. 675 let encodedParam = ""; 676 if (charset && charset != "UTF-8") { 677 try { 678 let converter = Cc[ 679 "@mozilla.org/intl/scriptableunicodeconverter" 680 ].createInstance(Ci.nsIScriptableUnicodeConverter); 681 converter.charset = charset; 682 encodedParam = converter.ConvertFromUnicode(param) + converter.Finish(); 683 } catch (ex) { 684 encodedParam = param; 685 } 686 encodedParam = escape(encodedParam).replace( 687 /[+@\/]+/g, 688 encodeURIComponent 689 ); 690 } else { 691 // Default charset is UTF-8 692 encodedParam = encodeURIComponent(param); 693 } 694 695 url = url.replace(/%s/g, encodedParam).replace(/%S/g, param); 696 if (hasPOSTParam) { 697 postData = decodedPostData 698 .replace(/%s/g, encodedParam) 699 .replace(/%S/g, param); 700 } 701 return [url, postData]; 702 }, 703 704 /** 705 * Generate a document fragment for a localized string that has DOM 706 * node replacements. This avoids using getFormattedString followed 707 * by assigning to innerHTML. Fluent can probably replace this when 708 * it is in use everywhere. 709 * 710 * @param {Document} doc 711 * @param {String} msg 712 * The string to put replacements in. Fetch from 713 * a stringbundle using getString or GetStringFromName, 714 * or even an inserted dtd string. 715 * @param {Node|String} nodesOrStrings 716 * The replacement items. Can be a mix of Nodes 717 * and Strings. However, for correct behaviour, the 718 * number of items provided needs to exactly match 719 * the number of replacement strings in the l10n string. 720 * @returns {DocumentFragment} 721 * A document fragment. In the trivial case (no 722 * replacements), this will simply be a fragment with 1 723 * child, a text node containing the localized string. 724 */ 725 getLocalizedFragment(doc, msg, ...nodesOrStrings) { 726 // Ensure replacement points are indexed: 727 for (let i = 1; i <= nodesOrStrings.length; i++) { 728 if (!msg.includes("%" + i + "$S")) { 729 msg = msg.replace(/%S/, "%" + i + "$S"); 730 } 731 } 732 let numberOfInsertionPoints = msg.match(/%\d+\$S/g).length; 733 if (numberOfInsertionPoints != nodesOrStrings.length) { 734 Cu.reportError( 735 `Message has ${numberOfInsertionPoints} insertion points, ` + 736 `but got ${nodesOrStrings.length} replacement parameters!` 737 ); 738 } 739 740 let fragment = doc.createDocumentFragment(); 741 let parts = [msg]; 742 let insertionPoint = 1; 743 for (let replacement of nodesOrStrings) { 744 let insertionString = "%" + insertionPoint++ + "$S"; 745 let partIndex = parts.findIndex( 746 part => typeof part == "string" && part.includes(insertionString) 747 ); 748 if (partIndex == -1) { 749 fragment.appendChild(doc.createTextNode(msg)); 750 return fragment; 751 } 752 753 if (typeof replacement == "string") { 754 parts[partIndex] = parts[partIndex].replace( 755 insertionString, 756 replacement 757 ); 758 } else { 759 let [firstBit, lastBit] = parts[partIndex].split(insertionString); 760 parts.splice(partIndex, 1, firstBit, replacement, lastBit); 761 } 762 } 763 764 // Put everything in a document fragment: 765 for (let part of parts) { 766 if (typeof part == "string") { 767 if (part) { 768 fragment.appendChild(doc.createTextNode(part)); 769 } 770 } else { 771 fragment.appendChild(part); 772 } 773 } 774 return fragment; 775 }, 776 777 /** 778 * Returns a Promise which resolves when the given observer topic has been 779 * observed. 780 * 781 * @param {string} topic 782 * The topic to observe. 783 * @param {function(nsISupports, string)} [test] 784 * An optional test function which, when called with the 785 * observer's subject and data, should return true if this is the 786 * expected notification, false otherwise. 787 * @returns {Promise<object>} 788 */ 789 promiseObserved(topic, test = () => true) { 790 return new Promise(resolve => { 791 let observer = (subject, topic, data) => { 792 if (test(subject, data)) { 793 Services.obs.removeObserver(observer, topic); 794 resolve({ subject, data }); 795 } 796 }; 797 Services.obs.addObserver(observer, topic); 798 }); 799 }, 800 801 removeSingleTrailingSlashFromURL(aURL) { 802 // remove single trailing slash for http/https/ftp URLs 803 return aURL.replace(/^((?:http|https|ftp):\/\/[^/]+)\/$/, "$1"); 804 }, 805 806 /** 807 * Returns a URL which has been trimmed by removing 'http://' and any 808 * trailing slash (in http/https/ftp urls). 809 * Note that a trimmed url may not load the same page as the original url, so 810 * before loading it, it must be passed through URIFixup, to check trimming 811 * doesn't change its destination. We don't run the URIFixup check here, 812 * because trimURL is in the page load path (see onLocationChange), so it 813 * must be fast and simple. 814 * 815 * @param {string} aURL The URL to trim. 816 * @returns {string} The trimmed string. 817 */ 818 get trimURLProtocol() { 819 return "http://"; 820 }, 821 trimURL(aURL) { 822 let url = this.removeSingleTrailingSlashFromURL(aURL); 823 // Remove "http://" prefix. 824 return url.startsWith(this.trimURLProtocol) 825 ? url.substring(this.trimURLProtocol.length) 826 : url; 827 }, 828 829 recordSiteOriginTelemetry(aWindows, aIsGeckoView) { 830 Services.tm.idleDispatchToMainThread(() => { 831 this._recordSiteOriginTelemetry(aWindows, aIsGeckoView); 832 }); 833 }, 834 835 _recordSiteOriginTelemetry(aWindows, aIsGeckoView) { 836 let currentTime = Date.now(); 837 838 // default is 5 minutes 839 if (!this.min_interval) { 840 this.min_interval = Services.prefs.getIntPref( 841 "telemetry.number_of_site_origin.min_interval", 842 300000 843 ); 844 } 845 846 // Discard the first load because most of the time the first load only has 1 847 // tab and 1 window open, so it is useless to report it. 848 if ( 849 !this._lastRecordSiteOrigin || 850 currentTime < this._lastRecordSiteOrigin + this.min_interval 851 ) { 852 if (!this._lastRecordSiteOrigin) { 853 this._lastRecordSiteOrigin = currentTime; 854 } 855 return; 856 } 857 858 this._lastRecordSiteOrigin = currentTime; 859 860 // Geckoview and Desktop work differently. On desktop, aBrowser objects 861 // holds an array of tabs which we can use to get the <browser> objects. 862 // In Geckoview, it is apps' responsibility to keep track of the tabs, so 863 // there isn't an easy way for us to get the tabs. 864 let tabs = []; 865 if (aIsGeckoView) { 866 // To get all active windows; Each tab has its own window 867 tabs = aWindows; 868 } else { 869 for (const win of aWindows) { 870 tabs = tabs.concat(win.gBrowser.tabs); 871 } 872 } 873 874 let topLevelBC = []; 875 876 for (const tab of tabs) { 877 let browser; 878 if (aIsGeckoView) { 879 browser = tab.browser; 880 } else { 881 browser = tab.linkedBrowser; 882 } 883 884 if (browser.browsingContext) { 885 // This is the top level browsingContext 886 topLevelBC.push(browser.browsingContext); 887 } 888 } 889 890 const count = CanonicalBrowsingContext.countSiteOrigins(topLevelBC); 891 892 Services.telemetry 893 .getHistogramById("FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_ALL_TABS") 894 .add(count); 895 }, 896 897 /** 898 * Converts a property bag to object. 899 * @param {nsIPropertyBag} bag - The property bag to convert 900 * @returns {Object} - The object representation of the nsIPropertyBag 901 */ 902 propBagToObject(bag) { 903 function toValue(property) { 904 if (typeof property != "object") { 905 return property; 906 } 907 if (Array.isArray(property)) { 908 return property.map(this.toValue, this); 909 } 910 if (property && property instanceof Ci.nsIPropertyBag) { 911 return this.propBagToObject(property); 912 } 913 return property; 914 } 915 if (!(bag instanceof Ci.nsIPropertyBag)) { 916 throw new TypeError("Not a property bag"); 917 } 918 let result = {}; 919 for (let { name, value: property } of bag.enumerator) { 920 let value = toValue(property); 921 result[name] = value; 922 } 923 return result; 924 }, 925 926 /** 927 * Converts an object to a property bag. 928 * @param {Object} obj - The object to convert. 929 * @returns {nsIPropertyBag} - The property bag representation of the object. 930 */ 931 objectToPropBag(obj) { 932 function fromValue(value) { 933 if (typeof value == "function") { 934 return null; // Emulating the behavior of JSON.stringify with functions 935 } 936 if (Array.isArray(value)) { 937 return value.map(this.fromValue, this); 938 } 939 if (value == null || typeof value != "object") { 940 // Auto-converted to nsIVariant 941 return value; 942 } 943 return this.objectToPropBag(value); 944 } 945 946 if (obj == null || typeof obj != "object") { 947 throw new TypeError("Invalid object: " + obj); 948 } 949 let bag = Cc["@mozilla.org/hash-property-bag;1"].createInstance( 950 Ci.nsIWritablePropertyBag 951 ); 952 for (let k of Object.keys(obj)) { 953 let value = fromValue(obj[k]); 954 bag.setProperty(k, value); 955 } 956 return bag; 957 }, 958}; 959