chromium/chrome/browser/resources/chromeos/crostini_upgrader/app.js

// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_checkbox/cr_checkbox.js';
import 'chrome://resources/ash/common/cr_elements/cr_shared_vars.css.js';
import 'chrome://resources/polymer/v3_0/paper-progress/paper-progress.js';
import './strings.m.js';

import {assert, assertNotReached} from 'chrome://resources/ash/common/assert.js';
import {loadTimeData} from 'chrome://resources/ash/common/load_time_data.m.js';
import {sanitizeInnerHtml} from 'chrome://resources/js/parse_html_subset.js';
import {Polymer} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './app.html.js';
import {BrowserProxy} from './browser_proxy.js';

/**
 * Enum for the state of `crostini-upgrader-app`.
 * @enum {string}
 */
const State = {
  PROMPT: 'prompt',
  BACKUP: 'backup',
  BACKUP_ERROR: 'backupError',
  BACKUP_SUCCEEDED: 'backupSucceeded',
  PRECHECKS_FAILED: 'prechecksFailed',
  UPGRADING: 'upgrading',
  UPGRADE_ERROR: 'upgrade_error',
  OFFER_RESTORE: 'offerRestore',
  RESTORE: 'restore',
  RESTORE_ERROR: 'restoreError',
  RESTORE_SUCCEEDED: 'restoreSucceeded',
  CANCELING: 'canceling',
  SUCCEEDED: 'succeeded',
};

const kMaxUpgradeAttempts = 3;


