/* 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 { html, styleMap } from "chrome://global/content/vendor/lit.all.mjs"; import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; import { ERRORS } from "chrome://browser/content/backup/backup-constants.mjs"; import { getErrorL10nId } from "chrome://browser/content/backup/backup-errors.mjs"; // eslint-disable-next-line import/no-unassigned-import import "chrome://global/content/elements/moz-message-bar.mjs"; /** * The widget for allowing users to select and restore from a * a backup file. */ export default class RestoreFromBackup extends MozLitElement { #placeholderFileIconURL = "chrome://global/skin/icons/page-portrait.svg"; /** * When the user clicks the button to choose a backup file to restore, we send * a message to the `BackupService` process asking it to read that file. * When we do this, we set this property to be a promise, which we resolve * when the file reading is complete. */ #backupFileReadPromise = null; /** * Resolves when BackupUIParent sends state for the first time. */ get initializedPromise() { return this.#initializedResolvers.promise; } #initializedResolvers = Promise.withResolvers(); /** * It's possible if the user selected an invalid backup file that there is a * filename but no info. To prevent that case from repeatedly asking for the * 'missing' info, this tracks the previous filename we asked for. */ #lastBackupInfoFilename = null; static properties = { _fileIconURL: { type: String }, _restoreType: { type: String }, aboutWelcomeEmbedded: { type: Boolean }, backupServiceState: { type: Object }, }; static get queries() { return { filePicker: "#backup-filepicker-input", passwordInput: "#backup-password-input", cancelButtonEl: "#restore-from-backup-cancel-button", confirmButtonEl: "#restore-from-backup-confirm-button", chooseButtonEl: "#backup-filepicker-button", errorMessageEl: "#restore-from-backup-error", }; } get isIncorrectPassword() { return this.backupServiceState?.recoveryErrorCode === ERRORS.UNAUTHORIZED; } get isFileError() { const code = this.backupServiceState?.recoveryErrorCode; return ( code === ERRORS.CORRUPTED_ARCHIVE || code === ERRORS.UNSUPPORTED_BACKUP_VERSION || code === ERRORS.UNSUPPORTED_APPLICATION ); } constructor() { super(); this._fileIconURL = ""; this._restoreType = "add"; // Set the default state this.backupServiceState = { backupDirPath: "", backupFileToRestore: null, backupFileInfo: null, defaultParent: { fileName: "", path: "", iconURL: "", }, encryptionEnabled: false, scheduledBackupsEnabled: false, selectableProfilesAllowed: false, lastBackupDate: null, lastBackupFileName: "", supportBaseLink: "https://support.mozilla.org/", backupInProgress: false, recoveryInProgress: false, recoveryErrorCode: ERRORS.NONE, }; } /** * Dispatches the BackupUI:InitWidget custom event upon being attached to the * DOM, which registers with BackupUIChild for BackupService state updates. */ connectedCallback() { super.connectedCallback(); this.dispatchEvent( new CustomEvent("BackupUI:InitWidget", { bubbles: true }) ); // If we have a backup file, but not the associated info, fetch the info this.maybeGetBackupFileInfo(); this.addEventListener("BackupUI:SelectNewFilepickerPath", this); this.addEventListener("BackupUI:StateWasUpdated", this); // Resize the textarea when the window is resized this._handleWindowResize = () => this.resizeTextarea(); window.addEventListener("resize", this._handleWindowResize); } maybeGetBackupFileInfo() { if ( this.backupServiceState?.backupFileToRestore && !this.backupServiceState?.backupFileInfo ) { this.getBackupFileInfo(); } } disconnectedCallback() { super.disconnectedCallback(); if (this._handleWindowResize) { window.removeEventListener("resize", this._handleWindowResize); this._handleWindowResize = null; } } updated(changedProperties) { super.updated(changedProperties); // Resize the textarea. This only runs once on initial render, // and once each time one of our reactive properties is changed. this.resizeTextarea(); if (changedProperties.has("backupServiceState")) { // If we got a recovery error, recoveryInProgress should be false const inProgress = this.backupServiceState.recoveryInProgress && !this.backupServiceState.recoveryErrorCode; this.dispatchEvent( new CustomEvent("BackupUI:RecoveryProgress", { bubbles: true, composed: true, detail: { recoveryInProgress: inProgress }, }) ); // It's possible that backupFileToRestore got updated and we need to // refetch the fileInfo this.maybeGetBackupFileInfo(); } } handleEvent(event) { if (event.type == "BackupUI:SelectNewFilepickerPath") { let { path, iconURL } = event.detail; this._fileIconURL = iconURL; // Check the backup info again even if it was the same file. this.#lastBackupInfoFilename = null; this.#backupFileReadPromise = Promise.withResolvers(); this.#backupFileReadPromise.promise.then(() => { const payload = { location: this.backupServiceState?.backupFileCoarseLocation, valid: this.backupServiceState?.recoveryErrorCode == ERRORS.NONE, }; if (payload.valid) { payload.backup_timestamp = new Date( this.backupServiceState?.backupFileInfo?.date || 0 ).getTime(); payload.restore_id = this.backupServiceState?.restoreID; payload.encryption = this.backupServiceState?.backupFileInfo?.isEncrypted; payload.app_name = this.backupServiceState?.backupFileInfo?.appName; payload.version = this.backupServiceState?.backupFileInfo?.appVersion; payload.build_id = this.backupServiceState?.backupFileInfo?.buildID; payload.os_name = this.backupServiceState?.backupFileInfo?.osName; payload.os_version = this.backupServiceState?.backupFileInfo?.osVersion; payload.telemetry_enabled = this.backupServiceState?.backupFileInfo?.healthTelemetryEnabled; } Glean.browserBackup.restoreFileChosen.record(payload); Services.obs.notifyObservers(null, "browser-backup-glean-sent"); }); this.getBackupFileInfo(path); } else if (event.type == "BackupUI:StateWasUpdated") { this.#initializedResolvers.resolve(); if (this.#backupFileReadPromise) { this.#backupFileReadPromise.resolve(); this.#backupFileReadPromise = null; } } } handleRestoreTypeChange(event) { this._restoreType = event.target.value; } handleChooseBackupFile() { this.dispatchEvent( new CustomEvent("BackupUI:ShowFilepicker", { bubbles: true, composed: true, detail: { win: window.browsingContext, filter: "filterHTML", existingBackupPath: this.backupServiceState?.backupFileToRestore, }, }) ); } getBackupFileInfo(pathToFile = null) { let backupFile = pathToFile || this.backupServiceState?.backupFileToRestore; if (!backupFile || this.#lastBackupInfoFilename === backupFile) { return; } this.#lastBackupInfoFilename = backupFile; this.dispatchEvent( new CustomEvent("BackupUI:GetBackupFileInfo", { bubbles: true, composed: true, detail: { backupFile, }, }) ); } handleCancel() { this.dispatchEvent( new CustomEvent("dialogCancel", { bubbles: true, composed: true, }) ); } handleConfirm() { let backupFile = this.backupServiceState?.backupFileToRestore; if (!backupFile || this.backupServiceState?.recoveryInProgress) { return; } let backupPassword = this.passwordInput?.value; this.dispatchEvent( new CustomEvent("BackupUI:RestoreFromBackupFile", { bubbles: true, composed: true, detail: { backupFile, backupPassword, restoreType: this._restoreType, }, }) ); } handleTextareaResize() { this.resizeTextarea(); } /** * Resizes the textarea to adjust to the size of the content within */ resizeTextarea() { const target = this.filePicker; if (!target) { return; } const hasValue = target.value && !!target.value.trim().length; target.style.height = "auto"; if (hasValue) { target.style.height = target.scrollHeight + "px"; } } /** * Constructs a support URL with UTM parameters for use * when embedded in about:welcome * * @param {string} supportPage - The support page slug * @returns {string} The full support URL including UTM params */ getSupportURLWithUTM(supportPage) { let supportURL = new URL( supportPage, this.backupServiceState.supportBaseLink ); supportURL.searchParams.set("utm_medium", "firefox-desktop"); supportURL.searchParams.set("utm_source", "npo"); supportURL.searchParams.set("utm_campaign", "fx-backup-restore"); supportURL.searchParams.set("utm_content", "restore-error"); return supportURL.href; } applyContentCustomizations() { if (this.aboutWelcomeEmbedded) { this.style.setProperty( "--label-font-weight", "var(--font-weight-semibold)" ); } } renderBackupFileInfo(backupFileInfo) { return html`
`; } renderBackupFileStatus() { const { backupFileInfo, recoveryErrorCode } = this.backupServiceState || {}; if ( recoveryErrorCode && !this.isIncorrectPassword && (this.isFileError || this.aboutWelcomeEmbedded) ) { return this.genericFileErrorTemplate(); } if (!backupFileInfo) { return null; } // Backup file found and no error return this.renderBackupFileInfo(backupFileInfo); } controlsTemplate() { let iconURL = this.#placeholderFileIconURL; if ( this.backupServiceState?.backupFileToRestore && !this.aboutWelcomeEmbedded ) { iconURL = this._fileIconURL || this.#placeholderFileIconURL; } return html` `; } inputTemplate(iconURL) { const styles = styleMap( iconURL ? { backgroundImage: `url(${iconURL})` } : {} ); const backupFileName = this.backupServiceState?.backupFileToRestore || ""; const { backupFileInfo, recoveryErrorCode } = this.backupServiceState || {}; const hasInlineFileError = recoveryErrorCode && !this.isIncorrectPassword && (this.isFileError || this.aboutWelcomeEmbedded); let describedBy = ""; if (hasInlineFileError) { describedBy = "backup-generic-file-error"; } else if (!backupFileInfo) { describedBy = "restore-from-backup-no-backup-file-link"; } else { describedBy = "restore-from-backup-backup-found-info"; } return html` `; } passwordEntryTemplate() { const isInvalid = this.isIncorrectPassword; const describedBy = isInvalid ? "backup-password-error" : "backup-password-description"; let passwordStatus; if (isInvalid && this.aboutWelcomeEmbedded) { passwordStatus = html` `; } else if (isInvalid) { passwordStatus = html` `; } else { passwordStatus = html``; } return html`