/* 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/. */ import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs", ScheduledTask: "resource://gre/modules/ScheduledTask.sys.mjs", Subprocess: "resource://gre/modules/Subprocess.sys.mjs", WindowsSetDefaultRedirect: "moz-src:///browser/components/shell/WindowsSetDefaultRedirect.sys.mjs", }); XPCOMUtils.defineLazyServiceGetter( lazy, "XreDirProvider", "@mozilla.org/xre/directory-provider;1", Ci.nsIXREDirProvider ); XPCOMUtils.defineLazyServiceGetter( lazy, "BackgroundTasks", "@mozilla.org/backgroundtasks;1", Ci.nsIBackgroundTasks ); XPCOMUtils.defineLazyServiceGetter( lazy, "imgTools", "@mozilla.org/image/tools;1", Ci.imgITools ); XPCOMUtils.defineLazyServiceGetter( lazy, "iniParserFactory", "@mozilla.org/xpcom/ini-parser-factory;1", Ci.nsIINIParserFactory ); XPCOMUtils.defineLazyServiceGetter( lazy, "secondaryTileService", "@mozilla.org/browser/secondary-tile-service;1", Ci.nsISecondaryTileService ); XPCOMUtils.defineLazyServiceGetter( lazy, "gioService", "@mozilla.org/gio-service;1", Ci.nsIGIOService ); ChromeUtils.defineLazyGetter(lazy, "log", () => { let { ConsoleAPI } = ChromeUtils.importESModule( "resource://gre/modules/Console.sys.mjs" ); let consoleOptions = { // tip: set maxLogLevel to "debug" and use log.debug() to create detailed // messages during development. See LOG_LEVELS in Console.sys.mjs for details. maxLogLevel: "error", maxLogLevelPref: "browser.shell.loglevel", prefix: "ShellService", }; return new ConsoleAPI(consoleOptions); }); const MSIX_PREVIOUSLY_PINNED_PREF = "browser.startMenu.msixPinnedWhenLastChecked"; // URLs handed to launchSetDefaultAppPicker (as the openWithArg) when setting a // protocol default, keyed by scheme. setAsDefaultProtocolHandler passes one of // these to the OS picker; on the round-trip WindowsSetDefaultAppCmdHandler // matches it via the stashed redirect. export const DEFAULT_PROTOCOL_URLS = { http: "http://www.firefox.com/?utm_medium=platform&utm_source=windows&utm_campaign=owl", https: "https://www.firefox.com/?utm_medium=platform&utm_source=windows&utm_campaign=owl", mailto: "mailto:owl@firefox.com", }; /** * Internal functionality to save and restore the docShell.allow* properties. */ let ShellServiceInternal = { /** * Used to determine whether or not to offer "Set as desktop background" * functionality. Even if shell service is available it is not * guaranteed that it is able to set the background for every desktop * which is especially true for Linux with its many different desktop * environments. */ get canSetDesktopBackground() { if (AppConstants.platform == "win" || AppConstants.platform == "macosx") { return true; } if (AppConstants.platform == "linux") { if (this.shellService) { let linuxShellService = this.shellService.QueryInterface( Ci.nsIGNOMEShellService ); return linuxShellService.canSetDesktopBackground; } } return false; }, /** * Used to determine based on the creation date of the home folder how old a * user profile is (and NOT the browser profile). */ async getOSUserProfileAgeInDays() { let currentDate = new Date(); let homeFolderCreationDate = new Date( (await IOUtils.stat(Services.dirsvc.get("Home", Ci.nsIFile).path)) .creationTime ); // Round and return the age (=difference between today and creation) to a // resolution of days. return Math.round( (currentDate - homeFolderCreationDate) / 1000 / // ms 60 / // sec 60 / // min 24 // hours ); }, /** * Used to determine whether or not to show a "Set Default Browser" * query dialog. This attribute is true if the application is starting * up and "browser.shell.checkDefaultBrowser" is true, otherwise it * is false. */ _checkedThisSession: false, get shouldCheckDefaultBrowser() { // If we've already checked, the browser has been started and this is a // new window open, and we don't want to check again. if (this._checkedThisSession) { return false; } if (!Services.prefs.getBoolPref("browser.shell.checkDefaultBrowser")) { return false; } return true; }, set shouldCheckDefaultBrowser(shouldCheck) { Services.prefs.setBoolPref( "browser.shell.checkDefaultBrowser", !!shouldCheck ); }, isDefaultBrowser(startupCheck, forAllTypes) { // If this is the first browser window, maintain internal state that we've // checked this session (so that subsequent window opens don't show the // default browser dialog). if (startupCheck) { this._checkedThisSession = true; } if (this.shellService) { return this.shellService.isDefaultBrowser(forAllTypes); } return false; }, /** * Check if UserChoice is impossible. * * Separated for easy stubbing in tests. * * @returns {string} * Telemetry result like "Err*", or null if UserChoice is possible. */ _userChoiceImpossibleTelemetryResult() { let winShellService = this.shellService.QueryInterface( Ci.nsIWindowsShellService ); if (!winShellService.checkAllProgIDsExist()) { return "ErrProgID"; } if (!winShellService.checkBrowserUserChoiceHashes()) { return "ErrHash"; } return null; }, /** * Whether Firefox can currently make itself the default browser by writing * the Windows UserChoice registry keys, instead of having to send the user * into the Windows Settings app to do it manually. `true` means a set-default * request would be honored or that Firefox is already the default (the * latter is a shortcut to `true` to avoid the risk of a UserChoice write * when we don't need to attempt to set to default anyway). * * If the UserChoice Protection Driver (UCPD) is running and Firefox isn't * already the default, this temporarily renames the `http` association key * and renames it back. UCPD versions where one-click still works permit * this rename, while those where it doesn't do not. * * Always `false` on macOS and Linux. Neither goes through UserChoice, and * neither is reliably one-click, since both can (but don't always) defer to * an OS consent prompt. Supporting them needs its own handling (see bug * 2060879). * * @returns {boolean} */ isOneClickSetDefaultEnabled() { if (AppConstants.platform != "win" || !this.shellService) { return false; } if ( !Services.prefs.getBoolPref( "browser.shell.setDefaultBrowserUserChoice", false ) ) { return false; } if (this._userChoiceImpossibleTelemetryResult() !== null) { return false; } if (!this.isUserChoiceProtectionDriverRunning()) { return true; } if (this.isDefaultBrowser(false)) { return true; } // A running UCPD doesn't always block the rename a UserChoice write needs, // so try renaming it to determine if it's actually blocked. return ( Services.prefs.getBoolPref( "browser.shell.setDefaultBrowserUserChoice.regRename", false ) && this.canRenameUserChoiceAssociationKey("http") ); }, /** * Accommodate `setDefaultPDFHandlerOnlyReplaceBrowsers` feature. * * @returns {boolean} * True if Firefox should set itself as default PDF handler, false otherwise. */ _shouldSetDefaultPDFHandler() { if ( !lazy.NimbusFeatures.shellService.getVariable( "setDefaultPDFHandlerOnlyReplaceBrowsers" ) ) { return true; } const handler = this.getDefaultPDFHandler(); if (handler === null) { // We only get an exception when something went really wrong. Fail // safely: don't set Firefox as default PDF handler. lazy.log.warn( "Could not determine default PDF handler: not setting Firefox as " + "default PDF handler!" ); return false; } if (!handler.registered) { lazy.log.debug( "Current default PDF handler has no registered association; " + "should set as default PDF handler." ); return true; } if (handler.knownBrowser) { lazy.log.debug( "Current default PDF handler progID matches known browser; should " + "set as default PDF handler." ); return true; } lazy.log.debug( "Current default PDF handler progID does not match known browser " + "prefix; should not set as default PDF handler." ); return false; }, getDefaultPDFHandler() { const knownBrowserPrefixes = [ "AppXq0fevzme2pys62n3e0fbqa7peapykr8v", // Edge before Blink, per https://stackoverflow.com/a/32724723. "AppXd4nrz8ff68srnhf9t5a8sbjyar1cr723", // Another pre-Blink Edge identifier. See Bug 1858729. "Brave", // For "BraveFile". "Chrome", // For "ChromeHTML". "Firefox", // For "FirefoxHTML-*" or "FirefoxPDF-*". Need to take from other installations of Firefox! "IE", // Best guess. "MSEdge", // For "MSEdgePDF". Edgium. "Opera", // For "OperaStable", presumably varying with channel. "Yandex", // For "YandexPDF.IHKFKZEIOKEMR6BGF62QXCRIKM", presumably varying with installation. ]; let currentProgID = ""; try { // Returns the empty string when no association is registered, in // which case the prefix matching will fail and we'll set Firefox as // the default PDF handler. currentProgID = this.queryCurrentDefaultHandlerFor(".pdf"); } catch (e) { // We only get an exception when something went really wrong. Fail // safely: don't set Firefox as default PDF handler. lazy.log.warn("Failed to queryCurrentDefaultHandlerFor:"); return null; } if (currentProgID == "") { return { registered: false, knownBrowser: false }; } const knownBrowserPrefix = knownBrowserPrefixes.find(it => currentProgID.startsWith(it) ); if (knownBrowserPrefix) { lazy.log.debug(`Found known browser prefix: ${knownBrowserPrefix}`); } return { registered: true, knownBrowser: !!knownBrowserPrefix, }; }, /** * Set the default browser through the UserChoice registry keys on Windows. * * NOTE: This does NOT open the System Settings app for manual selection * in case of failure. If that is desired, catch the exception and call * setDefaultBrowser(). * * @returns {Promise} * Resolves when successful, rejects with Error on failure. */ async setAsDefaultUserChoice() { if (AppConstants.platform != "win") { throw new Error("Windows-only"); } lazy.log.info("Setting Firefox as default using UserChoice"); let telemetryResult = "ErrOther"; try { telemetryResult = this._userChoiceImpossibleTelemetryResult() ?? "ErrOther"; if (telemetryResult == "ErrProgID") { throw new Error("checkAllProgIDsExist() failed"); } if (telemetryResult == "ErrHash") { throw new Error("checkBrowserUserChoiceHashes() failed"); } const aumi = lazy.XreDirProvider.getInstallHash(); telemetryResult = "ErrLaunchExe"; const extraFileExtensions = []; if ( lazy.NimbusFeatures.shellService.getVariable("setDefaultPDFHandler") ) { if (this._shouldSetDefaultPDFHandler()) { lazy.log.info("Setting Firefox as default PDF handler"); extraFileExtensions.push(".pdf", "FirefoxPDF"); } else { lazy.log.info("Not setting Firefox as default PDF handler"); } } try { await this.defaultAgent.setDefaultBrowserUserChoiceAsync( aumi, extraFileExtensions ); } catch (err) { telemetryResult = "ErrOther"; this._throwForWDBAResult(err.result || Cr.NS_ERROR_FAILURE); } telemetryResult = "Success"; } catch (ex) { if (ex instanceof WDBAError) { telemetryResult = ex.telemetryResult; } throw ex; } finally { Glean.browser.setDefaultUserChoiceResult[telemetryResult].add(1); } }, async setAsDefaultPDFHandlerUserChoice() { if (AppConstants.platform != "win") { throw new Error("Windows-only"); } const aumi = lazy.XreDirProvider.getInstallHash(); try { this.defaultAgent.setDefaultExtensionHandlersUserChoice(aumi, [ ".pdf", "FirefoxPDF", ]); } catch (err) { this._throwForWDBAResult(err.result || Cr.NS_ERROR_FAILURE); } }, async _maybeShowSetDefaultGuidanceNotification() { if ( lazy.NimbusFeatures.shellService.getVariable( "setDefaultGuidanceNotifications" ) && // Disable showing toast notification from Firefox Background Tasks. !lazy.BackgroundTasks?.isBackgroundTaskMode ) { await lazy.ASRouter.waitForInitialized; const win = Services.wm.getMostRecentBrowserWindow() ?? null; lazy.ASRouter.sendTriggerMessage({ browser: win, id: "deeplinkedToWindowsSettingsUI", }); } }, // override nsIShellService.setDefaultBrowser() on the ShellService proxy. async setDefaultBrowser(forAllUsers) { if (!Services.policies.isAllowed("setDefaultBrowser")) { lazy.log.warn("Setting the default browser is disallowed by policy"); return; } // On Windows, our best chance is to set UserChoice, so try that first. if ( AppConstants.platform == "win" && Services.prefs.getBoolPref("browser.shell.setDefaultBrowserUserChoice") ) { try { await this.setAsDefaultUserChoice(); return; } catch (err) { lazy.log.warn( "Error thrown during setAsDefaultUserChoice. Full exception:", err ); // intentionally fall through to setting via the non-user choice pathway on error } } this.shellService.setDefaultBrowser(forAllUsers); this._maybeShowSetDefaultGuidanceNotification(); }, async setAsDefault() { let setAsDefaultError = false; try { await ShellService.setDefaultBrowser(false); } catch (ex) { setAsDefaultError = true; console.error(ex); } // Here isUserDefault and setUserDefaultError appear // to be inverse of each other, but that is only because this function is // called when the browser is set as the default. During startup we record // the isUserDefault value without recording setUserDefaultError. Glean.browser.isUserDefault[!setAsDefaultError ? "true" : "false"].add(); Glean.browser.setDefaultError[setAsDefaultError ? "true" : "false"].add(); }, /** * Returns whether the current operating system is Windows 11. * * This is used so it can be mocked out in tests. */ _isWindows11() { return Services.sysinfo.isWindows10BuildOrLater(22000); }, /** * Returns the on-disk nsIFile for a PDF bundled under the browser directory * in NS_GRE_DIR. * * @param {string} aLeafName - The bundled PDF's file name, e.g. * "confused_fox.pdf". * @returns {nsIFile} The bundled file (which may not exist on disk). */ getBundledPdfFile(aLeafName) { const file = Services.dirsvc.get("GreD", Ci.nsIFile); file.append("browser"); file.append(aLeafName); return file; }, /** * Set Firefox as the default PDF handler. Supported on Windows and macOS * (macOS 12+). The OS may prompt the user to confirm, so the returned * promise can take a while to settle. * * @param {boolean} [onlyIfKnownBrowser] - When true, only proceed if the * current default PDF handler is another browser, so we don't displace a * dedicated PDF app such as Preview or Acrobat. * @param {boolean} [openInFirefox] - Windows-only; ignored on macOS. Only * meaningful on the "Open with" picker code path. After the user picks * Firefox, the OS relaunches Firefox with the bundled stub PDF; this flag * decides whether we then open a PDF in a new tab (true), to land the user in * Firefox, or silently absorb that relaunch (false). * @returns {Promise} Whether the attempt was carried out: on macOS * the OS reported the change applied; on Windows the chosen mechanism was * invoked without throwing. This is NOT a guarantee that Firefox is now the * default handler — the user may decline an OS prompt, or an out-of-process * picker may still be open. Callers that need the actual state should query * {@link ShellService.isDefaultHandlerFor} instead. */ async setAsDefaultPDFHandler( onlyIfKnownBrowser = false, openInFirefox = false ) { if (AppConstants.platform == "macosx") { return this._setAsDefaultPDFHandlerMac(onlyIfKnownBrowser); } if (AppConstants.platform == "win") { return this._setAsDefaultPDFHandlerWin(onlyIfKnownBrowser, openInFirefox); } throw new Error( "Setting the default PDF handler is only supported on Windows and macOS" ); }, /** * macOS implementation of {@link ShellService.setAsDefaultPDFHandler}. Goes * through NSWorkspace, which may prompt the user for consent and resolves * once that interaction completes with whether the change was applied. The * Windows picker/settings fallback machinery doesn't apply. * * @param {boolean} onlyIfKnownBrowser - When true, only proceed if the * current default PDF handler is another browser. * @returns {Promise} Whether NSWorkspace reported the change was * applied (i.e. it completed without error). Not a guarantee Firefox is the * default — see {@link ShellService.setAsDefaultPDFHandler}. */ async _setAsDefaultPDFHandlerMac(onlyIfKnownBrowser) { // Unsupported on macOS older than 12; don't attempt or record an attempt. if (!this.shellService.canSetAsDefaultHandler) { return false; } if ( onlyIfKnownBrowser && !this.shellService.isDefaultHandlerAWebBrowserFor(".pdf") ) { return false; } let success = false; try { success = await this.shellService.setAsDefaultHandlerFor(".pdf"); } catch (e) { lazy.log.debug("Setting default PDF handler failed", e); } // The promise resolved after the OS consent completed, so the default // handler state is current; sample it directly. Glean.browser.setDefaultPdfHandlerAttempt.record({ method: "launch_services", success, result_is_default: this.isDefaultHandlerFor(".pdf"), }); return success; }, /** * Windows implementation of {@link ShellService.setAsDefaultPDFHandler}. * * @param {boolean} onlyIfKnownBrowser - When true, only proceed if the * current default PDF handler is a known browser. * @param {boolean} openInFirefox - See * {@link ShellService.setAsDefaultPDFHandler}. * @returns {Promise} Whether a method was invoked without throwing. * Not a guarantee Firefox is the default — the user may not have picked * Firefox in an out-of-process picker. See * {@link ShellService.setAsDefaultPDFHandler}. */ async _setAsDefaultPDFHandlerWin(onlyIfKnownBrowser, openInFirefox) { if (onlyIfKnownBrowser && !this.getDefaultPDFHandler().knownBrowser) { return false; } // Tracks the last method attempted and whether its API call succeeded. // These feed into the consolidated set_default_pdf_handler_attempt event // recorded at the bottom of this function. let method = "user_choice"; let success = false; try { await this.setAsDefaultPDFHandlerUserChoice(); Glean.browser.setDefaultPdfHandlerUserChoiceResult.Success.add(1); success = true; } catch (e) { const telemetryResult = e instanceof WDBAError ? e.telemetryResult : "ErrOther"; Glean.browser.setDefaultPdfHandlerUserChoiceResult[telemetryResult].add( 1 ); lazy.log.debug( "Setting default by user-choice failed, falling through to open with launcher", e ); } // Optional second attempt via the undocumented IOpenWithLauncher API, // which surfaces the OS "Open with" picker so the user can pick Firefox // themselves. Gated by a pref so it can be remotely disabled if it // regresses. if ( !success && Services.prefs.getBoolPref( "browser.shell.setDefaultPDFHandler.useOpenWith", false ) ) { method = "open_with"; const openWithArg = this.getBundledPdfFile("confused_fox.pdf").path; // Arm the round-trip: the OS hands `openWithArg` back to Firefox if the user // selects us. We redirect that launch to the bundled PDF); otherwise overrideUri // is null and the launch is suppressed. const overrideUri = openInFirefox ? Services.io.newFileURI(this.getBundledPdfFile("blank.pdf")).spec : null; lazy.WindowsSetDefaultRedirect.arm( openWithArg, overrideUri, lazy.WindowsSetDefaultRedirect.TYPE.FILE ); try { const flags = this._isWindows11() ? Ci.nsIWindowsShellService.OPEN_WITH_SET_HANDLER : Ci.nsIWindowsShellService.OPEN_WITH_SET_HANDLER_WIN10; this.shellService.launchSetDefaultAppPicker(openWithArg, flags); success = true; } catch (e) { lazy.WindowsSetDefaultRedirect.clear(); // The picker API itself failed (e.g. COM error). Fall through to the // modern settings dialog rather than leaving the user without any // default-handler UI. lazy.log.debug( "Setting default by open with launcher failed, possibly falling through to modern settings", e ); } } // PDF default app settings are only available in Windows 11 (build 22000+). if (!success && this._isWindows11()) { method = "settings"; try { this.shellService.launchModernSettingsDialogDefaultApps(); Glean.browser.setDefaultPdfHandlerModernSettingsResult.Success.add(1); success = true; } catch (e) { Glean.browser.setDefaultPdfHandlerModernSettingsResult.Failure.add(1); lazy.log.debug( "Last attempt to set as default PDF failed through modern settings", e ); } } // Record the consolidated attempt event after a delay. For open_with and // settings the user is interacting with a launched dialog out-of-process, // so we wait before sampling isDefaultHandlerFor to give that interaction // time to complete. For user_choice the wait is unnecessary but harmless. const waitTimeMs = Services.prefs.getIntPref( "browser.shell.setDefaultPDFHandler.attemptWaitTimeMs", 30000 ); new lazy.ScheduledTask(() => { Glean.browser.setDefaultPdfHandlerAttempt.record({ method, success, result_is_default: this.isDefaultHandlerFor(".pdf"), }); }, Date.now() + waitTimeMs).arm(); return success; }, /** * Set Firefox as the Windows default handler for a protocol (scheme). * * @param {string} protocol - The scheme to claim, e.g. "https" or "mailto". * Selects the default URL and is the telemetry / isDefaultHandlerFor key. * @param {string} [url] - The URL handed to the OS picker (the openWithArg). * Defaults to the DEFAULT_PROTOCOL_URLS entry for protocol. * @param {boolean} [openInFirefox] - After the user picks Firefox, the OS * relaunches Firefox with that URL; this flag decides whether we then open * the protocol's default URL in a new tab (true) or suppress that relaunch * (false). */ async setAsDefaultProtocolHandler( protocol, url = DEFAULT_PROTOCOL_URLS[protocol], openInFirefox = false ) { if (AppConstants.platform != "win") { throw new Error("Windows-only"); } if (!url) { throw new Error( `No URL provided and no DEFAULT_PROTOCOL_URLS fallback for protocol: ${protocol}` ); } // Arm the round-trip for once the user picks a default: the OS hands `url` // back when Firefox becomes the handler. When opening in Firefox we then // open the protocol's default URL; otherwise the relaunch is suppressed. lazy.WindowsSetDefaultRedirect.arm( url, openInFirefox ? DEFAULT_PROTOCOL_URLS[protocol] : null, lazy.WindowsSetDefaultRedirect.TYPE.PROTOCOL ); // Tracks the last method attempted and whether its API call succeeded. // These feed into the consolidated set_default_protocol_handler_attempt // event recorded at the bottom of this function. let method = "open_with"; let success = false; const flags = (this._isWindows11() ? Ci.nsIWindowsShellService.OPEN_WITH_SET_HANDLER : Ci.nsIWindowsShellService.OPEN_WITH_SET_HANDLER_WIN10) | Ci.nsIWindowsShellService.OPEN_WITH_PROTOCOL_MESSAGING; try { this.shellService.launchSetDefaultAppPicker(url, flags); success = true; } catch (e) { lazy.WindowsSetDefaultRedirect.clear(); lazy.log.debug( "Setting default protocol handler by open with launcher failed, " + "falling through to modern settings", e ); } if (!success) { method = "settings"; try { this.shellService.launchModernSettingsDialogDefaultApps(); Glean.browser.setDefaultProtocolHandlerModernSettingsResult.Success.add( 1 ); success = true; } catch (e) { Glean.browser.setDefaultProtocolHandlerModernSettingsResult.Failure.add( 1 ); lazy.log.debug( "Last attempt to set as default protocol handler failed through " + "modern settings", e ); } } // Record the consolidated attempt event after a delay so the user has // time to interact with the launched picker or settings dialog before we // sample isDefaultHandlerFor. const waitTimeMs = Services.prefs.getIntPref( "browser.shell.setDefaultProtocolHandler.attemptWaitTimeMs", 30000 ); new lazy.ScheduledTask(() => { Glean.browser.setDefaultProtocolHandlerAttempt.record({ method, success, protocol, result_is_default: this.isDefaultHandlerFor(protocol), }); }, Date.now() + waitTimeMs).arm(); }, /** * Determine if we're the default handler for the given file extension (like * ".pdf") or protocol (like "https"). A leading "." marks a file extension; * anything else is treated as a protocol. * * Supported on Windows and macOS; always false on other platforms. On macOS * the query requires macOS 12, so it is false on older versions. * * @returns {boolean} true if we are the default handler, false otherwise. */ isDefaultHandlerFor(aFileExtensionOrProtocol) { if (AppConstants.platform == "win" || AppConstants.platform == "macosx") { return this.shellService.isDefaultHandlerFor(aFileExtensionOrProtocol); } return false; }, /** * Whether the platform supports offering to set Firefox as the default PDF * handler. Always true on Windows; on macOS requires macOS 12 (the * underlying NSWorkspace API); false on other platforms. * * @returns {boolean} */ get canSetAsDefaultPDFHandler() { if (AppConstants.platform == "win") { return true; } if (AppConstants.platform == "macosx") { return this.shellService.canSetAsDefaultHandler; } return false; }, /** * Checks if Firefox app can and isn't pinned to OS "taskbar." * * @throws if not called from main process. */ async doesAppNeedPin(privateBrowsing = false) { if ( Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT ) { throw new Components.Exception( "Can't determine pinned from child process", Cr.NS_ERROR_NOT_AVAILABLE ); } // Pretend pinning is not needed/supported if remotely disabled. if (lazy.NimbusFeatures.shellService.getVariable("disablePin")) { return false; } // Bug 1758770: Pinning private browsing on MSIX is currently // not possible. if ( privateBrowsing && AppConstants.platform === "win" && Services.sysinfo.getProperty("hasWinPackageId") ) { return false; } // Currently this only works on certain Windows versions. try { // First check if we can even pin the app where an exception means no. this.shellService.canPinToTaskbar(); let winTaskbar = Cc["@mozilla.org/windows-taskbar;1"].getService( Ci.nsIWinTaskbar ); // Then check if we're already pinned. return !(await this.shellService.isCurrentAppPinnedToTaskbar( privateBrowsing ? winTaskbar.defaultPrivateGroupId : winTaskbar.defaultGroupId )); } catch (ex) {} // Next check mac pinning to dock. try { // Accessing this.macDockSupport will ensure we're actually running // on Mac (it's possible to be on Linux in this block). const isInDock = this.macDockSupport.isAppInDock; // We can't pin Private Browsing mode on Mac, only a shortcut to the vanilla app return privateBrowsing ? false : !isInDock; } catch (ex) {} return false; }, /** * Pin Firefox app to the OS "taskbar." * * @param {bool} privateBrowsing - Pin a private browser window. * @param {bool} fireAndForget - Return after pin attempt is tried, but before * result is known if user input is necessary. * @returns {Promise} - Resolves either when pin attempt resolves, or when pin * request has been sent if fireAndForget is true. */ async pinToTaskbar(privateBrowsing = false, fireAndForget = false) { let needsPin = await this.doesAppNeedPin(privateBrowsing); if (!needsPin) { return; } try { if (AppConstants.platform == "win") { await this.shellService.pinCurrentAppToTaskbar( privateBrowsing, fireAndForget ); } else if (AppConstants.platform == "macosx") { this.macDockSupport.ensureAppIsPinnedToDock(); } } catch (ex) { console.error(ex); } }, /** * On MSIX builds, pins Firefox to the Windows Start Menu * * On non-MSIX builds, this function is a no-op and always returns false. * * @returns {boolean} true if we successfully pin and false otherwise. */ async pinToStartMenu() { if (await this.doesAppNeedStartMenuPin()) { try { let pinSuccess = await this.shellService.pinCurrentAppToStartMenu(); Services.prefs.setBoolPref(MSIX_PREVIOUSLY_PINNED_PREF, pinSuccess); return pinSuccess; } catch (err) { lazy.log.warn("Error thrown during pinCurrentAppToStartMenu", err); Services.prefs.setBoolPref(MSIX_PREVIOUSLY_PINNED_PREF, false); } } return false; }, /** * On MSIX builds, checks if Firefox app can be and is not * pinned to the Windows Start Menu. * * On non-MSIX builds, this function is a no-op and always returns false. * * @returns {boolean} true if this is an MSIX install and we are not yet * pinned to the Start Menu. * * @throws if not called from main process. */ async doesAppNeedStartMenuPin() { if ( Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT ) { throw new Components.Exception( "Can't determine pinned from child process", Cr.NS_ERROR_NOT_AVAILABLE ); } if ( Services.prefs.getBoolPref("browser.shell.disableStartMenuPin", false) ) { return false; } try { return ( AppConstants.platform === "win" && Services.sysinfo.getProperty("hasWinPackageId") && !(await this.shellService.isCurrentAppPinnedToStartMenu()) ); } catch (ex) {} return false; }, /** * On MSIX builds, checks if Firefox is no longer pinned to * the Windows Start Menu when it previously was and records * a Glean event if so. * * On non-MSIX builds, this function is a no-op. */ async recordWasPreviouslyPinnedToStartMenu() { if (!Services.sysinfo.getProperty("hasWinPackageId")) { return; } let isPinned = await this.shellService.isCurrentAppPinnedToStartMenu(); if ( !isPinned && Services.prefs.getBoolPref(MSIX_PREVIOUSLY_PINNED_PREF, false) ) { Services.prefs.setBoolPref(MSIX_PREVIOUSLY_PINNED_PREF, isPinned); Glean.startMenu.manuallyUnpinnedSinceLastLaunch.record(); } }, _throwForWDBAResult(exitCode) { if (exitCode != Cr.NS_OK) { const telemetryResult = new Map([ [Cr.NS_ERROR_WDBA_NO_PROGID, "ErrExeProgID"], [Cr.NS_ERROR_WDBA_HASH_CHECK, "ErrExeHash"], [Cr.NS_ERROR_WDBA_REJECTED, "ErrExeRejected"], [Cr.NS_ERROR_WDBA_BUILD, "ErrBuild"], ]).get(exitCode) ?? "ErrExeOther"; throw new WDBAError(exitCode, telemetryResult); } throw new Error( `_throwForWDBAResult called with unexpected exit code: ${exitCode}` ); }, get shortcutIconType() { if (AppConstants.platform === "win") { return { extension: "ico", mimeType: "image/vnd.microsoft.icon" }; } if (AppConstants.platform === "linux") { return { extension: "png", mimeType: "image/png" }; } throw new Error("Shortcut icons are not supported on this platform"); }, /** * This function can be used to convert compatible image formats into icons * compatible with the createShortcut function. * * @param {nsIFile} file - The file to write to. * @param {imgIContainer} imgContainer - The container holding the image. */ async writeShortcutIcon(file, imgContainer) { let stream = lazy.imgTools.encodeScaledImage( imgContainer, ShellService.shortcutIconType.mimeType, 256, 256 ); let streamSize = stream.available(); let bis = Cc["@mozilla.org/binaryinputstream;1"].createInstance( Ci.nsIBinaryInputStream ); bis.setInputStream(stream); let newByteArray = new Uint8Array(streamSize); bis.readArrayBuffer(streamSize, newByteArray.buffer); await IOUtils.write(file.path, newByteArray); }, /** * Creates a new Linux desktop entry for the current user. * * A Linux desktop entry is an INI-like file that complies with the * freedesktop.org Desktop Entry Specification [0]. It's similar to a Windows * shortcut, and it can appear on the desktop or application menus on * supported environments. * * [0]: https://specifications.freedesktop.org/desktop-entry/latest/ * * @param {string} appId - The application ID that this desktop entry will be * used for. This should match the app_id or WM_CLASS that will be associated * with the window. * @param {string} title - The default user-visible name of the desktop * entry. (Localization is currently not supported.) * @param {string[]} argv - Arguments that should be passed to the Firefox * executable. * @param {string} iconPath - Path to the icon that should be associated with * the desktop entry. */ async createLinuxDesktopEntry( appId, title, argv, iconPath, { window = null } = {} ) { if (AppConstants.platform !== "linux") { throw new Error( "createLinuxDesktopEntry is only supported on Linux-like systems" ); } let ini = lazy.iniParserFactory.createINIParser(); ini.QueryInterface(Ci.nsIINIParserWriter); // https://specifications.freedesktop.org/desktop-entry/latest/file-naming let isValidSegment = segment => !!segment.match(/^[A-Za-z-_][A-Za-z0-9-_]*$/); let segments = appId.split("."); if (!segments || segments.map(isValidSegment).includes(false)) { throw new Error(`Desktop entry ID '${appId}' is invalid`); } ini.setString("Desktop Entry", "Type", "Application"); ini.setString("Desktop Entry", "Version", "1.5"); ini.setString("Desktop Entry", "Name", title); ini.setString("Desktop Entry", "Icon", iconPath); // All desktop files made with this must run the Firefox executable. argv.unshift(await ShellService._findStartupCommand()); // https://specifications.freedesktop.org/desktop-entry/latest/exec-variables // (\x60 = backtick, \x24 = dollar sign, \x22 = double quote, and // \x5c = backslash; escaped to avoid messing with syntax highlighting) const escapeArg = arg => arg.replaceAll(/[\x60\x24\x22\x5c]/g, "\\$&"); ini.setString( "Desktop Entry", "Exec", argv.map(arg => `"${escapeArg(arg)}"`).join(" ") ); if ( lazy.gioService.isRunningUnderFlatpak || lazy.gioService.isRunningUnderSnap ) { await ShellService.requestInstallDynamicLauncher(appId, ini, window); } else { await IOUtils.writeUTF8( ShellService._getLinuxDesktopEntryPath(appId), ini.writeToString() ); } }, /** * Tries to find a command that will reliably start this installation, * using the information in argv[0] if possible. * * For example, on NixOS the installation directory changes each update, so * we should use '/run/current-system/sw/bin/firefox', and that itself points * to a script which configures LD_LIBRARY_PATH so all of the dependencies * can be found. See bug 2021897. If we referred to the executable directly, * it'd point to a stale version and might be missing libraries * * Note that this won't handle cases where the script redirects over, like: * #!/bin/sh * exec ./firefox-bin "$@" * since there's no good way to tell that this runs the current installation. * * To find the best command, we look at how the browser was run initially. If * argv[0] is provided, the return value will either be * - argv[0] itself; * - the absolute path of argv[0]; or * - the absolute path of a symlink _to_ argv[0] (e.g. 'firefox' or * 'firefox-esr'). * * If argv[0] is not provided, the return value will be * - the absolute path of the executable; or * - the absolute path of a symlink _to_ the executable (e.g. 'firefox' or * 'firefox-nightly'). * * This can't be perfect, but the goal is to get the best command possible. * * @returns {string} A path that, when run, should reliably start the * browser. */ async _findStartupCommand() { let executableFile = Services.dirsvc.get("XREExeF", Ci.nsIFile); let wanted = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); let argv0 = ShellService.getArgv0(); try { wanted.initWithPath(argv0); } catch (e) { if (argv0.includes("/")) { wanted.setRelativePath( Services.dirsvc.get("CurWorkD", Ci.nsIFile), argv0 ); } else { if (argv0 !== "") { // argv[0] doesn't seem to be a path to anything, so assume it's just // a command itself. If it seems to be present in the PATH, roll with // it, otherwise fall back to the executable. try { await lazy.Subprocess.pathSearch(argv0); return argv0; // if it doesn't throw } catch (inner) {} } // If that didn't work, or it's empty, just refer to the executable // directly. wanted.initWithFile(executableFile); } } let candidates = [ wanted.leafName, // e.g. 'firefox-nightly' AppConstants.MOZ_APP_NAME + "-" + AppConstants.MOZ_UPDATE_CHANNEL, // e.g. 'firefox' AppConstants.MOZ_APP_NAME, ]; let lookupPromises = candidates.map(cmd => lazy.Subprocess.pathSearch(cmd)); let results = await Promise.allSettled(lookupPromises); let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); for (const { value } of results.filter(got => got.status === "fulfilled")) { // See if it's a symlink to this installation. file.initWithPath(value); try { // TODO: I think this is main thread I/O, but it looks like there's // no good way around it...? file.initWithPath(file.target); } catch (e) { // If that fails, look at the file itself (e.g. if the installation // directory is in $PATH). } if (file.equals(wanted)) { return PathUtils.filename(value); } } return wanted.path; }, /** * Removes the Linux desktop entry given its app ID. * * This only removes entries within XDG_DATA_HOME as it is now, i.e. system * shortcuts will not be removed. * * @param {string} appId - The appId given to createLinuxDesktopEntry. */ async deleteLinuxDesktopEntry(appId) { if (AppConstants.platform !== "linux") { throw new Error( "deleteLinuxDesktopEntry is only supported on Linux-like systems" ); } if ( lazy.gioService.isRunningUnderFlatpak || lazy.gioService.isRunningUnderSnap ) { await ShellService.requestUninstallDynamicLauncher(appId); } else { await IOUtils.remove(ShellService._getLinuxDesktopEntryPath(appId)); } }, /** * Determines the location of a Linux desktop entry given its app ID. * * @param {string} appId - The basename of the desktop file's name. * @returns {string} The path to the desktop entry. */ _getLinuxDesktopEntryPath(appId) { if ( lazy.gioService.isRunningUnderFlatpak || lazy.gioService.isRunningUnderSnap ) { throw new Error( "Use DynamicLauncher instead of _getLinuxDesktopEntryPath when sandboxed" ); } // TODO is there any way to reuse existing logic for this? // Find the location of ~/.local/share/applications. let dataHome = Services.env.get("XDG_DATA_HOME"); if (!dataHome || !PathUtils.isAbsolute(dataHome)) { let home = Services.dirsvc.get("Home", Ci.nsIFile); dataHome = PathUtils.join(home.path, ".local", "share"); } return PathUtils.join(dataHome, "applications", `${appId}.desktop`); }, async requestCreateAndPinSecondaryTile(tileId, name, iconPath, args) { let resolver = Promise.withResolvers(); lazy.secondaryTileService.requestCreateAndPin( tileId, name, iconPath, args, this._secondaryTileListener("Secondary tile pinning failed", resolver) ); return resolver.promise; }, async requestDeleteSecondaryTile(tileId) { let resolver = Promise.withResolvers(); lazy.secondaryTileService.requestDelete( tileId, this._secondaryTileListener("Secondary tile unpinning failed", resolver) ); return resolver.promise; }, _secondaryTileListener(errorMessage, resolver) { return { QueryInterface: ChromeUtils.generateQI([Ci.nsISecondaryTileListener]), succeeded(outcome) { resolver.resolve(outcome); }, failed(hresult) { let formatted = hresult.toString(16).padStart(8, "0"); let error = new Error(`${errorMessage} (HRESULT ${formatted})`); resolver.reject(error); }, }; }, }; // Functions may be present or absent dependent on whether the `nsIShellService` // has been queried for the interface implementing it, as querying the interface // adds it's functions to the queried JS object. Coincidental querying is more // likely to occur for Firefox Desktop than a Firefox Background Task. To force // consistent behavior, we query the native shell interface inheriting from // `nsIShellService` on setup. let shellInterface; switch (AppConstants.platform) { case "win": shellInterface = Ci.nsIWindowsShellService; break; case "macosx": shellInterface = Ci.nsIMacShellService; break; case "linux": shellInterface = Ci.nsIGNOMEShellService; break; default: lazy.log.warn( `No platform native shell service interface for ${AppConstants.platform} queried, add for new platforms.` ); shellInterface = Ci.nsIShellService; } XPCOMUtils.defineLazyServiceGetters(ShellServiceInternal, { defaultAgent: ["@mozilla.org/default-agent;1", Ci.nsIDefaultAgent], shellService: ["@mozilla.org/browser/shell-service;1", shellInterface], macDockSupport: [ "@mozilla.org/widget/macdocksupport;1", Ci.nsIMacDockSupport, ], }); /** * The external API exported by this module. */ export var ShellService = new Proxy(ShellServiceInternal, { get(target, name) { if (name in target) { return target[name]; } // n.b. If a native shell interface member is not present on `shellService`, // it may be necessary to query the native interface. if (target.shellService && name in target.shellService) { return target.shellService[name]; } lazy.log.warn( `${name.toString()} not found in ShellService: ${target.shellService}` ); return undefined; }, }); class WDBAError extends Error { constructor(exitCode, telemetryResult) { super(`WDBA nonzero exit code ${exitCode}: ${telemetryResult}`); this.exitCode = exitCode; this.telemetryResult = telemetryResult; } }