diff --git a/lib/gui/app/app.js b/lib/gui/app/app.js deleted file mode 100644 index a93a1b16..00000000 --- a/lib/gui/app/app.js +++ /dev/null @@ -1,360 +0,0 @@ -/* - * Copyright 2016 balena.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' - -const electron = require('electron') -const sdk = require('etcher-sdk') -const _ = require('lodash') -const uuidV4 = require('uuid/v4') - -// eslint-disable-next-line node/no-missing-require -const EXIT_CODES = require('../../shared/exit-codes') -// eslint-disable-next-line node/no-missing-require -const messages = require('../../shared/messages') -const { - Actions, - observe, - store -// eslint-disable-next-line node/no-missing-require -} = require('./models/store') -const packageJSON = require('../../../package.json') -// eslint-disable-next-line node/no-missing-require -const flashState = require('./models/flash-state') -// eslint-disable-next-line node/no-missing-require -const settings = require('./models/settings') -// eslint-disable-next-line node/no-missing-require -const windowProgress = require('./os/window-progress') -// eslint-disable-next-line node/no-missing-require -const analytics = require('./modules/analytics') -// eslint-disable-next-line node/no-missing-require -const availableDrives = require('./models/available-drives') -// eslint-disable-next-line node/no-missing-require -const { scanner: driveScanner } = require('./modules/drive-scanner') -// eslint-disable-next-line node/no-missing-require -const osDialog = require('./os/dialog') -// eslint-disable-next-line node/no-missing-require -const exceptionReporter = require('./modules/exception-reporter') -// eslint-disable-next-line node/no-missing-require -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/balena-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: Actions.SET_APPLICATION_SESSION_UUID, - data: uuidV4() -}) - -// Set first flashing workflow UUID -store.dispatch({ - type: Actions.SET_FLASHING_WORKFLOW_UUID, - data: uuidV4() -}) - -const applicationSessionUuid = store.getState().toJS().applicationSessionUuid -const flashingWorkflowUuid = store.getState().toJS().flashingWorkflowUuid - -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')) - -const currentVersion = packageJSON.version - -analytics.logEvent('Application start', { - packageType: packageJSON.packageType, - version: currentVersion, - applicationSessionUuid -}) - -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' -} - -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)) -} - -// 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() - -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() - -settings.load().catch(exceptionReporter.report) - -require('./tsapp.tsx') diff --git a/lib/gui/app/app.ts b/lib/gui/app/app.ts new file mode 100644 index 00000000..713e59a2 --- /dev/null +++ b/lib/gui/app/app.ts @@ -0,0 +1,341 @@ +/* + * Copyright 2016 balena.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. + */ + +import * as electron from 'electron'; +import * as sdk from 'etcher-sdk'; +import * as _ from 'lodash'; +import outdent from 'outdent'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import * as uuidV4 from 'uuid/v4'; + +import * as packageJSON from '../../../package.json'; +import * as EXIT_CODES from '../../shared/exit-codes'; +import * as messages from '../../shared/messages'; +import * as availableDrives from './models/available-drives'; +import * as flashState from './models/flash-state'; +import * as settings from './models/settings'; +import { Actions, observe, store } from './models/store'; +import * as analytics from './modules/analytics'; +import { scanner as driveScanner } from './modules/drive-scanner'; +import * as exceptionReporter from './modules/exception-reporter'; +import { updateLock } from './modules/update-lock'; +import * as osDialog from './os/dialog'; +import * as windowProgress from './os/window-progress'; +import MainPage from './pages/main/MainPage'; + +window.addEventListener( + 'unhandledrejection', + (event: PromiseRejectionEvent | any) => { + // 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: Actions.SET_APPLICATION_SESSION_UUID, + data: uuidV4(), +}); + +// Set first flashing workflow UUID +store.dispatch({ + type: Actions.SET_FLASHING_WORKFLOW_UUID, + data: uuidV4(), +}); + +const applicationSessionUuid = store.getState().toJS().applicationSessionUuid; +const flashingWorkflowUuid = store.getState().toJS().flashingWorkflowUuid; + +console.log(outdent` + ${outdent} + _____ _ _ + | ___| | | | + | |__ | |_ ___| |__ ___ _ __ + | __|| __/ __| '_ \\ / _ \\ '__| + | |___| || (__| | | | __/ | + \\____/ \\__\\___|_| |_|\\___|_| + + Interested in joining the Etcher team? + Drop us a line at join+etcher@balena.io + + Version = ${packageJSON.version}, Type = ${packageJSON.packageType} +`); + +const currentVersion = packageJSON.version; + +analytics.logEvent('Application start', { + packageType: packageJSON.packageType, + version: currentVersion, + applicationSessionUuid, +}); + +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 + */ +const USB_ID_RADIX = 16; + +/** + * @summary The expected length of a USB ID number + */ +const USB_ID_LENGTH = 4; + +/** + * @summary Convert a USB id (e.g. product/vendor) to a string + * + * @example + * console.log(usbIdToString(2652)) + * > '0x0a5c' + */ +function usbIdToString(id: number): string { + return `0x${_.padStart(id.toString(USB_ID_RADIX), USB_ID_LENGTH, '0')}`; +} + +/** + * @summary Product ID of BCM2708 + */ +const USB_PRODUCT_ID_BCM2708_BOOT = 0x2763; + +/** + * @summary Product ID of BCM2710 + */ +const USB_PRODUCT_ID_BCM2710_BOOT = 0x2764; + +/** + * @summary Compute module descriptions + */ +const COMPUTE_MODULE_DESCRIPTIONS: _.Dictionary = { + [USB_PRODUCT_ID_BCM2708_BOOT]: 'Compute Module 1', + [USB_PRODUCT_ID_BCM2710_BOOT]: 'Compute Module 3', +}; + +const BLACKLISTED_DRIVES = settings.has('driveBlacklist') + ? settings.get('driveBlacklist').split(',') + : []; + +function driveIsAllowed(drive: { + devicePath: string; + device: string; + raw: string; +}) { + return !( + BLACKLISTED_DRIVES.includes(drive.devicePath) || + BLACKLISTED_DRIVES.includes(drive.device) || + BLACKLISTED_DRIVES.includes(drive.raw) + ); +} + +type Drive = + | sdk.sourceDestination.BlockDevice + | sdk.sourceDestination.UsbbootDrive + | sdk.sourceDestination.DriverlessDevice; + +function prepareDrive(drive: Drive) { + if (drive instanceof sdk.sourceDestination.BlockDevice) { + // @ts-ignore (BlockDevice.drive is private) + return drive.drive; + } else if (drive instanceof sdk.sourceDestination.UsbbootDrive) { + // This is a workaround etcher expecting a device string and a size + // @ts-ignore + drive.device = drive.usbDevice.portId; + drive.size = null; + // @ts-ignore + 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.toString() + ] || '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: outdent` + Would you like to download the necessary drivers from the Raspberry Pi Foundation? + This will open your browser. + + + Once opened, download and run the installer from the "Windows Installer" section to install the drivers + `, + }; + } +} + +function setDrives(drives: _.Dictionary) { + availableDrives.setDrives(_.values(drives)); +} + +function getDrives() { + return _.keyBy(availableDrives.getDrives() || [], 'device'); +} + +function addDrive(drive: Drive) { + const preparedDrive = prepareDrive(drive); + if (!driveIsAllowed(preparedDrive)) { + return; + } + const drives = getDrives(); + drives[preparedDrive.device] = preparedDrive; + setDrives(drives); +} + +function removeDrive(drive: Drive) { + const preparedDrive = prepareDrive(drive); + const drives = getDrives(); + delete drives[preparedDrive.device]; + setDrives(drives); +} + +function updateDriveProgress( + drive: sdk.sourceDestination.UsbbootDrive, + progress: number, +) { + const drives = getDrives(); + // @ts-ignore + 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(); + +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); +}); + +function extendLock() { + updateLock.extend(); +} + +window.addEventListener('click', extendLock); +window.addEventListener('touchstart', extendLock); + +// Initial update lock acquisition +extendLock(); + +settings.load().catch(exceptionReporter.report); + +ReactDOM.render(React.createElement(MainPage), document.getElementById('main')); diff --git a/lib/gui/app/tsapp.tsx b/lib/gui/app/tsapp.tsx deleted file mode 100644 index a4907968..00000000 --- a/lib/gui/app/tsapp.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2020 balena.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. - */ - -import * as React from 'react'; -import * as ReactDOM from 'react-dom'; - -import MainPage from './pages/main/MainPage'; - -ReactDOM.render(, document.getElementById('main')); diff --git a/webpack.config.js b/webpack.config.js index 37506ed7..efceabae 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -102,7 +102,7 @@ const guiConfig = { externalPackageJson('../../../package.json') ], entry: { - gui: path.join(__dirname, 'lib', 'gui', 'app', 'app.js') + gui: path.join(__dirname, 'lib', 'gui', 'app', 'app.ts') }, devtool: 'source-map' }