summaryrefslogtreecommitdiffstats
path: root/browser/components/urlbar/private/Weather.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/urlbar/private/Weather.sys.mjs')
-rw-r--r--browser/components/urlbar/private/Weather.sys.mjs499
1 files changed, 499 insertions, 0 deletions
diff --git a/browser/components/urlbar/private/Weather.sys.mjs b/browser/components/urlbar/private/Weather.sys.mjs
new file mode 100644
index 0000000000..ea9eddfa2c
--- /dev/null
+++ b/browser/components/urlbar/private/Weather.sys.mjs
@@ -0,0 +1,499 @@
+/* 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_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;
+}