tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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:
Mbrowser/components/profiles/ProfilesParent.sys.mjs | 17++++++++++++++++-
Mbrowser/components/profiles/SelectableProfile.sys.mjs | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/profiles/SelectableProfileService.sys.mjs | 2++
Mbrowser/components/profiles/content/edit-profile-card.mjs | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mbrowser/components/profiles/tests/browser/browser.toml | 5+++++
Abrowser/components/profiles/tests/browser/browser_desktop_shortcut_test.js | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/locales/en-US/browser/profiles.ftl | 3+++
Mtoolkit/modules/RemotePageAccessManager.sys.mjs | 1+
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",