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
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { NetworkGeoPositionObject } from "moz-src:///dom/system/NetworkGeoPosition.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
});
// A cached location is only valid on the network it was obtained from, so the
// cache drops itself when the network link changes and the public IP (or set of
// visible access points) may differ. See nsINetworkLinkService.
const NETWORK_LINK_TOPIC = "network:link-status-changed";
// Sent when nsINetworkLinkService.networkID changes, including when it is first
// determined after startup. Until then it is empty and a persisted entry cannot
// be checked against the current network.
const NETWORK_ID_TOPIC = "network:networkid-changed";
// Backup cache to file, so it persists beyond the browser session.
const CACHE_FILE = "networkGeolocationCache.json";
const CACHE_FILE_VERSION = 1;
// nsINetworkLinkService.networkID is mixed with a per-session seed, so the ID
// persisted with the entry only identifies the current network when compared
// through nsINetworkLinkService.matchNetworkID with the seed of the session
// that stored it. This pref is not declared so it is not available in
// content processes.
const CACHE_SEED_PREF = "geo.provider.network.requestCache.networkIDSeed";
// Bounds on geo.provider.network.requestCache.maxAgeDays, in days.
const CACHE_MAX_AGE_DAYS_LOWER_LIMIT = 1;
const CACHE_MAX_AGE_DAYS_UPPER_LIMIT = 30;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"gCacheMaxAgeDays",
"geo.provider.network.requestCache.maxAgeDays",
30
);
// Mochitest disables the request cache to simulate request failures.
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"gRequestCacheEnabled",
"geo.provider.network.debug.requestCache.enabled",
true
);
XPCOMUtils.defineLazyServiceGetter(
lazy,
"gNetworkLinkService",
"@mozilla.org/network/network-link-service;1",
Ci.nsINetworkLinkService
);
ChromeUtils.defineLazyGetter(lazy, "log", () => {
let consoleOptions = {
maxLogLevelPref: "geo.provider.network.loglevel",
prefix: "NetworkGeolocationCache",
};
return console.createInstance(consoleOptions);
});
// Build a Set of access-point MAC addresses from a wifi list.
function wifiMacSet(wifiList = []) {
return new Set(wifiList.map(ap => ap.macAddress));
}
// Two sets are approximately equal if at least 50% of the larger set is common
// to both.
function wifiSetsApproxEqual(setA, setB) {
if (!setA.size || !setB.size) {
return false;
}
let common = setA.intersection(setB).size;
let kPercentMatch = 0.5;
return common >= Math.max(setA.size, setB.size) * kPercentMatch;
}
// geo.provider.network.requestCache.maxAgeDays, clamped to
// [CACHE_MAX_AGE_DAYS_LOWER_LIMIT, CACHE_MAX_AGE_DAYS_UPPER_LIMIT], in
// milliseconds.
function cacheMaxAgeMs() {
let days = Math.min(
Math.max(lazy.gCacheMaxAgeDays, CACHE_MAX_AGE_DAYS_LOWER_LIMIT),
CACHE_MAX_AGE_DAYS_UPPER_LIMIT
);
return days * MS_PER_DAY;
}
// nsINetworkLinkService.networkID, or "" if the service cannot provide one.
function currentNetworkID() {
try {
return lazy.gNetworkLinkService.networkID;
} catch (err) {
lazy.log.warn("Could not read the network ID", err);
return "";
}
}
// Compares a network ID persisted by an earlier session with the current
// network. Returns null while the current network ID is not yet determined,
// otherwise the current network ID if they are the same network, or "".
function matchPersistedNetworkID(networkID) {
try {
let current = {};
if (
!lazy.gNetworkLinkService.matchNetworkID(
networkID,
Services.prefs.getStringPref(CACHE_SEED_PREF, ""),
current
)
) {
return null;
}
return current.value;
} catch (err) {
lazy.log.warn(
"Could not compare the persisted network ID (should be impossible)",
err
);
return "";
}
}
// The file holds { version, entry? }; returns true if the data has that shape
// and any entry has the fields store() writes.
function isValidPersistedData(data) {
if (data?.version !== CACHE_FILE_VERSION) {
return false;
}
let entry = data.entry;
if (entry === undefined) {
return true;
}
return (
typeof entry?.lat == "number" &&
typeof entry.lng == "number" &&
(entry.accuracy === undefined || typeof entry.accuracy == "number") &&
typeof entry.timestamp == "number" &&
typeof entry.networkID == "string" &&
Array.isArray(entry.wifis) &&
entry.wifis.every(mac => typeof mac == "string")
);
}
// Caches the most recent network-geolocation response so repeated lookups can
// reuse it instead of re-querying.
class CachedResponse {
QueryInterface = ChromeUtils.generateQI(["nsIObserver"]);
location = null;
#wifis = new Set();
#timestamp = 0;
// The network ID the entry was obtained on, or "" if it was stored before the
// ID had been determined.
#networkID = "";
/** @type {JSONFile?} null if the profile has no usable directory. */
#file = null;
// False until the persisted entry has been adopted, discarded or superseded.
#fileChecked = false;
// Resolves once the persisted entry has been read or is determined invalid.
loaded;
constructor() {
Services.obs.addObserver(this, NETWORK_LINK_TOPIC);
Services.obs.addObserver(this, NETWORK_ID_TOPIC);
try {
this.#file = new lazy.JSONFile({
path: PathUtils.join(PathUtils.profileDir, CACHE_FILE),
dataPostProcessor: data =>
isValidPersistedData(data) ? data : { version: CACHE_FILE_VERSION },
});
} catch (err) {
lazy.log.warn("The request cache will not be persisted", err);
this.#fileChecked = true;
}
this.loaded = this.#load();
}
async #load() {
if (!this.#file) {
return;
}
await this.#file.load();
this.#maybeAdoptPersistedEntry();
}
// Adopts the persisted entry if it was obtained on the current network
// (as determined by network ID) and discards it otherwise.
#maybeAdoptPersistedEntry() {
if (this.#fileChecked || !this.#file?.dataReady) {
return;
}
let { entry } = this.#file.data;
if (!entry) {
this.#fileChecked = true;
Glean.geolocation.persistedCache.absent.add();
return;
}
let networkID = matchPersistedNetworkID(entry.networkID);
if (networkID === null) {
lazy.log.debug("Persisted request cache awaits the network ID");
return;
}
this.#fileChecked = true;
if (!networkID) {
lazy.log.debug("Discarding persisted request cache: network changed");
Glean.geolocation.persistedCache.rejected.add();
this.clear();
return;
}
this.location = new NetworkGeoPositionObject(
entry.lat,
entry.lng,
entry.accuracy
);
this.#wifis = new Set(entry.wifis);
this.#timestamp = entry.timestamp;
this.#networkID = networkID;
Glean.geolocation.persistedCache.adopted.add();
lazy.log.debug("Loaded persisted request cache");
}
// True if the cache file holds a location that has not yet been checked
// against the current network.
#hasUncheckedPersistedEntry() {
// !#fileChecked means that the cache file hasn't been checked since the
// current network's identity was established.
// dataReady means the cache file has been read (it may not have existed)
// and data.entry means that it holds a location.
return (
!this.#fileChecked && this.#file?.dataReady && !!this.#file.data.entry
);
}
store(location, wifiList) {
if (this.#hasUncheckedPersistedEntry()) {
Glean.geolocation.persistedCache.superseded.add();
}
this.location = location;
this.#wifis = wifiMacSet(wifiList);
this.#timestamp = Date.now();
this.#networkID = currentNetworkID();
this.#fileChecked = true;
this.#persist();
}
clear() {
this.location = null;
this.#wifis = new Set();
this.#timestamp = 0;
this.#networkID = "";
this.#fileChecked = true;
this.#persist();
}
// Setting JSONFile.data before load() resolves makes load() a no-op, so a
// store() or clear() that arrives before the file was finished reading
// correctly overrides the persisted data.
#persist() {
if (!this.#file) {
return;
}
let entry;
if (this.location) {
entry = {
lat: this.location.coords.latitude,
lng: this.location.coords.longitude,
accuracy: this.location.coords.accuracy,
wifis: [...this.#wifis],
networkID: this.#networkID,
timestamp: this.#timestamp,
};
}
this.#file.data = { version: CACHE_FILE_VERSION, entry };
this.#file.saveSoon();
if (entry?.networkID) {
try {
Services.prefs.setStringPref(
CACHE_SEED_PREF,
lazy.gNetworkLinkService.networkIDSeed
);
} catch (err) {
lazy.log.warn("Could not record the network ID seed", err);
}
}
}
#onNetworkIDChanged() {
let networkID = currentNetworkID();
// The ID is empty when the link is down, which does not imply a different
// network; see the handling of "down" in observe().
if (!networkID) {
return;
}
if (!this.location) {
this.#maybeAdoptPersistedEntry();
return;
}
if (!this.#networkID) {
// The entry was obtained before the ID was determined. A real network
// change since then would have cleared the entry, so it corresponds to
// the current network ID.
this.#networkID = networkID;
this.#persist();
} else if (this.#networkID != networkID) {
this.clear();
}
}
hasLocation() {
return !!this.location;
}
// True if the entry is older than the configured maximum age, or is dated in
// the future, which would otherwise let a clock change keep it past the limit.
isExpired() {
let age = Date.now() - this.#timestamp;
return age < 0 || age >= cacheMaxAgeMs();
}
hasWifis() {
return this.#wifis.size > 0;
}
isGeoip() {
return !this.hasWifis();
}
isWifiApproxEqual(wifiList) {
return wifiSetsApproxEqual(this.#wifis, wifiMacSet(wifiList));
}
observe(subject, topic, data) {
if (topic === NETWORK_ID_TOPIC) {
this.#onNetworkIDChanged();
return;
}
if (topic !== NETWORK_LINK_TOPIC) {
return;
}
Glean.geolocation.networkLinkChange[data].add();
// "down"/"unknown" do not imply a different network. Losing the link cannot
// make a cached position wrong, and up/down are edge-triggered, so any
// return of the link fires "up" and invalidates before the first request
// that could have been served from the stale entry.
if (data === "changed" || data === "up") {
this.clear();
}
}
// The instance is unusable after this.
async finalizeForTesting() {
Services.obs.removeObserver(this, NETWORK_LINK_TOPIC);
Services.obs.removeObserver(this, NETWORK_ID_TOPIC);
await this.#file?.finalize();
}
}
// The single cache instance, created lazily to avoid needlessly registering
// the NetworkLinkService observer.
/** @type {CachedResponse?} */
var gCachedResponse = null;
var gDebugCacheReasoning = ""; // for logging the caching logic
function ensureCachedResponse() {
if (!gCachedResponse) {
gCachedResponse = new CachedResponse();
}
}
export const NetworkGeolocationCache = {
get debugReason() {
return gDebugCacheReasoning;
},
store(location, wifiList) {
ensureCachedResponse();
gCachedResponse.store(location, wifiList);
},
// Drops the cache, its file and the recorded network ID seed, for when the
// user clears cached data.
clear() {
// Do this to guarantee that any cache file we can access is cleared.
ensureCachedResponse();
gCachedResponse.clear();
Services.prefs.clearUserPref(CACHE_SEED_PREF);
},
// Drops the in-memory cache so the next lookup reloads it from disk.
async resetForTesting() {
await gCachedResponse?.finalizeForTesting();
gCachedResponse = null;
},
// Returns the cached location to reuse for a request with the given wifi list,
// or null if the cache is unusable: disabled, empty, expired, or
// insufficiently accurate to service the new request (as determined by
// isWifiApproxEqual).
async getValidLocation(newWifiList) {
gDebugCacheReasoning = "";
if (!lazy.gRequestCacheEnabled) {
gCachedResponse?.clear();
gDebugCacheReasoning = "Cache disabled";
return null;
}
ensureCachedResponse();
await gCachedResponse.loaded;
if (gCachedResponse.hasLocation() && gCachedResponse.isExpired()) {
gCachedResponse.clear();
gDebugCacheReasoning = "Cached data expired";
return null;
}
if (!gCachedResponse.hasLocation()) {
gDebugCacheReasoning = "No cached data";
return null;
}
if (!newWifiList) {
gDebugCacheReasoning = "New req. is GeoIP.";
return gCachedResponse.location;
}
let hasEqualWifis = gCachedResponse.isWifiApproxEqual(newWifiList);
gDebugCacheReasoning = `EqualWifis: ${hasEqualWifis}`;
if (gCachedResponse.hasWifis() && hasEqualWifis) {
gDebugCacheReasoning += ", Wifi only.";
return gCachedResponse.location;
}
return null;
},
};