Polymer({
  is: 'crostini-upgrader-app',

  _template: getTemplate(),

  properties: {
    /** @private {State} */
    state_: {
      type: String,
      value: State.PROMPT,
    },

    /** @private */
    backupCheckboxChecked_: {
      type: Boolean,
      value: true,
    },

    /** @private */
    backupProgress_: {
      type: Number,
    },

    /** @private */
    upgradeProgress_: {
      type: Number,
      value: 0,
    },

    /** @private */
    restoreProgress_: {
      type: Number,
    },

    /** @private */
    progressMessages_: {
      type: Array,
    },

    /** @private */
    progressLineNumber_: {
      type: Number,
      value: 0,
    },

    /** @private */
    lastProgressLine_: {
      type: String,
      value: '',
    },

    /** @private */
    progressLineDisplayMs_: {
      type: Number,
      value: 300,
    },

    /** @private */
    upgradeAttemptCount_: {
      type: Number,
      value: 0,
    },

    /** @private */
    logFileName_: {
      type: String,
      value: '',
    },

    /** @private */
    precheckStatus_: {
      type: Number,
      value: ash.crostiniUpgrader.mojom.UpgradePrecheckStatus.OK,
    },

    /**
     * Enable the html template to use State.
     * @private
     */
    State: {
      type: Object,
      value: State,
    },
  },

  /** @override */
  created() {
    // Must be set here rather then in the defaults above because arrays are
    // mutable objects and every instance of the element needs its own array.
    this.progressMessages_ = [];
  },

  /** @override */
  attached() {
    const callbackRouter = BrowserProxy.getInstance().callbackRouter;

    this.listenerIds_ = [
      callbackRouter.onBackupProgress.addListener((percent) => {
        this.state_ = State.BACKUP;
        this.backupProgress_ = percent;
      }),
      callbackRouter.onBackupSucceeded.addListener((wasCancelled) => {
        assert(this.state_ === State.BACKUP);
        this.state_ = State.BACKUP_SUCCEEDED;
        // We do a short (2 second) interstitial display of the backup success
        // message before continuing the upgrade.
        const timeout = new Promise((resolve, reject) => {
          setTimeout(resolve, wasCancelled ? 0 : 2000);
        });
        // We also want to wait for the prechecks to finish.
        const callback = new Promise((resolve, reject) => {
          this.startPrechecks_(resolve, reject);
        });
        Promise.all([timeout, callback]).then(() => {
          this.startUpgrade_();
        });
      }),
      callbackRouter.onBackupFailed.addListener(() => {
        assert(this.state_ === State.BACKUP);
        this.state_ = State.BACKUP_ERROR;
      }),
      callbackRouter.precheckStatus.addListener((status) => {
        if (status === ash.crostiniUpgrader.mojom.UpgradePrecheckStatus.OK) {
          this.precheckSuccessCallback_();
          this.precheckStatus_ = status;
        } else {
          this.precheckStatus_ = status;
          this.state_ = State.PRECHECKS_FAILED;
          this.precheckFailureCallback_();
        }
      }),
      callbackRouter.onUpgradeProgress.addListener((progressMessages) => {
        assert(this.state_ === State.UPGRADING);
        this.progressMessages_.push(...progressMessages);
        this.upgradeProgress_ = this.progressMessages_.length;

        if (this.progressLineNumber_ < this.upgradeProgress_) {
          this.updateProgressLine_();
        }
      }),
      callbackRouter.onUpgradeSucceeded.addListener(() => {
        assert(this.state_ === State.UPGRADING);
        this.state_ = State.SUCCEEDED;
      }),
      callbackRouter.onUpgradeFailed.addListener(() => {
        assert(this.state_ === State.UPGRADING);
        if (this.upgradeAttemptCount_ < kMaxUpgradeAttempts) {
          this.precheckThenUpgrade_();
          return;
        }
        if (this.backupCheckboxChecked_) {
          this.state_ = State.OFFER_RESTORE;
        } else {
          this.state_ = State.UPGRADE_ERROR;
        }
      }),
      callbackRouter.onRestoreProgress.addListener((percent) => {
        assert(this.state_ === State.RESTORE);
        this.restoreProgress_ = percent;
      }),
      callbackRouter.onRestoreSucceeded.addListener(() => {
        assert(this.state_ === State.RESTORE);
        this.state_ = State.RESTORE_SUCCEEDED;
      }),
      callbackRouter.onRestoreFailed.addListener(() => {
        assert(this.state_ === State.RESTORE);
        this.state_ = State.RESTORE_ERROR;
      }),
      callbackRouter.onCanceled.addListener(() => {
        if (this.state_ === State.RESTORE) {
          this.state_ = State.RESTORE_ERROR;
          return;
        }
        this.closePage_();
      }),
      callbackRouter.requestClose.addListener(() => {
        if (this.canCancel_(this.state_)) {
          this.onCancelButtonClick_();
        }
      }),
      callbackRouter.onLogFileCreated.addListener((path) => {
        this.logFileName_ = path;
      }),
    ];

    document.addEventListener('keyup', event => {
      if (event.key === 'Escape' && this.canCancel_(this.state_)) {
        this.onCancelButtonClick_();
        event.preventDefault();
      }
    });

    this.$$('.action-button').focus();
  },

  /** @override */
  detached() {
    const callbackRouter = BrowserProxy.getInstance().callbackRouter;
    this.listenerIds_.forEach(id => callbackRouter.removeListener(id));
  },

  /** @private */
  precheckThenUpgrade_() {
    this.startPrechecks_(() => {
      this.startUpgrade_();
    }, () => {});
  },

  /** @private */
  onActionButtonClick_() {
    switch (this.state_) {
      case State.SUCCEEDED:
        BrowserProxy.getInstance().handler.launch();
        this.closePage_();
        break;
      case State.PRECHECKS_FAILED:
        this.precheckThenUpgrade_();
        break;
      case State.PROMPT:
        if (this.backupCheckboxChecked_) {
          this.startBackup_(/*showFileChooser=*/ false);
        } else {
          this.precheckThenUpgrade_();
        }
        break;
      case State.OFFER_RESTORE:
        this.startRestore_();
        break;
      default:
        assertNotReached();
    }
  },

  /** @private */
  onCancelButtonClick_() {
    switch (this.state_) {
      case State.PROMPT:
        BrowserProxy.getInstance().handler.cancelBeforeStart();
        break;
      case State.UPGRADING:
        this.state_ = State.CANCELING;
        BrowserProxy.getInstance().handler.cancel();
        break;
      case State.RESTORE_SUCCEEDED:
        BrowserProxy.getInstance().handler.launch();
        this.closePage_();
        break;
      case State.PRECHECKS_FAILED:
      case State.BACKUP_ERROR:
      case State.UPGRADE_ERROR:
      case State.RESTORE_ERROR:
      case State.OFFER_RESTORE:
      case State.SUCCEEDED:
        this.closePage_();
        break;
      case State.CANCELING:
        break;
      default:
        assertNotReached();
    }
  },

  /** @private */
  onChangeLocationButtonClick_() {
    this.startBackup_(/*showFileChooser=*/ true);
  },

  /**
   * @param {boolean} showFileChooser
   * @private
   */
  startBackup_(showFileChooser) {
    BrowserProxy.getInstance().handler.backup(showFileChooser);
  },

  /** @private */
  startPrechecks_(success, failure) {
    this.precheckSuccessCallback_ = success;
    this.precheckFailureCallback_ = failure;
    BrowserProxy.getInstance().handler.startPrechecks();
  },

  /** @private */
  startUpgrade_() {
    this.state_ = State.UPGRADING;
    this.upgradeAttemptCount_++;
    BrowserProxy.getInstance().handler.upgrade();
  },

  /** @private */
  startRestore_() {
    this.state_ = State.RESTORE;
    BrowserProxy.getInstance().handler.restore();
  },

  /** @private */
  closePage_() {
    BrowserProxy.getInstance().handler.onPageClosed();
  },

  /**
   * @param {State} state1
   * @param {State} state2
   * @return {boolean}
   * @private
   */
  isState_(state1, state2) {
    return state1 === state2;
  },

  isErrorLogsHidden_(state) {
    return !(
        this.isState_(this.state_, State.UPGRADE_ERROR) ||
        this.isState_(this.state_, State.OFFER_RESTORE));
  },

  /**
   * @param {State} state
   * @return {boolean}
   * @private
   */
  canDoAction_(state) {
    switch (state) {
      case State.PROMPT:
      case State.PRECHECKS_FAILED:
      case State.SUCCEEDED:
      case State.OFFER_RESTORE:
        return true;
    }
    return false;
  },

  /**
   * @param {State} state
   * @return {boolean}
   * @private
   */
  canCancel_(state) {
    switch (state) {
      case State.BACKUP:
      case State.RESTORE:
      case State.BACKUP_SUCCEEDED:
      case State.CANCELING:
      case State.SUCCEEDED:
        return false;
    }
    return true;
  },

  /**
   * @return {string}
   * @private
   */
  getTitle_() {
    let titleId;
    switch (this.state_) {
      case State.PROMPT:
        titleId = 'promptTitle';
        break;
      case State.BACKUP:
        titleId = 'backingUpTitle';
        break;
      case State.BACKUP_ERROR:
        titleId = 'backupErrorTitle';
        break;
      case State.BACKUP_SUCCEEDED:
        titleId = 'backupSucceededTitle';
        break;
      case State.PRECHECKS_FAILED:
        titleId = 'prechecksFailedTitle';
        break;
      case State.UPGRADING:
        titleId = 'upgradingTitle';
        break;
      case State.OFFER_RESTORE:
      case State.UPGRADE_ERROR:
        titleId = 'errorTitle';
        break;
      case State.RESTORE:
        titleId = 'restoreTitle';
        break;
      case State.RESTORE_ERROR:
        titleId = 'restoreErrorTitle';
        break;
      case State.RESTORE_SUCCEEDED:
        titleId = 'restoreSucceededTitle';
        break;
      case State.CANCELING:
        titleId = 'cancelingTitle';
        break;
      case State.SUCCEEDED:
        titleId = 'succeededTitle';
        break;
      default:
        assertNotReached();
    }
    return loadTimeData.getString(/** @type {string} */ (titleId));
  },

  /**
   * @param {State} state
   * @return {string}
   * @private
   */
  getActionButtonLabel_(state) {
    switch (state) {
      case State.PROMPT:
        return loadTimeData.getString('upgrade');
      case State.PRECHECKS_FAILED:
        return loadTimeData.getString('retry');
      case State.SUCCEEDED:
        return loadTimeData.getString('done');
      case State.OFFER_RESTORE:
        return loadTimeData.getString('restore');
    }
    return '';
  },

  /**
   * @param {State} state
   * @return {string}
   * @private
   */
  getCancelButtonLabel_(state) {
    switch (state) {
      case State.RESTORE_SUCCEEDED:
      case State.BACKUP_ERROR:
      case State.UPGRADE_ERROR:
      case State.RESTORE_ERROR:
        return loadTimeData.getString('close');
      case State.PROMPT:
        return loadTimeData.getString('notNow');
      default:
        return loadTimeData.getString('cancel');
    }
  },

  /**
   * @param {State} state
   * @return {TrustedHTML}
   * @private
   */
  getProgressMessage_(state, precheckStatus, file_name) {
    let messageId = null;
    switch (state) {
      case State.PROMPT:
        messageId = 'promptMessage';
        break;
      case State.BACKUP:
        messageId = 'backingUpMessage';
        break;
      case State.BACKUP_ERROR:
        messageId = 'backupErrorMessage';
        break;
      case State.PRECHECKS_FAILED:
        switch (precheckStatus) {
          case ash.crostiniUpgrader.mojom.UpgradePrecheckStatus.NETWORK_FAILURE:
            messageId = 'precheckNoNetwork';
            break;
          case ash.crostiniUpgrader.mojom.UpgradePrecheckStatus.LOW_POWER:
            messageId = 'precheckNoPower';
            break;
          default:
            assertNotReached();
        }
        break;
      case State.UPGRADING:
        messageId = 'upgradingMessage';
        break;
      case State.RESTORE:
        messageId = 'restoreMessage';
        break;
      case State.RESTORE_ERROR:
        messageId = 'restoreErrorMessage';
        break;
      case State.SUCCEEDED:
        return sanitizeInnerHtml(
            loadTimeData.getStringF('logFileMessageSuccess', file_name));
        break;
      case State.UPGRADE_ERROR:
      case State.OFFER_RESTORE:
        return sanitizeInnerHtml(
            loadTimeData.getStringF('logFileMessageError', file_name));
        break;
    }
    return messageId ? sanitizeInnerHtml(loadTimeData.getString(messageId)) :
                       trustedTypes.emptyHTML;
  },

  /**
   * @param {State} state
   * @return {string}
   * @private
   */
  getErrorLogs_(state) {
    return this.progressMessages_.join('\n');
  },

  /**
   * @param {State} state
   * @return {string}
   * @private
   */
  getIllustrationStyle_(state) {
    switch (state) {
      case State.BACKUP_ERROR:
      case State.BACKUP_SUCCEEDED:
      case State.RESTORE_ERROR:
      case State.RESTORE_SUCCEEDED:
      case State.PRECHECKS_FAILED:
        return 'img-square-illustration';
    }
    return 'img-rect-illustration';
  },

  /**
   * @param {State} state
   * @return {string}
   * @private
   */
  getIllustrationURI_(state) {
    switch (state) {
      case State.BACKUP_SUCCEEDED:
      case State.RESTORE_SUCCEEDED:
        return 'images/success_illustration.svg';
      case State.PRECHECKS_FAILED:
      case State.BACKUP_ERROR:
      case State.RESTORE_ERROR:
        return 'images/error_illustration.png';
    }
    return 'images/linux_illustration.png';
  },

  /**
   * @param {State} state
   * @return {boolean}
   * @private
   */
  hideIllustration_(state) {
    switch (state) {
      case State.OFFER_RESTORE:
      case State.UPGRADE_ERROR:
        return true;
    }
    return false;
  },

  /** @private */
  updateProgressLine_() {
    if (this.progressLineNumber_ < this.upgradeProgress_) {
      this.lastProgressLine_ =
          this.progressMessages_[this.progressLineNumber_++];
      const t = setTimeout(
          this.updateProgressLine_.bind(this), this.progressLineDisplayMs_);
    }
  },
});