diff --git a/lib/cli/writer.js b/lib/cli/writer.js index 545c41b3..378e8528 100644 --- a/lib/cli/writer.js +++ b/lib/cli/writer.js @@ -16,24 +16,8 @@ 'use strict' -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('../sdk/image-stream') -const errors = require('../shared/errors') -const constraints = require('../shared/drive-constraints') const ImageWriter = require('../sdk/writer') -const diskpart = require('./diskpart') - -/** - * @summary Timeout, in milliseconds, to wait before unmounting on success - * @constant - * @type {Number} - */ -const UNMOUNT_ON_SUCCESS_TIMEOUT_MS = 2000 /** * @summary Write an image to a disk drive @@ -61,85 +45,18 @@ const UNMOUNT_ON_SUCCESS_TIMEOUT_MS = 2000 * }); */ exports.writeImage = (imagePath, drive, options, onProgress) => { - return drivelist.listAsync().then((drives) => { - const selectedDrive = _.find(drives, { - device: drive - }) + const writer = new ImageWriter({ + path: drive, + imagePath, + verify: options.validateWriteOnSuccess, + checksumAlgorithms: [ 'crc32' ], + unmountOnSuccess: options.unmountOnSuccess + }) - if (!selectedDrive) { - throw errors.createUserError({ - title: 'The selected drive was not found', - description: `We can't find ${drive} in your system. Did you unplug the drive?`, - code: 'EUNPLUGGED' - }) - } - - return selectedDrive - }).then((driveObject) => { - return Bluebird.try(() => { - // Unmounting a drive in Windows means we can't write to it anymore - if (os.platform() === 'win32') { - return Bluebird.resolve() - } - - return mountutils.unmountDiskAsync(driveObject.device) - }).then(() => { - return diskpart.clean(driveObject.device) - }).then(() => { - /* eslint-disable no-bitwise */ - const flags = fs.constants.O_RDWR | - fs.constants.O_NONBLOCK | - fs.constants.O_SYNC - /* eslint-enable no-bitwise */ - - 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' - }) - } - - 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) - }) - }) - }) + return new Bluebird((resolve, reject) => { + writer.flash() + .on('error', reject) + .on('progress', onProgress) + .on('finish', resolve) }) } diff --git a/lib/sdk/writer/index.js b/lib/sdk/writer/index.js index 9238b2cb..f7faa9f3 100644 --- a/lib/sdk/writer/index.js +++ b/lib/sdk/writer/index.js @@ -16,6 +16,11 @@ 'use strict' +const os = require('os') +const fs = require('fs') +const EventEmitter = require('events').EventEmitter +const mountutils = require('mountutils') +const drivelist = require('drivelist') const stream = require('readable-stream') const Pipage = require('pipage') const BlockMap = require('blockmap') @@ -24,10 +29,54 @@ const BlockWriteStream = require('./block-write-stream') const BlockReadStream = require('./block-read-stream') const ChecksumStream = require('./checksum-stream') const ProgressStream = require('./progress-stream') +const imageStream = require('../image-stream') +const diskpart = require('../../cli/diskpart') +const constraints = require('../../shared/drive-constraints') +const errors = require('../../shared/errors') const debug = require('debug')('etcher:writer') -const EventEmitter = require('events').EventEmitter const _ = require('lodash') +/* eslint-disable prefer-reflect */ + +/** + * @summary Timeout, in milliseconds, to wait before unmounting on success + * @constant + * @type {Number} + */ +const UNMOUNT_ON_SUCCESS_TIMEOUT_MS = 2000 + +/** + * @summary Helper function to run a set of async tasks in sequence + * @private + * @param {Array} tasks - set of tasks + * @param {Function} callback - callback(error) + * @example + * runSeries([ + * (next) => first(next), + * (next) => second(next), + * ], (error) => { + * // ... + * }) + */ +const runSeries = (tasks, callback) => { + /** + * @summary Task runner + * @param {Error} [error] - error + * @example + * run() + */ + const run = (error) => { + const task = tasks.shift() + if (error || task == null) { + callback(error) + return + } + task(run) + } + + run() +} + /** * @summary ImageWriter class * @class @@ -36,6 +85,11 @@ class ImageWriter extends EventEmitter { /** * @summary ImageWriter constructor * @param {Object} options - options + * @param {String} options.imagePath - disk image path + * @param {String} options.path - dest path + * @param {Boolean} options.verify - whether to verify the dest + * @param {Boolean} options.unmountOnSuccess - whether to unmount the dest after flashing + * @param {Array} options.checksumAlgorithms - checksums to calculate * @example * new ImageWriter(options) */ @@ -48,13 +102,187 @@ class ImageWriter extends EventEmitter { this.pipeline = null this.target = null - this.hadError = false - this.bytesRead = 0 this.bytesWritten = 0 this.checksum = {} } + /** + * @summary Verify that the selected destination device exists + * @param {Function} callback - callback(error) + * @private + * @example + * writer.checkSelectedDevice((error) => { + * // ... + * }) + */ + checkSelectedDevice (callback) { + debug('state:device-select', this.options.path) + this.destinationDevice = null + drivelist.list((error, drives) => { + debug('state:device-select', this.options.path, error ? 'NOT OK' : 'OK') + + if (error) { + callback.call(this, error) + return + } + + const selectedDrive = _.find(drives, { + device: this.options.path + }) + + if (!selectedDrive) { + const selectionError = errors.createUserError({ + title: 'The selected drive was not found', + description: `We can't find ${this.options.path} in your system. Did you unplug the drive?`, + code: 'EUNPLUGGED' + }) + debug('state:device-select', this.options.path, 'NOT OK') + callback.call(this, selectionError) + return + } + + this.destinationDevice = selectedDrive + + callback.call(this) + }) + } + + /** + * @summary Unmount the destination device + * @param {Function} callback - callback(error) + * @private + * @example + * writer.unmountDevice((error) => { + * // ... + * }) + */ + unmountDevice (callback) { + if (os.platform() === 'win32') { + callback.call(this) + return + } + + debug('state:unmount', this.destinationDevice.device) + + mountutils.unmountDisk(this.destinationDevice.device, (error) => { + debug('state:unmount', this.destinationDevice.device, error ? 'NOT OK' : 'OK') + callback.call(this, error) + }) + } + + /** + * @summary Clean a device's partition table + * @param {Function} callback - callback(error) + * @private + * @example + * writer.removePartitionTable((error) => { + * // ... + * }) + */ + removePartitionTable (callback) { + if (os.platform() !== 'win32') { + callback.call(this) + return + } + + debug('state:clean', this.destinationDevice.device) + + diskpart.clean(this.destinationDevice.device).asCallback((error) => { + debug('state:clean', this.destinationDevice.device, error ? 'NOT OK' : 'OK') + callback.call(this, error) + }) + } + + /** + * @summary Open the source for reading + * @param {Function} callback - callback(error) + * @private + * @example + * writer.openSource((error) => { + * // ... + * }) + */ + openSource (callback) { + debug('state:source-open', this.options.imagePath) + imageStream.getFromFilePath(this.options.imagePath).asCallback((error, image) => { + debug('state:source-open', this.options.imagePath, error ? 'NOT OK' : 'OK') + if (error) { + callback.call(this, error) + return + } + + if (!constraints.isDriveLargeEnough(this.destinationDevice, image)) { + const driveError = errors.createUserError({ + title: 'The image you selected is too big for this drive', + description: 'Please connect a bigger drive and try again' + }) + debug('state:source-open', this.options.imagePath, 'NOT OK') + callback.call(this, driveError) + return + } + + this.options.image = image + + callback.call(this) + }) + } + + /** + * @summary Open the destination for writing + * @param {Function} callback - callback(error) + * @private + * @example + * writer.openDestination((error) => { + * // ... + * }) + */ + openDestination (callback) { + debug('state:destination-open', this.destinationDevice.raw) + + /* eslint-disable no-bitwise */ + const flags = fs.constants.O_RDWR | + fs.constants.O_NONBLOCK | + fs.constants.O_SYNC + /* eslint-enable no-bitwise */ + + fs.open(this.destinationDevice.raw, flags, (error, fd) => { + debug('state:destination-open', this.destinationDevice.raw, error ? 'NOT OK' : 'OK') + this.options.fd = fd + callback.call(this, error) + }) + } + + /** + * @summary Start the flashing process + * @returns {ImageWriter} imageWriter + * @example + * imageWriter.flash() + * .on('error', reject) + * .on('progress', onProgress) + * .on('finish', resolve) + */ + flash () { + const tasks = [ + (next) => { this.checkSelectedDevice(next) }, + (next) => { this.unmountDevice(next) }, + (next) => { this.removePartitionTable(next) }, + (next) => { this.openSource(next) }, + (next) => { this.openDestination(next) } + ] + + runSeries(tasks, (error) => { + if (error) { + this.emit('error') + return + } + + this.write() + }) + + return this + } + /** * @summary Start the writing process * @returns {ImageWriter} imageWriter @@ -62,15 +290,12 @@ class ImageWriter extends EventEmitter { * imageWriter.write() */ write () { - this.hadError = false - this._createWritePipeline(this.options) .on('checksum', (checksum) => { debug('write:checksum', checksum) this.checksum = checksum }) .on('error', (error) => { - this.hadError = true this.emit('error', error) }) @@ -80,7 +305,7 @@ class ImageWriter extends EventEmitter { if (this.options.verify) { this.verify() } else { - this._emitFinish() + this._finish() } }) @@ -96,7 +321,6 @@ class ImageWriter extends EventEmitter { verify () { this._createVerifyPipeline(this.options) .on('error', (error) => { - this.hadError = true this.emit('error', error) }) .on('checksum', (checksum) => { @@ -106,13 +330,13 @@ class ImageWriter extends EventEmitter { error.code = 'EVALIDATION' this.emit('error', error) } - this._emitFinish() + this._finish() }) .on('finish', () => { debug('verify:end') // NOTE: As the 'checksum' event only happens after - // the 'finish' event, we `._emitFinish()` there instead of here + // the 'finish' event, we `._finish()` there instead of here }) return this @@ -125,22 +349,56 @@ class ImageWriter extends EventEmitter { */ abort () { if (this.source) { - this.emit('abort') this.source.destroy() } + this.emit('abort') + } + + /** + * @summary Cleanup after writing; close file descriptors & unmount + * @param {Function} callback - callback(error) + * @private + * @example + * writer._cleanup((error) => { + * // ... + * }) + */ + _cleanup (callback) { + debug('state:cleanup') + fs.close(this.options.fd, (closeError) => { + debug('state:cleanup', closeError ? 'NOT OK' : 'OK') + if (!this.options.unmountOnSuccess) { + callback.call(this, closeError) + return + } + + // Closing a file descriptor on a drive containing mountable + // partitions causes macOS to mount the drive. If we try to + // unmount too quickly, then the drive might get re-mounted + // right afterwards. + setTimeout(() => { + mountutils.unmountDisk(this.destinationDevice.device, (error) => { + debug('state:cleanup', error ? 'NOT OK' : 'OK') + callback.call(this, error) + }) + }, UNMOUNT_ON_SUCCESS_TIMEOUT_MS) + }) } /** * @summary Emits the `finish` event with state metadata * @private * @example - * this._emitFinish() + * this._finish() */ - _emitFinish () { - this.emit('finish', { - bytesRead: this.bytesRead, - bytesWritten: this.bytesWritten, - checksum: this.checksum + _finish () { + this._cleanup(() => { + this.emit('finish', { + drive: this.destinationDevice, + bytesRead: this.bytesRead, + bytesWritten: this.bytesWritten, + checksum: this.checksum + }) }) } @@ -216,9 +474,6 @@ class ImageWriter extends EventEmitter { const target = new BlockWriteStream({ fd: options.fd, - path: options.path, - flags: options.flags, - mode: options.mode, autoClose: false }) @@ -296,9 +551,6 @@ class ImageWriter extends EventEmitter { const source = new BlockReadStream({ fd: options.fd, - path: options.path, - flags: options.flags, - mode: options.mode, autoClose: false, start: 0, end: size