Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
import { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
XPCOMUtils.defineLazyServiceGetter(
lazy,
"OverrideService",
"@mozilla.org/security/certoverride;1",
"nsICertOverrideService"
);
XPCOMUtils.defineLazyServiceGetter(
lazy,
"IDNService",
"@mozilla.org/network/idn-service;1",
"nsIIDNService"
);
ChromeUtils.defineESModuleGetters(lazy, {
BrowserTelemetryUtils: "resource://gre/modules/BrowserTelemetryUtils.sys.mjs",
GleanStopwatch: "resource://gre/modules/GeckoViewTelemetry.sys.mjs",
});
var IdentityHandler = {
// The definitions below should be kept in sync with those in GeckoView.ProgressListener.SecurityInformation
// No trusted identity information. No site identity icon is shown.
IDENTITY_MODE_UNKNOWN: 0,
// Domain-Validation SSL CA-signed domain verification (DV).
IDENTITY_MODE_IDENTIFIED: 1,
// Extended-Validation SSL CA-signed identity information (EV). A more rigorous validation process.
IDENTITY_MODE_VERIFIED: 2,
// The following mixed content modes are only used if "security.mixed_content.block_active_content"
// is enabled. Our Java frontend coalesces them into one indicator.
// No mixed content information. No mixed content icon is shown.
MIXED_MODE_UNKNOWN: 0,
// Blocked active mixed content.
MIXED_MODE_CONTENT_BLOCKED: 1,
// Loaded active mixed content.
MIXED_MODE_CONTENT_LOADED: 2,
/**
* Determines the identity mode corresponding to the icon we show in the urlbar.
*/
getIdentityMode: function getIdentityMode(aState) {
if (aState & Ci.nsIWebProgressListener.STATE_IDENTITY_EV_TOPLEVEL) {
return this.IDENTITY_MODE_VERIFIED;
}
if (aState & Ci.nsIWebProgressListener.STATE_IS_SECURE) {
return this.IDENTITY_MODE_IDENTIFIED;
}
return this.IDENTITY_MODE_UNKNOWN;
},
getMixedDisplayMode: function getMixedDisplayMode(aState) {
if (aState & Ci.nsIWebProgressListener.STATE_LOADED_MIXED_DISPLAY_CONTENT) {
return this.MIXED_MODE_CONTENT_LOADED;
}
if (
aState & Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_DISPLAY_CONTENT
) {
return this.MIXED_MODE_CONTENT_BLOCKED;
}
return this.MIXED_MODE_UNKNOWN;
},
getMixedActiveMode: function getActiveDisplayMode(aState) {
// Only show an indicator for loaded mixed content if the pref to block it is enabled
if (
aState & Ci.nsIWebProgressListener.STATE_LOADED_MIXED_ACTIVE_CONTENT &&
!Services.prefs.getBoolPref("security.mixed_content.block_active_content")
) {
return this.MIXED_MODE_CONTENT_LOADED;
}
if (aState & Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_ACTIVE_CONTENT) {
return this.MIXED_MODE_CONTENT_BLOCKED;
}
return this.MIXED_MODE_UNKNOWN;
},
/**
* Determine the identity of the page being displayed by examining its SSL cert
* (if available). Return the data needed to update the UI.
*/
checkIdentity: function checkIdentity(aState, aBrowser) {
const identityMode = this.getIdentityMode(aState);
const mixedDisplay = this.getMixedDisplayMode(aState);
const mixedActive = this.getMixedActiveMode(aState);
const result = {
mode: {
identity: identityMode,
mixed_display: mixedDisplay,
mixed_active: mixedActive,
},
};
if (aBrowser.contentPrincipal) {
result.origin = aBrowser.contentPrincipal.originNoSuffix;
}
// Don't show identity data for pages with an unknown identity or if any
// mixed content is loaded (mixed display content is loaded by default).
if (
identityMode === this.IDENTITY_MODE_UNKNOWN ||
aState & Ci.nsIWebProgressListener.STATE_IS_BROKEN ||
aState & Ci.nsIWebProgressListener.STATE_IS_INSECURE
) {
result.secure = false;
return result;
}
result.secure = true;
let uri = aBrowser.currentURI || {};
try {
uri = Services.io.createExposableURI(uri);
} catch (e) {}
try {
result.host = lazy.IDNService.convertToDisplayIDN(uri.host);
} catch (e) {
result.host = uri.host;
}
const cert = aBrowser.securityUI.secInfo.serverCert;
result.certificate =
aBrowser.securityUI.secInfo.serverCert.getBase64DERString();
try {
result.securityException = lazy.OverrideService.hasMatchingOverride(
uri.host,
uri.port,
{},
cert,
{}
);
// If an override exists, the connection is being allowed but should not
// be considered secure.
result.secure = !result.securityException;
} catch (e) {}
return result;
},
};
class Tracker {
constructor(aModule) {
this._module = aModule;
}
get eventDispatcher() {
return this._module.eventDispatcher;
}
get browser() {
return this._module.browser;
}
QueryInterface = ChromeUtils.generateQI(["nsIWebProgressListener"]);
}
class ProgressTracker extends Tracker {
constructor(aModule) {
super(aModule);
this.pageLoadStopwatch = new lazy.GleanStopwatch(
Glean.geckoview.pageLoadTime
);
this.pageReloadStopwatch = new lazy.GleanStopwatch(
Glean.geckoview.pageReloadTime
);
this.pageLoadProgressStopwatch = new lazy.GleanStopwatch(
Glean.geckoview.pageLoadProgressTime
);
this.clear();
this._eventReceived = null;
}
start(aUri) {
debug`ProgressTracker start ${aUri}`;
if (this._eventReceived) {
// A request was already in process, let's cancel it
this.stop(/* isSuccess */ false);
}
this._eventReceived = new Set();
this.clear();
const data = this._data;
if (aUri === "about:blank") {
data.uri = null;
return;
}
this.pageLoadProgressStopwatch.start();
data.uri = aUri;
data.pageStart = true;
this.updateProgress();
}
changeLocation(aUri) {
debug`ProgressTracker changeLocation ${aUri}`;
const data = this._data;
data.locationChange = true;
data.uri = aUri;
}
stop(aIsSuccess) {
debug`ProgressTracker stop`;
if (!this._eventReceived) {
// No request in progress
return;
}
if (aIsSuccess) {
this.pageLoadProgressStopwatch.finish();
} else {
this.pageLoadProgressStopwatch.cancel();
}
const data = this._data;
data.pageStop = true;
this.updateProgress();
this._eventReceived = null;
}
onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {
debug`ProgressTracker onStateChange: isTopLevel=${aWebProgress.isTopLevel},
flags=${aStateFlags}, status=${aStatus}`;
if (!aWebProgress || !aWebProgress.isTopLevel) {
return;
}
const { displaySpec } = aRequest.QueryInterface(Ci.nsIChannel).URI;
if (aRequest.URI.schemeIs("about")) {
return;
}
debug`ProgressTracker onStateChange: uri=${displaySpec}`;
const isPageReload =
(aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_RELOAD) != 0;
const stopwatch = isPageReload
? this.pageReloadStopwatch
: this.pageLoadStopwatch;
const isStart = (aStateFlags & Ci.nsIWebProgressListener.STATE_START) != 0;
const isStop = (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) != 0;
const isRedirecting =
(aStateFlags & Ci.nsIWebProgressListener.STATE_REDIRECTING) != 0;
if (isStart) {
stopwatch.start();
this.start(displaySpec);
} else if (isStop && !aWebProgress.isLoadingDocument) {
stopwatch.finish();
this.stop(aStatus == Cr.NS_OK);
} else if (isRedirecting) {
stopwatch.start();
this.start(displaySpec);
}
// During history naviation, global window is recycled, so pagetitlechanged isn't fired
// Although Firefox Desktop always set title by onLocationChange, to reduce title change call,
// we only send title during history navigation.
if ((aStateFlags & Ci.nsIWebProgressListener.STATE_RESTORING) != 0) {
this.eventDispatcher.sendRequest({
type: "GeckoView:PageTitleChanged",
title: this.browser.contentTitle,
});
}
}
onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) {
if (
!aWebProgress ||
!aWebProgress.isTopLevel ||
!aLocationURI ||
aLocationURI.schemeIs("about")
) {
return;
}
debug`ProgressTracker onLocationChange: location=${aLocationURI.displaySpec},
flags=${aFlags}`;
if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) {
this.stop(/* isSuccess */ false);
} else {
this.changeLocation(aLocationURI.displaySpec);
}
}
handleEvent(aEvent) {
if (!this._eventReceived || this._eventReceived.has(aEvent.name)) {
// Either we're not tracking or we have received this event already
return;
}
const data = this._data;
if (!data.uri || data.uri !== aEvent.data?.uri) {
return;
}
debug`ProgressTracker handleEvent: ${aEvent.name}`;
let needsUpdate = false;
switch (aEvent.name) {
case "DOMContentLoaded":
needsUpdate = needsUpdate || !data.parsed;
data.parsed = true;
break;
case "MozAfterPaint":
needsUpdate = needsUpdate || !data.firstPaint;
data.firstPaint = true;
break;
case "pageshow":
needsUpdate = needsUpdate || !data.pageShow;
data.pageShow = true;
break;
}
this._eventReceived.add(aEvent.name);
if (needsUpdate) {
this.updateProgress();
}
}
clear() {
this._data = {
prev: 0,
uri: null,
locationChange: false,
pageStart: false,
pageStop: false,
firstPaint: false,
pageShow: false,
parsed: false,
};
}
_debugData() {
return {
prev: this._data.prev,
uri: this._data.uri,
locationChange: this._data.locationChange,
pageStart: this._data.pageStart,
pageStop: this._data.pageStop,
firstPaint: this._data.firstPaint,
pageShow: this._data.pageShow,
parsed: this._data.parsed,
};
}
updateProgress() {
debug`ProgressTracker updateProgress`;
const data = this._data;
if (!this._eventReceived || !data.uri) {
return;
}
let progress = 0;
if (data.pageStop || data.pageShow) {
progress = 100;
} else if (data.firstPaint) {
progress = 80;
} else if (data.parsed) {
progress = 55;
} else if (data.locationChange) {
progress = 30;
} else if (data.pageStart) {
progress = 15;
}
if (data.prev >= progress) {
return;
}
debug`ProgressTracker updateProgress data=${this._debugData()}
progress=${progress}`;
this.eventDispatcher.sendRequest({
type: "GeckoView:ProgressChanged",
progress,
});
data.prev = progress;
}
}
class StateTracker extends Tracker {
constructor(aModule) {
super(aModule);
this._inProgress = false;
this._uri = null;
}
start(aUri) {
this._inProgress = true;
this._uri = aUri;
this.eventDispatcher.sendRequest({
type: "GeckoView:PageStart",
uri: aUri,
});
}
stop(aIsSuccess) {
if (!this._inProgress) {
// No request in progress
return;
}
this._inProgress = false;
this._uri = null;
this.eventDispatcher.sendRequest({
type: "GeckoView:PageStop",
success: aIsSuccess,
});
lazy.BrowserTelemetryUtils.recordSiteOriginTelemetry(
Services.wm.getEnumerator("navigator:geckoview"),
true
);
}
onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {
debug`StateTracker onStateChange: isTopLevel=${aWebProgress.isTopLevel},
flags=${aStateFlags}, status=${aStatus}
loadType=${aWebProgress.loadType}`;
if (!aWebProgress.isTopLevel) {
return;
}
const { displaySpec } = aRequest.QueryInterface(Ci.nsIChannel).URI;
const isStart = (aStateFlags & Ci.nsIWebProgressListener.STATE_START) != 0;
const isStop = (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) != 0;
if (isStart) {
this.start(displaySpec);
} else if (isStop && !aWebProgress.isLoadingDocument) {
this.stop(aStatus == Cr.NS_OK);
}
}
}
class SecurityTracker extends Tracker {
constructor(aModule) {
super(aModule);
this._hostChanged = false;
}
onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) {
debug`SecurityTracker onLocationChange: location=${aLocationURI.displaySpec},
flags=${aFlags}`;
this._hostChanged = true;
}
onSecurityChange(aWebProgress, aRequest, aState) {
debug`onSecurityChange`;
// Don't need to do anything if the data we use to update the UI hasn't changed
if (this._state === aState && !this._hostChanged) {
return;
}
this._state = aState;
this._hostChanged = false;
const identity = IdentityHandler.checkIdentity(aState, this.browser);
this.eventDispatcher.sendRequest({
type: "GeckoView:SecurityChanged",
identity,
});
}
}
export class GeckoViewProgress extends GeckoViewModule {
onEnable() {
debug`onEnable`;
this._fireInitialLoad();
this._initialAboutBlank = true;
this._progressTracker = new ProgressTracker(this);
this._securityTracker = new SecurityTracker(this);
this._stateTracker = new StateTracker(this);
const flags =
Ci.nsIWebProgress.NOTIFY_STATE_NETWORK |
Ci.nsIWebProgress.NOTIFY_SECURITY |
Ci.nsIWebProgress.NOTIFY_LOCATION;
this.progressFilter = Cc[
"@mozilla.org/appshell/component/browser-status-filter;1"
].createInstance(Ci.nsIWebProgress);
this.progressFilter.addProgressListener(this, flags);
this.browser.addProgressListener(this.progressFilter, flags);
Services.obs.addObserver(this, "oop-frameloader-crashed");
this.registerListener("GeckoView:FlushSessionState");
}
onDisable() {
debug`onDisable`;
if (this.progressFilter) {
this.progressFilter.removeProgressListener(this);
this.browser.removeProgressListener(this.progressFilter);
}
Services.obs.removeObserver(this, "oop-frameloader-crashed");
this.unregisterListener("GeckoView:FlushSessionState");
}
receiveMessage(aMsg) {
debug`receiveMessage: ${aMsg.name}`;
switch (aMsg.name) {
case "DOMContentLoaded": // fall-through
case "MozAfterPaint": // fall-through
case "pageshow": {
this._progressTracker?.handleEvent(aMsg);
break;
}
}
}
onEvent(aEvent, aData) {
debug`onEvent: event=${aEvent}, data=${aData}`;
switch (aEvent) {
case "GeckoView:FlushSessionState":
this.messageManager.sendAsyncMessage("GeckoView:FlushSessionState");
break;
}
}
onStateChange(...args) {
// GeckoView never gets PageStart or PageStop for about:blank because we
// set nodefaultsrc to true unconditionally so we can assume here that
// we're starting a page load for a non-blank page (or a consumer-initiated
// about:blank load).
this._initialAboutBlank = false;
this._progressTracker.onStateChange(...args);
this._stateTracker.onStateChange(...args);
}
onSecurityChange(...args) {
// We don't report messages about the initial about:blank
if (this._initialAboutBlank) {
return;
}
this._securityTracker.onSecurityChange(...args);
}
onLocationChange(...args) {
this._securityTracker.onLocationChange(...args);
this._progressTracker.onLocationChange(...args);
}
// The initial about:blank load events are unreliable because docShell starts
// up concurrently with loading geckoview.js so we're never guaranteed to get
// the events.
// What we do instead is ignore all initial about:blank events and fire them
// manually once the child process has booted up.
_fireInitialLoad() {
this.eventDispatcher.sendRequest({
type: "GeckoView:PageStart",
uri: "about:blank",
});
this.eventDispatcher.sendRequest({
type: "GeckoView:LocationChange",
uri: "about:blank",
canGoBack: false,
canGoForward: false,
isTopLevel: true,
hasUserGesture: false,
});
this.eventDispatcher.sendRequest({
type: "GeckoView:PageStop",
success: true,
});
}
// nsIObserver event handler
observe(aSubject, aTopic) {
debug`observe: topic=${aTopic}`;
switch (aTopic) {
case "oop-frameloader-crashed": {
const browser = aSubject.ownerElement;
if (!browser || browser != this.browser) {
return;
}
this._progressTracker?.stop(/* isSuccess */ false);
this._stateTracker?.stop(/* isSuccess */ false);
}
}
}
}
const { debug, warn } = GeckoViewProgress.initLogging("GeckoViewProgress");