commit 915e56bb1277a90f46ef72a1b68cf725c1ce3753
parent 3877900f35e2d0976a296aa4bb83588a21990fa4
Author: Andrea Marchesini <amarchesini@mozilla.com>
Date: Mon, 13 Oct 2025 16:45:57 +0000
Bug 1993175 - Introduce a startup-cache for IP Protection service, r=ip-protection-reviewers,fchasen
Differential Revision: https://phabricator.services.mozilla.com/D267919
Diffstat:
7 files changed, 377 insertions(+), 18 deletions(-)
diff --git a/browser/components/ipprotection/IPPSignInWatcher.sys.mjs b/browser/components/ipprotection/IPPSignInWatcher.sys.mjs
@@ -10,12 +10,6 @@ ChromeUtils.defineESModuleGetters(lazy, {
UIState: "resource://services-sync/UIState.sys.mjs",
});
-ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
- return ChromeUtils.importESModule(
- "resource://gre/modules/FxAccounts.sys.mjs"
- ).getFxAccountsSingleton();
-});
-
/**
* This class monitors the Sign-In state and triggers the update of the service
* if needed.
@@ -31,10 +25,14 @@ class IPPSignInWatcherSingleton {
this.#signedIn = signedIn;
}
+ init() {
+ this.#signedIn = Services.prefs.prefHasUserValue("services.sync.username");
+ }
+
/**
- * Adds an observer for the FxA sign-in state.
+ * Adds an observer for the FxA sign-in state, only when the browser is fully started.
*/
- async init() {
+ async initOnStartupCompleted() {
this.fxaObserver = {
QueryInterface: ChromeUtils.generateQI([
Ci.nsIObserver,
@@ -52,13 +50,6 @@ class IPPSignInWatcherSingleton {
};
Services.obs.addObserver(this.fxaObserver, lazy.UIState.ON_UPDATE);
-
- // Let's check the sign-in state only if we have seen the user signed in
- // once at least.
- if (Services.prefs.prefHasUserValue("services.sync.username")) {
- const userData = await lazy.fxAccounts.getSignedInUser();
- this.#signedIn = userData?.verified || false;
- }
}
/**
diff --git a/browser/components/ipprotection/IPPStartupCache.sys.mjs b/browser/components/ipprotection/IPPStartupCache.sys.mjs
@@ -0,0 +1,133 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ IPProtectionService:
+ "resource:///modules/ipprotection/IPProtectionService.sys.mjs",
+ IPProtectionStates:
+ "resource:///modules/ipprotection/IPProtectionService.sys.mjs",
+});
+
+const STATE_CACHE_PREF = "browser.ipProtection.stateCache";
+const ENTITLEMENT_CACHE_PREF = "browser.ipProtection.entitlementCache";
+
+/**
+ * This class implements a cache for the IPP state machine. The cache is used
+ * until we receive the `sessionstore-windows-restored` event
+ */
+class IPPStartupCacheSingleton {
+ #stateFromCache = null;
+ #startupCompleted = false;
+
+ constructor() {
+ // For XPCShell tests, the cache must be disabled.
+ if (
+ Services.prefs.getBoolPref("browser.ipProtection.cacheDisabled", false)
+ ) {
+ this.#startupCompleted = true;
+ return;
+ }
+
+ this.handleEvent = this.#handleEvent.bind(this);
+
+ const stateFromCache = Services.prefs.getCharPref(
+ STATE_CACHE_PREF,
+ "unset"
+ );
+ if (stateFromCache !== "unset") {
+ this.#stateFromCache = stateFromCache;
+ }
+
+ Services.obs.addObserver(this, "sessionstore-windows-restored");
+ }
+
+ init() {
+ lazy.IPProtectionService.addEventListener(
+ "IPProtectionService:StateChanged",
+ this.handleEvent
+ );
+
+ // The state cannot be "ACTIVE" from cache. In case we need to activate the
+ // proxy at startup time, something else will take care of it.
+ if (this.#stateFromCache === lazy.IPProtectionStates.ACTIVE) {
+ this.#stateFromCache = lazy.IPProtectionStates.READY;
+ }
+ }
+
+ async initOnStartupCompleted() {}
+
+ uninit() {
+ lazy.IPProtectionService.removeEventListener(
+ "IPProtectionService:StateChanged",
+ this.handleEvent
+ );
+ }
+
+ get isStartupCompleted() {
+ return this.#startupCompleted;
+ }
+
+ get state() {
+ if (this.#startupCompleted) {
+ throw new Error("IPPStartupCache should not be used after the startup");
+ }
+
+ if (Object.values(lazy.IPProtectionStates).includes(this.#stateFromCache)) {
+ return this.#stateFromCache;
+ }
+
+ // This should not happen.
+ return lazy.IPProtectionStates.UNINITIALIZED;
+ }
+
+ async observe(_subject, topic, _) {
+ if (topic !== "sessionstore-windows-restored") {
+ return;
+ }
+
+ // The browser is ready! Let's invalidate the cache and let's recompute the
+ // state.
+
+ Services.obs.removeObserver(this, "sessionstore-windows-restored");
+ this.#startupCompleted = true;
+ this.#stateFromCache = null;
+
+ await lazy.IPProtectionService.initOnStartupCompleted();
+ lazy.IPProtectionService.updateState();
+ }
+
+ storeEntitlement(entitlement) {
+ Services.prefs.setCharPref(
+ ENTITLEMENT_CACHE_PREF,
+ JSON.stringify(entitlement)
+ );
+ }
+
+ get entitlement() {
+ try {
+ const entitlement = Services.prefs.getCharPref(
+ ENTITLEMENT_CACHE_PREF,
+ ""
+ );
+ return JSON.parse(entitlement);
+ } catch (e) {
+ return null;
+ }
+ }
+
+ #handleEvent(_event) {
+ const state = lazy.IPProtectionService.state;
+ if (this.#startupCompleted) {
+ Services.prefs.setCharPref(STATE_CACHE_PREF, state);
+ } else {
+ this.#stateFromCache = state;
+ }
+ }
+}
+
+const IPPStartupCache = new IPPStartupCacheSingleton();
+
+export { IPPStartupCache, IPPStartupCacheSingleton };
diff --git a/browser/components/ipprotection/IPProtectionHelpers.sys.mjs b/browser/components/ipprotection/IPProtectionHelpers.sys.mjs
@@ -20,6 +20,7 @@ ChromeUtils.defineESModuleGetters(lazy, {
});
import { IPPSignInWatcher } from "resource:///modules/ipprotection/IPPSignInWatcher.sys.mjs";
+import { IPPStartupCache } from "resource:///modules/ipprotection/IPPStartupCache.sys.mjs";
const VPN_ADDON_ID = "vpn@mozilla.com";
@@ -38,6 +39,8 @@ class UIHelper {
);
}
+ initOnStartupCompleted() {}
+
uninit() {
lazy.IPProtectionService.removeEventListener(
"IPProtectionService:StateChanged",
@@ -76,6 +79,8 @@ class AccountResetHelper {
);
}
+ initOnStartupCompleted() {}
+
uninit() {
lazy.IPProtectionService.removeEventListener(
"IPProtectionService:StateChanged",
@@ -101,10 +106,12 @@ class AccountResetHelper {
* This class removes the UI widget if the VPN add-on is installed.
*/
class VPNAddonHelper {
+ init() {}
+
/**
* Adds an observer to monitor the VPN add-on installation
*/
- init() {
+ initOnStartupCompleted() {
this.addonVPNListener = {
onInstallEnded(_install, addon) {
if (addon.id === VPN_ADDON_ID && lazy.IPProtectionService.hasUpgraded) {
@@ -133,7 +140,9 @@ class VPNAddonHelper {
* This class monitors the eligibility flag from Nimbus
*/
class EligibilityHelper {
- init() {
+ init() {}
+
+ initOnStartupCompleted() {
lazy.NimbusFeatures.ipProtection.onUpdate(
lazy.IPProtectionService.updateState
);
@@ -147,6 +156,7 @@ class EligibilityHelper {
}
const IPPHelpers = [
+ IPPStartupCache,
new AccountResetHelper(),
new EligibilityHelper(),
new VPNAddonHelper(),
diff --git a/browser/components/ipprotection/IPProtectionService.sys.mjs b/browser/components/ipprotection/IPProtectionService.sys.mjs
@@ -11,6 +11,7 @@ ChromeUtils.defineESModuleGetters(lazy, {
IPPHelpers: "resource:///modules/ipprotection/IPProtectionHelpers.sys.mjs",
IPPProxyManager: "resource:///modules/ipprotection/IPPProxyManager.sys.mjs",
IPPSignInWatcher: "resource:///modules/ipprotection/IPPSignInWatcher.sys.mjs",
+ IPPStartupCache: "resource:///modules/ipprotection/IPPStartupCache.sys.mjs",
SpecialMessageActions:
"resource://messaging-system/lib/SpecialMessageActions.sys.mjs",
NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
@@ -140,11 +141,17 @@ class IPProtectionServiceSingleton extends EventTarget {
if (this.#state !== IPProtectionStates.UNINITIALIZED) {
return;
}
+
this.proxyManager = new lazy.IPPProxyManager(this.guardian);
+ this.#entitlement = lazy.IPPStartupCache.entitlement;
- await Promise.allSettled(this.#helpers.map(helper => helper.init()));
+ this.#helpers.forEach(helper => helper.init());
await this.#updateState();
+
+ if (lazy.IPPStartupCache.isStartupCompleted) {
+ this.initOnStartupCompleted();
+ }
}
/**
@@ -169,6 +176,12 @@ class IPProtectionServiceSingleton extends EventTarget {
this.#setState(IPProtectionStates.UNINITIALIZED);
}
+ async initOnStartupCompleted() {
+ await Promise.allSettled(
+ this.#helpers.map(helper => helper.initOnStartupCompleted())
+ );
+ }
+
/**
* Start the proxy if the user is eligible.
*
@@ -267,6 +280,8 @@ class IPProtectionServiceSingleton extends EventTarget {
*/
resetAccount() {
this.#entitlement = null;
+ lazy.IPPStartupCache.storeEntitlement(null);
+
if (this.proxyManager?.active) {
this.stop(false);
}
@@ -317,6 +332,8 @@ class IPProtectionServiceSingleton extends EventTarget {
async refetchEntitlement() {
let prevState = this.#state;
this.#entitlement = null;
+ lazy.IPPStartupCache.storeEntitlement(null);
+
await this.#updateState();
// hasUpgraded might not change the state.
if (prevState === this.#state) {
@@ -383,6 +400,7 @@ class IPProtectionServiceSingleton extends EventTarget {
// Entitlement is set until the user changes or it is cleared to check subscription status.
this.#entitlement = entitlement;
+ lazy.IPPStartupCache.storeEntitlement(entitlement);
return entitlement;
}
@@ -419,6 +437,11 @@ class IPProtectionServiceSingleton extends EventTarget {
return IPProtectionStates.UNINITIALIZED;
}
+ // Maybe we have to use the cached state, because we are not initialized yet.
+ if (!lazy.IPPStartupCache.isStartupCompleted) {
+ return lazy.IPPStartupCache.state;
+ }
+
// For non authenticated users, we can check if they are eligible (the UI
// is shown and they have to login) or we don't know yet their current
// enroll state (no UI is shown).
diff --git a/browser/components/ipprotection/moz.build b/browser/components/ipprotection/moz.build
@@ -22,6 +22,7 @@ EXTRA_JS_MODULES.ipprotection += [
"IPProtectionService.sys.mjs",
"IPProtectionUsage.sys.mjs",
"IPPSignInWatcher.sys.mjs",
+ "IPPStartupCache.sys.mjs",
]
BROWSER_CHROME_MANIFESTS += [
diff --git a/browser/components/ipprotection/tests/xpcshell/test_IPPStartupCache.js b/browser/components/ipprotection/tests/xpcshell/test_IPPStartupCache.js
@@ -0,0 +1,198 @@
+/* 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";
+
+const { IPPStartupCacheSingleton } = ChromeUtils.importESModule(
+ "resource:///modules/ipprotection/IPPStartupCache.sys.mjs"
+);
+
+const { IPProtectionStates } = ChromeUtils.importESModule(
+ "resource:///modules/ipprotection/IPProtectionService.sys.mjs"
+);
+
+/**
+ * Test the disabled cache
+ */
+add_task(async function test_IPPStartupCache_disabled() {
+ // By default the cache is not active.
+ Services.prefs.setBoolPref("browser.ipProtection.cacheDisabled", true);
+ const cache = new IPPStartupCacheSingleton();
+ cache.init();
+
+ Assert.ok(
+ cache.isStartupCompleted,
+ "In XPCShell mode the cache is not active"
+ );
+});
+
+/**
+ * Test the enabled cache
+ */
+add_task(async function test_IPPStartupCache_enabled() {
+ // By default the cache is not active.
+ Services.prefs.setBoolPref("browser.ipProtection.cacheDisabled", false);
+
+ // Default state is UNINITIALIZED
+ {
+ const cache = new IPPStartupCacheSingleton();
+ cache.init();
+
+ Assert.ok(
+ !cache.isStartupCompleted,
+ "In XPCShell mode the cache is active"
+ );
+ Assert.equal(
+ cache.state,
+ IPProtectionStates.UNINITIALIZED,
+ "The state is unitialized"
+ );
+ }
+
+ // Fetch the cached state
+ {
+ Services.prefs.setCharPref(
+ "browser.ipProtection.stateCache",
+ IPProtectionStates.READY
+ );
+
+ const cache = new IPPStartupCacheSingleton();
+ cache.init();
+
+ Assert.ok(
+ !cache.isStartupCompleted,
+ "In XPCShell mode the cache is active"
+ );
+ Assert.equal(cache.state, IPProtectionStates.READY, "The state is READY");
+ }
+
+ // Invalid cache means UNINITIALIZED
+ {
+ Services.prefs.setCharPref(
+ "browser.ipProtection.stateCache",
+ "Hello World!"
+ );
+
+ const cache = new IPPStartupCacheSingleton();
+ cache.init();
+
+ Assert.ok(
+ !cache.isStartupCompleted,
+ "In XPCShell mode the cache is active"
+ );
+ Assert.equal(
+ cache.state,
+ IPProtectionStates.UNINITIALIZED,
+ "The state is unitialized"
+ );
+ }
+
+ // ACTIVE to READY
+ {
+ Services.prefs.setCharPref(
+ "browser.ipProtection.stateCache",
+ IPProtectionStates.ACTIVE
+ );
+
+ const cache = new IPPStartupCacheSingleton();
+ cache.init();
+
+ Assert.ok(
+ !cache.isStartupCompleted,
+ "In XPCShell mode the cache is active"
+ );
+ Assert.equal(cache.state, IPProtectionStates.READY, "The state is READY");
+ }
+});
+
+/**
+ * Cache the entitlement
+ */
+add_task(async function test_IPPStartupCache_enabled() {
+ // By default the cache is not active.
+ Services.prefs.setBoolPref("browser.ipProtection.cacheDisabled", false);
+
+ // Default entitlement is null
+ {
+ const cache = new IPPStartupCacheSingleton();
+ cache.init();
+
+ Assert.ok(
+ !cache.isStartupCompleted,
+ "In XPCShell mode the cache is active"
+ );
+ Assert.equal(cache.entitlement, null, "Null entitlement");
+ }
+
+ // A JSON object for entitlement
+ {
+ Services.prefs.setCharPref(
+ "browser.ipProtection.entitlementCache",
+ '{"a": 42}'
+ );
+
+ const cache = new IPPStartupCacheSingleton();
+ cache.init();
+
+ Assert.ok(
+ !cache.isStartupCompleted,
+ "In XPCShell mode the cache is active"
+ );
+ Assert.deepEqual(
+ cache.entitlement,
+ { a: 42 },
+ "A valid entitlement object"
+ );
+ }
+
+ // Invalid JSON
+ {
+ Services.prefs.setCharPref(
+ "browser.ipProtection.entitlementCache",
+ '{"a": 42}}}}'
+ );
+
+ const cache = new IPPStartupCacheSingleton();
+ cache.init();
+
+ Assert.ok(
+ !cache.isStartupCompleted,
+ "In XPCShell mode the cache is active"
+ );
+ Assert.equal(cache.entitlement, null, "Null entitlement");
+ }
+
+ // Setter
+ {
+ const cache = new IPPStartupCacheSingleton();
+ cache.init();
+
+ Assert.ok(
+ !cache.isStartupCompleted,
+ "In XPCShell mode the cache is active"
+ );
+ Assert.equal(cache.entitlement, null, "Null entitlement");
+
+ cache.storeEntitlement(42);
+ Assert.equal(
+ Services.prefs.getCharPref("browser.ipProtection.entitlementCache", ""),
+ "42",
+ "The cache is correctly stored (number)"
+ );
+
+ cache.storeEntitlement(null);
+ Assert.equal(
+ Services.prefs.getCharPref("browser.ipProtection.entitlementCache", ""),
+ "null",
+ "The cache is correctly stored (null)"
+ );
+
+ cache.storeEntitlement({ a: 42 });
+ Assert.equal(
+ Services.prefs.getCharPref("browser.ipProtection.entitlementCache", ""),
+ '{"a":42}',
+ "The cache is correctly stored (obj)"
+ );
+ }
+});
diff --git a/browser/components/ipprotection/tests/xpcshell/xpcshell.toml b/browser/components/ipprotection/tests/xpcshell/xpcshell.toml
@@ -4,12 +4,15 @@ head = "head.js"
firefox-appdir = "browser"
prefs = [
"browser.ipProtection.enabled=true",
+ "browser.ipProtection.cacheDisabled=true",
]
["test_GuardianClient.js"]
["test_IPPExceptionsManager_exclusions.js"]
+["test_IPPStartupCache.js"]
+
["test_IPProtection.js"]
["test_IPProtectionPanel.js"]