Merge pull request #3026 from balena-io/remove-remaining-angular

Remove remaining angular and convert everything to typescript
This commit is contained in:
Alexis Svinartchouk 2020-01-27 16:37:19 +01:00 committed by GitHub
commit 98611267d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
160 changed files with 13123 additions and 18078 deletions

View File

@ -128,7 +128,6 @@ TARGETS = \
lint-js \
lint-sass \
lint-cpp \
lint-html \
lint-spell \
test-spectron \
test-gui \
@ -151,10 +150,7 @@ sass:
node-sass lib/gui/app/scss/main.scss > lib/gui/css/main.css
lint-ts:
resin-lint --typescript lib
lint-js:
eslint --ignore-pattern scripts/resin/**/*.js lib tests scripts bin webpack.config.js
resin-lint --typescript typings lib tests scripts/clean-shrinkwrap.ts webpack.config.ts
lint-sass:
sass-lint lib/gui/scss
@ -162,9 +158,6 @@ lint-sass:
lint-cpp:
cpplint --recursive src
lint-html:
node scripts/html-lint.js
lint-spell:
codespell \
--dictionary - \
@ -172,20 +165,20 @@ lint-spell:
--skip *.svg *.gz,*.bz2,*.xz,*.zip,*.img,*.dmg,*.iso,*.rpi-sdcard,*.wic,.DS_Store,*.dtb,*.dtbo,*.dat,*.elf,*.bin,*.foo,xz-without-extension \
lib tests docs Makefile *.md LICENSE
lint: lint-ts lint-js lint-sass lint-cpp lint-html lint-spell
lint: lint-ts lint-sass lint-cpp lint-spell
MOCHA_OPTIONS=--recursive --reporter spec --require ts-node/register
# See https://github.com/electron/spectron/issues/127
ETCHER_SPECTRON_ENTRYPOINT ?= $(shell node -e 'console.log(require("electron"))')
test-spectron:
ETCHER_SPECTRON_ENTRYPOINT="$(ETCHER_SPECTRON_ENTRYPOINT)" mocha $(MOCHA_OPTIONS) tests/spectron
ETCHER_SPECTRON_ENTRYPOINT="$(ETCHER_SPECTRON_ENTRYPOINT)" mocha $(MOCHA_OPTIONS) tests/spectron/runner.spec.ts
test-gui:
electron-mocha $(MOCHA_OPTIONS) --full-trace --no-sandbox --renderer tests/gui
electron-mocha $(MOCHA_OPTIONS) --full-trace --no-sandbox --renderer tests/gui/**/*.ts
test-sdk:
electron-mocha $(MOCHA_OPTIONS) --full-trace --no-sandbox tests/shared
electron-mocha $(MOCHA_OPTIONS) --full-trace --no-sandbox tests/shared/**/*.ts
test: test-gui test-sdk test-spectron
@ -199,7 +192,6 @@ info:
sanity-checks:
./scripts/ci/ensure-staged-sass.sh
./scripts/ci/ensure-npm-dependencies-compatibility.sh
./scripts/ci/ensure-all-file-extensions-in-gitattributes.sh
clean:

View File

@ -12,7 +12,6 @@ technologies used in Etcher that you should become familiar with:
- [Electron][electron]
- [NodeJS][nodejs]
- [AngularJS][angularjs]
- [Redux][redux]
- [ImmutableJS][immutablejs]
- [Bootstrap][bootstrap]
@ -66,7 +65,6 @@ be documented instead!
[gui-dir]: https://github.com/balena-io/etcher/tree/master/lib/gui
[electron]: http://electron.atom.io
[nodejs]: https://nodejs.org
[angularjs]: https://angularjs.org
[redux]: http://redux.js.org
[immutablejs]: http://facebook.github.io/immutable-js/
[bootstrap]: http://getbootstrap.com

View File

@ -1,456 +0,0 @@
/*
* Copyright 2016 balena.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.
*/
/**
* @module Etcher
*/
'use strict'
/* eslint-disable no-var */
var angular = require('angular')
/* eslint-enable no-var */
const electron = require('electron')
const sdk = require('etcher-sdk')
const _ = require('lodash')
const uuidV4 = require('uuid/v4')
const EXIT_CODES = require('../../shared/exit-codes')
const messages = require('../../shared/messages')
const store = require('./models/store')
const packageJSON = require('../../../package.json')
const flashState = require('./models/flash-state')
const settings = require('./models/settings')
const windowProgress = require('./os/window-progress')
const analytics = require('./modules/analytics')
const availableDrives = require('./models/available-drives')
const driveScanner = require('./modules/drive-scanner')
const osDialog = require('./os/dialog')
const exceptionReporter = require('./modules/exception-reporter')
const updateLock = require('./modules/update-lock')
/* eslint-disable lodash/prefer-lodash-method,lodash/prefer-get */
// Enable debug information from all modules that use `debug`
// See https://github.com/visionmedia/debug#browser-support
//
// Enable drivelist debugging information
// See https://github.com/balena-io-modules/drivelist
process.env.DRIVELIST_DEBUG = /drivelist|^\*$/i.test(process.env.DEBUG) ? '1' : ''
window.localStorage.debug = process.env.DEBUG
window.addEventListener('unhandledrejection', (event) => {
// Promise: event.reason
// Bluebird: event.detail.reason
// Anything else: event
const error = event.reason || (event.detail && event.detail.reason) || event
analytics.logException(error)
event.preventDefault()
})
// Set application session UUID
store.dispatch({
type: store.Actions.SET_APPLICATION_SESSION_UUID,
data: uuidV4()
})
// Set first flashing workflow UUID
store.dispatch({
type: store.Actions.SET_FLASHING_WORKFLOW_UUID,
data: uuidV4()
})
const applicationSessionUuid = store.getState().toJS().applicationSessionUuid
const flashingWorkflowUuid = store.getState().toJS().flashingWorkflowUuid
const app = angular.module('Etcher', [
require('angular-ui-router'),
require('angular-ui-bootstrap'),
require('angular-if-state'),
// Components
require('./components/svg-icon'),
require('./components/safe-webview'),
// Pages
require('./pages/main/main.ts').MODULE_NAME,
require('./components/finish/index.ts').MODULE_NAME
])
app.run(() => {
console.log([
' _____ _ _',
'| ___| | | |',
'| |__ | |_ ___| |__ ___ _ __',
'| __|| __/ __| \'_ \\ / _ \\ \'__|',
'| |___| || (__| | | | __/ |',
'\\____/ \\__\\___|_| |_|\\___|_|',
'',
'Interested in joining the Etcher team?',
'Drop us a line at join+etcher@balena.io',
'',
`Version = ${packageJSON.version}, Type = ${packageJSON.packageType}`
].join('\n'))
})
app.run(() => {
const currentVersion = packageJSON.version
analytics.logEvent('Application start', {
packageType: packageJSON.packageType,
version: currentVersion,
applicationSessionUuid
})
})
app.run(() => {
store.observe(() => {
if (!flashState.isFlashing()) {
return
}
const currentFlashState = flashState.getFlashState()
const stateType = !currentFlashState.flashing && currentFlashState.verifying
? `Verifying ${currentFlashState.verifying}`
: `Flashing ${currentFlashState.flashing}`
// NOTE: There is usually a short time period between the `isFlashing()`
// property being set, and the flashing actually starting, which
// might cause some non-sense flashing state logs including
// `undefined` values.
analytics.logDebug(
`${stateType} devices, ` +
`${currentFlashState.percentage}% at ${currentFlashState.speed} MB/s ` +
`(total ${currentFlashState.totalSpeed} MB/s) ` +
`eta in ${currentFlashState.eta}s ` +
`with ${currentFlashState.failed} failed devices`
)
windowProgress.set(currentFlashState)
})
})
/**
* @summary The radix used by USB ID numbers
* @type {Number}
* @constant
*/
const USB_ID_RADIX = 16
/**
* @summary The expected length of a USB ID number
* @type {Number}
* @constant
*/
const USB_ID_LENGTH = 4
/**
* @summary Convert a USB id (e.g. product/vendor) to a string
* @function
* @private
*
* @param {Number} id - USB id
* @returns {String} string id
*
* @example
* console.log(usbIdToString(2652))
* > '0x0a5c'
*/
const usbIdToString = (id) => {
return `0x${_.padStart(id.toString(USB_ID_RADIX), USB_ID_LENGTH, '0')}`
}
/**
* @summary Product ID of BCM2708
* @type {Number}
* @constant
*/
const USB_PRODUCT_ID_BCM2708_BOOT = 0x2763
/**
* @summary Product ID of BCM2710
* @type {Number}
* @constant
*/
const USB_PRODUCT_ID_BCM2710_BOOT = 0x2764
/**
* @summary Compute module descriptions
* @type {Object}
* @constant
*/
const COMPUTE_MODULE_DESCRIPTIONS = {
[USB_PRODUCT_ID_BCM2708_BOOT]: 'Compute Module 1',
[USB_PRODUCT_ID_BCM2710_BOOT]: 'Compute Module 3'
}
app.run(($timeout) => {
const BLACKLISTED_DRIVES = settings.has('driveBlacklist')
? settings.get('driveBlacklist').split(',')
: []
// eslint-disable-next-line require-jsdoc
const driveIsAllowed = (drive) => {
return !(
BLACKLISTED_DRIVES.includes(drive.devicePath) ||
BLACKLISTED_DRIVES.includes(drive.device) ||
BLACKLISTED_DRIVES.includes(drive.raw)
)
}
// eslint-disable-next-line require-jsdoc,consistent-return
const prepareDrive = (drive) => {
if (drive instanceof sdk.sourceDestination.BlockDevice) {
return drive.drive
} else if (drive instanceof sdk.sourceDestination.UsbbootDrive) {
// This is a workaround etcher expecting a device string and a size
drive.device = drive.usbDevice.portId
drive.size = null
drive.progress = 0
drive.disabled = true
drive.on('progress', (progress) => {
updateDriveProgress(drive, progress)
})
return drive
} else if (drive instanceof sdk.sourceDestination.DriverlessDevice) {
const description = COMPUTE_MODULE_DESCRIPTIONS[drive.deviceDescriptor.idProduct] || 'Compute Module'
return {
device: `${usbIdToString(drive.deviceDescriptor.idVendor)}:${usbIdToString(drive.deviceDescriptor.idProduct)}`,
displayName: 'Missing drivers',
description,
mountpoints: [],
isReadOnly: false,
isSystem: false,
disabled: true,
icon: 'warning',
size: null,
link: 'https://www.raspberrypi.org/documentation/hardware/computemodule/cm-emmc-flashing.md',
linkCTA: 'Install',
linkTitle: 'Install missing drivers',
linkMessage: [
'Would you like to download the necessary drivers from the Raspberry Pi Foundation?',
'This will open your browser.\n\n',
'Once opened, download and run the installer from the "Windows Installer" section to install the drivers.'
].join(' ')
}
}
}
// eslint-disable-next-line require-jsdoc
const setDrives = (drives) => {
availableDrives.setDrives(_.values(drives))
// Safely trigger a digest cycle.
// In some cases, AngularJS doesn't acknowledge that the
// available drives list has changed, and incorrectly
// keeps asking the user to "Connect a drive".
$timeout()
}
// eslint-disable-next-line require-jsdoc
const getDrives = () => {
return _.keyBy(availableDrives.getDrives() || [], 'device')
}
// eslint-disable-next-line require-jsdoc
const addDrive = (drive) => {
const preparedDrive = prepareDrive(drive)
if (!driveIsAllowed(preparedDrive)) {
return
}
const drives = getDrives()
drives[preparedDrive.device] = preparedDrive
setDrives(drives)
}
// eslint-disable-next-line require-jsdoc
const removeDrive = (drive) => {
const preparedDrive = prepareDrive(drive)
const drives = getDrives()
// eslint-disable-next-line prefer-reflect
delete drives[preparedDrive.device]
setDrives(drives)
}
// eslint-disable-next-line require-jsdoc
const updateDriveProgress = (drive, progress) => {
const drives = getDrives()
const driveInMap = drives[drive.device]
if (driveInMap) {
driveInMap.progress = progress
setDrives(drives)
}
}
driveScanner.on('attach', addDrive)
driveScanner.on('detach', removeDrive)
driveScanner.on('error', (error) => {
// Stop the drive scanning loop in case of errors,
// otherwise we risk presenting the same error over
// and over again to the user, while also heavily
// spamming our error reporting service.
driveScanner.stop()
return exceptionReporter.report(error)
})
driveScanner.start()
})
app.run(($window) => {
let popupExists = false
$window.addEventListener('beforeunload', (event) => {
if (!flashState.isFlashing() || popupExists) {
analytics.logEvent('Close application', {
isFlashing: flashState.isFlashing(),
applicationSessionUuid
})
return
}
// Don't close window while flashing
event.returnValue = false
// Don't open any more popups
popupExists = true
analytics.logEvent('Close attempt while flashing', { applicationSessionUuid, flashingWorkflowUuid })
osDialog.showWarning({
confirmationLabel: 'Yes, quit',
rejectionLabel: 'Cancel',
title: 'Are you sure you want to close Etcher?',
description: messages.warning.exitWhileFlashing()
}).then((confirmed) => {
if (confirmed) {
analytics.logEvent('Close confirmed while flashing', {
flashInstanceUuid: flashState.getFlashUuid(),
applicationSessionUuid,
flashingWorkflowUuid
})
// This circumvents the 'beforeunload' event unlike
// electron.remote.app.quit() which does not.
electron.remote.process.exit(EXIT_CODES.SUCCESS)
}
analytics.logEvent('Close rejected while flashing', { applicationSessionUuid, flashingWorkflowUuid })
popupExists = false
}).catch(exceptionReporter.report)
})
/**
* @summary Helper fn for events
* @function
* @private
* @example
* window.addEventListener('click', extendLock)
*/
const extendLock = () => {
updateLock.extend()
}
$window.addEventListener('click', extendLock)
$window.addEventListener('touchstart', extendLock)
// Initial update lock acquisition
extendLock()
})
app.run(($rootScope) => {
$rootScope.$on('$stateChangeSuccess', (event, toState, toParams, fromState) => {
// Ignore first navigation
if (!fromState.name) {
return
}
analytics.logEvent('Navigate', {
to: toState.name,
from: fromState.name,
applicationSessionUuid
})
})
})
app.config(($urlRouterProvider) => {
$urlRouterProvider.otherwise('/main')
})
app.config(($provide) => {
$provide.decorator('$exceptionHandler', ($delegate) => {
return (exception, cause) => {
exceptionReporter.report(exception)
$delegate(exception, cause)
}
})
})
app.config(($locationProvider) => {
// NOTE(Shou): this seems to invoke a minor perf decrease when set to true
$locationProvider.html5Mode({
rewriteLinks: false
})
})
app.controller('StateController', function ($rootScope, $scope) {
const unregisterStateChange = $rootScope.$on('$stateChangeSuccess', (event, toState, toParams, fromState) => {
this.previousName = fromState.name
this.currentName = toState.name
})
$scope.$on('$destroy', unregisterStateChange)
/**
* @summary Get the previous state name
* @function
* @public
*
* @returns {String} previous state name
*
* @example
* if (StateController.previousName === 'main') {
* console.log('We left the main screen!');
* }
*/
this.previousName = null
/**
* @summary Get the current state name
* @function
* @public
*
* @returns {String} current state name
*
* @example
* if (StateController.currentName === 'main') {
* console.log('We are on the main screen!');
* }
*/
this.currentName = null
})
// Ensure user settings are loaded before
// we bootstrap the Angular.js application
angular.element(document).ready(() => {
settings.load().then(() => {
angular.bootstrap(document, [ 'Etcher' ])
}).catch(exceptionReporter.report)
})

341
lib/gui/app/app.ts Normal file
View File

@ -0,0 +1,341 @@
/*
* Copyright 2016 balena.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.
*/
import * as electron from 'electron';
import * as sdk from 'etcher-sdk';
import * as _ from 'lodash';
import outdent from 'outdent';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import * as uuidV4 from 'uuid/v4';
import * as packageJSON from '../../../package.json';
import * as EXIT_CODES from '../../shared/exit-codes';
import * as messages from '../../shared/messages';
import * as availableDrives from './models/available-drives';
import * as flashState from './models/flash-state';
import * as settings from './models/settings';
import { Actions, observe, store } from './models/store';
import * as analytics from './modules/analytics';
import { scanner as driveScanner } from './modules/drive-scanner';
import * as exceptionReporter from './modules/exception-reporter';
import { updateLock } from './modules/update-lock';
import * as osDialog from './os/dialog';
import * as windowProgress from './os/window-progress';
import MainPage from './pages/main/MainPage';
window.addEventListener(
'unhandledrejection',
(event: PromiseRejectionEvent | any) => {
// Promise: event.reason
// Bluebird: event.detail.reason
// Anything else: event
const error =
event.reason || (event.detail && event.detail.reason) || event;
analytics.logException(error);
event.preventDefault();
},
);
// Set application session UUID
store.dispatch({
type: Actions.SET_APPLICATION_SESSION_UUID,
data: uuidV4(),
});
// Set first flashing workflow UUID
store.dispatch({
type: Actions.SET_FLASHING_WORKFLOW_UUID,
data: uuidV4(),
});
const applicationSessionUuid = store.getState().toJS().applicationSessionUuid;
const flashingWorkflowUuid = store.getState().toJS().flashingWorkflowUuid;
console.log(outdent`
${outdent}
_____ _ _
| ___| | | |
| |__ | |_ ___| |__ ___ _ __
| __|| __/ __| '_ \\ / _ \\ '__|
| |___| || (__| | | | __/ |
\\____/ \\__\\___|_| |_|\\___|_|
Interested in joining the Etcher team?
Drop us a line at join+etcher@balena.io
Version = ${packageJSON.version}, Type = ${packageJSON.packageType}
`);
const currentVersion = packageJSON.version;
analytics.logEvent('Application start', {
packageType: packageJSON.packageType,
version: currentVersion,
applicationSessionUuid,
});
observe(() => {
if (!flashState.isFlashing()) {
return;
}
const currentFlashState = flashState.getFlashState();
const stateType =
!currentFlashState.flashing && currentFlashState.verifying
? `Verifying ${currentFlashState.verifying}`
: `Flashing ${currentFlashState.flashing}`;
// NOTE: There is usually a short time period between the `isFlashing()`
// property being set, and the flashing actually starting, which
// might cause some non-sense flashing state logs including
// `undefined` values.
analytics.logDebug(
`${stateType} devices, ` +
`${currentFlashState.percentage}% at ${currentFlashState.speed} MB/s ` +
`(total ${currentFlashState.totalSpeed} MB/s) ` +
`eta in ${currentFlashState.eta}s ` +
`with ${currentFlashState.failed} failed devices`,
);
windowProgress.set(currentFlashState);
});
/**
* @summary The radix used by USB ID numbers
*/
const USB_ID_RADIX = 16;
/**
* @summary The expected length of a USB ID number
*/
const USB_ID_LENGTH = 4;
/**
* @summary Convert a USB id (e.g. product/vendor) to a string
*
* @example
* console.log(usbIdToString(2652))
* > '0x0a5c'
*/
function usbIdToString(id: number): string {
return `0x${_.padStart(id.toString(USB_ID_RADIX), USB_ID_LENGTH, '0')}`;
}
/**
* @summary Product ID of BCM2708
*/
const USB_PRODUCT_ID_BCM2708_BOOT = 0x2763;
/**
* @summary Product ID of BCM2710
*/
const USB_PRODUCT_ID_BCM2710_BOOT = 0x2764;
/**
* @summary Compute module descriptions
*/
const COMPUTE_MODULE_DESCRIPTIONS: _.Dictionary<string> = {
[USB_PRODUCT_ID_BCM2708_BOOT]: 'Compute Module 1',
[USB_PRODUCT_ID_BCM2710_BOOT]: 'Compute Module 3',
};
const BLACKLISTED_DRIVES = settings.has('driveBlacklist')
? settings.get('driveBlacklist').split(',')
: [];
function driveIsAllowed(drive: {
devicePath: string;
device: string;
raw: string;
}) {
return !(
BLACKLISTED_DRIVES.includes(drive.devicePath) ||
BLACKLISTED_DRIVES.includes(drive.device) ||
BLACKLISTED_DRIVES.includes(drive.raw)
);
}
type Drive =
| sdk.sourceDestination.BlockDevice
| sdk.sourceDestination.UsbbootDrive
| sdk.sourceDestination.DriverlessDevice;
function prepareDrive(drive: Drive) {
if (drive instanceof sdk.sourceDestination.BlockDevice) {
// @ts-ignore (BlockDevice.drive is private)
return drive.drive;
} else if (drive instanceof sdk.sourceDestination.UsbbootDrive) {
// This is a workaround etcher expecting a device string and a size
// @ts-ignore
drive.device = drive.usbDevice.portId;
drive.size = null;
// @ts-ignore
drive.progress = 0;
drive.disabled = true;
drive.on('progress', progress => {
updateDriveProgress(drive, progress);
});
return drive;
} else if (drive instanceof sdk.sourceDestination.DriverlessDevice) {
const description =
COMPUTE_MODULE_DESCRIPTIONS[
drive.deviceDescriptor.idProduct.toString()
] || 'Compute Module';
return {
device: `${usbIdToString(
drive.deviceDescriptor.idVendor,
)}:${usbIdToString(drive.deviceDescriptor.idProduct)}`,
displayName: 'Missing drivers',
description,
mountpoints: [],
isReadOnly: false,
isSystem: false,
disabled: true,
icon: 'warning',
size: null,
link:
'https://www.raspberrypi.org/documentation/hardware/computemodule/cm-emmc-flashing.md',
linkCTA: 'Install',
linkTitle: 'Install missing drivers',
linkMessage: outdent`
Would you like to download the necessary drivers from the Raspberry Pi Foundation?
This will open your browser.
Once opened, download and run the installer from the "Windows Installer" section to install the drivers
`,
};
}
}
function setDrives(drives: _.Dictionary<any>) {
availableDrives.setDrives(_.values(drives));
}
function getDrives() {
return _.keyBy(availableDrives.getDrives() || [], 'device');
}
function addDrive(drive: Drive) {
const preparedDrive = prepareDrive(drive);
if (!driveIsAllowed(preparedDrive)) {
return;
}
const drives = getDrives();
drives[preparedDrive.device] = preparedDrive;
setDrives(drives);
}
function removeDrive(drive: Drive) {
const preparedDrive = prepareDrive(drive);
const drives = getDrives();
delete drives[preparedDrive.device];
setDrives(drives);
}
function updateDriveProgress(
drive: sdk.sourceDestination.UsbbootDrive,
progress: number,
) {
const drives = getDrives();
// @ts-ignore
const driveInMap = drives[drive.device];
if (driveInMap) {
driveInMap.progress = progress;
setDrives(drives);
}
}
driveScanner.on('attach', addDrive);
driveScanner.on('detach', removeDrive);
driveScanner.on('error', error => {
// Stop the drive scanning loop in case of errors,
// otherwise we risk presenting the same error over
// and over again to the user, while also heavily
// spamming our error reporting service.
driveScanner.stop();
return exceptionReporter.report(error);
});
driveScanner.start();
let popupExists = false;
window.addEventListener('beforeunload', async event => {
if (!flashState.isFlashing() || popupExists) {
analytics.logEvent('Close application', {
isFlashing: flashState.isFlashing(),
applicationSessionUuid,
});
return;
}
// Don't close window while flashing
event.returnValue = false;
// Don't open any more popups
popupExists = true;
analytics.logEvent('Close attempt while flashing', {
applicationSessionUuid,
flashingWorkflowUuid,
});
try {
const confirmed = await osDialog.showWarning({
confirmationLabel: 'Yes, quit',
rejectionLabel: 'Cancel',
title: 'Are you sure you want to close Etcher?',
description: messages.warning.exitWhileFlashing(),
});
if (confirmed) {
analytics.logEvent('Close confirmed while flashing', {
flashInstanceUuid: flashState.getFlashUuid(),
applicationSessionUuid,
flashingWorkflowUuid,
});
// This circumvents the 'beforeunload' event unlike
// electron.remote.app.quit() which does not.
electron.remote.process.exit(EXIT_CODES.SUCCESS);
}
analytics.logEvent('Close rejected while flashing', {
applicationSessionUuid,
flashingWorkflowUuid,
});
popupExists = false;
} catch (error) {
exceptionReporter.report(error);
}
});
function extendLock() {
updateLock.extend();
}
window.addEventListener('click', extendLock);
window.addEventListener('touchstart', extendLock);
// Initial update lock acquisition
extendLock();
settings.load().catch(exceptionReporter.report);
ReactDOM.render(React.createElement(MainPage), document.getElementById('main'));

View File

@ -1,337 +0,0 @@
/*
* Copyright 2019 balena.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')
const React = require('react')
const { Modal } = require('rendition')
const {
isDriveValid,
getDriveImageCompatibilityStatuses,
hasListDriveImageCompatibilityStatus,
COMPATIBILITY_STATUS_TYPES
} = require('../../../../shared/drive-constraints')
const store = require('../../models/store')
const analytics = require('../../modules/analytics')
const availableDrives = require('../../models/available-drives')
const selectionState = require('../../models/selection-state')
const { bytesToClosestUnit } = require('../../../../shared/units')
const utils = require('../../../../shared/utils')
const { open: openExternal } = require('../../os/open-external/services/open-external')
/**
* @summary Determine if we can change a drive's selection state
* @function
* @private
*
* @param {Object} drive - drive
* @returns {Promise}
*
* @example
* shouldChangeDriveSelectionState(drive)
* .then((shouldChangeDriveSelectionState) => {
* if (shouldChangeDriveSelectionState) doSomething();
* });
*/
const shouldChangeDriveSelectionState = (drive) => {
return isDriveValid(drive, selectionState.getImage())
}
/**
* @summary Toggle a drive selection
* @function
* @public
*
* @param {Object} drive - drive
* @returns {void}
*
* @example
* toggleDrive({
* device: '/dev/disk2',
* size: 999999999,
* name: 'Cruzer USB drive'
* });
*/
const toggleDrive = (drive) => {
const canChangeDriveSelectionState = shouldChangeDriveSelectionState(drive)
if (canChangeDriveSelectionState) {
analytics.logEvent('Toggle drive', {
drive,
previouslySelected: selectionState.isCurrentDrive(availableDrives.device),
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
selectionState.toggleDrive(drive.device)
}
}
/**
* @summary Memoized getDrives function
* @function
* @public
*
* @returns {Array<Object>} - memoized list of drives
*
* @example
* const drives = getDrives()
* // Do something with drives
*/
const getDrives = utils.memoize(availableDrives.getDrives, _.isEqual)
/**
* @summary Get a drive's compatibility status object(s)
* @function
* @public
*
* @description
* Given a drive, return its compatibility status with the selected image,
* containing the status type (ERROR, WARNING), and accompanying
* status message.
*
* @returns {Object[]} list of objects containing statuses
*
* @example
* const statuses = getDriveStatuses(drive);
*
* for ({ type, message } of statuses) {
* // do something
* }
*/
const getDriveStatuses = utils.memoize((drive) => {
return getDriveImageCompatibilityStatuses(drive, selectionState.getImage())
}, _.isEqual)
/**
* @summary Keyboard event drive toggling
* @function
* @public
*
* @description
* Keyboard-event specific entry to the toggleDrive function.
*
* @param {Object} drive - drive
* @param {Object} evt - event
*
* @example
* <div tabindex="1" onKeyPress="keyboardToggleDrive(drive, evt)">
* Tab-select me and press enter or space!
* </div>
*/
const keyboardToggleDrive = (drive, evt) => {
const ENTER = 13
const SPACE = 32
if (_.includes([ ENTER, SPACE ], evt.keyCode)) {
toggleDrive(drive)
}
}
const DriveSelectorModal = ({ close }) => {
const [ confirmModal, setConfirmModal ] = React.useState({ open: false })
const [ drives, setDrives ] = React.useState(getDrives())
React.useEffect(() => {
const unsubscribe = store.subscribe(() => {
setDrives(availableDrives.getDrives())
})
return unsubscribe
})
/**
* @summary Prompt the user to install missing usbboot drivers
* @function
* @public
*
* @param {Object} drive - drive
* @returns {void}
*
* @example
* installMissingDrivers({
* linkTitle: 'Go to example.com',
* linkMessage: 'Examples are great, right?',
* linkCTA: 'Call To Action',
* link: 'https://example.com'
* });
*/
const installMissingDrivers = (drive) => {
if (drive.link) {
analytics.logEvent('Open driver link modal', {
url: drive.link,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
setConfirmModal({
open: true,
options: {
width: 400,
title: drive.linkTitle,
cancel: () => setConfirmModal({ open: false }),
done: async (shouldContinue) => {
try {
if (shouldContinue) {
openExternal(drive.link)
} else {
setConfirmModal({ open: false })
}
} catch (error) {
analytics.logException(error)
}
},
action: 'Yes, continue',
cancelButtonProps: {
children: 'Cancel'
},
children: drive.linkMessage || `Etcher will open ${drive.link} in your browser`
}
})
}
}
/**
* @summary Select a drive and close the modal
* @function
* @public
*
* @param {Object} drive - drive
* @returns {void}
*
* @example
* selectDriveAndClose({
* device: '/dev/disk2',
* size: 999999999,
* name: 'Cruzer USB drive'
* });
*/
const selectDriveAndClose = async (drive) => {
const canChangeDriveSelectionState = await shouldChangeDriveSelectionState(drive)
if (canChangeDriveSelectionState) {
selectionState.selectDrive(drive.device)
analytics.logEvent('Drive selected (double click)', {
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
close()
}
}
const hasStatus = hasListDriveImageCompatibilityStatus(selectionState.getSelectedDrives(), selectionState.getImage())
return (
<Modal
className='modal-drive-selector-modal'
title='Select a Drive'
done={close}
action='Continue'
style={{
padding: '20px 30px 11px 30px'
}}
primaryButtonProps={{
primary: !hasStatus,
warning: hasStatus
}}
>
<div>
<ul style={{
height: '250px',
overflowX: 'hidden',
overflowY: 'auto',
padding: '0'
}}>
{_.map(drives, (drive, index) => {
return (
<li
key={`item-${drive.displayName}`}
className="list-group-item"
disabled={!isDriveValid(drive, selectionState.getImage())}
onDoubleClick={() => selectDriveAndClose(drive, close)}
onClick={() => toggleDrive(drive)}
>
{drive.icon && <img className="list-group-item-section" alt="Drive device type logo"
src={`../assets/${drive.icon}.svg`}
width="25"
height="30"/>}
<div
className="list-group-item-section list-group-item-section-expanded"
// eslint-disable-next-line no-magic-numbers
tabIndex={ 15 + index }
onKeyPress={(evt) => keyboardToggleDrive(drive, evt)}>
<h6 className="list-group-item-heading">
{ drive.description }
{drive.size && <span className="word-keep"> - { bytesToClosestUnit(drive.size) }</span>}
</h6>
{!drive.link && <p className="list-group-item-text">
{ drive.displayName }
</p>}
{drive.link && <p className="list-group-item-text">
{ drive.displayName } - <b><a onClick={() => installMissingDrivers(drive)}>{ drive.linkCTA }</a></b>
</p>}
<footer className="list-group-item-footer">
{_.map(getDriveStatuses(drive), (status, idx) => {
const className = {
[COMPATIBILITY_STATUS_TYPES.WARNING]: 'label-warning',
[COMPATIBILITY_STATUS_TYPES.ERROR]: 'label-danger'
}
return (
<span key={`${drive.displayName}-status-${idx}`} className={`label ${className[status.type]}`}>
{ status.message }
</span>
)
})}
</footer>
{Boolean(drive.progress) && (
<progress
className='drive-init-progress'
value={ drive.progress }
max="100">
</progress>
)}
</div>
{isDriveValid(drive, selectionState.getImage()) && (
<span className="list-group-item-section tick tick--success"
disabled={!selectionState.isDriveSelected(drive.device)}>
</span>
)}
</li>
)
})}
{!availableDrives.hasAvailableDrives() && <li className="list-group-item">
<div>
<b>Connect a drive!</b>
<div>No removable drive detected.</div>
</div>
</li>}
</ul>
</div>
{confirmModal.open && <Modal
{...confirmModal.options}
>
</Modal>
}
</Modal>
)
}
module.exports = DriveSelectorModal

View File

@ -0,0 +1,292 @@
/*
* Copyright 2019 balena.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.
*/
import { Drive as DrivelistDrive } from 'drivelist';
import * as _ from 'lodash';
import * as React from 'react';
import { Modal } from 'rendition';
import {
COMPATIBILITY_STATUS_TYPES,
getDriveImageCompatibilityStatuses,
hasListDriveImageCompatibilityStatus,
isDriveValid,
} from '../../../../shared/drive-constraints';
import { bytesToClosestUnit } from '../../../../shared/units';
import { getDrives, hasAvailableDrives } from '../../models/available-drives';
import * as selectionState from '../../models/selection-state';
import { store } from '../../models/store';
import * as analytics from '../../modules/analytics';
import { open as openExternal } from '../../os/open-external/services/open-external';
/**
* @summary Determine if we can change a drive's selection state
*/
function shouldChangeDriveSelectionState(drive: DrivelistDrive) {
return isDriveValid(drive, selectionState.getImage());
}
/**
* @summary Toggle a drive selection
*/
function toggleDrive(drive: DrivelistDrive) {
const canChangeDriveSelectionState = shouldChangeDriveSelectionState(drive);
if (canChangeDriveSelectionState) {
analytics.logEvent('Toggle drive', {
drive,
previouslySelected: selectionState.isDriveSelected(drive.device),
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
});
selectionState.toggleDrive(drive.device);
}
}
/**
* @summary Get a drive's compatibility status object(s)
*
* @description
* Given a drive, return its compatibility status with the selected image,
* containing the status type (ERROR, WARNING), and accompanying
* status message.
*/
function getDriveStatuses(
drive: DrivelistDrive,
): Array<{ type: number; message: string }> {
return getDriveImageCompatibilityStatuses(drive, selectionState.getImage());
}
function keyboardToggleDrive(
drive: DrivelistDrive,
event: React.KeyboardEvent<HTMLDivElement>,
) {
const ENTER = 13;
const SPACE = 32;
if (_.includes([ENTER, SPACE], event.keyCode)) {
toggleDrive(drive);
}
}
interface DriverlessDrive {
link: string;
linkTitle: string;
linkMessage: string;
}
export function DriveSelectorModal({ close }: { close: () => void }) {
const defaultMissingDriversModalState: { drive?: DriverlessDrive } = {};
const [missingDriversModal, setMissingDriversModal] = React.useState(
defaultMissingDriversModalState,
);
const [drives, setDrives] = React.useState(getDrives());
React.useEffect(() => {
const unsubscribe = store.subscribe(() => {
setDrives(getDrives());
});
return unsubscribe;
});
/**
* @summary Prompt the user to install missing usbboot drivers
*/
function installMissingDrivers(drive: {
link: string;
linkTitle: string;
linkMessage: string;
}) {
if (drive.link) {
analytics.logEvent('Open driver link modal', {
url: drive.link,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
});
setMissingDriversModal({ drive });
}
}
/**
* @summary Select a drive and close the modal
*/
async function selectDriveAndClose(drive: DrivelistDrive) {
const canChangeDriveSelectionState = await shouldChangeDriveSelectionState(
drive,
);
if (canChangeDriveSelectionState) {
selectionState.selectDrive(drive.device);
analytics.logEvent('Drive selected (double click)', {
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
});
close();
}
}
const hasStatus = hasListDriveImageCompatibilityStatus(
selectionState.getSelectedDrives(),
selectionState.getImage(),
);
return (
<Modal
className="modal-drive-selector-modal"
title="Select a Drive"
done={close}
action="Continue"
style={{
padding: '20px 30px 11px 30px',
}}
primaryButtonProps={{
primary: !hasStatus,
warning: hasStatus,
}}
>
<div>
<ul
style={{
height: '250px',
overflowX: 'hidden',
overflowY: 'auto',
padding: '0',
}}
>
{_.map(drives, (drive, index) => {
return (
<li
key={`item-${drive.displayName}`}
className="list-group-item"
// @ts-ignore (FIXME: not a valid <li> attribute but used by css rule)
disabled={!isDriveValid(drive, selectionState.getImage())}
onDoubleClick={() => selectDriveAndClose(drive)}
onClick={() => toggleDrive(drive)}
>
{drive.icon && (
<img
className="list-group-item-section"
alt="Drive device type logo"
src={`../assets/${drive.icon}.svg`}
width="25"
height="30"
/>
)}
<div
className="list-group-item-section list-group-item-section-expanded"
tabIndex={15 + index}
onKeyPress={evt => keyboardToggleDrive(drive, evt)}
>
<h6 className="list-group-item-heading">
{drive.description}
{drive.size && (
<span className="word-keep">
{' '}
- {bytesToClosestUnit(drive.size)}
</span>
)}
</h6>
{!drive.link && (
<p className="list-group-item-text">{drive.displayName}</p>
)}
{drive.link && (
<p className="list-group-item-text">
{drive.displayName} -{' '}
<b>
<a onClick={() => installMissingDrivers(drive)}>
{drive.linkCTA}
</a>
</b>
</p>
)}
<footer className="list-group-item-footer">
{_.map(getDriveStatuses(drive), (status, idx) => {
const className = {
[COMPATIBILITY_STATUS_TYPES.WARNING]: 'label-warning',
[COMPATIBILITY_STATUS_TYPES.ERROR]: 'label-danger',
};
return (
<span
key={`${drive.displayName}-status-${idx}`}
className={`label ${className[status.type]}`}
>
{status.message}
</span>
);
})}
</footer>
{Boolean(drive.progress) && (
<progress
className="drive-init-progress"
value={drive.progress}
max="100"
></progress>
)}
</div>
{isDriveValid(drive, selectionState.getImage()) && (
<span
className="list-group-item-section tick tick--success"
// @ts-ignore (FIXME: not a valid <span> attribute but used by css rule)
disabled={!selectionState.isDriveSelected(drive.device)}
></span>
)}
</li>
);
})}
{!hasAvailableDrives() && (
<li className="list-group-item">
<div>
<b>Connect a drive!</b>
<div>No removable drive detected.</div>
</div>
</li>
)}
</ul>
</div>
{missingDriversModal.drive !== undefined && (
<Modal
width={400}
title={missingDriversModal.drive.linkTitle}
cancel={() => setMissingDriversModal({})}
done={() => {
try {
if (missingDriversModal.drive !== undefined) {
openExternal(missingDriversModal.drive.link);
}
} catch (error) {
analytics.logException(error);
} finally {
setMissingDriversModal({});
}
}}
action={'Yes, continue'}
cancelButtonProps={{
children: 'Cancel',
}}
children={
missingDriversModal.drive.linkMessage ||
`Etcher will open ${missingDriversModal.drive.link} in your browser`
}
></Modal>
)}
</Modal>
);
}

View File

@ -1,164 +0,0 @@
/*
* Copyright 2019 balena.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.
*/
/* eslint-disable no-magic-numbers */
'use strict'
// eslint-disable-next-line no-unused-vars
const React = require('react')
const propTypes = require('prop-types')
const { default: styled } = require('styled-components')
const {
ChangeButton,
DetailsText,
StepButton,
StepNameButton
} = require('./../../styled-components')
const { Txt } = require('rendition')
const middleEllipsis = require('./../../utils/middle-ellipsis')
const { bytesToClosestUnit } = require('./../../../../shared/units')
const TargetDetail = styled((props) => (
<Txt.span {...props}>
</Txt.span>
)) `
float: ${({ float }) => float}
`
const TargetDisplayText = ({
description,
size,
...props
}) => {
return (
<Txt.span {...props}>
<TargetDetail
float='left'>
{description}
</TargetDetail>
<TargetDetail
float='right'
>
{size}
</TargetDetail>
</Txt.span>
)
}
const TargetSelector = (props) => {
const targets = props.selection.getSelectedDrives()
if (targets.length === 1) {
const target = targets[0]
return (
<React.Fragment>
<StepNameButton
plain
tooltip={props.tooltip}
>
{/* eslint-disable no-magic-numbers */}
{ middleEllipsis(target.description, 20) }
</StepNameButton>
{!props.flashing &&
<ChangeButton
plain
mb={14}
onClick={props.reselectDrive}
>
Change
</ChangeButton>
}
<DetailsText>
{ props.constraints.hasListDriveImageCompatibilityStatus(targets, props.image) &&
<Txt.span className='glyphicon glyphicon-exclamation-sign'
ml={2}
tooltip={
props.constraints.getListDriveImageCompatibilityStatuses(targets, props.image)[0].message
}
/>
}
{ bytesToClosestUnit(target.size) }
</DetailsText>
</React.Fragment>
)
}
if (targets.length > 1) {
const targetsTemplate = []
for (const target of targets) {
targetsTemplate.push((
<DetailsText
key={target.device}
tooltip={
`${target.description} ${target.displayName} ${bytesToClosestUnit(target.size)}`
}
px={21}
>
<TargetDisplayText
description={middleEllipsis(target.description, 14)}
size={bytesToClosestUnit(target.size)}
>
</TargetDisplayText>
</DetailsText>
))
}
return (
<React.Fragment>
<StepNameButton
plain
tooltip={props.tooltip}
>
{targets.length} Targets
</StepNameButton>
{ !props.flashing &&
<ChangeButton
plain
onClick={props.reselectDrive}
mb={14}
>
Change
</ChangeButton>
}
{targetsTemplate}
</React.Fragment>
)
}
return (
<StepButton
tabindex={(targets.length > 0) ? -1 : 2 }
disabled={props.disabled}
onClick={props.openDriveSelector}
>
Select target
</StepButton>
)
}
TargetSelector.propTypes = {
targets: propTypes.array,
disabled: propTypes.bool,
openDriveSelector: propTypes.func,
selection: propTypes.object,
reselectDrive: propTypes.func,
flashing: propTypes.bool,
constraints: propTypes.object,
show: propTypes.bool,
tooltip: propTypes.string
}
module.exports = TargetSelector

View File

@ -0,0 +1,143 @@
/*
* Copyright 2019 balena.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.
*/
import { Drive as DrivelistDrive } from 'drivelist';
import * as _ from 'lodash';
import * as React from 'react';
import { Txt } from 'rendition';
import { default as styled } from 'styled-components';
import {
getDriveImageCompatibilityStatuses,
Image,
} from '../../../../shared/drive-constraints';
import { bytesToClosestUnit } from '../../../../shared/units';
import { getSelectedDrives } from '../../models/selection-state';
import {
ChangeButton,
DetailsText,
StepButton,
StepNameButton,
} from '../../styled-components';
import { middleEllipsis } from '../../utils/middle-ellipsis';
const TargetDetail = styled(props => <Txt.span {...props}></Txt.span>)`
float: ${({ float }) => float};
`;
interface TargetSelectorProps {
targets: any[];
disabled: boolean;
openDriveSelector: () => any;
reselectDrive: () => any;
flashing: boolean;
show: boolean;
tooltip: string;
image: Image;
}
function DriveCompatibilityWarning(props: {
drive: DrivelistDrive;
image: Image;
}) {
const compatibilityWarnings = getDriveImageCompatibilityStatuses(
props.drive,
props.image,
);
if (compatibilityWarnings.length === 0) {
return null;
}
const messages = _.map(compatibilityWarnings, 'message');
return (
<Txt.span
className="glyphicon glyphicon-exclamation-sign"
ml={2}
tooltip={messages.join(', ')}
/>
);
}
export function TargetSelector(props: TargetSelectorProps) {
const targets = getSelectedDrives();
if (targets.length === 1) {
const target = targets[0];
return (
<>
<StepNameButton plain tooltip={props.tooltip}>
{middleEllipsis(target.description, 20)}
</StepNameButton>
{!props.flashing && (
<ChangeButton plain mb={14} onClick={props.reselectDrive}>
Change
</ChangeButton>
)}
<DetailsText>
<DriveCompatibilityWarning drive={target} image={props.image} />
{bytesToClosestUnit(target.size)}
</DetailsText>
</>
);
}
if (targets.length > 1) {
const targetsTemplate = [];
for (const target of targets) {
targetsTemplate.push(
<DetailsText
key={target.device}
tooltip={`${target.description} ${
target.displayName
} ${bytesToClosestUnit(target.size)}`}
px={21}
>
<Txt.span>
<DriveCompatibilityWarning drive={target} image={props.image} />
<TargetDetail float="left">
{middleEllipsis(target.description, 14)}
</TargetDetail>
<TargetDetail float="right">
{bytesToClosestUnit(target.size)}
</TargetDetail>
</Txt.span>
</DetailsText>,
);
}
return (
<>
<StepNameButton plain tooltip={props.tooltip}>
{targets.length} Targets
</StepNameButton>
{!props.flashing && (
<ChangeButton plain onClick={props.reselectDrive} mb={14}>
Change
</ChangeButton>
)}
{targetsTemplate}
</>
);
}
return (
<StepButton
tabindex={targets.length > 0 ? -1 : 2}
disabled={props.disabled}
onClick={props.openDriveSelector}
>
Select target
</StepButton>
);
}

View File

@ -1,57 +0,0 @@
/*
* Copyright 2016 balena.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 React = require('react')
const propTypes = require('prop-types')
const SafeWebview = require('../safe-webview/safe-webview.jsx')
const settings = require('../../models/settings')
const analytics = require('../../modules/analytics')
class FeaturedProject extends React.Component {
constructor (props) {
super(props)
this.state = {
endpoint: null
}
}
componentDidMount () {
return settings.load()
.then(() => {
const endpoint = settings.get('featuredProjectEndpoint') || 'https://assets.balena.io/etcher-featured/index.html'
this.setState({ endpoint })
})
.catch(analytics.logException)
}
render () {
return (this.state.endpoint) ? (
<SafeWebview
src={this.state.endpoint}
{...this.props}>
</SafeWebview>
) : null
}
}
FeaturedProject.propTypes = {
onWebviewShow: propTypes.func
}
module.exports = FeaturedProject

View File

@ -0,0 +1,57 @@
/*
* Copyright 2016 balena.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.
*/
import * as React from 'react';
import * as settings from '../../models/settings';
import * as analytics from '../../modules/analytics';
import { SafeWebview } from '../safe-webview/safe-webview';
interface FeaturedProjectProps {
onWebviewShow: (isWebviewShowing: boolean) => void;
}
interface FeaturedProjectState {
endpoint: string | null;
}
export class FeaturedProject extends React.Component<
FeaturedProjectProps,
FeaturedProjectState
> {
constructor(props: FeaturedProjectProps) {
super(props);
this.state = { endpoint: null };
}
public async componentDidMount() {
try {
await settings.load();
const endpoint =
settings.get('featuredProjectEndpoint') ||
'https://assets.balena.io/etcher-featured/index.html';
this.setState({ endpoint });
} catch (error) {
analytics.logException(error);
}
}
public render() {
return this.state.endpoint ? (
<SafeWebview src={this.state.endpoint} {...this.props}></SafeWebview>
) : null;
}
}

View File

@ -21,19 +21,18 @@ import * as uuidV4 from 'uuid/v4';
import * as messages from '../../../../shared/messages';
import * as flashState from '../../models/flash-state';
import * as selectionState from '../../models/selection-state';
import * as store from '../../models/store';
import { store } from '../../models/store';
import * as analytics from '../../modules/analytics';
import * as updateLock from '../../modules/update-lock';
import { updateLock } from '../../modules/update-lock';
import { open as openExternal } from '../../os/open-external/services/open-external';
import { FlashAnother } from '../flash-another/flash-another';
import { FlashResults } from '../flash-results/flash-results';
import * as SVGIcon from '../svg-icon/svg-icon';
import { SVGIcon } from '../svg-icon/svg-icon';
const restart = (options: any, $state: any) => {
const restart = (options: any, goToMain: () => void) => {
const {
applicationSessionUuid,
flashingWorkflowUuid,
// @ts-ignore
} = store.getState().toJS();
if (!options.preserveImage) {
selectionState.deselectImage();
@ -54,7 +53,7 @@ const restart = (options: any, $state: any) => {
data: uuidV4(),
});
$state.go('main');
goToMain();
};
const formattedErrors = () => {
@ -67,7 +66,7 @@ const formattedErrors = () => {
return errors.join('\n');
};
function FinishPage({ $state }: any) {
function FinishPage({ goToMain }: { goToMain: () => void }) {
// @ts-ignore
const results = flashState.getFlashResults().results || {};
const progressMessage = messages.progress;
@ -82,7 +81,7 @@ function FinishPage({ $state }: any) {
></FlashResults>
<FlashAnother
onClick={(options: any) => restart(options, $state)}
onClick={(options: any) => restart(options, goToMain)}
></FlashAnother>
</div>

View File

@ -1,35 +0,0 @@
/*
* Copyright 2019 balena.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.
*/
/**
* @module Etcher.Pages.Finish
*/
import * as angular from 'angular';
import { react2angular } from 'react2angular';
import FinishPage from './finish';
export const MODULE_NAME = 'Etcher.Pages.Finish';
const Finish = angular.module(MODULE_NAME, []);
Finish.component('finish', react2angular(FinishPage, [], ['$state']));
Finish.config(($stateProvider: any) => {
$stateProvider.state('success', {
url: '/success',
template: '<finish style="width:100%"></finish>',
});
});

View File

@ -1,24 +0,0 @@
/*
* Copyright 2019 balena.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.
*/
import * as angular from 'angular';
import { react2angular } from 'react2angular';
import { FlashAnother } from './flash-another';
export const MODULE_NAME = 'Etcher.Components.FlashAnother';
const FlashAnotherModule = angular.module(MODULE_NAME, []);
FlashAnotherModule.component('flashAnother', react2angular(FlashAnother));

View File

@ -1,28 +0,0 @@
/*
* Copyright 2019 balena.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.
*/
/**
* @module Etcher.Components.FlashResults
*/
import * as angular from 'angular';
import { react2angular } from 'react2angular';
import { FlashResults } from './flash-results';
export const MODULE_NAME = 'Etcher.Components.FlashResults';
const FlashResultsModule = angular.module(MODULE_NAME, []);
FlashResultsModule.component('flashResults', react2angular(FlashResults));

View File

@ -1,404 +0,0 @@
/*
* Copyright 2016 balena.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 sdk = require('etcher-sdk')
const _ = require('lodash')
const path = require('path')
const propTypes = require('prop-types')
const React = require('react')
const Dropzone = require('react-dropzone').default
const errors = require('../../../../shared/errors')
const messages = require('../../../../shared/messages')
const supportedFormats = require('../../../../shared/supported-formats')
const shared = require('../../../../shared/units')
const selectionState = require('../../models/selection-state')
const store = require('../../models/store')
const analytics = require('../../modules/analytics')
const exceptionReporter = require('../../modules/exception-reporter')
const osDialog = require('../../os/dialog')
const { replaceWindowsNetworkDriveLetter } = require('../../os/windows-network-drives')
const {
StepButton,
StepNameButton,
StepSelection,
Footer,
Underline,
DetailsText,
ChangeButton
} = require('../../styled-components')
const {
Modal
} = require('rendition')
const middleEllipsis = require('../../utils/middle-ellipsis')
const SVGIcon = require('../svg-icon/svg-icon.jsx')
const { default: styled } = require('styled-components')
// TODO move these styles to rendition
const ModalText = styled.p `
a {
color: rgb(0, 174, 239);
&:hover {
color: rgb(0, 139, 191);
}
}
`
/**
* @summary Main supported extensions
* @constant
* @type {String[]}
* @public
*/
const mainSupportedExtensions = _.intersection([
'img',
'iso',
'zip'
], supportedFormats.getAllExtensions())
/**
* @summary Extra supported extensions
* @constant
* @type {String[]}
* @public
*/
const extraSupportedExtensions = _.difference(
supportedFormats.getAllExtensions(),
mainSupportedExtensions
).sort()
const getState = () => {
return {
hasImage: selectionState.hasImage(),
imageName: selectionState.getImageName(),
imageSize: selectionState.getImageSize()
}
}
class ImageSelector extends React.Component {
constructor (props) {
super(props)
this.state = {
...getState(),
warning: null,
showImageDetails: false
}
this.openImageSelector = this.openImageSelector.bind(this)
this.reselectImage = this.reselectImage.bind(this)
this.handleOnDrop = this.handleOnDrop.bind(this)
this.showSelectedImageDetails = this.showSelectedImageDetails.bind(this)
}
componentDidMount () {
this.unsubscribe = store.observe(() => {
this.setState(getState())
})
}
componentWillUnmount () {
this.unsubscribe()
}
reselectImage () {
analytics.logEvent('Reselect image', {
previousImage: selectionState.getImage(),
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
this.openImageSelector()
}
selectImage (image) {
if (!supportedFormats.isSupportedImage(image.path)) {
const invalidImageError = errors.createUserError({
title: 'Invalid image',
description: messages.error.invalidImage(image)
})
osDialog.showError(invalidImageError)
analytics.logEvent('Invalid image', _.merge({
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
}, image))
return
}
Bluebird.try(() => {
let message = null
let title = null
if (supportedFormats.looksLikeWindowsImage(image.path)) {
analytics.logEvent('Possibly Windows image', {
image,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
message = messages.warning.looksLikeWindowsImage()
title = 'Possible Windows image detected'
} else if (!image.hasMBR) {
analytics.logEvent('Missing partition table', {
image,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
title = 'Missing partition table'
message = messages.warning.missingPartitionTable()
}
if (message) {
this.setState({
warning: {
message,
title
}
})
return
}
return false
}).then(() => {
selectionState.selectImage(image)
// An easy way so we can quickly identify if we're making use of
// certain features without printing pages of text to DevTools.
image.logo = Boolean(image.logo)
image.blockMap = Boolean(image.blockMap)
return analytics.logEvent('Select image', {
image,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
}).catch(exceptionReporter.report)
}
async selectImageByPath (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',
description: messages.error.invalidImage(imagePath)
})
osDialog.showError(invalidImageError)
analytics.logEvent('Invalid image', { path: imagePath })
return
}
const source = new sdk.sourceDestination.File(imagePath, sdk.sourceDestination.File.OpenFlags.Read)
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)
} 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
}
}
}
/**
* @summary Open image selector
* @function
* @public
*
* @example
* ImageSelectionController.openImageSelector();
*/
openImageSelector () {
analytics.logEvent('Open image selector', {
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
osDialog.selectImage().then((imagePath) => {
// Avoid analytics and selection state changes
// if no file was resolved from the dialog.
if (!imagePath) {
analytics.logEvent('Image selector closed', {
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
return
}
this.selectImageByPath(imagePath)
}).catch(exceptionReporter.report)
}
handleOnDrop (acceptedFiles) {
const [ file ] = acceptedFiles
if (file) {
this.selectImageByPath(file.path)
}
}
showSelectedImageDetails () {
analytics.logEvent('Show selected image tooltip', {
imagePath: selectionState.getImagePath(),
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid
})
this.setState({
showImageDetails: true
})
}
// TODO add a visual change when dragging a file over the selector
render () {
const {
flashing
} = this.props
const {
showImageDetails
} = this.state
const hasImage = selectionState.hasImage()
const imageBasename = hasImage ? path.basename(selectionState.getImagePath()) : ''
const imageName = selectionState.getImageName()
const imageSize = selectionState.getImageSize()
return (
<React.Fragment>
<div className="box text-center relative">
<Dropzone multiple={false} onDrop={this.handleOnDrop}>
{({ getRootProps, getInputProps }) => (
<div className="center-block" {...getRootProps()}>
<input {...getInputProps()} />
<SVGIcon contents={selectionState.getImageLogo()} paths={[ '../../assets/image.svg' ]} />
</div>
)}
</Dropzone>
<div className="space-vertical-large">
{hasImage ? (
<React.Fragment>
<StepNameButton
plain
onClick={this.showSelectedImageDetails}
tooltip={imageBasename}
>
{/* eslint-disable no-magic-numbers */}
{ middleEllipsis(imageName || imageBasename, 20) }
</StepNameButton>
{ !flashing &&
<ChangeButton
plain
mb={14}
onClick={this.reselectImage}
>
Change
</ChangeButton>
}
<DetailsText>
{shared.bytesToClosestUnit(imageSize)}
</DetailsText>
</React.Fragment>
) : (
<StepSelection>
<StepButton
onClick={this.openImageSelector}
>
Select image
</StepButton>
<Footer>
{ mainSupportedExtensions.join(', ') }, and{' '}
<Underline
tooltip={ extraSupportedExtensions.join(', ') }
>
many more
</Underline>
</Footer>
</StepSelection>
)}
</div>
</div>
{Boolean(this.state.warning) && (
<Modal
title={(
<span>
<span style={{ color: '#d9534f' }} className="glyphicon glyphicon-exclamation-sign"></span>
{' '}
<span>{this.state.warning.title}</span>
</span>
)}
action='Continue'
cancel={() => {
this.setState({ warning: null })
this.reselectImage()
}}
done={() => {
this.setState({ warning: null })
}}
primaryButtonProps={{ warning: true, primary: false }}
>
<ModalText dangerouslySetInnerHTML={{ __html: this.state.warning.message }} />
</Modal>
)}
{showImageDetails && (
<Modal
title="Image File Name"
done={() => {
this.setState({ showImageDetails: false })
}}
>
{selectionState.getImagePath()}
</Modal>
)}
</React.Fragment>
)
}
}
ImageSelector.propTypes = {
flashing: propTypes.bool
}
module.exports = ImageSelector

View File

@ -0,0 +1,413 @@
/*
* Copyright 2016 balena.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.
*/
import * as sdk from 'etcher-sdk';
import * as _ from 'lodash';
import { GPTPartition, MBRPartition } from 'partitioninfo';
import * as path from 'path';
import * as React from 'react';
import { default as Dropzone } from 'react-dropzone';
import { Modal } from 'rendition';
import { default as styled } from 'styled-components';
import * as errors from '../../../../shared/errors';
import * as messages from '../../../../shared/messages';
import * as supportedFormats from '../../../../shared/supported-formats';
import * as shared from '../../../../shared/units';
import * as selectionState from '../../models/selection-state';
import { observe, store } from '../../models/store';
import * as analytics from '../../modules/analytics';
import * as exceptionReporter from '../../modules/exception-reporter';
import * as osDialog from '../../os/dialog';
import { replaceWindowsNetworkDriveLetter } from '../../os/windows-network-drives';
import {
ChangeButton,
DetailsText,
Footer,
StepButton,
StepNameButton,
StepSelection,
Underline,
} from '../../styled-components';
import { middleEllipsis } from '../../utils/middle-ellipsis';
import { SVGIcon } from '../svg-icon/svg-icon';
// TODO move these styles to rendition
const ModalText = styled.p`
a {
color: rgb(0, 174, 239);
&:hover {
color: rgb(0, 139, 191);
}
}
`;
const mainSupportedExtensions = _.intersection(
['img', 'iso', 'zip'],
supportedFormats.getAllExtensions(),
);
const extraSupportedExtensions = _.difference(
supportedFormats.getAllExtensions(),
mainSupportedExtensions,
).sort();
function getState() {
return {
hasImage: selectionState.hasImage(),
imageName: selectionState.getImageName(),
imageSize: selectionState.getImageSize(),
};
}
interface ImageSelectorProps {
flashing: boolean;
}
interface ImageSelectorState {
hasImage: boolean;
imageName: string;
imageSize: number;
warning: { message: string; title: string | null } | null;
showImageDetails: boolean;
}
export class ImageSelector extends React.Component<
ImageSelectorProps,
ImageSelectorState
> {
private unsubscribe: () => void;
constructor(props: ImageSelectorProps) {
super(props);
this.state = {
...getState(),
warning: null,
showImageDetails: false,
};
this.openImageSelector = this.openImageSelector.bind(this);
this.reselectImage = this.reselectImage.bind(this);
this.handleOnDrop = this.handleOnDrop.bind(this);
this.showSelectedImageDetails = this.showSelectedImageDetails.bind(this);
}
public componentDidMount() {
this.unsubscribe = observe(() => {
this.setState(getState());
});
}
public componentWillUnmount() {
this.unsubscribe();
}
private reselectImage() {
analytics.logEvent('Reselect image', {
previousImage: selectionState.getImage(),
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
});
this.openImageSelector();
}
private selectImage(
image: sdk.sourceDestination.Metadata & {
path: string;
extension: string;
hasMBR: boolean;
},
) {
if (!supportedFormats.isSupportedImage(image.path)) {
const invalidImageError = errors.createUserError({
title: 'Invalid image',
description: messages.error.invalidImage(image.path),
});
osDialog.showError(invalidImageError);
analytics.logEvent(
'Invalid image',
_.merge(
{
applicationSessionUuid: store.getState().toJS()
.applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
},
image,
),
);
return;
}
try {
let message = null;
let title = null;
if (supportedFormats.looksLikeWindowsImage(image.path)) {
analytics.logEvent('Possibly Windows image', {
image,
applicationSessionUuid: store.getState().toJS()
.applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
});
message = messages.warning.looksLikeWindowsImage();
title = 'Possible Windows image detected';
} else if (!image.hasMBR) {
analytics.logEvent('Missing partition table', {
image,
applicationSessionUuid: store.getState().toJS()
.applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
});
title = 'Missing partition table';
message = messages.warning.missingPartitionTable();
}
if (message) {
this.setState({
warning: {
message,
title,
},
});
return;
}
selectionState.selectImage(image);
analytics.logEvent('Select image', {
// An easy way so we can quickly identify if we're making use of
// certain features without printing pages of text to DevTools.
image: {
...image,
logo: Boolean(image.logo),
blockMap: Boolean(image.blockMap),
},
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
});
} catch (error) {
exceptionReporter.report(error);
}
}
private async selectImageByPath(imagePath: string) {
try {
imagePath = await replaceWindowsNetworkDriveLetter(imagePath);
} catch (error) {
analytics.logException(error);
}
if (!supportedFormats.isSupportedImage(imagePath)) {
const invalidImageError = errors.createUserError({
title: 'Invalid image',
description: messages.error.invalidImage(imagePath),
});
osDialog.showError(invalidImageError);
analytics.logEvent('Invalid image', { path: imagePath });
return;
}
const source = new sdk.sourceDestination.File(
imagePath,
sdk.sourceDestination.File.OpenFlags.Read,
);
try {
const innerSource = await source.getInnerSource();
const metadata = (await innerSource.getMetadata()) as sdk.sourceDestination.Metadata & {
hasMBR: boolean;
partitions: MBRPartition[] | GPTPartition[];
path: string;
extension: string;
};
const partitionTable = await innerSource.getPartitionTable();
if (partitionTable) {
metadata.hasMBR = true;
metadata.partitions = partitionTable.partitions;
} else {
metadata.hasMBR = false;
}
metadata.path = imagePath;
metadata.extension = path.extname(imagePath).slice(1);
this.selectImage(metadata);
} 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
}
}
}
private async openImageSelector() {
analytics.logEvent('Open image selector', {
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
});
try {
const imagePath = await osDialog.selectImage();
// Avoid analytics and selection state changes
// if no file was resolved from the dialog.
if (!imagePath) {
analytics.logEvent('Image selector closed', {
applicationSessionUuid: store.getState().toJS()
.applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
});
return;
}
this.selectImageByPath(imagePath);
} catch (error) {
exceptionReporter.report(error);
}
}
private handleOnDrop(acceptedFiles: Array<{ path: string }>) {
const [file] = acceptedFiles;
if (file) {
this.selectImageByPath(file.path);
}
}
private showSelectedImageDetails() {
analytics.logEvent('Show selected image tooltip', {
imagePath: selectionState.getImagePath(),
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
});
this.setState({
showImageDetails: true,
});
}
// TODO add a visual change when dragging a file over the selector
public render() {
const { flashing } = this.props;
const { showImageDetails } = this.state;
const hasImage = selectionState.hasImage();
const imageBasename = hasImage
? path.basename(selectionState.getImagePath())
: '';
const imageName = selectionState.getImageName();
const imageSize = selectionState.getImageSize();
return (
<>
<div className="box text-center relative">
<Dropzone multiple={false} onDrop={this.handleOnDrop}>
{({ getRootProps, getInputProps }) => (
<div className="center-block" {...getRootProps()}>
<input {...getInputProps()} />
<SVGIcon
contents={[selectionState.getImageLogo()]}
paths={['../../assets/image.svg']}
/>
</div>
)}
</Dropzone>
<div className="space-vertical-large">
{hasImage ? (
<>
<StepNameButton
plain
onClick={this.showSelectedImageDetails}
tooltip={imageBasename}
>
{middleEllipsis(imageName || imageBasename, 20)}
</StepNameButton>
{!flashing && (
<ChangeButton plain mb={14} onClick={this.reselectImage}>
Change
</ChangeButton>
)}
<DetailsText>
{shared.bytesToClosestUnit(imageSize)}
</DetailsText>
</>
) : (
<StepSelection>
<StepButton onClick={this.openImageSelector}>
Select image
</StepButton>
<Footer>
{mainSupportedExtensions.join(', ')}, and{' '}
<Underline tooltip={extraSupportedExtensions.join(', ')}>
many more
</Underline>
</Footer>
</StepSelection>
)}
</div>
</div>
{this.state.warning != null && (
<Modal
titleElement={
<span>
<span
style={{ color: '#d9534f' }}
className="glyphicon glyphicon-exclamation-sign"
></span>{' '}
<span>{this.state.warning.title}</span>
</span>
}
action="Continue"
cancel={() => {
this.setState({ warning: null });
this.reselectImage();
}}
done={() => {
this.setState({ warning: null });
}}
primaryButtonProps={{ warning: true, primary: false }}
>
<ModalText
dangerouslySetInnerHTML={{ __html: this.state.warning.message }}
/>
</Modal>
)}
{showImageDetails && (
<Modal
title="Image File Name"
done={() => {
this.setState({ showImageDetails: false });
}}
>
{selectionState.getImagePath()}
</Modal>
)}
</>
);
}
}

View File

@ -1,100 +0,0 @@
/*
* Copyright 2016 balena.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')
const store = require('../../../models/store')
const analytics = require('../../../modules/analytics')
module.exports = function ($uibModal, $q) {
/**
* @summary Open a modal
* @function
* @public
*
* @param {Object} options - options
* @param {String} options.template - template contents
* @param {String} options.controller - controller
* @param {String} [options.size='sm'] - modal size
* @param {Object} options.resolve - modal resolves
* @returns {Object} modal
*
* @example
* ModalService.open({
* name: 'my modal',
* template: require('./path/to/modal.tpl.html'),
* controller: 'DriveSelectorController as modal',
* });
*/
this.open = (options = {}) => {
_.defaults(options, {
size: 'sm'
})
analytics.logEvent('Open modal', {
name: options.name,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
const modal = $uibModal.open({
animation: true,
template: options.template,
controller: options.controller,
size: options.size,
resolve: options.resolve,
backdrop: 'static'
})
return {
close: modal.close,
result: $q((resolve, reject) => {
modal.result.then((value) => {
analytics.logEvent('Modal accepted', {
name: options.name,
value,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
resolve(value)
}).catch((error) => {
// Bootstrap doesn't 'resolve' these but cancels the dialog
if (error === 'escape key press') {
analytics.logEvent('Modal rejected', {
name: options.name,
method: error,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
return resolve()
}
analytics.logEvent('Modal rejected', {
name: options.name,
value: error,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
return reject(error)
})
})
}
}
}

View File

@ -1,106 +0,0 @@
/*
* Copyright 2016 balena.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.
*/
.modal-content {
background-color: $palette-theme-light-background;
display: flex;
flex-direction: column;
margin: 0 auto;
height: auto;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: baseline;
font-size: 12px;
color: $palette-theme-light-soft-foreground;
padding: 11px 20px;
flex-grow: 0;
}
.modal-title {
font-size: inherit;
flex-grow: 1;
}
.modal-body {
flex-grow: 1;
color: $palette-theme-light-foreground;
padding: 20px;
max-height: 250px;
overflow: auto;
a {
color: $palette-theme-primary-background;
}
> p {
white-space: pre-line;
}
> p:last-child {
margin-bottom: 0;
}
}
.modal-menu {
display: flex;
> * {
flex-basis: auto;
}
}
// UI Bootstrap adds the `.modal-open` class to the <body>
// element and sets its right padding to the width of the
// window, causing the window content to overflow and get
// pushed to the bottom.
// The `!important` flag is needed since UI Bootstrap inlines
// the styles programmatically to the element.
.modal-open {
padding-right: 0 !important;
}
// Disable modal opacity
.modal-backdrop.in {
opacity: 0;
}
.modal-footer {
flex-grow: 0;
border: 0;
text-align: center;
}
.modal {
// Center the modal using Flexbox so we can
// freely use any height.
display: flex !important;
justify-content: center;
align-items: center;
.button[disabled] {
background-color: $palette-theme-light-disabled-background;
color: $palette-theme-light-disabled-foreground;
}
}
.modal-dialog {
margin: 0;
position: initial;
}

View File

@ -1,155 +0,0 @@
/*
* Copyright 2016 balena.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 React = require('react')
const propTypes = require('prop-types')
const Color = require('color')
const {
default: styled,
css,
keyframes
} = require('styled-components')
const { ProgressBar } = require('rendition')
const { colors } = require('./../../theme')
const { StepButton, StepSelection } = require('./../../styled-components')
const darkenForegroundStripes = 0.18
const desaturateForegroundStripes = 0.2
const progressButtonStripesForegroundColor = Color(colors.primary.background)
.darken(darkenForegroundStripes)
.desaturate(desaturateForegroundStripes)
.string()
const desaturateBackgroundStripes = 0.05
const progressButtonStripesBackgroundColor = Color(colors.primary.background)
.desaturate(desaturateBackgroundStripes)
.string()
const ProgressButtonStripes = keyframes `
0% {
background-position: 0 0;
}
100% {
background-position: 20px 20px;
}
`
const ProgressButtonStripesRule = css `
${ProgressButtonStripes} 1s linear infinite;
`
const FlashProgressBar = styled(ProgressBar) `
> div {
width: 200px;
height: 48px;
color: white !important;
text-shadow: none !important;
}
width: 200px;
height: 48px;
font-size: 16px;
line-height: 48px;
background: ${Color(colors.warning.background).darken(darkenForegroundStripes).string()};
`
const FlashProgressBarValidating = styled(FlashProgressBar) `
// Notice that we add 0.01 to certain gradient stop positions.
// That workarounds a Chrome rendering issue where diagonal
// lines look spiky.
// See https://github.com/balena-io/etcher/issues/472
background-image: -webkit-gradient(linear, 0 0, 100% 100%,
color-stop(0.25, ${progressButtonStripesForegroundColor}),
color-stop(0.26, ${progressButtonStripesBackgroundColor}),
color-stop(0.50, ${progressButtonStripesBackgroundColor}),
color-stop(0.51, ${progressButtonStripesForegroundColor}),
color-stop(0.75, ${progressButtonStripesForegroundColor}),
color-stop(0.76 , ${progressButtonStripesBackgroundColor}),
to(${progressButtonStripesBackgroundColor}));
background-color: white;
animation: ${ProgressButtonStripesRule};
overflow: hidden;
background-size: 20px 20px;
`
/**
* Progress Button component
*/
class ProgressButton extends React.Component {
render () {
if (this.props.active) {
if (this.props.striped) {
return (
<StepSelection>
<FlashProgressBarValidating
primary
emphasized
value= { this.props.percentage }
>
{ this.props.label }
</FlashProgressBarValidating>
</StepSelection>
)
}
return (
<StepSelection>
<FlashProgressBar
warning
emphasized
value= { this.props.percentage }
>
{ this.props.label }
</FlashProgressBar>
</StepSelection>
)
}
return (
<StepSelection>
<StepButton
onClick= { this.props.callback }
disabled= { this.props.disabled }
>
{this.props.label}
</StepButton>
</StepSelection>
)
}
}
ProgressButton.propTypes = {
striped: propTypes.bool,
active: propTypes.bool,
percentage: propTypes.number,
label: propTypes.string,
disabled: propTypes.bool,
callback: propTypes.func
}
module.exports = ProgressButton

View File

@ -0,0 +1,145 @@
/*
* Copyright 2016 balena.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.
*/
import * as Color from 'color';
import * as React from 'react';
import { ProgressBar } from 'rendition';
import { css, default as styled, keyframes } from 'styled-components';
import { StepButton, StepSelection } from '../../styled-components';
import { colors } from '../../theme';
const darkenForegroundStripes = 0.18;
const desaturateForegroundStripes = 0.2;
const progressButtonStripesForegroundColor = Color(colors.primary.background)
.darken(darkenForegroundStripes)
.desaturate(desaturateForegroundStripes)
.string();
const desaturateBackgroundStripes = 0.05;
const progressButtonStripesBackgroundColor = Color(colors.primary.background)
.desaturate(desaturateBackgroundStripes)
.string();
const ProgressButtonStripes = keyframes`
0% {
background-position: 0 0;
}
100% {
background-position: 20px 20px;
}
`;
const ProgressButtonStripesRule = css`
${ProgressButtonStripes} 1s linear infinite;
`;
const FlashProgressBar = styled(ProgressBar)`
> div {
width: 200px;
height: 48px;
color: white !important;
text-shadow: none !important;
}
width: 200px;
height: 48px;
font-size: 16px;
line-height: 48px;
background: ${Color(colors.warning.background)
.darken(darkenForegroundStripes)
.string()};
`;
const FlashProgressBarValidating = styled(FlashProgressBar)`
// Notice that we add 0.01 to certain gradient stop positions.
// That workarounds a Chrome rendering issue where diagonal
// lines look spiky.
// See https://github.com/balena-io/etcher/issues/472
background-image: -webkit-gradient(
linear,
0 0,
100% 100%,
color-stop(0.25, ${progressButtonStripesForegroundColor}),
color-stop(0.26, ${progressButtonStripesBackgroundColor}),
color-stop(0.5, ${progressButtonStripesBackgroundColor}),
color-stop(0.51, ${progressButtonStripesForegroundColor}),
color-stop(0.75, ${progressButtonStripesForegroundColor}),
color-stop(0.76, ${progressButtonStripesBackgroundColor}),
to(${progressButtonStripesBackgroundColor})
);
background-color: white;
animation: ${ProgressButtonStripesRule};
overflow: hidden;
background-size: 20px 20px;
`;
interface ProgressButtonProps {
striped: boolean;
active: boolean;
percentage: number;
label: string;
disabled: boolean;
callback: () => any;
}
/**
* Progress Button component
*/
export class ProgressButton extends React.Component<ProgressButtonProps> {
public render() {
if (this.props.active) {
if (this.props.striped) {
return (
<StepSelection>
<FlashProgressBarValidating
primary
emphasized
value={this.props.percentage}
>
{this.props.label}
</FlashProgressBarValidating>
</StepSelection>
);
}
return (
<StepSelection>
<FlashProgressBar warning emphasized value={this.props.percentage}>
{this.props.label}
</FlashProgressBar>
</StepSelection>
);
}
return (
<StepSelection>
<StepButton
onClick={this.props.callback}
disabled={this.props.disabled}
>
{this.props.label}
</StepButton>
</StepSelection>
);
}
}

View File

@ -1,81 +0,0 @@
/*
* Copyright 2016 balena.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 React = require('react')
const propTypes = require('prop-types')
const styled = require('styled-components').default
const { color } = require('styled-system')
const SvgIcon = require('../svg-icon/svg-icon.jsx')
const Div = styled.div `
position: absolute;
top: 45px;
left: 545px;
> span.step-name {
justify-content: flex-start;
> span {
margin-left: 10px;
}
> span:nth-child(2) {
font-weight: 500;
}
> span:nth-child(3) {
font-weight: 400;
font-style: italic;
}
}
.svg-icon[disabled] {
opacity: 0.4;
}
`
const Span = styled.span `
${color}
`
const ReducedFlashingInfos = (props) => {
return (props.shouldShow) ? (
<Div>
<Span className="step-name">
<SvgIcon disabled contents={[ props.imageLogo ]} paths={[ '../../assets/image.svg' ]} width='20px'></SvgIcon>
<Span>{ props.imageName }</Span>
<Span color='#7e8085'>{ props.imageSize }</Span>
</Span>
<Span className="step-name">
<SvgIcon disabled paths={[ '../../assets/drive.svg' ]} width='20px'></SvgIcon>
<Span>{ props.driveTitle }</Span>
</Span>
</Div>
) : null
}
ReducedFlashingInfos.propTypes = {
imageLogo: propTypes.string,
imageName: propTypes.string,
imageSize: propTypes.string,
driveTitle: propTypes.string,
shouldShow: propTypes.bool
}
module.exports = ReducedFlashingInfos

View File

@ -0,0 +1,95 @@
/*
* Copyright 2016 balena.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.
*/
import * as React from 'react';
import { default as styled } from 'styled-components';
import { color } from 'styled-system';
import { SVGIcon } from '../svg-icon/svg-icon';
const Div = styled.div`
position: absolute;
top: 45px;
left: 545px;
> span.step-name {
justify-content: flex-start;
> span {
margin-left: 10px;
}
> span:nth-child(2) {
font-weight: 500;
}
> span:nth-child(3) {
font-weight: 400;
font-style: italic;
}
}
.svg-icon[disabled] {
opacity: 0.4;
}
`;
const Span = styled.span`
${color}
`;
interface ReducedFlashingInfosProps {
imageLogo: string;
imageName: string;
imageSize: string;
driveTitle: string;
shouldShow: boolean;
}
export class ReducedFlashingInfos extends React.Component<
ReducedFlashingInfosProps
> {
constructor(props: ReducedFlashingInfosProps) {
super(props);
this.state = {};
}
public render() {
return this.props.shouldShow ? (
<Div>
<Span className="step-name">
<SVGIcon
disabled
contents={[this.props.imageLogo]}
paths={['../../assets/image.svg']}
width="20px"
></SVGIcon>
<Span>{this.props.imageName}</Span>
<Span color="#7e8085">{this.props.imageSize}</Span>
</Span>
<Span className="step-name">
<SVGIcon
disabled
paths={['../../assets/drive.svg']}
width="20px"
></SVGIcon>
<Span>{this.props.driveTitle}</Span>
</Span>
</Div>
) : null;
}
}

View File

@ -1,34 +0,0 @@
/*
* Copyright 2018 balena.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 Etcher.Components.SafeWebview
*/
const angular = require('angular')
const { react2angular } = require('react2angular')
const MODULE_NAME = 'Etcher.Components.SafeWebview'
const SafeWebview = angular.module(MODULE_NAME, [])
SafeWebview.component(
'safeWebview',
react2angular(require('./safe-webview.jsx'))
)
module.exports = MODULE_NAME

View File

@ -1,245 +0,0 @@
/*
* Copyright 2017 balena.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'
/* eslint-disable jsdoc/require-example */
const _ = require('lodash')
const electron = require('electron')
const react = require('react')
const propTypes = require('prop-types')
const analytics = require('../../modules/analytics')
const store = require('../../models/store')
const settings = require('../../models/settings')
const packageJSON = require('../../../../../package.json')
/**
* @summary Electron session identifier
* @constant
* @private
* @type {String}
*/
const ELECTRON_SESSION = 'persist:success-banner'
/**
* @summary Etcher version search-parameter key
* @constant
* @private
* @type {String}
*/
const ETCHER_VERSION_PARAM = 'etcher-version'
/**
* @summary API version search-parameter key
* @constant
* @private
* @type {String}
*/
const API_VERSION_PARAM = 'api-version'
/**
* @summary Opt-out analytics search-parameter key
* @constant
* @private
* @type {String}
*/
const OPT_OUT_ANALYTICS_PARAM = 'optOutAnalytics'
/**
* @summary Webview API version
* @constant
* @private
* @type {String}
*
* @description
* Changing this number represents a departure from an older API and as such
* should only be changed when truly necessary as it introduces breaking changes.
* This version number is exposed to the banner such that it can determine what
* features are safe to utilize.
*
* See `git blame -L n` where n is the line below for the history of version changes.
*/
const API_VERSION = 2
/**
* @summary Webviews that hide/show depending on the HTTP status returned
* @type {Object}
* @public
*
* @example
* <safe-webview src="https://etcher.io/"></safe-webview>
*/
class SafeWebview extends react.PureComponent {
/**
* @param {Object} props - React element properties
*/
constructor (props) {
super(props)
this.state = {
shouldShow: true
}
const url = new window.URL(props.src)
// We set the version GET parameters here.
url.searchParams.set(ETCHER_VERSION_PARAM, packageJSON.version)
url.searchParams.set(API_VERSION_PARAM, API_VERSION)
url.searchParams.set(OPT_OUT_ANALYTICS_PARAM, !settings.get('errorReporting'))
this.entryHref = url.href
// Events steal 'this'
this.didFailLoad = _.bind(this.didFailLoad, this)
this.didGetResponseDetails = _.bind(this.didGetResponseDetails, this)
const logWebViewMessage = (event) => {
console.log('Message from SafeWebview:', event.message)
}
this.eventTuples = [
[ 'did-fail-load', this.didFailLoad ],
[ 'new-window', this.constructor.newWindow ],
[ 'console-message', logWebViewMessage ]
]
// Make a persistent electron session for the webview
this.session = electron.remote.session.fromPartition(ELECTRON_SESSION, {
// Disable the cache for the session such that new content shows up when refreshing
cache: false
})
}
/**
* @returns {react.Element}
*/
render () {
return react.createElement('webview', {
ref: 'webview',
partition: ELECTRON_SESSION,
style: {
flex: this.state.shouldShow ? null : '0 1',
width: this.state.shouldShow ? null : '0',
height: this.state.shouldShow ? null : '0'
}
}, [])
}
/**
* @summary Add the Webview events
*/
componentDidMount () {
// Events React is unaware of have to be handled manually
_.map(this.eventTuples, (tuple) => {
this.refs.webview.addEventListener(...tuple)
})
this.session.webRequest.onCompleted(this.didGetResponseDetails)
// It's important that this comes after the partition setting, otherwise it will
// use another session and we can't change it without destroying the element again
this.refs.webview.src = this.entryHref
}
/**
* @summary Remove the Webview events
*/
componentWillUnmount () {
// Events that React is unaware of have to be handled manually
_.map(this.eventTuples, (tuple) => {
this.refs.webview.removeEventListener(...tuple)
})
this.session.webRequest.onCompleted(null)
}
/**
* @summary Set the element state to hidden
*/
didFailLoad () {
this.setState({
shouldShow: false
})
if (this.props.onWebviewShow) {
this.props.onWebviewShow(false)
}
}
/**
* @summary Set the element state depending on the HTTP response code
* @param {Event} event - Event object
*/
didGetResponseDetails (event) {
// This seems to pick up all requests related to the webview,
// only care about this event if it's a request for the main frame
if (event.resourceType === 'mainFrame') {
const HTTP_OK = 200
analytics.logEvent('SafeWebview loaded', {
event,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
this.setState({
shouldShow: event.statusCode === HTTP_OK
})
if (this.props.onWebviewShow) {
this.props.onWebviewShow(event.statusCode === HTTP_OK)
}
}
}
/**
* @summary Open link in browser if it's opened as a 'foreground-tab'
* @param {Event} event - event object
*/
static newWindow (event) {
const url = new window.URL(event.url)
if (_.every([
url.protocol === 'http:' || url.protocol === 'https:',
event.disposition === 'foreground-tab',
// Don't open links if they're disabled by the env var
!settings.get('disableExternalLinks')
])) {
electron.shell.openExternal(url.href)
}
}
}
SafeWebview.propTypes = {
/**
* @summary The website source URL
*/
src: propTypes.string.isRequired,
/**
* @summary Refresh the webview
*/
refreshNow: propTypes.bool,
/**
* @summary Webview lifecycle event
*/
onWebviewShow: propTypes.func
}
module.exports = SafeWebview

View File

@ -0,0 +1,213 @@
/*
* Copyright 2017 balena.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.
*/
import * as electron from 'electron';
import * as _ from 'lodash';
import * as React from 'react';
import * as packageJSON from '../../../../../package.json';
import * as settings from '../../models/settings';
import { store } from '../../models/store';
import * as analytics from '../../modules/analytics';
/**
* @summary Electron session identifier
*/
const ELECTRON_SESSION = 'persist:success-banner';
/**
* @summary Etcher version search-parameter key
*/
const ETCHER_VERSION_PARAM = 'etcher-version';
/**
* @summary API version search-parameter key
*/
const API_VERSION_PARAM = 'api-version';
/**
* @summary Opt-out analytics search-parameter key
*/
const OPT_OUT_ANALYTICS_PARAM = 'optOutAnalytics';
/**
* @summary Webview API version
*
* @description
* Changing this number represents a departure from an older API and as such
* should only be changed when truly necessary as it introduces breaking changes.
* This version number is exposed to the banner such that it can determine what
* features are safe to utilize.
*
* See `git blame -L n` where n is the line below for the history of version changes.
*/
const API_VERSION = '2';
interface SafeWebviewProps {
// The website source URL
src: string;
// @summary Refresh the webview
refreshNow?: boolean;
// Webview lifecycle event
onWebviewShow?: (isWebviewShowing: boolean) => void;
}
interface SafeWebviewState {
shouldShow: boolean;
}
/**
* @summary Webviews that hide/show depending on the HTTP status returned
*/
export class SafeWebview extends React.PureComponent<
SafeWebviewProps,
SafeWebviewState
> {
private entryHref: string;
private session: electron.Session;
private webviewRef: React.RefObject<electron.WebviewTag>;
constructor(props: SafeWebviewProps) {
super(props);
this.webviewRef = React.createRef();
this.state = {
shouldShow: true,
};
const url = new window.URL(this.props.src);
// We set the version GET parameters here.
url.searchParams.set(ETCHER_VERSION_PARAM, packageJSON.version);
url.searchParams.set(API_VERSION_PARAM, API_VERSION);
url.searchParams.set(
OPT_OUT_ANALYTICS_PARAM,
(!settings.get('errorReporting')).toString(),
);
this.entryHref = url.href;
// Events steal 'this'
this.didFailLoad = _.bind(this.didFailLoad, this);
this.didGetResponseDetails = _.bind(this.didGetResponseDetails, this);
// Make a persistent electron session for the webview
this.session = electron.remote.session.fromPartition(ELECTRON_SESSION, {
// Disable the cache for the session such that new content shows up when refreshing
cache: false,
});
}
private static logWebViewMessage(event: electron.ConsoleMessageEvent) {
console.log('Message from SafeWebview:', event.message);
}
public render() {
return (
<webview
ref={this.webviewRef}
partition={ELECTRON_SESSION}
style={{
flex: this.state.shouldShow ? undefined : '0 1',
width: this.state.shouldShow ? undefined : '0',
height: this.state.shouldShow ? undefined : '0',
}}
/>
);
}
// Add the Webview events
public componentDidMount() {
// Events React is unaware of have to be handled manually
if (this.webviewRef.current !== null) {
this.webviewRef.current.addEventListener(
'did-fail-load',
this.didFailLoad,
);
this.webviewRef.current.addEventListener(
'new-window',
SafeWebview.newWindow,
);
this.webviewRef.current.addEventListener(
'console-message',
SafeWebview.logWebViewMessage,
);
this.session.webRequest.onCompleted(this.didGetResponseDetails);
// It's important that this comes after the partition setting, otherwise it will
// use another session and we can't change it without destroying the element again
this.webviewRef.current.src = this.entryHref;
}
}
// Remove the Webview events
public componentWillUnmount() {
// Events that React is unaware of have to be handled manually
if (this.webviewRef.current !== null) {
this.webviewRef.current.removeEventListener(
'did-fail-load',
this.didFailLoad,
);
this.webviewRef.current.removeEventListener(
'new-window',
SafeWebview.newWindow,
);
this.webviewRef.current.removeEventListener(
'console-message',
SafeWebview.logWebViewMessage,
);
}
this.session.webRequest.onCompleted(null);
}
// Set the element state to hidden
public didFailLoad() {
this.setState({
shouldShow: false,
});
if (this.props.onWebviewShow) {
this.props.onWebviewShow(false);
}
}
// Set the element state depending on the HTTP response code
public didGetResponseDetails(event: electron.OnCompletedDetails) {
// This seems to pick up all requests related to the webview,
// only care about this event if it's a request for the main frame
if (event.resourceType === 'mainFrame') {
const HTTP_OK = 200;
analytics.logEvent('SafeWebview loaded', {
event,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
});
this.setState({
shouldShow: event.statusCode === HTTP_OK,
});
if (this.props.onWebviewShow) {
this.props.onWebviewShow(event.statusCode === HTTP_OK);
}
}
}
// Open link in browser if it's opened as a 'foreground-tab'
public static newWindow(event: electron.NewWindowEvent) {
const url = new window.URL(event.url);
if (
_.every([
url.protocol === 'http:' || url.protocol === 'https:',
event.disposition === 'foreground-tab',
// Don't open links if they're disabled by the env var
!settings.get('disableExternalLinks'),
])
) {
electron.shell.openExternal(url.href);
}
}
}

View File

@ -24,7 +24,7 @@ import styled from 'styled-components';
import { version } from '../../../../../package.json';
import * as settings from '../../models/settings';
import * as store from '../../models/store';
import { store } from '../../models/store';
import * as analytics from '../../modules/analytics';
import { open as openExternal } from '../../os/open-external/services/open-external';
@ -118,15 +118,11 @@ interface SettingsModalProps {
toggleModal: (value: boolean) => void;
}
interface Dictionary<T> {
[key: string]: T;
}
export const SettingsModal: any = styled(
({ toggleModal }: SettingsModalProps) => {
const [currentSettings, setCurrentSettings]: [
Dictionary<any>,
React.Dispatch<React.SetStateAction<Dictionary<any>>>,
_.Dictionary<any>,
React.Dispatch<React.SetStateAction<_.Dictionary<any>>>,
] = useState(settings.getAll());
const [warning, setWarning]: [
any,

View File

@ -1,9 +0,0 @@
svg-icon {
display: inline-block;
img {
width: 100%;
height: 100%;
}
}

View File

@ -1,167 +0,0 @@
/*
* Copyright 2018 balena.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 Etcher.Components.SVGIcon
*/
const _ = require('lodash')
const react = require('react')
const propTypes = require('prop-types')
const path = require('path')
const fs = require('fs')
const analytics = require('../../modules/analytics')
const domParser = new window.DOMParser()
const DEFAULT_SIZE = '40px'
/**
* @summary Try to parse SVG contents and return it data encoded
*
* @param {String} contents - SVG XML contents
* @returns {String|null}
*
* @example
* const encodedSVG = tryParseSVGContents('<svg><path></path></svg>')
*
* img.src = encodedSVG
*/
const tryParseSVGContents = (contents) => {
const doc = domParser.parseFromString(contents, 'image/svg+xml')
const parserError = doc.querySelector('parsererror')
const svg = doc.querySelector('svg')
if (!parserError && svg) {
return `data:image/svg+xml,${encodeURIComponent(svg.outerHTML)}`
}
return null
}
/* eslint-disable jsdoc/require-example */
/**
* @summary SVG element that takes both filepaths and file contents
* @type {Object}
* @public
*/
class SVGIcon extends react.Component {
/**
* @summary Render the SVG
* @returns {react.Element}
*/
render () {
// __dirname behaves strangely inside a Webpack bundle,
// so we need to provide different base directories
// depending on whether __dirname is absolute or not,
// which helps detecting a Webpack bundle.
// We use global.__dirname inside a Webpack bundle since
// that's the only way to get the "real" __dirname.
const baseDirectory = path.isAbsolute(__dirname)
? path.join(__dirname, '..')
// eslint-disable-next-line no-underscore-dangle
: global.__dirname
let svgData = ''
_.find(this.props.contents, (content) => {
const attempt = tryParseSVGContents(content)
if (attempt) {
svgData = attempt
return true
}
return false
})
if (!svgData) {
_.find(this.props.paths, (relativePath) => {
// This means the path to the icon should be
// relative to *this directory*.
// TODO: There might be a way to compute the path
// relatively to the `index.html`.
const imagePath = path.join(baseDirectory, 'assets', relativePath)
const contents = _.attempt(() => {
return fs.readFileSync(imagePath, {
encoding: 'utf8'
})
})
if (_.isError(contents)) {
analytics.logException(contents)
return false
}
const parsed = _.attempt(tryParseSVGContents, contents)
if (parsed) {
svgData = parsed
return true
}
return false
})
}
const width = this.props.width || DEFAULT_SIZE
const height = this.props.height || DEFAULT_SIZE
return react.createElement('img', {
className: 'svg-icon',
style: {
width,
height
},
src: svgData,
disabled: this.props.disabled
})
}
}
SVGIcon.propTypes = {
/**
* @summary Paths to SVG files to be tried in succession if any fails
*/
paths: propTypes.array,
/**
* @summary List of embedded SVG contents to be tried in succession if any fails
*/
contents: propTypes.array,
/**
* @summary SVG image width unit
*/
width: propTypes.string,
/**
* @summary SVG image height unit
*/
height: propTypes.string,
/**
* @summary Should the element visually appear grayed out and disabled?
*/
disabled: propTypes.bool
}
module.exports = SVGIcon

View File

@ -0,0 +1,142 @@
/*
* Copyright 2018 balena.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.
*/
import * as fs from 'fs';
import * as _ from 'lodash';
import * as path from 'path';
import * as React from 'react';
import * as analytics from '../../modules/analytics';
const domParser = new window.DOMParser();
const DEFAULT_SIZE = '40px';
/**
* @summary Try to parse SVG contents and return it data encoded
*
* @param {String} contents - SVG XML contents
* @returns {String|null}
*
* @example
* const encodedSVG = tryParseSVGContents('<svg><path></path></svg>')
*
* img.src = encodedSVG
*/
function tryParseSVGContents(contents: string) {
const doc = domParser.parseFromString(contents, 'image/svg+xml');
const parserError = doc.querySelector('parsererror');
const svg = doc.querySelector('svg');
if (!parserError && svg) {
return `data:image/svg+xml,${encodeURIComponent(svg.outerHTML)}`;
}
return null;
}
interface SVGIconProps {
// Paths to SVG files to be tried in succession if any fails
paths: string[];
// List of embedded SVG contents to be tried in succession if any fails
contents?: string[];
// SVG image width unit
width?: string;
// SVG image height unit
height?: string;
// Should the element visually appear grayed out and disabled?
disabled?: boolean;
}
/**
* @summary SVG element that takes both filepaths and file contents
*/
export class SVGIcon extends React.Component<SVGIconProps> {
public render() {
// __dirname behaves strangely inside a Webpack bundle,
// so we need to provide different base directories
// depending on whether __dirname is absolute or not,
// which helps detecting a Webpack bundle.
// We use global.__dirname inside a Webpack bundle since
// that's the only way to get the "real" __dirname.
let baseDirectory: string;
if (path.isAbsolute(__dirname)) {
baseDirectory = path.join(__dirname, '..');
} else {
// @ts-ignore
baseDirectory = global.__dirname;
}
let svgData = '';
_.find(this.props.contents, content => {
const attempt = tryParseSVGContents(content);
if (attempt) {
svgData = attempt;
return true;
}
return false;
});
if (!svgData) {
_.find(this.props.paths, relativePath => {
// This means the path to the icon should be
// relative to *this directory*.
// TODO: There might be a way to compute the path
// relatively to the `index.html`.
const imagePath = path.join(baseDirectory, 'assets', relativePath);
const contents = _.attempt(() => {
return fs.readFileSync(imagePath, {
encoding: 'utf8',
});
});
if (_.isError(contents)) {
analytics.logException(contents);
return false;
}
const parsed = tryParseSVGContents(contents);
if (parsed) {
svgData = parsed;
return true;
}
return false;
});
}
const width = this.props.width || DEFAULT_SIZE;
const height = this.props.height || DEFAULT_SIZE;
return (
<img
className="svg-icon"
style={{
width,
height,
}}
src={svgData}
// @ts-ignore
disabled={this.props.disabled}
></img>
);
}
}

View File

@ -6,21 +6,9 @@
<link rel="stylesheet" type="text/css" href="../../../node_modules/flexboxgrid/dist/flexboxgrid.css">
<link rel="stylesheet" type="text/css" href="../css/main.css">
<link rel="stylesheet" type="text/css" href="../css/desktop.css">
<link rel="stylesheet" type="text/css" href="../css/angular.css">
<script src="../../../generated/gui.js"></script>
</head>
<body>
<main ui-view></main>
<div class="section-loader"
ng-controller="StateController as state"
ng-class="{
isFinish: state.currentName === 'success'
}">
<safe-webview src="'https://www.balena.io/etcher/success-banner/'">
</safe-webview>
</div>
<main id="main"></main>
<script src="../../../generated/gui.js"></script>
</body>
</html>

View File

@ -1,70 +0,0 @@
/*
* Copyright 2016 balena.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')
const store = require('./store')
/**
* @summary Check if there are available drives
* @function
* @public
*
* @returns {Boolean} whether there are available drives
*
* @example
* if (availableDrives.hasAvailableDrives()) {
* console.log('There are available drives!');
* }
*/
exports.hasAvailableDrives = () => {
return !_.isEmpty(exports.getDrives())
}
/**
* @summary Set a list of drives
* @function
* @private
*
* @param {Object[]} drives - drives
*
* @throws Will throw if no drives
* @throws Will throw if drives is not an array of objects
*
* @example
* availableDrives.setDrives([ ... ]);
*/
exports.setDrives = (drives) => {
store.dispatch({
type: store.Actions.SET_AVAILABLE_DRIVES,
data: drives
})
}
/**
* @summary Get detected drives
* @function
* @private
*
* @returns {Object[]} drives
*
* @example
* const drives = availableDrives.getDrives();
*/
exports.getDrives = () => {
return store.getState().toJS().availableDrives
}

View File

@ -14,21 +14,21 @@
* limitations under the License.
*/
'use strict'
import * as _ from 'lodash';
const units = require('../../../../shared/units')
import { Actions, store } from './store';
module.exports = () => {
/**
* @summary Convert bytes to the closest unit
* @function
* @public
*
* @param {Number} bytes - bytes
* @returns {String} formatted string containing size and unit
*
* @example
* {{ 7801405440 | closestUnit }}
*/
return units.bytesToClosestUnit
export function hasAvailableDrives() {
return !_.isEmpty(getDrives());
}
export function setDrives(drives: any[]) {
store.dispatch({
type: Actions.SET_AVAILABLE_DRIVES,
data: drives,
});
}
export function getDrives(): any[] {
return store.getState().toJS().availableDrives;
}

View File

@ -1,156 +0,0 @@
/*
* Copyright 2018 balena.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 fs = Bluebird.promisifyAll(require('fs'))
const path = require('path')
const driveScanner = require('../modules/drive-scanner')
/* eslint-disable lodash/prefer-lodash-method */
/* eslint-disable no-undefined */
const CONCURRENCY = 10
const collator = new Intl.Collator(undefined, {
sensitivity: 'case'
})
/**
* @summary Sort files by their names / stats
* @param {FileEntry} fileA - first file
* @param {FileEntry} fileB - second file
* @returns {Number}
*
* @example
* files.readdirAsync(dirname).then((files) => {
* return files.sort(sortFiles)
* })
*/
const sortFiles = (fileA, fileB) => {
return (fileB.isDirectory - fileA.isDirectory) ||
collator.compare(fileA.basename, fileB.basename)
}
/**
* @summary FileEntry struct
* @class
* @type {FileEntry}
*/
class FileEntry {
/**
* @summary FileEntry
* @param {String} filename - filename
* @param {fs.Stats} stats - stats
*
* @example
* new FileEntry(filename, stats)
*/
constructor (filename, stats) {
const components = path.parse(filename)
this.path = filename
this.dirname = components.dir
this.basename = components.base
this.name = components.name
this.ext = components.ext
this.isHidden = components.name.startsWith('.')
this.isFile = stats.isFile()
this.isDirectory = stats.isDirectory()
this.size = stats.size
}
}
/**
* @summary Read a directory & stat all contents
* @param {String} dirpath - Directory path
* @returns {Array<FileEntry>}
*
* @example
* files.readdirAsync('/').then((files) => {
* // ...
* })
*/
exports.readdirAsync = (dirpath) => {
console.time('readdirAsync')
const dirname = path.resolve(dirpath)
return fs.readdirAsync(dirname).then((ls) => {
return ls.filter((filename) => {
return !filename.startsWith('.')
}).map((filename) => {
return path.join(dirname, filename)
})
}).map((filename, index, length) => {
return fs.statAsync(filename).then((stats) => {
return new FileEntry(filename, stats)
})
}, { concurrency: CONCURRENCY }).then((files) => {
console.timeEnd('readdirAsync')
return files.sort(sortFiles)
})
}
/**
* @summary Split a path on it's separator(s)
* @function
* @public
*
* @param {String} fullpath - full path to split
* @param {Array<String>} [subpaths] - this param shouldn't normally be used
* @returns {Array<String>}
*
* @example
* console.log(splitPath(path.join(os.homedir(), 'Downloads'))
* // Linux
* > [ '/', 'home', 'user', 'Downloads' ]
* // Windows
* > [ 'C:', 'Users', 'user', 'Downloads' ]
*/
exports.splitPath = (fullpath, subpaths = []) => {
const {
base,
dir,
root
} = path.parse(fullpath)
const isAbsolute = path.isAbsolute(fullpath)
// Takes care of 'relative/path'
if (!isAbsolute && dir === '') {
return [ base ].concat(subpaths)
// Takes care of '/'
} else if (isAbsolute && base === '') {
return [ root ].concat(subpaths)
}
return exports.splitPath(dir, [ base ].concat(subpaths))
}
/**
* @summary Get constraint path device
* @param {String} pathname - device path
* @returns {Drive} drive - drive object
* @example
* const device = files.getConstraintDevice('/dev/disk2')
*/
exports.getConstraintDevice = (pathname) => {
// This supposes the drive scanner is ready
return driveScanner.getBy('device', pathname) || driveScanner.getBy('devicePath', pathname)
}
exports.FileEntry = FileEntry

View File

@ -1,244 +0,0 @@
/*
* Copyright 2016 balena.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')
const store = require('./store')
const units = require('../../../shared/units')
/**
* @summary Reset flash state
* @function
* @public
*
* @example
* flashState.resetState();
*/
exports.resetState = () => {
store.dispatch({
type: store.Actions.RESET_FLASH_STATE
})
}
/**
* @summary Check if currently flashing
* @function
* @private
*
* @returns {Boolean} whether is flashing or not
*
* @example
* if (flashState.isFlashing()) {
* console.log('We\'re currently flashing');
* }
*/
exports.isFlashing = () => {
return store.getState().toJS().isFlashing
}
/**
* @summary Set the flashing flag
* @function
* @private
*
* @description
* This function is extracted for testing purposes.
*
* The flag is used to signify that we're going to
* start a flash process.
*
* @example
* flashState.setFlashingFlag();
*/
exports.setFlashingFlag = () => {
store.dispatch({
type: store.Actions.SET_FLASHING_FLAG
})
}
/**
* @summary Unset the flashing flag
* @function
* @private
*
* @description
* This function is extracted for testing purposes.
*
* The flag is used to signify that the write process ended.
*
* @param {Object} results - flash results
*
* @example
* flashState.unsetFlashingFlag({
* cancelled: false,
* sourceChecksum: 'a1b45d'
* });
*/
exports.unsetFlashingFlag = (results) => {
store.dispatch({
type: store.Actions.UNSET_FLASHING_FLAG,
data: results
})
}
/**
* @summary Set the flashing state
* @function
* @private
*
* @description
* This function is extracted for testing purposes.
*
* @param {Object} state - flashing state
*
* @example
* flashState.setProgressState({
* type: 'write',
* percentage: 50,
* eta: 15,
* speed: 100000000000
* });
*/
exports.setProgressState = (state) => {
// Preserve only one decimal place
const PRECISION = 1
const data = _.assign({}, state, {
percentage: _.isFinite(state.percentage)
? Math.floor(state.percentage)
// eslint-disable-next-line no-undefined
: undefined,
speed: _.attempt(() => {
if (_.isFinite(state.speed)) {
return _.round(units.bytesToMegabytes(state.speed), PRECISION)
}
return null
}),
totalSpeed: _.attempt(() => {
if (_.isFinite(state.totalSpeed)) {
return _.round(units.bytesToMegabytes(state.totalSpeed), PRECISION)
}
return null
})
})
store.dispatch({
type: store.Actions.SET_FLASH_STATE,
data
})
}
/**
* @summary Get the flash results
* @function
* @private
*
* @returns {Object} flash results
*
* @example
* const results = flashState.getFlashResults();
*/
exports.getFlashResults = () => {
return store.getState().toJS().flashResults
}
/**
* @summary Get the current flash state
* @function
* @public
*
* @returns {Object} flash state
*
* @example
* const flashState = flashState.getFlashState();
*/
exports.getFlashState = () => {
return store.getState().get('flashState').toJS()
}
/**
* @summary Determine if the last flash was cancelled
* @function
* @public
*
* @description
* This function returns false if there was no last flash.
*
* @returns {Boolean} whether the last flash was cancelled
*
* @example
* if (flashState.wasLastFlashCancelled()) {
* console.log('The last flash was cancelled');
* }
*/
exports.wasLastFlashCancelled = () => {
return _.get(exports.getFlashResults(), [ 'cancelled' ], false)
}
/**
* @summary Get last flash source checksum
* @function
* @public
*
* @description
* This function returns undefined if there was no last flash.
*
* @returns {(String|Undefined)} the last flash source checksum
*
* @example
* const checksum = flashState.getLastFlashSourceChecksum();
*/
exports.getLastFlashSourceChecksum = () => {
return exports.getFlashResults().sourceChecksum
}
/**
* @summary Get last flash error code
* @function
* @public
*
* @description
* This function returns undefined if there was no last flash.
*
* @returns {(String|Undefined)} the last flash error code
*
* @example
* const errorCode = flashState.getLastFlashErrorCode();
*/
exports.getLastFlashErrorCode = () => {
return exports.getFlashResults().errorCode
}
/**
* @summary Get current (or last) flash uuid
* @function
* @public
*
* @description
* This function returns undefined if no flash has been started yet.
*
* @returns {String} the last flash uuid
*
* @example
* const uuid = flashState.getFlashUuid();
*/
exports.getFlashUuid = () => {
return store.getState().toJS().flashUuid
}

View File

@ -0,0 +1,131 @@
/*
* Copyright 2016 balena.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.
*/
import * as sdk from 'etcher-sdk';
import * as _ from 'lodash';
import { bytesToMegabytes } from '../../../shared/units';
import { Actions, store } from './store';
/**
* @summary Reset flash state
*/
export function resetState() {
store.dispatch({
type: Actions.RESET_FLASH_STATE,
});
}
/**
* @summary Check if currently flashing
*/
export function isFlashing(): boolean {
return store.getState().toJS().isFlashing;
}
/**
* @summary Set the flashing flag
*
* @description
* The flag is used to signify that we're going to
* start a flash process.
*/
export function setFlashingFlag() {
store.dispatch({
type: Actions.SET_FLASHING_FLAG,
});
}
/**
* @summary Unset the flashing flag
*
* @description
* The flag is used to signify that the write process ended.
*/
export function unsetFlashingFlag(results: {
cancelled?: boolean;
sourceChecksum?: string;
errorCode?: string | number;
}) {
store.dispatch({
type: Actions.UNSET_FLASHING_FLAG,
data: results,
});
}
/**
* @summary Set the flashing state
*/
export function setProgressState(
state: sdk.multiWrite.MultiDestinationProgress,
) {
// Preserve only one decimal place
const PRECISION = 1;
const data = _.assign({}, state, {
percentage:
state.percentage !== undefined && _.isFinite(state.percentage)
? Math.floor(state.percentage)
: undefined,
speed: _.attempt(() => {
if (_.isFinite(state.speed)) {
return _.round(bytesToMegabytes(state.speed), PRECISION);
}
return null;
}),
totalSpeed: _.attempt(() => {
if (_.isFinite(state.totalSpeed)) {
return _.round(bytesToMegabytes(state.totalSpeed), PRECISION);
}
return null;
}),
});
store.dispatch({
type: Actions.SET_FLASH_STATE,
data,
});
}
export function getFlashResults() {
return store.getState().toJS().flashResults;
}
export function getFlashState() {
return store
.getState()
.get('flashState')
.toJS();
}
export function wasLastFlashCancelled() {
return _.get(getFlashResults(), ['cancelled'], false);
}
export function getLastFlashSourceChecksum(): string {
return getFlashResults().sourceChecksum;
}
export function getLastFlashErrorCode() {
return getFlashResults().errorCode;
}
export function getFlashUuid() {
return store.getState().toJS().flashUuid;
}

View File

@ -1,184 +0,0 @@
/*
* Copyright 2017 balena.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 fs = require('fs')
const path = require('path')
/**
* @summary Number of spaces to indent JSON output with
* @type {Number}
* @constant
*/
const JSON_INDENT = 2
/**
* @summary Userdata directory path
* @description
* Defaults to the following:
* - `%APPDATA%/etcher` on Windows
* - `$XDG_CONFIG_HOME/etcher` or `~/.config/etcher` on Linux
* - `~/Library/Application Support/etcher` on macOS
* See https://electronjs.org/docs/api/app#appgetpathname
* @constant
* @type {String}
*/
const USER_DATA_DIR = (() => {
// NOTE: The ternary is due to this module being loaded both,
// Electron's main process and renderer process
const electron = require('electron')
return electron.app
? electron.app.getPath('userData')
: electron.remote.app.getPath('userData')
})()
/**
* @summary Configuration file path
* @type {String}
* @constant
*/
const CONFIG_PATH = path.join(USER_DATA_DIR, 'config.json')
/**
* @summary Read a local config.json file
* @function
* @private
*
* @param {String} filename - file path
* @fulfil {Object} - settings
* @returns {Promise}
*
* @example
* readConfigFile('config.json').then((settings) => {
* console.log(settings)
* })
*/
const readConfigFile = (filename) => {
return new Bluebird((resolve, reject) => {
fs.readFile(filename, { encoding: 'utf8' }, (error, contents) => {
let data = {}
if (error) {
if (error.code === 'ENOENT') {
resolve(data)
} else {
reject(error)
}
} else {
try {
data = JSON.parse(contents)
} catch (parseError) {
console.error(parseError)
}
resolve(data)
}
})
})
}
/**
* @summary Write to the local configuration file
* @function
* @private
*
* @param {String} filename - file path
* @param {Object} data - data
* @fulfil {Object} data - data
* @returns {Promise}
*
* @example
* writeConfigFile('config.json', { something: 'good' })
* .then(() => {
* console.log('data written')
* })
*/
const writeConfigFile = (filename, data) => {
return new Bluebird((resolve, reject) => {
const contents = JSON.stringify(data, null, JSON_INDENT)
fs.writeFile(filename, contents, (error) => {
if (error) {
reject(error)
} else {
resolve(data)
}
})
})
}
/**
* @summary Read all local settings
* @function
* @public
*
* @fulfil {Object} - local settings
* @returns {Promise}
*
* @example
* localSettings.readAll().then((settings) => {
* console.log(settings);
* });
*/
exports.readAll = () => {
return readConfigFile(CONFIG_PATH)
}
/**
* @summary Write local settings
* @function
* @public
*
* @param {Object} settings - settings
* @fulfil {Object} settings - settings
* @returns {Promise}
*
* @example
* localSettings.writeAll({
* foo: 'bar'
* }).then(() => {
* console.log('Done!');
* });
*/
exports.writeAll = (settings) => {
return writeConfigFile(CONFIG_PATH, settings)
}
/**
* @summary Clear the local settings
* @function
* @private
*
* @description
* Exported for testing purposes
*
* @returns {Promise}
*
* @example
* localSettings.clear().then(() => {
* console.log('Done!');
* });
*/
exports.clear = () => {
return new Bluebird((resolve, reject) => {
fs.unlink(CONFIG_PATH, (error) => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
}

View File

@ -0,0 +1,73 @@
/*
* Copyright 2017 balena.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.
*/
import * as electron from 'electron';
import { promises as fs } from 'fs';
import * as path from 'path';
const JSON_INDENT = 2;
/**
* @summary Userdata directory path
* @description
* Defaults to the following:
* - `%APPDATA%/etcher` on Windows
* - `$XDG_CONFIG_HOME/etcher` or `~/.config/etcher` on Linux
* - `~/Library/Application Support/etcher` on macOS
* See https://electronjs.org/docs/api/app#appgetpathname
*
* NOTE: The ternary is due to this module being loaded both,
* Electron's main process and renderer process
*/
const USER_DATA_DIR = electron.app
? electron.app.getPath('userData')
: electron.remote.app.getPath('userData');
const CONFIG_PATH = path.join(USER_DATA_DIR, 'config.json');
async function readConfigFile(filename: string): Promise<any> {
let contents = '{}';
try {
contents = await fs.readFile(filename, { encoding: 'utf8' });
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
try {
return JSON.parse(contents);
} catch (parseError) {
console.error(parseError);
return {};
}
}
async function writeConfigFile(filename: string, data: any): Promise<any> {
await fs.writeFile(filename, JSON.stringify(data, null, JSON_INDENT));
return data;
}
export async function readAll(): Promise<any> {
return await readConfigFile(CONFIG_PATH);
}
export async function writeAll(settings: any): Promise<any> {
return await writeConfigFile(CONFIG_PATH, settings);
}
export async function clear(): Promise<void> {
await fs.unlink(CONFIG_PATH);
}

View File

@ -1,438 +0,0 @@
/*
* Copyright 2016 balena.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')
const store = require('./store')
const availableDrives = require('./available-drives')
/**
* @summary Select a drive by its device path
* @function
* @public
*
* @param {String} driveDevice - drive device
*
* @example
* selectionState.selectDrive('/dev/disk2');
*/
exports.selectDrive = (driveDevice) => {
store.dispatch({
type: store.Actions.SELECT_DRIVE,
data: driveDevice
})
}
/**
* @summary Toggle drive selection
* @function
* @public
*
* @param {String} driveDevice - drive device
*
* @example
* selectionState.toggleDrive('/dev/disk2');
*/
exports.toggleDrive = (driveDevice) => {
if (exports.isDriveSelected(driveDevice)) {
exports.deselectDrive(driveDevice)
} else {
exports.selectDrive(driveDevice)
}
}
/**
* @summary Deselect all other drives and keep the current drive's status
* @function
* @public
* @deprecated
*
* @description
* This is a temporary function during the transition to multi-writes,
* remove this and its uses when multi-selection should become user-facing.
*
* @param {String} driveDevice - drive device identifier
*
* @example
* console.log(selectionState.getSelectedDevices())
* > [ '/dev/disk1', '/dev/disk2', '/dev/disk3' ]
* selectionState.deselectOtherDrives('/dev/disk2')
* console.log(selectionState.getSelectedDevices())
* > [ '/dev/disk2' ]
*/
exports.deselectOtherDrives = (driveDevice) => {
if (exports.isDriveSelected(driveDevice)) {
const otherDevices = _.reject(exports.getSelectedDevices(), _.partial(_.isEqual, driveDevice))
_.each(otherDevices, exports.deselectDrive)
} else {
exports.deselectAllDrives()
}
}
/**
* @summary Select an image
* @function
* @public
*
* @param {Object} image - image
*
* @example
* selectionState.selectImage({
* path: 'foo.img',
* size: 1000000000,
* compressedSize: 1000000000,
* isSizeEstimated: false,
* });
*/
exports.selectImage = (image) => {
store.dispatch({
type: store.Actions.SELECT_IMAGE,
data: image
})
}
/**
* @summary Get all selected drives' devices
* @function
* @public
*
* @returns {String[]} selected drives' devices
*
* @example
* for (driveDevice of selectionState.getSelectedDevices()) {
* console.log(driveDevice)
* }
* > '/dev/disk1'
* > '/dev/disk2'
*/
exports.getSelectedDevices = () => {
return store.getState().getIn([ 'selection', 'devices' ]).toJS()
}
/**
* @summary Get all selected drive objects
* @function
* @public
*
* @returns {Object[]} selected drive objects
*
* @example
* for (drive of selectionState.getSelectedDrives()) {
* console.log(drive)
* }
* > '{ device: '/dev/disk1', size: 123456789, ... }'
* > '{ device: '/dev/disk2', size: 987654321, ... }'
*/
exports.getSelectedDrives = () => {
const drives = availableDrives.getDrives()
return _.map(exports.getSelectedDevices(), (device) => {
return _.find(drives, { device })
})
}
/**
* @summary Get the head of the list of selected drives
* @function
* @public
*
* @returns {Object} drive
*
* @example
* const drive = selectionState.getCurrentDrive();
* console.log(drive)
* > { device: '/dev/disk1', name: 'Flash drive', ... }
*/
exports.getCurrentDrive = () => {
const device = _.head(exports.getSelectedDevices())
return _.find(availableDrives.getDrives(), { device })
}
/**
* @summary Get the selected image
* @function
* @public
*
* @returns {Object} image
*
* @example
* const image = selectionState.getImage();
*/
exports.getImage = () => {
return _.get(store.getState().toJS(), [ 'selection', 'image' ])
}
/**
* @summary Get image path
* @function
* @public
*
* @returns {String} image path
*
* @example
* const imagePath = selectionState.getImagePath();
*/
exports.getImagePath = () => {
return _.get(store.getState().toJS(), [
'selection',
'image',
'path'
])
}
/**
* @summary Get image size
* @function
* @public
*
* @returns {Number} image size
*
* @example
* const imageSize = selectionState.getImageSize();
*/
exports.getImageSize = () => {
return _.get(store.getState().toJS(), [
'selection',
'image',
'size'
])
}
/**
* @summary Get image url
* @function
* @public
*
* @returns {String} image url
*
* @example
* const imageUrl = selectionState.getImageUrl();
*/
exports.getImageUrl = () => {
return _.get(store.getState().toJS(), [
'selection',
'image',
'url'
])
}
/**
* @summary Get image name
* @function
* @public
*
* @returns {String} image name
*
* @example
* const imageName = selectionState.getImageName();
*/
exports.getImageName = () => {
return _.get(store.getState().toJS(), [
'selection',
'image',
'name'
])
}
/**
* @summary Get image logo
* @function
* @public
*
* @returns {String} image logo
*
* @example
* const imageLogo = selectionState.getImageLogo();
*/
exports.getImageLogo = () => {
return _.get(store.getState().toJS(), [
'selection',
'image',
'logo'
])
}
/**
* @summary Get image support url
* @function
* @public
*
* @returns {String} image support url
*
* @example
* const imageSupportUrl = selectionState.getImageSupportUrl();
*/
exports.getImageSupportUrl = () => {
return _.get(store.getState().toJS(), [
'selection',
'image',
'supportUrl'
])
}
/**
* @summary Get image recommended drive size
* @function
* @public
*
* @returns {String} image recommended drive size
*
* @example
* const imageRecommendedDriveSize = selectionState.getImageRecommendedDriveSize();
*/
exports.getImageRecommendedDriveSize = () => {
return _.get(store.getState().toJS(), [
'selection',
'image',
'recommendedDriveSize'
])
}
/**
* @summary Check if there is a selected drive
* @function
* @public
*
* @returns {Boolean} whether there is a selected drive
*
* @example
* if (selectionState.hasDrive()) {
* console.log('There is a drive!');
* }
*/
exports.hasDrive = () => {
return Boolean(exports.getSelectedDevices().length)
}
/**
* @summary Check if there is a selected image
* @function
* @public
*
* @returns {Boolean} whether there is a selected image
*
* @example
* if (selectionState.hasImage()) {
* console.log('There is an image!');
* }
*/
exports.hasImage = () => {
return Boolean(exports.getImage())
}
/**
* @summary Remove drive from selection
* @function
* @public
*
* @param {String} driveDevice - drive device identifier
*
* @example
* selectionState.deselectDrive('/dev/sdc');
*
* @example
* selectionState.deselectDrive('\\\\.\\PHYSICALDRIVE3');
*/
exports.deselectDrive = (driveDevice) => {
store.dispatch({
type: store.Actions.DESELECT_DRIVE,
data: driveDevice
})
}
/**
* @summary Deselect image
* @function
* @public
*
* @example
* selectionState.deselectImage();
*/
exports.deselectImage = () => {
store.dispatch({
type: store.Actions.DESELECT_IMAGE
})
}
/**
* @summary Deselect all drives
* @function
* @public
*
* @example
* selectionState.deselectAllDrives()
*/
exports.deselectAllDrives = () => {
_.each(exports.getSelectedDevices(), exports.deselectDrive)
}
/**
* @summary Clear selections
* @function
* @public
*
* @example
* selectionState.clear();
*/
exports.clear = () => {
exports.deselectImage()
exports.deselectAllDrives()
}
/**
* @summary Check if a drive is the current drive
* @function
* @public
*
* @param {String} driveDevice - drive device
* @returns {Boolean} whether the drive is the current drive
*
* @example
* if (selectionState.isCurrentDrive('/dev/sdb')) {
* console.log('This is the current drive!');
* }
*/
exports.isCurrentDrive = (driveDevice) => {
if (!driveDevice) {
return false
}
return driveDevice === _.get(exports.getCurrentDrive(), [ 'device' ])
}
/**
* @summary Check whether a given device is selected.
* @function
* @public
*
* @param {String} driveDevice - drive device identifier
* @returns {Boolean}
*
* @example
* const isSelected = selectionState.isDriveSelected('/dev/sdb')
*
* if (isSelected) {
* selectionState.deselectDrive(driveDevice)
* }
*/
exports.isDriveSelected = (driveDevice) => {
if (!driveDevice) {
return false
}
const selectedDriveDevices = exports.getSelectedDevices()
return _.includes(selectedDriveDevices, driveDevice)
}

View File

@ -0,0 +1,161 @@
/*
* Copyright 2016 balena.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.
*/
import * as _ from 'lodash';
import * as availableDrives from './available-drives';
import { Actions, store } from './store';
/**
* @summary Select a drive by its device path
*/
export function selectDrive(driveDevice: string) {
store.dispatch({
type: Actions.SELECT_DRIVE,
data: driveDevice,
});
}
/**
* @summary Toggle drive selection
*/
export function toggleDrive(driveDevice: string) {
if (isDriveSelected(driveDevice)) {
deselectDrive(driveDevice);
} else {
selectDrive(driveDevice);
}
}
export function selectImage(image: any) {
store.dispatch({
type: Actions.SELECT_IMAGE,
data: image,
});
}
/**
* @summary Get all selected drives' devices
*/
export function getSelectedDevices(): string[] {
return store
.getState()
.getIn(['selection', 'devices'])
.toJS();
}
/**
* @summary Get all selected drive objects
*/
export function getSelectedDrives(): any[] {
const drives = availableDrives.getDrives();
return _.map(getSelectedDevices(), device => {
return _.find(drives, { device });
});
}
/**
* @summary Get the selected image
*/
export function getImage() {
return _.get(store.getState().toJS(), ['selection', 'image']);
}
export function getImagePath(): string {
return _.get(store.getState().toJS(), ['selection', 'image', 'path']);
}
export function getImageSize(): number {
return _.get(store.getState().toJS(), ['selection', 'image', 'size']);
}
export function getImageUrl(): string {
return _.get(store.getState().toJS(), ['selection', 'image', 'url']);
}
export function getImageName(): string {
return _.get(store.getState().toJS(), ['selection', 'image', 'name']);
}
export function getImageLogo(): string {
return _.get(store.getState().toJS(), ['selection', 'image', 'logo']);
}
export function getImageSupportUrl(): string {
return _.get(store.getState().toJS(), ['selection', 'image', 'supportUrl']);
}
export function getImageRecommendedDriveSize(): number {
return _.get(store.getState().toJS(), [
'selection',
'image',
'recommendedDriveSize',
]);
}
/**
* @summary Check if there is a selected drive
*/
export function hasDrive(): boolean {
return Boolean(getSelectedDevices().length);
}
/**
* @summary Check if there is a selected image
*/
export function hasImage(): boolean {
return Boolean(getImage());
}
/**
* @summary Remove drive from selection
*/
export function deselectDrive(driveDevice: string) {
store.dispatch({
type: Actions.DESELECT_DRIVE,
data: driveDevice,
});
}
export function deselectImage() {
store.dispatch({
type: Actions.DESELECT_IMAGE,
});
}
export function deselectAllDrives() {
_.each(getSelectedDevices(), deselectDrive);
}
/**
* @summary Clear selections
*/
export function clear() {
deselectImage();
deselectAllDrives();
}
/**
* @summary Check whether a given device is selected.
*/
export function isDriveSelected(driveDevice: string) {
if (!driveDevice) {
return false;
}
const selectedDriveDevices = getSelectedDevices();
return _.includes(selectedDriveDevices, driveDevice);
}

View File

@ -1,232 +0,0 @@
/*
* Copyright 2016 balena.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 Etcher.Models.Settings
*/
const _ = require('lodash')
const Bluebird = require('bluebird')
const localSettings = require('./local-settings')
const errors = require('../../../shared/errors')
const packageJSON = require('../../../../package.json')
const debug = require('debug')('etcher:models:settings')
/**
* @summary Default settings
* @constant
* @type {Object}
*/
const DEFAULT_SETTINGS = {
unsafeMode: false,
errorReporting: true,
unmountOnSuccess: true,
validateWriteOnSuccess: true,
trim: false,
updatesEnabled: packageJSON.updates.enabled && !_.includes([ 'rpm', 'deb' ], packageJSON.packageType),
lastSleptUpdateNotifier: null,
lastSleptUpdateNotifierVersion: null,
desktopNotifications: true
}
/**
* @summary Settings state
* @type {Object}
* @private
*/
let settings = _.cloneDeep(DEFAULT_SETTINGS)
/**
* @summary Reset settings to their default values
* @function
* @public
*
* @returns {Promise}
*
* @example
* settings.reset().then(() => {
* console.log('Done!');
* });
*/
exports.reset = () => {
debug('reset')
// TODO: Remove default settings from config file (?)
settings = _.cloneDeep(DEFAULT_SETTINGS)
return localSettings.writeAll(settings)
}
/**
* @summary Extend the current settings
* @function
* @public
*
* @param {Object} value - value
* @returns {Promise}
*
* @example
* settings.assign({
* foo: 'bar'
* }).then(() => {
* console.log('Done!');
* });
*/
exports.assign = (value) => {
debug('assign', value)
if (_.isNil(value)) {
return Bluebird.reject(errors.createError({
title: 'Missing settings'
}))
}
if (!_.isPlainObject(value)) {
return Bluebird.reject(errors.createError({
title: 'Settings must be an object'
}))
}
const newSettings = _.assign({}, settings, value)
return localSettings.writeAll(newSettings)
.then((updatedSettings) => {
// NOTE: Only update in memory settings when successfully written
settings = updatedSettings
})
}
/**
* @summary Extend the application state with the local settings
* @function
* @public
*
* @returns {Promise}
*
* @example
* settings.load().then(() => {
* console.log('Done!');
* });
*/
exports.load = () => {
debug('load')
return localSettings.readAll().then((loadedSettings) => {
return _.assign(settings, loadedSettings)
})
}
/**
* @summary Set a setting value
* @function
* @public
*
* @param {String} key - setting key
* @param {*} value - setting value
* @returns {Promise}
*
* @example
* settings.set('unmountOnSuccess', true).then(() => {
* console.log('Done!');
* });
*/
exports.set = (key, value) => {
debug('set', key, value)
if (_.isNil(key)) {
return Bluebird.reject(errors.createError({
title: 'Missing setting key'
}))
}
if (!_.isString(key)) {
return Bluebird.reject(errors.createError({
title: `Invalid setting key: ${key}`
}))
}
const previousValue = settings[key]
settings[key] = value
return localSettings.writeAll(settings)
.catch((error) => {
// Revert to previous value if persisting settings failed
settings[key] = previousValue
throw error
})
}
/**
* @summary Get a setting value
* @function
* @public
*
* @param {String} key - setting key
* @returns {*} setting value
*
* @example
* const value = settings.get('unmountOnSuccess');
*/
exports.get = (key) => {
return _.cloneDeep(_.get(settings, [ key ]))
}
/**
* @summary Check if setting value exists
* @function
* @public
*
* @param {String} key - setting key
* @returns {Boolean} exists
*
* @example
* const hasValue = settings.has('unmountOnSuccess');
*/
exports.has = (key) => {
/* eslint-disable no-eq-null */
return settings[key] != null
}
/**
* @summary Get all setting values
* @function
* @public
*
* @returns {Object} all setting values
*
* @example
* const allSettings = settings.getAll();
* console.log(allSettings.unmountOnSuccess);
*/
exports.getAll = () => {
debug('getAll')
return _.cloneDeep(settings)
}
/**
* @summary Get the default setting values
* @function
* @public
*
* @returns {Object} all setting values
*
* @example
* const defaults = settings.getDefaults();
* console.log(defaults.unmountOnSuccess);
*/
exports.getDefaults = () => {
debug('getDefaults')
return _.cloneDeep(DEFAULT_SETTINGS)
}

View File

@ -0,0 +1,141 @@
/*
* Copyright 2016 balena.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.
*/
import * as _debug from 'debug';
import * as _ from 'lodash';
import * as packageJSON from '../../../../package.json';
import * as errors from '../../../shared/errors';
import * as localSettings from './local-settings';
const debug = _debug('etcher:models:settings');
const DEFAULT_SETTINGS: _.Dictionary<any> = {
unsafeMode: false,
errorReporting: true,
unmountOnSuccess: true,
validateWriteOnSuccess: true,
trim: false,
updatesEnabled:
packageJSON.updates.enabled &&
!_.includes(['rpm', 'deb'], packageJSON.packageType),
lastSleptUpdateNotifier: null,
lastSleptUpdateNotifierVersion: null,
desktopNotifications: true,
};
let settings = _.cloneDeep(DEFAULT_SETTINGS);
/**
* @summary Reset settings to their default values
*/
export async function reset(): Promise<void> {
debug('reset');
// TODO: Remove default settings from config file (?)
settings = _.cloneDeep(DEFAULT_SETTINGS);
return await localSettings.writeAll(settings);
}
/**
* @summary Extend the current settings
*/
export async function assign(value: _.Dictionary<any>): Promise<void> {
debug('assign', value);
if (_.isNil(value)) {
throw errors.createError({
title: 'Missing settings',
});
}
if (!_.isPlainObject(value)) {
throw errors.createError({
title: 'Settings must be an object',
});
}
const newSettings = _.assign({}, settings, value);
const updatedSettings = await localSettings.writeAll(newSettings);
// NOTE: Only update in memory settings when successfully written
settings = updatedSettings;
}
/**
* @summary Extend the application state with the local settings
*/
export async function load(): Promise<void> {
debug('load');
const loadedSettings = await localSettings.readAll();
_.assign(settings, loadedSettings);
}
/**
* @summary Set a setting value
*/
export async function set(key: string, value: any): Promise<void> {
debug('set', key, value);
if (_.isNil(key)) {
throw errors.createError({
title: 'Missing setting key',
});
}
if (!_.isString(key)) {
throw errors.createError({
title: `Invalid setting key: ${key}`,
});
}
const previousValue = settings[key];
settings[key] = value;
try {
await localSettings.writeAll(settings);
} catch (error) {
// Revert to previous value if persisting settings failed
settings[key] = previousValue;
throw error;
}
}
/**
* @summary Get a setting value
*/
export function get(key: string): any {
return _.cloneDeep(_.get(settings, [key]));
}
/**
* @summary Check if setting value exists
*/
export function has(key: string): boolean {
return settings[key] != null;
}
/**
* @summary Get all setting values
*/
export function getAll() {
debug('getAll');
return _.cloneDeep(settings);
}
/**
* @summary Get the default setting values
*/
export function getDefaults() {
debug('getDefaults');
return _.cloneDeep(DEFAULT_SETTINGS);
}

View File

@ -1,164 +0,0 @@
/*
* Copyright 2018 balena.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 INDENTATION_SPACES = 2
/**
* @summary Localstorage class and helper functions
* @class
* @public
*/
class Storage {
/**
* @function
* @public
*
* @param {String} superkey - superkey
*
* @example
* const potatoStorage = new Storage('potato')
*/
constructor (superkey) {
this.superkey = superkey
}
/**
* @summary Get the whole object under the superkey
* @function
* @public
*
* @returns {Object}
*
* @example
* for (const key in potatoStorage.getAll()) {
* console.log(key)
* }
*/
getAll () {
try {
// JSON.parse(null) === null, so we fallback to {}
return JSON.parse(window.localStorage.getItem(this.superkey)) || {}
} catch (err) {
this.setAll({})
throw err
}
}
/**
* @summary Set the whole object under the superkey
* @function
* @public
*
* @param {Any} value - any valid JSON value
*
* @example
* potatoStorage.setAll({
* location: 'somewhere',
* freshness: 100,
* edible: true
* })
*/
setAll (value) {
window.localStorage.setItem(this.superkey, JSON.stringify(value, null, INDENTATION_SPACES))
}
/**
* @summary Clear the whole object under the superkey
* @function
* @public
*
* @example
* potatoStorage.clearAll()
*/
clearAll () {
window.localStorage.removeItem(this.superkey)
}
/**
* @summary Get a stored value
* @function
* @public
*
* @param {String} key - object field key
* @param {Any} defaultValue - any valid JSON value
* @returns {Any} - the JSON parsed value
*
* @example
* potatoStorage.get('location', 'my farm')
*/
get (key, defaultValue) {
const value = this.getAll()[key]
// eslint-disable-next-line no-undefined
if (value === undefined) {
return defaultValue
}
return value
}
/**
* @summary Modify a stored value
* @function
* @public
*
* @param {String} key - object field key
* @param {Function} func - function to apply to the value
* @param {Any} defaultValue - fallback value
* @returns {Any} - the value returned by the function applied above
*
* @example
* potatoStorage.modify('freshness', (freshness) => {
* return freshness + 1
* })
*/
modify (key, func, defaultValue) {
const obj = this.getAll()
let result = null
// eslint-disable-next-line no-undefined
if (obj[key] === undefined) {
result = func(defaultValue)
} else {
result = func(obj[key])
}
// eslint-disable-next-line lodash/prefer-lodash-method
this.setAll(Object.assign(obj, { [key]: result }))
return result
}
/**
* @summary Set a stored value
* @function
* @public
*
* @param {String} key - object field key
* @param {Any} value - value to set
*
* @example
* potatoStorage.set('edible', true)
*/
set (key, value) {
this.modify(key, () => {
return value
})
}
}
module.exports = Storage

View File

@ -1,554 +0,0 @@
/*
* Copyright 2016 balena.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 Immutable = require('immutable')
const _ = require('lodash')
const redux = require('redux')
const uuidV4 = require('uuid/v4')
const constraints = require('../../../shared/drive-constraints')
const supportedFormats = require('../../../shared/supported-formats')
const errors = require('../../../shared/errors')
const fileExtensions = require('../../../shared/file-extensions')
const utils = require('../../../shared/utils')
const settings = require('./settings')
/**
* @summary Verify and throw if any state fields are nil
* @function
* @public
*
* @param {Object} object - state object
* @param {Array<Array<String>> | Array<String>} fields - array of object field paths
* @param {String} name - name of the state we're dealing with
* @throws
*
* @example
* const fields = [ 'type', 'percentage' ]
* verifyNoNilFields(action.data, fields, 'flash')
*/
const verifyNoNilFields = (object, fields, name) => {
const nilFields = _.filter(fields, (field) => {
return _.isNil(_.get(object, field))
})
if (nilFields.length) {
throw new Error(`Missing ${name} fields: ${nilFields.join(', ')}`)
}
}
/**
* @summary FLASH_STATE fields that can't be nil
* @constant
* @private
*/
const flashStateNoNilFields = [
'speed',
'totalSpeed'
]
/**
* @summary SELECT_IMAGE fields that can't be nil
* @constant
* @private
*/
const selectImageNoNilFields = [
'path',
'extension'
]
/**
* @summary Application default state
* @type {Object}
* @constant
* @private
*/
const DEFAULT_STATE = Immutable.fromJS({
applicationSessionUuid: '',
flashingWorkflowUuid: '',
availableDrives: [],
selection: {
devices: new Immutable.OrderedSet()
},
isFlashing: false,
flashResults: {},
flashState: {
flashing: 0,
verifying: 0,
successful: 0,
failed: 0,
percentage: 0,
speed: null,
totalSpeed: null
}
})
/**
* @summary Application supported action messages
* @type {Object}
* @constant
*/
const ACTIONS = _.fromPairs(_.map([
'SET_AVAILABLE_DRIVES',
'SET_FLASH_STATE',
'RESET_FLASH_STATE',
'SET_FLASHING_FLAG',
'UNSET_FLASHING_FLAG',
'SELECT_DRIVE',
'SELECT_IMAGE',
'DESELECT_DRIVE',
'DESELECT_IMAGE',
'SET_APPLICATION_SESSION_UUID',
'SET_FLASHING_WORKFLOW_UUID',
'SET_WEBVIEW_SHOWING_STATUS'
], (message) => {
return [ message, message ]
}))
/**
* @summary Get available drives from the state
* @function
* @public
*
* @param {Object} state - state object
* @returns {Object} new state
*
* @example
* const drives = getAvailableDrives(state)
* _.find(drives, { device: '/dev/sda' })
*/
const getAvailableDrives = (state) => {
// eslint-disable-next-line lodash/prefer-lodash-method
return state.get('availableDrives').toJS()
}
/**
* @summary The redux store reducer
* @function
* @private
*
* @param {Object} state - application state
* @param {Object} action - dispatched action
* @returns {Object} new application state
*
* @example
* const newState = storeReducer(DEFAULT_STATE, {
* type: ACTIONS.DESELECT_DRIVE
* });
*/
const storeReducer = (state = DEFAULT_STATE, action) => {
switch (action.type) {
case ACTIONS.SET_AVAILABLE_DRIVES: {
// Type: action.data : Array<DriveObject>
if (!action.data) {
throw errors.createError({
title: 'Missing drives'
})
}
const drives = action.data
if (!_.isArray(drives) || !_.every(drives, _.isObject)) {
throw errors.createError({
title: `Invalid drives: ${drives}`
})
}
const newState = state.set('availableDrives', Immutable.fromJS(drives))
const selectedDevices = newState.getIn([ 'selection', 'devices' ]).toJS()
// Remove selected drives that are stale, i.e. missing from availableDrives
const nonStaleNewState = _.reduce(selectedDevices, (accState, device) => {
// Check whether the drive still exists in availableDrives
if (device && !_.find(drives, {
device
})) {
// Deselect this drive gone from availableDrives
return storeReducer(accState, {
type: ACTIONS.DESELECT_DRIVE,
data: device
})
}
return accState
}, newState)
const shouldAutoselectAll = Boolean(settings.get('disableExplicitDriveSelection'))
const AUTOSELECT_DRIVE_COUNT = 1
const nonStaleSelectedDevices = nonStaleNewState.getIn([ 'selection', 'devices' ]).toJS()
const hasSelectedDevices = nonStaleSelectedDevices.length >= AUTOSELECT_DRIVE_COUNT
const shouldAutoselectOne = drives.length === AUTOSELECT_DRIVE_COUNT && !hasSelectedDevices
if (shouldAutoselectOne || shouldAutoselectAll) {
// Even if there's no image selected, we need to call several
// drive/image related checks, and `{}` works fine with them
const image = state.getIn([ 'selection', 'image' ], Immutable.fromJS({})).toJS()
return _.reduce(drives, (accState, drive) => {
if (_.every([
constraints.isDriveValid(drive, image),
constraints.isDriveSizeRecommended(drive, image),
// We don't want to auto-select large drives
!constraints.isDriveSizeLarge(drive),
// We don't want to auto-select system drives,
// even when "unsafe mode" is enabled
!constraints.isSystemDrive(drive)
]) || (shouldAutoselectAll && constraints.isDriveValid(drive, image))) {
// Auto-select this drive
return storeReducer(accState, {
type: ACTIONS.SELECT_DRIVE,
data: drive.device
})
}
// Deselect this drive in case it still is selected
return storeReducer(accState, {
type: ACTIONS.DESELECT_DRIVE,
data: drive.device
})
}, nonStaleNewState)
}
return nonStaleNewState
}
case ACTIONS.SET_FLASH_STATE: {
// Type: action.data : FlashStateObject
if (!state.get('isFlashing')) {
throw errors.createError({
title: 'Can\'t set the flashing state when not flashing'
})
}
verifyNoNilFields(action.data, flashStateNoNilFields, 'flash')
if (!_.every(_.pick(action.data, [
'flashing',
'verifying',
'successful',
'failed'
]), _.isFinite)) {
throw errors.createError({
title: 'State quantity field(s) not finite number'
})
}
if (!_.isUndefined(action.data.percentage) && !utils.isValidPercentage(action.data.percentage)) {
throw errors.createError({
title: `Invalid state percentage: ${action.data.percentage}`
})
}
if (!_.isUndefined(action.data.eta) && !_.isNumber(action.data.eta)) {
throw errors.createError({
title: `Invalid state eta: ${action.data.eta}`
})
}
return state.set('flashState', Immutable.fromJS(action.data))
}
case ACTIONS.RESET_FLASH_STATE: {
return state
.set('isFlashing', false)
.set('flashState', DEFAULT_STATE.get('flashState'))
.set('flashResults', DEFAULT_STATE.get('flashResults'))
.delete('flashUuid')
}
case ACTIONS.SET_FLASHING_FLAG: {
return state
.set('isFlashing', true)
.set('flashUuid', uuidV4())
.set('flashResults', DEFAULT_STATE.get('flashResults'))
}
case ACTIONS.UNSET_FLASHING_FLAG: {
// Type: action.data : FlashResultsObject
if (!action.data) {
throw errors.createError({
title: 'Missing results'
})
}
_.defaults(action.data, {
cancelled: false
})
if (!_.isBoolean(action.data.cancelled)) {
throw errors.createError({
title: `Invalid results cancelled: ${action.data.cancelled}`
})
}
if (action.data.cancelled && action.data.sourceChecksum) {
throw errors.createError({
title: 'The sourceChecksum value can\'t exist if the flashing was cancelled'
})
}
if (action.data.sourceChecksum && !_.isString(action.data.sourceChecksum)) {
throw errors.createError({
title: `Invalid results sourceChecksum: ${action.data.sourceChecksum}`
})
}
if (action.data.errorCode && !_.isString(action.data.errorCode) && !_.isNumber(action.data.errorCode)) {
throw errors.createError({
title: `Invalid results errorCode: ${action.data.errorCode}`
})
}
return state
.set('isFlashing', false)
.set('flashResults', Immutable.fromJS(action.data))
.set('flashState', DEFAULT_STATE.get('flashState'))
}
case ACTIONS.SELECT_DRIVE: {
// Type: action.data : String
const device = action.data
if (!device) {
throw errors.createError({
title: 'Missing drive'
})
}
if (!_.isString(device)) {
throw errors.createError({
title: `Invalid drive: ${device}`
})
}
const selectedDrive = _.find(getAvailableDrives(state), { device })
if (!selectedDrive) {
throw errors.createError({
title: `The drive is not available: ${device}`
})
}
if (selectedDrive.isReadOnly) {
throw errors.createError({
title: 'The drive is write-protected'
})
}
const image = state.getIn([ 'selection', 'image' ])
if (image && !constraints.isDriveLargeEnough(selectedDrive, image.toJS())) {
throw errors.createError({
title: 'The drive is not large enough'
})
}
const selectedDevices = state.getIn([ 'selection', 'devices' ])
return state.setIn([ 'selection', 'devices' ], selectedDevices.add(device))
}
// TODO(jhermsmeier): Consolidate these assertions
// with image-stream / supported-formats, and have *one*
// place where all the image extension / format handling
// takes place, to avoid having to check 2+ locations with different logic
case ACTIONS.SELECT_IMAGE: {
// Type: action.data : ImageObject
verifyNoNilFields(action.data, selectImageNoNilFields, 'image')
if (!_.isString(action.data.path)) {
throw errors.createError({
title: `Invalid image path: ${action.data.path}`
})
}
if (!_.isString(action.data.extension)) {
throw errors.createError({
title: `Invalid image extension: ${action.data.extension}`
})
}
const extension = _.toLower(action.data.extension)
if (!_.includes(supportedFormats.getAllExtensions(), extension)) {
throw errors.createError({
title: `Invalid image extension: ${action.data.extension}`
})
}
let lastImageExtension = fileExtensions.getLastFileExtension(action.data.path)
lastImageExtension = _.isString(lastImageExtension) ? _.toLower(lastImageExtension) : lastImageExtension
if (lastImageExtension !== extension) {
if (!_.isString(action.data.archiveExtension)) {
throw errors.createError({
title: 'Missing image archive extension'
})
}
const archiveExtension = _.toLower(action.data.archiveExtension)
if (!_.includes(supportedFormats.getAllExtensions(), archiveExtension)) {
throw errors.createError({
title: `Invalid image archive extension: ${action.data.archiveExtension}`
})
}
if (lastImageExtension !== archiveExtension) {
throw errors.createError({
title: `Image archive extension mismatch: ${action.data.archiveExtension} and ${lastImageExtension}`
})
}
}
const MINIMUM_IMAGE_SIZE = 0
// eslint-disable-next-line no-undefined
if (action.data.size !== undefined) {
if ((action.data.size < MINIMUM_IMAGE_SIZE) || !_.isInteger(action.data.size)) {
throw errors.createError({
title: `Invalid image size: ${action.data.size}`
})
}
}
if (!_.isUndefined(action.data.compressedSize)) {
if ((action.data.compressedSize < MINIMUM_IMAGE_SIZE) || !_.isInteger(action.data.compressedSize)) {
throw errors.createError({
title: `Invalid image compressed size: ${action.data.compressedSize}`
})
}
}
if (action.data.url && !_.isString(action.data.url)) {
throw errors.createError({
title: `Invalid image url: ${action.data.url}`
})
}
if (action.data.name && !_.isString(action.data.name)) {
throw errors.createError({
title: `Invalid image name: ${action.data.name}`
})
}
if (action.data.logo && !_.isString(action.data.logo)) {
throw errors.createError({
title: `Invalid image logo: ${action.data.logo}`
})
}
const selectedDevices = state.getIn([ 'selection', 'devices' ])
// Remove image-incompatible drives from selection with `constraints.isDriveValid`
return _.reduce(selectedDevices.toJS(), (accState, device) => {
const drive = _.find(getAvailableDrives(state), { device })
if (!constraints.isDriveValid(drive, action.data) || !constraints.isDriveSizeRecommended(drive, action.data)) {
return storeReducer(accState, {
type: ACTIONS.DESELECT_DRIVE,
data: device
})
}
return accState
}, state).setIn([ 'selection', 'image' ], Immutable.fromJS(action.data))
}
case ACTIONS.DESELECT_DRIVE: {
// Type: action.data : String
if (!action.data) {
throw errors.createError({
title: 'Missing drive'
})
}
if (!_.isString(action.data)) {
throw errors.createError({
title: `Invalid drive: ${action.data}`
})
}
const selectedDevices = state.getIn([ 'selection', 'devices' ])
// Remove drive from set in state
return state.setIn([ 'selection', 'devices' ], selectedDevices.delete(action.data))
}
case ACTIONS.DESELECT_IMAGE: {
return state.deleteIn([ 'selection', 'image' ])
}
case ACTIONS.SET_APPLICATION_SESSION_UUID: {
return state.set('applicationSessionUuid', action.data)
}
case ACTIONS.SET_FLASHING_WORKFLOW_UUID: {
return state.set('flashingWorkflowUuid', action.data)
}
case ACTIONS.SET_WEBVIEW_SHOWING_STATUS: {
return state.set('isWebviewShowing', action.data)
}
default: {
return state
}
}
}
module.exports = _.merge(redux.createStore(storeReducer, DEFAULT_STATE), {
Actions: ACTIONS,
Defaults: DEFAULT_STATE
})
/**
* @summary Observe the store for changes
* @param {Function} onChange - change handler
* @returns {Function} unsubscribe
* @example
* store.observe((newState) => {
* // ...
* })
*/
module.exports.observe = (onChange) => {
let currentState = null
/**
* @summary Internal change detection handler
* @private
* @example
* store.subscribe(changeHandler)
*/
const changeHandler = () => {
const nextState = module.exports.getState()
if (!_.isEqual(nextState, currentState)) {
currentState = nextState
onChange(currentState)
}
}
changeHandler()
return module.exports.subscribe(changeHandler)
}

565
lib/gui/app/models/store.ts Normal file
View File

@ -0,0 +1,565 @@
/*
* Copyright 2016 balena.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.
*/
import * as Immutable from 'immutable';
import * as _ from 'lodash';
import * as redux from 'redux';
import * as uuidV4 from 'uuid/v4';
import * as constraints from '../../../shared/drive-constraints';
import * as errors from '../../../shared/errors';
import * as fileExtensions from '../../../shared/file-extensions';
import * as supportedFormats from '../../../shared/supported-formats';
import * as utils from '../../../shared/utils';
import * as settings from './settings';
/**
* @summary Verify and throw if any state fields are nil
*/
function verifyNoNilFields(
object: _.Dictionary<any>,
fields: string[],
name: string,
) {
const nilFields = _.filter(fields, field => {
return _.isNil(_.get(object, field));
});
if (nilFields.length) {
throw new Error(`Missing ${name} fields: ${nilFields.join(', ')}`);
}
}
/**
* @summary FLASH_STATE fields that can't be nil
*/
const flashStateNoNilFields = ['speed', 'totalSpeed'];
/**
* @summary SELECT_IMAGE fields that can't be nil
*/
const selectImageNoNilFields = ['path', 'extension'];
/**
* @summary Application default state
*/
const DEFAULT_STATE = Immutable.fromJS({
applicationSessionUuid: '',
flashingWorkflowUuid: '',
availableDrives: [],
selection: {
devices: Immutable.OrderedSet(),
},
isFlashing: false,
flashResults: {},
flashState: {
flashing: 0,
verifying: 0,
successful: 0,
failed: 0,
percentage: 0,
speed: null,
totalSpeed: null,
},
});
/**
* @summary Application supported action messages
*/
export enum Actions {
SET_AVAILABLE_DRIVES,
SET_FLASH_STATE,
RESET_FLASH_STATE,
SET_FLASHING_FLAG,
UNSET_FLASHING_FLAG,
SELECT_DRIVE,
SELECT_IMAGE,
DESELECT_DRIVE,
DESELECT_IMAGE,
SET_APPLICATION_SESSION_UUID,
SET_FLASHING_WORKFLOW_UUID,
}
interface Action {
type: Actions;
data: any;
}
/**
* @summary Get available drives from the state
*
* @param {Object} state - state object
* @returns {Object} new state
*/
function getAvailableDrives(state: typeof DEFAULT_STATE) {
return state.get('availableDrives').toJS();
}
/**
* @summary The redux store reducer
*/
function storeReducer(
state = DEFAULT_STATE,
action: Action,
): typeof DEFAULT_STATE {
switch (action.type) {
case Actions.SET_AVAILABLE_DRIVES: {
// Type: action.data : Array<DriveObject>
if (!action.data) {
throw errors.createError({
title: 'Missing drives',
});
}
const drives = action.data;
if (!_.isArray(drives) || !_.every(drives, _.isObject)) {
throw errors.createError({
title: `Invalid drives: ${drives}`,
});
}
const newState = state.set('availableDrives', Immutable.fromJS(drives));
const selectedDevices = newState.getIn(['selection', 'devices']).toJS();
// Remove selected drives that are stale, i.e. missing from availableDrives
const nonStaleNewState = _.reduce(
selectedDevices,
(accState, device) => {
// Check whether the drive still exists in availableDrives
if (
device &&
!_.find(drives, {
device,
})
) {
// Deselect this drive gone from availableDrives
return storeReducer(accState, {
type: Actions.DESELECT_DRIVE,
data: device,
});
}
return accState;
},
newState,
);
const shouldAutoselectAll = Boolean(
settings.get('disableExplicitDriveSelection'),
);
const AUTOSELECT_DRIVE_COUNT = 1;
const nonStaleSelectedDevices = nonStaleNewState
.getIn(['selection', 'devices'])
.toJS();
const hasSelectedDevices =
nonStaleSelectedDevices.length >= AUTOSELECT_DRIVE_COUNT;
const shouldAutoselectOne =
drives.length === AUTOSELECT_DRIVE_COUNT && !hasSelectedDevices;
if (shouldAutoselectOne || shouldAutoselectAll) {
// Even if there's no image selected, we need to call several
// drive/image related checks, and `{}` works fine with them
const image = state
.getIn(['selection', 'image'], Immutable.fromJS({}))
.toJS();
return _.reduce(
drives,
(accState, drive) => {
if (
_.every([
constraints.isDriveValid(drive, image),
constraints.isDriveSizeRecommended(drive, image),
// We don't want to auto-select large drives
!constraints.isDriveSizeLarge(drive),
// We don't want to auto-select system drives,
// even when "unsafe mode" is enabled
!constraints.isSystemDrive(drive),
]) ||
(shouldAutoselectAll && constraints.isDriveValid(drive, image))
) {
// Auto-select this drive
return storeReducer(accState, {
type: Actions.SELECT_DRIVE,
data: drive.device,
});
}
// Deselect this drive in case it still is selected
return storeReducer(accState, {
type: Actions.DESELECT_DRIVE,
data: drive.device,
});
},
nonStaleNewState,
);
}
return nonStaleNewState;
}
case Actions.SET_FLASH_STATE: {
// Type: action.data : FlashStateObject
if (!state.get('isFlashing')) {
throw errors.createError({
title: "Can't set the flashing state when not flashing",
});
}
verifyNoNilFields(action.data, flashStateNoNilFields, 'flash');
if (
!_.every(
_.pick(action.data, [
'flashing',
'verifying',
'successful',
'failed',
]),
_.isFinite,
)
) {
throw errors.createError({
title: 'State quantity field(s) not finite number',
});
}
if (
!_.isUndefined(action.data.percentage) &&
!utils.isValidPercentage(action.data.percentage)
) {
throw errors.createError({
title: `Invalid state percentage: ${action.data.percentage}`,
});
}
if (!_.isUndefined(action.data.eta) && !_.isNumber(action.data.eta)) {
throw errors.createError({
title: `Invalid state eta: ${action.data.eta}`,
});
}
return state.set('flashState', Immutable.fromJS(action.data));
}
case Actions.RESET_FLASH_STATE: {
return state
.set('isFlashing', false)
.set('flashState', DEFAULT_STATE.get('flashState'))
.set('flashResults', DEFAULT_STATE.get('flashResults'))
.delete('flashUuid');
}
case Actions.SET_FLASHING_FLAG: {
return state
.set('isFlashing', true)
.set('flashUuid', uuidV4())
.set('flashResults', DEFAULT_STATE.get('flashResults'));
}
case Actions.UNSET_FLASHING_FLAG: {
// Type: action.data : FlashResultsObject
if (!action.data) {
throw errors.createError({
title: 'Missing results',
});
}
_.defaults(action.data, {
cancelled: false,
});
if (!_.isBoolean(action.data.cancelled)) {
throw errors.createError({
title: `Invalid results cancelled: ${action.data.cancelled}`,
});
}
if (action.data.cancelled && action.data.sourceChecksum) {
throw errors.createError({
title:
"The sourceChecksum value can't exist if the flashing was cancelled",
});
}
if (
action.data.sourceChecksum &&
!_.isString(action.data.sourceChecksum)
) {
throw errors.createError({
title: `Invalid results sourceChecksum: ${action.data.sourceChecksum}`,
});
}
if (
action.data.errorCode &&
!_.isString(action.data.errorCode) &&
!_.isNumber(action.data.errorCode)
) {
throw errors.createError({
title: `Invalid results errorCode: ${action.data.errorCode}`,
});
}
return state
.set('isFlashing', false)
.set('flashResults', Immutable.fromJS(action.data))
.set('flashState', DEFAULT_STATE.get('flashState'));
}
case Actions.SELECT_DRIVE: {
// Type: action.data : String
const device = action.data;
if (!device) {
throw errors.createError({
title: 'Missing drive',
});
}
if (!_.isString(device)) {
throw errors.createError({
title: `Invalid drive: ${device}`,
});
}
const selectedDrive = _.find(getAvailableDrives(state), { device });
if (!selectedDrive) {
throw errors.createError({
title: `The drive is not available: ${device}`,
});
}
if (selectedDrive.isReadOnly) {
throw errors.createError({
title: 'The drive is write-protected',
});
}
const image = state.getIn(['selection', 'image']);
if (
image &&
!constraints.isDriveLargeEnough(selectedDrive, image.toJS())
) {
throw errors.createError({
title: 'The drive is not large enough',
});
}
const selectedDevices = state.getIn(['selection', 'devices']);
return state.setIn(['selection', 'devices'], selectedDevices.add(device));
}
// TODO(jhermsmeier): Consolidate these assertions
// with image-stream / supported-formats, and have *one*
// place where all the image extension / format handling
// takes place, to avoid having to check 2+ locations with different logic
case Actions.SELECT_IMAGE: {
// Type: action.data : ImageObject
verifyNoNilFields(action.data, selectImageNoNilFields, 'image');
if (!_.isString(action.data.path)) {
throw errors.createError({
title: `Invalid image path: ${action.data.path}`,
});
}
if (!_.isString(action.data.extension)) {
throw errors.createError({
title: `Invalid image extension: ${action.data.extension}`,
});
}
const extension = _.toLower(action.data.extension);
if (!_.includes(supportedFormats.getAllExtensions(), extension)) {
throw errors.createError({
title: `Invalid image extension: ${action.data.extension}`,
});
}
let lastImageExtension = fileExtensions.getLastFileExtension(
action.data.path,
);
lastImageExtension = _.isString(lastImageExtension)
? _.toLower(lastImageExtension)
: lastImageExtension;
if (lastImageExtension !== extension) {
if (!_.isString(action.data.archiveExtension)) {
throw errors.createError({
title: 'Missing image archive extension',
});
}
const archiveExtension = _.toLower(action.data.archiveExtension);
if (
!_.includes(supportedFormats.getAllExtensions(), archiveExtension)
) {
throw errors.createError({
title: `Invalid image archive extension: ${action.data.archiveExtension}`,
});
}
if (lastImageExtension !== archiveExtension) {
throw errors.createError({
title: `Image archive extension mismatch: ${action.data.archiveExtension} and ${lastImageExtension}`,
});
}
}
const MINIMUM_IMAGE_SIZE = 0;
if (action.data.size !== undefined) {
if (
action.data.size < MINIMUM_IMAGE_SIZE ||
!_.isInteger(action.data.size)
) {
throw errors.createError({
title: `Invalid image size: ${action.data.size}`,
});
}
}
if (!_.isUndefined(action.data.compressedSize)) {
if (
action.data.compressedSize < MINIMUM_IMAGE_SIZE ||
!_.isInteger(action.data.compressedSize)
) {
throw errors.createError({
title: `Invalid image compressed size: ${action.data.compressedSize}`,
});
}
}
if (action.data.url && !_.isString(action.data.url)) {
throw errors.createError({
title: `Invalid image url: ${action.data.url}`,
});
}
if (action.data.name && !_.isString(action.data.name)) {
throw errors.createError({
title: `Invalid image name: ${action.data.name}`,
});
}
if (action.data.logo && !_.isString(action.data.logo)) {
throw errors.createError({
title: `Invalid image logo: ${action.data.logo}`,
});
}
const selectedDevices = state.getIn(['selection', 'devices']);
// Remove image-incompatible drives from selection with `constraints.isDriveValid`
return _.reduce(
selectedDevices.toJS(),
(accState, device) => {
const drive = _.find(getAvailableDrives(state), { device });
if (
!constraints.isDriveValid(drive, action.data) ||
!constraints.isDriveSizeRecommended(drive, action.data)
) {
return storeReducer(accState, {
type: Actions.DESELECT_DRIVE,
data: device,
});
}
return accState;
},
state,
).setIn(['selection', 'image'], Immutable.fromJS(action.data));
}
case Actions.DESELECT_DRIVE: {
// Type: action.data : String
if (!action.data) {
throw errors.createError({
title: 'Missing drive',
});
}
if (!_.isString(action.data)) {
throw errors.createError({
title: `Invalid drive: ${action.data}`,
});
}
const selectedDevices = state.getIn(['selection', 'devices']);
// Remove drive from set in state
return state.setIn(
['selection', 'devices'],
selectedDevices.delete(action.data),
);
}
case Actions.DESELECT_IMAGE: {
return state.deleteIn(['selection', 'image']);
}
case Actions.SET_APPLICATION_SESSION_UUID: {
return state.set('applicationSessionUuid', action.data);
}
case Actions.SET_FLASHING_WORKFLOW_UUID: {
return state.set('flashingWorkflowUuid', action.data);
}
default: {
return state;
}
}
}
export const store = redux.createStore(storeReducer, DEFAULT_STATE);
/**
* @summary Observe the store for changes
* @param {Function} onChange - change handler
* @returns {Function} unsubscribe
*/
export function observe(onChange: (state: typeof DEFAULT_STATE) => void) {
let currentState: typeof DEFAULT_STATE | null = null;
/**
* @summary Internal change detection handler
*/
const changeHandler = () => {
const nextState = store.getState();
if (!_.isEqual(nextState, currentState)) {
currentState = nextState;
onChange(currentState);
}
};
changeHandler();
return store.subscribe(changeHandler);
}

View File

@ -1,148 +0,0 @@
/*
* Copyright 2016 balena.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')
const resinCorvus = require('resin-corvus/browser')
const packageJSON = require('../../../../package.json')
const settings = require('../models/settings')
const { getConfig, hasProps } = require('../../../shared/utils')
const sentryToken = settings.get('analyticsSentryToken') ||
_.get(packageJSON, [ 'analytics', 'sentry', 'token' ])
const mixpanelToken = settings.get('analyticsMixpanelToken') ||
_.get(packageJSON, [ 'analytics', 'mixpanel', 'token' ])
const configUrl = settings.get('configUrl') || 'https://balena.io/etcher/static/config.json'
const DEFAULT_PROBABILITY = 0.1
const services = {
sentry: sentryToken,
mixpanel: mixpanelToken
}
resinCorvus.install({
services,
options: {
release: packageJSON.version,
shouldReport: () => {
return settings.get('errorReporting')
},
mixpanelDeferred: true
}
})
let mixpanelSample = DEFAULT_PROBABILITY
/**
* @summary Init analytics configurations
* @example initConfig()
*/
const initConfig = async () => {
let validatedConfig = null
try {
const config = await getConfig(configUrl)
const mixpanel = _.get(config, [ 'analytics', 'mixpanel' ], {})
mixpanelSample = mixpanel.probability || DEFAULT_PROBABILITY
if (isClientEligible(mixpanelSample)) {
validatedConfig = validateMixpanelConfig(mixpanel)
}
} catch (err) {
resinCorvus.logException(err)
}
resinCorvus.setConfigs({
mixpanel: validatedConfig
})
}
initConfig()
/**
* @summary Check that the client is eligible for analytics
* @param {Object} - config
*/
// eslint-disable-next-line
function isClientEligible(probability) {
return Math.random() < probability
}
/**
* @summary Check that config has at least HTTP_PROTOCOL and api_host
* @param {Object} - config
*/
// eslint-disable-next-line
function validateMixpanelConfig (config) {
/* eslint-disable camelcase */
const mixpanelConfig = {
api_host: 'https://api.mixpanel.com'
}
if (hasProps(config, [ 'HTTP_PROTOCOL', 'api_host' ])) {
mixpanelConfig.api_host = `${config.HTTP_PROTOCOL}://${config.api_host}`
}
return mixpanelConfig
/* eslint-enable camelcase */
}
/**
* @summary Log a debug message
* @function
* @public
*
* @description
* This function sends the debug message to error reporting services.
*
* @param {String} message - message
*
* @example
* analytics.log('Hello World');
*/
exports.logDebug = resinCorvus.logDebug
/**
* @summary Log an event
* @function
* @public
*
* @description
* This function sends the debug message to product analytics services.
*
* @param {String} message - message
* @param {Object} [data] - event data
*
* @example
* analytics.logEvent('Select image', {
* image: '/dev/disk2'
* });
*/
exports.logEvent = (message, data) => {
resinCorvus.logEvent(message, { ...data, sample: mixpanelSample })
}
/**
* @summary Log an exception
* @function
* @public
*
* @description
* This function logs an exception to error reporting services.
*
* @param {Error} exception - exception
*
* @example
* analytics.logException(new Error('Something happened'));
*/
exports.logException = resinCorvus.logException

View File

@ -0,0 +1,123 @@
/*
* Copyright 2016 balena.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.
*/
import * as _ from 'lodash';
import * as resinCorvus from 'resin-corvus/browser';
import * as packageJSON from '../../../../package.json';
import { getConfig, hasProps } from '../../../shared/utils';
import * as settings from '../models/settings';
const sentryToken =
settings.get('analyticsSentryToken') ||
_.get(packageJSON, ['analytics', 'sentry', 'token']);
const mixpanelToken =
settings.get('analyticsMixpanelToken') ||
_.get(packageJSON, ['analytics', 'mixpanel', 'token']);
const configUrl =
settings.get('configUrl') || 'https://balena.io/etcher/static/config.json';
const DEFAULT_PROBABILITY = 0.1;
const services = {
sentry: sentryToken,
mixpanel: mixpanelToken,
};
resinCorvus.install({
services,
options: {
release: packageJSON.version,
shouldReport: () => {
return settings.get('errorReporting');
},
mixpanelDeferred: true,
},
});
let mixpanelSample = DEFAULT_PROBABILITY;
/**
* @summary Init analytics configurations
*/
async function initConfig() {
let validatedConfig = null;
try {
const config = await getConfig(configUrl);
const mixpanel = _.get(config, ['analytics', 'mixpanel'], {});
mixpanelSample = mixpanel.probability || DEFAULT_PROBABILITY;
if (isClientEligible(mixpanelSample)) {
validatedConfig = validateMixpanelConfig(mixpanel);
}
} catch (err) {
resinCorvus.logException(err);
}
resinCorvus.setConfigs({
mixpanel: validatedConfig,
});
}
initConfig();
/**
* @summary Check that the client is eligible for analytics
*/
function isClientEligible(probability: number) {
return Math.random() < probability;
}
/**
* @summary Check that config has at least HTTP_PROTOCOL and api_host
*/
function validateMixpanelConfig(config: {
api_host?: string;
HTTP_PROTOCOL?: string;
}) {
const mixpanelConfig = {
api_host: 'https://api.mixpanel.com',
};
if (hasProps(config, ['HTTP_PROTOCOL', 'api_host'])) {
mixpanelConfig.api_host = `${config.HTTP_PROTOCOL}://${config.api_host}`;
}
return mixpanelConfig;
}
/**
* @summary Log a debug message
*
* @description
* This function sends the debug message to error reporting services.
*/
export const logDebug = resinCorvus.logDebug;
/**
* @summary Log an event
*
* @description
* This function sends the debug message to product analytics services.
*/
export function logEvent(message: string, data: any) {
resinCorvus.logEvent(message, { ...data, sample: mixpanelSample });
}
/**
* @summary Log an exception
*
* @description
* This function logs an exception to error reporting services.
*/
export const logException = resinCorvus.logException;

View File

@ -14,41 +14,33 @@
* limitations under the License.
*/
'use strict'
import * as sdk from 'etcher-sdk';
import { geteuid, platform } from 'process';
const sdk = require('etcher-sdk')
const process = require('process')
const settings = require('../models/settings')
import * as settings from '../models/settings';
/**
* @summary returns true if system drives should be shown
* @function
*
* @returns {Boolean}
*
* @example
* const shouldInclude = includeSystemDrives()
*/
const includeSystemDrives = () => {
return settings.get('unsafeMode') && !settings.get('disableUnsafeMode')
function includeSystemDrives() {
return settings.get('unsafeMode') && !settings.get('disableUnsafeMode');
}
const adapters = [
new sdk.scanner.adapters.BlockDeviceAdapter(includeSystemDrives)
]
const adapters: sdk.scanner.adapters.Adapter[] = [
new sdk.scanner.adapters.BlockDeviceAdapter(includeSystemDrives),
];
// Can't use permissions.isElevated() here as it returns a promise and we need to set
// module.exports = scanner right now.
// eslint-disable-next-line no-magic-numbers
if ((process.platform !== 'linux') || (process.geteuid() === 0)) {
adapters.push(new sdk.scanner.adapters.UsbbootDeviceAdapter())
if (platform !== 'linux' || geteuid() === 0) {
adapters.push(new sdk.scanner.adapters.UsbbootDeviceAdapter());
}
if (process.platform === 'win32') {
adapters.push(new sdk.scanner.adapters.DriverlessDeviceAdapter())
if (
platform === 'win32' &&
sdk.scanner.adapters.DriverlessDeviceAdapter !== undefined
) {
adapters.push(new sdk.scanner.adapters.DriverlessDeviceAdapter());
}
const scanner = new sdk.scanner.Scanner(adapters)
module.exports = scanner
export const scanner = new sdk.scanner.Scanner(adapters);

View File

@ -14,18 +14,16 @@
* limitations under the License.
*/
'use strict'
import { logException } from '../modules/analytics';
import { showError } from '../os/dialog';
/**
* @module Etcher.Components.Modal
* @summary Report an exception
*/
const angular = require('angular')
const MODULE_NAME = 'Etcher.Components.Modal'
const Modal = angular.module(MODULE_NAME, [
require('angular-ui-bootstrap')
])
Modal.service('ModalService', require('./services/modal'))
module.exports = MODULE_NAME
export function report(exception?: Error) {
if (exception === undefined) {
return;
}
showError(exception);
logException(exception);
}

View File

@ -1,377 +0,0 @@
/*
* Copyright 2016 balena.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 _ = require('lodash')
const path = require('path')
const os = require('os')
const ipc = require('node-ipc')
const electron = require('electron')
const store = require('../models/store')
const settings = require('../models/settings')
const flashState = require('../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 updateLock = require('./update-lock')
const packageJSON = require('../../../../package.json')
const selectionState = require('../models/selection-state')
/**
* @summary Number of threads per CPU to allocate to the UV_THREADPOOL
* @type {Number}
* @constant
*/
const THREADS_PER_CPU = 16
/**
* @summary Handle a flash error and log it to analytics
* @function
* @private
*
* @param {Error} error - error object
* @param {Object} analyticsData - analytics object
*
* @example
* handleErrorLogging({ code: 'EUNPLUGGED' }, { image: 'balena.img' })
*/
const handleErrorLogging = (error, analyticsData) => {
const eventData = _.assign({
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
flashInstanceUuid: flashState.getFlashUuid()
}, analyticsData)
if (error.code === 'EVALIDATION') {
analytics.logEvent('Validation error', eventData)
} else if (error.code === 'EUNPLUGGED') {
analytics.logEvent('Drive unplugged', eventData)
} else if (error.code === 'EIO') {
analytics.logEvent('Input/output error', eventData)
} else if (error.code === 'ENOSPC') {
analytics.logEvent('Out of space', eventData)
} else if (error.code === 'ECHILDDIED') {
analytics.logEvent('Child died unexpectedly', eventData)
} else {
analytics.logEvent('Flash error', _.merge({
error: errors.toJSON(error)
}, eventData))
}
}
/**
* @summary Perform write operation
* @function
* @private
*
* @description
* This function is extracted for testing purposes.
*
* @param {String} image - image path
* @param {Array<String>} drives - drives
* @param {Function} onProgress - in progress callback (state)
*
* @fulfil {Object} - flash results
* @returns {Promise}
*
* @example
* imageWriter.performWrite('path/to/image.img', [ '/dev/disk2' ], (state) => {
* console.log(state.percentage)
* })
*/
exports.performWrite = (image, drives, onProgress) => {
// 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)
// NOTE: Ensure this isn't disabled, as it will cause
// the stdout maxBuffer size to be exceeded when flashing
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()
})
ipc.server.stop()
}
return new Bluebird((resolve, reject) => {
ipc.server.on('error', (error) => {
terminateServer()
const errorObject = errors.fromJSON(error)
reject(errorObject)
})
ipc.server.on('log', (message) => {
console.log(message)
})
const flashResults = {}
const analyticsData = {
image,
drives,
driveCount: drives.length,
uuid: flashState.getFlashUuid(),
flashInstanceUuid: flashState.getFlashUuid(),
unmountOnSuccess: settings.get('unmountOnSuccess'),
validateWriteOnSuccess: settings.get('validateWriteOnSuccess'),
trim: settings.get('trim')
}
ipc.server.on('fail', ({ device, error }) => {
handleErrorLogging(error, analyticsData)
})
ipc.server.on('done', (event) => {
event.results.errors = _.map(event.results.errors, (data) => {
return errors.fromJSON(data)
})
_.merge(flashResults, event)
})
ipc.server.on('abort', () => {
terminateServer()
resolve({
cancelled: true
})
})
ipc.server.on('state', onProgress)
ipc.server.on('ready', (data, socket) => {
ipc.server.emit(socket, 'write', {
imagePath: image,
destinations: drives,
validateWriteOnSuccess: settings.get('validateWriteOnSuccess'),
trim: settings.get('trim'),
unmountOnSuccess: settings.get('unmountOnSuccess')
})
})
const argv = _.attempt(() => {
let entryPoint = electron.remote.app.getAppPath()
// 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) {
entryPoint = _.replace(entryPoint, process.env.APPDIR, '')
return [
process.env.APPIMAGE,
'-e',
`require(\`\${process.env.APPDIR}${entryPoint}\`)`
]
}
return [
_.first(process.argv),
entryPoint
]
})
ipc.server.on('start', () => {
console.log(`Elevating command: ${_.join(argv, ' ')}`)
const env = _.assign({}, process.platform === 'win32' ? {} : process.env, {
IPC_SERVER_ID,
IPC_CLIENT_ID,
IPC_SOCKET_ROOT: ipc.config.socketRoot,
ELECTRON_RUN_AS_NODE: 1,
UV_THREADPOOL_SIZE: os.cpus().length * THREADS_PER_CPU,
// This environment variable prevents the AppImages
// desktop integration script from presenting the
// "installation" dialog
SKIP: 1
})
permissions.elevateCommand(argv, {
applicationName: packageJSON.displayName,
environment: env
}).then((results) => {
flashResults.cancelled = results.cancelled
console.log('Flash results', flashResults)
// This likely means the child died halfway through
if (!flashResults.cancelled && !_.get(flashResults, [ 'results', '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'
})
}
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'
}
reject(error)
}).finally(() => {
console.log('Terminating IPC server')
terminateServer()
})
})
// Clear the update lock timer to prevent longer
// flashing timing it out, and releasing the lock
updateLock.pause()
ipc.server.start()
})
}
/**
* @summary Flash an image to drives
* @function
* @public
*
* @description
* This function will update `imageWriter.state` with the current writing state.
*
* @param {String} image - image path
* @param {Array<String>} drives - drives
* @returns {Promise}
*
* @example
* imageWriter.flash('foo.img', [ '/dev/disk2' ]).then(() => {
* console.log('Write completed!')
* })
*/
exports.flash = (image, drives) => {
if (flashState.isFlashing()) {
return Bluebird.reject(new Error('There is already a flash in progress'))
}
flashState.setFlashingFlag()
const analyticsData = {
image,
drives,
driveCount: drives.length,
uuid: flashState.getFlashUuid(),
status: 'started',
flashInstanceUuid: flashState.getFlashUuid(),
unmountOnSuccess: settings.get('unmountOnSuccess'),
validateWriteOnSuccess: settings.get('validateWriteOnSuccess'),
trim: settings.get('trim'),
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
}
analytics.logEvent('Flash', analyticsData)
return exports.performWrite(image, drives, (state) => {
flashState.setProgressState(state)
}).then(flashState.unsetFlashingFlag).then(() => {
if (flashState.wasLastFlashCancelled()) {
const eventData = _.assign({ status: 'cancel' }, analyticsData)
analytics.logEvent('Elevation cancelled', eventData)
} else {
const { results } = flashState.getFlashResults()
const eventData = _.assign({
errors: results.errors,
devices: results.devices,
status: 'finished'
},
analyticsData)
analytics.logEvent('Done', eventData)
}
}).catch((error) => {
flashState.unsetFlashingFlag({
errorCode: error.code
})
// eslint-disable-next-line no-magic-numbers
if (drives.length > 1) {
const { results } = flashState.getFlashResults()
const eventData = _.assign({
errors: results.errors,
devices: results.devices,
status: 'failed'
},
analyticsData)
analytics.logEvent('Write failed', eventData)
}
return Bluebird.reject(error)
}).finally(() => {
windowProgress.clear()
})
}
/**
* @summary Cancel write operation
* @function
* @public
*
* @example
* imageWriter.cancel()
*/
exports.cancel = () => {
const drives = selectionState.getSelectedDevices()
const analyticsData = {
image: selectionState.getImagePath(),
drives,
driveCount: drives.length,
uuid: flashState.getFlashUuid(),
flashInstanceUuid: flashState.getFlashUuid(),
unmountOnSuccess: settings.get('unmountOnSuccess'),
validateWriteOnSuccess: settings.get('validateWriteOnSuccess'),
trim: settings.get('trim'),
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
status: 'cancel'
}
analytics.logEvent('Cancel', analyticsData)
// Re-enable lock release on inactivity
updateLock.resume()
try {
const [ socket ] = ipc.server.sockets
// eslint-disable-next-line no-undefined
if (socket !== undefined) {
ipc.server.emit(socket, 'cancel')
}
} catch (error) {
analytics.logException(error)
}
}

View File

@ -0,0 +1,350 @@
/*
* Copyright 2016 balena.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.
*/
import { Drive as DrivelistDrive } from 'drivelist';
import * as electron from 'electron';
import * as sdk from 'etcher-sdk';
import * as _ from 'lodash';
import * as ipc from 'node-ipc';
import * as os from 'os';
import * as path from 'path';
import * as packageJSON from '../../../../package.json';
import * as errors from '../../../shared/errors';
import * as permissions from '../../../shared/permissions';
import * as flashState from '../models/flash-state';
import * as selectionState from '../models/selection-state';
import * as settings from '../models/settings';
import { store } from '../models/store';
import * as analytics from '../modules/analytics';
import * as windowProgress from '../os/window-progress';
import { updateLock } from './update-lock';
const THREADS_PER_CPU = 16;
// 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,
);
// NOTE: Ensure this isn't disabled, as it will cause
// the stdout maxBuffer size to be exceeded when flashing
ipc.config.silent = true;
/**
* @summary Handle a flash error and log it to analytics
*/
function handleErrorLogging(
error: Error & { code: string },
analyticsData: any,
) {
const eventData = {
...analyticsData,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
flashInstanceUuid: flashState.getFlashUuid(),
};
if (error.code === 'EVALIDATION') {
analytics.logEvent('Validation error', eventData);
} else if (error.code === 'EUNPLUGGED') {
analytics.logEvent('Drive unplugged', eventData);
} else if (error.code === 'EIO') {
analytics.logEvent('Input/output error', eventData);
} else if (error.code === 'ENOSPC') {
analytics.logEvent('Out of space', eventData);
} else if (error.code === 'ECHILDDIED') {
analytics.logEvent('Child died unexpectedly', eventData);
} else {
analytics.logEvent('Flash error', {
...eventData,
error: errors.toJSON(error),
});
}
}
function 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.
// @ts-ignore (no Server.sockets in @types/node-ipc)
for (const socket of ipc.server.sockets) {
socket.destroy();
}
ipc.server.stop();
}
function writerArgv(): string[] {
let entryPoint = electron.remote.app.getAppPath();
// 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) {
entryPoint = entryPoint.replace(process.env.APPDIR, '');
return [
process.env.APPIMAGE,
'-e',
`require(\`\${process.env.APPDIR}${entryPoint}\`)`,
];
} else {
return [process.argv[0], entryPoint];
}
}
function writerEnv() {
return {
IPC_SERVER_ID,
IPC_CLIENT_ID,
IPC_SOCKET_ROOT: ipc.config.socketRoot,
ELECTRON_RUN_AS_NODE: '1',
UV_THREADPOOL_SIZE: (os.cpus().length * THREADS_PER_CPU).toString(),
// This environment variable prevents the AppImages
// desktop integration script from presenting the
// "installation" dialog
SKIP: '1',
...(process.platform === 'win32' ? {} : process.env),
};
}
interface FlashResults {
cancelled?: boolean;
}
/**
* @summary Perform write operation
*
* @description
* This function is extracted for testing purposes.
*/
export function performWrite(
image: string,
drives: DrivelistDrive[],
onProgress: sdk.multiWrite.OnProgressFunction,
): Promise<{ cancelled?: boolean }> {
let cancelled = false;
ipc.serve();
return new Promise((resolve, reject) => {
ipc.server.on('error', error => {
terminateServer();
const errorObject = errors.fromJSON(error);
reject(errorObject);
});
ipc.server.on('log', message => {
console.log(message);
});
const flashResults: FlashResults = {};
const analyticsData = {
image,
drives,
driveCount: drives.length,
uuid: flashState.getFlashUuid(),
flashInstanceUuid: flashState.getFlashUuid(),
unmountOnSuccess: settings.get('unmountOnSuccess'),
validateWriteOnSuccess: settings.get('validateWriteOnSuccess'),
trim: settings.get('trim'),
};
ipc.server.on('fail', ({ error }) => {
handleErrorLogging(error, analyticsData);
});
ipc.server.on('done', event => {
event.results.errors = _.map(event.results.errors, data => {
return errors.fromJSON(data);
});
_.merge(flashResults, event);
});
ipc.server.on('abort', () => {
terminateServer();
cancelled = true;
});
// @ts-ignore
ipc.server.on('state', onProgress);
ipc.server.on('ready', (_data, socket) => {
ipc.server.emit(socket, 'write', {
imagePath: image,
destinations: drives,
validateWriteOnSuccess: settings.get('validateWriteOnSuccess'),
trim: settings.get('trim'),
unmountOnSuccess: settings.get('unmountOnSuccess'),
});
});
const argv = writerArgv();
ipc.server.on('start', async () => {
console.log(`Elevating command: ${_.join(argv, ' ')}`);
const env = writerEnv();
try {
const results = await permissions.elevateCommand(argv, {
applicationName: packageJSON.displayName,
environment: env,
});
flashResults.cancelled = cancelled || results.cancelled;
} 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';
}
reject(error);
} finally {
console.log('Terminating IPC server');
terminateServer();
}
console.log('Flash results', flashResults);
// This likely means the child died halfway through
if (
!flashResults.cancelled &&
!_.get(flashResults, ['results', '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',
});
}
resolve(flashResults);
});
// Clear the update lock timer to prevent longer
// flashing timing it out, and releasing the lock
updateLock.pause();
ipc.server.start();
});
}
/**
* @summary Flash an image to drives
*/
export async function flash(
image: string,
drives: DrivelistDrive[],
): Promise<void> {
if (flashState.isFlashing()) {
throw new Error('There is already a flash in progress');
}
flashState.setFlashingFlag();
const analyticsData = {
image,
drives,
driveCount: drives.length,
uuid: flashState.getFlashUuid(),
status: 'started',
flashInstanceUuid: flashState.getFlashUuid(),
unmountOnSuccess: settings.get('unmountOnSuccess'),
validateWriteOnSuccess: settings.get('validateWriteOnSuccess'),
trim: settings.get('trim'),
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
};
analytics.logEvent('Flash', analyticsData);
try {
// Using it from exports so it can be mocked during tests
const result = await exports.performWrite(
image,
drives,
flashState.setProgressState,
);
flashState.unsetFlashingFlag(result);
} catch (error) {
flashState.unsetFlashingFlag({ cancelled: false, errorCode: error.code });
windowProgress.clear();
let { results } = flashState.getFlashResults();
results = results || {};
const eventData = {
...analyticsData,
errors: results.errors,
devices: results.devices,
status: 'failed',
error,
};
analytics.logEvent('Write failed', eventData);
throw error;
}
windowProgress.clear();
if (flashState.wasLastFlashCancelled()) {
const eventData = {
...analyticsData,
status: 'cancel',
};
analytics.logEvent('Elevation cancelled', eventData);
} else {
const { results } = flashState.getFlashResults();
const eventData = {
...analyticsData,
errors: results.errors,
devices: results.devices,
status: 'finished',
};
analytics.logEvent('Done', eventData);
}
}
/**
* @summary Cancel write operation
*/
export function cancel() {
const drives = selectionState.getSelectedDevices();
const analyticsData = {
image: selectionState.getImagePath(),
drives,
driveCount: drives.length,
uuid: flashState.getFlashUuid(),
flashInstanceUuid: flashState.getFlashUuid(),
unmountOnSuccess: settings.get('unmountOnSuccess'),
validateWriteOnSuccess: settings.get('validateWriteOnSuccess'),
trim: settings.get('trim'),
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
status: 'cancel',
};
analytics.logEvent('Cancel', analyticsData);
// Re-enable lock release on inactivity
updateLock.resume();
try {
// @ts-ignore (no Server.sockets in @types/node-ipc)
const [socket] = ipc.server.sockets;
if (socket !== undefined) {
ipc.server.emit(socket, 'cancel');
}
} catch (error) {
analytics.logException(error);
}
}

View File

@ -1,74 +0,0 @@
/*
* Copyright 2017 balena.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 settings = require('../models/settings')
const utils = require('../../../shared/utils')
const units = require('../../../shared/units')
/**
* @summary Make the progress status subtitle string
*
* @param {Object} state - flashing metadata
*
* @returns {String}
*
* @example
* const status = progressStatus.fromFlashState({
* flashing: 1,
* verifying: 0,
* successful: 0,
* failed: 0,
* percentage: 55,
* speed: 2049
* })
*
* console.log(status)
* // '55% Flashing'
*/
exports.fromFlashState = (state) => {
const isFlashing = Boolean(state.flashing)
const isValidating = !isFlashing && Boolean(state.verifying)
const shouldValidate = settings.get('validateWriteOnSuccess')
const shouldUnmount = settings.get('unmountOnSuccess')
if (state.percentage === utils.PERCENTAGE_MINIMUM && !state.speed) {
if (isValidating) {
return 'Validating...'
}
return 'Starting...'
} else if (state.percentage === utils.PERCENTAGE_MAXIMUM) {
if ((isValidating || !shouldValidate) && shouldUnmount) {
return 'Unmounting...'
}
return 'Finishing...'
} else if (isFlashing) {
// eslint-disable-next-line no-eq-null
if (state.percentage != null) {
return `${state.percentage}% Flashing`
}
return `${units.bytesToClosestUnit(state.position)} flashed`
} else if (isValidating) {
return `${state.percentage}% Validating`
} else if (!isFlashing && !isValidating) {
return 'Failed'
}
throw new Error(`Invalid state: ${JSON.stringify(state)}`)
}

View File

@ -0,0 +1,80 @@
/*
* Copyright 2017 balena.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.
*/
import { bytesToClosestUnit } from '../../../shared/units';
import * as settings from '../models/settings';
export interface FlashState {
flashing: number;
verifying: number;
successful: number;
failed: number;
percentage?: number;
speed: number;
position: number;
}
/**
* @summary Make the progress status subtitle string
*
* @param {Object} state - flashing metadata
*
* @returns {String}
*
* @example
* const status = progressStatus.fromFlashState({
* flashing: 1,
* verifying: 0,
* successful: 0,
* failed: 0,
* percentage: 55,
* speed: 2049
* })
*
* console.log(status)
* // '55% Flashing'
*/
export function fromFlashState(state: FlashState): string {
const isFlashing = Boolean(state.flashing);
const isValidating = !isFlashing && Boolean(state.verifying);
const shouldValidate = settings.get('validateWriteOnSuccess');
const shouldUnmount = settings.get('unmountOnSuccess');
if (state.percentage === 0 && !state.speed) {
if (isValidating) {
return 'Validating...';
}
return 'Starting...';
} else if (state.percentage === 100) {
if ((isValidating || !shouldValidate) && shouldUnmount) {
return 'Unmounting...';
}
return 'Finishing...';
} else if (isFlashing) {
if (state.percentage != null) {
return `${state.percentage}% Flashing`;
}
return `${bytesToClosestUnit(state.position)} flashed`;
} else if (isValidating) {
return `${state.percentage}% Validating`;
} else if (!isFlashing && !isValidating) {
return 'Failed';
}
throw new Error(`Invalid state: ${JSON.stringify(state)}`);
}

View File

@ -1,214 +0,0 @@
/*
* Copyright 2018 balena.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 electron = require('electron')
const EventEmitter = require('events')
const createInactivityTimer = require('inactivity-timer')
const debug = require('debug')('etcher:update-lock')
const analytics = require('./analytics')
const settings = require('../models/settings')
/* eslint-disable no-magic-numbers, callback-return */
/**
* Interaction timeout in milliseconds (defaults to 5 minutes)
* @type {Number}
* @constant
*/
const INTERACTION_TIMEOUT_MS = settings.has('interactionTimeout')
? parseInt(settings.get('interactionTimeout'), 10)
: 5 * 60 * 1000
/**
* Balena Update Lock
* @class
*/
class UpdateLock extends EventEmitter {
/**
* @summary Balena Update Lock
* @example
* new UpdateLock()
*/
constructor () {
super()
this.paused = false
this.on('inactive', UpdateLock.onInactive)
this.lockTimer = createInactivityTimer(INTERACTION_TIMEOUT_MS, () => {
debug('inactive')
this.emit('inactive')
})
}
/**
* @summary Inactivity event handler, releases the balena update lock on inactivity
* @private
* @example
* this.on('inactive', onInactive)
*/
static onInactive () {
if (settings.get('resinUpdateLock')) {
UpdateLock.check((checkError, isLocked) => {
debug('inactive-check', Boolean(checkError))
if (checkError) {
analytics.logException(checkError)
}
if (isLocked) {
UpdateLock.release((error) => {
debug('inactive-release', Boolean(error))
if (error) {
analytics.logException(error)
}
})
}
})
}
}
/**
* @summary Acquire the update lock
* @private
* @param {Function} callback - callback(error)
* @example
* UpdateLock.acquire((error) => {
* // ...
* })
*/
static acquire (callback) {
debug('lock')
if (settings.get('resinUpdateLock')) {
electron.ipcRenderer.once('resin-update-lock', (event, error) => {
callback(error)
})
electron.ipcRenderer.send('resin-update-lock', 'lock')
} else {
callback(new Error('Update lock disabled'))
}
}
/**
* @summary Release the update lock
* @private
* @param {Function} callback - callback(error)
* @example
* UpdateLock.release((error) => {
* // ...
* })
*/
static release (callback) {
debug('unlock')
if (settings.get('resinUpdateLock')) {
electron.ipcRenderer.once('resin-update-lock', (event, error) => {
callback(error)
})
electron.ipcRenderer.send('resin-update-lock', 'unlock')
} else {
callback(new Error('Update lock disabled'))
}
}
/**
* @summary Check the state of the update lock
* @private
* @param {Function} callback - callback(error, isLocked)
* @example
* UpdateLock.check((error, isLocked) => {
* if (isLocked) {
* // ...
* }
* })
*/
static check (callback) {
debug('check')
if (settings.get('resinUpdateLock')) {
electron.ipcRenderer.once('resin-update-lock', (event, error, isLocked) => {
callback(error, isLocked)
})
electron.ipcRenderer.send('resin-update-lock', 'check')
} else {
callback(new Error('Update lock disabled'))
}
}
/**
* @summary Extend the lock timer
* @example
* updateLock.extend()
*/
extend () {
debug('extend')
if (this.paused) {
debug('extend:paused')
return
}
this.lockTimer.signal()
// When extending, check that we have the lock,
// and acquire it, if not
if (settings.get('resinUpdateLock')) {
UpdateLock.check((checkError, isLocked) => {
if (checkError) {
analytics.logException(checkError)
}
if (!isLocked) {
UpdateLock.acquire((error) => {
if (error) {
analytics.logException(error)
}
debug('extend-acquire', Boolean(error))
})
}
})
}
}
/**
* @summary Clear the lock timer
* @example
* updateLock.clearTimer()
*/
clearTimer () {
debug('clear')
this.lockTimer.clear()
}
/**
* @summary Clear the lock timer, and pause extension, avoiding triggering until resume()d
* @example
* updateLock.pause()
*/
pause () {
debug('pause')
this.paused = true
this.clearTimer()
}
/**
* @summary Un-pause lock extension, and restart the timer
* @example
* updateLock.resume()
*/
resume () {
debug('resume')
this.paused = false
this.extend()
}
}
module.exports = new UpdateLock()

View File

@ -0,0 +1,188 @@
/*
* Copyright 2018 balena.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.
*/
import * as _debug from 'debug';
import * as electron from 'electron';
import { EventEmitter } from 'events';
import * as createInactivityTimer from 'inactivity-timer';
import * as settings from '../models/settings';
import { logException } from './analytics';
const debug = _debug('etcher:update-lock');
/**
* Interaction timeout in milliseconds (defaults to 5 minutes)
* @type {Number}
* @constant
*/
const INTERACTION_TIMEOUT_MS = settings.has('interactionTimeout')
? parseInt(settings.get('interactionTimeout'), 10)
: 5 * 60 * 1000;
class UpdateLock extends EventEmitter {
private paused: boolean;
private lockTimer: any;
constructor() {
super();
this.paused = false;
this.on('inactive', UpdateLock.onInactive);
this.lockTimer = createInactivityTimer(INTERACTION_TIMEOUT_MS, () => {
debug('inactive');
this.emit('inactive');
});
}
/**
* @summary Inactivity event handler, releases the balena update lock on inactivity
*/
private static onInactive() {
if (settings.get('resinUpdateLock')) {
UpdateLock.check((checkError: Error, isLocked: boolean) => {
debug('inactive-check', Boolean(checkError));
if (checkError) {
logException(checkError);
}
if (isLocked) {
UpdateLock.release((error?: Error) => {
debug('inactive-release', Boolean(error));
if (error) {
logException(error);
}
});
}
});
}
}
/**
* @summary Acquire the update lock
*/
private static acquire(callback: (error?: Error) => void) {
debug('lock');
if (settings.get('resinUpdateLock')) {
electron.ipcRenderer.once('resin-update-lock', (_event, error) => {
callback(error);
});
electron.ipcRenderer.send('resin-update-lock', 'lock');
} else {
callback(new Error('Update lock disabled'));
}
}
/**
* @summary Release the update lock
*/
public static release(callback: (error?: Error) => void) {
debug('unlock');
if (settings.get('resinUpdateLock')) {
electron.ipcRenderer.once('resin-update-lock', (_event, error) => {
callback(error);
});
electron.ipcRenderer.send('resin-update-lock', 'unlock');
} else {
callback(new Error('Update lock disabled'));
}
}
/**
* @summary Check the state of the update lock
* @param {Function} callback - callback(error, isLocked)
* @example
* UpdateLock.check((error, isLocked) => {
* if (isLocked) {
* // ...
* }
* })
*/
private static check(
callback: (error: Error | null, isLocked?: boolean) => void,
) {
debug('check');
if (settings.get('resinUpdateLock')) {
electron.ipcRenderer.once(
'resin-update-lock',
(_event, error, isLocked) => {
callback(error, isLocked);
},
);
electron.ipcRenderer.send('resin-update-lock', 'check');
} else {
callback(new Error('Update lock disabled'));
}
}
/**
* @summary Extend the lock timer
*/
public extend() {
debug('extend');
if (this.paused) {
debug('extend:paused');
return;
}
this.lockTimer.signal();
// When extending, check that we have the lock,
// and acquire it, if not
if (settings.get('resinUpdateLock')) {
UpdateLock.check((checkError, isLocked) => {
if (checkError) {
logException(checkError);
}
if (!isLocked) {
UpdateLock.acquire(error => {
if (error) {
logException(error);
}
debug('extend-acquire', Boolean(error));
});
}
});
}
}
/**
* @summary Clear the lock timer
*/
private clearTimer() {
debug('clear');
this.lockTimer.clear();
}
/**
* @summary Clear the lock timer, and pause extension, avoiding triggering until resume()d
*/
public pause() {
debug('pause');
this.paused = true;
this.clearTimer();
}
/**
* @summary Un-pause lock extension, and restart the timer
*/
public resume() {
debug('resume');
this.paused = false;
this.extend();
}
}
export const updateLock = new UpdateLock();

View File

@ -1,147 +0,0 @@
/*
* Copyright 2016 balena.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')
const electron = require('electron')
const Bluebird = require('bluebird')
const errors = require('../../../shared/errors')
const supportedFormats = require('../../../shared/supported-formats')
/**
* @summary Current renderer BrowserWindow instance
* @type {Object}
* @private
*/
const currentWindow = electron.remote.getCurrentWindow()
/**
* @summary Open an image selection dialog
* @function
* @public
*
* @description
* Notice that by image, we mean *.img/*.iso/*.zip/etc files.
*
* @fulfil {Object} - selected image
* @returns {Promise};
*
* @example
* osDialog.selectImage().then((image) => {
* console.log('The selected image is', image.path);
* });
*/
exports.selectImage = () => {
return new Bluebird((resolve) => {
electron.remote.dialog.showOpenDialog(currentWindow, {
// This variable is set when running in GNU/Linux from
// inside an AppImage, and represents the working directory
// from where the AppImage was run (which might not be the
// place where the AppImage is located). `OWD` stands for
// "Original Working Directory".
//
// See: https://github.com/probonopd/AppImageKit/commit/1569d6f8540aa6c2c618dbdb5d6fcbf0003952b7
defaultPath: process.env.OWD,
properties: [
'openFile',
'treatPackageAsDirectory'
],
filters: [
{
name: 'OS Images',
extensions: _.sortBy(supportedFormats.getAllExtensions())
}
]
}, (files) => {
// `_.first` is smart enough to not throw and return `undefined`
// if we pass it an `undefined` value (e.g: when the selection
// dialog was cancelled).
return resolve(_.first(files))
})
})
}
/**
* @summary Open a warning dialog
* @function
* @public
*
* @param {Object} options - options
* @param {String} options.title - dialog title
* @param {String} options.description - dialog description
* @param {String} [options.confirmationLabel="OK"] - confirmation label
* @param {String} [options.rejectionLabel="Cancel"] - rejection label
* @fulfil {Boolean} - whether the dialog was confirmed or not
* @returns {Promise};
*
* @example
* osDialog.showWarning({
* title: 'This is a warning',
* description: 'Are you sure you want to continue?',
* confirmationLabel: 'Yes, continue',
* rejectionLabel: 'Cancel'
* }).then((confirmed) => {
* if (confirmed) {
* console.log('The dialog was confirmed');
* }
* });
*/
exports.showWarning = (options) => {
_.defaults(options, {
confirmationLabel: 'OK',
rejectionLabel: 'Cancel'
})
const BUTTONS = [
options.confirmationLabel,
options.rejectionLabel
]
const BUTTON_CONFIRMATION_INDEX = _.indexOf(BUTTONS, options.confirmationLabel)
const BUTTON_REJECTION_INDEX = _.indexOf(BUTTONS, options.rejectionLabel)
return new Bluebird((resolve) => {
electron.remote.dialog.showMessageBox(currentWindow, {
type: 'warning',
buttons: BUTTONS,
defaultId: BUTTON_REJECTION_INDEX,
cancelId: BUTTON_REJECTION_INDEX,
title: 'Attention',
message: options.title,
detail: options.description
}, (response) => {
return resolve(response === BUTTON_CONFIRMATION_INDEX)
})
})
}
/**
* @summary Show error dialog for an Error instance
* @function
* @public
*
* @param {Error} error - error
*
* @example
* osDialog.showError(new Error('Foo Bar'));
*/
exports.showError = (error) => {
const title = errors.getTitle(error)
const message = errors.getDescription(error)
electron.remote.dialog.showErrorBox(title, message)
}

104
lib/gui/app/os/dialog.ts Normal file
View File

@ -0,0 +1,104 @@
/*
* Copyright 2016 balena.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.
*/
import * as electron from 'electron';
import * as _ from 'lodash';
import * as errors from '../../../shared/errors';
import * as supportedFormats from '../../../shared/supported-formats';
/**
* @summary Open an image selection dialog
*
* @description
* Notice that by image, we mean *.img/*.iso/*.zip/etc files.
*/
export function selectImage(): Promise<string> {
return new Promise(resolve => {
electron.remote.dialog.showOpenDialog(
electron.remote.getCurrentWindow(),
{
// This variable is set when running in GNU/Linux from
// inside an AppImage, and represents the working directory
// from where the AppImage was run (which might not be the
// place where the AppImage is located). `OWD` stands for
// "Original Working Directory".
//
// See: https://github.com/probonopd/AppImageKit/commit/1569d6f8540aa6c2c618dbdb5d6fcbf0003952b7
defaultPath: process.env.OWD,
properties: ['openFile', 'treatPackageAsDirectory'],
filters: [
{
name: 'OS Images',
extensions: _.sortBy(supportedFormats.getAllExtensions()),
},
],
},
(files: string[]) => {
// `_.first` is smart enough to not throw and return `undefined`
// if we pass it an `undefined` value (e.g: when the selection
// dialog was cancelled).
resolve(_.first(files));
},
);
});
}
/**
* @summary Open a warning dialog
*/
export async function showWarning(options: {
confirmationLabel: string;
rejectionLabel: string;
title: string;
description: string;
}): Promise<boolean> {
_.defaults(options, {
confirmationLabel: 'OK',
rejectionLabel: 'Cancel',
});
const BUTTONS = [options.confirmationLabel, options.rejectionLabel];
const BUTTON_CONFIRMATION_INDEX = _.indexOf(
BUTTONS,
options.confirmationLabel,
);
const BUTTON_REJECTION_INDEX = _.indexOf(BUTTONS, options.rejectionLabel);
const { response } = await electron.remote.dialog.showMessageBox(
electron.remote.getCurrentWindow(),
{
type: 'warning',
buttons: BUTTONS,
defaultId: BUTTON_REJECTION_INDEX,
cancelId: BUTTON_REJECTION_INDEX,
title: 'Attention',
message: options.title,
detail: options.description,
},
);
return response === BUTTON_CONFIRMATION_INDEX;
}
/**
* @summary Show error dialog for an Error instance
*/
export function showError(error: Error) {
const title = errors.getTitle(error);
const message = errors.getDescription(error);
electron.remote.dialog.showErrorBox(title, message);
}

View File

@ -1,56 +0,0 @@
/*
* Copyright 2017 balena.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 electron = require('electron')
const settings = require('../models/settings')
/**
* @summary Send a notification
* @function
* @public
*
* @description
* This function makes use of Electron's notification desktop
* integration feature. See:
* http://electron.atom.io/docs/v0.37.5/tutorial/desktop-environment-integration/
*
* @param {String} title - notification title
* @param {Object} options - options object
* @param {String} options.body - notification body
* @param {String} options.icon - supported icon path
* @returns {Object} HTML5 notification instance
*
* @example
* notification.send('Hello', {
* body: 'Foo Bar Bar',
* icon: 'icon.png'
* });
*/
exports.send = (title, options) => {
// Bail out if desktop notifications are disabled
if (!settings.get('desktopNotifications')) {
return null
}
// `app.dock` is only defined in OS X
if (electron.remote.app.dock) {
electron.remote.app.dock.bounce()
}
return new window.Notification(title, options)
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2016 balena.io
* Copyright 2017 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -14,19 +14,23 @@
* limitations under the License.
*/
'use strict'
import * as electron from 'electron';
/* eslint-disable jsdoc/require-example */
import * as settings from '../models/settings';
/**
* @module Etcher.Components.SVGIcon
* @summary Send a notification
*/
export function send(title: string, body: string, icon: string) {
// Bail out if desktop notifications are disabled
if (!settings.get('desktopNotifications')) {
return;
}
const angular = require('angular')
const react2angular = require('react2angular').react2angular
// `app.dock` is only defined in OS X
if (electron.remote.app.dock) {
electron.remote.app.dock.bounce();
}
const MODULE_NAME = 'Etcher.Components.SVGIcon'
const angularSVGIcon = angular.module(MODULE_NAME, [])
angularSVGIcon.component('svgIcon', react2angular(require('./svg-icon.jsx')))
module.exports = MODULE_NAME
return new window.Notification(title, { body, icon });
}

View File

@ -1,54 +0,0 @@
/*
* Copyright 2016 balena.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 electron = require('electron')
const store = require('../../../models/store')
const analytics = require('../../../modules/analytics')
const settings = require('../../../models/settings')
/**
* @summary Open an external resource
* @function
* @public
*
* @param {String} url - url
*
* @example
* OSOpenExternalService.open('https://www.google.com');
*/
const open = (url) => {
// Don't open links if they're disabled by the env var
if (settings.get('disableExternalLinks')) {
return
}
analytics.logEvent('Open external link', {
url,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid
})
if (url) {
electron.shell.openExternal(url)
}
}
module.exports = function () {
this.open = open
}
module.exports.open = open

View File

@ -14,23 +14,26 @@
* limitations under the License.
*/
'use strict'
import * as electron from 'electron';
import * as settings from '../../../models/settings';
import { store } from '../../../models/store';
import { logEvent } from '../../../modules/analytics';
/**
* The purpose of this module is to provide utilities
* to work with sizes in bytes.
*
* @module Etcher.Utils.ByteSize
* @summary Open an external resource
*/
export function open(url: string) {
// Don't open links if they're disabled by the env var
if (settings.get('disableExternalLinks')) {
return;
}
const angular = require('angular')
const MODULE_NAME = 'Etcher.Utils.ByteSize'
const ByteSize = angular.module(MODULE_NAME, [])
logEvent('Open external link', {
url,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
});
/* eslint-disable lodash/prefer-lodash-method */
ByteSize.filter('closestUnit', require('./filter.js'))
/* eslint-enable lodash/prefer-lodash-method */
module.exports = MODULE_NAME
if (url) {
electron.shell.openExternal(url);
}
}

View File

@ -1,114 +0,0 @@
/*
* Copyright 2016 balena.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 electron = require('electron')
const utils = require('../../../shared/utils')
const progressStatus = require('../modules/progress-status')
/**
* @summary The title of the main window upon program launch
* @type {String}
* @private
* @constant
*/
const INITIAL_TITLE = document.title
/**
* @summary Make the full window status title
* @private
*
* @param {Object} state - flash state object
*
* @returns {String}
*
* @example
* const title = getWindowTitle({
* flashing: 1,
* validating: 0,
* successful: 0,
* failed: 0,
* percentage: 55,
* speed: 2049
* });
*
* console.log(title);
* // 'Etcher \u2013 55% Flashing'
*/
const getWindowTitle = (state) => {
if (state) {
const subtitle = progressStatus.fromFlashState(state)
const DASH_UNICODE_CHAR = '\u2013'
return `${INITIAL_TITLE} ${DASH_UNICODE_CHAR} ${subtitle}`
}
return INITIAL_TITLE
}
/**
* @summary A reference to the current renderer Electron window
* @type {Object}
* @protected
*
* @description
* We expose this property to `this` for testability purposes.
*/
exports.currentWindow = electron.remote.getCurrentWindow()
/**
* @summary Set operating system window progress
* @function
* @public
*
* @description
* Show progress inline in operating system task bar
*
* @param {Number} state - flash state object
*
* @example
* windowProgress.set({
* flashing: 1,
* validating: 0,
* successful: 0,
* failed: 0,
* percentage: 55,
* speed: 2049
* })
*/
exports.set = (state) => {
// eslint-disable-next-line no-eq-null
if (state.percentage != null) {
exports.currentWindow.setProgressBar(utils.percentageToFloat(state.percentage))
}
exports.currentWindow.setTitle(getWindowTitle(state))
}
/**
* @summary Clear the window progress bar
* @function
* @public
*
* @example
* windowProgress.clear();
*/
exports.clear = () => {
// Passing 0 or null/undefined doesn't work.
const ELECTRON_PROGRESS_BAR_RESET_VALUE = -1
exports.currentWindow.setProgressBar(ELECTRON_PROGRESS_BAR_RESET_VALUE)
exports.currentWindow.setTitle(getWindowTitle(null))
}

View File

@ -0,0 +1,65 @@
/*
* Copyright 2016 balena.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.
*/
import * as electron from 'electron';
import { percentageToFloat } from '../../../shared/utils';
import { FlashState, fromFlashState } from '../modules/progress-status';
/**
* @summary The title of the main window upon program launch
*/
const INITIAL_TITLE = document.title;
/**
* @summary Make the full window status title
*/
function getWindowTitle(state?: FlashState) {
if (state) {
return `${INITIAL_TITLE} ${fromFlashState(state)}`;
}
return INITIAL_TITLE;
}
/**
* @summary A reference to the current renderer Electron window
*
* @description
* We expose this property to `this` for testability purposes.
*/
export const currentWindow = electron.remote.getCurrentWindow();
/**
* @summary Set operating system window progress
*
* @description
* Show progress inline in operating system task bar
*/
export function set(state: FlashState) {
if (state.percentage != null) {
exports.currentWindow.setProgressBar(percentageToFloat(state.percentage));
}
exports.currentWindow.setTitle(getWindowTitle(state));
}
/**
* @summary Clear the window progress bar
*/
export function clear() {
// Passing 0 or null/undefined doesn't work.
exports.currentWindow.setProgressBar(-1);
exports.currentWindow.setTitle(getWindowTitle(undefined));
}

View File

@ -1,143 +0,0 @@
/*
* Copyright 2019 balena.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 cp = require('child_process')
const fs = require('fs')
const _ = require('lodash')
const os = require('os')
const Path = require('path')
const process = require('process')
const { promisify } = require('util')
const { tmpFileDisposer } = require('../../../shared/utils')
const readFileAsync = promisify(fs.readFile)
const execAsync = promisify(cp.exec)
/**
* @summary Returns wmic's output for network drives
* @function
*
* @returns {Promise<String>}
*
* @example
* const output = await getWmicNetworkDrivesOutput()
*/
exports.getWmicNetworkDrivesOutput = async () => {
// Exported for tests.
// When trying to read wmic's stdout directly from node, it is encoded with the current
// console codepage (depending on the computer).
// Decoding this would require getting this codepage somehow and using iconv as node
// doesn't know how to read cp850 directly for example.
// We could also use wmic's "/output:" switch but it doesn't work when the filename
// contains a space and the os temp dir may contain spaces ("D:\Windows Temp Files" for example).
// So we just redirect to a file and read it afterwards as we know it will be ucs2 encoded.
const options = {
// Close the file once it's created
discardDescriptor: true,
// Wmic fails with "Invalid global switch" when the "/output:" switch filename contains a dash ("-")
prefix: 'tmp'
}
return Bluebird.using(tmpFileDisposer(options), async ({ path }) => {
const command = [
Path.join(process.env.SystemRoot, 'System32', 'Wbem', 'wmic'),
'path',
'Win32_LogicalDisk',
'Where',
'DriveType="4"',
'get',
'DeviceID,ProviderName',
'>',
`"${path}"`
]
await execAsync(command.join(' '), { windowsHide: true })
return readFileAsync(path, 'ucs2')
})
}
/**
* @summary returns a Map of drive letter -> network locations on Windows
* @function
*
* @returns {Promise<Map<String, String>>} - 'Z:' -> '\\\\192.168.0.1\\Public'
*
* @example
* getWindowsNetworkDrives()
* .then(console.log);
*/
const getWindowsNetworkDrives = async () => {
const result = await exports.getWmicNetworkDrivesOutput()
const couples = _.chain(result)
.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}`)
}
// eslint-disable-next-line no-magic-numbers
return [ str.slice(0, colonPosition + 1), _.trim(str.slice(colonPosition + 1)) ]
})
// eslint-disable-next-line no-magic-numbers
.filter((couple) => couple[1].length > 0)
.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
}

View File

@ -0,0 +1,115 @@
/*
* Copyright 2019 balena.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.
*/
import { using } from 'bluebird';
import { exec } from 'child_process';
import { readFile } from 'fs';
import { chain, trim } from 'lodash';
import { platform } from 'os';
import { join } from 'path';
import { env } from 'process';
import { promisify } from 'util';
import { tmpFileDisposer } from '../../../shared/utils';
const readFileAsync = promisify(readFile);
const execAsync = promisify(exec);
/**
* @summary Returns wmic's output for network drives
*/
export async function getWmicNetworkDrivesOutput(): Promise<string> {
// Exported for tests.
// When trying to read wmic's stdout directly from node, it is encoded with the current
// console codepage (depending on the computer).
// Decoding this would require getting this codepage somehow and using iconv as node
// doesn't know how to read cp850 directly for example.
// We could also use wmic's "/output:" switch but it doesn't work when the filename
// contains a space and the os temp dir may contain spaces ("D:\Windows Temp Files" for example).
// So we just redirect to a file and read it afterwards as we know it will be ucs2 encoded.
const options = {
// Close the file once it's created
discardDescriptor: true,
// Wmic fails with "Invalid global switch" when the "/output:" switch filename contains a dash ("-")
prefix: 'tmp',
};
return using(tmpFileDisposer(options), async ({ path }) => {
const command = [
join(env.SystemRoot as string, 'System32', 'Wbem', 'wmic'),
'path',
'Win32_LogicalDisk',
'Where',
'DriveType="4"',
'get',
'DeviceID,ProviderName',
'>',
`"${path}"`,
];
await execAsync(command.join(' '), { windowsHide: true });
return readFileAsync(path, 'ucs2');
});
}
/**
* @summary returns a Map of drive letter -> network locations on Windows: 'Z:' -> '\\\\192.168.0.1\\Public'
*/
async function getWindowsNetworkDrives(): Promise<Map<string, string>> {
// Use getWindowsNetworkDrives from "exports." so it can be mocked in tests
const result = await exports.getWmicNetworkDrivesOutput();
const couples: Array<[string, string]> = chain(result)
.split('\n')
// Remove header line
.slice(1)
// Remove extra spaces / tabs / carriage returns
.invokeMap(String.prototype.trim)
// Filter out empty lines
.compact()
.map((str: string): [string, string] => {
const colonPosition = str.indexOf(':');
if (colonPosition === -1) {
throw new Error(`Can't parse wmic output: ${result}`);
}
return [
str.slice(0, colonPosition + 1),
trim(str.slice(colonPosition + 1)),
];
})
.filter(couple => couple[1].length > 0)
.value();
return new Map(couples);
}
/**
* @summary Replaces network drive letter with network drive location in the provided filePath on Windows
*/
export async function replaceWindowsNetworkDriveLetter(
filePath: string,
): Promise<string> {
let result = filePath;
if (platform() === 'win32') {
const matches = /^([A-Z]+:)\\(.*)$/.exec(filePath);
if (matches !== null) {
const [, drive, relativePath] = matches;
const drives = await getWindowsNetworkDrives();
const location = drives.get(drive);
if (location !== undefined) {
result = `${location}\\${relativePath}`;
}
}
}
return result;
}

View File

@ -91,6 +91,7 @@
color: white;
height: 320px;
width: 100vw;
left: 0;
> * {
display: flex;

View File

@ -15,17 +15,14 @@
*/
import * as _ from 'lodash';
import * as propTypes from 'prop-types';
import * as React from 'react';
import styled from 'styled-components';
import * as driveConstraints from '../../../../shared/drive-constraints';
import * as utils from '../../../../shared/utils';
import * as DriveSelectorModal from '../../components/drive-selector/DriveSelectorModal.jsx';
import * as TargetSelector from '../../components/drive-selector/target-selector.jsx';
import * as SvgIcon from '../../components/svg-icon/svg-icon.jsx';
import * as selectionState from '../../models/selection-state';
import { DriveSelectorModal } from '../../components/drive-selector/DriveSelectorModal';
import { TargetSelector } from '../../components/drive-selector/target-selector';
import { SVGIcon } from '../../components/svg-icon/svg-icon';
import { getImage, getSelectedDrives } from '../../models/selection-state';
import * as settings from '../../models/settings';
import * as store from '../../models/store';
import { observe, store } from '../../models/store';
import * as analytics from '../../modules/analytics';
const StepBorder = styled.div<{
@ -48,18 +45,13 @@ const StepBorder = styled.div<{
const getDriveListLabel = () => {
return _.join(
_.map(selectionState.getSelectedDrives(), (drive: any) => {
_.map(getSelectedDrives(), (drive: any) => {
return `${drive.description} (${drive.displayName})`;
}),
'\n',
);
};
const getMemoizedSelectedDrives = utils.memoize(
selectionState.getSelectedDrives,
_.isEqual,
);
const shouldShowDrivesButton = () => {
return !settings.get('disableExplicitDriveSelection');
};
@ -67,19 +59,28 @@ const shouldShowDrivesButton = () => {
const getDriveSelectionStateSlice = () => ({
showDrivesButton: shouldShowDrivesButton(),
driveListLabel: getDriveListLabel(),
targets: getMemoizedSelectedDrives(),
targets: getSelectedDrives(),
image: getImage(),
});
interface DriveSelectorProps {
webviewShowing: boolean;
disabled: boolean;
nextStepDisabled: boolean;
hasDrive: boolean;
flashing: boolean;
}
export const DriveSelector = ({
webviewShowing,
disabled,
nextStepDisabled,
hasDrive,
flashing,
}: any) => {
}: DriveSelectorProps) => {
// TODO: inject these from redux-connector
const [
{ showDrivesButton, driveListLabel, targets },
{ showDrivesButton, driveListLabel, targets, image },
setStateSlice,
] = React.useState(getDriveSelectionStateSlice());
const [showDriveSelectorModal, setShowDriveSelectorModal] = React.useState(
@ -87,7 +88,7 @@ export const DriveSelector = ({
);
React.useEffect(() => {
return (store as any).observe(() => {
return observe(() => {
setStateSlice(getDriveSelectionStateSlice());
});
}, []);
@ -97,14 +98,14 @@ export const DriveSelector = ({
return (
<div className="box text-center relative">
{showStepConnectingLines && (
<React.Fragment>
<>
<StepBorder disabled={disabled} left />
<StepBorder disabled={nextStepDisabled} right />
</React.Fragment>
</>
)}
<div className="center-block">
<SvgIcon paths={['../../assets/drive.svg']} disabled={disabled} />
<SVGIcon paths={['../../assets/drive.svg']} disabled={disabled} />
</div>
<div className="space-vertical-large">
@ -112,22 +113,21 @@ export const DriveSelector = ({
disabled={disabled}
show={!hasDrive && showDrivesButton}
tooltip={driveListLabel}
selection={selectionState}
openDriveSelector={() => {
setShowDriveSelectorModal(true);
}}
reselectDrive={() => {
analytics.logEvent('Reselect drive', {
applicationSessionUuid: (store as any).getState().toJS()
applicationSessionUuid: store.getState().toJS()
.applicationSessionUuid,
flashingWorkflowUuid: (store as any).getState().toJS()
flashingWorkflowUuid: store.getState().toJS()
.flashingWorkflowUuid,
});
setShowDriveSelectorModal(true);
}}
flashing={flashing}
constraints={driveConstraints}
targets={targets}
image={image}
/>
</div>
@ -139,11 +139,3 @@ export const DriveSelector = ({
</div>
);
};
DriveSelector.propTypes = {
webviewShowing: propTypes.bool,
disabled: propTypes.bool,
nextStepDisabled: propTypes.bool,
hasDrive: propTypes.bool,
flashing: propTypes.bool,
};

View File

@ -20,15 +20,15 @@ import * as React from 'react';
import { Modal, Txt } from 'rendition';
import * as constraints from '../../../../shared/drive-constraints';
import * as messages from '../../../../shared/messages';
import * as DriveSelectorModal from '../../components/drive-selector/DriveSelectorModal.jsx';
import * as ProgressButton from '../../components/progress-button/progress-button.jsx';
import * as SvgIcon from '../../components/svg-icon/svg-icon.jsx';
import { DriveSelectorModal } from '../../components/drive-selector/DriveSelectorModal';
import { ProgressButton } from '../../components/progress-button/progress-button';
import { SVGIcon } from '../../components/svg-icon/svg-icon';
import * as availableDrives from '../../models/available-drives';
import * as flashState from '../../models/flash-state';
import * as selection from '../../models/selection-state';
import * as store from '../../models/store';
import { store } from '../../models/store';
import * as analytics from '../../modules/analytics';
import * as driveScanner from '../../modules/drive-scanner';
import { scanner as driveScanner } from '../../modules/drive-scanner';
import * as imageWriter from '../../modules/image-writer';
import * as progressStatus from '../../modules/progress-status';
import * as notification from '../../os/notification';
@ -78,7 +78,6 @@ const flashImageToDrive = async (goToSuccess: () => void) => {
return _.includes(devices, drive.device);
});
// eslint-disable-next-line no-magic-numbers
if (drives.length === 0 || flashState.isFlashing()) {
return '';
}
@ -93,14 +92,15 @@ const flashImageToDrive = async (goToSuccess: () => void) => {
await imageWriter.flash(image.path, drives);
if (!flashState.wasLastFlashCancelled()) {
const flashResults: any = flashState.getFlashResults();
notification.send('Flash complete!', {
body: messages.info.flashComplete(
notification.send(
'Flash complete!',
messages.info.flashComplete(
basename,
drives as any,
flashResults.results.devices,
),
icon: iconPath,
});
iconPath,
);
goToSuccess();
}
} catch (error) {
@ -109,10 +109,11 @@ const flashImageToDrive = async (goToSuccess: () => void) => {
return '';
}
notification.send('Oops! Looks like the flash failed.', {
body: messages.error.flashFailure(path.basename(image.path), drives),
icon: iconPath,
});
notification.send(
'Oops! Looks like the flash failed.',
messages.error.flashFailure(path.basename(image.path), drives),
iconPath,
);
let errorMessage = getErrorMessageFromCode(error.code);
if (!errorMessage) {
@ -145,30 +146,24 @@ const getProgressButtonLabel = () => {
return 'Flash!';
}
return progressStatus.fromFlashState(flashState.getFlashState());
// TODO: no any
return progressStatus.fromFlashState(flashState.getFlashState() as any);
};
const formatSeconds = (totalSeconds: number) => {
if (!totalSeconds && !_.isNumber(totalSeconds)) {
return '';
}
// eslint-disable-next-line no-magic-numbers
const minutes = Math.floor(totalSeconds / 60);
// eslint-disable-next-line no-magic-numbers
const seconds = Math.floor(totalSeconds - minutes * 60);
return `${minutes}m${seconds}s`;
};
export const Flash = ({
shouldFlashStepBeDisabled,
lastFlashErrorCode,
progressMessage,
goToSuccess,
}: any) => {
export const Flash = ({ shouldFlashStepBeDisabled, goToSuccess }: any) => {
const state: any = flashState.getFlashState();
const isFlashing = flashState.isFlashing();
const flashErrorCode = lastFlashErrorCode();
const flashErrorCode = flashState.getLastFlashErrorCode();
const [warningMessages, setWarningMessages] = React.useState<string[]>([]);
const [errorMessage, setErrorMessage] = React.useState('');
@ -192,10 +187,8 @@ export const Flash = ({
flashState.resetState();
if (shouldRetry) {
analytics.logEvent('Restart after failure', {
applicationSessionUuid: (store as any).getState().toJS()
.applicationSessionUuid,
flashingWorkflowUuid: (store as any).getState().toJS()
.flashingWorkflowUuid,
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
});
} else {
selection.clear();
@ -209,7 +202,6 @@ export const Flash = ({
return _.includes(devices, drive.device);
});
// eslint-disable-next-line no-magic-numbers
if (drives.length === 0 || flashState.isFlashing()) {
return;
}
@ -227,10 +219,10 @@ export const Flash = ({
};
return (
<React.Fragment>
<>
<div className="box text-center">
<div className="center-block">
<SvgIcon
<SVGIcon
paths={['../../assets/flash.svg']}
disabled={shouldFlashStepBeDisabled}
/>
@ -238,7 +230,6 @@ export const Flash = ({
<div className="space-vertical-large">
<ProgressButton
tabindex="3"
striped={state.type === 'verifying'}
active={isFlashing}
percentage={state.percentage}
@ -272,7 +263,7 @@ export const Flash = ({
<span className="target-status-dot"></span>
<span className="target-status-quantity">{state.failed}</span>
<span className="target-status-message">
{progressMessage.failed(state.failed)}{' '}
{messages.progress.failed(state.failed)}{' '}
</span>
</div>
</div>
@ -280,7 +271,6 @@ export const Flash = ({
</div>
</div>
{/* eslint-disable-next-line no-magic-numbers */}
{warningMessages && warningMessages.length > 0 && (
<Modal
width={400}
@ -318,6 +308,6 @@ export const Flash = ({
close={() => setShowDriveSelectorModal(false)}
></DriveSelectorModal>
)}
</React.Fragment>
</>
);
};

View File

@ -16,37 +16,37 @@
import { faCog, faQuestionCircle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import * as _ from 'lodash';
import * as path from 'path';
import * as React from 'react';
import { Button } from 'rendition';
import * as FeaturedProject from '../../components/featured-project/featured-project';
import * as ImageSelector from '../../components/image-selector/image-selector';
import * as ReducedFlashingInfos from '../../components/reduced-flashing-infos/reduced-flashing-infos';
import { FeaturedProject } from '../../components/featured-project/featured-project';
import FinishPage from '../../components/finish/finish';
import { ImageSelector } from '../../components/image-selector/image-selector';
import { ReducedFlashingInfos } from '../../components/reduced-flashing-infos/reduced-flashing-infos';
import { SafeWebview } from '../../components/safe-webview/safe-webview';
import { SettingsModal } from '../../components/settings/settings';
import * as SvgIcon from '../../components/svg-icon/svg-icon.jsx';
import { SVGIcon } from '../../components/svg-icon/svg-icon';
import * as flashState from '../../models/flash-state';
import * as selectionState from '../../models/selection-state';
import * as settings from '../../models/settings';
import * as store from '../../models/store';
import { observe } from '../../models/store';
import { open as openExternal } from '../../os/open-external/services/open-external';
import { ThemedProvider } from '../../styled-components';
import { colors } from '../../theme';
import * as middleEllipsis from '../../utils/middle-ellipsis';
import { middleEllipsis } from '../../utils/middle-ellipsis';
import * as messages from '../../../../shared/messages';
import { bytesToClosestUnit } from '../../../../shared/units';
import { DriveSelector } from './DriveSelector';
import { Flash } from './Flash';
const DEFAULT_SUPPORT_URL =
'https://github.com/balena-io/etcher/blob/master/SUPPORT.md';
const getDrivesTitle = (selection: any) => {
const drives = selection.getSelectedDrives();
function getDrivesTitle() {
const drives = selectionState.getSelectedDrives();
if (drives.length === 1) {
// @ts-ignore
return drives[0].description || 'Untitled Device';
}
@ -55,162 +55,208 @@ const getDrivesTitle = (selection: any) => {
}
return `${drives.length} Targets`;
};
}
const getImageBasename = (selection: any) => {
if (!selection.hasImage()) {
function getImageBasename() {
if (!selectionState.hasImage()) {
return '';
}
const selectionImageName = selection.getImageName();
const imageBasename = path.basename(selection.getImagePath());
const selectionImageName = selectionState.getImageName();
const imageBasename = path.basename(selectionState.getImagePath());
return selectionImageName || imageBasename;
};
}
const MainPage = ({ $state }: any) => {
const setRefresh = React.useState(false)[1];
const [isWebviewShowing, setIsWebviewShowing] = React.useState(false);
const [hideSettings, setHideSettings] = React.useState(true);
React.useEffect(() => {
return (store as any).observe(() => {
setRefresh(ref => !ref);
interface MainPageStateFromStore {
isFlashing: boolean;
hasImage: boolean;
hasDrive: boolean;
imageLogo: string;
imageSize: number;
imageName: string;
driveTitle: string;
}
interface MainPageState {
current: 'main' | 'success';
isWebviewShowing: boolean;
hideSettings: boolean;
}
export class MainPage extends React.Component<
{},
MainPageState & MainPageStateFromStore
> {
constructor(props: {}) {
super(props);
this.state = {
current: 'main',
isWebviewShowing: false,
hideSettings: true,
...this.stateHelper(),
};
}
private stateHelper(): MainPageStateFromStore {
return {
isFlashing: flashState.isFlashing(),
hasImage: selectionState.hasImage(),
hasDrive: selectionState.hasDrive(),
imageLogo: selectionState.getImageLogo(),
imageSize: selectionState.getImageSize(),
imageName: getImageBasename(),
driveTitle: getDrivesTitle(),
};
}
public componentDidMount() {
observe(() => {
this.setState(this.stateHelper());
});
}, []);
}
const setWebviewShowing = (isShowing: boolean) => {
setIsWebviewShowing(isShowing);
store.dispatch({
type: 'SET_WEBVIEW_SHOWING_STATUS',
data: Boolean(isShowing),
});
};
public render() {
const shouldDriveStepBeDisabled = !this.state.hasImage;
const shouldFlashStepBeDisabled =
!this.state.hasImage || !this.state.hasDrive;
const isFlashing = flashState.isFlashing();
const shouldDriveStepBeDisabled = !selectionState.hasImage();
const shouldFlashStepBeDisabled =
!selectionState.hasDrive() || shouldDriveStepBeDisabled;
const hasDrive = selectionState.hasDrive();
const imageLogo = selectionState.getImageLogo();
const imageSize = bytesToClosestUnit(selectionState.getImageSize());
const imageName = middleEllipsis(getImageBasename(selectionState), 16);
const driveTitle = middleEllipsis(getDrivesTitle(selectionState), 16);
const shouldShowFlashingInfos = isFlashing && isWebviewShowing;
const lastFlashErrorCode = flashState.getLastFlashErrorCode;
const progressMessage = messages.progress;
return (
<ThemedProvider style={{ height: '100%' }}>
<header
id="app-header"
style={{
width: '100%',
padding: '13px 14px',
textAlign: 'center',
}}
>
<span
style={{
cursor: 'pointer',
}}
onClick={() =>
openExternal('https://www.balena.io/etcher?ref=etcher_footer')
}
tabIndex={100}
>
<SvgIcon
paths={['../../assets/etcher.svg']}
width="123px"
height="22px"
></SvgIcon>
</span>
<span
style={{
float: 'right',
position: 'absolute',
right: 0,
}}
>
<Button
icon={<FontAwesomeIcon icon={faCog} />}
color={colors.secondary.background}
fontSize={24}
style={{ width: '30px' }}
plain
onClick={() => setHideSettings(false)}
tabIndex={5}
/>
{!settings.get('disableExternalLinks') && (
<Button
icon={<FontAwesomeIcon icon={faQuestionCircle} />}
color={colors.secondary.background}
fontSize={24}
style={{ width: '30px' }}
plain
if (this.state.current === 'main') {
return (
<ThemedProvider style={{ height: '100%', width: '100%' }}>
<header
id="app-header"
style={{
width: '100%',
padding: '13px 14px',
textAlign: 'center',
}}
>
<span
style={{
cursor: 'pointer',
}}
onClick={() =>
openExternal(
selectionState.getImageSupportUrl() || DEFAULT_SUPPORT_URL,
)
openExternal('https://www.balena.io/etcher?ref=etcher_footer')
}
tabIndex={5}
tabIndex={100}
>
<SVGIcon
paths={['../../assets/etcher.svg']}
width="123px"
height="22px"
/>
</span>
<span
style={{
float: 'right',
position: 'absolute',
right: 0,
}}
>
<Button
icon={<FontAwesomeIcon icon={faCog} />}
color={colors.secondary.background}
fontSize={24}
style={{ width: '30px' }}
plain
onClick={() => this.setState({ hideSettings: false })}
tabIndex={5}
/>
{!settings.get('disableExternalLinks') && (
<Button
icon={<FontAwesomeIcon icon={faQuestionCircle} />}
color={colors.secondary.background}
fontSize={24}
style={{ width: '30px' }}
plain
onClick={() =>
openExternal(
selectionState.getImageSupportUrl() ||
'https://github.com/balena-io/etcher/blob/master/SUPPORT.md',
)
}
tabIndex={5}
/>
)}
</span>
</header>
{this.state.hideSettings ? null : (
<SettingsModal
toggleModal={(value: boolean) => {
this.setState({ hideSettings: !value });
}}
/>
)}
</span>
</header>
{hideSettings ? null : (
<SettingsModal
toggleModal={(value: boolean) => {
setHideSettings(!value);
}}
/>
)}
<div className="page-main row around-xs" style={{ margin: '110px 50px' }}>
<div className="col-xs">
<ImageSelector flashing={isFlashing} />
</div>
<div className="col-xs">
<DriveSelector
webviewShowing={isWebviewShowing}
disabled={shouldDriveStepBeDisabled}
nextStepDisabled={shouldFlashStepBeDisabled}
hasDrive={hasDrive}
flashing={isFlashing}
/>
</div>
{isFlashing && (
<div
className={`featured-project ${
isFlashing && isWebviewShowing ? 'fp-visible' : ''
}`}
className="page-main row around-xs"
style={{ margin: '110px 50px' }}
>
<FeaturedProject onWebviewShow={setWebviewShowing} />
<div className="col-xs">
<ImageSelector flashing={this.state.isFlashing} />
</div>
<div className="col-xs">
<DriveSelector
webviewShowing={this.state.isWebviewShowing}
disabled={shouldDriveStepBeDisabled}
nextStepDisabled={shouldFlashStepBeDisabled}
hasDrive={this.state.hasDrive}
flashing={this.state.isFlashing}
/>
</div>
{this.state.isFlashing && (
<div
className={`featured-project ${
this.state.isFlashing && this.state.isWebviewShowing
? 'fp-visible'
: ''
}`}
>
<FeaturedProject
onWebviewShow={(isWebviewShowing: boolean) => {
this.setState({ isWebviewShowing });
}}
/>
</div>
)}
<div>
<ReducedFlashingInfos
imageLogo={this.state.imageLogo}
imageName={middleEllipsis(this.state.imageName, 16)}
imageSize={
_.isNumber(this.state.imageSize)
? (bytesToClosestUnit(this.state.imageSize) as string)
: ''
}
driveTitle={middleEllipsis(this.state.driveTitle, 16)}
shouldShow={
this.state.isFlashing && this.state.isWebviewShowing
}
/>
</div>
<div className="col-xs">
<Flash
goToSuccess={() => this.setState({ current: 'success' })}
shouldFlashStepBeDisabled={shouldFlashStepBeDisabled}
/>
</div>
</div>
)}
<div>
<ReducedFlashingInfos
imageLogo={imageLogo}
imageName={imageName}
imageSize={imageSize}
driveTitle={driveTitle}
shouldShow={shouldShowFlashingInfos}
/>
</ThemedProvider>
);
} else if (this.state.current === 'success') {
return (
<div className="section-loader isFinish">
<FinishPage goToMain={() => this.setState({ current: 'main' })} />
<SafeWebview src="https://www.balena.io/etcher/success-banner/" />
</div>
<div className="col-xs">
<Flash
goToSuccess={() => $state.go('success')}
shouldFlashStepBeDisabled={shouldFlashStepBeDisabled}
lastFlashErrorCode={lastFlashErrorCode}
progressMessage={progressMessage}
/>
</div>
</div>
</ThemedProvider>
);
};
);
}
}
}
export default MainPage;

View File

@ -1,51 +0,0 @@
/*
* Copyright 2019 balena.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 page represents the application main page.
*
* @module Etcher.Pages.Main
*/
import * as angular from 'angular';
// @ts-ignore
import * as angularRouter from 'angular-ui-router';
import { react2angular } from 'react2angular';
import MainPage from './MainPage';
import { MODULE_NAME as flashAnother } from '../../components/flash-another';
import { MODULE_NAME as flashResults } from '../../components/flash-results';
import * as byteSize from '../../utils/byte-size/byte-size';
export const MODULE_NAME = 'Etcher.Pages.Main';
const Main = angular.module(MODULE_NAME, [
angularRouter,
flashAnother,
flashResults,
byteSize,
]);
Main.component('mainPage', react2angular(MainPage, [], ['$state']));
Main.config(($stateProvider: any) => {
$stateProvider.state('main', {
url: '/main',
template: '<main-page style="width:100%"></main-page>',
});
});

View File

@ -31,9 +31,7 @@ $disabled-opacity: 0.2;
@import "./components/caption";
@import "./components/button";
@import "./components/tick";
@import "../components/modal/styles/modal";
@import "../components/drive-selector/styles/drive-selector";
@import "../components/svg-icon/styles/svg-icon";
@import "../pages/main/styles/main";
@import "../pages/finish/styles/finish";
@ -142,35 +140,6 @@ body {
}
}
.section-footer-main {
display: flex;
align-items: center;
justify-content: center;
position: relative;
color: $palette-theme-dark-disabled-foreground;
margin: 0 30px 16px 30px;
padding-top: 15px;
border-top: 2px solid $palette-theme-dark-soft-background;
text-align: center;
// Override default column padding
// set by flexboxgrid.
.col-xs {
padding: 0;
}
.svg-icon {
margin: 0 13px;
}
.footer-right {
font-size: 10px;
position: absolute;
right: 0;
}
}
.section-loader {
webview {
flex: 0 1;

View File

@ -1,123 +0,0 @@
/*
* Copyright 2018 balena.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'
// eslint-disable-next-line no-unused-vars
const React = require('react')
const { default: styled } = require('styled-components')
const {
Button,
Txt,
Flex,
Provider
} = require('rendition')
const {
space
} = require('styled-system')
const { colors } = require('./theme')
const theme = {
// TODO: Standardize how the colors are specified to match with rendition's format.
customColors: colors,
button: {
border: {
width: '0',
radius: '24px'
},
disabled: {
opacity: 1
},
extend: () => `
width: 200px;
height: 48px;
font-size: 16px;
&:disabled {
background-color: ${colors.dark.disabled.background};
color: ${colors.dark.disabled.foreground};
opacity: 1;
&:hover {
background-color: ${colors.dark.disabled.background};
color: ${colors.dark.disabled.foreground};
}
}
`
}
}
exports.ThemedProvider = (props) => (
<Provider theme={theme} {...props}>
</Provider>
)
const BaseButton = styled(Button) `
height: 48px;
`
exports.BaseButton = BaseButton
exports.StepButton = (props) => (
<BaseButton primary {...props}>
</BaseButton>
)
exports.ChangeButton = styled(BaseButton) `
color: ${colors.primary.background};
padding: 0;
width: 100%;
height: auto;
&:enabled {
&:hover, &:focus, &:active {
color: #8f9297;
}
}
${space}
`
exports.StepNameButton = styled(BaseButton) `
display: flex;
justify-content: center;
align-items: center;
width: 100%;
font-weight: bold;
color: ${colors.dark.foreground};
&:enabled {
&:hover, &:focus, &:active{
color: #8f9297;
}
}
`
exports.StepSelection = styled(Flex) `
flex-wrap: wrap;
justify-content: center;
`
exports.Footer = styled(Txt) `
margin-top: 10px;
color: ${colors.dark.disabled.foreground};
font-size: 10px;
`
exports.Underline = styled(Txt.span) `
border-bottom: 1px dotted;
padding-bottom: 2px;
`
exports.DetailsText = styled(Txt.p) `
color: ${colors.dark.disabled.foreground};
margin-bottom: 0;
`

View File

@ -1,71 +0,0 @@
/*
* Copyright 2018 balena.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'
exports.colors = {
dark: {
foreground: '#fff',
background: '#4d5057',
soft: {
foreground: '#ddd',
background: '#64686a'
},
disabled: {
foreground: '#787c7f',
background: '#3a3c41'
}
},
light: {
foreground: '#666',
background: '#fff',
soft: {
foreground: '#b3b3b3'
},
disabled: {
foreground: '#787c7f',
background: '#d5d5d5'
}
},
default: {
foreground: '#b3b3b3',
background: '#ececec'
},
primary: {
foreground: '#fff',
background: '#2297de'
},
secondary: {
foreground: '#000',
background: '#ddd'
},
warning: {
foreground: '#fff',
background: '#fca321'
},
danger: {
foreground: '#fff',
background: '#d9534f'
},
success: {
foreground: '#fff',
background: '#5fb835'
}
}
exports.consts = {
btnMaxWidth: '170px'
}

65
lib/gui/app/theme.ts Normal file
View File

@ -0,0 +1,65 @@
/*
* Copyright 2018 balena.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.
*/
export const colors = {
dark: {
foreground: '#fff',
background: '#4d5057',
soft: {
foreground: '#ddd',
background: '#64686a',
},
disabled: {
foreground: '#787c7f',
background: '#3a3c41',
},
},
light: {
foreground: '#666',
background: '#fff',
soft: {
foreground: '#b3b3b3',
},
disabled: {
foreground: '#787c7f',
background: '#d5d5d5',
},
},
default: {
foreground: '#b3b3b3',
background: '#ececec',
},
primary: {
foreground: '#fff',
background: '#2297de',
},
secondary: {
foreground: '#000',
background: '#ddd',
},
warning: {
foreground: '#fff',
background: '#fca321',
},
danger: {
foreground: '#fff',
background: '#d9534f',
},
success: {
foreground: '#fff',
background: '#5fb835',
},
};

View File

@ -1,68 +0,0 @@
/*
* Copyright 2016 Juan Cruz Viotti. https://github.com/jviotti
* Copyright 2018 balena.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'
/**
* @summary Truncate text from the middle with an ellipsis
* @public
* @function
*
* @param {String} input - input string
* @param {Number} limit - output limit
* @returns {String} truncated string
*
* @throws Will throw if `limit` < 3
*
* @example
* middleEllipsis('MyVeryLongString', 5)
* > 'My\u2026ng'
*/
module.exports = (input, limit) => {
const MIDDLE_ELLIPSIS_CHARACTER = '\u2026'
const MINIMUM_LENGTH = 3
// We can't provide a 100% expected result if the limit is less than 3. For example:
//
// If the limit == 2:
// Should we display the first at last character without an ellipses in the middle?
// Should we display just one character and an ellipses before or after?
// Should we display nothing at all?
//
// If the limit == 1:
// Should we display just one character?
// Should we display just an ellipses?
// Should we display nothing at all?
//
// Etc.
if (limit < MINIMUM_LENGTH) {
throw new Error('middleEllipsis: Limit should be at least 3')
}
// Do nothing, the string doesn't need truncation.
if (input.length <= limit) {
return input
}
/* eslint-disable no-magic-numbers */
const lengthOfTheSidesAfterTruncation = Math.floor((limit - 1) / 2)
const finalLeftPart = input.slice(0, lengthOfTheSidesAfterTruncation)
const finalRightPart = input.slice(input.length - lengthOfTheSidesAfterTruncation)
/* eslint-enable no-magic-numbers */
return finalLeftPart + MIDDLE_ELLIPSIS_CHARACTER + finalRightPart
}

View File

@ -0,0 +1,51 @@
/*
* Copyright 2016 Juan Cruz Viotti. https://github.com/jviotti
* Copyright 2018 balena.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.
*/
/**
* @summary Truncate text from the middle with an ellipsis
*/
export function middleEllipsis(input: string, limit: number): string {
// We can't provide a 100% expected result if the limit is less than 3. For example:
//
// If the limit == 2:
// Should we display the first at last character without an ellipses in the middle?
// Should we display just one character and an ellipses before or after?
// Should we display nothing at all?
//
// If the limit == 1:
// Should we display just one character?
// Should we display just an ellipses?
// Should we display nothing at all?
//
// Etc.
if (limit < 3) {
throw new Error('middleEllipsis: Limit should be at least 3');
}
// Do nothing, the string doesn't need truncation.
if (input.length <= limit) {
return input;
}
const lengthOfTheSidesAfterTruncation = Math.floor((limit - 1) / 2);
const finalLeftPart = input.slice(0, lengthOfTheSidesAfterTruncation);
const finalRightPart = input.slice(
input.length - lengthOfTheSidesAfterTruncation,
);
return finalLeftPart + '…' + finalRightPart;
}

View File

@ -6125,82 +6125,6 @@ body {
background-color: #d9534f;
border-color: #d9534f; }
/*
* Copyright 2016 balena.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.
*/
.modal-content {
background-color: #fff;
display: flex;
flex-direction: column;
margin: 0 auto;
height: auto;
overflow: hidden; }
.modal-header {
display: flex;
align-items: baseline;
font-size: 12px;
color: #b3b3b3;
padding: 11px 20px;
flex-grow: 0; }
.modal-title {
font-size: inherit;
flex-grow: 1; }
.modal-body {
flex-grow: 1;
color: #666;
padding: 20px;
max-height: 250px;
overflow: auto; }
.modal-body a {
color: #2297de; }
.modal-body > p {
white-space: pre-line; }
.modal-body > p:last-child {
margin-bottom: 0; }
.modal-menu {
display: flex; }
.modal-menu > * {
flex-basis: auto; }
.modal-open {
padding-right: 0 !important; }
.modal-backdrop.in {
opacity: 0; }
.modal-footer {
flex-grow: 0;
border: 0;
text-align: center; }
.modal {
display: flex !important;
justify-content: center;
align-items: center; }
.modal .button[disabled] {
background-color: #d5d5d5;
color: #787c7f; }
.modal-dialog {
margin: 0;
position: initial; }
/*
* Copyright 2016 balena.io
*
@ -6283,12 +6207,6 @@ body {
.modal-drive-selector-modal .word-keep {
word-break: keep-all; }
svg-icon {
display: inline-block; }
svg-icon img {
width: 100%;
height: 100%; }
/*
* Copyright 2016 balena.io
*
@ -6528,7 +6446,8 @@ img[disabled] {
bottom: 0;
color: white;
height: 320px;
width: 100vw; }
width: 100vw;
left: 0; }
.page-finish .fallback-banner > * {
display: flex;
justify-content: center;
@ -9822,25 +9741,6 @@ body {
body > footer {
flex: 0 0 auto; }
.section-footer-main {
display: flex;
align-items: center;
justify-content: center;
position: relative;
color: #787c7f;
margin: 0 30px 16px 30px;
padding-top: 15px;
border-top: 2px solid #64686a;
text-align: center; }
.section-footer-main .col-xs {
padding: 0; }
.section-footer-main .svg-icon {
margin: 0 13px; }
.section-footer-main .footer-right {
font-size: 10px;
position: absolute;
right: 0; }
.section-loader webview {
flex: 0 1;
height: 0;

View File

@ -1,172 +0,0 @@
/*
* Copyright 2016 balena.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 electron = require('electron')
const path = require('path')
const _ = require('lodash')
const { autoUpdater } = require('electron-updater')
const Bluebird = require('bluebird')
const semver = require('semver')
const EXIT_CODES = require('../shared/exit-codes')
const buildWindowMenu = require('./menu')
const settings = require('./app/models/settings')
const analytics = require('./app/modules/analytics')
const { getConfig } = require('../shared/utils')
const { version, packageType } = require('../../package.json')
/* eslint-disable lodash/prefer-lodash-method */
/* eslint-disable no-magic-numbers */
const config = settings.getDefaults()
const configUrl = settings.get('configUrl') || 'https://balena.io/etcher/static/config.json'
const updatablePackageTypes = [
'appimage',
'nsis',
'dmg'
]
const packageUpdatable = _.includes(updatablePackageTypes, packageType)
let packageUpdated = false
/**
*
* @param {Number} interval - interval to wait to check for updates
* @example checkForUpdates()
*/
const checkForUpdates = async (interval) => {
// We use a while loop instead of a setInterval to preserve
// async execution time between each function call
while (!packageUpdated) {
if (settings.get('updatesEnabled')) {
try {
const release = await autoUpdater.checkForUpdates()
const isOutdated = semver.compare(release.updateInfo.version, version) > 0
const shouldUpdate = parseInt(release.updateInfo.stagingPercentage, 10) > 0
if (shouldUpdate && isOutdated) {
await autoUpdater.downloadUpdate()
packageUpdated = true
}
} catch (err) {
analytics.logException(err)
}
}
await Bluebird.delay(interval)
}
}
/**
* @summary Create Etcher's main window
* @example
* electron.app.on('ready', createMainWindow)
*/
const createMainWindow = () => {
const mainWindow = new electron.BrowserWindow({
// eslint-disable-next-line no-magic-numbers
width: parseInt(config.width, 10) || 800,
// eslint-disable-next-line no-magic-numbers
height: parseInt(config.height, 10) || 480,
frame: !config.fullscreen,
useContentSize: false,
show: false,
resizable: false,
maximizable: false,
fullscreen: Boolean(config.fullscreen),
fullscreenable: Boolean(config.fullscreen),
kiosk: Boolean(config.fullscreen),
autoHideMenuBar: true,
titleBarStyle: 'hiddenInset',
icon: path.join(__dirname, '..', '..', 'assets', 'icon.png'),
darkTheme: true,
webPreferences: {
backgroundThrottling: false,
nodeIntegration: true,
webviewTag: true
}
})
buildWindowMenu(mainWindow)
// Prevent flash of white when starting the application
mainWindow.on('ready-to-show', () => {
console.timeEnd('ready-to-show')
mainWindow.show()
})
// Prevent external resources from being loaded (like images)
// when dropping them on the WebView.
// See https://github.com/electron/electron/issues/5919
mainWindow.webContents.on('will-navigate', (event) => {
event.preventDefault()
})
const dir = __dirname.split(path.sep).pop()
if (dir === 'generated') {
mainWindow.loadURL(`file://${path.join(__dirname, '..', 'lib', 'gui', 'app', 'index.html')}`)
} else {
mainWindow.loadURL(`file://${path.join(__dirname, 'app', 'index.html')}`)
}
const page = mainWindow.webContents
page.once('did-frame-finish-load', async () => {
autoUpdater.on('error', (err) => {
analytics.logException(err)
})
if (packageUpdatable) {
try {
const onlineConfig = await getConfig(configUrl)
const autoUpdaterConfig = _.get(onlineConfig, [ 'autoUpdates', 'autoUpdaterConfig' ], {
autoDownload: false
})
_.merge(autoUpdater, autoUpdaterConfig)
// eslint-disable-next-line no-magic-numbers
const checkForUpdatesTimer = _.get(onlineConfig, [ 'autoUpdates', 'checkForUpdatesTimer' ], 300000)
checkForUpdates(checkForUpdatesTimer)
} catch (err) {
analytics.logException(err)
}
}
})
}
electron.app.on('window-all-closed', electron.app.quit)
// Sending a `SIGINT` (e.g: Ctrl-C) to an Electron app that registers
// a `beforeunload` window event handler results in a disconnected white
// browser window in GNU/Linux and macOS.
// The `before-quit` Electron event is triggered in `SIGINT`, so we can
// make use of it to ensure the browser window is completely destroyed.
// See https://github.com/electron/electron/issues/5273
electron.app.on('before-quit', () => {
process.exit(EXIT_CODES.SUCCESS)
})
settings.load().then((localSettings) => {
Object.assign(config, localSettings)
}).catch((error) => {
// TODO: What do if loading the config fails?
console.error('Error loading settings:')
console.error(error)
}).finally(() => {
if (electron.app.isReady()) {
createMainWindow()
} else {
electron.app.on('ready', createMainWindow)
}
})
console.time('ready-to-show')

169
lib/gui/etcher.ts Normal file
View File

@ -0,0 +1,169 @@
/*
* Copyright 2016 balena.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.
*/
import { delay } from 'bluebird';
import * as electron from 'electron';
import { autoUpdater } from 'electron-updater';
import * as _ from 'lodash';
import * as path from 'path';
import * as semver from 'semver';
import { packageType, version } from '../../package.json';
import * as EXIT_CODES from '../shared/exit-codes';
import { getConfig } from '../shared/utils';
import * as settings from './app/models/settings';
import * as analytics from './app/modules/analytics';
import { buildWindowMenu } from './menu';
const config = settings.getDefaults();
const configUrl =
settings.get('configUrl') || 'https://balena.io/etcher/static/config.json';
const updatablePackageTypes = ['appimage', 'nsis', 'dmg'];
const packageUpdatable = _.includes(updatablePackageTypes, packageType);
let packageUpdated = false;
async function checkForUpdates(interval: number) {
// We use a while loop instead of a setInterval to preserve
// async execution time between each function call
while (!packageUpdated) {
if (settings.get('updatesEnabled')) {
try {
const release = await autoUpdater.checkForUpdates();
const isOutdated =
semver.compare(release.updateInfo.version, version) > 0;
const shouldUpdate = release.updateInfo.stagingPercentage || 0 > 0;
if (shouldUpdate && isOutdated) {
await autoUpdater.downloadUpdate();
packageUpdated = true;
}
} catch (err) {
analytics.logException(err);
}
}
await delay(interval);
}
}
function createMainWindow() {
const mainWindow = new electron.BrowserWindow({
width: parseInt(config.width, 10) || 800,
height: parseInt(config.height, 10) || 480,
frame: !config.fullscreen,
useContentSize: false,
show: false,
resizable: false,
maximizable: false,
fullscreen: Boolean(config.fullscreen),
fullscreenable: Boolean(config.fullscreen),
kiosk: Boolean(config.fullscreen),
autoHideMenuBar: true,
titleBarStyle: 'hiddenInset',
icon: path.join(__dirname, '..', '..', 'assets', 'icon.png'),
darkTheme: true,
webPreferences: {
backgroundThrottling: false,
nodeIntegration: true,
webviewTag: true,
},
});
buildWindowMenu(mainWindow);
// Prevent flash of white when starting the application
mainWindow.on('ready-to-show', () => {
console.timeEnd('ready-to-show');
mainWindow.show();
});
// Prevent external resources from being loaded (like images)
// when dropping them on the WebView.
// See https://github.com/electron/electron/issues/5919
mainWindow.webContents.on('will-navigate', event => {
event.preventDefault();
});
const dir = __dirname.split(path.sep).pop();
if (dir === 'generated') {
mainWindow.loadURL(
`file://${path.join(__dirname, '..', 'lib', 'gui', 'app', 'index.html')}`,
);
} else {
mainWindow.loadURL(`file://${path.join(__dirname, 'app', 'index.html')}`);
}
const page = mainWindow.webContents;
page.once('did-frame-finish-load', async () => {
autoUpdater.on('error', err => {
analytics.logException(err);
});
if (packageUpdatable) {
try {
const onlineConfig = await getConfig(configUrl);
const autoUpdaterConfig = _.get(
onlineConfig,
['autoUpdates', 'autoUpdaterConfig'],
{
autoDownload: false,
},
);
_.merge(autoUpdater, autoUpdaterConfig);
const checkForUpdatesTimer = _.get(
onlineConfig,
['autoUpdates', 'checkForUpdatesTimer'],
300000,
);
checkForUpdates(checkForUpdatesTimer);
} catch (err) {
analytics.logException(err);
}
}
});
}
electron.app.on('window-all-closed', electron.app.quit);
// Sending a `SIGINT` (e.g: Ctrl-C) to an Electron app that registers
// a `beforeunload` window event handler results in a disconnected white
// browser window in GNU/Linux and macOS.
// The `before-quit` Electron event is triggered in `SIGINT`, so we can
// make use of it to ensure the browser window is completely destroyed.
// See https://github.com/electron/electron/issues/5273
electron.app.on('before-quit', () => {
process.exit(EXIT_CODES.SUCCESS);
});
async function main(): Promise<void> {
try {
const localSettings = await settings.load();
Object.assign(config, localSettings);
} catch (error) {
// TODO: What do if loading the config fails?
console.error('Error loading settings:');
console.error(error);
} finally {
if (electron.app.isReady()) {
createMainWindow();
} else {
electron.app.on('ready', createMainWindow);
}
}
}
main();
console.time('ready-to-show');

View File

@ -1,129 +0,0 @@
/*
* Copyright 2017 balena.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 electron = require('electron')
const packageJson = require('../../package.json')
/* eslint-disable no-magic-numbers */
/**
* @summary Builds a native application menu for a given window
* @param {electron.BrowserWindow} window - BrowserWindow instance
* @example
* buildWindowMenu(mainWindow)
*/
const buildWindowMenu = (window) => {
/**
* @summary Toggle the main window's devtools
* @example
* toggleDevTools()
*/
const toggleDevTools = () => {
if (!window) {
return
}
// NOTE: We can't use `webContents.toggleDevTools()` here,
// as we need to force detached mode
if (window.webContents.isDevToolsOpened()) {
window.webContents.closeDevTools()
} else {
window.webContents.openDevTools({
mode: 'detach'
})
}
}
const menuTemplate = [
{
role: 'editMenu'
},
{
label: 'View',
submenu: [
{
label: 'Toggle Developer Tools',
accelerator: process.platform === 'darwin'
? 'Command+Alt+I' : 'Control+Shift+I',
click: toggleDevTools
}
]
},
{
role: 'windowMenu'
},
{
role: 'help',
submenu: [
{
label: 'Etcher Pro',
click () {
electron.shell.openExternal('https://etcher.io/pro?utm_source=etcher_menu&ref=etcher_menu')
}
},
{
label: 'Etcher Website',
click () {
electron.shell.openExternal('https://etcher.io?ref=etcher_menu')
}
},
{
label: 'Report an issue',
click () {
electron.shell.openExternal('https://github.com/balena-io/etcher/issues')
}
}
]
}
]
if (process.platform === 'darwin') {
menuTemplate.unshift({
label: packageJson.displayName,
submenu: [ {
role: 'about',
label: 'About Etcher'
}, {
type: 'separator'
}, {
role: 'hide'
}, {
role: 'hideothers'
}, {
role: 'unhide'
}, {
type: 'separator'
}, {
role: 'quit'
} ]
})
} else {
menuTemplate.unshift({
label: packageJson.displayName,
submenu: [ {
role: 'quit'
} ]
})
}
const menu = electron.Menu.buildFromTemplate(menuTemplate)
electron.Menu.setApplicationMenu(menu)
}
module.exports = buildWindowMenu

131
lib/gui/menu.ts Normal file
View File

@ -0,0 +1,131 @@
/*
* Copyright 2017 balena.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.
*/
import * as electron from 'electron';
import { displayName } from '../../package.json';
/**
* @summary Builds a native application menu for a given window
*/
export function buildWindowMenu(window: electron.BrowserWindow) {
/**
* @summary Toggle the main window's devtools
*/
function toggleDevTools() {
if (!window) {
return;
}
// NOTE: We can't use `webContents.toggleDevTools()` here,
// as we need to force detached mode
if (window.webContents.isDevToolsOpened()) {
window.webContents.closeDevTools();
} else {
window.webContents.openDevTools({
mode: 'detach',
});
}
}
const menuTemplate: electron.MenuItemConstructorOptions[] = [
{
role: 'editMenu',
},
{
label: 'View',
submenu: [
{
label: 'Toggle Developer Tools',
accelerator:
process.platform === 'darwin' ? 'Command+Alt+I' : 'Control+Shift+I',
click: toggleDevTools,
},
],
},
{
role: 'windowMenu',
},
{
role: 'help',
submenu: [
{
label: 'Etcher Pro',
click() {
electron.shell.openExternal(
'https://etcher.io/pro?utm_source=etcher_menu&ref=etcher_menu',
);
},
},
{
label: 'Etcher Website',
click() {
electron.shell.openExternal('https://etcher.io?ref=etcher_menu');
},
},
{
label: 'Report an issue',
click() {
electron.shell.openExternal(
'https://github.com/balena-io/etcher/issues',
);
},
},
],
},
];
if (process.platform === 'darwin') {
menuTemplate.unshift({
label: displayName,
submenu: [
{
role: 'about' as const,
label: 'About Etcher',
},
{
type: 'separator' as const,
},
{
role: 'hide' as const,
},
{
role: 'hideOthers' as const,
},
{
role: 'unhide' as const,
},
{
type: 'separator' as const,
},
{
role: 'quit' as const,
},
],
});
} else {
menuTemplate.unshift({
label: displayName,
submenu: [
{
role: 'quit',
},
],
});
}
const menu = electron.Menu.buildFromTemplate(menuTemplate);
electron.Menu.setApplicationMenu(menu);
}

View File

@ -1,242 +0,0 @@
/*
* Copyright 2017 balena.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 _ = require('lodash')
const ipc = require('node-ipc')
const sdk = require('etcher-sdk')
const EXIT_CODES = require('../../shared/exit-codes')
const errors = require('../../shared/errors')
ipc.config.id = process.env.IPC_CLIENT_ID
ipc.config.socketRoot = process.env.IPC_SOCKET_ROOT
// NOTE: Ensure this isn't disabled, as it will cause
// the stdout maxBuffer size to be exceeded when flashing
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 this process as well.
ipc.config.stopRetrying = 0
const DISCONNECT_DELAY = 100
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.nextTick(() => {
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 = async (error) => {
ipc.of[IPC_SERVER_ID].emit('error', errors.toJSON(error))
await Bluebird.delay(DISCONNECT_DELAY)
terminate(EXIT_CODES.GENERAL_ERROR)
}
/**
* @summary writes the source to the destinations and valiates the writes
* @param {SourceDestination} source - source
* @param {SourceDestination[]} destinations - destinations
* @param {Boolean} verify - whether to validate the writes or not
* @param {Boolean} trim - whether to trim ext partitions before writing
* @param {Function} onProgress - function to call on progress
* @param {Function} onFail - function to call on fail
* @returns {Promise<{ bytesWritten, devices, errors} >}
*
* @example
* writeAndValidate(source, destinations, verify, onProgress, onFail, onFinish, onError)
*/
const writeAndValidate = async (source, destinations, verify, trim, onProgress, onFail) => {
let innerSource = await source.getInnerSource()
if (trim && (await innerSource.canRead())) {
innerSource = new sdk.sourceDestination.ConfiguredSource(
innerSource,
trim,
// Create stream from file-disk (not source stream)
true
)
}
const { failures, bytesWritten } = await sdk.multiWrite.pipeSourceToDestinations(
innerSource,
destinations,
onFail,
onProgress,
verify
)
const result = {
bytesWritten,
devices: {
failed: failures.size,
successful: destinations.length - failures.size
},
errors: []
}
for (const [ destination, error ] of failures) {
error.device = destination.drive.device
result.errors.push(error)
}
return result
}
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('write', async (options) => {
/**
* @summary Progress handler
* @param {Object} state - progress state
* @example
* writer.on('progress', onProgress)
*/
const onProgress = (state) => {
ipc.of[IPC_SERVER_ID].emit('state', state)
}
let exitCode = EXIT_CODES.SUCCESS
/**
* @summary Abort handler
* @example
* writer.on('abort', onAbort)
*/
const onAbort = async () => {
log('Abort')
ipc.of[IPC_SERVER_ID].emit('abort')
await Bluebird.delay(DISCONNECT_DELAY)
terminate(exitCode)
}
ipc.of[IPC_SERVER_ID].on('cancel', onAbort)
/**
* @summary Failure handler (non-fatal errors)
* @param {SourceDestination} destination - destination
* @param {Error} error - error
* @example
* writer.on('fail', onFail)
*/
const onFail = (destination, error) => {
ipc.of[IPC_SERVER_ID].emit('fail', {
// TODO: device should be destination
device: destination.drive,
error: errors.toJSON(error)
})
}
const destinations = _.map(options.destinations, 'device')
log(`Image: ${options.imagePath}`)
log(`Devices: ${destinations.join(', ')}`)
log(`Umount on success: ${options.unmountOnSuccess}`)
log(`Validate on success: ${options.validateWriteOnSuccess}`)
log(`Trim: ${options.trim}`)
const dests = _.map(options.destinations, (destination) => {
return new sdk.sourceDestination.BlockDevice(destination, options.unmountOnSuccess)
})
const source = new sdk.sourceDestination.File(options.imagePath, sdk.sourceDestination.File.OpenFlags.Read)
try {
const results = await writeAndValidate(
source,
dests,
options.validateWriteOnSuccess,
options.trim,
onProgress,
onFail
)
log(`Finish: ${results.bytesWritten}`)
results.errors = _.map(results.errors, (error) => {
return errors.toJSON(error)
})
ipc.of[IPC_SERVER_ID].emit('done', { results })
await Bluebird.delay(DISCONNECT_DELAY)
terminate(exitCode)
} catch (error) {
log(`Error: ${error.message}`)
exitCode = EXIT_CODES.GENERAL_ERROR
ipc.of[IPC_SERVER_ID].emit('error', errors.toJSON(error))
}
})
ipc.of[IPC_SERVER_ID].on('connect', () => {
log(`Successfully connected to IPC server: ${IPC_SERVER_ID}, socket root ${ipc.config.socketRoot}`)
ipc.of[IPC_SERVER_ID].emit('ready', {})
})
})

View File

@ -0,0 +1,260 @@
/*
* Copyright 2017 balena.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.
*/
import { delay } from 'bluebird';
import { Drive as DrivelistDrive } from 'drivelist';
import * as sdk from 'etcher-sdk';
import * as _ from 'lodash';
import * as ipc from 'node-ipc';
import { toJSON } from '../../shared/errors';
import { GENERAL_ERROR, SUCCESS } from '../../shared/exit-codes';
ipc.config.id = process.env.IPC_CLIENT_ID as string;
ipc.config.socketRoot = process.env.IPC_SOCKET_ROOT as string;
// NOTE: Ensure this isn't disabled, as it will cause
// the stdout maxBuffer size to be exceeded when flashing
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 this process as well.
// @ts-ignore (0 is a valid value for stopRetrying and is not the same as false)
ipc.config.stopRetrying = 0;
const DISCONNECT_DELAY = 100;
const IPC_SERVER_ID = process.env.IPC_SERVER_ID as string;
/**
* @summary Send a log debug message to the IPC server
*/
function log(message: string) {
ipc.of[IPC_SERVER_ID].emit('log', message);
}
/**
* @summary Terminate the child writer process
*/
function terminate(exitCode: number) {
ipc.disconnect(IPC_SERVER_ID);
process.nextTick(() => {
process.exit(exitCode || SUCCESS);
});
}
/**
* @summary Handle a child writer error
*/
async function handleError(error: Error) {
ipc.of[IPC_SERVER_ID].emit('error', toJSON(error));
await delay(DISCONNECT_DELAY);
terminate(GENERAL_ERROR);
}
interface WriteResult {
bytesWritten: number;
devices: {
failed: number;
successful: number;
};
errors: Array<Error & { device: string }>;
}
/**
* @summary writes the source to the destinations and valiates the writes
* @param {SourceDestination} source - source
* @param {SourceDestination[]} destinations - destinations
* @param {Boolean} verify - whether to validate the writes or not
* @param {Boolean} trim - whether to trim ext partitions before writing
* @param {Function} onProgress - function to call on progress
* @param {Function} onFail - function to call on fail
* @returns {Promise<{ bytesWritten, devices, errors} >}
*/
async function writeAndValidate(
source: sdk.sourceDestination.SourceDestination,
destinations: sdk.sourceDestination.BlockDevice[],
verify: boolean,
trim: boolean,
onProgress: sdk.multiWrite.OnProgressFunction,
onFail: sdk.multiWrite.OnFailFunction,
): Promise<WriteResult> {
let innerSource: sdk.sourceDestination.SourceDestination = await source.getInnerSource();
if (trim && (await innerSource.canRead())) {
// @ts-ignore FIXME: ts thinks that SparseReadStream can't be assigned to SparseReadable (which it implements)
innerSource = new sdk.sourceDestination.ConfiguredSource(
innerSource,
trim,
// Create stream from file-disk (not source stream)
true,
);
}
const {
failures,
bytesWritten,
} = await sdk.multiWrite.pipeSourceToDestinations(
innerSource,
// @ts-ignore FIXME: ts thinks that BlockWriteStream can't be assigned to WritableStream (which it implements)
destinations,
onFail,
onProgress,
verify,
);
const result: WriteResult = {
bytesWritten,
devices: {
failed: failures.size,
successful: destinations.length - failures.size,
},
errors: [],
};
for (const [destination, error] of failures) {
(error as (Error & { device: string })).device = destination.drive.device;
result.errors.push(error);
}
return result;
}
interface WriteOptions {
imagePath: string;
destinations: DrivelistDrive[];
unmountOnSuccess: boolean;
validateWriteOnSuccess: boolean;
trim: boolean;
}
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(SUCCESS);
});
process.once('SIGTERM', () => {
terminate(SUCCESS);
});
// The IPC server failed. Abort.
ipc.of[IPC_SERVER_ID].on('error', () => {
terminate(SUCCESS);
});
// The IPC server was disconnected. Abort.
ipc.of[IPC_SERVER_ID].on('disconnect', () => {
terminate(SUCCESS);
});
ipc.of[IPC_SERVER_ID].on('write', async (options: WriteOptions) => {
/**
* @summary Progress handler
* @param {Object} state - progress state
* @example
* writer.on('progress', onProgress)
*/
const onProgress = (state: sdk.multiWrite.MultiDestinationProgress) => {
ipc.of[IPC_SERVER_ID].emit('state', state);
};
let exitCode = SUCCESS;
/**
* @summary Abort handler
* @example
* writer.on('abort', onAbort)
*/
const onAbort = async () => {
log('Abort');
ipc.of[IPC_SERVER_ID].emit('abort');
await delay(DISCONNECT_DELAY);
terminate(exitCode);
};
ipc.of[IPC_SERVER_ID].on('cancel', onAbort);
/**
* @summary Failure handler (non-fatal errors)
* @param {SourceDestination} destination - destination
* @param {Error} error - error
* @example
* writer.on('fail', onFail)
*/
const onFail = (
destination: sdk.sourceDestination.BlockDevice,
error: Error,
) => {
ipc.of[IPC_SERVER_ID].emit('fail', {
// TODO: device should be destination
// @ts-ignore (destination.drive is private)
device: destination.drive,
error: toJSON(error),
});
};
const destinations = _.map(options.destinations, 'device');
log(`Image: ${options.imagePath}`);
log(`Devices: ${destinations.join(', ')}`);
log(`Umount on success: ${options.unmountOnSuccess}`);
log(`Validate on success: ${options.validateWriteOnSuccess}`);
log(`Trim: ${options.trim}`);
const dests = _.map(options.destinations, destination => {
return new sdk.sourceDestination.BlockDevice(
destination,
options.unmountOnSuccess,
);
});
const source = new sdk.sourceDestination.File(
options.imagePath,
sdk.sourceDestination.File.OpenFlags.Read,
);
try {
const results = await writeAndValidate(
// @ts-ignore FIXME: ts thinks that SparseWriteStream can't be assigned to SparseWritable (which it implements)
source,
dests,
options.validateWriteOnSuccess,
options.trim,
onProgress,
onFail,
);
log(`Finish: ${results.bytesWritten}`);
results.errors = _.map(results.errors, error => {
return toJSON(error);
});
ipc.of[IPC_SERVER_ID].emit('done', { results });
await delay(DISCONNECT_DELAY);
terminate(exitCode);
} catch (error) {
log(`Error: ${error.message}`);
exitCode = GENERAL_ERROR;
ipc.of[IPC_SERVER_ID].emit('error', toJSON(error));
}
});
ipc.of[IPC_SERVER_ID].on('connect', () => {
log(
`Successfully connected to IPC server: ${IPC_SERVER_ID}, socket root ${ipc.config.socketRoot}`,
);
ipc.of[IPC_SERVER_ID].emit('ready', {});
});
});

View File

@ -1,7 +1,5 @@
#!/usr/bin/env osascript -l JavaScript
/* eslint-disable */
ObjC.import('stdlib')
const app = Application.currentApplication()

View File

@ -1,55 +0,0 @@
'use strict'
const { execFile } = require('child_process')
const { argv, env } = require('process')
const { join } = require('path')
const { promisify } = require('util')
const execFileAsync = promisify(execFile)
const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED'
const EXPECTED_SUCCESSFUL_AUTH_MARKER = `${SUCCESSFUL_AUTH_MARKER}\n`
/* eslint-disable-next-line require-jsdoc */
const getAppPath = () => {
for (const arg of argv) {
/* eslint-disable-next-line lodash/prefer-lodash-method */
const [ option, value ] = arg.split('=')
if (option === '--app-path') {
return value
}
}
/* eslint-disable-next-line quotes */
throw new Error("Couldn't find --app-path= in argv")
}
exports.sudo = async (command) => {
try {
const { stdout, stderr } = await execFileAsync(
'sudo',
[ '--askpass', 'sh', '-c', `echo ${SUCCESSFUL_AUTH_MARKER} && ${command}` ],
{
encoding: 'utf8',
env: {
PATH: env.PATH,
SUDO_ASKPASS: join(getAppPath(), __dirname, 'sudo-askpass.osascript.js')
}
}
)
return {
cancelled: false,
stdout: stdout.slice(EXPECTED_SUCCESSFUL_AUTH_MARKER.length),
stderr
}
} catch (error) {
/* eslint-disable-next-line no-magic-numbers */
if (error.code === 1) {
/* eslint-disable-next-line lodash/prefer-lodash-method */
if (!error.stdout.startsWith(EXPECTED_SUCCESSFUL_AUTH_MARKER)) {
return { cancelled: true }
}
error.stdout = error.stdout.slice(EXPECTED_SUCCESSFUL_AUTH_MARKER.length)
}
throw error
}
}

View File

@ -0,0 +1,70 @@
/*
* Copyright 2019 balena.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.
*/
import { execFile } from 'child_process';
import { join } from 'path';
import { argv, env } from 'process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED';
const EXPECTED_SUCCESSFUL_AUTH_MARKER = `${SUCCESSFUL_AUTH_MARKER}\n`;
function getAppPath() {
for (const arg of argv) {
const [option, value] = arg.split('=');
if (option === '--app-path') {
return value;
}
}
throw new Error("Couldn't find --app-path= in argv");
}
export async function sudo(
command: string,
): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> {
try {
const { stdout, stderr } = await execFileAsync(
'sudo',
['--askpass', 'sh', '-c', `echo ${SUCCESSFUL_AUTH_MARKER} && ${command}`],
{
encoding: 'utf8',
env: {
PATH: env.PATH,
SUDO_ASKPASS: join(
getAppPath(),
__dirname,
'sudo-askpass.osascript.js',
),
},
},
);
return {
cancelled: false,
stdout: stdout.slice(EXPECTED_SUCCESSFUL_AUTH_MARKER.length),
stderr,
};
} catch (error) {
if (error.code === 1) {
if (!error.stdout.startsWith(EXPECTED_SUCCESSFUL_AUTH_MARKER)) {
return { cancelled: true };
}
error.stdout = error.stdout.slice(EXPECTED_SUCCESSFUL_AUTH_MARKER.length);
}
throw error;
}
}

View File

@ -1,474 +0,0 @@
/*
* Copyright 2016 balena.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')
const pathIsInside = require('path-is-inside')
const prettyBytes = require('pretty-bytes')
const messages = require('./messages')
/**
* @summary The default unknown size for things such as images and drives
* @constant
* @private
* @type {Number}
*/
const UNKNOWN_SIZE = 0
/**
* @summary Check if a drive is locked
* @function
* @public
*
* @description
* This usually points out a locked SD Card.
*
* @param {Object} drive - drive
* @returns {Boolean} whether the drive is locked
*
* @example
* if (constraints.isDriveLocked({
* device: '/dev/disk2',
* name: 'My Drive',
* size: 123456789,
* isReadOnly: true
* })) {
* console.log('This drive is locked (e.g: write-protected)');
* }
*/
exports.isDriveLocked = (drive) => {
return Boolean(_.get(drive, [ 'isReadOnly' ], false))
}
/**
* @summary Check if a drive is a system drive
* @function
* @public
* @param {Object} drive - drive
* @returns {Boolean} whether the drive is a system drive
*
* @example
* if (constraints.isSystemDrive({
* device: '/dev/disk2',
* name: 'My Drive',
* size: 123456789,
* isReadOnly: true,
* system: true
* })) {
* console.log('This drive is a system drive!');
* }
*/
exports.isSystemDrive = (drive) => {
return Boolean(_.get(drive, [ 'isSystem' ], false))
}
/**
* @summary Check if a drive is source drive
* @function
* @public
*
* @description
* In the context of Etcher, a source drive is a drive
* containing the image.
*
* @param {Object} drive - drive
* @param {Object} image - image
* @returns {Boolean} whether the drive is a source drive
*
*
* @example
* if (constraints.isSourceDrive({
* device: '/dev/disk2',
* name: 'My Drive',
* size: 123456789,
* isReadOnly: true,
* system: true,
* mountpoints: [
* {
* path: '/Volumes/Untitled'
* }
* ]
* }, {
* path: '/Volumes/Untitled/image.img',
* size: 1000000000,
* compressedSize: 1000000000,
* isSizeEstimated: false,
* })) {
* console.log('This drive is a source drive!');
* }
*/
exports.isSourceDrive = (drive, image) => {
const mountpoints = _.get(drive, [ 'mountpoints' ], [])
const imagePath = _.get(image, [ 'path' ])
if (!imagePath || _.isEmpty(mountpoints)) {
return false
}
return _.some(_.map(mountpoints, (mountpoint) => {
return pathIsInside(imagePath, mountpoint.path)
}))
}
/**
* @summary Check if a drive is large enough for an image
* @function
* @public
*
* @param {Object} drive - drive
* @param {Object} image - image
* @returns {Boolean} whether the drive is large enough
*
* @example
* if (constraints.isDriveLargeEnough({
* device: '/dev/disk2',
* name: 'My Drive',
* size: 1000000000
* }, {
* path: 'rpi.img',
* size: 1000000000,
* compressedSize: 1000000000,
* isSizeEstimated: false,
* })) {
* console.log('We can flash the image to this drive!');
* }
*/
exports.isDriveLargeEnough = (drive, image) => {
const driveSize = _.get(drive, [ 'size' ], UNKNOWN_SIZE)
if (_.get(image, [ 'isSizeEstimated' ])) {
// If the drive size is smaller than the original image size, and
// the final image size is just an estimation, then we stop right
// here, based on the assumption that the final size will never
// be less than the original size.
if (driveSize < _.get(image, [ 'compressedSize' ], UNKNOWN_SIZE)) {
return false
}
// If the final image size is just an estimation then consider it
// large enough. In the worst case, the user gets an error saying
// the drive has ran out of space, instead of prohibiting the flash
// at all, when the estimation may be wrong.
return true
}
return driveSize >= _.get(image, [ 'size' ], UNKNOWN_SIZE)
}
/**
* @summary Check if a drive is disabled (i.e. not ready for selection)
* @function
* @public
*
* @param {Object} drive - drive
* @returns {Boolean} whether the drive is disabled
*
* @example
* if (constraints.isDriveDisabled({
* device: '/dev/disk2',
* name: 'My Drive',
* size: 1000000000,
* disabled: true
* })) {
* console.log('The drive is disabled');
* }
*/
exports.isDriveDisabled = (drive) => {
return _.get(drive, [ 'disabled' ], false)
}
/**
* @summary Check if a drive is valid, i.e. not locked and large enough for an image
* @function
* @public
*
* @param {Object} drive - drive
* @param {Object} image - image
* @returns {Boolean} whether the drive is valid
*
* @example
* if (constraints.isDriveValid({
* device: '/dev/disk2',
* name: 'My Drive',
* size: 1000000000,
* isReadOnly: false
* }, {
* path: 'rpi.img',
* size: 1000000000,
* compressedSize: 1000000000,
* isSizeEstimated: false,
* recommendedDriveSize: 2000000000
* })) {
* console.log('This drive is valid!');
* }
*/
exports.isDriveValid = (drive, image) => {
return !this.isDriveLocked(drive) &&
this.isDriveLargeEnough(drive, image) &&
!this.isSourceDrive(drive, image) &&
!this.isDriveDisabled(drive)
}
/**
* @summary Check if a drive meets the recommended drive size suggestion
* @function
* @public
*
* @description
* If the image doesn't have a recommended size, this function returns true.
*
* @param {Object} drive - drive
* @param {Object} image - image
* @returns {Boolean} whether the drive size is recommended
*
* @example
* const drive = {
* device: '/dev/disk2',
* name: 'My Drive',
* size: 4000000000
* };
*
* const image = {
* path: 'rpi.img',
* size: 2000000000,
* compressedSize: 2000000000,
* isSizeEstimated: false,
* recommendedDriveSize: 4000000000
* });
*
* if (constraints.isDriveSizeRecommended(drive, image)) {
* console.log('We meet the recommended drive size!');
* }
*/
exports.isDriveSizeRecommended = (drive, image) => {
return _.get(drive, [ 'size' ], UNKNOWN_SIZE) >= _.get(image, [ 'recommendedDriveSize' ], UNKNOWN_SIZE)
}
/**
* @summary 64GB
* @private
* @constant
*/
exports.LARGE_DRIVE_SIZE = 64e9
/**
* @summary Check whether a drive's size is 'large'
* @public
*
* @param {Object} drive - drive
* @returns {Boolean} whether drive size is large
*
* @example
* if (constraints.isDriveSizeLarge(drive)) {
* console.log('Impressive')
* }
*/
exports.isDriveSizeLarge = (drive) => {
return _.get(drive, [ 'size' ], UNKNOWN_SIZE) > exports.LARGE_DRIVE_SIZE
}
/**
* @summary Drive/image compatibility status types.
* @public
* @type {Object}
*
* @description
* Status types classifying what kind of message it is, i.e. error, warning.
*/
exports.COMPATIBILITY_STATUS_TYPES = {
WARNING: 1,
ERROR: 2
}
/**
* @summary Get drive/image compatibility in an object
* @function
* @public
*
* @description
* Given an image and a drive, return their compatibility status object
* containing the status type (ERROR, WARNING), and accompanying
* status message.
*
* @param {Object} drive - drive
* @param {Object} image - image
* @returns {Object[]} list of compatibility status objects
*
* @example
* const drive = {
* device: '/dev/disk2',
* name: 'My Drive',
* size: 4000000000
* };
*
* const image = {
* path: '/path/to/rpi.img',
* size: 2000000000,
* compressedSize: 2000000000,
* isSizeEstimated: false,
* recommendedDriveSize: 4000000000
* });
*
* const statuses = constraints.getDriveImageCompatibilityStatuses(drive, image);
*
* for ({ type, message } of statuses) {
* if (type === constraints.COMPATIBILITY_STATUS_TYPES.WARNING) {
* // do something
* } else if (type === constraints.COMPATIBILITY_STATUS_TYPES.ERROR) {
* // do something else
* }
* }
*/
exports.getDriveImageCompatibilityStatuses = (drive, image) => {
const statusList = []
// Mind the order of the if-statements if you modify.
if (exports.isSourceDrive(drive, image)) {
statusList.push({
type: exports.COMPATIBILITY_STATUS_TYPES.ERROR,
message: messages.compatibility.containsImage()
})
} else if (exports.isDriveLocked(drive)) {
statusList.push({
type: exports.COMPATIBILITY_STATUS_TYPES.ERROR,
message: messages.compatibility.locked()
})
} else if (!_.isNil(drive) && !_.isNil(drive.size) && !exports.isDriveLargeEnough(drive, image)) {
const imageSize = image.isSizeEstimated ? image.compressedSize : image.size
const relativeBytes = imageSize - drive.size
statusList.push({
type: exports.COMPATIBILITY_STATUS_TYPES.ERROR,
message: messages.compatibility.tooSmall(prettyBytes(relativeBytes))
})
} else {
if (exports.isSystemDrive(drive)) {
statusList.push({
type: exports.COMPATIBILITY_STATUS_TYPES.WARNING,
message: messages.compatibility.system()
})
}
if (exports.isDriveSizeLarge(drive)) {
statusList.push({
type: exports.COMPATIBILITY_STATUS_TYPES.WARNING,
message: messages.compatibility.largeDrive()
})
}
if (!_.isNil(drive) && !exports.isDriveSizeRecommended(drive, image)) {
statusList.push({
type: exports.COMPATIBILITY_STATUS_TYPES.WARNING,
message: messages.compatibility.sizeNotRecommended()
})
}
}
return statusList
}
/**
* @summary Get drive/image compatibility status for many drives
* @function
* @public
*
* @description
* Given an image and a list of drives, return all compatibility status objects,
* containing the status type (ERROR, WARNING), and accompanying status message.
*
* @param {Object[]} drives - drives
* @param {Object} image - image
* @returns {Object[]} list of compatibility status objects
*
* @example
* const drives = [
* {
* device: '/dev/disk2',
* name: 'My Drive',
* size: 4000000000
* },
* {
* device: '/dev/disk1',
* name: 'My Other Drive',
* size: 780000000
* }
* ]
*
* const image = {
* path: '/path/to/rpi.img',
* size: 2000000000,
* compressedSize: 2000000000,
* isSizeEstimated: false,
* recommendedDriveSize: 4000000000
* })
*
* const statuses = constraints.getListDriveImageCompatibilityStatuses(drives, image)
*
* for ({ type, message } of statuses) {
* if (type === constraints.COMPATIBILITY_STATUS_TYPES.WARNING) {
* // do something
* } else if (type === constraints.COMPATIBILITY_STATUS_TYPES.ERROR) {
* // do something else
* }
* }
*/
exports.getListDriveImageCompatibilityStatuses = (drives, image) => {
return _.flatMap(drives, (drive) => {
return exports.getDriveImageCompatibilityStatuses(drive, image)
})
}
/**
* @summary Does the drive/image pair have at least one compatibility status?
* @function
* @public
*
* @description
* Given an image and a drive, return whether they have a connected compatibility status object.
*
* @param {Object} drive - drive
* @param {Object} image - image
* @returns {Boolean}
*
* @example
* if (constraints.hasDriveImageCompatibilityStatus(drive, image)) {
* console.log('This drive-image pair has a compatibility status message!')
* }
*/
exports.hasDriveImageCompatibilityStatus = (drive, image) => {
return Boolean(exports.getDriveImageCompatibilityStatuses(drive, image).length)
}
/**
* @summary Does any drive/image pair have at least one compatibility status?
* @function
* @public
*
* @description
* Given an image and a drive, return whether they have a connected compatibility status object.
*
* @param {Object[]} drives - drives
* @param {Object} image - image
* @returns {Boolean}
*
* @example
* if (constraints.hasDriveImageCompatibilityStatus(drive, image)) {
* console.log('This drive-image pair has a compatibility status message!')
* }
*/
exports.hasListDriveImageCompatibilityStatus = (drives, image) => {
return Boolean(exports.getListDriveImageCompatibilityStatuses(drives, image).length)
}

View File

@ -0,0 +1,278 @@
/*
* Copyright 2016 balena.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.
*/
import { Drive as DrivelistDrive } from 'drivelist';
import * as _ from 'lodash';
import * as pathIsInside from 'path-is-inside';
import * as prettyBytes from 'pretty-bytes';
import * as messages from './messages';
/**
* @summary The default unknown size for things such as images and drives
*/
const UNKNOWN_SIZE = 0;
/**
* @summary Check if a drive is locked
*
* @description
* This usually points out a locked SD Card.
*/
export function isDriveLocked(drive: DrivelistDrive): boolean {
return Boolean(_.get(drive, ['isReadOnly'], false));
}
/**
* @summary Check if a drive is a system drive
*/
export function isSystemDrive(drive: DrivelistDrive): boolean {
return Boolean(_.get(drive, ['isSystem'], false));
}
export interface Image {
path: string;
isSizeEstimated?: boolean;
compressedSize?: number;
recommendedDriveSize?: number;
size?: number;
}
/**
* @summary Check if a drive is source drive
*
* @description
* In the context of Etcher, a source drive is a drive
* containing the image.
*/
export function isSourceDrive(drive: DrivelistDrive, image: Image): boolean {
const mountpoints = _.get(drive, ['mountpoints'], []);
const imagePath = _.get(image, ['path']);
if (!imagePath || _.isEmpty(mountpoints)) {
return false;
}
return _.some(
_.map(mountpoints, mountpoint => {
return pathIsInside(imagePath, mountpoint.path);
}),
);
}
/**
* @summary Check if a drive is large enough for an image
*/
export function isDriveLargeEnough(
drive: DrivelistDrive | undefined,
image: Image,
): boolean {
const driveSize = _.get(drive, 'size') || UNKNOWN_SIZE;
if (_.get(image, ['isSizeEstimated'])) {
// If the drive size is smaller than the original image size, and
// the final image size is just an estimation, then we stop right
// here, based on the assumption that the final size will never
// be less than the original size.
if (driveSize < _.get(image, ['compressedSize'], UNKNOWN_SIZE)) {
return false;
}
// If the final image size is just an estimation then consider it
// large enough. In the worst case, the user gets an error saying
// the drive has ran out of space, instead of prohibiting the flash
// at all, when the estimation may be wrong.
return true;
}
return driveSize >= _.get(image, ['size'], UNKNOWN_SIZE);
}
/**
* @summary Check if a drive is disabled (i.e. not ready for selection)
*/
export function isDriveDisabled(drive: DrivelistDrive): boolean {
return _.get(drive, ['disabled'], false);
}
/**
* @summary Check if a drive is valid, i.e. not locked and large enough for an image
*/
export function isDriveValid(drive: DrivelistDrive, image: Image): boolean {
return (
!isDriveLocked(drive) &&
isDriveLargeEnough(drive, image) &&
!isSourceDrive(drive, image) &&
!isDriveDisabled(drive)
);
}
/**
* @summary Check if a drive meets the recommended drive size suggestion
*
* @description
* If the image doesn't have a recommended size, this function returns true.
*/
export function isDriveSizeRecommended(
drive: DrivelistDrive | undefined,
image: Image,
): boolean {
const driveSize = _.get(drive, 'size') || UNKNOWN_SIZE;
return driveSize >= _.get(image, ['recommendedDriveSize'], UNKNOWN_SIZE);
}
/**
* @summary 64GB
*/
export const LARGE_DRIVE_SIZE = 64e9;
/**
* @summary Check whether a drive's size is 'large'
*/
export function isDriveSizeLarge(drive?: DrivelistDrive): boolean {
const driveSize = _.get(drive, 'size') || UNKNOWN_SIZE;
return driveSize > LARGE_DRIVE_SIZE;
}
/**
* @summary Drive/image compatibility status types.
*
* @description
* Status types classifying what kind of message it is, i.e. error, warning.
*/
export const COMPATIBILITY_STATUS_TYPES = {
WARNING: 1,
ERROR: 2,
};
/**
* @summary Get drive/image compatibility in an object
*
* @description
* Given an image and a drive, return their compatibility status object
* containing the status type (ERROR, WARNING), and accompanying
* status message.
*
* @returns {Object[]} list of compatibility status objects
*/
export function getDriveImageCompatibilityStatuses(
drive: DrivelistDrive,
image: Image,
) {
const statusList = [];
// Mind the order of the if-statements if you modify.
if (isSourceDrive(drive, image)) {
statusList.push({
type: COMPATIBILITY_STATUS_TYPES.ERROR,
message: messages.compatibility.containsImage(),
});
} else if (isDriveLocked(drive)) {
statusList.push({
type: COMPATIBILITY_STATUS_TYPES.ERROR,
message: messages.compatibility.locked(),
});
} else if (
!_.isNil(drive) &&
!_.isNil(drive.size) &&
!isDriveLargeEnough(drive, image)
) {
const imageSize = (image.isSizeEstimated
? image.compressedSize
: image.size) as number;
const relativeBytes = imageSize - drive.size;
statusList.push({
type: COMPATIBILITY_STATUS_TYPES.ERROR,
message: messages.compatibility.tooSmall(prettyBytes(relativeBytes)),
});
} else {
if (isSystemDrive(drive)) {
statusList.push({
type: COMPATIBILITY_STATUS_TYPES.WARNING,
message: messages.compatibility.system(),
});
}
if (isDriveSizeLarge(drive)) {
statusList.push({
type: COMPATIBILITY_STATUS_TYPES.WARNING,
message: messages.compatibility.largeDrive(),
});
}
if (!_.isNil(drive) && !isDriveSizeRecommended(drive, image)) {
statusList.push({
type: COMPATIBILITY_STATUS_TYPES.WARNING,
message: messages.compatibility.sizeNotRecommended(),
});
}
}
return statusList;
}
/**
* @summary Get drive/image compatibility status for many drives
*
* @description
* Given an image and a list of drives, return all compatibility status objects,
* containing the status type (ERROR, WARNING), and accompanying status message.
*/
export function getListDriveImageCompatibilityStatuses(
drives: DrivelistDrive[],
image: Image,
) {
return _.flatMap(drives, drive => {
return getDriveImageCompatibilityStatuses(drive, image);
});
}
/**
* @summary Does the drive/image pair have at least one compatibility status?
*
* @description
* Given an image and a drive, return whether they have a connected compatibility status object.
*/
export function hasDriveImageCompatibilityStatus(
drive: DrivelistDrive,
image: Image,
) {
return Boolean(getDriveImageCompatibilityStatuses(drive, image).length);
}
/**
* @summary Does any drive/image pair have at least one compatibility status?
* @function
* @public
*
* @description
* Given an image and a drive, return whether they have a connected compatibility status object.
*
* @param {Object[]} drives - drives
* @param {Object} image - image
* @returns {Boolean}
*
* @example
* if (constraints.hasDriveImageCompatibilityStatus(drive, image)) {
* console.log('This drive-image pair has a compatibility status message!')
* }
*/
export function hasListDriveImageCompatibilityStatus(
drives: DrivelistDrive[],
image: Image,
) {
return Boolean(getListDriveImageCompatibilityStatuses(drives, image).length);
}

View File

@ -1,369 +0,0 @@
/*
* Copyright 2016 balena.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 Create an error details object
* @function
* @private
*
* @param {Object} options - options
* @param {(String|Function)} options.title - error title
* @param {(String|Function)} options.description - error description
* @returns {Object} error details object
*
* @example
* const details = createErrorDetails({
* title: (error) => {
* return `An error happened, the code is ${error.code}`;
* },
* description: 'This is the error description'
* });
*/
const createErrorDetails = (options) => {
return _.pick(_.mapValues(options, (value) => {
return _.isFunction(value) ? value : _.constant(value)
}), [ 'title', 'description' ])
}
/**
* @summary Human-friendly error messages
* @namespace HUMAN_FRIENDLY
* @public
*/
exports.HUMAN_FRIENDLY = {
/* eslint-disable new-cap */
/**
* @namespace ENOENT
* @memberof HUMAN_FRIENDLY
*/
ENOENT: createErrorDetails({
title: (error) => {
return `No such file or directory: ${error.path}`
},
description: 'The file you\'re trying to access doesn\'t exist'
}),
/**
* @namespace EPERM
* @memberof HUMAN_FRIENDLY
*/
EPERM: createErrorDetails({
title: 'You\'re not authorized to perform this operation',
description: 'Please ensure you have necessary permissions for this task'
}),
/**
* @namespace EACCES
* @memberof HUMAN_FRIENDLY
*/
EACCES: createErrorDetails({
title: 'You don\'t have access to this resource',
description: 'Please ensure you have necessary permissions to access this resource'
}),
/**
* @namespace ENOMEM
* @memberof HUMAN_FRIENDLY
*/
ENOMEM: createErrorDetails({
title: 'Your system ran out of memory',
description: 'Please make sure your system has enough available memory for this task'
})
/* eslint-enable new-cap */
}
/**
* @summary Get user friendly property from an error
* @function
* @private
*
* @param {Error} error - error
* @param {String} property - HUMAN_FRIENDLY property
* @returns {(String|Undefined)} user friendly message
*
* @example
* const error = new Error('My error');
* error.code = 'ENOMEM';
*
* const friendlyDescription = getUserFriendlyMessageProperty(error, 'description');
*
* if (friendlyDescription) {
* console.log(friendlyDescription);
* }
*/
const getUserFriendlyMessageProperty = (error, property) => {
const code = _.get(error, [ 'code' ])
if (_.isNil(code) || !_.isString(code)) {
return null
}
return _.invoke(exports.HUMAN_FRIENDLY, [ code, property ], error)
}
/**
* @summary Check if a string is blank
* @function
* @private
*
* @param {String} string - string
* @returns {Boolean} whether the string is blank
*
* @example
* if (isBlank(' ')) {
* console.log('The string is blank');
* }
*/
const isBlank = _.flow([ _.trim, _.isEmpty ])
/**
* @summary Get the title of an error
* @function
* @public
*
* @description
* Try to get as much information as possible about the error
* rather than falling back to generic messages right away.
*
* @param {Error} error - error
* @returns {String} error title
*
* @example
* const error = new Error('Foo bar');
* const title = errors.getTitle(error);
* console.log(title);
*/
exports.getTitle = (error) => {
if (!_.isError(error) && !_.isPlainObject(error) && !_.isNil(error)) {
return _.toString(error)
}
const codeTitle = getUserFriendlyMessageProperty(error, 'title')
if (!_.isNil(codeTitle)) {
return codeTitle
}
const message = _.get(error, [ 'message' ])
if (!isBlank(message)) {
return message
}
const code = _.get(error, [ 'code' ])
if (!_.isNil(code) && !isBlank(code)) {
return `Error code: ${code}`
}
return 'An error ocurred'
}
/**
* @summary Get the description of an error
* @function
* @public
*
* @param {Error} error - error
* @param {Object} options - options
* @param {Boolean} [options.userFriendlyDescriptionsOnly=false] - only return user friendly descriptions
* @returns {String} error description
*
* @example
* const error = new Error('Foo bar');
* const description = errors.getDescription(error);
* console.log(description);
*/
exports.getDescription = (error, options = {}) => {
_.defaults(options, {
userFriendlyDescriptionsOnly: false
})
if (!_.isError(error) && !_.isPlainObject(error)) {
return ''
}
if (!isBlank(error.description)) {
return error.description
}
const codeDescription = getUserFriendlyMessageProperty(error, 'description')
if (!_.isNil(codeDescription)) {
return codeDescription
}
if (options.userFriendlyDescriptionsOnly) {
return ''
}
if (error.stack) {
return error.stack
}
if (_.isEmpty(error)) {
return ''
}
const INDENTATION_SPACES = 2
return JSON.stringify(error, null, INDENTATION_SPACES)
}
/**
* @summary Create an error
* @function
* @public
*
* @param {Object} options - options
* @param {String} options.title - error title
* @param {String} [options.description] - error description
* @param {Boolean} [options.report] - report error
* @returns {Error} error
*
* @example
* const error = errors.createError({
* title: 'Foo'
* description: 'Bar'
* });
*
* throw error;
*/
exports.createError = (options) => {
if (isBlank(options.title)) {
throw new Error(`Invalid error title: ${options.title}`)
}
const error = new Error(options.title)
error.description = options.description
if (!_.isNil(options.report) && !options.report) {
error.report = false
}
if (!_.isNil(options.code)) {
error.code = options.code
}
return error
}
/**
* @summary Create a user error
* @function
* @public
*
* @description
* User errors represent invalid states that the user
* caused, that are not errors on the application itself.
* Therefore, user errors don't get reported to analytics
* and error reporting services.
*
* @param {Object} options - options
* @param {String} options.title - error title
* @param {String} [options.description] - error description
* @returns {Error} user error
*
* @example
* const error = errors.createUserError({
* title: 'Foo',
* description: 'Bar'
* });
*
* throw error;
*/
exports.createUserError = (options) => {
return exports.createError({
title: options.title,
description: options.description,
report: false,
code: options.code
})
}
/**
* @summary Check if an error is an user error
* @function
* @public
*
* @param {Error} error - error
* @returns {Boolean} whether the error is a user error
*
* @example
* const error = errors.createUserError('Foo', 'Bar');
*
* if (errors.isUserError(error)) {
* console.log('This error is a user error');
* }
*/
exports.isUserError = (error) => {
return _.isNil(error.report) ? false : !error.report
}
/**
* @summary Convert an Error object to a JSON object
* @function
* @public
*
* @param {Error} error - error object
* @returns {Object} json error
*
* @example
* const error = errors.toJSON(new Error('foo'))
*
* console.log(error.message);
* > 'foo'
*/
exports.toJSON = (error) => {
// Handle string error objects to be on the safe side
const isErrorLike = _.isError(error) || _.isPlainObject(error)
const errorObject = isErrorLike ? error : new Error(error)
return {
name: errorObject.name,
message: errorObject.message,
description: errorObject.description,
stack: errorObject.stack,
report: errorObject.report,
code: errorObject.code,
syscall: errorObject.syscall,
errno: errorObject.errno,
stdout: errorObject.stdout,
stderr: errorObject.stderr,
device: errorObject.device
}
}
/**
* @summary Convert a JSON object to an Error object
* @function
* @public
*
* @param {Error} json - json object
* @returns {Object} error object
*
* @example
* const error = errors.fromJSON(errors.toJSON(new Error('foo')));
*
* console.log(error.message);
* > 'foo'
*/
exports.fromJSON = (json) => {
return _.assign(new Error(json.message), json)
}

264
lib/shared/errors.ts Normal file
View File

@ -0,0 +1,264 @@
/*
* Copyright 2016 balena.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.
*/
import * as _ from 'lodash';
function createErrorDetails(options: {
title: string | ((error: Error) => string);
description: string | ((error: Error) => string);
}): {
title: (error: Error) => string;
description: (error: Error) => string;
} {
return _.pick(
_.mapValues(options, value => {
return _.isFunction(value) ? value : _.constant(value);
}),
['title', 'description'],
);
}
/**
* @summary Human-friendly error messages
*/
export const HUMAN_FRIENDLY = {
ENOENT: createErrorDetails({
title: (error: Error & { path: string }) => {
return `No such file or directory: ${error.path}`;
},
description: "The file you're trying to access doesn't exist",
}),
EPERM: createErrorDetails({
title: "You're not authorized to perform this operation",
description: 'Please ensure you have necessary permissions for this task',
}),
EACCES: createErrorDetails({
title: "You don't have access to this resource",
description:
'Please ensure you have necessary permissions to access this resource',
}),
ENOMEM: createErrorDetails({
title: 'Your system ran out of memory',
description:
'Please make sure your system has enough available memory for this task',
}),
};
/**
* @summary Get user friendly property from an error
*
* @example
* const error = new Error('My error');
* error.code = 'ENOMEM';
*
* const friendlyDescription = getUserFriendlyMessageProperty(error, 'description');
*
* if (friendlyDescription) {
* console.log(friendlyDescription);
* }
*/
function getUserFriendlyMessageProperty(
error: Error,
property: 'title' | 'description',
): string | null {
const code = _.get(error, ['code']);
if (_.isNil(code) || !_.isString(code)) {
return null;
}
return _.invoke(HUMAN_FRIENDLY, [code, property], error);
}
const isBlank = _.flow([_.trim, _.isEmpty]);
/**
* @summary Get the title of an error
*
* @description
* Try to get as much information as possible about the error
* rather than falling back to generic messages right away.
*/
export function getTitle(error: Error): string {
if (!_.isError(error) && !_.isPlainObject(error) && !_.isNil(error)) {
return _.toString(error);
}
const codeTitle = getUserFriendlyMessageProperty(error, 'title');
if (!_.isNil(codeTitle)) {
return codeTitle;
}
const message = _.get(error, ['message']);
if (!isBlank(message)) {
return message;
}
const code = _.get(error, ['code']);
if (!_.isNil(code) && !isBlank(code)) {
return `Error code: ${code}`;
}
return 'An error ocurred';
}
/**
* @summary Get the description of an error
*/
export function getDescription(
error: Error & { description?: string },
options: { userFriendlyDescriptionsOnly?: boolean } = {},
): string {
_.defaults(options, {
userFriendlyDescriptionsOnly: false,
});
if (!_.isError(error) && !_.isPlainObject(error)) {
return '';
}
if (!isBlank(error.description)) {
return error.description as string;
}
const codeDescription = getUserFriendlyMessageProperty(error, 'description');
if (!_.isNil(codeDescription)) {
return codeDescription;
}
if (options.userFriendlyDescriptionsOnly) {
return '';
}
if (error.stack) {
return error.stack;
}
if (_.isEmpty(error)) {
return '';
}
const INDENTATION_SPACES = 2;
return JSON.stringify(error, null, INDENTATION_SPACES);
}
/**
* @summary Create an error
*/
export function createError(options: {
title: string;
description?: string;
report?: boolean;
code?: string;
}): Error & { description?: string; report?: boolean; code?: string } {
if (isBlank(options.title)) {
throw new Error(`Invalid error title: ${options.title}`);
}
const error: Error & {
description?: string;
report?: boolean;
code?: string;
} = new Error(options.title);
error.description = options.description;
if (!_.isNil(options.report) && !options.report) {
error.report = false;
}
if (!_.isNil(options.code)) {
error.code = options.code;
}
return error;
}
/**
* @summary Create a user error
*
* @description
* User errors represent invalid states that the user
* caused, that are not errors on the application itself.
* Therefore, user errors don't get reported to analytics
* and error reporting services.
*/
export function createUserError(options: {
title: string;
description: string;
code?: string;
}): Error {
return createError({
title: options.title,
description: options.description,
report: false,
code: options.code,
});
}
/**
* @summary Check if an error is an user error
*/
export function isUserError(error: Error & { report?: boolean }): boolean {
return _.isNil(error.report) ? false : !error.report;
}
/**
* @summary Convert an Error object to a JSON object
* @function
* @public
*
* @param {Error} error - error object
* @returns {Object} json error
*
* @example
* const error = errors.toJSON(new Error('foo'))
*
* console.log(error.message);
* > 'foo'
*/
export function toJSON(
error: Error & {
description?: string;
report?: boolean;
code?: string;
syscall?: string;
errno?: number;
stdout?: string;
stderr?: string;
device?: string;
},
): any {
return {
name: error.name,
message: error.message,
description: error.description,
stack: error.stack,
report: error.report,
code: error.code,
syscall: error.syscall,
errno: error.errno,
stdout: error.stdout,
stderr: error.stderr,
device: error.device,
};
}
/**
* @summary Convert a JSON object to an Error object
*/
export function fromJSON(json: any): Error {
return _.assign(new Error(json.message), json);
}

View File

@ -1,66 +0,0 @@
/*
* Copyright 2016 balena.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'
/**
* @summary Etcher exit codes
* @namespace EXIT_CODES
* @public
*/
module.exports = {
/**
* @property {Number} SUCCESS
* @memberof EXIT_CODES
*
* @description
* This exit code is used to represent a successful exit
* status, with no problems on the way.
*/
SUCCESS: 0,
/**
* @property {Number} GENERAL_ERROR
* @memberof EXIT_CODES
*
* @description
* This exit code is used to represent a general error
* situation. If the reasons of the error is not
* documented as a specialised error code, this one
* should be used.
*/
GENERAL_ERROR: 1,
/**
* @property {Number} VALIDATION_ERROR
* @memberof EXIT_CODES
*
* @description
* This exit code is used to represent a validation error.
*/
VALIDATION_ERROR: 2,
/**
* @property {Number} CANCELLED
* @memberof EXIT_CODES
*
* @description
* This exit code is used to represent a cancelled write process.
*/
CANCELLED: 3
}

View File

@ -14,7 +14,7 @@
* limitations under the License.
*/
[ng-click] {
cursor: pointer;
-webkit-app-region: no-drag;
}
export const SUCCESS = 0;
export const GENERAL_ERROR = 1;
export const VALIDATION_ERROR = 2;
export const CANCELLED = 3;

View File

@ -14,63 +14,50 @@
* limitations under the License.
*/
'use strict'
const mime = require('mime-types')
const _ = require('lodash')
import * as _ from 'lodash';
import { lookup } from 'mime-types';
/**
* @summary Get the extensions of a file
* @function
* @public
*
* @param {String} filePath - file path
* @returns {String[]} extensions
*
* @example
* const extensions = fileExtensions.getFileExtensions('path/to/foo.img.gz');
* console.log(extensions);
* > [ 'img', 'gz' ]
*/
exports.getFileExtensions = _.memoize((filePath) => {
return _.chain(filePath)
.split('.')
.tail()
.map(_.toLower)
.value()
})
export function getFileExtensions(filePath: string): string[] {
return _.chain(filePath)
.split('.')
.tail()
.map(_.toLower)
.value();
}
/**
* @summary Get the last file extension
* @function
* @public
*
* @param {String} filePath - file path
* @returns {(String|Null)} last extension
*
* @example
* const extension = fileExtensions.getLastFileExtension('path/to/foo.img.gz');
* console.log(extension);
* > 'gz'
*/
exports.getLastFileExtension = (filePath) => {
return _.last(exports.getFileExtensions(filePath)) || null
export function getLastFileExtension(filePath: string): string | null {
return _.last(getFileExtensions(filePath)) || null;
}
/**
* @summary Get the penultimate file extension
* @function
* @public
*
* @param {String} filePath - file path
* @returns {(String|Null)} penultimate extension
*
* @example
* const extension = fileExtensions.getPenultimateFileExtension('path/to/foo.img.gz');
* console.log(extension);
* > 'img'
*/
exports.getPenultimateFileExtension = (filePath) => {
const ext = _.last(_.initial(exports.getFileExtensions(filePath)))
return !_.isNil(ext) && mime.lookup(ext) ? ext : null
export function getPenultimateFileExtension(filePath: string): string | null {
const extensions = getFileExtensions(filePath);
if (extensions.length >= 2) {
const ext = extensions[extensions.length - 2];
return lookup(ext) ? ext : null;
}
return null;
}

View File

@ -1,234 +0,0 @@
/*
* Copyright 2016 balena.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.
*/
/* eslint-disable lodash/prefer-constant */
'use strict'
/**
* @summary Application messages
* @namespace messages
* @public
*/
module.exports = {
/**
* @summary Progress messages
* @namespace progress
* @memberof messages
*/
progress: {
successful: (quantity) => {
// eslint-disable-next-line no-magic-numbers
const plural = quantity === 1 ? '' : 's'
return `Successful device${plural}`
},
failed: (quantity) => {
// eslint-disable-next-line no-magic-numbers
const plural = quantity === 1 ? '' : 's'
return `Failed device${plural}`
}
},
/**
* @summary Informational messages
* @namespace info
* @memberof messages
*/
info: {
flashComplete: (imageBasename, [ drive ], { failed, successful }) => {
/* eslint-disable no-magic-numbers */
const targets = []
if (failed + successful === 1) {
targets.push(`to ${drive.description} (${drive.displayName})`)
} else {
if (successful) {
const plural = successful === 1 ? '' : 's'
targets.push(`to ${successful} target${plural}`)
}
if (failed) {
const plural = failed === 1 ? '' : 's'
targets.push(`and failed to be flashed to ${failed} target${plural}`)
}
}
return `${imageBasename} was successfully flashed ${targets.join(' ')}`
/* eslint-enable no-magic-numbers */
}
},
/**
* @summary Drive compatibility messages
* @namespace compatibility
* @memberof messages
*/
compatibility: {
sizeNotRecommended () {
return 'Not Recommended'
},
tooSmall (additionalSpace) {
return `Insufficient space, additional ${additionalSpace} required`
},
locked () {
return 'Locked'
},
system () {
return 'System Drive'
},
containsImage () {
return 'Drive Mountpoint Contains Image'
},
// The drive is large and therefore likely not a medium you want to write to.
largeDrive () {
return 'Large Drive'
}
},
/**
* @summary Warning messages
* @namespace warning
* @memberof messages
*/
warning: {
unrecommendedDriveSize: (image, drive) => {
return [
`This image recommends a ${image.recommendedDriveSize}`,
`bytes drive, however ${drive.device} is only ${drive.size} bytes.`
].join(' ')
},
exitWhileFlashing: () => {
return [
'You are currently flashing a drive.',
'Closing Etcher may leave your drive in an unusable state.'
].join(' ')
},
looksLikeWindowsImage: () => {
return [
'It looks like you are trying to burn a Windows image.\n\n',
'Unlike other images, Windows images require special processing to be made bootable.',
'We suggest you use a tool specially designed for this purpose, such as',
'<a href="https://rufus.akeo.ie">Rufus</a> (Windows),',
'<a href="https://github.com/slacka/WoeUSB">WoeUSB</a> (Linux),',
'or Boot Camp Assistant (macOS).'
].join(' ')
},
missingPartitionTable: () => {
return [
'It looks like this is not a bootable image.\n\n',
'The image does not appear to contain a partition table,',
'and might not be recognized or bootable by your device.'
].join(' ')
},
largeDriveSize: (drive) => {
return [
`Drive ${drive.description} (${drive.device}) is unusually large for an SD card or USB stick.`,
'\n\nAre you sure you want to flash this drive?'
].join(' ')
}
},
/**
* @summary Error messages
* @namespace error
* @memberof messages
*/
error: {
notEnoughSpaceInDrive: () => {
return [
'Not enough space on the drive.',
'Please insert larger one and try again.'
].join(' ')
},
genericFlashError: () => {
return 'Something went wrong. If it is a compressed image, please check that the archive is not corrupted.'
},
validation: () => {
return [
'The write has been completed successfully but Etcher detected potential',
'corruption issues when reading the image back from the drive.',
'\n\nPlease consider writing the image to a different drive.'
].join(' ')
},
invalidImage: (imagePath) => {
return `${imagePath} is not a supported image type.`
},
openImage: (imageBasename, errorMessage) => {
return [
`Something went wrong while opening ${imageBasename}\n\n`,
`Error: ${errorMessage}`
].join('')
},
elevationRequired: () => {
return 'This should should be run with root/administrator permissions.'
},
flashFailure: (imageBasename, drives) => {
/* eslint-disable no-magic-numbers */
const target = drives.length === 1
? `${drives[0].description} (${drives[0].displayName})`
: `${drives.length} targets`
return `Something went wrong while writing ${imageBasename} to ${target}.`
/* eslint-enable no-magic-numbers */
},
driveUnplugged: () => {
return [
'Looks like Etcher lost access to the drive.',
'Did it get unplugged accidentally?',
'\n\nSometimes this error is caused by faulty readers that don\'t provide stable access to the drive.'
].join(' ')
},
inputOutput: () => {
return [
'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: () => {
return [
'The writer process ended unexpectedly.',
'Please try again, and contact the Etcher team if the problem persists.'
].join(' ')
}
}
}

192
lib/shared/messages.ts Normal file
View File

@ -0,0 +1,192 @@
/*
* Copyright 2016 balena.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.
*/
export const progress = {
successful: (quantity: number) => {
const plural = quantity === 1 ? '' : 's';
return `Successful device${plural}`;
},
failed: (quantity: number) => {
const plural = quantity === 1 ? '' : 's';
return `Failed device${plural}`;
},
};
export const info = {
flashComplete: (
imageBasename: string,
[drive]: [{ description: string; displayName: string }],
{ failed, successful }: { failed: number; successful: number },
) => {
const targets = [];
if (failed + successful === 1) {
targets.push(`to ${drive.description} (${drive.displayName})`);
} else {
if (successful) {
const plural = successful === 1 ? '' : 's';
targets.push(`to ${successful} target${plural}`);
}
if (failed) {
const plural = failed === 1 ? '' : 's';
targets.push(`and failed to be flashed to ${failed} target${plural}`);
}
}
return `${imageBasename} was successfully flashed ${targets.join(' ')}`;
},
};
export const compatibility = {
sizeNotRecommended: () => {
return 'Not Recommended';
},
tooSmall: (additionalSpace: string) => {
return `Insufficient space, additional ${additionalSpace} required`;
},
locked: () => {
return 'Locked';
},
system: () => {
return 'System Drive';
},
containsImage: () => {
return 'Drive Mountpoint Contains Image';
},
// The drive is large and therefore likely not a medium you want to write to.
largeDrive: () => {
return 'Large Drive';
},
} as const;
export const warning = {
unrecommendedDriveSize: (
image: { recommendedDriveSize: number },
drive: { device: string; size: number },
) => {
return [
`This image recommends a ${image.recommendedDriveSize}`,
`bytes drive, however ${drive.device} is only ${drive.size} bytes.`,
].join(' ');
},
exitWhileFlashing: () => {
return [
'You are currently flashing a drive.',
'Closing Etcher may leave your drive in an unusable state.',
].join(' ');
},
looksLikeWindowsImage: () => {
return [
'It looks like you are trying to burn a Windows image.\n\n',
'Unlike other images, Windows images require special processing to be made bootable.',
'We suggest you use a tool specially designed for this purpose, such as',
'<a href="https://rufus.akeo.ie">Rufus</a> (Windows),',
'<a href="https://github.com/slacka/WoeUSB">WoeUSB</a> (Linux),',
'or Boot Camp Assistant (macOS).',
].join(' ');
},
missingPartitionTable: () => {
return [
'It looks like this is not a bootable image.\n\n',
'The image does not appear to contain a partition table,',
'and might not be recognized or bootable by your device.',
].join(' ');
},
largeDriveSize: (drive: { description: string; device: string }) => {
return [
`Drive ${drive.description} (${drive.device}) is unusually large for an SD card or USB stick.`,
'\n\nAre you sure you want to flash this drive?',
].join(' ');
},
};
export const error = {
notEnoughSpaceInDrive: () => {
return [
'Not enough space on the drive.',
'Please insert larger one and try again.',
].join(' ');
},
genericFlashError: () => {
return 'Something went wrong. If it is a compressed image, please check that the archive is not corrupted.';
},
validation: () => {
return [
'The write has been completed successfully but Etcher detected potential',
'corruption issues when reading the image back from the drive.',
'\n\nPlease consider writing the image to a different drive.',
].join(' ');
},
invalidImage: (imagePath: string) => {
return `${imagePath} is not a supported image type.`;
},
openImage: (imageBasename: string, errorMessage: string) => {
return [
`Something went wrong while opening ${imageBasename}\n\n`,
`Error: ${errorMessage}`,
].join('');
},
elevationRequired: () => {
return 'This should should be run with root/administrator permissions.';
},
flashFailure: (
imageBasename: string,
drives: Array<{ description: string; displayName: string }>,
) => {
const target =
drives.length === 1
? `${drives[0].description} (${drives[0].displayName})`
: `${drives.length} targets`;
return `Something went wrong while writing ${imageBasename} to ${target}.`;
},
driveUnplugged: () => {
return [
'Looks like Etcher lost access to the drive.',
'Did it get unplugged accidentally?',
"\n\nSometimes this error is caused by faulty readers that don't provide stable access to the drive.",
].join(' ');
},
inputOutput: () => {
return [
'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: () => {
return [
'The writer process ended unexpectedly.',
'Please try again, and contact the Etcher team if the problem persists.',
].join(' ');
},
};

View File

@ -1,238 +0,0 @@
/*
* Copyright 2017 balena.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.
*/
/* eslint-disable lodash/prefer-lodash-method,quotes,no-magic-numbers,require-jsdoc */
'use strict'
const bindings = require('bindings')
const Bluebird = require('bluebird')
const childProcess = Bluebird.promisifyAll(require('child_process'))
const fs = require('fs')
const _ = require('lodash')
const os = require('os')
const semver = require('semver')
const sudoPrompt = Bluebird.promisifyAll(require('sudo-prompt'))
const { promisify } = require('util')
const errors = require('./errors')
const { tmpFileDisposer } = require('./utils')
const { sudo: catalinaSudo } = require('./catalina-sudo/sudo')
const writeFileAsync = promisify(fs.writeFile)
/**
* @summary The user id of the UNIX "superuser"
* @constant
* @type {Number}
*/
const UNIX_SUPERUSER_USER_ID = 0
/**
* @summary Check if the current process is running with elevated permissions
* @function
* @public
*
* @description
* This function has been adapted from https://github.com/sindresorhus/is-elevated,
* which was originally licensed under MIT.
*
* We're not using such module directly given that it
* contains dependencies with dynamic undeclared dependencies,
* causing a mess when trying to concatenate the code.
*
* @fulfil {Boolean} - whether the current process has elevated permissions
* @returns {Promise}
*
* @example
* permissions.isElevated().then((isElevated) => {
* if (isElevated) {
* console.log('This process has elevated permissions');
* }
* });
*/
exports.isElevated = () => {
if (os.platform() === 'win32') {
// `fltmc` is available on WinPE, XP, Vista, 7, 8, and 10
// Works even when the "Server" service is disabled
// See http://stackoverflow.com/a/28268802
return childProcess.execAsync('fltmc')
.then(_.constant(true))
.catch({
code: os.constants.errno.EPERM
}, _.constant(false))
}
return Bluebird.resolve(process.geteuid() === UNIX_SUPERUSER_USER_ID)
}
/**
* @summary Check if the current process is running with elevated permissions
* @function
* @public
*
* @description
*
* @returns {Boolean}
*
* @example
* permissions.isElevatedUnixSync()
* if (isElevated) {
* console.log('This process has elevated permissions');
* }
* });
*/
exports.isElevatedUnixSync = () => {
return (process.geteuid() === UNIX_SUPERUSER_USER_ID)
}
const escapeSh = (value) => {
// Make sure it's a string
// Replace ' -> '\'' (closing quote, escaped quote, opening quote)
// Surround with quotes
return `'${String(value).replace(/'/g, "'\\''")}'`
}
const escapeParamCmd = (value) => {
// Make sure it's a string
// Escape " -> \"
// Surround with double quotes
return `"${String(value).replace(/"/g, '\\"')}"`
}
const setEnvVarSh = (value, name) => {
return `export ${name}=${escapeSh(value)}`
}
const setEnvVarCmd = (value, name) => {
return `set "${name}=${String(value)}"`
}
// Exported for tests
exports.createLaunchScript = (command, argv, environment) => {
const isWindows = os.platform() === 'win32'
const lines = []
if (isWindows) {
// Switch to utf8
lines.push('chcp 65001')
}
const [ setEnvVarFn, escapeFn ] = isWindows ? [ setEnvVarCmd, escapeParamCmd ] : [ setEnvVarSh, escapeSh ]
lines.push(..._.map(environment, setEnvVarFn))
lines.push([ command, ...argv ].map(escapeFn).join(' '))
return lines.join(os.EOL)
}
const elevateScriptWindows = async (path) => {
// 'elevator' imported here as it only exists on windows
// TODO: replace this with sudo-prompt once https://github.com/jorangreef/sudo-prompt/issues/96 is fixed
const elevateAsync = promisify(bindings({ bindings: 'elevator' }).elevate)
// '&' needs to be escaped here (but not when written to a .cmd file)
const cmd = [ 'cmd', '/c', escapeParamCmd(path).replace(/&/g, '^&') ]
const { cancelled } = await elevateAsync(cmd)
return { cancelled }
}
const elevateScriptUnix = async (path, name) => {
const cmd = [ 'bash', escapeSh(path) ].join(' ')
const [ , stderr ] = await sudoPrompt.execAsync(cmd, { name })
if (!_.isEmpty(stderr)) {
throw errors.createError({ title: stderr })
}
return { cancelled: false }
}
const elevateScriptCatalina = async (path) => {
const cmd = [ 'bash', escapeSh(path) ].join(' ')
try {
const { cancelled } = await catalinaSudo(cmd)
return { cancelled }
} catch (error) {
return errors.createError({ title: error.stderr })
}
}
/**
* @summary Elevate a command
* @function
* @public
*
* @param {String[]} command - command arguments
* @param {Object} options - options
* @param {String} options.applicationName - application name
* @param {Object} options.environment - environment variables
* @fulfil {Object} - elevation results
* @returns {Promise}
*
* @example
* permissions.elevateCommand([ 'foo', 'bar' ], {
* applicationName: 'My App',
* environment: {
* FOO: 'bar'
* }
* }).then((results) => {
* if (results.cancelled) {
* console.log('Elevation has been cancelled');
* }
* });
*/
exports.elevateCommand = async (command, options) => {
if (await exports.isElevated()) {
await childProcess.execFileAsync(command[0], command.slice(1), { env: options.environment })
return { cancelled: false }
}
const isWindows = os.platform() === 'win32'
const launchScript = exports.createLaunchScript(command[0], command.slice(1), options.environment)
return Bluebird.using(tmpFileDisposer({ postfix: '.cmd' }), async ({ path }) => {
await writeFileAsync(path, launchScript)
if (isWindows) {
return elevateScriptWindows(path)
}
if (os.platform() === 'darwin' && semver.compare(os.release(), '19.0.0') >= 0) {
// >= macOS Catalina
return elevateScriptCatalina(path)
}
try {
return await elevateScriptUnix(path, options.applicationName)
} catch (error) {
// We're hardcoding internal error messages declared by `sudo-prompt`.
// There doesn't seem to be a better way to handle these errors, so
// for now, we should make sure we double check if the error messages
// have changed every time we upgrade `sudo-prompt`.
console.log('error', error)
if (_.includes(error.message, 'is not in the sudoers file')) {
throw errors.createUserError({
title: "Your user doesn't have enough privileges to proceed",
description: 'This application requires sudo privileges to be able to write to drives'
})
} else if (_.startsWith(error.message, 'Command failed:')) {
throw errors.createUserError({
title: 'The elevated process died unexpectedly',
description: `The process error code was ${error.code}`
})
} else if (error.message === 'User did not grant permission.') {
return { cancelled: true }
} else if (error.message === 'No polkit authentication agent found.') {
throw errors.createUserError({
title: 'No polkit authentication agent found',
description: 'Please install a polkit authentication agent for your desktop environment of choice to continue'
})
}
throw error
}
})
}

209
lib/shared/permissions.ts Executable file
View File

@ -0,0 +1,209 @@
/*
* Copyright 2017 balena.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.
*/
import bindings = require('bindings');
import * as Bluebird from 'bluebird';
import * as childProcess from 'child_process';
import { promises as fs } from 'fs';
import * as _ from 'lodash';
import * as os from 'os';
import * as semver from 'semver';
import * as sudoPrompt from 'sudo-prompt';
import { promisify } from 'util';
import { sudo as catalinaSudo } from './catalina-sudo/sudo';
import * as errors from './errors';
import { tmpFileDisposer } from './utils';
const execAsync = promisify(childProcess.exec);
const execFileAsync = promisify(childProcess.execFile);
const sudoExecAsync = promisify(sudoPrompt.exec);
/**
* @summary The user id of the UNIX "superuser"
*/
const UNIX_SUPERUSER_USER_ID = 0;
export async function isElevated(): Promise<boolean> {
if (os.platform() === 'win32') {
// `fltmc` is available on WinPE, XP, Vista, 7, 8, and 10
// Works even when the "Server" service is disabled
// See http://stackoverflow.com/a/28268802
try {
await execAsync('fltmc');
} catch (error) {
if (error.code === os.constants.errno.EPERM) {
return false;
}
throw error;
}
return true;
}
return process.geteuid() === UNIX_SUPERUSER_USER_ID;
}
/**
* @summary Check if the current process is running with elevated permissions
*/
export function isElevatedUnixSync(): boolean {
return process.geteuid() === UNIX_SUPERUSER_USER_ID;
}
function escapeSh(value: any): string {
// Make sure it's a string
// Replace ' -> '\'' (closing quote, escaped quote, opening quote)
// Surround with quotes
return `'${String(value).replace(/'/g, "'\\''")}'`;
}
function escapeParamCmd(value: any): string {
// Make sure it's a string
// Escape " -> \"
// Surround with double quotes
return `"${String(value).replace(/"/g, '\\"')}"`;
}
function setEnvVarSh(value: any, name: string): string {
return `export ${name}=${escapeSh(value)}`;
}
function setEnvVarCmd(value: any, name: string): string {
return `set "${name}=${String(value)}"`;
}
// Exported for tests
export function createLaunchScript(
command: string,
argv: string[],
environment: _.Dictionary<string | undefined>,
): string {
const isWindows = os.platform() === 'win32';
const lines = [];
if (isWindows) {
// Switch to utf8
lines.push('chcp 65001');
}
const [setEnvVarFn, escapeFn] = isWindows
? [setEnvVarCmd, escapeParamCmd]
: [setEnvVarSh, escapeSh];
lines.push(..._.map(environment, setEnvVarFn));
lines.push([command, ...argv].map(escapeFn).join(' '));
return lines.join(os.EOL);
}
async function elevateScriptWindows(
path: string,
): Promise<{ cancelled: boolean }> {
// 'elevator' imported here as it only exists on windows
// TODO: replace this with sudo-prompt once https://github.com/jorangreef/sudo-prompt/issues/96 is fixed
const elevateAsync = promisify(bindings('elevator').elevate);
// '&' needs to be escaped here (but not when written to a .cmd file)
const cmd = ['cmd', '/c', escapeParamCmd(path).replace(/&/g, '^&')];
const { cancelled } = await elevateAsync(cmd);
return { cancelled };
}
async function elevateScriptUnix(
path: string,
name: string,
): Promise<{ cancelled: boolean }> {
const cmd = ['bash', escapeSh(path)].join(' ');
const [, stderr] = await sudoExecAsync(cmd, { name });
if (!_.isEmpty(stderr)) {
throw errors.createError({ title: stderr });
}
return { cancelled: false };
}
async function elevateScriptCatalina(
path: string,
): Promise<{ cancelled: boolean }> {
const cmd = ['bash', escapeSh(path)].join(' ');
try {
const { cancelled } = await catalinaSudo(cmd);
return { cancelled };
} catch (error) {
throw errors.createError({ title: error.stderr });
}
}
export async function elevateCommand(
command: string[],
options: {
environment: _.Dictionary<string | undefined>;
applicationName: string;
},
): Promise<{ cancelled: boolean }> {
if (await isElevated()) {
await execFileAsync(command[0], command.slice(1), {
env: options.environment,
});
return { cancelled: false };
}
const isWindows = os.platform() === 'win32';
const launchScript = createLaunchScript(
command[0],
command.slice(1),
options.environment,
);
return Bluebird.using(
tmpFileDisposer({ postfix: '.cmd' }),
async ({ path }) => {
await fs.writeFile(path, launchScript);
if (isWindows) {
return elevateScriptWindows(path);
}
if (
os.platform() === 'darwin' &&
semver.compare(os.release(), '19.0.0') >= 0
) {
// >= macOS Catalina
return elevateScriptCatalina(path);
}
try {
return await elevateScriptUnix(path, options.applicationName);
} catch (error) {
// We're hardcoding internal error messages declared by `sudo-prompt`.
// There doesn't seem to be a better way to handle these errors, so
// for now, we should make sure we double check if the error messages
// have changed every time we upgrade `sudo-prompt`.
console.log('error', error);
if (_.includes(error.message, 'is not in the sudoers file')) {
throw errors.createUserError({
title: "Your user doesn't have enough privileges to proceed",
description:
'This application requires sudo privileges to be able to write to drives',
});
} else if (_.startsWith(error.message, 'Command failed:')) {
throw errors.createUserError({
title: 'The elevated process died unexpectedly',
description: `The process error code was ${error.code}`,
});
} else if (error.message === 'User did not grant permission.') {
return { cancelled: true };
} else if (error.message === 'No polkit authentication agent found.') {
throw errors.createUserError({
title: 'No polkit authentication agent found',
description:
'Please install a polkit authentication agent for your desktop environment of choice to continue',
});
}
throw error;
}
},
);
}

View File

@ -1,150 +0,0 @@
/*
* Copyright 2016 balena.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 sdk = require('etcher-sdk')
const _ = require('lodash')
const mime = require('mime-types')
const path = require('path')
const fileExtensions = require('./file-extensions')
/**
* @summary Get compressed extensions
* @function
* @public
*
* @returns {String[]} compressed extensions
*
* @example
* _.each(supportedFormats.getCompressedExtensions(), (extension) => {
* console.log('We support the ' + extension + ' compressed file format');
* });
*/
exports.getCompressedExtensions = () => {
const result = []
for (const [ mimetype, cls ] of sdk.sourceDestination.SourceDestination.mimetypes.entries()) {
if (cls.prototype instanceof sdk.sourceDestination.CompressedSource) {
const extension = mime.extension(mimetype)
if (extension) {
result.push(extension)
}
}
}
return result
}
/**
* @summary Get non compressed extensions
* @function
* @public
*
* @returns {String[]} no compressed extensions
*
* @example
* _.each(supportedFormats.getNonCompressedExtensions(), (extension) => {
* console.log('We support the ' + extension + ' file format');
* });
*/
exports.getNonCompressedExtensions = () => {
return sdk.sourceDestination.SourceDestination.imageExtensions
}
/**
* @summary Get archive extensions
* @function
* @public
*
* @returns {String[]} archive extensions
*
* @example
* _.each(supportedFormats.getArchiveExtensions(), (extension) => {
* console.log('We support the ' + extension + ' file format');
* });
*/
exports.getArchiveExtensions = () => {
return [ 'zip', 'etch' ]
}
/**
* @summary Get all supported extensions
* @function
* @public
*
* @returns {String[]} extensions
*
* @example
* _.each(supportedFormats.getAllExtensions(), (extension) => {
* console.log('We support the ' + extension + ' format');
* });
*/
exports.getAllExtensions = () => {
return [ ...exports.getArchiveExtensions(), ...exports.getNonCompressedExtensions(), ...exports.getCompressedExtensions() ]
}
/**
* @summary Check if an image is supported
* @function
* @public
*
* @param {String} imagePath - image path
* @returns {Boolean} whether the image is supported
*
* @example
* if (supportedFormats.isSupportedImage('foo.iso.bz2')) {
* console.log('The image is supported!');
* }
*/
exports.isSupportedImage = (imagePath) => {
const lastExtension = fileExtensions.getLastFileExtension(imagePath)
const penultimateExtension = fileExtensions.getPenultimateFileExtension(imagePath)
if (_.some([
_.includes(exports.getNonCompressedExtensions(), lastExtension),
_.includes(exports.getArchiveExtensions(), lastExtension)
])) {
return true
}
if (_.every([
_.includes(exports.getCompressedExtensions(), lastExtension),
_.includes(exports.getNonCompressedExtensions(), penultimateExtension)
])) {
return true
}
return _.isNil(penultimateExtension) &&
_.includes(exports.getCompressedExtensions(), lastExtension)
}
/**
* @summary Check if an image seems to be a Windows image
* @function
* @public
*
* @param {String} imagePath - image path
* @returns {Boolean} whether the image seems to be a Windows image
*
* @example
* if (supportedFormats.looksLikeWindowsImage('path/to/en_windows_7_ultimate_with_sp1_x86_dvd_u_677460.iso')) {
* console.log('Looks like a Windows image');
* }
*/
exports.looksLikeWindowsImage = (imagePath) => {
const regex = /windows|win7|win8|win10|winxp/i
return regex.test(path.basename(imagePath))
}

View File

@ -0,0 +1,91 @@
/*
* Copyright 2016 balena.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.
*/
import * as sdk from 'etcher-sdk';
import * as _ from 'lodash';
import * as mime from 'mime-types';
import * as path from 'path';
import {
getLastFileExtension,
getPenultimateFileExtension,
} from './file-extensions';
export function getCompressedExtensions(): string[] {
const result = [];
for (const [
mimetype,
cls,
// @ts-ignore (mimetypes is private)
] of sdk.sourceDestination.SourceDestination.mimetypes.entries()) {
if (cls.prototype instanceof sdk.sourceDestination.CompressedSource) {
const extension = mime.extension(mimetype);
if (extension) {
result.push(extension);
}
}
}
return result;
}
export function getNonCompressedExtensions(): string[] {
return sdk.sourceDestination.SourceDestination.imageExtensions;
}
export function getArchiveExtensions(): string[] {
return ['zip', 'etch'];
}
export function getAllExtensions(): string[] {
return [
...getArchiveExtensions(),
...getNonCompressedExtensions(),
...getCompressedExtensions(),
];
}
export function isSupportedImage(imagePath: string): boolean {
const lastExtension = getLastFileExtension(imagePath);
const penultimateExtension = getPenultimateFileExtension(imagePath);
if (
_.some([
_.includes(getNonCompressedExtensions(), lastExtension),
_.includes(getArchiveExtensions(), lastExtension),
])
) {
return true;
}
if (
_.every([
_.includes(getCompressedExtensions(), lastExtension),
_.includes(getNonCompressedExtensions(), penultimateExtension),
])
) {
return true;
}
return (
_.isNil(penultimateExtension) &&
_.includes(getCompressedExtensions(), lastExtension)
);
}
export function looksLikeWindowsImage(imagePath: string): boolean {
const regex = /windows|win7|win8|win10|winxp/i;
return regex.test(path.basename(imagePath));
}

View File

@ -1,93 +0,0 @@
/*
* Copyright 2016 balena.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')
const prettyBytes = require('pretty-bytes')
/**
* @summary Megabyte to byte ratio
* @constant
* @private
* @type {Number}
*
* @description
* 1 MB = 1e+6 B
*/
const MEGABYTE_TO_BYTE_RATIO = 1000000
/**
* @summary Milliseconds in a day
* @constant
* @private
* @type {Number}
*
* @description
* From 24 * 60 * 60 * 1000
*/
const MILLISECONDS_IN_A_DAY = 86400000
/**
* @summary Convert bytes to megabytes
* @function
* @public
*
* @param {Number} bytes - bytes
* @returns {Number} megabytes
*
* @example
* const result = units.bytesToMegabytes(7801405440);
*/
exports.bytesToMegabytes = (bytes) => {
return bytes / MEGABYTE_TO_BYTE_RATIO
}
/**
* @summary Convert bytes to most appropriate unit string
* @function
* @public
*
* @param {Number} bytes - bytes
* @returns {String} size and unit string
*
* @example
* const humanReadable = units.bytesToClosestUnit(7801405440);
* > '7.8 GB'
*/
exports.bytesToClosestUnit = (bytes) => {
if (_.isNumber(bytes)) {
return prettyBytes(bytes)
}
return null
}
/**
* @summary Convert days to milliseconds
* @function
* @public
*
* @param {Number} days - days
* @returns {Number} milliseconds
*
* @example
* const result = units.daysToMilliseconds(2);
*/
exports.daysToMilliseconds = (days) => {
return days * MILLISECONDS_IN_A_DAY
}

View File

@ -14,27 +14,23 @@
* limitations under the License.
*/
'use strict'
import * as _ from 'lodash';
import * as prettyBytes from 'pretty-bytes';
const _ = require('lodash')
const analytics = require('../modules/analytics')
const osDialog = require('../os/dialog')
const MEGABYTE_TO_BYTE_RATIO = 1000000;
const MILLISECONDS_IN_A_DAY = 86400000;
/**
* @summary Report an exception
* @function
* @public
*
* @param {Error} exception - exception
*
* @example
* exceptionReporter.report(new Error('Something happened'));
*/
exports.report = (exception) => {
if (_.isUndefined(exception)) {
return
}
osDialog.showError(exception)
analytics.logException(exception)
export function bytesToMegabytes(bytes: number): number {
return bytes / MEGABYTE_TO_BYTE_RATIO;
}
export function bytesToClosestUnit(bytes: number): string | null {
if (_.isNumber(bytes)) {
return prettyBytes(bytes);
}
return null;
}
export function daysToMilliseconds(days: number): number {
return days * MILLISECONDS_IN_A_DAY;
}

Some files were not shown because too many files have changed in this diff Show More