GeckoViewProgress.sys.mjs (17978B)
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 file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; 6 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 7 8 const lazy = {}; 9 10 XPCOMUtils.defineLazyServiceGetter( 11 lazy, 12 "OverrideService", 13 "@mozilla.org/security/certoverride;1", 14 Ci.nsICertOverrideService 15 ); 16 17 XPCOMUtils.defineLazyServiceGetter( 18 lazy, 19 "IDNService", 20 "@mozilla.org/network/idn-service;1", 21 Ci.nsIIDNService 22 ); 23 24 ChromeUtils.defineESModuleGetters(lazy, { 25 BrowserTelemetryUtils: "resource://gre/modules/BrowserTelemetryUtils.sys.mjs", 26 GleanStopwatch: "resource://gre/modules/GeckoViewTelemetry.sys.mjs", 27 }); 28 29 var IdentityHandler = { 30 // The definitions below should be kept in sync with those in GeckoView.ProgressListener.SecurityInformation 31 // No trusted identity information. No site identity icon is shown. 32 IDENTITY_MODE_UNKNOWN: 0, 33 34 // Domain-Validation SSL CA-signed domain verification (DV). 35 IDENTITY_MODE_IDENTIFIED: 1, 36 37 // Extended-Validation SSL CA-signed identity information (EV). A more rigorous validation process. 38 IDENTITY_MODE_VERIFIED: 2, 39 40 // The following mixed content modes are only used if "security.mixed_content.block_active_content" 41 // is enabled. Our Java frontend coalesces them into one indicator. 42 43 // No mixed content information. No mixed content icon is shown. 44 MIXED_MODE_UNKNOWN: 0, 45 46 // Blocked active mixed content. 47 MIXED_MODE_CONTENT_BLOCKED: 1, 48 49 // Loaded active mixed content. 50 MIXED_MODE_CONTENT_LOADED: 2, 51 52 /** 53 * Determines the identity mode corresponding to the icon we show in the urlbar. 54 */ 55 getIdentityMode: function getIdentityMode(aState) { 56 if (aState & Ci.nsIWebProgressListener.STATE_IDENTITY_EV_TOPLEVEL) { 57 return this.IDENTITY_MODE_VERIFIED; 58 } 59 60 if (aState & Ci.nsIWebProgressListener.STATE_IS_SECURE) { 61 return this.IDENTITY_MODE_IDENTIFIED; 62 } 63 64 return this.IDENTITY_MODE_UNKNOWN; 65 }, 66 67 getMixedDisplayMode: function getMixedDisplayMode(aState) { 68 if (aState & Ci.nsIWebProgressListener.STATE_LOADED_MIXED_DISPLAY_CONTENT) { 69 return this.MIXED_MODE_CONTENT_LOADED; 70 } 71 72 if ( 73 aState & Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_DISPLAY_CONTENT 74 ) { 75 return this.MIXED_MODE_CONTENT_BLOCKED; 76 } 77 78 return this.MIXED_MODE_UNKNOWN; 79 }, 80 81 getMixedActiveMode: function getActiveDisplayMode(aState) { 82 // Only show an indicator for loaded mixed content if the pref to block it is enabled 83 if ( 84 aState & Ci.nsIWebProgressListener.STATE_LOADED_MIXED_ACTIVE_CONTENT && 85 !Services.prefs.getBoolPref("security.mixed_content.block_active_content") 86 ) { 87 return this.MIXED_MODE_CONTENT_LOADED; 88 } 89 90 if (aState & Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_ACTIVE_CONTENT) { 91 return this.MIXED_MODE_CONTENT_BLOCKED; 92 } 93 94 return this.MIXED_MODE_UNKNOWN; 95 }, 96 97 /** 98 * Determine the identity of the page being displayed by examining its SSL cert 99 * (if available). Return the data needed to update the UI. 100 */ 101 checkIdentity: function checkIdentity(aState, aBrowser) { 102 const identityMode = this.getIdentityMode(aState); 103 const mixedDisplay = this.getMixedDisplayMode(aState); 104 const mixedActive = this.getMixedActiveMode(aState); 105 const result = { 106 mode: { 107 identity: identityMode, 108 mixed_display: mixedDisplay, 109 mixed_active: mixedActive, 110 }, 111 }; 112 113 if (aBrowser.contentPrincipal) { 114 result.origin = aBrowser.contentPrincipal.originNoSuffix; 115 } 116 117 // Don't show identity data for pages with an unknown identity or if any 118 // mixed content is loaded (mixed display content is loaded by default). 119 if ( 120 identityMode === this.IDENTITY_MODE_UNKNOWN || 121 aState & Ci.nsIWebProgressListener.STATE_IS_BROKEN || 122 aState & Ci.nsIWebProgressListener.STATE_IS_INSECURE 123 ) { 124 result.secure = false; 125 return result; 126 } 127 128 result.secure = true; 129 130 let uri = aBrowser.currentURI || {}; 131 try { 132 uri = Services.io.createExposableURI(uri); 133 } catch (e) {} 134 135 try { 136 result.host = lazy.IDNService.convertToDisplayIDN(uri.host); 137 } catch (e) { 138 result.host = uri.host; 139 } 140 141 if (!aBrowser.securityUI.secInfo) { 142 return result; 143 } 144 145 const cert = aBrowser.securityUI.secInfo.serverCert; 146 147 result.certificate = 148 aBrowser.securityUI.secInfo.serverCert.getBase64DERString(); 149 150 try { 151 result.securityException = lazy.OverrideService.hasMatchingOverride( 152 uri.host, 153 uri.port, 154 {}, 155 cert, 156 {} 157 ); 158 159 // If an override exists, the connection is being allowed but should not 160 // be considered secure. 161 result.secure = !result.securityException; 162 } catch (e) {} 163 164 return result; 165 }, 166 }; 167 168 class Tracker { 169 constructor(aModule) { 170 this._module = aModule; 171 } 172 173 get eventDispatcher() { 174 return this._module.eventDispatcher; 175 } 176 177 get browser() { 178 return this._module.browser; 179 } 180 181 QueryInterface = ChromeUtils.generateQI(["nsIWebProgressListener"]); 182 } 183 184 class ProgressTracker extends Tracker { 185 constructor(aModule) { 186 super(aModule); 187 188 this.pageLoadStopwatch = new lazy.GleanStopwatch( 189 Glean.geckoview.pageLoadTime 190 ); 191 this.pageReloadStopwatch = new lazy.GleanStopwatch( 192 Glean.geckoview.pageReloadTime 193 ); 194 this.pageLoadProgressStopwatch = new lazy.GleanStopwatch( 195 Glean.geckoview.pageLoadProgressTime 196 ); 197 198 this.clear(); 199 this._eventReceived = null; 200 } 201 202 start(aUri) { 203 debug`ProgressTracker start ${aUri}`; 204 205 if (this._eventReceived) { 206 // A request was already in process, let's cancel it 207 this.stop(/* isSuccess */ false); 208 } 209 210 this._eventReceived = new Set(); 211 this.clear(); 212 const data = this._data; 213 214 if (aUri === "about:blank") { 215 data.uri = null; 216 return; 217 } 218 219 this.pageLoadProgressStopwatch.start(); 220 221 data.uri = aUri; 222 data.pageStart = true; 223 this.updateProgress(); 224 } 225 226 changeLocation(aUri) { 227 debug`ProgressTracker changeLocation ${aUri}`; 228 229 const data = this._data; 230 data.locationChange = true; 231 data.uri = aUri; 232 } 233 234 stop(aIsSuccess) { 235 debug`ProgressTracker stop`; 236 237 if (!this._eventReceived) { 238 // No request in progress 239 return; 240 } 241 242 if (aIsSuccess) { 243 this.pageLoadProgressStopwatch.finish(); 244 } else { 245 this.pageLoadProgressStopwatch.cancel(); 246 } 247 248 const data = this._data; 249 data.pageStop = true; 250 this.updateProgress(); 251 this._eventReceived = null; 252 } 253 254 onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) { 255 debug`ProgressTracker onStateChange: isTopLevel=${aWebProgress.isTopLevel}, 256 flags=${aStateFlags}, status=${aStatus}`; 257 258 if (!aWebProgress || !aWebProgress.isTopLevel) { 259 return; 260 } 261 262 const { displaySpec } = aRequest.QueryInterface(Ci.nsIChannel).URI; 263 264 if (aRequest.URI.schemeIs("about")) { 265 return; 266 } 267 268 debug`ProgressTracker onStateChange: uri=${displaySpec}`; 269 270 const isPageReload = 271 (aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_RELOAD) != 0; 272 const stopwatch = isPageReload 273 ? this.pageReloadStopwatch 274 : this.pageLoadStopwatch; 275 276 const isStart = (aStateFlags & Ci.nsIWebProgressListener.STATE_START) != 0; 277 const isStop = (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) != 0; 278 const isRedirecting = 279 (aStateFlags & Ci.nsIWebProgressListener.STATE_REDIRECTING) != 0; 280 281 if (isStart) { 282 stopwatch.start(); 283 this.start(displaySpec); 284 } else if (isStop && !aWebProgress.isLoadingDocument) { 285 stopwatch.finish(); 286 this.stop(aStatus == Cr.NS_OK); 287 } else if (isRedirecting) { 288 stopwatch.start(); 289 this.start(displaySpec); 290 } 291 292 // During history naviation, global window is recycled, so pagetitlechanged isn't fired 293 // Although Firefox Desktop always set title by onLocationChange, to reduce title change call, 294 // we only send title during history navigation. 295 if ((aStateFlags & Ci.nsIWebProgressListener.STATE_RESTORING) != 0) { 296 this.eventDispatcher.sendRequest({ 297 type: "GeckoView:PageTitleChanged", 298 title: this.browser.contentTitle, 299 }); 300 } 301 } 302 303 onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) { 304 if ( 305 !aWebProgress || 306 !aWebProgress.isTopLevel || 307 !aLocationURI || 308 aLocationURI.schemeIs("about") 309 ) { 310 return; 311 } 312 313 debug`ProgressTracker onLocationChange: location=${aLocationURI.displaySpec}, 314 flags=${aFlags}`; 315 316 if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) { 317 this.stop(/* isSuccess */ false); 318 } else { 319 this.changeLocation(aLocationURI.displaySpec); 320 } 321 } 322 323 handleEvent(aEvent) { 324 if (!this._eventReceived || this._eventReceived.has(aEvent.name)) { 325 // Either we're not tracking or we have received this event already 326 return; 327 } 328 329 const data = this._data; 330 331 if (!data.uri || data.uri !== aEvent.data?.uri) { 332 return; 333 } 334 335 debug`ProgressTracker handleEvent: ${aEvent.name}`; 336 337 let needsUpdate = false; 338 339 switch (aEvent.name) { 340 case "DOMContentLoaded": 341 needsUpdate = needsUpdate || !data.parsed; 342 // if page_load.progressbar_completion is set to 1, we complete the page load progress when 343 // DOMContentLoaded is received. 344 if (this._progressBarCompletion == 1) { 345 data.completeProgress = true; 346 } else { 347 data.parsed = true; 348 } 349 break; 350 case "MozAfterPaint": 351 needsUpdate = needsUpdate || !data.firstPaint; 352 // if page_load.progressbar_completion is set to 2, we complete the page load progress at 353 // the first MozAfterPaint after DOMContentLoaded is received. 354 if (this._progressBarCompletion == 2 && data.parsed) { 355 data.completeProgress = true; 356 } else { 357 data.firstPaint = true; 358 } 359 break; 360 case "pageshow": 361 needsUpdate = needsUpdate || !data.pageShow; 362 data.pageShow = true; 363 break; 364 } 365 366 this._eventReceived.add(aEvent.name); 367 368 if (needsUpdate) { 369 this.updateProgress(); 370 } 371 } 372 373 clear() { 374 this._data = { 375 prev: 0, 376 uri: null, 377 locationChange: false, 378 pageStart: false, 379 pageStop: false, 380 firstPaint: false, 381 pageShow: false, 382 parsed: false, 383 completeProgress: false, 384 }; 385 this._progressBarCompletion = Services.prefs.getIntPref( 386 "page_load.progressbar_completion" 387 ); 388 } 389 390 _debugData() { 391 return { 392 prev: this._data.prev, 393 uri: this._data.uri, 394 locationChange: this._data.locationChange, 395 pageStart: this._data.pageStart, 396 pageStop: this._data.pageStop, 397 firstPaint: this._data.firstPaint, 398 pageShow: this._data.pageShow, 399 parsed: this._data.parsed, 400 }; 401 } 402 403 updateProgress() { 404 debug`ProgressTracker updateProgress`; 405 406 const data = this._data; 407 408 if (!this._eventReceived || !data.uri) { 409 return; 410 } 411 412 let progress = 0; 413 if (data.pageStop || data.pageShow || data.completeProgress) { 414 progress = 100; 415 } else if (data.firstPaint) { 416 progress = 80; 417 } else if (data.parsed) { 418 progress = 55; 419 } else if (data.locationChange) { 420 progress = 30; 421 } else if (data.pageStart) { 422 progress = 15; 423 } 424 425 if (data.prev >= progress) { 426 return; 427 } 428 429 debug`ProgressTracker updateProgress data=${this._debugData()} 430 progress=${progress}`; 431 432 this.eventDispatcher.sendRequest({ 433 type: "GeckoView:ProgressChanged", 434 progress, 435 }); 436 437 data.prev = progress; 438 } 439 } 440 441 class StateTracker extends Tracker { 442 constructor(aModule) { 443 super(aModule); 444 this._inProgress = false; 445 this._uri = null; 446 } 447 448 start(aUri) { 449 this._inProgress = true; 450 this._uri = aUri; 451 this.eventDispatcher.sendRequest({ 452 type: "GeckoView:PageStart", 453 uri: aUri, 454 }); 455 } 456 457 stop(aIsSuccess) { 458 if (!this._inProgress) { 459 // No request in progress 460 return; 461 } 462 463 this._inProgress = false; 464 this._uri = null; 465 466 this.eventDispatcher.sendRequest({ 467 type: "GeckoView:PageStop", 468 success: aIsSuccess, 469 }); 470 471 lazy.BrowserTelemetryUtils.recordSiteOriginTelemetry( 472 Services.wm.getEnumerator("navigator:geckoview"), 473 true 474 ); 475 } 476 477 onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) { 478 debug`StateTracker onStateChange: isTopLevel=${aWebProgress.isTopLevel}, 479 flags=${aStateFlags}, status=${aStatus} 480 loadType=${aWebProgress.loadType}`; 481 482 if (!aWebProgress.isTopLevel) { 483 return; 484 } 485 486 const { displaySpec } = aRequest.QueryInterface(Ci.nsIChannel).URI; 487 const isStart = (aStateFlags & Ci.nsIWebProgressListener.STATE_START) != 0; 488 const isStop = (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) != 0; 489 490 if (isStart) { 491 this.start(displaySpec); 492 } else if (isStop && !aWebProgress.isLoadingDocument) { 493 this.stop(aStatus == Cr.NS_OK); 494 } 495 } 496 } 497 498 class SecurityTracker extends Tracker { 499 constructor(aModule) { 500 super(aModule); 501 this._hostChanged = false; 502 } 503 504 onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) { 505 debug`SecurityTracker onLocationChange: location=${aLocationURI.displaySpec}, 506 flags=${aFlags}`; 507 508 this._hostChanged = true; 509 } 510 511 onSecurityChange(aWebProgress, aRequest, aState) { 512 debug`onSecurityChange`; 513 514 // Don't need to do anything if the data we use to update the UI hasn't changed 515 if (this._state === aState && !this._hostChanged) { 516 return; 517 } 518 519 this._state = aState; 520 this._hostChanged = false; 521 522 const identity = IdentityHandler.checkIdentity(aState, this.browser); 523 524 this.eventDispatcher.sendRequest({ 525 type: "GeckoView:SecurityChanged", 526 identity, 527 }); 528 } 529 } 530 531 export class GeckoViewProgress extends GeckoViewModule { 532 onEnable() { 533 debug`onEnable`; 534 535 this._fireInitialLoad(); 536 this._initialAboutBlank = true; 537 538 this._progressTracker = new ProgressTracker(this); 539 this._securityTracker = new SecurityTracker(this); 540 this._stateTracker = new StateTracker(this); 541 542 const flags = 543 Ci.nsIWebProgress.NOTIFY_STATE_NETWORK | 544 Ci.nsIWebProgress.NOTIFY_SECURITY | 545 Ci.nsIWebProgress.NOTIFY_LOCATION; 546 this.progressFilter = Cc[ 547 "@mozilla.org/appshell/component/browser-status-filter;1" 548 ].createInstance(Ci.nsIWebProgress); 549 this.progressFilter.addProgressListener(this, flags); 550 this.browser.addProgressListener(this.progressFilter, flags); 551 Services.obs.addObserver(this, "oop-frameloader-crashed"); 552 this.registerListener("GeckoView:FlushSessionState"); 553 } 554 555 onDisable() { 556 debug`onDisable`; 557 558 if (this.progressFilter) { 559 this.progressFilter.removeProgressListener(this); 560 this.browser.removeProgressListener(this.progressFilter); 561 } 562 563 Services.obs.removeObserver(this, "oop-frameloader-crashed"); 564 this.unregisterListener("GeckoView:FlushSessionState"); 565 } 566 567 receiveMessage(aMsg) { 568 debug`receiveMessage: ${aMsg.name}`; 569 570 switch (aMsg.name) { 571 case "DOMContentLoaded": // fall-through 572 case "MozAfterPaint": // fall-through 573 case "pageshow": { 574 this._progressTracker?.handleEvent(aMsg); 575 break; 576 } 577 } 578 } 579 580 onEvent(aEvent, aData) { 581 debug`onEvent: event=${aEvent}, data=${aData}`; 582 583 switch (aEvent) { 584 case "GeckoView:FlushSessionState": 585 this.messageManager.sendAsyncMessage("GeckoView:FlushSessionState"); 586 break; 587 } 588 } 589 590 onStateChange(...args) { 591 // GeckoView never gets PageStart or PageStop for about:blank because we 592 // set nodefaultsrc to true unconditionally so we can assume here that 593 // we're starting a page load for a non-blank page (or a consumer-initiated 594 // about:blank load). 595 this._initialAboutBlank = false; 596 597 this._progressTracker.onStateChange(...args); 598 this._stateTracker.onStateChange(...args); 599 } 600 601 onSecurityChange(...args) { 602 // We don't report messages about the initial about:blank 603 if (this._initialAboutBlank) { 604 return; 605 } 606 607 this._securityTracker.onSecurityChange(...args); 608 } 609 610 onLocationChange(...args) { 611 this._securityTracker.onLocationChange(...args); 612 this._progressTracker.onLocationChange(...args); 613 } 614 615 // The initial about:blank load events are unreliable because docShell starts 616 // up concurrently with loading geckoview.js so we're never guaranteed to get 617 // the events. 618 // What we do instead is ignore all initial about:blank events and fire them 619 // manually once the child process has booted up. 620 _fireInitialLoad() { 621 this.eventDispatcher.sendRequest({ 622 type: "GeckoView:PageStart", 623 uri: "about:blank", 624 }); 625 this.eventDispatcher.sendRequest({ 626 type: "GeckoView:LocationChange", 627 uri: "about:blank", 628 canGoBack: false, 629 canGoForward: false, 630 isTopLevel: true, 631 hasUserGesture: false, 632 }); 633 this.eventDispatcher.sendRequest({ 634 type: "GeckoView:PageStop", 635 success: true, 636 }); 637 } 638 639 // nsIObserver event handler 640 observe(aSubject, aTopic) { 641 debug`observe: topic=${aTopic}`; 642 643 switch (aTopic) { 644 case "oop-frameloader-crashed": { 645 const browser = aSubject.ownerElement; 646 if (!browser || browser != this.browser) { 647 return; 648 } 649 650 this._progressTracker?.stop(/* isSuccess */ false); 651 this._stateTracker?.stop(/* isSuccess */ false); 652 } 653 } 654 } 655 } 656 657 const { debug, warn } = GeckoViewProgress.initLogging("GeckoViewProgress");