etcher/lib/gui/app/app.js
Stevche Radevski 8e47829905 Move the main controller to React
Change-type: patch
Signed-off-by: Stevche Radevski <stevche@balena.io>
2019-12-12 18:25:43 +01:00

499 lines
14 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 sdk = require('etcher-sdk')
const _ = require('lodash')
const uuidV4 = require('uuid/v4')
const EXIT_CODES = require('../../shared/exit-codes')
const messages = require('../../shared/messages')
const store = require('./models/store')
const packageJSON = require('../../../package.json')
const flashState = require('./models/flash-state')
const settings = require('./models/settings')
const windowProgress = require('./os/window-progress')
const analytics = require('./modules/analytics')
const availableDrives = require('./models/available-drives')
const selectionState = require('./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,lodash/prefer-get */
// 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
window.addEventListener('unhandledrejection', (event) => {
// Promise: event.reason
// Bluebird: event.detail.reason
// Anything else: event
const error = event.reason || (event.detail && event.detail.reason) || event
analytics.logException(error)
event.preventDefault()
})
// Set application session UUID
store.dispatch({
type: store.Actions.SET_APPLICATION_SESSION_UUID,
data: uuidV4()
})
// Set first flashing workflow UUID
store.dispatch({
type: store.Actions.SET_FLASHING_WORKFLOW_UUID,
data: uuidV4()
})
const applicationSessionUuid = store.getState().toJS().applicationSessionUuid
const flashingWorkflowUuid = store.getState().toJS().flashingWorkflowUuid
const app = angular.module('Etcher', [
require('angular-ui-router'),
require('angular-ui-bootstrap'),
require('angular-if-state'),
// Components
require('./components/svg-icon'),
require('./components/safe-webview'),
// Pages
require('./pages/main/main.ts').MODULE_NAME,
require('./pages/finish/finish'),
require('./components/settings/index.ts').MODULE_NAME,
// OS
require('./os/open-external/open-external'),
// Utils
require('./utils/manifest-bind/manifest-bind')
])
app.run(() => {
console.log([
' _____ _ _',
'| ___| | | |',
'| |__ | |_ ___| |__ ___ _ __',
'| __|| __/ __| \'_ \\ / _ \\ \'__|',
'| |___| || (__| | | | __/ |',
'\\____/ \\__\\___|_| |_|\\___|_|',
'',
'Interested in joining the Etcher team?',
'Drop us a line at join+etcher@balena.io',
'',
`Version = ${packageJSON.version}, Type = ${packageJSON.packageType}`
].join('\n'))
})
app.run(() => {
const currentVersion = packageJSON.version
analytics.logEvent('Application start', {
packageType: packageJSON.packageType,
version: currentVersion,
applicationSessionUuid
})
})
app.run(() => {
store.observe(() => {
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)
})
})
/**
* @summary The radix used by USB ID numbers
* @type {Number}
* @constant
*/
const USB_ID_RADIX = 16
/**
* @summary The expected length of a USB ID number
* @type {Number}
* @constant
*/
const USB_ID_LENGTH = 4
/**
* @summary Convert a USB id (e.g. product/vendor) to a string
* @function
* @private
*
* @param {Number} id - USB id
* @returns {String} string id
*
* @example
* console.log(usbIdToString(2652))
* > '0x0a5c'
*/
const usbIdToString = (id) => {
return `0x${_.padStart(id.toString(USB_ID_RADIX), USB_ID_LENGTH, '0')}`
}
/**
* @summary Product ID of BCM2708
* @type {Number}
* @constant
*/
const USB_PRODUCT_ID_BCM2708_BOOT = 0x2763
/**
* @summary Product ID of BCM2710
* @type {Number}
* @constant
*/
const USB_PRODUCT_ID_BCM2710_BOOT = 0x2764
/**
* @summary Compute module descriptions
* @type {Object}
* @constant
*/
const COMPUTE_MODULE_DESCRIPTIONS = {
[USB_PRODUCT_ID_BCM2708_BOOT]: 'Compute Module 1',
[USB_PRODUCT_ID_BCM2710_BOOT]: 'Compute Module 3'
}
app.run(($timeout) => {
const BLACKLISTED_DRIVES = settings.has('driveBlacklist')
? settings.get('driveBlacklist').split(',')
: []
// eslint-disable-next-line require-jsdoc
const driveIsAllowed = (drive) => {
return !(
BLACKLISTED_DRIVES.includes(drive.devicePath) ||
BLACKLISTED_DRIVES.includes(drive.device) ||
BLACKLISTED_DRIVES.includes(drive.raw)
)
}
// eslint-disable-next-line require-jsdoc,consistent-return
const prepareDrive = (drive) => {
if (drive instanceof sdk.sourceDestination.BlockDevice) {
return drive.drive
} else if (drive instanceof sdk.sourceDestination.UsbbootDrive) {
// This is a workaround etcher expecting a device string and a size
drive.device = drive.usbDevice.portId
drive.size = null
drive.progress = 0
drive.disabled = true
drive.on('progress', (progress) => {
updateDriveProgress(drive, progress)
})
return drive
} else if (drive instanceof sdk.sourceDestination.DriverlessDevice) {
const description = COMPUTE_MODULE_DESCRIPTIONS[drive.deviceDescriptor.idProduct] || 'Compute Module'
return {
device: `${usbIdToString(drive.deviceDescriptor.idVendor)}:${usbIdToString(drive.deviceDescriptor.idProduct)}`,
displayName: 'Missing drivers',
description,
mountpoints: [],
isReadOnly: false,
isSystem: false,
disabled: true,
icon: 'warning',
size: null,
link: 'https://www.raspberrypi.org/documentation/hardware/computemodule/cm-emmc-flashing.md',
linkCTA: 'Install',
linkTitle: 'Install missing drivers',
linkMessage: [
'Would you like to download the necessary drivers from the Raspberry Pi Foundation?',
'This will open your browser.\n\n',
'Once opened, download and run the installer from the "Windows Installer" section to install the drivers.'
].join(' ')
}
}
}
// eslint-disable-next-line require-jsdoc
const setDrives = (drives) => {
availableDrives.setDrives(_.values(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()
}
// eslint-disable-next-line require-jsdoc
const getDrives = () => {
return _.keyBy(availableDrives.getDrives() || [], 'device')
}
// eslint-disable-next-line require-jsdoc
const addDrive = (drive) => {
const preparedDrive = prepareDrive(drive)
if (!driveIsAllowed(preparedDrive)) {
return
}
const drives = getDrives()
drives[preparedDrive.device] = preparedDrive
setDrives(drives)
}
// eslint-disable-next-line require-jsdoc
const removeDrive = (drive) => {
const preparedDrive = prepareDrive(drive)
const drives = getDrives()
// eslint-disable-next-line prefer-reflect
delete drives[preparedDrive.device]
setDrives(drives)
}
// eslint-disable-next-line require-jsdoc
const updateDriveProgress = (drive, progress) => {
const drives = getDrives()
const driveInMap = drives[drive.device]
if (driveInMap) {
driveInMap.progress = progress
setDrives(drives)
}
}
driveScanner.on('attach', addDrive)
driveScanner.on('detach', removeDrive)
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(),
applicationSessionUuid
})
return
}
// Don't close window while flashing
event.returnValue = false
// Don't open any more popups
popupExists = true
analytics.logEvent('Close attempt while flashing', { applicationSessionUuid, flashingWorkflowUuid })
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', {
flashInstanceUuid: flashState.getFlashUuid(),
applicationSessionUuid,
flashingWorkflowUuid
})
// 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', { applicationSessionUuid, flashingWorkflowUuid })
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,
applicationSessionUuid
})
})
})
app.config(($urlRouterProvider) => {
$urlRouterProvider.otherwise('/main')
})
app.config(($provide) => {
$provide.decorator('$exceptionHandler', ($delegate) => {
return (exception, cause) => {
exceptionReporter.report(exception)
$delegate(exception, cause)
}
})
})
app.config(($locationProvider) => {
// NOTE(Shou): this seems to invoke a minor perf decrease when set to true
$locationProvider.html5Mode({
rewriteLinks: false
})
})
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 !settings.get('disableExternalLinks')
}
})
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
})
// 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)
})