/* * 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 { basename } from 'path'; import * as redux from 'redux'; import { v4 as uuidV4 } from 'uuid'; import * as constraints from '../../../shared/drive-constraints'; import * as errors from '../../../shared/errors'; 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, 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']; /** * @summary SELECT_IMAGE fields that can't be nil */ const selectImageNoNilFields = ['path', 'extension']; /** * @summary Application default state */ export const DEFAULT_STATE = Immutable.fromJS({ applicationSessionUuid: '', flashingWorkflowUuid: '', availableDrives: [], selection: { devices: Immutable.OrderedSet(), }, isFlashing: false, devicePaths: [], failedDeviceErrors: [], flashResults: {}, flashState: { active: 0, failed: 0, percentage: 0, speed: null, averageSpeed: null, }, lastAverageFlashingSpeed: null, }); /** * @summary Application supported action messages */ export enum Actions { SET_DEVICE_PATHS, SET_FAILED_DEVICE_ERRORS, SET_AVAILABLE_TARGETS, SET_FLASH_STATE, RESET_FLASH_STATE, SET_FLASHING_FLAG, UNSET_FLASHING_FLAG, SELECT_TARGET, SELECT_SOURCE, DESELECT_TARGET, DESELECT_SOURCE, 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_TARGETS: { // Type: action.data : Array if (!action.data) { throw errors.createError({ title: 'Missing drives', }); } let drives = action.data; if (!_.isArray(drives) || !_.every(drives, _.isObject)) { throw errors.createError({ title: `Invalid drives: ${drives}`, }); } // Drives order is a list of devicePaths const drivesOrder = settings.getSync('drivesOrder') ?? []; drives = _.sortBy(drives, [ // System drives last (d) => !!d.isSystem, // Devices with no devicePath first (usbboot) (d) => !!d.devicePath, // Sort as defined in the drivesOrder setting if there is one (only for Linux with udev) (d) => drivesOrder.indexOf(basename(d.devicePath || '')), // Then sort by devicePath (only available on Linux with udev) or device (d) => d.devicePath || d.device, ]); 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_TARGET, data: device, }); } return accState; }, newState, ); const shouldAutoselectAll = Boolean( settings.getSync('autoSelectAllDrives'), ); 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 ( constraints.isDriveValid(drive, image) && !drive.isReadOnly && constraints.isDriveSizeRecommended(drive, image) && // We don't want to auto-select large drives except if autoSelectAllDrives is true (!constraints.isDriveSizeLarge(drive) || shouldAutoselectAll) && // We don't want to auto-select system drives !constraints.isSystemDrive(drive) ) { // Auto-select this drive return storeReducer(accState, { type: Actions.SELECT_TARGET, data: drive.device, }); } // Deselect this drive in case it still is selected return storeReducer(accState, { type: Actions.DESELECT_TARGET, 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, ['active', '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}`, }); } let ret = state.set('flashState', Immutable.fromJS(action.data)); if (action.data.type === 'flashing') { ret = ret.set('lastAverageFlashingSpeed', action.data.averageSpeed); } return ret; } case Actions.RESET_FLASH_STATE: { return state .set('isFlashing', false) .set('flashState', DEFAULT_STATE.get('flashState')) .set('flashResults', DEFAULT_STATE.get('flashResults')) .set('devicePaths', DEFAULT_STATE.get('devicePaths')) .set('failedDeviceErrors', DEFAULT_STATE.get('failedDeviceErrors')) .set( 'lastAverageFlashingSpeed', DEFAULT_STATE.get('lastAverageFlashingSpeed'), ) .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, skip: 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}`, }); } if (action.data.results) { action.data.results.averageFlashingSpeed = state.get( 'lastAverageFlashingSpeed', ); } if (action.data.skip) { return state .set('isFlashing', false) .set('flashResults', Immutable.fromJS(action.data)); } return state .set('isFlashing', false) .set('flashResults', Immutable.fromJS(action.data)) .set('flashState', DEFAULT_STATE.get('flashState')); } case Actions.SELECT_TARGET: { // 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_SOURCE: { // Type: action.data : ImageObject if (!action.data.drive) { verifyNoNilFields(action.data, selectImageNoNilFields, 'image'); } if (!_.isString(action.data.path)) { throw errors.createError({ title: `Invalid image path: ${action.data.path}`, }); } 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_TARGET, data: device, }); } return accState; }, state, ).setIn(['selection', 'image'], Immutable.fromJS(action.data)); } case Actions.DESELECT_TARGET: { // 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_SOURCE: { 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_DEVICE_PATHS: { return state.set('devicePaths', action.data); } case Actions.SET_FAILED_DEVICE_ERRORS: { return state.set('failedDeviceErrors', 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); }