Skip to content

Instantly share code, notes, and snippets.

@rhelmer
Last active April 26, 2024 21:49
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rhelmer/66c1804fe5c3c7d02727fbc31525c4a8 to your computer and use it in GitHub Desktop.
Save rhelmer/66c1804fe5c3c7d02727fbc31525c4a8 to your computer and use it in GitHub Desktop.
diff --git a/browser/components/BrowserContentHandler.jsm b/browser/components/BrowserContentHandler.jsm
--- a/browser/components/BrowserContentHandler.jsm
+++ b/browser/components/BrowserContentHandler.jsm
@@ -19,6 +19,7 @@ XPCOMUtils.defineLazyModuleGetters(this,
BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
HeadlessShell: "resource:///modules/HeadlessShell.jsm",
HomePage: "resource:///modules/HomePage.jsm",
+ FirstStartup: "resource://gre/modules/FirstStartup.jsm",
LaterRun: "resource:///modules/LaterRun.jsm",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
SessionStartup: "resource:///modules/sessionstore/SessionStartup.jsm",
@@ -558,6 +559,10 @@ nsBrowserContentHandler.prototype = {
ShellService.setDefaultBrowser(true, true);
}
+ if (cmdLine.handleFlag("first-startup", false)) {
+ FirstStartup.init();
+ }
+
var fileParam = cmdLine.handleFlagWithParam("file", false);
if (fileParam) {
var file = cmdLine.resolveFile(fileParam);
@@ -599,6 +604,8 @@ nsBrowserContentHandler.prototype = {
info +=
" --search <term> Search <term> with your default search engine.\n";
info += " --setDefaultBrowser Set this app as the default browser.\n";
+ info +=
+ " --first-startup Run post-install actions before opening a new window.\n";
return info;
},
diff --git a/browser/installer/windows/nsis/installer.nsi b/browser/installer/windows/nsis/installer.nsi
--- a/browser/installer/windows/nsis/installer.nsi
+++ b/browser/installer/windows/nsis/installer.nsi
@@ -949,7 +949,7 @@ Function LaunchApp
${GetParameters} $0
${GetOptions} "$0" "/UAC:" $1
${If} ${Errors}
- ${ExecAndWaitForInputIdle} "$\"$INSTDIR\${FileMainEXE}$\""
+ ${ExecAndWaitForInputIdle} "$\"$INSTDIR\${FileMainEXE}$\" -first-startup"
${Else}
GetFunctionAddress $0 LaunchAppFromElevatedProcess
UAC::ExecCodeSegment $0
@@ -962,7 +962,7 @@ Function LaunchAppFromElevatedProcess
; Set our current working directory to the application's install directory
; otherwise the 7-Zip temp directory will be in use and won't be deleted.
SetOutPath "$INSTDIR"
- ${ExecAndWaitForInputIdle} "$\"$INSTDIR\${FileMainEXE}$\""
+ ${ExecAndWaitForInputIdle} "$\"$INSTDIR\${FileMainEXE}$\" -first-startup"
FunctionEnd
Function SendPing
diff --git a/browser/installer/windows/nsis/stub.nsi b/browser/installer/windows/nsis/stub.nsi
--- a/browser/installer/windows/nsis/stub.nsi
+++ b/browser/installer/windows/nsis/stub.nsi
@@ -1789,9 +1789,9 @@ Function LaunchApp
${GetOptions} "$0" "/UAC:" $1
${If} ${Errors}
${If} $CheckboxCleanupProfile == 1
- ${ExecAndWaitForInputIdle} "$\"$INSTDIR\${FileMainEXE}$\" -reset-profile -migration"
+ ${ExecAndWaitForInputIdle} "$\"$INSTDIR\${FileMainEXE}$\" -reset-profile -migration -first-startup"
${Else}
- ${ExecAndWaitForInputIdle} "$\"$INSTDIR\${FileMainEXE}$\""
+ ${ExecAndWaitForInputIdle} "$\"$INSTDIR\${FileMainEXE}$\" -first-startup"
${EndIf}
${Else}
StrCpy $R1 $CheckboxCleanupProfile
@@ -1807,9 +1807,9 @@ Function LaunchAppFromElevatedProcess
; Set the current working directory to the installation directory
SetOutPath "$INSTDIR"
${If} $R1 == 1
- ${ExecAndWaitForInputIdle} "$\"$INSTDIR\${FileMainEXE}$\" -reset-profile -migration"
+ ${ExecAndWaitForInputIdle} "$\"$INSTDIR\${FileMainEXE}$\" -reset-profile -migration -first-startup"
${Else}
- ${ExecAndWaitForInputIdle} "$\"$INSTDIR\${FileMainEXE}$\""
+ ${ExecAndWaitForInputIdle} "$\"$INSTDIR\${FileMainEXE}$\" -first-startup"
${EndIf}
FunctionEnd
diff --git a/toolkit/components/normandy/Normandy.jsm b/toolkit/components/normandy/Normandy.jsm
--- a/toolkit/components/normandy/Normandy.jsm
+++ b/toolkit/components/normandy/Normandy.jsm
@@ -43,7 +43,7 @@ var Normandy = {
studyPrefsChanged: {},
rolloutPrefsChanged: {},
- async init() {
+ async init({ runAsync = true } = {}) {
// Initialization that needs to happen before the first paint on startup.
await NormandyMigrations.applyAll();
this.rolloutPrefsChanged = this.applyStartupPrefs(
@@ -53,8 +53,16 @@ var Normandy = {
STARTUP_EXPERIMENT_PREFS_BRANCH
);
- // Wait until the UI is available before finishing initialization.
- Services.obs.addObserver(this, UI_AVAILABLE_NOTIFICATION);
+ if (runAsync) {
+ Services.obs.addObserver(this, UI_AVAILABLE_NOTIFICATION);
+ } else {
+ // Remove any observers, if present.
+ try {
+ Services.obs.removeObserver(this, UI_AVAILABLE_NOTIFICATION);
+ } catch (e) {}
+
+ await this.finishInit();
+ }
},
observe(subject, topic, data) {
diff --git a/toolkit/components/normandy/lib/RecipeRunner.jsm b/toolkit/components/normandy/lib/RecipeRunner.jsm
--- a/toolkit/components/normandy/lib/RecipeRunner.jsm
+++ b/toolkit/components/normandy/lib/RecipeRunner.jsm
@@ -238,7 +238,7 @@ var RecipeRunner = {
if (!this.enabled) {
return;
}
- this.run({ trigger: "sync" });
+ await this.run({ trigger: "sync" });
};
gRemoteSettingsClient.on("sync", this._onSync);
diff --git a/toolkit/components/utils/ClientEnvironment.jsm b/toolkit/components/utils/ClientEnvironment.jsm
--- a/toolkit/components/utils/ClientEnvironment.jsm
+++ b/toolkit/components/utils/ClientEnvironment.jsm
@@ -36,6 +36,17 @@ ChromeUtils.defineModuleGetter(
"AppConstants",
"resource://gre/modules/AppConstants.jsm"
);
+ChromeUtils.defineModuleGetter(
+ this,
+ "FirstStartup",
+ "resource://gre/modules/FirstStartup.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "AttributionCode",
+ "resource:///modules/AttributionCode.jsm"
+);
var EXPORTED_SYMBOLS = ["ClientEnvironmentBase"];
@@ -96,6 +107,11 @@ class ClientEnvironmentBase {
}
static get searchEngine() {
+ // Telemetry Environment is not available in early first-startup.
+ if (FirstStartup.state === FirstStartup.IN_PROGRESS) {
+ return undefined;
+ }
+
return (async () => {
await TelemetryEnvironment.onInitialized();
return TelemetryEnvironment.currentEnvironment.settings
@@ -164,6 +180,11 @@ class ClientEnvironmentBase {
}
static get os() {
+ // Telemetry Environment is not available in early first-startup.
+ if (FirstStartup.state === FirstStartup.IN_PROGRESS) {
+ return undefined;
+ }
+
function coerceToNumber(version) {
const parts = version.split(".");
return parseFloat(parts.slice(0, 2).join("."));
@@ -202,4 +223,8 @@ class ClientEnvironmentBase {
return rv;
})();
}
+
+ static get attribution() {
+ return AttributionCode.getAttrDataAsync();
+ }
}
diff --git a/toolkit/modules/FirstStartup.jsm b/toolkit/modules/FirstStartup.jsm
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/FirstStartup.jsm
@@ -0,0 +1,78 @@
+/* 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/. */
+
+var EXPORTED_SYMBOLS = ["FirstStartup"];
+
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ Normandy: "resource://normandy/Normandy.jsm",
+});
+
+const PREF_TIMEOUT = "first-startup.timeout";
+const PROBE_NAME = "firstStartup.statusCode";
+
+/**
+ * Service for blocking application startup, to be used on the first install. The intended
+ * use case is for `FirstStartup` to be invoked when the application is called by an installer,
+ * such as the Windows Stub Installer, to allow the application to do some first-install tasks
+ * such as performance tuning and downloading critical data.
+ *
+ * In this scenario, the installer does not exit until the first application window appears,
+ * which gives the user experience of the application starting up quickly on first install.
+ */
+var FirstStartup = {
+ NOT_STARTED: 0,
+ IN_PROGRESS: 1,
+ TIMED_OUT: 2,
+ SUCCESS: 3,
+ UNSUPPORTED: 4,
+
+ _state: this.NOT_STARTED,
+ /**
+ * Initialize and run first-startup services. This will always run synchronously
+ * and spin the event loop until either all required services have
+ * completed, or until a timeout is reached.
+ *
+ * In the latter case, services are expected to run post-UI instead as usual.
+ */
+ init() {
+ this._state = this.IN_PROGRESS;
+ const timeout = Services.prefs.getIntPref(PREF_TIMEOUT, 5000); // default to 5 seconds
+ let expiredTime = Date.now() + timeout;
+
+ if (AppConstants.MOZ_NORMANDY) {
+ let normandyInitialized = false;
+
+ Normandy.init({ runAsync: false }).then(
+ () => (normandyInitialized = true)
+ );
+
+ Services.tm.spinEventLoopUntil(() => {
+ if (Date.now() >= expiredTime) {
+ this._state = this.TIMED_OUT;
+ return true;
+ } else if (normandyInitialized) {
+ this._state = this.SUCCESS;
+ return true;
+ }
+ return false;
+ });
+ } else {
+ this._state = this.UNSUPPORTED;
+ }
+
+ Services.telemetry.scalarSet(PROBE_NAME, this._state);
+ },
+
+ get state() {
+ return this._state;
+ },
+};
diff --git a/toolkit/modules/docs/FirstStartup.rst b/toolkit/modules/docs/FirstStartup.rst
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/docs/FirstStartup.rst
@@ -0,0 +1,81 @@
+.. _FirstStartup:
+
+==============
+FirstStartup
+==============
+
+``FirstStartup`` is a module which is invoked on application startup by the Windows Installer,
+to initialize services before the first application window appears.
+
+This is useful for:
+
+- one-time performance tuning
+- downloading critical data (hotfixes, experiments, etc)
+
+Blocking until the first Application window appears is important because the Installer
+will show a progress bar until this happens. This gives a user experience of:
+
+1. User downloads and starts the Windows Stub Installer.
+2. Progress bar advances while the application is downloaded and installed.
+3. Installer invokes the application with ``--first-startup``.
+4. Application window appears, and the installer window closes.
+
+Overall, the user experiences a very fast first-startup, with critical tasks that normally
+would be deferred until after UI startup already complete.
+
+.. _FirstStartup Architecture:
+
+FirstStartup: Example use case
+==============================
+
+An example use of the ``FirstStartup`` module is to invoke the Normandy client to download an experiment
+that will be used to customize the first-run page that Firefox shows.
+
+In this example, the first-run page would be loaded experimentally based on an attribution code provided
+by the Installer. The flow for this looks like:
+
+1. User clicks on download link containing an attribution (UTM) code(s).
+2. The download page serves a custom Windows Stub Installer with the appropriate attribution code embedded.
+3. The installer invokes Firefox with the `--first-startup` flag, which blocks the first window.
+4. Normandy is run by ``FirstStartup`` and downloads a list of available experiments, or "recipes".
+5. Recipes are evaluated and filtered based on local information, such as the OS platform and the attribution codes.
+6. A recipe is found which matches the current attribution code, and appropriate data is made available to the first-run page.
+7. ``FirstStartup`` completes and unblocks, which causes Firefox to show the first window and load the appropriate first-run data.
+
+List of phases
+==============
+
+``FirstStartup.NOT_STARTED``
+
+ The ``FirstStartup`` module has not been initalized (the ``init()``
+ function has not been called). This is the default state.
+
+``FirstStartup.IN_PROGRESS``
+
+ ``FirstStartup.init()`` has been called, and the event loop is
+ spinning. This state will persist until either all startup tasks
+ have finished, or time-out has been reached.
+
+ The time-out defaults to 5 seconds, but is configurable via the
+ ``first-startup.timeout`` pref, which is specified in milliseconds.
+
+``FirstStartup.TIMED_OUT``
+
+ The time-out has been reached before startup tasks are complete.
+
+ This status code is reported to Telemetry via the ``firstStartup.statusCode``
+ scalar.
+
+``FirstStartup.SUCCESS``
+
+ All startup tasks have completed successfully, and application startup may resume.
+
+ This status code is reported to Telemetry via the ``firstStartup.statusCode``
+ scalar.
+
+``FirstStartup.UNSUPPORTED``
+
+ No startup tasks are supported, and `FirstStartup` exited.
+
+ This status code is reported to Telemetry via the ``firstStartup.statusCode``
+ scalar.
diff --git a/toolkit/modules/docs/index.rst b/toolkit/modules/docs/index.rst
--- a/toolkit/modules/docs/index.rst
+++ b/toolkit/modules/docs/index.rst
@@ -8,3 +8,4 @@ The ``/toolkit/modules`` directory conta
:maxdepth: 1
AsyncShutdown
+ FirstStartup
diff --git a/toolkit/modules/tests/xpcshell/test_firstStartup.js b/toolkit/modules/tests/xpcshell/test_firstStartup.js
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/tests/xpcshell/test_firstStartup.js
@@ -0,0 +1,50 @@
+"use strict";
+
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+const { FirstStartup } = ChromeUtils.import(
+ "resource://gre/modules/FirstStartup.jsm"
+);
+
+const PREF_TIMEOUT = "first-startup.timeout";
+const PROBE_NAME = "firstStartup.statusCode";
+
+add_task(async function test_success() {
+ FirstStartup.init();
+ if (AppConstants.MOZ_NORMANDY) {
+ equal(FirstStartup.state, FirstStartup.SUCCESS);
+ } else {
+ equal(FirstStartup.state, FirstStartup.UNSUPPORTED);
+ }
+
+ const scalars = Services.telemetry.getSnapshotForScalars("main", false)
+ .parent;
+ ok(PROBE_NAME in scalars);
+
+ if (AppConstants.MOZ_NORMANDY) {
+ equal(scalars[PROBE_NAME], FirstStartup.SUCCESS);
+ } else {
+ equal(scalars[PROBE_NAME], FirstStartup.UNSUPPORTED);
+ }
+});
+
+add_task(async function test_timeout() {
+ Services.prefs.setIntPref(PREF_TIMEOUT, 0);
+ FirstStartup.init();
+
+ if (AppConstants.MOZ_NORMANDY) {
+ equal(FirstStartup.state, FirstStartup.TIMED_OUT);
+ } else {
+ equal(FirstStartup.state, FirstStartup.UNSUPPORTED);
+ }
+
+ const scalars = Services.telemetry.getSnapshotForScalars("main", false)
+ .parent;
+ ok(PROBE_NAME in scalars);
+ if (AppConstants.MOZ_NORMANDY) {
+ equal(scalars[PROBE_NAME], FirstStartup.TIMED_OUT);
+ } else {
+ equal(scalars[PROBE_NAME], FirstStartup.UNSUPPORTED);
+ }
+});
diff --git a/toolkit/components/telemetry/Scalars.yaml b/toolkit/components/telemetry/Scalars.yaml
--- a/toolkit/components/telemetry/Scalars.yaml
+++ b/toolkit/components/telemetry/Scalars.yaml
@@ -4586,6 +4586,22 @@ blocklist:
record_in_processes:
- main
+firstStartup:
+ statusCode:
+ bug_numbers:
+ - 1515712
+ description: >-
+ Status of the FirstRun service, which runs post-install/early-startup in Firefox.
+ expires: "75"
+ kind: uint
+ notification_emails:
+ - rhelmer@mozilla.com
+ release_channel_collection: opt-out
+ products:
+ - 'firefox'
+ record_in_processes:
+ - main
+
# The following section is for probes testing the Telemetry system. They will not be
# submitted in pings and are only used for testing.
telemetry.test:
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment