tor-browser

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

SelectableProfile.sys.mjs (23231B)


      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
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
      6 import { DownloadPaths } from "resource://gre/modules/DownloadPaths.sys.mjs";
      7 import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs";
      8 import { ProfilesDatastoreService } from "moz-src:///toolkit/profile/ProfilesDatastoreService.sys.mjs";
      9 import { SelectableProfileService } from "resource:///modules/profiles/SelectableProfileService.sys.mjs";
     10 import { BackupService } from "resource:///modules/backup/BackupService.sys.mjs";
     11 
     12 const lazy = {};
     13 
     14 ChromeUtils.defineLazyGetter(lazy, "localization", () => {
     15  return new Localization(["branding/brand.ftl", "browser/profiles.ftl"]);
     16 });
     17 
     18 const STANDARD_AVATARS = new Set([
     19  "barbell",
     20  "bike",
     21  "book",
     22  "briefcase",
     23  "canvas",
     24  "craft",
     25  "default-favicon",
     26  "diamond",
     27  "flower",
     28  "folder",
     29  "hammer",
     30  "heart",
     31  "heart-rate",
     32  "history",
     33  "leaf",
     34  "lightbulb",
     35  "makeup",
     36  "message",
     37  "musical-note",
     38  "palette",
     39  "paw-print",
     40  "plane",
     41  "present",
     42  "shopping",
     43  "soccer",
     44  "sparkle-single",
     45  "star",
     46  "video-game-controller",
     47 ]);
     48 
     49 const STANDARD_AVATAR_SIZES = [16, 20, 48, 80];
     50 
     51 function standardAvatarURL(avatar, size = "80") {
     52  return `chrome://browser/content/profiles/assets/${size}_${avatar}.svg`;
     53 }
     54 
     55 /**
     56 * Resolve a relative path against an absolute path. The relative path may only
     57 * contain parent directory or child directory parts.
     58 *
     59 * @param {string} absolute The absolute path
     60 * @param {string} relative The relative path
     61 * @returns {string} The resolved path
     62 */
     63 function resolveDir(absolute, relative) {
     64  let target = absolute;
     65 
     66  for (let pathPart of PathUtils.splitRelative(relative, {
     67    allowParentDir: true,
     68  })) {
     69    if (pathPart === "..") {
     70      target = PathUtils.parent(target);
     71 
     72      // On Windows there is no notion of a single root directory. Instead each
     73      // disk has a root directory. Traversing to a different disk means allowing
     74      // going above the root of the first disk then the next path part will be
     75      // the new disk.
     76      if (!target && AppConstants.platform != "win") {
     77        throw new Error("Invalid path");
     78      }
     79    } else {
     80      target = target ? PathUtils.join(target, pathPart) : pathPart;
     81    }
     82  }
     83 
     84  if (!target) {
     85    throw new Error("Invalid path");
     86  }
     87 
     88  return target;
     89 }
     90 
     91 /**
     92 * The selectable profile
     93 */
     94 export class SelectableProfile {
     95  // DB internal autoincremented integer ID.
     96  // eslint-disable-next-line no-unused-private-class-members
     97  #id;
     98 
     99  // Path to profile on disk.
    100  #path;
    101 
    102  // The user-editable name
    103  #name;
    104 
    105  // Name of the user's chosen avatar, which corresponds to a list of standard
    106  // SVG avatars. Or if the avatar is a custom image, the filename of the image
    107  // stored in the avatars directory.
    108  #avatar;
    109 
    110  // lastAvatarURL is saved when URL.createObjectURL is invoked so we can
    111  // revoke the url at a later time.
    112  #lastAvatarURL;
    113 
    114  // Cached theme properties, used to allow displaying a SelectableProfile
    115  // without loading the AddonManager to get theme info.
    116  #themeId;
    117  #themeFg;
    118  #themeBg;
    119 
    120  constructor(row) {
    121    this.#id = row.getResultByName("id");
    122    this.#path = row.getResultByName("path");
    123    this.#name = row.getResultByName("name");
    124    this.#avatar = row.getResultByName("avatar");
    125    this.#themeId = row.getResultByName("themeId");
    126    this.#themeFg = row.getResultByName("themeFg");
    127    this.#themeBg = row.getResultByName("themeBg");
    128  }
    129 
    130  /**
    131   * Get the id of the profile.
    132   *
    133   * @returns {number} Id of profile
    134   */
    135  get id() {
    136    return this.#id;
    137  }
    138 
    139  // Note: setters update the object, then ask the SelectableProfileService to save it.
    140 
    141  /**
    142   * Get the user-editable name for the profile.
    143   *
    144   * @returns {string} Name of profile
    145   */
    146  get name() {
    147    return this.#name;
    148  }
    149 
    150  /**
    151   * Update the user-editable name for the profile, then trigger saving the profile,
    152   * which will notify() other running instances.
    153   *
    154   * @param {string} aName The new name of the profile
    155   */
    156  set name(aName) {
    157    this.#name = aName;
    158 
    159    this.saveUpdatesToDB();
    160 
    161    Services.prefs.setBoolPref("browser.profiles.profile-name.updated", true);
    162  }
    163 
    164  /**
    165   * Get the full path to the profile as a string.
    166   *
    167   * @returns {string} Path of profile
    168   */
    169  get path() {
    170    return resolveDir(
    171      ProfilesDatastoreService.constructor.getDirectory("UAppData").path,
    172      this.#path
    173    );
    174  }
    175 
    176  /**
    177   * Get the profile directory as an nsIFile.
    178   *
    179   * @returns {Promise<nsIFile>} A promise that resolves to an nsIFile for
    180   * the profile directory
    181   */
    182  get rootDir() {
    183    return IOUtils.getDirectory(this.path);
    184  }
    185 
    186  /**
    187   * Get the profile local directory as an nsIFile.
    188   *
    189   * @returns {Promise<nsIFile>} A promise that resolves to an nsIFile for
    190   * the profile local directory
    191   */
    192  get localDir() {
    193    return this.rootDir.then(root =>
    194      ProfilesDatastoreService.toolkitProfileService.getLocalDirFromRootDir(
    195        root
    196      )
    197    );
    198  }
    199 
    200  /**
    201   * Get the name of the avatar for the profile.
    202   *
    203   * @returns {string} Name of the avatar
    204   */
    205  get avatar() {
    206    return this.#avatar;
    207  }
    208 
    209  /**
    210   * Get the path of the current avatar.
    211   * If the avatar is standard, the return value will be of the form
    212   * 'chrome://browser/content/profiles/assets/{avatar}.svg'.
    213   * If the avatar is custom, the return value will be the path to the file on
    214   * disk.
    215   *
    216   * @param {string|number} size
    217   * @returns {string} Path to the current avatar.
    218   */
    219  getAvatarPath(size) {
    220    if (!this.hasCustomAvatar) {
    221      return standardAvatarURL(this.avatar, size);
    222    }
    223 
    224    return PathUtils.join(
    225      ProfilesDatastoreService.constructor.PROFILE_GROUPS_DIR,
    226      "avatars",
    227      this.avatar
    228    );
    229  }
    230 
    231  /**
    232   * Get the URL of the current avatar.
    233   * If the avatar is standard, the return value will be of the form
    234   * 'chrome://browser/content/profiles/assets/${size}_${avatar}.svg'.
    235   * If the avatar is custom, the return value will be a blob URL.
    236   *
    237   * @param {string|number} size optional Must be one of the sizes in
    238   * STANDARD_AVATAR_SIZES. Will be converted to a string.
    239   *
    240   * @returns {Promise<string>} Resolves to the URL of the current avatar
    241   */
    242  async getAvatarURL(size) {
    243    if (!this.hasCustomAvatar) {
    244      return standardAvatarURL(this.avatar, size);
    245    }
    246 
    247    if (this.#lastAvatarURL) {
    248      URL.revokeObjectURL(this.#lastAvatarURL);
    249    }
    250 
    251    const fileExists = await IOUtils.exists(this.getAvatarPath());
    252    if (!fileExists) {
    253      throw new Error("Custom avatar file doesn't exist.");
    254    }
    255    const file = await File.createFromFileName(this.getAvatarPath());
    256    this.#lastAvatarURL = URL.createObjectURL(file);
    257 
    258    return this.#lastAvatarURL;
    259  }
    260 
    261  /**
    262   * Get the avatar file. This is only used for custom avatars to generate an
    263   * object url. Standard avatars should use getAvatarURL or getAvatarPath.
    264   *
    265   * @returns {Promise<File>} Resolves to a file of the avatar
    266   */
    267  async getAvatarFile() {
    268    if (!this.hasCustomAvatar) {
    269      throw new Error(
    270        "Profile does not have custom avatar. Custom avatar file doesn't exist."
    271      );
    272    }
    273 
    274    return File.createFromFileName(this.getAvatarPath());
    275  }
    276 
    277  get hasCustomAvatar() {
    278    return !STANDARD_AVATARS.has(this.avatar);
    279  }
    280 
    281  /**
    282   * Update the avatar, then trigger saving the profile, which will notify()
    283   * other running instances.
    284   *
    285   * @param {string|File} aAvatarOrFile Name of the avatar or File os custom avatar
    286   */
    287  async setAvatar(aAvatarOrFile) {
    288    if (aAvatarOrFile === this.avatar) {
    289      // The avatar is the same so do nothing. See the comment in
    290      // SelectableProfileService.maybeSetupDataStore for resetting the avatar
    291      // to draw the avatar icon in the dock.
    292    } else if (STANDARD_AVATARS.has(aAvatarOrFile)) {
    293      this.#avatar = aAvatarOrFile;
    294    } else {
    295      await this.#uploadCustomAvatar(aAvatarOrFile);
    296    }
    297 
    298    await this.saveUpdatesToDB();
    299  }
    300 
    301  async #uploadCustomAvatar(file) {
    302    const avatarsDir = PathUtils.join(
    303      ProfilesDatastoreService.constructor.PROFILE_GROUPS_DIR,
    304      "avatars"
    305    );
    306 
    307    // Create avatars directory if it does not exist
    308    await IOUtils.makeDirectory(avatarsDir, { ignoreExisting: true });
    309 
    310    let uuid = Services.uuid.generateUUID().toString().slice(1, -1);
    311 
    312    const filePath = PathUtils.join(avatarsDir, uuid);
    313 
    314    const arrayBuffer = await file.arrayBuffer();
    315    const uint8Array = new Uint8Array(arrayBuffer);
    316 
    317    await IOUtils.write(filePath, uint8Array, { tmpPath: `${filePath}.tmp` });
    318 
    319    this.#avatar = uuid;
    320  }
    321 
    322  /**
    323   * Get the l10n id for the current avatar.
    324   *
    325   * @returns {string} L10n id for the current avatar
    326   */
    327  get avatarL10nId() {
    328    switch (this.avatar) {
    329      case "barbell":
    330        return "barbell-avatar-alt";
    331      case "bike":
    332        return "bike-avatar-alt";
    333      case "book":
    334        return "book-avatar-alt";
    335      case "briefcase":
    336        return "briefcase-avatar-alt";
    337      case "canvas":
    338        return "picture-avatar-alt";
    339      case "craft":
    340        return "craft-avatar-alt";
    341      case "default-favicon":
    342        return "globe-avatar-alt";
    343      case "diamond":
    344        return "diamond-avatar-alt";
    345      case "flower":
    346        return "flower-avatar-alt";
    347      case "folder":
    348        return "folder-avatar-alt";
    349      case "hammer":
    350        return "hammer-avatar-alt";
    351      case "heart":
    352        return "heart-avatar-alt";
    353      case "heart-rate":
    354        return "heart-rate-avatar-alt";
    355      case "history":
    356        return "clock-avatar-alt";
    357      case "leaf":
    358        return "leaf-avatar-alt";
    359      case "lightbulb":
    360        return "lightbulb-avatar-alt";
    361      case "makeup":
    362        return "makeup-avatar-alt";
    363      case "message":
    364        return "message-avatar-alt";
    365      case "musical-note":
    366        return "musical-note-avatar-alt";
    367      case "palette":
    368        return "palette-avatar-alt";
    369      case "paw-print":
    370        return "paw-print-avatar-alt";
    371      case "plane":
    372        return "plane-avatar-alt";
    373      case "present":
    374        return "present-avatar-alt";
    375      case "shopping":
    376        return "shopping-avatar-alt";
    377      case "soccer":
    378        return "soccer-ball-avatar-alt";
    379      case "sparkle-single":
    380        return "sparkle-single-avatar-alt";
    381      case "star":
    382        return "star-avatar-alt";
    383      case "video-game-controller":
    384        return "video-game-controller-avatar-alt";
    385      default:
    386        return "custom-avatar-alt";
    387    }
    388  }
    389 
    390  // Note, theme properties are set and returned as a group.
    391 
    392  /**
    393   * Get the theme l10n-id as a string, like "theme-foo-name".
    394   *     the theme foreground color as CSS style string, like "rgb(1,1,1)",
    395   *     the theme background color as CSS style string, like "rgb(0,0,0)".
    396   *
    397   * @returns {object} an object of the form { themeId, themeFg, themeBg }.
    398   */
    399  get theme() {
    400    return {
    401      themeId: this.#themeId,
    402      themeFg: this.#themeFg,
    403      themeBg: this.#themeBg,
    404    };
    405  }
    406 
    407  get iconPaintContext() {
    408    return {
    409      fillColor: this.#themeBg,
    410      strokeColor: this.#themeFg,
    411      fillOpacity: 1.0,
    412      strokeOpacity: 1.0,
    413    };
    414  }
    415 
    416  /**
    417   * Update the theme (all three properties are required), then trigger saving
    418   * the profile, which will notify() other running instances.
    419   *
    420   * @param {object} param0 The theme object
    421   * @param {string} param0.themeId L10n id of the theme
    422   * @param {string} param0.themeFg Foreground color of theme as CSS style string, like "rgb(1,1,1)",
    423   * @param {string} param0.themeBg Background color of theme as CSS style string, like "rgb(0,0,0)".
    424   */
    425  set theme({ themeId, themeFg, themeBg }) {
    426    this.#themeId = themeId;
    427    this.#themeFg = themeFg;
    428    this.#themeBg = themeBg;
    429 
    430    this.saveUpdatesToDB();
    431  }
    432 
    433  saveUpdatesToDB() {
    434    SelectableProfileService.updateProfile(this);
    435  }
    436 
    437  /**
    438   * Returns on object with only fields needed for the database.
    439   *
    440   * @returns {object} An object with only fields need for the database
    441   */
    442  toDbObject() {
    443    let profileObj = {
    444      id: this.id,
    445      path: this.#path,
    446      name: this.name,
    447      avatar: this.avatar,
    448      ...this.theme,
    449    };
    450 
    451    return profileObj;
    452  }
    453 
    454  /**
    455   * Returns an object representation of the profile.
    456   * Note: No custom avatar URLs are included because URL.createObjectURL needs
    457   * to be invoked in the content process for the avatar to be visible.
    458   *
    459   * @returns {object} An object representation of the profile
    460   */
    461  async toContentSafeObject() {
    462    let profileObj = {
    463      id: this.id,
    464      path: this.#path,
    465      name: this.name,
    466      avatar: this.avatar,
    467      avatarL10nId: this.avatarL10nId,
    468      hasCustomAvatar: this.hasCustomAvatar,
    469      ...this.theme,
    470    };
    471 
    472    if (this.hasCustomAvatar) {
    473      let path = this.getAvatarPath();
    474      let file = await this.getAvatarFile();
    475 
    476      profileObj.avatarPaths = Object.fromEntries(
    477        STANDARD_AVATAR_SIZES.map(s => [`path${s}`, path])
    478      );
    479      profileObj.avatarFiles = Object.fromEntries(
    480        STANDARD_AVATAR_SIZES.map(s => [`file${s}`, file])
    481      );
    482      profileObj.avatarURLs = {};
    483    } else {
    484      profileObj.avatarPaths = Object.fromEntries(
    485        STANDARD_AVATAR_SIZES.map(s => [`path${s}`, this.getAvatarPath(s)])
    486      );
    487      profileObj.avatarURLs = Object.fromEntries(
    488        await Promise.all(
    489          STANDARD_AVATAR_SIZES.map(async s => [
    490            `url${s}`,
    491            await this.getAvatarURL(s),
    492          ])
    493        )
    494      );
    495 
    496      const response = await fetch(profileObj.avatarURLs.url16);
    497 
    498      let faviconSVGText = await response.text();
    499      faviconSVGText = faviconSVGText
    500        .replaceAll("context-fill", profileObj.themeBg)
    501        .replaceAll("context-stroke", profileObj.themeFg);
    502      profileObj.faviconSVGText = faviconSVGText;
    503    }
    504 
    505    return profileObj;
    506  }
    507 
    508  async copyProfile() {
    509    // This pref is used to control targeting for the backup welcome messaging.
    510    // If this pref is set, the backup welcome messaging will not show.
    511    // We set the pref here so the copied profile will inherit this pref and
    512    // the copied profile will not show the backup welcome messaging.
    513    Services.prefs.setBoolPref("browser.profiles.profile-copied", true);
    514 
    515    // If the user has created a desktop shortcut, clear the shortcut name pref
    516    // to prevent the copied profile trying to manage the original profile's
    517    // shortcut.
    518    let shortcutFileName = Services.prefs.getCharPref(
    519      "browser.profiles.shortcutFileName",
    520      ""
    521    );
    522    if (shortcutFileName !== "") {
    523      Services.prefs.clearUserPref("browser.profiles.shortcutFileName");
    524    }
    525 
    526    const backupServiceInstance = new BackupService();
    527 
    528    let encState = await backupServiceInstance.loadEncryptionState(this.path);
    529    let createdEncState = false;
    530    if (!encState) {
    531      // If we don't have encryption enabled, temporarily create encryption so
    532      // we can copy resources that require encryption
    533      await backupServiceInstance.enableEncryption(
    534        Services.uuid.generateUUID().toString().slice(1, -1),
    535        this.path
    536      );
    537      encState = await backupServiceInstance.loadEncryptionState(this.path);
    538      createdEncState = true;
    539    }
    540    let result = await backupServiceInstance.createAndPopulateStagingFolder(
    541      this.path
    542    );
    543 
    544    // Clear the pref now that the copied profile has inherited it.
    545    Services.prefs.clearUserPref("browser.profiles.profile-copied");
    546 
    547    // Restore the desktop shortcut pref now that the copied profile will not
    548    // inherit it.
    549    if (shortcutFileName !== "") {
    550      Services.prefs.setCharPref(
    551        "browser.profiles.shortcutFileName",
    552        shortcutFileName
    553      );
    554    }
    555 
    556    if (result.error) {
    557      throw result.error;
    558    }
    559 
    560    let copiedProfile =
    561      await backupServiceInstance.recoverFromSnapshotFolderIntoSelectableProfile(
    562        result.stagingPath,
    563        true, // shouldLaunch
    564        encState, // encState
    565        this // copiedProfile
    566      );
    567 
    568    if (createdEncState) {
    569      await backupServiceInstance.disableEncryption(this.path);
    570    }
    571 
    572    copiedProfile.theme = this.theme;
    573    await copiedProfile.setAvatar(this.avatar);
    574 
    575    return copiedProfile;
    576  }
    577 
    578  // Desktop shortcut-related methods, currently Windows-only.
    579 
    580  /**
    581   * Getter that returns the nsIWindowsShellService, created to simplify
    582   * mocking for tests.
    583   *
    584   * @returns {nsIWindowsShellService|null} shell service on Windows, null on other platforms
    585   */
    586  getWindowsShellService() {
    587    if (AppConstants.platform !== "win") {
    588      return null;
    589    }
    590    return Cc["@mozilla.org/browser/shell-service;1"].getService(
    591      Ci.nsIWindowsShellService
    592    );
    593  }
    594 
    595  /**
    596   * Returns a promise that resolves to the desktop shortcut as an nsIFile,
    597   * or null on platforms other than Windows.
    598   *
    599   * @returns {Promise<nsIFile|null>}
    600   *   A promise that resolves to the desktop shortcut or null.
    601   */
    602  async ensureDesktopShortcut() {
    603    if (AppConstants.platform !== "win") {
    604      return null;
    605    }
    606 
    607    if (!this.hasDesktopShortcut()) {
    608      let shortcutFileName = await this.getSafeDesktopShortcutFileName();
    609      if (!shortcutFileName) {
    610        return null;
    611      }
    612 
    613      let exeFile = Services.dirsvc.get("XREExeF", Ci.nsIFile);
    614      let shellService = this.getWindowsShellService();
    615      try {
    616        await shellService.createShortcut(
    617          exeFile,
    618          ["--profile", this.path],
    619          this.name,
    620          exeFile,
    621          0,
    622          "",
    623          "Desktop",
    624          shortcutFileName
    625        );
    626 
    627        // The shortcut name is not necessarily the sanitized profile name.
    628        // In certain circumstances we use a default shortcut name, or might
    629        // have duplicate shortcuts on the desktop that require appending a
    630        // counter like "(1)" or "(2)", etc., to the filename to deduplicate.
    631        // Save the shortcut name in a pref to keep track of it.
    632        Services.prefs.setCharPref(
    633          "browser.profiles.shortcutFileName",
    634          shortcutFileName
    635        );
    636      } catch (e) {
    637        console.error("Failed to create shortcut: ", e);
    638      }
    639    }
    640 
    641    return this.getDesktopShortcut();
    642  }
    643 
    644  /**
    645   * Returns a promise that resolves to the desktop shortcut, either null
    646   * if it was deleted, or the shortcut as an nsIFile if deletion failed.
    647   *
    648   * @returns {boolean} true if deletion succeeded, false otherwise
    649   */
    650  async removeDesktopShortcut() {
    651    if (!this.hasDesktopShortcut()) {
    652      return false;
    653    }
    654 
    655    let fileName = Services.prefs.getCharPref(
    656      "browser.profiles.shortcutFileName",
    657      ""
    658    );
    659    try {
    660      let shellService = this.getWindowsShellService();
    661      await shellService.deleteShortcut("Desktop", fileName);
    662 
    663      // Wait to clear the pref until deletion succeeds.
    664      Services.prefs.clearUserPref("browser.profiles.shortcutFileName");
    665    } catch (e) {
    666      console.error("Failed to remove shortcut: ", e);
    667    }
    668    return this.hasDesktopShortcut();
    669  }
    670 
    671  /**
    672   * Returns the desktop shortcut as an nsIFile, or null if not found.
    673   *
    674   * Note the shortcut will not be found if the profile name has changed since
    675   * the shortcut was created (we plan to handle name updates in bug 1992897).
    676   *
    677   * @returns {nsIFile|null} The desktop shortcut or null.
    678   */
    679  getDesktopShortcut() {
    680    if (AppConstants.platform !== "win") {
    681      return null;
    682    }
    683 
    684    let shortcutName = Services.prefs.getCharPref(
    685      "browser.profiles.shortcutFileName",
    686      ""
    687    );
    688    if (!shortcutName) {
    689      return null;
    690    }
    691 
    692    let file;
    693    try {
    694      file = new FileUtils.File(
    695        PathUtils.join(
    696          Services.dirsvc.get("Desk", Ci.nsIFile).path,
    697          shortcutName
    698        )
    699      );
    700    } catch (e) {
    701      console.error("Failed to get shortcut: ", e);
    702    }
    703    return file?.exists() ? file : null;
    704  }
    705 
    706  /**
    707   * Checks the filesystem to determine if the desktop shortcut exists with the
    708   * expected name from the pref.
    709   *
    710   * @returns {boolean} - true if the shortcut exists on the Desktop
    711   */
    712  hasDesktopShortcut() {
    713    let shortcut = this.getDesktopShortcut();
    714    return shortcut !== null;
    715  }
    716 
    717  /**
    718   * Returns the profile name with illegal characters sanitized, length
    719   * truncated, and with ".lnk" appended, suitable for use as the name of
    720   * a Windows desktop shortcut. If the sanitized profile name is empty,
    721   * uses a reasonable default. Appends "(1)", "(2)", etc. as needed to ensure
    722   * the desktop shortcut file name is unique.
    723   *
    724   * @returns {string} Safe desktop shortcut file name for the current profile,
    725   *                   or empty string if something went wrong.
    726   */
    727  async getSafeDesktopShortcutFileName() {
    728    let existingShortcutName = Services.prefs.getCharPref(
    729      "browser.profiles.shortcutFileName",
    730      ""
    731    );
    732    if (existingShortcutName) {
    733      return existingShortcutName;
    734    }
    735 
    736    let desktopFile = Services.dirsvc.get("Desk", Ci.nsIFile);
    737 
    738    // Strip out any illegal chars and whitespace. Most illegal chars are
    739    // converted to '_' but others are just removed (".", "\\") so we may
    740    // wind up with an empty string.
    741    let fileName = DownloadPaths.sanitize(this.name);
    742 
    743    // To avoid exceeding the Windows default `MAX_PATH` of 260 chars, subtract
    744    // the length of the Desktop path, 4 chars for the ".lnk" file extension,
    745    // one more char for the path separator between "Desktop" and the shortcut
    746    // file name, and 6 chars for the largest possible deduplicating counter
    747    // "(9999)" added by `DownloadPaths.createNiceUniqueFile()` below,
    748    // giving us a working max path of 260 - 4 - 1 - 6 = 249.
    749    let maxLength = 249 - desktopFile.path.length;
    750    fileName = fileName.substring(0, maxLength);
    751 
    752    // Use the brand name as default if the sanitized `fileName` is empty.
    753    if (!fileName) {
    754      let strings = await lazy.localization.formatMessages([
    755        "default-desktop-shortcut-name",
    756      ]);
    757      fileName = strings[0].value;
    758    }
    759 
    760    fileName = fileName + ".lnk";
    761 
    762    // At this point, it's possible the fileName would not be a unique file
    763    // because of other shortcuts on the desktop. To ensure uniqueness, we use
    764    // `DownloadPaths.createNiceUniqueFile()` to append a "(1)", "(2)", etc.,
    765    // up to a max of (9999). See `DownloadPaths` docs for other things we try
    766    // if incrementing a counter in the name fails.
    767    try {
    768      let shortcutFile = new FileUtils.File(
    769        PathUtils.join(desktopFile.path, fileName)
    770      );
    771      let uniqueShortcutFile = DownloadPaths.createNiceUniqueFile(shortcutFile);
    772      fileName = uniqueShortcutFile.leafName;
    773      // `createNiceUniqueFile` actually creates the file, which we don't want.
    774      await IOUtils.remove(uniqueShortcutFile.path);
    775    } catch (e) {
    776      console.error("Unable to create a shortcut name: ", e);
    777      fileName = "";
    778    }
    779 
    780    return fileName;
    781  }
    782 }