diff --git a/.eslintrc.yml b/.eslintrc.yml index 6c8d9f0d..99f5c1ea 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -289,7 +289,7 @@ rules: - error - anonymous: always named: always - asyncArrow: never + asyncArrow: always template-tag-spacing: - error - always diff --git a/lib/gui/app/models/flash-state.js b/lib/gui/app/models/flash-state.js index 9c28ce1e..493d95dc 100644 --- a/lib/gui/app/models/flash-state.js +++ b/lib/gui/app/models/flash-state.js @@ -119,7 +119,8 @@ exports.setProgressState = (state) => { const data = _.assign({}, state, { percentage: _.isFinite(state.percentage) ? Math.floor(state.percentage) - : state.percentage, + // eslint-disable-next-line no-undefined + : undefined, speed: _.attempt(() => { if (_.isFinite(state.speed)) { diff --git a/lib/gui/app/os/windows-network-drives.js b/lib/gui/app/os/windows-network-drives.js new file mode 100644 index 00000000..2d41d85f --- /dev/null +++ b/lib/gui/app/os/windows-network-drives.js @@ -0,0 +1,122 @@ +/* + * Copyright 2019 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 cp = require('child_process') +const _ = require('lodash') +const os = require('os') + +/** + * @summary Promisified child_process.execFile + * @function + * + * @param {String} file - command + * @param {String[]} args - arguments + * @param {Object} options - child_process.execFile options + * + * @returns {Promise} - { stdout, stderr } + * + * @example + * execFileAsync('ls', [ '.' ]) + * .then(console.log); + */ +const execFileAsync = async (file, args, options) => { + return new Promise((resolve, reject) => { + cp.execFile( + file, + args, + options, + (error, stdout, stderr) => { + if (error) { + reject(error) + } else { + resolve({ stdout, stderr }) + } + } + ) + }) +} + +/** + * @summary returns a Map of drive letter -> network locations on Windows + * @function + * + * @returns {Promise>} - 'Z:' -> '\\\\192.168.0.1\\Public' + * + * @example + * getWindowsNetworkDrives() + * .then(console.log); + */ +const getWindowsNetworkDrives = async () => { + const result = await execFileAsync( + 'wmic', + [ 'path', 'Win32_LogicalDisk', 'Where', 'DriveType="4"', 'get', 'DeviceID,ProviderName' ], + { windowsHide: true, windowsVerbatimArguments: true } + ) + const couples = _.chain(result.stdout) + .split('\n') + + // Remove header line + // eslint-disable-next-line no-magic-numbers + .slice(1) + + // Remove extra spaces / tabs / carriage returns + .invokeMap(String.prototype.trim) + + // Filter out empty lines + .compact() + .map((str) => { + const colonPosition = str.indexOf(':') + // eslint-disable-next-line no-magic-numbers + if (colonPosition === -1) { + throw new Error(`Can't parse wmic output: ${result.stdout}`) + } + // eslint-disable-next-line no-magic-numbers + return [ str.slice(0, colonPosition + 1), _.trim(str.slice(colonPosition + 1)) ] + }) + .value() + return new Map(couples) +} + +/** + * @summary Replaces network drive letter with network drive location in the provided filePath on Windows + * @function + * + * @param {String} filePath - file path + * + * @returns {String} - updated file path + * + * @example + * replaceWindowsNetworkDriveLetter('Z:\\some-file') + * .then(console.log); + */ +exports.replaceWindowsNetworkDriveLetter = async (filePath) => { + let result = filePath + if (os.platform() === 'win32') { + const matches = /^([A-Z]+:)\\(.*)$/.exec(filePath) + if (matches !== null) { + const [ , drive, relativePath ] = matches + const drives = await getWindowsNetworkDrives() + const location = drives.get(drive) + // eslint-disable-next-line no-undefined + if (location !== undefined) { + result = `${location}\\${relativePath}` + } + } + } + return result +} diff --git a/lib/gui/app/pages/main/controllers/flash.js b/lib/gui/app/pages/main/controllers/flash.js index 0a98852c..64003f0c 100644 --- a/lib/gui/app/pages/main/controllers/flash.js +++ b/lib/gui/app/pages/main/controllers/flash.js @@ -31,7 +31,6 @@ const availableDrives = require('../../../models/available-drives') const selection = require('../../../models/selection-state') module.exports = function ( - $q, $state, $timeout, FlashErrorModalService, @@ -67,39 +66,34 @@ module.exports = function ( * * @param {Array} drives - list of drive objects * @param {Object} image - image object - * @returns {Promise} + * @returns {Promise} * * @example - * displayTailoredWarning(drives, image).then(() => { - * console.log('Continue pressed') - * }).catch(() => { - * console.log('Change pressed') + * displayTailoredWarning(drives, image).then((ok) => { + * if (ok) { + * console.log('No warning was shown or continue was pressed') + * } else { + * console.log('Change was pressed') + * } * }) */ - const displayTailoredWarning = (drives, image) => { - const warningMessages = _.reduce(drives, (accumMessages, drive) => { + const displayTailoredWarning = async (drives, image) => { + const warningMessages = [] + for (const drive of drives) { if (constraints.isDriveSizeLarge(drive)) { - return accumMessages.concat(messages.warning.largeDriveSize(drive)) + warningMessages.push(messages.warning.largeDriveSize(drive)) } else if (!constraints.isDriveSizeRecommended(drive, image)) { - return accumMessages.concat(messages.warning.unrecommendedDriveSize(image, drive)) + warningMessages.push(messages.warning.unrecommendedDriveSize(image, drive)) } - return accumMessages - }, []) - - if (!warningMessages.length) { // TODO(Shou): we should consider adding the same warning dialog for system drives and remove unsafe mode - return $q.resolve() } - return confirmationWarningModal(warningMessages).then((value) => { - if (!value) { - DriveSelectorService.open() - return $q.reject() - } + if (!warningMessages.length) { + return true + } - return $q.resolve() - }) + return confirmationWarningModal(warningMessages) } /** @@ -118,35 +112,41 @@ module.exports = function ( * '/dev/disk5' * ]) */ - this.flashImageToDrive = () => { - const image = selection.getImage() + this.flashImageToDrive = async () => { const devices = selection.getSelectedDevices() + const image = selection.getImage() + const drives = _.filter(availableDrives.getDrives(), (drive) => { + return _.includes(devices, drive.device) + }) + + // eslint-disable-next-line no-magic-numbers + if (drives.length === 0) { + return + } + + const hasDangerStatus = constraints.hasListDriveImageCompatibilityStatus(drives, image) + if (hasDangerStatus) { + if (!(await displayTailoredWarning(drives, image))) { + DriveSelectorService.open() + return + } + } if (flashState.isFlashing()) { return } - const drives = _.filter(availableDrives.getDrives(), (drive) => { - return _.includes(devices, drive.device) - }) - - const hasDangerStatus = constraints.hasListDriveImageCompatibilityStatus(drives, image) - - const userConfirm = hasDangerStatus ? _.partial(displayTailoredWarning, drives, image) : $q.resolve - // Trigger Angular digests along with store updates, as the flash state // updates. Without this there is essentially no progress to watch. const unsubscribe = store.observe($timeout) + // Stop scanning drives when flashing + // otherwise Windows throws EPERM + driveScanner.stop() + const iconPath = '../../../assets/icon.png' - - userConfirm().then(() => { - // Stop scanning drives when flashing - // otherwise Windows throws EPERM - driveScanner.stop() - - return imageWriter.flash(image.path, drives) - }).then(() => { + try { + await imageWriter.flash(image.path, drives) if (!flashState.wasLastFlashCancelled()) { const flashResults = flashState.getFlashResults() notification.send('Flash complete!', { @@ -155,7 +155,7 @@ module.exports = function ( }) $state.go('success') } - }).catch((error) => { + } catch (error) { // When flashing is cancelled before starting above there is no error if (!error) { return @@ -183,11 +183,11 @@ module.exports = function ( FlashErrorModalService.show(messages.error.genericFlashError()) exceptionReporter.report(error) } - }).finally(() => { + } finally { availableDrives.setDrives([]) driveScanner.start() unsubscribe() - }) + } } /** diff --git a/lib/gui/app/pages/main/controllers/image-selection.js b/lib/gui/app/pages/main/controllers/image-selection.js index e7ad1313..3060c713 100644 --- a/lib/gui/app/pages/main/controllers/image-selection.js +++ b/lib/gui/app/pages/main/controllers/image-selection.js @@ -29,6 +29,7 @@ const analytics = require('../../../modules/analytics') const settings = require('../../../models/settings') const selectionState = require('../../../models/selection-state') const osDialog = require('../../../os/dialog') +const { replaceWindowsNetworkDriveLetter } = require('../../../os/windows-network-drives') const exceptionReporter = require('../../../modules/exception-reporter') module.exports = function ( @@ -145,7 +146,13 @@ module.exports = function ( * @example * ImageSelectionController.selectImageByPath('path/to/image.img'); */ - this.selectImageByPath = (imagePath) => { + this.selectImageByPath = async (imagePath) => { + try { + // eslint-disable-next-line no-param-reassign + imagePath = await replaceWindowsNetworkDriveLetter(imagePath) + } catch (error) { + analytics.logException(error) + } if (!supportedFormats.isSupportedImage(imagePath)) { const invalidImageError = errors.createUserError({ title: 'Invalid image', @@ -158,35 +165,33 @@ module.exports = function ( } const source = new sdk.sourceDestination.File(imagePath, sdk.sourceDestination.File.OpenFlags.Read) - source.getInnerSource() - .then((innerSource) => { - return innerSource.getMetadata() - .then((metadata) => { - return innerSource.getPartitionTable() - .then((partitionTable) => { - if (partitionTable) { - metadata.hasMBR = true - metadata.partitions = partitionTable.partitions - } - metadata.path = imagePath - // eslint-disable-next-line no-magic-numbers - metadata.extension = path.extname(imagePath).slice(1) - this.selectImage(metadata) - $timeout() - }) - }) - }) - .catch((error) => { - const imageError = errors.createUserError({ - title: 'Error opening image', - description: messages.error.openImage(path.basename(imagePath), error.message) - }) - osDialog.showError(imageError) - analytics.logException(error) - }) - .finally(() => { - return source.close().catch(_.noop) + try { + const innerSource = await source.getInnerSource() + const metadata = await innerSource.getMetadata() + const partitionTable = await innerSource.getPartitionTable() + if (partitionTable) { + metadata.hasMBR = true + metadata.partitions = partitionTable.partitions + } + metadata.path = imagePath + // eslint-disable-next-line no-magic-numbers + metadata.extension = path.extname(imagePath).slice(1) + this.selectImage(metadata) + $timeout() + } catch (error) { + const imageError = errors.createUserError({ + title: 'Error opening image', + description: messages.error.openImage(path.basename(imagePath), error.message) }) + osDialog.showError(imageError) + analytics.logException(error) + } finally { + try { + await source.close() + } catch (error) { + // Noop + } + } } /** diff --git a/tests/data/wmic-output.txt b/tests/data/wmic-output.txt new file mode 100755 index 00000000..b6fbbd1d Binary files /dev/null and b/tests/data/wmic-output.txt differ diff --git a/tests/gui/models/flash-state.spec.js b/tests/gui/models/flash-state.spec.js index db35c667..d8229d6e 100644 --- a/tests/gui/models/flash-state.spec.js +++ b/tests/gui/models/flash-state.spec.js @@ -126,23 +126,6 @@ describe('Model: flashState', function () { }).to.not.throw('Missing flash fields: percentage') }) - it('should throw if percentage is not a number', function () { - flashState.setFlashingFlag() - m.chai.expect(function () { - flashState.setProgressState({ - flashing: 2, - verifying: 0, - successful: 0, - failed: 0, - type: 'write', - percentage: '50', - eta: 15, - speed: 100000000000, - totalSpeed: 200000000000 - }) - }).to.throw('Invalid state percentage: 50') - }) - it('should throw if percentage is outside maximum bound', function () { flashState.setFlashingFlag() m.chai.expect(function () { diff --git a/tests/gui/os/windows-network-drives.spec.js b/tests/gui/os/windows-network-drives.spec.js new file mode 100644 index 00000000..136b68cb --- /dev/null +++ b/tests/gui/os/windows-network-drives.spec.js @@ -0,0 +1,47 @@ +/* + * 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 { readFile } = require('fs') +const os = require('os') +const cp = require('child_process') +const m = require('mochainon') +const { promisify } = require('util') + +const { replaceWindowsNetworkDriveLetter } = require('../../../lib/gui/app/os/windows-network-drives') + +const readFileAsync = promisify(readFile) + +describe('Network drives on Windows', () => { + before(async () => { + this.osPlatformStub = m.sinon.stub(os, 'platform') + this.osPlatformStub.returns('win32') + const wmicOutput = await readFileAsync('tests/data/wmic-output.txt', { encoding: 'ucs2' }) + this.execFileStub = m.sinon.stub(cp, 'execFile') + this.execFileStub.callsArgWith(3, null, wmicOutput) + }) + + it('should parse network drive mapping on Windows', async () => { + m.chai.expect(await replaceWindowsNetworkDriveLetter('Z:\\some-folder\\some-file')) + .to.equal('\\\\192.168.1.1\\Public\\some-folder\\some-file') + }) + + after(() => { + this.osPlatformStub.restore() + this.execFileStub.restore() + }) +}) diff --git a/versionist.conf.js b/versionist.conf.js deleted file mode 100644 index a3ecb8db..00000000 --- a/versionist.conf.js +++ /dev/null @@ -1,97 +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' - -module.exports = { - - subjectParser: 'angular', - - getGitReferenceFromVersion: 'v-prefix', - - editChangelog: true, - - editVersion: true, - - addEntryToChangelog: { - preset: 'prepend', - fromLine: 5 - }, - - includeCommitWhen: 'has-changelog-entry', - - getIncrementLevelFromCommit: (commit) => { - if (/none/i.test(commit.footer['Change-type'])) { - return null - } - return commit.footer['Change-type'] && - commit.footer['Change-type'].toLowerCase() - }, - - transformTemplateData: (data) => { - if (data.commits.length === 0) { - throw new Error('No commits annotated with Changelog-entry') - } - data.features = data.commits.filter((commit) => { - return commit.subject.type === 'feat' - }) - - data.fixes = data.commits.filter((commit) => { - return commit.subject.type === 'fix' - }) - - data.misc = data.commits.filter((commit) => { - return !([ 'fix', 'feat' ].includes(commit.subject.type)) - }) - - return data - }, - - template: [ - '## v{{version}} - {{moment date "Y-MM-DD"}}', - '{{#if features.length}}', - '', - '### Features', - '', - '{{#each features}}', - '{{#with footer}}', - '- {{capitalize Changelog-entry}}', - '{{/with}}', - '{{/each}}', - '{{/if}}', - '{{#if fixes.length}}', - '', - '### Fixes', - '', - '{{#each fixes}}', - '{{#with footer}}', - '- {{capitalize Changelog-entry}}', - '{{/with}}', - '{{/each}}', - '{{/if}}', - '{{#if misc.length}}', - '', - '### Misc', - '', - '{{#each misc}}', - '{{#with footer}}', - '- {{capitalize Changelog-entry}}', - '{{/with}}', - '{{/each}}', - '{{/if}}' - ].join('\n') - -}