Bug 1923868 - Support setting Firefox as default based on installer attribution campaign r=firefox-desktop-core-reviewers ,nalexander,omc-reviewers,pdahiya

This patch adds an startup idle task that sets the browser as default if an attribution campaign id of "set_default_browser" is present on first run. This works supports an upcoming experiment where users will have the option to "download as default" via [[ https://www.mozilla.org/en-US/firefox/new/ | the stub installer marketing page ]].

Differential Revision: https://phabricator.services.mozilla.com/D225212
This commit is contained in:
Meg Viar 2024-11-22 14:29:15 +00:00
parent 858d3f15e6
commit 22cb24bd81
15 changed files with 454 additions and 13 deletions

View File

@ -137,6 +137,9 @@ export class AboutWelcomeChild extends JSWindowActorChild {
Cu.exportFunction(this.AWNewScreen.bind(this), window, {
defineAs: "AWNewScreen",
});
Cu.exportFunction(this.AWGetUnhandledCampaignAction.bind(this), window, {
defineAs: "AWGetUnhandledCampaignAction",
});
}
/**
@ -389,6 +392,12 @@ export class AboutWelcomeChild extends JSWindowActorChild {
return this.wrapPromise(this.sendQuery("AWPage:NEW_SCREEN", screenId));
}
AWGetUnhandledCampaignAction() {
return this.sendQueryAndCloneForContent(
"AWPage:GET_UNHANDLED_CAMPAIGN_ACTION"
);
}
/**
* @param {{type: string, detail?: any}} event
* @override

View File

@ -34,6 +34,8 @@ ChromeUtils.defineLazyGetter(
);
const DID_SEE_ABOUT_WELCOME_PREF = "trailhead.firstrun.didSeeAboutWelcome";
const DID_HANDLE_CAMAPAIGN_ACTION_PREF =
"trailhead.firstrun.didHandleCampaignAction";
const AWTerminate = {
WINDOW_CLOSED: "welcome-window-closed",
TAB_CLOSED: "welcome-tab-closed",
@ -260,6 +262,28 @@ export class AboutWelcomeParent extends JSWindowActorParent {
case "AWPage:SEND_TO_DEVICE_EMAILS_SUPPORTED": {
return lazy.BrowserUtils.sendToDeviceEmailsSupported();
}
case "AWPage:GET_UNHANDLED_CAMPAIGN_ACTION": {
if (
!Services.prefs.getBoolPref(DID_HANDLE_CAMAPAIGN_ACTION_PREF, false)
) {
return lazy.AWScreenUtils.getUnhandledCampaignAction();
}
break;
}
case "AWPage:HANDLE_CAMPAIGN_ACTION": {
if (
!Services.prefs.getBoolPref(DID_HANDLE_CAMAPAIGN_ACTION_PREF, false)
) {
lazy.SpecialMessageActions.handleAction({ type: data }, browser);
try {
Services.prefs.setBoolPref(DID_HANDLE_CAMAPAIGN_ACTION_PREF, true);
} catch (e) {
lazy.log.debug(`Fails to set ${DID_HANDLE_CAMAPAIGN_ACTION_PREF}.`);
}
return true;
}
break;
}
default:
lazy.log.debug(`Unexpected event ${type} was not handled.`);
}

View File

@ -59,6 +59,23 @@ export const MultiStageAboutWelcome = props => {
didFilter.current = true;
// After completing screen filtering, trigger any unhandled campaign
// action present in the attribution campaign data. This updates the
// "trailhead.firstrun.didHandleCampaignAction" preference, marking the
// actions as complete to prevent them from being handled on subsequent
// visits to about:welcome. Do not await getting the action to avoid
// blocking the thread.
window
.AWGetUnhandledCampaignAction?.()
.then(action => {
if (typeof action === "string") {
AboutWelcomeUtils.handleCampaignAction(action, props.message_id);
}
})
.catch(error => {
console.error("Failed to get unhandled campaign action:", error);
});
const screenInitials = filteredScreens
.map(({ id }) => id?.split("_")[1]?.[0])
.join("");

View File

@ -70,6 +70,13 @@ export const AboutWelcomeUtils = {
getLoadingStrategyFor(url) {
return url?.startsWith("http") ? "lazy" : "eager";
},
handleCampaignAction(action, messageId) {
window.AWSendToParent("HANDLE_CAMPAIGN_ACTION", action).then(handled => {
if (handled) {
this.sendActionTelemetry(messageId, "CAMPAIGN_ACTION");
}
});
},
};
export const DEFAULT_RTAMO_CONTENT = {

View File

@ -100,6 +100,13 @@ const AboutWelcomeUtils = {
getLoadingStrategyFor(url) {
return url?.startsWith("http") ? "lazy" : "eager";
},
handleCampaignAction(action, messageId) {
window.AWSendToParent("HANDLE_CAMPAIGN_ACTION", action).then(handled => {
if (handled) {
this.sendActionTelemetry(messageId, "CAMPAIGN_ACTION");
}
});
},
};
const DEFAULT_RTAMO_CONTENT = {
@ -215,6 +222,20 @@ const MultiStageAboutWelcome = props => {
// e.g. if AW_LANGUAGE_MISMATCH exists, use it from existing screens
setScreens(filteredScreens.map(filtered => screens.find(s => s.id === filtered.id) ?? filtered));
didFilter.current = true;
// After completing screen filtering, trigger any unhandled campaign
// action present in the attribution campaign data. This updates the
// "trailhead.firstrun.didHandleCampaignAction" preference, marking the
// actions as complete to prevent them from being handled on subsequent
// visits to about:welcome. Do not await getting the action to avoid
// blocking the thread.
window.AWGetUnhandledCampaignAction?.().then(action => {
if (typeof action === "string") {
_lib_aboutwelcome_utils_mjs__WEBPACK_IMPORTED_MODULE_2__.AboutWelcomeUtils.handleCampaignAction(action, props.message_id);
}
}).catch(error => {
console.error("Failed to get unhandled campaign action:", error);
});
const screenInitials = filteredScreens.map(({
id
}) => id?.split("_")[1]?.[0]).join("");

View File

@ -42,6 +42,20 @@ export const AWScreenUtils = {
return true;
},
/**
* Returns the string identifier of an unhandled campaign action, if
* applicable otherwise false.
*
* @returns {string|boolean}
*/
async getUnhandledCampaignAction() {
const UNHANDLED_CAMPAIGN_ACTION_TARGETING = "unhandledCampaignAction";
let result = await lazy.ASRouter.evaluateExpression({
expression: UNHANDLED_CAMPAIGN_ACTION_TARGETING,
context: lazy.ASRouterTargeting.Environment,
});
return result?.evaluationStatus?.result || false;
},
/**
* Filter out screens whose targeting do not match.
*

View File

@ -90,7 +90,7 @@ const MR_ABOUT_WELCOME_DEFAULT = {
{
id: "AW_EASY_SETUP_NEEDS_DEFAULT_AND_PIN",
targeting:
"doesAppNeedPin && 'browser.shell.checkDefaultBrowser'|preferenceValue && !isDefaultBrowser",
"doesAppNeedPin && (unhandledCampaignAction != 'SET_DEFAULT_BROWSER') && 'browser.shell.checkDefaultBrowser'|preferenceValue && !isDefaultBrowser",
content: {
position: "split",
split_narrow_bkg_position: "-60px",
@ -224,7 +224,7 @@ const MR_ABOUT_WELCOME_DEFAULT = {
{
id: "AW_EASY_SETUP_NEEDS_DEFAULT",
targeting:
"!doesAppNeedPin && 'browser.shell.checkDefaultBrowser'|preferenceValue && !isDefaultBrowser",
"!doesAppNeedPin && (unhandledCampaignAction != 'SET_DEFAULT_BROWSER') && 'browser.shell.checkDefaultBrowser'|preferenceValue && !isDefaultBrowser",
content: {
position: "split",
split_narrow_bkg_position: "-60px",
@ -335,7 +335,7 @@ const MR_ABOUT_WELCOME_DEFAULT = {
{
id: "AW_EASY_SETUP_NEEDS_PIN",
targeting:
"doesAppNeedPin && (!'browser.shell.checkDefaultBrowser'|preferenceValue || isDefaultBrowser)",
"doesAppNeedPin && (!'browser.shell.checkDefaultBrowser'|preferenceValue || isDefaultBrowser || (unhandledCampaignAction == 'SET_DEFAULT_BROWSER'))",
content: {
position: "split",
split_narrow_bkg_position: "-60px",
@ -457,7 +457,7 @@ const MR_ABOUT_WELCOME_DEFAULT = {
{
id: "AW_EASY_SETUP_ONLY_IMPORT",
targeting:
"!doesAppNeedPin && (!'browser.shell.checkDefaultBrowser'|preferenceValue || isDefaultBrowser)",
"!doesAppNeedPin && (!'browser.shell.checkDefaultBrowser'|preferenceValue || isDefaultBrowser || (unhandledCampaignAction == 'SET_DEFAULT_BROWSER'))",
content: {
position: "split",
split_narrow_bkg_position: "-60px",

View File

@ -14,6 +14,13 @@ run-if = [
]
skip-if = ["os == 'win' && msix"] # These tests rely on the ability to write postSigningData, which we can't do in MSIX builds. https://bugzilla.mozilla.org/show_bug.cgi?id=1805911
["browser_aboutwelcome_campaign_actions.js"]
run-if = [
"os == 'win'", # installation attribution is only available on Windows and macOS
"os == 'mac'", # installation attribution is only available on Windows and macOS
]
skip-if = ["os == 'win' && msix"] # Attribution code cannot be written for MSIX builds
["browser_aboutwelcome_configurable_ui.js"]
["browser_aboutwelcome_fxa_signin_flow.js"]

View File

@ -0,0 +1,96 @@
"use strict";
const { ASRouter } = ChromeUtils.importESModule(
"resource:///modules/asrouter/ASRouter.sys.mjs"
);
const { AttributionCode } = ChromeUtils.importESModule(
"resource:///modules/AttributionCode.sys.mjs"
);
const { SpecialMessageActions } = ChromeUtils.importESModule(
"resource://messaging-system/lib/SpecialMessageActions.sys.mjs"
);
const TEST_ATTRIBUTION_DATA = {
campaign: "set_default_browser",
};
const DID_HANDLE_CAMAPAIGN_ACTION_PREF =
"trailhead.firstrun.didHandleCampaignAction";
const TEST_PROTON_CONTENT = [
{
id: "AW_STEP1",
content: {
title: "Step 1",
primary_button: {
label: "Next",
action: {
navigate: true,
},
},
},
},
];
add_task(async function test_unhandled_campaign_action() {
const sandbox = sinon.createSandbox();
const handleActionStub = sandbox
.stub(SpecialMessageActions, "handleAction")
.resolves();
await AttributionCode.deleteFileAsync();
await ASRouter.forceAttribution(TEST_ATTRIBUTION_DATA);
const TEST_PROTON_JSON = JSON.stringify(TEST_PROTON_CONTENT);
await setAboutWelcomePref(true);
await pushPrefs(["browser.aboutwelcome.screens", TEST_PROTON_JSON]);
AttributionCode._clearCache();
const data = await AttributionCode.getAttrDataAsync();
Assert.equal(
data.campaign,
"set_default_browser",
"Attribution campaign should be set"
);
let tab = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
"about:welcome",
true
);
await TestUtils.waitForCondition(() => handleActionStub.called);
Assert.equal(
handleActionStub.firstCall.args[0].type,
"SET_DEFAULT_BROWSER",
"Set default special message action is called"
);
Assert.equal(
Services.prefs.getBoolPref(DID_HANDLE_CAMAPAIGN_ACTION_PREF, false),
true,
"Set default campaign action handled pref is set to true"
);
handleActionStub.reset();
// Open a new about:welcome tab to ensure the action does not run again
let tab2 = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
"about:welcome",
true
);
sinon.assert.notCalled(handleActionStub);
registerCleanupFunction(async () => {
BrowserTestUtils.removeTab(tab);
BrowserTestUtils.removeTab(tab2);
await ASRouter.forceAttribution("");
Services.prefs.clearUserPref(DID_HANDLE_CAMAPAIGN_ACTION_PREF);
Services.prefs.clearUserPref("browser.aboutwelcome.screens");
sandbox.restore();
});
});

View File

@ -86,7 +86,7 @@ add_task(async function test_aboutwelcome_easy_setup_screen_impression() {
.stub(AWScreenUtils, "evaluateScreenTargeting")
.resolves(false)
.withArgs(
"doesAppNeedPin && 'browser.shell.checkDefaultBrowser'|preferenceValue && !isDefaultBrowser"
"doesAppNeedPin && (unhandledCampaignAction != 'SET_DEFAULT_BROWSER') && 'browser.shell.checkDefaultBrowser'|preferenceValue && !isDefaultBrowser"
)
.resolves(true)
.withArgs("isDeviceMigration")

View File

@ -137,4 +137,19 @@ describe("AWScreenUtils", () => {
assert.equal(addScreenImpressionStub.firstCall.args[0].id, testScreen.id);
});
});
describe("getUnhandledCampaignAction", () => {
it("Should call evaluateExpression", () => {
const evaluateExpressionStub = sandbox.stub(
ASRouter,
"evaluateExpression"
);
AWScreenUtils.getUnhandledCampaignAction();
assert.calledOnce(evaluateExpressionStub);
assert.equal(
evaluateExpressionStub.firstCall.args[0].expression,
"unhandledCampaignAction"
);
});
});
});

View File

@ -12,6 +12,13 @@ import { shallow, mount } from "enzyme";
import { AboutWelcomeDefaults } from "modules/AboutWelcomeDefaults.sys.mjs";
import { AboutWelcomeUtils } from "content-src/lib/aboutwelcome-utils.mjs";
const spinEventLoop = async () => {
// Spin the event loop to allow the useEffect hooks to execute,
// any promises to resolve, and re-rendering to happen after the
// promises have updated the state/props
await new Promise(resolve => setTimeout(resolve, 0));
};
describe("MultiStageAboutWelcome module", () => {
let globals;
let sandbox;
@ -30,6 +37,7 @@ describe("MultiStageAboutWelcome module", () => {
AWEvaluateScreenTargeting: () => {},
AWGetSelectedTheme: () => Promise.resolve("automatic"),
AWGetInstalledAddons: () => Promise.resolve(["test-addon-id"]),
AWGetUnhandledCampaignAction: () => Promise.resolve(false),
AWSendEventTelemetry: () => {},
AWSendToParent: () => {},
AWWaitForMigrationClose: () => Promise.resolve(),
@ -53,10 +61,7 @@ describe("MultiStageAboutWelcome module", () => {
it("should pass activeTheme and initialTheme props to WelcomeScreen", async () => {
let wrapper = mount(<MultiStageAboutWelcome {...DEFAULT_PROPS} />);
// Spin the event loop to allow the useEffect hooks to execute,
// any promises to resolve, and re-rendering to happen after the
// promises have updated the state/props
await new Promise(resolve => setTimeout(resolve, 0));
await spinEventLoop();
// sync up enzyme's representation with the real DOM
wrapper.update();
@ -70,10 +75,7 @@ describe("MultiStageAboutWelcome module", () => {
it("should fetch a list of installed Addons", async () => {
let wrapper = mount(<MultiStageAboutWelcome {...DEFAULT_PROPS} />);
// Spin the event loop to allow the useEffect hooks to execute,
// any promises to resolve, and re-rendering to happen after the
// promises have updated the state/props
await new Promise(resolve => setTimeout(resolve, 0));
await spinEventLoop();
// sync up enzyme's representation with the real DOM
wrapper.update();
@ -1132,6 +1134,103 @@ describe("MultiStageAboutWelcome module", () => {
AboutWelcomeUtils.handleUserAction.resetHistory();
}
});
it("Should handle a campaign action when applicable", async () => {
let actionSpy = sandbox.spy(AboutWelcomeUtils, "handleCampaignAction");
let telemetrySpy = sandbox.spy(
AboutWelcomeUtils,
"sendActionTelemetry"
);
globals.set("AWGetUnhandledCampaignAction", () =>
Promise.resolve("SET_DEFAULT_BROWSER")
);
// Return true when "HANDLE_CAMPAIGN_ACTION" is sent to parent
globals.set("AWSendToParent", () => Promise.resolve(true));
const screens = [
{
content: {
title: "test title",
},
},
];
const TEST_PROPS = {
defaultScreens: screens,
message_id: "DEFAULT_ABOUTWELCOME",
startScreen: 0,
};
let wrapper = mount(<MultiStageAboutWelcome {...TEST_PROPS} />);
await spinEventLoop();
wrapper.update();
assert.calledOnce(actionSpy);
// If campaign is handled, we should send telemetry
assert.calledOnce(telemetrySpy);
assert.equal(telemetrySpy.firstCall.args[1], "CAMPAIGN_ACTION");
globals.restore();
});
it("Should not handle a campaign action when the action has already been handled", async () => {
let actionSpy = sandbox.spy(AboutWelcomeUtils, "handleCampaignAction");
let telemetrySpy = sandbox.spy(
AboutWelcomeUtils,
"sendActionTelemetry"
);
globals.set("AWGetUnhandledCampaignAction", () =>
Promise.resolve(false)
);
const screens = [
{
content: {
title: "test title",
},
},
];
const TEST_PROPS = {
defaultScreens: screens,
message_id: "DEFAULT_ABOUTWELCOME",
startScreen: 0,
};
let wrapper = mount(<MultiStageAboutWelcome {...TEST_PROPS} />);
await spinEventLoop();
wrapper.update();
assert.notCalled(actionSpy);
assert.notCalled(telemetrySpy);
globals.restore();
});
it("Should not send telemetrty when campaign action handling fails", async () => {
let actionSpy = sandbox.spy(AboutWelcomeUtils, "handleCampaignAction");
let telemetrySpy = sandbox.spy(
AboutWelcomeUtils,
"sendActionTelemetry"
);
globals.set("AWGetUnhandledCampaignAction", () =>
Promise.resolve("SET_DEFAULT_BROWSER")
);
// Return undefined when "HANDLE_CAMPAIGN_ACTION" is sent to parent as
// though "AWPage:HANDLE_CAMPAIGN_ACTION" case did not return true due
// to failure executing action or the campaign handled pref being true
globals.set("AWSendToParent", () => Promise.resolve(undefined));
const screens = [
{
content: {
title: "test title",
},
},
];
const TEST_PROPS = {
defaultScreens: screens,
message_id: "DEFAULT_ABOUTWELCOME",
startScreen: 0,
};
let wrapper = mount(<MultiStageAboutWelcome {...TEST_PROPS} />);
await spinEventLoop();
wrapper.update();
assert.calledOnce(actionSpy);
// If campaign handling fails, we should not send telemetry
assert.notCalled(telemetrySpy);
globals.restore();
});
});
describe("#handleUserAction", () => {

View File

@ -49,6 +49,7 @@ Please note that some targeting attributes require stricter controls on the tele
* [isMajorUpgrade](#ismajorupgrade)
* [isMSIX](#ismsix)
* [isRTAMO](#isrtamo)
* [unhandledCampaignAction](#unhandledCampaignAction)
* [launchOnLoginEnabled](#launchonloginenabled)
* [locale](#locale)
* [localeLanguageCode](#localelanguagecode)
@ -1041,6 +1042,10 @@ A boolean. `true` when both the current install and current profile support crea
A boolean. `true` when the `toolkit.profiles.storeID` pref has a value. Indicates that the profile is part of a profile group managed by the `SelectableProfileService`, and the user has used the multiple profiles feature. `false` otherwise.
### `unhandledCampaignAction`
A string. A special message action to be executed on first-run. For example, `"SET_DEFAULT_BROWSER"` when the user selected to set as default via the [install marketing page](https://www.mozilla.org/firefox/new/) and set default has not yet been automatically triggered, `null` otherwise.
### `isMSIX`
A boolean. `true` when hasPackageId is `true` on Windows, `false` otherwise.

View File

@ -176,6 +176,12 @@ XPCOMUtils.defineLazyPreferenceGetter(
"toolkit.profiles.storeID",
null
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"didHandleCampaignAction",
"trailhead.firstrun.didHandleCampaignAction",
false
);
XPCOMUtils.defineLazyServiceGetters(lazy, {
AUS: ["@mozilla.org/updates/update-service;1", "nsIApplicationUpdateService"],
@ -258,6 +264,39 @@ function CacheListAttachedOAuthClients() {
};
}
function CacheUnhandledCampaignAction() {
return {
_lastUpdated: 0,
_value: null,
expire() {
this._lastUpdated = 0;
this._value = null;
},
get() {
const now = Date.now();
// Don't get cached value until the action has been handled to ensure
// proper screen targeting in about:welcome
if (
now - this._lastUpdated >= FRECENT_SITES_UPDATE_INTERVAL ||
!lazy.didHandleCampaignAction
) {
this._value = null;
if (!lazy.didHandleCampaignAction) {
const attributionData =
lazy.AttributionCode.getCachedAttributionData();
const ALLOWED_CAMPAIGN_ACTIONS = ["SET_DEFAULT_BROWSER"];
const campaign = attributionData?.campaign?.toUpperCase();
if (campaign && ALLOWED_CAMPAIGN_ACTIONS.includes(campaign)) {
this._value = campaign;
}
}
this._lastUpdated = now;
}
return this._value;
},
};
}
function CheckBrowserNeedsUpdate(
updateInterval = FRECENT_SITES_UPDATE_INTERVAL
) {
@ -326,6 +365,7 @@ export const QueryCache = {
RecentBookmarks: new CachedTargetingGetter("getRecentBookmarks"),
ListAttachedOAuthClients: new CacheListAttachedOAuthClients(),
UserMonthlyActivity: new CachedTargetingGetter("getUserMonthlyActivity"),
UnhandledCampaignAction: new CacheUnhandledCampaignAction(),
},
getters: {
doesAppNeedPin: new CachedTargetingGetter(
@ -1066,6 +1106,17 @@ const TargetingGetters = {
return attributionData?.campaign === "migration";
},
/**
* Whether the user opted into a special message action represented by an
* installer attribution campaign and this choice still needs to be honored.
* @return {string} A special message action to be executed on first-run. For
* example, `"SET_DEFAULT_BROWSER"` when the user selected to set as default
* via the install marketing page and set default has not yet been
* automatically triggered, 'null' otherwise.
*/
get unhandledCampaignAction() {
return QueryCache.queries.UnhandledCampaignAction.get();
},
/**
* The values of the height and width available to the browser to display
* web content. The available height and width are each calculated taking

View File

@ -1859,3 +1859,79 @@ add_task(
);
}
);
add_task(async function check_unhandledCampaignAction() {
is(
typeof ASRouterTargeting.Environment.unhandledCampaignAction,
"object",
"Should return an object" // is null unless an unhandled action is present
);
const DID_HANDLE_CAMAPAIGN_ACTION_PREF =
"trailhead.firstrun.didHandleCampaignAction";
const TEST_CASES = [
{
title: "unsupported open_url campaign action",
attributionData: {
campaign: "open_url",
},
expected: null,
after: () => {
QueryCache.queries.UnhandledCampaignAction.expire();
},
},
{
title: "supported and unhandled set default browser campaign action",
attributionData: {
campaign: "set_default_browser",
},
expected: "SET_DEFAULT_BROWSER",
after: () => {
QueryCache.queries.UnhandledCampaignAction.expire();
},
},
{
title: "supported and handled set default browser campaign action",
attributionData: {
campaign: "set_default_browser",
},
expected: null,
before: async () => {
await pushPrefs([DID_HANDLE_CAMAPAIGN_ACTION_PREF, true]);
},
after: () => {
Services.prefs.clearUserPref(DID_HANDLE_CAMAPAIGN_ACTION_PREF);
QueryCache.queries.UnhandledCampaignAction.expire();
},
},
];
const sandbox = sinon.createSandbox();
registerCleanupFunction(async () => {
sandbox.restore();
});
const stub = sandbox.stub(AttributionCode, "getCachedAttributionData");
for (const {
title,
attributionData,
expected,
before,
after,
} of TEST_CASES) {
if (before) {
await before();
}
stub.returns(attributionData);
is(
ASRouterTargeting.Environment.unhandledCampaignAction,
expected,
`${title} - Expected unhandledCampaignAction to have the expected value`
);
if (after) {
after();
}
}
});