Files
tubestation/browser/components/urlbar/private/Weather.sys.mjs
Drew Willcoxon 15f1e8095d Bug 1831971 - Remove zero-prefix functionality from the weather suggestion and don't start fetches until a config is set. r=daisuke
This fixes the bug by not starting fetches until a config is set from either
remote settings or Nimbus. By "config" I mean keywords basically, but we sync
more than keywords -- the min keyword length and min keyword length cap -- so
that's why I use different term. So, before remote settings is synced, no config
will be set, so no fetches will happen, so the suggestion will be null. When the
urlbar provider starts a query, it will see the suggestion is null and not add a
result. Once a config is set from RS or Nimbus, we'll start fetching.

Currently we allow zero prefix when keywords or the min keyword length aren't
set. This patch removes that functionality because on second thought, there's
not a safe and obvious way to support zero prefix using these keyword-related
config properties/variables by themselves, and zero prefix isn't a feature
requirement anymore anyway. If we wanted to keep supporting it with these
properties/variables, there are a few options, and I don't like any of them:

* If `keywords` is undefined or null, use zero prefix. This is dangerous because
  we may make a mistake in RS or Nimbus and forget to set it. Also, we use null
  as the default value for the Nimbus var, and since we use UrlbarPrefs to
  access Nimbus vars, there's no good way to tell whether null was set
  intentionally or not.
* If `keywords` is an empty array, use zero prefix. This is awkward because the
  user can now increment the min keyword length, which means the keywords array
  kept by `Weather` can become empty when the min keyword length is incremented
  a lot. In that case, no suggestion should be shown at all.
* If `min_keyword_length` is zero/falsey, use zero prefix. This has the same
  problems as the first point above.

If we do need to support zero prefix again in the future, I think we should add
an RS property/Nimbus variable that makes it clear what's happening, e.g., a
`useZeroPrefix` boolean.

I removed the exposure scalar because it's entirely based on zero prefix. We can
use Glean for that now anyway.

I also noticed weather suggestions are case insenstive, so I fixed that and
added a test task.

Differential Revision: https://phabricator.services.mozilla.com/D177448
2023-05-09 06:37:34 +00:00

