etcher/lib/gui/app/modules/image-writer.js
Alexis Svinartchouk fe230e7d30 Rename resin -> balena
Change-type: patch
2019-12-12 18:25:54 +01:00

378 lines
11 KiB
JavaScript

/*
* 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')
const store = require('../models/store')
const settings = require('../models/settings')
const flashState = require('../models/flash-state')
const errors = require('../../../shared/errors')
const permissions = require('../../../shared/permissions')
const windowProgress = require('../os/window-progress')
const analytics = require('../modules/analytics')
const updateLock = require('./update-lock')
const packageJSON = require('../../../../package.json')
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<String>} 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<String>} 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)
}
}