etcher/lib/gui/app/app.js
Jonas Hermsmeier bb2dac7504
feat(gui): Add resin update lock based on inactivity
This adds functionality to acquire & release the update lock
when running under resinOS, re-using the `ELECTRON_RESIN_UPDATE_LOCK`
environment variable from `resin-electronjs`.

Further this adds the `ETCHER_INTERACTION_TIMEOUT_MS` env var, to
facilitate adjusting the inactivity period required to release the lock.

Change-Type: minor
2018-05-10 19:15:31 +02:00

419 lines
12 KiB
JavaScript

/*
* Copyright 2016 resin.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @module Etcher
*/
'use strict'
/* eslint-disable no-var */
var angular = require('angular')
/* eslint-enable no-var */
const electron = require('electron')
const Bluebird = require('bluebird')
const semver = require('semver')
const EXIT_CODES = require('../../shared/exit-codes')
const messages = require('../../shared/messages')
const s3Packages = require('../../shared/s3-packages')
const release = require('../../shared/release')
const store = require('../../shared/store')
const errors = require('../../shared/errors')
const packageJSON = require('../../../package.json')
const flashState = require('../../shared/models/flash-state')
const settings = require('./models/settings')
const windowProgress = require('./os/window-progress')
const analytics = require('./modules/analytics')
const updateNotifier = require('./components/update-notifier')
const availableDrives = require('../../shared/models/available-drives')
const selectionState = require('../../shared/models/selection-state')
const driveScanner = require('./modules/drive-scanner')
const osDialog = require('./os/dialog')
const exceptionReporter = require('./modules/exception-reporter')
const updateLock = require('./modules/update-lock')
/* eslint-disable lodash/prefer-lodash-method */
// Enable debug information from all modules that use `debug`
// See https://github.com/visionmedia/debug#browser-support
//
// Enable drivelist debugging information
// See https://github.com/resin-io-modules/drivelist
process.env.DRIVELIST_DEBUG = /drivelist|^\*$/i.test(process.env.DEBUG) ? '1' : ''
window.localStorage.debug = process.env.DEBUG
/**
* @summary Environment variable blacklisted drives by path
* @type {Array<String>}
* @constant
* @private
*/
const BLACKLISTED_DRIVES = process.env.ETCHER_BLACKLISTED_DRIVES
? process.env.ETCHER_BLACKLISTED_DRIVES.split(',')
: []
const app = angular.module('Etcher', [
require('angular-ui-router'),
require('angular-ui-bootstrap'),
require('angular-if-state'),
// Components
require('./components/svg-icon'),
require('./components/warning-modal/warning-modal'),
require('./components/safe-webview'),
// Pages
require('./pages/main/main'),
require('./pages/finish/finish'),
require('./pages/settings/settings'),
// OS
require('./os/open-external/open-external'),
require('./os/dropzone/dropzone'),
// Utils
require('./utils/manifest-bind/manifest-bind')
])
app.run(() => {
console.log([
' _____ _ _',
'| ___| | | |',
'| |__ | |_ ___| |__ ___ _ __',
'| __|| __/ __| \'_ \\ / _ \\ \'__|',
'| |___| || (__| | | | __/ |',
'\\____/ \\__\\___|_| |_|\\___|_|',
'',
'Interested in joining the Etcher team?',
'Drop us a line at join+etcher@resin.io',
'',
`Version = ${packageJSON.version}, Type = ${packageJSON.packageType}`
].join('\n'))
})
app.run(() => {
const currentVersion = packageJSON.version
analytics.logEvent('Application start', {
packageType: packageJSON.packageType,
version: currentVersion
})
const shouldCheckForUpdates = updateNotifier.shouldCheckForUpdates({
currentVersion,
lastSleptUpdateNotifier: settings.get('lastSleptUpdateNotifier'),
lastSleptUpdateNotifierVersion: settings.get('lastSleptUpdateNotifierVersion')
})
const isStableRelease = release.isStableRelease(currentVersion)
const updatesEnabled = settings.get('updatesEnabled')
if (!shouldCheckForUpdates || !updatesEnabled) {
analytics.logEvent('Not checking for updates', {
shouldCheckForUpdates,
updatesEnabled,
stable: isStableRelease
})
return Bluebird.resolve()
}
const updateSemverRange = packageJSON.updates.semverRange
const includeUnstableChannel = settings.get('includeUnstableUpdateChannel')
analytics.logEvent('Checking for updates', {
currentVersion,
stable: isStableRelease,
updateSemverRange,
includeUnstableChannel
})
return s3Packages.getLatestVersion(release.getReleaseType(currentVersion), {
range: updateSemverRange,
includeUnstableChannel
}).then((latestVersion) => {
if (semver.gte(currentVersion, latestVersion || '0.0.0')) {
analytics.logEvent('Update notification skipped', {
reason: 'Latest version'
})
return Bluebird.resolve()
}
// In case the internet connection is not good and checking the
// latest published version takes too long, only show notify
// the user about the new version if he didn't start the flash
// process (e.g: selected an image), otherwise such interruption
// might be annoying.
if (selectionState.hasImage()) {
analytics.logEvent('Update notification skipped', {
reason: 'Image selected'
})
return Bluebird.resolve()
}
analytics.logEvent('Notifying update', {
latestVersion
})
return updateNotifier.notify(latestVersion, {
allowSleepUpdateCheck: isStableRelease
})
// If the error is an update user error, then we don't want
// to bother users each time they open the app.
// See: https://github.com/resin-io/etcher/issues/1525
}).catch((error) => {
return errors.isUserError(error) && error.code === 'UPDATE_USER_ERROR'
}, (error) => {
analytics.logEvent('Update check user error', {
title: errors.getTitle(error),
description: errors.getDescription(error)
})
}).catch(exceptionReporter.report)
})
app.run(() => {
store.subscribe(() => {
if (!flashState.isFlashing()) {
return
}
const currentFlashState = flashState.getFlashState()
const stateType = !currentFlashState.flashing && currentFlashState.verifying
? `Verifying ${currentFlashState.verifying}`
: `Flashing ${currentFlashState.flashing}`
// NOTE: There is usually a short time period between the `isFlashing()`
// property being set, and the flashing actually starting, which
// might cause some non-sense flashing state logs including
// `undefined` values.
analytics.logDebug(
`${stateType} devices, ` +
`${currentFlashState.percentage}% at ${currentFlashState.speed} MB/s ` +
`(total ${currentFlashState.totalSpeed} MB/s) ` +
`eta in ${currentFlashState.eta}s ` +
`with ${currentFlashState.failed} failed devices`
)
windowProgress.set(currentFlashState)
})
})
app.run(($timeout) => {
driveScanner.on('devices', (drives) => {
// Safely trigger a digest cycle.
// In some cases, AngularJS doesn't acknowledge that the
// available drives list has changed, and incorrectly
// keeps asking the user to "Connect a drive".
$timeout(() => {
if (BLACKLISTED_DRIVES.length) {
const allowedDrives = drives.filter((drive) => {
return !BLACKLISTED_DRIVES.includes(drive.device)
})
availableDrives.setDrives(allowedDrives)
} else {
availableDrives.setDrives(drives)
}
})
})
driveScanner.on('error', (error) => {
// Stop the drive scanning loop in case of errors,
// otherwise we risk presenting the same error over
// and over again to the user, while also heavily
// spamming our error reporting service.
driveScanner.stop()
return exceptionReporter.report(error)
})
driveScanner.start()
})
app.run(($window) => {
let popupExists = false
$window.addEventListener('beforeunload', (event) => {
if (!flashState.isFlashing() || popupExists) {
analytics.logEvent('Close application', {
isFlashing: flashState.isFlashing()
})
return
}
// Don't close window while flashing
event.returnValue = false
// Don't open any more popups
popupExists = true
analytics.logEvent('Close attempt while flashing')
osDialog.showWarning({
confirmationLabel: 'Yes, quit',
rejectionLabel: 'Cancel',
title: 'Are you sure you want to close Etcher?',
description: messages.warning.exitWhileFlashing()
}).then((confirmed) => {
if (confirmed) {
analytics.logEvent('Close confirmed while flashing', {
uuid: flashState.getFlashUuid()
})
// This circumvents the 'beforeunload' event unlike
// electron.remote.app.quit() which does not.
electron.remote.process.exit(EXIT_CODES.SUCCESS)
}
analytics.logEvent('Close rejected while flashing')
popupExists = false
}).catch(exceptionReporter.report)
})
/**
* @summary Helper fn for events
* @function
* @private
* @example
* window.addEventListener('click', extendLock)
*/
const extendLock = () => {
updateLock.extend()
}
$window.addEventListener('click', extendLock)
$window.addEventListener('touchstart', extendLock)
// Initial update lock acquisition
extendLock()
})
app.run(($rootScope) => {
$rootScope.$on('$stateChangeSuccess', (event, toState, toParams, fromState) => {
// Ignore first navigation
if (!fromState.name) {
return
}
analytics.logEvent('Navigate', {
to: toState.name,
from: fromState.name
})
})
})
app.config(($urlRouterProvider) => {
$urlRouterProvider.otherwise('/main')
})
app.config(($provide) => {
$provide.decorator('$exceptionHandler', ($delegate) => {
return (exception, cause) => {
exceptionReporter.report(exception)
$delegate(exception, cause)
}
})
})
app.controller('HeaderController', function (OSOpenExternalService) {
/**
* @summary Open help page
* @function
* @public
*
* @description
* This application will open either the image's support url, declared
* in the archive `manifest.json`, or the default Etcher help page.
*
* @example
* HeaderController.openHelpPage();
*/
this.openHelpPage = () => {
const DEFAULT_SUPPORT_URL = 'https://github.com/resin-io/etcher/blob/master/SUPPORT.md'
const supportUrl = selectionState.getImageSupportUrl() || DEFAULT_SUPPORT_URL
OSOpenExternalService.open(supportUrl)
}
/**
* @summary Whether to show the help link
* @function
* @public
*
* @returns {Boolean}
*
* @example
* HeaderController.shouldShowHelp()
*/
this.shouldShowHelp = () => {
return !process.env.ETCHER_DISABLE_EXTERNAL_LINKS
}
})
app.controller('StateController', function ($rootScope, $scope) {
const unregisterStateChange = $rootScope.$on('$stateChangeSuccess', (event, toState, toParams, fromState) => {
this.previousName = fromState.name
this.currentName = toState.name
})
$scope.$on('$destroy', unregisterStateChange)
/**
* @summary Get the previous state name
* @function
* @public
*
* @returns {String} previous state name
*
* @example
* if (StateController.previousName === 'main') {
* console.log('We left the main screen!');
* }
*/
this.previousName = null
/**
* @summary Get the current state name
* @function
* @public
*
* @returns {String} current state name
*
* @example
* if (StateController.currentName === 'main') {
* console.log('We are on the main screen!');
* }
*/
this.currentName = null
})
// Handle keyboard shortcut to open the settings
app.run(($state) => {
electron.ipcRenderer.on('menu:preferences', () => {
$state.go('settings')
})
})
// Ensure user settings are loaded before
// we bootstrap the Angular.js application
angular.element(document).ready(() => {
settings.load().then(() => {
angular.bootstrap(document, [ 'Etcher' ])
}).catch(exceptionReporter.report)
})