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 }