зеркало из https://github.com/mozilla/gecko-dev.git
618 строки
19 KiB
JavaScript
618 строки
19 KiB
JavaScript
/* 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/. */
|
|
|
|
"use strict";
|
|
|
|
/*
|
|
* This module runs the automated heuristics to enable/disable DoH on different
|
|
* networks. Heuristics are run at startup and upon network changes.
|
|
* Heuristics are disabled if the user sets their DoH provider or mode manually.
|
|
*/
|
|
var EXPORTED_SYMBOLS = ["DoHController"];
|
|
|
|
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
|
|
const { XPCOMUtils } = ChromeUtils.import(
|
|
"resource://gre/modules/XPCOMUtils.jsm"
|
|
);
|
|
|
|
XPCOMUtils.defineLazyModuleGetters(this, {
|
|
AsyncShutdown: "resource://gre/modules/AsyncShutdown.jsm",
|
|
ClientID: "resource://gre/modules/ClientID.jsm",
|
|
ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.jsm",
|
|
Config: "resource:///modules/DoHConfig.jsm",
|
|
Heuristics: "resource:///modules/DoHHeuristics.jsm",
|
|
Preferences: "resource://gre/modules/Preferences.jsm",
|
|
setTimeout: "resource://gre/modules/Timer.jsm",
|
|
clearTimeout: "resource://gre/modules/Timer.jsm",
|
|
});
|
|
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
this,
|
|
"kDebounceTimeout",
|
|
"doh-rollout.network-debounce-timeout",
|
|
1000
|
|
);
|
|
|
|
XPCOMUtils.defineLazyServiceGetter(
|
|
this,
|
|
"gCaptivePortalService",
|
|
"@mozilla.org/network/captive-portal-service;1",
|
|
"nsICaptivePortalService"
|
|
);
|
|
|
|
XPCOMUtils.defineLazyServiceGetter(
|
|
this,
|
|
"gDNSService",
|
|
"@mozilla.org/network/dns-service;1",
|
|
"nsIDNSService"
|
|
);
|
|
|
|
XPCOMUtils.defineLazyServiceGetter(
|
|
this,
|
|
"gNetworkLinkService",
|
|
"@mozilla.org/network/network-link-service;1",
|
|
"nsINetworkLinkService"
|
|
);
|
|
|
|
// Stores whether we've done first-run.
|
|
const FIRST_RUN_PREF = "doh-rollout.doneFirstRun";
|
|
|
|
// Records if the user opted in/out of DoH study by clicking on doorhanger
|
|
const DOORHANGER_USER_DECISION_PREF = "doh-rollout.doorhanger-decision";
|
|
|
|
// Set when we detect that the user set their DoH provider or mode manually.
|
|
// If set, we don't run heuristics.
|
|
const DISABLED_PREF = "doh-rollout.disable-heuristics";
|
|
|
|
// Set when we detect either a non-DoH enterprise policy, or a DoH policy that
|
|
// tells us to disable it. This pref's effect is to suppress the opt-out CFR.
|
|
const SKIP_HEURISTICS_PREF = "doh-rollout.skipHeuristicsCheck";
|
|
|
|
// Whether to clear doh-rollout.mode on shutdown. When false, the mode value
|
|
// that exists at shutdown will be used at startup until heuristics re-run.
|
|
const CLEAR_ON_SHUTDOWN_PREF = "doh-rollout.clearModeOnShutdown";
|
|
|
|
const BREADCRUMB_PREF = "doh-rollout.self-enabled";
|
|
|
|
// Necko TRR prefs to watch for user-set values.
|
|
const NETWORK_TRR_MODE_PREF = "network.trr.mode";
|
|
const NETWORK_TRR_URI_PREF = "network.trr.uri";
|
|
|
|
const TRR_LIST_PREF = "network.trr.resolvers";
|
|
|
|
const ROLLOUT_MODE_PREF = "doh-rollout.mode";
|
|
const ROLLOUT_URI_PREF = "doh-rollout.uri";
|
|
|
|
const TRR_SELECT_DRY_RUN_RESULT_PREF =
|
|
"doh-rollout.trr-selection.dry-run-result";
|
|
|
|
const HEURISTICS_TELEMETRY_CATEGORY = "doh";
|
|
const TRRSELECT_TELEMETRY_CATEGORY = "security.doh.trrPerformance";
|
|
|
|
const kLinkStatusChangedTopic = "network:link-status-changed";
|
|
const kConnectivityTopic = "network:captive-portal-connectivity";
|
|
const kPrefChangedTopic = "nsPref:changed";
|
|
|
|
// Helper function to hash the network ID concatenated with telemetry client ID.
|
|
// This prevents us from being able to tell if 2 clients are on the same network.
|
|
function getHashedNetworkID() {
|
|
let currentNetworkID = gNetworkLinkService.networkID;
|
|
if (!currentNetworkID) {
|
|
return "";
|
|
}
|
|
|
|
let hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
|
|
Ci.nsICryptoHash
|
|
);
|
|
|
|
hasher.init(Ci.nsICryptoHash.SHA256);
|
|
// Concat the client ID with the network ID before hashing.
|
|
let clientNetworkID = ClientID.getClientID() + currentNetworkID;
|
|
hasher.update(
|
|
clientNetworkID.split("").map(c => c.charCodeAt(0)),
|
|
clientNetworkID.length
|
|
);
|
|
return hasher.finish(true);
|
|
}
|
|
|
|
const DoHController = {
|
|
_heuristicsAreEnabled: false,
|
|
|
|
async init() {
|
|
await this.migrateLocalStoragePrefs();
|
|
await this.migrateOldTrrMode();
|
|
await this.migrateNextDNSEndpoint();
|
|
|
|
Services.telemetry.setEventRecordingEnabled(
|
|
HEURISTICS_TELEMETRY_CATEGORY,
|
|
true
|
|
);
|
|
Services.telemetry.setEventRecordingEnabled(
|
|
TRRSELECT_TELEMETRY_CATEGORY,
|
|
true
|
|
);
|
|
|
|
Services.obs.addObserver(this, Config.kConfigUpdateTopic);
|
|
Preferences.observe(NETWORK_TRR_MODE_PREF, this);
|
|
Preferences.observe(NETWORK_TRR_URI_PREF, this);
|
|
|
|
if (Config.enabled) {
|
|
await this.maybeEnableHeuristics();
|
|
} else if (Preferences.get(FIRST_RUN_PREF, false)) {
|
|
await this.rollback();
|
|
}
|
|
|
|
this._asyncShutdownBlocker = async () => {
|
|
await this.disableHeuristics("shutdown");
|
|
};
|
|
|
|
AsyncShutdown.profileBeforeChange.addBlocker(
|
|
"DoHController: clear state and remove observers",
|
|
this._asyncShutdownBlocker
|
|
);
|
|
|
|
Preferences.set(FIRST_RUN_PREF, true);
|
|
},
|
|
|
|
// Also used by tests to reset DoHController state (prefs are not cleared
|
|
// here - tests do that when needed between _uninit and init).
|
|
async _uninit() {
|
|
Services.obs.removeObserver(this, Config.kConfigUpdateTopic);
|
|
Preferences.ignore(NETWORK_TRR_MODE_PREF, this);
|
|
Preferences.ignore(NETWORK_TRR_URI_PREF, this);
|
|
AsyncShutdown.profileBeforeChange.removeBlocker(this._asyncShutdownBlocker);
|
|
await this.disableHeuristics("shutdown");
|
|
},
|
|
|
|
// Called to reset state when a new config is available.
|
|
async reset() {
|
|
await this._uninit();
|
|
await this.init();
|
|
},
|
|
|
|
async migrateLocalStoragePrefs() {
|
|
const BALROG_MIGRATION_COMPLETED_PREF = "doh-rollout.balrog-migration-done";
|
|
const ADDON_ID = "doh-rollout@mozilla.org";
|
|
|
|
// Migrate updated local storage item names. If this has already been done once, skip the migration
|
|
const isMigrated = Preferences.get(BALROG_MIGRATION_COMPLETED_PREF, false);
|
|
|
|
if (isMigrated) {
|
|
return;
|
|
}
|
|
|
|
let policy = WebExtensionPolicy.getByID(ADDON_ID);
|
|
if (!policy) {
|
|
return;
|
|
}
|
|
|
|
const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal(
|
|
policy.extension
|
|
);
|
|
const idbConn = await ExtensionStorageIDB.open(storagePrincipal);
|
|
|
|
// Previously, the DoH heuristics were bundled as an add-on. Early versions
|
|
// of this add-on used local storage instead of prefs to persist state. This
|
|
// function migrates the values that are still relevant to their new pref
|
|
// counterparts.
|
|
const legacyLocalStorageKeys = [
|
|
"doneFirstRun",
|
|
DOORHANGER_USER_DECISION_PREF,
|
|
DISABLED_PREF,
|
|
];
|
|
|
|
for (let item of legacyLocalStorageKeys) {
|
|
let data = await idbConn.get(item);
|
|
let value = data[item];
|
|
|
|
if (data.hasOwnProperty(item)) {
|
|
let migratedName = item;
|
|
|
|
if (!item.startsWith("doh-rollout.")) {
|
|
migratedName = "doh-rollout." + item;
|
|
}
|
|
|
|
Preferences.set(migratedName, value);
|
|
}
|
|
}
|
|
|
|
await idbConn.clear();
|
|
await idbConn.close();
|
|
|
|
// Set pref to skip this function in the future.
|
|
Preferences.set(BALROG_MIGRATION_COMPLETED_PREF, true);
|
|
},
|
|
|
|
// Previous versions of the DoH frontend worked by setting network.trr.mode
|
|
// directly to turn DoH on/off. This makes sure we clear that value and also
|
|
// the pref we formerly used to track changes to it.
|
|
async migrateOldTrrMode() {
|
|
const PREVIOUS_TRR_MODE_PREF = "doh-rollout.previous.trr.mode";
|
|
|
|
if (Preferences.get(PREVIOUS_TRR_MODE_PREF) === undefined) {
|
|
return;
|
|
}
|
|
|
|
if (Preferences.get(NETWORK_TRR_MODE_PREF) !== 5) {
|
|
Preferences.reset(NETWORK_TRR_MODE_PREF);
|
|
}
|
|
Preferences.reset(PREVIOUS_TRR_MODE_PREF);
|
|
},
|
|
|
|
async migrateNextDNSEndpoint() {
|
|
// NextDNS endpoint changed from trr.dns.nextdns.io to firefox.dns.nextdns.io
|
|
// This migration updates any pref values that might be using the old value
|
|
// to the new one. We support values that match the exact URL that shipped
|
|
// in the network.trr.resolvers pref in prior versions of the browser.
|
|
// The migration is a direct static replacement of the string.
|
|
const oldURL = "https://trr.dns.nextdns.io/";
|
|
const newURL = "https://firefox.dns.nextdns.io/";
|
|
const prefsToMigrate = [
|
|
"network.trr.resolvers",
|
|
"network.trr.uri",
|
|
"network.trr.custom_uri",
|
|
"doh-rollout.trr-selection.dry-run-result",
|
|
"doh-rollout.uri",
|
|
];
|
|
|
|
for (let pref of prefsToMigrate) {
|
|
if (!Preferences.isSet(pref)) {
|
|
continue;
|
|
}
|
|
Preferences.set(pref, Preferences.get(pref).replaceAll(oldURL, newURL));
|
|
}
|
|
},
|
|
|
|
// The "maybe" is because there are two cases when we don't enable heuristics:
|
|
// 1. If we detect that TRR mode or URI have user values, or we previously
|
|
// detected this (i.e. DISABLED_PREF is true)
|
|
// 2. If there are any non-DoH enterprise policies active
|
|
async maybeEnableHeuristics() {
|
|
if (Preferences.get(DISABLED_PREF)) {
|
|
return;
|
|
}
|
|
|
|
let policyResult = await Heuristics.checkEnterprisePolicy();
|
|
|
|
if (["policy_without_doh", "disable_doh"].includes(policyResult)) {
|
|
await this.setState("policyDisabled");
|
|
Preferences.set(SKIP_HEURISTICS_PREF, true);
|
|
return;
|
|
}
|
|
|
|
Preferences.reset(SKIP_HEURISTICS_PREF);
|
|
|
|
if (
|
|
Preferences.isSet(NETWORK_TRR_MODE_PREF) ||
|
|
Preferences.isSet(NETWORK_TRR_URI_PREF)
|
|
) {
|
|
await this.setState("manuallyDisabled");
|
|
Preferences.set(DISABLED_PREF, true);
|
|
return;
|
|
}
|
|
|
|
await this.runTRRSelection();
|
|
await this.runHeuristics("startup");
|
|
Services.obs.addObserver(this, kLinkStatusChangedTopic);
|
|
Services.obs.addObserver(this, kConnectivityTopic);
|
|
|
|
this._heuristicsAreEnabled = true;
|
|
},
|
|
|
|
_lastHeuristicsRunTimestamp: 0,
|
|
async runHeuristics(evaluateReason) {
|
|
let start = Date.now();
|
|
// If this function is called in quick succession, _lastHeuristicsRunTimestamp
|
|
// might be refreshed while we are still awaiting Heuristics.run() below.
|
|
this._lastHeuristicsRunTimestamp = start;
|
|
|
|
let results = await Heuristics.run();
|
|
|
|
if (
|
|
!gNetworkLinkService.isLinkUp ||
|
|
this._lastDebounceTimestamp > start ||
|
|
this._lastHeuristicsRunTimestamp > start ||
|
|
gCaptivePortalService.state == gCaptivePortalService.LOCKED_PORTAL
|
|
) {
|
|
// If the network is currently down or there was a debounce triggered
|
|
// while we were running heuristics, it means the network fluctuated
|
|
// during this heuristics run. We simply discard the results in this case.
|
|
// Same thing if there was another heuristics run triggered or if we have
|
|
// detected a locked captive portal while this one was ongoing.
|
|
return;
|
|
}
|
|
|
|
let decision = Object.values(results).includes(Heuristics.DISABLE_DOH)
|
|
? Heuristics.DISABLE_DOH
|
|
: Heuristics.ENABLE_DOH;
|
|
|
|
let getCaptiveStateString = () => {
|
|
switch (gCaptivePortalService.state) {
|
|
case gCaptivePortalService.NOT_CAPTIVE:
|
|
return "not_captive";
|
|
case gCaptivePortalService.UNLOCKED_PORTAL:
|
|
return "unlocked";
|
|
case gCaptivePortalService.LOCKED_PORTAL:
|
|
return "locked";
|
|
default:
|
|
return "unknown";
|
|
}
|
|
};
|
|
|
|
let resultsForTelemetry = {
|
|
evaluateReason,
|
|
steeredProvider: "",
|
|
captiveState: getCaptiveStateString(),
|
|
// NOTE: This might not yet be available after a network change. We mainly
|
|
// care about the startup case though - we want to look at whether the
|
|
// heuristics result is consistent for networkIDs often seen at startup.
|
|
// TODO: Use this data to implement cached results to use early at startup.
|
|
networkID: getHashedNetworkID(),
|
|
};
|
|
|
|
if (results.steeredProvider) {
|
|
gDNSService.setDetectedTrrURI(results.steeredProvider.uri);
|
|
resultsForTelemetry.steeredProvider = results.steeredProvider.name;
|
|
}
|
|
|
|
if (decision === Heuristics.DISABLE_DOH) {
|
|
await this.setState("disabled");
|
|
} else {
|
|
await this.setState("enabled");
|
|
}
|
|
|
|
// For telemetry, we group the heuristics results into three categories.
|
|
// Only heuristics with a DISABLE_DOH result are included.
|
|
// Each category is finally included in the event as a comma-separated list.
|
|
let canaries = [];
|
|
let filtering = [];
|
|
let enterprise = [];
|
|
let platform = [];
|
|
|
|
for (let [heuristicName, result] of Object.entries(results)) {
|
|
if (result !== Heuristics.DISABLE_DOH) {
|
|
continue;
|
|
}
|
|
|
|
if (["canary", "zscalerCanary"].includes(heuristicName)) {
|
|
canaries.push(heuristicName);
|
|
} else if (
|
|
["browserParent", "google", "youtube"].includes(heuristicName)
|
|
) {
|
|
filtering.push(heuristicName);
|
|
} else if (
|
|
["policy", "modifiedRoots", "thirdPartyRoots"].includes(heuristicName)
|
|
) {
|
|
enterprise.push(heuristicName);
|
|
} else if (["vpn", "proxy", "nrpt"].includes(heuristicName)) {
|
|
platform.push(heuristicName);
|
|
}
|
|
}
|
|
|
|
resultsForTelemetry.canaries = canaries.join(",");
|
|
resultsForTelemetry.filtering = filtering.join(",");
|
|
resultsForTelemetry.enterprise = enterprise.join(",");
|
|
resultsForTelemetry.platform = platform.join(",");
|
|
|
|
Services.telemetry.recordEvent(
|
|
HEURISTICS_TELEMETRY_CATEGORY,
|
|
"evaluate_v2",
|
|
"heuristics",
|
|
decision,
|
|
resultsForTelemetry
|
|
);
|
|
},
|
|
|
|
async setState(state) {
|
|
switch (state) {
|
|
case "disabled":
|
|
Preferences.set(ROLLOUT_MODE_PREF, 0);
|
|
break;
|
|
case "UIOk":
|
|
Preferences.set(BREADCRUMB_PREF, true);
|
|
break;
|
|
case "enabled":
|
|
Preferences.set(ROLLOUT_MODE_PREF, 2);
|
|
Preferences.set(BREADCRUMB_PREF, true);
|
|
break;
|
|
case "policyDisabled":
|
|
case "manuallyDisabled":
|
|
case "UIDisabled":
|
|
Preferences.reset(BREADCRUMB_PREF);
|
|
// Fall through.
|
|
case "rollback":
|
|
Preferences.reset(ROLLOUT_MODE_PREF);
|
|
break;
|
|
case "shutdown":
|
|
if (Preferences.get(CLEAR_ON_SHUTDOWN_PREF, true)) {
|
|
Preferences.reset(ROLLOUT_MODE_PREF);
|
|
}
|
|
break;
|
|
}
|
|
|
|
Services.telemetry.recordEvent(
|
|
HEURISTICS_TELEMETRY_CATEGORY,
|
|
"state",
|
|
state,
|
|
"null"
|
|
);
|
|
},
|
|
|
|
async disableHeuristics(state) {
|
|
await this.setState(state);
|
|
|
|
if (!this._heuristicsAreEnabled) {
|
|
return;
|
|
}
|
|
|
|
Services.obs.removeObserver(this, kLinkStatusChangedTopic);
|
|
Services.obs.removeObserver(this, kConnectivityTopic);
|
|
this._heuristicsAreEnabled = false;
|
|
},
|
|
|
|
async rollback() {
|
|
await this.disableHeuristics("rollback");
|
|
},
|
|
|
|
async runTRRSelection() {
|
|
// If persisting the selection is disabled, clear the existing
|
|
// selection.
|
|
if (!Config.trrSelection.commitResult) {
|
|
Preferences.reset(ROLLOUT_URI_PREF);
|
|
}
|
|
|
|
if (!Config.trrSelection.enabled) {
|
|
return;
|
|
}
|
|
|
|
if (Preferences.isSet(ROLLOUT_URI_PREF)) {
|
|
return;
|
|
}
|
|
|
|
await this.runTRRSelectionDryRun();
|
|
|
|
// If persisting the selection is disabled, don't commit the value.
|
|
if (!Config.trrSelection.commitResult) {
|
|
return;
|
|
}
|
|
|
|
Preferences.set(
|
|
ROLLOUT_URI_PREF,
|
|
Preferences.get(TRR_SELECT_DRY_RUN_RESULT_PREF)
|
|
);
|
|
},
|
|
|
|
async runTRRSelectionDryRun() {
|
|
if (Preferences.isSet(TRR_SELECT_DRY_RUN_RESULT_PREF)) {
|
|
// Check whether the existing dry-run-result is in the default
|
|
// list of TRRs. If it is, all good. Else, run the dry run again.
|
|
let dryRunResult = Preferences.get(TRR_SELECT_DRY_RUN_RESULT_PREF);
|
|
let defaultTRRs = JSON.parse(
|
|
Services.prefs.getDefaultBranch("").getCharPref(TRR_LIST_PREF)
|
|
);
|
|
let dryRunResultIsValid = defaultTRRs.some(
|
|
trr => trr.url == dryRunResult
|
|
);
|
|
if (dryRunResultIsValid) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
let setDryRunResultAndRecordTelemetry = trr => {
|
|
Preferences.set(TRR_SELECT_DRY_RUN_RESULT_PREF, trr);
|
|
Services.telemetry.recordEvent(
|
|
TRRSELECT_TELEMETRY_CATEGORY,
|
|
"trrselect",
|
|
"dryrunresult",
|
|
trr.substring(0, 40) // Telemetry payload max length
|
|
);
|
|
};
|
|
|
|
if (Cu.isInAutomation) {
|
|
// For mochitests, just record telemetry with a dummy result.
|
|
// TRRPerformance.jsm is tested in xpcshell.
|
|
setDryRunResultAndRecordTelemetry("https://dummytrr.com/query");
|
|
return;
|
|
}
|
|
|
|
// Importing the module here saves us from having to do it at startup, and
|
|
// ensures tests have time to set prefs before the module initializes.
|
|
let { TRRRacer } = ChromeUtils.import(
|
|
"resource:///modules/TRRPerformance.jsm"
|
|
);
|
|
await new Promise(resolve => {
|
|
let racer = new TRRRacer(() => {
|
|
setDryRunResultAndRecordTelemetry(racer.getFastestTRR(true));
|
|
resolve();
|
|
});
|
|
racer.run();
|
|
});
|
|
},
|
|
|
|
observe(subject, topic, data) {
|
|
switch (topic) {
|
|
case kLinkStatusChangedTopic:
|
|
this.onConnectionChanged();
|
|
break;
|
|
case kConnectivityTopic:
|
|
this.onConnectivityAvailable();
|
|
break;
|
|
case kPrefChangedTopic:
|
|
this.onPrefChanged(data);
|
|
break;
|
|
case Config.kConfigUpdateTopic:
|
|
this.reset();
|
|
break;
|
|
}
|
|
},
|
|
|
|
async onPrefChanged(pref) {
|
|
switch (pref) {
|
|
case NETWORK_TRR_URI_PREF:
|
|
case NETWORK_TRR_MODE_PREF:
|
|
Preferences.set(DISABLED_PREF, true);
|
|
await this.disableHeuristics("manuallyDisabled");
|
|
break;
|
|
}
|
|
},
|
|
|
|
// Connection change events are debounced to allow the network to settle.
|
|
// We wait for the network to be up for a period of kDebounceTimeout before
|
|
// handling the change. The timer is canceled when the network goes down and
|
|
// restarted the first time we learn that it went back up.
|
|
_debounceTimer: null,
|
|
_cancelDebounce() {
|
|
if (!this._debounceTimer) {
|
|
return;
|
|
}
|
|
|
|
clearTimeout(this._debounceTimer);
|
|
this._debounceTimer = null;
|
|
},
|
|
|
|
_lastDebounceTimestamp: 0,
|
|
onConnectionChanged() {
|
|
if (!gNetworkLinkService.isLinkUp) {
|
|
// Network is down - reset debounce timer.
|
|
this._cancelDebounce();
|
|
return;
|
|
}
|
|
|
|
if (this._debounceTimer) {
|
|
// Already debouncing - nothing to do.
|
|
return;
|
|
}
|
|
|
|
this._lastDebounceTimestamp = Date.now();
|
|
this._debounceTimer = setTimeout(() => {
|
|
this._cancelDebounce();
|
|
this.onConnectionChangedDebounced();
|
|
}, kDebounceTimeout);
|
|
},
|
|
|
|
async onConnectionChangedDebounced() {
|
|
if (!gNetworkLinkService.isLinkUp) {
|
|
return;
|
|
}
|
|
|
|
if (gCaptivePortalService.state == gCaptivePortalService.LOCKED_PORTAL) {
|
|
return;
|
|
}
|
|
|
|
// The network is up and we don't know that we're in a locked portal.
|
|
// Run heuristics. If we detect a portal later, we'll run heuristics again
|
|
// when it's unlocked. In that case, this run will likely have failed.
|
|
await this.runHeuristics("netchange");
|
|
},
|
|
|
|
async onConnectivityAvailable() {
|
|
if (this._debounceTimer) {
|
|
// Already debouncing - nothing to do.
|
|
return;
|
|
}
|
|
|
|
await this.runHeuristics("connectivity");
|
|
},
|
|
};
|