diff --git a/.gitignore b/.gitignore index c9602d02..fcabc3d6 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,7 @@ node_modules # OSX files .DS_Store + +# VSCode files + +.vscode diff --git a/lib/gui/app/app.ts b/lib/gui/app/app.ts index 6443d943..ab4325f8 100644 --- a/lib/gui/app/app.ts +++ b/lib/gui/app/app.ts @@ -23,7 +23,11 @@ import * as ReactDOM from 'react-dom'; import { v4 as uuidV4 } from 'uuid'; import * as packageJSON from '../../../package.json'; -import { isDriveValid, isSourceDrive } from '../../shared/drive-constraints'; +import { + DrivelistDrive, + isDriveValid, + isSourceDrive, +} from '../../shared/drive-constraints'; import * as EXIT_CODES from '../../shared/exit-codes'; import * as messages from '../../shared/messages'; import * as availableDrives from './models/available-drives'; @@ -231,12 +235,12 @@ function prepareDrive(drive: Drive) { } } -function setDrives(drives: _.Dictionary) { +function setDrives(drives: _.Dictionary) { availableDrives.setDrives(_.values(drives)); } function getDrives() { - return _.keyBy(availableDrives.getDrives() || [], 'device'); + return _.keyBy(availableDrives.getDrives(), 'device'); } async function addDrive(drive: Drive) { diff --git a/lib/gui/app/components/target-selector/target-selector-modal.tsx b/lib/gui/app/components/drive-selector/drive-selector.tsx similarity index 52% rename from lib/gui/app/components/target-selector/target-selector-modal.tsx rename to lib/gui/app/components/drive-selector/drive-selector.tsx index 58b7d004..8bd3daef 100644 --- a/lib/gui/app/components/target-selector/target-selector-modal.tsx +++ b/lib/gui/app/components/drive-selector/drive-selector.tsx @@ -16,7 +16,7 @@ import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg'; import ChevronDownSvg from '@fortawesome/fontawesome-free/svgs/solid/chevron-down.svg'; -import { scanner, sourceDestination } from 'etcher-sdk'; +import * as sourceDestination from 'etcher-sdk/build/source-destination/'; import * as React from 'react'; import { Flex, @@ -31,25 +31,22 @@ import styled from 'styled-components'; import { getDriveImageCompatibilityStatuses, - hasListDriveImageCompatibilityStatus, isDriveValid, - TargetStatus, - Image, + DriveStatus, + DrivelistDrive, + isDriveSizeLarge, } from '../../../../shared/drive-constraints'; -import { compatibility } from '../../../../shared/messages'; -import { bytesToClosestUnit } from '../../../../shared/units'; +import { compatibility, warning } from '../../../../shared/messages'; +import * as prettyBytes from 'pretty-bytes'; import { getDrives, hasAvailableDrives } from '../../models/available-drives'; -import { - getImage, - getSelectedDrives, - isDriveSelected, -} from '../../models/selection-state'; +import { getImage, isDriveSelected } from '../../models/selection-state'; import { store } from '../../models/store'; import { logEvent, logException } from '../../modules/analytics'; import { open as openExternal } from '../../os/open-external/services/open-external'; -import { Modal, ScrollableFlex } from '../../styled-components'; +import { Alert, Modal, ScrollableFlex } from '../../styled-components'; -import TargetSVGIcon from '../../../assets/tgt.svg'; +import DriveSVGIcon from '../../../assets/tgt.svg'; +import { SourceMetadata } from '../source-selector/source-selector'; interface UsbbootDrive extends sourceDestination.UsbbootDrive { progress: number; @@ -64,47 +61,88 @@ interface DriverlessDrive { linkCTA: string; } -type Target = scanner.adapters.DrivelistDrive | DriverlessDrive | UsbbootDrive; +type Drive = DrivelistDrive | DriverlessDrive | UsbbootDrive; -function isUsbbootDrive(drive: Target): drive is UsbbootDrive { +function isUsbbootDrive(drive: Drive): drive is UsbbootDrive { return (drive as UsbbootDrive).progress !== undefined; } -function isDriverlessDrive(drive: Target): drive is DriverlessDrive { +function isDriverlessDrive(drive: Drive): drive is DriverlessDrive { return (drive as DriverlessDrive).link !== undefined; } -function isDrivelistDrive( - drive: Target, -): drive is scanner.adapters.DrivelistDrive { - return typeof (drive as scanner.adapters.DrivelistDrive).size === 'number'; +function isDrivelistDrive(drive: Drive): drive is DrivelistDrive { + return typeof (drive as DrivelistDrive).size === 'number'; } -const TargetsTable = styled(({ refFn, ...props }) => { - return ( -
- ref={refFn} {...props} /> -
- ); -})` - [data-display='table-head'] [data-display='table-cell'] { +const DrivesTable = styled(({ refFn, ...props }) => ( +
+ ref={refFn} {...props} /> +
+))` + [data-display='table-head'] + > [data-display='table-row'] + > [data-display='table-cell'] { position: sticky; top: 0; background-color: ${(props) => props.theme.colors.quartenary.light}; + + input[type='checkbox'] + div { + display: ${({ multipleSelection }) => + multipleSelection ? 'flex' : 'none'}; + } + + &:first-child { + padding-left: 15px; + } + + &:nth-child(2) { + width: 38%; + } + + &:nth-child(3) { + width: 15%; + } + + &:nth-child(4) { + width: 15%; + } + + &:nth-child(5) { + width: 32%; + } } - [data-display='table-cell']:first-child { - padding-left: 15px; - } + [data-display='table-body'] > [data-display='table-row'] { + > [data-display='table-cell']:first-child { + padding-left: 15px; + } - [data-display='table-cell']:last-child { - width: 150px; + > [data-display='table-cell']:last-child { + padding-right: 0; + } + + &[data-highlight='true'] { + &.system { + background-color: ${(props) => + props.showWarnings ? '#fff5e6' : '#e8f5fc'}; + } + + > [data-display='table-cell']:first-child { + box-shadow: none; + } + } } && [data-display='table-row'] > [data-display='table-cell'] { padding: 6px 8px; color: #2a506f; } + + input[type='checkbox'] + div { + border-radius: ${({ multipleSelection }) => + multipleSelection ? '4px' : '50%'}; + } `; function badgeShadeFromStatus(status: string) { @@ -112,6 +150,7 @@ function badgeShadeFromStatus(status: string) { case compatibility.containsImage(): return 16; case compatibility.system(): + case compatibility.tooSmall(): return 5; default: return 14; @@ -145,30 +184,42 @@ const InitProgress = styled( } `; -interface TargetSelectorModalProps extends Omit { - done: (targets: scanner.adapters.DrivelistDrive[]) => void; +export interface DriveSelectorProps + extends Omit { + multipleSelection: boolean; + showWarnings?: boolean; + cancel: () => void; + done: (drives: DrivelistDrive[]) => void; + titleLabel: string; + emptyListLabel: string; + selectedList?: DrivelistDrive[]; + updateSelectedList?: () => DrivelistDrive[]; } -interface TargetSelectorModalState { - drives: Target[]; - image: Image; +interface DriveSelectorState { + drives: Drive[]; + image?: SourceMetadata; missingDriversModal: { drive?: DriverlessDrive }; - selectedList: scanner.adapters.DrivelistDrive[]; + selectedList: DrivelistDrive[]; showSystemDrives: boolean; } -export class TargetSelectorModal extends React.Component< - TargetSelectorModalProps, - TargetSelectorModalState +function isSystemDrive(drive: Drive) { + return isDrivelistDrive(drive) && drive.isSystem; +} + +export class DriveSelector extends React.Component< + DriveSelectorProps, + DriveSelectorState > { private unsubscribe: (() => void) | undefined; - tableColumns: Array>; + tableColumns: Array>; - constructor(props: TargetSelectorModalProps) { + constructor(props: DriveSelectorProps) { super(props); const defaultMissingDriversModalState: { drive?: DriverlessDrive } = {}; - const selectedList = getSelectedDrives(); + const selectedList = this.props.selectedList || []; this.state = { drives: getDrives(), @@ -182,24 +233,33 @@ export class TargetSelectorModal extends React.Component< { field: 'description', label: 'Name', - render: (description: string, drive: Target) => { - return isDrivelistDrive(drive) && drive.isSystem ? ( - - - {description} - - ) : ( - {description} - ); + render: (description: string, drive: Drive) => { + if (isDrivelistDrive(drive)) { + const isLargeDrive = isDriveSizeLarge(drive); + const hasWarnings = + this.props.showWarnings && (isLargeDrive || drive.isSystem); + return ( + + {hasWarnings && ( + + )} + {description} + + ); + } + return {description}; }, }, { field: 'description', key: 'size', label: 'Size', - render: (_description: string, drive: Target) => { + render: (_description: string, drive: Drive) => { if (isDrivelistDrive(drive) && drive.size !== null) { - return bytesToClosestUnit(drive.size); + return prettyBytes(drive.size); } }, }, @@ -207,7 +267,7 @@ export class TargetSelectorModal extends React.Component< field: 'description', key: 'link', label: 'Location', - render: (_description: string, drive: Target) => { + render: (_description: string, drive: Drive) => { return ( {drive.displayName} @@ -229,22 +289,20 @@ export class TargetSelectorModal extends React.Component< { field: 'description', key: 'extra', - // Space as empty string would use the field name as label - label: ' ', - render: (_description: string, drive: Target) => { + // We use an empty React fragment otherwise it uses the field name as label + label: <>, + render: (_description: string, drive: Drive) => { if (isUsbbootDrive(drive)) { return this.renderProgress(drive.progress); } else if (isDrivelistDrive(drive)) { - return this.renderStatuses( - getDriveImageCompatibilityStatuses(drive, this.state.image), - ); + return this.renderStatuses(drive); } }, }, ]; } - private driveShouldBeDisabled(drive: Target, image: any) { + private driveShouldBeDisabled(drive: Drive, image?: SourceMetadata) { return ( isUsbbootDrive(drive) || isDriverlessDrive(drive) || @@ -252,8 +310,8 @@ export class TargetSelectorModal extends React.Component< ); } - private getDisplayedTargets(targets: Target[]): Target[] { - return targets.filter((drive) => { + private getDisplayedDrives(drives: Drive[]): Drive[] { + return drives.filter((drive) => { return ( isUsbbootDrive(drive) || isDriverlessDrive(drive) || @@ -264,7 +322,7 @@ export class TargetSelectorModal extends React.Component< }); } - private getDisabledTargets(drives: Target[], image: any): string[] { + private getDisabledDrives(drives: Drive[], image?: SourceMetadata): string[] { return drives .filter((drive) => this.driveShouldBeDisabled(drive, image)) .map((drive) => drive.displayName); @@ -279,14 +337,45 @@ export class TargetSelectorModal extends React.Component< ); } - private renderStatuses(statuses: TargetStatus[]) { + private warningFromStatus( + status: string, + drive: { device: string; size: number }, + ) { + switch (status) { + case compatibility.containsImage(): + return warning.sourceDrive(); + case compatibility.largeDrive(): + return warning.largeDriveSize(); + case compatibility.system(): + return warning.systemDrive(); + case compatibility.tooSmall(): + const recommendedDriveSize = + this.state.image?.recommendedDriveSize || this.state.image?.size || 0; + return warning.unrecommendedDriveSize({ recommendedDriveSize }, drive); + } + } + + private renderStatuses(drive: DrivelistDrive) { + const statuses: DriveStatus[] = getDriveImageCompatibilityStatuses( + drive, + this.state.image, + ).slice(0, 2); return ( // the column render fn expects a single Element <> {statuses.map((status) => { const badgeShade = badgeShadeFromStatus(status.message); + const warningMessage = this.warningFromStatus(status.message, { + device: drive.device, + size: drive.size || 0, + }); return ( - + {status.message} ); @@ -311,7 +400,9 @@ export class TargetSelectorModal extends React.Component< this.setState({ drives, image, - selectedList: getSelectedDrives(), + selectedList: + (this.props.updateSelectedList && this.props.updateSelectedList()) || + [], }); }); } @@ -324,24 +415,22 @@ export class TargetSelectorModal extends React.Component< const { cancel, done, ...props } = this.props; const { selectedList, drives, image, missingDriversModal } = this.state; - const displayedTargets = this.getDisplayedTargets(drives); - const disabledTargets = this.getDisabledTargets(drives, image); - const numberOfSystemDrives = drives.filter( - (drive) => isDrivelistDrive(drive) && drive.isSystem, - ).length; - const numberOfDisplayedSystemDrives = displayedTargets.filter( - (drive) => isDrivelistDrive(drive) && drive.isSystem, - ).length; + const displayedDrives = this.getDisplayedDrives(drives); + const disabledDrives = this.getDisabledDrives(drives, image); + const numberOfSystemDrives = drives.filter(isSystemDrive).length; + const numberOfDisplayedSystemDrives = displayedDrives.filter(isSystemDrive) + .length; const numberOfHiddenSystemDrives = numberOfSystemDrives - numberOfDisplayedSystemDrives; - const hasStatus = hasListDriveImageCompatibilityStatus(selectedList, image); + const hasSystemDrives = selectedList.filter(isSystemDrive).length; + const showWarnings = this.props.showWarnings && hasSystemDrives; return ( - Select target + {this.props.titleLabel} done(selectedList)} action={`Select (${selectedList.length})`} primaryButtonProps={{ - primary: !hasStatus, - warning: hasStatus, + primary: !showWarnings, + warning: showWarnings, disabled: !hasAvailableDrives(), }} {...props} @@ -372,45 +461,62 @@ export class TargetSelectorModal extends React.Component< alignItems="center" width="100%" > - - Plug a target drive + + {this.props.emptyListLabel} ) : ( - ) => { + ) => { if (t !== null) { t.setRowSelection(selectedList); } }} + multipleSelection={this.props.multipleSelection} columns={this.tableColumns} - data={displayedTargets} - disabledRows={disabledTargets} + data={displayedDrives} + disabledRows={disabledDrives} + getRowClass={(row: Drive) => + isDrivelistDrive(row) && row.isSystem ? ['system'] : [] + } rowKey="displayName" - onCheck={(rows: Target[]) => { + onCheck={(rows: Drive[]) => { + const newSelection = rows.filter(isDrivelistDrive); + if (this.props.multipleSelection) { + this.setState({ + selectedList: newSelection, + }); + return; + } this.setState({ - selectedList: rows.filter(isDrivelistDrive), + selectedList: newSelection.slice(newSelection.length - 1), }); }} - onRowClick={(row: Target) => { + onRowClick={(row: Drive) => { if ( !isDrivelistDrive(row) || this.driveShouldBeDisabled(row, image) ) { return; } - const newList = [...selectedList]; - const selectedIndex = selectedList.findIndex( - (target) => target.device === row.device, - ); - if (selectedIndex === -1) { - newList.push(row); - } else { - // Deselect if selected - newList.splice(selectedIndex, 1); + if (this.props.multipleSelection) { + const newList = [...selectedList]; + const selectedIndex = selectedList.findIndex( + (drive) => drive.device === row.device, + ); + if (selectedIndex === -1) { + newList.push(row); + } else { + // Deselect if selected + newList.splice(selectedIndex, 1); + } + this.setState({ + selectedList: newList, + }); + return; } this.setState({ - selectedList: newList, + selectedList: [row], }); }} /> @@ -418,6 +524,7 @@ export class TargetSelectorModal extends React.Component< this.setState({ showSystemDrives: true })} > @@ -428,6 +535,12 @@ export class TargetSelectorModal extends React.Component< )} )} + {this.props.showWarnings && hasSystemDrives ? ( + + Selecting your system drive is dangerous and will erase your + drive! + + ) : null} {missingDriversModal.drive !== undefined && ( diff --git a/lib/gui/app/components/drive-status-warning-modal/drive-status-warning-modal.tsx b/lib/gui/app/components/drive-status-warning-modal/drive-status-warning-modal.tsx new file mode 100644 index 00000000..4000917e --- /dev/null +++ b/lib/gui/app/components/drive-status-warning-modal/drive-status-warning-modal.tsx @@ -0,0 +1,82 @@ +import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg'; +import * as _ from 'lodash'; +import * as React from 'react'; +import { Badge, Flex, Txt, ModalProps } from 'rendition'; +import { Modal, ScrollableFlex } from '../../styled-components'; +import { middleEllipsis } from '../../utils/middle-ellipsis'; + +import * as prettyBytes from 'pretty-bytes'; +import { DriveWithWarnings } from '../../pages/main/Flash'; + +const DriveStatusWarningModal = ({ + done, + cancel, + isSystem, + drivesWithWarnings, +}: ModalProps & { + isSystem: boolean; + drivesWithWarnings: DriveWithWarnings[]; +}) => { + let warningSubtitle = 'You are about to erase an unusually large drive'; + let warningCta = 'Are you sure the selected drive is not a storage drive?'; + + if (isSystem) { + warningSubtitle = "You are about to erase your computer's drives"; + warningCta = 'Are you sure you want to flash your system drive?'; + } + return ( + + + + + + WARNING! + + + {warningSubtitle} + + {drivesWithWarnings.map((drive, i, array) => ( + <> + + {middleEllipsis(drive.description, 28)}{' '} + {drive.size && prettyBytes(drive.size) + ' '} + {drive.statuses[0].message} + + {i !== array.length - 1 ?
: null} + + ))} +
+ {warningCta} +
+
+ ); +}; + +export default DriveStatusWarningModal; diff --git a/lib/gui/app/components/reduced-flashing-infos/reduced-flashing-infos.tsx b/lib/gui/app/components/reduced-flashing-infos/reduced-flashing-infos.tsx index 527f45fc..539c3b2f 100644 --- a/lib/gui/app/components/reduced-flashing-infos/reduced-flashing-infos.tsx +++ b/lib/gui/app/components/reduced-flashing-infos/reduced-flashing-infos.tsx @@ -23,8 +23,8 @@ import { SVGIcon } from '../svg-icon/svg-icon'; import { middleEllipsis } from '../../utils/middle-ellipsis'; interface ReducedFlashingInfosProps { - imageLogo: string; - imageName: string; + imageLogo?: string; + imageName?: string; imageSize: string; driveTitle: string; driveLabel: string; @@ -40,6 +40,7 @@ export class ReducedFlashingInfos extends React.Component< } public render() { + const { imageName = '' } = this.props; return ( - {middleEllipsis(this.props.imageName, 16)} + {middleEllipsis(imageName, 16)} {this.props.imageSize} diff --git a/lib/gui/app/components/source-selector/source-selector.tsx b/lib/gui/app/components/source-selector/source-selector.tsx index 7d8fa78f..2e8dc3d6 100644 --- a/lib/gui/app/components/source-selector/source-selector.tsx +++ b/lib/gui/app/components/source-selector/source-selector.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import CopySvg from '@fortawesome/fontawesome-free/svgs/solid/copy.svg'; import FileSvg from '@fortawesome/fontawesome-free/svgs/solid/file.svg'; import LinkSvg from '@fortawesome/fontawesome-free/svgs/solid/link.svg'; import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg'; @@ -22,6 +23,7 @@ import { ipcRenderer, IpcRendererEvent } from 'electron'; import * as _ from 'lodash'; import { GPTPartition, MBRPartition } from 'partitioninfo'; import * as path from 'path'; +import * as prettyBytes from 'pretty-bytes'; import * as React from 'react'; import { Flex, @@ -37,7 +39,6 @@ import 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 } from '../../models/store'; import * as analytics from '../../modules/analytics'; @@ -57,6 +58,8 @@ import { middleEllipsis } from '../../utils/middle-ellipsis'; import { SVGIcon } from '../svg-icon/svg-icon'; import ImageSvg from '../../../assets/image.svg'; +import { DriveSelector } from '../drive-selector/drive-selector'; +import { DrivelistDrive } from '../../../../shared/drive-constraints'; const recentUrlImagesKey = 'recentUrlImages'; @@ -92,6 +95,9 @@ function setRecentUrlImages(urls: URL[]) { localStorage.setItem(recentUrlImagesKey, JSON.stringify(normalized)); } +const isURL = (imagePath: string) => + imagePath.startsWith('https://') || imagePath.startsWith('http://'); + const Card = styled(BaseCard)` hr { margin: 5px 0; @@ -117,6 +123,10 @@ function getState() { }; } +function isString(value: any): value is string { + return typeof value === 'string'; +} + const URLSelector = ({ done, cancel, @@ -152,44 +162,46 @@ const URLSelector = ({ await done(imageURL); }} > - - - Use Image URL - - ) => - setImageURL(evt.target.value) - } - /> - - {recentImages.length > 0 && ( - - Recent - - ( - { - setImageURL(recent.href); - }} - style={{ - overflowWrap: 'break-word', - }} - > - {recent.pathname.split('/').pop()} - {recent.href} - - )) - .reverse()} - /> - + + + + Use Image URL + + ) => + setImageURL(evt.target.value) + } + /> - )} + {recentImages.length > 0 && ( + + Recent + + ( + { + setImageURL(recent.href); + }} + style={{ + overflowWrap: 'break-word', + }} + > + {recent.pathname.split('/').pop()} - {recent.href} + + )) + .reverse()} + /> + + + )} +
); }; @@ -203,7 +215,12 @@ interface Flow { const FlowSelector = styled( ({ flow, ...props }: { flow: Flow; props?: ButtonProps }) => { return ( - + flow.onClick(evt)} + icon={flow.icon} + {...props} + > {flow.label} ); @@ -225,25 +242,33 @@ const FlowSelector = styled( export type Source = | typeof sourceDestination.File + | typeof sourceDestination.BlockDevice | typeof sourceDestination.Http; -export interface SourceOptions { - imagePath: string; +export interface SourceMetadata extends sourceDestination.Metadata { + hasMBR?: boolean; + partitions?: MBRPartition[] | GPTPartition[]; + path: string; + displayName: string; + description: string; SourceType: Source; + drive?: DrivelistDrive; + extension?: string; + archiveExtension?: string; } interface SourceSelectorProps { flashing: boolean; - afterSelected: (options: SourceOptions) => void; } interface SourceSelectorState { hasImage: boolean; - imageName: string; - imageSize: number; + imageName?: string; + imageSize?: number; warning: { message: string; title: string | null } | null; showImageDetails: boolean; showURLSelector: boolean; + showDriveSelector: boolean; } export class SourceSelector extends React.Component< @@ -251,7 +276,6 @@ export class SourceSelector extends React.Component< SourceSelectorState > { private unsubscribe: (() => void) | undefined; - private afterSelected: SourceSelectorProps['afterSelected']; constructor(props: SourceSelectorProps) { super(props); @@ -260,15 +284,8 @@ export class SourceSelector extends React.Component< warning: null, showImageDetails: false, showURLSelector: false, + showDriveSelector: false, }; - - this.openImageSelector = this.openImageSelector.bind(this); - this.openURLSelector = this.openURLSelector.bind(this); - this.reselectImage = this.reselectImage.bind(this); - this.onSelectImage = this.onSelectImage.bind(this); - this.onDrop = this.onDrop.bind(this); - this.showSelectedImageDetails = this.showSelectedImageDetails.bind(this); - this.afterSelected = props.afterSelected.bind(this); } public componentDidMount() { @@ -285,15 +302,28 @@ export class SourceSelector extends React.Component< } private async onSelectImage(_event: IpcRendererEvent, imagePath: string) { - const isURL = - imagePath.startsWith('https://') || imagePath.startsWith('http://'); - await this.selectImageByPath({ + await this.selectSource( imagePath, - SourceType: isURL ? sourceDestination.Http : sourceDestination.File, - }).promise; + isURL(imagePath) ? sourceDestination.Http : sourceDestination.File, + ).promise; } - private reselectImage() { + private async createSource(selected: string, SourceType: Source) { + try { + selected = await replaceWindowsNetworkDriveLetter(selected); + } catch (error) { + analytics.logException(error); + } + + if (SourceType === sourceDestination.File) { + return new sourceDestination.File({ + path: selected, + }); + } + return new sourceDestination.Http({ url: selected }); + } + + private reselectSource() { analytics.logEvent('Reselect image', { previousImage: selectionState.getImage(), }); @@ -301,144 +331,142 @@ export class SourceSelector extends React.Component< selectionState.deselectImage(); } - private selectImage( - image: sourceDestination.Metadata & { - path: string; - extension: string; - hasMBR: boolean; - }, - ) { - try { - let message = null; - let title = null; - - if (supportedFormats.looksLikeWindowsImage(image.path)) { - analytics.logEvent('Possibly Windows image', { image }); - message = messages.warning.looksLikeWindowsImage(); - title = 'Possible Windows image detected'; - } else if (!image.hasMBR) { - analytics.logEvent('Missing partition table', { image }); - title = 'Missing partition table'; - message = messages.warning.missingPartitionTable(); - } - - if (message) { - this.setState({ - warning: { - message, - title, - }, - }); - } - - 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), - }, - }); - } catch (error) { - exceptionReporter.report(error); - } - } - - private selectImageByPath({ - imagePath, - SourceType, - }: SourceOptions): { promise: Promise; cancel: () => void } { + private selectSource( + selected: string | DrivelistDrive, + SourceType: Source, + ): { promise: Promise; cancel: () => void } { let cancelled = false; return { cancel: () => { cancelled = true; }, promise: (async () => { - try { - imagePath = await replaceWindowsNetworkDriveLetter(imagePath); - } catch (error) { - analytics.logException(error); - } - if (cancelled) { - return; - } - + const sourcePath = isString(selected) ? selected : selected.device; let source; - if (SourceType === sourceDestination.File) { - source = new sourceDestination.File({ - path: imagePath, - }); - } else { - if ( - !imagePath.startsWith('https://') && - !imagePath.startsWith('http://') - ) { - const invalidImageError = errors.createUserError({ - title: 'Unsupported protocol', - description: messages.error.unsupportedProtocol(), - }); - - osDialog.showError(invalidImageError); - analytics.logEvent('Unsupported protocol', { path: imagePath }); + let metadata: SourceMetadata | undefined; + if (isString(selected)) { + if (SourceType === sourceDestination.Http && !isURL(selected)) { + this.handleError( + 'Unsupported protocol', + selected, + messages.error.unsupportedProtocol(), + ); return; } - source = new sourceDestination.Http({ url: imagePath }); + + if (supportedFormats.looksLikeWindowsImage(selected)) { + analytics.logEvent('Possibly Windows image', { image: selected }); + this.setState({ + warning: { + message: messages.warning.looksLikeWindowsImage(), + title: 'Possible Windows image detected', + }, + }); + } + source = await this.createSource(selected, SourceType); + + if (cancelled) { + return; + } + + try { + const innerSource = await source.getInnerSource(); + if (cancelled) { + return; + } + metadata = await this.getMetadata(innerSource, selected); + if (cancelled) { + return; + } + metadata.SourceType = SourceType; + + if (!metadata.hasMBR) { + analytics.logEvent('Missing partition table', { metadata }); + this.setState({ + warning: { + message: messages.warning.missingPartitionTable(), + title: 'Missing partition table', + }, + }); + } + } catch (error) { + this.handleError( + 'Error opening source', + sourcePath, + messages.error.openSource(sourcePath, error.message), + error, + ); + } finally { + try { + await source.close(); + } catch (error) { + // Noop + } + } + } else { + metadata = { + path: selected.device, + displayName: selected.displayName, + description: selected.displayName, + size: selected.size as SourceMetadata['size'], + SourceType: sourceDestination.BlockDevice, + drive: selected, + }; } - try { - const innerSource = await source.getInnerSource(); - if (cancelled) { - return; - } - const metadata = (await innerSource.getMetadata()) as sourceDestination.Metadata & { - hasMBR: boolean; - partitions: MBRPartition[] | GPTPartition[]; - path: string; - extension: string; - }; - if (cancelled) { - return; - } - const partitionTable = await innerSource.getPartitionTable(); - if (cancelled) { - return; - } - 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); - this.afterSelected({ - imagePath, - SourceType, + if (metadata !== undefined) { + selectionState.selectSource(metadata); + 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: { + ...metadata, + logo: Boolean(metadata.logo), + blockMap: Boolean(metadata.blockMap), + }, }); - } 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 handleError( + title: string, + sourcePath: string, + description: string, + error?: Error, + ) { + const imageError = errors.createUserError({ + title, + description, + }); + osDialog.showError(imageError); + if (error) { + analytics.logException(error); + return; + } + analytics.logEvent(title, { path: sourcePath }); + } + + private async getMetadata( + source: sourceDestination.SourceDestination, + selected: string | DrivelistDrive, + ) { + const metadata = (await source.getMetadata()) as SourceMetadata; + const partitionTable = await source.getPartitionTable(); + if (partitionTable) { + metadata.hasMBR = true; + metadata.partitions = partitionTable.partitions; + } else { + metadata.hasMBR = false; + } + if (isString(selected)) { + metadata.extension = path.extname(selected).slice(1); + metadata.path = selected; + } + return metadata; + } + private async openImageSelector() { analytics.logEvent('Open image selector'); @@ -450,10 +478,7 @@ export class SourceSelector extends React.Component< analytics.logEvent('Image selector closed'); return; } - await this.selectImageByPath({ - imagePath, - SourceType: sourceDestination.File, - }).promise; + await this.selectSource(imagePath, sourceDestination.File).promise; } catch (error) { exceptionReporter.report(error); } @@ -462,10 +487,7 @@ export class SourceSelector extends React.Component< private async onDrop(event: React.DragEvent) { const [file] = event.dataTransfer.files; if (file) { - await this.selectImageByPath({ - imagePath: file.path, - SourceType: sourceDestination.File, - }).promise; + await this.selectSource(file.path, sourceDestination.File).promise; } } @@ -477,6 +499,14 @@ export class SourceSelector extends React.Component< }); } + private openDriveSelector() { + analytics.logEvent('Open drive selector'); + + this.setState({ + showDriveSelector: true, + }); + } + private onDragOver(event: React.DragEvent) { // Needed to get onDrop events on div elements event.preventDefault(); @@ -500,27 +530,35 @@ export class SourceSelector extends React.Component< // TODO add a visual change when dragging a file over the selector public render() { const { flashing } = this.props; - const { showImageDetails, showURLSelector } = this.state; + const { showImageDetails, showURLSelector, showDriveSelector } = this.state; + const selectionImage = selectionState.getImage(); + let image: SourceMetadata | DrivelistDrive = + selectionImage !== undefined ? selectionImage : ({} as SourceMetadata); - const hasImage = selectionState.hasImage(); + image = image.drive ?? image; - const imagePath = selectionState.getImagePath(); - const imageBasename = hasImage ? path.basename(imagePath) : ''; - const imageName = selectionState.getImageName(); - const imageSize = selectionState.getImageSize(); - const imageLogo = selectionState.getImageLogo(); let cancelURLSelection = () => { // noop }; + image.name = image.description || image.name; + const imagePath = image.path || image.displayName || ''; + const imageBasename = path.basename(imagePath); + const imageName = image.name || ''; + const imageSize = image.size; + const imageLogo = image.logo || ''; return ( <> ) => this.onDrop(evt)} + onDragEnter={(evt: React.DragEvent) => + this.onDragEnter(evt) + } + onDragOver={(evt: React.DragEvent) => + this.onDragOver(evt) + } > - {hasImage ? ( + {selectionImage !== undefined ? ( <> this.showSelectedImageDetails()} tooltip={imageName || imageBasename} > {middleEllipsis(imageName || imageBasename, 20)} {!flashing && ( - + this.reselectSource()} + > Remove )} - {shared.bytesToClosestUnit(imageSize)} + {!_.isNil(imageSize) && ( + {prettyBytes(imageSize)} + )} ) : ( <> this.openImageSelector(), label: 'Flash from file', icon: , }} @@ -559,11 +603,19 @@ export class SourceSelector extends React.Component< this.openURLSelector(), label: 'Flash from URL', icon: , }} /> + this.openDriveSelector(), + label: 'Clone drive', + icon: , + }} + /> )} @@ -579,7 +631,7 @@ export class SourceSelector extends React.Component< action="Continue" cancel={() => { this.setState({ warning: null }); - this.reselectImage(); + this.reselectSource(); }} done={() => { this.setState({ warning: null }); @@ -625,13 +677,10 @@ export class SourceSelector extends React.Component< analytics.logEvent('URL selector closed'); } else { let promise; - ({ - promise, - cancel: cancelURLSelection, - } = this.selectImageByPath({ - imagePath: imageURL, - SourceType: sourceDestination.Http, - })); + ({ promise, cancel: cancelURLSelection } = this.selectSource( + imageURL, + sourceDestination.Http, + )); await promise; } this.setState({ @@ -640,6 +689,30 @@ export class SourceSelector extends React.Component< }} /> )} + + {showDriveSelector && ( + { + this.setState({ + showDriveSelector: false, + }); + }} + done={async (drives: DrivelistDrive[]) => { + if (drives.length) { + await this.selectSource( + drives[0], + sourceDestination.BlockDevice, + ); + } + this.setState({ + showDriveSelector: false, + }); + }} + /> + )} ); } diff --git a/lib/gui/app/components/svg-icon/svg-icon.tsx b/lib/gui/app/components/svg-icon/svg-icon.tsx index bcf2d777..3059fe8e 100644 --- a/lib/gui/app/components/svg-icon/svg-icon.tsx +++ b/lib/gui/app/components/svg-icon/svg-icon.tsx @@ -37,8 +37,9 @@ function tryParseSVGContents(contents?: string): string | undefined { } interface SVGIconProps { - // List of embedded SVG contents to be tried in succession if any fails - contents: string; + // Optional string representing the SVG contents to be tried + contents?: string; + // Fallback SVG element to show if `contents` is invalid/undefined fallback: React.FunctionComponent>; // SVG image width unit width?: string; diff --git a/lib/gui/app/components/target-selector/target-selector-button.tsx b/lib/gui/app/components/target-selector/target-selector-button.tsx index e6bd6424..1b529b45 100644 --- a/lib/gui/app/components/target-selector/target-selector-button.tsx +++ b/lib/gui/app/components/target-selector/target-selector-button.tsx @@ -15,15 +15,15 @@ */ import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg'; -import { Drive as DrivelistDrive } from 'drivelist'; import * as React from 'react'; import { Flex, FlexProps, Txt } from 'rendition'; import { getDriveImageCompatibilityStatuses, - Image, + DriveStatus, } from '../../../../shared/drive-constraints'; -import { bytesToClosestUnit } from '../../../../shared/units'; +import { compatibility, warning } from '../../../../shared/messages'; +import * as prettyBytes from 'pretty-bytes'; import { getSelectedDrives } from '../../models/selection-state'; import { ChangeButton, @@ -41,40 +41,54 @@ interface TargetSelectorProps { flashing: boolean; show: boolean; tooltip: string; - image: Image; } -function DriveCompatibilityWarning({ - drive, - image, +function getDriveWarning(status: DriveStatus) { + switch (status.message) { + case compatibility.containsImage(): + return warning.sourceDrive(); + case compatibility.largeDrive(): + return warning.largeDriveSize(); + case compatibility.system(): + return warning.systemDrive(); + default: + return ''; + } +} + +const DriveCompatibilityWarning = ({ + warnings, ...props }: { - drive: DrivelistDrive; - image: Image; -} & FlexProps) { - const compatibilityWarnings = getDriveImageCompatibilityStatuses( - drive, - image, + warnings: string[]; +} & FlexProps) => { + const systemDrive = warnings.find( + (message) => message === warning.systemDrive(), ); - if (compatibilityWarnings.length === 0) { - return null; - } - const messages = compatibilityWarnings.map((warning) => warning.message); return ( - - + + ); -} +}; -export function TargetSelector(props: TargetSelectorProps) { +export function TargetSelectorButton(props: TargetSelectorProps) { const targets = getSelectedDrives(); if (targets.length === 1) { const target = targets[0]; + const warnings = getDriveImageCompatibilityStatuses(target).map( + getDriveWarning, + ); return ( <> + {warnings.length > 0 && ( + + )} {middleEllipsis(target.description, 20)} {!props.flashing && ( @@ -82,14 +96,9 @@ export function TargetSelector(props: TargetSelectorProps) { Change )} - - - {bytesToClosestUnit(target.size)} - + {target.size != null && ( + {prettyBytes(target.size)} + )} ); } @@ -97,21 +106,22 @@ export function TargetSelector(props: TargetSelectorProps) { if (targets.length > 1) { const targetsTemplate = []; for (const target of targets) { + const warnings = getDriveImageCompatibilityStatuses(target).map( + getDriveWarning, + ); targetsTemplate.push( - + {warnings.length > 0 ? ( + + ) : null} {middleEllipsis(target.description, 14)} - {bytesToClosestUnit(target.size)} + {target.size != null && {prettyBytes(target.size)}} , ); } diff --git a/lib/gui/app/pages/main/DriveSelector.tsx b/lib/gui/app/components/target-selector/target-selector.tsx similarity index 76% rename from lib/gui/app/pages/main/DriveSelector.tsx rename to lib/gui/app/components/target-selector/target-selector.tsx index f89e4d98..2ec79ddd 100644 --- a/lib/gui/app/pages/main/DriveSelector.tsx +++ b/lib/gui/app/components/target-selector/target-selector.tsx @@ -16,9 +16,12 @@ import { scanner } from 'etcher-sdk'; import * as React from 'react'; -import { Flex } from 'rendition'; -import { TargetSelector } from '../../components/target-selector/target-selector-button'; -import { TargetSelectorModal } from '../../components/target-selector/target-selector-modal'; +import { Flex, Txt } from 'rendition'; + +import { + DriveSelector, + DriveSelectorProps, +} from '../drive-selector/drive-selector'; import { isDriveSelected, getImage, @@ -29,7 +32,10 @@ import { import * as settings from '../../models/settings'; import { observe } from '../../models/store'; import * as analytics from '../../modules/analytics'; +import { TargetSelectorButton } from './target-selector-button'; + import DriveSvg from '../../../assets/drive.svg'; +import { warning } from '../../../../shared/messages'; export const getDriveListLabel = () => { return getSelectedDrives() @@ -50,6 +56,23 @@ const getDriveSelectionStateSlice = () => ({ image: getImage(), }); +export const TargetSelectorModal = ( + props: Omit< + DriveSelectorProps, + 'titleLabel' | 'emptyListLabel' | 'multipleSelection' + >, +) => ( + +); + export const selectAllTargets = ( modalTargets: scanner.adapters.DrivelistDrive[], ) => { @@ -79,20 +102,20 @@ export const selectAllTargets = ( }); }; -interface DriveSelectorProps { +interface TargetSelectorProps { disabled: boolean; hasDrive: boolean; flashing: boolean; } -export const DriveSelector = ({ +export const TargetSelector = ({ disabled, hasDrive, flashing, -}: DriveSelectorProps) => { +}: TargetSelectorProps) => { // TODO: inject these from redux-connector const [ - { showDrivesButton, driveListLabel, targets, image }, + { showDrivesButton, driveListLabel, targets }, setStateSlice, ] = React.useState(getDriveSelectionStateSlice()); const [showTargetSelectorModal, setShowTargetSelectorModal] = React.useState( @@ -105,6 +128,7 @@ export const DriveSelector = ({ }); }, []); + const hasSystemDrives = targets.some((target) => target.isSystem); return ( - + {hasSystemDrives ? ( + + Warning: {warning.systemDrive()} + + ) : null} + {showTargetSelectorModal && ( setShowTargetSelectorModal(false)} @@ -138,7 +173,7 @@ export const DriveSelector = ({ selectAllTargets(modalTargets); setShowTargetSelectorModal(false); }} - > + /> )} ); diff --git a/lib/gui/app/css/main.css b/lib/gui/app/css/main.css index b5350722..fcf89cf1 100644 --- a/lib/gui/app/css/main.css +++ b/lib/gui/app/css/main.css @@ -19,7 +19,6 @@ src: url("./fonts/SourceSansPro-Regular.ttf") format("truetype"); font-weight: 500; font-style: normal; - font-display: block; } @font-face { @@ -27,7 +26,6 @@ src: url("./fonts/SourceSansPro-SemiBold.ttf") format("truetype"); font-weight: 600; font-style: normal; - font-display: block; } html, @@ -53,10 +51,16 @@ body { a:focus, input:focus, button:focus, -[tabindex]:focus { +[tabindex]:focus, +input[type="checkbox"] + div { outline: none !important; + box-shadow: none !important; } .disabled { opacity: 0.4; } + +#rendition-tooltip-root > div { + font-family: "SourceSansPro", sans-serif; +} diff --git a/lib/gui/app/models/available-drives.ts b/lib/gui/app/models/available-drives.ts index f9b77df9..0bff74fb 100644 --- a/lib/gui/app/models/available-drives.ts +++ b/lib/gui/app/models/available-drives.ts @@ -14,21 +14,20 @@ * limitations under the License. */ -import * as _ from 'lodash'; - +import { DrivelistDrive } from '../../../shared/drive-constraints'; import { Actions, store } from './store'; export function hasAvailableDrives() { - return !_.isEmpty(getDrives()); + return getDrives().length > 0; } export function setDrives(drives: any[]) { store.dispatch({ - type: Actions.SET_AVAILABLE_DRIVES, + type: Actions.SET_AVAILABLE_TARGETS, data: drives, }); } -export function getDrives(): any[] { +export function getDrives(): DrivelistDrive[] { return store.getState().toJS().availableDrives; } diff --git a/lib/gui/app/models/leds.ts b/lib/gui/app/models/leds.ts index 6478233e..5a31e3f3 100644 --- a/lib/gui/app/models/leds.ts +++ b/lib/gui/app/models/leds.ts @@ -14,11 +14,13 @@ * limitations under the License. */ -import { Drive as DrivelistDrive } from 'drivelist'; import * as _ from 'lodash'; import { AnimationFunction, Color, RGBLed } from 'sys-class-rgb-led'; -import { isSourceDrive } from '../../../shared/drive-constraints'; +import { + isSourceDrive, + DrivelistDrive, +} from '../../../shared/drive-constraints'; import * as settings from './settings'; import { DEFAULT_STATE, observe } from './store'; diff --git a/lib/gui/app/models/selection-state.ts b/lib/gui/app/models/selection-state.ts index 232c3de3..959cf828 100644 --- a/lib/gui/app/models/selection-state.ts +++ b/lib/gui/app/models/selection-state.ts @@ -1,3 +1,4 @@ +import { DrivelistDrive } from '../../../shared/drive-constraints'; /* * Copyright 2016 balena.io * @@ -14,7 +15,7 @@ * limitations under the License. */ -import * as _ from 'lodash'; +import { SourceMetadata } from '../components/source-selector/source-selector'; import * as availableDrives from './available-drives'; import { Actions, store } from './store'; @@ -24,7 +25,7 @@ import { Actions, store } from './store'; */ export function selectDrive(driveDevice: string) { store.dispatch({ - type: Actions.SELECT_DRIVE, + type: Actions.SELECT_TARGET, data: driveDevice, }); } @@ -40,10 +41,10 @@ export function toggleDrive(driveDevice: string) { } } -export function selectImage(image: any) { +export function selectSource(source: SourceMetadata) { store.dispatch({ - type: Actions.SELECT_IMAGE, - data: image, + type: Actions.SELECT_SOURCE, + data: source, }); } @@ -57,50 +58,38 @@ export function getSelectedDevices(): string[] { /** * @summary Get all selected drive objects */ -export function getSelectedDrives(): any[] { - const drives = availableDrives.getDrives(); - return _.map(getSelectedDevices(), (device) => { - return _.find(drives, { device }); - }); +export function getSelectedDrives(): DrivelistDrive[] { + const selectedDevices = getSelectedDevices(); + return availableDrives + .getDrives() + .filter((drive) => selectedDevices.includes(drive.device)); } /** * @summary Get the selected image */ -export function getImage() { - return _.get(store.getState().toJS(), ['selection', 'image']); +export function getImage(): SourceMetadata | undefined { + return store.getState().toJS().selection.image; } -export function getImagePath(): string { - return _.get(store.getState().toJS(), ['selection', 'image', 'path']); +export function getImagePath() { + return getImage()?.path; } -export function getImageSize(): number { - return _.get(store.getState().toJS(), ['selection', 'image', 'size']); +export function getImageSize() { + return getImage()?.size; } -export function getImageUrl(): string { - return _.get(store.getState().toJS(), ['selection', 'image', 'url']); +export function getImageName() { + return getImage()?.name; } -export function getImageName(): string { - return _.get(store.getState().toJS(), ['selection', 'image', 'name']); +export function getImageLogo() { + return getImage()?.logo; } -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', - ]); +export function getImageSupportUrl() { + return getImage()?.supportUrl; } /** @@ -114,7 +103,7 @@ export function hasDrive(): boolean { * @summary Check if there is a selected image */ export function hasImage(): boolean { - return Boolean(getImage()); + return getImage() !== undefined; } /** @@ -122,20 +111,20 @@ export function hasImage(): boolean { */ export function deselectDrive(driveDevice: string) { store.dispatch({ - type: Actions.DESELECT_DRIVE, + type: Actions.DESELECT_TARGET, data: driveDevice, }); } export function deselectImage() { store.dispatch({ - type: Actions.DESELECT_IMAGE, + type: Actions.DESELECT_SOURCE, data: {}, }); } export function deselectAllDrives() { - _.each(getSelectedDevices(), deselectDrive); + getSelectedDevices().forEach(deselectDrive); } /** @@ -155,5 +144,5 @@ export function isDriveSelected(driveDevice: string) { } const selectedDriveDevices = getSelectedDevices(); - return _.includes(selectedDriveDevices, driveDevice); + return selectedDriveDevices.includes(driveDevice); } diff --git a/lib/gui/app/models/store.ts b/lib/gui/app/models/store.ts index fe1b3fff..0a1ac58b 100644 --- a/lib/gui/app/models/store.ts +++ b/lib/gui/app/models/store.ts @@ -80,15 +80,15 @@ export const DEFAULT_STATE = Immutable.fromJS({ export enum Actions { SET_DEVICE_PATHS, SET_FAILED_DEVICE_PATHS, - SET_AVAILABLE_DRIVES, + SET_AVAILABLE_TARGETS, SET_FLASH_STATE, RESET_FLASH_STATE, SET_FLASHING_FLAG, UNSET_FLASHING_FLAG, - SELECT_DRIVE, - SELECT_IMAGE, - DESELECT_DRIVE, - DESELECT_IMAGE, + SELECT_TARGET, + SELECT_SOURCE, + DESELECT_TARGET, + DESELECT_SOURCE, SET_APPLICATION_SESSION_UUID, SET_FLASHING_WORKFLOW_UUID, } @@ -116,7 +116,7 @@ function storeReducer( action: Action, ): typeof DEFAULT_STATE { switch (action.type) { - case Actions.SET_AVAILABLE_DRIVES: { + case Actions.SET_AVAILABLE_TARGETS: { // Type: action.data : Array if (!action.data) { @@ -158,7 +158,7 @@ function storeReducer( ) { // Deselect this drive gone from availableDrives return storeReducer(accState, { - type: Actions.DESELECT_DRIVE, + type: Actions.DESELECT_TARGET, data: device, }); } @@ -206,14 +206,14 @@ function storeReducer( ) { // Auto-select this drive return storeReducer(accState, { - type: Actions.SELECT_DRIVE, + type: Actions.SELECT_TARGET, data: drive.device, }); } // Deselect this drive in case it still is selected return storeReducer(accState, { - type: Actions.DESELECT_DRIVE, + type: Actions.DESELECT_TARGET, data: drive.device, }); }, @@ -341,7 +341,7 @@ function storeReducer( .set('flashState', DEFAULT_STATE.get('flashState')); } - case Actions.SELECT_DRIVE: { + case Actions.SELECT_TARGET: { // Type: action.data : String const device = action.data; @@ -391,10 +391,12 @@ function storeReducer( // 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: { + case Actions.SELECT_SOURCE: { // Type: action.data : ImageObject - verifyNoNilFields(action.data, selectImageNoNilFields, 'image'); + if (!action.data.drive) { + verifyNoNilFields(action.data, selectImageNoNilFields, 'image'); + } if (!_.isString(action.data.path)) { throw errors.createError({ @@ -456,7 +458,7 @@ function storeReducer( !constraints.isDriveSizeRecommended(drive, action.data) ) { return storeReducer(accState, { - type: Actions.DESELECT_DRIVE, + type: Actions.DESELECT_TARGET, data: device, }); } @@ -467,7 +469,7 @@ function storeReducer( ).setIn(['selection', 'image'], Immutable.fromJS(action.data)); } - case Actions.DESELECT_DRIVE: { + case Actions.DESELECT_TARGET: { // Type: action.data : String if (!action.data) { @@ -491,7 +493,7 @@ function storeReducer( ); } - case Actions.DESELECT_IMAGE: { + case Actions.DESELECT_SOURCE: { return state.deleteIn(['selection', 'image']); } diff --git a/lib/gui/app/modules/image-writer.ts b/lib/gui/app/modules/image-writer.ts index 76c70fcd..8091ede7 100644 --- a/lib/gui/app/modules/image-writer.ts +++ b/lib/gui/app/modules/image-writer.ts @@ -25,7 +25,7 @@ import * as path from 'path'; import * as packageJSON from '../../../../package.json'; import * as errors from '../../../shared/errors'; import * as permissions from '../../../shared/permissions'; -import { SourceOptions } from '../components/source-selector/source-selector'; +import { SourceMetadata } from '../components/source-selector/source-selector'; import * as flashState from '../models/flash-state'; import * as selectionState from '../models/selection-state'; import * as settings from '../models/settings'; @@ -134,15 +134,11 @@ interface FlashResults { cancelled?: boolean; } -/** - * @summary Perform write operation - */ async function performWrite( - image: string, + image: SourceMetadata, drives: DrivelistDrive[], onProgress: sdk.multiWrite.OnProgressFunction, - source: SourceOptions, -): Promise { +): Promise<{ cancelled?: boolean }> { let cancelled = false; ipc.serve(); const { @@ -196,10 +192,9 @@ async function performWrite( ipc.server.on('ready', (_data, socket) => { ipc.server.emit(socket, 'write', { - imagePath: image, + image, destinations: drives, - source, - SourceType: source.SourceType.name, + SourceType: image.SourceType.name, validateWriteOnSuccess, autoBlockmapping, unmountOnSuccess, @@ -258,9 +253,8 @@ async function performWrite( * @summary Flash an image to drives */ export async function flash( - image: string, + image: SourceMetadata, drives: DrivelistDrive[], - source: SourceOptions, // This function is a parameter so it can be mocked in tests write = performWrite, ): Promise { @@ -287,12 +281,7 @@ export async function flash( analytics.logEvent('Flash', analyticsData); try { - const result = await write( - image, - drives, - flashState.setProgressState, - source, - ); + const result = await write(image, drives, flashState.setProgressState); flashState.unsetFlashingFlag(result); } catch (error) { flashState.unsetFlashingFlag({ cancelled: false, errorCode: error.code }); diff --git a/lib/gui/app/modules/progress-status.ts b/lib/gui/app/modules/progress-status.ts index 4dcf9780..6c48b2c2 100644 --- a/lib/gui/app/modules/progress-status.ts +++ b/lib/gui/app/modules/progress-status.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { bytesToClosestUnit } from '../../../shared/units'; +import * as prettyBytes from 'pretty-bytes'; export interface FlashState { active: number; @@ -51,7 +51,7 @@ export function fromFlashState({ } else { return { status: 'Flashing...', - position: `${bytesToClosestUnit(position)}`, + position: `${position ? prettyBytes(position) : ''}`, }; } } else if (type === 'verifying') { diff --git a/lib/gui/app/pages/main/Flash.tsx b/lib/gui/app/pages/main/Flash.tsx index 24c6e9c6..57c4b4f3 100644 --- a/lib/gui/app/pages/main/Flash.tsx +++ b/lib/gui/app/pages/main/Flash.tsx @@ -18,13 +18,11 @@ import CircleSvg from '@fortawesome/fontawesome-free/svgs/solid/circle.svg'; import * as _ from 'lodash'; import * as path from 'path'; import * as React from 'react'; -import { Flex, Modal, Txt } from 'rendition'; +import { Flex, Modal as SmallModal, Txt } from 'rendition'; import * as constraints from '../../../../shared/drive-constraints'; import * as messages from '../../../../shared/messages'; import { ProgressButton } from '../../components/progress-button/progress-button'; -import { SourceOptions } from '../../components/source-selector/source-selector'; -import { TargetSelectorModal } from '../../components/target-selector/target-selector-modal'; import * as availableDrives from '../../models/available-drives'; import * as flashState from '../../models/flash-state'; import * as selection from '../../models/selection-state'; @@ -32,30 +30,17 @@ import * as analytics from '../../modules/analytics'; import { scanner as driveScanner } from '../../modules/drive-scanner'; import * as imageWriter from '../../modules/image-writer'; import * as notification from '../../os/notification'; -import { selectAllTargets } from './DriveSelector'; +import { + selectAllTargets, + TargetSelectorModal, +} from '../../components/target-selector/target-selector'; import FlashSvg from '../../../assets/flash.svg'; +import DriveStatusWarningModal from '../../components/drive-status-warning-modal/drive-status-warning-modal'; const COMPLETED_PERCENTAGE = 100; const SPEED_PRECISION = 2; -const getWarningMessages = (drives: any, image: any) => { - const warningMessages = []; - for (const drive of drives) { - if (constraints.isDriveSizeLarge(drive)) { - warningMessages.push(messages.warning.largeDriveSize(drive)); - } else if (!constraints.isDriveSizeRecommended(drive, image)) { - warningMessages.push( - messages.warning.unrecommendedDriveSize(image, drive), - ); - } - - // TODO(Shou): we should consider adding the same warning dialog for system drives and remove unsafe mode - } - - return warningMessages; -}; - const getErrorMessageFromCode = (errorCode: string) => { // TODO: All these error codes to messages translations // should go away if the writer emitted user friendly @@ -77,12 +62,11 @@ const getErrorMessageFromCode = (errorCode: string) => { async function flashImageToDrive( isFlashing: boolean, goToSuccess: () => void, - sourceOptions: SourceOptions, ): Promise { const devices = selection.getSelectedDevices(); const image: any = selection.getImage(); - const drives = _.filter(availableDrives.getDrives(), (drive: any) => { - return _.includes(devices, drive.device); + const drives = availableDrives.getDrives().filter((drive: any) => { + return devices.includes(drive.device); }); if (drives.length === 0 || isFlashing) { @@ -96,7 +80,7 @@ async function flashImageToDrive( const iconPath = path.join('media', 'icon.png'); const basename = path.basename(image.path); try { - await imageWriter.flash(image.path, drives, sourceOptions); + await imageWriter.flash(image, drives); if (!flashState.wasLastFlashCancelled()) { const flashResults: any = flashState.getFlashResults(); notification.send( @@ -132,7 +116,7 @@ async function flashImageToDrive( } const formatSeconds = (totalSeconds: number) => { - if (!totalSeconds && !_.isNumber(totalSeconds)) { + if (typeof totalSeconds !== 'number' || !Number.isFinite(totalSeconds)) { return ''; } const minutes = Math.floor(totalSeconds / 60); @@ -144,7 +128,6 @@ const formatSeconds = (totalSeconds: number) => { interface FlashStepProps { shouldFlashStepBeDisabled: boolean; goToSuccess: () => void; - source: SourceOptions; isFlashing: boolean; style?: React.CSSProperties; // TODO: factorize @@ -156,10 +139,16 @@ interface FlashStepProps { eta?: number; } +export interface DriveWithWarnings extends constraints.DrivelistDrive { + statuses: constraints.DriveStatus[]; +} + interface FlashStepState { - warningMessages: string[]; + warningMessage: boolean; errorMessage: string; showDriveSelectorModal: boolean; + systemDrives: boolean; + drivesWithWarnings: DriveWithWarnings[]; } export class FlashStep extends React.PureComponent< @@ -169,14 +158,16 @@ export class FlashStep extends React.PureComponent< constructor(props: FlashStepProps) { super(props); this.state = { - warningMessages: [], + warningMessage: false, errorMessage: '', showDriveSelectorModal: false, + systemDrives: false, + drivesWithWarnings: [], }; } private async handleWarningResponse(shouldContinue: boolean) { - this.setState({ warningMessages: [] }); + this.setState({ warningMessage: false }); if (!shouldContinue) { this.setState({ showDriveSelectorModal: true }); return; @@ -185,7 +176,6 @@ export class FlashStep extends React.PureComponent< errorMessage: await flashImageToDrive( this.props.isFlashing, this.props.goToSuccess, - this.props.source, ), }); } @@ -200,35 +190,45 @@ export class FlashStep extends React.PureComponent< } } - private hasListWarnings(drives: any[], image: any) { + private hasListWarnings(drives: any[]) { if (drives.length === 0 || flashState.isFlashing()) { return; } - return constraints.hasListDriveImageCompatibilityStatus(drives, image); + return drives.filter((drive) => drive.isSystem).length > 0; } private async tryFlash() { - const devices = selection.getSelectedDevices(); - const image = selection.getImage(); - const drives = _.filter( - availableDrives.getDrives(), - (drive: { device: string }) => { - return _.includes(devices, drive.device); - }, - ); + const drives = selection.getSelectedDrives().map((drive) => { + return { + ...drive, + statuses: constraints.getDriveImageCompatibilityStatuses(drive), + }; + }); if (drives.length === 0 || this.props.isFlashing) { return; } - const hasDangerStatus = this.hasListWarnings(drives, image); + const hasDangerStatus = drives.some((drive) => drive.statuses.length > 0); if (hasDangerStatus) { - this.setState({ warningMessages: getWarningMessages(drives, image) }); + const systemDrives = drives.some((drive) => + drive.statuses.includes(constraints.statuses.system), + ); + this.setState({ + systemDrives, + drivesWithWarnings: drives.filter((driveWithWarnings) => { + return ( + driveWithWarnings.isSystem || + (!systemDrives && + driveWithWarnings.statuses.includes(constraints.statuses.large)) + ); + }), + warningMessage: true, + }); return; } this.setState({ errorMessage: await flashImageToDrive( this.props.isFlashing, this.props.goToSuccess, - this.props.source, ), }); } @@ -256,13 +256,8 @@ export class FlashStep extends React.PureComponent< position={this.props.position} disabled={this.props.shouldFlashStepBeDisabled} cancel={imageWriter.cancel} - warning={this.hasListWarnings( - selection.getSelectedDrives(), - selection.getImage(), - )} - callback={() => { - this.tryFlash(); - }} + warning={this.hasListWarnings(selection.getSelectedDrives())} + callback={() => this.tryFlash()} /> {!_.isNil(this.props.speed) && @@ -273,9 +268,7 @@ export class FlashStep extends React.PureComponent< color="#7e8085" width="100%" > - {!_.isNil(this.props.speed) && ( - {this.props.speed.toFixed(SPEED_PRECISION)} MB/s - )} + {this.props.speed.toFixed(SPEED_PRECISION)} MB/s {!_.isNil(this.props.eta) && ( ETA: {formatSeconds(this.props.eta)} )} @@ -291,28 +284,17 @@ export class FlashStep extends React.PureComponent< )} - {this.state.warningMessages.length > 0 && ( - this.handleWarningResponse(false)} + {this.state.warningMessage && ( + this.handleWarningResponse(true)} - cancelButtonProps={{ - children: 'Change', - }} - action={'Continue'} - primaryButtonProps={{ primary: false, warning: true }} - > - {_.map(this.state.warningMessages, (message, key) => ( - - {message} - - ))} - + cancel={() => this.handleWarningResponse(false)} + isSystem={this.state.systemDrives} + drivesWithWarnings={this.state.drivesWithWarnings} + /> )} {this.state.errorMessage && ( - this.handleFlashErrorResponse(false)} @@ -320,11 +302,11 @@ export class FlashStep extends React.PureComponent< action={'Retry'} > - {_.map(this.state.errorMessage.split('\n'), (message, key) => ( + {this.state.errorMessage.split('\n').map((message, key) => (

{message}

))}
-
+ )} {this.state.showDriveSelectorModal && ( + /> )} ); diff --git a/lib/gui/app/pages/main/MainPage.tsx b/lib/gui/app/pages/main/MainPage.tsx index d4577175..47e2c9da 100644 --- a/lib/gui/app/pages/main/MainPage.tsx +++ b/lib/gui/app/pages/main/MainPage.tsx @@ -17,9 +17,8 @@ import CogSvg from '@fortawesome/fontawesome-free/svgs/solid/cog.svg'; import QuestionCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/question-circle.svg'; -import { sourceDestination } from 'etcher-sdk'; -import * as _ from 'lodash'; import * as path from 'path'; +import * as prettyBytes from 'pretty-bytes'; import * as React from 'react'; import { Flex } from 'rendition'; import styled from 'styled-components'; @@ -29,7 +28,7 @@ import { ReducedFlashingInfos } from '../../components/reduced-flashing-infos/re import { SafeWebview } from '../../components/safe-webview/safe-webview'; import { SettingsModal } from '../../components/settings/settings'; import { - SourceOptions, + SourceMetadata, SourceSelector, } from '../../components/source-selector/source-selector'; import * as flashState from '../../models/flash-state'; @@ -42,9 +41,10 @@ import { ThemedProvider, } from '../../styled-components'; -import { bytesToClosestUnit } from '../../../../shared/units'; - -import { DriveSelector, getDriveListLabel } from './DriveSelector'; +import { + TargetSelector, + getDriveListLabel, +} from '../../components/target-selector/target-selector'; import { FlashStep } from './Flash'; import EtcherSvg from '../../../assets/etcher.svg'; @@ -67,14 +67,16 @@ function getDrivesTitle() { return `${drives.length} Targets`; } -function getImageBasename() { - if (!selectionState.hasImage()) { +function getImageBasename(image?: SourceMetadata) { + if (image === undefined) { return ''; } - const selectionImageName = selectionState.getImageName(); - const imageBasename = path.basename(selectionState.getImagePath()); - return selectionImageName || imageBasename; + if (image.drive) { + return image.drive.description; + } + const imageBasename = path.basename(image.path); + return image.name || imageBasename; } const StepBorder = styled.div<{ @@ -101,9 +103,9 @@ interface MainPageStateFromStore { isFlashing: boolean; hasImage: boolean; hasDrive: boolean; - imageLogo: string; - imageSize: number; - imageName: string; + imageLogo?: string; + imageSize?: number; + imageName?: string; driveTitle: string; driveLabel: string; } @@ -112,7 +114,6 @@ interface MainPageState { current: 'main' | 'success'; isWebviewShowing: boolean; hideSettings: boolean; - source: SourceOptions; featuredProjectURL?: string; } @@ -126,10 +127,6 @@ export class MainPage extends React.Component< current: 'main', isWebviewShowing: false, hideSettings: true, - source: { - imagePath: '', - SourceType: sourceDestination.File, - }, ...this.stateHelper(), }; } @@ -141,7 +138,7 @@ export class MainPage extends React.Component< hasDrive: selectionState.hasDrive(), imageLogo: selectionState.getImageLogo(), imageSize: selectionState.getImageSize(), - imageName: getImageBasename(), + imageName: getImageBasename(selectionState.getImage()), driveTitle: getDrivesTitle(), driveLabel: getDriveListLabel(), }; @@ -243,16 +240,11 @@ export class MainPage extends React.Component< > {notFlashingOrSplitView && ( <> - - this.setState({ source }) - } - /> + - this.setState({ current: 'success' })} shouldFlashStepBeDisabled={shouldFlashStepBeDisabled} - source={this.state.source} isFlashing={this.state.isFlashing} step={state.type} percentage={state.percentage} diff --git a/lib/gui/app/styled-components.tsx b/lib/gui/app/styled-components.tsx index 1d006364..7ecd0487 100644 --- a/lib/gui/app/styled-components.tsx +++ b/lib/gui/app/styled-components.tsx @@ -16,6 +16,7 @@ import * as React from 'react'; import { + Alert as AlertBase, Flex, FlexProps, Button, @@ -25,7 +26,7 @@ import { Txt, Theme as renditionTheme, } from 'rendition'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { colors, theme } from './theme'; @@ -68,6 +69,7 @@ export const StepButton = styled((props: ButtonProps) => ( ))` color: #ffffff; + font-size: 14px; `; export const ChangeButton = styled(Button)` @@ -93,7 +95,7 @@ export const StepNameButton = styled(BaseButton)` justify-content: center; align-items: center; width: 100%; - font-weight: bold; + font-weight: normal; color: ${colors.dark.foreground}; &:enabled { @@ -119,6 +121,19 @@ export const DetailsText = (props: FlexProps) => ( /> ); +const modalFooterShadowCss = css` + overflow: auto; + background: 0, linear-gradient(rgba(255, 255, 255, 0), white 70%) 0 100%, 0, + linear-gradient(rgba(255, 255, 255, 0), rgba(221, 225, 240, 0.5) 70%) 0 100%; + background-repeat: no-repeat; + background-size: 100% 40px, 100% 40px, 100% 8px, 100% 8px; + + background-repeat: no-repeat; + background-color: white; + background-size: 100% 40px, 100% 40px, 100% 8px, 100% 8px; + background-attachment: local, local, scroll, scroll; +`; + export const Modal = styled(({ style, ...props }) => { return ( { > { }, }} style={{ - height: '86.5vh', + height: '87.5vh', ...style, }} {...props} @@ -157,27 +172,42 @@ export const Modal = styled(({ style, ...props }) => { ); })` > div { - padding: 24px 30px; - height: calc(100% - 80px); - - ::-webkit-scrollbar { - display: none; - } + padding: 0; + height: 100%; > h3 { margin: 0; + padding: 24px 30px 0; + height: 14.3%; + } + + > div:first-child { + height: 81%; + padding: 24px 30px 0; + } + + > div:nth-child(2) { + height: 61%; + + > div:not(.system-drive-alert) { + padding: 0 30px; + ${modalFooterShadowCss} + } } > div:last-child { + margin: 0; + flex-direction: ${(props) => + props.reverseFooterButtons ? 'row-reverse' : 'row'}; border-radius: 0 0 7px 7px; height: 80px; background-color: #fff; justify-content: center; - position: absolute; - bottom: 0; - left: 0; width: 100%; - box-shadow: 0 -2px 10px 0 rgba(221, 225, 240, 0.5), 0 -1px 0 0 #dde1f0; + } + + ::-webkit-scrollbar { + display: none; } } `; @@ -194,3 +224,28 @@ export const ScrollableFlex = styled(Flex)` overflow-x: visible; } `; + +export const Alert = styled((props) => ( + +))` + position: fixed; + top: -40px; + left: 50%; + transform: translate(-50%, 0px); + height: 30px; + min-width: 50%; + padding: 0px; + justify-content: center; + align-items: center; + font-size: 14px; + background-color: #fca321; + text-align: center; + + * { + color: #ffffff; + } + + > div:first-child { + display: none; + } +`; diff --git a/lib/gui/app/theme.ts b/lib/gui/app/theme.ts index 20ab7c32..e6a4ae95 100644 --- a/lib/gui/app/theme.ts +++ b/lib/gui/app/theme.ts @@ -90,20 +90,21 @@ export const theme = { opacity: 1, }, extend: () => ` - && { - width: 200px; - height: 48px; - font-size: 16px; + width: 200px; + font-size: 16px; - :disabled { + && { + height: 48px; + } + + :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}; - opacity: 1; - - :hover { - background-color: ${colors.dark.disabled.background}; - color: ${colors.dark.disabled.foreground}; - } } } `, diff --git a/lib/gui/etcher.ts b/lib/gui/etcher.ts index 044e9f13..59163bad 100644 --- a/lib/gui/etcher.ts +++ b/lib/gui/etcher.ts @@ -161,6 +161,9 @@ async function createMainWindow() { // Prevent flash of white when starting the application mainWindow.on('ready-to-show', () => { console.timeEnd('ready-to-show'); + // Electron sometimes caches the zoomFactor + // making it obnoxious to switch back-and-forth + mainWindow.webContents.setZoomFactor(width / defaultWidth); mainWindow.show(); }); diff --git a/lib/gui/modules/child-writer.ts b/lib/gui/modules/child-writer.ts index 24052220..1f60fdd7 100644 --- a/lib/gui/modules/child-writer.ts +++ b/lib/gui/modules/child-writer.ts @@ -20,10 +20,11 @@ import { cleanupTmpFiles } from 'etcher-sdk/build/tmp'; import * as ipc from 'node-ipc'; import { totalmem } from 'os'; +import { BlockDevice, File, Http } from 'etcher-sdk/build/source-destination'; import { toJSON } from '../../shared/errors'; import { GENERAL_ERROR, SUCCESS } from '../../shared/exit-codes'; import { delay } from '../../shared/utils'; -import { SourceOptions } from '../app/components/source-selector/source-selector'; +import { SourceMetadata } from '../app/components/source-selector/source-selector'; ipc.config.id = process.env.IPC_CLIENT_ID as string; ipc.config.socketRoot = process.env.IPC_SOCKET_ROOT as string; @@ -143,13 +144,12 @@ async function writeAndValidate({ } interface WriteOptions { - imagePath: string; + image: SourceMetadata; destinations: DrivelistDrive[]; unmountOnSuccess: boolean; validateWriteOnSuccess: boolean; autoBlockmapping: boolean; decompressFirst: boolean; - source: SourceOptions; SourceType: string; } @@ -228,7 +228,8 @@ ipc.connectTo(IPC_SERVER_ID, () => { }; const destinations = options.destinations.map((d) => d.device); - log(`Image: ${options.imagePath}`); + const imagePath = options.image.path; + log(`Image: ${imagePath}`); log(`Devices: ${destinations.join(', ')}`); log(`Umount on success: ${options.unmountOnSuccess}`); log(`Validate on success: ${options.validateWriteOnSuccess}`); @@ -243,18 +244,22 @@ ipc.connectTo(IPC_SERVER_ID, () => { }); }); const { SourceType } = options; - let source; - if (SourceType === sdk.sourceDestination.File.name) { - source = new sdk.sourceDestination.File({ - path: options.imagePath, - }); - } else { - source = new sdk.sourceDestination.Http({ - url: options.imagePath, - avoidRandomAccess: true, - }); - } try { + let source; + if (options.image.drive) { + source = new BlockDevice({ + drive: options.image.drive, + direct: !options.autoBlockmapping, + }); + } else { + if (SourceType === File.name) { + source = new File({ + path: imagePath, + }); + } else { + source = new Http({ url: imagePath, avoidRandomAccess: true }); + } + } const results = await writeAndValidate({ source, destinations: dests, diff --git a/lib/shared/catalina-sudo/sudo-askpass.osascript.js b/lib/shared/catalina-sudo/sudo-askpass.osascript.js index 854d1d3a..32ad4025 100755 --- a/lib/shared/catalina-sudo/sudo-askpass.osascript.js +++ b/lib/shared/catalina-sudo/sudo-askpass.osascript.js @@ -5,9 +5,9 @@ ObjC.import('stdlib') const app = Application.currentApplication() app.includeStandardAdditions = true -const result = app.displayDialog('balenaEtcher wants to make changes. Type your password to allow this.', { +const result = app.displayDialog('balenaEtcher needs privileged access in order to flash disks.\n\nType your password to allow this.', { defaultAnswer: '', - withIcon: 'stop', + withIcon: 'caution', buttons: ['Cancel', 'Ok'], defaultButton: 'Ok', hiddenAnswer: true, diff --git a/lib/shared/drive-constraints.ts b/lib/shared/drive-constraints.ts index f68d27ed..c75bd719 100644 --- a/lib/shared/drive-constraints.ts +++ b/lib/shared/drive-constraints.ts @@ -14,18 +14,26 @@ * limitations under the License. */ -import { Drive as DrivelistDrive } from 'drivelist'; +import { Drive } from 'drivelist'; import * as _ from 'lodash'; import * as pathIsInside from 'path-is-inside'; -import * as prettyBytes from 'pretty-bytes'; import * as messages from './messages'; +import { SourceMetadata } from '../gui/app/components/source-selector/source-selector'; /** * @summary The default unknown size for things such as images and drives */ const UNKNOWN_SIZE = 0; +export type DrivelistDrive = Drive & { + disabled: boolean; + name: string; + path: string; + logo: string; + displayName: string; +}; + /** * @summary Check if a drive is locked * @@ -33,22 +41,23 @@ const UNKNOWN_SIZE = 0; * This usually points out a locked SD Card. */ export function isDriveLocked(drive: DrivelistDrive): boolean { - return Boolean(_.get(drive, ['isReadOnly'], false)); + return Boolean(drive.isReadOnly); } /** * @summary Check if a drive is a system drive */ export function isSystemDrive(drive: DrivelistDrive): boolean { - return Boolean(_.get(drive, ['isSystem'], false)); + return Boolean(drive.isSystem); } -export interface Image { - path?: string; - isSizeEstimated?: boolean; - compressedSize?: number; - recommendedDriveSize?: number; - size?: number; +function sourceIsInsideDrive(source: string, drive: DrivelistDrive) { + for (const mountpoint of drive.mountpoints || []) { + if (pathIsInside(source, mountpoint.path)) { + return true; + } + } + return false; } /** @@ -60,11 +69,16 @@ export interface Image { */ export function isSourceDrive( drive: DrivelistDrive, - image: Image = {}, + selection?: SourceMetadata, ): boolean { - for (const mountpoint of drive.mountpoints || []) { - if (image.path !== undefined && pathIsInside(image.path, mountpoint.path)) { - return true; + if (selection) { + if (selection.drive) { + const sourcePath = selection.drive.devicePath || selection.drive.device; + const drivePath = drive.devicePath || drive.device; + return pathIsInside(sourcePath, drivePath); + } + if (selection.path) { + return sourceIsInsideDrive(selection.path, drive); } } return false; @@ -74,17 +88,21 @@ export function isSourceDrive( * @summary Check if a drive is large enough for an image */ export function isDriveLargeEnough( - drive: DrivelistDrive | undefined, - image: Image, + drive: DrivelistDrive, + image?: SourceMetadata, ): boolean { - const driveSize = _.get(drive, 'size') || UNKNOWN_SIZE; + const driveSize = drive.size || UNKNOWN_SIZE; - if (_.get(image, ['isSizeEstimated'])) { + if (image === undefined) { + return true; + } + + if (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)) { + if (driveSize < (image.compressedSize || UNKNOWN_SIZE)) { return false; } @@ -95,24 +113,27 @@ export function isDriveLargeEnough( return true; } - return driveSize >= _.get(image, ['size'], UNKNOWN_SIZE); + return driveSize >= (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); + return 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 { +export function isDriveValid( + drive: DrivelistDrive, + image?: SourceMetadata, +): boolean { return ( !isDriveLocked(drive) && isDriveLargeEnough(drive, image) && - !isSourceDrive(drive, image) && + !isSourceDrive(drive, image as SourceMetadata) && !isDriveDisabled(drive) ); } @@ -124,23 +145,23 @@ export function isDriveValid(drive: DrivelistDrive, image: Image): boolean { * If the image doesn't have a recommended size, this function returns true. */ export function isDriveSizeRecommended( - drive: DrivelistDrive | undefined, - image: Image, + drive: DrivelistDrive, + image?: SourceMetadata, ): boolean { - const driveSize = _.get(drive, 'size') || UNKNOWN_SIZE; - return driveSize >= _.get(image, ['recommendedDriveSize'], UNKNOWN_SIZE); + const driveSize = drive.size || UNKNOWN_SIZE; + return driveSize >= (image?.recommendedDriveSize || UNKNOWN_SIZE); } /** - * @summary 64GB + * @summary 128GB */ -export const LARGE_DRIVE_SIZE = 64e9; +export const LARGE_DRIVE_SIZE = 128e9; /** * @summary Check whether a drive's size is 'large' */ -export function isDriveSizeLarge(drive?: DrivelistDrive): boolean { - const driveSize = _.get(drive, 'size') || UNKNOWN_SIZE; +export function isDriveSizeLarge(drive: DrivelistDrive): boolean { + const driveSize = drive.size || UNKNOWN_SIZE; return driveSize > LARGE_DRIVE_SIZE; } @@ -155,6 +176,33 @@ export const COMPATIBILITY_STATUS_TYPES = { ERROR: 2, }; +export const statuses = { + locked: { + type: COMPATIBILITY_STATUS_TYPES.ERROR, + message: messages.compatibility.locked(), + }, + system: { + type: COMPATIBILITY_STATUS_TYPES.WARNING, + message: messages.compatibility.system(), + }, + containsImage: { + type: COMPATIBILITY_STATUS_TYPES.ERROR, + message: messages.compatibility.containsImage(), + }, + large: { + type: COMPATIBILITY_STATUS_TYPES.WARNING, + message: messages.compatibility.largeDrive(), + }, + small: { + type: COMPATIBILITY_STATUS_TYPES.ERROR, + message: messages.compatibility.tooSmall(), + }, + sizeNotRecommended: { + type: COMPATIBILITY_STATUS_TYPES.WARNING, + message: messages.compatibility.sizeNotRecommended(), + }, +}; + /** * @summary Get drive/image compatibility in an object * @@ -167,7 +215,7 @@ export const COMPATIBILITY_STATUS_TYPES = { */ export function getDriveImageCompatibilityStatuses( drive: DrivelistDrive, - image: Image = {}, + image?: SourceMetadata, ) { const statusList = []; @@ -182,41 +230,25 @@ export function getDriveImageCompatibilityStatuses( !_.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)), - }); + statusList.push(statuses.small); } else { - if (isSourceDrive(drive, image)) { - statusList.push({ - type: COMPATIBILITY_STATUS_TYPES.ERROR, - message: messages.compatibility.containsImage(), - }); - } - + // Avoid showing "large drive" with "system drive" status if (isSystemDrive(drive)) { - statusList.push({ - type: COMPATIBILITY_STATUS_TYPES.WARNING, - message: messages.compatibility.system(), - }); + statusList.push(statuses.system); + } else if (isDriveSizeLarge(drive)) { + statusList.push(statuses.large); } - if (isDriveSizeLarge(drive)) { - statusList.push({ - type: COMPATIBILITY_STATUS_TYPES.WARNING, - message: messages.compatibility.largeDrive(), - }); + if (isSourceDrive(drive, image as SourceMetadata)) { + statusList.push(statuses.containsImage); } - if (!_.isNil(drive) && !isDriveSizeRecommended(drive, image)) { - statusList.push({ - type: COMPATIBILITY_STATUS_TYPES.WARNING, - message: messages.compatibility.sizeNotRecommended(), - }); + if ( + image !== undefined && + !_.isNil(drive) && + !isDriveSizeRecommended(drive, image) + ) { + statusList.push(statuses.sizeNotRecommended); } } @@ -232,9 +264,9 @@ export function getDriveImageCompatibilityStatuses( */ export function getListDriveImageCompatibilityStatuses( drives: DrivelistDrive[], - image: Image, + image: SourceMetadata, ) { - return _.flatMap(drives, (drive) => { + return drives.flatMap((drive) => { return getDriveImageCompatibilityStatuses(drive, image); }); } @@ -247,36 +279,12 @@ export function getListDriveImageCompatibilityStatuses( */ export function hasDriveImageCompatibilityStatus( drive: DrivelistDrive, - image: Image, + image: SourceMetadata, ) { 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); -} - -export interface TargetStatus { +export interface DriveStatus { message: string; type: number; } diff --git a/lib/shared/messages.ts b/lib/shared/messages.ts index d88c1ff3..9bdd3372 100644 --- a/lib/shared/messages.ts +++ b/lib/shared/messages.ts @@ -15,6 +15,8 @@ */ import { Dictionary } from 'lodash'; +import { outdent } from 'outdent'; +import * as prettyBytes from 'pretty-bytes'; export const progress: Dictionary<(quantity: number) => string> = { successful: (quantity: number) => { @@ -53,11 +55,11 @@ export const info = { export const compatibility = { sizeNotRecommended: () => { - return 'Not Recommended'; + return 'Not recommended'; }, - tooSmall: (additionalSpace: string) => { - return `Insufficient space, additional ${additionalSpace} required`; + tooSmall: () => { + return 'Too small'; }, locked: () => { @@ -83,10 +85,10 @@ export const warning = { 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(' '); + return outdent({ newline: ' ' })` + This image recommends a ${prettyBytes(image.recommendedDriveSize)} + drive, however ${drive.device} is only ${prettyBytes(drive.size)}. + `; }, exitWhileFlashing: () => { @@ -115,11 +117,16 @@ export const warning = { ].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(' '); + largeDriveSize: () => { + return 'This is a large drive! Make sure it doesn\'t contain files that you want to keep.'; + }, + + systemDrive: () => { + return 'Selecting your system drive is dangerous and will erase your drive!'; + }, + + sourceDrive: () => { + return 'Contains the image you chose to flash'; }, }; @@ -143,11 +150,12 @@ export const error = { ].join(' '); }, - openImage: (imageBasename: string, errorMessage: string) => { - return [ - `Something went wrong while opening ${imageBasename}\n\n`, - `Error: ${errorMessage}`, - ].join(''); + openSource: (sourceName: string, errorMessage: string) => { + return outdent` + Something went wrong while opening ${sourceName} + + Error: ${errorMessage} + `; }, flashFailure: ( diff --git a/lib/shared/units.ts b/lib/shared/units.ts index dcf3646c..ebf31a65 100644 --- a/lib/shared/units.ts +++ b/lib/shared/units.ts @@ -14,18 +14,8 @@ * limitations under the License. */ -import * as _ from 'lodash'; -import * as prettyBytes from 'pretty-bytes'; - const MEGABYTE_TO_BYTE_RATIO = 1000000; 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; -} diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index fdb297d4..ce8d16f3 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -6683,15 +6683,15 @@ "dev": true }, "etcher-sdk": { - "version": "4.1.29", - "resolved": "https://registry.npmjs.org/etcher-sdk/-/etcher-sdk-4.1.29.tgz", - "integrity": "sha512-dMzrCFgd6WHe/tqsFapHKjTXA32YL/J+p/RnJztQeMfV3b0cQiUINp6ZX4cU6lfbL8cpRVp4y61Qo5vhMbycZw==", + "version": "4.1.30", + "resolved": "https://registry.npmjs.org/etcher-sdk/-/etcher-sdk-4.1.30.tgz", + "integrity": "sha512-HINIm5b/nOnY4v5XGRQFYQsHOSHGM/iukMm56WblsKEQPRBjZzZfHUzsyZcbsclFhw//x+iPbkDKUbf5uBpk1Q==", "dev": true, "requires": { "@balena/udif": "^1.1.0", "@ronomon/direct-io": "^3.0.1", "axios": "^0.19.2", - "balena-image-fs": "^7.0.0-remove-bluebird-9150c6c0fee21e33beef0ddaeea56ad1ce175c96", + "balena-image-fs": "^7.0.1", "blockmap": "^4.0.1", "check-disk-space": "^2.1.0", "cyclic-32": "^1.1.0", @@ -6871,9 +6871,9 @@ } }, "ext2fs": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ext2fs/-/ext2fs-2.0.1.tgz", - "integrity": "sha512-ZhnpAINB0+Lsgt5jwyAMQKe/w9L1WaNiERyGvXlO7sd9doGaxrVotyX3+ZPbyNMgPb/7wJ0zbeRp+DLAzZQdug==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/ext2fs/-/ext2fs-2.0.4.tgz", + "integrity": "sha512-7ILtkKb6j9L+nR1qO4zCiy6aZulzKu7dO82na+qXwc6KEoEr23u/u476/thebbPcvYJMv71I7FebJv8P4MNjHw==", "dev": true, "requires": { "bindings": "^1.3.0", @@ -16775,4 +16775,4 @@ "dev": true } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index c04f63c7..61eb5749 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "electron-notarize": "^1.0.0", "electron-rebuild": "^1.11.0", "electron-updater": "^4.3.2", - "etcher-sdk": "^4.1.29", + "etcher-sdk": "^4.1.30", "file-loader": "^6.0.0", "husky": "^4.2.5", "immutable": "^3.8.1", diff --git a/tests/gui/models/available-drives.spec.ts b/tests/gui/models/available-drives.spec.ts index a28fc493..81b9933e 100644 --- a/tests/gui/models/available-drives.spec.ts +++ b/tests/gui/models/available-drives.spec.ts @@ -15,6 +15,7 @@ */ import { expect } from 'chai'; +import { File } from 'etcher-sdk/build/source-destination'; import * as path from 'path'; import * as availableDrives from '../../../lib/gui/app/models/available-drives'; @@ -157,11 +158,14 @@ describe('Model: availableDrives', function () { } selectionState.clear(); - selectionState.selectImage({ + selectionState.selectSource({ + description: this.imagePath.split('/').pop(), + displayName: this.imagePath, path: this.imagePath, extension: 'img', size: 999999999, isSizeEstimated: false, + SourceType: File, recommendedDriveSize: 2000000000, }); }); diff --git a/tests/gui/models/selection-state.spec.ts b/tests/gui/models/selection-state.spec.ts index 6a690ed9..3e28f8c4 100644 --- a/tests/gui/models/selection-state.spec.ts +++ b/tests/gui/models/selection-state.spec.ts @@ -15,11 +15,13 @@ */ import { expect } from 'chai'; -import * as _ from 'lodash'; +import { File } from 'etcher-sdk/build/source-destination'; import * as path from 'path'; +import { SourceMetadata } from '../../../lib/gui/app/components/source-selector/source-selector'; import * as availableDrives from '../../../lib/gui/app/models/available-drives'; import * as selectionState from '../../../lib/gui/app/models/selection-state'; +import { DrivelistDrive } from '../../../lib/shared/drive-constraints'; describe('Model: selectionState', function () { describe('given a clean state', function () { @@ -39,10 +41,6 @@ describe('Model: selectionState', function () { expect(selectionState.getImageSize()).to.be.undefined; }); - it('getImageUrl() should return undefined', function () { - expect(selectionState.getImageUrl()).to.be.undefined; - }); - it('getImageName() should return undefined', function () { expect(selectionState.getImageName()).to.be.undefined; }); @@ -55,10 +53,6 @@ describe('Model: selectionState', function () { expect(selectionState.getImageSupportUrl()).to.be.undefined; }); - it('getImageRecommendedDriveSize() should return undefined', function () { - expect(selectionState.getImageRecommendedDriveSize()).to.be.undefined; - }); - it('hasDrive() should return false', function () { const hasDrive = selectionState.hasDrive(); expect(hasDrive).to.be.false; @@ -138,10 +132,10 @@ describe('Model: selectionState', function () { it('should queue the drive', function () { selectionState.selectDrive('/dev/disk5'); const drives = selectionState.getSelectedDevices(); - const lastDriveDevice = _.last(drives); - const lastDrive = _.find(availableDrives.getDrives(), { - device: lastDriveDevice, - }); + const lastDriveDevice = drives.pop(); + const lastDrive = availableDrives + .getDrives() + .find((drive) => drive.device === lastDriveDevice); expect(lastDrive).to.deep.equal({ device: '/dev/disk5', name: 'USB Drive', @@ -214,7 +208,7 @@ describe('Model: selectionState', function () { it('should be able to add more drives', function () { selectionState.selectDrive(this.drives[2].device); expect(selectionState.getSelectedDevices()).to.deep.equal( - _.map(this.drives, 'device'), + this.drives.map((drive: DrivelistDrive) => drive.device), ); }); @@ -234,13 +228,13 @@ describe('Model: selectionState', function () { system: true, }; - const newDrives = [..._.initial(this.drives), systemDrive]; + const newDrives = [...this.drives.slice(0, -1), systemDrive]; availableDrives.setDrives(newDrives); selectionState.selectDrive(systemDrive.device); availableDrives.setDrives(newDrives); expect(selectionState.getSelectedDevices()).to.deep.equal( - _.map(newDrives, 'device'), + newDrives.map((drive: DrivelistDrive) => drive.device), ); }); @@ -271,6 +265,12 @@ describe('Model: selectionState', function () { describe('.getSelectedDrives()', function () { it('should return the selected drives', function () { expect(selectionState.getSelectedDrives()).to.deep.equal([ + { + device: '/dev/disk2', + name: 'USB Drive 2', + size: 999999999, + isReadOnly: false, + }, { device: '/dev/sdb', description: 'DataTraveler 2.0', @@ -280,12 +280,6 @@ describe('Model: selectionState', function () { system: false, isReadOnly: false, }, - { - device: '/dev/disk2', - name: 'USB Drive 2', - size: 999999999, - isReadOnly: false, - }, ]); }); }); @@ -359,7 +353,7 @@ describe('Model: selectionState', function () { logo: 'Raspbian', }; - selectionState.selectImage(this.image); + selectionState.selectSource(this.image); }); describe('.selectDrive()', function () { @@ -399,13 +393,6 @@ describe('Model: selectionState', function () { }); }); - describe('.getImageUrl()', function () { - it('should return the image url', function () { - const imageUrl = selectionState.getImageUrl(); - expect(imageUrl).to.equal('https://www.raspbian.org'); - }); - }); - describe('.getImageName()', function () { it('should return the image name', function () { const imageName = selectionState.getImageName(); @@ -429,13 +416,6 @@ describe('Model: selectionState', function () { }); }); - describe('.getImageRecommendedDriveSize()', function () { - it('should return the image recommended drive size', function () { - const imageRecommendedDriveSize = selectionState.getImageRecommendedDriveSize(); - expect(imageRecommendedDriveSize).to.equal(1000000000); - }); - }); - describe('.hasImage()', function () { it('should return true', function () { const hasImage = selectionState.hasImage(); @@ -445,11 +425,14 @@ describe('Model: selectionState', function () { describe('.selectImage()', function () { it('should override the image', function () { - selectionState.selectImage({ + selectionState.selectSource({ + description: 'bar.img', + displayName: 'bar.img', path: 'bar.img', extension: 'img', size: 999999999, isSizeEstimated: false, + SourceType: File, }); const imagePath = selectionState.getImagePath(); @@ -475,13 +458,19 @@ describe('Model: selectionState', function () { describe('.selectImage()', function () { afterEach(selectionState.clear); + const image: SourceMetadata = { + description: 'foo.img', + displayName: 'foo.img', + path: 'foo.img', + extension: 'img', + size: 999999999, + isSizeEstimated: false, + SourceType: File, + recommendedDriveSize: 2000000000, + }; + it('should be able to set an image', function () { - selectionState.selectImage({ - path: 'foo.img', - extension: 'img', - size: 999999999, - isSizeEstimated: false, - }); + selectionState.selectSource(image); const imagePath = selectionState.getImagePath(); expect(imagePath).to.equal('foo.img'); @@ -490,12 +479,10 @@ describe('Model: selectionState', function () { }); it('should be able to set an image with an archive extension', function () { - selectionState.selectImage({ + selectionState.selectSource({ + ...image, path: 'foo.zip', - extension: 'img', archiveExtension: 'zip', - size: 999999999, - isSizeEstimated: false, }); const imagePath = selectionState.getImagePath(); @@ -503,12 +490,10 @@ describe('Model: selectionState', function () { }); it('should infer a compressed raw image if the penultimate extension is missing', function () { - selectionState.selectImage({ + selectionState.selectSource({ + ...image, path: 'foo.xz', - extension: 'img', archiveExtension: 'xz', - size: 999999999, - isSizeEstimated: false, }); const imagePath = selectionState.getImagePath(); @@ -516,54 +501,20 @@ describe('Model: selectionState', function () { }); it('should infer a compressed raw image if the penultimate extension is not a file extension', function () { - selectionState.selectImage({ + selectionState.selectSource({ + ...image, path: 'something.linux-x86-64.gz', - extension: 'img', archiveExtension: 'gz', - size: 999999999, - isSizeEstimated: false, }); const imagePath = selectionState.getImagePath(); expect(imagePath).to.equal('something.linux-x86-64.gz'); }); - it('should throw if no path', function () { - expect(function () { - selectionState.selectImage({ - extension: 'img', - size: 999999999, - isSizeEstimated: false, - }); - }).to.throw('Missing image fields: path'); - }); - - it('should throw if path is not a string', function () { - expect(function () { - selectionState.selectImage({ - path: 123, - extension: 'img', - size: 999999999, - isSizeEstimated: false, - }); - }).to.throw('Invalid image path: 123'); - }); - - it('should throw if the original size is not a number', function () { - expect(function () { - selectionState.selectImage({ - path: 'foo.img', - extension: 'img', - size: 999999999, - compressedSize: '999999999', - isSizeEstimated: false, - }); - }).to.throw('Invalid image compressed size: 999999999'); - }); - it('should throw if the original size is a float number', function () { expect(function () { - selectionState.selectImage({ + selectionState.selectSource({ + ...image, path: 'foo.img', extension: 'img', size: 999999999, @@ -575,85 +526,31 @@ describe('Model: selectionState', function () { it('should throw if the original size is negative', function () { expect(function () { - selectionState.selectImage({ - path: 'foo.img', - extension: 'img', - size: 999999999, + selectionState.selectSource({ + ...image, compressedSize: -1, - isSizeEstimated: false, }); }).to.throw('Invalid image compressed size: -1'); }); - it('should throw if the final size is not a number', function () { - expect(function () { - selectionState.selectImage({ - path: 'foo.img', - extension: 'img', - size: '999999999', - isSizeEstimated: false, - }); - }).to.throw('Invalid image size: 999999999'); - }); - it('should throw if the final size is a float number', function () { expect(function () { - selectionState.selectImage({ - path: 'foo.img', - extension: 'img', + selectionState.selectSource({ + ...image, size: 999999999.999, - isSizeEstimated: false, }); }).to.throw('Invalid image size: 999999999.999'); }); it('should throw if the final size is negative', function () { expect(function () { - selectionState.selectImage({ - path: 'foo.img', - extension: 'img', + selectionState.selectSource({ + ...image, size: -1, - isSizeEstimated: false, }); }).to.throw('Invalid image size: -1'); }); - it("should throw if url is defined but it's not a string", function () { - expect(function () { - selectionState.selectImage({ - path: 'foo.img', - extension: 'img', - size: 999999999, - isSizeEstimated: false, - url: 1234, - }); - }).to.throw('Invalid image url: 1234'); - }); - - it("should throw if name is defined but it's not a string", function () { - expect(function () { - selectionState.selectImage({ - path: 'foo.img', - extension: 'img', - size: 999999999, - isSizeEstimated: false, - name: 1234, - }); - }).to.throw('Invalid image name: 1234'); - }); - - it("should throw if logo is defined but it's not a string", function () { - expect(function () { - selectionState.selectImage({ - path: 'foo.img', - extension: 'img', - size: 999999999, - isSizeEstimated: false, - logo: 1234, - }); - }).to.throw('Invalid image logo: 1234'); - }); - it('should de-select a previously selected not-large-enough drive', function () { availableDrives.setDrives([ { @@ -667,11 +564,9 @@ describe('Model: selectionState', function () { selectionState.selectDrive('/dev/disk1'); expect(selectionState.hasDrive()).to.be.true; - selectionState.selectImage({ - path: 'foo.img', - extension: 'img', + selectionState.selectSource({ + ...image, size: 1234567890, - isSizeEstimated: false, }); expect(selectionState.hasDrive()).to.be.false; @@ -691,11 +586,8 @@ describe('Model: selectionState', function () { selectionState.selectDrive('/dev/disk1'); expect(selectionState.hasDrive()).to.be.true; - selectionState.selectImage({ - path: 'foo.img', - extension: 'img', - size: 999999999, - isSizeEstimated: false, + selectionState.selectSource({ + ...image, recommendedDriveSize: 1500000000, }); @@ -726,11 +618,11 @@ describe('Model: selectionState', function () { selectionState.selectDrive('/dev/disk1'); expect(selectionState.hasDrive()).to.be.true; - selectionState.selectImage({ + selectionState.selectSource({ + ...image, path: imagePath, extension: 'img', size: 999999999, - isSizeEstimated: false, }); expect(selectionState.hasDrive()).to.be.false; @@ -740,6 +632,16 @@ describe('Model: selectionState', function () { }); describe('given a drive and an image', function () { + const image: SourceMetadata = { + description: 'foo.img', + displayName: 'foo.img', + path: 'foo.img', + extension: 'img', + size: 999999999, + SourceType: File, + isSizeEstimated: false, + }; + beforeEach(function () { availableDrives.setDrives([ { @@ -752,12 +654,7 @@ describe('Model: selectionState', function () { selectionState.selectDrive('/dev/disk1'); - selectionState.selectImage({ - path: 'foo.img', - extension: 'img', - size: 999999999, - isSizeEstimated: false, - }); + selectionState.selectSource(image); }); describe('.clear()', function () { @@ -824,6 +721,16 @@ describe('Model: selectionState', function () { }); describe('given several drives', function () { + const image: SourceMetadata = { + description: 'foo.img', + displayName: 'foo.img', + path: 'foo.img', + extension: 'img', + size: 999999999, + SourceType: File, + isSizeEstimated: false, + }; + beforeEach(function () { availableDrives.setDrives([ { @@ -850,12 +757,7 @@ describe('Model: selectionState', function () { selectionState.selectDrive('/dev/disk2'); selectionState.selectDrive('/dev/disk3'); - selectionState.selectImage({ - path: 'foo.img', - extension: 'img', - size: 999999999, - isSizeEstimated: false, - }); + selectionState.selectSource(image); }); describe('.clear()', function () { diff --git a/tests/gui/modules/image-writer.spec.ts b/tests/gui/modules/image-writer.spec.ts index ab820cd7..4b38c844 100644 --- a/tests/gui/modules/image-writer.spec.ts +++ b/tests/gui/modules/image-writer.spec.ts @@ -20,6 +20,7 @@ import { sourceDestination } from 'etcher-sdk'; import * as ipc from 'node-ipc'; import { assert, SinonStub, stub } from 'sinon'; +import { SourceMetadata } from '../../../lib/gui/app/components/source-selector/source-selector'; import * as flashState from '../../../lib/gui/app/models/flash-state'; import * as imageWriter from '../../../lib/gui/app/modules/image-writer'; @@ -28,10 +29,14 @@ const fakeDrive: DrivelistDrive = {}; describe('Browser: imageWriter', () => { describe('.flash()', () => { - const imagePath = 'foo.img'; - const sourceOptions = { - imagePath, + const image: SourceMetadata = { + hasMBR: false, + partitions: [], + description: 'foo.img', + displayName: 'foo.img', + path: 'foo.img', SourceType: sourceDestination.File, + extension: 'img', }; describe('given a successful write', () => { @@ -58,12 +63,7 @@ describe('Browser: imageWriter', () => { }); try { - await imageWriter.flash( - imagePath, - [fakeDrive], - sourceOptions, - performWriteStub, - ); + await imageWriter.flash(image, [fakeDrive], performWriteStub); } catch { // noop } finally { @@ -79,18 +79,8 @@ describe('Browser: imageWriter', () => { try { await Promise.all([ - imageWriter.flash( - imagePath, - [fakeDrive], - sourceOptions, - performWriteStub, - ), - imageWriter.flash( - imagePath, - [fakeDrive], - sourceOptions, - performWriteStub, - ), + imageWriter.flash(image, [fakeDrive], performWriteStub), + imageWriter.flash(image, [fakeDrive], performWriteStub), ]); assert.fail('Writing twice should fail'); } catch (error) { @@ -117,12 +107,7 @@ describe('Browser: imageWriter', () => { it('should set flashing to false when done', async () => { try { - await imageWriter.flash( - imagePath, - [fakeDrive], - sourceOptions, - performWriteStub, - ); + await imageWriter.flash(image, [fakeDrive], performWriteStub); } catch { // noop } finally { @@ -132,12 +117,7 @@ describe('Browser: imageWriter', () => { it('should set the error code in the flash results', async () => { try { - await imageWriter.flash( - imagePath, - [fakeDrive], - sourceOptions, - performWriteStub, - ); + await imageWriter.flash(image, [fakeDrive], performWriteStub); } catch { // noop } finally { @@ -152,12 +132,7 @@ describe('Browser: imageWriter', () => { sourceChecksum: '1234', }); try { - await imageWriter.flash( - imagePath, - [fakeDrive], - sourceOptions, - performWriteStub, - ); + await imageWriter.flash(image, [fakeDrive], performWriteStub); } catch (error) { expect(error).to.be.an.instanceof(Error); expect(error.message).to.equal('write error'); diff --git a/tests/shared/drive-constraints.spec.ts b/tests/shared/drive-constraints.spec.ts index e5ecf1de..d557f905 100644 --- a/tests/shared/drive-constraints.spec.ts +++ b/tests/shared/drive-constraints.spec.ts @@ -15,9 +15,9 @@ */ import { expect } from 'chai'; -import { Drive as DrivelistDrive } from 'drivelist'; -import * as _ from 'lodash'; +import { sourceDestination } from 'etcher-sdk'; import * as path from 'path'; +import { SourceMetadata } from '../../lib/gui/app/components/source-selector/source-selector'; import * as constraints from '../../lib/shared/drive-constraints'; import * as messages from '../../lib/shared/messages'; @@ -29,7 +29,7 @@ describe('Shared: DriveConstraints', function () { device: '/dev/disk2', size: 999999999, isReadOnly: true, - } as DrivelistDrive); + } as constraints.DrivelistDrive); expect(result).to.be.true; }); @@ -39,7 +39,7 @@ describe('Shared: DriveConstraints', function () { device: '/dev/disk2', size: 999999999, isReadOnly: false, - } as DrivelistDrive); + } as constraints.DrivelistDrive); expect(result).to.be.false; }); @@ -48,16 +48,10 @@ describe('Shared: DriveConstraints', function () { const result = constraints.isDriveLocked({ device: '/dev/disk2', size: 999999999, - } as DrivelistDrive); + } as constraints.DrivelistDrive); expect(result).to.be.false; }); - - it('should return false if the drive is undefined', function () { - // @ts-ignore - const result = constraints.isDriveLocked(undefined); - expect(result).to.be.false; - }); }); describe('.isSystemDrive()', function () { @@ -67,7 +61,7 @@ describe('Shared: DriveConstraints', function () { size: 999999999, isReadOnly: true, isSystem: true, - } as DrivelistDrive); + } as constraints.DrivelistDrive); expect(result).to.be.true; }); @@ -77,7 +71,7 @@ describe('Shared: DriveConstraints', function () { device: '/dev/disk2', size: 999999999, isReadOnly: true, - } as DrivelistDrive); + } as constraints.DrivelistDrive); expect(result).to.be.false; }); @@ -88,16 +82,10 @@ describe('Shared: DriveConstraints', function () { size: 999999999, isReadOnly: true, isSystem: false, - } as DrivelistDrive); + } as constraints.DrivelistDrive); expect(result).to.be.false; }); - - it('should return false if the drive is undefined', function () { - // @ts-ignore - const result = constraints.isSystemDrive(undefined); - expect(result).to.be.false; - }); }); describe('.isSourceDrive()', function () { @@ -108,7 +96,7 @@ describe('Shared: DriveConstraints', function () { size: 999999999, isReadOnly: true, isSystem: false, - } as DrivelistDrive, + } as constraints.DrivelistDrive, // @ts-ignore undefined, ); @@ -123,9 +111,14 @@ describe('Shared: DriveConstraints', function () { size: 999999999, isReadOnly: true, isSystem: false, - } as DrivelistDrive, + } as constraints.DrivelistDrive, { + description: 'image.img', + displayName: 'image.img', path: '/Volumes/Untitled/image.img', + hasMBR: false, + partitions: [], + SourceType: sourceDestination.File, }, ); @@ -133,6 +126,14 @@ describe('Shared: DriveConstraints', function () { }); describe('given Windows paths', function () { + const windowsImage: SourceMetadata = { + description: 'image.img', + displayName: 'image.img', + path: 'E:\\image.img', + hasMBR: false, + partitions: [], + SourceType: sourceDestination.File, + }; beforeEach(function () { this.separator = path.sep; // @ts-ignore @@ -157,10 +158,8 @@ describe('Shared: DriveConstraints', function () { path: 'F:', }, ], - } as DrivelistDrive, - { - path: 'E:\\image.img', - }, + } as constraints.DrivelistDrive, + windowsImage, ); expect(result).to.be.true; @@ -179,8 +178,9 @@ describe('Shared: DriveConstraints', function () { path: 'F:', }, ], - } as DrivelistDrive, + } as constraints.DrivelistDrive, { + ...windowsImage, path: 'E:\\foo\\bar\\image.img', }, ); @@ -201,8 +201,9 @@ describe('Shared: DriveConstraints', function () { path: 'F:', }, ], - } as DrivelistDrive, + } as constraints.DrivelistDrive, { + ...windowsImage, path: 'G:\\image.img', }, ); @@ -219,8 +220,9 @@ describe('Shared: DriveConstraints', function () { path: 'E:\\fo', }, ], - } as DrivelistDrive, + } as constraints.DrivelistDrive, { + ...windowsImage, path: 'E:\\foo/image.img', }, ); @@ -230,6 +232,14 @@ describe('Shared: DriveConstraints', function () { }); describe('given UNIX paths', function () { + const image: SourceMetadata = { + description: 'image.img', + displayName: 'image.img', + path: '/Volumes/Untitled/image.img', + hasMBR: false, + partitions: [], + SourceType: sourceDestination.File, + }; beforeEach(function () { this.separator = path.sep; // @ts-ignore @@ -249,8 +259,9 @@ describe('Shared: DriveConstraints', function () { path: '/', }, ], - } as DrivelistDrive, + } as constraints.DrivelistDrive, { + ...image, path: '/image.img', }, ); @@ -269,8 +280,9 @@ describe('Shared: DriveConstraints', function () { path: '/Volumes/B', }, ], - } as DrivelistDrive, + } as constraints.DrivelistDrive, { + ...image, path: '/Volumes/A/image.img', }, ); @@ -289,8 +301,9 @@ describe('Shared: DriveConstraints', function () { path: '/Volumes/B', }, ], - } as DrivelistDrive, + } as constraints.DrivelistDrive, { + ...image, path: '/Volumes/A/foo/bar/image.img', }, ); @@ -309,8 +322,9 @@ describe('Shared: DriveConstraints', function () { path: '/Volumes/B', }, ], - } as DrivelistDrive, + } as constraints.DrivelistDrive, { + ...image, path: '/Volumes/C/image.img', }, ); @@ -326,8 +340,9 @@ describe('Shared: DriveConstraints', function () { path: '/Volumes/fo', }, ], - } as DrivelistDrive, + } as constraints.DrivelistDrive, { + ...image, path: '/Volumes/foo/image.img', }, ); @@ -515,35 +530,19 @@ describe('Shared: DriveConstraints', function () { }); }); - it('should return false if the drive is undefined', function () { - const result = constraints.isDriveLargeEnough(undefined, { - path: path.join(__dirname, 'rpi.img'), - size: 1000000000, - isSizeEstimated: false, - }); - - expect(result).to.be.false; - }); - it('should return true if the image is undefined', function () { const result = constraints.isDriveLargeEnough( { device: '/dev/disk1', size: 1000000000, isReadOnly: false, - } as DrivelistDrive, + } as constraints.DrivelistDrive, // @ts-ignore undefined, ); expect(result).to.be.true; }); - - it('should return false if the drive and image are undefined', function () { - // @ts-ignore - const result = constraints.isDriveLargeEnough(undefined, undefined); - expect(result).to.be.true; - }); }); describe('.isDriveDisabled()', function () { @@ -553,7 +552,7 @@ describe('Shared: DriveConstraints', function () { size: 1000000000, isReadOnly: false, disabled: true, - } as unknown) as DrivelistDrive); + } as unknown) as constraints.DrivelistDrive); expect(result).to.be.true; }); @@ -564,7 +563,7 @@ describe('Shared: DriveConstraints', function () { size: 1000000000, isReadOnly: false, disabled: false, - } as unknown) as DrivelistDrive); + } as unknown) as constraints.DrivelistDrive); expect(result).to.be.false; }); @@ -574,26 +573,30 @@ describe('Shared: DriveConstraints', function () { device: '/dev/disk1', size: 1000000000, isReadOnly: false, - } as DrivelistDrive); + } as constraints.DrivelistDrive); expect(result).to.be.false; }); }); describe('.isDriveSizeRecommended()', function () { + const image: SourceMetadata = { + description: 'rpi.img', + displayName: 'rpi.img', + path: path.join(__dirname, 'rpi.img'), + size: 1000000000, + isSizeEstimated: false, + recommendedDriveSize: 2000000000, + SourceType: sourceDestination.File, + }; it('should return true if the drive size is greater than the recommended size ', function () { const result = constraints.isDriveSizeRecommended( { device: '/dev/disk1', size: 2000000001, isReadOnly: false, - } as DrivelistDrive, - { - path: path.join(__dirname, 'rpi.img'), - size: 1000000000, - isSizeEstimated: false, - recommendedDriveSize: 2000000000, - }, + } as constraints.DrivelistDrive, + image, ); expect(result).to.be.true; @@ -605,13 +608,8 @@ describe('Shared: DriveConstraints', function () { device: '/dev/disk1', size: 2000000000, isReadOnly: false, - } as DrivelistDrive, - { - path: path.join(__dirname, 'rpi.img'), - size: 1000000000, - isSizeEstimated: false, - recommendedDriveSize: 2000000000, - }, + } as constraints.DrivelistDrive, + image, ); expect(result).to.be.true; @@ -623,11 +621,9 @@ describe('Shared: DriveConstraints', function () { device: '/dev/disk1', size: 2000000000, isReadOnly: false, - } as DrivelistDrive, + } as constraints.DrivelistDrive, { - path: path.join(__dirname, 'rpi.img'), - size: 1000000000, - isSizeEstimated: false, + ...image, recommendedDriveSize: 2000000001, }, ); @@ -641,47 +637,29 @@ describe('Shared: DriveConstraints', function () { device: '/dev/disk1', size: 2000000000, isReadOnly: false, - } as DrivelistDrive, + } as constraints.DrivelistDrive, { - path: path.join(__dirname, 'rpi.img'), - size: 1000000000, - isSizeEstimated: false, + ...image, + recommendedDriveSize: undefined, }, ); expect(result).to.be.true; }); - it('should return false if the drive is undefined', function () { - const result = constraints.isDriveSizeRecommended(undefined, { - path: path.join(__dirname, 'rpi.img'), - size: 1000000000, - isSizeEstimated: false, - recommendedDriveSize: 1000000000, - }); - - expect(result).to.be.false; - }); - it('should return true if the image is undefined', function () { const result = constraints.isDriveSizeRecommended( { device: '/dev/disk1', size: 2000000000, isReadOnly: false, - } as DrivelistDrive, + } as constraints.DrivelistDrive, // @ts-ignore undefined, ); expect(result).to.be.true; }); - - it('should return false if the drive and image are undefined', function () { - // @ts-ignore - const result = constraints.isDriveSizeRecommended(undefined, undefined); - expect(result).to.be.true; - }); }); describe('.isDriveValid()', function () { @@ -709,16 +687,29 @@ describe('Shared: DriveConstraints', function () { }); describe('given the drive is disabled', function () { + const image: SourceMetadata = { + description: 'rpi.img', + displayName: 'rpi.img', + path: '', + SourceType: sourceDestination.File, + size: 2000000000, + isSizeEstimated: false, + }; beforeEach(function () { this.drive.disabled = true; }); it('should return false if the drive is not large enough and is a source drive', function () { + console.log('YAYYY', { + ...image, + path: path.join(this.mountpoint, 'rpi.img'), + size: 5000000000, + }); expect( constraints.isDriveValid(this.drive, { + ...image, path: path.join(this.mountpoint, 'rpi.img'), size: 5000000000, - isSizeEstimated: false, }), ).to.be.false; }); @@ -726,35 +717,35 @@ describe('Shared: DriveConstraints', function () { it('should return false if the drive is not large enough and is not a source drive', function () { expect( constraints.isDriveValid(this.drive, { + ...image, path: path.resolve(this.mountpoint, '../bar/rpi.img'), - size: 5000000000, - isSizeEstimated: false, }), ).to.be.false; }); it('should return false if the drive is large enough and is a source drive', function () { - expect( - constraints.isDriveValid(this.drive, { - path: path.join(this.mountpoint, 'rpi.img'), - size: 2000000000, - isSizeEstimated: false, - }), - ).to.be.false; + expect(constraints.isDriveValid(this.drive, image)).to.be.false; }); it('should return false if the drive is large enough and is not a source drive', function () { expect( constraints.isDriveValid(this.drive, { + ...image, path: path.resolve(this.mountpoint, '../bar/rpi.img'), - size: 2000000000, - isSizeEstimated: false, }), ).to.be.false; }); }); describe('given the drive is not disabled', function () { + const image: SourceMetadata = { + description: 'rpi.img', + displayName: 'rpi.img', + path: '', + SourceType: sourceDestination.File, + size: 2000000000, + isSizeEstimated: false, + }; beforeEach(function () { this.drive.disabled = false; }); @@ -762,9 +753,9 @@ describe('Shared: DriveConstraints', function () { it('should return false if the drive is not large enough and is a source drive', function () { expect( constraints.isDriveValid(this.drive, { + ...image, path: path.join(this.mountpoint, 'rpi.img'), size: 5000000000, - isSizeEstimated: false, }), ).to.be.false; }); @@ -772,29 +763,22 @@ describe('Shared: DriveConstraints', function () { it('should return false if the drive is not large enough and is not a source drive', function () { expect( constraints.isDriveValid(this.drive, { + ...image, path: path.resolve(this.mountpoint, '../bar/rpi.img'), size: 5000000000, - isSizeEstimated: false, }), ).to.be.false; }); it('should return false if the drive is large enough and is a source drive', function () { - expect( - constraints.isDriveValid(this.drive, { - path: path.join(this.mountpoint, 'rpi.img'), - size: 2000000000, - isSizeEstimated: false, - }), - ).to.be.false; + expect(constraints.isDriveValid(this.drive, image)).to.be.false; }); it('should return false if the drive is large enough and is not a source drive', function () { expect( constraints.isDriveValid(this.drive, { + ...image, path: path.resolve(this.mountpoint, '../bar/rpi.img'), - size: 2000000000, - isSizeEstimated: false, }), ).to.be.false; }); @@ -802,6 +786,14 @@ describe('Shared: DriveConstraints', function () { }); describe('given the drive is not locked', function () { + const image: SourceMetadata = { + description: 'rpi.img', + displayName: 'rpi.img', + path: '', + SourceType: sourceDestination.File, + size: 2000000000, + isSizeEstimated: false, + }; beforeEach(function () { this.drive.isReadOnly = false; }); @@ -814,9 +806,9 @@ describe('Shared: DriveConstraints', function () { it('should return false if the drive is not large enough and is a source drive', function () { expect( constraints.isDriveValid(this.drive, { + ...image, path: path.join(this.mountpoint, 'rpi.img'), size: 5000000000, - isSizeEstimated: false, }), ).to.be.false; }); @@ -824,29 +816,22 @@ describe('Shared: DriveConstraints', function () { it('should return false if the drive is not large enough and is not a source drive', function () { expect( constraints.isDriveValid(this.drive, { + ...image, path: path.resolve(this.mountpoint, '../bar/rpi.img'), size: 5000000000, - isSizeEstimated: false, }), ).to.be.false; }); it('should return false if the drive is large enough and is a source drive', function () { - expect( - constraints.isDriveValid(this.drive, { - path: path.join(this.mountpoint, 'rpi.img'), - size: 2000000000, - isSizeEstimated: false, - }), - ).to.be.false; + expect(constraints.isDriveValid(this.drive, image)).to.be.false; }); it('should return false if the drive is large enough and is not a source drive', function () { expect( constraints.isDriveValid(this.drive, { + ...image, path: path.resolve(this.mountpoint, '../bar/rpi.img'), - size: 2000000000, - isSizeEstimated: false, }), ).to.be.false; }); @@ -860,9 +845,9 @@ describe('Shared: DriveConstraints', function () { it('should return false if the drive is not large enough and is a source drive', function () { expect( constraints.isDriveValid(this.drive, { + ...image, path: path.join(this.mountpoint, 'rpi.img'), size: 5000000000, - isSizeEstimated: false, }), ).to.be.false; }); @@ -870,9 +855,9 @@ describe('Shared: DriveConstraints', function () { it('should return false if the drive is not large enough and is not a source drive', function () { expect( constraints.isDriveValid(this.drive, { + ...image, path: path.resolve(this.mountpoint, '../bar/rpi.img'), size: 5000000000, - isSizeEstimated: false, }), ).to.be.false; }); @@ -880,9 +865,8 @@ describe('Shared: DriveConstraints', function () { it('should return false if the drive is large enough and is a source drive', function () { expect( constraints.isDriveValid(this.drive, { + ...image, path: path.join(this.mountpoint, 'rpi.img'), - size: 2000000000, - isSizeEstimated: false, }), ).to.be.false; }); @@ -890,9 +874,8 @@ describe('Shared: DriveConstraints', function () { it('should return true if the drive is large enough and is not a source drive', function () { expect( constraints.isDriveValid(this.drive, { + ...image, path: path.resolve(this.mountpoint, '../bar/rpi.img'), - size: 2000000000, - isSizeEstimated: false, }), ).to.be.true; }); @@ -916,6 +899,7 @@ describe('Shared: DriveConstraints', function () { }; this.image = { + SourceType: sourceDestination.File, path: path.join(__dirname, 'rpi.img'), size: this.drive.size - 1, isSizeEstimated: false, @@ -960,28 +944,41 @@ describe('Shared: DriveConstraints', function () { }; this.image = { + SourceType: sourceDestination.File, path: path.join(__dirname, 'rpi.img'), size: this.drive.size - 1, isSizeEstimated: false, }; }); + const compareTuplesMessages = ( + tuple1: { message: string }, + tuple2: { message: string }, + ) => { + if (tuple1.message.toLowerCase() === tuple2.message.toLowerCase()) { + return 0; + } + return tuple1.message.toLowerCase() > tuple2.message.toLowerCase() + ? 1 + : -1; + }; + const expectStatusTypesAndMessagesToBe = ( resultList: Array<{ message: string }>, expectedTuples: Array<['WARNING' | 'ERROR', string]>, + params?: number, ) => { // Sort so that order doesn't matter - const expectedTuplesSorted = _.sortBy( - _.map(expectedTuples, (tuple) => { + const expectedTuplesSorted = expectedTuples + .map((tuple) => { return { type: constraints.COMPATIBILITY_STATUS_TYPES[tuple[0]], // @ts-ignore - message: messages.compatibility[tuple[1]](), + message: messages.compatibility[tuple[1]](params), }; - }), - ['message'], - ); - const resultTuplesSorted = _.sortBy(resultList, ['message']); + }) + .sort(compareTuplesMessages); + const resultTuplesSorted = resultList.sort(compareTuplesMessages); expect(resultTuplesSorted).to.deep.equal(expectedTuplesSorted); }; @@ -1051,7 +1048,7 @@ describe('Shared: DriveConstraints', function () { ); const expected = [ { - message: messages.compatibility.tooSmall('1 B'), + message: messages.compatibility.tooSmall(), type: constraints.COMPATIBILITY_STATUS_TYPES.ERROR, }, ]; @@ -1117,11 +1114,14 @@ describe('Shared: DriveConstraints', function () { this.drive, this.image, ); - // @ts-ignore const expectedTuples = [['WARNING', 'largeDrive']]; - // @ts-ignore - expectStatusTypesAndMessagesToBe(result, expectedTuples); + expectStatusTypesAndMessagesToBe( + result, + // @ts-ignore + expectedTuples, + this.drive.size, + ); }); }); @@ -1169,7 +1169,7 @@ describe('Shared: DriveConstraints', function () { ); const expected = [ { - message: messages.compatibility.tooSmall('1 B'), + message: messages.compatibility.tooSmall(), type: constraints.COMPATIBILITY_STATUS_TYPES.ERROR, }, ]; @@ -1220,7 +1220,7 @@ describe('Shared: DriveConstraints', function () { mountpoints: [{ path: __dirname }], isSystem: false, isReadOnly: false, - } as unknown) as DrivelistDrive, + } as unknown) as constraints.DrivelistDrive, ({ device: drivePaths[1], description: 'My Other Drive', @@ -1229,7 +1229,7 @@ describe('Shared: DriveConstraints', function () { mountpoints: [], isSystem: false, isReadOnly: true, - } as unknown) as DrivelistDrive, + } as unknown) as constraints.DrivelistDrive, ({ device: drivePaths[2], description: 'My Drive', @@ -1238,7 +1238,7 @@ describe('Shared: DriveConstraints', function () { mountpoints: [], isSystem: false, isReadOnly: false, - } as unknown) as DrivelistDrive, + } as unknown) as constraints.DrivelistDrive, ({ device: drivePaths[3], description: 'My Drive', @@ -1247,16 +1247,16 @@ describe('Shared: DriveConstraints', function () { mountpoints: [], isSystem: true, isReadOnly: false, - } as unknown) as DrivelistDrive, + } as unknown) as constraints.DrivelistDrive, ({ device: drivePaths[4], description: 'My Drive', - size: 64000000001, + size: 128000000001, displayName: drivePaths[4], mountpoints: [], isSystem: false, isReadOnly: false, - } as unknown) as DrivelistDrive, + } as unknown) as constraints.DrivelistDrive, ({ device: drivePaths[5], description: 'My Drive', @@ -1265,7 +1265,7 @@ describe('Shared: DriveConstraints', function () { mountpoints: [], isSystem: false, isReadOnly: false, - } as unknown) as DrivelistDrive, + } as unknown) as constraints.DrivelistDrive, ({ device: drivePaths[6], description: 'My Drive', @@ -1274,11 +1274,14 @@ describe('Shared: DriveConstraints', function () { mountpoints: [], isSystem: false, isReadOnly: false, - } as unknown) as DrivelistDrive, + } as unknown) as constraints.DrivelistDrive, ]; - const image = { + const image: SourceMetadata = { + description: 'rpi.img', + displayName: 'rpi.img', path: path.join(__dirname, 'rpi.img'), + SourceType: sourceDestination.File, // @ts-ignore size: drives[2].size + 1, isSizeEstimated: false, @@ -1331,7 +1334,7 @@ describe('Shared: DriveConstraints', function () { ), ).to.deep.equal([ { - message: 'Insufficient space, additional 1 B required', + message: 'Too small', type: 2, }, ]); @@ -1373,7 +1376,7 @@ describe('Shared: DriveConstraints', function () { ), ).to.deep.equal([ { - message: 'Not Recommended', + message: 'Not recommended', type: 1, }, ]); @@ -1394,7 +1397,7 @@ describe('Shared: DriveConstraints', function () { type: 2, }, { - message: 'Insufficient space, additional 1 B required', + message: 'Too small', type: 2, }, { @@ -1406,157 +1409,11 @@ describe('Shared: DriveConstraints', function () { type: 1, }, { - message: 'Not Recommended', + message: 'Not recommended', type: 1, }, ]); }); }); }); - - describe('.hasListDriveImageCompatibilityStatus()', function () { - const drivePaths = - process.platform === 'win32' - ? ['E:\\', 'F:\\', 'G:\\', 'H:\\', 'J:\\', 'K:\\'] - : [ - '/dev/disk1', - '/dev/disk2', - '/dev/disk3', - '/dev/disk4', - '/dev/disk5', - '/dev/disk6', - ]; - const drives = [ - ({ - device: drivePaths[0], - description: 'My Drive', - size: 123456789, - displayName: drivePaths[0], - mountpoints: [{ path: __dirname }], - isSystem: false, - isReadOnly: false, - } as unknown) as DrivelistDrive, - ({ - device: drivePaths[1], - description: 'My Other Drive', - size: 123456789, - displayName: drivePaths[1], - mountpoints: [], - isSystem: false, - isReadOnly: true, - } as unknown) as DrivelistDrive, - ({ - device: drivePaths[2], - description: 'My Drive', - size: 1234567, - displayName: drivePaths[2], - mountpoints: [], - isSystem: false, - isReadOnly: false, - } as unknown) as DrivelistDrive, - ({ - device: drivePaths[3], - description: 'My Drive', - size: 123456789, - displayName: drivePaths[3], - mountpoints: [], - isSystem: true, - isReadOnly: false, - } as unknown) as DrivelistDrive, - ({ - device: drivePaths[4], - description: 'My Drive', - size: 64000000001, - displayName: drivePaths[4], - mountpoints: [], - isSystem: false, - isReadOnly: false, - } as unknown) as DrivelistDrive, - ({ - device: drivePaths[5], - description: 'My Drive', - size: 12345678, - displayName: drivePaths[5], - mountpoints: [], - isSystem: false, - isReadOnly: false, - } as unknown) as DrivelistDrive, - ({ - device: drivePaths[6], - description: 'My Drive', - size: 123456789, - displayName: drivePaths[6], - mountpoints: [], - isSystem: false, - isReadOnly: false, - } as unknown) as DrivelistDrive, - ]; - - const image = { - path: path.join(__dirname, 'rpi.img'), - // @ts-ignore - size: drives[2].size + 1, - isSizeEstimated: false, - // @ts-ignore - recommendedDriveSize: drives[5].size + 1, - }; - - describe('given no drives', function () { - it('should return false', function () { - expect(constraints.hasListDriveImageCompatibilityStatus([], image)).to - .be.false; - }); - }); - - describe('given one drive', function () { - it('should return true given a drive that contains the image', function () { - expect( - constraints.hasListDriveImageCompatibilityStatus([drives[0]], image), - ).to.be.true; - }); - - it('should return true given a drive that is locked', function () { - expect( - constraints.hasListDriveImageCompatibilityStatus([drives[1]], image), - ).to.be.true; - }); - - it('should return true given a drive that is too small for the image', function () { - expect( - constraints.hasListDriveImageCompatibilityStatus([drives[2]], image), - ).to.be.true; - }); - - it('should return true given a drive that is a system drive', function () { - expect( - constraints.hasListDriveImageCompatibilityStatus([drives[3]], image), - ).to.be.true; - }); - - it('should return true given a drive that is large', function () { - expect( - constraints.hasListDriveImageCompatibilityStatus([drives[4]], image), - ).to.be.true; - }); - - it('should return true given a drive that is not recommended', function () { - expect( - constraints.hasListDriveImageCompatibilityStatus([drives[5]], image), - ).to.be.true; - }); - - it('should return false given a drive with no warnings or errors', function () { - expect( - constraints.hasListDriveImageCompatibilityStatus([drives[6]], image), - ).to.be.false; - }); - }); - - describe('given many drives', function () { - it('should return true given some drives with errors or warnings', function () { - expect(constraints.hasListDriveImageCompatibilityStatus(drives, image)) - .to.be.true; - }); - }); - }); }); diff --git a/tests/shared/units.spec.ts b/tests/shared/units.spec.ts index ccf4b665..071b020e 100644 --- a/tests/shared/units.spec.ts +++ b/tests/shared/units.spec.ts @@ -15,45 +15,13 @@ */ import { expect } from 'chai'; -import * as units from '../../lib/shared/units'; +import { bytesToMegabytes } from '../../lib/shared/units'; describe('Shared: Units', function () { - describe('.bytesToClosestUnit()', function () { - it('should convert bytes to terabytes', function () { - expect(units.bytesToClosestUnit(1000000000000)).to.equal('1 TB'); - expect(units.bytesToClosestUnit(2987801405440)).to.equal('2.99 TB'); - expect(units.bytesToClosestUnit(999900000000000)).to.equal('1000 TB'); - }); - - it('should convert bytes to gigabytes', function () { - expect(units.bytesToClosestUnit(1000000000)).to.equal('1 GB'); - expect(units.bytesToClosestUnit(7801405440)).to.equal('7.8 GB'); - expect(units.bytesToClosestUnit(999900000000)).to.equal('1000 GB'); - }); - - it('should convert bytes to megabytes', function () { - expect(units.bytesToClosestUnit(1000000)).to.equal('1 MB'); - expect(units.bytesToClosestUnit(801405440)).to.equal('801 MB'); - expect(units.bytesToClosestUnit(999900000)).to.equal('1000 MB'); - }); - - it('should convert bytes to kilobytes', function () { - expect(units.bytesToClosestUnit(1000)).to.equal('1 kB'); - expect(units.bytesToClosestUnit(5440)).to.equal('5.44 kB'); - expect(units.bytesToClosestUnit(999900)).to.equal('1000 kB'); - }); - - it('should keep bytes as bytes', function () { - expect(units.bytesToClosestUnit(1)).to.equal('1 B'); - expect(units.bytesToClosestUnit(8)).to.equal('8 B'); - expect(units.bytesToClosestUnit(999)).to.equal('999 B'); - }); - }); - describe('.bytesToMegabytes()', function () { it('should convert bytes to megabytes', function () { - expect(units.bytesToMegabytes(1.2e7)).to.equal(12); - expect(units.bytesToMegabytes(332000)).to.equal(0.332); + expect(bytesToMegabytes(1.2e7)).to.equal(12); + expect(bytesToMegabytes(332000)).to.equal(0.332); }); }); }); diff --git a/tsconfig.json b/tsconfig.json index 9cdd39ef..aefede61 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,8 @@ "noUnusedLocals": true, "noUnusedParameters": true, "resolveJsonModule": true, + "target": "es2019", + "moduleResolution": "node", "jsx": "react", "typeRoots": ["./node_modules/@types", "./typings"] }