From 093008dee7a936c91b9ecdde8bebee9e6dace5b5 Mon Sep 17 00:00:00 2001 From: Lorenzo Alberto Maria Ambrosi Date: Thu, 23 Jul 2020 14:50:28 +0200 Subject: [PATCH 01/27] Rework system & large drives handling logic Change-type: patch Changelog-entry: Rework system & large drives handling logic Signed-off-by: Lorenzo Alberto Maria Ambrosi --- .../drive-selector/drive-selector.tsx | 227 +++++--- .../drive-status-warning-modal.tsx | 82 +++ .../source-selector/source-selector.tsx | 181 ++++--- .../target-selector-button.tsx | 72 +-- .../target-selector/target-selector.tsx | 33 +- lib/gui/app/css/main.css | 10 +- lib/gui/app/models/available-drives.ts | 4 +- lib/gui/app/models/leds.ts | 6 +- lib/gui/app/models/selection-state.ts | 5 +- lib/gui/app/modules/image-writer.ts | 2 +- lib/gui/app/modules/progress-status.ts | 2 +- lib/gui/app/pages/main/Flash.tsx | 121 ++--- lib/gui/app/pages/main/MainPage.tsx | 17 +- lib/gui/app/styled-components.tsx | 83 ++- lib/gui/app/theme.ts | 23 +- lib/gui/modules/child-writer.ts | 2 +- lib/shared/drive-constraints.ts | 163 +++--- lib/shared/messages.ts | 26 +- lib/shared/units.ts | 7 +- tests/gui/modules/image-writer.spec.ts | 7 +- tests/shared/drive-constraints.spec.ts | 490 ++++++------------ 21 files changed, 826 insertions(+), 737 deletions(-) create mode 100644 lib/gui/app/components/drive-status-warning-modal/drive-status-warning-modal.tsx diff --git a/lib/gui/app/components/drive-selector/drive-selector.tsx b/lib/gui/app/components/drive-selector/drive-selector.tsx index cdcb374f..290db14c 100644 --- a/lib/gui/app/components/drive-selector/drive-selector.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, DriveStatus, - Image, + 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 DriveSVGIcon from '../../../assets/tgt.svg'; +import { SourceMetadata } from '../source-selector/source-selector'; interface UsbbootDrive extends sourceDestination.UsbbootDrive { progress: number; @@ -64,7 +61,7 @@ interface DriverlessDrive { linkCTA: string; } -type Drive = scanner.adapters.DrivelistDrive | DriverlessDrive | UsbbootDrive; +type Drive = DrivelistDrive | DriverlessDrive | UsbbootDrive; function isUsbbootDrive(drive: Drive): drive is UsbbootDrive { return (drive as UsbbootDrive).progress !== undefined; @@ -74,37 +71,78 @@ function isDriverlessDrive(drive: Drive): drive is DriverlessDrive { return (drive as DriverlessDrive).link !== undefined; } -function isDrivelistDrive( - drive: Drive, -): 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 DrivesTable = 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; @@ -147,39 +186,40 @@ const InitProgress = styled( export interface DriveSelectorProps extends Omit { - multipleSelection?: boolean; + multipleSelection: boolean; + showWarnings?: boolean; cancel: () => void; - done: (drives: scanner.adapters.DrivelistDrive[]) => void; + done: (drives: DrivelistDrive[]) => void; titleLabel: string; emptyListLabel: string; + selectedList?: DrivelistDrive[]; + updateSelectedList?: () => DrivelistDrive[]; } interface DriveSelectorState { drives: Drive[]; - image: Image; + image?: SourceMetadata; missingDriversModal: { drive?: DriverlessDrive }; - selectedList: scanner.adapters.DrivelistDrive[]; + selectedList: DrivelistDrive[]; showSystemDrives: boolean; } +function isSystemDrive(drive: Drive) { + return isDrivelistDrive(drive) && drive.isSystem; +} + export class DriveSelector extends React.Component< DriveSelectorProps, DriveSelectorState > { private unsubscribe: (() => void) | undefined; - multipleSelection: boolean = true; tableColumns: Array>; constructor(props: DriveSelectorProps) { super(props); const defaultMissingDriversModalState: { drive?: DriverlessDrive } = {}; - const selectedList = getSelectedDrives(); - const multipleSelection = this.props.multipleSelection; - this.multipleSelection = - multipleSelection !== undefined - ? !!multipleSelection - : this.multipleSelection; + const selectedList = this.props.selectedList || []; this.state = { drives: getDrives(), @@ -194,14 +234,23 @@ export class DriveSelector extends React.Component< field: 'description', label: 'Name', render: (description: string, drive: Drive) => { - return isDrivelistDrive(drive) && drive.isSystem ? ( - - - {description} - - ) : ( - {description} - ); + if (isDrivelistDrive(drive)) { + const isLargeDrive = isDriveSizeLarge(drive); + const hasWarnings = + this.props.showWarnings && (isLargeDrive || drive.isSystem); + return ( + + {hasWarnings && ( + + )} + {description} + + ); + } + return {description}; }, }, { @@ -210,7 +259,7 @@ export class DriveSelector extends React.Component< label: 'Size', render: (_description: string, drive: Drive) => { if (isDrivelistDrive(drive) && drive.size !== null) { - return bytesToClosestUnit(drive.size); + return prettyBytes(drive.size); } }, }, @@ -241,21 +290,19 @@ export class DriveSelector extends React.Component< field: 'description', key: 'extra', // Space as empty string would use the field name as label - 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: Drive, image: any) { + private driveShouldBeDisabled(drive: Drive, image?: SourceMetadata) { return ( isUsbbootDrive(drive) || isDriverlessDrive(drive) || @@ -275,7 +322,7 @@ export class DriveSelector extends React.Component< }); } - private getDisabledDrives(drives: Drive[], image: any): string[] { + private getDisabledDrives(drives: Drive[], image?: SourceMetadata): string[] { return drives .filter((drive) => this.driveShouldBeDisabled(drive, image)) .map((drive) => drive.displayName); @@ -290,14 +337,45 @@ export class DriveSelector extends React.Component< ); } - private renderStatuses(statuses: DriveStatus[]) { + 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} ); @@ -322,7 +400,9 @@ export class DriveSelector extends React.Component< this.setState({ drives, image, - selectedList: getSelectedDrives(), + selectedList: + (this.props.updateSelectedList && this.props.updateSelectedList()) || + [], }); }); } @@ -337,15 +417,13 @@ export class DriveSelector extends React.Component< const displayedDrives = this.getDisplayedDrives(drives); const disabledDrives = this.getDisabledDrives(drives, image); - const numberOfSystemDrives = drives.filter( - (drive) => isDrivelistDrive(drive) && drive.isSystem, - ).length; - const numberOfDisplayedSystemDrives = displayedDrives.filter( - (drive) => isDrivelistDrive(drive) && drive.isSystem, - ).length; + 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 ( done(selectedList)} action={`Select (${selectedList.length})`} primaryButtonProps={{ - primary: !hasStatus, - warning: hasStatus, + primary: !showWarnings, + warning: showWarnings, disabled: !hasAvailableDrives(), }} {...props} @@ -394,13 +472,17 @@ export class DriveSelector extends React.Component< t.setRowSelection(selectedList); } }} + multipleSelection={this.props.multipleSelection} columns={this.tableColumns} data={displayedDrives} disabledRows={disabledDrives} + getRowClass={(row: Drive) => + isDrivelistDrive(row) && row.isSystem ? ['system'] : [] + } rowKey="displayName" onCheck={(rows: Drive[]) => { const newSelection = rows.filter(isDrivelistDrive); - if (this.multipleSelection) { + if (this.props.multipleSelection) { this.setState({ selectedList: newSelection, }); @@ -417,7 +499,7 @@ export class DriveSelector extends React.Component< ) { return; } - if (this.multipleSelection) { + if (this.props.multipleSelection) { const newList = [...selectedList]; const selectedIndex = selectedList.findIndex( (drive) => drive.device === row.device, @@ -442,6 +524,7 @@ export class DriveSelector extends React.Component< this.setState({ showSystemDrives: true })} > @@ -452,6 +535,12 @@ export class DriveSelector 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..e310c8da --- /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 { bytesToClosestUnit } from '../../../../shared/units'; +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)}{' '} + {bytesToClosestUnit(drive.size || 0)}{' '} + {drive.statuses[0].message} + + {i !== array.length - 1 ?
: null} + + ))} +
+ {warningCta} +
+
+ ); +}; + +export default DriveStatusWarningModal; diff --git a/lib/gui/app/components/source-selector/source-selector.tsx b/lib/gui/app/components/source-selector/source-selector.tsx index 69738224..c07fd83c 100644 --- a/lib/gui/app/components/source-selector/source-selector.tsx +++ b/lib/gui/app/components/source-selector/source-selector.tsx @@ -18,11 +18,12 @@ 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'; -import { sourceDestination, scanner } from 'etcher-sdk'; +import { sourceDestination } from 'etcher-sdk'; 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, @@ -38,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'; @@ -59,6 +59,7 @@ 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'; @@ -161,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()} + /> + + + )} +
); }; @@ -243,11 +246,13 @@ export type Source = | typeof sourceDestination.Http; export interface SourceMetadata extends sourceDestination.Metadata { - hasMBR: boolean; - partitions: MBRPartition[] | GPTPartition[]; + hasMBR?: boolean; + partitions?: MBRPartition[] | GPTPartition[]; path: string; + displayName: string; + description: string; SourceType: Source; - drive?: scanner.adapters.DrivelistDrive; + drive?: DrivelistDrive; extension?: string; } @@ -326,7 +331,7 @@ export class SourceSelector extends React.Component< } private selectSource( - selected: string | scanner.adapters.DrivelistDrive, + selected: string | DrivelistDrive, SourceType: Source, ): { promise: Promise; cancel: () => void } { let cancelled = false; @@ -336,40 +341,43 @@ export class SourceSelector extends React.Component< }, promise: (async () => { const sourcePath = isString(selected) ? selected : selected.device; + let source; let metadata: SourceMetadata | undefined; if (isString(selected)) { - const source = await this.createSource(selected, SourceType); + if (SourceType === sourceDestination.Http && !isURL(selected)) { + this.handleError( + 'Unsupported protocol', + selected, + messages.error.unsupportedProtocol(), + ); + return; + } + + 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); + metadata = await this.getMetadata(innerSource, selected); if (cancelled) { return; } - if (SourceType === sourceDestination.Http && !isURL(selected)) { - this.handleError( - 'Unsupported protocol', - selected, - messages.error.unsupportedProtocol(), - ); - return; - } - if (supportedFormats.looksLikeWindowsImage(selected)) { - analytics.logEvent('Possibly Windows image', { image: selected }); - this.setState({ - warning: { - message: messages.warning.looksLikeWindowsImage(), - title: 'Possible Windows image detected', - }, - }); - } - metadata.extension = path.extname(selected).slice(1); - metadata.path = selected; + metadata.SourceType = SourceType; if (!metadata.hasMBR) { analytics.logEvent('Missing partition table', { metadata }); @@ -397,9 +405,9 @@ export class SourceSelector extends React.Component< } else { metadata = { path: selected.device, + displayName: selected.displayName, + description: selected.displayName, size: selected.size as SourceMetadata['size'], - hasMBR: false, - partitions: [], SourceType: sourceDestination.BlockDevice, drive: selected, }; @@ -425,7 +433,7 @@ export class SourceSelector extends React.Component< title: string, sourcePath: string, description: string, - error?: any, + error?: Error, ) { const imageError = errors.createUserError({ title, @@ -440,7 +448,8 @@ export class SourceSelector extends React.Component< } private async getMetadata( - source: sourceDestination.SourceDestination | sourceDestination.BlockDevice, + source: sourceDestination.SourceDestination, + selected: string | DrivelistDrive, ) { const metadata = (await source.getMetadata()) as SourceMetadata; const partitionTable = await source.getPartitionTable(); @@ -450,6 +459,10 @@ export class SourceSelector extends React.Component< } else { metadata.hasMBR = false; } + if (isString(selected)) { + metadata.extension = path.extname(selected).slice(1); + metadata.path = selected; + } return metadata; } @@ -517,20 +530,20 @@ export class SourceSelector extends React.Component< public render() { const { flashing } = this.props; const { showImageDetails, showURLSelector, showDriveSelector } = this.state; + const selectionImage = selectionState.getImage(); + let image: SourceMetadata | DrivelistDrive = + selectionImage !== undefined ? selectionImage : ({} as SourceMetadata); - const hasSource = selectionState.hasImage(); - let image = hasSource ? selectionState.getImage() : {}; - - image = image.drive ? image.drive : image; + image = image.drive ?? image; let cancelURLSelection = () => { // noop }; image.name = image.description || image.name; - const imagePath = image.path || ''; - const imageBasename = path.basename(image.path || ''); + const imagePath = image.path || image.displayName || ''; + const imageBasename = path.basename(imagePath); const imageName = image.name || ''; - const imageSize = image.size || ''; + const imageSize = image.size || 0; const imageLogo = image.logo || ''; return ( @@ -554,7 +567,7 @@ export class SourceSelector extends React.Component< }} /> - {hasSource ? ( + {selectionImage !== undefined ? ( <> )} - {shared.bytesToClosestUnit(imageSize)} + {prettyBytes(imageSize)} ) : ( <> @@ -684,15 +697,13 @@ export class SourceSelector extends React.Component< showDriveSelector: false, }); }} - done={async (drives: scanner.adapters.DrivelistDrive[]) => { - if (!drives.length) { - analytics.logEvent('Drive selector closed'); - this.setState({ - showDriveSelector: false, - }); - return; + done={async (drives: DrivelistDrive[]) => { + if (drives.length) { + await this.selectSource( + drives[0], + sourceDestination.BlockDevice, + ); } - await this.selectSource(drives[0], sourceDestination.BlockDevice); this.setState({ showDriveSelector: false, }); 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 f7abd371..00ea5b1f 100644 --- a/lib/gui/app/components/target-selector/target-selector-button.tsx +++ b/lib/gui/app/components/target-selector/target-selector-button.tsx @@ -15,14 +15,14 @@ */ 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 { compatibility, warning } from '../../../../shared/messages'; import { bytesToClosestUnit } from '../../../../shared/units'; import { getSelectedDrives } from '../../models/selection-state'; import { @@ -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 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,7 @@ export function TargetSelectorButton(props: TargetSelectorProps) { Change )} - - - {bytesToClosestUnit(target.size)} - + {bytesToClosestUnit(target.size)} ); } @@ -97,6 +104,9 @@ export function TargetSelectorButton(props: TargetSelectorProps) { if (targets.length > 1) { const targetsTemplate = []; for (const target of targets) { + const warnings = getDriveImageCompatibilityStatuses(target).map( + getDriveWarning, + ); targetsTemplate.push( - + {warnings.length && ( + + )} {middleEllipsis(target.description, 14)} {bytesToClosestUnit(target.size)} , diff --git a/lib/gui/app/components/target-selector/target-selector.tsx b/lib/gui/app/components/target-selector/target-selector.tsx index 958e99f9..2ec79ddd 100644 --- a/lib/gui/app/components/target-selector/target-selector.tsx +++ b/lib/gui/app/components/target-selector/target-selector.tsx @@ -16,9 +16,8 @@ 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, @@ -33,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() @@ -55,11 +57,18 @@ const getDriveSelectionStateSlice = () => ({ }); export const TargetSelectorModal = ( - props: Omit, + props: Omit< + DriveSelectorProps, + 'titleLabel' | 'emptyListLabel' | 'multipleSelection' + >, ) => ( ); @@ -106,7 +115,7 @@ export const TargetSelector = ({ }: TargetSelectorProps) => { // TODO: inject these from redux-connector const [ - { showDrivesButton, driveListLabel, targets, image }, + { showDrivesButton, driveListLabel, targets }, setStateSlice, ] = React.useState(getDriveSelectionStateSlice()); const [showTargetSelectorModal, setShowTargetSelectorModal] = React.useState( @@ -119,6 +128,7 @@ export const TargetSelector = ({ }); }, []); + const hasSystemDrives = targets.some((target) => target.isSystem); return ( + {hasSystemDrives ? ( + + Warning: {warning.systemDrive()} + + ) : null} + {showTargetSelectorModal && ( 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 6389886f..7acc4a55 100644 --- a/lib/gui/app/models/available-drives.ts +++ b/lib/gui/app/models/available-drives.ts @@ -14,12 +14,10 @@ * limitations under the License. */ -import * as _ from 'lodash'; - import { Actions, store } from './store'; export function hasAvailableDrives() { - return !_.isEmpty(getDrives()); + return getDrives().length > 0; } export function setDrives(drives: any[]) { 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 6843e256..06244e05 100644 --- a/lib/gui/app/models/selection-state.ts +++ b/lib/gui/app/models/selection-state.ts @@ -15,6 +15,7 @@ */ import * as _ from 'lodash'; +import { SourceMetadata } from '../components/source-selector/source-selector'; import * as availableDrives from './available-drives'; import { Actions, store } from './store'; @@ -67,7 +68,7 @@ export function getSelectedDrives(): any[] { /** * @summary Get the selected image */ -export function getImage() { +export function getImage(): SourceMetadata { return _.get(store.getState().toJS(), ['selection', 'image']); } @@ -114,7 +115,7 @@ export function hasDrive(): boolean { * @summary Check if there is a selected image */ export function hasImage(): boolean { - return Boolean(getImage()); + return !_.isEmpty(getImage()); } /** diff --git a/lib/gui/app/modules/image-writer.ts b/lib/gui/app/modules/image-writer.ts index e1ff0670..8091ede7 100644 --- a/lib/gui/app/modules/image-writer.ts +++ b/lib/gui/app/modules/image-writer.ts @@ -134,7 +134,7 @@ interface FlashResults { cancelled?: boolean; } -export async function performWrite( +async function performWrite( image: SourceMetadata, drives: DrivelistDrive[], onProgress: sdk.multiWrite.OnProgressFunction, diff --git a/lib/gui/app/modules/progress-status.ts b/lib/gui/app/modules/progress-status.ts index 4dcf9780..950ac463 100644 --- a/lib/gui/app/modules/progress-status.ts +++ b/lib/gui/app/modules/progress-status.ts @@ -51,7 +51,7 @@ export function fromFlashState({ } else { return { status: 'Flashing...', - position: `${bytesToClosestUnit(position)}`, + position: `${position ? bytesToClosestUnit(position) : ''}`, }; } } else if (type === 'verifying') { diff --git a/lib/gui/app/pages/main/Flash.tsx b/lib/gui/app/pages/main/Flash.tsx index 58ea0895..62ed0637 100644 --- a/lib/gui/app/pages/main/Flash.tsx +++ b/lib/gui/app/pages/main/Flash.tsx @@ -18,7 +18,7 @@ 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'; @@ -36,27 +36,11 @@ import { } 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 @@ -81,8 +65,8 @@ async function flashImageToDrive( ): 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) { @@ -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); @@ -155,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< @@ -168,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; @@ -198,28 +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 = availableDrives + .getDrives() + .filter((drive: { device: string }) => { + return devices.includes(drive.device); + }) + .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({ @@ -253,13 +262,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) && @@ -270,9 +274,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)} )} @@ -288,28 +290,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)} @@ -317,11 +308,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 && ( ( ))` 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/modules/child-writer.ts b/lib/gui/modules/child-writer.ts index 61fafe72..1f60fdd7 100644 --- a/lib/gui/modules/child-writer.ts +++ b/lib/gui/modules/child-writer.ts @@ -229,6 +229,7 @@ ipc.connectTo(IPC_SERVER_ID, () => { const destinations = options.destinations.map((d) => d.device); const imagePath = options.image.path; + log(`Image: ${imagePath}`); log(`Devices: ${destinations.join(', ')}`); log(`Umount on success: ${options.unmountOnSuccess}`); log(`Validate on success: ${options.validateWriteOnSuccess}`); @@ -248,7 +249,6 @@ ipc.connectTo(IPC_SERVER_ID, () => { if (options.image.drive) { source = new BlockDevice({ drive: options.image.drive, - write: false, direct: !options.autoBlockmapping, }); } else { diff --git a/lib/shared/drive-constraints.ts b/lib/shared/drive-constraints.ts index f8630de8..c75bd719 100644 --- a/lib/shared/drive-constraints.ts +++ b/lib/shared/drive-constraints.ts @@ -14,10 +14,9 @@ * 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'; @@ -27,6 +26,14 @@ import { SourceMetadata } from '../gui/app/components/source-selector/source-sel */ 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 * @@ -34,22 +41,14 @@ 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)); -} - -export interface Image { - path: string; - isSizeEstimated?: boolean; - compressedSize?: number; - recommendedDriveSize?: number; - size?: number; + return Boolean(drive.isSystem); } function sourceIsInsideDrive(source: string, drive: DrivelistDrive) { @@ -89,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; } @@ -110,20 +113,23 @@ 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) && @@ -139,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; } @@ -170,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 * @@ -182,7 +215,7 @@ export const COMPATIBILITY_STATUS_TYPES = { */ export function getDriveImageCompatibilityStatuses( drive: DrivelistDrive, - image: Image, + image?: SourceMetadata, ) { const statusList = []; @@ -197,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 as SourceMetadata)) { - 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); } } @@ -247,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); }); } @@ -262,35 +279,11 @@ 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 DriveStatus { message: string; type: number; diff --git a/lib/shared/messages.ts b/lib/shared/messages.ts index 500c3468..b3cd838b 100644 --- a/lib/shared/messages.ts +++ b/lib/shared/messages.ts @@ -15,6 +15,7 @@ */ import { Dictionary } from 'lodash'; +import * as prettyBytes from 'pretty-bytes'; export const progress: Dictionary<(quantity: number) => string> = { successful: (quantity: number) => { @@ -53,11 +54,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: () => { @@ -84,8 +85,8 @@ export const warning = { drive: { device: string; size: number }, ) => { return [ - `This image recommends a ${image.recommendedDriveSize}`, - `bytes drive, however ${drive.device} is only ${drive.size} bytes.`, + `This image recommends a ${prettyBytes(image.recommendedDriveSize)}`, + `drive, however ${drive.device} is only ${prettyBytes(drive.size)}.`, ].join(' '); }, @@ -115,11 +116,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'; }, }; diff --git a/lib/shared/units.ts b/lib/shared/units.ts index dcf3646c..daae6707 100644 --- a/lib/shared/units.ts +++ b/lib/shared/units.ts @@ -23,9 +23,6 @@ export function bytesToMegabytes(bytes: number): number { return bytes / MEGABYTE_TO_BYTE_RATIO; } -export function bytesToClosestUnit(bytes: number): string | null { - if (_.isNumber(bytes)) { - return prettyBytes(bytes); - } - return null; +export function bytesToClosestUnit(bytes: number): string { + return prettyBytes(bytes); } diff --git a/tests/gui/modules/image-writer.spec.ts b/tests/gui/modules/image-writer.spec.ts index 677b2f6c..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,9 +29,11 @@ const fakeDrive: DrivelistDrive = {}; describe('Browser: imageWriter', () => { describe('.flash()', () => { - const image = { + const image: SourceMetadata = { hasMBR: false, partitions: [], + description: 'foo.img', + displayName: 'foo.img', path: 'foo.img', SourceType: sourceDestination.File, extension: 'img', @@ -60,7 +63,7 @@ describe('Browser: imageWriter', () => { }); try { - imageWriter.flash(image, [fakeDrive], performWriteStub); + await imageWriter.flash(image, [fakeDrive], performWriteStub); } catch { // noop } finally { diff --git a/tests/shared/drive-constraints.spec.ts b/tests/shared/drive-constraints.spec.ts index 62687fde..d557f905 100644 --- a/tests/shared/drive-constraints.spec.ts +++ b/tests/shared/drive-constraints.spec.ts @@ -15,10 +15,9 @@ */ import { expect } from 'chai'; -import { Drive as DrivelistDrive } from 'drivelist'; import { sourceDestination } from 'etcher-sdk'; -import * as _ from 'lodash'; 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'; @@ -30,7 +29,7 @@ describe('Shared: DriveConstraints', function () { device: '/dev/disk2', size: 999999999, isReadOnly: true, - } as DrivelistDrive); + } as constraints.DrivelistDrive); expect(result).to.be.true; }); @@ -40,7 +39,7 @@ describe('Shared: DriveConstraints', function () { device: '/dev/disk2', size: 999999999, isReadOnly: false, - } as DrivelistDrive); + } as constraints.DrivelistDrive); expect(result).to.be.false; }); @@ -49,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 () { @@ -68,7 +61,7 @@ describe('Shared: DriveConstraints', function () { size: 999999999, isReadOnly: true, isSystem: true, - } as DrivelistDrive); + } as constraints.DrivelistDrive); expect(result).to.be.true; }); @@ -78,7 +71,7 @@ describe('Shared: DriveConstraints', function () { device: '/dev/disk2', size: 999999999, isReadOnly: true, - } as DrivelistDrive); + } as constraints.DrivelistDrive); expect(result).to.be.false; }); @@ -89,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 () { @@ -109,7 +96,7 @@ describe('Shared: DriveConstraints', function () { size: 999999999, isReadOnly: true, isSystem: false, - } as DrivelistDrive, + } as constraints.DrivelistDrive, // @ts-ignore undefined, ); @@ -124,8 +111,10 @@ 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: [], @@ -137,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 @@ -161,13 +158,8 @@ describe('Shared: DriveConstraints', function () { path: 'F:', }, ], - } as DrivelistDrive, - { - path: 'E:\\image.img', - hasMBR: false, - partitions: [], - SourceType: sourceDestination.File, - }, + } as constraints.DrivelistDrive, + windowsImage, ); expect(result).to.be.true; @@ -186,12 +178,10 @@ describe('Shared: DriveConstraints', function () { path: 'F:', }, ], - } as DrivelistDrive, + } as constraints.DrivelistDrive, { + ...windowsImage, path: 'E:\\foo\\bar\\image.img', - hasMBR: false, - partitions: [], - SourceType: sourceDestination.File, }, ); @@ -211,12 +201,10 @@ describe('Shared: DriveConstraints', function () { path: 'F:', }, ], - } as DrivelistDrive, + } as constraints.DrivelistDrive, { + ...windowsImage, path: 'G:\\image.img', - hasMBR: false, - partitions: [], - SourceType: sourceDestination.File, }, ); @@ -232,12 +220,10 @@ describe('Shared: DriveConstraints', function () { path: 'E:\\fo', }, ], - } as DrivelistDrive, + } as constraints.DrivelistDrive, { + ...windowsImage, path: 'E:\\foo/image.img', - hasMBR: false, - partitions: [], - SourceType: sourceDestination.File, }, ); @@ -246,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 @@ -265,12 +259,10 @@ describe('Shared: DriveConstraints', function () { path: '/', }, ], - } as DrivelistDrive, + } as constraints.DrivelistDrive, { + ...image, path: '/image.img', - hasMBR: false, - partitions: [], - SourceType: sourceDestination.File, }, ); @@ -288,12 +280,10 @@ describe('Shared: DriveConstraints', function () { path: '/Volumes/B', }, ], - } as DrivelistDrive, + } as constraints.DrivelistDrive, { + ...image, path: '/Volumes/A/image.img', - hasMBR: false, - partitions: [], - SourceType: sourceDestination.File, }, ); @@ -311,12 +301,10 @@ describe('Shared: DriveConstraints', function () { path: '/Volumes/B', }, ], - } as DrivelistDrive, + } as constraints.DrivelistDrive, { + ...image, path: '/Volumes/A/foo/bar/image.img', - hasMBR: false, - partitions: [], - SourceType: sourceDestination.File, }, ); @@ -334,12 +322,10 @@ describe('Shared: DriveConstraints', function () { path: '/Volumes/B', }, ], - } as DrivelistDrive, + } as constraints.DrivelistDrive, { + ...image, path: '/Volumes/C/image.img', - hasMBR: false, - partitions: [], - SourceType: sourceDestination.File, }, ); @@ -354,12 +340,10 @@ describe('Shared: DriveConstraints', function () { path: '/Volumes/fo', }, ], - } as DrivelistDrive, + } as constraints.DrivelistDrive, { + ...image, path: '/Volumes/foo/image.img', - hasMBR: false, - partitions: [], - SourceType: sourceDestination.File, }, ); @@ -546,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 () { @@ -584,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; }); @@ -595,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; }); @@ -605,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; @@ -636,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; @@ -654,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, }, ); @@ -672,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 () { @@ -740,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; }); @@ -757,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; }); @@ -793,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; }); @@ -803,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; }); @@ -833,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; }); @@ -845,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; }); @@ -855,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; }); @@ -891,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; }); @@ -901,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; }); @@ -911,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; }); @@ -921,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; }); @@ -947,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, @@ -991,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); }; @@ -1082,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, }, ]; @@ -1148,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, + ); }); }); @@ -1200,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, }, ]; @@ -1251,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', @@ -1260,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', @@ -1269,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', @@ -1278,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', @@ -1296,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', @@ -1305,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, @@ -1362,7 +1334,7 @@ describe('Shared: DriveConstraints', function () { ), ).to.deep.equal([ { - message: 'Insufficient space, additional 1 B required', + message: 'Too small', type: 2, }, ]); @@ -1404,7 +1376,7 @@ describe('Shared: DriveConstraints', function () { ), ).to.deep.equal([ { - message: 'Not Recommended', + message: 'Not recommended', type: 1, }, ]); @@ -1425,7 +1397,7 @@ describe('Shared: DriveConstraints', function () { type: 2, }, { - message: 'Insufficient space, additional 1 B required', + message: 'Too small', type: 2, }, { @@ -1437,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; - }); - }); - }); }); From 8fa6e618c4d52f4ec5e5c9fc93c74fb301c789c9 Mon Sep 17 00:00:00 2001 From: Lorenzo Alberto Maria Ambrosi Date: Tue, 1 Sep 2020 12:01:21 +0200 Subject: [PATCH 02/27] Use pretty-bytes instead of custom function Change-type: patch Signed-off-by: Lorenzo Alberto Maria Ambrosi --- .../drive-status-warning-modal.tsx | 4 +- .../target-selector-button.tsx | 12 +++--- lib/gui/app/modules/progress-status.ts | 4 +- lib/gui/app/pages/main/MainPage.tsx | 3 +- lib/shared/units.ts | 7 ---- npm-shrinkwrap.json | 2 +- tests/shared/units.spec.ts | 38 ++----------------- 7 files changed, 15 insertions(+), 55 deletions(-) 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 index e310c8da..451a74fb 100644 --- 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 @@ -5,7 +5,7 @@ import { Badge, Flex, Txt, ModalProps } from 'rendition'; import { Modal, ScrollableFlex } from '../../styled-components'; import { middleEllipsis } from '../../utils/middle-ellipsis'; -import { bytesToClosestUnit } from '../../../../shared/units'; +import * as prettyBytes from 'pretty-bytes'; import { DriveWithWarnings } from '../../pages/main/Flash'; const DriveStatusWarningModal = ({ @@ -66,7 +66,7 @@ const DriveStatusWarningModal = ({ <> {middleEllipsis(drive.description, 28)}{' '} - {bytesToClosestUnit(drive.size || 0)}{' '} + {prettyBytes(drive.size || 0)}{' '} {drive.statuses[0].message} {i !== array.length - 1 ?
: null} 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 00ea5b1f..4a05cea4 100644 --- a/lib/gui/app/components/target-selector/target-selector-button.tsx +++ b/lib/gui/app/components/target-selector/target-selector-button.tsx @@ -23,7 +23,7 @@ import { DriveStatus, } from '../../../../shared/drive-constraints'; import { compatibility, warning } from '../../../../shared/messages'; -import { bytesToClosestUnit } from '../../../../shared/units'; +import * as prettyBytes from 'pretty-bytes'; import { getSelectedDrives } from '../../models/selection-state'; import { ChangeButton, @@ -96,7 +96,7 @@ export function TargetSelectorButton(props: TargetSelectorProps) { Change )} - {bytesToClosestUnit(target.size)} + {prettyBytes(target.size)} ); } @@ -110,16 +110,16 @@ export function TargetSelectorButton(props: TargetSelectorProps) { targetsTemplate.push( {warnings.length && ( )} {middleEllipsis(target.description, 14)} - {bytesToClosestUnit(target.size)} + {prettyBytes(target.size)} , ); } diff --git a/lib/gui/app/modules/progress-status.ts b/lib/gui/app/modules/progress-status.ts index 950ac463..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: `${position ? bytesToClosestUnit(position) : ''}`, + position: `${position ? prettyBytes(position) : ''}`, }; } } else if (type === 'verifying') { diff --git a/lib/gui/app/pages/main/MainPage.tsx b/lib/gui/app/pages/main/MainPage.tsx index 8deca62a..d23a6874 100644 --- a/lib/gui/app/pages/main/MainPage.tsx +++ b/lib/gui/app/pages/main/MainPage.tsx @@ -18,6 +18,7 @@ import CogSvg from '@fortawesome/fontawesome-free/svgs/solid/cog.svg'; import QuestionCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/question-circle.svg'; 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'; @@ -40,8 +41,6 @@ import { ThemedProvider, } from '../../styled-components'; -import { bytesToClosestUnit } from '../../../../shared/units'; - import { TargetSelector, getDriveListLabel, diff --git a/lib/shared/units.ts b/lib/shared/units.ts index daae6707..ebf31a65 100644 --- a/lib/shared/units.ts +++ b/lib/shared/units.ts @@ -14,15 +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 { - return prettyBytes(bytes); -} diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index fdb297d4..433cc4a6 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -16775,4 +16775,4 @@ "dev": true } } -} +} \ No newline at end of file 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); }); }); }); From 14a89b3b8a25ae82e153e56bc97fcad983e1bbf4 Mon Sep 17 00:00:00 2001 From: Lorenzo Alberto Maria Ambrosi Date: Wed, 2 Sep 2020 17:39:40 +0200 Subject: [PATCH 03/27] Remove lodash from selection-state.ts Change-type: patch Signed-off-by: Lorenzo Alberto Maria Ambrosi --- lib/gui/app/models/selection-state.ts | 33 ++++++++++++--------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/lib/gui/app/models/selection-state.ts b/lib/gui/app/models/selection-state.ts index 06244e05..a7de51aa 100644 --- a/lib/gui/app/models/selection-state.ts +++ b/lib/gui/app/models/selection-state.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import * as _ from 'lodash'; import { SourceMetadata } from '../components/source-selector/source-selector'; import * as availableDrives from './available-drives'; @@ -60,48 +59,44 @@ export function getSelectedDevices(): string[] { */ export function getSelectedDrives(): any[] { const drives = availableDrives.getDrives(); - return _.map(getSelectedDevices(), (device) => { - return _.find(drives, { device }); + return getSelectedDevices().map((device) => { + return drives.find((drive) => drive.device === device); }); } /** * @summary Get the selected image */ -export function getImage(): SourceMetadata { - 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']); + return store.getState().toJS().selection.image?.path; } export function getImageSize(): number { - return _.get(store.getState().toJS(), ['selection', 'image', 'size']); + return store.getState().toJS().selection.image?.size; } export function getImageUrl(): string { - return _.get(store.getState().toJS(), ['selection', 'image', 'url']); + return store.getState().toJS().selection.image?.url; } export function getImageName(): string { - return _.get(store.getState().toJS(), ['selection', 'image', 'name']); + return store.getState().toJS().selection.image?.name; } export function getImageLogo(): string { - return _.get(store.getState().toJS(), ['selection', 'image', 'logo']); + return store.getState().toJS().selection.image?.logo; } export function getImageSupportUrl(): string { - return _.get(store.getState().toJS(), ['selection', 'image', 'supportUrl']); + return store.getState().toJS().selection.image?.supportUrl; } export function getImageRecommendedDriveSize(): number { - return _.get(store.getState().toJS(), [ - 'selection', - 'image', - 'recommendedDriveSize', - ]); + return store.getState().toJS().selection.image?.recommendedDriveSize; } /** @@ -115,7 +110,7 @@ export function hasDrive(): boolean { * @summary Check if there is a selected image */ export function hasImage(): boolean { - return !_.isEmpty(getImage()); + return getImage() !== undefined; } /** @@ -136,7 +131,7 @@ export function deselectImage() { } export function deselectAllDrives() { - _.each(getSelectedDevices(), deselectDrive); + getSelectedDevices().forEach(deselectDrive); } /** @@ -156,5 +151,5 @@ export function isDriveSelected(driveDevice: string) { } const selectedDriveDevices = getSelectedDevices(); - return _.includes(selectedDriveDevices, driveDevice); + return selectedDriveDevices.includes(driveDevice); } From f9d79521a11f09fdd2a31ccba9de096a11b292eb Mon Sep 17 00:00:00 2001 From: Lorenzo Alberto Maria Ambrosi Date: Wed, 2 Sep 2020 17:40:11 +0200 Subject: [PATCH 04/27] Fix tests not running Change-type: patch Signed-off-by: Lorenzo Alberto Maria Ambrosi --- lib/shared/drive-constraints.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/shared/drive-constraints.ts b/lib/shared/drive-constraints.ts index c75bd719..0e92a615 100644 --- a/lib/shared/drive-constraints.ts +++ b/lib/shared/drive-constraints.ts @@ -266,6 +266,7 @@ export function getListDriveImageCompatibilityStatuses( drives: DrivelistDrive[], image: SourceMetadata, ) { + // @ts-ignore return drives.flatMap((drive) => { return getDriveImageCompatibilityStatuses(drive, image); }); From 3e45691d0b207eb476df38a1b2250ffe4fa91fa7 Mon Sep 17 00:00:00 2001 From: Alexis Svinartchouk Date: Tue, 1 Sep 2020 16:40:34 +0200 Subject: [PATCH 05/27] Re-enable ext partitions trimming on 32 bit Windows Changelog-entry: Re-enable ext partitions trimming on 32 bit Windows Change-type: patch --- npm-shrinkwrap.json | 14 +++++++------- package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 433cc4a6..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", 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", From eeab35163658c982f9ec35f37b40649d5f99fad6 Mon Sep 17 00:00:00 2001 From: Lorenzo Alberto Maria Ambrosi Date: Wed, 2 Sep 2020 19:00:07 +0200 Subject: [PATCH 06/27] Fix tests hanging on array.flatMap Change-type: patch Signed-off-by: Lorenzo Alberto Maria Ambrosi --- lib/shared/drive-constraints.ts | 1 - tsconfig.json | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/shared/drive-constraints.ts b/lib/shared/drive-constraints.ts index 0e92a615..c75bd719 100644 --- a/lib/shared/drive-constraints.ts +++ b/lib/shared/drive-constraints.ts @@ -266,7 +266,6 @@ export function getListDriveImageCompatibilityStatuses( drives: DrivelistDrive[], image: SourceMetadata, ) { - // @ts-ignore return drives.flatMap((drive) => { return getDriveImageCompatibilityStatuses(drive, image); }); 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"] } From b76366a514edd494188cfdc6eccbd2a1d2c49c61 Mon Sep 17 00:00:00 2001 From: Lorenzo Alberto Maria Ambrosi Date: Thu, 3 Sep 2020 15:46:18 +0200 Subject: [PATCH 07/27] Add more typings & refactor code accordingly Change-type: patch Signed-off-by: Lorenzo Alberto Maria Ambrosi --- lib/gui/app/app.ts | 10 +- .../drive-selector/drive-selector.tsx | 4 +- .../drive-status-warning-modal.tsx | 2 +- .../reduced-flashing-infos.tsx | 9 +- .../source-selector/source-selector.tsx | 11 +- lib/gui/app/components/svg-icon/svg-icon.tsx | 5 +- .../target-selector-button.tsx | 16 +- lib/gui/app/models/available-drives.ts | 3 +- lib/gui/app/models/selection-state.ts | 41 ++-- lib/gui/app/pages/main/Flash.tsx | 18 +- lib/gui/app/pages/main/MainPage.tsx | 8 +- lib/shared/messages.ts | 18 +- tests/gui/models/available-drives.spec.ts | 4 + tests/gui/models/selection-state.spec.ts | 224 +++++------------- 14 files changed, 140 insertions(+), 233 deletions(-) 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/drive-selector/drive-selector.tsx b/lib/gui/app/components/drive-selector/drive-selector.tsx index 290db14c..8bd3daef 100644 --- a/lib/gui/app/components/drive-selector/drive-selector.tsx +++ b/lib/gui/app/components/drive-selector/drive-selector.tsx @@ -289,8 +289,8 @@ export class DriveSelector extends React.Component< { field: 'description', key: 'extra', - // Space as empty string would use the field name as label - label: , + // 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); 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 index 451a74fb..4000917e 100644 --- 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 @@ -66,7 +66,7 @@ const DriveStatusWarningModal = ({ <> {middleEllipsis(drive.description, 28)}{' '} - {prettyBytes(drive.size || 0)}{' '} + {drive.size && prettyBytes(drive.size) + ' '} {drive.statuses[0].message} {i !== array.length - 1 ?
: null} 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 c07fd83c..2e8dc3d6 100644 --- a/lib/gui/app/components/source-selector/source-selector.tsx +++ b/lib/gui/app/components/source-selector/source-selector.tsx @@ -254,6 +254,7 @@ export interface SourceMetadata extends sourceDestination.Metadata { SourceType: Source; drive?: DrivelistDrive; extension?: string; + archiveExtension?: string; } interface SourceSelectorProps { @@ -262,8 +263,8 @@ interface SourceSelectorProps { interface SourceSelectorState { hasImage: boolean; - imageName: string; - imageSize: number; + imageName?: string; + imageSize?: number; warning: { message: string; title: string | null } | null; showImageDetails: boolean; showURLSelector: boolean; @@ -543,7 +544,7 @@ export class SourceSelector extends React.Component< const imagePath = image.path || image.displayName || ''; const imageBasename = path.basename(imagePath); const imageName = image.name || ''; - const imageSize = image.size || 0; + const imageSize = image.size; const imageLogo = image.logo || ''; return ( @@ -585,7 +586,9 @@ export class SourceSelector extends React.Component< Remove )} - {prettyBytes(imageSize)} + {!_.isNil(imageSize) && ( + {prettyBytes(imageSize)} + )} ) : ( <> 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 4a05cea4..1b529b45 100644 --- a/lib/gui/app/components/target-selector/target-selector-button.tsx +++ b/lib/gui/app/components/target-selector/target-selector-button.tsx @@ -96,7 +96,9 @@ export function TargetSelectorButton(props: TargetSelectorProps) { Change )} - {prettyBytes(target.size)} + {target.size != null && ( + {prettyBytes(target.size)} + )} ); } @@ -110,16 +112,16 @@ export function TargetSelectorButton(props: TargetSelectorProps) { targetsTemplate.push( - {warnings.length && ( + {warnings.length > 0 ? ( - )} + ) : null} {middleEllipsis(target.description, 14)} - {prettyBytes(target.size)} + {target.size != null && {prettyBytes(target.size)}} , ); } diff --git a/lib/gui/app/models/available-drives.ts b/lib/gui/app/models/available-drives.ts index 7acc4a55..0bff74fb 100644 --- a/lib/gui/app/models/available-drives.ts +++ b/lib/gui/app/models/available-drives.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { DrivelistDrive } from '../../../shared/drive-constraints'; import { Actions, store } from './store'; export function hasAvailableDrives() { @@ -27,6 +28,6 @@ export function setDrives(drives: any[]) { }); } -export function getDrives(): any[] { +export function getDrives(): DrivelistDrive[] { return store.getState().toJS().availableDrives; } diff --git a/lib/gui/app/models/selection-state.ts b/lib/gui/app/models/selection-state.ts index a7de51aa..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 * @@ -40,7 +41,7 @@ export function toggleDrive(driveDevice: string) { } } -export function selectSource(source: any) { +export function selectSource(source: SourceMetadata) { store.dispatch({ type: Actions.SELECT_SOURCE, data: source, @@ -57,11 +58,11 @@ export function getSelectedDevices(): string[] { /** * @summary Get all selected drive objects */ -export function getSelectedDrives(): any[] { - const drives = availableDrives.getDrives(); - return getSelectedDevices().map((device) => { - return drives.find((drive) => drive.device === device); - }); +export function getSelectedDrives(): DrivelistDrive[] { + const selectedDevices = getSelectedDevices(); + return availableDrives + .getDrives() + .filter((drive) => selectedDevices.includes(drive.device)); } /** @@ -71,32 +72,24 @@ export function getImage(): SourceMetadata | undefined { return store.getState().toJS().selection.image; } -export function getImagePath(): string { - return store.getState().toJS().selection.image?.path; +export function getImagePath() { + return getImage()?.path; } -export function getImageSize(): number { - return store.getState().toJS().selection.image?.size; +export function getImageSize() { + return getImage()?.size; } -export function getImageUrl(): string { - return store.getState().toJS().selection.image?.url; +export function getImageName() { + return getImage()?.name; } -export function getImageName(): string { - return store.getState().toJS().selection.image?.name; +export function getImageLogo() { + return getImage()?.logo; } -export function getImageLogo(): string { - return store.getState().toJS().selection.image?.logo; -} - -export function getImageSupportUrl(): string { - return store.getState().toJS().selection.image?.supportUrl; -} - -export function getImageRecommendedDriveSize(): number { - return store.getState().toJS().selection.image?.recommendedDriveSize; +export function getImageSupportUrl() { + return getImage()?.supportUrl; } /** diff --git a/lib/gui/app/pages/main/Flash.tsx b/lib/gui/app/pages/main/Flash.tsx index 62ed0637..57c4b4f3 100644 --- a/lib/gui/app/pages/main/Flash.tsx +++ b/lib/gui/app/pages/main/Flash.tsx @@ -198,18 +198,12 @@ export class FlashStep extends React.PureComponent< } private async tryFlash() { - const devices = selection.getSelectedDevices(); - const drives = availableDrives - .getDrives() - .filter((drive: { device: string }) => { - return devices.includes(drive.device); - }) - .map((drive) => { - return { - ...drive, - statuses: constraints.getDriveImageCompatibilityStatuses(drive), - }; - }); + const drives = selection.getSelectedDrives().map((drive) => { + return { + ...drive, + statuses: constraints.getDriveImageCompatibilityStatuses(drive), + }; + }); if (drives.length === 0 || this.props.isFlashing) { return; } diff --git a/lib/gui/app/pages/main/MainPage.tsx b/lib/gui/app/pages/main/MainPage.tsx index d23a6874..47e2c9da 100644 --- a/lib/gui/app/pages/main/MainPage.tsx +++ b/lib/gui/app/pages/main/MainPage.tsx @@ -103,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; } @@ -272,7 +272,7 @@ export class MainPage extends React.Component< imageName={this.state.imageName} imageSize={ typeof this.state.imageSize === 'number' - ? (prettyBytes(this.state.imageSize) as string) + ? prettyBytes(this.state.imageSize) : '' } driveTitle={this.state.driveTitle} diff --git a/lib/shared/messages.ts b/lib/shared/messages.ts index b3cd838b..9bdd3372 100644 --- a/lib/shared/messages.ts +++ b/lib/shared/messages.ts @@ -15,6 +15,7 @@ */ import { Dictionary } from 'lodash'; +import { outdent } from 'outdent'; import * as prettyBytes from 'pretty-bytes'; export const progress: Dictionary<(quantity: number) => string> = { @@ -84,10 +85,10 @@ export const warning = { image: { recommendedDriveSize: number }, drive: { device: string; size: number }, ) => { - return [ - `This image recommends a ${prettyBytes(image.recommendedDriveSize)}`, - `drive, however ${drive.device} is only ${prettyBytes(drive.size)}.`, - ].join(' '); + return outdent({ newline: ' ' })` + This image recommends a ${prettyBytes(image.recommendedDriveSize)} + drive, however ${drive.device} is only ${prettyBytes(drive.size)}. + `; }, exitWhileFlashing: () => { @@ -150,10 +151,11 @@ export const error = { }, openSource: (sourceName: string, errorMessage: string) => { - return [ - `Something went wrong while opening ${sourceName}\n\n`, - `Error: ${errorMessage}`, - ].join(''); + return outdent` + Something went wrong while opening ${sourceName} + + Error: ${errorMessage} + `; }, flashFailure: ( diff --git a/tests/gui/models/available-drives.spec.ts b/tests/gui/models/available-drives.spec.ts index 126a4640..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'; @@ -158,10 +159,13 @@ describe('Model: availableDrives', function () { selectionState.clear(); 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 234dde8b..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, - }, ]); }); }); @@ -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(); @@ -446,10 +426,13 @@ describe('Model: selectionState', function () { describe('.selectImage()', function () { it('should override the image', function () { 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.selectSource({ - path: 'foo.img', - extension: 'img', - size: 999999999, - isSizeEstimated: false, - }); + selectionState.selectSource(image); const imagePath = selectionState.getImagePath(); expect(imagePath).to.equal('foo.img'); @@ -491,11 +480,9 @@ describe('Model: selectionState', function () { it('should be able to set an image with an archive extension', function () { selectionState.selectSource({ + ...image, path: 'foo.zip', - extension: 'img', archiveExtension: 'zip', - size: 999999999, - isSizeEstimated: false, }); const imagePath = selectionState.getImagePath(); @@ -504,11 +491,9 @@ describe('Model: selectionState', function () { it('should infer a compressed raw image if the penultimate extension is missing', function () { selectionState.selectSource({ + ...image, path: 'foo.xz', - extension: 'img', archiveExtension: 'xz', - size: 999999999, - isSizeEstimated: false, }); const imagePath = selectionState.getImagePath(); @@ -517,53 +502,19 @@ describe('Model: selectionState', function () { it('should infer a compressed raw image if the penultimate extension is not a file extension', function () { 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.selectSource({ - 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.selectSource({ - 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.selectSource({ - 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.selectSource({ + ...image, path: 'foo.img', extension: 'img', size: 999999999, @@ -576,33 +527,17 @@ describe('Model: selectionState', function () { it('should throw if the original size is negative', function () { expect(function () { selectionState.selectSource({ - path: 'foo.img', - extension: 'img', - size: 999999999, + ...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.selectSource({ - 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.selectSource({ - path: 'foo.img', - extension: 'img', + ...image, size: 999999999.999, - isSizeEstimated: false, }); }).to.throw('Invalid image size: 999999999.999'); }); @@ -610,50 +545,12 @@ describe('Model: selectionState', function () { it('should throw if the final size is negative', function () { expect(function () { selectionState.selectSource({ - path: 'foo.img', - extension: 'img', + ...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.selectSource({ - 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.selectSource({ - 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.selectSource({ - 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([ { @@ -668,10 +565,8 @@ describe('Model: selectionState', function () { expect(selectionState.hasDrive()).to.be.true; selectionState.selectSource({ - path: 'foo.img', - extension: 'img', + ...image, size: 1234567890, - isSizeEstimated: false, }); expect(selectionState.hasDrive()).to.be.false; @@ -692,10 +587,7 @@ describe('Model: selectionState', function () { expect(selectionState.hasDrive()).to.be.true; selectionState.selectSource({ - path: 'foo.img', - extension: 'img', - size: 999999999, - isSizeEstimated: false, + ...image, recommendedDriveSize: 1500000000, }); @@ -727,10 +619,10 @@ describe('Model: selectionState', function () { expect(selectionState.hasDrive()).to.be.true; 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.selectSource({ - 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.selectSource({ - path: 'foo.img', - extension: 'img', - size: 999999999, - isSizeEstimated: false, - }); + selectionState.selectSource(image); }); describe('.clear()', function () { From 78a5339e3e24a3ad2cc44f6925f63890d4d9d135 Mon Sep 17 00:00:00 2001 From: Balena CI <34882892+balena-ci@users.noreply.github.com> Date: Mon, 7 Sep 2020 12:50:26 +0300 Subject: [PATCH 08/27] v1.5.107 --- CHANGELOG.md | 8 ++++++++ npm-shrinkwrap.json | 4 ++-- package.json | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0264ee91..f5521120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +# v1.5.107 +## (2020-09-04) + +* Re-enable ext partitions trimming on 32 bit Windows [Alexis Svinartchouk] +* Rework system & large drives handling logic [Lorenzo Alberto Maria Ambrosi] +* Reword macOS Catalina askpass message [Lorenzo Alberto Maria Ambrosi] +* Add clone-drive workflow [Lorenzo Alberto Maria Ambrosi] + # v1.5.106 ## (2020-08-27) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index ce8d16f3..860f20b1 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,6 +1,6 @@ { "name": "balena-etcher", - "version": "1.5.106", + "version": "1.5.107", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -16775,4 +16775,4 @@ "dev": true } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 61eb5749..a1a3803d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "balena-etcher", "private": true, "displayName": "balenaEtcher", - "version": "1.5.106", + "version": "1.5.107", "packageType": "local", "main": "generated/etcher.js", "description": "Flash OS images to SD cards and USB drives, safely and easily.", From b9076d01af583572aa914968994b2c6e05f9c88c Mon Sep 17 00:00:00 2001 From: Alexis Svinartchouk Date: Wed, 9 Sep 2020 17:06:04 +0200 Subject: [PATCH 09/27] Fix content not loading when the app path contains special characters Changelog-entry: Fix content not loading when the app path contains special characters Change-type: patch --- lib/gui/etcher.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/gui/etcher.ts b/lib/gui/etcher.ts index 59163bad..36282fd4 100644 --- a/lib/gui/etcher.ts +++ b/lib/gui/etcher.ts @@ -174,7 +174,13 @@ async function createMainWindow() { event.preventDefault(); }); - mainWindow.loadURL(`file://${path.join(__dirname, 'index.html')}`); + mainWindow.loadURL( + `file://${path.join( + '/', + ...__dirname.split(path.sep).map(encodeURIComponent), + 'index.html', + )}`, + ); const page = mainWindow.webContents; From ae62812c619081b6847ef21897831e132243676d Mon Sep 17 00:00:00 2001 From: Balena CI <34882892+balena-ci@users.noreply.github.com> Date: Thu, 10 Sep 2020 20:33:45 +0300 Subject: [PATCH 10/27] v1.5.108 --- CHANGELOG.md | 5 +++++ npm-shrinkwrap.json | 2 +- package.json | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5521120..9947cd24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +# v1.5.108 +## (2020-09-10) + +* Fix content not loading when the app path contains special characters [Alexis Svinartchouk] + # v1.5.107 ## (2020-09-04) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 860f20b1..c493b36e 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,6 +1,6 @@ { "name": "balena-etcher", - "version": "1.5.107", + "version": "1.5.108", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index a1a3803d..bedfbcbe 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "balena-etcher", "private": true, "displayName": "balenaEtcher", - "version": "1.5.107", + "version": "1.5.108", "packageType": "local", "main": "generated/etcher.js", "description": "Flash OS images to SD cards and USB drives, safely and easily.", From 7c2644ec51097e9251ac587845552ac23036084c Mon Sep 17 00:00:00 2001 From: Alexis Svinartchouk Date: Fri, 11 Sep 2020 14:38:54 +0200 Subject: [PATCH 11/27] Workaround elevation bug on Windows when the username contains an ampersand Changelog-entry: Workaround elevation bug on Windows when the username contains an ampersand Change-type: patch --- npm-shrinkwrap.json | 7 +++---- package.json | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index c493b36e..6104f7da 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -14257,9 +14257,8 @@ } }, "sudo-prompt": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/sudo-prompt/-/sudo-prompt-9.2.1.tgz", - "integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==", + "version": "github:zvin/sudo-prompt#81cab70c1f3f816b71539c4c5d7ecf1309094f8c", + "from": "github:zvin/sudo-prompt#workaround-windows-amperstand-in-username", "dev": true }, "sumchecker": { @@ -16775,4 +16774,4 @@ "dev": true } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index bedfbcbe..83317216 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "spectron": "^11.0.0", "string-replace-loader": "^2.3.0", "styled-components": "^5.1.0", - "sudo-prompt": "^9.0.0", + "sudo-prompt": "github:zvin/sudo-prompt#workaround-windows-amperstand-in-username", "sys-class-rgb-led": "^2.1.0", "tmp": "^0.2.1", "ts-loader": "^8.0.0", From 0a28a7794d4a5fa2fb55e11999b69d3a982536d3 Mon Sep 17 00:00:00 2001 From: Alexis Svinartchouk Date: Mon, 14 Sep 2020 16:08:44 +0200 Subject: [PATCH 12/27] Update ext2fs to v2.0.5 Change-type: patch --- npm-shrinkwrap.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 6104f7da..4c68d1c4 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -6871,9 +6871,9 @@ } }, "ext2fs": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/ext2fs/-/ext2fs-2.0.4.tgz", - "integrity": "sha512-7ILtkKb6j9L+nR1qO4zCiy6aZulzKu7dO82na+qXwc6KEoEr23u/u476/thebbPcvYJMv71I7FebJv8P4MNjHw==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/ext2fs/-/ext2fs-2.0.5.tgz", + "integrity": "sha512-qNv+XrXrauspqoUYRgcKV7HNkoDAAY/KU6nZHGB8Y2tT553fiMtiZd4VYOdxd+0zrNZozi+0fJjLbiGBnEGJUw==", "dev": true, "requires": { "bindings": "^1.3.0", From e9603505d2f9f6bee6575bd4c99a09c277d77132 Mon Sep 17 00:00:00 2001 From: Balena CI <34882892+balena-ci@users.noreply.github.com> Date: Mon, 14 Sep 2020 19:27:56 +0300 Subject: [PATCH 13/27] v1.5.109 --- CHANGELOG.md | 5 +++++ npm-shrinkwrap.json | 4 ++-- package.json | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9947cd24..f6fe70d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +# v1.5.109 +## (2020-09-14) + +* Workaround elevation bug on Windows when the username contains an ampersand [Alexis Svinartchouk] + # v1.5.108 ## (2020-09-10) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 4c68d1c4..77e1b4d0 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,6 +1,6 @@ { "name": "balena-etcher", - "version": "1.5.108", + "version": "1.5.109", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -16774,4 +16774,4 @@ "dev": true } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 83317216..0d505dee 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "balena-etcher", "private": true, "displayName": "balenaEtcher", - "version": "1.5.108", + "version": "1.5.109", "packageType": "local", "main": "generated/etcher.js", "description": "Flash OS images to SD cards and USB drives, safely and easily.", From 8ff8b02f37af7d2ffb9eb16465788d316e327ffe Mon Sep 17 00:00:00 2001 From: Lorenzo Alberto Maria Ambrosi Date: Wed, 24 Jun 2020 19:04:33 +0200 Subject: [PATCH 14/27] Rework success screen Change-type: patch Changelog-entry: Rework success screen Signed-off-by: Lorenzo Alberto Maria Ambrosi --- lib/gui/app/components/finish/finish.tsx | 82 +++---- .../flash-another/flash-another.tsx | 2 +- .../flash-results/flash-results.tsx | 156 ++++++++++--- .../components/safe-webview/safe-webview.tsx | 5 +- lib/gui/app/pages/main/MainPage.tsx | 212 ++++++++---------- lib/gui/modules/child-writer.ts | 6 +- 6 files changed, 269 insertions(+), 194 deletions(-) diff --git a/lib/gui/app/components/finish/finish.tsx b/lib/gui/app/components/finish/finish.tsx index 6484461f..373c9cc2 100644 --- a/lib/gui/app/components/finish/finish.tsx +++ b/lib/gui/app/components/finish/finish.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import * as _ from 'lodash'; import * as React from 'react'; import { Flex } from 'rendition'; import { v4 as uuidV4 } from 'uuid'; @@ -23,13 +22,9 @@ import * as flashState from '../../models/flash-state'; import * as selectionState from '../../models/selection-state'; import { Actions, store } from '../../models/store'; import * as analytics from '../../modules/analytics'; -import { open as openExternal } from '../../os/open-external/services/open-external'; import { FlashAnother } from '../flash-another/flash-another'; import { FlashResults } from '../flash-results/flash-results'; - -import EtcherSvg from '../../../assets/etcher.svg'; -import LoveSvg from '../../../assets/love.svg'; -import BalenaSvg from '../../../assets/balena.svg'; +import { SafeWebview } from '../safe-webview/safe-webview'; function restart(goToMain: () => void) { selectionState.deselectAllDrives(); @@ -44,22 +39,31 @@ function restart(goToMain: () => void) { goToMain(); } -function formattedErrors() { - const errors = _.map( - _.get(flashState.getFlashResults(), ['results', 'errors']), - (error) => { - return `${error.device}: ${error.message || error.code}`; - }, - ); - return errors.join('\n'); -} - function FinishPage({ goToMain }: { goToMain: () => void }) { + const [webviewShowing, setWebviewShowing] = React.useState(false); + const errors = flashState.getFlashResults().results?.errors; const results = flashState.getFlashResults().results || {}; return ( - - - + + + { @@ -67,34 +71,18 @@ function FinishPage({ goToMain }: { goToMain: () => void }) { }} /> - - - - Thanks for using - - openExternal('https://balena.io/etcher?ref=etcher_offline_banner') - } - /> - - - made with - - by - openExternal('https://balena.io?ref=etcher_success')} - /> - - + ); } diff --git a/lib/gui/app/components/flash-another/flash-another.tsx b/lib/gui/app/components/flash-another/flash-another.tsx index 5efc25b4..3b5741a3 100644 --- a/lib/gui/app/components/flash-another/flash-another.tsx +++ b/lib/gui/app/components/flash-another/flash-another.tsx @@ -25,7 +25,7 @@ export interface FlashAnotherProps { export const FlashAnother = (props: FlashAnotherProps) => { return ( - Flash Another + Flash another ); }; diff --git a/lib/gui/app/components/flash-results/flash-results.tsx b/lib/gui/app/components/flash-results/flash-results.tsx index 9749599d..a1bedc16 100644 --- a/lib/gui/app/components/flash-results/flash-results.tsx +++ b/lib/gui/app/components/flash-results/flash-results.tsx @@ -19,16 +19,82 @@ import CheckCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/check-circl import * as _ from 'lodash'; import outdent from 'outdent'; import * as React from 'react'; -import { Flex, Txt } from 'rendition'; +import { Flex, FlexProps, Link, Table, TableColumn, Txt } from 'rendition'; +import styled from 'styled-components'; import { progress } from '../../../../shared/messages'; import { bytesToMegabytes } from '../../../../shared/units'; +import FlashSvg from '../../../assets/flash.svg'; +import { middleEllipsis } from '../../utils/middle-ellipsis'; +import { Modal } from '../../styled-components'; + +const ErrorsTable = styled(({ refFn, ...props }) => { + return ( +
+ ref={refFn} {...props} /> +
+ ); +})` + [data-display='table-head'] [data-display='table-cell'] { + width: 50%; + position: sticky; + top: 0; + background-color: ${(props) => props.theme.colors.quartenary.light}; + } + + [data-display='table-cell']:first-child { + padding-left: 15px; + } + + [data-display='table-cell']:last-child { + width: 150px; + } + + && [data-display='table-row'] > [data-display='table-cell'] { + padding: 6px 8px; + color: #2a506f; + } +`; + +interface FlashError extends Error { + description: string; + device: string; + code: string; +} + +function formattedErrors(errors: FlashError[]) { + return errors + .map((error) => `${error.device}: ${error.message || error.code}`) + .join('\n'); +} + +const columns: Array> = [ + { + field: 'description', + label: 'Target', + }, + { + field: 'device', + label: 'Location', + }, + { + field: 'message', + label: 'Error', + render: (message: string, { code }: FlashError) => { + return message ? message : code; + }, + }, +]; + export function FlashResults({ + image = '', errors, results, + ...props }: { - errors: string; + image?: string; + errors: FlashError[]; results: { bytesWritten: number; sourceMetadata: { @@ -38,7 +104,8 @@ export function FlashResults({ averageFlashingSpeed: number; devices: { failed: number; successful: number }; }; -}) { +} & FlexProps) { + const [showErrorsInfo, setShowErrorsInfo] = React.useState(false); const allDevicesFailed = results.devices.successful === 0; const effectiveSpeed = _.round( bytesToMegabytes( @@ -48,40 +115,58 @@ export function FlashResults({ 1, ); return ( - - - - + + + + + + {middleEllipsis(image, 16)} + + Flash Complete! - + {Object.entries(results.devices).map(([type, quantity]) => { + const failedTargets = type === 'failed'; return quantity ? ( - + - {quantity} - {progress[type](quantity)} + + {quantity} + + + {progress[type](quantity)} + + {failedTargets && ( + setShowErrorsInfo(true)}> + more info + + )} ) : null; })} @@ -101,6 +186,21 @@ export function FlashResults({ )} + + {showErrorsInfo && ( + + + Failed targets + + + } + done={() => setShowErrorsInfo(false)} + > + + + )}
); } diff --git a/lib/gui/app/components/safe-webview/safe-webview.tsx b/lib/gui/app/components/safe-webview/safe-webview.tsx index 614ae11d..6e7f1ea3 100644 --- a/lib/gui/app/components/safe-webview/safe-webview.tsx +++ b/lib/gui/app/components/safe-webview/safe-webview.tsx @@ -15,7 +15,6 @@ */ import * as electron from 'electron'; -import * as _ from 'lodash'; import * as React from 'react'; import * as packageJSON from '../../../../../package.json'; @@ -94,8 +93,8 @@ export class SafeWebview extends React.PureComponent< ); this.entryHref = url.href; // Events steal 'this' - this.didFailLoad = _.bind(this.didFailLoad, this); - this.didGetResponseDetails = _.bind(this.didGetResponseDetails, this); + this.didFailLoad = this.didFailLoad.bind(this); + this.didGetResponseDetails = this.didGetResponseDetails.bind(this); // Make a persistent electron session for the webview this.session = electron.remote.session.fromPartition(ELECTRON_SESSION, { // Disable the cache for the session such that new content shows up when refreshing diff --git a/lib/gui/app/pages/main/MainPage.tsx b/lib/gui/app/pages/main/MainPage.tsx index 47e2c9da..6cf5a1ad 100644 --- a/lib/gui/app/pages/main/MainPage.tsx +++ b/lib/gui/app/pages/main/MainPage.tsx @@ -25,7 +25,6 @@ import styled from 'styled-components'; import FinishPage from '../../components/finish/finish'; import { ReducedFlashingInfos } from '../../components/reduced-flashing-infos/reduced-flashing-infos'; -import { SafeWebview } from '../../components/safe-webview/safe-webview'; import { SettingsModal } from '../../components/settings/settings'; import { SourceMetadata, @@ -48,6 +47,7 @@ import { import { FlashStep } from './Flash'; import EtcherSvg from '../../../assets/etcher.svg'; +import { SafeWebview } from '../../components/safe-webview/safe-webview'; const Icon = styled(BaseIcon)` margin-right: 20px; @@ -169,7 +169,104 @@ export class MainPage extends React.Component< const notFlashingOrSplitView = !this.state.isFlashing || !this.state.isWebviewShowing; return ( - <> + + {notFlashingOrSplitView && ( + <> + + + + + + + + + + )} + + {this.state.isFlashing && this.state.isWebviewShowing && ( + + + + )} + {this.state.isFlashing && this.state.featuredProjectURL && ( + { + this.setState({ isWebviewShowing }); + }} + style={{ + position: 'absolute', + right: 0, + bottom: 0, + width: '63.8vw', + height: '100vh', + }} + /> + )} + + this.setState({ current: 'success' })} + shouldFlashStepBeDisabled={shouldFlashStepBeDisabled} + isFlashing={this.state.isFlashing} + step={state.type} + percentage={state.percentage} + position={state.position} + failed={state.failed} + speed={state.speed} + eta={state.eta} + style={{ zIndex: 1 }} + /> + + ); + } + + private renderSuccess() { + return ( + { + flashState.resetState(); + this.setState({ current: 'main' }); + }} + /> + ); + } + + public render() { + return ( + )} - - - {notFlashingOrSplitView && ( - <> - - - - - - - - - - )} - - {this.state.isFlashing && this.state.isWebviewShowing && ( - - - - )} - {this.state.isFlashing && this.state.featuredProjectURL && ( - { - this.setState({ isWebviewShowing }); - }} - style={{ - position: 'absolute', - right: 0, - bottom: 0, - width: '63.8vw', - height: '100vh', - }} - /> - )} - - this.setState({ current: 'success' })} - shouldFlashStepBeDisabled={shouldFlashStepBeDisabled} - isFlashing={this.state.isFlashing} - step={state.type} - percentage={state.percentage} - position={state.position} - failed={state.failed} - speed={state.speed} - eta={state.eta} - style={{ zIndex: 1 }} - /> - - - ); - } - - private renderSuccess() { - return ( - - { - flashState.resetState(); - this.setState({ current: 'main' }); - }} - /> - - - ); - } - - public render() { - return ( - {this.state.current === 'main' ? this.renderMain() : this.renderSuccess()} diff --git a/lib/gui/modules/child-writer.ts b/lib/gui/modules/child-writer.ts index 1f60fdd7..4c135dac 100644 --- a/lib/gui/modules/child-writer.ts +++ b/lib/gui/modules/child-writer.ts @@ -136,8 +136,10 @@ async function writeAndValidate({ sourceMetadata, }; for (const [destination, error] of failures) { - const err = error as Error & { device: string }; - err.device = (destination as sdk.sourceDestination.BlockDevice).device; + const err = error as Error & { device: string; description: string }; + const drive = destination as sdk.sourceDestination.BlockDevice; + err.device = drive.device; + err.description = drive.description; result.errors.push(err); } return result; From 74a78076cf0cc7144fe916d0a77bca743fb3aace Mon Sep 17 00:00:00 2001 From: Lorenzo Alberto Maria Ambrosi Date: Wed, 8 Jul 2020 16:07:15 +0200 Subject: [PATCH 15/27] Add skip function to validation Change-type: patch Changelog-entry: Add skip function to validation Signed-off-by: Lorenzo Alberto Maria Ambrosi --- lib/gui/app/components/finish/finish.tsx | 32 +++++++++++++-- .../flash-results/flash-results.tsx | 5 ++- .../progress-button/progress-button.tsx | 41 +++++++++++-------- lib/gui/app/models/flash-state.ts | 19 +++++++-- lib/gui/app/models/leds.ts | 5 ++- lib/gui/app/models/store.ts | 4 +- lib/gui/app/modules/image-writer.ts | 23 +++++++---- lib/gui/app/pages/main/Flash.tsx | 10 ++--- lib/gui/modules/child-writer.ts | 9 ++++ 9 files changed, 108 insertions(+), 40 deletions(-) diff --git a/lib/gui/app/components/finish/finish.tsx b/lib/gui/app/components/finish/finish.tsx index 373c9cc2..e5c3a9aa 100644 --- a/lib/gui/app/components/finish/finish.tsx +++ b/lib/gui/app/components/finish/finish.tsx @@ -23,7 +23,7 @@ import * as selectionState from '../../models/selection-state'; import { Actions, store } from '../../models/store'; import * as analytics from '../../modules/analytics'; import { FlashAnother } from '../flash-another/flash-another'; -import { FlashResults } from '../flash-results/flash-results'; +import { FlashResults, FlashError } from '../flash-results/flash-results'; import { SafeWebview } from '../safe-webview/safe-webview'; function restart(goToMain: () => void) { @@ -41,8 +41,33 @@ function restart(goToMain: () => void) { function FinishPage({ goToMain }: { goToMain: () => void }) { const [webviewShowing, setWebviewShowing] = React.useState(false); - const errors = flashState.getFlashResults().results?.errors; - const results = flashState.getFlashResults().results || {}; + let errors = flashState.getFlashResults().results?.errors; + if (errors === undefined) { + errors = (store.getState().toJS().failedDevicePaths || []).map( + ([, error]: [string, FlashError]) => ({ + ...error, + }), + ); + } + const { + averageSpeed, + blockmappedSize, + bytesWritten, + failed, + size, + } = flashState.getFlashState(); + const { + skip, + results = { + bytesWritten, + sourceMetadata: { + size, + blockmappedSize, + }, + averageFlashingSpeed: averageSpeed, + devices: { failed, successful: 0 }, + }, + } = flashState.getFlashResults(); return ( void }) { diff --git a/lib/gui/app/components/flash-results/flash-results.tsx b/lib/gui/app/components/flash-results/flash-results.tsx index a1bedc16..72879ee5 100644 --- a/lib/gui/app/components/flash-results/flash-results.tsx +++ b/lib/gui/app/components/flash-results/flash-results.tsx @@ -57,7 +57,7 @@ const ErrorsTable = styled(({ refFn, ...props }) => { } `; -interface FlashError extends Error { +export interface FlashError extends Error { description: string; device: string; code: string; @@ -91,10 +91,12 @@ export function FlashResults({ image = '', errors, results, + skip, ...props }: { image?: string; errors: FlashError[]; + skip: boolean; results: { bytesWritten: number; sourceMetadata: { @@ -142,6 +144,7 @@ export function FlashResults({ Flash Complete! + {skip ? Validation has been skipped : null} {Object.entries(results.devices).map(([type, quantity]) => { diff --git a/lib/gui/app/components/progress-button/progress-button.tsx b/lib/gui/app/components/progress-button/progress-button.tsx index c46f85ed..9e328eea 100644 --- a/lib/gui/app/components/progress-button/progress-button.tsx +++ b/lib/gui/app/components/progress-button/progress-button.tsx @@ -49,7 +49,7 @@ interface ProgressButtonProps { percentage: number; position: number; disabled: boolean; - cancel: () => void; + cancel: (type: string) => void; callback: () => void; warning?: boolean; } @@ -60,11 +60,14 @@ const colors = { verifying: '#1ac135', } as const; -const CancelButton = styled((props) => ( - -))` +const CancelButton = styled(({ type, onClick, ...props }) => { + const status = type === 'verifying' ? 'Skip' : 'Cancel'; + return ( + + ); +})` font-weight: 600; &&& { width: auto; @@ -75,10 +78,13 @@ const CancelButton = styled((props) => ( export class ProgressButton extends React.PureComponent { public render() { + const type = this.props.type; + const percentage = this.props.percentage; + const warning = this.props.warning; const { status, position } = fromFlashState({ - type: this.props.type, + type, + percentage, position: this.props.position, - percentage: this.props.percentage, }); if (this.props.active) { return ( @@ -96,21 +102,24 @@ export class ProgressButton extends React.PureComponent { > {status}  - {position} + {position} - + {type && ( + + )} - + ); } return ( devicePath, + ); const newLedsState = { step, sourceDrive: sourceDrivePath, availableDrives: availableDrivesPaths, selectedDrives: selectedDrivesPaths, - failedDrives: s.failedDevicePaths, + failedDrives: failedDevicePaths, }; if (!_.isEqual(newLedsState, ledsState)) { updateLeds(newLedsState); diff --git a/lib/gui/app/models/store.ts b/lib/gui/app/models/store.ts index 0a1ac58b..9d8e30fa 100644 --- a/lib/gui/app/models/store.ts +++ b/lib/gui/app/models/store.ts @@ -295,6 +295,7 @@ function storeReducer( _.defaults(action.data, { cancelled: false, + skip: false, }); if (!_.isBoolean(action.data.cancelled)) { @@ -337,8 +338,7 @@ function storeReducer( return state .set('isFlashing', false) - .set('flashResults', Immutable.fromJS(action.data)) - .set('flashState', DEFAULT_STATE.get('flashState')); + .set('flashResults', Immutable.fromJS(action.data)); } case Actions.SELECT_TARGET: { diff --git a/lib/gui/app/modules/image-writer.ts b/lib/gui/app/modules/image-writer.ts index 8091ede7..175de132 100644 --- a/lib/gui/app/modules/image-writer.ts +++ b/lib/gui/app/modules/image-writer.ts @@ -131,6 +131,7 @@ function writerEnv() { } interface FlashResults { + skip?: boolean; cancelled?: boolean; } @@ -140,6 +141,7 @@ async function performWrite( onProgress: sdk.multiWrite.OnProgressFunction, ): Promise<{ cancelled?: boolean }> { let cancelled = false; + let skip = false; ipc.serve(); const { unmountOnSuccess, @@ -171,7 +173,7 @@ async function performWrite( ipc.server.on('fail', ({ device, error }) => { if (device.devicePath) { - flashState.addFailedDevicePath(device.devicePath); + flashState.addFailedDevicePath({ device, error }); } handleErrorLogging(error, analyticsData); }); @@ -188,6 +190,11 @@ async function performWrite( cancelled = true; }); + ipc.server.on('skip', () => { + terminateServer(); + skip = true; + }); + ipc.server.on('state', onProgress); ipc.server.on('ready', (_data, socket) => { @@ -213,6 +220,7 @@ async function performWrite( environment: env, }); flashResults.cancelled = cancelled || results.cancelled; + flashResults.skip = skip; } catch (error) { // This happens when the child is killed using SIGKILL const SIGKILL_EXIT_CODE = 137; @@ -229,6 +237,7 @@ async function performWrite( // This likely means the child died halfway through if ( !flashResults.cancelled && + !flashResults.skip && !_.get(flashResults, ['results', 'bytesWritten']) ) { reject( @@ -286,8 +295,7 @@ export async function flash( } catch (error) { flashState.unsetFlashingFlag({ cancelled: false, errorCode: error.code }); windowProgress.clear(); - let { results } = flashState.getFlashResults(); - results = results || {}; + const { results = {} } = flashState.getFlashResults(); const eventData = { ...analyticsData, errors: results.errors, @@ -306,7 +314,7 @@ export async function flash( }; analytics.logEvent('Elevation cancelled', eventData); } else { - const { results } = flashState.getFlashResults(); + const { results = {} } = flashState.getFlashResults(); const eventData = { ...analyticsData, errors: results.errors, @@ -322,7 +330,8 @@ export async function flash( /** * @summary Cancel write operation */ -export async function cancel() { +export async function cancel(type: string) { + const status = type.toLowerCase(); const drives = selectionState.getSelectedDevices(); const analyticsData = { image: selectionState.getImagePath(), @@ -332,7 +341,7 @@ export async function cancel() { flashInstanceUuid: flashState.getFlashUuid(), unmountOnSuccess: await settings.get('unmountOnSuccess'), validateWriteOnSuccess: await settings.get('validateWriteOnSuccess'), - status: 'cancel', + status, }; analytics.logEvent('Cancel', analyticsData); @@ -342,7 +351,7 @@ export async function cancel() { // @ts-ignore (no Server.sockets in @types/node-ipc) const [socket] = ipc.server.sockets; if (socket !== undefined) { - ipc.server.emit(socket, 'cancel'); + ipc.server.emit(socket, status); } } catch (error) { analytics.logException(error); diff --git a/lib/gui/app/pages/main/Flash.tsx b/lib/gui/app/pages/main/Flash.tsx index 57c4b4f3..2722db07 100644 --- a/lib/gui/app/pages/main/Flash.tsx +++ b/lib/gui/app/pages/main/Flash.tsx @@ -82,14 +82,12 @@ async function flashImageToDrive( try { await imageWriter.flash(image, drives); if (!flashState.wasLastFlashCancelled()) { - const flashResults: any = flashState.getFlashResults(); + const { + results = { devices: { successful: 0, failed: 0 } }, + } = flashState.getFlashResults(); notification.send( 'Flash complete!', - messages.info.flashComplete( - basename, - drives as any, - flashResults.results.devices, - ), + messages.info.flashComplete(basename, drives as any, results.devices), iconPath, ); goToSuccess(); diff --git a/lib/gui/modules/child-writer.ts b/lib/gui/modules/child-writer.ts index 4c135dac..ca0ba9e9 100644 --- a/lib/gui/modules/child-writer.ts +++ b/lib/gui/modules/child-writer.ts @@ -208,8 +208,17 @@ ipc.connectTo(IPC_SERVER_ID, () => { terminate(exitCode); }; + const onSkip = async () => { + log('Skip validation'); + ipc.of[IPC_SERVER_ID].emit('skip'); + await delay(DISCONNECT_DELAY); + terminate(exitCode); + }; + ipc.of[IPC_SERVER_ID].on('cancel', onAbort); + ipc.of[IPC_SERVER_ID].on('skip', onSkip); + /** * @summary Failure handler (non-fatal errors) * @param {SourceDestination} destination - destination From 3c77800b1d4f902673beff56e84dbb264cf2425c Mon Sep 17 00:00:00 2001 From: Lorenzo Alberto Maria Ambrosi Date: Fri, 21 Aug 2020 15:34:36 +0200 Subject: [PATCH 16/27] Cleanup after child-process is terminated Change-type: patch Signed-off-by: Lorenzo Alberto Maria Ambrosi --- lib/gui/app/models/store.ts | 9 ++++++++- lib/gui/modules/child-writer.ts | 27 ++++++++++++++------------- tests/gui/models/flash-state.spec.ts | 1 + 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/lib/gui/app/models/store.ts b/lib/gui/app/models/store.ts index 9d8e30fa..b9301d7c 100644 --- a/lib/gui/app/models/store.ts +++ b/lib/gui/app/models/store.ts @@ -334,11 +334,18 @@ function storeReducer( action.data.results.averageFlashingSpeed = state.get( 'lastAverageFlashingSpeed', ); + + if (action.data.results.skip) { + return state + .set('isFlashing', false) + .set('flashResults', Immutable.fromJS(action.data)); + } } return state .set('isFlashing', false) - .set('flashResults', Immutable.fromJS(action.data)); + .set('flashResults', Immutable.fromJS(action.data)) + .set('flashState', DEFAULT_STATE.get('flashState')); } case Actions.SELECT_TARGET: { diff --git a/lib/gui/modules/child-writer.ts b/lib/gui/modules/child-writer.ts index ca0ba9e9..dda3c326 100644 --- a/lib/gui/modules/child-writer.ts +++ b/lib/gui/modules/child-writer.ts @@ -55,8 +55,9 @@ function log(message: string) { /** * @summary Terminate the child writer process */ -function terminate(exitCode: number) { +async function terminate(exitCode: number) { ipc.disconnect(IPC_SERVER_ID); + await cleanupTmpFiles(Date.now()); process.nextTick(() => { process.exit(exitCode || SUCCESS); }); @@ -68,7 +69,7 @@ function terminate(exitCode: number) { async function handleError(error: Error) { ipc.of[IPC_SERVER_ID].emit('error', toJSON(error)); await delay(DISCONNECT_DELAY); - terminate(GENERAL_ERROR); + await terminate(GENERAL_ERROR); } interface WriteResult { @@ -165,22 +166,22 @@ ipc.connectTo(IPC_SERVER_ID, () => { // no flashing information is available, then it will // assume that the child died halfway through. - process.once('SIGINT', () => { - terminate(SUCCESS); + process.once('SIGINT', async () => { + await terminate(SUCCESS); }); - process.once('SIGTERM', () => { - terminate(SUCCESS); + process.once('SIGTERM', async () => { + await terminate(SUCCESS); }); // The IPC server failed. Abort. - ipc.of[IPC_SERVER_ID].on('error', () => { - terminate(SUCCESS); + ipc.of[IPC_SERVER_ID].on('error', async () => { + await terminate(SUCCESS); }); // The IPC server was disconnected. Abort. - ipc.of[IPC_SERVER_ID].on('disconnect', () => { - terminate(SUCCESS); + ipc.of[IPC_SERVER_ID].on('disconnect', async () => { + await terminate(SUCCESS); }); ipc.of[IPC_SERVER_ID].on('write', async (options: WriteOptions) => { @@ -205,14 +206,14 @@ ipc.connectTo(IPC_SERVER_ID, () => { log('Abort'); ipc.of[IPC_SERVER_ID].emit('abort'); await delay(DISCONNECT_DELAY); - terminate(exitCode); + await terminate(exitCode); }; const onSkip = async () => { log('Skip validation'); ipc.of[IPC_SERVER_ID].emit('skip'); await delay(DISCONNECT_DELAY); - terminate(exitCode); + await terminate(exitCode); }; ipc.of[IPC_SERVER_ID].on('cancel', onAbort); @@ -286,7 +287,7 @@ ipc.connectTo(IPC_SERVER_ID, () => { }); ipc.of[IPC_SERVER_ID].emit('done', { results }); await delay(DISCONNECT_DELAY); - terminate(exitCode); + await terminate(exitCode); } catch (error) { log(`Error: ${error.message}`); exitCode = GENERAL_ERROR; diff --git a/tests/gui/models/flash-state.spec.ts b/tests/gui/models/flash-state.spec.ts index f03cad06..e5d966a0 100644 --- a/tests/gui/models/flash-state.spec.ts +++ b/tests/gui/models/flash-state.spec.ts @@ -393,6 +393,7 @@ describe('Model: flashState', function () { expect(flashResults).to.deep.equal({ cancelled: false, + skip: false, sourceChecksum: '1234', }); }); From 6584cef77420b4892b6ff5d6faa57a5e464a01ca Mon Sep 17 00:00:00 2001 From: Lorenzo Alberto Maria Ambrosi Date: Mon, 24 Aug 2020 14:14:39 +0200 Subject: [PATCH 17/27] Add retry button to the errors modal in success screen Change-type: patch Signed-off-by: Lorenzo Alberto Maria Ambrosi --- lib/gui/app/components/finish/finish.tsx | 6 +- .../flash-results/flash-results.tsx | 59 ++++++++++++++----- lib/gui/app/models/store.ts | 10 ++-- 3 files changed, 54 insertions(+), 21 deletions(-) diff --git a/lib/gui/app/components/finish/finish.tsx b/lib/gui/app/components/finish/finish.tsx index e5c3a9aa..e8018627 100644 --- a/lib/gui/app/components/finish/finish.tsx +++ b/lib/gui/app/components/finish/finish.tsx @@ -41,7 +41,8 @@ function restart(goToMain: () => void) { function FinishPage({ goToMain }: { goToMain: () => void }) { const [webviewShowing, setWebviewShowing] = React.useState(false); - let errors = flashState.getFlashResults().results?.errors; + const flashResults = flashState.getFlashResults(); + let errors = flashResults?.results?.errors; if (errors === undefined) { errors = (store.getState().toJS().failedDevicePaths || []).map( ([, error]: [string, FlashError]) => ({ @@ -67,7 +68,7 @@ function FinishPage({ goToMain }: { goToMain: () => void }) { averageFlashingSpeed: averageSpeed, devices: { failed, successful: 0 }, }, - } = flashState.getFlashResults(); + } = flashResults; return ( void }) { skip={skip} errors={errors} mb="32px" + goToMain={goToMain} /> { } `; +const DoneIcon = (props: { allFailed: boolean; someFailed: boolean }) => { + const { allFailed, someFailed } = props; + const someOrAllFailed = allFailed || someFailed; + const svgProps = { + width: '24px', + fill: someOrAllFailed ? '#c6c8c9' : '#1ac135', + style: { + width: '28px', + height: '28px', + marginTop: '-25px', + marginLeft: '13px', + zIndex: 1, + color: someOrAllFailed ? '#c6c8c9' : '#1ac135', + }, + }; + return allFailed ? ( + + ) : ( + + ); +}; + export interface FlashError extends Error { description: string; device: string; @@ -88,12 +113,14 @@ const columns: Array> = [ ]; export function FlashResults({ + goToMain, image = '', errors, results, skip, ...props }: { + goToMain: () => void; image?: string; errors: FlashError[]; skip: boolean; @@ -108,7 +135,7 @@ export function FlashResults({ }; } & FlexProps) { const [showErrorsInfo, setShowErrorsInfo] = React.useState(false); - const allDevicesFailed = results.devices.successful === 0; + const allFailed = results.devices.successful === 0; const effectiveSpeed = _.round( bytesToMegabytes( results.sourceMetadata.size / @@ -127,17 +154,9 @@ export function FlashResults({ flexDirection="column" > - {middleEllipsis(image, 16)} @@ -173,7 +192,7 @@ export function FlashResults({ ) : null; })} - {!allDevicesFailed && ( + {!allFailed && ( } - done={() => setShowErrorsInfo(false)} + action="Retry failed targets" + cancel={() => setShowErrorsInfo(false)} + done={() => { + setShowErrorsInfo(false); + resetState(); + selection + .getSelectedDrives() + .filter((drive) => + errors.every((error) => error.device !== drive.device), + ) + .forEach((drive) => selection.deselectDrive(drive.device)); + goToMain(); + }} > diff --git a/lib/gui/app/models/store.ts b/lib/gui/app/models/store.ts index b9301d7c..17373af6 100644 --- a/lib/gui/app/models/store.ts +++ b/lib/gui/app/models/store.ts @@ -334,12 +334,12 @@ function storeReducer( action.data.results.averageFlashingSpeed = state.get( 'lastAverageFlashingSpeed', ); + } - if (action.data.results.skip) { - return state - .set('isFlashing', false) - .set('flashResults', Immutable.fromJS(action.data)); - } + if (action.data.skip) { + return state + .set('isFlashing', false) + .set('flashResults', Immutable.fromJS(action.data)); } return state From 06a96db72db7f03dd1bf18b8035d806adea49d1d Mon Sep 17 00:00:00 2001 From: Lorenzo Alberto Maria Ambrosi Date: Mon, 31 Aug 2020 09:03:37 +0200 Subject: [PATCH 18/27] Fix zoomFactor in webviews Change-type: patch Signed-off-by: Lorenzo Alberto Maria Ambrosi --- lib/gui/app/app.ts | 10 ++++++++++ lib/gui/app/models/settings.ts | 3 +++ lib/gui/etcher.ts | 4 ++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/gui/app/app.ts b/lib/gui/app/app.ts index ab4325f8..f177a9e9 100644 --- a/lib/gui/app/app.ts +++ b/lib/gui/app/app.ts @@ -356,6 +356,16 @@ async function main() { ReactDOM.render( React.createElement(MainPage), document.getElementById('main'), + // callback to set the correct zoomFactor for webviews as well + async () => { + const fullscreen = await settings.get('fullscreen'); + const width = fullscreen ? window.screen.width : window.outerWidth; + try { + electron.webFrame.setZoomFactor(width / settings.DEFAULT_WIDTH); + } catch (err) { + // noop + } + }, ); } diff --git a/lib/gui/app/models/settings.ts b/lib/gui/app/models/settings.ts index 7deb1f11..8bfc9106 100644 --- a/lib/gui/app/models/settings.ts +++ b/lib/gui/app/models/settings.ts @@ -26,6 +26,9 @@ const debug = _debug('etcher:models:settings'); const JSON_INDENT = 2; +export const DEFAULT_WIDTH = 800; +export const DEFAULT_HEIGHT = 480; + /** * @summary Userdata directory path * @description diff --git a/lib/gui/etcher.ts b/lib/gui/etcher.ts index 36282fd4..02657539 100644 --- a/lib/gui/etcher.ts +++ b/lib/gui/etcher.ts @@ -122,8 +122,8 @@ interface AutoUpdaterConfig { async function createMainWindow() { const fullscreen = Boolean(await settings.get('fullscreen')); - const defaultWidth = 800; - const defaultHeight = 480; + const defaultWidth = settings.DEFAULT_WIDTH; + const defaultHeight = settings.DEFAULT_HEIGHT; let width = defaultWidth; let height = defaultHeight; if (fullscreen) { From 27695babfdadc1784ae03b6cd9d80bee94b4e19c Mon Sep 17 00:00:00 2001 From: Lorenzo Alberto Maria Ambrosi Date: Fri, 18 Sep 2020 09:39:42 +0200 Subject: [PATCH 19/27] Update rendition to v18.8.3 Change-type: patch Signed-off-by: Lorenzo Alberto Maria Ambrosi --- .../source-selector/source-selector.tsx | 27 +-- lib/gui/app/pages/main/MainPage.tsx | 5 +- npm-shrinkwrap.json | 157 +++++++++--------- package.json | 2 +- 4 files changed, 92 insertions(+), 99 deletions(-) diff --git a/lib/gui/app/components/source-selector/source-selector.tsx b/lib/gui/app/components/source-selector/source-selector.tsx index 2e8dc3d6..abd4a944 100644 --- a/lib/gui/app/components/source-selector/source-selector.tsx +++ b/lib/gui/app/components/source-selector/source-selector.tsx @@ -213,22 +213,25 @@ interface Flow { } const FlowSelector = styled( - ({ flow, ...props }: { flow: Flow; props?: ButtonProps }) => { - return ( - flow.onClick(evt)} - icon={flow.icon} - {...props} - > - {flow.label} - - ); - }, + ({ flow, ...props }: { flow: Flow; props?: ButtonProps }) => ( + flow.onClick(evt)} + icon={flow.icon} + {...props} + > + {flow.label} + + ), )` border-radius: 24px; color: rgba(255, 255, 255, 0.7); + :enabled:focus, + :enabled:focus svg { + color: ${colors.primary.foreground} !important; + } + :enabled:hover { background-color: ${colors.primary.background}; color: ${colors.primary.foreground}; diff --git a/lib/gui/app/pages/main/MainPage.tsx b/lib/gui/app/pages/main/MainPage.tsx index 6cf5a1ad..00bce90c 100644 --- a/lib/gui/app/pages/main/MainPage.tsx +++ b/lib/gui/app/pages/main/MainPage.tsx @@ -48,6 +48,7 @@ import { FlashStep } from './Flash'; import EtcherSvg from '../../../assets/etcher.svg'; import { SafeWebview } from '../../components/safe-webview/safe-webview'; +import { colors } from '../../theme'; const Icon = styled(BaseIcon)` margin-right: 20px; @@ -87,9 +88,7 @@ const StepBorder = styled.div<{ position: relative; height: 2px; background-color: ${(props) => - props.disabled - ? props.theme.colors.dark.disabled.foreground - : props.theme.colors.dark.foreground}; + props.disabled ? colors.dark.disabled.foreground : colors.dark.foreground}; width: 120px; top: 19px; diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 77e1b4d0..be7b814f 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1570,34 +1570,32 @@ } }, "@react-google-maps/api": { - "version": "1.9.12", - "resolved": "https://registry.npmjs.org/@react-google-maps/api/-/api-1.9.12.tgz", - "integrity": "sha512-YpYZOMduxiQIt8+njdffoqD4fYdOugudoafnAD1N+mEUrVnFlslUPMQ+gOJwuYdlkTAR5NZUbCt80LJWEN+ZnA==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@react-google-maps/api/-/api-1.10.1.tgz", + "integrity": "sha512-hb8urUcwZw99Cu3yQnZWUbXjR1Ym/8C21kSX6B02I29l6DXNxDbJ5Jo/T5swhnizPKY7TNhR1oTctC/HY7SQWA==", "dev": true, "requires": { - "@react-google-maps/infobox": "1.9.11", - "@react-google-maps/marker-clusterer": "1.9.11", - "acorn": "7.4.0", - "acorn-jsx": "^5.2.0", + "@react-google-maps/infobox": "1.10.0", + "@react-google-maps/marker-clusterer": "1.10.0", "invariant": "2.2.4" } }, "@react-google-maps/infobox": { - "version": "1.9.11", - "resolved": "https://registry.npmjs.org/@react-google-maps/infobox/-/infobox-1.9.11.tgz", - "integrity": "sha512-22ewm+OpOh69ikypG29idsdRz2OWeFsN+8zvYBzSETxKP782rmUGqhSIvXXmHa8TOcktm7EaEqOWWvZwaxymag==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@react-google-maps/infobox/-/infobox-1.10.0.tgz", + "integrity": "sha512-MhT2nMmjeG7TCxRv/JdylDyNd/n66ggSQQhTWVjJJTtdB/xqd0T8BHCkBWDN9uF0i0yCZzMFl2P2Y1zJ+xppBg==", "dev": true }, "@react-google-maps/marker-clusterer": { - "version": "1.9.11", - "resolved": "https://registry.npmjs.org/@react-google-maps/marker-clusterer/-/marker-clusterer-1.9.11.tgz", - "integrity": "sha512-yIABKlkORju131efXUZs/tL7FCK9IXtvy2M9SQRZy/mwgoOIYeoJlPPaBjn81DQqZLRj6AdAocydk+MnjWqFiQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@react-google-maps/marker-clusterer/-/marker-clusterer-1.10.0.tgz", + "integrity": "sha512-3GLVgeXNStVcdiLMxzi3cBjr32ctlexLPPGQguwcYd6yPLaCcnVCwyzhV68KvL00xqOAD1c3aABV9EGgY8u6Qw==", "dev": true }, "@rjsf/core": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-2.3.0.tgz", - "integrity": "sha512-OZKYHt9tjKhzOH4CvsPiCwepuIacqI++cNmnL2fsxh1IF+uEWGlo3NLDWhhSaBbOv9jps6a5YQcLbLtjNuSwug==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-2.4.0.tgz", + "integrity": "sha512-8zlydBkGldOxGXFEwNGFa1gzTxpcxaYn7ofegcu8XHJ7IKMCfpnU3ABg+H3eml1KZCX3FODmj1tHFJKuTmfynw==", "dev": true, "requires": { "@babel/runtime-corejs2": "^7.8.7", @@ -2180,9 +2178,9 @@ } }, "@types/react-native": { - "version": "0.63.9", - "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.63.9.tgz", - "integrity": "sha512-6ec/z9zjAkFH3rD1RYqbrA/Lj+jux6bumWCte4yRy3leyelTdqtmOd2Ph+86IXQQzsIArEMBwmraAbNQ0J3UAA==", + "version": "0.63.18", + "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.63.18.tgz", + "integrity": "sha512-WwEWqmHiqFn61M1FZR/+frj+E8e2o8i5cPqu9mjbjtZS/gBfCKVESF2ai/KAlaQECkkWkx/nMJeCc5eHMmLQgw==", "dev": true, "requires": { "@types/react": "*" @@ -2237,9 +2235,9 @@ "dev": true }, "@types/styled-components": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.2.tgz", - "integrity": "sha512-HNocYLfrsnNNm8NTS/W53OERSjRA8dx5Bn6wBd2rXXwt4Z3s+oqvY6/PbVt3e6sgtzI63GX//WiWiRhWur08qQ==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.3.tgz", + "integrity": "sha512-HGpirof3WOhiX17lb61Q/tpgqn48jxO8EfZkdJ8ueYqwLbK2AHQe/G08DasdA2IdKnmwOIP1s9X2bopxKXgjRw==", "dev": true, "requires": { "@types/hoist-non-react-statics": "*", @@ -2692,18 +2690,6 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true }, - "acorn": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.0.tgz", - "integrity": "sha512-+G7P8jJmCHr+S+cLfQxygbWhXy+8YTVGzAkpEbcLo2mLoL7tij/VG41QSHACSf5QgYRhMZYHuNc6drJaO0Da+w==", - "dev": true - }, - "acorn-jsx": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.2.0.tgz", - "integrity": "sha512-HiUX/+K2YpkpJ+SzBffkM/AQ2YE03S0U1kjTLVpoJdhZMOWy8qvXVN9JdLqv2QsaQ6MPYQIuNmwD8zOiYUofLQ==", - "dev": true - }, "agent-base": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", @@ -5281,6 +5267,12 @@ "assert-plus": "^1.0.0" } }, + "date-fns": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.16.1.tgz", + "integrity": "sha512-sAJVKx/FqrLYHAQeN7VpJrPhagZc9R4ImZIWYRFZaaohR3KzmuK88touwsSwSVT8Qcbd4zoDsnGfX4GFB4imyQ==", + "dev": true + }, "de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", @@ -5496,15 +5488,6 @@ "minimalistic-assert": "^1.0.0" } }, - "detab": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/detab/-/detab-2.0.3.tgz", - "integrity": "sha512-Up8P0clUVwq0FnFjDclzZsy9PadzRn5FFxrr47tQQvMHqyiFYVbpH8oXDzWtF0Q7pYy3l+RPmtBl+BsFF6wH0A==", - "dev": true, - "requires": { - "repeat-string": "^1.5.4" - } - }, "detect-file": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", @@ -8939,9 +8922,9 @@ "dev": true }, "json-e": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/json-e/-/json-e-4.1.0.tgz", - "integrity": "sha512-Jb8kMB1lICgjAAppv+q0EFFovOPdjE3htb7pt9+uE2j3J1W5ZCuBOmAdGi0OUetCZ4wqSO6qT/Np36XDRjHH7w==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/json-e/-/json-e-4.3.0.tgz", + "integrity": "sha512-E3zcmx6pHsBgQ4ZztQNG4OAZHreBZfGBrg68kv9nGOkRqAdKfs792asP/wp9Fayfx1THDiHKYStqWJj/N7Bb9A==", "dev": true, "requires": { "json-stable-stringify-without-jsonify": "^1.0.1" @@ -9749,18 +9732,15 @@ } }, "mdast-util-to-hast": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-9.1.0.tgz", - "integrity": "sha512-Akl2Vi9y9cSdr19/Dfu58PVwifPXuFt1IrHe7l+Crme1KvgUT+5z+cHLVcQVGCiNTZZcdqjnuv9vPkGsqWytWA==", + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-9.1.1.tgz", + "integrity": "sha512-vpMWKFKM2mnle+YbNgDXxx95vv0CoLU0v/l3F5oFAG5DV7qwkZVWA206LsAdOnEVyf5vQcLnb3cWJywu7mUxsQ==", "dev": true, "requires": { "@types/mdast": "^3.0.0", "@types/unist": "^2.0.3", - "collapse-white-space": "^1.0.0", - "detab": "^2.0.0", "mdast-util-definitions": "^3.0.0", "mdurl": "^1.0.0", - "trim-lines": "^1.0.0", "unist-builder": "^2.0.0", "unist-util-generated": "^1.0.0", "unist-util-position": "^3.0.0", @@ -9842,9 +9822,9 @@ }, "dependencies": { "crypto-random-string": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-3.2.0.tgz", - "integrity": "sha512-8vPu5bsKaq2uKRy3OL7h1Oo7RayAWB8sYexLKAqvCXVib8SxgbmoF1IN4QMKjBv8uI8mp5gPPMbiRah25GMrVQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-3.3.0.tgz", + "integrity": "sha512-teWAwfMb1d6brahYyKqcBEb5Yp8PJPvPOdOonXDnvaKOTmKDFNVE8E3Y2XQuzjNV/3XMwHbrX9fHWvrhRKt4Gg==", "dev": true, "requires": { "type-fest": "^0.8.1" @@ -11897,9 +11877,9 @@ } }, "polished": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/polished/-/polished-3.6.5.tgz", - "integrity": "sha512-VwhC9MlhW7O5dg/z7k32dabcAFW1VI2+7fSe8cE/kXcfL7mVdoa5UxciYGW2sJU78ldDLT6+ROEKIZKFNTnUXQ==", + "version": "3.6.6", + "resolved": "https://registry.npmjs.org/polished/-/polished-3.6.6.tgz", + "integrity": "sha512-yiB2ims2DZPem0kCD6V0wnhcVGFEhNh0Iw0axNpKU+oSAgFt6yx6HxIT23Qg0WWvgS379cS35zT4AOyZZRzpQQ==", "dev": true, "requires": { "@babel/runtime": "^7.9.2" @@ -12511,9 +12491,9 @@ } }, "react-notifications-component": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/react-notifications-component/-/react-notifications-component-2.4.0.tgz", - "integrity": "sha512-0IhtgqAmsKSyjY1wBUxciUVXiYGRr5BRdn67pYDlkqq9ORF98NZekpG7/MNX0BzzfGvt9Wg7rFhT1BtwOvvLLg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/react-notifications-component/-/react-notifications-component-2.4.1.tgz", + "integrity": "sha512-RloHzm15egnuPihf8PvldIEvPQoT9+5BE9UxCNTt+GfsWeI3SEZKyaX9mq90v899boqteLiOI736Zd4tXtl7Tg==", "dev": true, "requires": { "prop-types": "^15.6.2" @@ -12660,6 +12640,21 @@ "integrity": "sha512-jbD/FT0+9MBU2XAZluI7w2OBs1RBi6p9M83nkoZayQXXU9e8Robt69FcZc7wU4eJD/YFTjn1JdCk3rbMJajz8Q==", "dev": true }, + "regexp-match-indices": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regexp-match-indices/-/regexp-match-indices-1.0.2.tgz", + "integrity": "sha512-DwZuAkt8NF5mKwGGER1EGh2PRqyvhRhhLviH+R8y8dIuaQROlUfXjt4s9ZTXstIsSkptf06BSvwcEmmfheJJWQ==", + "dev": true, + "requires": { + "regexp-tree": "^0.1.11" + } + }, + "regexp-tree": { + "version": "0.1.21", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.21.tgz", + "integrity": "sha512-kUUXjX4AnqnR8KRTCrayAo9PzYMRKmVoGgaz2tBuz0MF3g1ZbGebmtW0yFHfFK9CmBjQKeYIgoL22pFLBJY7sw==", + "dev": true + }, "regexpu-core": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.7.0.tgz", @@ -12827,9 +12822,9 @@ "optional": true }, "rendition": { - "version": "18.4.1", - "resolved": "https://registry.npmjs.org/rendition/-/rendition-18.4.1.tgz", - "integrity": "sha512-mV/0p+M8XR/Xa/ZFzgflZPHelpuONiTSa/CMMuHkmXR7vhF7tB2ORxLRc/DbymmdN6cWQwXAyA81t9TDAOhgVQ==", + "version": "18.8.3", + "resolved": "https://registry.npmjs.org/rendition/-/rendition-18.8.3.tgz", + "integrity": "sha512-kDuXFheXY9KlSvIMdB4Er2OeAnwgj9aya5Xu43hwpXxC4KlFlNKqQNmcOvKLc/Fk9dyw04TKOr1SbXyM148yRg==", "dev": true, "requires": { "@fortawesome/fontawesome-svg-core": "^1.2.25", @@ -12855,6 +12850,7 @@ "color": "^3.1.2", "color-hash": "^1.0.3", "copy-to-clipboard": "^3.0.8", + "date-fns": "^2.16.1", "grommet": "^2.14.0", "hast-util-sanitize": "^3.0.0", "json-e": "^4.1.0", @@ -12869,6 +12865,7 @@ "react-simplemde-editor": "^4.1.1", "recompose": "0.26.0", "regex-parser": "^2.2.7", + "regexp-match-indices": "^1.0.2", "rehype-raw": "^4.0.2", "rehype-react": "^6.1.0", "rehype-sanitize": "^3.0.1", @@ -12885,9 +12882,9 @@ }, "dependencies": { "@types/node": { - "version": "13.13.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.15.tgz", - "integrity": "sha512-kwbcs0jySLxzLsa2nWUAGOd/s21WU1jebrEdtzhsj1D4Yps1EOuyI1Qcu+FD56dL7NRNIJtDDjcqIG22NwkgLw==", + "version": "13.13.20", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.20.tgz", + "integrity": "sha512-1kx55tU3AvGX2Cjk2W4GMBxbgIz892V+X10S2gUreIAq8qCWgaQH+tZBOWc0bi2BKFhQt+CX0BTx28V9QPNa+A==", "dev": true }, "uuid": { @@ -14745,12 +14742,6 @@ "integrity": "sha1-WFhUf2spB1fulczMZm+1AITEYN0=", "dev": true }, - "trim-lines": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-1.1.3.tgz", - "integrity": "sha512-E0ZosSWYK2mkSu+KEtQ9/KqarVjA9HztOSX+9FDdNacRAq29RRV6ZQNgob3iuW8Htar9vAfEa6yyt5qBAHZDBA==", - "dev": true - }, "trim-trailing-lines": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/trim-trailing-lines/-/trim-trailing-lines-1.1.3.tgz", @@ -15035,9 +15026,9 @@ "dev": true }, "uglify-js": { - "version": "3.10.2", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.10.2.tgz", - "integrity": "sha512-GXCYNwqoo0MbLARghYjxVBxDCnU0tLqN7IPLdHHbibCb1NI5zBkU2EPcy/GaVxc0BtTjqyGXJCINe6JMR2Dpow==", + "version": "3.10.4", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.10.4.tgz", + "integrity": "sha512-kBFT3U4Dcj4/pJ52vfjCSfyLyvG9VYYuGYPmrPvAxRw/i7xHiT4VvCev+uiEMcEEiu6UNB6KgWmGtSUYIWScbw==", "dev": true }, "unbzip2-stream": { @@ -16466,9 +16457,9 @@ } }, "whatwg-fetch": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.4.0.tgz", - "integrity": "sha512-rsum2ulz2iuZH08mJkT0Yi6JnKhwdw4oeyMjokgxd+mmqYSd9cPpOQf01TIWgjxG/U4+QR+AwKq6lSbXVxkyoQ==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.4.1.tgz", + "integrity": "sha512-sofZVzE1wKwO+EYPbWfiwzaKovWiZXf4coEzjGP9b2GBVgQRLQUZ2QcuPpQExGDAW5GItpEm6Tl4OU5mywnAoQ==", "dev": true }, "which": { @@ -16660,9 +16651,9 @@ "dev": true }, "xterm": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/xterm/-/xterm-4.8.1.tgz", - "integrity": "sha512-ax91ny4tI5eklqIfH79OUSGE2PUX2rGbwONmB6DfqpyhSZO8/cf++sqiaMWEVCMjACyMfnISW7C3gGMoNvNolQ==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-4.9.0.tgz", + "integrity": "sha512-wGfqufmioctKr8VkbRuZbVDfjlXWGZZ1PWHy1yqqpGT3Nm6yaJx8lxDbSEBANtgaiVPTcKSp97sxOy5IlpqYfw==", "dev": true }, "xterm-addon-fit": { @@ -16774,4 +16765,4 @@ "dev": true } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 0d505dee..885929d5 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "react": "^16.8.5", "react-dom": "^16.8.5", "redux": "^4.0.5", - "rendition": "^18.4.1", + "rendition": "^18.8.3", "resin-corvus": "^2.0.5", "semver": "^7.3.2", "simple-progress-webpack-plugin": "^1.1.2", From 78aca6a19f5e9137171e760099a6c65c0e67faba Mon Sep 17 00:00:00 2001 From: Lorenzo Alberto Maria Ambrosi Date: Fri, 18 Sep 2020 09:43:12 +0200 Subject: [PATCH 20/27] Use drive-selector's table for flash errors table Change-type: patch Signed-off-by: Lorenzo Alberto Maria Ambrosi --- .../drive-selector/drive-selector.tsx | 263 +++++++----------- lib/gui/app/components/finish/finish.tsx | 2 +- .../flash-results/flash-results.tsx | 55 ++-- lib/gui/app/styled-components.tsx | 127 +++++++-- lib/gui/app/theme.ts | 15 +- 5 files changed, 242 insertions(+), 220 deletions(-) diff --git a/lib/gui/app/components/drive-selector/drive-selector.tsx b/lib/gui/app/components/drive-selector/drive-selector.tsx index 8bd3daef..7cb5196a 100644 --- a/lib/gui/app/components/drive-selector/drive-selector.tsx +++ b/lib/gui/app/components/drive-selector/drive-selector.tsx @@ -18,15 +18,7 @@ import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exc import ChevronDownSvg from '@fortawesome/fontawesome-free/svgs/solid/chevron-down.svg'; import * as sourceDestination from 'etcher-sdk/build/source-destination/'; import * as React from 'react'; -import { - Flex, - ModalProps, - Txt, - Badge, - Link, - Table, - TableColumn, -} from 'rendition'; +import { Flex, ModalProps, Txt, Badge, Link, TableColumn } from 'rendition'; import styled from 'styled-components'; import { @@ -43,7 +35,12 @@ 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 { Alert, Modal, ScrollableFlex } from '../../styled-components'; +import { + Alert, + GenericTableProps, + Modal, + Table, +} from '../../styled-components'; import DriveSVGIcon from '../../../assets/tgt.svg'; import { SourceMetadata } from '../source-selector/source-selector'; @@ -75,74 +72,29 @@ function isDrivelistDrive(drive: Drive): drive is DrivelistDrive { return typeof (drive as DrivelistDrive).size === 'number'; } -const DrivesTable = styled(({ refFn, ...props }) => ( -
- ref={refFn} {...props} /> -
+const DrivesTable = styled((props: GenericTableProps) => ( + {...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-body'] > [data-display='table-row'] { - > [data-display='table-cell']:first-child { - padding-left: 15px; - } - - > [data-display='table-cell']:last-child { - padding-right: 0; - } - - &[data-highlight='true'] { - &.system { - background-color: ${(props) => - props.showWarnings ? '#fff5e6' : '#e8f5fc'}; + [data-display='table-head'], + [data-display='table-body'] { + > [data-display='table-row'] > [data-display='table-cell'] { + &:nth-child(2) { + width: 38%; } - > [data-display='table-cell']:first-child { - box-shadow: none; + &:nth-child(3) { + width: 15%; + } + + &:nth-child(4) { + width: 15%; + } + + &:nth-child(5) { + width: 32%; } } } - - && [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) { @@ -453,95 +405,92 @@ export class DriveSelector extends React.Component< }} {...props} > - - {!hasAvailableDrives() ? ( - - - {this.props.emptyListLabel} - - ) : ( - - ) => { - if (t !== null) { - t.setRowSelection(selectedList); - } - }} - multipleSelection={this.props.multipleSelection} - columns={this.tableColumns} - data={displayedDrives} - disabledRows={disabledDrives} - getRowClass={(row: Drive) => - isDrivelistDrive(row) && row.isSystem ? ['system'] : [] + {!hasAvailableDrives() ? ( + + + {this.props.emptyListLabel} + + ) : ( + <> + { + if (t !== null) { + t.setRowSelection(selectedList); } - rowKey="displayName" - onCheck={(rows: Drive[]) => { - const newSelection = rows.filter(isDrivelistDrive); - if (this.props.multipleSelection) { - this.setState({ - selectedList: newSelection, - }); - return; + }} + multipleSelection={this.props.multipleSelection} + columns={this.tableColumns} + data={displayedDrives} + disabledRows={disabledDrives} + getRowClass={(row: Drive) => + isDrivelistDrive(row) && row.isSystem ? ['system'] : [] + } + rowKey="displayName" + onCheck={(rows: Drive[]) => { + const newSelection = rows.filter(isDrivelistDrive); + if (this.props.multipleSelection) { + this.setState({ + selectedList: newSelection, + }); + return; + } + this.setState({ + selectedList: newSelection.slice(newSelection.length - 1), + }); + }} + onRowClick={(row: Drive) => { + if ( + !isDrivelistDrive(row) || + this.driveShouldBeDisabled(row, image) + ) { + return; + } + 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: newSelection.slice(newSelection.length - 1), + selectedList: newList, }); - }} - onRowClick={(row: Drive) => { - if ( - !isDrivelistDrive(row) || - this.driveShouldBeDisabled(row, image) - ) { - return; - } - 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: [row], - }); - }} - /> - {numberOfHiddenSystemDrives > 0 && ( - this.setState({ showSystemDrives: true })} - > - - - Show {numberOfHiddenSystemDrives} hidden - - - )} - - )} - {this.props.showWarnings && hasSystemDrives ? ( - - Selecting your system drive is dangerous and will erase your - drive! - - ) : null} - + return; + } + this.setState({ + selectedList: [row], + }); + }} + /> + {numberOfHiddenSystemDrives > 0 && ( + this.setState({ showSystemDrives: true })} + > + + + Show {numberOfHiddenSystemDrives} hidden + + + )} + + )} + {this.props.showWarnings && hasSystemDrives ? ( + + Selecting your system drive is dangerous and will erase your drive! + + ) : null} {missingDriversModal.drive !== undefined && ( void) { function FinishPage({ goToMain }: { goToMain: () => void }) { const [webviewShowing, setWebviewShowing] = React.useState(false); const flashResults = flashState.getFlashResults(); - let errors = flashResults?.results?.errors; + let errors: FlashError[] = flashResults.results?.errors; if (errors === undefined) { errors = (store.getState().toJS().failedDevicePaths || []).map( ([, error]: [string, FlashError]) => ({ diff --git a/lib/gui/app/components/flash-results/flash-results.tsx b/lib/gui/app/components/flash-results/flash-results.tsx index 5360f0c6..562984be 100644 --- a/lib/gui/app/components/flash-results/flash-results.tsx +++ b/lib/gui/app/components/flash-results/flash-results.tsx @@ -20,7 +20,7 @@ import TimesCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/times-circl import * as _ from 'lodash'; import outdent from 'outdent'; import * as React from 'react'; -import { Flex, FlexProps, Link, Table, TableColumn, Txt } from 'rendition'; +import { Flex, FlexProps, Link, TableColumn, Txt } from 'rendition'; import styled from 'styled-components'; import { progress } from '../../../../shared/messages'; @@ -30,37 +30,31 @@ import FlashSvg from '../../../assets/flash.svg'; import { resetState } from '../../models/flash-state'; import * as selection from '../../models/selection-state'; import { middleEllipsis } from '../../utils/middle-ellipsis'; -import { Modal } from '../../styled-components'; +import { Modal, Table } from '../../styled-components'; -const ErrorsTable = styled(({ refFn, ...props }) => { - return ( -
- ref={refFn} {...props} /> -
- ); -})` - [data-display='table-head'] [data-display='table-cell'] { - width: 50%; - position: sticky; - top: 0; - background-color: ${(props) => props.theme.colors.quartenary.light}; - } +const ErrorsTable = styled((props) => {...props} />)` + [data-display='table-head'], + [data-display='table-body'] { + [data-display='table-cell'] { + &:first-child { + width: 30%; + } - [data-display='table-cell']:first-child { - padding-left: 15px; - } + &:nth-child(2) { + width: 20%; + } - [data-display='table-cell']:last-child { - width: 150px; - } - - && [data-display='table-row'] > [data-display='table-cell'] { - padding: 6px 8px; - color: #2a506f; - } + &:last-child { + width: 50%; + } + } `; -const DoneIcon = (props: { allFailed: boolean; someFailed: boolean }) => { +const DoneIcon = (props: { + skipped: boolean; + allFailed: boolean; + someFailed: boolean; +}) => { const { allFailed, someFailed } = props; const someOrAllFailed = allFailed || someFailed; const svgProps = { @@ -75,7 +69,7 @@ const DoneIcon = (props: { allFailed: boolean; someFailed: boolean }) => { color: someOrAllFailed ? '#c6c8c9' : '#1ac135', }, }; - return allFailed ? ( + return allFailed && !props.skipped ? ( ) : ( @@ -107,7 +101,7 @@ const columns: Array> = [ field: 'message', label: 'Error', render: (message: string, { code }: FlashError) => { - return message ? message : code; + return message ?? code; }, }, ]; @@ -155,10 +149,11 @@ export function FlashResults({ > - {middleEllipsis(image, 16)} + {middleEllipsis(image, 24)}
Flash Complete! diff --git a/lib/gui/app/styled-components.tsx b/lib/gui/app/styled-components.tsx index 7ecd0487..c9af1786 100644 --- a/lib/gui/app/styled-components.tsx +++ b/lib/gui/app/styled-components.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import * as _ from 'lodash'; import * as React from 'react'; import { Alert as AlertBase, @@ -23,27 +24,16 @@ import { ButtonProps, Modal as ModalBase, Provider, + Table as BaseTable, + TableProps as BaseTableProps, Txt, - Theme as renditionTheme, } from 'rendition'; import styled, { css } from 'styled-components'; import { colors, theme } from './theme'; -const defaultTheme = { - ...renditionTheme, - ...theme, - layer: { - extend: () => ` - > div:first-child { - background-color: transparent; - } - `, - }, -}; - export const ThemedProvider = (props: any) => ( - + ); export const BaseButton = styled(Button)` @@ -134,24 +124,23 @@ const modalFooterShadowCss = css` background-attachment: local, local, scroll, scroll; `; -export const Modal = styled(({ style, ...props }) => { +export const Modal = styled(({ style, children, ...props }) => { return ( ` - ${defaultTheme.layer.extend()} + ${theme.layer.extend()} - > div:last-child { - top: 0; - } - `, + > div:last-child { + top: 0; + } + `, }, - }} + })} > { ...style, }} {...props} - /> + > + + {...children} + + ); })` @@ -188,11 +181,8 @@ export const Modal = styled(({ style, ...props }) => { > div:nth-child(2) { height: 61%; - - > div:not(.system-drive-alert) { - padding: 0 30px; - ${modalFooterShadowCss} - } + padding: 0 30px; + ${modalFooterShadowCss} } > div:last-child { @@ -249,3 +239,82 @@ export const Alert = styled((props) => ( display: none; } `; + +export interface GenericTableProps extends BaseTableProps { + refFn: (t: BaseTable) => void; + multipleSelection: boolean; + showWarnings?: boolean; +} + +const GenericTable: ( + props: GenericTableProps, +) => React.ReactElement> = ({ + refFn, + ...props +}: GenericTableProps) => ( +
+ ref={refFn} {...props} /> +
+); + +function StyledTable() { + return styled((props: GenericTableProps) => ( + {...props} /> + ))` + [data-display='table-head'] + > [data-display='table-row'] + > [data-display='table-cell'] { + position: sticky; + background-color: #f8f9fd; + top: 0; + z-index: 1; + + input[type='checkbox'] + div { + display: ${(props) => (props.multipleSelection ? 'flex' : 'none')}; + } + } + + [data-display='table-head'] > [data-display='table-row'], + [data-display='table-body'] > [data-display='table-row'] { + > [data-display='table-cell']:first-child { + padding-left: 15px; + width: 6%; + } + + > [data-display='table-cell']:last-child { + padding-right: 0; + } + } + + [data-display='table-body'] > [data-display='table-row'] { + &:nth-of-type(2n) { + background: transparent; + } + + &[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: ${(props) => (props.multipleSelection ? '4px' : '50%')}; + } + `; +} + +export const Table = (props: GenericTableProps) => { + const TypedStyledFunctional = StyledTable(); + return ; +}; diff --git a/lib/gui/app/theme.ts b/lib/gui/app/theme.ts index e6a4ae95..9339034c 100644 --- a/lib/gui/app/theme.ts +++ b/lib/gui/app/theme.ts @@ -14,6 +14,9 @@ * limitations under the License. */ +import * as _ from 'lodash'; +import { Theme } from 'rendition'; + export const colors = { dark: { foreground: '#fff', @@ -67,8 +70,7 @@ export const colors = { const font = 'SourceSansPro'; -export const theme = { - colors, +export const theme = _.merge({}, Theme, { font, global: { font: { @@ -109,4 +111,11 @@ export const theme = { } `, }, -}; + layer: { + extend: () => ` + > div:first-child { + background-color: transparent; + } + `, + }, +}); From 153e37b9dcc0d6d20d0cff33abe0c9e86e8cbb62 Mon Sep 17 00:00:00 2001 From: Lorenzo Alberto Maria Ambrosi Date: Fri, 18 Sep 2020 11:03:08 +0200 Subject: [PATCH 21/27] Fix settings spacing Change-type: patch Signed-off-by: Lorenzo Alberto Maria Ambrosi --- lib/gui/app/components/settings/settings.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/gui/app/components/settings/settings.tsx b/lib/gui/app/components/settings/settings.tsx index 97510871..5feec15e 100644 --- a/lib/gui/app/components/settings/settings.tsx +++ b/lib/gui/app/components/settings/settings.tsx @@ -61,7 +61,7 @@ async function getSettingsList(): Promise { { name: 'updatesEnabled', label: 'Auto-updates enabled', - hide: _.includes(['rpm', 'deb'], packageType), + hide: ['rpm', 'deb'].includes(packageType), }, ]; } @@ -121,9 +121,9 @@ export function SettingsModal({ toggleModal }: SettingsModalProps) { done={() => toggleModal(false)} > - {_.map(settingsList, (setting: Setting, i: number) => { + {settingsList.map((setting: Setting, i: number) => { return setting.hide ? null : ( - + openExternal( From c3296eed541f6f41e13515f0f3d7be8401fcf874 Mon Sep 17 00:00:00 2001 From: Lorenzo Alberto Maria Ambrosi Date: Tue, 29 Sep 2020 15:27:23 +0200 Subject: [PATCH 22/27] Add dash on table when selecting only some rows Change-type: patch Signed-off-by: Lorenzo Alberto Maria Ambrosi --- .../drive-selector/drive-selector.tsx | 46 +++++++++++-------- lib/gui/app/styled-components.tsx | 26 ++++++++++- 2 files changed, 52 insertions(+), 20 deletions(-) diff --git a/lib/gui/app/components/drive-selector/drive-selector.tsx b/lib/gui/app/components/drive-selector/drive-selector.tsx index 7cb5196a..d09c8b4b 100644 --- a/lib/gui/app/components/drive-selector/drive-selector.tsx +++ b/lib/gui/app/components/drive-selector/drive-selector.tsx @@ -79,7 +79,7 @@ const DrivesTable = styled((props: GenericTableProps) => ( [data-display='table-body'] { > [data-display='table-row'] > [data-display='table-cell'] { &:nth-child(2) { - width: 38%; + width: 32%; } &:nth-child(3) { @@ -345,6 +345,16 @@ export class DriveSelector extends React.Component< } } + private deselectingAll(rows: DrivelistDrive[]) { + return ( + rows.length > 0 && + rows.length === this.state.selectedList.length && + this.state.selectedList.every( + (d) => rows.findIndex((r) => d.device === r.device) > -1, + ) + ); + } + componentDidMount() { this.unsubscribe = store.subscribe(() => { const drives = getDrives(); @@ -423,6 +433,7 @@ export class DriveSelector extends React.Component< t.setRowSelection(selectedList); } }} + checkedRowsNumber={selectedList.length} multipleSelection={this.props.multipleSelection} columns={this.tableColumns} data={displayedDrives} @@ -432,8 +443,11 @@ export class DriveSelector extends React.Component< } rowKey="displayName" onCheck={(rows: Drive[]) => { - const newSelection = rows.filter(isDrivelistDrive); + let newSelection = rows.filter(isDrivelistDrive); if (this.props.multipleSelection) { + if (this.deselectingAll(newSelection)) { + newSelection = []; + } this.setState({ selectedList: newSelection, }); @@ -450,24 +464,20 @@ export class DriveSelector extends React.Component< ) { return; } - 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; + const index = selectedList.findIndex( + (d) => d.device === row.device, + ); + const newList = this.props.multipleSelection + ? [...selectedList] + : []; + if (index === -1) { + newList.push(row); + } else { + // Deselect if selected + newList.splice(index, 1); } this.setState({ - selectedList: [row], + selectedList: newList, }); }} /> diff --git a/lib/gui/app/styled-components.tsx b/lib/gui/app/styled-components.tsx index c9af1786..a4c08c19 100644 --- a/lib/gui/app/styled-components.tsx +++ b/lib/gui/app/styled-components.tsx @@ -168,6 +168,11 @@ export const Modal = styled(({ style, children, ...props }) => { padding: 0; height: 100%; + > div:first-child { + height: 81%; + padding: 24px 30px 0; + } + > h3 { margin: 0; padding: 24px 30px 0; @@ -242,6 +247,8 @@ export const Alert = styled((props) => ( export interface GenericTableProps extends BaseTableProps { refFn: (t: BaseTable) => void; + data: T[]; + checkedRowsNumber?: number; multipleSelection: boolean; showWarnings?: boolean; } @@ -271,6 +278,22 @@ function StyledTable() { input[type='checkbox'] + div { display: ${(props) => (props.multipleSelection ? 'flex' : 'none')}; + + ${(props) => + props.multipleSelection && + props.checkedRowsNumber !== 0 && + props.checkedRowsNumber !== props.data.length + ? ` + font-weight: 600; + color: ${colors.primary.foreground}; + background: ${colors.primary.background}; + + ::after { + content: '–'; + } + ` + : ''} + } } } @@ -293,8 +316,7 @@ function StyledTable() { &[data-highlight='true'] { &.system { - background-color: ${(props) => - props.showWarnings ? '#fff5e6' : '#e8f5fc'}; + background-color: ${(props) => (props.showWarnings ? '#fff5e6' : '#e8f5fc')}; } > [data-display='table-cell']:first-child { From c6cd421f1760baa058f901264e6117fd7ee03bd9 Mon Sep 17 00:00:00 2001 From: Lorenzo Alberto Maria Ambrosi Date: Wed, 14 Oct 2020 12:30:55 +0200 Subject: [PATCH 23/27] Fix URL not being selected with custom protocol Change-type: patch Changelog-entry: Fix URL not being selected with custom protocol Signed-off-by: Lorenzo Alberto Maria Ambrosi --- lib/gui/app/components/source-selector/source-selector.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/gui/app/components/source-selector/source-selector.tsx b/lib/gui/app/components/source-selector/source-selector.tsx index abd4a944..437efcf4 100644 --- a/lib/gui/app/components/source-selector/source-selector.tsx +++ b/lib/gui/app/components/source-selector/source-selector.tsx @@ -289,6 +289,9 @@ export class SourceSelector extends React.Component< showURLSelector: false, showDriveSelector: false, }; + + // Bind `this` since it's used in an event's callback + this.onSelectImage = this.onSelectImage.bind(this); } public componentDidMount() { From 2e3978b3c902536cd53ede35124430ae275fa390 Mon Sep 17 00:00:00 2001 From: Lorenzo Alberto Maria Ambrosi Date: Thu, 3 Sep 2020 15:46:18 +0200 Subject: [PATCH 24/27] Add more typings & refactor code accordingly Change-type: patch Signed-off-by: Lorenzo Alberto Maria Ambrosi --- lib/gui/app/models/selection-state.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/gui/app/models/selection-state.ts b/lib/gui/app/models/selection-state.ts index 959cf828..b55e8ef1 100644 --- a/lib/gui/app/models/selection-state.ts +++ b/lib/gui/app/models/selection-state.ts @@ -72,24 +72,24 @@ export function getImage(): SourceMetadata | undefined { return store.getState().toJS().selection.image; } -export function getImagePath() { - return getImage()?.path; +export function getImagePath(): string | undefined { + return store.getState().toJS().selection.image?.path; } -export function getImageSize() { - return getImage()?.size; +export function getImageSize(): number | undefined { + return store.getState().toJS().selection.image?.size; } -export function getImageName() { - return getImage()?.name; +export function getImageName(): string | undefined { + return store.getState().toJS().selection.image?.name; } -export function getImageLogo() { - return getImage()?.logo; +export function getImageLogo(): string | undefined { + return store.getState().toJS().selection.image?.logo; } -export function getImageSupportUrl() { - return getImage()?.supportUrl; +export function getImageSupportUrl(): string | undefined { + return store.getState().toJS().selection.image?.supportUrl; } /** From b4e697011970286a1f024186649ec8662c2e5f8e Mon Sep 17 00:00:00 2001 From: Lorenzo Alberto Maria Ambrosi Date: Thu, 23 Jul 2020 14:50:28 +0200 Subject: [PATCH 25/27] Rework system & large drives handling logic Change-type: patch Changelog-entry: Rework system & large drives handling logic Signed-off-by: Lorenzo Alberto Maria Ambrosi --- tests/shared/drive-constraints.spec.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/shared/drive-constraints.spec.ts b/tests/shared/drive-constraints.spec.ts index d557f905..7b952de2 100644 --- a/tests/shared/drive-constraints.spec.ts +++ b/tests/shared/drive-constraints.spec.ts @@ -700,11 +700,6 @@ describe('Shared: DriveConstraints', function () { }); 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, From b80a6b2feba1b3b3a3bd15d607fc13795fde9560 Mon Sep 17 00:00:00 2001 From: Lorenzo Alberto Maria Ambrosi Date: Fri, 14 Aug 2020 14:32:23 +0200 Subject: [PATCH 26/27] Add UI option to save images flashed from URLs Change-type: patch Signed-off-by: Lorenzo Alberto Maria Ambrosi --- .../progress-button/progress-button.tsx | 10 +- .../source-selector/source-selector.tsx | 132 +------------- .../components/url-selector/url-selector.tsx | 167 ++++++++++++++++++ lib/gui/app/models/settings.ts | 11 +- lib/gui/app/modules/image-writer.ts | 4 + lib/gui/app/modules/progress-status.ts | 8 +- lib/gui/app/os/dialog.ts | 35 ++-- lib/gui/app/utils/start-ellipsis.ts | 28 +++ lib/gui/modules/child-writer.ts | 62 ++++++- 9 files changed, 304 insertions(+), 153 deletions(-) create mode 100644 lib/gui/app/components/url-selector/url-selector.tsx create mode 100644 lib/gui/app/utils/start-ellipsis.ts diff --git a/lib/gui/app/components/progress-button/progress-button.tsx b/lib/gui/app/components/progress-button/progress-button.tsx index 9e328eea..e84eada6 100644 --- a/lib/gui/app/components/progress-button/progress-button.tsx +++ b/lib/gui/app/components/progress-button/progress-button.tsx @@ -18,7 +18,7 @@ import * as React from 'react'; import { Flex, Button, ProgressBar, Txt } from 'rendition'; import { default as styled } from 'styled-components'; -import { fromFlashState } from '../../modules/progress-status'; +import { fromFlashState, FlashState } from '../../modules/progress-status'; import { StepButton } from '../../styled-components'; const FlashProgressBar = styled(ProgressBar)` @@ -44,7 +44,7 @@ const FlashProgressBar = styled(ProgressBar)` `; interface ProgressButtonProps { - type: 'decompressing' | 'flashing' | 'verifying'; + type: FlashState['type']; active: boolean; percentage: number; position: number; @@ -58,6 +58,8 @@ const colors = { decompressing: '#00aeef', flashing: '#da60ff', verifying: '#1ac135', + downloading: '#00aeef', + default: '#00aeef', } as const; const CancelButton = styled(({ type, onClick, ...props }) => { @@ -78,11 +80,11 @@ const CancelButton = styled(({ type, onClick, ...props }) => { export class ProgressButton extends React.PureComponent { public render() { - const type = this.props.type; + const type = this.props.type || 'default'; const percentage = this.props.percentage; const warning = this.props.warning; const { status, position } = fromFlashState({ - type, + type: this.props.type, percentage, position: this.props.position, }); diff --git a/lib/gui/app/components/source-selector/source-selector.tsx b/lib/gui/app/components/source-selector/source-selector.tsx index 437efcf4..b159f3d9 100644 --- a/lib/gui/app/components/source-selector/source-selector.tsx +++ b/lib/gui/app/components/source-selector/source-selector.tsx @@ -25,15 +25,7 @@ import { GPTPartition, MBRPartition } from 'partitioninfo'; import * as path from 'path'; import * as prettyBytes from 'pretty-bytes'; import * as React from 'react'; -import { - Flex, - ButtonProps, - Modal as SmallModal, - Txt, - Card as BaseCard, - Input, - Spinner, -} from 'rendition'; +import { Flex, ButtonProps, Modal as SmallModal, Txt } from 'rendition'; import styled from 'styled-components'; import * as errors from '../../../../shared/errors'; @@ -48,62 +40,21 @@ import { replaceWindowsNetworkDriveLetter } from '../../os/windows-network-drive import { ChangeButton, DetailsText, - Modal, StepButton, StepNameButton, - ScrollableFlex, } from '../../styled-components'; import { colors } from '../../theme'; import { middleEllipsis } from '../../utils/middle-ellipsis'; +import URLSelector from '../url-selector/url-selector'; 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'; - -function normalizeRecentUrlImages(urls: any[]): URL[] { - if (!Array.isArray(urls)) { - urls = []; - } - urls = urls - .map((url) => { - try { - return new URL(url); - } catch (error) { - // Invalid URL, skip - } - }) - .filter((url) => url !== undefined); - urls = _.uniqBy(urls, (url) => url.href); - return urls.slice(urls.length - 5); -} - -function getRecentUrlImages(): URL[] { - let urls = []; - try { - urls = JSON.parse(localStorage.getItem(recentUrlImagesKey) || '[]'); - } catch { - // noop - } - return normalizeRecentUrlImages(urls); -} - -function setRecentUrlImages(urls: URL[]) { - const normalized = normalizeRecentUrlImages(urls.map((url: URL) => url.href)); - localStorage.setItem(recentUrlImagesKey, JSON.stringify(normalized)); -} - const isURL = (imagePath: string) => imagePath.startsWith('https://') || imagePath.startsWith('http://'); -const Card = styled(BaseCard)` - hr { - margin: 5px 0; - } -`; - // TODO move these styles to rendition const ModalText = styled.p` a { @@ -127,85 +78,6 @@ function isString(value: any): value is string { return typeof value === 'string'; } -const URLSelector = ({ - done, - cancel, -}: { - done: (imageURL: string) => void; - cancel: () => void; -}) => { - const [imageURL, setImageURL] = React.useState(''); - const [recentImages, setRecentImages] = React.useState([]); - const [loading, setLoading] = React.useState(false); - React.useEffect(() => { - const fetchRecentUrlImages = async () => { - const recentUrlImages: URL[] = await getRecentUrlImages(); - setRecentImages(recentUrlImages); - }; - fetchRecentUrlImages(); - }, []); - return ( - : 'OK'} - done={async () => { - setLoading(true); - const urlStrings = recentImages.map((url: URL) => url.href); - const normalizedRecentUrls = normalizeRecentUrlImages([ - ...urlStrings, - imageURL, - ]); - setRecentUrlImages(normalizedRecentUrls); - 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()} - /> - - - )} - - - ); -}; - interface Flow { icon?: JSX.Element; onClick: (evt: React.MouseEvent) => void; diff --git a/lib/gui/app/components/url-selector/url-selector.tsx b/lib/gui/app/components/url-selector/url-selector.tsx new file mode 100644 index 00000000..2a013af0 --- /dev/null +++ b/lib/gui/app/components/url-selector/url-selector.tsx @@ -0,0 +1,167 @@ +import { uniqBy } from 'lodash'; +import * as React from 'react'; +import Checkbox from 'rendition/dist_esm5/components/Checkbox'; +import { Flex } from 'rendition/dist_esm5/components/Flex'; +import Input from 'rendition/dist_esm5/components/Input'; +import Link from 'rendition/dist_esm5/components/Link'; +import RadioButton from 'rendition/dist_esm5/components/RadioButton'; +import Txt from 'rendition/dist_esm5/components/Txt'; + +import * as settings from '../../models/settings'; +import { Modal, ScrollableFlex } from '../../styled-components'; +import { openDialog } from '../../os/dialog'; +import { startEllipsis } from '../../utils/start-ellipsis'; + +const RECENT_URL_IMAGES_KEY = 'recentUrlImages'; +const SAVE_IMAGE_AFTER_FLASH_KEY = 'saveUrlImage'; +const SAVE_IMAGE_AFTER_FLASH_PATH_KEY = 'saveUrlImageTo'; + +function normalizeRecentUrlImages(urls: any[]): URL[] { + if (!Array.isArray(urls)) { + urls = []; + } + urls = urls + .map((url) => { + try { + return new URL(url); + } catch (error) { + // Invalid URL, skip + } + }) + .filter((url) => url !== undefined); + urls = uniqBy(urls, (url) => url.href); + return urls.slice(-5); +} + +function getRecentUrlImages(): URL[] { + let urls = []; + try { + urls = JSON.parse(localStorage.getItem(RECENT_URL_IMAGES_KEY) || '[]'); + } catch { + // noop + } + return normalizeRecentUrlImages(urls); +} + +function setRecentUrlImages(urls: string[]) { + localStorage.setItem(RECENT_URL_IMAGES_KEY, JSON.stringify(urls)); +} + +export const URLSelector = ({ + done, + cancel, +}: { + done: (imageURL: string) => void; + cancel: () => void; +}) => { + const [imageURL, setImageURL] = React.useState(''); + const [recentImages, setRecentImages] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [saveImage, setSaveImage] = React.useState(false); + const [saveImagePath, setSaveImagePath] = React.useState(''); + React.useEffect(() => { + const fetchRecentUrlImages = async () => { + const recentUrlImages: URL[] = await getRecentUrlImages(); + setRecentImages(recentUrlImages); + }; + const getSaveImageSettings = async () => { + const saveUrlImage: boolean = await settings.get( + SAVE_IMAGE_AFTER_FLASH_KEY, + ); + const saveUrlImageToPath: string = await settings.get( + SAVE_IMAGE_AFTER_FLASH_PATH_KEY, + ); + setSaveImage(saveUrlImage); + setSaveImagePath(saveUrlImageToPath); + }; + fetchRecentUrlImages(); + getSaveImageSettings(); + }, []); + return ( + { + setLoading(true); + const urlStrings = recentImages + .map((url: URL) => url.href) + .concat(imageURL); + setRecentUrlImages(urlStrings); + await done(imageURL); + }} + > + + + ) => + setImageURL(evt.target.value) + } + /> + + { + const value = evt.target.checked; + setSaveImage(value); + settings + .set(SAVE_IMAGE_AFTER_FLASH_KEY, value) + .then(() => setSaveImage(value)); + }} + label={<>Save file to: } + /> + { + if (saveImage) { + const folder = await openDialog('openDirectory'); + if (folder) { + await settings.set(SAVE_IMAGE_AFTER_FLASH_PATH_KEY, folder); + setSaveImagePath(folder); + } + } + }} + > + {startEllipsis(saveImagePath, 20)} + + + + {recentImages.length > 0 && ( + + + Recent + + + {recentImages + .map((recent, i) => ( + { + setImageURL(recent.href); + }} + style={{ + overflowWrap: 'break-word', + }} + /> + )) + .reverse()} + + + )} + + + ); +}; + +export default URLSelector; diff --git a/lib/gui/app/models/settings.ts b/lib/gui/app/models/settings.ts index 8bfc9106..ecbf157b 100644 --- a/lib/gui/app/models/settings.ts +++ b/lib/gui/app/models/settings.ts @@ -41,12 +41,15 @@ export const DEFAULT_HEIGHT = 480; * NOTE: The ternary is due to this module being loaded both, * Electron's main process and renderer process */ -const USER_DATA_DIR = electron.app - ? electron.app.getPath('userData') - : electron.remote.app.getPath('userData'); + +const app = electron.app || electron.remote.app; + +const USER_DATA_DIR = app.getPath('userData'); const CONFIG_PATH = join(USER_DATA_DIR, 'config.json'); +const DOWNLOADS_DIR = app.getPath('downloads'); + async function readConfigFile(filename: string): Promise<_.Dictionary> { let contents = '{}'; try { @@ -83,6 +86,8 @@ const DEFAULT_SETTINGS: _.Dictionary = { desktopNotifications: true, autoBlockmapping: true, decompressFirst: true, + saveUrlImage: false, + saveUrlImageTo: DOWNLOADS_DIR, }; const settings = _.cloneDeep(DEFAULT_SETTINGS); diff --git a/lib/gui/app/modules/image-writer.ts b/lib/gui/app/modules/image-writer.ts index 175de132..8720f99b 100644 --- a/lib/gui/app/modules/image-writer.ts +++ b/lib/gui/app/modules/image-writer.ts @@ -148,6 +148,8 @@ async function performWrite( validateWriteOnSuccess, autoBlockmapping, decompressFirst, + saveUrlImage, + saveUrlImageTo, } = await settings.getAll(); return await new Promise((resolve, reject) => { ipc.server.on('error', (error) => { @@ -206,6 +208,8 @@ async function performWrite( autoBlockmapping, unmountOnSuccess, decompressFirst, + saveUrlImage, + saveUrlImageTo, }); }); diff --git a/lib/gui/app/modules/progress-status.ts b/lib/gui/app/modules/progress-status.ts index 6c48b2c2..9c6b0c01 100644 --- a/lib/gui/app/modules/progress-status.ts +++ b/lib/gui/app/modules/progress-status.ts @@ -22,7 +22,7 @@ export interface FlashState { percentage?: number; speed: number; position: number; - type?: 'decompressing' | 'flashing' | 'verifying'; + type?: 'decompressing' | 'flashing' | 'verifying' | 'downloading'; } export function fromFlashState({ @@ -62,6 +62,12 @@ export function fromFlashState({ } else { return { status: 'Finishing...' }; } + } else if (type === 'downloading') { + if (percentage == null) { + return { status: 'Downloading...' }; + } else if (percentage < 100) { + return { position: `${percentage}%`, status: 'Downloading...' }; + } } return { status: 'Failed' }; } diff --git a/lib/gui/app/os/dialog.ts b/lib/gui/app/os/dialog.ts index ce906265..506b31b3 100644 --- a/lib/gui/app/os/dialog.ts +++ b/lib/gui/app/os/dialog.ts @@ -40,6 +40,12 @@ async function mountSourceDrive() { * Notice that by image, we mean *.img/*.iso/*.zip/etc files. */ export async function selectImage(): Promise { + return await openDialog(); +} + +export async function openDialog( + type: 'openFile' | 'openDirectory' = 'openFile', +) { await mountSourceDrive(); const options: electron.OpenDialogOptions = { // This variable is set when running in GNU/Linux from @@ -50,23 +56,26 @@ export async function selectImage(): Promise { // // See: https://github.com/probonopd/AppImageKit/commit/1569d6f8540aa6c2c618dbdb5d6fcbf0003952b7 defaultPath: process.env.OWD, - properties: ['openFile', 'treatPackageAsDirectory'], - filters: [ - { - name: 'OS Images', - extensions: SUPPORTED_EXTENSIONS, - }, - { - name: 'All', - extensions: ['*'], - }, - ], + properties: [type, 'treatPackageAsDirectory'], + filters: + type === 'openFile' + ? [ + { + name: 'OS Images', + extensions: SUPPORTED_EXTENSIONS, + }, + { + name: 'All', + extensions: ['*'], + }, + ] + : undefined, }; const currentWindow = electron.remote.getCurrentWindow(); - const [file] = ( + const [path] = ( await electron.remote.dialog.showOpenDialog(currentWindow, options) ).filePaths; - return file; + return path; } /** diff --git a/lib/gui/app/utils/start-ellipsis.ts b/lib/gui/app/utils/start-ellipsis.ts new file mode 100644 index 00000000..45d0710a --- /dev/null +++ b/lib/gui/app/utils/start-ellipsis.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2020 balena.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @summary Truncate text from the start with an ellipsis + */ +export function startEllipsis(input: string, limit: number): string { + // Do nothing, the string doesn't need truncation. + if (input.length <= limit) { + return input; + } + + const lastPart = input.slice(input.length - limit, input.length); + return `…${lastPart}`; +} diff --git a/lib/gui/modules/child-writer.ts b/lib/gui/modules/child-writer.ts index dda3c326..bf466342 100644 --- a/lib/gui/modules/child-writer.ts +++ b/lib/gui/modules/child-writer.ts @@ -17,6 +17,8 @@ import { Drive as DrivelistDrive } from 'drivelist'; import * as sdk from 'etcher-sdk'; import { cleanupTmpFiles } from 'etcher-sdk/build/tmp'; +import { promises as fs } from 'fs'; +import * as _ from 'lodash'; import * as ipc from 'node-ipc'; import { totalmem } from 'os'; @@ -154,6 +156,13 @@ interface WriteOptions { autoBlockmapping: boolean; decompressFirst: boolean; SourceType: string; + saveUrlImage: boolean; + saveUrlImageTo: string; +} + +interface ProgressState + extends Omit { + type: sdk.multiWrite.MultiDestinationProgress['type'] | 'downloading'; } ipc.connectTo(IPC_SERVER_ID, () => { @@ -191,7 +200,7 @@ ipc.connectTo(IPC_SERVER_ID, () => { * @example * writer.on('progress', onProgress) */ - const onProgress = (state: sdk.multiWrite.MultiDestinationProgress) => { + const onProgress = (state: ProgressState) => { ipc.of[IPC_SERVER_ID].emit('state', state); }; @@ -269,7 +278,16 @@ ipc.connectTo(IPC_SERVER_ID, () => { path: imagePath, }); } else { - source = new Http({ url: imagePath, avoidRandomAccess: true }); + if (options.saveUrlImage) { + source = await saveFileBeforeFlash( + imagePath, + options.saveUrlImageTo, + onProgress, + onFail, + ); + } else { + source = new Http({ url: imagePath, avoidRandomAccess: true }); + } } } const results = await writeAndValidate({ @@ -302,3 +320,43 @@ ipc.connectTo(IPC_SERVER_ID, () => { ipc.of[IPC_SERVER_ID].emit('ready', {}); }); }); + +async function saveFileBeforeFlash( + imagePath: string, + saveUrlImageTo: string, + onProgress: (state: ProgressState) => void, + onFail: ( + destination: sdk.sourceDestination.SourceDestination, + error: Error, + ) => void, +) { + const urlImage = new Http({ url: imagePath, avoidRandomAccess: true }); + const source = await urlImage.getInnerSource(); + const metadata = await source.getMetadata(); + const fileName = `${saveUrlImageTo}/${metadata.name}`; + let alreadyDownloaded = false; + try { + alreadyDownloaded = (await fs.stat(fileName)).isFile(); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + if (!alreadyDownloaded) { + await sdk.multiWrite.decompressThenFlash({ + source, + destinations: [new File({ path: fileName, write: true })], + onProgress: (progress) => { + onProgress({ + ...progress, + type: 'downloading', + }); + }, + onFail: (...args) => { + onFail(...args); + }, + verify: true, + }); + } + return new File({ path: fileName }); +} From 3feb22ee664328a7259fd208f0516fbbbb0aac50 Mon Sep 17 00:00:00 2001 From: Lorenzo Alberto Maria Ambrosi Date: Thu, 1 Oct 2020 16:54:49 +0200 Subject: [PATCH 27/27] Add primary colors to default flow Change-type: patch Signed-off-by: Lorenzo Alberto Maria Ambrosi --- .../source-selector/source-selector.tsx | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/gui/app/components/source-selector/source-selector.tsx b/lib/gui/app/components/source-selector/source-selector.tsx index b159f3d9..b61791d9 100644 --- a/lib/gui/app/components/source-selector/source-selector.tsx +++ b/lib/gui/app/components/source-selector/source-selector.tsx @@ -85,10 +85,13 @@ interface Flow { } const FlowSelector = styled( - ({ flow, ...props }: { flow: Flow; props?: ButtonProps }) => ( + ({ flow, ...props }: { flow: Flow } & ButtonProps) => ( flow.onClick(evt)} + plain={!props.primary} + primary={props.primary} + onClick={(evt: React.MouseEvent) => + flow.onClick(evt) + } icon={flow.icon} {...props} > @@ -144,6 +147,7 @@ interface SourceSelectorState { showImageDetails: boolean; showURLSelector: boolean; showDriveSelector: boolean; + defaultFlowActive: boolean; } export class SourceSelector extends React.Component< @@ -160,6 +164,7 @@ export class SourceSelector extends React.Component< showImageDetails: false, showURLSelector: false, showDriveSelector: false, + defaultFlowActive: true, }; // Bind `this` since it's used in an event's callback @@ -405,6 +410,10 @@ export class SourceSelector extends React.Component< }); } + private setDefaultFlowActive(defaultFlowActive: boolean) { + this.setState({ defaultFlowActive }); + } + // TODO add a visual change when dragging a file over the selector public render() { const { flashing } = this.props; @@ -471,12 +480,15 @@ export class SourceSelector extends React.Component< ) : ( <> this.openImageSelector(), label: 'Flash from file', icon: , }} + onMouseEnter={() => this.setDefaultFlowActive(false)} + onMouseLeave={() => this.setDefaultFlowActive(true)} /> , }} + onMouseEnter={() => this.setDefaultFlowActive(false)} + onMouseLeave={() => this.setDefaultFlowActive(true)} /> , }} + onMouseEnter={() => this.setDefaultFlowActive(false)} + onMouseLeave={() => this.setDefaultFlowActive(true)} /> )}