diff --git a/.eslintrc.yml b/.eslintrc.yml index 369a4fec..f5694193 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -222,8 +222,7 @@ rules: - error - max: 1 multiline-ternary: - - error - - never + - off newline-per-chained-call: - off no-bitwise: diff --git a/Makefile b/Makefile index 21f52c7c..2e5d4c5e 100644 --- a/Makefile +++ b/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 diff --git a/lib/child-writer/README.md b/lib/child-writer/README.md deleted file mode 100644 index ad00ffbe..00000000 --- a/lib/child-writer/README.md +++ /dev/null @@ -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! diff --git a/lib/child-writer/cli.js b/lib/child-writer/cli.js deleted file mode 100644 index 7f61bef2..00000000 --- a/lib/child-writer/cli.js +++ /dev/null @@ -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 -} diff --git a/lib/child-writer/constants.js b/lib/child-writer/constants.js deleted file mode 100644 index 9301d7c0..00000000 --- a/lib/child-writer/constants.js +++ /dev/null @@ -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') - -} diff --git a/lib/child-writer/index.js b/lib/child-writer/index.js deleted file mode 100644 index e627e20b..00000000 --- a/lib/child-writer/index.js +++ /dev/null @@ -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 -} diff --git a/lib/child-writer/renderer-utils.js b/lib/child-writer/renderer-utils.js deleted file mode 100644 index 9dba0d07..00000000 --- a/lib/child-writer/renderer-utils.js +++ /dev/null @@ -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) -} diff --git a/lib/child-writer/utils.js b/lib/child-writer/utils.js deleted file mode 100644 index fc3b14ec..00000000 --- a/lib/child-writer/utils.js +++ /dev/null @@ -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() -} diff --git a/lib/child-writer/writer-proxy.js b/lib/child-writer/writer-proxy.js deleted file mode 100644 index 1bb5e2ce..00000000 --- a/lib/child-writer/writer-proxy.js +++ /dev/null @@ -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) -}) diff --git a/lib/cli/README.md b/lib/cli/README.md index 8c0f7b94..49b46eb4 100644 --- a/lib/cli/README.md +++ b/lib/cli/README.md @@ -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 diff --git a/lib/cli/etcher.js b/lib/cli/etcher.js index 5aa1f3eb..8d127a14 100644 --- a/lib/cli/etcher.js +++ b/lib/cli/etcher.js @@ -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(() => { diff --git a/lib/cli/options.js b/lib/cli/options.js index 0dda5b84..d74f5b96 100644 --- a/lib/cli/options.js +++ b/lib/cli/options.js @@ -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) { diff --git a/lib/cli/writer.js b/lib/cli/writer.js index 173d7dcb..22e63ea9 100644 --- a/lib/cli/writer.js +++ b/lib/cli/writer.js @@ -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) + }) }) }) }) diff --git a/lib/gui/modules/child-writer.js b/lib/gui/modules/child-writer.js new file mode 100644 index 00000000..640829e7 --- /dev/null +++ b/lib/gui/modules/child-writer.js @@ -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) + }) +}) diff --git a/lib/gui/modules/image-writer.js b/lib/gui/modules/image-writer.js index f47e34c2..3e4ba83d 100644 --- a/lib/gui/modules/image-writer.js +++ b/lib/gui/modules/image-writer.js @@ -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) diff --git a/lib/gui/pages/main/controllers/flash.js b/lib/gui/pages/main/controllers/flash.js index cd98b23e..62637bd9 100644 --- a/lib/gui/pages/main/controllers/flash.js +++ b/lib/gui/pages/main/controllers/flash.js @@ -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) diff --git a/lib/shared/messages.js b/lib/shared/messages.js index 45a50907..6dadf11a 100644 --- a/lib/shared/messages.js +++ b/lib/shared/messages.js @@ -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(' ')) } diff --git a/lib/shared/models/flash-state.js b/lib/shared/models/flash-state.js index f7b29a1f..37479f51 100644 --- a/lib/shared/models/flash-state.js +++ b/lib/shared/models/flash-state.js @@ -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(() => { diff --git a/lib/shared/permissions.js b/lib/shared/permissions.js index 80d99987..ef246e2b 100644 --- a/lib/shared/permissions.js +++ b/lib/shared/permissions.js @@ -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.' }, () => { diff --git a/lib/shared/robot/README.md b/lib/shared/robot/README.md index 2925b27e..8930098d 100644 --- a/lib/shared/robot/README.md +++ b/lib/shared/robot/README.md @@ -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 diff --git a/lib/start.js b/lib/start.js index 4462945e..32f470d6 100644 --- a/lib/start.js +++ b/lib/start.js @@ -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') } diff --git a/tests/child-writer/cli.spec.js b/tests/child-writer/cli.spec.js deleted file mode 100644 index 344fe0e6..00000000 --- a/tests/child-writer/cli.spec.js +++ /dev/null @@ -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' - ]) - }) - }) -}) diff --git a/tests/child-writer/utils.spec.js b/tests/child-writer/utils.spec.js deleted file mode 100644 index 5f6eaef0..00000000 --- a/tests/child-writer/utils.spec.js +++ /dev/null @@ -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"}' - ]) - }) - }) -}) diff --git a/tests/shared/models/flash-state.spec.js b/tests/shared/models/flash-state.spec.js index ceaba748..1ba752c2 100644 --- a/tests/shared/models/flash-state.spec.js +++ b/tests/shared/models/flash-state.spec.js @@ -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 () {