restore-from-backup.mjs (18938B)
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 { 6 html, 7 ifDefined, 8 styleMap, 9 } from "chrome://global/content/vendor/lit.all.mjs"; 10 import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; 11 import { ERRORS } from "chrome://browser/content/backup/backup-constants.mjs"; 12 import { getErrorL10nId } from "chrome://browser/content/backup/backup-errors.mjs"; 13 14 // eslint-disable-next-line import/no-unassigned-import 15 import "chrome://global/content/elements/moz-message-bar.mjs"; 16 17 /** 18 * The widget for allowing users to select and restore from a 19 * a backup file. 20 */ 21 export default class RestoreFromBackup extends MozLitElement { 22 #placeholderFileIconURL = "chrome://global/skin/icons/page-portrait.svg"; 23 /** 24 * When the user clicks the button to choose a backup file to restore, we send 25 * a message to the `BackupService` process asking it to read that file. 26 * When we do this, we set this property to be a promise, which we resolve 27 * when the file reading is complete. 28 */ 29 #backupFileReadPromise = null; 30 31 /** 32 * Resolves when BackupUIParent sends state for the first time. 33 */ 34 get initializedPromise() { 35 return this.#initializedResolvers.promise; 36 } 37 #initializedResolvers = Promise.withResolvers(); 38 39 static properties = { 40 _fileIconURL: { type: String }, 41 aboutWelcomeEmbedded: { type: Boolean }, 42 backupServiceState: { type: Object }, 43 }; 44 45 static get queries() { 46 return { 47 filePicker: "#backup-filepicker-input", 48 passwordInput: "#backup-password-input", 49 cancelButtonEl: "#restore-from-backup-cancel-button", 50 confirmButtonEl: "#restore-from-backup-confirm-button", 51 chooseButtonEl: "#backup-filepicker-button", 52 errorMessageEl: "#restore-from-backup-error", 53 }; 54 } 55 56 get isIncorrectPassword() { 57 return this.backupServiceState?.recoveryErrorCode === ERRORS.UNAUTHORIZED; 58 } 59 60 constructor() { 61 super(); 62 this._fileIconURL = ""; 63 // Set the default state 64 this.backupServiceState = { 65 backupDirPath: "", 66 backupFileToRestore: null, 67 backupFileInfo: null, 68 defaultParent: { 69 fileName: "", 70 path: "", 71 iconURL: "", 72 }, 73 encryptionEnabled: false, 74 scheduledBackupsEnabled: false, 75 lastBackupDate: null, 76 lastBackupFileName: "", 77 supportBaseLink: "https://support.mozilla.org/", 78 backupInProgress: false, 79 recoveryInProgress: false, 80 recoveryErrorCode: ERRORS.NONE, 81 }; 82 } 83 84 /** 85 * Dispatches the BackupUI:InitWidget custom event upon being attached to the 86 * DOM, which registers with BackupUIChild for BackupService state updates. 87 */ 88 connectedCallback() { 89 super.connectedCallback(); 90 this.dispatchEvent( 91 new CustomEvent("BackupUI:InitWidget", { bubbles: true }) 92 ); 93 94 // If we have a backup file, but not the associated info, fetch the info 95 this.maybeGetBackupFileInfo(); 96 97 this.addEventListener("BackupUI:SelectNewFilepickerPath", this); 98 this.addEventListener("BackupUI:StateWasUpdated", this); 99 100 // Resize the textarea when the window is resized 101 if (this.aboutWelcomeEmbedded) { 102 this._handleWindowResize = () => this.resizeTextarea(); 103 window.addEventListener("resize", this._handleWindowResize); 104 } 105 } 106 107 maybeGetBackupFileInfo() { 108 if ( 109 this.backupServiceState?.backupFileToRestore && 110 !this.backupServiceState?.backupFileInfo 111 ) { 112 this.getBackupFileInfo(); 113 } 114 } 115 116 disconnectedCallback() { 117 super.disconnectedCallback(); 118 if (this._handleWindowResize) { 119 window.removeEventListener("resize", this._handleWindowResize); 120 this._handleWindowResize = null; 121 } 122 } 123 124 updated(changedProperties) { 125 super.updated(changedProperties); 126 127 // Resize the textarea. This only runs once on initial render, 128 // and once each time one of our reactive properties is changed. 129 if (this.aboutWelcomeEmbedded) { 130 this.resizeTextarea(); 131 } 132 133 if (changedProperties.has("backupServiceState")) { 134 // If we got a recovery error, recoveryInProgress should be false 135 const inProgress = 136 this.backupServiceState.recoveryInProgress && 137 !this.backupServiceState.recoveryErrorCode; 138 139 this.dispatchEvent( 140 new CustomEvent("BackupUI:RecoveryProgress", { 141 bubbles: true, 142 composed: true, 143 detail: { recoveryInProgress: inProgress }, 144 }) 145 ); 146 147 // It's possible that backupFileToRestore got updated and we need to 148 // refetch the fileInfo 149 this.maybeGetBackupFileInfo(); 150 } 151 } 152 153 handleEvent(event) { 154 if (event.type == "BackupUI:SelectNewFilepickerPath") { 155 let { path, iconURL } = event.detail; 156 this._fileIconURL = iconURL; 157 158 this.#backupFileReadPromise = Promise.withResolvers(); 159 this.#backupFileReadPromise.promise.then(() => { 160 const payload = { 161 location: this.backupServiceState?.backupFileCoarseLocation, 162 valid: this.backupServiceState?.recoveryErrorCode == ERRORS.NONE, 163 }; 164 if (payload.valid) { 165 payload.backup_timestamp = new Date( 166 this.backupServiceState?.backupFileInfo?.date || 0 167 ).getTime(); 168 payload.restore_id = this.backupServiceState?.restoreID; 169 payload.encryption = 170 this.backupServiceState?.backupFileInfo?.isEncrypted; 171 payload.app_name = this.backupServiceState?.backupFileInfo?.appName; 172 payload.version = this.backupServiceState?.backupFileInfo?.appVersion; 173 payload.build_id = this.backupServiceState?.backupFileInfo?.buildID; 174 payload.os_name = this.backupServiceState?.backupFileInfo?.osName; 175 payload.os_version = 176 this.backupServiceState?.backupFileInfo?.osVersion; 177 payload.telemetry_enabled = 178 this.backupServiceState?.backupFileInfo?.healthTelemetryEnabled; 179 } 180 Glean.browserBackup.restoreFileChosen.record(payload); 181 Services.obs.notifyObservers(null, "browser-backup-glean-sent"); 182 }); 183 184 this.getBackupFileInfo(path); 185 } else if (event.type == "BackupUI:StateWasUpdated") { 186 this.#initializedResolvers.resolve(); 187 if (this.#backupFileReadPromise) { 188 this.#backupFileReadPromise.resolve(); 189 this.#backupFileReadPromise = null; 190 } 191 } 192 } 193 194 handleChooseBackupFile() { 195 this.dispatchEvent( 196 new CustomEvent("BackupUI:ShowFilepicker", { 197 bubbles: true, 198 composed: true, 199 detail: { 200 win: window.browsingContext, 201 filter: "filterHTML", 202 existingBackupPath: this.backupServiceState?.backupFileToRestore, 203 }, 204 }) 205 ); 206 } 207 208 getBackupFileInfo(pathToFile = null) { 209 let backupFile = pathToFile || this.backupServiceState?.backupFileToRestore; 210 if (!backupFile) { 211 return; 212 } 213 this.dispatchEvent( 214 new CustomEvent("BackupUI:GetBackupFileInfo", { 215 bubbles: true, 216 composed: true, 217 detail: { 218 backupFile, 219 }, 220 }) 221 ); 222 } 223 224 handleCancel() { 225 this.dispatchEvent( 226 new CustomEvent("dialogCancel", { 227 bubbles: true, 228 composed: true, 229 }) 230 ); 231 } 232 233 handleConfirm() { 234 let backupFile = this.backupServiceState?.backupFileToRestore; 235 if (!backupFile || this.backupServiceState?.recoveryInProgress) { 236 return; 237 } 238 let backupPassword = this.passwordInput?.value; 239 this.dispatchEvent( 240 new CustomEvent("BackupUI:RestoreFromBackupFile", { 241 bubbles: true, 242 composed: true, 243 detail: { 244 backupFile, 245 backupPassword, 246 }, 247 }) 248 ); 249 } 250 251 handleTextareaResize() { 252 this.resizeTextarea(); 253 } 254 255 /** 256 * Resizes the textarea to adjust to the size of the content within 257 */ 258 resizeTextarea() { 259 const target = this.filePicker; 260 if (!target) { 261 return; 262 } 263 264 const hasValue = target.value && !!target.value.trim().length; 265 266 target.style.height = "auto"; 267 if (hasValue) { 268 target.style.height = target.scrollHeight + "px"; 269 } 270 } 271 272 /** 273 * Constructs a support URL with UTM parameters for use 274 * when embedded in about:welcome 275 * 276 * @param {string} supportPage - The support page slug 277 * @returns {string} The full support URL including UTM params 278 */ 279 280 getSupportURLWithUTM(supportPage) { 281 let supportURL = new URL( 282 supportPage, 283 this.backupServiceState.supportBaseLink 284 ); 285 supportURL.searchParams.set("utm_medium", "firefox-desktop"); 286 supportURL.searchParams.set("utm_source", "npo"); 287 supportURL.searchParams.set("utm_campaign", "fx-backup-restore"); 288 supportURL.searchParams.set("utm_content", "restore-error"); 289 return supportURL.href; 290 } 291 292 /** 293 * Returns a support link anchor element, either with UTM params for use in 294 * about:welcome, or falling back to moz-support-link otherwise 295 * 296 * @param {object} options - Link configuration options 297 * @param {string} options.id - The element id 298 * @param {string} options.l10nId - The fluent l10n id 299 * @param {string} options.l10nName - The fluent l10n name 300 * @param {string} options.supportPage - The support page slug 301 * @returns {TemplateResult} The link template 302 */ 303 304 getSupportLinkAnchor({ 305 id, 306 l10nId, 307 l10nName, 308 supportPage = "firefox-backup", 309 }) { 310 if (this.aboutWelcomeEmbedded) { 311 return html`<a 312 id=${id} 313 target="_blank" 314 href=${this.getSupportURLWithUTM(supportPage)} 315 data-l10n-id=${ifDefined(l10nId)} 316 data-l10n-name=${ifDefined(l10nName)} 317 dir="auto" 318 rel="noopener noreferrer" 319 ></a>`; 320 } 321 322 return html`<a 323 id=${id} 324 slot="support-link" 325 is="moz-support-link" 326 support-page=${supportPage} 327 data-l10n-id=${ifDefined(l10nId)} 328 data-l10n-name=${ifDefined(l10nName)} 329 dir="auto" 330 ></a>`; 331 } 332 333 applyContentCustomizations() { 334 if (this.aboutWelcomeEmbedded) { 335 this.style.setProperty( 336 "--label-font-weight", 337 "var(--font-weight-semibold)" 338 ); 339 } 340 } 341 342 renderBackupFileInfo(backupFileInfo) { 343 return html`<p 344 id="restore-from-backup-backup-found-info" 345 data-l10n-id="backup-file-creation-date-and-device" 346 data-l10n-args=${JSON.stringify({ 347 machineName: backupFileInfo.deviceName ?? "", 348 date: backupFileInfo.date ? new Date(backupFileInfo.date).getTime() : 0, 349 })} 350 ></p>`; 351 } 352 353 renderBackupFileStatus() { 354 const { backupFileInfo, recoveryErrorCode } = this.backupServiceState || {}; 355 356 // We have errors and are embedded in about:welcome 357 if ( 358 recoveryErrorCode && 359 !this.isIncorrectPassword && 360 this.aboutWelcomeEmbedded 361 ) { 362 return this.genericFileErrorTemplate(); 363 } 364 365 // No backup file selected 366 if (!backupFileInfo) { 367 return this.getSupportLinkAnchor({ 368 id: "restore-from-backup-no-backup-file-link", 369 l10nId: "restore-from-backup-no-backup-file-link", 370 }); 371 } 372 373 // Backup file found and no error 374 return this.renderBackupFileInfo(backupFileInfo); 375 } 376 377 controlsTemplate() { 378 let iconURL = this.#placeholderFileIconURL; 379 if ( 380 this.backupServiceState?.backupFileToRestore && 381 !this.aboutWelcomeEmbedded 382 ) { 383 iconURL = this._fileIconURL || this.#placeholderFileIconURL; 384 } 385 return html` 386 <fieldset id="backup-restore-controls"> 387 <fieldset id="backup-filepicker-controls"> 388 <label 389 id="backup-filepicker-label" 390 for="backup-filepicker-input" 391 data-l10n-id="restore-from-backup-filepicker-label" 392 ></label> 393 <div id="backup-filepicker"> 394 ${this.inputTemplate(iconURL)} 395 <moz-button 396 id="backup-filepicker-button" 397 @click=${this.handleChooseBackupFile} 398 data-l10n-id="restore-from-backup-file-choose-button" 399 aria-controls="backup-filepicker-input" 400 ></moz-button> 401 </div> 402 403 ${this.renderBackupFileStatus()} 404 </fieldset> 405 406 <fieldset id="password-entry-controls"> 407 ${this.backupServiceState?.backupFileInfo?.isEncrypted 408 ? this.passwordEntryTemplate() 409 : null} 410 </fieldset> 411 </fieldset> 412 `; 413 } 414 415 inputTemplate(iconURL) { 416 const styles = styleMap( 417 iconURL ? { backgroundImage: `url(${iconURL})` } : {} 418 ); 419 const backupFileName = this.backupServiceState?.backupFileToRestore || ""; 420 421 // Determine the ID of the element that will be rendered by renderBackupFileStatus() 422 // to reference with aria-describedby 423 let describedBy = ""; 424 const { backupFileInfo, recoveryErrorCode } = this.backupServiceState || {}; 425 426 if (this.aboutWelcomeEmbedded) { 427 if (recoveryErrorCode && !this.isIncorrectPassword) { 428 describedBy = "backup-generic-file-error"; 429 } else if (!backupFileInfo) { 430 describedBy = "restore-from-backup-no-backup-file-link"; 431 } else { 432 describedBy = "restore-from-backup-backup-found-info"; 433 } 434 } 435 436 if (this.aboutWelcomeEmbedded) { 437 return html` 438 <textarea 439 id="backup-filepicker-input" 440 rows="1" 441 readonly 442 .value=${backupFileName} 443 style=${styles} 444 @input=${this.handleTextareaResize} 445 aria-describedby=${describedBy} 446 data-l10n-id="restore-from-backup-filepicker-input" 447 ></textarea> 448 `; 449 } 450 451 return html` 452 <input 453 id="backup-filepicker-input" 454 type="text" 455 readonly 456 .value=${backupFileName} 457 style=${styles} 458 data-l10n-id="restore-from-backup-filepicker-input" 459 /> 460 `; 461 } 462 463 passwordEntryTemplate() { 464 const isInvalid = this.isIncorrectPassword; 465 const describedBy = isInvalid 466 ? "backup-password-error" 467 : "backup-password-description"; 468 469 return html` <fieldset id="backup-password"> 470 <label id="backup-password-label" for="backup-password-input"> 471 <span 472 id="backup-password-span" 473 data-l10n-id="restore-from-backup-password-label" 474 ></span> 475 <input 476 type="password" 477 id="backup-password-input" 478 aria-invalid=${String(isInvalid)} 479 aria-describedby=${describedBy} 480 /> 481 </label> 482 ${isInvalid 483 ? html` 484 <span 485 id="backup-password-error" 486 class="field-error" 487 data-l10n-id="backup-service-error-incorrect-password" 488 > 489 ${this.getSupportLinkAnchor({ 490 id: "backup-incorrect-password-support-link", 491 l10nName: "incorrect-password-support-link", 492 })} 493 </span> 494 ` 495 : html`<label 496 id="backup-password-description" 497 data-l10n-id="restore-from-backup-password-description" 498 ></label> `} 499 </fieldset>`; 500 } 501 502 contentTemplate() { 503 let buttonL10nId = !this.backupServiceState?.recoveryInProgress 504 ? "restore-from-backup-confirm-button" 505 : "restore-from-backup-restoring-button"; 506 507 return html` 508 <div 509 id="restore-from-backup-wrapper" 510 aria-labelledby="restore-from-backup-header" 511 aria-describedby="restore-from-backup-description" 512 > 513 ${this.aboutWelcomeEmbedded ? null : this.headerTemplate()} 514 <main id="restore-from-backup-content"> 515 ${!this.aboutWelcomeEmbedded && 516 this.backupServiceState?.recoveryErrorCode 517 ? this.errorTemplate() 518 : null} 519 ${!this.aboutWelcomeEmbedded && 520 this.backupServiceState?.backupFileInfo 521 ? this.descriptionTemplate() 522 : null} 523 ${this.controlsTemplate()} 524 </main> 525 526 <moz-button-group id="restore-from-backup-button-group"> 527 ${this.aboutWelcomeEmbedded ? null : this.cancelButtonTemplate()} 528 <moz-button 529 id="restore-from-backup-confirm-button" 530 @click=${this.handleConfirm} 531 type="primary" 532 data-l10n-id=${buttonL10nId} 533 ?disabled=${!this.backupServiceState?.backupFileToRestore || 534 this.backupServiceState?.recoveryInProgress} 535 ></moz-button> 536 </moz-button-group> 537 </div> 538 `; 539 } 540 541 headerTemplate() { 542 return html` 543 <h1 544 id="restore-from-backup-header" 545 class="heading-medium" 546 data-l10n-id="restore-from-backup-header" 547 ></h1> 548 `; 549 } 550 551 cancelButtonTemplate() { 552 return html` 553 <moz-button 554 id="restore-from-backup-cancel-button" 555 @click=${this.handleCancel} 556 data-l10n-id="restore-from-backup-cancel-button" 557 ></moz-button> 558 `; 559 } 560 561 descriptionTemplate() { 562 let { date } = this.backupServiceState?.backupFileInfo || {}; 563 let dateTime = date && new Date(date).getTime(); 564 return html` 565 <moz-message-bar 566 id="restore-from-backup-description" 567 type="info" 568 data-l10n-id="restore-from-backup-description-with-metadata" 569 data-l10n-args=${JSON.stringify({ 570 date: dateTime, 571 })} 572 > 573 <a 574 id="restore-from-backup-learn-more-link" 575 slot="support-link" 576 is="moz-support-link" 577 support-page="firefox-backup" 578 data-l10n-id="restore-from-backup-support-link" 579 ></a> 580 </moz-message-bar> 581 `; 582 } 583 584 errorTemplate() { 585 // We handle incorrect password errors in the password input 586 if (this.isIncorrectPassword) { 587 return null; 588 } 589 590 return html` 591 <moz-message-bar 592 id="restore-from-backup-error" 593 type="error" 594 data-l10n-id=${getErrorL10nId( 595 this.backupServiceState?.recoveryErrorCode 596 )} 597 > 598 </moz-message-bar> 599 `; 600 } 601 602 genericFileErrorTemplate() { 603 // We handle incorrect password errors in the password input 604 if (this.isIncorrectPassword) { 605 return null; 606 } 607 608 return html` 609 <span 610 id="backup-generic-file-error" 611 class="field-error" 612 data-l10n-id="backup-file-restore-file-validation-error" 613 > 614 <a 615 id="backup-generic-error-link" 616 target="_blank" 617 slot="support-link" 618 data-l10n-name="restore-problems" 619 href=${this.getSupportURLWithUTM("firefox-backup")} 620 rel="noopener noreferrer" 621 ></a> 622 </span> 623 `; 624 } 625 626 render() { 627 this.applyContentCustomizations(); 628 return html` 629 <link 630 rel="stylesheet" 631 href="chrome://browser/content/backup/restore-from-backup.css" 632 /> 633 ${this.contentTemplate()} 634 `; 635 } 636 } 637 638 customElements.define("restore-from-backup", RestoreFromBackup);