mirror of
https://github.com/balena-io/etcher.git
synced 2025-07-29 14:16:36 +00:00
refactor(GUI): remove the intermediate child writer proxy process (#1910)
Etcher currently elevates a child writer proxy that itself spawns the Etcher CLI in robot mode, parses the output, and proxies those messages to the GUI application over IPC. After these set of changes, Etcher elevates a single child writer process that directly communicates back with the GUI using IPC. The main purpose behind these changes is to simplify the overall architecture and fix various issues caused by the current complex child process tree. Here's a summary of the changes: - Stop wrapping the Etcher CLI to perform writing - Remove the robot option from the Etcher CLI (along with related documentation) - Elevate a new `child-write.js` standalone executable - Move the relevant bits of `lib/child-writer` to the `image-writer` GUI module - Remove the `lib/child-writer` directory - Add a new "Child died unexpectedly" Mixpanel event - Floor state percentage in the flash state model The above changes made is possible to tackle all the remaining issues where the writer process would remain alive even if the parent died. Change-Type: patch Changelog-Entry: Ensure the writer process dies when the GUI application is killed. See: https://github.com/resin-io/etcher/pull/1873 See: https://github.com/resin-io/etcher/pull/1843 Signed-off-by: Juan Cruz Viotti <jv@jviotti.com>
This commit is contained in:
parent
0ce2fca40a
commit
2291321b46
@ -222,8 +222,7 @@ rules:
|
||||
- error
|
||||
- max: 1
|
||||
multiline-ternary:
|
||||
- error
|
||||
- never
|
||||
- off
|
||||
newline-per-chained-call:
|
||||
- off
|
||||
no-bitwise:
|
||||
|
1
Makefile
1
Makefile
@ -560,7 +560,6 @@ test-gui:
|
||||
test-sdk:
|
||||
electron-mocha $(MOCHA_OPTIONS) \
|
||||
tests/shared \
|
||||
tests/child-writer \
|
||||
tests/image-stream
|
||||
|
||||
test: test-gui test-sdk test-spectron
|
||||
|
@ -1,69 +0,0 @@
|
||||
Etcher Child Writer
|
||||
===================
|
||||
|
||||
This module is in charge of dealing with the gory details of elevating and
|
||||
managing the child writer process. As a word of warning, it contains tons of
|
||||
workarounds and "hacks" to deal with platform differences, packaging, and
|
||||
inter-process communication. This empowers us to write this small guide to
|
||||
explain how it works in a more high level manner, hoping to make it easier to
|
||||
grok for contributors.
|
||||
|
||||
The problem
|
||||
-----------
|
||||
|
||||
Elevating a forked process is an easy task. Thanks to the widely available NPM
|
||||
modules to display nice GUI prompt dialogs, elevation is just a matter of
|
||||
executing the process with one of those modules instead of with `child_process`
|
||||
directly.
|
||||
|
||||
The main problems we faced are:
|
||||
|
||||
- The modules that implement elevation provide "execution" support, but don't
|
||||
allow us to fork/spawn the process and consume its `stdout` and `stderr` in a
|
||||
stream fashion. This also means that we can't use the nice `process.send` IPC
|
||||
communication channel directly that `child_process.fork` gives us to send
|
||||
messages back to the parent.
|
||||
|
||||
- Since we can't assume anything from the environment Etcher is running on, we
|
||||
must make use of the same application entry point to execute both the GUI and
|
||||
the CLI code, which starts to get messy once we throw `asar` packaging into
|
||||
the mix.
|
||||
|
||||
- Each elevation mechanism has its quirks, mainly on GNU/Linux. Making sure
|
||||
that the forked process was elevated correctly and could work without issues
|
||||
required various workarounds targeting `pkexec` or `kdesudo`.
|
||||
|
||||
How it works
|
||||
------------
|
||||
|
||||
The Etcher binary runs in CLI or GUI mode depending on an environment variable
|
||||
called `ELECTRON_RUN_AS_NODE`. When this variable is set, it instructs Electron
|
||||
to run as a normal NodeJS process (without Chromium, etc), but still keep any
|
||||
patches applied by Electron, like `asar` support.
|
||||
|
||||
When the Etcher GUI is ran, and the user presses the "Flash!" button, the GUI
|
||||
creates an IPC server, and forks a process called the "writer proxy", passing
|
||||
it all the required information to perform the flashing, such as the image
|
||||
path, the device path, the current settings, etc.
|
||||
|
||||
The writer proxy then checks if its currently elevated, and if not, prompts the
|
||||
user for elevation and re-spawns itself.
|
||||
|
||||
Once the writer proxy has enough permissions to directly access devices, it
|
||||
spawns the Etcher CLI passing the `--robot` option along with all the
|
||||
information gathered before. The `--robot` option basically tells the Etcher
|
||||
CLI to output state information in a way that can be very easily parsed by the
|
||||
parent process.
|
||||
|
||||
The output of the Etcher CLI is then sent to the IPC server that was opened by
|
||||
the GUI, which nicely displays them in the progress bar the user sees.
|
||||
|
||||
Summary
|
||||
-------
|
||||
|
||||
There are lots of details we're omitting for the sake of clarity. Feel free to
|
||||
dive in inside the child writer code, which is heavily commented to explain the
|
||||
reasons behind each decision or workaround.
|
||||
|
||||
Don't hesitate in getting in touch if you have any suggestion, or just want to
|
||||
know more!
|
@ -1,100 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
|
||||
/**
|
||||
* @summary Get the explicit boolean form of an argument
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @description
|
||||
* We refer as "explicit boolean form of an argument" to a boolean
|
||||
* argument in either normal or negated form.
|
||||
*
|
||||
* For example: `--check` and `--no-check`;
|
||||
*
|
||||
* @param {String} argumentName - argument name
|
||||
* @param {Boolean} value - argument value
|
||||
* @returns {String} argument
|
||||
*
|
||||
* @example
|
||||
* console.log(cli.getBooleanArgumentForm('check', true));
|
||||
* > '--check'
|
||||
*
|
||||
* @example
|
||||
* console.log(cli.getBooleanArgumentForm('check', false));
|
||||
* > '--no-check'
|
||||
*/
|
||||
exports.getBooleanArgumentForm = (argumentName, value) => {
|
||||
const prefix = _.attempt(() => {
|
||||
if (!value) {
|
||||
return '--no-'
|
||||
}
|
||||
|
||||
const SHORT_OPTION_LENGTH = 1
|
||||
if (_.size(argumentName) === SHORT_OPTION_LENGTH) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
return '--'
|
||||
})
|
||||
|
||||
return prefix + argumentName
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get CLI writer arguments
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {Object} options - options
|
||||
* @param {String} options.image - image
|
||||
* @param {String} options.device - device
|
||||
* @param {String} options.entryPoint - entry point
|
||||
* @param {Boolean} [options.validateWriteOnSuccess] - validate write on success
|
||||
* @param {Boolean} [options.unmountOnSuccess] - unmount on success
|
||||
* @returns {String[]} arguments
|
||||
*
|
||||
* @example
|
||||
* const argv = cli.getArguments({
|
||||
* image: 'path/to/rpi.img',
|
||||
* device: '/dev/disk2'
|
||||
* entryPoint: 'path/to/app.asar',
|
||||
* validateWriteOnSuccess: true,
|
||||
* unmountOnSuccess: true
|
||||
* });
|
||||
*/
|
||||
exports.getArguments = (options) => {
|
||||
const argv = [
|
||||
options.entryPoint,
|
||||
options.image,
|
||||
'--drive',
|
||||
options.device,
|
||||
|
||||
// Explicitly set the boolean flag in positive
|
||||
// or negative way in order to be on the safe
|
||||
// side in case the Etcher CLI changes the
|
||||
// default value of these options.
|
||||
exports.getBooleanArgumentForm('unmount', options.unmountOnSuccess),
|
||||
exports.getBooleanArgumentForm('check', options.validateWriteOnSuccess)
|
||||
|
||||
]
|
||||
|
||||
return argv
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const path = require('path')
|
||||
const os = require('os')
|
||||
|
||||
/**
|
||||
* @summary Child writer constants
|
||||
* @namespace CONSTANTS
|
||||
* @public
|
||||
*/
|
||||
module.exports = {
|
||||
|
||||
/**
|
||||
* @property {String} TMP_DIRECTORY
|
||||
* @memberof CONSTANTS
|
||||
* @constant
|
||||
*/
|
||||
TMP_DIRECTORY: path.join(process.env.XDG_RUNTIME_DIR || os.tmpdir(), path.sep),
|
||||
|
||||
/**
|
||||
* @property {String} PROJECT_ROOT
|
||||
* @memberof CONSTANTS
|
||||
*/
|
||||
PROJECT_ROOT: path.join(__dirname, '..', '..'),
|
||||
|
||||
/**
|
||||
* @property {String} WRITER_PROXY_SCRIPT
|
||||
* @memberof CONSTANTS
|
||||
*/
|
||||
WRITER_PROXY_SCRIPT: path.join(__dirname, 'writer-proxy.js')
|
||||
|
||||
}
|
@ -1,223 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const EventEmitter = require('events').EventEmitter
|
||||
const _ = require('lodash')
|
||||
const childProcess = require('child_process')
|
||||
const ipc = require('node-ipc')
|
||||
const rendererUtils = require('./renderer-utils')
|
||||
const cli = require('./cli')
|
||||
const CONSTANTS = require('./constants')
|
||||
const EXIT_CODES = require('../shared/exit-codes')
|
||||
const robot = require('../shared/robot')
|
||||
|
||||
// There might be multiple Etcher instances running at
|
||||
// the same time, therefore we must ensure each IPC
|
||||
// server/client has a different name.
|
||||
process.env.IPC_SERVER_ID = `etcher-server-${process.pid}`
|
||||
process.env.IPC_CLIENT_ID = `etcher-client-${process.pid}`
|
||||
|
||||
ipc.config.id = process.env.IPC_SERVER_ID
|
||||
ipc.config.socketRoot = CONSTANTS.TMP_DIRECTORY
|
||||
ipc.config.silent = true
|
||||
|
||||
/**
|
||||
* @summary Perform a write
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @param {String} image - image
|
||||
* @param {Object} drive - drive
|
||||
* @param {Object} options - options
|
||||
* @returns {EventEmitter} event emitter
|
||||
*
|
||||
* @example
|
||||
* const child = childWriter.write('path/to/rpi.img', {
|
||||
* device: '/dev/disk2'
|
||||
* }, {
|
||||
* validateWriteOnSuccess: true,
|
||||
* unmountOnSuccess: true
|
||||
* });
|
||||
*
|
||||
* child.on('progress', (state) => {
|
||||
* console.log(state);
|
||||
* });
|
||||
*
|
||||
* child.on('error', (error) => {
|
||||
* throw error;
|
||||
* });
|
||||
*
|
||||
* child.on('done', () => {
|
||||
* console.log('Validation was successful!');
|
||||
* });
|
||||
*/
|
||||
exports.write = (image, drive, options) => {
|
||||
const emitter = new EventEmitter()
|
||||
|
||||
const argv = cli.getArguments({
|
||||
entryPoint: rendererUtils.getApplicationEntryPoint(),
|
||||
image,
|
||||
device: drive.device,
|
||||
validateWriteOnSuccess: options.validateWriteOnSuccess,
|
||||
unmountOnSuccess: options.unmountOnSuccess
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Emit an error to the client
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @param {Error} error - error
|
||||
*
|
||||
* @example
|
||||
* emitError(new Error('foo bar'));
|
||||
*/
|
||||
const emitError = (error) => {
|
||||
terminateServer()
|
||||
emitter.emit('error', error)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Bridge robot message to the child writer caller
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @param {String} message - robot message
|
||||
*
|
||||
* @example
|
||||
* bridgeRobotMessage(robot.buildMessage('foo', {
|
||||
* bar: 'baz'
|
||||
* }));
|
||||
*/
|
||||
const bridgeRobotMessage = (message) => {
|
||||
const parsedMessage = _.attempt(() => {
|
||||
if (robot.isMessage(message)) {
|
||||
return robot.parseMessage(message)
|
||||
}
|
||||
|
||||
// Don't be so strict. If a message doesn't look like
|
||||
// a robot message, then make the child writer log it
|
||||
// for debugging purposes.
|
||||
return robot.parseMessage(robot.buildMessage(robot.COMMAND.LOG, {
|
||||
message
|
||||
}))
|
||||
})
|
||||
|
||||
if (_.isError(parsedMessage)) {
|
||||
emitError(parsedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// These are lighweight accessor methods for
|
||||
// the properties of the parsed message
|
||||
const messageCommand = robot.getCommand(parsedMessage)
|
||||
const messageData = robot.getData(parsedMessage)
|
||||
|
||||
// The error object is decomposed by the CLI for serialisation
|
||||
// purposes. We compose it back to an `Error` here in order
|
||||
// to provide better encapsulation.
|
||||
if (messageCommand === robot.COMMAND.ERROR) {
|
||||
emitError(robot.recomposeErrorMessage(parsedMessage))
|
||||
} else if (messageCommand === robot.COMMAND.LOG) {
|
||||
// If the message data is an object and it contains a
|
||||
// message string then log the message string only.
|
||||
if (_.isPlainObject(messageData) && _.isString(messageData.message)) {
|
||||
console.log(messageData.message)
|
||||
} else {
|
||||
console.log(messageData)
|
||||
}
|
||||
} else {
|
||||
emitter.emit(messageCommand, messageData)
|
||||
}
|
||||
} catch (error) {
|
||||
emitError(error)
|
||||
}
|
||||
}
|
||||
|
||||
ipc.server.on('error', emitError)
|
||||
ipc.server.on('message', bridgeRobotMessage)
|
||||
|
||||
ipc.server.on('start', () => {
|
||||
const child = childProcess.fork(CONSTANTS.WRITER_PROXY_SCRIPT, argv, {
|
||||
silent: true,
|
||||
env: process.env
|
||||
})
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
console.info(`WRITER: ${data.toString()}`)
|
||||
})
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
bridgeRobotMessage(data.toString())
|
||||
|
||||
// This function causes the `close` event to be emitted
|
||||
child.kill()
|
||||
})
|
||||
|
||||
child.on('error', emitError)
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
terminateServer()
|
||||
|
||||
if (code === EXIT_CODES.CANCELLED) {
|
||||
return emitter.emit('done', {
|
||||
cancelled: true
|
||||
})
|
||||
}
|
||||
|
||||
// We shouldn't emit the `done` event manually here
|
||||
// since the writer process will take care of it.
|
||||
if (code === EXIT_CODES.SUCCESS || code === EXIT_CODES.VALIDATION_ERROR) {
|
||||
return null
|
||||
}
|
||||
|
||||
const error = new Error(`Child process exited with code ${code}, signal ${signal}`)
|
||||
error.code = code
|
||||
error.signal = signal
|
||||
|
||||
return emitError(error)
|
||||
})
|
||||
})
|
||||
|
||||
ipc.server.start()
|
||||
|
||||
return emitter
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* This file is only meant to be loaded by the renderer process.
|
||||
*/
|
||||
|
||||
const path = require('path')
|
||||
const isRunningInAsar = require('electron-is-running-in-asar')
|
||||
const electron = require('electron')
|
||||
const CONSTANTS = require('./constants')
|
||||
|
||||
/**
|
||||
* @summary Get application entry point
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @returns {String} entry point
|
||||
*
|
||||
* @example
|
||||
* const entryPoint = rendererUtils.getApplicationEntryPoint();
|
||||
*/
|
||||
exports.getApplicationEntryPoint = () => {
|
||||
if (isRunningInAsar()) {
|
||||
return path.join(process.resourcesPath, 'app.asar')
|
||||
}
|
||||
|
||||
const ENTRY_POINT_ARGV_INDEX = 1
|
||||
const relativeEntryPoint = electron.remote.process.argv[ENTRY_POINT_ARGV_INDEX]
|
||||
|
||||
return path.join(CONSTANTS.PROJECT_ROOT, relativeEntryPoint)
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
|
||||
/**
|
||||
* @summary Split stringified object lines
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @description
|
||||
* This function takes special care to not consider new lines
|
||||
* inside the object properties.
|
||||
*
|
||||
* @param {String} lines - lines
|
||||
* @returns {String[]} split lines
|
||||
*
|
||||
* @example
|
||||
* const result = utils.splitObjectLines('{"foo":"bar"}\n{"hello":"Hello\nWorld"}');
|
||||
* console.log(result);
|
||||
*
|
||||
* > [ '{"foo":"bar"}', '{"hello":"Hello\nWorld"}' ]
|
||||
*/
|
||||
exports.splitObjectLines = (lines) => {
|
||||
return _.chain(lines)
|
||||
.split(/((?:[^\n"']|"[^"]*"|'[^']*')+)/)
|
||||
.map(_.trim)
|
||||
.reject(_.isEmpty)
|
||||
.value()
|
||||
}
|
@ -1,215 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const Bluebird = require('bluebird')
|
||||
const childProcess = require('child_process')
|
||||
const ipc = require('node-ipc')
|
||||
const _ = require('lodash')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
const utils = require('./utils')
|
||||
const EXIT_CODES = require('../shared/exit-codes')
|
||||
const robot = require('../shared/robot')
|
||||
const permissions = require('../shared/permissions')
|
||||
const packageJSON = require('../../package.json')
|
||||
const CONSTANTS = require('./constants')
|
||||
|
||||
/* eslint-disable no-eq-null */
|
||||
|
||||
// This script is in charge of spawning the writer process and
|
||||
// ensuring it has the necessary privileges. It might look a bit
|
||||
// complex at first sight, but this is only because elevation
|
||||
// modules don't work in a spawn/fork fashion.
|
||||
//
|
||||
// This script spawns the writer process and redirects its `stdout`
|
||||
// and `stderr` to the parent process using IPC communication,
|
||||
// taking care of the writer elevation as needed.
|
||||
|
||||
/**
|
||||
* @summary The Etcher executable file path
|
||||
* @constant
|
||||
* @private
|
||||
* @type {String}
|
||||
*/
|
||||
const executable = _.first(process.argv)
|
||||
|
||||
/**
|
||||
* @summary The first index that represents an actual option argument
|
||||
* @constant
|
||||
* @private
|
||||
* @type {Number}
|
||||
*
|
||||
* @description
|
||||
* The first arguments are usually the program executable itself, etc.
|
||||
*/
|
||||
const OPTIONS_INDEX_START = 2
|
||||
|
||||
/**
|
||||
* @summary The list of Etcher argument options
|
||||
* @constant
|
||||
* @private
|
||||
* @type {String[]}
|
||||
*/
|
||||
const etcherArguments = process.argv.slice(OPTIONS_INDEX_START)
|
||||
|
||||
ipc.config.id = process.env.IPC_CLIENT_ID
|
||||
ipc.config.socketRoot = CONSTANTS.TMP_DIRECTORY
|
||||
ipc.config.silent = true
|
||||
|
||||
// > If set to 0, the client will NOT try to reconnect.
|
||||
// See https://github.com/RIAEvangelist/node-ipc/
|
||||
//
|
||||
// The purpose behind this change is for this process
|
||||
// to emit a "disconnect" event as soon as the GUI
|
||||
// process is closed, so we can kill the CLI as well.
|
||||
ipc.config.stopRetrying = 0
|
||||
|
||||
permissions.isElevated().then((elevated) => {
|
||||
if (!elevated) {
|
||||
console.log('Attempting to elevate')
|
||||
|
||||
const commandArguments = _.attempt(() => {
|
||||
if (os.platform() === 'linux' && process.env.APPIMAGE && process.env.APPDIR) {
|
||||
// Translate the current arguments to point to the AppImage
|
||||
// Relative paths are resolved from `/tmp/.mount_XXXXXX/usr`
|
||||
const translatedArguments = _.chain(process.argv)
|
||||
.tail()
|
||||
.invokeMap('replace', path.join(process.env.APPDIR, 'usr/'), '')
|
||||
.value()
|
||||
|
||||
return _.concat([ process.env.APPIMAGE ], translatedArguments)
|
||||
}
|
||||
|
||||
return process.argv
|
||||
})
|
||||
|
||||
// For debugging purposes
|
||||
console.log(`Running: ${commandArguments.join(' ')}`)
|
||||
|
||||
const commandEnv = {
|
||||
PATH: process.env.PATH,
|
||||
DEBUG: process.env.DEBUG,
|
||||
XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
|
||||
ELECTRON_RUN_AS_NODE: 1,
|
||||
IPC_SERVER_ID: process.env.IPC_SERVER_ID,
|
||||
IPC_CLIENT_ID: process.env.IPC_CLIENT_ID,
|
||||
|
||||
// This environment variable prevents the AppImages
|
||||
// desktop integration script from presenting the
|
||||
// "installation" dialog.
|
||||
SKIP: 1
|
||||
}
|
||||
|
||||
return permissions.elevateCommand(commandArguments, {
|
||||
applicationName: packageJSON.displayName,
|
||||
environment: commandEnv
|
||||
}).then((results) => {
|
||||
if (results.cancelled) {
|
||||
process.exit(EXIT_CODES.CANCELLED)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
console.log('Re-spawning with elevation')
|
||||
|
||||
return new Bluebird((resolve, reject) => {
|
||||
let child = null
|
||||
|
||||
/**
|
||||
* @summary Emit an object message to the IPC server
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @param {Buffer} data - json message data
|
||||
*
|
||||
* @example
|
||||
* emitMessage(Buffer.from(JSON.stringify({
|
||||
* foo: 'bar'
|
||||
* })));
|
||||
*/
|
||||
const emitMessage = (data) => {
|
||||
// Output from stdout/stderr coming from the CLI might be buffered,
|
||||
// causing several progress lines to come up at once as single message.
|
||||
// Trying to parse multiple JSON objects separated by new lines will
|
||||
// of course make the parser confused, causing errors later on.
|
||||
_.each(utils.splitObjectLines(data.toString()), (object) => {
|
||||
ipc.of[process.env.IPC_SERVER_ID].emit('message', object)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Emit an error message over the IPC and shut down the child
|
||||
* @function
|
||||
* @private
|
||||
* @param {Error} error - error
|
||||
* @example
|
||||
* onError(error)
|
||||
*/
|
||||
const onError = (error) => {
|
||||
ipc.of[process.env.IPC_SERVER_ID].emit('message', {
|
||||
error: error.message,
|
||||
data: error.stack
|
||||
})
|
||||
child && child.kill()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
ipc.connectTo(process.env.IPC_SERVER_ID, () => {
|
||||
ipc.of[process.env.IPC_SERVER_ID].on('error', onError)
|
||||
ipc.of[process.env.IPC_SERVER_ID].on('disconnect', () => {
|
||||
onError(new Error('Writer process disconnected'))
|
||||
})
|
||||
|
||||
ipc.of[process.env.IPC_SERVER_ID].on('connect', () => {
|
||||
// Inherit the parent evnironment
|
||||
const childEnv = _.assign({}, process.env, {
|
||||
ELECTRON_RUN_AS_NODE: 1,
|
||||
ETCHER_CLI_ROBOT: 1,
|
||||
|
||||
// Enable extra logging from mountutils
|
||||
// See https://github.com/resin-io-modules/mountutils
|
||||
MOUNTUTILS_DEBUG: 1
|
||||
})
|
||||
|
||||
child = childProcess.spawn(executable, etcherArguments, {
|
||||
env: childEnv
|
||||
})
|
||||
|
||||
child.on('error', onError)
|
||||
child.on('exit', (code, signal) => {
|
||||
if (code != null && signal == null) {
|
||||
resolve(code)
|
||||
} else {
|
||||
const error = new Error(`Exited with code ${code}, signal ${signal}`)
|
||||
error.code = code
|
||||
error.signal = signal
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
|
||||
child.stdout.on('data', emitMessage)
|
||||
child.stderr.on('data', emitMessage)
|
||||
})
|
||||
})
|
||||
}).then((exitCode) => {
|
||||
process.exit(exitCode)
|
||||
})
|
||||
}).catch((error) => {
|
||||
robot.printError(error)
|
||||
process.exit(EXIT_CODES.GENERAL_ERROR)
|
||||
})
|
@ -9,30 +9,7 @@ This module also has the task of unmounting the drives before and after
|
||||
flashing.
|
||||
|
||||
Notice the Etcher CLI is not worried about elevation, and assumes it has enough
|
||||
permissions to continue, throwing an error otherwise. Consult the
|
||||
[`lib/child-writer`][child-writer] module to understand how elevation works on
|
||||
Etcher.
|
||||
|
||||
The robot option
|
||||
----------------
|
||||
|
||||
Setting the `ETCHER_CLI_ROBOT` environment variable allows other applications
|
||||
to easily consume the output of the Etcher CLI in real-time. When using the
|
||||
`ETCHER_CLI_ROBOT` option, the `--yes` option is implicit, therefore you need
|
||||
to manually specify `--drive`.
|
||||
|
||||
When `ETCHER_CLI_ROBOT` is used, the program will output JSON lines containing
|
||||
the progress state and other useful information. For example:
|
||||
|
||||
```
|
||||
$ sudo ETCHER_CLI_ROBOT=1 etcher image.iso --drive /dev/disk2
|
||||
{"command":"progress","data":{"type":"write","percentage":1,"eta":130,"speed":1703936}}
|
||||
...
|
||||
{"command":"progress","data":{"type":"check","percentage":100,"eta":0,"speed":17180514}}
|
||||
{"command":"done","data":{"sourceChecksum":"27c39a5d"}}
|
||||
```
|
||||
|
||||
See documentation about the robot mode at [`lib/shared/robot`][robot].
|
||||
permissions to continue, throwing an error otherwise.
|
||||
|
||||
Exit codes
|
||||
----------
|
||||
@ -42,5 +19,3 @@ These are documented in [`lib/shared/exit-codes.js`][exit-codes] and are also
|
||||
printed on the Etcher CLI help page.
|
||||
|
||||
[exit-codes]: https://github.com/resin-io/etcher/blob/master/lib/shared/exit-codes.js
|
||||
[robot]: https://github.com/resin-io/etcher/tree/master/lib/shared/robot
|
||||
[child-writer]: https://github.com/resin-io/etcher/tree/master/lib/child-writer
|
||||
|
@ -16,16 +16,13 @@
|
||||
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const path = require('path')
|
||||
const Bluebird = require('bluebird')
|
||||
const visuals = require('resin-cli-visuals')
|
||||
const form = require('resin-cli-form')
|
||||
const drivelist = Bluebird.promisifyAll(require('drivelist'))
|
||||
const writer = require('./writer')
|
||||
const utils = require('./utils')
|
||||
const options = require('./options')
|
||||
const robot = require('../shared/robot')
|
||||
const messages = require('../shared/messages')
|
||||
const EXIT_CODES = require('../shared/exit-codes')
|
||||
const errors = require('../shared/errors')
|
||||
@ -61,7 +58,7 @@ permissions.isElevated().then((elevated) => {
|
||||
// If `options.yes` is `false`, pass `null`,
|
||||
// otherwise the question will not be asked because
|
||||
// `false` is a defined value.
|
||||
yes: robot.isEnabled(process.env) || options.yes || null
|
||||
yes: options.yes || null
|
||||
|
||||
}
|
||||
})
|
||||
@ -78,55 +75,26 @@ permissions.isElevated().then((elevated) => {
|
||||
check: new visuals.Progress('Validating')
|
||||
}
|
||||
|
||||
return drivelist.listAsync().then((drives) => {
|
||||
const selectedDrive = _.find(drives, {
|
||||
device: answers.drive
|
||||
})
|
||||
|
||||
if (!selectedDrive) {
|
||||
throw errors.createUserError({
|
||||
title: 'The selected drive was not found',
|
||||
description: `We can't find ${answers.drive} in your system. Did you unplug the drive?`
|
||||
})
|
||||
}
|
||||
|
||||
return writer.writeImage(imagePath, selectedDrive, {
|
||||
return writer.writeImage(imagePath, answers.drive, {
|
||||
unmountOnSuccess: options.unmount,
|
||||
validateWriteOnSuccess: options.check
|
||||
}, (state) => {
|
||||
if (robot.isEnabled(process.env)) {
|
||||
robot.printMessage('progress', {
|
||||
type: state.type,
|
||||
percentage: Math.floor(state.percentage),
|
||||
eta: state.eta,
|
||||
speed: Math.floor(state.speed)
|
||||
})
|
||||
} else {
|
||||
progressBars[state.type].update(state)
|
||||
}
|
||||
}).then((results) => {
|
||||
return {
|
||||
imagePath,
|
||||
flash: results,
|
||||
drive: selectedDrive
|
||||
flash: results
|
||||
}
|
||||
})
|
||||
})
|
||||
}).then((results) => {
|
||||
return Bluebird.try(() => {
|
||||
if (robot.isEnabled(process.env)) {
|
||||
return robot.printMessage('done', {
|
||||
sourceChecksum: results.flash.sourceChecksum
|
||||
})
|
||||
}
|
||||
|
||||
console.log(messages.info.flashComplete({
|
||||
drive: results.drive,
|
||||
drive: results.flash.drive,
|
||||
imageBasename: path.basename(results.imagePath)
|
||||
}))
|
||||
|
||||
if (results.flash.sourceChecksum) {
|
||||
console.log(`Checksum: ${results.flash.sourceChecksum}`)
|
||||
if (results.flash.checksum.crc32) {
|
||||
console.log(`Checksum: ${results.flash.checksum.crc32}`)
|
||||
}
|
||||
|
||||
return Bluebird.resolve()
|
||||
@ -135,10 +103,6 @@ permissions.isElevated().then((elevated) => {
|
||||
})
|
||||
}).catch((error) => {
|
||||
return Bluebird.try(() => {
|
||||
if (robot.isEnabled(process.env)) {
|
||||
return robot.printError(error)
|
||||
}
|
||||
|
||||
utils.printError(error)
|
||||
return Bluebird.resolve()
|
||||
}).then(() => {
|
||||
|
@ -20,7 +20,6 @@ const _ = require('lodash')
|
||||
const fs = require('fs')
|
||||
const yargs = require('yargs')
|
||||
const utils = require('./utils')
|
||||
const robot = require('../shared/robot')
|
||||
const EXIT_CODES = require('../shared/exit-codes')
|
||||
const errors = require('../shared/errors')
|
||||
const packageJSON = require('../../package.json')
|
||||
@ -97,13 +96,8 @@ module.exports = yargs
|
||||
title: message
|
||||
})
|
||||
|
||||
if (robot.isEnabled(process.env)) {
|
||||
robot.printError(errorObject)
|
||||
} else {
|
||||
yargs.showHelp()
|
||||
utils.printError(errorObject)
|
||||
}
|
||||
|
||||
process.exit(EXIT_CODES.GENERAL_ERROR)
|
||||
})
|
||||
|
||||
@ -123,17 +117,6 @@ module.exports = yargs
|
||||
return true
|
||||
})
|
||||
|
||||
.check((argv) => {
|
||||
if (robot.isEnabled(process.env) && !argv.drive) {
|
||||
throw errors.createUserError({
|
||||
title: 'Missing drive',
|
||||
description: 'You need to explicitly pass a drive when enabling robot mode'
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// Assert that if the `yes` flag is provided, the `drive` flag is also provided.
|
||||
.check((argv) => {
|
||||
if (argv.yes && !argv.drive) {
|
||||
|
@ -16,14 +16,16 @@
|
||||
|
||||
'use strict'
|
||||
|
||||
const ImageWriter = require('../writer')
|
||||
const _ = require('lodash')
|
||||
const Bluebird = require('bluebird')
|
||||
const fs = Bluebird.promisifyAll(require('fs'))
|
||||
const mountutils = Bluebird.promisifyAll(require('mountutils'))
|
||||
const drivelist = Bluebird.promisifyAll(require('drivelist'))
|
||||
const os = require('os')
|
||||
const imageStream = require('../image-stream')
|
||||
const errors = require('../shared/errors')
|
||||
const constraints = require('../shared/drive-constraints')
|
||||
const ImageWriter = require('../writer')
|
||||
const diskpart = require('./diskpart')
|
||||
|
||||
/**
|
||||
@ -38,12 +40,8 @@ const UNMOUNT_ON_SUCCESS_TIMEOUT_MS = 2000
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @description
|
||||
* See https://github.com/resin-io-modules/etcher-image-write for information
|
||||
* about the `state` object passed to `onProgress` callback.
|
||||
*
|
||||
* @param {String} imagePath - path to image
|
||||
* @param {Object} drive - drive
|
||||
* @param {String} drive - drive
|
||||
* @param {Object} options - options
|
||||
* @param {Boolean} [options.unmountOnSuccess=false] - unmount on success
|
||||
* @param {Boolean} [options.validateWriteOnSuccess=false] - validate write on success
|
||||
@ -53,9 +51,7 @@ const UNMOUNT_ON_SUCCESS_TIMEOUT_MS = 2000
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @example
|
||||
* writer.writeImage('path/to/image.img', {
|
||||
* device: '/dev/disk2'
|
||||
* }, {
|
||||
* writer.writeImage('path/to/image.img', '/dev/disk2', {
|
||||
* unmountOnSuccess: true,
|
||||
* validateWriteOnSuccess: true
|
||||
* }, (state) => {
|
||||
@ -65,15 +61,30 @@ const UNMOUNT_ON_SUCCESS_TIMEOUT_MS = 2000
|
||||
* });
|
||||
*/
|
||||
exports.writeImage = (imagePath, drive, options, onProgress) => {
|
||||
return drivelist.listAsync().then((drives) => {
|
||||
const selectedDrive = _.find(drives, {
|
||||
device: drive
|
||||
})
|
||||
|
||||
if (!selectedDrive) {
|
||||
throw errors.createUserError({
|
||||
title: 'The selected drive was not found',
|
||||
description: `We can't find ${drive} in your system. Did you unplug the drive?`,
|
||||
code: 'EUNPLUGGED'
|
||||
})
|
||||
}
|
||||
|
||||
return selectedDrive
|
||||
}).then((driveObject) => {
|
||||
return Bluebird.try(() => {
|
||||
// Unmounting a drive in Windows means we can't write to it anymore
|
||||
if (os.platform() === 'win32') {
|
||||
return Bluebird.resolve()
|
||||
}
|
||||
|
||||
return mountutils.unmountDiskAsync(drive.device)
|
||||
return mountutils.unmountDiskAsync(driveObject.device)
|
||||
}).then(() => {
|
||||
return diskpart.clean(drive.device)
|
||||
return diskpart.clean(driveObject.device)
|
||||
}).then(() => {
|
||||
/* eslint-disable no-bitwise */
|
||||
const flags = fs.constants.O_RDWR |
|
||||
@ -83,10 +94,10 @@ exports.writeImage = (imagePath, drive, options, onProgress) => {
|
||||
fs.constants.O_DIRECT
|
||||
/* eslint-enable no-bitwise */
|
||||
|
||||
return fs.openAsync(drive.raw, flags)
|
||||
return fs.openAsync(driveObject.raw, flags)
|
||||
}).then((driveFileDescriptor) => {
|
||||
return imageStream.getFromFilePath(imagePath).then((image) => {
|
||||
if (!constraints.isDriveLargeEnough(drive, image)) {
|
||||
if (!constraints.isDriveLargeEnough(driveObject, image)) {
|
||||
throw errors.createUserError({
|
||||
title: 'The image you selected is too big for this drive',
|
||||
description: 'Please connect a bigger drive and try again'
|
||||
@ -96,7 +107,7 @@ exports.writeImage = (imagePath, drive, options, onProgress) => {
|
||||
const writer = new ImageWriter({
|
||||
image,
|
||||
fd: driveFileDescriptor,
|
||||
path: drive.raw,
|
||||
path: driveObject.raw,
|
||||
verify: options.validateWriteOnSuccess,
|
||||
checksumAlgorithms: [ 'crc32' ]
|
||||
})
|
||||
@ -106,7 +117,10 @@ exports.writeImage = (imagePath, drive, options, onProgress) => {
|
||||
return new Bluebird((resolve, reject) => {
|
||||
writer.on('progress', onProgress)
|
||||
writer.on('error', reject)
|
||||
writer.on('finish', resolve)
|
||||
writer.on('finish', (results) => {
|
||||
results.drive = driveObject
|
||||
resolve(results)
|
||||
})
|
||||
})
|
||||
}).tap(() => {
|
||||
// Make sure the device stream file descriptor is closed
|
||||
@ -124,9 +138,10 @@ exports.writeImage = (imagePath, drive, options, onProgress) => {
|
||||
// unmount to quickly, then the drive might get re-mounted
|
||||
// right afterwards.
|
||||
return Bluebird.delay(UNMOUNT_ON_SUCCESS_TIMEOUT_MS)
|
||||
.return(drive.device)
|
||||
.return(driveObject.device)
|
||||
.then(mountutils.unmountDiskAsync)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
130
lib/gui/modules/child-writer.js
Normal file
130
lib/gui/modules/child-writer.js
Normal file
@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Copyright 2017 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.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const ipc = require('node-ipc')
|
||||
const EXIT_CODES = require('../../shared/exit-codes')
|
||||
const errors = require('../../shared/errors')
|
||||
const writer = require('../../cli/writer')
|
||||
|
||||
ipc.config.id = process.env.IPC_CLIENT_ID
|
||||
ipc.config.socketRoot = process.env.IPC_SOCKET_ROOT
|
||||
ipc.config.silent = false
|
||||
|
||||
// > If set to 0, the client will NOT try to reconnect.
|
||||
// See https://github.com/RIAEvangelist/node-ipc/
|
||||
//
|
||||
// The purpose behind this change is for this process
|
||||
// to emit a "disconnect" event as soon as the GUI
|
||||
// process is closed, so we can kill this process as well.
|
||||
ipc.config.stopRetrying = 0
|
||||
|
||||
const IPC_SERVER_ID = process.env.IPC_SERVER_ID
|
||||
|
||||
/**
|
||||
* @summary Send a log debug message to the IPC server
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @param {String} message - message
|
||||
*
|
||||
* @example
|
||||
* log('Hello world!')
|
||||
*/
|
||||
const log = (message) => {
|
||||
ipc.of[IPC_SERVER_ID].emit('log', message)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Terminate the child writer process
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @param {Number} [code=0] - exit code
|
||||
*
|
||||
* @example
|
||||
* terminate(1)
|
||||
*/
|
||||
const terminate = (code) => {
|
||||
ipc.disconnect(IPC_SERVER_ID)
|
||||
process.exit(code || EXIT_CODES.SUCCESS)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Handle a child writer error
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @param {Error} error - error
|
||||
*
|
||||
* @example
|
||||
* handleError(new Error('Something bad happened!'))
|
||||
*/
|
||||
const handleError = (error) => {
|
||||
ipc.of[IPC_SERVER_ID].emit('error', errors.toJSON(error))
|
||||
terminate(EXIT_CODES.GENERAL_ERROR)
|
||||
}
|
||||
|
||||
ipc.connectTo(IPC_SERVER_ID, () => {
|
||||
process.once('uncaughtException', handleError)
|
||||
|
||||
// Gracefully exit on the following cases. If the parent
|
||||
// process detects that child exit successfully but
|
||||
// no flashing information is available, then it will
|
||||
// assume that the child died halfway through.
|
||||
|
||||
process.once('SIGINT', () => {
|
||||
terminate(EXIT_CODES.SUCCESS)
|
||||
})
|
||||
process.once('SIGTERM', () => {
|
||||
terminate(EXIT_CODES.SUCCESS)
|
||||
})
|
||||
|
||||
// The IPC server failed. Abort.
|
||||
ipc.of[IPC_SERVER_ID].on('error', () => {
|
||||
terminate(EXIT_CODES.SUCCESS)
|
||||
})
|
||||
|
||||
// The IPC server was disconnected. Abort.
|
||||
ipc.of[IPC_SERVER_ID].on('disconnect', () => {
|
||||
terminate(EXIT_CODES.SUCCESS)
|
||||
})
|
||||
|
||||
ipc.of[IPC_SERVER_ID].on('connect', () => {
|
||||
log(`Successfully connected to IPC server: ${IPC_SERVER_ID}, socket root ${ipc.config.socketRoot}`)
|
||||
log(`Image: ${process.env.OPTION_IMAGE}`)
|
||||
log(`Device: ${process.env.OPTION_DEVICE}`)
|
||||
|
||||
// These come as strings containing 0 or 1. We need to convert
|
||||
// them to numbers first, and then to booleans, otherwise something
|
||||
// like `Boolean('0')` will be `true`
|
||||
const unmountOnSuccess = Boolean(Number(process.env.OPTION_UNMOUNT))
|
||||
const validateWriteOnSuccess = Boolean(Number(process.env.OPTION_VALIDATE))
|
||||
log(`Umount on success: ${unmountOnSuccess}`)
|
||||
log(`Validate on success: ${validateWriteOnSuccess}`)
|
||||
|
||||
writer.writeImage(process.env.OPTION_IMAGE, process.env.OPTION_DEVICE, {
|
||||
unmountOnSuccess,
|
||||
validateWriteOnSuccess
|
||||
}, (state) => {
|
||||
ipc.of[IPC_SERVER_ID].emit('state', state)
|
||||
}).then((results) => {
|
||||
ipc.of[IPC_SERVER_ID].emit('done', results)
|
||||
terminate(EXIT_CODES.SUCCESS)
|
||||
}).catch(handleError)
|
||||
})
|
||||
})
|
@ -18,12 +18,40 @@
|
||||
|
||||
const Bluebird = require('bluebird')
|
||||
const _ = require('lodash')
|
||||
const childWriter = require('../../child-writer')
|
||||
const path = require('path')
|
||||
const os = require('os')
|
||||
const ipc = require('node-ipc')
|
||||
const isRunningInAsar = require('electron-is-running-in-asar')
|
||||
const electron = require('electron')
|
||||
const settings = require('../models/settings')
|
||||
const flashState = require('../../shared/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 packageJSON = require('../../../package.json')
|
||||
|
||||
/**
|
||||
* @summary Get application entry point
|
||||
* @function
|
||||
* @private
|
||||
*
|
||||
* @returns {String} entry point
|
||||
*
|
||||
* @example
|
||||
* const entryPoint = imageWriter.getApplicationEntryPoint()
|
||||
*/
|
||||
const getApplicationEntryPoint = () => {
|
||||
if (isRunningInAsar()) {
|
||||
return path.join(process.resourcesPath, 'app.asar')
|
||||
}
|
||||
|
||||
const ENTRY_POINT_ARGV_INDEX = 1
|
||||
const relativeEntryPoint = electron.remote.process.argv[ENTRY_POINT_ARGV_INDEX]
|
||||
|
||||
const PROJECT_ROOT = path.join(__dirname, '..', '..', '..')
|
||||
return path.join(PROJECT_ROOT, relativeEntryPoint)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Perform write operation
|
||||
@ -48,14 +76,134 @@ const analytics = require('../modules/analytics')
|
||||
* })
|
||||
*/
|
||||
exports.performWrite = (image, drive, onProgress) => {
|
||||
return new Bluebird((resolve, reject) => {
|
||||
const child = childWriter.write(image, drive, {
|
||||
validateWriteOnSuccess: settings.get('validateWriteOnSuccess'),
|
||||
unmountOnSuccess: settings.get('unmountOnSuccess')
|
||||
// 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)
|
||||
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()
|
||||
})
|
||||
child.on('error', reject)
|
||||
child.on('done', resolve)
|
||||
child.on('progress', onProgress)
|
||||
|
||||
ipc.server.stop()
|
||||
}
|
||||
|
||||
return new Bluebird((resolve, reject) => {
|
||||
ipc.server.on('error', (error) => {
|
||||
terminateServer()
|
||||
const errorObject = _.isError(error) ? error : errors.fromJSON(error)
|
||||
reject(errorObject)
|
||||
})
|
||||
|
||||
ipc.server.on('log', (message) => {
|
||||
console.log(message)
|
||||
})
|
||||
|
||||
const flashResults = {}
|
||||
ipc.server.on('done', (results) => {
|
||||
_.merge(flashResults, results)
|
||||
})
|
||||
|
||||
ipc.server.on('state', onProgress)
|
||||
|
||||
const argv = _.attempt(() => {
|
||||
const entryPoint = getApplicationEntryPoint()
|
||||
|
||||
// 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) {
|
||||
return [
|
||||
process.env.APPIMAGE,
|
||||
|
||||
// Executing the AppImage with ELECTRON_RUN_AS_NODE opens
|
||||
// the Node.js REPL without loading the default entry point.
|
||||
// As a workaround, we can pass the path to the file we want
|
||||
// to load, relative to the usr/ directory of the mounted
|
||||
// AppImage.
|
||||
_.replace(entryPoint, path.join(process.env.APPDIR, 'usr/'), '')
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
_.first(process.argv),
|
||||
entryPoint
|
||||
]
|
||||
})
|
||||
|
||||
ipc.server.on('start', () => {
|
||||
console.log(`Elevating command: ${_.join(argv, ' ')}`)
|
||||
|
||||
permissions.elevateCommand(argv, {
|
||||
applicationName: packageJSON.displayName,
|
||||
environment: {
|
||||
IPC_SERVER_ID,
|
||||
IPC_CLIENT_ID,
|
||||
IPC_SOCKET_ROOT: ipc.config.socketRoot,
|
||||
ELECTRON_RUN_AS_NODE: 1,
|
||||
|
||||
// Casting to Number nicely converts booleans to 0 or 1.
|
||||
OPTION_VALIDATE: Number(settings.get('validateWriteOnSuccess')),
|
||||
OPTION_UNMOUNT: Number(settings.get('unmountOnSuccess')),
|
||||
|
||||
OPTION_IMAGE: image,
|
||||
OPTION_DEVICE: drive.device,
|
||||
|
||||
// This environment variable prevents the AppImages
|
||||
// desktop integration script from presenting the
|
||||
// "installation" dialog
|
||||
SKIP: 1
|
||||
}
|
||||
}).then((results) => {
|
||||
flashResults.cancelled = results.cancelled
|
||||
console.log('Flash results', flashResults)
|
||||
|
||||
// This likely means the child died halfway through
|
||||
if (!flashResults.cancelled && !flashResults.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'
|
||||
})
|
||||
}
|
||||
|
||||
return 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'
|
||||
}
|
||||
|
||||
return reject(error)
|
||||
}).finally(() => {
|
||||
console.log('Terminating IPC server')
|
||||
terminateServer()
|
||||
})
|
||||
})
|
||||
|
||||
ipc.server.start()
|
||||
})
|
||||
}
|
||||
|
||||
@ -116,6 +264,8 @@ exports.flash = (image, drive) => {
|
||||
analytics.logEvent('Input/output error', analyticsData)
|
||||
} else if (error.code === 'ENOSPC') {
|
||||
analytics.logEvent('Out of space', analyticsData)
|
||||
} else if (error.code === 'ECHILDDIED') {
|
||||
analytics.logEvent('Child died unexpectedly', analyticsData)
|
||||
} else {
|
||||
analytics.logEvent('Flash error', _.merge({
|
||||
error: errors.toJSON(error)
|
||||
|
@ -104,6 +104,8 @@ module.exports = function (
|
||||
FlashErrorModalService.show(messages.error.inputOutput())
|
||||
} else if (error.code === 'ENOSPC') {
|
||||
FlashErrorModalService.show(messages.error.notEnoughSpaceInDrive())
|
||||
} else if (error.code === 'ECHILDDIED') {
|
||||
FlashErrorModalService.show(messages.error.childWriterDied())
|
||||
} else {
|
||||
FlashErrorModalService.show(messages.error.genericFlashError())
|
||||
exceptionReporter.report(error)
|
||||
|
@ -115,6 +115,11 @@ module.exports = {
|
||||
'Looks like Etcher is not able to write to this location of the drive.',
|
||||
'This error is usually caused by a faulty drive, reader, or port.',
|
||||
'\n\nPlease try again with another drive, reader, or port.'
|
||||
].join(' ')),
|
||||
|
||||
childWriterDied: _.template([
|
||||
'The writer process ended unexpectedly.',
|
||||
'Please try again, and contact the Etcher team if the problem persists.'
|
||||
].join(' '))
|
||||
|
||||
}
|
||||
|
@ -118,7 +118,9 @@ exports.setProgressState = (state) => {
|
||||
type: store.Actions.SET_FLASH_STATE,
|
||||
data: {
|
||||
type: state.type,
|
||||
percentage: state.percentage,
|
||||
percentage: _.isNumber(state.percentage) && !_.isNaN(state.percentage)
|
||||
? Math.floor(state.percentage)
|
||||
: state.percentage,
|
||||
eta: state.eta,
|
||||
|
||||
speed: _.attempt(() => {
|
||||
|
@ -205,6 +205,13 @@ exports.elevateCommand = (command, options) => {
|
||||
title: 'Your user doesn\'t have enough privileges to proceed',
|
||||
description: 'This application requires sudo privileges to be able to write to drives'
|
||||
})
|
||||
}).catch((error) => {
|
||||
return _.startsWith(error.message, 'Command failed:')
|
||||
}, (error) => {
|
||||
throw errors.createUserError({
|
||||
title: 'The elevated process died unexpectedly',
|
||||
description: `The process error code was ${error.code}`
|
||||
})
|
||||
}).catch({
|
||||
message: 'User did not grant permission.'
|
||||
}, () => {
|
||||
|
@ -1,17 +1,8 @@
|
||||
The "robot" mechanism
|
||||
=====================
|
||||
|
||||
As discussed in [`lib/child-writer`][child-writer], communication between the
|
||||
main Etcher application and its elevated writer process happens through an IPC
|
||||
(Inter Process Communication) channel. In a nutshell, we emit every single line
|
||||
that the writer process prints to the parent as a "message". Since these
|
||||
"lines" need to convey non-trivial information such as progress information,
|
||||
speed, final computer checksums, etc we are in need of a basic form of a
|
||||
"protocol".
|
||||
|
||||
The "robot" module is the entity that implements this protocol, and provides
|
||||
utility functions to read/send messages using the protocol targeted at both
|
||||
parties (the client and the writer processes).
|
||||
The "robot" module is an entity that implements a text-based protocol to share
|
||||
objects between processes.
|
||||
|
||||
The contents and structure of these messages is what the "robot" module is
|
||||
mainly concerned with. Each "message" consists of a type (a "command" in robot
|
||||
@ -139,4 +130,3 @@ original error, even if this was created in another process. This is how the
|
||||
writer process propagates informational errors to the GUI.
|
||||
|
||||
[xz-man]: https://www.freebsd.org/cgi/man.cgi?query=xz&sektion=1&manpath=FreeBSD+8.3-RELEASE
|
||||
[child-writer]: https://github.com/resin-io/etcher/tree/master/lib/child-writer
|
||||
|
@ -27,7 +27,7 @@
|
||||
// an older equivalent of `ELECTRON_RUN_AS_NODE` that still gets set when
|
||||
// using `child_process.fork()`.
|
||||
if (process.env.ELECTRON_RUN_AS_NODE || process.env.ATOM_SHELL_INTERNAL_RUN_AS_NODE) {
|
||||
require('./cli/etcher')
|
||||
require('./gui/modules/child-writer')
|
||||
} else {
|
||||
require('./gui/etcher')
|
||||
}
|
||||
|
@ -1,110 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const m = require('mochainon')
|
||||
const cli = require('../../lib/child-writer/cli')
|
||||
|
||||
describe('ChildWriter CLI', function () {
|
||||
describe('.getBooleanArgumentForm()', function () {
|
||||
it('should prepend --no if the value is false and option is long', function () {
|
||||
m.chai.expect(cli.getBooleanArgumentForm('foo', false)).to.equal('--no-foo')
|
||||
})
|
||||
|
||||
it('should prepend -- if the value is true and option is long', function () {
|
||||
m.chai.expect(cli.getBooleanArgumentForm('foo', true)).to.equal('--foo')
|
||||
})
|
||||
|
||||
it('should prepend --no if the value is false and option is short', function () {
|
||||
m.chai.expect(cli.getBooleanArgumentForm('x', false)).to.equal('--no-x')
|
||||
})
|
||||
|
||||
it('should prepend - if the value is true and option is short', function () {
|
||||
m.chai.expect(cli.getBooleanArgumentForm('x', true)).to.equal('-x')
|
||||
})
|
||||
})
|
||||
|
||||
describe('.getArguments()', function () {
|
||||
it('should return a list of arguments given validate = false, unmount = false', function () {
|
||||
m.chai.expect(cli.getArguments({
|
||||
image: 'path/to/image.img',
|
||||
device: '/dev/disk2',
|
||||
entryPoint: 'path/to/app.asar',
|
||||
validateWriteOnSuccess: false,
|
||||
unmountOnSuccess: false
|
||||
})).to.deep.equal([
|
||||
'path/to/app.asar',
|
||||
'path/to/image.img',
|
||||
'--drive',
|
||||
'/dev/disk2',
|
||||
'--no-unmount',
|
||||
'--no-check'
|
||||
])
|
||||
})
|
||||
|
||||
it('should return a list of arguments given validate = false, unmount = true', function () {
|
||||
m.chai.expect(cli.getArguments({
|
||||
image: 'path/to/image.img',
|
||||
device: '/dev/disk2',
|
||||
entryPoint: 'path/to/app.asar',
|
||||
validateWriteOnSuccess: false,
|
||||
unmountOnSuccess: true
|
||||
})).to.deep.equal([
|
||||
'path/to/app.asar',
|
||||
'path/to/image.img',
|
||||
'--drive',
|
||||
'/dev/disk2',
|
||||
'--unmount',
|
||||
'--no-check'
|
||||
])
|
||||
})
|
||||
|
||||
it('should return a list of arguments given validate = true, unmount = false', function () {
|
||||
m.chai.expect(cli.getArguments({
|
||||
image: 'path/to/image.img',
|
||||
device: '/dev/disk2',
|
||||
entryPoint: 'path/to/app.asar',
|
||||
validateWriteOnSuccess: true,
|
||||
unmountOnSuccess: false
|
||||
})).to.deep.equal([
|
||||
'path/to/app.asar',
|
||||
'path/to/image.img',
|
||||
'--drive',
|
||||
'/dev/disk2',
|
||||
'--no-unmount',
|
||||
'--check'
|
||||
])
|
||||
})
|
||||
|
||||
it('should return a list of arguments given validate = true, unmount = true', function () {
|
||||
m.chai.expect(cli.getArguments({
|
||||
image: 'path/to/image.img',
|
||||
device: '/dev/disk2',
|
||||
entryPoint: 'path/to/app.asar',
|
||||
validateWriteOnSuccess: true,
|
||||
unmountOnSuccess: true
|
||||
})).to.deep.equal([
|
||||
'path/to/app.asar',
|
||||
'path/to/image.img',
|
||||
'--drive',
|
||||
'/dev/disk2',
|
||||
'--unmount',
|
||||
'--check'
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
@ -1,77 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const m = require('mochainon')
|
||||
const utils = require('../../lib/child-writer/utils')
|
||||
|
||||
describe('ChildWriter Utils', function () {
|
||||
describe('.splitObjectLines()', function () {
|
||||
it('should split multiple object lines', function () {
|
||||
const input = '{"id":"foo"}\n{"id":"bar"}\n{"id":"baz"}'
|
||||
m.chai.expect(utils.splitObjectLines(input)).to.deep.equal([
|
||||
'{"id":"foo"}',
|
||||
'{"id":"bar"}',
|
||||
'{"id":"baz"}'
|
||||
])
|
||||
})
|
||||
|
||||
it('should ignore spaces in between', function () {
|
||||
const input = '{"id":"foo"} \n {"id":"bar"}\n {"id":"baz"}'
|
||||
m.chai.expect(utils.splitObjectLines(input)).to.deep.equal([
|
||||
'{"id":"foo"}',
|
||||
'{"id":"bar"}',
|
||||
'{"id":"baz"}'
|
||||
])
|
||||
})
|
||||
|
||||
it('should ignore multiple new lines', function () {
|
||||
const input = '{"id":"foo"}\n\n\n\n{"id":"bar"}\n\n{"id":"baz"}'
|
||||
m.chai.expect(utils.splitObjectLines(input)).to.deep.equal([
|
||||
'{"id":"foo"}',
|
||||
'{"id":"bar"}',
|
||||
'{"id":"baz"}'
|
||||
])
|
||||
})
|
||||
|
||||
it('should ignore new lines inside properties', function () {
|
||||
const input = '{"id":"foo\nbar"}\n{"id":"\nhello\n"}'
|
||||
m.chai.expect(utils.splitObjectLines(input)).to.deep.equal([
|
||||
'{"id":"foo\nbar"}',
|
||||
'{"id":"\nhello\n"}'
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle carriage returns', function () {
|
||||
const input = '{"id":"foo"}\r\n{"id":"bar"}\r\n{"id":"baz"}'
|
||||
m.chai.expect(utils.splitObjectLines(input)).to.deep.equal([
|
||||
'{"id":"foo"}',
|
||||
'{"id":"bar"}',
|
||||
'{"id":"baz"}'
|
||||
])
|
||||
})
|
||||
|
||||
it('should ignore multiple carriage returns', function () {
|
||||
const input = '{"id":"foo"}\r\n\r\n{"id":"bar"}\r\n\r\n\r\n{"id":"baz"}'
|
||||
m.chai.expect(utils.splitObjectLines(input)).to.deep.equal([
|
||||
'{"id":"foo"}',
|
||||
'{"id":"bar"}',
|
||||
'{"id":"baz"}'
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
@ -233,6 +233,18 @@ describe('Model: flashState', function () {
|
||||
})
|
||||
}).to.not.throw('Missing state speed')
|
||||
})
|
||||
|
||||
it('should floor the percentage number', function () {
|
||||
flashState.setFlashingFlag()
|
||||
flashState.setProgressState({
|
||||
type: 'write',
|
||||
percentage: 50.253559459485,
|
||||
eta: 15,
|
||||
speed: 0
|
||||
})
|
||||
|
||||
m.chai.expect(flashState.getFlashState().percentage).to.equal(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('.getFlashResults()', function () {
|
||||
|
Loading…
x
Reference in New Issue
Block a user