InfoBar.sys.mjs (23349B)
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 /* eslint-disable no-use-before-define */ 6 const lazy = {}; 7 8 ChromeUtils.defineESModuleGetters(lazy, { 9 ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs", 10 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 11 RemoteL10n: "resource:///modules/asrouter/RemoteL10n.sys.mjs", 12 SpecialMessageActions: 13 "resource://messaging-system/lib/SpecialMessageActions.sys.mjs", 14 }); 15 16 const TYPES = { 17 UNIVERSAL: "universal", 18 GLOBAL: "global", 19 }; 20 21 const FTL_FILES = [ 22 "browser/newtab/asrouter.ftl", 23 "browser/defaultBrowserNotification.ftl", 24 "browser/profiles.ftl", 25 "browser/termsofuse.ftl", 26 ]; 27 28 class InfoBarNotification { 29 constructor(message, dispatch) { 30 this._dispatch = dispatch; 31 this.dispatchUserAction = this.dispatchUserAction.bind(this); 32 this.buttonCallback = this.buttonCallback.bind(this); 33 this.infobarCallback = this.infobarCallback.bind(this); 34 this.message = message; 35 this.notification = null; 36 const dismissPrefConfig = message?.content?.dismissOnPrefChange; 37 // If set, these are the prefs to watch for changes to auto-dismiss the infobar. 38 if (Array.isArray(dismissPrefConfig)) { 39 this._dismissPrefs = dismissPrefConfig; 40 } else if (dismissPrefConfig) { 41 this._dismissPrefs = [dismissPrefConfig]; 42 } else { 43 this._dismissPrefs = []; 44 } 45 this._prefObserver = null; 46 } 47 48 /** 49 * Ensure a hidden container of <a data-l10n-name> templates exists, and 50 * inject the request links using hrefs from message.content.linkUrls. 51 */ 52 _ensureLinkTemplatesFor(doc, names) { 53 let container = doc.getElementById("infobar-link-templates"); 54 // We inject a hidden <div> of <a data-l10n-name> templates into the 55 // document because Fluent’s DOM-overlay scans the page for those 56 // placeholders. 57 if (!container) { 58 container = doc.createElement("div"); 59 container.id = "infobar-link-templates"; 60 container.hidden = true; 61 doc.body.appendChild(container); 62 } 63 64 const linkUrls = this.message.content.linkUrls || {}; 65 for (let name of names) { 66 if (!container.querySelector(`a[data-l10n-name="${name}"]`)) { 67 const a = doc.createElement("a"); 68 a.dataset.l10nName = name; 69 a.href = linkUrls[name]; 70 container.appendChild(a); 71 } 72 } 73 } 74 75 /** 76 * Async helper to render a Fluent string. If the translation contains `<a 77 * data-l10n-name>`, it will parse and inject the associated link contained 78 * in the message. 79 */ 80 async _buildMessageFragment(doc, browser, stringId, args) { 81 // Get the raw HTML translation 82 const html = await lazy.RemoteL10n.formatLocalizableText({ 83 string_id: stringId, 84 ...(args && { args }), 85 }); 86 87 // If no inline anchors, just return a span 88 if (!html.includes('data-l10n-name="')) { 89 return lazy.RemoteL10n.createElement(doc, "span", { 90 content: { string_id: stringId, ...(args && { args }) }, 91 }); 92 } 93 94 // Otherwise parse it and set up a fragment 95 const temp = new DOMParser().parseFromString(html, "text/html").body; 96 const frag = doc.createDocumentFragment(); 97 98 // Prepare <a data-l10n-name> templates 99 const names = [...temp.querySelectorAll("a[data-l10n-name]")].map( 100 a => a.dataset.l10nName 101 ); 102 this._ensureLinkTemplatesFor(doc, names); 103 104 // Import each node and wire up any anchors it contains 105 for (const node of temp.childNodes) { 106 // Nodes from DOMParser belong to a different document, so importNode() 107 // clones them into our target doc 108 const importedNode = doc.importNode(node, true); 109 110 if (importedNode.nodeType === Node.ELEMENT_NODE) { 111 // collect this node if it's an anchor, and all child anchors 112 const anchors = []; 113 if (importedNode.matches("a[data-l10n-name]")) { 114 anchors.push(importedNode); 115 } 116 anchors.push(...importedNode.querySelectorAll("a[data-l10n-name]")); 117 118 const linkActions = this.message.content.linkActions || {}; 119 120 for (const a of anchors) { 121 const name = a.dataset.l10nName; 122 const template = doc 123 .getElementById("infobar-link-templates") 124 .querySelector(`a[data-l10n-name="${name}"]`); 125 if (!template) { 126 continue; 127 } 128 a.href = template.href; 129 a.addEventListener("click", e => { 130 e.preventDefault(); 131 // Open link URL 132 try { 133 lazy.SpecialMessageActions.handleAction( 134 { 135 type: "OPEN_URL", 136 data: { args: a.href, where: args?.where || "tab" }, 137 }, 138 browser 139 ); 140 } catch (err) { 141 console.error(`Error handling OPEN_URL action:`, err); 142 } 143 // Then fire the defined actions for that link, if applicable 144 if (linkActions[name]) { 145 try { 146 lazy.SpecialMessageActions.handleAction( 147 linkActions[name], 148 browser 149 ); 150 } catch (err) { 151 console.error( 152 `Error handling ${linkActions[name]} action:`, 153 err 154 ); 155 } 156 if (linkActions[name].dismiss) { 157 this.notification?.dismiss(); 158 } 159 } 160 }); 161 } 162 } 163 164 frag.appendChild(importedNode); 165 } 166 167 return frag; 168 } 169 170 /** 171 * Displays the infobar notification in the specified browser and sends an impression ping. 172 * Formats the message and buttons, and appends the notification. 173 * For universal infobars, only records an impression for the first instance. 174 * 175 * @param {object} browser - The browser reference for the currently selected tab. 176 */ 177 async showNotification(browser) { 178 let { content } = this.message; 179 let { gBrowser } = browser.ownerGlobal; 180 let doc = gBrowser.ownerDocument; 181 let notificationContainer; 182 if ([TYPES.GLOBAL, TYPES.UNIVERSAL].includes(content.type)) { 183 notificationContainer = browser.ownerGlobal.gNotificationBox; 184 } else { 185 notificationContainer = gBrowser.getNotificationBox(browser); 186 } 187 188 let priority = content.priority || notificationContainer.PRIORITY_SYSTEM; 189 190 let labelNode = await this.formatMessageConfig(doc, browser, content.text); 191 192 this.notification = await notificationContainer.appendNotification( 193 this.message.id, 194 { 195 label: labelNode, 196 image: content.icon || "chrome://branding/content/icon64.png", 197 priority, 198 eventCallback: this.infobarCallback, 199 style: content.style || {}, 200 }, 201 content.buttons.map(b => this.formatButtonConfig(b)), 202 true, // Disables clickjacking protections 203 content.dismissable 204 ); 205 // If the infobar is universal, only record an impression for the first 206 // instance. 207 if ( 208 content.type !== TYPES.UNIVERSAL || 209 !InfoBar._universalInfobars.length 210 ) { 211 this.addImpression(browser); 212 } 213 214 // Only add if the universal infobar is still active. Prevents race condition 215 // where a notification could add itself after removeUniversalInfobars(). 216 if ( 217 content.type === TYPES.UNIVERSAL && 218 InfoBar._activeInfobar?.message?.id === this.message.id 219 ) { 220 InfoBar._universalInfobars.push({ 221 box: notificationContainer, 222 notification: this.notification, 223 }); 224 } 225 226 // After the notification exists, attach a pref observer if applicable. 227 this._maybeAttachPrefObserver(); 228 } 229 230 _createLinkNode(doc, browser, { href, where = "tab", string_id, args, raw }) { 231 const a = doc.createElement("a"); 232 a.href = href; 233 a.addEventListener("click", e => { 234 e.preventDefault(); 235 lazy.SpecialMessageActions.handleAction( 236 { type: "OPEN_URL", data: { args: a.href, where } }, 237 browser 238 ); 239 }); 240 241 if (string_id) { 242 // wrap a localized span inside 243 const span = lazy.RemoteL10n.createElement(doc, "span", { 244 content: { string_id, ...(args && { args }) }, 245 }); 246 a.appendChild(span); 247 } else { 248 a.textContent = raw || ""; 249 } 250 251 return a; 252 } 253 254 async formatMessageConfig(doc, browser, content) { 255 const frag = doc.createDocumentFragment(); 256 const parts = Array.isArray(content) ? content : [content]; 257 258 for (const part of parts) { 259 if (!part) { 260 continue; 261 } 262 if (part.href) { 263 frag.appendChild(this._createLinkNode(doc, browser, part)); 264 continue; 265 } 266 267 if (part.string_id) { 268 const subFrag = await this._buildMessageFragment( 269 doc, 270 browser, 271 part.string_id, 272 part.args 273 ); 274 frag.appendChild(subFrag); 275 continue; 276 } 277 278 if (typeof part === "string") { 279 frag.appendChild(doc.createTextNode(part)); 280 continue; 281 } 282 283 if (part.raw && typeof part.raw === "string") { 284 frag.appendChild(doc.createTextNode(part.raw)); 285 } 286 } 287 288 return frag; 289 } 290 291 formatButtonConfig(button) { 292 let btnConfig = { callback: this.buttonCallback, ...button }; 293 // notificationbox will set correct data-l10n-id attributes if passed in 294 // using the l10n-id key. Otherwise the `button.label` text is used. 295 if (button.label.string_id) { 296 btnConfig["l10n-id"] = button.label.string_id; 297 } 298 299 return btnConfig; 300 } 301 302 handleImpressionAction(browser) { 303 const ALLOWED_IMPRESSION_ACTIONS = ["SET_PREF"]; 304 const impressionAction = this.message.content.impression_action; 305 const actions = 306 impressionAction.type === "MULTI_ACTION" 307 ? impressionAction.data.actions 308 : [impressionAction]; 309 310 actions.forEach(({ type, data, once }) => { 311 if (!ALLOWED_IMPRESSION_ACTIONS.includes(type)) { 312 return; 313 } 314 315 let { messageImpressions } = lazy.ASRouter.state; 316 // If we only want to perform the action on first impression, ensure no 317 // impressions exist for this message. 318 if (once && messageImpressions[this.message.id]?.length) { 319 return; 320 } 321 322 data.onImpression = true; 323 try { 324 lazy.SpecialMessageActions.handleAction({ type, data }, browser); 325 } catch (err) { 326 console.error(`Error handling ${type} impression action:`, err); 327 } 328 }); 329 } 330 331 addImpression(browser) { 332 // If the message has an impression action, handle it before dispatching the 333 // impression. `this._dispatch` may be async and we want to ensure we have a 334 // consistent impression count when handling impression actions that should 335 // only occur once. 336 if (this.message.content.impression_action) { 337 this.handleImpressionAction(browser); 338 } 339 // Record an impression in ASRouter for frequency capping 340 this._dispatch({ type: "IMPRESSION", data: this.message }); 341 // Send a user impression telemetry ping 342 this.sendUserEventTelemetry("IMPRESSION"); 343 } 344 345 /** 346 * Callback fired when a button in the infobar is clicked. 347 * 348 * @param {Element} notificationBox - The `<notification-message>` element representing the infobar. 349 * @param {object} btnDescription - An object describing the button, includes the label, the action with an optional dismiss property, and primary button styling. 350 * @param {Element} target - The <button> DOM element that was clicked. 351 * @returns {boolean} `true` to keep the infobar open, `false` to dismiss it. 352 */ 353 buttonCallback(notificationBox, btnDescription, target) { 354 this.dispatchUserAction( 355 btnDescription.action, 356 target.ownerGlobal.gBrowser.selectedBrowser 357 ); 358 let isPrimary = target.classList.contains("primary"); 359 let eventName = isPrimary 360 ? "CLICK_PRIMARY_BUTTON" 361 : "CLICK_SECONDARY_BUTTON"; 362 this.sendUserEventTelemetry(eventName); 363 364 // Prevents infobar dismissal when dismiss is explicitly set to `false` 365 return btnDescription.action?.dismiss === false; 366 } 367 368 dispatchUserAction(action, selectedBrowser) { 369 this._dispatch({ type: "USER_ACTION", data: action }, selectedBrowser); 370 } 371 372 /** 373 * Handles infobar events triggered by the notification interactions (excluding button clicks). 374 * Cleans up the notification and active infobar state when the infobar is removed or dismissed. 375 * If the removed infobar is universal, ensures all universal infobars and related observers are also removed. 376 * 377 * @param {string} eventType - The type of event (e.g., "removed"). 378 */ 379 infobarCallback(eventType) { 380 // Clean up the pref observer on any removal/dismissal path. 381 this._removePrefObserver(); 382 const wasUniversal = this.message.content.type === TYPES.UNIVERSAL; 383 const isActiveMessage = 384 InfoBar._activeInfobar?.message?.id === this.message.id; 385 if (eventType === "removed") { 386 this.notification = null; 387 if (isActiveMessage) { 388 InfoBar._activeInfobar = null; 389 } 390 } else if (this.notification) { 391 this.sendUserEventTelemetry("DISMISSED"); 392 this.notification = null; 393 394 if (isActiveMessage) { 395 InfoBar._activeInfobar = null; 396 } 397 } 398 // If one instance of universal infobar is removed, remove all instances and 399 // the new window observer 400 if (wasUniversal && isActiveMessage && InfoBar._universalInfobars.length) { 401 this.removeUniversalInfobars(); 402 } 403 } 404 405 /** 406 * If content.dismissOnPrefChange is set (string or array), observe those 407 * pref(s) and dismiss the infobar whenever any of them changes (including 408 * when it is set for the first time). 409 */ 410 _maybeAttachPrefObserver() { 411 if (!this._dismissPrefs?.length || this._prefObserver) { 412 return; 413 } 414 // Weak observer to avoid leaks. 415 this._prefObserver = { 416 QueryInterface: ChromeUtils.generateQI([ 417 "nsIObserver", 418 "nsISupportsWeakReference", 419 ]), 420 observe: (subject, topic, data) => { 421 if (topic === "nsPref:changed" && this._dismissPrefs.includes(data)) { 422 try { 423 this.notification?.dismiss(); 424 } catch (e) { 425 console.error("Failed to dismiss infobar on pref change:", e); 426 } 427 } 428 }, 429 }; 430 try { 431 // Register each pref with a weak observer and ignore per-pref failures. 432 for (const pref of this._dismissPrefs) { 433 try { 434 Services.prefs.addObserver(pref, this._prefObserver, true); 435 } catch (_) {} 436 } 437 } catch (e) { 438 console.error( 439 "Failed to add prefs observer(s) for dismissOnPrefChange:", 440 e 441 ); 442 } 443 } 444 445 _removePrefObserver() { 446 if (!this._dismissPrefs?.length || !this._prefObserver) { 447 return; 448 } 449 for (const pref of this._dismissPrefs) { 450 try { 451 Services.prefs.removeObserver(pref, this._prefObserver); 452 } catch (_) { 453 // Ignore as the observer might already be removed during shutdown/teardown. 454 } 455 } 456 this._prefObserver = null; 457 } 458 459 /** 460 * Removes all active universal infobars from each window. 461 * Unregisters the observer for new windows, clears the tracking array, and resets the 462 * active infobar state. 463 */ 464 removeUniversalInfobars() { 465 // Remove the new window observer 466 if (InfoBar._observingWindowOpened) { 467 InfoBar._observingWindowOpened = false; 468 Services.obs.removeObserver(InfoBar, "domwindowopened"); 469 } 470 // Remove the universal infobar 471 InfoBar._universalInfobars.forEach(({ box, notification }) => { 472 try { 473 if (box && notification) { 474 box.removeNotification(notification); 475 } 476 } catch (error) { 477 console.error("Failed to remove notification: ", error); 478 } 479 }); 480 InfoBar._universalInfobars = []; 481 482 if (InfoBar._activeInfobar?.message.content.type === TYPES.UNIVERSAL) { 483 InfoBar._activeInfobar = null; 484 } 485 } 486 487 sendUserEventTelemetry(event) { 488 const ping = { 489 message_id: this.message.id, 490 event, 491 }; 492 this._dispatch({ 493 type: "INFOBAR_TELEMETRY", 494 data: { action: "infobar_user_event", ...ping }, 495 }); 496 } 497 } 498 499 export const InfoBar = { 500 _activeInfobar: null, 501 _universalInfobars: [], 502 _observingWindowOpened: false, 503 504 maybeLoadCustomElement(win) { 505 if (!win.customElements.get("remote-text")) { 506 Services.scriptloader.loadSubScript( 507 "chrome://browser/content/asrouter/components/remote-text.js", 508 win 509 ); 510 } 511 }, 512 513 maybeInsertFTL(win) { 514 FTL_FILES.forEach(path => win.MozXULElement.insertFTLIfNeeded(path)); 515 }, 516 517 /** 518 * Helper to check the window's state and whether it's a 519 * private browsing window, a popup or a taskbar tab. 520 * 521 * @returns {boolean} `true` if the window is valid for showing an infobar. 522 */ 523 isValidInfobarWindow(win) { 524 if (!win || win.closed) { 525 return false; 526 } 527 if (lazy.PrivateBrowsingUtils.isWindowPrivate(win)) { 528 return false; 529 } 530 if (!win.toolbar?.visible) { 531 // Popups don't have a visible toolbar 532 return false; 533 } 534 if (win.document.documentElement.hasAttribute("taskbartab")) { 535 return false; 536 } 537 return true; 538 }, 539 540 /** 541 * Displays the universal infobar in all open, fully loaded browser windows. 542 * 543 * @param {InfoBarNotification} notification - The notification instance to display. 544 */ 545 async showNotificationAllWindows(notification) { 546 for (let win of Services.wm.getEnumerator("navigator:browser")) { 547 if ( 548 !win.gBrowser || 549 win.document?.readyState !== "complete" || 550 !this.isValidInfobarWindow(win) 551 ) { 552 continue; 553 } 554 this.maybeLoadCustomElement(win); 555 this.maybeInsertFTL(win); 556 const browser = win.gBrowser.selectedBrowser; 557 await notification.showNotification(browser); 558 } 559 }, 560 561 _maybeReplaceActiveInfoBar(nextMessage) { 562 if (!this._activeInfobar) { 563 return false; 564 } 565 const replacementEligible = nextMessage?.content?.canReplace || []; 566 const activeId = this._activeInfobar.message?.id; 567 if (!replacementEligible.includes(activeId)) { 568 return false; 569 } 570 const activeType = this._activeInfobar.message?.content?.type; 571 if (activeType === TYPES.UNIVERSAL) { 572 this._activeInfobar.notification?.removeUniversalInfobars(); 573 } else { 574 try { 575 this._activeInfobar.notification?.notification.dismiss(); 576 } catch (e) { 577 console.error("Failed to dismiss active infobar:", e); 578 } 579 } 580 this._activeInfobar = null; 581 return true; 582 }, 583 584 /** 585 * Displays an infobar notification in the specified browser window. 586 * For the first universal infobar, shows the notification in all open browser windows 587 * and sets up an observer to handle new windows. 588 * For non-universal, displays the notification only in the given window. 589 * 590 * @param {object} browser - The browser reference for the currently selected tab. 591 * @param {object} message - The message object describing the infobar content. 592 * @param {function} dispatch - The dispatch function for actions. 593 * @param {boolean} universalInNewWin - `True` if this is a universal infobar for a new window. 594 * @returns {Promise<InfoBarNotification|null>} The notification instance, or null if not shown. 595 */ 596 async showInfoBarMessage(browser, message, dispatch, universalInNewWin) { 597 const win = browser?.ownerGlobal; 598 if (!this.isValidInfobarWindow(win)) { 599 return null; 600 } 601 const isUniversal = message.content.type === TYPES.UNIVERSAL; 602 // Check if this is the first instance of a universal infobar 603 const isFirstUniversal = !universalInNewWin && isUniversal; 604 // Prevent stacking multiple infobars 605 if (this._activeInfobar && !universalInNewWin) { 606 // Check if infobar is configured to replace the current infobar. 607 if (!this._maybeReplaceActiveInfoBar(message)) { 608 return null; 609 } 610 } 611 612 this.maybeLoadCustomElement(win); 613 this.maybeInsertFTL(win); 614 615 let notification = new InfoBarNotification(message, dispatch); 616 617 if (!universalInNewWin) { 618 this._activeInfobar = { message, dispatch, notification }; 619 } 620 621 if (isFirstUniversal) { 622 await this.showNotificationAllWindows(notification); 623 if (!this._observingWindowOpened) { 624 this._observingWindowOpened = true; 625 Services.obs.addObserver(this, "domwindowopened"); 626 } else { 627 // TODO: At least during testing it seems that we can get here more 628 // than once without passing through removeUniversalInfobars(). Is 629 // this expected? 630 console.warn( 631 "InfoBar: Already observing new windows for universal infobar." 632 ); 633 } 634 } else { 635 await notification.showNotification(browser); 636 } 637 638 if (!universalInNewWin) { 639 this._activeInfobar = { message, dispatch, notification }; 640 // If the window closes before the user interacts with the active infobar, 641 // clear it 642 win.addEventListener( 643 "unload", 644 () => { 645 // Remove this window’s stale entry 646 InfoBar._universalInfobars = InfoBar._universalInfobars.filter( 647 ({ box }) => box.ownerGlobal !== win 648 ); 649 650 if (isUniversal) { 651 // If there’s still at least one live universal infobar, 652 // make it the active infobar; otherwise clear the active infobar 653 const nextEntry = InfoBar._universalInfobars.find( 654 ({ box }) => !box.ownerGlobal?.closed 655 ); 656 const nextNotification = nextEntry?.notification; 657 InfoBar._activeInfobar = nextNotification 658 ? { message, dispatch, nextNotification } 659 : null; 660 } else { 661 // Non-universal always clears on unload 662 InfoBar._activeInfobar = null; 663 } 664 }, 665 { once: true } 666 ); 667 } 668 669 return notification; 670 }, 671 672 /** 673 * Observer callback fired when a new window is opened. 674 * If the topic is "domwindowopened" and the window is a valid target, 675 * the universal infobar will be shown in the new window once loaded. 676 * 677 * @param {Window} aSubject - The newly opened window. 678 * @param {string} aTopic - The topic of the observer notification. 679 */ 680 observe(aSubject, aTopic) { 681 if (aTopic !== "domwindowopened") { 682 return; 683 } 684 const win = aSubject; 685 686 if (!this.isValidInfobarWindow(win)) { 687 return; 688 } 689 690 const { message, dispatch } = this._activeInfobar || {}; 691 if (!message || message.content.type !== TYPES.UNIVERSAL) { 692 return; 693 } 694 695 const onWindowReady = () => { 696 if (!win.gBrowser || win.closed) { 697 return; 698 } 699 if ( 700 !InfoBar._activeInfobar || 701 InfoBar._activeInfobar.message !== message 702 ) { 703 return; 704 } 705 this.showInfoBarMessage( 706 win.gBrowser.selectedBrowser, 707 message, 708 dispatch, 709 true 710 ); 711 }; 712 713 if (win.document?.readyState === "complete") { 714 onWindowReady(); 715 } else { 716 win.addEventListener("load", onWindowReady, { once: true }); 717 } 718 }, 719 };