commit 1b3787c361452ef9c9e37d8ffd4c414135d547f5
parent 93531a24f87bba968d62ffd6ec092ecda46235cd
Author: Jared Hirsch <ohai@6a68.net>
Date: Thu, 16 Oct 2025 17:24:03 +0000
Bug 1958955 - Add profile specific desktop shortcuts to Windows OS r=fluent-reviewers,niklas,profiles-reviewers,bolsson
Differential Revision: https://phabricator.services.mozilla.com/D265123
Diffstat:
8 files changed, 431 insertions(+), 12 deletions(-)
diff --git a/browser/components/profiles/ProfilesParent.sys.mjs b/browser/components/profiles/ProfilesParent.sys.mjs
@@ -4,6 +4,7 @@
import { SelectableProfileService } from "resource:///modules/profiles/SelectableProfileService.sys.mjs";
import { ProfileAge } from "resource://gre/modules/ProfileAge.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
const lazy = {};
@@ -267,10 +268,12 @@ export class ProfilesParent extends JSWindowActorParent {
let themes = await this.getSafeForContentThemes(isDark);
return {
currentProfile: await currentProfile.toContentSafeObject(),
+ isInAutomation: Cu.isInAutomation,
+ hasDesktopShortcut: currentProfile.hasDesktopShortcut(),
+ platform: AppConstants.platform,
profiles: await Promise.all(profiles.map(p => p.toContentSafeObject())),
profileCreated: await profileAge.created,
themes,
- isInAutomation: Cu.isInAutomation,
};
}
@@ -369,6 +372,18 @@ export class ProfilesParent extends JSWindowActorParent {
SelectableProfileService.currentProfile.name = profileObj.name;
break;
}
+ case "Profiles:SetDesktopShortcut": {
+ let profile = SelectableProfileService.currentProfile;
+ let { shouldEnable } = message.data;
+ if (shouldEnable) {
+ await profile.ensureDesktopShortcut();
+ } else {
+ await profile.removeDesktopShortcut();
+ }
+ return {
+ hasDesktopShortcut: profile.hasDesktopShortcut(),
+ };
+ }
case "Profiles:GetDeleteProfileContent": {
// Make sure SelectableProfileService is initialized
await SelectableProfileService.init();
diff --git a/browser/components/profiles/SelectableProfile.sys.mjs b/browser/components/profiles/SelectableProfile.sys.mjs
@@ -2,9 +2,18 @@
* 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/. */
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { DownloadPaths } from "resource://gre/modules/DownloadPaths.sys.mjs";
+import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs";
import { ProfilesDatastoreService } from "moz-src:///toolkit/profile/ProfilesDatastoreService.sys.mjs";
import { SelectableProfileService } from "resource:///modules/profiles/SelectableProfileService.sys.mjs";
+const lazy = {};
+
+ChromeUtils.defineLazyGetter(lazy, "localization", () => {
+ return new Localization(["branding/brand.ftl", "browser/profiles.ftl"]);
+});
+
const STANDARD_AVATARS = new Set([
"barbell",
"bike",
@@ -462,4 +471,209 @@ export class SelectableProfile {
return profileObj;
}
+
+ // Desktop shortcut-related methods, currently Windows-only.
+
+ /**
+ * Getter that returns the nsIWindowsShellService, created to simplify
+ * mocking for tests.
+ *
+ * @returns {nsIWindowsShellService|null} shell service on Windows, null on other platforms
+ */
+ getWindowsShellService() {
+ if (AppConstants.platform !== "win") {
+ return null;
+ }
+ return Cc["@mozilla.org/browser/shell-service;1"].getService(
+ Ci.nsIWindowsShellService
+ );
+ }
+
+ /**
+ * Returns a promise that resolves to the desktop shortcut as an nsIFile,
+ * or null on platforms other than Windows.
+ *
+ * @returns {Promise<nsIFile|null>}
+ * A promise that resolves to the desktop shortcut or null.
+ */
+ async ensureDesktopShortcut() {
+ if (AppConstants.platform !== "win") {
+ return null;
+ }
+
+ if (!this.hasDesktopShortcut()) {
+ let shortcutFileName = await this.getSafeDesktopShortcutFileName();
+ if (!shortcutFileName) {
+ return null;
+ }
+
+ let exeFile = Services.dirsvc.get("XREExeF", Ci.nsIFile);
+ let shellService = this.getWindowsShellService();
+ try {
+ await shellService.createShortcut(
+ exeFile,
+ ["--profile", this.path],
+ this.name,
+ exeFile,
+ 0,
+ "",
+ "Desktop",
+ shortcutFileName
+ );
+
+ // The shortcut name is not necessarily the sanitized profile name.
+ // In certain circumstances we use a default shortcut name, or might
+ // have duplicate shortcuts on the desktop that require appending a
+ // counter like "(1)" or "(2)", etc., to the filename to deduplicate.
+ // Save the shortcut name in a pref to keep track of it.
+ Services.prefs.setCharPref(
+ "browser.profiles.shortcutFileName",
+ shortcutFileName
+ );
+ } catch (e) {
+ console.error("Failed to create shortcut: ", e);
+ }
+ }
+
+ return this.getDesktopShortcut();
+ }
+
+ /**
+ * Returns a promise that resolves to the desktop shortcut, either null
+ * if it was deleted, or the shortcut as an nsIFile if deletion failed.
+ *
+ * @returns {boolean} true if deletion succeeded, false otherwise
+ */
+ async removeDesktopShortcut() {
+ if (!this.hasDesktopShortcut()) {
+ return false;
+ }
+
+ let fileName = Services.prefs.getCharPref(
+ "browser.profiles.shortcutFileName",
+ ""
+ );
+ try {
+ let shellService = this.getWindowsShellService();
+ await shellService.deleteShortcut("Desktop", fileName);
+
+ // Wait to clear the pref until deletion succeeds.
+ Services.prefs.clearUserPref("browser.profiles.shortcutFileName");
+ } catch (e) {
+ console.error("Failed to remove shortcut: ", e);
+ }
+ return this.hasDesktopShortcut();
+ }
+
+ /**
+ * Returns the desktop shortcut as an nsIFile, or null if not found.
+ *
+ * Note the shortcut will not be found if the profile name has changed since
+ * the shortcut was created (we plan to handle name updates in bug 1992897).
+ *
+ * @returns {nsIFile|null} The desktop shortcut or null.
+ */
+ getDesktopShortcut() {
+ if (AppConstants.platform !== "win") {
+ return null;
+ }
+
+ let shortcutName = Services.prefs.getCharPref(
+ "browser.profiles.shortcutFileName",
+ ""
+ );
+ if (!shortcutName) {
+ return null;
+ }
+
+ let file;
+ try {
+ file = new FileUtils.File(
+ PathUtils.join(
+ Services.dirsvc.get("Desk", Ci.nsIFile).path,
+ shortcutName
+ )
+ );
+ } catch (e) {
+ console.error("Failed to get shortcut: ", e);
+ }
+ return file?.exists() ? file : null;
+ }
+
+ /**
+ * Checks the filesystem to determine if the desktop shortcut exists with the
+ * expected name from the pref.
+ *
+ * @returns {boolean} - true if the shortcut exists on the Desktop
+ */
+ hasDesktopShortcut() {
+ let shortcut = this.getDesktopShortcut();
+ return shortcut !== null;
+ }
+
+ /**
+ * Returns the profile name with illegal characters sanitized, length
+ * truncated, and with ".lnk" appended, suitable for use as the name of
+ * a Windows desktop shortcut. If the sanitized profile name is empty,
+ * uses a reasonable default. Appends "(1)", "(2)", etc. as needed to ensure
+ * the desktop shortcut file name is unique.
+ *
+ * @returns {string} Safe desktop shortcut file name for the current profile,
+ * or empty string if something went wrong.
+ */
+ async getSafeDesktopShortcutFileName() {
+ let existingShortcutName = Services.prefs.getCharPref(
+ "browser.profiles.shortcutFileName",
+ ""
+ );
+ if (existingShortcutName) {
+ return existingShortcutName;
+ }
+
+ let desktopFile = Services.dirsvc.get("Desk", Ci.nsIFile);
+
+ // Strip out any illegal chars and whitespace. Most illegal chars are
+ // converted to '_' but others are just removed (".", "\\") so we may
+ // wind up with an empty string.
+ let fileName = DownloadPaths.sanitize(this.name);
+
+ // To avoid exceeding the Windows default `MAX_PATH` of 260 chars, subtract
+ // the length of the Desktop path, 4 chars for the ".lnk" file extension,
+ // one more char for the path separator between "Desktop" and the shortcut
+ // file name, and 6 chars for the largest possible deduplicating counter
+ // "(9999)" added by `DownloadPaths.createNiceUniqueFile()` below,
+ // giving us a working max path of 260 - 4 - 1 - 6 = 249.
+ let maxLength = 249 - desktopFile.path.length;
+ fileName = fileName.substring(0, maxLength);
+
+ // Use the brand name as default if the sanitized `fileName` is empty.
+ if (!fileName) {
+ let strings = await lazy.localization.formatMessages([
+ "default-desktop-shortcut-name",
+ ]);
+ fileName = strings[0].value;
+ }
+
+ fileName = fileName + ".lnk";
+
+ // At this point, it's possible the fileName would not be a unique file
+ // because of other shortcuts on the desktop. To ensure uniqueness, we use
+ // `DownloadPaths.createNiceUniqueFile()` to append a "(1)", "(2)", etc.,
+ // up to a max of (9999). See `DownloadPaths` docs for other things we try
+ // if incrementing a counter in the name fails.
+ try {
+ let shortcutFile = new FileUtils.File(
+ PathUtils.join(desktopFile.path, fileName)
+ );
+ let uniqueShortcutFile = DownloadPaths.createNiceUniqueFile(shortcutFile);
+ fileName = uniqueShortcutFile.leafName;
+ // `createNiceUniqueFile` actually creates the file, which we don't want.
+ await IOUtils.remove(uniqueShortcutFile.path);
+ } catch (e) {
+ console.error("Unable to create a shortcut name: ", e);
+ fileName = "";
+ }
+
+ return fileName;
+ }
}
diff --git a/browser/components/profiles/SelectableProfileService.sys.mjs b/browser/components/profiles/SelectableProfileService.sys.mjs
@@ -1353,6 +1353,8 @@ class SelectableProfileServiceClass extends EventEmitter {
let newDefault = profiles.find(p => p.id !== this.currentProfile.id);
await this.setDefaultProfileForGroup(newDefault);
+ await this.currentProfile.removeDesktopShortcut();
+
await this.#connection.executeBeforeShutdown(
"SelectableProfileService: deleteCurrentProfile",
async db => {
diff --git a/browser/components/profiles/content/edit-profile-card.mjs b/browser/components/profiles/content/edit-profile-card.mjs
@@ -62,6 +62,8 @@ import "chrome://browser/content/profiles/avatar.mjs";
import "chrome://browser/content/profiles/profiles-theme-card.mjs";
// eslint-disable-next-line import/no-unassigned-import
import "chrome://browser/content/profiles/profile-avatar-selector.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://global/content/elements/moz-toggle.mjs";
const SAVE_NAME_TIMEOUT = 2000;
const SAVED_MESSAGE_TIMEOUT = 5000;
@@ -71,23 +73,25 @@ const SAVED_MESSAGE_TIMEOUT = 5000;
*/
export class EditProfileCard extends MozLitElement {
static properties = {
+ hasDesktopShortcut: { type: Boolean },
profile: { type: Object },
profiles: { type: Array },
themes: { type: Array },
};
static queries = {
- mozCard: "moz-card",
- nameInput: "#profile-name",
- errorMessage: "#error-message",
- savedMessage: "#saved-message",
+ avatarSelector: "profile-avatar-selector",
+ avatarSelectorLink: "#profile-avatar-selector-link",
deleteButton: "#delete-button",
doneButton: "#done-button",
- moreThemesLink: "#more-themes",
+ errorMessage: "#error-message",
headerAvatar: "#header-avatar",
+ moreThemesLink: "#more-themes",
+ mozCard: "moz-card",
+ nameInput: "#profile-name",
+ savedMessage: "#saved-message",
+ shortcutToggle: "#desktop-shortcut-toggle",
themesPicker: "#themes",
- avatarSelector: "profile-avatar-selector",
- avatarSelectorLink: "#profile-avatar-selector-link",
};
updateNameDebouncer = null;
@@ -127,15 +131,23 @@ export class EditProfileCard extends MozLitElement {
return;
}
- let { currentProfile, profiles, themes, isInAutomation } =
- await RPMSendQuery("Profiles:GetEditProfileContent");
+ let {
+ currentProfile,
+ hasDesktopShortcut,
+ isInAutomation,
+ platform,
+ profiles,
+ themes,
+ } = await RPMSendQuery("Profiles:GetEditProfileContent");
if (isInAutomation) {
this.updateNameDebouncer.timeout = 50;
}
- this.setProfile(currentProfile);
+ this.hasDesktopShortcut = hasDesktopShortcut;
+ this.platform = platform;
this.profiles = profiles;
+ this.setProfile(currentProfile);
this.themes = themes;
}
@@ -397,6 +409,36 @@ export class EditProfileCard extends MozLitElement {
</moz-visual-picker>`;
}
+ desktopShortcutTemplate() {
+ if (this.platform !== "win") {
+ return null;
+ }
+
+ return html`<div id="desktop-shortcut-section">
+ <label
+ for="desktop-shortcut-toggle"
+ data-l10n-id="edit-profile-page-desktop-shortcut-header"
+ ></label>
+ <moz-toggle
+ id="desktop-shortcut-toggle"
+ ?pressed=${this.hasDesktopShortcut}
+ @click=${this.handleDesktopShortcutToggle}
+ ></moz-toggle>
+ </div>`;
+ }
+
+ async handleDesktopShortcutToggle(event) {
+ event.preventDefault();
+ let { hasDesktopShortcut } = await RPMSendQuery(
+ "Profiles:SetDesktopShortcut",
+ {
+ shouldEnable: event.target.pressed,
+ }
+ );
+ this.shortcutToggle.pressed = hasDesktopShortcut;
+ this.requestUpdate();
+ }
+
handleThemeChange() {
this.updateTheme(this.themesPicker.value);
}
@@ -496,7 +538,7 @@ export class EditProfileCard extends MozLitElement {
${this.headerAvatarTemplate()}
<div id="profile-content">
${this.headerTemplate()}${this.profilesNameTemplate()}
- ${this.themesTemplate()}
+ ${this.themesTemplate()} ${this.desktopShortcutTemplate()}
<a
id="more-themes"
diff --git a/browser/components/profiles/tests/browser/browser.toml b/browser/components/profiles/tests/browser/browser.toml
@@ -23,6 +23,11 @@ skip-if = [
["browser_delete_profile_page_test.js"]
+["browser_desktop_shortcut_test.js"]
+run-if = [
+ "os == 'win'",
+] # Desktop shortcuts are only supported on Windows.
+
["browser_edit_profile_test.js"]
skip-if = [
"os == 'linux' && os_version == '18.04' && processor == 'x86_64' && debug", # Bug 1942961
diff --git a/browser/components/profiles/tests/browser/browser_desktop_shortcut_test.js b/browser/components/profiles/tests/browser/browser_desktop_shortcut_test.js
@@ -0,0 +1,137 @@
+/* Any copyright is dedicated to the Public Domain.
+ https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+const setup = async () => {
+ await initGroupDatabase();
+ let profile = SelectableProfileService.currentProfile;
+ Assert.ok(profile, "Should have a profile now");
+
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+ Services.telemetry.clearEvents();
+ return profile;
+};
+
+add_task(async function test_create_shortcut() {
+ if (!AppConstants.MOZ_SELECTABLE_PROFILES) {
+ // `mochitest-browser` suite `add_task` does not yet support
+ // `properties.skip_if`.
+ ok(true, "Skipping because !AppConstants.MOZ_SELECTABLE_PROFILES");
+ return;
+ }
+ const profile = await setup();
+ const sandbox = sinon.createSandbox();
+ let createShortcutCalled = false;
+ sandbox.stub(profile, "getWindowsShellService").returns({
+ createShortcut: () => {
+ // Stub out the existence check so the toggle can update.
+ sandbox.stub(profile, "hasDesktopShortcut").returns(true);
+ createShortcutCalled = true;
+ return Promise.resolve();
+ },
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:editprofile",
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ let editProfileCard =
+ content.document.querySelector("edit-profile-card").wrappedJSObject;
+ await ContentTaskUtils.waitForCondition(
+ () => editProfileCard.initialized,
+ "Waiting for edit-profile-card to be initialized"
+ );
+ await editProfileCard.updateComplete;
+
+ let shortcutToggle = editProfileCard.shortcutToggle;
+ Assert.ok(
+ !shortcutToggle.pressed,
+ "The desktop shortcut toggle should initially be in the off position"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(shortcutToggle, {}, content);
+
+ await ContentTaskUtils.waitForCondition(
+ () => shortcutToggle.pressed,
+ "The shortcut toggle should flip its state"
+ );
+ });
+ }
+ );
+
+ Assert.ok(
+ createShortcutCalled,
+ "ShellService.createShortcut should have been called"
+ );
+ sandbox.restore();
+});
+
+add_task(async function test_delete_shortcut() {
+ if (!AppConstants.MOZ_SELECTABLE_PROFILES) {
+ // `mochitest-browser` suite `add_task` does not yet support
+ // `properties.skip_if`.
+ ok(true, "Skipping because !AppConstants.MOZ_SELECTABLE_PROFILES");
+ return;
+ }
+ const profile = await setup();
+ const sandbox = sinon.createSandbox();
+
+ // Stub out the existence check so the toggle can update.
+ sandbox.stub(profile, "hasDesktopShortcut").returns(true);
+
+ let deleteShortcutCalled = false;
+ sandbox.stub(profile, "getWindowsShellService").returns({
+ deleteShortcut: () => {
+ deleteShortcutCalled = true;
+ profile.hasDesktopShortcut.restore();
+ sandbox.stub(profile, "hasDesktopShortcut").returns(false);
+ return Promise.resolve();
+ },
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:editprofile",
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ let editProfileCard =
+ content.document.querySelector("edit-profile-card").wrappedJSObject;
+ await ContentTaskUtils.waitForCondition(
+ () => editProfileCard.initialized,
+ "Waiting for edit-profile-card to be initialized"
+ );
+ await editProfileCard.updateComplete;
+
+ let shortcutToggle = editProfileCard.shortcutToggle;
+ Assert.ok(
+ shortcutToggle.pressed,
+ "The desktop shortcut toggle should initially be in the on position"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(shortcutToggle, {}, content);
+
+ await ContentTaskUtils.waitForCondition(
+ () => !shortcutToggle.pressed,
+ "The shortcut toggle should flip its state"
+ );
+ });
+ }
+ );
+
+ Assert.ok(
+ deleteShortcutCalled,
+ "ShellService.deleteShortcut should have been called"
+ );
+ sandbox.restore();
+});
diff --git a/browser/locales/en-US/browser/profiles.ftl b/browser/locales/en-US/browser/profiles.ftl
@@ -34,12 +34,15 @@ default-profile-name = Profile { $number }
# The word 'original' is used in the sense that it is the initial or starting profile when you install Firefox.
original-profile-name = Original profile
+default-desktop-shortcut-name = { -brand-short-name }
+
edit-profile-page-title = Edit profile
edit-profile-page-header = Edit your profile
edit-profile-page-profile-name-label = Profile name
edit-profile-page-theme-header-2 =
.label = Theme
edit-profile-page-explore-themes = Explore more themes
+edit-profile-page-desktop-shortcut-header = Create desktop shortcut
edit-profile-page-avatar-header-2 =
.label = Avatar
edit-profile-page-delete-button =
diff --git a/toolkit/modules/RemotePageAccessManager.sys.mjs b/toolkit/modules/RemotePageAccessManager.sys.mjs
@@ -143,6 +143,7 @@ export let RemotePageAccessManager = {
"Profiles:GetEditProfileContent",
"Profiles:UpdateProfileTheme",
"Profiles:UpdateProfileAvatar",
+ "Profiles:SetDesktopShortcut",
],
RPMSendAsyncMessage: [
"Profiles:UpdateProfileName",