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:
Juan Cruz Viotti 2018-01-04 15:46:09 -04:00 committed by Jonas Hermsmeier
parent 0ce2fca40a
commit 2291321b46
24 changed files with 415 additions and 1116 deletions

View File

@ -222,8 +222,7 @@ rules:
- error
- max: 1
multiline-ternary:
- error
- never
- off
newline-per-chained-call:
- off
no-bitwise:

View File

@ -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

View File

@ -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!

View File

@ -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
}

View File

@ -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')
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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()
}

View File

@ -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)
})

View File

@ -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

View File

@ -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, answers.drive, {
unmountOnSuccess: options.unmount,
validateWriteOnSuccess: options.check
}, (state) => {
progressBars[state.type].update(state)
}).then((results) => {
return {
imagePath,
flash: results
}
return writer.writeImage(imagePath, selectedDrive, {
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
}
})
})
}).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(() => {

View File

@ -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)
}
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) {

View File

@ -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,67 +61,86 @@ const UNMOUNT_ON_SUCCESS_TIMEOUT_MS = 2000
* });
*/
exports.writeImage = (imagePath, drive, options, onProgress) => {
return Bluebird.try(() => {
// Unmounting a drive in Windows means we can't write to it anymore
if (os.platform() === 'win32') {
return Bluebird.resolve()
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 mountutils.unmountDiskAsync(drive.device)
}).then(() => {
return diskpart.clean(drive.device)
}).then(() => {
/* eslint-disable no-bitwise */
const flags = fs.constants.O_RDWR |
fs.constants.O_EXCL |
fs.constants.O_NONBLOCK |
fs.constants.O_SYNC |
fs.constants.O_DIRECT
/* eslint-enable no-bitwise */
return fs.openAsync(drive.raw, flags)
}).then((driveFileDescriptor) => {
return imageStream.getFromFilePath(imagePath).then((image) => {
if (!constraints.isDriveLargeEnough(drive, image)) {
throw errors.createUserError({
title: 'The image you selected is too big for this drive',
description: 'Please connect a bigger drive and try again'
})
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()
}
const writer = new ImageWriter({
image,
fd: driveFileDescriptor,
path: drive.raw,
verify: options.validateWriteOnSuccess,
checksumAlgorithms: [ 'crc32' ]
})
return mountutils.unmountDiskAsync(driveObject.device)
}).then(() => {
return diskpart.clean(driveObject.device)
}).then(() => {
/* eslint-disable no-bitwise */
const flags = fs.constants.O_RDWR |
fs.constants.O_EXCL |
fs.constants.O_NONBLOCK |
fs.constants.O_SYNC |
fs.constants.O_DIRECT
/* eslint-enable no-bitwise */
return writer.write()
}).then((writer) => {
return new Bluebird((resolve, reject) => {
writer.on('progress', onProgress)
writer.on('error', reject)
writer.on('finish', resolve)
})
}).tap(() => {
// Make sure the device stream file descriptor is closed
// before returning control the the caller. Not closing
// the file descriptor (and waiting for it) results in
// `EBUSY` errors when attempting to unmount the drive
// right afterwards in some Windows 7 systems.
return fs.closeAsync(driveFileDescriptor).then(() => {
if (!options.unmountOnSuccess) {
return Bluebird.resolve()
return fs.openAsync(driveObject.raw, flags)
}).then((driveFileDescriptor) => {
return imageStream.getFromFilePath(imagePath).then((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'
})
}
// Closing a file descriptor on a drive containing mountable
// partitions causes macOS to mount the drive. If we try to
// unmount to quickly, then the drive might get re-mounted
// right afterwards.
return Bluebird.delay(UNMOUNT_ON_SUCCESS_TIMEOUT_MS)
.return(drive.device)
.then(mountutils.unmountDiskAsync)
const writer = new ImageWriter({
image,
fd: driveFileDescriptor,
path: driveObject.raw,
verify: options.validateWriteOnSuccess,
checksumAlgorithms: [ 'crc32' ]
})
return writer.write()
}).then((writer) => {
return new Bluebird((resolve, reject) => {
writer.on('progress', onProgress)
writer.on('error', reject)
writer.on('finish', (results) => {
results.drive = driveObject
resolve(results)
})
})
}).tap(() => {
// Make sure the device stream file descriptor is closed
// before returning control the the caller. Not closing
// the file descriptor (and waiting for it) results in
// `EBUSY` errors when attempting to unmount the drive
// right afterwards in some Windows 7 systems.
return fs.closeAsync(driveFileDescriptor).then(() => {
if (!options.unmountOnSuccess) {
return Bluebird.resolve()
}
// Closing a file descriptor on a drive containing mountable
// partitions causes macOS to mount the drive. If we try to
// unmount to quickly, then the drive might get re-mounted
// right afterwards.
return Bluebird.delay(UNMOUNT_ON_SUCCESS_TIMEOUT_MS)
.return(driveObject.device)
.then(mountutils.unmountDiskAsync)
})
})
})
})

View 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)
})
})

View File

@ -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)

View File

@ -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)

View File

@ -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(' '))
}

View File

@ -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(() => {

View File

@ -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.'
}, () => {

View File

@ -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

View File

@ -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')
}

View File

@ -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'
])
})
})
})

View File

@ -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"}'
])
})
})
})

View File

@ -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 () {