mirror of
https://github.com/balena-io/etcher.git
synced 2025-04-24 15:27:17 +00:00
Merge pull request #3026 from balena-io/remove-remaining-angular
Remove remaining angular and convert everything to typescript
This commit is contained in:
commit
98611267d5
18
Makefile
18
Makefile
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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
341
lib/gui/app/app.ts
Normal 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'));
|
@ -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
|
292
lib/gui/app/components/drive-selector/DriveSelectorModal.tsx
Normal file
292
lib/gui/app/components/drive-selector/DriveSelectorModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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
|
143
lib/gui/app/components/drive-selector/target-selector.tsx
Normal file
143
lib/gui/app/components/drive-selector/target-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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
|
57
lib/gui/app/components/featured-project/featured-project.tsx
Normal file
57
lib/gui/app/components/featured-project/featured-project.tsx
Normal 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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
||||
|
@ -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>',
|
||||
});
|
||||
});
|
@ -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));
|
@ -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));
|
@ -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
|
413
lib/gui/app/components/image-selector/image-selector.tsx
Normal file
413
lib/gui/app/components/image-selector/image-selector.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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
|
145
lib/gui/app/components/progress-button/progress-button.tsx
Normal file
145
lib/gui/app/components/progress-button/progress-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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
|
@ -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;
|
||||
}
|
||||
}
|
@ -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
|
@ -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
|
213
lib/gui/app/components/safe-webview/safe-webview.tsx
Normal file
213
lib/gui/app/components/safe-webview/safe-webview.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -1,9 +0,0 @@
|
||||
|
||||
svg-icon {
|
||||
display: inline-block;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
@ -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
|
142
lib/gui/app/components/svg-icon/svg-icon.tsx
Normal file
142
lib/gui/app/components/svg-icon/svg-icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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
|
||||
}
|
@ -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;
|
||||
}
|
@ -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
|
@ -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
|
||||
}
|
131
lib/gui/app/models/flash-state.ts
Normal file
131
lib/gui/app/models/flash-state.ts
Normal 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;
|
||||
}
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
73
lib/gui/app/models/local-settings.ts
Normal file
73
lib/gui/app/models/local-settings.ts
Normal 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);
|
||||
}
|
@ -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)
|
||||
}
|
161
lib/gui/app/models/selection-state.ts
Normal file
161
lib/gui/app/models/selection-state.ts
Normal 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);
|
||||
}
|
@ -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)
|
||||
}
|
141
lib/gui/app/models/settings.ts
Normal file
141
lib/gui/app/models/settings.ts
Normal 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);
|
||||
}
|
@ -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
|
@ -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
565
lib/gui/app/models/store.ts
Normal 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);
|
||||
}
|
@ -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
|
123
lib/gui/app/modules/analytics.ts
Normal file
123
lib/gui/app/modules/analytics.ts
Normal 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;
|
@ -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);
|
@ -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);
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
350
lib/gui/app/modules/image-writer.ts
Normal file
350
lib/gui/app/modules/image-writer.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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)}`)
|
||||
}
|
80
lib/gui/app/modules/progress-status.ts
Normal file
80
lib/gui/app/modules/progress-status.ts
Normal 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)}`);
|
||||
}
|
@ -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()
|
188
lib/gui/app/modules/update-lock.ts
Normal file
188
lib/gui/app/modules/update-lock.ts
Normal 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();
|
@ -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
104
lib/gui/app/os/dialog.ts
Normal 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);
|
||||
}
|
@ -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)
|
||||
}
|
@ -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 });
|
||||
}
|
@ -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
|
@ -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);
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
65
lib/gui/app/os/window-progress.ts
Normal file
65
lib/gui/app/os/window-progress.ts
Normal 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));
|
||||
}
|
@ -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
|
||||
}
|
115
lib/gui/app/os/windows-network-drives.ts
Executable file
115
lib/gui/app/os/windows-network-drives.ts
Executable 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;
|
||||
}
|
@ -91,6 +91,7 @@
|
||||
color: white;
|
||||
height: 320px;
|
||||
width: 100vw;
|
||||
left: 0;
|
||||
|
||||
> * {
|
||||
display: flex;
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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>',
|
||||
});
|
||||
});
|
@ -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;
|
||||
|
@ -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;
|
||||
`
|
@ -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
65
lib/gui/app/theme.ts
Normal 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',
|
||||
},
|
||||
};
|
@ -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
|
||||
}
|
51
lib/gui/app/utils/middle-ellipsis.ts
Normal file
51
lib/gui/app/utils/middle-ellipsis.ts
Normal 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;
|
||||
}
|
@ -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;
|
||||
|
@ -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
169
lib/gui/etcher.ts
Normal 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');
|
129
lib/gui/menu.js
129
lib/gui/menu.js
@ -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
131
lib/gui/menu.ts
Normal 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);
|
||||
}
|
@ -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', {})
|
||||
})
|
||||
})
|
260
lib/gui/modules/child-writer.ts
Normal file
260
lib/gui/modules/child-writer.ts
Normal 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', {});
|
||||
});
|
||||
});
|
@ -1,7 +1,5 @@
|
||||
#!/usr/bin/env osascript -l JavaScript
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
ObjC.import('stdlib')
|
||||
|
||||
const app = Application.currentApplication()
|
||||
|
@ -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
|
||||
}
|
||||
}
|
70
lib/shared/catalina-sudo/sudo.ts
Normal file
70
lib/shared/catalina-sudo/sudo.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
278
lib/shared/drive-constraints.ts
Normal file
278
lib/shared/drive-constraints.ts
Normal 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);
|
||||
}
|
@ -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
264
lib/shared/errors.ts
Normal 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);
|
||||
}
|
@ -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
|
||||
|
||||
}
|
@ -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;
|
@ -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;
|
||||
}
|
@ -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
192
lib/shared/messages.ts
Normal 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(' ');
|
||||
},
|
||||
};
|
@ -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
209
lib/shared/permissions.ts
Executable 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;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
@ -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))
|
||||
}
|
91
lib/shared/supported-formats.ts
Normal file
91
lib/shared/supported-formats.ts
Normal 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));
|
||||
}
|
@ -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
|
||||
}
|
@ -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
Loading…
x
Reference in New Issue
Block a user