Merge pull request #3273 from balena-io/add-clone-drive

Add clone drive
This commit is contained in:
bulldozer-balena[bot] 2020-09-07 09:48:16 +00:00 committed by GitHub
commit b099770cb1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1338 additions and 1279 deletions

4
.gitignore vendored
View File

@ -47,3 +47,7 @@ node_modules
# OSX files # OSX files
.DS_Store .DS_Store
# VSCode files
.vscode

View File

@ -23,7 +23,11 @@ import * as ReactDOM from 'react-dom';
import { v4 as uuidV4 } from 'uuid'; import { v4 as uuidV4 } from 'uuid';
import * as packageJSON from '../../../package.json'; 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 EXIT_CODES from '../../shared/exit-codes';
import * as messages from '../../shared/messages'; import * as messages from '../../shared/messages';
import * as availableDrives from './models/available-drives'; import * as availableDrives from './models/available-drives';
@ -231,12 +235,12 @@ function prepareDrive(drive: Drive) {
} }
} }
function setDrives(drives: _.Dictionary<any>) { function setDrives(drives: _.Dictionary<DrivelistDrive>) {
availableDrives.setDrives(_.values(drives)); availableDrives.setDrives(_.values(drives));
} }
function getDrives() { function getDrives() {
return _.keyBy(availableDrives.getDrives() || [], 'device'); return _.keyBy(availableDrives.getDrives(), 'device');
} }
async function addDrive(drive: Drive) { async function addDrive(drive: Drive) {

View File

@ -16,7 +16,7 @@
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg'; import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg';
import ChevronDownSvg from '@fortawesome/fontawesome-free/svgs/solid/chevron-down.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 * as React from 'react';
import { import {
Flex, Flex,
@ -31,25 +31,22 @@ import styled from 'styled-components';
import { import {
getDriveImageCompatibilityStatuses, getDriveImageCompatibilityStatuses,
hasListDriveImageCompatibilityStatus,
isDriveValid, isDriveValid,
TargetStatus, DriveStatus,
Image, DrivelistDrive,
isDriveSizeLarge,
} from '../../../../shared/drive-constraints'; } from '../../../../shared/drive-constraints';
import { compatibility } from '../../../../shared/messages'; import { compatibility, warning } from '../../../../shared/messages';
import { bytesToClosestUnit } from '../../../../shared/units'; import * as prettyBytes from 'pretty-bytes';
import { getDrives, hasAvailableDrives } from '../../models/available-drives'; import { getDrives, hasAvailableDrives } from '../../models/available-drives';
import { import { getImage, isDriveSelected } from '../../models/selection-state';
getImage,
getSelectedDrives,
isDriveSelected,
} from '../../models/selection-state';
import { store } from '../../models/store'; import { store } from '../../models/store';
import { logEvent, logException } from '../../modules/analytics'; import { logEvent, logException } from '../../modules/analytics';
import { open as openExternal } from '../../os/open-external/services/open-external'; import { open as openExternal } from '../../os/open-external/services/open-external';
import { Modal, ScrollableFlex } from '../../styled-components'; import { Alert, Modal, ScrollableFlex } from '../../styled-components';
import TargetSVGIcon from '../../../assets/tgt.svg'; import DriveSVGIcon from '../../../assets/tgt.svg';
import { SourceMetadata } from '../source-selector/source-selector';
interface UsbbootDrive extends sourceDestination.UsbbootDrive { interface UsbbootDrive extends sourceDestination.UsbbootDrive {
progress: number; progress: number;
@ -64,47 +61,88 @@ interface DriverlessDrive {
linkCTA: string; linkCTA: string;
} }
type Target = scanner.adapters.DrivelistDrive | DriverlessDrive | UsbbootDrive; type Drive = DrivelistDrive | DriverlessDrive | UsbbootDrive;
function isUsbbootDrive(drive: Target): drive is UsbbootDrive { function isUsbbootDrive(drive: Drive): drive is UsbbootDrive {
return (drive as UsbbootDrive).progress !== undefined; return (drive as UsbbootDrive).progress !== undefined;
} }
function isDriverlessDrive(drive: Target): drive is DriverlessDrive { function isDriverlessDrive(drive: Drive): drive is DriverlessDrive {
return (drive as DriverlessDrive).link !== undefined; return (drive as DriverlessDrive).link !== undefined;
} }
function isDrivelistDrive( function isDrivelistDrive(drive: Drive): drive is DrivelistDrive {
drive: Target, return typeof (drive as DrivelistDrive).size === 'number';
): drive is scanner.adapters.DrivelistDrive {
return typeof (drive as scanner.adapters.DrivelistDrive).size === 'number';
} }
const TargetsTable = styled(({ refFn, ...props }) => { const DrivesTable = styled(({ refFn, ...props }) => (
return ( <div>
<div> <Table<Drive> ref={refFn} {...props} />
<Table<Target> ref={refFn} {...props} /> </div>
</div> ))`
); [data-display='table-head']
})` > [data-display='table-row']
[data-display='table-head'] [data-display='table-cell'] { > [data-display='table-cell'] {
position: sticky; position: sticky;
top: 0; top: 0;
background-color: ${(props) => props.theme.colors.quartenary.light}; 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 { [data-display='table-body'] > [data-display='table-row'] {
padding-left: 15px; > [data-display='table-cell']:first-child {
} padding-left: 15px;
}
[data-display='table-cell']:last-child { > [data-display='table-cell']:last-child {
width: 150px; 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'] { && [data-display='table-row'] > [data-display='table-cell'] {
padding: 6px 8px; padding: 6px 8px;
color: #2a506f; color: #2a506f;
} }
input[type='checkbox'] + div {
border-radius: ${({ multipleSelection }) =>
multipleSelection ? '4px' : '50%'};
}
`; `;
function badgeShadeFromStatus(status: string) { function badgeShadeFromStatus(status: string) {
@ -112,6 +150,7 @@ function badgeShadeFromStatus(status: string) {
case compatibility.containsImage(): case compatibility.containsImage():
return 16; return 16;
case compatibility.system(): case compatibility.system():
case compatibility.tooSmall():
return 5; return 5;
default: default:
return 14; return 14;
@ -145,30 +184,42 @@ const InitProgress = styled(
} }
`; `;
interface TargetSelectorModalProps extends Omit<ModalProps, 'done'> { export interface DriveSelectorProps
done: (targets: scanner.adapters.DrivelistDrive[]) => void; extends Omit<ModalProps, 'done' | 'cancel'> {
multipleSelection: boolean;
showWarnings?: boolean;
cancel: () => void;
done: (drives: DrivelistDrive[]) => void;
titleLabel: string;
emptyListLabel: string;
selectedList?: DrivelistDrive[];
updateSelectedList?: () => DrivelistDrive[];
} }
interface TargetSelectorModalState { interface DriveSelectorState {
drives: Target[]; drives: Drive[];
image: Image; image?: SourceMetadata;
missingDriversModal: { drive?: DriverlessDrive }; missingDriversModal: { drive?: DriverlessDrive };
selectedList: scanner.adapters.DrivelistDrive[]; selectedList: DrivelistDrive[];
showSystemDrives: boolean; showSystemDrives: boolean;
} }
export class TargetSelectorModal extends React.Component< function isSystemDrive(drive: Drive) {
TargetSelectorModalProps, return isDrivelistDrive(drive) && drive.isSystem;
TargetSelectorModalState }
export class DriveSelector extends React.Component<
DriveSelectorProps,
DriveSelectorState
> { > {
private unsubscribe: (() => void) | undefined; private unsubscribe: (() => void) | undefined;
tableColumns: Array<TableColumn<Target>>; tableColumns: Array<TableColumn<Drive>>;
constructor(props: TargetSelectorModalProps) { constructor(props: DriveSelectorProps) {
super(props); super(props);
const defaultMissingDriversModalState: { drive?: DriverlessDrive } = {}; const defaultMissingDriversModalState: { drive?: DriverlessDrive } = {};
const selectedList = getSelectedDrives(); const selectedList = this.props.selectedList || [];
this.state = { this.state = {
drives: getDrives(), drives: getDrives(),
@ -182,24 +233,33 @@ export class TargetSelectorModal extends React.Component<
{ {
field: 'description', field: 'description',
label: 'Name', label: 'Name',
render: (description: string, drive: Target) => { render: (description: string, drive: Drive) => {
return isDrivelistDrive(drive) && drive.isSystem ? ( if (isDrivelistDrive(drive)) {
<Flex alignItems="center"> const isLargeDrive = isDriveSizeLarge(drive);
<ExclamationTriangleSvg height="1em" fill="#fca321" /> const hasWarnings =
<Txt ml={8}>{description}</Txt> this.props.showWarnings && (isLargeDrive || drive.isSystem);
</Flex> return (
) : ( <Flex alignItems="center">
<Txt>{description}</Txt> {hasWarnings && (
); <ExclamationTriangleSvg
height="1em"
fill={drive.isSystem ? '#fca321' : '#8f9297'}
/>
)}
<Txt ml={(hasWarnings && 8) || 0}>{description}</Txt>
</Flex>
);
}
return <Txt>{description}</Txt>;
}, },
}, },
{ {
field: 'description', field: 'description',
key: 'size', key: 'size',
label: 'Size', label: 'Size',
render: (_description: string, drive: Target) => { render: (_description: string, drive: Drive) => {
if (isDrivelistDrive(drive) && drive.size !== null) { if (isDrivelistDrive(drive) && drive.size !== null) {
return bytesToClosestUnit(drive.size); return prettyBytes(drive.size);
} }
}, },
}, },
@ -207,7 +267,7 @@ export class TargetSelectorModal extends React.Component<
field: 'description', field: 'description',
key: 'link', key: 'link',
label: 'Location', label: 'Location',
render: (_description: string, drive: Target) => { render: (_description: string, drive: Drive) => {
return ( return (
<Txt> <Txt>
{drive.displayName} {drive.displayName}
@ -229,22 +289,20 @@ export class TargetSelectorModal extends React.Component<
{ {
field: 'description', field: 'description',
key: 'extra', key: 'extra',
// Space as empty string would use the field name as label // We use an empty React fragment otherwise it uses the field name as label
label: ' ', label: <></>,
render: (_description: string, drive: Target) => { render: (_description: string, drive: Drive) => {
if (isUsbbootDrive(drive)) { if (isUsbbootDrive(drive)) {
return this.renderProgress(drive.progress); return this.renderProgress(drive.progress);
} else if (isDrivelistDrive(drive)) { } else if (isDrivelistDrive(drive)) {
return this.renderStatuses( return this.renderStatuses(drive);
getDriveImageCompatibilityStatuses(drive, this.state.image),
);
} }
}, },
}, },
]; ];
} }
private driveShouldBeDisabled(drive: Target, image: any) { private driveShouldBeDisabled(drive: Drive, image?: SourceMetadata) {
return ( return (
isUsbbootDrive(drive) || isUsbbootDrive(drive) ||
isDriverlessDrive(drive) || isDriverlessDrive(drive) ||
@ -252,8 +310,8 @@ export class TargetSelectorModal extends React.Component<
); );
} }
private getDisplayedTargets(targets: Target[]): Target[] { private getDisplayedDrives(drives: Drive[]): Drive[] {
return targets.filter((drive) => { return drives.filter((drive) => {
return ( return (
isUsbbootDrive(drive) || isUsbbootDrive(drive) ||
isDriverlessDrive(drive) || isDriverlessDrive(drive) ||
@ -264,7 +322,7 @@ export class TargetSelectorModal extends React.Component<
}); });
} }
private getDisabledTargets(drives: Target[], image: any): string[] { private getDisabledDrives(drives: Drive[], image?: SourceMetadata): string[] {
return drives return drives
.filter((drive) => this.driveShouldBeDisabled(drive, image)) .filter((drive) => this.driveShouldBeDisabled(drive, image))
.map((drive) => drive.displayName); .map((drive) => drive.displayName);
@ -279,14 +337,45 @@ export class TargetSelectorModal extends React.Component<
); );
} }
private renderStatuses(statuses: TargetStatus[]) { private warningFromStatus(
status: string,
drive: { device: string; size: number },
) {
switch (status) {
case compatibility.containsImage():
return warning.sourceDrive();
case compatibility.largeDrive():
return warning.largeDriveSize();
case compatibility.system():
return warning.systemDrive();
case compatibility.tooSmall():
const recommendedDriveSize =
this.state.image?.recommendedDriveSize || this.state.image?.size || 0;
return warning.unrecommendedDriveSize({ recommendedDriveSize }, drive);
}
}
private renderStatuses(drive: DrivelistDrive) {
const statuses: DriveStatus[] = getDriveImageCompatibilityStatuses(
drive,
this.state.image,
).slice(0, 2);
return ( return (
// the column render fn expects a single Element // the column render fn expects a single Element
<> <>
{statuses.map((status) => { {statuses.map((status) => {
const badgeShade = badgeShadeFromStatus(status.message); const badgeShade = badgeShadeFromStatus(status.message);
const warningMessage = this.warningFromStatus(status.message, {
device: drive.device,
size: drive.size || 0,
});
return ( return (
<Badge key={status.message} shade={badgeShade}> <Badge
key={status.message}
shade={badgeShade}
mr="8px"
tooltip={this.props.showWarnings ? warningMessage : ''}
>
{status.message} {status.message}
</Badge> </Badge>
); );
@ -311,7 +400,9 @@ export class TargetSelectorModal extends React.Component<
this.setState({ this.setState({
drives, drives,
image, image,
selectedList: getSelectedDrives(), selectedList:
(this.props.updateSelectedList && this.props.updateSelectedList()) ||
[],
}); });
}); });
} }
@ -324,24 +415,22 @@ export class TargetSelectorModal extends React.Component<
const { cancel, done, ...props } = this.props; const { cancel, done, ...props } = this.props;
const { selectedList, drives, image, missingDriversModal } = this.state; const { selectedList, drives, image, missingDriversModal } = this.state;
const displayedTargets = this.getDisplayedTargets(drives); const displayedDrives = this.getDisplayedDrives(drives);
const disabledTargets = this.getDisabledTargets(drives, image); const disabledDrives = this.getDisabledDrives(drives, image);
const numberOfSystemDrives = drives.filter( const numberOfSystemDrives = drives.filter(isSystemDrive).length;
(drive) => isDrivelistDrive(drive) && drive.isSystem, const numberOfDisplayedSystemDrives = displayedDrives.filter(isSystemDrive)
).length; .length;
const numberOfDisplayedSystemDrives = displayedTargets.filter(
(drive) => isDrivelistDrive(drive) && drive.isSystem,
).length;
const numberOfHiddenSystemDrives = const numberOfHiddenSystemDrives =
numberOfSystemDrives - numberOfDisplayedSystemDrives; numberOfSystemDrives - numberOfDisplayedSystemDrives;
const hasStatus = hasListDriveImageCompatibilityStatus(selectedList, image); const hasSystemDrives = selectedList.filter(isSystemDrive).length;
const showWarnings = this.props.showWarnings && hasSystemDrives;
return ( return (
<Modal <Modal
titleElement={ titleElement={
<Flex alignItems="baseline" mb={18}> <Flex alignItems="baseline" mb={18}>
<Txt fontSize={24} align="left"> <Txt fontSize={24} align="left">
Select target {this.props.titleLabel}
</Txt> </Txt>
<Txt <Txt
fontSize={11} fontSize={11}
@ -358,8 +447,8 @@ export class TargetSelectorModal extends React.Component<
done={() => done(selectedList)} done={() => done(selectedList)}
action={`Select (${selectedList.length})`} action={`Select (${selectedList.length})`}
primaryButtonProps={{ primaryButtonProps={{
primary: !hasStatus, primary: !showWarnings,
warning: hasStatus, warning: showWarnings,
disabled: !hasAvailableDrives(), disabled: !hasAvailableDrives(),
}} }}
{...props} {...props}
@ -372,45 +461,62 @@ export class TargetSelectorModal extends React.Component<
alignItems="center" alignItems="center"
width="100%" width="100%"
> >
<TargetSVGIcon width="40px" height="90px" /> <DriveSVGIcon width="40px" height="90px" />
<b>Plug a target drive</b> <b>{this.props.emptyListLabel}</b>
</Flex> </Flex>
) : ( ) : (
<ScrollableFlex flexDirection="column" width="100%"> <ScrollableFlex flexDirection="column" width="100%">
<TargetsTable <DrivesTable
refFn={(t: Table<Target>) => { refFn={(t: Table<Drive>) => {
if (t !== null) { if (t !== null) {
t.setRowSelection(selectedList); t.setRowSelection(selectedList);
} }
}} }}
multipleSelection={this.props.multipleSelection}
columns={this.tableColumns} columns={this.tableColumns}
data={displayedTargets} data={displayedDrives}
disabledRows={disabledTargets} disabledRows={disabledDrives}
getRowClass={(row: Drive) =>
isDrivelistDrive(row) && row.isSystem ? ['system'] : []
}
rowKey="displayName" rowKey="displayName"
onCheck={(rows: Target[]) => { onCheck={(rows: Drive[]) => {
const newSelection = rows.filter(isDrivelistDrive);
if (this.props.multipleSelection) {
this.setState({
selectedList: newSelection,
});
return;
}
this.setState({ this.setState({
selectedList: rows.filter(isDrivelistDrive), selectedList: newSelection.slice(newSelection.length - 1),
}); });
}} }}
onRowClick={(row: Target) => { onRowClick={(row: Drive) => {
if ( if (
!isDrivelistDrive(row) || !isDrivelistDrive(row) ||
this.driveShouldBeDisabled(row, image) this.driveShouldBeDisabled(row, image)
) { ) {
return; return;
} }
const newList = [...selectedList]; if (this.props.multipleSelection) {
const selectedIndex = selectedList.findIndex( const newList = [...selectedList];
(target) => target.device === row.device, const selectedIndex = selectedList.findIndex(
); (drive) => drive.device === row.device,
if (selectedIndex === -1) { );
newList.push(row); if (selectedIndex === -1) {
} else { newList.push(row);
// Deselect if selected } else {
newList.splice(selectedIndex, 1); // Deselect if selected
newList.splice(selectedIndex, 1);
}
this.setState({
selectedList: newList,
});
return;
} }
this.setState({ this.setState({
selectedList: newList, selectedList: [row],
}); });
}} }}
/> />
@ -418,6 +524,7 @@ export class TargetSelectorModal extends React.Component<
<Link <Link
mt={15} mt={15}
mb={15} mb={15}
fontSize="14px"
onClick={() => this.setState({ showSystemDrives: true })} onClick={() => this.setState({ showSystemDrives: true })}
> >
<Flex alignItems="center"> <Flex alignItems="center">
@ -428,6 +535,12 @@ export class TargetSelectorModal extends React.Component<
)} )}
</ScrollableFlex> </ScrollableFlex>
)} )}
{this.props.showWarnings && hasSystemDrives ? (
<Alert className="system-drive-alert" style={{ width: '67%' }}>
Selecting your system drive is dangerous and will erase your
drive!
</Alert>
) : null}
</Flex> </Flex>
{missingDriversModal.drive !== undefined && ( {missingDriversModal.drive !== undefined && (

View File

@ -0,0 +1,82 @@
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg';
import * as _ from 'lodash';
import * as React from 'react';
import { Badge, Flex, Txt, ModalProps } from 'rendition';
import { Modal, ScrollableFlex } from '../../styled-components';
import { middleEllipsis } from '../../utils/middle-ellipsis';
import * as prettyBytes from 'pretty-bytes';
import { DriveWithWarnings } from '../../pages/main/Flash';
const DriveStatusWarningModal = ({
done,
cancel,
isSystem,
drivesWithWarnings,
}: ModalProps & {
isSystem: boolean;
drivesWithWarnings: DriveWithWarnings[];
}) => {
let warningSubtitle = 'You are about to erase an unusually large drive';
let warningCta = 'Are you sure the selected drive is not a storage drive?';
if (isSystem) {
warningSubtitle = "You are about to erase your computer's drives";
warningCta = 'Are you sure you want to flash your system drive?';
}
return (
<Modal
footerShadow={false}
reverseFooterButtons={true}
done={done}
cancel={cancel}
cancelButtonProps={{
primary: false,
warning: true,
children: 'Change target',
}}
action={"Yes, I'm sure"}
primaryButtonProps={{
primary: false,
outline: true,
}}
>
<Flex
flexDirection="column"
alignItems="center"
justifyContent="center"
width="100%"
>
<Flex flexDirection="column">
<ExclamationTriangleSvg height="2em" fill="#fca321" />
<Txt fontSize="24px" color="#fca321">
WARNING!
</Txt>
</Flex>
<Txt fontSize="24px">{warningSubtitle}</Txt>
<ScrollableFlex
flexDirection="column"
backgroundColor="#fff5e6"
m="2em 0"
p="1em 2em"
width="420px"
maxHeight="100px"
>
{drivesWithWarnings.map((drive, i, array) => (
<>
<Flex justifyContent="space-between" alignItems="baseline">
<strong>{middleEllipsis(drive.description, 28)}</strong>{' '}
{drive.size && prettyBytes(drive.size) + ' '}
<Badge shade={5}>{drive.statuses[0].message}</Badge>
</Flex>
{i !== array.length - 1 ? <hr style={{ width: '100%' }} /> : null}
</>
))}
</ScrollableFlex>
<Txt style={{ fontWeight: 600 }}>{warningCta}</Txt>
</Flex>
</Modal>
);
};
export default DriveStatusWarningModal;

View File

@ -23,8 +23,8 @@ import { SVGIcon } from '../svg-icon/svg-icon';
import { middleEllipsis } from '../../utils/middle-ellipsis'; import { middleEllipsis } from '../../utils/middle-ellipsis';
interface ReducedFlashingInfosProps { interface ReducedFlashingInfosProps {
imageLogo: string; imageLogo?: string;
imageName: string; imageName?: string;
imageSize: string; imageSize: string;
driveTitle: string; driveTitle: string;
driveLabel: string; driveLabel: string;
@ -40,6 +40,7 @@ export class ReducedFlashingInfos extends React.Component<
} }
public render() { public render() {
const { imageName = '' } = this.props;
return ( return (
<Flex <Flex
flexDirection="column" flexDirection="column"
@ -56,9 +57,9 @@ export class ReducedFlashingInfos extends React.Component<
/> />
<Txt <Txt
style={{ marginRight: '9px' }} style={{ marginRight: '9px' }}
tooltip={{ text: this.props.imageName, placement: 'right' }} tooltip={{ text: imageName, placement: 'right' }}
> >
{middleEllipsis(this.props.imageName, 16)} {middleEllipsis(imageName, 16)}
</Txt> </Txt>
<Txt color="#7e8085">{this.props.imageSize}</Txt> <Txt color="#7e8085">{this.props.imageSize}</Txt>
</Flex> </Flex>

View File

@ -14,6 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import CopySvg from '@fortawesome/fontawesome-free/svgs/solid/copy.svg';
import FileSvg from '@fortawesome/fontawesome-free/svgs/solid/file.svg'; import FileSvg from '@fortawesome/fontawesome-free/svgs/solid/file.svg';
import LinkSvg from '@fortawesome/fontawesome-free/svgs/solid/link.svg'; import LinkSvg from '@fortawesome/fontawesome-free/svgs/solid/link.svg';
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg'; import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg';
@ -22,6 +23,7 @@ import { ipcRenderer, IpcRendererEvent } from 'electron';
import * as _ from 'lodash'; import * as _ from 'lodash';
import { GPTPartition, MBRPartition } from 'partitioninfo'; import { GPTPartition, MBRPartition } from 'partitioninfo';
import * as path from 'path'; import * as path from 'path';
import * as prettyBytes from 'pretty-bytes';
import * as React from 'react'; import * as React from 'react';
import { import {
Flex, Flex,
@ -37,7 +39,6 @@ import styled from 'styled-components';
import * as errors from '../../../../shared/errors'; import * as errors from '../../../../shared/errors';
import * as messages from '../../../../shared/messages'; import * as messages from '../../../../shared/messages';
import * as supportedFormats from '../../../../shared/supported-formats'; import * as supportedFormats from '../../../../shared/supported-formats';
import * as shared from '../../../../shared/units';
import * as selectionState from '../../models/selection-state'; import * as selectionState from '../../models/selection-state';
import { observe } from '../../models/store'; import { observe } from '../../models/store';
import * as analytics from '../../modules/analytics'; import * as analytics from '../../modules/analytics';
@ -57,6 +58,8 @@ import { middleEllipsis } from '../../utils/middle-ellipsis';
import { SVGIcon } from '../svg-icon/svg-icon'; import { SVGIcon } from '../svg-icon/svg-icon';
import ImageSvg from '../../../assets/image.svg'; import ImageSvg from '../../../assets/image.svg';
import { DriveSelector } from '../drive-selector/drive-selector';
import { DrivelistDrive } from '../../../../shared/drive-constraints';
const recentUrlImagesKey = 'recentUrlImages'; const recentUrlImagesKey = 'recentUrlImages';
@ -92,6 +95,9 @@ function setRecentUrlImages(urls: URL[]) {
localStorage.setItem(recentUrlImagesKey, JSON.stringify(normalized)); localStorage.setItem(recentUrlImagesKey, JSON.stringify(normalized));
} }
const isURL = (imagePath: string) =>
imagePath.startsWith('https://') || imagePath.startsWith('http://');
const Card = styled(BaseCard)` const Card = styled(BaseCard)`
hr { hr {
margin: 5px 0; margin: 5px 0;
@ -117,6 +123,10 @@ function getState() {
}; };
} }
function isString(value: any): value is string {
return typeof value === 'string';
}
const URLSelector = ({ const URLSelector = ({
done, done,
cancel, cancel,
@ -152,44 +162,46 @@ const URLSelector = ({
await done(imageURL); await done(imageURL);
}} }}
> >
<Flex style={{ width: '100%' }} flexDirection="column"> <Flex flexDirection="column">
<Txt mb="10px" fontSize="24px"> <Flex style={{ width: '100%' }} flexDirection="column">
Use Image URL <Txt mb="10px" fontSize="24px">
</Txt> Use Image URL
<Input </Txt>
value={imageURL} <Input
placeholder="Enter a valid URL" value={imageURL}
type="text" placeholder="Enter a valid URL"
onChange={(evt: React.ChangeEvent<HTMLInputElement>) => type="text"
setImageURL(evt.target.value) onChange={(evt: React.ChangeEvent<HTMLInputElement>) =>
} setImageURL(evt.target.value)
/> }
</Flex> />
{recentImages.length > 0 && (
<Flex flexDirection="column" height="78.6%">
<Txt fontSize={18}>Recent</Txt>
<ScrollableFlex flexDirection="column">
<Card
p="10px 15px"
rows={recentImages
.map((recent) => (
<Txt
key={recent.href}
onClick={() => {
setImageURL(recent.href);
}}
style={{
overflowWrap: 'break-word',
}}
>
{recent.pathname.split('/').pop()} - {recent.href}
</Txt>
))
.reverse()}
/>
</ScrollableFlex>
</Flex> </Flex>
)} {recentImages.length > 0 && (
<Flex flexDirection="column" height="78.6%">
<Txt fontSize={18}>Recent</Txt>
<ScrollableFlex flexDirection="column">
<Card
p="10px 15px"
rows={recentImages
.map((recent) => (
<Txt
key={recent.href}
onClick={() => {
setImageURL(recent.href);
}}
style={{
overflowWrap: 'break-word',
}}
>
{recent.pathname.split('/').pop()} - {recent.href}
</Txt>
))
.reverse()}
/>
</ScrollableFlex>
</Flex>
)}
</Flex>
</Modal> </Modal>
); );
}; };
@ -203,7 +215,12 @@ interface Flow {
const FlowSelector = styled( const FlowSelector = styled(
({ flow, ...props }: { flow: Flow; props?: ButtonProps }) => { ({ flow, ...props }: { flow: Flow; props?: ButtonProps }) => {
return ( return (
<StepButton plain onClick={flow.onClick} icon={flow.icon} {...props}> <StepButton
plain
onClick={(evt) => flow.onClick(evt)}
icon={flow.icon}
{...props}
>
{flow.label} {flow.label}
</StepButton> </StepButton>
); );
@ -225,25 +242,33 @@ const FlowSelector = styled(
export type Source = export type Source =
| typeof sourceDestination.File | typeof sourceDestination.File
| typeof sourceDestination.BlockDevice
| typeof sourceDestination.Http; | typeof sourceDestination.Http;
export interface SourceOptions { export interface SourceMetadata extends sourceDestination.Metadata {
imagePath: string; hasMBR?: boolean;
partitions?: MBRPartition[] | GPTPartition[];
path: string;
displayName: string;
description: string;
SourceType: Source; SourceType: Source;
drive?: DrivelistDrive;
extension?: string;
archiveExtension?: string;
} }
interface SourceSelectorProps { interface SourceSelectorProps {
flashing: boolean; flashing: boolean;
afterSelected: (options: SourceOptions) => void;
} }
interface SourceSelectorState { interface SourceSelectorState {
hasImage: boolean; hasImage: boolean;
imageName: string; imageName?: string;
imageSize: number; imageSize?: number;
warning: { message: string; title: string | null } | null; warning: { message: string; title: string | null } | null;
showImageDetails: boolean; showImageDetails: boolean;
showURLSelector: boolean; showURLSelector: boolean;
showDriveSelector: boolean;
} }
export class SourceSelector extends React.Component< export class SourceSelector extends React.Component<
@ -251,7 +276,6 @@ export class SourceSelector extends React.Component<
SourceSelectorState SourceSelectorState
> { > {
private unsubscribe: (() => void) | undefined; private unsubscribe: (() => void) | undefined;
private afterSelected: SourceSelectorProps['afterSelected'];
constructor(props: SourceSelectorProps) { constructor(props: SourceSelectorProps) {
super(props); super(props);
@ -260,15 +284,8 @@ export class SourceSelector extends React.Component<
warning: null, warning: null,
showImageDetails: false, showImageDetails: false,
showURLSelector: false, showURLSelector: false,
showDriveSelector: false,
}; };
this.openImageSelector = this.openImageSelector.bind(this);
this.openURLSelector = this.openURLSelector.bind(this);
this.reselectImage = this.reselectImage.bind(this);
this.onSelectImage = this.onSelectImage.bind(this);
this.onDrop = this.onDrop.bind(this);
this.showSelectedImageDetails = this.showSelectedImageDetails.bind(this);
this.afterSelected = props.afterSelected.bind(this);
} }
public componentDidMount() { public componentDidMount() {
@ -285,15 +302,28 @@ export class SourceSelector extends React.Component<
} }
private async onSelectImage(_event: IpcRendererEvent, imagePath: string) { private async onSelectImage(_event: IpcRendererEvent, imagePath: string) {
const isURL = await this.selectSource(
imagePath.startsWith('https://') || imagePath.startsWith('http://');
await this.selectImageByPath({
imagePath, imagePath,
SourceType: isURL ? sourceDestination.Http : sourceDestination.File, isURL(imagePath) ? sourceDestination.Http : sourceDestination.File,
}).promise; ).promise;
} }
private reselectImage() { private async createSource(selected: string, SourceType: Source) {
try {
selected = await replaceWindowsNetworkDriveLetter(selected);
} catch (error) {
analytics.logException(error);
}
if (SourceType === sourceDestination.File) {
return new sourceDestination.File({
path: selected,
});
}
return new sourceDestination.Http({ url: selected });
}
private reselectSource() {
analytics.logEvent('Reselect image', { analytics.logEvent('Reselect image', {
previousImage: selectionState.getImage(), previousImage: selectionState.getImage(),
}); });
@ -301,144 +331,142 @@ export class SourceSelector extends React.Component<
selectionState.deselectImage(); selectionState.deselectImage();
} }
private selectImage( private selectSource(
image: sourceDestination.Metadata & { selected: string | DrivelistDrive,
path: string; SourceType: Source,
extension: string; ): { promise: Promise<void>; cancel: () => void } {
hasMBR: boolean;
},
) {
try {
let message = null;
let title = null;
if (supportedFormats.looksLikeWindowsImage(image.path)) {
analytics.logEvent('Possibly Windows image', { image });
message = messages.warning.looksLikeWindowsImage();
title = 'Possible Windows image detected';
} else if (!image.hasMBR) {
analytics.logEvent('Missing partition table', { image });
title = 'Missing partition table';
message = messages.warning.missingPartitionTable();
}
if (message) {
this.setState({
warning: {
message,
title,
},
});
}
selectionState.selectImage(image);
analytics.logEvent('Select image', {
// An easy way so we can quickly identify if we're making use of
// certain features without printing pages of text to DevTools.
image: {
...image,
logo: Boolean(image.logo),
blockMap: Boolean(image.blockMap),
},
});
} catch (error) {
exceptionReporter.report(error);
}
}
private selectImageByPath({
imagePath,
SourceType,
}: SourceOptions): { promise: Promise<void>; cancel: () => void } {
let cancelled = false; let cancelled = false;
return { return {
cancel: () => { cancel: () => {
cancelled = true; cancelled = true;
}, },
promise: (async () => { promise: (async () => {
try { const sourcePath = isString(selected) ? selected : selected.device;
imagePath = await replaceWindowsNetworkDriveLetter(imagePath);
} catch (error) {
analytics.logException(error);
}
if (cancelled) {
return;
}
let source; let source;
if (SourceType === sourceDestination.File) { let metadata: SourceMetadata | undefined;
source = new sourceDestination.File({ if (isString(selected)) {
path: imagePath, if (SourceType === sourceDestination.Http && !isURL(selected)) {
}); this.handleError(
} else { 'Unsupported protocol',
if ( selected,
!imagePath.startsWith('https://') && messages.error.unsupportedProtocol(),
!imagePath.startsWith('http://') );
) {
const invalidImageError = errors.createUserError({
title: 'Unsupported protocol',
description: messages.error.unsupportedProtocol(),
});
osDialog.showError(invalidImageError);
analytics.logEvent('Unsupported protocol', { path: imagePath });
return; return;
} }
source = new sourceDestination.Http({ url: imagePath });
if (supportedFormats.looksLikeWindowsImage(selected)) {
analytics.logEvent('Possibly Windows image', { image: selected });
this.setState({
warning: {
message: messages.warning.looksLikeWindowsImage(),
title: 'Possible Windows image detected',
},
});
}
source = await this.createSource(selected, SourceType);
if (cancelled) {
return;
}
try {
const innerSource = await source.getInnerSource();
if (cancelled) {
return;
}
metadata = await this.getMetadata(innerSource, selected);
if (cancelled) {
return;
}
metadata.SourceType = SourceType;
if (!metadata.hasMBR) {
analytics.logEvent('Missing partition table', { metadata });
this.setState({
warning: {
message: messages.warning.missingPartitionTable(),
title: 'Missing partition table',
},
});
}
} catch (error) {
this.handleError(
'Error opening source',
sourcePath,
messages.error.openSource(sourcePath, error.message),
error,
);
} finally {
try {
await source.close();
} catch (error) {
// Noop
}
}
} else {
metadata = {
path: selected.device,
displayName: selected.displayName,
description: selected.displayName,
size: selected.size as SourceMetadata['size'],
SourceType: sourceDestination.BlockDevice,
drive: selected,
};
} }
try { if (metadata !== undefined) {
const innerSource = await source.getInnerSource(); selectionState.selectSource(metadata);
if (cancelled) { analytics.logEvent('Select image', {
return; // An easy way so we can quickly identify if we're making use of
} // certain features without printing pages of text to DevTools.
const metadata = (await innerSource.getMetadata()) as sourceDestination.Metadata & { image: {
hasMBR: boolean; ...metadata,
partitions: MBRPartition[] | GPTPartition[]; logo: Boolean(metadata.logo),
path: string; blockMap: Boolean(metadata.blockMap),
extension: string; },
};
if (cancelled) {
return;
}
const partitionTable = await innerSource.getPartitionTable();
if (cancelled) {
return;
}
if (partitionTable) {
metadata.hasMBR = true;
metadata.partitions = partitionTable.partitions;
} else {
metadata.hasMBR = false;
}
metadata.path = imagePath;
metadata.extension = path.extname(imagePath).slice(1);
this.selectImage(metadata);
this.afterSelected({
imagePath,
SourceType,
}); });
} catch (error) {
const imageError = errors.createUserError({
title: 'Error opening image',
description: messages.error.openImage(
path.basename(imagePath),
error.message,
),
});
osDialog.showError(imageError);
analytics.logException(error);
} finally {
try {
await source.close();
} catch (error) {
// Noop
}
} }
})(), })(),
}; };
} }
private handleError(
title: string,
sourcePath: string,
description: string,
error?: Error,
) {
const imageError = errors.createUserError({
title,
description,
});
osDialog.showError(imageError);
if (error) {
analytics.logException(error);
return;
}
analytics.logEvent(title, { path: sourcePath });
}
private async getMetadata(
source: sourceDestination.SourceDestination,
selected: string | DrivelistDrive,
) {
const metadata = (await source.getMetadata()) as SourceMetadata;
const partitionTable = await source.getPartitionTable();
if (partitionTable) {
metadata.hasMBR = true;
metadata.partitions = partitionTable.partitions;
} else {
metadata.hasMBR = false;
}
if (isString(selected)) {
metadata.extension = path.extname(selected).slice(1);
metadata.path = selected;
}
return metadata;
}
private async openImageSelector() { private async openImageSelector() {
analytics.logEvent('Open image selector'); analytics.logEvent('Open image selector');
@ -450,10 +478,7 @@ export class SourceSelector extends React.Component<
analytics.logEvent('Image selector closed'); analytics.logEvent('Image selector closed');
return; return;
} }
await this.selectImageByPath({ await this.selectSource(imagePath, sourceDestination.File).promise;
imagePath,
SourceType: sourceDestination.File,
}).promise;
} catch (error) { } catch (error) {
exceptionReporter.report(error); exceptionReporter.report(error);
} }
@ -462,10 +487,7 @@ export class SourceSelector extends React.Component<
private async onDrop(event: React.DragEvent<HTMLDivElement>) { private async onDrop(event: React.DragEvent<HTMLDivElement>) {
const [file] = event.dataTransfer.files; const [file] = event.dataTransfer.files;
if (file) { if (file) {
await this.selectImageByPath({ await this.selectSource(file.path, sourceDestination.File).promise;
imagePath: file.path,
SourceType: sourceDestination.File,
}).promise;
} }
} }
@ -477,6 +499,14 @@ export class SourceSelector extends React.Component<
}); });
} }
private openDriveSelector() {
analytics.logEvent('Open drive selector');
this.setState({
showDriveSelector: true,
});
}
private onDragOver(event: React.DragEvent<HTMLDivElement>) { private onDragOver(event: React.DragEvent<HTMLDivElement>) {
// Needed to get onDrop events on div elements // Needed to get onDrop events on div elements
event.preventDefault(); event.preventDefault();
@ -500,27 +530,35 @@ export class SourceSelector extends React.Component<
// TODO add a visual change when dragging a file over the selector // TODO add a visual change when dragging a file over the selector
public render() { public render() {
const { flashing } = this.props; const { flashing } = this.props;
const { showImageDetails, showURLSelector } = this.state; const { showImageDetails, showURLSelector, showDriveSelector } = this.state;
const selectionImage = selectionState.getImage();
let image: SourceMetadata | DrivelistDrive =
selectionImage !== undefined ? selectionImage : ({} as SourceMetadata);
const hasImage = selectionState.hasImage(); image = image.drive ?? image;
const imagePath = selectionState.getImagePath();
const imageBasename = hasImage ? path.basename(imagePath) : '';
const imageName = selectionState.getImageName();
const imageSize = selectionState.getImageSize();
const imageLogo = selectionState.getImageLogo();
let cancelURLSelection = () => { let cancelURLSelection = () => {
// noop // noop
}; };
image.name = image.description || image.name;
const imagePath = image.path || image.displayName || '';
const imageBasename = path.basename(imagePath);
const imageName = image.name || '';
const imageSize = image.size;
const imageLogo = image.logo || '';
return ( return (
<> <>
<Flex <Flex
flexDirection="column" flexDirection="column"
alignItems="center" alignItems="center"
onDrop={this.onDrop} onDrop={(evt: React.DragEvent<HTMLDivElement>) => this.onDrop(evt)}
onDragEnter={this.onDragEnter} onDragEnter={(evt: React.DragEvent<HTMLDivElement>) =>
onDragOver={this.onDragOver} this.onDragEnter(evt)
}
onDragOver={(evt: React.DragEvent<HTMLDivElement>) =>
this.onDragOver(evt)
}
> >
<SVGIcon <SVGIcon
contents={imageLogo} contents={imageLogo}
@ -530,28 +568,34 @@ export class SourceSelector extends React.Component<
}} }}
/> />
{hasImage ? ( {selectionImage !== undefined ? (
<> <>
<StepNameButton <StepNameButton
plain plain
onClick={this.showSelectedImageDetails} onClick={() => this.showSelectedImageDetails()}
tooltip={imageName || imageBasename} tooltip={imageName || imageBasename}
> >
{middleEllipsis(imageName || imageBasename, 20)} {middleEllipsis(imageName || imageBasename, 20)}
</StepNameButton> </StepNameButton>
{!flashing && ( {!flashing && (
<ChangeButton plain mb={14} onClick={this.reselectImage}> <ChangeButton
plain
mb={14}
onClick={() => this.reselectSource()}
>
Remove Remove
</ChangeButton> </ChangeButton>
)} )}
<DetailsText>{shared.bytesToClosestUnit(imageSize)}</DetailsText> {!_.isNil(imageSize) && (
<DetailsText>{prettyBytes(imageSize)}</DetailsText>
)}
</> </>
) : ( ) : (
<> <>
<FlowSelector <FlowSelector
key="Flash from file" key="Flash from file"
flow={{ flow={{
onClick: this.openImageSelector, onClick: () => this.openImageSelector(),
label: 'Flash from file', label: 'Flash from file',
icon: <FileSvg height="1em" fill="currentColor" />, icon: <FileSvg height="1em" fill="currentColor" />,
}} }}
@ -559,11 +603,19 @@ export class SourceSelector extends React.Component<
<FlowSelector <FlowSelector
key="Flash from URL" key="Flash from URL"
flow={{ flow={{
onClick: this.openURLSelector, onClick: () => this.openURLSelector(),
label: 'Flash from URL', label: 'Flash from URL',
icon: <LinkSvg height="1em" fill="currentColor" />, icon: <LinkSvg height="1em" fill="currentColor" />,
}} }}
/> />
<FlowSelector
key="Clone drive"
flow={{
onClick: () => this.openDriveSelector(),
label: 'Clone drive',
icon: <CopySvg height="1em" fill="currentColor" />,
}}
/>
</> </>
)} )}
</Flex> </Flex>
@ -579,7 +631,7 @@ export class SourceSelector extends React.Component<
action="Continue" action="Continue"
cancel={() => { cancel={() => {
this.setState({ warning: null }); this.setState({ warning: null });
this.reselectImage(); this.reselectSource();
}} }}
done={() => { done={() => {
this.setState({ warning: null }); this.setState({ warning: null });
@ -625,13 +677,10 @@ export class SourceSelector extends React.Component<
analytics.logEvent('URL selector closed'); analytics.logEvent('URL selector closed');
} else { } else {
let promise; let promise;
({ ({ promise, cancel: cancelURLSelection } = this.selectSource(
promise, imageURL,
cancel: cancelURLSelection, sourceDestination.Http,
} = this.selectImageByPath({ ));
imagePath: imageURL,
SourceType: sourceDestination.Http,
}));
await promise; await promise;
} }
this.setState({ this.setState({
@ -640,6 +689,30 @@ export class SourceSelector extends React.Component<
}} }}
/> />
)} )}
{showDriveSelector && (
<DriveSelector
multipleSelection={false}
titleLabel="Select source"
emptyListLabel="Plug a source"
cancel={() => {
this.setState({
showDriveSelector: false,
});
}}
done={async (drives: DrivelistDrive[]) => {
if (drives.length) {
await this.selectSource(
drives[0],
sourceDestination.BlockDevice,
);
}
this.setState({
showDriveSelector: false,
});
}}
/>
)}
</> </>
); );
} }

View File

@ -37,8 +37,9 @@ function tryParseSVGContents(contents?: string): string | undefined {
} }
interface SVGIconProps { interface SVGIconProps {
// List of embedded SVG contents to be tried in succession if any fails // Optional string representing the SVG contents to be tried
contents: string; contents?: string;
// Fallback SVG element to show if `contents` is invalid/undefined
fallback: React.FunctionComponent<React.SVGProps<HTMLOrSVGElement>>; fallback: React.FunctionComponent<React.SVGProps<HTMLOrSVGElement>>;
// SVG image width unit // SVG image width unit
width?: string; width?: string;

View File

@ -15,15 +15,15 @@
*/ */
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg'; import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg';
import { Drive as DrivelistDrive } from 'drivelist';
import * as React from 'react'; import * as React from 'react';
import { Flex, FlexProps, Txt } from 'rendition'; import { Flex, FlexProps, Txt } from 'rendition';
import { import {
getDriveImageCompatibilityStatuses, getDriveImageCompatibilityStatuses,
Image, DriveStatus,
} from '../../../../shared/drive-constraints'; } from '../../../../shared/drive-constraints';
import { bytesToClosestUnit } from '../../../../shared/units'; import { compatibility, warning } from '../../../../shared/messages';
import * as prettyBytes from 'pretty-bytes';
import { getSelectedDrives } from '../../models/selection-state'; import { getSelectedDrives } from '../../models/selection-state';
import { import {
ChangeButton, ChangeButton,
@ -41,40 +41,54 @@ interface TargetSelectorProps {
flashing: boolean; flashing: boolean;
show: boolean; show: boolean;
tooltip: string; tooltip: string;
image: Image;
} }
function DriveCompatibilityWarning({ function getDriveWarning(status: DriveStatus) {
drive, switch (status.message) {
image, case compatibility.containsImage():
return warning.sourceDrive();
case compatibility.largeDrive():
return warning.largeDriveSize();
case compatibility.system():
return warning.systemDrive();
default:
return '';
}
}
const DriveCompatibilityWarning = ({
warnings,
...props ...props
}: { }: {
drive: DrivelistDrive; warnings: string[];
image: Image; } & FlexProps) => {
} & FlexProps) { const systemDrive = warnings.find(
const compatibilityWarnings = getDriveImageCompatibilityStatuses( (message) => message === warning.systemDrive(),
drive,
image,
); );
if (compatibilityWarnings.length === 0) {
return null;
}
const messages = compatibilityWarnings.map((warning) => warning.message);
return ( return (
<Flex tooltip={messages.join(', ')} {...props}> <Flex tooltip={warnings.join(', ')} {...props}>
<ExclamationTriangleSvg fill="currentColor" height="1em" /> <ExclamationTriangleSvg
fill={systemDrive ? '#fca321' : '#8f9297'}
height="1em"
/>
</Flex> </Flex>
); );
} };
export function TargetSelector(props: TargetSelectorProps) { export function TargetSelectorButton(props: TargetSelectorProps) {
const targets = getSelectedDrives(); const targets = getSelectedDrives();
if (targets.length === 1) { if (targets.length === 1) {
const target = targets[0]; const target = targets[0];
const warnings = getDriveImageCompatibilityStatuses(target).map(
getDriveWarning,
);
return ( return (
<> <>
<StepNameButton plain tooltip={props.tooltip}> <StepNameButton plain tooltip={props.tooltip}>
{warnings.length > 0 && (
<DriveCompatibilityWarning warnings={warnings} mr={2} />
)}
{middleEllipsis(target.description, 20)} {middleEllipsis(target.description, 20)}
</StepNameButton> </StepNameButton>
{!props.flashing && ( {!props.flashing && (
@ -82,14 +96,9 @@ export function TargetSelector(props: TargetSelectorProps) {
Change Change
</ChangeButton> </ChangeButton>
)} )}
<DetailsText> {target.size != null && (
<DriveCompatibilityWarning <DetailsText>{prettyBytes(target.size)}</DetailsText>
drive={target} )}
image={props.image}
mr={2}
/>
{bytesToClosestUnit(target.size)}
</DetailsText>
</> </>
); );
} }
@ -97,21 +106,22 @@ export function TargetSelector(props: TargetSelectorProps) {
if (targets.length > 1) { if (targets.length > 1) {
const targetsTemplate = []; const targetsTemplate = [];
for (const target of targets) { for (const target of targets) {
const warnings = getDriveImageCompatibilityStatuses(target).map(
getDriveWarning,
);
targetsTemplate.push( targetsTemplate.push(
<DetailsText <DetailsText
key={target.device} key={target.device}
tooltip={`${target.description} ${ tooltip={`${target.description} ${target.displayName} ${
target.displayName target.size != null ? prettyBytes(target.size) : ''
} ${bytesToClosestUnit(target.size)}`} }`}
px={21} px={21}
> >
<DriveCompatibilityWarning {warnings.length > 0 ? (
drive={target} <DriveCompatibilityWarning warnings={warnings} mr={2} />
image={props.image} ) : null}
mr={2}
/>
<Txt mr={2}>{middleEllipsis(target.description, 14)}</Txt> <Txt mr={2}>{middleEllipsis(target.description, 14)}</Txt>
<Txt>{bytesToClosestUnit(target.size)}</Txt> {target.size != null && <Txt>{prettyBytes(target.size)}</Txt>}
</DetailsText>, </DetailsText>,
); );
} }

View File

@ -16,9 +16,12 @@
import { scanner } from 'etcher-sdk'; import { scanner } from 'etcher-sdk';
import * as React from 'react'; import * as React from 'react';
import { Flex } from 'rendition'; import { Flex, Txt } from 'rendition';
import { TargetSelector } from '../../components/target-selector/target-selector-button';
import { TargetSelectorModal } from '../../components/target-selector/target-selector-modal'; import {
DriveSelector,
DriveSelectorProps,
} from '../drive-selector/drive-selector';
import { import {
isDriveSelected, isDriveSelected,
getImage, getImage,
@ -29,7 +32,10 @@ import {
import * as settings from '../../models/settings'; import * as settings from '../../models/settings';
import { observe } from '../../models/store'; import { observe } from '../../models/store';
import * as analytics from '../../modules/analytics'; import * as analytics from '../../modules/analytics';
import { TargetSelectorButton } from './target-selector-button';
import DriveSvg from '../../../assets/drive.svg'; import DriveSvg from '../../../assets/drive.svg';
import { warning } from '../../../../shared/messages';
export const getDriveListLabel = () => { export const getDriveListLabel = () => {
return getSelectedDrives() return getSelectedDrives()
@ -50,6 +56,23 @@ const getDriveSelectionStateSlice = () => ({
image: getImage(), image: getImage(),
}); });
export const TargetSelectorModal = (
props: Omit<
DriveSelectorProps,
'titleLabel' | 'emptyListLabel' | 'multipleSelection'
>,
) => (
<DriveSelector
multipleSelection={true}
titleLabel="Select target"
emptyListLabel="Plug a target drive"
showWarnings={true}
selectedList={getSelectedDrives()}
updateSelectedList={getSelectedDrives}
{...props}
/>
);
export const selectAllTargets = ( export const selectAllTargets = (
modalTargets: scanner.adapters.DrivelistDrive[], modalTargets: scanner.adapters.DrivelistDrive[],
) => { ) => {
@ -79,20 +102,20 @@ export const selectAllTargets = (
}); });
}; };
interface DriveSelectorProps { interface TargetSelectorProps {
disabled: boolean; disabled: boolean;
hasDrive: boolean; hasDrive: boolean;
flashing: boolean; flashing: boolean;
} }
export const DriveSelector = ({ export const TargetSelector = ({
disabled, disabled,
hasDrive, hasDrive,
flashing, flashing,
}: DriveSelectorProps) => { }: TargetSelectorProps) => {
// TODO: inject these from redux-connector // TODO: inject these from redux-connector
const [ const [
{ showDrivesButton, driveListLabel, targets, image }, { showDrivesButton, driveListLabel, targets },
setStateSlice, setStateSlice,
] = React.useState(getDriveSelectionStateSlice()); ] = React.useState(getDriveSelectionStateSlice());
const [showTargetSelectorModal, setShowTargetSelectorModal] = React.useState( const [showTargetSelectorModal, setShowTargetSelectorModal] = React.useState(
@ -105,6 +128,7 @@ export const DriveSelector = ({
}); });
}, []); }, []);
const hasSystemDrives = targets.some((target) => target.isSystem);
return ( return (
<Flex flexDirection="column" alignItems="center"> <Flex flexDirection="column" alignItems="center">
<DriveSvg <DriveSvg
@ -115,7 +139,7 @@ export const DriveSelector = ({
}} }}
/> />
<TargetSelector <TargetSelectorButton
disabled={disabled} disabled={disabled}
show={!hasDrive && showDrivesButton} show={!hasDrive && showDrivesButton}
tooltip={driveListLabel} tooltip={driveListLabel}
@ -128,9 +152,20 @@ export const DriveSelector = ({
}} }}
flashing={flashing} flashing={flashing}
targets={targets} targets={targets}
image={image}
/> />
{hasSystemDrives ? (
<Txt
color="#fca321"
style={{
position: 'absolute',
bottom: '25px',
}}
>
Warning: {warning.systemDrive()}
</Txt>
) : null}
{showTargetSelectorModal && ( {showTargetSelectorModal && (
<TargetSelectorModal <TargetSelectorModal
cancel={() => setShowTargetSelectorModal(false)} cancel={() => setShowTargetSelectorModal(false)}
@ -138,7 +173,7 @@ export const DriveSelector = ({
selectAllTargets(modalTargets); selectAllTargets(modalTargets);
setShowTargetSelectorModal(false); setShowTargetSelectorModal(false);
}} }}
></TargetSelectorModal> />
)} )}
</Flex> </Flex>
); );

View File

@ -19,7 +19,6 @@
src: url("./fonts/SourceSansPro-Regular.ttf") format("truetype"); src: url("./fonts/SourceSansPro-Regular.ttf") format("truetype");
font-weight: 500; font-weight: 500;
font-style: normal; font-style: normal;
font-display: block;
} }
@font-face { @font-face {
@ -27,7 +26,6 @@
src: url("./fonts/SourceSansPro-SemiBold.ttf") format("truetype"); src: url("./fonts/SourceSansPro-SemiBold.ttf") format("truetype");
font-weight: 600; font-weight: 600;
font-style: normal; font-style: normal;
font-display: block;
} }
html, html,
@ -53,10 +51,16 @@ body {
a:focus, a:focus,
input:focus, input:focus,
button:focus, button:focus,
[tabindex]:focus { [tabindex]:focus,
input[type="checkbox"] + div {
outline: none !important; outline: none !important;
box-shadow: none !important;
} }
.disabled { .disabled {
opacity: 0.4; opacity: 0.4;
} }
#rendition-tooltip-root > div {
font-family: "SourceSansPro", sans-serif;
}

View File

@ -14,21 +14,20 @@
* limitations under the License. * limitations under the License.
*/ */
import * as _ from 'lodash'; import { DrivelistDrive } from '../../../shared/drive-constraints';
import { Actions, store } from './store'; import { Actions, store } from './store';
export function hasAvailableDrives() { export function hasAvailableDrives() {
return !_.isEmpty(getDrives()); return getDrives().length > 0;
} }
export function setDrives(drives: any[]) { export function setDrives(drives: any[]) {
store.dispatch({ store.dispatch({
type: Actions.SET_AVAILABLE_DRIVES, type: Actions.SET_AVAILABLE_TARGETS,
data: drives, data: drives,
}); });
} }
export function getDrives(): any[] { export function getDrives(): DrivelistDrive[] {
return store.getState().toJS().availableDrives; return store.getState().toJS().availableDrives;
} }

View File

@ -14,11 +14,13 @@
* limitations under the License. * limitations under the License.
*/ */
import { Drive as DrivelistDrive } from 'drivelist';
import * as _ from 'lodash'; import * as _ from 'lodash';
import { AnimationFunction, Color, RGBLed } from 'sys-class-rgb-led'; 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 * as settings from './settings';
import { DEFAULT_STATE, observe } from './store'; import { DEFAULT_STATE, observe } from './store';

View File

@ -1,3 +1,4 @@
import { DrivelistDrive } from '../../../shared/drive-constraints';
/* /*
* Copyright 2016 balena.io * Copyright 2016 balena.io
* *
@ -14,7 +15,7 @@
* limitations under the License. * limitations under the License.
*/ */
import * as _ from 'lodash'; import { SourceMetadata } from '../components/source-selector/source-selector';
import * as availableDrives from './available-drives'; import * as availableDrives from './available-drives';
import { Actions, store } from './store'; import { Actions, store } from './store';
@ -24,7 +25,7 @@ import { Actions, store } from './store';
*/ */
export function selectDrive(driveDevice: string) { export function selectDrive(driveDevice: string) {
store.dispatch({ store.dispatch({
type: Actions.SELECT_DRIVE, type: Actions.SELECT_TARGET,
data: driveDevice, data: driveDevice,
}); });
} }
@ -40,10 +41,10 @@ export function toggleDrive(driveDevice: string) {
} }
} }
export function selectImage(image: any) { export function selectSource(source: SourceMetadata) {
store.dispatch({ store.dispatch({
type: Actions.SELECT_IMAGE, type: Actions.SELECT_SOURCE,
data: image, data: source,
}); });
} }
@ -57,50 +58,38 @@ export function getSelectedDevices(): string[] {
/** /**
* @summary Get all selected drive objects * @summary Get all selected drive objects
*/ */
export function getSelectedDrives(): any[] { export function getSelectedDrives(): DrivelistDrive[] {
const drives = availableDrives.getDrives(); const selectedDevices = getSelectedDevices();
return _.map(getSelectedDevices(), (device) => { return availableDrives
return _.find(drives, { device }); .getDrives()
}); .filter((drive) => selectedDevices.includes(drive.device));
} }
/** /**
* @summary Get the selected image * @summary Get the selected image
*/ */
export function getImage() { export function getImage(): SourceMetadata | undefined {
return _.get(store.getState().toJS(), ['selection', 'image']); return store.getState().toJS().selection.image;
} }
export function getImagePath(): string { export function getImagePath() {
return _.get(store.getState().toJS(), ['selection', 'image', 'path']); return getImage()?.path;
} }
export function getImageSize(): number { export function getImageSize() {
return _.get(store.getState().toJS(), ['selection', 'image', 'size']); return getImage()?.size;
} }
export function getImageUrl(): string { export function getImageName() {
return _.get(store.getState().toJS(), ['selection', 'image', 'url']); return getImage()?.name;
} }
export function getImageName(): string { export function getImageLogo() {
return _.get(store.getState().toJS(), ['selection', 'image', 'name']); return getImage()?.logo;
} }
export function getImageLogo(): string { export function getImageSupportUrl() {
return _.get(store.getState().toJS(), ['selection', 'image', 'logo']); return getImage()?.supportUrl;
}
export function getImageSupportUrl(): string {
return _.get(store.getState().toJS(), ['selection', 'image', 'supportUrl']);
}
export function getImageRecommendedDriveSize(): number {
return _.get(store.getState().toJS(), [
'selection',
'image',
'recommendedDriveSize',
]);
} }
/** /**
@ -114,7 +103,7 @@ export function hasDrive(): boolean {
* @summary Check if there is a selected image * @summary Check if there is a selected image
*/ */
export function hasImage(): boolean { export function hasImage(): boolean {
return Boolean(getImage()); return getImage() !== undefined;
} }
/** /**
@ -122,20 +111,20 @@ export function hasImage(): boolean {
*/ */
export function deselectDrive(driveDevice: string) { export function deselectDrive(driveDevice: string) {
store.dispatch({ store.dispatch({
type: Actions.DESELECT_DRIVE, type: Actions.DESELECT_TARGET,
data: driveDevice, data: driveDevice,
}); });
} }
export function deselectImage() { export function deselectImage() {
store.dispatch({ store.dispatch({
type: Actions.DESELECT_IMAGE, type: Actions.DESELECT_SOURCE,
data: {}, data: {},
}); });
} }
export function deselectAllDrives() { export function deselectAllDrives() {
_.each(getSelectedDevices(), deselectDrive); getSelectedDevices().forEach(deselectDrive);
} }
/** /**
@ -155,5 +144,5 @@ export function isDriveSelected(driveDevice: string) {
} }
const selectedDriveDevices = getSelectedDevices(); const selectedDriveDevices = getSelectedDevices();
return _.includes(selectedDriveDevices, driveDevice); return selectedDriveDevices.includes(driveDevice);
} }

View File

@ -80,15 +80,15 @@ export const DEFAULT_STATE = Immutable.fromJS({
export enum Actions { export enum Actions {
SET_DEVICE_PATHS, SET_DEVICE_PATHS,
SET_FAILED_DEVICE_PATHS, SET_FAILED_DEVICE_PATHS,
SET_AVAILABLE_DRIVES, SET_AVAILABLE_TARGETS,
SET_FLASH_STATE, SET_FLASH_STATE,
RESET_FLASH_STATE, RESET_FLASH_STATE,
SET_FLASHING_FLAG, SET_FLASHING_FLAG,
UNSET_FLASHING_FLAG, UNSET_FLASHING_FLAG,
SELECT_DRIVE, SELECT_TARGET,
SELECT_IMAGE, SELECT_SOURCE,
DESELECT_DRIVE, DESELECT_TARGET,
DESELECT_IMAGE, DESELECT_SOURCE,
SET_APPLICATION_SESSION_UUID, SET_APPLICATION_SESSION_UUID,
SET_FLASHING_WORKFLOW_UUID, SET_FLASHING_WORKFLOW_UUID,
} }
@ -116,7 +116,7 @@ function storeReducer(
action: Action, action: Action,
): typeof DEFAULT_STATE { ): typeof DEFAULT_STATE {
switch (action.type) { switch (action.type) {
case Actions.SET_AVAILABLE_DRIVES: { case Actions.SET_AVAILABLE_TARGETS: {
// Type: action.data : Array<DriveObject> // Type: action.data : Array<DriveObject>
if (!action.data) { if (!action.data) {
@ -158,7 +158,7 @@ function storeReducer(
) { ) {
// Deselect this drive gone from availableDrives // Deselect this drive gone from availableDrives
return storeReducer(accState, { return storeReducer(accState, {
type: Actions.DESELECT_DRIVE, type: Actions.DESELECT_TARGET,
data: device, data: device,
}); });
} }
@ -206,14 +206,14 @@ function storeReducer(
) { ) {
// Auto-select this drive // Auto-select this drive
return storeReducer(accState, { return storeReducer(accState, {
type: Actions.SELECT_DRIVE, type: Actions.SELECT_TARGET,
data: drive.device, data: drive.device,
}); });
} }
// Deselect this drive in case it still is selected // Deselect this drive in case it still is selected
return storeReducer(accState, { return storeReducer(accState, {
type: Actions.DESELECT_DRIVE, type: Actions.DESELECT_TARGET,
data: drive.device, data: drive.device,
}); });
}, },
@ -341,7 +341,7 @@ function storeReducer(
.set('flashState', DEFAULT_STATE.get('flashState')); .set('flashState', DEFAULT_STATE.get('flashState'));
} }
case Actions.SELECT_DRIVE: { case Actions.SELECT_TARGET: {
// Type: action.data : String // Type: action.data : String
const device = action.data; const device = action.data;
@ -391,10 +391,12 @@ function storeReducer(
// with image-stream / supported-formats, and have *one* // with image-stream / supported-formats, and have *one*
// place where all the image extension / format handling // place where all the image extension / format handling
// takes place, to avoid having to check 2+ locations with different logic // takes place, to avoid having to check 2+ locations with different logic
case Actions.SELECT_IMAGE: { case Actions.SELECT_SOURCE: {
// Type: action.data : ImageObject // Type: action.data : ImageObject
verifyNoNilFields(action.data, selectImageNoNilFields, 'image'); if (!action.data.drive) {
verifyNoNilFields(action.data, selectImageNoNilFields, 'image');
}
if (!_.isString(action.data.path)) { if (!_.isString(action.data.path)) {
throw errors.createError({ throw errors.createError({
@ -456,7 +458,7 @@ function storeReducer(
!constraints.isDriveSizeRecommended(drive, action.data) !constraints.isDriveSizeRecommended(drive, action.data)
) { ) {
return storeReducer(accState, { return storeReducer(accState, {
type: Actions.DESELECT_DRIVE, type: Actions.DESELECT_TARGET,
data: device, data: device,
}); });
} }
@ -467,7 +469,7 @@ function storeReducer(
).setIn(['selection', 'image'], Immutable.fromJS(action.data)); ).setIn(['selection', 'image'], Immutable.fromJS(action.data));
} }
case Actions.DESELECT_DRIVE: { case Actions.DESELECT_TARGET: {
// Type: action.data : String // Type: action.data : String
if (!action.data) { if (!action.data) {
@ -491,7 +493,7 @@ function storeReducer(
); );
} }
case Actions.DESELECT_IMAGE: { case Actions.DESELECT_SOURCE: {
return state.deleteIn(['selection', 'image']); return state.deleteIn(['selection', 'image']);
} }

View File

@ -25,7 +25,7 @@ import * as path from 'path';
import * as packageJSON from '../../../../package.json'; import * as packageJSON from '../../../../package.json';
import * as errors from '../../../shared/errors'; import * as errors from '../../../shared/errors';
import * as permissions from '../../../shared/permissions'; import * as permissions from '../../../shared/permissions';
import { SourceOptions } from '../components/source-selector/source-selector'; import { SourceMetadata } from '../components/source-selector/source-selector';
import * as flashState from '../models/flash-state'; import * as flashState from '../models/flash-state';
import * as selectionState from '../models/selection-state'; import * as selectionState from '../models/selection-state';
import * as settings from '../models/settings'; import * as settings from '../models/settings';
@ -134,15 +134,11 @@ interface FlashResults {
cancelled?: boolean; cancelled?: boolean;
} }
/**
* @summary Perform write operation
*/
async function performWrite( async function performWrite(
image: string, image: SourceMetadata,
drives: DrivelistDrive[], drives: DrivelistDrive[],
onProgress: sdk.multiWrite.OnProgressFunction, onProgress: sdk.multiWrite.OnProgressFunction,
source: SourceOptions, ): Promise<{ cancelled?: boolean }> {
): Promise<FlashResults> {
let cancelled = false; let cancelled = false;
ipc.serve(); ipc.serve();
const { const {
@ -196,10 +192,9 @@ async function performWrite(
ipc.server.on('ready', (_data, socket) => { ipc.server.on('ready', (_data, socket) => {
ipc.server.emit(socket, 'write', { ipc.server.emit(socket, 'write', {
imagePath: image, image,
destinations: drives, destinations: drives,
source, SourceType: image.SourceType.name,
SourceType: source.SourceType.name,
validateWriteOnSuccess, validateWriteOnSuccess,
autoBlockmapping, autoBlockmapping,
unmountOnSuccess, unmountOnSuccess,
@ -258,9 +253,8 @@ async function performWrite(
* @summary Flash an image to drives * @summary Flash an image to drives
*/ */
export async function flash( export async function flash(
image: string, image: SourceMetadata,
drives: DrivelistDrive[], drives: DrivelistDrive[],
source: SourceOptions,
// This function is a parameter so it can be mocked in tests // This function is a parameter so it can be mocked in tests
write = performWrite, write = performWrite,
): Promise<void> { ): Promise<void> {
@ -287,12 +281,7 @@ export async function flash(
analytics.logEvent('Flash', analyticsData); analytics.logEvent('Flash', analyticsData);
try { try {
const result = await write( const result = await write(image, drives, flashState.setProgressState);
image,
drives,
flashState.setProgressState,
source,
);
flashState.unsetFlashingFlag(result); flashState.unsetFlashingFlag(result);
} catch (error) { } catch (error) {
flashState.unsetFlashingFlag({ cancelled: false, errorCode: error.code }); flashState.unsetFlashingFlag({ cancelled: false, errorCode: error.code });

View File

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { bytesToClosestUnit } from '../../../shared/units'; import * as prettyBytes from 'pretty-bytes';
export interface FlashState { export interface FlashState {
active: number; active: number;
@ -51,7 +51,7 @@ export function fromFlashState({
} else { } else {
return { return {
status: 'Flashing...', status: 'Flashing...',
position: `${bytesToClosestUnit(position)}`, position: `${position ? prettyBytes(position) : ''}`,
}; };
} }
} else if (type === 'verifying') { } else if (type === 'verifying') {

View File

@ -18,13 +18,11 @@ import CircleSvg from '@fortawesome/fontawesome-free/svgs/solid/circle.svg';
import * as _ from 'lodash'; import * as _ from 'lodash';
import * as path from 'path'; import * as path from 'path';
import * as React from 'react'; 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 constraints from '../../../../shared/drive-constraints';
import * as messages from '../../../../shared/messages'; import * as messages from '../../../../shared/messages';
import { ProgressButton } from '../../components/progress-button/progress-button'; import { ProgressButton } from '../../components/progress-button/progress-button';
import { SourceOptions } from '../../components/source-selector/source-selector';
import { TargetSelectorModal } from '../../components/target-selector/target-selector-modal';
import * as availableDrives from '../../models/available-drives'; import * as availableDrives from '../../models/available-drives';
import * as flashState from '../../models/flash-state'; import * as flashState from '../../models/flash-state';
import * as selection from '../../models/selection-state'; import * as selection from '../../models/selection-state';
@ -32,30 +30,17 @@ import * as analytics from '../../modules/analytics';
import { scanner as driveScanner } from '../../modules/drive-scanner'; import { scanner as driveScanner } from '../../modules/drive-scanner';
import * as imageWriter from '../../modules/image-writer'; import * as imageWriter from '../../modules/image-writer';
import * as notification from '../../os/notification'; import * as notification from '../../os/notification';
import { selectAllTargets } from './DriveSelector'; import {
selectAllTargets,
TargetSelectorModal,
} from '../../components/target-selector/target-selector';
import FlashSvg from '../../../assets/flash.svg'; import FlashSvg from '../../../assets/flash.svg';
import DriveStatusWarningModal from '../../components/drive-status-warning-modal/drive-status-warning-modal';
const COMPLETED_PERCENTAGE = 100; const COMPLETED_PERCENTAGE = 100;
const SPEED_PRECISION = 2; 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) => { const getErrorMessageFromCode = (errorCode: string) => {
// TODO: All these error codes to messages translations // TODO: All these error codes to messages translations
// should go away if the writer emitted user friendly // should go away if the writer emitted user friendly
@ -77,12 +62,11 @@ const getErrorMessageFromCode = (errorCode: string) => {
async function flashImageToDrive( async function flashImageToDrive(
isFlashing: boolean, isFlashing: boolean,
goToSuccess: () => void, goToSuccess: () => void,
sourceOptions: SourceOptions,
): Promise<string> { ): Promise<string> {
const devices = selection.getSelectedDevices(); const devices = selection.getSelectedDevices();
const image: any = selection.getImage(); const image: any = selection.getImage();
const drives = _.filter(availableDrives.getDrives(), (drive: any) => { const drives = availableDrives.getDrives().filter((drive: any) => {
return _.includes(devices, drive.device); return devices.includes(drive.device);
}); });
if (drives.length === 0 || isFlashing) { if (drives.length === 0 || isFlashing) {
@ -96,7 +80,7 @@ async function flashImageToDrive(
const iconPath = path.join('media', 'icon.png'); const iconPath = path.join('media', 'icon.png');
const basename = path.basename(image.path); const basename = path.basename(image.path);
try { try {
await imageWriter.flash(image.path, drives, sourceOptions); await imageWriter.flash(image, drives);
if (!flashState.wasLastFlashCancelled()) { if (!flashState.wasLastFlashCancelled()) {
const flashResults: any = flashState.getFlashResults(); const flashResults: any = flashState.getFlashResults();
notification.send( notification.send(
@ -132,7 +116,7 @@ async function flashImageToDrive(
} }
const formatSeconds = (totalSeconds: number) => { const formatSeconds = (totalSeconds: number) => {
if (!totalSeconds && !_.isNumber(totalSeconds)) { if (typeof totalSeconds !== 'number' || !Number.isFinite(totalSeconds)) {
return ''; return '';
} }
const minutes = Math.floor(totalSeconds / 60); const minutes = Math.floor(totalSeconds / 60);
@ -144,7 +128,6 @@ const formatSeconds = (totalSeconds: number) => {
interface FlashStepProps { interface FlashStepProps {
shouldFlashStepBeDisabled: boolean; shouldFlashStepBeDisabled: boolean;
goToSuccess: () => void; goToSuccess: () => void;
source: SourceOptions;
isFlashing: boolean; isFlashing: boolean;
style?: React.CSSProperties; style?: React.CSSProperties;
// TODO: factorize // TODO: factorize
@ -156,10 +139,16 @@ interface FlashStepProps {
eta?: number; eta?: number;
} }
export interface DriveWithWarnings extends constraints.DrivelistDrive {
statuses: constraints.DriveStatus[];
}
interface FlashStepState { interface FlashStepState {
warningMessages: string[]; warningMessage: boolean;
errorMessage: string; errorMessage: string;
showDriveSelectorModal: boolean; showDriveSelectorModal: boolean;
systemDrives: boolean;
drivesWithWarnings: DriveWithWarnings[];
} }
export class FlashStep extends React.PureComponent< export class FlashStep extends React.PureComponent<
@ -169,14 +158,16 @@ export class FlashStep extends React.PureComponent<
constructor(props: FlashStepProps) { constructor(props: FlashStepProps) {
super(props); super(props);
this.state = { this.state = {
warningMessages: [], warningMessage: false,
errorMessage: '', errorMessage: '',
showDriveSelectorModal: false, showDriveSelectorModal: false,
systemDrives: false,
drivesWithWarnings: [],
}; };
} }
private async handleWarningResponse(shouldContinue: boolean) { private async handleWarningResponse(shouldContinue: boolean) {
this.setState({ warningMessages: [] }); this.setState({ warningMessage: false });
if (!shouldContinue) { if (!shouldContinue) {
this.setState({ showDriveSelectorModal: true }); this.setState({ showDriveSelectorModal: true });
return; return;
@ -185,7 +176,6 @@ export class FlashStep extends React.PureComponent<
errorMessage: await flashImageToDrive( errorMessage: await flashImageToDrive(
this.props.isFlashing, this.props.isFlashing,
this.props.goToSuccess, this.props.goToSuccess,
this.props.source,
), ),
}); });
} }
@ -200,35 +190,45 @@ export class FlashStep extends React.PureComponent<
} }
} }
private hasListWarnings(drives: any[], image: any) { private hasListWarnings(drives: any[]) {
if (drives.length === 0 || flashState.isFlashing()) { if (drives.length === 0 || flashState.isFlashing()) {
return; return;
} }
return constraints.hasListDriveImageCompatibilityStatus(drives, image); return drives.filter((drive) => drive.isSystem).length > 0;
} }
private async tryFlash() { private async tryFlash() {
const devices = selection.getSelectedDevices(); const drives = selection.getSelectedDrives().map((drive) => {
const image = selection.getImage(); return {
const drives = _.filter( ...drive,
availableDrives.getDrives(), statuses: constraints.getDriveImageCompatibilityStatuses(drive),
(drive: { device: string }) => { };
return _.includes(devices, drive.device); });
},
);
if (drives.length === 0 || this.props.isFlashing) { if (drives.length === 0 || this.props.isFlashing) {
return; return;
} }
const hasDangerStatus = this.hasListWarnings(drives, image); const hasDangerStatus = drives.some((drive) => drive.statuses.length > 0);
if (hasDangerStatus) { 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; return;
} }
this.setState({ this.setState({
errorMessage: await flashImageToDrive( errorMessage: await flashImageToDrive(
this.props.isFlashing, this.props.isFlashing,
this.props.goToSuccess, this.props.goToSuccess,
this.props.source,
), ),
}); });
} }
@ -256,13 +256,8 @@ export class FlashStep extends React.PureComponent<
position={this.props.position} position={this.props.position}
disabled={this.props.shouldFlashStepBeDisabled} disabled={this.props.shouldFlashStepBeDisabled}
cancel={imageWriter.cancel} cancel={imageWriter.cancel}
warning={this.hasListWarnings( warning={this.hasListWarnings(selection.getSelectedDrives())}
selection.getSelectedDrives(), callback={() => this.tryFlash()}
selection.getImage(),
)}
callback={() => {
this.tryFlash();
}}
/> />
{!_.isNil(this.props.speed) && {!_.isNil(this.props.speed) &&
@ -273,9 +268,7 @@ export class FlashStep extends React.PureComponent<
color="#7e8085" color="#7e8085"
width="100%" width="100%"
> >
{!_.isNil(this.props.speed) && ( <Txt>{this.props.speed.toFixed(SPEED_PRECISION)} MB/s</Txt>
<Txt>{this.props.speed.toFixed(SPEED_PRECISION)} MB/s</Txt>
)}
{!_.isNil(this.props.eta) && ( {!_.isNil(this.props.eta) && (
<Txt>ETA: {formatSeconds(this.props.eta)}</Txt> <Txt>ETA: {formatSeconds(this.props.eta)}</Txt>
)} )}
@ -291,28 +284,17 @@ export class FlashStep extends React.PureComponent<
)} )}
</Flex> </Flex>
{this.state.warningMessages.length > 0 && ( {this.state.warningMessage && (
<Modal <DriveStatusWarningModal
width={400}
titleElement={'Attention'}
cancel={() => this.handleWarningResponse(false)}
done={() => this.handleWarningResponse(true)} done={() => this.handleWarningResponse(true)}
cancelButtonProps={{ cancel={() => this.handleWarningResponse(false)}
children: 'Change', isSystem={this.state.systemDrives}
}} drivesWithWarnings={this.state.drivesWithWarnings}
action={'Continue'} />
primaryButtonProps={{ primary: false, warning: true }}
>
{_.map(this.state.warningMessages, (message, key) => (
<Txt key={key} whitespace="pre-line" mt={2}>
{message}
</Txt>
))}
</Modal>
)} )}
{this.state.errorMessage && ( {this.state.errorMessage && (
<Modal <SmallModal
width={400} width={400}
titleElement={'Attention'} titleElement={'Attention'}
cancel={() => this.handleFlashErrorResponse(false)} cancel={() => this.handleFlashErrorResponse(false)}
@ -320,11 +302,11 @@ export class FlashStep extends React.PureComponent<
action={'Retry'} action={'Retry'}
> >
<Txt> <Txt>
{_.map(this.state.errorMessage.split('\n'), (message, key) => ( {this.state.errorMessage.split('\n').map((message, key) => (
<p key={key}>{message}</p> <p key={key}>{message}</p>
))} ))}
</Txt> </Txt>
</Modal> </SmallModal>
)} )}
{this.state.showDriveSelectorModal && ( {this.state.showDriveSelectorModal && (
<TargetSelectorModal <TargetSelectorModal
@ -333,7 +315,7 @@ export class FlashStep extends React.PureComponent<
selectAllTargets(modalTargets); selectAllTargets(modalTargets);
this.setState({ showDriveSelectorModal: false }); this.setState({ showDriveSelectorModal: false });
}} }}
></TargetSelectorModal> />
)} )}
</> </>
); );

View File

@ -17,9 +17,8 @@
import CogSvg from '@fortawesome/fontawesome-free/svgs/solid/cog.svg'; import CogSvg from '@fortawesome/fontawesome-free/svgs/solid/cog.svg';
import QuestionCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/question-circle.svg'; import QuestionCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/question-circle.svg';
import { sourceDestination } from 'etcher-sdk';
import * as _ from 'lodash';
import * as path from 'path'; import * as path from 'path';
import * as prettyBytes from 'pretty-bytes';
import * as React from 'react'; import * as React from 'react';
import { Flex } from 'rendition'; import { Flex } from 'rendition';
import styled from 'styled-components'; import styled from 'styled-components';
@ -29,7 +28,7 @@ import { ReducedFlashingInfos } from '../../components/reduced-flashing-infos/re
import { SafeWebview } from '../../components/safe-webview/safe-webview'; import { SafeWebview } from '../../components/safe-webview/safe-webview';
import { SettingsModal } from '../../components/settings/settings'; import { SettingsModal } from '../../components/settings/settings';
import { import {
SourceOptions, SourceMetadata,
SourceSelector, SourceSelector,
} from '../../components/source-selector/source-selector'; } from '../../components/source-selector/source-selector';
import * as flashState from '../../models/flash-state'; import * as flashState from '../../models/flash-state';
@ -42,9 +41,10 @@ import {
ThemedProvider, ThemedProvider,
} from '../../styled-components'; } from '../../styled-components';
import { bytesToClosestUnit } from '../../../../shared/units'; import {
TargetSelector,
import { DriveSelector, getDriveListLabel } from './DriveSelector'; getDriveListLabel,
} from '../../components/target-selector/target-selector';
import { FlashStep } from './Flash'; import { FlashStep } from './Flash';
import EtcherSvg from '../../../assets/etcher.svg'; import EtcherSvg from '../../../assets/etcher.svg';
@ -67,14 +67,16 @@ function getDrivesTitle() {
return `${drives.length} Targets`; return `${drives.length} Targets`;
} }
function getImageBasename() { function getImageBasename(image?: SourceMetadata) {
if (!selectionState.hasImage()) { if (image === undefined) {
return ''; return '';
} }
const selectionImageName = selectionState.getImageName(); if (image.drive) {
const imageBasename = path.basename(selectionState.getImagePath()); return image.drive.description;
return selectionImageName || imageBasename; }
const imageBasename = path.basename(image.path);
return image.name || imageBasename;
} }
const StepBorder = styled.div<{ const StepBorder = styled.div<{
@ -101,9 +103,9 @@ interface MainPageStateFromStore {
isFlashing: boolean; isFlashing: boolean;
hasImage: boolean; hasImage: boolean;
hasDrive: boolean; hasDrive: boolean;
imageLogo: string; imageLogo?: string;
imageSize: number; imageSize?: number;
imageName: string; imageName?: string;
driveTitle: string; driveTitle: string;
driveLabel: string; driveLabel: string;
} }
@ -112,7 +114,6 @@ interface MainPageState {
current: 'main' | 'success'; current: 'main' | 'success';
isWebviewShowing: boolean; isWebviewShowing: boolean;
hideSettings: boolean; hideSettings: boolean;
source: SourceOptions;
featuredProjectURL?: string; featuredProjectURL?: string;
} }
@ -126,10 +127,6 @@ export class MainPage extends React.Component<
current: 'main', current: 'main',
isWebviewShowing: false, isWebviewShowing: false,
hideSettings: true, hideSettings: true,
source: {
imagePath: '',
SourceType: sourceDestination.File,
},
...this.stateHelper(), ...this.stateHelper(),
}; };
} }
@ -141,7 +138,7 @@ export class MainPage extends React.Component<
hasDrive: selectionState.hasDrive(), hasDrive: selectionState.hasDrive(),
imageLogo: selectionState.getImageLogo(), imageLogo: selectionState.getImageLogo(),
imageSize: selectionState.getImageSize(), imageSize: selectionState.getImageSize(),
imageName: getImageBasename(), imageName: getImageBasename(selectionState.getImage()),
driveTitle: getDrivesTitle(), driveTitle: getDrivesTitle(),
driveLabel: getDriveListLabel(), driveLabel: getDriveListLabel(),
}; };
@ -243,16 +240,11 @@ export class MainPage extends React.Component<
> >
{notFlashingOrSplitView && ( {notFlashingOrSplitView && (
<> <>
<SourceSelector <SourceSelector flashing={this.state.isFlashing} />
flashing={this.state.isFlashing}
afterSelected={(source: SourceOptions) =>
this.setState({ source })
}
/>
<Flex> <Flex>
<StepBorder disabled={shouldDriveStepBeDisabled} left /> <StepBorder disabled={shouldDriveStepBeDisabled} left />
</Flex> </Flex>
<DriveSelector <TargetSelector
disabled={shouldDriveStepBeDisabled} disabled={shouldDriveStepBeDisabled}
hasDrive={this.state.hasDrive} hasDrive={this.state.hasDrive}
flashing={this.state.isFlashing} flashing={this.state.isFlashing}
@ -279,8 +271,8 @@ export class MainPage extends React.Component<
imageLogo={this.state.imageLogo} imageLogo={this.state.imageLogo}
imageName={this.state.imageName} imageName={this.state.imageName}
imageSize={ imageSize={
_.isNumber(this.state.imageSize) typeof this.state.imageSize === 'number'
? (bytesToClosestUnit(this.state.imageSize) as string) ? prettyBytes(this.state.imageSize)
: '' : ''
} }
driveTitle={this.state.driveTitle} driveTitle={this.state.driveTitle}
@ -313,7 +305,6 @@ export class MainPage extends React.Component<
<FlashStep <FlashStep
goToSuccess={() => this.setState({ current: 'success' })} goToSuccess={() => this.setState({ current: 'success' })}
shouldFlashStepBeDisabled={shouldFlashStepBeDisabled} shouldFlashStepBeDisabled={shouldFlashStepBeDisabled}
source={this.state.source}
isFlashing={this.state.isFlashing} isFlashing={this.state.isFlashing}
step={state.type} step={state.type}
percentage={state.percentage} percentage={state.percentage}

View File

@ -16,6 +16,7 @@
import * as React from 'react'; import * as React from 'react';
import { import {
Alert as AlertBase,
Flex, Flex,
FlexProps, FlexProps,
Button, Button,
@ -25,7 +26,7 @@ import {
Txt, Txt,
Theme as renditionTheme, Theme as renditionTheme,
} from 'rendition'; } from 'rendition';
import styled from 'styled-components'; import styled, { css } from 'styled-components';
import { colors, theme } from './theme'; import { colors, theme } from './theme';
@ -68,6 +69,7 @@ export const StepButton = styled((props: ButtonProps) => (
<BaseButton {...props}></BaseButton> <BaseButton {...props}></BaseButton>
))` ))`
color: #ffffff; color: #ffffff;
font-size: 14px;
`; `;
export const ChangeButton = styled(Button)` export const ChangeButton = styled(Button)`
@ -93,7 +95,7 @@ export const StepNameButton = styled(BaseButton)`
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 100%; width: 100%;
font-weight: bold; font-weight: normal;
color: ${colors.dark.foreground}; color: ${colors.dark.foreground};
&:enabled { &: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 }) => { export const Modal = styled(({ style, ...props }) => {
return ( return (
<Provider <Provider
@ -140,7 +155,7 @@ export const Modal = styled(({ style, ...props }) => {
> >
<ModalBase <ModalBase
position="top" position="top"
width="96vw" width="97vw"
cancelButtonProps={{ cancelButtonProps={{
style: { style: {
marginRight: '20px', marginRight: '20px',
@ -148,7 +163,7 @@ export const Modal = styled(({ style, ...props }) => {
}, },
}} }}
style={{ style={{
height: '86.5vh', height: '87.5vh',
...style, ...style,
}} }}
{...props} {...props}
@ -157,27 +172,42 @@ export const Modal = styled(({ style, ...props }) => {
); );
})` })`
> div { > div {
padding: 24px 30px; padding: 0;
height: calc(100% - 80px); height: 100%;
::-webkit-scrollbar {
display: none;
}
> h3 { > h3 {
margin: 0; 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 { > div:last-child {
margin: 0;
flex-direction: ${(props) =>
props.reverseFooterButtons ? 'row-reverse' : 'row'};
border-radius: 0 0 7px 7px; border-radius: 0 0 7px 7px;
height: 80px; height: 80px;
background-color: #fff; background-color: #fff;
justify-content: center; justify-content: center;
position: absolute;
bottom: 0;
left: 0;
width: 100%; 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; overflow-x: visible;
} }
`; `;
export const Alert = styled((props) => (
<AlertBase warning emphasized {...props}></AlertBase>
))`
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;
}
`;

View File

@ -90,20 +90,21 @@ export const theme = {
opacity: 1, opacity: 1,
}, },
extend: () => ` extend: () => `
&& { width: 200px;
width: 200px; font-size: 16px;
height: 48px;
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}; background-color: ${colors.dark.disabled.background};
color: ${colors.dark.disabled.foreground}; color: ${colors.dark.disabled.foreground};
opacity: 1;
:hover {
background-color: ${colors.dark.disabled.background};
color: ${colors.dark.disabled.foreground};
}
} }
} }
`, `,

View File

@ -161,6 +161,9 @@ async function createMainWindow() {
// Prevent flash of white when starting the application // Prevent flash of white when starting the application
mainWindow.on('ready-to-show', () => { mainWindow.on('ready-to-show', () => {
console.timeEnd('ready-to-show'); console.timeEnd('ready-to-show');
// Electron sometimes caches the zoomFactor
// making it obnoxious to switch back-and-forth
mainWindow.webContents.setZoomFactor(width / defaultWidth);
mainWindow.show(); mainWindow.show();
}); });

View File

@ -20,10 +20,11 @@ import { cleanupTmpFiles } from 'etcher-sdk/build/tmp';
import * as ipc from 'node-ipc'; import * as ipc from 'node-ipc';
import { totalmem } from 'os'; import { totalmem } from 'os';
import { BlockDevice, File, Http } from 'etcher-sdk/build/source-destination';
import { toJSON } from '../../shared/errors'; import { toJSON } from '../../shared/errors';
import { GENERAL_ERROR, SUCCESS } from '../../shared/exit-codes'; import { GENERAL_ERROR, SUCCESS } from '../../shared/exit-codes';
import { delay } from '../../shared/utils'; import { delay } from '../../shared/utils';
import { SourceOptions } from '../app/components/source-selector/source-selector'; import { SourceMetadata } from '../app/components/source-selector/source-selector';
ipc.config.id = process.env.IPC_CLIENT_ID as string; ipc.config.id = process.env.IPC_CLIENT_ID as string;
ipc.config.socketRoot = process.env.IPC_SOCKET_ROOT as string; ipc.config.socketRoot = process.env.IPC_SOCKET_ROOT as string;
@ -143,13 +144,12 @@ async function writeAndValidate({
} }
interface WriteOptions { interface WriteOptions {
imagePath: string; image: SourceMetadata;
destinations: DrivelistDrive[]; destinations: DrivelistDrive[];
unmountOnSuccess: boolean; unmountOnSuccess: boolean;
validateWriteOnSuccess: boolean; validateWriteOnSuccess: boolean;
autoBlockmapping: boolean; autoBlockmapping: boolean;
decompressFirst: boolean; decompressFirst: boolean;
source: SourceOptions;
SourceType: string; SourceType: string;
} }
@ -228,7 +228,8 @@ ipc.connectTo(IPC_SERVER_ID, () => {
}; };
const destinations = options.destinations.map((d) => d.device); const destinations = options.destinations.map((d) => d.device);
log(`Image: ${options.imagePath}`); const imagePath = options.image.path;
log(`Image: ${imagePath}`);
log(`Devices: ${destinations.join(', ')}`); log(`Devices: ${destinations.join(', ')}`);
log(`Umount on success: ${options.unmountOnSuccess}`); log(`Umount on success: ${options.unmountOnSuccess}`);
log(`Validate on success: ${options.validateWriteOnSuccess}`); log(`Validate on success: ${options.validateWriteOnSuccess}`);
@ -243,18 +244,22 @@ ipc.connectTo(IPC_SERVER_ID, () => {
}); });
}); });
const { SourceType } = options; const { SourceType } = options;
let source;
if (SourceType === sdk.sourceDestination.File.name) {
source = new sdk.sourceDestination.File({
path: options.imagePath,
});
} else {
source = new sdk.sourceDestination.Http({
url: options.imagePath,
avoidRandomAccess: true,
});
}
try { try {
let source;
if (options.image.drive) {
source = new BlockDevice({
drive: options.image.drive,
direct: !options.autoBlockmapping,
});
} else {
if (SourceType === File.name) {
source = new File({
path: imagePath,
});
} else {
source = new Http({ url: imagePath, avoidRandomAccess: true });
}
}
const results = await writeAndValidate({ const results = await writeAndValidate({
source, source,
destinations: dests, destinations: dests,

View File

@ -5,9 +5,9 @@ ObjC.import('stdlib')
const app = Application.currentApplication() const app = Application.currentApplication()
app.includeStandardAdditions = true app.includeStandardAdditions = true
const result = app.displayDialog('balenaEtcher wants to make changes. Type your password to allow this.', { const result = app.displayDialog('balenaEtcher needs privileged access in order to flash disks.\n\nType your password to allow this.', {
defaultAnswer: '', defaultAnswer: '',
withIcon: 'stop', withIcon: 'caution',
buttons: ['Cancel', 'Ok'], buttons: ['Cancel', 'Ok'],
defaultButton: 'Ok', defaultButton: 'Ok',
hiddenAnswer: true, hiddenAnswer: true,

View File

@ -14,18 +14,26 @@
* limitations under the License. * limitations under the License.
*/ */
import { Drive as DrivelistDrive } from 'drivelist'; import { Drive } from 'drivelist';
import * as _ from 'lodash'; import * as _ from 'lodash';
import * as pathIsInside from 'path-is-inside'; import * as pathIsInside from 'path-is-inside';
import * as prettyBytes from 'pretty-bytes';
import * as messages from './messages'; import * as messages from './messages';
import { SourceMetadata } from '../gui/app/components/source-selector/source-selector';
/** /**
* @summary The default unknown size for things such as images and drives * @summary The default unknown size for things such as images and drives
*/ */
const UNKNOWN_SIZE = 0; 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 * @summary Check if a drive is locked
* *
@ -33,22 +41,23 @@ const UNKNOWN_SIZE = 0;
* This usually points out a locked SD Card. * This usually points out a locked SD Card.
*/ */
export function isDriveLocked(drive: DrivelistDrive): boolean { 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 * @summary Check if a drive is a system drive
*/ */
export function isSystemDrive(drive: DrivelistDrive): boolean { export function isSystemDrive(drive: DrivelistDrive): boolean {
return Boolean(_.get(drive, ['isSystem'], false)); return Boolean(drive.isSystem);
} }
export interface Image { function sourceIsInsideDrive(source: string, drive: DrivelistDrive) {
path?: string; for (const mountpoint of drive.mountpoints || []) {
isSizeEstimated?: boolean; if (pathIsInside(source, mountpoint.path)) {
compressedSize?: number; return true;
recommendedDriveSize?: number; }
size?: number; }
return false;
} }
/** /**
@ -60,11 +69,16 @@ export interface Image {
*/ */
export function isSourceDrive( export function isSourceDrive(
drive: DrivelistDrive, drive: DrivelistDrive,
image: Image = {}, selection?: SourceMetadata,
): boolean { ): boolean {
for (const mountpoint of drive.mountpoints || []) { if (selection) {
if (image.path !== undefined && pathIsInside(image.path, mountpoint.path)) { if (selection.drive) {
return true; const sourcePath = selection.drive.devicePath || selection.drive.device;
const drivePath = drive.devicePath || drive.device;
return pathIsInside(sourcePath, drivePath);
}
if (selection.path) {
return sourceIsInsideDrive(selection.path, drive);
} }
} }
return false; return false;
@ -74,17 +88,21 @@ export function isSourceDrive(
* @summary Check if a drive is large enough for an image * @summary Check if a drive is large enough for an image
*/ */
export function isDriveLargeEnough( export function isDriveLargeEnough(
drive: DrivelistDrive | undefined, drive: DrivelistDrive,
image: Image, image?: SourceMetadata,
): boolean { ): 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 // If the drive size is smaller than the original image size, and
// the final image size is just an estimation, then we stop right // the final image size is just an estimation, then we stop right
// here, based on the assumption that the final size will never // here, based on the assumption that the final size will never
// be less than the original size. // be less than the original size.
if (driveSize < _.get(image, ['compressedSize'], UNKNOWN_SIZE)) { if (driveSize < (image.compressedSize || UNKNOWN_SIZE)) {
return false; return false;
} }
@ -95,24 +113,27 @@ export function isDriveLargeEnough(
return true; 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) * @summary Check if a drive is disabled (i.e. not ready for selection)
*/ */
export function isDriveDisabled(drive: DrivelistDrive): boolean { 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 * @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 ( return (
!isDriveLocked(drive) && !isDriveLocked(drive) &&
isDriveLargeEnough(drive, image) && isDriveLargeEnough(drive, image) &&
!isSourceDrive(drive, image) && !isSourceDrive(drive, image as SourceMetadata) &&
!isDriveDisabled(drive) !isDriveDisabled(drive)
); );
} }
@ -124,23 +145,23 @@ export function isDriveValid(drive: DrivelistDrive, image: Image): boolean {
* If the image doesn't have a recommended size, this function returns true. * If the image doesn't have a recommended size, this function returns true.
*/ */
export function isDriveSizeRecommended( export function isDriveSizeRecommended(
drive: DrivelistDrive | undefined, drive: DrivelistDrive,
image: Image, image?: SourceMetadata,
): boolean { ): boolean {
const driveSize = _.get(drive, 'size') || UNKNOWN_SIZE; const driveSize = drive.size || UNKNOWN_SIZE;
return driveSize >= _.get(image, ['recommendedDriveSize'], 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' * @summary Check whether a drive's size is 'large'
*/ */
export function isDriveSizeLarge(drive?: DrivelistDrive): boolean { export function isDriveSizeLarge(drive: DrivelistDrive): boolean {
const driveSize = _.get(drive, 'size') || UNKNOWN_SIZE; const driveSize = drive.size || UNKNOWN_SIZE;
return driveSize > LARGE_DRIVE_SIZE; return driveSize > LARGE_DRIVE_SIZE;
} }
@ -155,6 +176,33 @@ export const COMPATIBILITY_STATUS_TYPES = {
ERROR: 2, 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 * @summary Get drive/image compatibility in an object
* *
@ -167,7 +215,7 @@ export const COMPATIBILITY_STATUS_TYPES = {
*/ */
export function getDriveImageCompatibilityStatuses( export function getDriveImageCompatibilityStatuses(
drive: DrivelistDrive, drive: DrivelistDrive,
image: Image = {}, image?: SourceMetadata,
) { ) {
const statusList = []; const statusList = [];
@ -182,41 +230,25 @@ export function getDriveImageCompatibilityStatuses(
!_.isNil(drive.size) && !_.isNil(drive.size) &&
!isDriveLargeEnough(drive, image) !isDriveLargeEnough(drive, image)
) { ) {
const imageSize = (image.isSizeEstimated statusList.push(statuses.small);
? image.compressedSize
: image.size) as number;
const relativeBytes = imageSize - drive.size;
statusList.push({
type: COMPATIBILITY_STATUS_TYPES.ERROR,
message: messages.compatibility.tooSmall(prettyBytes(relativeBytes)),
});
} else { } else {
if (isSourceDrive(drive, image)) { // Avoid showing "large drive" with "system drive" status
statusList.push({
type: COMPATIBILITY_STATUS_TYPES.ERROR,
message: messages.compatibility.containsImage(),
});
}
if (isSystemDrive(drive)) { if (isSystemDrive(drive)) {
statusList.push({ statusList.push(statuses.system);
type: COMPATIBILITY_STATUS_TYPES.WARNING, } else if (isDriveSizeLarge(drive)) {
message: messages.compatibility.system(), statusList.push(statuses.large);
});
} }
if (isDriveSizeLarge(drive)) { if (isSourceDrive(drive, image as SourceMetadata)) {
statusList.push({ statusList.push(statuses.containsImage);
type: COMPATIBILITY_STATUS_TYPES.WARNING,
message: messages.compatibility.largeDrive(),
});
} }
if (!_.isNil(drive) && !isDriveSizeRecommended(drive, image)) { if (
statusList.push({ image !== undefined &&
type: COMPATIBILITY_STATUS_TYPES.WARNING, !_.isNil(drive) &&
message: messages.compatibility.sizeNotRecommended(), !isDriveSizeRecommended(drive, image)
}); ) {
statusList.push(statuses.sizeNotRecommended);
} }
} }
@ -232,9 +264,9 @@ export function getDriveImageCompatibilityStatuses(
*/ */
export function getListDriveImageCompatibilityStatuses( export function getListDriveImageCompatibilityStatuses(
drives: DrivelistDrive[], drives: DrivelistDrive[],
image: Image, image: SourceMetadata,
) { ) {
return _.flatMap(drives, (drive) => { return drives.flatMap((drive) => {
return getDriveImageCompatibilityStatuses(drive, image); return getDriveImageCompatibilityStatuses(drive, image);
}); });
} }
@ -247,36 +279,12 @@ export function getListDriveImageCompatibilityStatuses(
*/ */
export function hasDriveImageCompatibilityStatus( export function hasDriveImageCompatibilityStatus(
drive: DrivelistDrive, drive: DrivelistDrive,
image: Image, image: SourceMetadata,
) { ) {
return Boolean(getDriveImageCompatibilityStatuses(drive, image).length); return Boolean(getDriveImageCompatibilityStatuses(drive, image).length);
} }
/** export interface DriveStatus {
* @summary Does any drive/image pair have at least one compatibility status?
* @function
* @public
*
* @description
* Given an image and a drive, return whether they have a connected compatibility status object.
*
* @param {Object[]} drives - drives
* @param {Object} image - image
* @returns {Boolean}
*
* @example
* if (constraints.hasDriveImageCompatibilityStatus(drive, image)) {
* console.log('This drive-image pair has a compatibility status message!')
* }
*/
export function hasListDriveImageCompatibilityStatus(
drives: DrivelistDrive[],
image: Image,
) {
return Boolean(getListDriveImageCompatibilityStatuses(drives, image).length);
}
export interface TargetStatus {
message: string; message: string;
type: number; type: number;
} }

View File

@ -15,6 +15,8 @@
*/ */
import { Dictionary } from 'lodash'; import { Dictionary } from 'lodash';
import { outdent } from 'outdent';
import * as prettyBytes from 'pretty-bytes';
export const progress: Dictionary<(quantity: number) => string> = { export const progress: Dictionary<(quantity: number) => string> = {
successful: (quantity: number) => { successful: (quantity: number) => {
@ -53,11 +55,11 @@ export const info = {
export const compatibility = { export const compatibility = {
sizeNotRecommended: () => { sizeNotRecommended: () => {
return 'Not Recommended'; return 'Not recommended';
}, },
tooSmall: (additionalSpace: string) => { tooSmall: () => {
return `Insufficient space, additional ${additionalSpace} required`; return 'Too small';
}, },
locked: () => { locked: () => {
@ -83,10 +85,10 @@ export const warning = {
image: { recommendedDriveSize: number }, image: { recommendedDriveSize: number },
drive: { device: string; size: number }, drive: { device: string; size: number },
) => { ) => {
return [ return outdent({ newline: ' ' })`
`This image recommends a ${image.recommendedDriveSize}`, This image recommends a ${prettyBytes(image.recommendedDriveSize)}
`bytes drive, however ${drive.device} is only ${drive.size} bytes.`, drive, however ${drive.device} is only ${prettyBytes(drive.size)}.
].join(' '); `;
}, },
exitWhileFlashing: () => { exitWhileFlashing: () => {
@ -115,11 +117,16 @@ export const warning = {
].join(' '); ].join(' ');
}, },
largeDriveSize: (drive: { description: string; device: string }) => { largeDriveSize: () => {
return [ return 'This is a large drive! Make sure it doesn\'t contain files that you want to keep.';
`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(' '); systemDrive: () => {
return 'Selecting your system drive is dangerous and will erase your drive!';
},
sourceDrive: () => {
return 'Contains the image you chose to flash';
}, },
}; };
@ -143,11 +150,12 @@ export const error = {
].join(' '); ].join(' ');
}, },
openImage: (imageBasename: string, errorMessage: string) => { openSource: (sourceName: string, errorMessage: string) => {
return [ return outdent`
`Something went wrong while opening ${imageBasename}\n\n`, Something went wrong while opening ${sourceName}
`Error: ${errorMessage}`,
].join(''); Error: ${errorMessage}
`;
}, },
flashFailure: ( flashFailure: (

View File

@ -14,18 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
import * as _ from 'lodash';
import * as prettyBytes from 'pretty-bytes';
const MEGABYTE_TO_BYTE_RATIO = 1000000; const MEGABYTE_TO_BYTE_RATIO = 1000000;
export function bytesToMegabytes(bytes: number): number { export function bytesToMegabytes(bytes: number): number {
return bytes / MEGABYTE_TO_BYTE_RATIO; return bytes / MEGABYTE_TO_BYTE_RATIO;
} }
export function bytesToClosestUnit(bytes: number): string | null {
if (_.isNumber(bytes)) {
return prettyBytes(bytes);
}
return null;
}

16
npm-shrinkwrap.json generated
View File

@ -6683,15 +6683,15 @@
"dev": true "dev": true
}, },
"etcher-sdk": { "etcher-sdk": {
"version": "4.1.29", "version": "4.1.30",
"resolved": "https://registry.npmjs.org/etcher-sdk/-/etcher-sdk-4.1.29.tgz", "resolved": "https://registry.npmjs.org/etcher-sdk/-/etcher-sdk-4.1.30.tgz",
"integrity": "sha512-dMzrCFgd6WHe/tqsFapHKjTXA32YL/J+p/RnJztQeMfV3b0cQiUINp6ZX4cU6lfbL8cpRVp4y61Qo5vhMbycZw==", "integrity": "sha512-HINIm5b/nOnY4v5XGRQFYQsHOSHGM/iukMm56WblsKEQPRBjZzZfHUzsyZcbsclFhw//x+iPbkDKUbf5uBpk1Q==",
"dev": true, "dev": true,
"requires": { "requires": {
"@balena/udif": "^1.1.0", "@balena/udif": "^1.1.0",
"@ronomon/direct-io": "^3.0.1", "@ronomon/direct-io": "^3.0.1",
"axios": "^0.19.2", "axios": "^0.19.2",
"balena-image-fs": "^7.0.0-remove-bluebird-9150c6c0fee21e33beef0ddaeea56ad1ce175c96", "balena-image-fs": "^7.0.1",
"blockmap": "^4.0.1", "blockmap": "^4.0.1",
"check-disk-space": "^2.1.0", "check-disk-space": "^2.1.0",
"cyclic-32": "^1.1.0", "cyclic-32": "^1.1.0",
@ -6871,9 +6871,9 @@
} }
}, },
"ext2fs": { "ext2fs": {
"version": "2.0.1", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/ext2fs/-/ext2fs-2.0.1.tgz", "resolved": "https://registry.npmjs.org/ext2fs/-/ext2fs-2.0.4.tgz",
"integrity": "sha512-ZhnpAINB0+Lsgt5jwyAMQKe/w9L1WaNiERyGvXlO7sd9doGaxrVotyX3+ZPbyNMgPb/7wJ0zbeRp+DLAzZQdug==", "integrity": "sha512-7ILtkKb6j9L+nR1qO4zCiy6aZulzKu7dO82na+qXwc6KEoEr23u/u476/thebbPcvYJMv71I7FebJv8P4MNjHw==",
"dev": true, "dev": true,
"requires": { "requires": {
"bindings": "^1.3.0", "bindings": "^1.3.0",
@ -16775,4 +16775,4 @@
"dev": true "dev": true
} }
} }
} }

View File

@ -77,7 +77,7 @@
"electron-notarize": "^1.0.0", "electron-notarize": "^1.0.0",
"electron-rebuild": "^1.11.0", "electron-rebuild": "^1.11.0",
"electron-updater": "^4.3.2", "electron-updater": "^4.3.2",
"etcher-sdk": "^4.1.29", "etcher-sdk": "^4.1.30",
"file-loader": "^6.0.0", "file-loader": "^6.0.0",
"husky": "^4.2.5", "husky": "^4.2.5",
"immutable": "^3.8.1", "immutable": "^3.8.1",

View File

@ -15,6 +15,7 @@
*/ */
import { expect } from 'chai'; import { expect } from 'chai';
import { File } from 'etcher-sdk/build/source-destination';
import * as path from 'path'; import * as path from 'path';
import * as availableDrives from '../../../lib/gui/app/models/available-drives'; import * as availableDrives from '../../../lib/gui/app/models/available-drives';
@ -157,11 +158,14 @@ describe('Model: availableDrives', function () {
} }
selectionState.clear(); selectionState.clear();
selectionState.selectImage({ selectionState.selectSource({
description: this.imagePath.split('/').pop(),
displayName: this.imagePath,
path: this.imagePath, path: this.imagePath,
extension: 'img', extension: 'img',
size: 999999999, size: 999999999,
isSizeEstimated: false, isSizeEstimated: false,
SourceType: File,
recommendedDriveSize: 2000000000, recommendedDriveSize: 2000000000,
}); });
}); });

View File

@ -15,11 +15,13 @@
*/ */
import { expect } from 'chai'; import { expect } from 'chai';
import * as _ from 'lodash'; import { File } from 'etcher-sdk/build/source-destination';
import * as path from 'path'; 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 availableDrives from '../../../lib/gui/app/models/available-drives';
import * as selectionState from '../../../lib/gui/app/models/selection-state'; import * as selectionState from '../../../lib/gui/app/models/selection-state';
import { DrivelistDrive } from '../../../lib/shared/drive-constraints';
describe('Model: selectionState', function () { describe('Model: selectionState', function () {
describe('given a clean state', function () { describe('given a clean state', function () {
@ -39,10 +41,6 @@ describe('Model: selectionState', function () {
expect(selectionState.getImageSize()).to.be.undefined; expect(selectionState.getImageSize()).to.be.undefined;
}); });
it('getImageUrl() should return undefined', function () {
expect(selectionState.getImageUrl()).to.be.undefined;
});
it('getImageName() should return undefined', function () { it('getImageName() should return undefined', function () {
expect(selectionState.getImageName()).to.be.undefined; expect(selectionState.getImageName()).to.be.undefined;
}); });
@ -55,10 +53,6 @@ describe('Model: selectionState', function () {
expect(selectionState.getImageSupportUrl()).to.be.undefined; expect(selectionState.getImageSupportUrl()).to.be.undefined;
}); });
it('getImageRecommendedDriveSize() should return undefined', function () {
expect(selectionState.getImageRecommendedDriveSize()).to.be.undefined;
});
it('hasDrive() should return false', function () { it('hasDrive() should return false', function () {
const hasDrive = selectionState.hasDrive(); const hasDrive = selectionState.hasDrive();
expect(hasDrive).to.be.false; expect(hasDrive).to.be.false;
@ -138,10 +132,10 @@ describe('Model: selectionState', function () {
it('should queue the drive', function () { it('should queue the drive', function () {
selectionState.selectDrive('/dev/disk5'); selectionState.selectDrive('/dev/disk5');
const drives = selectionState.getSelectedDevices(); const drives = selectionState.getSelectedDevices();
const lastDriveDevice = _.last(drives); const lastDriveDevice = drives.pop();
const lastDrive = _.find(availableDrives.getDrives(), { const lastDrive = availableDrives
device: lastDriveDevice, .getDrives()
}); .find((drive) => drive.device === lastDriveDevice);
expect(lastDrive).to.deep.equal({ expect(lastDrive).to.deep.equal({
device: '/dev/disk5', device: '/dev/disk5',
name: 'USB Drive', name: 'USB Drive',
@ -214,7 +208,7 @@ describe('Model: selectionState', function () {
it('should be able to add more drives', function () { it('should be able to add more drives', function () {
selectionState.selectDrive(this.drives[2].device); selectionState.selectDrive(this.drives[2].device);
expect(selectionState.getSelectedDevices()).to.deep.equal( 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, system: true,
}; };
const newDrives = [..._.initial(this.drives), systemDrive]; const newDrives = [...this.drives.slice(0, -1), systemDrive];
availableDrives.setDrives(newDrives); availableDrives.setDrives(newDrives);
selectionState.selectDrive(systemDrive.device); selectionState.selectDrive(systemDrive.device);
availableDrives.setDrives(newDrives); availableDrives.setDrives(newDrives);
expect(selectionState.getSelectedDevices()).to.deep.equal( 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 () { describe('.getSelectedDrives()', function () {
it('should return the selected drives', function () { it('should return the selected drives', function () {
expect(selectionState.getSelectedDrives()).to.deep.equal([ expect(selectionState.getSelectedDrives()).to.deep.equal([
{
device: '/dev/disk2',
name: 'USB Drive 2',
size: 999999999,
isReadOnly: false,
},
{ {
device: '/dev/sdb', device: '/dev/sdb',
description: 'DataTraveler 2.0', description: 'DataTraveler 2.0',
@ -280,12 +280,6 @@ describe('Model: selectionState', function () {
system: false, system: false,
isReadOnly: false, isReadOnly: false,
}, },
{
device: '/dev/disk2',
name: 'USB Drive 2',
size: 999999999,
isReadOnly: false,
},
]); ]);
}); });
}); });
@ -359,7 +353,7 @@ describe('Model: selectionState', function () {
logo: '<svg><text fill="red">Raspbian</text></svg>', logo: '<svg><text fill="red">Raspbian</text></svg>',
}; };
selectionState.selectImage(this.image); selectionState.selectSource(this.image);
}); });
describe('.selectDrive()', function () { describe('.selectDrive()', function () {
@ -399,13 +393,6 @@ describe('Model: selectionState', function () {
}); });
}); });
describe('.getImageUrl()', function () {
it('should return the image url', function () {
const imageUrl = selectionState.getImageUrl();
expect(imageUrl).to.equal('https://www.raspbian.org');
});
});
describe('.getImageName()', function () { describe('.getImageName()', function () {
it('should return the image name', function () { it('should return the image name', function () {
const imageName = selectionState.getImageName(); 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 () { describe('.hasImage()', function () {
it('should return true', function () { it('should return true', function () {
const hasImage = selectionState.hasImage(); const hasImage = selectionState.hasImage();
@ -445,11 +425,14 @@ describe('Model: selectionState', function () {
describe('.selectImage()', function () { describe('.selectImage()', function () {
it('should override the image', function () { it('should override the image', function () {
selectionState.selectImage({ selectionState.selectSource({
description: 'bar.img',
displayName: 'bar.img',
path: 'bar.img', path: 'bar.img',
extension: 'img', extension: 'img',
size: 999999999, size: 999999999,
isSizeEstimated: false, isSizeEstimated: false,
SourceType: File,
}); });
const imagePath = selectionState.getImagePath(); const imagePath = selectionState.getImagePath();
@ -475,13 +458,19 @@ describe('Model: selectionState', function () {
describe('.selectImage()', function () { describe('.selectImage()', function () {
afterEach(selectionState.clear); 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 () { it('should be able to set an image', function () {
selectionState.selectImage({ selectionState.selectSource(image);
path: 'foo.img',
extension: 'img',
size: 999999999,
isSizeEstimated: false,
});
const imagePath = selectionState.getImagePath(); const imagePath = selectionState.getImagePath();
expect(imagePath).to.equal('foo.img'); expect(imagePath).to.equal('foo.img');
@ -490,12 +479,10 @@ describe('Model: selectionState', function () {
}); });
it('should be able to set an image with an archive extension', function () { it('should be able to set an image with an archive extension', function () {
selectionState.selectImage({ selectionState.selectSource({
...image,
path: 'foo.zip', path: 'foo.zip',
extension: 'img',
archiveExtension: 'zip', archiveExtension: 'zip',
size: 999999999,
isSizeEstimated: false,
}); });
const imagePath = selectionState.getImagePath(); const imagePath = selectionState.getImagePath();
@ -503,12 +490,10 @@ describe('Model: selectionState', function () {
}); });
it('should infer a compressed raw image if the penultimate extension is missing', function () { it('should infer a compressed raw image if the penultimate extension is missing', function () {
selectionState.selectImage({ selectionState.selectSource({
...image,
path: 'foo.xz', path: 'foo.xz',
extension: 'img',
archiveExtension: 'xz', archiveExtension: 'xz',
size: 999999999,
isSizeEstimated: false,
}); });
const imagePath = selectionState.getImagePath(); const imagePath = selectionState.getImagePath();
@ -516,54 +501,20 @@ describe('Model: selectionState', function () {
}); });
it('should infer a compressed raw image if the penultimate extension is not a file extension', function () { it('should infer a compressed raw image if the penultimate extension is not a file extension', function () {
selectionState.selectImage({ selectionState.selectSource({
...image,
path: 'something.linux-x86-64.gz', path: 'something.linux-x86-64.gz',
extension: 'img',
archiveExtension: 'gz', archiveExtension: 'gz',
size: 999999999,
isSizeEstimated: false,
}); });
const imagePath = selectionState.getImagePath(); const imagePath = selectionState.getImagePath();
expect(imagePath).to.equal('something.linux-x86-64.gz'); expect(imagePath).to.equal('something.linux-x86-64.gz');
}); });
it('should throw if no path', function () {
expect(function () {
selectionState.selectImage({
extension: 'img',
size: 999999999,
isSizeEstimated: false,
});
}).to.throw('Missing image fields: path');
});
it('should throw if path is not a string', function () {
expect(function () {
selectionState.selectImage({
path: 123,
extension: 'img',
size: 999999999,
isSizeEstimated: false,
});
}).to.throw('Invalid image path: 123');
});
it('should throw if the original size is not a number', function () {
expect(function () {
selectionState.selectImage({
path: 'foo.img',
extension: 'img',
size: 999999999,
compressedSize: '999999999',
isSizeEstimated: false,
});
}).to.throw('Invalid image compressed size: 999999999');
});
it('should throw if the original size is a float number', function () { it('should throw if the original size is a float number', function () {
expect(function () { expect(function () {
selectionState.selectImage({ selectionState.selectSource({
...image,
path: 'foo.img', path: 'foo.img',
extension: 'img', extension: 'img',
size: 999999999, size: 999999999,
@ -575,85 +526,31 @@ describe('Model: selectionState', function () {
it('should throw if the original size is negative', function () { it('should throw if the original size is negative', function () {
expect(function () { expect(function () {
selectionState.selectImage({ selectionState.selectSource({
path: 'foo.img', ...image,
extension: 'img',
size: 999999999,
compressedSize: -1, compressedSize: -1,
isSizeEstimated: false,
}); });
}).to.throw('Invalid image compressed size: -1'); }).to.throw('Invalid image compressed size: -1');
}); });
it('should throw if the final size is not a number', function () {
expect(function () {
selectionState.selectImage({
path: 'foo.img',
extension: 'img',
size: '999999999',
isSizeEstimated: false,
});
}).to.throw('Invalid image size: 999999999');
});
it('should throw if the final size is a float number', function () { it('should throw if the final size is a float number', function () {
expect(function () { expect(function () {
selectionState.selectImage({ selectionState.selectSource({
path: 'foo.img', ...image,
extension: 'img',
size: 999999999.999, size: 999999999.999,
isSizeEstimated: false,
}); });
}).to.throw('Invalid image size: 999999999.999'); }).to.throw('Invalid image size: 999999999.999');
}); });
it('should throw if the final size is negative', function () { it('should throw if the final size is negative', function () {
expect(function () { expect(function () {
selectionState.selectImage({ selectionState.selectSource({
path: 'foo.img', ...image,
extension: 'img',
size: -1, size: -1,
isSizeEstimated: false,
}); });
}).to.throw('Invalid image size: -1'); }).to.throw('Invalid image size: -1');
}); });
it("should throw if url is defined but it's not a string", function () {
expect(function () {
selectionState.selectImage({
path: 'foo.img',
extension: 'img',
size: 999999999,
isSizeEstimated: false,
url: 1234,
});
}).to.throw('Invalid image url: 1234');
});
it("should throw if name is defined but it's not a string", function () {
expect(function () {
selectionState.selectImage({
path: 'foo.img',
extension: 'img',
size: 999999999,
isSizeEstimated: false,
name: 1234,
});
}).to.throw('Invalid image name: 1234');
});
it("should throw if logo is defined but it's not a string", function () {
expect(function () {
selectionState.selectImage({
path: 'foo.img',
extension: 'img',
size: 999999999,
isSizeEstimated: false,
logo: 1234,
});
}).to.throw('Invalid image logo: 1234');
});
it('should de-select a previously selected not-large-enough drive', function () { it('should de-select a previously selected not-large-enough drive', function () {
availableDrives.setDrives([ availableDrives.setDrives([
{ {
@ -667,11 +564,9 @@ describe('Model: selectionState', function () {
selectionState.selectDrive('/dev/disk1'); selectionState.selectDrive('/dev/disk1');
expect(selectionState.hasDrive()).to.be.true; expect(selectionState.hasDrive()).to.be.true;
selectionState.selectImage({ selectionState.selectSource({
path: 'foo.img', ...image,
extension: 'img',
size: 1234567890, size: 1234567890,
isSizeEstimated: false,
}); });
expect(selectionState.hasDrive()).to.be.false; expect(selectionState.hasDrive()).to.be.false;
@ -691,11 +586,8 @@ describe('Model: selectionState', function () {
selectionState.selectDrive('/dev/disk1'); selectionState.selectDrive('/dev/disk1');
expect(selectionState.hasDrive()).to.be.true; expect(selectionState.hasDrive()).to.be.true;
selectionState.selectImage({ selectionState.selectSource({
path: 'foo.img', ...image,
extension: 'img',
size: 999999999,
isSizeEstimated: false,
recommendedDriveSize: 1500000000, recommendedDriveSize: 1500000000,
}); });
@ -726,11 +618,11 @@ describe('Model: selectionState', function () {
selectionState.selectDrive('/dev/disk1'); selectionState.selectDrive('/dev/disk1');
expect(selectionState.hasDrive()).to.be.true; expect(selectionState.hasDrive()).to.be.true;
selectionState.selectImage({ selectionState.selectSource({
...image,
path: imagePath, path: imagePath,
extension: 'img', extension: 'img',
size: 999999999, size: 999999999,
isSizeEstimated: false,
}); });
expect(selectionState.hasDrive()).to.be.false; expect(selectionState.hasDrive()).to.be.false;
@ -740,6 +632,16 @@ describe('Model: selectionState', function () {
}); });
describe('given a drive and an image', 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 () { beforeEach(function () {
availableDrives.setDrives([ availableDrives.setDrives([
{ {
@ -752,12 +654,7 @@ describe('Model: selectionState', function () {
selectionState.selectDrive('/dev/disk1'); selectionState.selectDrive('/dev/disk1');
selectionState.selectImage({ selectionState.selectSource(image);
path: 'foo.img',
extension: 'img',
size: 999999999,
isSizeEstimated: false,
});
}); });
describe('.clear()', function () { describe('.clear()', function () {
@ -824,6 +721,16 @@ describe('Model: selectionState', function () {
}); });
describe('given several drives', 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 () { beforeEach(function () {
availableDrives.setDrives([ availableDrives.setDrives([
{ {
@ -850,12 +757,7 @@ describe('Model: selectionState', function () {
selectionState.selectDrive('/dev/disk2'); selectionState.selectDrive('/dev/disk2');
selectionState.selectDrive('/dev/disk3'); selectionState.selectDrive('/dev/disk3');
selectionState.selectImage({ selectionState.selectSource(image);
path: 'foo.img',
extension: 'img',
size: 999999999,
isSizeEstimated: false,
});
}); });
describe('.clear()', function () { describe('.clear()', function () {

View File

@ -20,6 +20,7 @@ import { sourceDestination } from 'etcher-sdk';
import * as ipc from 'node-ipc'; import * as ipc from 'node-ipc';
import { assert, SinonStub, stub } from 'sinon'; 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 flashState from '../../../lib/gui/app/models/flash-state';
import * as imageWriter from '../../../lib/gui/app/modules/image-writer'; import * as imageWriter from '../../../lib/gui/app/modules/image-writer';
@ -28,10 +29,14 @@ const fakeDrive: DrivelistDrive = {};
describe('Browser: imageWriter', () => { describe('Browser: imageWriter', () => {
describe('.flash()', () => { describe('.flash()', () => {
const imagePath = 'foo.img'; const image: SourceMetadata = {
const sourceOptions = { hasMBR: false,
imagePath, partitions: [],
description: 'foo.img',
displayName: 'foo.img',
path: 'foo.img',
SourceType: sourceDestination.File, SourceType: sourceDestination.File,
extension: 'img',
}; };
describe('given a successful write', () => { describe('given a successful write', () => {
@ -58,12 +63,7 @@ describe('Browser: imageWriter', () => {
}); });
try { try {
await imageWriter.flash( await imageWriter.flash(image, [fakeDrive], performWriteStub);
imagePath,
[fakeDrive],
sourceOptions,
performWriteStub,
);
} catch { } catch {
// noop // noop
} finally { } finally {
@ -79,18 +79,8 @@ describe('Browser: imageWriter', () => {
try { try {
await Promise.all([ await Promise.all([
imageWriter.flash( imageWriter.flash(image, [fakeDrive], performWriteStub),
imagePath, imageWriter.flash(image, [fakeDrive], performWriteStub),
[fakeDrive],
sourceOptions,
performWriteStub,
),
imageWriter.flash(
imagePath,
[fakeDrive],
sourceOptions,
performWriteStub,
),
]); ]);
assert.fail('Writing twice should fail'); assert.fail('Writing twice should fail');
} catch (error) { } catch (error) {
@ -117,12 +107,7 @@ describe('Browser: imageWriter', () => {
it('should set flashing to false when done', async () => { it('should set flashing to false when done', async () => {
try { try {
await imageWriter.flash( await imageWriter.flash(image, [fakeDrive], performWriteStub);
imagePath,
[fakeDrive],
sourceOptions,
performWriteStub,
);
} catch { } catch {
// noop // noop
} finally { } finally {
@ -132,12 +117,7 @@ describe('Browser: imageWriter', () => {
it('should set the error code in the flash results', async () => { it('should set the error code in the flash results', async () => {
try { try {
await imageWriter.flash( await imageWriter.flash(image, [fakeDrive], performWriteStub);
imagePath,
[fakeDrive],
sourceOptions,
performWriteStub,
);
} catch { } catch {
// noop // noop
} finally { } finally {
@ -152,12 +132,7 @@ describe('Browser: imageWriter', () => {
sourceChecksum: '1234', sourceChecksum: '1234',
}); });
try { try {
await imageWriter.flash( await imageWriter.flash(image, [fakeDrive], performWriteStub);
imagePath,
[fakeDrive],
sourceOptions,
performWriteStub,
);
} catch (error) { } catch (error) {
expect(error).to.be.an.instanceof(Error); expect(error).to.be.an.instanceof(Error);
expect(error.message).to.equal('write error'); expect(error.message).to.equal('write error');

View File

@ -15,9 +15,9 @@
*/ */
import { expect } from 'chai'; 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 * 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 constraints from '../../lib/shared/drive-constraints';
import * as messages from '../../lib/shared/messages'; import * as messages from '../../lib/shared/messages';
@ -29,7 +29,7 @@ describe('Shared: DriveConstraints', function () {
device: '/dev/disk2', device: '/dev/disk2',
size: 999999999, size: 999999999,
isReadOnly: true, isReadOnly: true,
} as DrivelistDrive); } as constraints.DrivelistDrive);
expect(result).to.be.true; expect(result).to.be.true;
}); });
@ -39,7 +39,7 @@ describe('Shared: DriveConstraints', function () {
device: '/dev/disk2', device: '/dev/disk2',
size: 999999999, size: 999999999,
isReadOnly: false, isReadOnly: false,
} as DrivelistDrive); } as constraints.DrivelistDrive);
expect(result).to.be.false; expect(result).to.be.false;
}); });
@ -48,16 +48,10 @@ describe('Shared: DriveConstraints', function () {
const result = constraints.isDriveLocked({ const result = constraints.isDriveLocked({
device: '/dev/disk2', device: '/dev/disk2',
size: 999999999, size: 999999999,
} as DrivelistDrive); } as constraints.DrivelistDrive);
expect(result).to.be.false; 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 () { describe('.isSystemDrive()', function () {
@ -67,7 +61,7 @@ describe('Shared: DriveConstraints', function () {
size: 999999999, size: 999999999,
isReadOnly: true, isReadOnly: true,
isSystem: true, isSystem: true,
} as DrivelistDrive); } as constraints.DrivelistDrive);
expect(result).to.be.true; expect(result).to.be.true;
}); });
@ -77,7 +71,7 @@ describe('Shared: DriveConstraints', function () {
device: '/dev/disk2', device: '/dev/disk2',
size: 999999999, size: 999999999,
isReadOnly: true, isReadOnly: true,
} as DrivelistDrive); } as constraints.DrivelistDrive);
expect(result).to.be.false; expect(result).to.be.false;
}); });
@ -88,16 +82,10 @@ describe('Shared: DriveConstraints', function () {
size: 999999999, size: 999999999,
isReadOnly: true, isReadOnly: true,
isSystem: false, isSystem: false,
} as DrivelistDrive); } as constraints.DrivelistDrive);
expect(result).to.be.false; 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 () { describe('.isSourceDrive()', function () {
@ -108,7 +96,7 @@ describe('Shared: DriveConstraints', function () {
size: 999999999, size: 999999999,
isReadOnly: true, isReadOnly: true,
isSystem: false, isSystem: false,
} as DrivelistDrive, } as constraints.DrivelistDrive,
// @ts-ignore // @ts-ignore
undefined, undefined,
); );
@ -123,9 +111,14 @@ describe('Shared: DriveConstraints', function () {
size: 999999999, size: 999999999,
isReadOnly: true, isReadOnly: true,
isSystem: false, isSystem: false,
} as DrivelistDrive, } as constraints.DrivelistDrive,
{ {
description: 'image.img',
displayName: 'image.img',
path: '/Volumes/Untitled/image.img', path: '/Volumes/Untitled/image.img',
hasMBR: false,
partitions: [],
SourceType: sourceDestination.File,
}, },
); );
@ -133,6 +126,14 @@ describe('Shared: DriveConstraints', function () {
}); });
describe('given Windows paths', function () { 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 () { beforeEach(function () {
this.separator = path.sep; this.separator = path.sep;
// @ts-ignore // @ts-ignore
@ -157,10 +158,8 @@ describe('Shared: DriveConstraints', function () {
path: 'F:', path: 'F:',
}, },
], ],
} as DrivelistDrive, } as constraints.DrivelistDrive,
{ windowsImage,
path: 'E:\\image.img',
},
); );
expect(result).to.be.true; expect(result).to.be.true;
@ -179,8 +178,9 @@ describe('Shared: DriveConstraints', function () {
path: 'F:', path: 'F:',
}, },
], ],
} as DrivelistDrive, } as constraints.DrivelistDrive,
{ {
...windowsImage,
path: 'E:\\foo\\bar\\image.img', path: 'E:\\foo\\bar\\image.img',
}, },
); );
@ -201,8 +201,9 @@ describe('Shared: DriveConstraints', function () {
path: 'F:', path: 'F:',
}, },
], ],
} as DrivelistDrive, } as constraints.DrivelistDrive,
{ {
...windowsImage,
path: 'G:\\image.img', path: 'G:\\image.img',
}, },
); );
@ -219,8 +220,9 @@ describe('Shared: DriveConstraints', function () {
path: 'E:\\fo', path: 'E:\\fo',
}, },
], ],
} as DrivelistDrive, } as constraints.DrivelistDrive,
{ {
...windowsImage,
path: 'E:\\foo/image.img', path: 'E:\\foo/image.img',
}, },
); );
@ -230,6 +232,14 @@ describe('Shared: DriveConstraints', function () {
}); });
describe('given UNIX paths', 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 () { beforeEach(function () {
this.separator = path.sep; this.separator = path.sep;
// @ts-ignore // @ts-ignore
@ -249,8 +259,9 @@ describe('Shared: DriveConstraints', function () {
path: '/', path: '/',
}, },
], ],
} as DrivelistDrive, } as constraints.DrivelistDrive,
{ {
...image,
path: '/image.img', path: '/image.img',
}, },
); );
@ -269,8 +280,9 @@ describe('Shared: DriveConstraints', function () {
path: '/Volumes/B', path: '/Volumes/B',
}, },
], ],
} as DrivelistDrive, } as constraints.DrivelistDrive,
{ {
...image,
path: '/Volumes/A/image.img', path: '/Volumes/A/image.img',
}, },
); );
@ -289,8 +301,9 @@ describe('Shared: DriveConstraints', function () {
path: '/Volumes/B', path: '/Volumes/B',
}, },
], ],
} as DrivelistDrive, } as constraints.DrivelistDrive,
{ {
...image,
path: '/Volumes/A/foo/bar/image.img', path: '/Volumes/A/foo/bar/image.img',
}, },
); );
@ -309,8 +322,9 @@ describe('Shared: DriveConstraints', function () {
path: '/Volumes/B', path: '/Volumes/B',
}, },
], ],
} as DrivelistDrive, } as constraints.DrivelistDrive,
{ {
...image,
path: '/Volumes/C/image.img', path: '/Volumes/C/image.img',
}, },
); );
@ -326,8 +340,9 @@ describe('Shared: DriveConstraints', function () {
path: '/Volumes/fo', path: '/Volumes/fo',
}, },
], ],
} as DrivelistDrive, } as constraints.DrivelistDrive,
{ {
...image,
path: '/Volumes/foo/image.img', path: '/Volumes/foo/image.img',
}, },
); );
@ -515,35 +530,19 @@ describe('Shared: DriveConstraints', function () {
}); });
}); });
it('should return false if the drive is undefined', function () {
const result = constraints.isDriveLargeEnough(undefined, {
path: path.join(__dirname, 'rpi.img'),
size: 1000000000,
isSizeEstimated: false,
});
expect(result).to.be.false;
});
it('should return true if the image is undefined', function () { it('should return true if the image is undefined', function () {
const result = constraints.isDriveLargeEnough( const result = constraints.isDriveLargeEnough(
{ {
device: '/dev/disk1', device: '/dev/disk1',
size: 1000000000, size: 1000000000,
isReadOnly: false, isReadOnly: false,
} as DrivelistDrive, } as constraints.DrivelistDrive,
// @ts-ignore // @ts-ignore
undefined, undefined,
); );
expect(result).to.be.true; 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 () { describe('.isDriveDisabled()', function () {
@ -553,7 +552,7 @@ describe('Shared: DriveConstraints', function () {
size: 1000000000, size: 1000000000,
isReadOnly: false, isReadOnly: false,
disabled: true, disabled: true,
} as unknown) as DrivelistDrive); } as unknown) as constraints.DrivelistDrive);
expect(result).to.be.true; expect(result).to.be.true;
}); });
@ -564,7 +563,7 @@ describe('Shared: DriveConstraints', function () {
size: 1000000000, size: 1000000000,
isReadOnly: false, isReadOnly: false,
disabled: false, disabled: false,
} as unknown) as DrivelistDrive); } as unknown) as constraints.DrivelistDrive);
expect(result).to.be.false; expect(result).to.be.false;
}); });
@ -574,26 +573,30 @@ describe('Shared: DriveConstraints', function () {
device: '/dev/disk1', device: '/dev/disk1',
size: 1000000000, size: 1000000000,
isReadOnly: false, isReadOnly: false,
} as DrivelistDrive); } as constraints.DrivelistDrive);
expect(result).to.be.false; expect(result).to.be.false;
}); });
}); });
describe('.isDriveSizeRecommended()', function () { 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 () { it('should return true if the drive size is greater than the recommended size ', function () {
const result = constraints.isDriveSizeRecommended( const result = constraints.isDriveSizeRecommended(
{ {
device: '/dev/disk1', device: '/dev/disk1',
size: 2000000001, size: 2000000001,
isReadOnly: false, isReadOnly: false,
} as DrivelistDrive, } as constraints.DrivelistDrive,
{ image,
path: path.join(__dirname, 'rpi.img'),
size: 1000000000,
isSizeEstimated: false,
recommendedDriveSize: 2000000000,
},
); );
expect(result).to.be.true; expect(result).to.be.true;
@ -605,13 +608,8 @@ describe('Shared: DriveConstraints', function () {
device: '/dev/disk1', device: '/dev/disk1',
size: 2000000000, size: 2000000000,
isReadOnly: false, isReadOnly: false,
} as DrivelistDrive, } as constraints.DrivelistDrive,
{ image,
path: path.join(__dirname, 'rpi.img'),
size: 1000000000,
isSizeEstimated: false,
recommendedDriveSize: 2000000000,
},
); );
expect(result).to.be.true; expect(result).to.be.true;
@ -623,11 +621,9 @@ describe('Shared: DriveConstraints', function () {
device: '/dev/disk1', device: '/dev/disk1',
size: 2000000000, size: 2000000000,
isReadOnly: false, isReadOnly: false,
} as DrivelistDrive, } as constraints.DrivelistDrive,
{ {
path: path.join(__dirname, 'rpi.img'), ...image,
size: 1000000000,
isSizeEstimated: false,
recommendedDriveSize: 2000000001, recommendedDriveSize: 2000000001,
}, },
); );
@ -641,47 +637,29 @@ describe('Shared: DriveConstraints', function () {
device: '/dev/disk1', device: '/dev/disk1',
size: 2000000000, size: 2000000000,
isReadOnly: false, isReadOnly: false,
} as DrivelistDrive, } as constraints.DrivelistDrive,
{ {
path: path.join(__dirname, 'rpi.img'), ...image,
size: 1000000000, recommendedDriveSize: undefined,
isSizeEstimated: false,
}, },
); );
expect(result).to.be.true; 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 () { it('should return true if the image is undefined', function () {
const result = constraints.isDriveSizeRecommended( const result = constraints.isDriveSizeRecommended(
{ {
device: '/dev/disk1', device: '/dev/disk1',
size: 2000000000, size: 2000000000,
isReadOnly: false, isReadOnly: false,
} as DrivelistDrive, } as constraints.DrivelistDrive,
// @ts-ignore // @ts-ignore
undefined, undefined,
); );
expect(result).to.be.true; 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 () { describe('.isDriveValid()', function () {
@ -709,16 +687,29 @@ describe('Shared: DriveConstraints', function () {
}); });
describe('given the drive is disabled', 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 () { beforeEach(function () {
this.drive.disabled = true; this.drive.disabled = true;
}); });
it('should return false if the drive is not large enough and is a source drive', 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( expect(
constraints.isDriveValid(this.drive, { constraints.isDriveValid(this.drive, {
...image,
path: path.join(this.mountpoint, 'rpi.img'), path: path.join(this.mountpoint, 'rpi.img'),
size: 5000000000, size: 5000000000,
isSizeEstimated: false,
}), }),
).to.be.false; ).to.be.false;
}); });
@ -726,35 +717,35 @@ describe('Shared: DriveConstraints', function () {
it('should return false if the drive is not large enough and is not a source drive', function () { it('should return false if the drive is not large enough and is not a source drive', function () {
expect( expect(
constraints.isDriveValid(this.drive, { constraints.isDriveValid(this.drive, {
...image,
path: path.resolve(this.mountpoint, '../bar/rpi.img'), path: path.resolve(this.mountpoint, '../bar/rpi.img'),
size: 5000000000,
isSizeEstimated: false,
}), }),
).to.be.false; ).to.be.false;
}); });
it('should return false if the drive is large enough and is a source drive', function () { it('should return false if the drive is large enough and is a source drive', function () {
expect( expect(constraints.isDriveValid(this.drive, image)).to.be.false;
constraints.isDriveValid(this.drive, {
path: path.join(this.mountpoint, 'rpi.img'),
size: 2000000000,
isSizeEstimated: false,
}),
).to.be.false;
}); });
it('should return false if the drive is large enough and is not a source drive', function () { it('should return false if the drive is large enough and is not a source drive', function () {
expect( expect(
constraints.isDriveValid(this.drive, { constraints.isDriveValid(this.drive, {
...image,
path: path.resolve(this.mountpoint, '../bar/rpi.img'), path: path.resolve(this.mountpoint, '../bar/rpi.img'),
size: 2000000000,
isSizeEstimated: false,
}), }),
).to.be.false; ).to.be.false;
}); });
}); });
describe('given the drive is not disabled', function () { 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 () { beforeEach(function () {
this.drive.disabled = false; this.drive.disabled = false;
}); });
@ -762,9 +753,9 @@ describe('Shared: DriveConstraints', function () {
it('should return false if the drive is not large enough and is a source drive', function () { it('should return false if the drive is not large enough and is a source drive', function () {
expect( expect(
constraints.isDriveValid(this.drive, { constraints.isDriveValid(this.drive, {
...image,
path: path.join(this.mountpoint, 'rpi.img'), path: path.join(this.mountpoint, 'rpi.img'),
size: 5000000000, size: 5000000000,
isSizeEstimated: false,
}), }),
).to.be.false; ).to.be.false;
}); });
@ -772,29 +763,22 @@ describe('Shared: DriveConstraints', function () {
it('should return false if the drive is not large enough and is not a source drive', function () { it('should return false if the drive is not large enough and is not a source drive', function () {
expect( expect(
constraints.isDriveValid(this.drive, { constraints.isDriveValid(this.drive, {
...image,
path: path.resolve(this.mountpoint, '../bar/rpi.img'), path: path.resolve(this.mountpoint, '../bar/rpi.img'),
size: 5000000000, size: 5000000000,
isSizeEstimated: false,
}), }),
).to.be.false; ).to.be.false;
}); });
it('should return false if the drive is large enough and is a source drive', function () { it('should return false if the drive is large enough and is a source drive', function () {
expect( expect(constraints.isDriveValid(this.drive, image)).to.be.false;
constraints.isDriveValid(this.drive, {
path: path.join(this.mountpoint, 'rpi.img'),
size: 2000000000,
isSizeEstimated: false,
}),
).to.be.false;
}); });
it('should return false if the drive is large enough and is not a source drive', function () { it('should return false if the drive is large enough and is not a source drive', function () {
expect( expect(
constraints.isDriveValid(this.drive, { constraints.isDriveValid(this.drive, {
...image,
path: path.resolve(this.mountpoint, '../bar/rpi.img'), path: path.resolve(this.mountpoint, '../bar/rpi.img'),
size: 2000000000,
isSizeEstimated: false,
}), }),
).to.be.false; ).to.be.false;
}); });
@ -802,6 +786,14 @@ describe('Shared: DriveConstraints', function () {
}); });
describe('given the drive is not locked', 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 () { beforeEach(function () {
this.drive.isReadOnly = false; this.drive.isReadOnly = false;
}); });
@ -814,9 +806,9 @@ describe('Shared: DriveConstraints', function () {
it('should return false if the drive is not large enough and is a source drive', function () { it('should return false if the drive is not large enough and is a source drive', function () {
expect( expect(
constraints.isDriveValid(this.drive, { constraints.isDriveValid(this.drive, {
...image,
path: path.join(this.mountpoint, 'rpi.img'), path: path.join(this.mountpoint, 'rpi.img'),
size: 5000000000, size: 5000000000,
isSizeEstimated: false,
}), }),
).to.be.false; ).to.be.false;
}); });
@ -824,29 +816,22 @@ describe('Shared: DriveConstraints', function () {
it('should return false if the drive is not large enough and is not a source drive', function () { it('should return false if the drive is not large enough and is not a source drive', function () {
expect( expect(
constraints.isDriveValid(this.drive, { constraints.isDriveValid(this.drive, {
...image,
path: path.resolve(this.mountpoint, '../bar/rpi.img'), path: path.resolve(this.mountpoint, '../bar/rpi.img'),
size: 5000000000, size: 5000000000,
isSizeEstimated: false,
}), }),
).to.be.false; ).to.be.false;
}); });
it('should return false if the drive is large enough and is a source drive', function () { it('should return false if the drive is large enough and is a source drive', function () {
expect( expect(constraints.isDriveValid(this.drive, image)).to.be.false;
constraints.isDriveValid(this.drive, {
path: path.join(this.mountpoint, 'rpi.img'),
size: 2000000000,
isSizeEstimated: false,
}),
).to.be.false;
}); });
it('should return false if the drive is large enough and is not a source drive', function () { it('should return false if the drive is large enough and is not a source drive', function () {
expect( expect(
constraints.isDriveValid(this.drive, { constraints.isDriveValid(this.drive, {
...image,
path: path.resolve(this.mountpoint, '../bar/rpi.img'), path: path.resolve(this.mountpoint, '../bar/rpi.img'),
size: 2000000000,
isSizeEstimated: false,
}), }),
).to.be.false; ).to.be.false;
}); });
@ -860,9 +845,9 @@ describe('Shared: DriveConstraints', function () {
it('should return false if the drive is not large enough and is a source drive', function () { it('should return false if the drive is not large enough and is a source drive', function () {
expect( expect(
constraints.isDriveValid(this.drive, { constraints.isDriveValid(this.drive, {
...image,
path: path.join(this.mountpoint, 'rpi.img'), path: path.join(this.mountpoint, 'rpi.img'),
size: 5000000000, size: 5000000000,
isSizeEstimated: false,
}), }),
).to.be.false; ).to.be.false;
}); });
@ -870,9 +855,9 @@ describe('Shared: DriveConstraints', function () {
it('should return false if the drive is not large enough and is not a source drive', function () { it('should return false if the drive is not large enough and is not a source drive', function () {
expect( expect(
constraints.isDriveValid(this.drive, { constraints.isDriveValid(this.drive, {
...image,
path: path.resolve(this.mountpoint, '../bar/rpi.img'), path: path.resolve(this.mountpoint, '../bar/rpi.img'),
size: 5000000000, size: 5000000000,
isSizeEstimated: false,
}), }),
).to.be.false; ).to.be.false;
}); });
@ -880,9 +865,8 @@ describe('Shared: DriveConstraints', function () {
it('should return false if the drive is large enough and is a source drive', function () { it('should return false if the drive is large enough and is a source drive', function () {
expect( expect(
constraints.isDriveValid(this.drive, { constraints.isDriveValid(this.drive, {
...image,
path: path.join(this.mountpoint, 'rpi.img'), path: path.join(this.mountpoint, 'rpi.img'),
size: 2000000000,
isSizeEstimated: false,
}), }),
).to.be.false; ).to.be.false;
}); });
@ -890,9 +874,8 @@ describe('Shared: DriveConstraints', function () {
it('should return true if the drive is large enough and is not a source drive', function () { it('should return true if the drive is large enough and is not a source drive', function () {
expect( expect(
constraints.isDriveValid(this.drive, { constraints.isDriveValid(this.drive, {
...image,
path: path.resolve(this.mountpoint, '../bar/rpi.img'), path: path.resolve(this.mountpoint, '../bar/rpi.img'),
size: 2000000000,
isSizeEstimated: false,
}), }),
).to.be.true; ).to.be.true;
}); });
@ -916,6 +899,7 @@ describe('Shared: DriveConstraints', function () {
}; };
this.image = { this.image = {
SourceType: sourceDestination.File,
path: path.join(__dirname, 'rpi.img'), path: path.join(__dirname, 'rpi.img'),
size: this.drive.size - 1, size: this.drive.size - 1,
isSizeEstimated: false, isSizeEstimated: false,
@ -960,28 +944,41 @@ describe('Shared: DriveConstraints', function () {
}; };
this.image = { this.image = {
SourceType: sourceDestination.File,
path: path.join(__dirname, 'rpi.img'), path: path.join(__dirname, 'rpi.img'),
size: this.drive.size - 1, size: this.drive.size - 1,
isSizeEstimated: false, 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 = ( const expectStatusTypesAndMessagesToBe = (
resultList: Array<{ message: string }>, resultList: Array<{ message: string }>,
expectedTuples: Array<['WARNING' | 'ERROR', string]>, expectedTuples: Array<['WARNING' | 'ERROR', string]>,
params?: number,
) => { ) => {
// Sort so that order doesn't matter // Sort so that order doesn't matter
const expectedTuplesSorted = _.sortBy( const expectedTuplesSorted = expectedTuples
_.map(expectedTuples, (tuple) => { .map((tuple) => {
return { return {
type: constraints.COMPATIBILITY_STATUS_TYPES[tuple[0]], type: constraints.COMPATIBILITY_STATUS_TYPES[tuple[0]],
// @ts-ignore // @ts-ignore
message: messages.compatibility[tuple[1]](), message: messages.compatibility[tuple[1]](params),
}; };
}), })
['message'], .sort(compareTuplesMessages);
); const resultTuplesSorted = resultList.sort(compareTuplesMessages);
const resultTuplesSorted = _.sortBy(resultList, ['message']);
expect(resultTuplesSorted).to.deep.equal(expectedTuplesSorted); expect(resultTuplesSorted).to.deep.equal(expectedTuplesSorted);
}; };
@ -1051,7 +1048,7 @@ describe('Shared: DriveConstraints', function () {
); );
const expected = [ const expected = [
{ {
message: messages.compatibility.tooSmall('1 B'), message: messages.compatibility.tooSmall(),
type: constraints.COMPATIBILITY_STATUS_TYPES.ERROR, type: constraints.COMPATIBILITY_STATUS_TYPES.ERROR,
}, },
]; ];
@ -1117,11 +1114,14 @@ describe('Shared: DriveConstraints', function () {
this.drive, this.drive,
this.image, this.image,
); );
// @ts-ignore
const expectedTuples = [['WARNING', 'largeDrive']]; const expectedTuples = [['WARNING', 'largeDrive']];
// @ts-ignore expectStatusTypesAndMessagesToBe(
expectStatusTypesAndMessagesToBe(result, expectedTuples); result,
// @ts-ignore
expectedTuples,
this.drive.size,
);
}); });
}); });
@ -1169,7 +1169,7 @@ describe('Shared: DriveConstraints', function () {
); );
const expected = [ const expected = [
{ {
message: messages.compatibility.tooSmall('1 B'), message: messages.compatibility.tooSmall(),
type: constraints.COMPATIBILITY_STATUS_TYPES.ERROR, type: constraints.COMPATIBILITY_STATUS_TYPES.ERROR,
}, },
]; ];
@ -1220,7 +1220,7 @@ describe('Shared: DriveConstraints', function () {
mountpoints: [{ path: __dirname }], mountpoints: [{ path: __dirname }],
isSystem: false, isSystem: false,
isReadOnly: false, isReadOnly: false,
} as unknown) as DrivelistDrive, } as unknown) as constraints.DrivelistDrive,
({ ({
device: drivePaths[1], device: drivePaths[1],
description: 'My Other Drive', description: 'My Other Drive',
@ -1229,7 +1229,7 @@ describe('Shared: DriveConstraints', function () {
mountpoints: [], mountpoints: [],
isSystem: false, isSystem: false,
isReadOnly: true, isReadOnly: true,
} as unknown) as DrivelistDrive, } as unknown) as constraints.DrivelistDrive,
({ ({
device: drivePaths[2], device: drivePaths[2],
description: 'My Drive', description: 'My Drive',
@ -1238,7 +1238,7 @@ describe('Shared: DriveConstraints', function () {
mountpoints: [], mountpoints: [],
isSystem: false, isSystem: false,
isReadOnly: false, isReadOnly: false,
} as unknown) as DrivelistDrive, } as unknown) as constraints.DrivelistDrive,
({ ({
device: drivePaths[3], device: drivePaths[3],
description: 'My Drive', description: 'My Drive',
@ -1247,16 +1247,16 @@ describe('Shared: DriveConstraints', function () {
mountpoints: [], mountpoints: [],
isSystem: true, isSystem: true,
isReadOnly: false, isReadOnly: false,
} as unknown) as DrivelistDrive, } as unknown) as constraints.DrivelistDrive,
({ ({
device: drivePaths[4], device: drivePaths[4],
description: 'My Drive', description: 'My Drive',
size: 64000000001, size: 128000000001,
displayName: drivePaths[4], displayName: drivePaths[4],
mountpoints: [], mountpoints: [],
isSystem: false, isSystem: false,
isReadOnly: false, isReadOnly: false,
} as unknown) as DrivelistDrive, } as unknown) as constraints.DrivelistDrive,
({ ({
device: drivePaths[5], device: drivePaths[5],
description: 'My Drive', description: 'My Drive',
@ -1265,7 +1265,7 @@ describe('Shared: DriveConstraints', function () {
mountpoints: [], mountpoints: [],
isSystem: false, isSystem: false,
isReadOnly: false, isReadOnly: false,
} as unknown) as DrivelistDrive, } as unknown) as constraints.DrivelistDrive,
({ ({
device: drivePaths[6], device: drivePaths[6],
description: 'My Drive', description: 'My Drive',
@ -1274,11 +1274,14 @@ describe('Shared: DriveConstraints', function () {
mountpoints: [], mountpoints: [],
isSystem: false, isSystem: false,
isReadOnly: 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'), path: path.join(__dirname, 'rpi.img'),
SourceType: sourceDestination.File,
// @ts-ignore // @ts-ignore
size: drives[2].size + 1, size: drives[2].size + 1,
isSizeEstimated: false, isSizeEstimated: false,
@ -1331,7 +1334,7 @@ describe('Shared: DriveConstraints', function () {
), ),
).to.deep.equal([ ).to.deep.equal([
{ {
message: 'Insufficient space, additional 1 B required', message: 'Too small',
type: 2, type: 2,
}, },
]); ]);
@ -1373,7 +1376,7 @@ describe('Shared: DriveConstraints', function () {
), ),
).to.deep.equal([ ).to.deep.equal([
{ {
message: 'Not Recommended', message: 'Not recommended',
type: 1, type: 1,
}, },
]); ]);
@ -1394,7 +1397,7 @@ describe('Shared: DriveConstraints', function () {
type: 2, type: 2,
}, },
{ {
message: 'Insufficient space, additional 1 B required', message: 'Too small',
type: 2, type: 2,
}, },
{ {
@ -1406,157 +1409,11 @@ describe('Shared: DriveConstraints', function () {
type: 1, type: 1,
}, },
{ {
message: 'Not Recommended', message: 'Not recommended',
type: 1, 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;
});
});
});
}); });

View File

@ -15,45 +15,13 @@
*/ */
import { expect } from 'chai'; import { expect } from 'chai';
import * as units from '../../lib/shared/units'; import { bytesToMegabytes } from '../../lib/shared/units';
describe('Shared: Units', function () { 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 () { describe('.bytesToMegabytes()', function () {
it('should convert bytes to megabytes', function () { it('should convert bytes to megabytes', function () {
expect(units.bytesToMegabytes(1.2e7)).to.equal(12); expect(bytesToMegabytes(1.2e7)).to.equal(12);
expect(units.bytesToMegabytes(332000)).to.equal(0.332); expect(bytesToMegabytes(332000)).to.equal(0.332);
}); });
}); });
}); });

View File

@ -4,6 +4,8 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"target": "es2019",
"moduleResolution": "node",
"jsx": "react", "jsx": "react",
"typeRoots": ["./node_modules/@types", "./typings"] "typeRoots": ["./node_modules/@types", "./typings"]
} }