commit 1dc0dfe400cfa1fc8c650283404e1499bdba475a
parent 792a8ccf58ec9d8cc1adbc1e4a4a417c7018e485
Author: Mike Conley <mconley@mozilla.com>
Date: Wed, 17 Dec 2025 16:19:02 +0000
Bug 2001387 - Part 2: Have FirstStartup provide a category hook for modules that need to block first-window opening for a new profile on Windows. r=Gijs
Differential Revision: https://phabricator.services.mozilla.com/D276295
Diffstat:
7 files changed, 242 insertions(+), 9 deletions(-)
diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js
@@ -3318,6 +3318,7 @@ pref("devtools.popup.disable_autohide", false);
// FirstStartup service time-out in ms
pref("first-startup.timeout", 30000);
+pref("first-startup.category-tasks-enabled", true);
// Enable the default browser agent.
// The agent still runs as scheduled if this pref is disabled,
diff --git a/toolkit/modules/FirstStartup.sys.mjs b/toolkit/modules/FirstStartup.sys.mjs
@@ -7,11 +7,14 @@ import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
+ BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
Normandy: "resource://normandy/Normandy.sys.mjs",
TaskScheduler: "resource://gre/modules/TaskScheduler.sys.mjs",
});
const PREF_TIMEOUT = "first-startup.timeout";
+const PREF_CATEGORY_TASKS = "first-startup.category-tasks-enabled";
+const CATEGORY_NAME = "first-startup-new-profile";
/**
* Service for blocking application startup, to be used on the first install. The intended
@@ -63,12 +66,14 @@ export var FirstStartup = {
let promises = [];
let normandyInitEndTime = null;
+ let normandyInitPromise = null;
if (AppConstants.MOZ_NORMANDY) {
- promises.push(
- lazy.Normandy.init({ runAsync: false }).finally(() => {
+ normandyInitPromise = lazy.Normandy.init({ runAsync: false }).finally(
+ () => {
normandyInitEndTime = ChromeUtils.now();
- })
+ }
);
+ promises.push(normandyInitPromise);
}
let deleteTasksEndTime = null;
@@ -84,8 +89,37 @@ export var FirstStartup = {
);
}
+ // Very important things that need to run before we launch the first window
+ // during new profile setup can register a hook with CATEGORY_NAME.
+ //
+ // Consumers should be aware that:
+ //
+ // * This blocks first startup window opening for new installs on Windows
+ // ONLY for the first created default profile
+ // * If PREF_TIMEOUT elapses before all of these promises complete, the
+ // registered category entries might not have all had a chance to
+ // successfully complete before the first browser window appears.
+ const CATEGORY_TASKS_ENABLED = Services.prefs.getBoolPref(
+ PREF_CATEGORY_TASKS,
+ false
+ );
+ let categoryTasksEndTime = null;
+ if (CATEGORY_TASKS_ENABLED && AppConstants.MOZ_NORMANDY) {
+ promises.push(
+ normandyInitPromise.finally(() => {
+ return lazy.BrowserUtils.callModulesFromCategory({
+ categoryName: CATEGORY_NAME,
+ profileMarker: "first-startup-new-profile-tasks",
+ idleDispatch: false,
+ }).finally(() => {
+ categoryTasksEndTime = ChromeUtils.now();
+ });
+ })
+ );
+ }
+
if (promises.length) {
- Promise.all(promises).then(() => (initialized = true));
+ Promise.allSettled(promises).then(() => (initialized = true));
this.elapsed = 0;
Services.tm.spinEventLoopUntil("FirstStartup.sys.mjs:init", () => {
@@ -115,6 +149,12 @@ export var FirstStartup = {
);
}
+ if (CATEGORY_TASKS_ENABLED) {
+ Glean.firstStartup.categoryTasksTime.set(
+ Math.ceil(categoryTasksEndTime || ChromeUtils.now() - startingTime)
+ );
+ }
+
Glean.firstStartup.statusCode.set(this._state);
Glean.firstStartup.elapsed.set(this.elapsed);
GleanPings.firstStartup.submit();
diff --git a/toolkit/modules/metrics.yaml b/toolkit/modules/metrics.yaml
@@ -24,7 +24,6 @@ first_startup:
data_sensitivity:
- technical
notification_emails:
- - rhelmer@mozilla.com
- mconley@mozilla.com
expires: never
send_in_pings:
@@ -42,7 +41,6 @@ first_startup:
data_sensitivity:
- technical
notification_emails:
- - rhelmer@mozilla.com
- mconley@mozilla.com
expires: never
send_in_pings:
@@ -60,7 +58,6 @@ first_startup:
data_sensitivity:
- technical
notification_emails:
- - rhelmer@mozilla.com
- mconley@mozilla.com
expires: never
send_in_pings:
@@ -78,7 +75,24 @@ first_startup:
data_sensitivity:
- technical
notification_emails:
- - rhelmer@mozilla.com
+ - mconley@mozilla.com
+ expires: never
+ send_in_pings:
+ - first-startup
+
+ category_tasks_time:
+ type: quantity
+ unit: milliseconds
+ description: >
+ Number of milliseconds until the tasks registered for first-startup-new-profile-task
+ have resolved.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=2001387
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=2001387
+ data_sensitivity:
+ - technical
+ notification_emails:
- mconley@mozilla.com
expires: never
send_in_pings:
@@ -98,7 +112,6 @@ first_startup:
data_sensitivity:
- technical
notification_emails:
- - rhelmer@mozilla.com
- mconley@mozilla.com
expires: never
send_in_pings:
diff --git a/toolkit/modules/tests/xpcshell/FirstStartupCategoryModule.sys.mjs b/toolkit/modules/tests/xpcshell/FirstStartupCategoryModule.sys.mjs
@@ -0,0 +1,9 @@
+export let FirstStartupCategoryModule = {
+ async firstStartupNewProfile() {
+ Services.obs.notifyObservers(
+ null,
+ "first-startup-category-task-called",
+ "executed"
+ );
+ },
+};
diff --git a/toolkit/modules/tests/xpcshell/test_firstStartup.js b/toolkit/modules/tests/xpcshell/test_firstStartup.js
@@ -7,6 +7,7 @@ const { updateAppInfo } = ChromeUtils.importESModule(
"resource://testing-common/AppInfo.sys.mjs"
);
+const CATEGORY_NAME = "first-startup-new-profile";
const PREF_TIMEOUT = "first-startup.timeout";
add_setup(function test_setup() {
@@ -15,6 +16,10 @@ add_setup(function test_setup() {
// FOG needs to be initialized in order for data to flow.
Services.fog.initializeFOG();
+
+ // Delete any categories that have been registered statically so that we're
+ // just running the one here under test.
+ Services.catMan.deleteCategory(CATEGORY_NAME);
});
add_task(async function test_success() {
diff --git a/toolkit/modules/tests/xpcshell/test_firstStartup_categoryTasks.js b/toolkit/modules/tests/xpcshell/test_firstStartup_categoryTasks.js
@@ -0,0 +1,160 @@
+"use strict";
+
+const { FirstStartup } = ChromeUtils.importESModule(
+ "resource://gre/modules/FirstStartup.sys.mjs"
+);
+const { updateAppInfo } = ChromeUtils.importESModule(
+ "resource://testing-common/AppInfo.sys.mjs"
+);
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+const PREF_CATEGORY_TASKS = "first-startup.category-tasks-enabled";
+const CATEGORY_NAME = "first-startup-new-profile";
+const TEST_MODULE = "resource://test/FirstStartupCategoryModule.sys.mjs";
+const CATEGORY_TASK_TOPIC = "first-startup-category-task-called";
+
+add_setup(function test_setup() {
+ do_get_profile();
+ Services.fog.initializeFOG();
+ // Delete any categories that have been registered statically so that we're
+ // just running the one here under test.
+ Services.catMan.deleteCategory(CATEGORY_NAME);
+});
+
+/**
+ * Test that category tasks registered with the first-startup-new-profile
+ * category are invoked during FirstStartup initialization, and that they run
+ * after Normandy initialization completes. Verifies that the
+ * categoryTasksTime Glean metric is recorded.
+ */
+add_task(async function test_categoryTasks_called_after_normandy() {
+ if (!AppConstants.MOZ_NORMANDY) {
+ info("Skipping test - MOZ_NORMANDY not enabled");
+ return;
+ }
+
+ updateAppInfo();
+ Services.prefs.setBoolPref(PREF_CATEGORY_TASKS, true);
+ FirstStartup.resetForTesting();
+
+ let catManUpdated = TestUtils.topicObserved("xpcom-category-entry-added");
+ Services.catMan.addCategoryEntry(
+ CATEGORY_NAME,
+ TEST_MODULE,
+ "FirstStartupCategoryModule.firstStartupNewProfile",
+ false,
+ false
+ );
+ await catManUpdated;
+
+ const { Normandy } = ChromeUtils.importESModule(
+ "resource://normandy/Normandy.sys.mjs"
+ );
+ let sandbox = sinon.createSandbox();
+ let normandyInitComplete = false;
+ let originalInit = Normandy.init.bind(Normandy);
+
+ sandbox.stub(Normandy, "init").callsFake(function (...args) {
+ return originalInit(...args).finally(() => {
+ normandyInitComplete = true;
+ });
+ });
+
+ let categoryTaskCalled = false;
+ let categoryTaskPromise = TestUtils.topicObserved(CATEGORY_TASK_TOPIC).then(
+ ([_subj, data]) => {
+ Assert.ok(
+ Normandy.init.calledOnce,
+ "Normandy.init should have been called"
+ );
+ Assert.ok(
+ normandyInitComplete,
+ "Normandy init should be complete before category task starts"
+ );
+ categoryTaskCalled = true;
+ return data;
+ }
+ );
+
+ let submissionPromise = new Promise(resolve => {
+ GleanPings.firstStartup.testBeforeNextSubmit(() => {
+ Assert.equal(FirstStartup.state, FirstStartup.SUCCESS);
+ Assert.ok(Glean.firstStartup.newProfile.testGetValue());
+ Assert.equal(
+ Glean.firstStartup.statusCode.testGetValue(),
+ FirstStartup.SUCCESS
+ );
+ Assert.greater(Glean.firstStartup.normandyInitTime.testGetValue(), 0);
+ Assert.greater(
+ Glean.firstStartup.categoryTasksTime.testGetValue(),
+ 0,
+ "Category tasks time should be recorded"
+ );
+ resolve();
+ });
+ });
+
+ FirstStartup.init(true);
+
+ await submissionPromise;
+
+ Assert.equal(
+ await categoryTaskPromise,
+ "executed",
+ "Category task should have been called"
+ );
+ Assert.ok(categoryTaskCalled, "Category task was invoked");
+
+ sandbox.restore();
+ Services.catMan.deleteCategoryEntry(CATEGORY_NAME, TEST_MODULE, false);
+ Services.prefs.clearUserPref(PREF_CATEGORY_TASKS);
+});
+
+/**
+ * Test that category tasks are not invoked when the category tasks pref is
+ * disabled, even when tasks are registered with the category.
+ */
+add_task(async function test_categoryTasks_disabled_by_pref() {
+ if (!AppConstants.MOZ_NORMANDY) {
+ info("Skipping test - MOZ_NORMANDY not enabled");
+ return;
+ }
+
+ updateAppInfo();
+ Services.prefs.setBoolPref(PREF_CATEGORY_TASKS, false);
+ FirstStartup.resetForTesting();
+
+ let catManUpdated = TestUtils.topicObserved("xpcom-category-entry-added");
+ Services.catMan.addCategoryEntry(
+ CATEGORY_NAME,
+ TEST_MODULE,
+ "FirstStartupCategoryModule.firstStartupNewProfile",
+ false,
+ false
+ );
+ await catManUpdated;
+
+ let categoryTaskCalled = false;
+ Services.obs.addObserver(() => {
+ categoryTaskCalled = true;
+ }, CATEGORY_TASK_TOPIC);
+
+ let submissionPromise = new Promise(resolve => {
+ GleanPings.firstStartup.testBeforeNextSubmit(() => {
+ resolve();
+ });
+ });
+
+ FirstStartup.init(true);
+ await submissionPromise;
+
+ Assert.ok(
+ !categoryTaskCalled,
+ "Category task should not be called when pref is false"
+ );
+
+ Services.catMan.deleteCategoryEntry(CATEGORY_NAME, TEST_MODULE, false);
+ Services.prefs.clearUserPref(PREF_CATEGORY_TASKS);
+});
diff --git a/toolkit/modules/tests/xpcshell/xpcshell.toml b/toolkit/modules/tests/xpcshell/xpcshell.toml
@@ -127,6 +127,11 @@ tags = "os_integration"
["test_UrlUtils.js"]
["test_firstStartup.js"]
+
+["test_firstStartup_categoryTasks.js"]
+support-files = [
+ "FirstStartupCategoryModule.sys.mjs",
+]
skip-if = [
"os == 'android'",
]