505 lines
16 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/. */
import { BaseFeature } from "resource:///modules/urlbar/private/BaseFeature.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
clearTimeout: "resource://gre/modules/Timer.sys.mjs",
MerinoClient: "resource:///modules/MerinoClient.sys.mjs",
PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
QuickSuggestRemoteSettings:
"resource:///modules/urlbar/private/QuickSuggestRemoteSettings.sys.mjs",
setTimeout: "resource://gre/modules/Timer.sys.mjs",
UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
});
const FETCH_DELAY_AFTER_COMING_ONLINE_MS = 3000; // 3s
const FETCH_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
const MERINO_PROVIDER = "accuweather";
const MERINO_TIMEOUT_MS = 5000; // 5s
const HISTOGRAM_LATENCY = "FX_URLBAR_MERINO_LATENCY_WEATHER_MS";
const HISTOGRAM_RESPONSE = "FX_URLBAR_MERINO_RESPONSE_WEATHER";
const NOTIFICATIONS = {
CAPTIVE_PORTAL_LOGIN: "captive-portal-login-success",
LINK_STATUS_CHANGED: "network:link-status-changed",
OFFLINE_STATUS_CHANGED: "network:offline-status-changed",
WAKE: "wake_notification",
};
/**
* A feature that periodically fetches weather suggestions from Merino.
*/
export class Weather extends BaseFeature {
get shouldEnable() {
// The feature itself is enabled by setting these prefs regardless of
// whether any config is defined. This is necessary to allow the feature to
// sync the config from remote settings and Nimbus. Suggestion fetches will
// not start until the config has been either synced from remote settings or
// set by Nimbus.
return (
lazy.UrlbarPrefs.get("weatherFeatureGate") &&
lazy.UrlbarPrefs.get("suggest.weather") &&
lazy.UrlbarPrefs.get("merinoEnabled")
);
}
get enablingPreferences() {
return ["suggest.weather"];
}
/**
* @returns {object}
* The last weather suggestion fetched from Merino or null if none.
*/
get suggestion() {
return this.#suggestion;
}
/**
* @returns {Set}
* The set of keywords that should trigger the weather suggestion. This will
* be null when no config is defined.
*/
get keywords() {
return this.#keywords;
}
/**
* @returns {number}
* The minimum prefix length of a weather keyword the user must type to
* trigger the suggestion. Note that the strings returned from `keywords`
* already take this into account. The min length is determined from the
* first config source below whose value is non-zero. If no source has a
* non-zero value, zero will be returned, and `this.keywords` will contain
* only full keywords.
*
* 1. The `weather.minKeywordLength` pref, which is set when the user
* increments the min length
* 2. `weatherKeywordsMinimumLength` in Nimbus
* 3. `min_keyword_length` in remote settings
*/
get minKeywordLength() {
let minLength =
lazy.UrlbarPrefs.get("weather.minKeywordLength") ||
lazy.UrlbarPrefs.get("weatherKeywordsMinimumLength") ||
this.#rsData?.min_keyword_length ||
0;
return Math.max(minLength, 0);
}
/**
* @returns {boolean}
* Weather the min keyword length can be incremented. A cap on the min
* length can be set in remote settings and Nimbus.
*/
get canIncrementMinKeywordLength() {
let cap =
lazy.UrlbarPrefs.get("weatherKeywordsMinimumLengthCap") ||
this.#rsData?.min_keyword_length_cap ||
0;
return !cap || this.minKeywordLength < cap;
}
update() {
let wasEnabled = this.isEnabled;
super.update();
// This method is called by `QuickSuggest` in a
// `NimbusFeatures.urlbar.onUpdate()` callback, when a change occurs to a
// Nimbus variable or to a pref that's a fallback for a Nimbus variable. A
// config-related variable or pref may have changed, so update it, but only
// if the feature was already enabled because if it wasn't, `enable(true)`
// was just called, which calls `#init()`, which calls `#updateConfig()`.
if (wasEnabled && this.isEnabled) {
this.#updateConfig();
}
}
enable(enabled) {
if (enabled) {
this.#init();
} else {
this.#uninit();
}
}
/**
* Increments the minimum prefix length of a weather keyword the user must
* type to trigger the suggestion, if possible. A cap on the min length can be
* set in remote settings and Nimbus, and if the cap has been reached, the
* length is not incremented.
*/
incrementMinKeywordLength() {
if (this.canIncrementMinKeywordLength) {
lazy.UrlbarPrefs.set(
"weather.minKeywordLength",
this.minKeywordLength + 1
);
}
}
/**
* Returns a promise that resolves when all pending fetches finish, if there
* are pending fetches. If there aren't, the promise resolves when all pending
* fetches starting with the next fetch finish.
*
* @returns {Promise}
*/
waitForFetches() {
if (!this.#waitForFetchesDeferred) {
this.#waitForFetchesDeferred = lazy.PromiseUtils.defer();
}
return this.#waitForFetchesDeferred.promise;
}
async onRemoteSettingsSync(rs) {
this.logger.debug("Loading weather config from remote settings");
let records = await rs.get({ filters: { type: "weather" } });
if (rs != lazy.QuickSuggestRemoteSettings.rs) {
return;
}
this.logger.debug("Got weather records: " + JSON.stringify(records));
this.#rsData = records?.[0]?.weather;
this.#updateConfig();
}
get #vpnDetected() {
if (lazy.UrlbarPrefs.get("weather.ignoreVPN")) {
return false;
}
let linkService =
this._test_linkService ||
Cc["@mozilla.org/network/network-link-service;1"].getService(
Ci.nsINetworkLinkService
);
// `platformDNSIndications` throws `NS_ERROR_NOT_IMPLEMENTED` on all
// platforms except Windows, so we can't detect a VPN on any other platform.
try {
return (
linkService.platformDNSIndications &
Ci.nsINetworkLinkService.VPN_DETECTED
);
} catch (e) {}
return false;
}
#init() {
// On feature init, we only update the config and listen for changes that
// affect the config. Suggestion fetches will not start until a config has
// been either synced from remote settings or set by Nimbus.
this.#updateConfig();
lazy.UrlbarPrefs.addObserver(this);
lazy.QuickSuggestRemoteSettings.register(this);
}
#uninit() {
this.#stopFetching();
lazy.QuickSuggestRemoteSettings.unregister(this);
lazy.UrlbarPrefs.removeObserver(this);
this.#keywords = null;
}
#startFetching() {
if (this.#merino) {
this.logger.debug("Suggestion fetching already started");
return;
}
this.logger.debug("Starting suggestion fetching");
this.#merino = new lazy.MerinoClient(this.constructor.name);
this.#fetch();
for (let notif of Object.values(NOTIFICATIONS)) {
Services.obs.addObserver(this, notif);
}
}
#stopFetching() {
if (!this.#merino) {
this.logger.debug("Suggestion fetching already stopped");
return;
}
this.logger.debug("Stopping suggestion fetching");
for (let notif of Object.values(NOTIFICATIONS)) {
Services.obs.removeObserver(this, notif);
}
lazy.clearTimeout(this.#fetchTimer);
this.#merino = null;
this.#suggestion = null;
this.#fetchTimer = 0;
}
async #fetch() {
this.logger.info("Fetching suggestion");
if (this.#vpnDetected) {
// A VPN is detected, so Merino will not be able to accurately determine
// the user's location. Set the suggestion to null. We treat this as if
// the network is offline (see below). When the VPN is disconnected, a
// `network:link-status-changed` notification will be sent, triggering a
// new fetch.
this.logger.info("VPN detected, not fetching");
this.#suggestion = null;
if (!this.#pendingFetchCount) {
this.#waitForFetchesDeferred?.resolve();
this.#waitForFetchesDeferred = null;
}
return;
}
// This `Weather` instance may be uninitialized while awaiting the fetch or
// even uninitialized and re-initialized a number of times. Multiple fetches
// may also happen at once. Ignore the fetch below if `#merino` changes or
// another fetch happens in the meantime.
let merino = this.#merino;
let instance = (this.#fetchInstance = {});
this.#restartFetchTimer();
this.#lastFetchTimeMs = Date.now();
this.#pendingFetchCount++;
let suggestions;
try {
suggestions = await merino.fetch({
query: "",
providers: [MERINO_PROVIDER],
timeoutMs: this.#timeoutMs,
extraLatencyHistogram: HISTOGRAM_LATENCY,
extraResponseHistogram: HISTOGRAM_RESPONSE,
});
} finally {
this.#pendingFetchCount--;
}
// Reset the Merino client's session so different fetches use different
// sessions. A single session is intended to represent a single user
// engagement in the urlbar, which this is not. Practically this isn't
// necessary since the client automatically resets the session on a timer
// whose period is much shorter than our fetch period, but there's no reason
// to keep it ticking in the meantime.
merino.resetSession();
if (merino != this.#merino || instance != this.#fetchInstance) {
this.logger.info("Fetch finished but is out of date, ignoring");
} else {
let suggestion = suggestions?.[0];
if (!suggestion) {
// No suggestion was received. The network may be offline or there may
// be some other problem. Set the suggestion to null: Better to show
// nothing than outdated weather information. When the network comes
// back online, one or more network notifications will be sent,
// triggering a new fetch.
this.logger.info("No suggestion received");
this.#suggestion = null;
} else {
this.logger.info("Got suggestion");
this.logger.debug(JSON.stringify({ suggestion }));
this.#suggestion = { ...suggestion, source: "merino" };
}
}
if (!this.#pendingFetchCount) {
this.#waitForFetchesDeferred?.resolve();
this.#waitForFetchesDeferred = null;
}
}
#restartFetchTimer(ms = this.#fetchIntervalMs) {
this.logger.debug(
"Restarting fetch timer: " +
JSON.stringify({ ms, fetchIntervalMs: this.#fetchIntervalMs })
);
lazy.clearTimeout(this.#fetchTimer);
this.#fetchTimer = lazy.setTimeout(() => {
this.logger.debug("Fetch timer fired");
this.#fetch();
}, ms);
this._test_fetchTimerMs = ms;
}
#onMaybeCameOnline() {
this.logger.debug("Maybe came online");
// If the suggestion is null, we were offline the last time we tried to
// fetch, at the start of the current fetch period. Otherwise the suggestion
// was fetched successfully at the start of the current fetch period and is
// therefore still fresh.
if (!this.suggestion) {
// Multiple notifications can occur at once when the network comes online,
// and we don't want to do separate fetches for each. Start the timer with
// a small timeout. If another notification happens in the meantime, we'll
// start it again.
this.#restartFetchTimer(this.#fetchDelayAfterComingOnlineMs);
}
}
#onWake() {
// Calculate the elapsed time between the last fetch and now, and the
// remaining interval in the current fetch period.
let elapsedMs = Date.now() - this.#lastFetchTimeMs;
let remainingIntervalMs = this.#fetchIntervalMs - elapsedMs;
this.logger.debug(
"Wake: " +
JSON.stringify({
elapsedMs,
remainingIntervalMs,
fetchIntervalMs: this.#fetchIntervalMs,
})
);
// Regardless of the elapsed time, we need to restart the fetch timer
// because it didn't tick while the computer was asleep. If the elapsed time
// >= the fetch interval, the remaining interval will be negative and we
// need to fetch now, but do it after a brief delay in case other
// notifications occur soon when the network comes online. If the elapsed
// time < the fetch interval, the suggestion is still fresh so there's no
// need to fetch. Just restart the timer with the remaining interval.
if (remainingIntervalMs <= 0) {
remainingIntervalMs = this.#fetchDelayAfterComingOnlineMs;
}
this.#restartFetchTimer(remainingIntervalMs);
}
#updateConfig() {
this.logger.debug("Starting config update");
// Get the full keywords, preferring Nimbus over remote settings.
let fullKeywords =
lazy.UrlbarPrefs.get("weatherKeywords") ?? this.#rsData?.keywords;
if (!fullKeywords) {
this.logger.debug("No keywords defined, stopping suggestion fetching");
this.#keywords = null;
this.#stopFetching();
return;
}
let minLength = this.minKeywordLength;
this.logger.debug(
"Updating keywords: " + JSON.stringify({ fullKeywords, minLength })
);
if (!minLength) {
this.logger.debug("Min length is undefined or zero, using full keywords");
this.#keywords = new Set(fullKeywords);
} else {
// Create keywords that are prefixes of the full keywords starting at the
// specified minimum length.
this.#keywords = new Set();
for (let full of fullKeywords) {
for (let i = minLength; i <= full.length; i++) {
this.#keywords.add(full.substring(0, i));
}
}
}
this.#startFetching();
}
onPrefChanged(pref) {
if (pref == "weather.minKeywordLength") {
this.#updateConfig();
}
}
observe(subject, topic, data) {
this.logger.debug(
"Observed notification: " + JSON.stringify({ topic, data })
);
switch (topic) {
case NOTIFICATIONS.CAPTIVE_PORTAL_LOGIN:
this.#onMaybeCameOnline();
break;
case NOTIFICATIONS.LINK_STATUS_CHANGED:
// This notificaton means the user's connection status changed. See
// nsINetworkLinkService.
if (data != "down") {
this.#onMaybeCameOnline();
}
break;
case NOTIFICATIONS.OFFLINE_STATUS_CHANGED:
// This notificaton means the user toggled the "Work Offline" pref.
// See nsIIOService.
if (data != "offline") {
this.#onMaybeCameOnline();
}
break;
case NOTIFICATIONS.WAKE:
this.#onWake();
break;
}
}
get _test_fetchDelayAfterComingOnlineMs() {
return this.#fetchDelayAfterComingOnlineMs;
}
set _test_fetchDelayAfterComingOnlineMs(ms) {
this.#fetchDelayAfterComingOnlineMs =
ms < 0 ? FETCH_DELAY_AFTER_COMING_ONLINE_MS : ms;
}
get _test_fetchIntervalMs() {
return this.#fetchIntervalMs;
}
set _test_fetchIntervalMs(ms) {
this.#fetchIntervalMs = ms < 0 ? FETCH_INTERVAL_MS : ms;
}
get _test_fetchTimer() {
return this.#fetchTimer;
}
get _test_lastFetchTimeMs() {
return this.#lastFetchTimeMs;
}
get _test_merino() {
return this.#merino;
}
get _test_pendingFetchCount() {
return this.#pendingFetchCount;
}
async _test_fetch() {
await this.#fetch();
}
_test_setRsData(data) {
this.#rsData = data;
this.#updateConfig();
}
_test_setSuggestionToNull() {
this.#suggestion = null;
}
_test_setTimeoutMs(ms) {
this.#timeoutMs = ms < 0 ? MERINO_TIMEOUT_MS : ms;
}
#fetchDelayAfterComingOnlineMs = FETCH_DELAY_AFTER_COMING_ONLINE_MS;
#fetchInstance = null;
#fetchIntervalMs = FETCH_INTERVAL_MS;
#fetchTimer = 0;
#keywords = null;
#lastFetchTimeMs = 0;
#merino = null;
#pendingFetchCount = 0;
#rsData = null;
#suggestion = null;
#timeoutMs = MERINO_TIMEOUT_MS;
#waitForFetchesDeferred = null;
}