diff --git a/lib/gui/app/models/flash-state.ts b/lib/gui/app/models/flash-state.ts index 928b9969..a8a5af4d 100644 --- a/lib/gui/app/models/flash-state.ts +++ b/lib/gui/app/models/flash-state.ts @@ -56,8 +56,9 @@ export function setFlashingFlag() { * The flag is used to signify that the write process ended. */ export function unsetFlashingFlag(results: { - cancelled: boolean; - sourceChecksum?: number; + cancelled?: boolean; + sourceChecksum?: string; + errorCode?: string | number; }) { store.dispatch({ type: Actions.UNSET_FLASHING_FLAG, diff --git a/lib/gui/app/modules/image-writer.js b/lib/gui/app/modules/image-writer.js deleted file mode 100644 index 9023e473..00000000 --- a/lib/gui/app/modules/image-writer.js +++ /dev/null @@ -1,386 +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. - */ - -'use strict' - -const Bluebird = require('bluebird') -const _ = require('lodash') -const path = require('path') -const os = require('os') -const ipc = require('node-ipc') -const electron = require('electron') -// eslint-disable-next-line node/no-missing-require -const { store } = require('../models/store') -// eslint-disable-next-line node/no-missing-require -const settings = require('../models/settings') -// eslint-disable-next-line node/no-missing-require -const flashState = require('../models/flash-state') -// eslint-disable-next-line node/no-missing-require -const errors = require('../../../shared/errors') -// eslint-disable-next-line node/no-missing-require -const permissions = require('../../../shared/permissions') -// 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 { updateLock } = require('./update-lock') -const packageJSON = require('../../../../package.json') -// eslint-disable-next-line node/no-missing-require -const selectionState = require('../models/selection-state') - -/** - * @summary Number of threads per CPU to allocate to the UV_THREADPOOL - * @type {Number} - * @constant - */ -const THREADS_PER_CPU = 16 - -/** - * @summary Handle a flash error and log it to analytics - * @function - * @private - * - * @param {Error} error - error object - * @param {Object} analyticsData - analytics object - * - * @example - * handleErrorLogging({ code: 'EUNPLUGGED' }, { image: 'balena.img' }) - */ -const handleErrorLogging = (error, analyticsData) => { - const eventData = _.assign({ - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, - flashInstanceUuid: flashState.getFlashUuid() - }, analyticsData) - - if (error.code === 'EVALIDATION') { - analytics.logEvent('Validation error', eventData) - } else if (error.code === 'EUNPLUGGED') { - analytics.logEvent('Drive unplugged', eventData) - } else if (error.code === 'EIO') { - analytics.logEvent('Input/output error', eventData) - } else if (error.code === 'ENOSPC') { - analytics.logEvent('Out of space', eventData) - } else if (error.code === 'ECHILDDIED') { - analytics.logEvent('Child died unexpectedly', eventData) - } else { - analytics.logEvent('Flash error', _.merge({ - error: errors.toJSON(error) - }, eventData)) - } -} - -/** - * @summary Perform write operation - * @function - * @private - * - * @description - * This function is extracted for testing purposes. - * - * @param {String} image - image path - * @param {Array} drives - drives - * @param {Function} onProgress - in progress callback (state) - * - * @fulfil {Object} - flash results - * @returns {Promise} - * - * @example - * imageWriter.performWrite('path/to/image.img', [ '/dev/disk2' ], (state) => { - * console.log(state.percentage) - * }) - */ -exports.performWrite = (image, drives, onProgress) => { - // There might be multiple Etcher instances running at - // the same time, therefore we must ensure each IPC - // server/client has a different name. - const IPC_SERVER_ID = `etcher-server-${process.pid}` - const IPC_CLIENT_ID = `etcher-client-${process.pid}` - - ipc.config.id = IPC_SERVER_ID - ipc.config.socketRoot = path.join(process.env.XDG_RUNTIME_DIR || os.tmpdir(), path.sep) - - // NOTE: Ensure this isn't disabled, as it will cause - // the stdout maxBuffer size to be exceeded when flashing - ipc.config.silent = true - ipc.serve() - - /** - * @summary Safely terminate the IPC server - * @function - * @private - * - * @example - * terminateServer() - */ - const terminateServer = () => { - // Turns out we need to destroy all sockets for - // the server to actually close. Otherwise, it - // just stops receiving any further connections, - // but remains open if there are active ones. - _.each(ipc.server.sockets, (socket) => { - socket.destroy() - }) - - ipc.server.stop() - } - - return new Bluebird((resolve, reject) => { - ipc.server.on('error', (error) => { - terminateServer() - const errorObject = errors.fromJSON(error) - reject(errorObject) - }) - - ipc.server.on('log', (message) => { - console.log(message) - }) - - const flashResults = {} - const analyticsData = { - image, - drives, - driveCount: drives.length, - uuid: flashState.getFlashUuid(), - flashInstanceUuid: flashState.getFlashUuid(), - unmountOnSuccess: settings.get('unmountOnSuccess'), - validateWriteOnSuccess: settings.get('validateWriteOnSuccess'), - trim: settings.get('trim') - } - - ipc.server.on('fail', ({ device, error }) => { - handleErrorLogging(error, analyticsData) - }) - - ipc.server.on('done', (event) => { - event.results.errors = _.map(event.results.errors, (data) => { - return errors.fromJSON(data) - }) - _.merge(flashResults, event) - }) - - ipc.server.on('abort', () => { - terminateServer() - resolve({ - cancelled: true - }) - }) - - ipc.server.on('state', onProgress) - - ipc.server.on('ready', (data, socket) => { - ipc.server.emit(socket, 'write', { - imagePath: image, - destinations: drives, - validateWriteOnSuccess: settings.get('validateWriteOnSuccess'), - trim: settings.get('trim'), - unmountOnSuccess: settings.get('unmountOnSuccess') - }) - }) - - const argv = _.attempt(() => { - let entryPoint = electron.remote.app.getAppPath() - - // AppImages run over FUSE, so the files inside the mount point - // can only be accessed by the user that mounted the AppImage. - // This means we can't re-spawn Etcher as root from the same - // mount-point, and as a workaround, we re-mount the original - // AppImage as root. - if (os.platform() === 'linux' && process.env.APPIMAGE && process.env.APPDIR) { - entryPoint = _.replace(entryPoint, process.env.APPDIR, '') - return [ - process.env.APPIMAGE, - '-e', - `require(\`\${process.env.APPDIR}${entryPoint}\`)` - ] - } - return [ - _.first(process.argv), - entryPoint - ] - }) - - ipc.server.on('start', () => { - console.log(`Elevating command: ${_.join(argv, ' ')}`) - - const env = _.assign({}, process.platform === 'win32' ? {} : process.env, { - IPC_SERVER_ID, - IPC_CLIENT_ID, - IPC_SOCKET_ROOT: ipc.config.socketRoot, - ELECTRON_RUN_AS_NODE: 1, - UV_THREADPOOL_SIZE: os.cpus().length * THREADS_PER_CPU, - - // This environment variable prevents the AppImages - // desktop integration script from presenting the - // "installation" dialog - SKIP: 1 - }) - - permissions.elevateCommand(argv, { - applicationName: packageJSON.displayName, - environment: env - }).then((results) => { - flashResults.cancelled = results.cancelled - console.log('Flash results', flashResults) - - // This likely means the child died halfway through - if (!flashResults.cancelled && !_.get(flashResults, [ 'results', 'bytesWritten' ])) { - throw errors.createUserError({ - title: 'The writer process ended unexpectedly', - description: 'Please try again, and contact the Etcher team if the problem persists', - code: 'ECHILDDIED' - }) - } - - resolve(flashResults) - }).catch((error) => { - // This happens when the child is killed using SIGKILL - const SIGKILL_EXIT_CODE = 137 - if (error.code === SIGKILL_EXIT_CODE) { - error.code = 'ECHILDDIED' - } - reject(error) - }).finally(() => { - console.log('Terminating IPC server') - terminateServer() - }) - }) - - // Clear the update lock timer to prevent longer - // flashing timing it out, and releasing the lock - updateLock.pause() - ipc.server.start() - }) -} - -/** - * @summary Flash an image to drives - * @function - * @public - * - * @description - * This function will update `imageWriter.state` with the current writing state. - * - * @param {String} image - image path - * @param {Array} drives - drives - * @returns {Promise} - * - * @example - * imageWriter.flash('foo.img', [ '/dev/disk2' ]).then(() => { - * console.log('Write completed!') - * }) - */ -exports.flash = (image, drives) => { - if (flashState.isFlashing()) { - return Bluebird.reject(new Error('There is already a flash in progress')) - } - - flashState.setFlashingFlag() - - const analyticsData = { - image, - drives, - driveCount: drives.length, - uuid: flashState.getFlashUuid(), - status: 'started', - flashInstanceUuid: flashState.getFlashUuid(), - unmountOnSuccess: settings.get('unmountOnSuccess'), - validateWriteOnSuccess: settings.get('validateWriteOnSuccess'), - trim: settings.get('trim'), - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid - } - - analytics.logEvent('Flash', analyticsData) - - return exports.performWrite(image, drives, (state) => { - flashState.setProgressState(state) - }).then(flashState.unsetFlashingFlag).then(() => { - if (flashState.wasLastFlashCancelled()) { - const eventData = _.assign({ status: 'cancel' }, analyticsData) - analytics.logEvent('Elevation cancelled', eventData) - } else { - const { results } = flashState.getFlashResults() - const eventData = _.assign({ - errors: results.errors, - devices: results.devices, - status: 'finished' - }, - analyticsData) - analytics.logEvent('Done', eventData) - } - }).catch((error) => { - flashState.unsetFlashingFlag({ - errorCode: error.code - }) - - // eslint-disable-next-line no-magic-numbers - if (drives.length > 1) { - const { results } = flashState.getFlashResults() - const eventData = _.assign({ - errors: results.errors, - devices: results.devices, - status: 'failed' - }, - analyticsData) - analytics.logEvent('Write failed', eventData) - } - - return Bluebird.reject(error) - }).finally(() => { - windowProgress.clear() - }) -} - -/** - * @summary Cancel write operation - * @function - * @public - * - * @example - * imageWriter.cancel() - */ -exports.cancel = () => { - const drives = selectionState.getSelectedDevices() - const analyticsData = { - image: selectionState.getImagePath(), - drives, - driveCount: drives.length, - uuid: flashState.getFlashUuid(), - flashInstanceUuid: flashState.getFlashUuid(), - unmountOnSuccess: settings.get('unmountOnSuccess'), - validateWriteOnSuccess: settings.get('validateWriteOnSuccess'), - trim: settings.get('trim'), - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, - status: 'cancel' - } - analytics.logEvent('Cancel', analyticsData) - - // Re-enable lock release on inactivity - updateLock.resume() - - try { - const [ socket ] = ipc.server.sockets - // eslint-disable-next-line no-undefined - if (socket !== undefined) { - ipc.server.emit(socket, 'cancel') - } - } catch (error) { - analytics.logException(error) - } -} diff --git a/lib/gui/app/modules/image-writer.ts b/lib/gui/app/modules/image-writer.ts new file mode 100644 index 00000000..fcd5dd8b --- /dev/null +++ b/lib/gui/app/modules/image-writer.ts @@ -0,0 +1,356 @@ +/* + * 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 { Drive as DrivelistDrive } from 'drivelist'; +import * as electron from 'electron'; +import * as sdk from 'etcher-sdk'; +import * as _ from 'lodash'; +import * as ipc from 'node-ipc'; +import * as os from 'os'; +import * as path from 'path'; + +import * as packageJSON from '../../../../package.json'; +import * as errors from '../../../shared/errors'; +import * as permissions from '../../../shared/permissions'; +import * as flashState from '../models/flash-state'; +import * as selectionState from '../models/selection-state'; +import * as settings from '../models/settings'; +import { store } from '../models/store'; +import * as analytics from '../modules/analytics'; +import * as windowProgress from '../os/window-progress'; +import { updateLock } from './update-lock'; + +const THREADS_PER_CPU = 16; + +// There might be multiple Etcher instances running at +// the same time, therefore we must ensure each IPC +// server/client has a different name. +const IPC_SERVER_ID = `etcher-server-${process.pid}`; +const IPC_CLIENT_ID = `etcher-client-${process.pid}`; + +ipc.config.id = IPC_SERVER_ID; +ipc.config.socketRoot = path.join( + process.env.XDG_RUNTIME_DIR || os.tmpdir(), + path.sep, +); + +// NOTE: Ensure this isn't disabled, as it will cause +// the stdout maxBuffer size to be exceeded when flashing +ipc.config.silent = true; + +/** + * @summary Handle a flash error and log it to analytics + */ +function handleErrorLogging( + error: Error & { code: string }, + analyticsData: any, +) { + const eventData = _.assign( + { + applicationSessionUuid: store.getState().toJS().applicationSessionUuid, + flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, + flashInstanceUuid: flashState.getFlashUuid(), + }, + analyticsData, + ); + + if (error.code === 'EVALIDATION') { + analytics.logEvent('Validation error', eventData); + } else if (error.code === 'EUNPLUGGED') { + analytics.logEvent('Drive unplugged', eventData); + } else if (error.code === 'EIO') { + analytics.logEvent('Input/output error', eventData); + } else if (error.code === 'ENOSPC') { + analytics.logEvent('Out of space', eventData); + } else if (error.code === 'ECHILDDIED') { + analytics.logEvent('Child died unexpectedly', eventData); + } else { + analytics.logEvent( + 'Flash error', + _.merge( + { + error: errors.toJSON(error), + }, + eventData, + ), + ); + } +} + +function terminateServer() { + // Turns out we need to destroy all sockets for + // the server to actually close. Otherwise, it + // just stops receiving any further connections, + // but remains open if there are active ones. + // @ts-ignore (no Server.sockets in @types/node-ipc) + for (const socket of ipc.server.sockets) { + socket.destroy(); + } + ipc.server.stop(); +} + +function writerArgv(): string[] { + let entryPoint = electron.remote.app.getAppPath(); + // AppImages run over FUSE, so the files inside the mount point + // can only be accessed by the user that mounted the AppImage. + // This means we can't re-spawn Etcher as root from the same + // mount-point, and as a workaround, we re-mount the original + // AppImage as root. + if (os.platform() === 'linux' && process.env.APPIMAGE && process.env.APPDIR) { + entryPoint = entryPoint.replace(process.env.APPDIR, ''); + return [ + process.env.APPIMAGE, + '-e', + `require(\`\${process.env.APPDIR}${entryPoint}\`)`, + ]; + } else { + return [process.argv[0], entryPoint]; + } +} + +function writerEnv() { + return { + IPC_SERVER_ID, + IPC_CLIENT_ID, + IPC_SOCKET_ROOT: ipc.config.socketRoot, + ELECTRON_RUN_AS_NODE: '1', + UV_THREADPOOL_SIZE: (os.cpus().length * THREADS_PER_CPU).toString(), + // This environment variable prevents the AppImages + // desktop integration script from presenting the + // "installation" dialog + SKIP: '1', + ...(process.platform === 'win32' ? {} : process.env), + }; +} + +interface FlashResults { + cancelled?: boolean; +} + +/** + * @summary Perform write operation + * + * @description + * This function is extracted for testing purposes. + */ +export function performWrite( + image: string, + drives: DrivelistDrive[], + onProgress: sdk.multiWrite.OnProgressFunction, +): Promise<{ cancelled?: boolean }> { + let cancelled = false; + ipc.serve(); + return new Promise((resolve, reject) => { + ipc.server.on('error', error => { + terminateServer(); + const errorObject = errors.fromJSON(error); + reject(errorObject); + }); + + ipc.server.on('log', message => { + console.log(message); + }); + + const flashResults: FlashResults = {}; + const analyticsData = { + image, + drives, + driveCount: drives.length, + uuid: flashState.getFlashUuid(), + flashInstanceUuid: flashState.getFlashUuid(), + unmountOnSuccess: settings.get('unmountOnSuccess'), + validateWriteOnSuccess: settings.get('validateWriteOnSuccess'), + trim: settings.get('trim'), + }; + + ipc.server.on('fail', ({ error }) => { + handleErrorLogging(error, analyticsData); + }); + + ipc.server.on('done', event => { + event.results.errors = _.map(event.results.errors, data => { + return errors.fromJSON(data); + }); + _.merge(flashResults, event); + }); + + ipc.server.on('abort', () => { + terminateServer(); + cancelled = true; + }); + + // @ts-ignore + ipc.server.on('state', onProgress); + + ipc.server.on('ready', (_data, socket) => { + ipc.server.emit(socket, 'write', { + imagePath: image, + destinations: drives, + validateWriteOnSuccess: settings.get('validateWriteOnSuccess'), + trim: settings.get('trim'), + unmountOnSuccess: settings.get('unmountOnSuccess'), + }); + }); + + const argv = writerArgv(); + + ipc.server.on('start', async () => { + console.log(`Elevating command: ${_.join(argv, ' ')}`); + const env = writerEnv(); + try { + const results = await permissions.elevateCommand(argv, { + applicationName: packageJSON.displayName, + environment: env, + }); + flashResults.cancelled = cancelled || results.cancelled; + } catch (error) { + // This happens when the child is killed using SIGKILL + const SIGKILL_EXIT_CODE = 137; + if (error.code === SIGKILL_EXIT_CODE) { + error.code = 'ECHILDDIED'; + } + reject(error); + } finally { + console.log('Terminating IPC server'); + terminateServer(); + } + console.log('Flash results', flashResults); + + // This likely means the child died halfway through + if ( + !flashResults.cancelled && + !_.get(flashResults, ['results', 'bytesWritten']) + ) { + throw errors.createUserError({ + title: 'The writer process ended unexpectedly', + description: + 'Please try again, and contact the Etcher team if the problem persists', + code: 'ECHILDDIED', + }); + } + resolve(flashResults); + }); + + // Clear the update lock timer to prevent longer + // flashing timing it out, and releasing the lock + updateLock.pause(); + ipc.server.start(); + }); +} + +/** + * @summary Flash an image to drives + */ +export async function flash( + image: string, + drives: DrivelistDrive[], +): Promise { + if (flashState.isFlashing()) { + throw new Error('There is already a flash in progress'); + } + + flashState.setFlashingFlag(); + + const analyticsData = { + image, + drives, + driveCount: drives.length, + uuid: flashState.getFlashUuid(), + status: 'started', + flashInstanceUuid: flashState.getFlashUuid(), + unmountOnSuccess: settings.get('unmountOnSuccess'), + validateWriteOnSuccess: settings.get('validateWriteOnSuccess'), + trim: settings.get('trim'), + applicationSessionUuid: store.getState().toJS().applicationSessionUuid, + flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, + }; + + analytics.logEvent('Flash', analyticsData); + + try { + // Using it from exports so it can be mocked during tests + const result = await exports.performWrite( + image, + drives, + flashState.setProgressState, + ); + flashState.unsetFlashingFlag(result); + } catch (error) { + flashState.unsetFlashingFlag({ cancelled: false, errorCode: error.code }); + windowProgress.clear(); + const { results } = flashState.getFlashResults(); + const eventData = _.assign( + { + errors: results.errors, + devices: results.devices, + status: 'failed', + }, + analyticsData, + ); + analytics.logEvent('Write failed', eventData); + throw error; + } + windowProgress.clear(); + if (flashState.wasLastFlashCancelled()) { + const eventData = _.assign({ status: 'cancel' }, analyticsData); + analytics.logEvent('Elevation cancelled', eventData); + } else { + const { results } = flashState.getFlashResults(); + const eventData = _.assign( + { + errors: results.errors, + devices: results.devices, + status: 'finished', + }, + analyticsData, + ); + analytics.logEvent('Done', eventData); + } +} + +/** + * @summary Cancel write operation + */ +export function cancel() { + const drives = selectionState.getSelectedDevices(); + const analyticsData = { + image: selectionState.getImagePath(), + drives, + driveCount: drives.length, + uuid: flashState.getFlashUuid(), + flashInstanceUuid: flashState.getFlashUuid(), + unmountOnSuccess: settings.get('unmountOnSuccess'), + validateWriteOnSuccess: settings.get('validateWriteOnSuccess'), + trim: settings.get('trim'), + applicationSessionUuid: store.getState().toJS().applicationSessionUuid, + flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, + status: 'cancel', + }; + analytics.logEvent('Cancel', analyticsData); + + // Re-enable lock release on inactivity + updateLock.resume(); + + try { + // @ts-ignore (no Server.sockets in @types/node-ipc) + const [socket] = ipc.server.sockets; + if (socket !== undefined) { + ipc.server.emit(socket, 'cancel'); + } + } catch (error) { + analytics.logException(error); + } +} diff --git a/lib/shared/permissions.ts b/lib/shared/permissions.ts index 6deac918..dfa542da 100755 --- a/lib/shared/permissions.ts +++ b/lib/shared/permissions.ts @@ -143,7 +143,10 @@ async function elevateScriptCatalina( export async function elevateCommand( command: string[], - options: { environment: Dictionary; applicationName: string }, + options: { + environment: Dictionary; + applicationName: string; + }, ): Promise<{ cancelled: boolean }> { if (await exports.isElevated()) { await execFileAsync(command[0], command.slice(1), { diff --git a/tests/gui/modules/image-writer.spec.js b/tests/gui/modules/image-writer.spec.js index 457f9701..9959cb6b 100644 --- a/tests/gui/modules/image-writer.spec.js +++ b/tests/gui/modules/image-writer.spec.js @@ -6,6 +6,7 @@ const ipc = require('node-ipc') const Bluebird = require('bluebird') // eslint-disable-next-line node/no-missing-require const flashState = require('../../../lib/gui/app/models/flash-state') +// eslint-disable-next-line node/no-missing-require const imageWriter = require('../../../lib/gui/app/modules/image-writer') describe('Browser: imageWriter', () => { @@ -105,8 +106,6 @@ describe('Browser: imageWriter', () => { describe('.performWrite()', function () { it('should set the ipc config to silent', function () { // Reset this value as it can persist from other tests - ipc.config.silent = false - imageWriter.performWrite(undefined, undefined, undefined).cancel() m.chai.expect(ipc.config.silent).to.be.true }) })