diff --git a/lib/gui/app/app.js b/lib/gui/app/app.js index 0914b5fa..efe130cf 100644 --- a/lib/gui/app/app.js +++ b/lib/gui/app/app.js @@ -29,7 +29,12 @@ const uuidV4 = require('uuid/v4') const EXIT_CODES = require('../../shared/exit-codes') // eslint-disable-next-line node/no-missing-require const messages = require('../../shared/messages') -const store = require('./models/store') +const { + Actions, + observe, + store +// eslint-disable-next-line node/no-missing-require +} = require('./models/store') const packageJSON = require('../../../package.json') const flashState = require('./models/flash-state') // eslint-disable-next-line node/no-missing-require @@ -68,13 +73,13 @@ window.addEventListener('unhandledrejection', (event) => { // Set application session UUID store.dispatch({ - type: store.Actions.SET_APPLICATION_SESSION_UUID, + type: Actions.SET_APPLICATION_SESSION_UUID, data: uuidV4() }) // Set first flashing workflow UUID store.dispatch({ - type: store.Actions.SET_FLASHING_WORKFLOW_UUID, + type: Actions.SET_FLASHING_WORKFLOW_UUID, data: uuidV4() }) @@ -103,7 +108,7 @@ analytics.logEvent('Application start', { applicationSessionUuid }) -store.observe(() => { +observe(() => { if (!flashState.isFlashing()) { return } diff --git a/lib/gui/app/components/drive-selector/DriveSelectorModal.jsx b/lib/gui/app/components/drive-selector/DriveSelectorModal.jsx index a63931ea..1d54d451 100644 --- a/lib/gui/app/components/drive-selector/DriveSelectorModal.jsx +++ b/lib/gui/app/components/drive-selector/DriveSelectorModal.jsx @@ -25,7 +25,7 @@ const { hasListDriveImageCompatibilityStatus, COMPATIBILITY_STATUS_TYPES } = require('../../../../shared/drive-constraints') -const store = require('../../models/store') +const { store } = require('../../models/store') const analytics = require('../../modules/analytics') const availableDrives = require('../../models/available-drives') const selectionState = require('../../models/selection-state') diff --git a/lib/gui/app/components/finish/finish.tsx b/lib/gui/app/components/finish/finish.tsx index 1f670c99..462833ca 100644 --- a/lib/gui/app/components/finish/finish.tsx +++ b/lib/gui/app/components/finish/finish.tsx @@ -21,7 +21,7 @@ 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 { updateLock } from '../../modules/update-lock'; import { open as openExternal } from '../../os/open-external/services/open-external'; @@ -33,7 +33,6 @@ const restart = (options: any, goToMain: () => void) => { const { applicationSessionUuid, flashingWorkflowUuid, - // @ts-ignore } = store.getState().toJS(); if (!options.preserveImage) { selectionState.deselectImage(); diff --git a/lib/gui/app/components/image-selector/image-selector.jsx b/lib/gui/app/components/image-selector/image-selector.jsx index 421d9b9e..07faa88f 100644 --- a/lib/gui/app/components/image-selector/image-selector.jsx +++ b/lib/gui/app/components/image-selector/image-selector.jsx @@ -28,7 +28,7 @@ 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 { observe, store } = require('../../models/store') const analytics = require('../../modules/analytics') const exceptionReporter = require('../../modules/exception-reporter') const osDialog = require('../../os/dialog') @@ -108,7 +108,7 @@ class ImageSelector extends React.Component { } componentDidMount () { - this.unsubscribe = store.observe(() => { + this.unsubscribe = observe(() => { this.setState(getState()) }) } diff --git a/lib/gui/app/components/safe-webview/safe-webview.jsx b/lib/gui/app/components/safe-webview/safe-webview.jsx index 39ea9c6c..2b06fd2e 100644 --- a/lib/gui/app/components/safe-webview/safe-webview.jsx +++ b/lib/gui/app/components/safe-webview/safe-webview.jsx @@ -23,7 +23,7 @@ const electron = require('electron') const react = require('react') const propTypes = require('prop-types') const analytics = require('../../modules/analytics') -const store = require('../../models/store') +const { store } = require('../../models/store') const settings = require('../../models/settings') const packageJSON = require('../../../../../package.json') diff --git a/lib/gui/app/components/settings/settings.tsx b/lib/gui/app/components/settings/settings.tsx index 134c9ea8..c4c2ae65 100644 --- a/lib/gui/app/components/settings/settings.tsx +++ b/lib/gui/app/components/settings/settings.tsx @@ -25,7 +25,7 @@ import styled from 'styled-components'; import { version } from '../../../../../package.json'; import { Dictionary } from '../../../../shared/utils'; 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'; diff --git a/lib/gui/app/models/available-drives.js b/lib/gui/app/models/available-drives.js index 85549b45..a6f4b33f 100644 --- a/lib/gui/app/models/available-drives.js +++ b/lib/gui/app/models/available-drives.js @@ -17,7 +17,8 @@ 'use strict' const _ = require('lodash') -const store = require('./store') +// eslint-disable-next-line node/no-missing-require +const { Actions, store } = require('./store') /** * @summary Check if there are available drives @@ -50,7 +51,7 @@ exports.hasAvailableDrives = () => { */ exports.setDrives = (drives) => { store.dispatch({ - type: store.Actions.SET_AVAILABLE_DRIVES, + type: Actions.SET_AVAILABLE_DRIVES, data: drives }) } diff --git a/lib/gui/app/models/flash-state.js b/lib/gui/app/models/flash-state.js index 18f57a85..574cdfd7 100644 --- a/lib/gui/app/models/flash-state.js +++ b/lib/gui/app/models/flash-state.js @@ -17,7 +17,8 @@ 'use strict' const _ = require('lodash') -const store = require('./store') +// eslint-disable-next-line node/no-missing-require +const { Actions, store } = require('./store') // eslint-disable-next-line node/no-missing-require const units = require('../../../shared/units') @@ -31,7 +32,7 @@ const units = require('../../../shared/units') */ exports.resetState = () => { store.dispatch({ - type: store.Actions.RESET_FLASH_STATE + type: Actions.RESET_FLASH_STATE }) } @@ -67,7 +68,7 @@ exports.isFlashing = () => { */ exports.setFlashingFlag = () => { store.dispatch({ - type: store.Actions.SET_FLASHING_FLAG + type: Actions.SET_FLASHING_FLAG }) } @@ -91,7 +92,7 @@ exports.setFlashingFlag = () => { */ exports.unsetFlashingFlag = (results) => { store.dispatch({ - type: store.Actions.UNSET_FLASHING_FLAG, + type: Actions.UNSET_FLASHING_FLAG, data: results }) } @@ -141,7 +142,7 @@ exports.setProgressState = (state) => { }) store.dispatch({ - type: store.Actions.SET_FLASH_STATE, + type: Actions.SET_FLASH_STATE, data }) } diff --git a/lib/gui/app/models/selection-state.js b/lib/gui/app/models/selection-state.js index 0e32f288..b8d0eb70 100644 --- a/lib/gui/app/models/selection-state.js +++ b/lib/gui/app/models/selection-state.js @@ -17,7 +17,8 @@ 'use strict' const _ = require('lodash') -const store = require('./store') +// eslint-disable-next-line node/no-missing-require +const { Actions, store } = require('./store') const availableDrives = require('./available-drives') /** @@ -32,7 +33,7 @@ const availableDrives = require('./available-drives') */ exports.selectDrive = (driveDevice) => { store.dispatch({ - type: store.Actions.SELECT_DRIVE, + type: Actions.SELECT_DRIVE, data: driveDevice }) } @@ -100,7 +101,7 @@ exports.deselectOtherDrives = (driveDevice) => { */ exports.selectImage = (image) => { store.dispatch({ - type: store.Actions.SELECT_IMAGE, + type: Actions.SELECT_IMAGE, data: image }) } @@ -348,7 +349,7 @@ exports.hasImage = () => { */ exports.deselectDrive = (driveDevice) => { store.dispatch({ - type: store.Actions.DESELECT_DRIVE, + type: Actions.DESELECT_DRIVE, data: driveDevice }) } @@ -363,7 +364,7 @@ exports.deselectDrive = (driveDevice) => { */ exports.deselectImage = () => { store.dispatch({ - type: store.Actions.DESELECT_IMAGE + type: Actions.DESELECT_IMAGE }) } diff --git a/lib/gui/app/models/store.js b/lib/gui/app/models/store.js deleted file mode 100644 index 5a75255c..00000000 --- a/lib/gui/app/models/store.js +++ /dev/null @@ -1,555 +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') -// eslint-disable-next-line node/no-missing-require -const constraints = require('../../../shared/drive-constraints') -// eslint-disable-next-line node/no-missing-require -const supportedFormats = require('../../../shared/supported-formats') -// eslint-disable-next-line node/no-missing-require -const errors = require('../../../shared/errors') -// eslint-disable-next-line node/no-missing-require -const fileExtensions = require('../../../shared/file-extensions') -// eslint-disable-next-line node/no-missing-require -const utils = require('../../../shared/utils') -// eslint-disable-next-line node/no-missing-require -const settings = require('./settings') - -/** - * @summary Verify and throw if any state fields are nil - * @function - * @public - * - * @param {Object} object - state object - * @param {Array> | Array} 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' -], (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 - - 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) - } - - 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) -} diff --git a/lib/gui/app/models/store.ts b/lib/gui/app/models/store.ts new file mode 100644 index 00000000..d28deb4a --- /dev/null +++ b/lib/gui/app/models/store.ts @@ -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: utils.Dictionary, + 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 + + 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); +} diff --git a/lib/gui/app/modules/image-writer.js b/lib/gui/app/modules/image-writer.js index b28963d9..6c7fcf42 100644 --- a/lib/gui/app/modules/image-writer.js +++ b/lib/gui/app/modules/image-writer.js @@ -22,7 +22,8 @@ const path = require('path') const os = require('os') const ipc = require('node-ipc') const electron = require('electron') -const store = require('../models/store') +// eslint-disable-next-line node/no-missing-require +const { store } = require('../models/store') // eslint-disable-next-line node/no-missing-require const settings = require('../models/settings') const flashState = require('../models/flash-state') diff --git a/lib/gui/app/os/open-external/services/open-external.ts b/lib/gui/app/os/open-external/services/open-external.ts index dda1ca72..c843bbf9 100644 --- a/lib/gui/app/os/open-external/services/open-external.ts +++ b/lib/gui/app/os/open-external/services/open-external.ts @@ -16,7 +16,7 @@ import * as electron from 'electron'; import * as settings from '../../../models/settings'; -import * as store from '../../../models/store'; +import { store } from '../../../models/store'; import { logEvent } from '../../../modules/analytics'; /** @@ -30,7 +30,6 @@ export function open(url: string) { logEvent('Open external link', { url, - // @ts-ignore applicationSessionUuid: store.getState().toJS().applicationSessionUuid, }); diff --git a/lib/gui/app/pages/main/DriveSelector.tsx b/lib/gui/app/pages/main/DriveSelector.tsx index a3664d51..a8cff995 100644 --- a/lib/gui/app/pages/main/DriveSelector.tsx +++ b/lib/gui/app/pages/main/DriveSelector.tsx @@ -23,7 +23,7 @@ import * as TargetSelector from '../../components/drive-selector/target-selector import * as SvgIcon from '../../components/svg-icon/svg-icon.jsx'; import * as selectionState 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<{ @@ -88,7 +88,7 @@ export const DriveSelector = ({ ); React.useEffect(() => { - return (store as any).observe(() => { + return observe(() => { setStateSlice(getDriveSelectionStateSlice()); }); }, []); @@ -119,9 +119,9 @@ export const DriveSelector = ({ }} 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); diff --git a/lib/gui/app/pages/main/Flash.tsx b/lib/gui/app/pages/main/Flash.tsx index 5401a85b..8835356e 100644 --- a/lib/gui/app/pages/main/Flash.tsx +++ b/lib/gui/app/pages/main/Flash.tsx @@ -26,7 +26,7 @@ import * as SvgIcon from '../../components/svg-icon/svg-icon.jsx'; 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 { scanner as driveScanner } from '../../modules/drive-scanner'; import * as imageWriter from '../../modules/image-writer'; @@ -190,10 +190,8 @@ export const Flash = ({ shouldFlashStepBeDisabled, goToSuccess }: any) => { 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(); diff --git a/lib/gui/app/pages/main/MainPage.tsx b/lib/gui/app/pages/main/MainPage.tsx index 90008e8d..e7e028f0 100644 --- a/lib/gui/app/pages/main/MainPage.tsx +++ b/lib/gui/app/pages/main/MainPage.tsx @@ -30,7 +30,7 @@ import * as SvgIcon from '../../components/svg-icon/svg-icon.jsx'; 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'; @@ -109,7 +109,7 @@ export class MainPage extends React.Component< } public componentDidMount() { - (store as any).observe(() => { + observe(() => { this.setState(this.stateHelper()); }); }