ShellService.sys.mjs (20344B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; 6 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 7 8 const lazy = {}; 9 10 ChromeUtils.defineESModuleGetters(lazy, { 11 NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", 12 ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs", 13 }); 14 15 XPCOMUtils.defineLazyServiceGetter( 16 lazy, 17 "XreDirProvider", 18 "@mozilla.org/xre/directory-provider;1", 19 Ci.nsIXREDirProvider 20 ); 21 22 XPCOMUtils.defineLazyServiceGetter( 23 lazy, 24 "BackgroundTasks", 25 "@mozilla.org/backgroundtasks;1", 26 Ci.nsIBackgroundTasks 27 ); 28 29 ChromeUtils.defineLazyGetter(lazy, "log", () => { 30 let { ConsoleAPI } = ChromeUtils.importESModule( 31 "resource://gre/modules/Console.sys.mjs" 32 ); 33 let consoleOptions = { 34 // tip: set maxLogLevel to "debug" and use log.debug() to create detailed 35 // messages during development. See LOG_LEVELS in Console.sys.mjs for details. 36 maxLogLevel: "error", 37 maxLogLevelPref: "browser.shell.loglevel", 38 prefix: "ShellService", 39 }; 40 return new ConsoleAPI(consoleOptions); 41 }); 42 43 const MSIX_PREVIOUSLY_PINNED_PREF = 44 "browser.startMenu.msixPinnedWhenLastChecked"; 45 46 /** 47 * Internal functionality to save and restore the docShell.allow* properties. 48 */ 49 let ShellServiceInternal = { 50 /** 51 * Used to determine whether or not to offer "Set as desktop background" 52 * functionality. Even if shell service is available it is not 53 * guaranteed that it is able to set the background for every desktop 54 * which is especially true for Linux with its many different desktop 55 * environments. 56 */ 57 get canSetDesktopBackground() { 58 if (AppConstants.platform == "win" || AppConstants.platform == "macosx") { 59 return true; 60 } 61 62 if (AppConstants.platform == "linux") { 63 if (this.shellService) { 64 let linuxShellService = this.shellService.QueryInterface( 65 Ci.nsIGNOMEShellService 66 ); 67 return linuxShellService.canSetDesktopBackground; 68 } 69 } 70 71 return false; 72 }, 73 74 /** 75 * Used to determine based on the creation date of the home folder how old a 76 * user profile is (and NOT the browser profile). 77 */ 78 async getOSUserProfileAgeInDays() { 79 let currentDate = new Date(); 80 let homeFolderCreationDate = new Date( 81 ( 82 await IOUtils.stat(Services.dirsvc.get("Home", Ci.nsIFile).path) 83 ).creationTime 84 ); 85 // Round and return the age (=difference between today and creation) to a 86 // resolution of days. 87 return Math.round( 88 (currentDate - homeFolderCreationDate) / 89 1000 / // ms 90 60 / // sec 91 60 / // min 92 24 // hours 93 ); 94 }, 95 96 /** 97 * Used to determine whether or not to show a "Set Default Browser" 98 * query dialog. This attribute is true if the application is starting 99 * up and "browser.shell.checkDefaultBrowser" is true, otherwise it 100 * is false. 101 */ 102 _checkedThisSession: false, 103 get shouldCheckDefaultBrowser() { 104 // If we've already checked, the browser has been started and this is a 105 // new window open, and we don't want to check again. 106 if (this._checkedThisSession) { 107 return false; 108 } 109 110 if (!Services.prefs.getBoolPref("browser.shell.checkDefaultBrowser")) { 111 return false; 112 } 113 114 return true; 115 }, 116 117 set shouldCheckDefaultBrowser(shouldCheck) { 118 Services.prefs.setBoolPref( 119 "browser.shell.checkDefaultBrowser", 120 !!shouldCheck 121 ); 122 }, 123 124 isDefaultBrowser(startupCheck, forAllTypes) { 125 // If this is the first browser window, maintain internal state that we've 126 // checked this session (so that subsequent window opens don't show the 127 // default browser dialog). 128 if (startupCheck) { 129 this._checkedThisSession = true; 130 } 131 if (this.shellService) { 132 return this.shellService.isDefaultBrowser(forAllTypes); 133 } 134 return false; 135 }, 136 137 /** 138 * Check if UserChoice is impossible. 139 * 140 * Separated for easy stubbing in tests. 141 * 142 * @returns {string} 143 * Telemetry result like "Err*", or null if UserChoice is possible. 144 */ 145 _userChoiceImpossibleTelemetryResult() { 146 let winShellService = this.shellService.QueryInterface( 147 Ci.nsIWindowsShellService 148 ); 149 if (!winShellService.checkAllProgIDsExist()) { 150 return "ErrProgID"; 151 } 152 if (!winShellService.checkBrowserUserChoiceHashes()) { 153 return "ErrHash"; 154 } 155 return null; 156 }, 157 158 /** 159 * Accommodate `setDefaultPDFHandlerOnlyReplaceBrowsers` feature. 160 * 161 * @returns {boolean} 162 * True if Firefox should set itself as default PDF handler, false otherwise. 163 */ 164 _shouldSetDefaultPDFHandler() { 165 if ( 166 !lazy.NimbusFeatures.shellService.getVariable( 167 "setDefaultPDFHandlerOnlyReplaceBrowsers" 168 ) 169 ) { 170 return true; 171 } 172 173 const handler = this.getDefaultPDFHandler(); 174 if (handler === null) { 175 // We only get an exception when something went really wrong. Fail 176 // safely: don't set Firefox as default PDF handler. 177 lazy.log.warn( 178 "Could not determine default PDF handler: not setting Firefox as " + 179 "default PDF handler!" 180 ); 181 return false; 182 } 183 184 if (!handler.registered) { 185 lazy.log.debug( 186 "Current default PDF handler has no registered association; " + 187 "should set as default PDF handler." 188 ); 189 return true; 190 } 191 192 if (handler.knownBrowser) { 193 lazy.log.debug( 194 "Current default PDF handler progID matches known browser; should " + 195 "set as default PDF handler." 196 ); 197 return true; 198 } 199 200 lazy.log.debug( 201 "Current default PDF handler progID does not match known browser " + 202 "prefix; should not set as default PDF handler." 203 ); 204 return false; 205 }, 206 207 getDefaultPDFHandler() { 208 const knownBrowserPrefixes = [ 209 "AppXq0fevzme2pys62n3e0fbqa7peapykr8v", // Edge before Blink, per https://stackoverflow.com/a/32724723. 210 "AppXd4nrz8ff68srnhf9t5a8sbjyar1cr723", // Another pre-Blink Edge identifier. See Bug 1858729. 211 "Brave", // For "BraveFile". 212 "Chrome", // For "ChromeHTML". 213 "Firefox", // For "FirefoxHTML-*" or "FirefoxPDF-*". Need to take from other installations of Firefox! 214 "IE", // Best guess. 215 "MSEdge", // For "MSEdgePDF". Edgium. 216 "Opera", // For "OperaStable", presumably varying with channel. 217 "Yandex", // For "YandexPDF.IHKFKZEIOKEMR6BGF62QXCRIKM", presumably varying with installation. 218 ]; 219 220 let currentProgID = ""; 221 try { 222 // Returns the empty string when no association is registered, in 223 // which case the prefix matching will fail and we'll set Firefox as 224 // the default PDF handler. 225 currentProgID = this.queryCurrentDefaultHandlerFor(".pdf"); 226 } catch (e) { 227 // We only get an exception when something went really wrong. Fail 228 // safely: don't set Firefox as default PDF handler. 229 lazy.log.warn("Failed to queryCurrentDefaultHandlerFor:"); 230 return null; 231 } 232 233 if (currentProgID == "") { 234 return { registered: false, knownBrowser: false }; 235 } 236 237 const knownBrowserPrefix = knownBrowserPrefixes.find(it => 238 currentProgID.startsWith(it) 239 ); 240 241 if (knownBrowserPrefix) { 242 lazy.log.debug(`Found known browser prefix: ${knownBrowserPrefix}`); 243 } 244 245 return { 246 registered: true, 247 knownBrowser: !!knownBrowserPrefix, 248 }; 249 }, 250 251 /** 252 * Set the default browser through the UserChoice registry keys on Windows. 253 * 254 * NOTE: This does NOT open the System Settings app for manual selection 255 * in case of failure. If that is desired, catch the exception and call 256 * setDefaultBrowser(). 257 * 258 * @returns {Promise<void>} 259 * Resolves when successful, rejects with Error on failure. 260 */ 261 async setAsDefaultUserChoice() { 262 if (AppConstants.platform != "win") { 263 throw new Error("Windows-only"); 264 } 265 266 lazy.log.info("Setting Firefox as default using UserChoice"); 267 268 let telemetryResult = "ErrOther"; 269 270 try { 271 telemetryResult = 272 this._userChoiceImpossibleTelemetryResult() ?? "ErrOther"; 273 if (telemetryResult == "ErrProgID") { 274 throw new Error("checkAllProgIDsExist() failed"); 275 } 276 if (telemetryResult == "ErrHash") { 277 throw new Error("checkBrowserUserChoiceHashes() failed"); 278 } 279 280 const aumi = lazy.XreDirProvider.getInstallHash(); 281 282 telemetryResult = "ErrLaunchExe"; 283 const extraFileExtensions = []; 284 if ( 285 lazy.NimbusFeatures.shellService.getVariable("setDefaultPDFHandler") 286 ) { 287 if (this._shouldSetDefaultPDFHandler()) { 288 lazy.log.info("Setting Firefox as default PDF handler"); 289 extraFileExtensions.push(".pdf", "FirefoxPDF"); 290 } else { 291 lazy.log.info("Not setting Firefox as default PDF handler"); 292 } 293 } 294 try { 295 await this.defaultAgent.setDefaultBrowserUserChoiceAsync( 296 aumi, 297 extraFileExtensions 298 ); 299 } catch (err) { 300 telemetryResult = "ErrOther"; 301 this._handleWDBAResult(err.result || Cr.NS_ERROR_FAILURE); 302 } 303 telemetryResult = "Success"; 304 } catch (ex) { 305 if (ex instanceof WDBAError) { 306 telemetryResult = ex.telemetryResult; 307 } 308 309 throw ex; 310 } finally { 311 Glean.browser.setDefaultUserChoiceResult[telemetryResult].add(1); 312 } 313 }, 314 315 async setAsDefaultPDFHandlerUserChoice() { 316 if (AppConstants.platform != "win") { 317 throw new Error("Windows-only"); 318 } 319 320 let telemetryResult = "ErrOther"; 321 322 try { 323 const aumi = lazy.XreDirProvider.getInstallHash(); 324 try { 325 this.defaultAgent.setDefaultExtensionHandlersUserChoice(aumi, [ 326 ".pdf", 327 "FirefoxPDF", 328 ]); 329 } catch (err) { 330 telemetryResult = "ErrOther"; 331 this._handleWDBAResult(err.result || Cr.NS_ERROR_FAILURE); 332 } 333 telemetryResult = "Success"; 334 } catch (ex) { 335 if (ex instanceof WDBAError) { 336 telemetryResult = ex.telemetryResult; 337 } 338 339 throw ex; 340 } finally { 341 Glean.browser.setDefaultPdfHandlerUserChoiceResult[telemetryResult].add( 342 1 343 ); 344 } 345 }, 346 347 async _maybeShowSetDefaultGuidanceNotification() { 348 if ( 349 lazy.NimbusFeatures.shellService.getVariable( 350 "setDefaultGuidanceNotifications" 351 ) && 352 // Disable showing toast notification from Firefox Background Tasks. 353 !lazy.BackgroundTasks?.isBackgroundTaskMode 354 ) { 355 await lazy.ASRouter.waitForInitialized; 356 const win = Services.wm.getMostRecentBrowserWindow() ?? null; 357 lazy.ASRouter.sendTriggerMessage({ 358 browser: win, 359 id: "deeplinkedToWindowsSettingsUI", 360 }); 361 } 362 }, 363 364 // override nsIShellService.setDefaultBrowser() on the ShellService proxy. 365 async setDefaultBrowser(forAllUsers) { 366 // On Windows, our best chance is to set UserChoice, so try that first. 367 if ( 368 AppConstants.platform == "win" && 369 Services.prefs.getBoolPref("browser.shell.setDefaultBrowserUserChoice") 370 ) { 371 try { 372 await this.setAsDefaultUserChoice(); 373 return; 374 } catch (err) { 375 lazy.log.warn( 376 "Error thrown during setAsDefaultUserChoice. Full exception:", 377 err 378 ); 379 380 // intentionally fall through to setting via the non-user choice pathway on error 381 } 382 } 383 384 this.shellService.setDefaultBrowser(forAllUsers); 385 this._maybeShowSetDefaultGuidanceNotification(); 386 }, 387 388 async setAsDefault() { 389 let setAsDefaultError = false; 390 try { 391 await ShellService.setDefaultBrowser(false); 392 } catch (ex) { 393 setAsDefaultError = true; 394 console.error(ex); 395 } 396 // Here isUserDefault and setUserDefaultError appear 397 // to be inverse of each other, but that is only because this function is 398 // called when the browser is set as the default. During startup we record 399 // the isUserDefault value without recording setUserDefaultError. 400 Glean.browser.isUserDefault[!setAsDefaultError ? "true" : "false"].add(); 401 Glean.browser.setDefaultError[setAsDefaultError ? "true" : "false"].add(); 402 }, 403 404 setAsDefaultPDFHandler(onlyIfKnownBrowser = false) { 405 if (onlyIfKnownBrowser && !this.getDefaultPDFHandler().knownBrowser) { 406 return; 407 } 408 409 if (AppConstants.platform == "win") { 410 this.setAsDefaultPDFHandlerUserChoice(); 411 } 412 }, 413 414 /** 415 * Determine if we're the default handler for the given file extension (like 416 * ".pdf") or protocol (like "https"). Windows-only for now. 417 * 418 * @returns {boolean} true if we are the default handler, false otherwise. 419 */ 420 isDefaultHandlerFor(aFileExtensionOrProtocol) { 421 if (AppConstants.platform == "win") { 422 return this.shellService 423 .QueryInterface(Ci.nsIWindowsShellService) 424 .isDefaultHandlerFor(aFileExtensionOrProtocol); 425 } 426 return false; 427 }, 428 429 /** 430 * Checks if Firefox app can and isn't pinned to OS "taskbar." 431 * 432 * @throws if not called from main process. 433 */ 434 async doesAppNeedPin(privateBrowsing = false) { 435 if ( 436 Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT 437 ) { 438 throw new Components.Exception( 439 "Can't determine pinned from child process", 440 Cr.NS_ERROR_NOT_AVAILABLE 441 ); 442 } 443 444 // Pretend pinning is not needed/supported if remotely disabled. 445 if (lazy.NimbusFeatures.shellService.getVariable("disablePin")) { 446 return false; 447 } 448 449 // Bug 1758770: Pinning private browsing on MSIX is currently 450 // not possible. 451 if ( 452 privateBrowsing && 453 AppConstants.platform === "win" && 454 Services.sysinfo.getProperty("hasWinPackageId") 455 ) { 456 return false; 457 } 458 459 // Currently this only works on certain Windows versions. 460 try { 461 // First check if we can even pin the app where an exception means no. 462 await this.shellService 463 .QueryInterface(Ci.nsIWindowsShellService) 464 .checkPinCurrentAppToTaskbarAsync(privateBrowsing); 465 let winTaskbar = Cc["@mozilla.org/windows-taskbar;1"].getService( 466 Ci.nsIWinTaskbar 467 ); 468 469 // Then check if we're already pinned. 470 return !(await this.shellService.isCurrentAppPinnedToTaskbarAsync( 471 privateBrowsing 472 ? winTaskbar.defaultPrivateGroupId 473 : winTaskbar.defaultGroupId 474 )); 475 } catch (ex) {} 476 477 // Next check mac pinning to dock. 478 try { 479 // Accessing this.macDockSupport will ensure we're actually running 480 // on Mac (it's possible to be on Linux in this block). 481 const isInDock = this.macDockSupport.isAppInDock; 482 // We can't pin Private Browsing mode on Mac, only a shortcut to the vanilla app 483 return privateBrowsing ? false : !isInDock; 484 } catch (ex) {} 485 return false; 486 }, 487 488 /** 489 * Pin Firefox app to the OS "taskbar." 490 */ 491 async pinToTaskbar(privateBrowsing = false) { 492 if (await this.doesAppNeedPin(privateBrowsing)) { 493 try { 494 if (AppConstants.platform == "win") { 495 await this.shellService.pinCurrentAppToTaskbarAsync(privateBrowsing); 496 } else if (AppConstants.platform == "macosx") { 497 this.macDockSupport.ensureAppIsPinnedToDock(); 498 } 499 } catch (ex) { 500 console.error(ex); 501 } 502 } 503 }, 504 505 /** 506 * On MSIX builds, pins Firefox to the Windows Start Menu 507 * 508 * On non-MSIX builds, this function is a no-op and always returns false. 509 * 510 * @returns {boolean} true if we successfully pin and false otherwise. 511 */ 512 async pinToStartMenu() { 513 if (await this.doesAppNeedStartMenuPin()) { 514 try { 515 let pinSuccess = 516 await this.shellService.pinCurrentAppToStartMenuAsync(false); 517 Services.prefs.setBoolPref(MSIX_PREVIOUSLY_PINNED_PREF, pinSuccess); 518 return pinSuccess; 519 } catch (err) { 520 lazy.log.warn("Error thrown during pinCurrentAppToStartMenuAsync", err); 521 Services.prefs.setBoolPref(MSIX_PREVIOUSLY_PINNED_PREF, false); 522 } 523 } 524 return false; 525 }, 526 527 /** 528 * On MSIX builds, checks if Firefox app can be and is not 529 * pinned to the Windows Start Menu. 530 * 531 * On non-MSIX builds, this function is a no-op and always returns false. 532 * 533 * @returns {boolean} true if this is an MSIX install and we are not yet 534 * pinned to the Start Menu. 535 * 536 * @throws if not called from main process. 537 */ 538 async doesAppNeedStartMenuPin() { 539 if ( 540 Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT 541 ) { 542 throw new Components.Exception( 543 "Can't determine pinned from child process", 544 Cr.NS_ERROR_NOT_AVAILABLE 545 ); 546 } 547 if ( 548 Services.prefs.getBoolPref("browser.shell.disableStartMenuPin", false) 549 ) { 550 return false; 551 } 552 try { 553 return ( 554 AppConstants.platform === "win" && 555 Services.sysinfo.getProperty("hasWinPackageId") && 556 !(await this.shellService.isCurrentAppPinnedToStartMenuAsync()) 557 ); 558 } catch (ex) {} 559 return false; 560 }, 561 562 /** 563 * On MSIX builds, checks if Firefox is no longer pinned to 564 * the Windows Start Menu when it previously was and records 565 * a Glean event if so. 566 * 567 * On non-MSIX builds, this function is a no-op. 568 */ 569 async recordWasPreviouslyPinnedToStartMenu() { 570 if (!Services.sysinfo.getProperty("hasWinPackageId")) { 571 return; 572 } 573 let isPinned = await this.shellService.isCurrentAppPinnedToStartMenuAsync(); 574 if ( 575 !isPinned && 576 Services.prefs.getBoolPref(MSIX_PREVIOUSLY_PINNED_PREF, false) 577 ) { 578 Services.prefs.setBoolPref(MSIX_PREVIOUSLY_PINNED_PREF, isPinned); 579 Glean.startMenu.manuallyUnpinnedSinceLastLaunch.record(); 580 } 581 }, 582 583 _handleWDBAResult(exitCode) { 584 if (exitCode != Cr.NS_OK) { 585 const telemetryResult = 586 new Map([ 587 [Cr.NS_ERROR_WDBA_NO_PROGID, "ErrExeProgID"], 588 [Cr.NS_ERROR_WDBA_HASH_CHECK, "ErrExeHash"], 589 [Cr.NS_ERROR_WDBA_REJECTED, "ErrExeRejected"], 590 [Cr.NS_ERROR_WDBA_BUILD, "ErrBuild"], 591 ]).get(exitCode) ?? "ErrExeOther"; 592 593 throw new WDBAError(exitCode, telemetryResult); 594 } 595 }, 596 }; 597 598 // Functions may be present or absent dependent on whether the `nsIShellService` 599 // has been queried for the interface implementing it, as querying the interface 600 // adds it's functions to the queried JS object. Coincidental querying is more 601 // likely to occur for Firefox Desktop than a Firefox Background Task. To force 602 // consistent behavior, we query the native shell interface inheriting from 603 // `nsIShellService` on setup. 604 let shellInterface; 605 switch (AppConstants.platform) { 606 case "win": 607 shellInterface = Ci.nsIWindowsShellService; 608 break; 609 case "macosx": 610 shellInterface = Ci.nsIMacShellService; 611 break; 612 case "linux": 613 shellInterface = Ci.nsIGNOMEShellService; 614 break; 615 default: 616 lazy.log.warn( 617 `No platform native shell service interface for ${AppConstants.platform} queried, add for new platforms.` 618 ); 619 shellInterface = Ci.nsIShellService; 620 } 621 622 XPCOMUtils.defineLazyServiceGetters(ShellServiceInternal, { 623 defaultAgent: ["@mozilla.org/default-agent;1", Ci.nsIDefaultAgent], 624 shellService: ["@mozilla.org/browser/shell-service;1", shellInterface], 625 macDockSupport: [ 626 "@mozilla.org/widget/macdocksupport;1", 627 Ci.nsIMacDockSupport, 628 ], 629 }); 630 631 /** 632 * The external API exported by this module. 633 */ 634 export var ShellService = new Proxy(ShellServiceInternal, { 635 get(target, name) { 636 if (name in target) { 637 return target[name]; 638 } 639 // n.b. If a native shell interface member is not present on `shellService`, 640 // it may be necessary to query the native interface. 641 if (target.shellService && name in target.shellService) { 642 return target.shellService[name]; 643 } 644 lazy.log.warn( 645 `${name.toString()} not found in ShellService: ${target.shellService}` 646 ); 647 return undefined; 648 }, 649 }); 650 651 class WDBAError extends Error { 652 constructor(exitCode, telemetryResult) { 653 super(`WDBA nonzero exit code ${exitCode}: ${telemetryResult}`); 654 655 this.exitCode = exitCode; 656 this.telemetryResult = telemetryResult; 657 } 658 }