cozy-labs/cozy-desktop

View on GitHub
gui/js/updater.window.js

Summary

Maintainability
A
2 hrs
Test Coverage
/* @flow */

const Promise = require('bluebird')
const WindowManager = require('./window_manager')
const { autoUpdater } = require('electron-updater')
const { translate } = require('./i18n')
const { dialog } = require('electron')
const path = require('path')
const { enable: enableRemoteModule } = require('@electron/remote/main')

/*::
import type { App as ElectronApp } from 'electron'
import type { App as CoreApp } from '../../core/app'
*/

const log = require('../../core/app').logger({
  component: 'GUI:autoupdater'
})

/** The delay starting from the update info request after which it is skipped.
 *
 * Long enough so users with slow connection have chances to start downloading
 * the update before it is skipped (5s was tried but didn't seem sufficient in
 * some cases).
 *
 * App startup could be slower in case GitHub is down exactly at the same
 * time. But the delay still seems acceptable  for an app starting
 * automatically on boot. Even in case it is started by hand.
 *
 * Except for downtimes, users with fast connection should still get a fast
 * available or unavailable update answer anyway.
 */
const UPDATE_CHECK_TIMEOUT = 10000
const UPDATE_RETRY_DELAY = 1000
const UPDATE_RETRIES = 5

module.exports = class UpdaterWM extends WindowManager {
  /*::
  retriesLeft: number
  */

  windowOptions() {
    return {
      title: 'UPDATER',
      width: 500,
      height: 400
    }
  }

  create() {
    super.create()

    enableRemoteModule(this.win.webContents)
  }

  humanError(err /*: ErrnoError */) {
    switch (err.code) {
      case 'EPERM':
        return translate('Updater Error EPERM')
      case 'ENOSP':
        return translate('Updater Error ENOSPC')
      default:
        return translate('Updater Error Other')
    }
  }

  constructor(...opts /*: { app: ElectronApp, desktop: CoreApp } */) {
    super(...opts)

    autoUpdater.logger = log
    autoUpdater.autoDownload = false
    autoUpdater.on('update-available', info => {
      this.clearTimeoutIfAny()
      log.info({ update: info, skipped: this.skipped }, 'Update available')
      // Make sure UI doesn't show up after timeout
      if (!this.skipped) {
        const shouldUpdate =
          dialog.showMessageBoxSync({
            icon: path.resolve(__dirname, '..', 'images', 'icon.png'),
            title: 'Cozy Drive',
            message: 'Cozy Drive',
            detail: translate(
              'A new version is available, do you want to update?'
            ),
            type: 'question',
            buttons: ['Update', 'Cancel'].map(translate)
          }) === 0

        if (shouldUpdate) {
          autoUpdater.downloadUpdate()
          this.show()
        } else {
          this.skipUpdate('refused update')
        }
      }
    })
    autoUpdater.on('update-not-available', info => {
      log.info({ update: info }, 'No update available')
      this.afterUpToDate()
    })
    autoUpdater.on('error', async err => {
      if (this.skipped) {
        return
      } else if (err.code === 'ENOENT') {
        this.skipUpdate('assuming development environment')
      } else if (this.retriesLeft > 0) {
        this.retriesLeft--
        await Promise.delay(UPDATE_RETRY_DELAY)
        await autoUpdater.checkForUpdates()
      } else {
        this.retriesLeft = UPDATE_RETRIES
        this.skipUpdate(err.message)
      }
    })
    autoUpdater.on('download-progress', progressObj => {
      log.trace({ progress: progressObj }, 'Downloading...')
      this.send('update-downloading', progressObj)
    })
    autoUpdater.on('update-downloaded', info => {
      log.info({ update: info }, 'Update downloaded. Exit and install...')
      setImmediate(() =>
        this.desktop
          .stopSync()
          .then(() => this.desktop.pouch.db.close())
          .then(() => autoUpdater.quitAndInstall())
          .then(() => this.app.quit())
          .then(() => this.app.exit(0))
          .catch(err => this.send('error-updating', this.humanError(err)))
      )
    })

    this.retriesLeft = UPDATE_RETRIES
  }

  clearTimeoutIfAny() {
    if (this.timeout) {
      clearTimeout(this.timeout)
      this.timeout = null
    }
  }

  onUpToDate(handler /*: any */) {
    this.afterUpToDate = () => {
      this.clearTimeoutIfAny()
      handler()
    }
  }

  async checkForUpdates() {
    this.skipped = false
    this.timeout = setTimeout(() => {
      this.skipUpdate(`check is taking more than ${UPDATE_CHECK_TIMEOUT} ms`)
    }, UPDATE_CHECK_TIMEOUT)

    try {
      await autoUpdater.checkForUpdates()
    } catch (err) {
      // Dealing with the error itself is alreay done via the listener on the
      // `error` event.
      log.error({ err })
    }
  }

  skipUpdate(reason /*: string */) {
    log.info({ sentry: true }, `Not updating: ${reason}`)
    this.skipped = true

    // Disable handler & warn on future calls
    const handler = this.afterUpToDate
    this.afterUpToDate = () => {}

    if (typeof handler === 'function') handler()
  }

  hash() {
    return '#updater'
  }

  ipcEvents() {
    return {}
  }
}