Merge branch 'save-url-image-2' of github.com:balena-io/etcher into save-url-image-2

This commit is contained in:
Lorenzo Alberto Maria Ambrosi 2020-10-19 12:54:34 +02:00
commit f6ce9a217d
40 changed files with 1296 additions and 993 deletions

View File

@ -3,6 +3,24 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/). This project adheres to [Semantic Versioning](http://semver.org/).
# v1.5.109
## (2020-09-14)
* Workaround elevation bug on Windows when the username contains an ampersand [Alexis Svinartchouk]
# v1.5.108
## (2020-09-10)
* Fix content not loading when the app path contains special characters [Alexis Svinartchouk]
# v1.5.107
## (2020-09-04)
* Re-enable ext partitions trimming on 32 bit Windows [Alexis Svinartchouk]
* Rework system & large drives handling logic [Lorenzo Alberto Maria Ambrosi]
* Reword macOS Catalina askpass message [Lorenzo Alberto Maria Ambrosi]
* Add clone-drive workflow [Lorenzo Alberto Maria Ambrosi]
# v1.5.106 # v1.5.106
## (2020-08-27) ## (2020-08-27)

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) {
@ -352,6 +356,16 @@ async function main() {
ReactDOM.render( ReactDOM.render(
React.createElement(MainPage), React.createElement(MainPage),
document.getElementById('main'), document.getElementById('main'),
// callback to set the correct zoomFactor for webviews as well
async () => {
const fullscreen = await settings.get('fullscreen');
const width = fullscreen ? window.screen.width : window.outerWidth;
try {
electron.webFrame.setZoomFactor(width / settings.DEFAULT_WIDTH);
} catch (err) {
// noop
}
},
); );
} }

View File

@ -18,15 +18,7 @@ import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exc
import ChevronDownSvg from '@fortawesome/fontawesome-free/svgs/solid/chevron-down.svg'; import ChevronDownSvg from '@fortawesome/fontawesome-free/svgs/solid/chevron-down.svg';
import * as sourceDestination from 'etcher-sdk/build/source-destination/'; import * as sourceDestination from 'etcher-sdk/build/source-destination/';
import * as React from 'react'; import * as React from 'react';
import { import { Flex, ModalProps, Txt, Badge, Link, TableColumn } from 'rendition';
Flex,
ModalProps,
Txt,
Badge,
Link,
Table,
TableColumn,
} from 'rendition';
import styled from 'styled-components'; import styled from 'styled-components';
import { import {
@ -43,7 +35,12 @@ import { getImage, 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 { Alert, Modal, ScrollableFlex } from '../../styled-components'; import {
Alert,
GenericTableProps,
Modal,
Table,
} from '../../styled-components';
import DriveSVGIcon from '../../../assets/tgt.svg'; import DriveSVGIcon from '../../../assets/tgt.svg';
import { SourceMetadata } from '../source-selector/source-selector'; import { SourceMetadata } from '../source-selector/source-selector';
@ -75,29 +72,14 @@ function isDrivelistDrive(drive: Drive): drive is DrivelistDrive {
return typeof (drive as DrivelistDrive).size === 'number'; return typeof (drive as DrivelistDrive).size === 'number';
} }
const DrivesTable = styled(({ refFn, ...props }) => ( const DrivesTable = styled((props: GenericTableProps<Drive>) => (
<div> <Table<Drive> {...props} />
<Table<Drive> ref={refFn} {...props} />
</div>
))` ))`
[data-display='table-head'] [data-display='table-head'],
> [data-display='table-row'] [data-display='table-body'] {
> [data-display='table-cell'] { > [data-display='table-row'] > [data-display='table-cell'] {
position: sticky;
top: 0;
background-color: ${(props) => props.theme.colors.quartenary.light};
input[type='checkbox'] + div {
display: ${({ multipleSelection }) =>
multipleSelection ? 'flex' : 'none'};
}
&:first-child {
padding-left: 15px;
}
&:nth-child(2) { &:nth-child(2) {
width: 38%; width: 32%;
} }
&:nth-child(3) { &:nth-child(3) {
@ -112,36 +94,6 @@ const DrivesTable = styled(({ refFn, ...props }) => (
width: 32%; width: 32%;
} }
} }
[data-display='table-body'] > [data-display='table-row'] {
> [data-display='table-cell']:first-child {
padding-left: 15px;
}
> [data-display='table-cell']:last-child {
padding-right: 0;
}
&[data-highlight='true'] {
&.system {
background-color: ${(props) =>
props.showWarnings ? '#fff5e6' : '#e8f5fc'};
}
> [data-display='table-cell']:first-child {
box-shadow: none;
}
}
}
&& [data-display='table-row'] > [data-display='table-cell'] {
padding: 6px 8px;
color: #2a506f;
}
input[type='checkbox'] + div {
border-radius: ${({ multipleSelection }) =>
multipleSelection ? '4px' : '50%'};
} }
`; `;
@ -289,8 +241,8 @@ export class DriveSelector 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: <Txt></Txt>, label: <></>,
render: (_description: string, drive: Drive) => { render: (_description: string, drive: Drive) => {
if (isUsbbootDrive(drive)) { if (isUsbbootDrive(drive)) {
return this.renderProgress(drive.progress); return this.renderProgress(drive.progress);
@ -393,6 +345,16 @@ export class DriveSelector extends React.Component<
} }
} }
private deselectingAll(rows: DrivelistDrive[]) {
return (
rows.length > 0 &&
rows.length === this.state.selectedList.length &&
this.state.selectedList.every(
(d) => rows.findIndex((r) => d.device === r.device) > -1,
)
);
}
componentDidMount() { componentDidMount() {
this.unsubscribe = store.subscribe(() => { this.unsubscribe = store.subscribe(() => {
const drives = getDrives(); const drives = getDrives();
@ -453,7 +415,6 @@ export class DriveSelector extends React.Component<
}} }}
{...props} {...props}
> >
<Flex width="100%" height="90%">
{!hasAvailableDrives() ? ( {!hasAvailableDrives() ? (
<Flex <Flex
flexDirection="column" flexDirection="column"
@ -465,13 +426,14 @@ export class DriveSelector extends React.Component<
<b>{this.props.emptyListLabel}</b> <b>{this.props.emptyListLabel}</b>
</Flex> </Flex>
) : ( ) : (
<ScrollableFlex flexDirection="column" width="100%"> <>
<DrivesTable <DrivesTable
refFn={(t: Table<Drive>) => { refFn={(t) => {
if (t !== null) { if (t !== null) {
t.setRowSelection(selectedList); t.setRowSelection(selectedList);
} }
}} }}
checkedRowsNumber={selectedList.length}
multipleSelection={this.props.multipleSelection} multipleSelection={this.props.multipleSelection}
columns={this.tableColumns} columns={this.tableColumns}
data={displayedDrives} data={displayedDrives}
@ -481,8 +443,11 @@ export class DriveSelector extends React.Component<
} }
rowKey="displayName" rowKey="displayName"
onCheck={(rows: Drive[]) => { onCheck={(rows: Drive[]) => {
const newSelection = rows.filter(isDrivelistDrive); let newSelection = rows.filter(isDrivelistDrive);
if (this.props.multipleSelection) { if (this.props.multipleSelection) {
if (this.deselectingAll(newSelection)) {
newSelection = [];
}
this.setState({ this.setState({
selectedList: newSelection, selectedList: newSelection,
}); });
@ -499,25 +464,21 @@ export class DriveSelector extends React.Component<
) { ) {
return; return;
} }
if (this.props.multipleSelection) { const index = selectedList.findIndex(
const newList = [...selectedList]; (d) => d.device === row.device,
const selectedIndex = selectedList.findIndex(
(drive) => drive.device === row.device,
); );
if (selectedIndex === -1) { const newList = this.props.multipleSelection
? [...selectedList]
: [];
if (index === -1) {
newList.push(row); newList.push(row);
} else { } else {
// Deselect if selected // Deselect if selected
newList.splice(selectedIndex, 1); newList.splice(index, 1);
} }
this.setState({ this.setState({
selectedList: newList, selectedList: newList,
}); });
return;
}
this.setState({
selectedList: [row],
});
}} }}
/> />
{numberOfHiddenSystemDrives > 0 && ( {numberOfHiddenSystemDrives > 0 && (
@ -533,15 +494,13 @@ export class DriveSelector extends React.Component<
</Flex> </Flex>
</Link> </Link>
)} )}
</ScrollableFlex> </>
)} )}
{this.props.showWarnings && hasSystemDrives ? ( {this.props.showWarnings && hasSystemDrives ? (
<Alert className="system-drive-alert" style={{ width: '67%' }}> <Alert className="system-drive-alert" style={{ width: '67%' }}>
Selecting your system drive is dangerous and will erase your Selecting your system drive is dangerous and will erase your drive!
drive!
</Alert> </Alert>
) : null} ) : null}
</Flex>
{missingDriversModal.drive !== undefined && ( {missingDriversModal.drive !== undefined && (
<Modal <Modal

View File

@ -5,7 +5,7 @@ import { Badge, Flex, Txt, ModalProps } from 'rendition';
import { Modal, ScrollableFlex } from '../../styled-components'; import { Modal, ScrollableFlex } from '../../styled-components';
import { middleEllipsis } from '../../utils/middle-ellipsis'; import { middleEllipsis } from '../../utils/middle-ellipsis';
import { bytesToClosestUnit } from '../../../../shared/units'; import * as prettyBytes from 'pretty-bytes';
import { DriveWithWarnings } from '../../pages/main/Flash'; import { DriveWithWarnings } from '../../pages/main/Flash';
const DriveStatusWarningModal = ({ const DriveStatusWarningModal = ({
@ -66,7 +66,7 @@ const DriveStatusWarningModal = ({
<> <>
<Flex justifyContent="space-between" alignItems="baseline"> <Flex justifyContent="space-between" alignItems="baseline">
<strong>{middleEllipsis(drive.description, 28)}</strong>{' '} <strong>{middleEllipsis(drive.description, 28)}</strong>{' '}
{bytesToClosestUnit(drive.size || 0)}{' '} {drive.size && prettyBytes(drive.size) + ' '}
<Badge shade={5}>{drive.statuses[0].message}</Badge> <Badge shade={5}>{drive.statuses[0].message}</Badge>
</Flex> </Flex>
{i !== array.length - 1 ? <hr style={{ width: '100%' }} /> : null} {i !== array.length - 1 ? <hr style={{ width: '100%' }} /> : null}

View File

@ -14,7 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
import * as _ from 'lodash';
import * as React from 'react'; import * as React from 'react';
import { Flex } from 'rendition'; import { Flex } from 'rendition';
import { v4 as uuidV4 } from 'uuid'; import { v4 as uuidV4 } from 'uuid';
@ -23,13 +22,9 @@ import * as flashState from '../../models/flash-state';
import * as selectionState from '../../models/selection-state'; import * as selectionState from '../../models/selection-state';
import { Actions, store } from '../../models/store'; import { Actions, store } from '../../models/store';
import * as analytics from '../../modules/analytics'; import * as analytics from '../../modules/analytics';
import { open as openExternal } from '../../os/open-external/services/open-external';
import { FlashAnother } from '../flash-another/flash-another'; import { FlashAnother } from '../flash-another/flash-another';
import { FlashResults } from '../flash-results/flash-results'; import { FlashResults, FlashError } from '../flash-results/flash-results';
import { SafeWebview } from '../safe-webview/safe-webview';
import EtcherSvg from '../../../assets/etcher.svg';
import LoveSvg from '../../../assets/love.svg';
import BalenaSvg from '../../../assets/balena.svg';
function restart(goToMain: () => void) { function restart(goToMain: () => void) {
selectionState.deselectAllDrives(); selectionState.deselectAllDrives();
@ -44,22 +39,59 @@ function restart(goToMain: () => void) {
goToMain(); goToMain();
} }
function formattedErrors() {
const errors = _.map(
_.get(flashState.getFlashResults(), ['results', 'errors']),
(error) => {
return `${error.device}: ${error.message || error.code}`;
},
);
return errors.join('\n');
}
function FinishPage({ goToMain }: { goToMain: () => void }) { function FinishPage({ goToMain }: { goToMain: () => void }) {
const results = flashState.getFlashResults().results || {}; const [webviewShowing, setWebviewShowing] = React.useState(false);
const flashResults = flashState.getFlashResults();
let errors: FlashError[] = flashResults.results?.errors;
if (errors === undefined) {
errors = (store.getState().toJS().failedDevicePaths || []).map(
([, error]: [string, FlashError]) => ({
...error,
}),
);
}
const {
averageSpeed,
blockmappedSize,
bytesWritten,
failed,
size,
} = flashState.getFlashState();
const {
skip,
results = {
bytesWritten,
sourceMetadata: {
size,
blockmappedSize,
},
averageFlashingSpeed: averageSpeed,
devices: { failed, successful: 0 },
},
} = flashResults;
return ( return (
<Flex flexDirection="column" width="100%" color="#fff"> <Flex height="100%" justifyContent="space-between">
<Flex height="160px" alignItems="center" justifyContent="center"> <Flex
<FlashResults results={results} errors={formattedErrors()} /> width={webviewShowing ? '36.2vw' : '100vw'}
height="100vh"
alignItems="center"
justifyContent="center"
flexDirection="column"
style={{
position: 'absolute',
top: 0,
zIndex: 1,
boxShadow: '0 2px 15px 0 rgba(0, 0, 0, 0.2)',
}}
>
<FlashResults
image={selectionState.getImageName()}
results={results}
skip={skip}
errors={errors}
mb="32px"
goToMain={goToMain}
/>
<FlashAnother <FlashAnother
onClick={() => { onClick={() => {
@ -67,35 +99,19 @@ function FinishPage({ goToMain }: { goToMain: () => void }) {
}} }}
/> />
</Flex> </Flex>
<SafeWebview
<Flex src="https://www.balena.io/etcher/success-banner?borderTop=false&darkBackground=true"
flexDirection="column" onWebviewShow={setWebviewShowing}
height="320px" style={{
justifyContent="space-between" display: webviewShowing ? 'flex' : 'none',
alignItems="center" position: 'absolute',
> right: 0,
<Flex fontSize="28px" mt="40px"> bottom: 0,
Thanks for using width: '63.8vw',
<EtcherSvg height: '100vh',
width="165px" }}
style={{ margin: '0 10px', cursor: 'pointer' }}
onClick={() =>
openExternal('https://balena.io/etcher?ref=etcher_offline_banner')
}
/> />
</Flex> </Flex>
<Flex mb="10px">
made with
<LoveSvg height="20px" style={{ margin: '0 10px' }} />
by
<BalenaSvg
height="20px"
style={{ margin: '0 10px', cursor: 'pointer' }}
onClick={() => openExternal('https://balena.io?ref=etcher_success')}
/>
</Flex>
</Flex>
</Flex>
); );
} }

View File

@ -25,7 +25,7 @@ export interface FlashAnotherProps {
export const FlashAnother = (props: FlashAnotherProps) => { export const FlashAnother = (props: FlashAnotherProps) => {
return ( return (
<BaseButton primary onClick={props.onClick}> <BaseButton primary onClick={props.onClick}>
Flash Another Flash another
</BaseButton> </BaseButton>
); );
}; };

View File

@ -16,19 +16,108 @@
import CircleSvg from '@fortawesome/fontawesome-free/svgs/solid/circle.svg'; import CircleSvg from '@fortawesome/fontawesome-free/svgs/solid/circle.svg';
import CheckCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/check-circle.svg'; import CheckCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/check-circle.svg';
import TimesCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/times-circle.svg';
import * as _ from 'lodash'; import * as _ from 'lodash';
import outdent from 'outdent'; import outdent from 'outdent';
import * as React from 'react'; import * as React from 'react';
import { Flex, Txt } from 'rendition'; import { Flex, FlexProps, Link, TableColumn, Txt } from 'rendition';
import styled from 'styled-components';
import { progress } from '../../../../shared/messages'; import { progress } from '../../../../shared/messages';
import { bytesToMegabytes } from '../../../../shared/units'; import { bytesToMegabytes } from '../../../../shared/units';
import FlashSvg from '../../../assets/flash.svg';
import { resetState } from '../../models/flash-state';
import * as selection from '../../models/selection-state';
import { middleEllipsis } from '../../utils/middle-ellipsis';
import { Modal, Table } from '../../styled-components';
const ErrorsTable = styled((props) => <Table<FlashError> {...props} />)`
[data-display='table-head'],
[data-display='table-body'] {
[data-display='table-cell'] {
&:first-child {
width: 30%;
}
&:nth-child(2) {
width: 20%;
}
&:last-child {
width: 50%;
}
}
`;
const DoneIcon = (props: {
skipped: boolean;
allFailed: boolean;
someFailed: boolean;
}) => {
const { allFailed, someFailed } = props;
const someOrAllFailed = allFailed || someFailed;
const svgProps = {
width: '24px',
fill: someOrAllFailed ? '#c6c8c9' : '#1ac135',
style: {
width: '28px',
height: '28px',
marginTop: '-25px',
marginLeft: '13px',
zIndex: 1,
color: someOrAllFailed ? '#c6c8c9' : '#1ac135',
},
};
return allFailed && !props.skipped ? (
<TimesCircleSvg {...svgProps} />
) : (
<CheckCircleSvg {...svgProps} />
);
};
export interface FlashError extends Error {
description: string;
device: string;
code: string;
}
function formattedErrors(errors: FlashError[]) {
return errors
.map((error) => `${error.device}: ${error.message || error.code}`)
.join('\n');
}
const columns: Array<TableColumn<FlashError>> = [
{
field: 'description',
label: 'Target',
},
{
field: 'device',
label: 'Location',
},
{
field: 'message',
label: 'Error',
render: (message: string, { code }: FlashError) => {
return message ?? code;
},
},
];
export function FlashResults({ export function FlashResults({
goToMain,
image = '',
errors, errors,
results, results,
skip,
...props
}: { }: {
errors: string; goToMain: () => void;
image?: string;
errors: FlashError[];
skip: boolean;
results: { results: {
bytesWritten: number; bytesWritten: number;
sourceMetadata: { sourceMetadata: {
@ -38,8 +127,9 @@ export function FlashResults({
averageFlashingSpeed: number; averageFlashingSpeed: number;
devices: { failed: number; successful: number }; devices: { failed: number; successful: number };
}; };
}) { } & FlexProps) {
const allDevicesFailed = results.devices.successful === 0; const [showErrorsInfo, setShowErrorsInfo] = React.useState(false);
const allFailed = results.devices.successful === 0;
const effectiveSpeed = _.round( const effectiveSpeed = _.round(
bytesToMegabytes( bytesToMegabytes(
results.sourceMetadata.size / results.sourceMetadata.size /
@ -48,44 +138,56 @@ export function FlashResults({
1, 1,
); );
return ( return (
<Flex <Flex flexDirection="column" {...props}>
flexDirection="column" <Flex alignItems="center" flexDirection="column">
mr="80px"
height="90px"
style={{
position: 'relative',
top: '25px',
}}
>
<Flex alignItems="center">
<CheckCircleSvg
width="24px"
fill={allDevicesFailed ? '#c6c8c9' : '#1ac135'}
style={{
margin: '0 15px 0 0',
}}
/>
<Txt fontSize={24} color="#fff">
Flash Complete!
</Txt>
</Flex>
<Flex flexDirection="column" mr="0" mb="0" ml="40px" color="#7e8085">
{Object.entries(results.devices).map(([type, quantity]) => {
return quantity ? (
<Flex <Flex
alignItems="center" alignItems="center"
tooltip={type === 'failed' ? errors : undefined} mt="50px"
mb="32px"
color="#7e8085"
flexDirection="column"
> >
<FlashSvg width="40px" height="40px" className="disabled" />
<DoneIcon
skipped={skip}
allFailed={allFailed}
someFailed={results.devices.failed !== 0}
/>
<Txt>{middleEllipsis(image, 24)}</Txt>
</Flex>
<Txt fontSize={24} color="#fff" mb="17px">
Flash Complete!
</Txt>
{skip ? <Flex color="#7e8085">Validation has been skipped</Flex> : null}
</Flex>
<Flex flexDirection="column" color="#7e8085">
{Object.entries(results.devices).map(([type, quantity]) => {
const failedTargets = type === 'failed';
return quantity ? (
<Flex alignItems="center">
<CircleSvg <CircleSvg
width="14px" width="14px"
fill={type === 'failed' ? '#ff4444' : '#1ac135'} fill={type === 'failed' ? '#ff4444' : '#1ac135'}
color={failedTargets ? '#ff4444' : '#1ac135'}
/> />
<Txt ml={10}>{quantity}</Txt> <Txt ml="10px" color="#fff">
<Txt ml={10}>{progress[type](quantity)}</Txt> {quantity}
</Txt>
<Txt
ml="10px"
tooltip={failedTargets ? formattedErrors(errors) : undefined}
>
{progress[type](quantity)}
</Txt>
{failedTargets && (
<Link ml="10px" onClick={() => setShowErrorsInfo(true)}>
more info
</Link>
)}
</Flex> </Flex>
) : null; ) : null;
})} })}
{!allDevicesFailed && ( {!allFailed && (
<Txt <Txt
fontSize="10px" fontSize="10px"
style={{ style={{
@ -101,6 +203,33 @@ export function FlashResults({
</Txt> </Txt>
)} )}
</Flex> </Flex>
{showErrorsInfo && (
<Modal
titleElement={
<Flex alignItems="baseline" mb={18}>
<Txt fontSize={24} align="left">
Failed targets
</Txt>
</Flex>
}
action="Retry failed targets"
cancel={() => setShowErrorsInfo(false)}
done={() => {
setShowErrorsInfo(false);
resetState();
selection
.getSelectedDrives()
.filter((drive) =>
errors.every((error) => error.device !== drive.device),
)
.forEach((drive) => selection.deselectDrive(drive.device));
goToMain();
}}
>
<ErrorsTable columns={columns} data={errors} />
</Modal>
)}
</Flex> </Flex>
); );
} }

View File

@ -18,7 +18,7 @@ import * as React from 'react';
import { Flex, Button, ProgressBar, Txt } from 'rendition'; import { Flex, Button, ProgressBar, Txt } from 'rendition';
import { default as styled } from 'styled-components'; import { default as styled } from 'styled-components';
import { fromFlashState } from '../../modules/progress-status'; import { fromFlashState, FlashState } from '../../modules/progress-status';
import { StepButton } from '../../styled-components'; import { StepButton } from '../../styled-components';
const FlashProgressBar = styled(ProgressBar)` const FlashProgressBar = styled(ProgressBar)`
@ -44,12 +44,12 @@ const FlashProgressBar = styled(ProgressBar)`
`; `;
interface ProgressButtonProps { interface ProgressButtonProps {
type: 'decompressing' | 'flashing' | 'verifying'; type: FlashState['type'];
active: boolean; active: boolean;
percentage: number; percentage: number;
position: number; position: number;
disabled: boolean; disabled: boolean;
cancel: () => void; cancel: (type: string) => void;
callback: () => void; callback: () => void;
warning?: boolean; warning?: boolean;
} }
@ -58,13 +58,18 @@ const colors = {
decompressing: '#00aeef', decompressing: '#00aeef',
flashing: '#da60ff', flashing: '#da60ff',
verifying: '#1ac135', verifying: '#1ac135',
downloading: '#00aeef',
default: '#00aeef',
} as const; } as const;
const CancelButton = styled((props) => ( const CancelButton = styled(({ type, onClick, ...props }) => {
<Button plain {...props}> const status = type === 'verifying' ? 'Skip' : 'Cancel';
Cancel return (
<Button plain onClick={() => onClick(status)} {...props}>
{status}
</Button> </Button>
))` );
})`
font-weight: 600; font-weight: 600;
&&& { &&& {
width: auto; width: auto;
@ -75,10 +80,13 @@ const CancelButton = styled((props) => (
export class ProgressButton extends React.PureComponent<ProgressButtonProps> { export class ProgressButton extends React.PureComponent<ProgressButtonProps> {
public render() { public render() {
const type = this.props.type || 'default';
const percentage = this.props.percentage;
const warning = this.props.warning;
const { status, position } = fromFlashState({ const { status, position } = fromFlashState({
type: this.props.type, type: this.props.type,
percentage,
position: this.props.position, position: this.props.position,
percentage: this.props.percentage,
}); });
if (this.props.active) { if (this.props.active) {
return ( return (
@ -96,21 +104,24 @@ export class ProgressButton extends React.PureComponent<ProgressButtonProps> {
> >
<Flex> <Flex>
<Txt color="#fff">{status}&nbsp;</Txt> <Txt color="#fff">{status}&nbsp;</Txt>
<Txt color={colors[this.props.type]}>{position}</Txt> <Txt color={colors[type]}>{position}</Txt>
</Flex> </Flex>
<CancelButton onClick={this.props.cancel} color="#00aeef" /> {type && (
</Flex> <CancelButton
<FlashProgressBar type={type}
background={colors[this.props.type]} onClick={this.props.cancel}
value={this.props.percentage} color="#00aeef"
/> />
)}
</Flex>
<FlashProgressBar background={colors[type]} value={percentage} />
</> </>
); );
} }
return ( return (
<StepButton <StepButton
primary={!this.props.warning} primary={!warning}
warning={this.props.warning} warning={warning}
onClick={this.props.callback} onClick={this.props.callback}
disabled={this.props.disabled} disabled={this.props.disabled}
style={{ style={{

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

@ -15,7 +15,6 @@
*/ */
import * as electron from 'electron'; import * as electron from 'electron';
import * as _ from 'lodash';
import * as React from 'react'; import * as React from 'react';
import * as packageJSON from '../../../../../package.json'; import * as packageJSON from '../../../../../package.json';
@ -94,8 +93,8 @@ export class SafeWebview extends React.PureComponent<
); );
this.entryHref = url.href; this.entryHref = url.href;
// Events steal 'this' // Events steal 'this'
this.didFailLoad = _.bind(this.didFailLoad, this); this.didFailLoad = this.didFailLoad.bind(this);
this.didGetResponseDetails = _.bind(this.didGetResponseDetails, this); this.didGetResponseDetails = this.didGetResponseDetails.bind(this);
// Make a persistent electron session for the webview // Make a persistent electron session for the webview
this.session = electron.remote.session.fromPartition(ELECTRON_SESSION, { this.session = electron.remote.session.fromPartition(ELECTRON_SESSION, {
// Disable the cache for the session such that new content shows up when refreshing // Disable the cache for the session such that new content shows up when refreshing

View File

@ -61,7 +61,7 @@ async function getSettingsList(): Promise<Setting[]> {
{ {
name: 'updatesEnabled', name: 'updatesEnabled',
label: 'Auto-updates enabled', label: 'Auto-updates enabled',
hide: _.includes(['rpm', 'deb'], packageType), hide: ['rpm', 'deb'].includes(packageType),
}, },
]; ];
} }
@ -121,9 +121,9 @@ export function SettingsModal({ toggleModal }: SettingsModalProps) {
done={() => toggleModal(false)} done={() => toggleModal(false)}
> >
<Flex flexDirection="column"> <Flex flexDirection="column">
{_.map(settingsList, (setting: Setting, i: number) => { {settingsList.map((setting: Setting, i: number) => {
return setting.hide ? null : ( return setting.hide ? null : (
<Flex key={setting.name}> <Flex key={setting.name} mb={14}>
<Checkbox <Checkbox
toggle toggle
tabIndex={6 + i} tabIndex={6 + i}
@ -135,12 +135,13 @@ export function SettingsModal({ toggleModal }: SettingsModalProps) {
); );
})} })}
<Flex <Flex
mt={28} mt={18}
alignItems="center" alignItems="center"
color="#00aeef" color="#00aeef"
style={{ style={{
width: 'fit-content', width: 'fit-content',
cursor: 'pointer', cursor: 'pointer',
fontSize: 14,
}} }}
onClick={() => onClick={() =>
openExternal( openExternal(

View File

@ -25,15 +25,7 @@ import { GPTPartition, MBRPartition } from 'partitioninfo';
import * as path from 'path'; import * as path from 'path';
import * as prettyBytes from 'pretty-bytes'; import * as prettyBytes from 'pretty-bytes';
import * as React from 'react'; import * as React from 'react';
import { import { Flex, ButtonProps, Modal as SmallModal, Txt } from 'rendition';
Flex,
ButtonProps,
Modal as SmallModal,
Txt,
Card as BaseCard,
Input,
Spinner,
} from 'rendition';
import styled from 'styled-components'; import styled from 'styled-components';
import * as errors from '../../../../shared/errors'; import * as errors from '../../../../shared/errors';
@ -48,62 +40,21 @@ import { replaceWindowsNetworkDriveLetter } from '../../os/windows-network-drive
import { import {
ChangeButton, ChangeButton,
DetailsText, DetailsText,
Modal,
StepButton, StepButton,
StepNameButton, StepNameButton,
ScrollableFlex,
} from '../../styled-components'; } from '../../styled-components';
import { colors } from '../../theme'; import { colors } from '../../theme';
import { middleEllipsis } from '../../utils/middle-ellipsis'; import { middleEllipsis } from '../../utils/middle-ellipsis';
import URLSelector from '../url-selector/url-selector';
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 { DriveSelector } from '../drive-selector/drive-selector';
import { DrivelistDrive } from '../../../../shared/drive-constraints'; import { DrivelistDrive } from '../../../../shared/drive-constraints';
const recentUrlImagesKey = 'recentUrlImages';
function normalizeRecentUrlImages(urls: any[]): URL[] {
if (!Array.isArray(urls)) {
urls = [];
}
urls = urls
.map((url) => {
try {
return new URL(url);
} catch (error) {
// Invalid URL, skip
}
})
.filter((url) => url !== undefined);
urls = _.uniqBy(urls, (url) => url.href);
return urls.slice(urls.length - 5);
}
function getRecentUrlImages(): URL[] {
let urls = [];
try {
urls = JSON.parse(localStorage.getItem(recentUrlImagesKey) || '[]');
} catch {
// noop
}
return normalizeRecentUrlImages(urls);
}
function setRecentUrlImages(urls: URL[]) {
const normalized = normalizeRecentUrlImages(urls.map((url: URL) => url.href));
localStorage.setItem(recentUrlImagesKey, JSON.stringify(normalized));
}
const isURL = (imagePath: string) => const isURL = (imagePath: string) =>
imagePath.startsWith('https://') || imagePath.startsWith('http://'); imagePath.startsWith('https://') || imagePath.startsWith('http://');
const Card = styled(BaseCard)`
hr {
margin: 5px 0;
}
`;
// TODO move these styles to rendition // TODO move these styles to rendition
const ModalText = styled.p` const ModalText = styled.p`
a { a {
@ -127,85 +78,6 @@ function isString(value: any): value is string {
return typeof value === 'string'; return typeof value === 'string';
} }
const URLSelector = ({
done,
cancel,
}: {
done: (imageURL: string) => void;
cancel: () => void;
}) => {
const [imageURL, setImageURL] = React.useState('');
const [recentImages, setRecentImages] = React.useState<URL[]>([]);
const [loading, setLoading] = React.useState(false);
React.useEffect(() => {
const fetchRecentUrlImages = async () => {
const recentUrlImages: URL[] = await getRecentUrlImages();
setRecentImages(recentUrlImages);
};
fetchRecentUrlImages();
}, []);
return (
<Modal
cancel={cancel}
primaryButtonProps={{
disabled: loading || !imageURL,
}}
action={loading ? <Spinner /> : 'OK'}
done={async () => {
setLoading(true);
const urlStrings = recentImages.map((url: URL) => url.href);
const normalizedRecentUrls = normalizeRecentUrlImages([
...urlStrings,
imageURL,
]);
setRecentUrlImages(normalizedRecentUrls);
await done(imageURL);
}}
>
<Flex flexDirection="column">
<Flex style={{ width: '100%' }} flexDirection="column">
<Txt mb="10px" fontSize="24px">
Use Image URL
</Txt>
<Input
value={imageURL}
placeholder="Enter a valid URL"
type="text"
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>
</Modal>
);
};
interface Flow { interface Flow {
icon?: JSX.Element; icon?: JSX.Element;
onClick: (evt: React.MouseEvent) => void; onClick: (evt: React.MouseEvent) => void;
@ -213,22 +85,28 @@ interface Flow {
} }
const FlowSelector = styled( const FlowSelector = styled(
({ flow, ...props }: { flow: Flow; props?: ButtonProps }) => { ({ flow, ...props }: { flow: Flow } & ButtonProps) => (
return (
<StepButton <StepButton
plain plain={!props.primary}
onClick={(evt) => flow.onClick(evt)} primary={props.primary}
onClick={(evt: React.MouseEvent<Element, MouseEvent>) =>
flow.onClick(evt)
}
icon={flow.icon} icon={flow.icon}
{...props} {...props}
> >
{flow.label} {flow.label}
</StepButton> </StepButton>
); ),
},
)` )`
border-radius: 24px; border-radius: 24px;
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
:enabled:focus,
:enabled:focus svg {
color: ${colors.primary.foreground} !important;
}
:enabled:hover { :enabled:hover {
background-color: ${colors.primary.background}; background-color: ${colors.primary.background};
color: ${colors.primary.foreground}; color: ${colors.primary.foreground};
@ -254,6 +132,7 @@ export interface SourceMetadata extends sourceDestination.Metadata {
SourceType: Source; SourceType: Source;
drive?: DrivelistDrive; drive?: DrivelistDrive;
extension?: string; extension?: string;
archiveExtension?: string;
} }
interface SourceSelectorProps { interface SourceSelectorProps {
@ -262,12 +141,13 @@ interface SourceSelectorProps {
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; showDriveSelector: boolean;
defaultFlowActive: boolean;
} }
export class SourceSelector extends React.Component< export class SourceSelector extends React.Component<
@ -284,7 +164,11 @@ export class SourceSelector extends React.Component<
showImageDetails: false, showImageDetails: false,
showURLSelector: false, showURLSelector: false,
showDriveSelector: false, showDriveSelector: false,
defaultFlowActive: true,
}; };
// Bind `this` since it's used in an event's callback
this.onSelectImage = this.onSelectImage.bind(this);
} }
public componentDidMount() { public componentDidMount() {
@ -526,6 +410,10 @@ export class SourceSelector extends React.Component<
}); });
} }
private setDefaultFlowActive(defaultFlowActive: boolean) {
this.setState({ defaultFlowActive });
}
// TODO add a visual change when dragging a file over the selector // TODO add a visual change when dragging a file over the selector
public render() { public render() {
const { flashing } = this.props; const { flashing } = this.props;
@ -543,7 +431,7 @@ export class SourceSelector extends React.Component<
const imagePath = image.path || image.displayName || ''; const imagePath = image.path || image.displayName || '';
const imageBasename = path.basename(imagePath); const imageBasename = path.basename(imagePath);
const imageName = image.name || ''; const imageName = image.name || '';
const imageSize = image.size || 0; const imageSize = image.size;
const imageLogo = image.logo || ''; const imageLogo = image.logo || '';
return ( return (
@ -585,17 +473,22 @@ export class SourceSelector extends React.Component<
Remove Remove
</ChangeButton> </ChangeButton>
)} )}
{!_.isNil(imageSize) && (
<DetailsText>{prettyBytes(imageSize)}</DetailsText> <DetailsText>{prettyBytes(imageSize)}</DetailsText>
)}
</> </>
) : ( ) : (
<> <>
<FlowSelector <FlowSelector
primary={this.state.defaultFlowActive}
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" />,
}} }}
onMouseEnter={() => this.setDefaultFlowActive(false)}
onMouseLeave={() => this.setDefaultFlowActive(true)}
/> />
<FlowSelector <FlowSelector
key="Flash from URL" key="Flash from URL"
@ -604,6 +497,8 @@ export class SourceSelector extends React.Component<
label: 'Flash from URL', label: 'Flash from URL',
icon: <LinkSvg height="1em" fill="currentColor" />, icon: <LinkSvg height="1em" fill="currentColor" />,
}} }}
onMouseEnter={() => this.setDefaultFlowActive(false)}
onMouseLeave={() => this.setDefaultFlowActive(true)}
/> />
<FlowSelector <FlowSelector
key="Clone drive" key="Clone drive"
@ -612,6 +507,8 @@ export class SourceSelector extends React.Component<
label: 'Clone drive', label: 'Clone drive',
icon: <CopySvg height="1em" fill="currentColor" />, icon: <CopySvg height="1em" fill="currentColor" />,
}} }}
onMouseEnter={() => this.setDefaultFlowActive(false)}
onMouseLeave={() => this.setDefaultFlowActive(true)}
/> />
</> </>
)} )}

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

@ -23,7 +23,7 @@ import {
DriveStatus, DriveStatus,
} from '../../../../shared/drive-constraints'; } from '../../../../shared/drive-constraints';
import { compatibility, warning } from '../../../../shared/messages'; import { compatibility, warning } from '../../../../shared/messages';
import { bytesToClosestUnit } from '../../../../shared/units'; import * as prettyBytes from 'pretty-bytes';
import { getSelectedDrives } from '../../models/selection-state'; import { getSelectedDrives } from '../../models/selection-state';
import { import {
ChangeButton, ChangeButton,
@ -96,7 +96,9 @@ export function TargetSelectorButton(props: TargetSelectorProps) {
Change Change
</ChangeButton> </ChangeButton>
)} )}
<DetailsText>{bytesToClosestUnit(target.size)}</DetailsText> {target.size != null && (
<DetailsText>{prettyBytes(target.size)}</DetailsText>
)}
</> </>
); );
} }
@ -110,16 +112,16 @@ export function TargetSelectorButton(props: TargetSelectorProps) {
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}
> >
{warnings.length && ( {warnings.length > 0 ? (
<DriveCompatibilityWarning warnings={warnings} mr={2} /> <DriveCompatibilityWarning warnings={warnings} mr={2} />
)} ) : null}
<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

@ -0,0 +1,167 @@
import { uniqBy } from 'lodash';
import * as React from 'react';
import Checkbox from 'rendition/dist_esm5/components/Checkbox';
import { Flex } from 'rendition/dist_esm5/components/Flex';
import Input from 'rendition/dist_esm5/components/Input';
import Link from 'rendition/dist_esm5/components/Link';
import RadioButton from 'rendition/dist_esm5/components/RadioButton';
import Txt from 'rendition/dist_esm5/components/Txt';
import * as settings from '../../models/settings';
import { Modal, ScrollableFlex } from '../../styled-components';
import { openDialog } from '../../os/dialog';
import { startEllipsis } from '../../utils/start-ellipsis';
const RECENT_URL_IMAGES_KEY = 'recentUrlImages';
const SAVE_IMAGE_AFTER_FLASH_KEY = 'saveUrlImage';
const SAVE_IMAGE_AFTER_FLASH_PATH_KEY = 'saveUrlImageTo';
function normalizeRecentUrlImages(urls: any[]): URL[] {
if (!Array.isArray(urls)) {
urls = [];
}
urls = urls
.map((url) => {
try {
return new URL(url);
} catch (error) {
// Invalid URL, skip
}
})
.filter((url) => url !== undefined);
urls = uniqBy(urls, (url) => url.href);
return urls.slice(-5);
}
function getRecentUrlImages(): URL[] {
let urls = [];
try {
urls = JSON.parse(localStorage.getItem(RECENT_URL_IMAGES_KEY) || '[]');
} catch {
// noop
}
return normalizeRecentUrlImages(urls);
}
function setRecentUrlImages(urls: string[]) {
localStorage.setItem(RECENT_URL_IMAGES_KEY, JSON.stringify(urls));
}
export const URLSelector = ({
done,
cancel,
}: {
done: (imageURL: string) => void;
cancel: () => void;
}) => {
const [imageURL, setImageURL] = React.useState('');
const [recentImages, setRecentImages] = React.useState<URL[]>([]);
const [loading, setLoading] = React.useState(false);
const [saveImage, setSaveImage] = React.useState(false);
const [saveImagePath, setSaveImagePath] = React.useState('');
React.useEffect(() => {
const fetchRecentUrlImages = async () => {
const recentUrlImages: URL[] = await getRecentUrlImages();
setRecentImages(recentUrlImages);
};
const getSaveImageSettings = async () => {
const saveUrlImage: boolean = await settings.get(
SAVE_IMAGE_AFTER_FLASH_KEY,
);
const saveUrlImageToPath: string = await settings.get(
SAVE_IMAGE_AFTER_FLASH_PATH_KEY,
);
setSaveImage(saveUrlImage);
setSaveImagePath(saveUrlImageToPath);
};
fetchRecentUrlImages();
getSaveImageSettings();
}, []);
return (
<Modal
title="Use Image URL"
cancel={cancel}
primaryButtonProps={{
className: loading || !imageURL ? 'disabled' : '',
}}
done={async () => {
setLoading(true);
const urlStrings = recentImages
.map((url: URL) => url.href)
.concat(imageURL);
setRecentUrlImages(urlStrings);
await done(imageURL);
}}
>
<Flex flexDirection="column">
<Flex mb="16px" width="100%" height="auto" flexDirection="column">
<Input
value={imageURL}
placeholder="Enter a valid URL"
type="text"
onChange={(evt: React.ChangeEvent<HTMLInputElement>) =>
setImageURL(evt.target.value)
}
/>
<Flex alignItems="flex-end">
<Checkbox
mt="16px"
checked={saveImage}
onChange={(evt) => {
const value = evt.target.checked;
setSaveImage(value);
settings
.set(SAVE_IMAGE_AFTER_FLASH_KEY, value)
.then(() => setSaveImage(value));
}}
label={<>Save file to:&nbsp;</>}
/>
<Link
disabled={!saveImage}
onClick={async () => {
if (saveImage) {
const folder = await openDialog('openDirectory');
if (folder) {
await settings.set(SAVE_IMAGE_AFTER_FLASH_PATH_KEY, folder);
setSaveImagePath(folder);
}
}
}}
>
{startEllipsis(saveImagePath, 20)}
</Link>
</Flex>
</Flex>
{recentImages.length > 0 && (
<Flex flexDirection="column" height="58%">
<Txt fontSize={18} mb="10px">
Recent
</Txt>
<ScrollableFlex flexDirection="column" p="0">
{recentImages
.map((recent, i) => (
<RadioButton
mb={i !== 0 ? '6px' : '0'}
key={recent.href}
checked={imageURL === recent.href}
label={`${recent.pathname.split('/').pop()} - ${
recent.href
}`}
onChange={() => {
setImageURL(recent.href);
}}
style={{
overflowWrap: 'break-word',
}}
/>
))
.reverse()}
</ScrollableFlex>
</Flex>
)}
</Flex>
</Modal>
);
};
export default URLSelector;

View File

@ -14,6 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { DrivelistDrive } from '../../../shared/drive-constraints';
import { Actions, store } from './store'; import { Actions, store } from './store';
export function hasAvailableDrives() { export function hasAvailableDrives() {
@ -27,6 +28,6 @@ export function setDrives(drives: any[]) {
}); });
} }
export function getDrives(): any[] { export function getDrives(): DrivelistDrive[] {
return store.getState().toJS().availableDrives; return store.getState().toJS().availableDrives;
} }

View File

@ -75,14 +75,25 @@ export function setDevicePaths(devicePaths: string[]) {
}); });
} }
export function addFailedDevicePath(devicePath: string) { export function addFailedDevicePath({
const failedDevicePathsSet = new Set( device,
error,
}: {
device: sdk.scanner.adapters.DrivelistDrive;
error: Error;
}) {
const failedDevicePathsMap = new Map(
store.getState().toJS().failedDevicePaths, store.getState().toJS().failedDevicePaths,
); );
failedDevicePathsSet.add(devicePath); failedDevicePathsMap.set(device.device, {
description: device.description,
device: device.device,
devicePath: device.devicePath,
...error,
});
store.dispatch({ store.dispatch({
type: Actions.SET_FAILED_DEVICE_PATHS, type: Actions.SET_FAILED_DEVICE_PATHS,
data: Array.from(failedDevicePathsSet), data: Array.from(failedDevicePathsMap),
}); });
} }

View File

@ -188,12 +188,15 @@ function stateObserver(state: typeof DEFAULT_STATE) {
} else { } else {
selectedDrivesPaths = s.devicePaths; selectedDrivesPaths = s.devicePaths;
} }
const failedDevicePaths = s.failedDevicePaths.map(
([devicePath]: [string]) => devicePath,
);
const newLedsState = { const newLedsState = {
step, step,
sourceDrive: sourceDrivePath, sourceDrive: sourceDrivePath,
availableDrives: availableDrivesPaths, availableDrives: availableDrivesPaths,
selectedDrives: selectedDrivesPaths, selectedDrives: selectedDrivesPaths,
failedDrives: s.failedDevicePaths, failedDrives: failedDevicePaths,
}; };
if (!_.isEqual(newLedsState, ledsState)) { if (!_.isEqual(newLedsState, ledsState)) {
updateLeds(newLedsState); updateLeds(newLedsState);

View File

@ -1,3 +1,4 @@
import { DrivelistDrive } from '../../../shared/drive-constraints';
/* /*
* Copyright 2016 balena.io * Copyright 2016 balena.io
* *
@ -14,7 +15,6 @@
* limitations under the License. * limitations under the License.
*/ */
import * as _ from 'lodash';
import { SourceMetadata } from '../components/source-selector/source-selector'; import { SourceMetadata } from '../components/source-selector/source-selector';
import * as availableDrives from './available-drives'; import * as availableDrives from './available-drives';
@ -41,7 +41,7 @@ export function toggleDrive(driveDevice: string) {
} }
} }
export function selectSource(source: any) { export function selectSource(source: SourceMetadata) {
store.dispatch({ store.dispatch({
type: Actions.SELECT_SOURCE, type: Actions.SELECT_SOURCE,
data: source, data: source,
@ -58,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(): SourceMetadata { 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(): string | undefined {
return _.get(store.getState().toJS(), ['selection', 'image', 'path']); return store.getState().toJS().selection.image?.path;
} }
export function getImageSize(): number { export function getImageSize(): number | undefined {
return _.get(store.getState().toJS(), ['selection', 'image', 'size']); return store.getState().toJS().selection.image?.size;
} }
export function getImageUrl(): string { export function getImageName(): string | undefined {
return _.get(store.getState().toJS(), ['selection', 'image', 'url']); return store.getState().toJS().selection.image?.name;
} }
export function getImageName(): string { export function getImageLogo(): string | undefined {
return _.get(store.getState().toJS(), ['selection', 'image', 'name']); return store.getState().toJS().selection.image?.logo;
} }
export function getImageLogo(): string { export function getImageSupportUrl(): string | undefined {
return _.get(store.getState().toJS(), ['selection', 'image', 'logo']); return store.getState().toJS().selection.image?.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',
]);
} }
/** /**
@ -115,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 !_.isEmpty(getImage()); return getImage() !== undefined;
} }
/** /**
@ -136,7 +124,7 @@ export function deselectImage() {
} }
export function deselectAllDrives() { export function deselectAllDrives() {
_.each(getSelectedDevices(), deselectDrive); getSelectedDevices().forEach(deselectDrive);
} }
/** /**
@ -156,5 +144,5 @@ export function isDriveSelected(driveDevice: string) {
} }
const selectedDriveDevices = getSelectedDevices(); const selectedDriveDevices = getSelectedDevices();
return _.includes(selectedDriveDevices, driveDevice); return selectedDriveDevices.includes(driveDevice);
} }

View File

@ -26,6 +26,9 @@ const debug = _debug('etcher:models:settings');
const JSON_INDENT = 2; const JSON_INDENT = 2;
export const DEFAULT_WIDTH = 800;
export const DEFAULT_HEIGHT = 480;
/** /**
* @summary Userdata directory path * @summary Userdata directory path
* @description * @description
@ -38,12 +41,15 @@ const JSON_INDENT = 2;
* NOTE: The ternary is due to this module being loaded both, * NOTE: The ternary is due to this module being loaded both,
* Electron's main process and renderer process * Electron's main process and renderer process
*/ */
const USER_DATA_DIR = electron.app
? electron.app.getPath('userData') const app = electron.app || electron.remote.app;
: electron.remote.app.getPath('userData');
const USER_DATA_DIR = app.getPath('userData');
const CONFIG_PATH = join(USER_DATA_DIR, 'config.json'); const CONFIG_PATH = join(USER_DATA_DIR, 'config.json');
const DOWNLOADS_DIR = app.getPath('downloads');
async function readConfigFile(filename: string): Promise<_.Dictionary<any>> { async function readConfigFile(filename: string): Promise<_.Dictionary<any>> {
let contents = '{}'; let contents = '{}';
try { try {
@ -80,6 +86,8 @@ const DEFAULT_SETTINGS: _.Dictionary<any> = {
desktopNotifications: true, desktopNotifications: true,
autoBlockmapping: true, autoBlockmapping: true,
decompressFirst: true, decompressFirst: true,
saveUrlImage: false,
saveUrlImageTo: DOWNLOADS_DIR,
}; };
const settings = _.cloneDeep(DEFAULT_SETTINGS); const settings = _.cloneDeep(DEFAULT_SETTINGS);

View File

@ -295,6 +295,7 @@ function storeReducer(
_.defaults(action.data, { _.defaults(action.data, {
cancelled: false, cancelled: false,
skip: false,
}); });
if (!_.isBoolean(action.data.cancelled)) { if (!_.isBoolean(action.data.cancelled)) {
@ -335,6 +336,12 @@ function storeReducer(
); );
} }
if (action.data.skip) {
return state
.set('isFlashing', false)
.set('flashResults', Immutable.fromJS(action.data));
}
return state return state
.set('isFlashing', false) .set('isFlashing', false)
.set('flashResults', Immutable.fromJS(action.data)) .set('flashResults', Immutable.fromJS(action.data))

View File

@ -131,6 +131,7 @@ function writerEnv() {
} }
interface FlashResults { interface FlashResults {
skip?: boolean;
cancelled?: boolean; cancelled?: boolean;
} }
@ -140,12 +141,15 @@ async function performWrite(
onProgress: sdk.multiWrite.OnProgressFunction, onProgress: sdk.multiWrite.OnProgressFunction,
): Promise<{ cancelled?: boolean }> { ): Promise<{ cancelled?: boolean }> {
let cancelled = false; let cancelled = false;
let skip = false;
ipc.serve(); ipc.serve();
const { const {
unmountOnSuccess, unmountOnSuccess,
validateWriteOnSuccess, validateWriteOnSuccess,
autoBlockmapping, autoBlockmapping,
decompressFirst, decompressFirst,
saveUrlImage,
saveUrlImageTo,
} = await settings.getAll(); } = await settings.getAll();
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
ipc.server.on('error', (error) => { ipc.server.on('error', (error) => {
@ -171,7 +175,7 @@ async function performWrite(
ipc.server.on('fail', ({ device, error }) => { ipc.server.on('fail', ({ device, error }) => {
if (device.devicePath) { if (device.devicePath) {
flashState.addFailedDevicePath(device.devicePath); flashState.addFailedDevicePath({ device, error });
} }
handleErrorLogging(error, analyticsData); handleErrorLogging(error, analyticsData);
}); });
@ -188,6 +192,11 @@ async function performWrite(
cancelled = true; cancelled = true;
}); });
ipc.server.on('skip', () => {
terminateServer();
skip = true;
});
ipc.server.on('state', onProgress); ipc.server.on('state', onProgress);
ipc.server.on('ready', (_data, socket) => { ipc.server.on('ready', (_data, socket) => {
@ -199,6 +208,8 @@ async function performWrite(
autoBlockmapping, autoBlockmapping,
unmountOnSuccess, unmountOnSuccess,
decompressFirst, decompressFirst,
saveUrlImage,
saveUrlImageTo,
}); });
}); });
@ -213,6 +224,7 @@ async function performWrite(
environment: env, environment: env,
}); });
flashResults.cancelled = cancelled || results.cancelled; flashResults.cancelled = cancelled || results.cancelled;
flashResults.skip = skip;
} catch (error) { } catch (error) {
// This happens when the child is killed using SIGKILL // This happens when the child is killed using SIGKILL
const SIGKILL_EXIT_CODE = 137; const SIGKILL_EXIT_CODE = 137;
@ -229,6 +241,7 @@ async function performWrite(
// This likely means the child died halfway through // This likely means the child died halfway through
if ( if (
!flashResults.cancelled && !flashResults.cancelled &&
!flashResults.skip &&
!_.get(flashResults, ['results', 'bytesWritten']) !_.get(flashResults, ['results', 'bytesWritten'])
) { ) {
reject( reject(
@ -286,8 +299,7 @@ export async function flash(
} catch (error) { } catch (error) {
flashState.unsetFlashingFlag({ cancelled: false, errorCode: error.code }); flashState.unsetFlashingFlag({ cancelled: false, errorCode: error.code });
windowProgress.clear(); windowProgress.clear();
let { results } = flashState.getFlashResults(); const { results = {} } = flashState.getFlashResults();
results = results || {};
const eventData = { const eventData = {
...analyticsData, ...analyticsData,
errors: results.errors, errors: results.errors,
@ -306,7 +318,7 @@ export async function flash(
}; };
analytics.logEvent('Elevation cancelled', eventData); analytics.logEvent('Elevation cancelled', eventData);
} else { } else {
const { results } = flashState.getFlashResults(); const { results = {} } = flashState.getFlashResults();
const eventData = { const eventData = {
...analyticsData, ...analyticsData,
errors: results.errors, errors: results.errors,
@ -322,7 +334,8 @@ export async function flash(
/** /**
* @summary Cancel write operation * @summary Cancel write operation
*/ */
export async function cancel() { export async function cancel(type: string) {
const status = type.toLowerCase();
const drives = selectionState.getSelectedDevices(); const drives = selectionState.getSelectedDevices();
const analyticsData = { const analyticsData = {
image: selectionState.getImagePath(), image: selectionState.getImagePath(),
@ -332,7 +345,7 @@ export async function cancel() {
flashInstanceUuid: flashState.getFlashUuid(), flashInstanceUuid: flashState.getFlashUuid(),
unmountOnSuccess: await settings.get('unmountOnSuccess'), unmountOnSuccess: await settings.get('unmountOnSuccess'),
validateWriteOnSuccess: await settings.get('validateWriteOnSuccess'), validateWriteOnSuccess: await settings.get('validateWriteOnSuccess'),
status: 'cancel', status,
}; };
analytics.logEvent('Cancel', analyticsData); analytics.logEvent('Cancel', analyticsData);
@ -342,7 +355,7 @@ export async function cancel() {
// @ts-ignore (no Server.sockets in @types/node-ipc) // @ts-ignore (no Server.sockets in @types/node-ipc)
const [socket] = ipc.server.sockets; const [socket] = ipc.server.sockets;
if (socket !== undefined) { if (socket !== undefined) {
ipc.server.emit(socket, 'cancel'); ipc.server.emit(socket, status);
} }
} catch (error) { } catch (error) {
analytics.logException(error); analytics.logException(error);

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;
@ -22,7 +22,7 @@ export interface FlashState {
percentage?: number; percentage?: number;
speed: number; speed: number;
position: number; position: number;
type?: 'decompressing' | 'flashing' | 'verifying'; type?: 'decompressing' | 'flashing' | 'verifying' | 'downloading';
} }
export function fromFlashState({ export function fromFlashState({
@ -51,7 +51,7 @@ export function fromFlashState({
} else { } else {
return { return {
status: 'Flashing...', status: 'Flashing...',
position: `${position ? bytesToClosestUnit(position) : ''}`, position: `${position ? prettyBytes(position) : ''}`,
}; };
} }
} else if (type === 'verifying') { } else if (type === 'verifying') {
@ -62,6 +62,12 @@ export function fromFlashState({
} else { } else {
return { status: 'Finishing...' }; return { status: 'Finishing...' };
} }
} else if (type === 'downloading') {
if (percentage == null) {
return { status: 'Downloading...' };
} else if (percentage < 100) {
return { position: `${percentage}%`, status: 'Downloading...' };
}
} }
return { status: 'Failed' }; return { status: 'Failed' };
} }

View File

@ -40,6 +40,12 @@ async function mountSourceDrive() {
* Notice that by image, we mean *.img/*.iso/*.zip/etc files. * Notice that by image, we mean *.img/*.iso/*.zip/etc files.
*/ */
export async function selectImage(): Promise<string | undefined> { export async function selectImage(): Promise<string | undefined> {
return await openDialog();
}
export async function openDialog(
type: 'openFile' | 'openDirectory' = 'openFile',
) {
await mountSourceDrive(); await mountSourceDrive();
const options: electron.OpenDialogOptions = { const options: electron.OpenDialogOptions = {
// This variable is set when running in GNU/Linux from // This variable is set when running in GNU/Linux from
@ -50,8 +56,10 @@ export async function selectImage(): Promise<string | undefined> {
// //
// See: https://github.com/probonopd/AppImageKit/commit/1569d6f8540aa6c2c618dbdb5d6fcbf0003952b7 // See: https://github.com/probonopd/AppImageKit/commit/1569d6f8540aa6c2c618dbdb5d6fcbf0003952b7
defaultPath: process.env.OWD, defaultPath: process.env.OWD,
properties: ['openFile', 'treatPackageAsDirectory'], properties: [type, 'treatPackageAsDirectory'],
filters: [ filters:
type === 'openFile'
? [
{ {
name: 'OS Images', name: 'OS Images',
extensions: SUPPORTED_EXTENSIONS, extensions: SUPPORTED_EXTENSIONS,
@ -60,13 +68,14 @@ export async function selectImage(): Promise<string | undefined> {
name: 'All', name: 'All',
extensions: ['*'], extensions: ['*'],
}, },
], ]
: undefined,
}; };
const currentWindow = electron.remote.getCurrentWindow(); const currentWindow = electron.remote.getCurrentWindow();
const [file] = ( const [path] = (
await electron.remote.dialog.showOpenDialog(currentWindow, options) await electron.remote.dialog.showOpenDialog(currentWindow, options)
).filePaths; ).filePaths;
return file; return path;
} }
/** /**

View File

@ -82,14 +82,12 @@ async function flashImageToDrive(
try { try {
await imageWriter.flash(image, drives); await imageWriter.flash(image, drives);
if (!flashState.wasLastFlashCancelled()) { if (!flashState.wasLastFlashCancelled()) {
const flashResults: any = flashState.getFlashResults(); const {
results = { devices: { successful: 0, failed: 0 } },
} = flashState.getFlashResults();
notification.send( notification.send(
'Flash complete!', 'Flash complete!',
messages.info.flashComplete( messages.info.flashComplete(basename, drives as any, results.devices),
basename,
drives as any,
flashResults.results.devices,
),
iconPath, iconPath,
); );
goToSuccess(); goToSuccess();
@ -198,13 +196,7 @@ export class FlashStep extends React.PureComponent<
} }
private async tryFlash() { private async tryFlash() {
const devices = selection.getSelectedDevices(); const drives = selection.getSelectedDrives().map((drive) => {
const drives = availableDrives
.getDrives()
.filter((drive: { device: string }) => {
return devices.includes(drive.device);
})
.map((drive) => {
return { return {
...drive, ...drive,
statuses: constraints.getDriveImageCompatibilityStatuses(drive), statuses: constraints.getDriveImageCompatibilityStatuses(drive),

View File

@ -18,13 +18,13 @@ 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 * 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';
import FinishPage from '../../components/finish/finish'; import FinishPage from '../../components/finish/finish';
import { ReducedFlashingInfos } from '../../components/reduced-flashing-infos/reduced-flashing-infos'; import { ReducedFlashingInfos } from '../../components/reduced-flashing-infos/reduced-flashing-infos';
import { SafeWebview } from '../../components/safe-webview/safe-webview';
import { SettingsModal } from '../../components/settings/settings'; import { SettingsModal } from '../../components/settings/settings';
import { import {
SourceMetadata, SourceMetadata,
@ -40,8 +40,6 @@ import {
ThemedProvider, ThemedProvider,
} from '../../styled-components'; } from '../../styled-components';
import { bytesToClosestUnit } from '../../../../shared/units';
import { import {
TargetSelector, TargetSelector,
getDriveListLabel, getDriveListLabel,
@ -49,6 +47,8 @@ import {
import { FlashStep } from './Flash'; import { FlashStep } from './Flash';
import EtcherSvg from '../../../assets/etcher.svg'; import EtcherSvg from '../../../assets/etcher.svg';
import { SafeWebview } from '../../components/safe-webview/safe-webview';
import { colors } from '../../theme';
const Icon = styled(BaseIcon)` const Icon = styled(BaseIcon)`
margin-right: 20px; margin-right: 20px;
@ -88,9 +88,7 @@ const StepBorder = styled.div<{
position: relative; position: relative;
height: 2px; height: 2px;
background-color: ${(props) => background-color: ${(props) =>
props.disabled props.disabled ? colors.dark.disabled.foreground : colors.dark.foreground};
? props.theme.colors.dark.disabled.foreground
: props.theme.colors.dark.foreground};
width: 120px; width: 120px;
top: 19px; top: 19px;
@ -104,9 +102,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;
} }
@ -170,7 +168,104 @@ export class MainPage extends React.Component<
const notFlashingOrSplitView = const notFlashingOrSplitView =
!this.state.isFlashing || !this.state.isWebviewShowing; !this.state.isFlashing || !this.state.isWebviewShowing;
return ( return (
<Flex
m={`110px ${this.state.isWebviewShowing ? 35 : 55}px`}
justifyContent="space-between"
>
{notFlashingOrSplitView && (
<> <>
<SourceSelector flashing={this.state.isFlashing} />
<Flex>
<StepBorder disabled={shouldDriveStepBeDisabled} left />
</Flex>
<TargetSelector
disabled={shouldDriveStepBeDisabled}
hasDrive={this.state.hasDrive}
flashing={this.state.isFlashing}
/>
<Flex>
<StepBorder disabled={shouldFlashStepBeDisabled} right />
</Flex>
</>
)}
{this.state.isFlashing && this.state.isWebviewShowing && (
<Flex
style={{
position: 'absolute',
top: 0,
left: 0,
width: '36.2vw',
height: '100vh',
zIndex: 1,
boxShadow: '0 2px 15px 0 rgba(0, 0, 0, 0.2)',
}}
>
<ReducedFlashingInfos
imageLogo={this.state.imageLogo}
imageName={this.state.imageName}
imageSize={
typeof this.state.imageSize === 'number'
? prettyBytes(this.state.imageSize)
: ''
}
driveTitle={this.state.driveTitle}
driveLabel={this.state.driveLabel}
style={{
position: 'absolute',
color: '#fff',
left: 35,
top: 72,
}}
/>
</Flex>
)}
{this.state.isFlashing && this.state.featuredProjectURL && (
<SafeWebview
src={this.state.featuredProjectURL}
onWebviewShow={(isWebviewShowing: boolean) => {
this.setState({ isWebviewShowing });
}}
style={{
position: 'absolute',
right: 0,
bottom: 0,
width: '63.8vw',
height: '100vh',
}}
/>
)}
<FlashStep
goToSuccess={() => this.setState({ current: 'success' })}
shouldFlashStepBeDisabled={shouldFlashStepBeDisabled}
isFlashing={this.state.isFlashing}
step={state.type}
percentage={state.percentage}
position={state.position}
failed={state.failed}
speed={state.speed}
eta={state.eta}
style={{ zIndex: 1 }}
/>
</Flex>
);
}
private renderSuccess() {
return (
<FinishPage
goToMain={() => {
flashState.resetState();
this.setState({ current: 'main' });
}}
/>
);
}
public render() {
return (
<ThemedProvider style={{ height: '100%', width: '100%' }}>
<Flex <Flex
justifyContent="space-between" justifyContent="space-between"
alignItems="center" alignItems="center"
@ -234,117 +329,6 @@ export class MainPage extends React.Component<
}} }}
/> />
)} )}
<Flex
m={`110px ${this.state.isWebviewShowing ? 35 : 55}px`}
justifyContent="space-between"
>
{notFlashingOrSplitView && (
<>
<SourceSelector flashing={this.state.isFlashing} />
<Flex>
<StepBorder disabled={shouldDriveStepBeDisabled} left />
</Flex>
<TargetSelector
disabled={shouldDriveStepBeDisabled}
hasDrive={this.state.hasDrive}
flashing={this.state.isFlashing}
/>
<Flex>
<StepBorder disabled={shouldFlashStepBeDisabled} right />
</Flex>
</>
)}
{this.state.isFlashing && this.state.isWebviewShowing && (
<Flex
style={{
position: 'absolute',
top: 0,
left: 0,
width: '36.2vw',
height: '100vh',
zIndex: 1,
boxShadow: '0 2px 15px 0 rgba(0, 0, 0, 0.2)',
}}
>
<ReducedFlashingInfos
imageLogo={this.state.imageLogo}
imageName={this.state.imageName}
imageSize={
typeof this.state.imageSize === 'number'
? (prettyBytes(this.state.imageSize) as string)
: ''
}
driveTitle={this.state.driveTitle}
driveLabel={this.state.driveLabel}
style={{
position: 'absolute',
color: '#fff',
left: 35,
top: 72,
}}
/>
</Flex>
)}
{this.state.isFlashing && this.state.featuredProjectURL && (
<SafeWebview
src={this.state.featuredProjectURL}
onWebviewShow={(isWebviewShowing: boolean) => {
this.setState({ isWebviewShowing });
}}
style={{
position: 'absolute',
right: 0,
bottom: 0,
width: '63.8vw',
height: '100vh',
}}
/>
)}
<FlashStep
goToSuccess={() => this.setState({ current: 'success' })}
shouldFlashStepBeDisabled={shouldFlashStepBeDisabled}
isFlashing={this.state.isFlashing}
step={state.type}
percentage={state.percentage}
position={state.position}
failed={state.failed}
speed={state.speed}
eta={state.eta}
style={{ zIndex: 1 }}
/>
</Flex>
</>
);
}
private renderSuccess() {
return (
<Flex flexDirection="column" alignItems="center" height="100%">
<FinishPage
goToMain={() => {
flashState.resetState();
this.setState({ current: 'main' });
}}
/>
<SafeWebview
src="https://www.balena.io/etcher/success-banner/"
style={{
width: '100%',
height: '320px',
position: 'absolute',
bottom: 0,
}}
/>
</Flex>
);
}
public render() {
return (
<ThemedProvider style={{ height: '100%', width: '100%' }}>
{this.state.current === 'main' {this.state.current === 'main'
? this.renderMain() ? this.renderMain()
: this.renderSuccess()} : this.renderSuccess()}

View File

@ -14,6 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import * as _ from 'lodash';
import * as React from 'react'; import * as React from 'react';
import { import {
Alert as AlertBase, Alert as AlertBase,
@ -23,27 +24,16 @@ import {
ButtonProps, ButtonProps,
Modal as ModalBase, Modal as ModalBase,
Provider, Provider,
Table as BaseTable,
TableProps as BaseTableProps,
Txt, Txt,
Theme as renditionTheme,
} from 'rendition'; } from 'rendition';
import styled, { css } from 'styled-components'; import styled, { css } from 'styled-components';
import { colors, theme } from './theme'; import { colors, theme } from './theme';
const defaultTheme = {
...renditionTheme,
...theme,
layer: {
extend: () => `
> div:first-child {
background-color: transparent;
}
`,
},
};
export const ThemedProvider = (props: any) => ( export const ThemedProvider = (props: any) => (
<Provider theme={defaultTheme} {...props}></Provider> <Provider theme={theme} {...props}></Provider>
); );
export const BaseButton = styled(Button)` export const BaseButton = styled(Button)`
@ -134,24 +124,23 @@ const modalFooterShadowCss = css`
background-attachment: local, local, scroll, scroll; background-attachment: local, local, scroll, scroll;
`; `;
export const Modal = styled(({ style, ...props }) => { export const Modal = styled(({ style, children, ...props }) => {
return ( return (
<Provider <Provider
theme={{ theme={_.merge({}, theme, {
...defaultTheme,
header: { header: {
height: '50px', height: '50px',
}, },
layer: { layer: {
extend: () => ` extend: () => `
${defaultTheme.layer.extend()} ${theme.layer.extend()}
> div:last-child { > div:last-child {
top: 0; top: 0;
} }
`, `,
}, },
}} })}
> >
<ModalBase <ModalBase
position="top" position="top"
@ -167,7 +156,11 @@ export const Modal = styled(({ style, ...props }) => {
...style, ...style,
}} }}
{...props} {...props}
/> >
<ScrollableFlex flexDirection="column" width="100%" height="90%">
{...children}
</ScrollableFlex>
</ModalBase>
</Provider> </Provider>
); );
})` })`
@ -175,6 +168,11 @@ export const Modal = styled(({ style, ...props }) => {
padding: 0; padding: 0;
height: 100%; height: 100%;
> div:first-child {
height: 81%;
padding: 24px 30px 0;
}
> h3 { > h3 {
margin: 0; margin: 0;
padding: 24px 30px 0; padding: 24px 30px 0;
@ -188,12 +186,9 @@ export const Modal = styled(({ style, ...props }) => {
> div:nth-child(2) { > div:nth-child(2) {
height: 61%; height: 61%;
> div:not(.system-drive-alert) {
padding: 0 30px; padding: 0 30px;
${modalFooterShadowCss} ${modalFooterShadowCss}
} }
}
> div:last-child { > div:last-child {
margin: 0; margin: 0;
@ -249,3 +244,99 @@ export const Alert = styled((props) => (
display: none; display: none;
} }
`; `;
export interface GenericTableProps<T> extends BaseTableProps<T> {
refFn: (t: BaseTable<T>) => void;
data: T[];
checkedRowsNumber?: number;
multipleSelection: boolean;
showWarnings?: boolean;
}
const GenericTable: <T>(
props: GenericTableProps<T>,
) => React.ReactElement<GenericTableProps<T>> = <T extends {}>({
refFn,
...props
}: GenericTableProps<T>) => (
<div>
<BaseTable<T> ref={refFn} {...props} />
</div>
);
function StyledTable<T>() {
return styled((props: GenericTableProps<T>) => (
<GenericTable<T> {...props} />
))`
[data-display='table-head']
> [data-display='table-row']
> [data-display='table-cell'] {
position: sticky;
background-color: #f8f9fd;
top: 0;
z-index: 1;
input[type='checkbox'] + div {
display: ${(props) => (props.multipleSelection ? 'flex' : 'none')};
${(props) =>
props.multipleSelection &&
props.checkedRowsNumber !== 0 &&
props.checkedRowsNumber !== props.data.length
? `
font-weight: 600;
color: ${colors.primary.foreground};
background: ${colors.primary.background};
::after {
content: '';
}
`
: ''}
}
}
}
[data-display='table-head'] > [data-display='table-row'],
[data-display='table-body'] > [data-display='table-row'] {
> [data-display='table-cell']:first-child {
padding-left: 15px;
width: 6%;
}
> [data-display='table-cell']:last-child {
padding-right: 0;
}
}
[data-display='table-body'] > [data-display='table-row'] {
&:nth-of-type(2n) {
background: transparent;
}
&[data-highlight='true'] {
&.system {
background-color: ${(props) => (props.showWarnings ? '#fff5e6' : '#e8f5fc')};
}
> [data-display='table-cell']:first-child {
box-shadow: none;
}
}
}
&& [data-display='table-row'] > [data-display='table-cell'] {
padding: 6px 8px;
color: #2a506f;
}
input[type='checkbox'] + div {
border-radius: ${(props) => (props.multipleSelection ? '4px' : '50%')};
}
`;
}
export const Table = <T extends {}>(props: GenericTableProps<T>) => {
const TypedStyledFunctional = StyledTable<T>();
return <TypedStyledFunctional {...props} />;
};

View File

@ -14,6 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
import * as _ from 'lodash';
import { Theme } from 'rendition';
export const colors = { export const colors = {
dark: { dark: {
foreground: '#fff', foreground: '#fff',
@ -67,8 +70,7 @@ export const colors = {
const font = 'SourceSansPro'; const font = 'SourceSansPro';
export const theme = { export const theme = _.merge({}, Theme, {
colors,
font, font,
global: { global: {
font: { font: {
@ -109,4 +111,11 @@ export const theme = {
} }
`, `,
}, },
}; layer: {
extend: () => `
> div:first-child {
background-color: transparent;
}
`,
},
});

View File

@ -0,0 +1,28 @@
/*
* Copyright 2020 balena.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @summary Truncate text from the start with an ellipsis
*/
export function startEllipsis(input: string, limit: number): string {
// Do nothing, the string doesn't need truncation.
if (input.length <= limit) {
return input;
}
const lastPart = input.slice(input.length - limit, input.length);
return `${lastPart}`;
}

View File

@ -122,8 +122,8 @@ interface AutoUpdaterConfig {
async function createMainWindow() { async function createMainWindow() {
const fullscreen = Boolean(await settings.get('fullscreen')); const fullscreen = Boolean(await settings.get('fullscreen'));
const defaultWidth = 800; const defaultWidth = settings.DEFAULT_WIDTH;
const defaultHeight = 480; const defaultHeight = settings.DEFAULT_HEIGHT;
let width = defaultWidth; let width = defaultWidth;
let height = defaultHeight; let height = defaultHeight;
if (fullscreen) { if (fullscreen) {
@ -174,7 +174,13 @@ async function createMainWindow() {
event.preventDefault(); event.preventDefault();
}); });
mainWindow.loadURL(`file://${path.join(__dirname, 'index.html')}`); mainWindow.loadURL(
`file://${path.join(
'/',
...__dirname.split(path.sep).map(encodeURIComponent),
'index.html',
)}`,
);
const page = mainWindow.webContents; const page = mainWindow.webContents;

View File

@ -17,6 +17,8 @@
import { Drive as DrivelistDrive } from 'drivelist'; import { Drive as DrivelistDrive } from 'drivelist';
import * as sdk from 'etcher-sdk'; import * as sdk from 'etcher-sdk';
import { cleanupTmpFiles } from 'etcher-sdk/build/tmp'; import { cleanupTmpFiles } from 'etcher-sdk/build/tmp';
import { promises as fs } from 'fs';
import * as _ from 'lodash';
import * as ipc from 'node-ipc'; import * as ipc from 'node-ipc';
import { totalmem } from 'os'; import { totalmem } from 'os';
@ -55,8 +57,9 @@ function log(message: string) {
/** /**
* @summary Terminate the child writer process * @summary Terminate the child writer process
*/ */
function terminate(exitCode: number) { async function terminate(exitCode: number) {
ipc.disconnect(IPC_SERVER_ID); ipc.disconnect(IPC_SERVER_ID);
await cleanupTmpFiles(Date.now());
process.nextTick(() => { process.nextTick(() => {
process.exit(exitCode || SUCCESS); process.exit(exitCode || SUCCESS);
}); });
@ -68,7 +71,7 @@ function terminate(exitCode: number) {
async function handleError(error: Error) { async function handleError(error: Error) {
ipc.of[IPC_SERVER_ID].emit('error', toJSON(error)); ipc.of[IPC_SERVER_ID].emit('error', toJSON(error));
await delay(DISCONNECT_DELAY); await delay(DISCONNECT_DELAY);
terminate(GENERAL_ERROR); await terminate(GENERAL_ERROR);
} }
interface WriteResult { interface WriteResult {
@ -136,8 +139,10 @@ async function writeAndValidate({
sourceMetadata, sourceMetadata,
}; };
for (const [destination, error] of failures) { for (const [destination, error] of failures) {
const err = error as Error & { device: string }; const err = error as Error & { device: string; description: string };
err.device = (destination as sdk.sourceDestination.BlockDevice).device; const drive = destination as sdk.sourceDestination.BlockDevice;
err.device = drive.device;
err.description = drive.description;
result.errors.push(err); result.errors.push(err);
} }
return result; return result;
@ -151,6 +156,13 @@ interface WriteOptions {
autoBlockmapping: boolean; autoBlockmapping: boolean;
decompressFirst: boolean; decompressFirst: boolean;
SourceType: string; SourceType: string;
saveUrlImage: boolean;
saveUrlImageTo: string;
}
interface ProgressState
extends Omit<sdk.multiWrite.MultiDestinationProgress, 'type'> {
type: sdk.multiWrite.MultiDestinationProgress['type'] | 'downloading';
} }
ipc.connectTo(IPC_SERVER_ID, () => { ipc.connectTo(IPC_SERVER_ID, () => {
@ -163,22 +175,22 @@ ipc.connectTo(IPC_SERVER_ID, () => {
// no flashing information is available, then it will // no flashing information is available, then it will
// assume that the child died halfway through. // assume that the child died halfway through.
process.once('SIGINT', () => { process.once('SIGINT', async () => {
terminate(SUCCESS); await terminate(SUCCESS);
}); });
process.once('SIGTERM', () => { process.once('SIGTERM', async () => {
terminate(SUCCESS); await terminate(SUCCESS);
}); });
// The IPC server failed. Abort. // The IPC server failed. Abort.
ipc.of[IPC_SERVER_ID].on('error', () => { ipc.of[IPC_SERVER_ID].on('error', async () => {
terminate(SUCCESS); await terminate(SUCCESS);
}); });
// The IPC server was disconnected. Abort. // The IPC server was disconnected. Abort.
ipc.of[IPC_SERVER_ID].on('disconnect', () => { ipc.of[IPC_SERVER_ID].on('disconnect', async () => {
terminate(SUCCESS); await terminate(SUCCESS);
}); });
ipc.of[IPC_SERVER_ID].on('write', async (options: WriteOptions) => { ipc.of[IPC_SERVER_ID].on('write', async (options: WriteOptions) => {
@ -188,7 +200,7 @@ ipc.connectTo(IPC_SERVER_ID, () => {
* @example * @example
* writer.on('progress', onProgress) * writer.on('progress', onProgress)
*/ */
const onProgress = (state: sdk.multiWrite.MultiDestinationProgress) => { const onProgress = (state: ProgressState) => {
ipc.of[IPC_SERVER_ID].emit('state', state); ipc.of[IPC_SERVER_ID].emit('state', state);
}; };
@ -203,11 +215,20 @@ ipc.connectTo(IPC_SERVER_ID, () => {
log('Abort'); log('Abort');
ipc.of[IPC_SERVER_ID].emit('abort'); ipc.of[IPC_SERVER_ID].emit('abort');
await delay(DISCONNECT_DELAY); await delay(DISCONNECT_DELAY);
terminate(exitCode); await terminate(exitCode);
};
const onSkip = async () => {
log('Skip validation');
ipc.of[IPC_SERVER_ID].emit('skip');
await delay(DISCONNECT_DELAY);
await terminate(exitCode);
}; };
ipc.of[IPC_SERVER_ID].on('cancel', onAbort); ipc.of[IPC_SERVER_ID].on('cancel', onAbort);
ipc.of[IPC_SERVER_ID].on('skip', onSkip);
/** /**
* @summary Failure handler (non-fatal errors) * @summary Failure handler (non-fatal errors)
* @param {SourceDestination} destination - destination * @param {SourceDestination} destination - destination
@ -256,10 +277,19 @@ ipc.connectTo(IPC_SERVER_ID, () => {
source = new File({ source = new File({
path: imagePath, path: imagePath,
}); });
} else {
if (options.saveUrlImage) {
source = await saveFileBeforeFlash(
imagePath,
options.saveUrlImageTo,
onProgress,
onFail,
);
} else { } else {
source = new Http({ url: imagePath, avoidRandomAccess: true }); source = new Http({ url: imagePath, avoidRandomAccess: true });
} }
} }
}
const results = await writeAndValidate({ const results = await writeAndValidate({
source, source,
destinations: dests, destinations: dests,
@ -275,7 +305,7 @@ ipc.connectTo(IPC_SERVER_ID, () => {
}); });
ipc.of[IPC_SERVER_ID].emit('done', { results }); ipc.of[IPC_SERVER_ID].emit('done', { results });
await delay(DISCONNECT_DELAY); await delay(DISCONNECT_DELAY);
terminate(exitCode); await terminate(exitCode);
} catch (error) { } catch (error) {
log(`Error: ${error.message}`); log(`Error: ${error.message}`);
exitCode = GENERAL_ERROR; exitCode = GENERAL_ERROR;
@ -290,3 +320,43 @@ ipc.connectTo(IPC_SERVER_ID, () => {
ipc.of[IPC_SERVER_ID].emit('ready', {}); ipc.of[IPC_SERVER_ID].emit('ready', {});
}); });
}); });
async function saveFileBeforeFlash(
imagePath: string,
saveUrlImageTo: string,
onProgress: (state: ProgressState) => void,
onFail: (
destination: sdk.sourceDestination.SourceDestination,
error: Error,
) => void,
) {
const urlImage = new Http({ url: imagePath, avoidRandomAccess: true });
const source = await urlImage.getInnerSource();
const metadata = await source.getMetadata();
const fileName = `${saveUrlImageTo}/${metadata.name}`;
let alreadyDownloaded = false;
try {
alreadyDownloaded = (await fs.stat(fileName)).isFile();
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
if (!alreadyDownloaded) {
await sdk.multiWrite.decompressThenFlash({
source,
destinations: [new File({ path: fileName, write: true })],
onProgress: (progress) => {
onProgress({
...progress,
type: 'downloading',
});
},
onFail: (...args) => {
onFail(...args);
},
verify: true,
});
}
return new File({ path: fileName });
}

View File

@ -15,6 +15,7 @@
*/ */
import { Dictionary } from 'lodash'; import { Dictionary } from 'lodash';
import { outdent } from 'outdent';
import * as prettyBytes from 'pretty-bytes'; import * as prettyBytes from 'pretty-bytes';
export const progress: Dictionary<(quantity: number) => string> = { export const progress: Dictionary<(quantity: number) => string> = {
@ -84,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 ${prettyBytes(image.recommendedDriveSize)}`, This image recommends a ${prettyBytes(image.recommendedDriveSize)}
`drive, however ${drive.device} is only ${prettyBytes(drive.size)}.`, drive, however ${drive.device} is only ${prettyBytes(drive.size)}.
].join(' '); `;
}, },
exitWhileFlashing: () => { exitWhileFlashing: () => {
@ -150,10 +151,11 @@ export const error = {
}, },
openSource: (sourceName: string, errorMessage: string) => { openSource: (sourceName: string, errorMessage: string) => {
return [ return outdent`
`Something went wrong while opening ${sourceName}\n\n`, Something went wrong while opening ${sourceName}
`Error: ${errorMessage}`,
].join(''); Error: ${errorMessage}
`;
}, },
flashFailure: ( flashFailure: (

View File

@ -14,15 +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 {
return prettyBytes(bytes);
}

176
npm-shrinkwrap.json generated
View File

@ -1,6 +1,6 @@
{ {
"name": "balena-etcher", "name": "balena-etcher",
"version": "1.5.106", "version": "1.5.109",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@ -1570,34 +1570,32 @@
} }
}, },
"@react-google-maps/api": { "@react-google-maps/api": {
"version": "1.9.12", "version": "1.10.1",
"resolved": "https://registry.npmjs.org/@react-google-maps/api/-/api-1.9.12.tgz", "resolved": "https://registry.npmjs.org/@react-google-maps/api/-/api-1.10.1.tgz",
"integrity": "sha512-YpYZOMduxiQIt8+njdffoqD4fYdOugudoafnAD1N+mEUrVnFlslUPMQ+gOJwuYdlkTAR5NZUbCt80LJWEN+ZnA==", "integrity": "sha512-hb8urUcwZw99Cu3yQnZWUbXjR1Ym/8C21kSX6B02I29l6DXNxDbJ5Jo/T5swhnizPKY7TNhR1oTctC/HY7SQWA==",
"dev": true, "dev": true,
"requires": { "requires": {
"@react-google-maps/infobox": "1.9.11", "@react-google-maps/infobox": "1.10.0",
"@react-google-maps/marker-clusterer": "1.9.11", "@react-google-maps/marker-clusterer": "1.10.0",
"acorn": "7.4.0",
"acorn-jsx": "^5.2.0",
"invariant": "2.2.4" "invariant": "2.2.4"
} }
}, },
"@react-google-maps/infobox": { "@react-google-maps/infobox": {
"version": "1.9.11", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@react-google-maps/infobox/-/infobox-1.9.11.tgz", "resolved": "https://registry.npmjs.org/@react-google-maps/infobox/-/infobox-1.10.0.tgz",
"integrity": "sha512-22ewm+OpOh69ikypG29idsdRz2OWeFsN+8zvYBzSETxKP782rmUGqhSIvXXmHa8TOcktm7EaEqOWWvZwaxymag==", "integrity": "sha512-MhT2nMmjeG7TCxRv/JdylDyNd/n66ggSQQhTWVjJJTtdB/xqd0T8BHCkBWDN9uF0i0yCZzMFl2P2Y1zJ+xppBg==",
"dev": true "dev": true
}, },
"@react-google-maps/marker-clusterer": { "@react-google-maps/marker-clusterer": {
"version": "1.9.11", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@react-google-maps/marker-clusterer/-/marker-clusterer-1.9.11.tgz", "resolved": "https://registry.npmjs.org/@react-google-maps/marker-clusterer/-/marker-clusterer-1.10.0.tgz",
"integrity": "sha512-yIABKlkORju131efXUZs/tL7FCK9IXtvy2M9SQRZy/mwgoOIYeoJlPPaBjn81DQqZLRj6AdAocydk+MnjWqFiQ==", "integrity": "sha512-3GLVgeXNStVcdiLMxzi3cBjr32ctlexLPPGQguwcYd6yPLaCcnVCwyzhV68KvL00xqOAD1c3aABV9EGgY8u6Qw==",
"dev": true "dev": true
}, },
"@rjsf/core": { "@rjsf/core": {
"version": "2.3.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-2.3.0.tgz", "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-2.4.0.tgz",
"integrity": "sha512-OZKYHt9tjKhzOH4CvsPiCwepuIacqI++cNmnL2fsxh1IF+uEWGlo3NLDWhhSaBbOv9jps6a5YQcLbLtjNuSwug==", "integrity": "sha512-8zlydBkGldOxGXFEwNGFa1gzTxpcxaYn7ofegcu8XHJ7IKMCfpnU3ABg+H3eml1KZCX3FODmj1tHFJKuTmfynw==",
"dev": true, "dev": true,
"requires": { "requires": {
"@babel/runtime-corejs2": "^7.8.7", "@babel/runtime-corejs2": "^7.8.7",
@ -2180,9 +2178,9 @@
} }
}, },
"@types/react-native": { "@types/react-native": {
"version": "0.63.9", "version": "0.63.18",
"resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.63.9.tgz", "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.63.18.tgz",
"integrity": "sha512-6ec/z9zjAkFH3rD1RYqbrA/Lj+jux6bumWCte4yRy3leyelTdqtmOd2Ph+86IXQQzsIArEMBwmraAbNQ0J3UAA==", "integrity": "sha512-WwEWqmHiqFn61M1FZR/+frj+E8e2o8i5cPqu9mjbjtZS/gBfCKVESF2ai/KAlaQECkkWkx/nMJeCc5eHMmLQgw==",
"dev": true, "dev": true,
"requires": { "requires": {
"@types/react": "*" "@types/react": "*"
@ -2237,9 +2235,9 @@
"dev": true "dev": true
}, },
"@types/styled-components": { "@types/styled-components": {
"version": "5.1.2", "version": "5.1.3",
"resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.2.tgz", "resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.3.tgz",
"integrity": "sha512-HNocYLfrsnNNm8NTS/W53OERSjRA8dx5Bn6wBd2rXXwt4Z3s+oqvY6/PbVt3e6sgtzI63GX//WiWiRhWur08qQ==", "integrity": "sha512-HGpirof3WOhiX17lb61Q/tpgqn48jxO8EfZkdJ8ueYqwLbK2AHQe/G08DasdA2IdKnmwOIP1s9X2bopxKXgjRw==",
"dev": true, "dev": true,
"requires": { "requires": {
"@types/hoist-non-react-statics": "*", "@types/hoist-non-react-statics": "*",
@ -2692,18 +2690,6 @@
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"dev": true "dev": true
}, },
"acorn": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.0.tgz",
"integrity": "sha512-+G7P8jJmCHr+S+cLfQxygbWhXy+8YTVGzAkpEbcLo2mLoL7tij/VG41QSHACSf5QgYRhMZYHuNc6drJaO0Da+w==",
"dev": true
},
"acorn-jsx": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.2.0.tgz",
"integrity": "sha512-HiUX/+K2YpkpJ+SzBffkM/AQ2YE03S0U1kjTLVpoJdhZMOWy8qvXVN9JdLqv2QsaQ6MPYQIuNmwD8zOiYUofLQ==",
"dev": true
},
"agent-base": { "agent-base": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz",
@ -5281,6 +5267,12 @@
"assert-plus": "^1.0.0" "assert-plus": "^1.0.0"
} }
}, },
"date-fns": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.16.1.tgz",
"integrity": "sha512-sAJVKx/FqrLYHAQeN7VpJrPhagZc9R4ImZIWYRFZaaohR3KzmuK88touwsSwSVT8Qcbd4zoDsnGfX4GFB4imyQ==",
"dev": true
},
"de-indent": { "de-indent": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@ -5496,15 +5488,6 @@
"minimalistic-assert": "^1.0.0" "minimalistic-assert": "^1.0.0"
} }
}, },
"detab": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/detab/-/detab-2.0.3.tgz",
"integrity": "sha512-Up8P0clUVwq0FnFjDclzZsy9PadzRn5FFxrr47tQQvMHqyiFYVbpH8oXDzWtF0Q7pYy3l+RPmtBl+BsFF6wH0A==",
"dev": true,
"requires": {
"repeat-string": "^1.5.4"
}
},
"detect-file": { "detect-file": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz",
@ -6683,15 +6666,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 +6854,9 @@
} }
}, },
"ext2fs": { "ext2fs": {
"version": "2.0.1", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/ext2fs/-/ext2fs-2.0.1.tgz", "resolved": "https://registry.npmjs.org/ext2fs/-/ext2fs-2.0.5.tgz",
"integrity": "sha512-ZhnpAINB0+Lsgt5jwyAMQKe/w9L1WaNiERyGvXlO7sd9doGaxrVotyX3+ZPbyNMgPb/7wJ0zbeRp+DLAzZQdug==", "integrity": "sha512-qNv+XrXrauspqoUYRgcKV7HNkoDAAY/KU6nZHGB8Y2tT553fiMtiZd4VYOdxd+0zrNZozi+0fJjLbiGBnEGJUw==",
"dev": true, "dev": true,
"requires": { "requires": {
"bindings": "^1.3.0", "bindings": "^1.3.0",
@ -8939,9 +8922,9 @@
"dev": true "dev": true
}, },
"json-e": { "json-e": {
"version": "4.1.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/json-e/-/json-e-4.1.0.tgz", "resolved": "https://registry.npmjs.org/json-e/-/json-e-4.3.0.tgz",
"integrity": "sha512-Jb8kMB1lICgjAAppv+q0EFFovOPdjE3htb7pt9+uE2j3J1W5ZCuBOmAdGi0OUetCZ4wqSO6qT/Np36XDRjHH7w==", "integrity": "sha512-E3zcmx6pHsBgQ4ZztQNG4OAZHreBZfGBrg68kv9nGOkRqAdKfs792asP/wp9Fayfx1THDiHKYStqWJj/N7Bb9A==",
"dev": true, "dev": true,
"requires": { "requires": {
"json-stable-stringify-without-jsonify": "^1.0.1" "json-stable-stringify-without-jsonify": "^1.0.1"
@ -9749,18 +9732,15 @@
} }
}, },
"mdast-util-to-hast": { "mdast-util-to-hast": {
"version": "9.1.0", "version": "9.1.1",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-9.1.0.tgz", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-9.1.1.tgz",
"integrity": "sha512-Akl2Vi9y9cSdr19/Dfu58PVwifPXuFt1IrHe7l+Crme1KvgUT+5z+cHLVcQVGCiNTZZcdqjnuv9vPkGsqWytWA==", "integrity": "sha512-vpMWKFKM2mnle+YbNgDXxx95vv0CoLU0v/l3F5oFAG5DV7qwkZVWA206LsAdOnEVyf5vQcLnb3cWJywu7mUxsQ==",
"dev": true, "dev": true,
"requires": { "requires": {
"@types/mdast": "^3.0.0", "@types/mdast": "^3.0.0",
"@types/unist": "^2.0.3", "@types/unist": "^2.0.3",
"collapse-white-space": "^1.0.0",
"detab": "^2.0.0",
"mdast-util-definitions": "^3.0.0", "mdast-util-definitions": "^3.0.0",
"mdurl": "^1.0.0", "mdurl": "^1.0.0",
"trim-lines": "^1.0.0",
"unist-builder": "^2.0.0", "unist-builder": "^2.0.0",
"unist-util-generated": "^1.0.0", "unist-util-generated": "^1.0.0",
"unist-util-position": "^3.0.0", "unist-util-position": "^3.0.0",
@ -9842,9 +9822,9 @@
}, },
"dependencies": { "dependencies": {
"crypto-random-string": { "crypto-random-string": {
"version": "3.2.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-3.2.0.tgz", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-3.3.0.tgz",
"integrity": "sha512-8vPu5bsKaq2uKRy3OL7h1Oo7RayAWB8sYexLKAqvCXVib8SxgbmoF1IN4QMKjBv8uI8mp5gPPMbiRah25GMrVQ==", "integrity": "sha512-teWAwfMb1d6brahYyKqcBEb5Yp8PJPvPOdOonXDnvaKOTmKDFNVE8E3Y2XQuzjNV/3XMwHbrX9fHWvrhRKt4Gg==",
"dev": true, "dev": true,
"requires": { "requires": {
"type-fest": "^0.8.1" "type-fest": "^0.8.1"
@ -11897,9 +11877,9 @@
} }
}, },
"polished": { "polished": {
"version": "3.6.5", "version": "3.6.6",
"resolved": "https://registry.npmjs.org/polished/-/polished-3.6.5.tgz", "resolved": "https://registry.npmjs.org/polished/-/polished-3.6.6.tgz",
"integrity": "sha512-VwhC9MlhW7O5dg/z7k32dabcAFW1VI2+7fSe8cE/kXcfL7mVdoa5UxciYGW2sJU78ldDLT6+ROEKIZKFNTnUXQ==", "integrity": "sha512-yiB2ims2DZPem0kCD6V0wnhcVGFEhNh0Iw0axNpKU+oSAgFt6yx6HxIT23Qg0WWvgS379cS35zT4AOyZZRzpQQ==",
"dev": true, "dev": true,
"requires": { "requires": {
"@babel/runtime": "^7.9.2" "@babel/runtime": "^7.9.2"
@ -12511,9 +12491,9 @@
} }
}, },
"react-notifications-component": { "react-notifications-component": {
"version": "2.4.0", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/react-notifications-component/-/react-notifications-component-2.4.0.tgz", "resolved": "https://registry.npmjs.org/react-notifications-component/-/react-notifications-component-2.4.1.tgz",
"integrity": "sha512-0IhtgqAmsKSyjY1wBUxciUVXiYGRr5BRdn67pYDlkqq9ORF98NZekpG7/MNX0BzzfGvt9Wg7rFhT1BtwOvvLLg==", "integrity": "sha512-RloHzm15egnuPihf8PvldIEvPQoT9+5BE9UxCNTt+GfsWeI3SEZKyaX9mq90v899boqteLiOI736Zd4tXtl7Tg==",
"dev": true, "dev": true,
"requires": { "requires": {
"prop-types": "^15.6.2" "prop-types": "^15.6.2"
@ -12660,6 +12640,21 @@
"integrity": "sha512-jbD/FT0+9MBU2XAZluI7w2OBs1RBi6p9M83nkoZayQXXU9e8Robt69FcZc7wU4eJD/YFTjn1JdCk3rbMJajz8Q==", "integrity": "sha512-jbD/FT0+9MBU2XAZluI7w2OBs1RBi6p9M83nkoZayQXXU9e8Robt69FcZc7wU4eJD/YFTjn1JdCk3rbMJajz8Q==",
"dev": true "dev": true
}, },
"regexp-match-indices": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/regexp-match-indices/-/regexp-match-indices-1.0.2.tgz",
"integrity": "sha512-DwZuAkt8NF5mKwGGER1EGh2PRqyvhRhhLviH+R8y8dIuaQROlUfXjt4s9ZTXstIsSkptf06BSvwcEmmfheJJWQ==",
"dev": true,
"requires": {
"regexp-tree": "^0.1.11"
}
},
"regexp-tree": {
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.21.tgz",
"integrity": "sha512-kUUXjX4AnqnR8KRTCrayAo9PzYMRKmVoGgaz2tBuz0MF3g1ZbGebmtW0yFHfFK9CmBjQKeYIgoL22pFLBJY7sw==",
"dev": true
},
"regexpu-core": { "regexpu-core": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.7.0.tgz", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.7.0.tgz",
@ -12827,9 +12822,9 @@
"optional": true "optional": true
}, },
"rendition": { "rendition": {
"version": "18.4.1", "version": "18.8.3",
"resolved": "https://registry.npmjs.org/rendition/-/rendition-18.4.1.tgz", "resolved": "https://registry.npmjs.org/rendition/-/rendition-18.8.3.tgz",
"integrity": "sha512-mV/0p+M8XR/Xa/ZFzgflZPHelpuONiTSa/CMMuHkmXR7vhF7tB2ORxLRc/DbymmdN6cWQwXAyA81t9TDAOhgVQ==", "integrity": "sha512-kDuXFheXY9KlSvIMdB4Er2OeAnwgj9aya5Xu43hwpXxC4KlFlNKqQNmcOvKLc/Fk9dyw04TKOr1SbXyM148yRg==",
"dev": true, "dev": true,
"requires": { "requires": {
"@fortawesome/fontawesome-svg-core": "^1.2.25", "@fortawesome/fontawesome-svg-core": "^1.2.25",
@ -12855,6 +12850,7 @@
"color": "^3.1.2", "color": "^3.1.2",
"color-hash": "^1.0.3", "color-hash": "^1.0.3",
"copy-to-clipboard": "^3.0.8", "copy-to-clipboard": "^3.0.8",
"date-fns": "^2.16.1",
"grommet": "^2.14.0", "grommet": "^2.14.0",
"hast-util-sanitize": "^3.0.0", "hast-util-sanitize": "^3.0.0",
"json-e": "^4.1.0", "json-e": "^4.1.0",
@ -12869,6 +12865,7 @@
"react-simplemde-editor": "^4.1.1", "react-simplemde-editor": "^4.1.1",
"recompose": "0.26.0", "recompose": "0.26.0",
"regex-parser": "^2.2.7", "regex-parser": "^2.2.7",
"regexp-match-indices": "^1.0.2",
"rehype-raw": "^4.0.2", "rehype-raw": "^4.0.2",
"rehype-react": "^6.1.0", "rehype-react": "^6.1.0",
"rehype-sanitize": "^3.0.1", "rehype-sanitize": "^3.0.1",
@ -12885,9 +12882,9 @@
}, },
"dependencies": { "dependencies": {
"@types/node": { "@types/node": {
"version": "13.13.15", "version": "13.13.20",
"resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.15.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.20.tgz",
"integrity": "sha512-kwbcs0jySLxzLsa2nWUAGOd/s21WU1jebrEdtzhsj1D4Yps1EOuyI1Qcu+FD56dL7NRNIJtDDjcqIG22NwkgLw==", "integrity": "sha512-1kx55tU3AvGX2Cjk2W4GMBxbgIz892V+X10S2gUreIAq8qCWgaQH+tZBOWc0bi2BKFhQt+CX0BTx28V9QPNa+A==",
"dev": true "dev": true
}, },
"uuid": { "uuid": {
@ -14257,9 +14254,8 @@
} }
}, },
"sudo-prompt": { "sudo-prompt": {
"version": "9.2.1", "version": "github:zvin/sudo-prompt#81cab70c1f3f816b71539c4c5d7ecf1309094f8c",
"resolved": "https://registry.npmjs.org/sudo-prompt/-/sudo-prompt-9.2.1.tgz", "from": "github:zvin/sudo-prompt#workaround-windows-amperstand-in-username",
"integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==",
"dev": true "dev": true
}, },
"sumchecker": { "sumchecker": {
@ -14746,12 +14742,6 @@
"integrity": "sha1-WFhUf2spB1fulczMZm+1AITEYN0=", "integrity": "sha1-WFhUf2spB1fulczMZm+1AITEYN0=",
"dev": true "dev": true
}, },
"trim-lines": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-1.1.3.tgz",
"integrity": "sha512-E0ZosSWYK2mkSu+KEtQ9/KqarVjA9HztOSX+9FDdNacRAq29RRV6ZQNgob3iuW8Htar9vAfEa6yyt5qBAHZDBA==",
"dev": true
},
"trim-trailing-lines": { "trim-trailing-lines": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/trim-trailing-lines/-/trim-trailing-lines-1.1.3.tgz", "resolved": "https://registry.npmjs.org/trim-trailing-lines/-/trim-trailing-lines-1.1.3.tgz",
@ -15036,9 +15026,9 @@
"dev": true "dev": true
}, },
"uglify-js": { "uglify-js": {
"version": "3.10.2", "version": "3.10.4",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.10.2.tgz", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.10.4.tgz",
"integrity": "sha512-GXCYNwqoo0MbLARghYjxVBxDCnU0tLqN7IPLdHHbibCb1NI5zBkU2EPcy/GaVxc0BtTjqyGXJCINe6JMR2Dpow==", "integrity": "sha512-kBFT3U4Dcj4/pJ52vfjCSfyLyvG9VYYuGYPmrPvAxRw/i7xHiT4VvCev+uiEMcEEiu6UNB6KgWmGtSUYIWScbw==",
"dev": true "dev": true
}, },
"unbzip2-stream": { "unbzip2-stream": {
@ -16467,9 +16457,9 @@
} }
}, },
"whatwg-fetch": { "whatwg-fetch": {
"version": "3.4.0", "version": "3.4.1",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.4.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.4.1.tgz",
"integrity": "sha512-rsum2ulz2iuZH08mJkT0Yi6JnKhwdw4oeyMjokgxd+mmqYSd9cPpOQf01TIWgjxG/U4+QR+AwKq6lSbXVxkyoQ==", "integrity": "sha512-sofZVzE1wKwO+EYPbWfiwzaKovWiZXf4coEzjGP9b2GBVgQRLQUZ2QcuPpQExGDAW5GItpEm6Tl4OU5mywnAoQ==",
"dev": true "dev": true
}, },
"which": { "which": {
@ -16661,9 +16651,9 @@
"dev": true "dev": true
}, },
"xterm": { "xterm": {
"version": "4.8.1", "version": "4.9.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-4.8.1.tgz", "resolved": "https://registry.npmjs.org/xterm/-/xterm-4.9.0.tgz",
"integrity": "sha512-ax91ny4tI5eklqIfH79OUSGE2PUX2rGbwONmB6DfqpyhSZO8/cf++sqiaMWEVCMjACyMfnISW7C3gGMoNvNolQ==", "integrity": "sha512-wGfqufmioctKr8VkbRuZbVDfjlXWGZZ1PWHy1yqqpGT3Nm6yaJx8lxDbSEBANtgaiVPTcKSp97sxOy5IlpqYfw==",
"dev": true "dev": true
}, },
"xterm-addon-fit": { "xterm-addon-fit": {

View File

@ -2,7 +2,7 @@
"name": "balena-etcher", "name": "balena-etcher",
"private": true, "private": true,
"displayName": "balenaEtcher", "displayName": "balenaEtcher",
"version": "1.5.106", "version": "1.5.109",
"packageType": "local", "packageType": "local",
"main": "generated/etcher.js", "main": "generated/etcher.js",
"description": "Flash OS images to SD cards and USB drives, safely and easily.", "description": "Flash OS images to SD cards and USB drives, safely and easily.",
@ -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",
@ -94,7 +94,7 @@
"react": "^16.8.5", "react": "^16.8.5",
"react-dom": "^16.8.5", "react-dom": "^16.8.5",
"redux": "^4.0.5", "redux": "^4.0.5",
"rendition": "^18.4.1", "rendition": "^18.8.3",
"resin-corvus": "^2.0.5", "resin-corvus": "^2.0.5",
"semver": "^7.3.2", "semver": "^7.3.2",
"simple-progress-webpack-plugin": "^1.1.2", "simple-progress-webpack-plugin": "^1.1.2",
@ -102,7 +102,7 @@
"spectron": "^11.0.0", "spectron": "^11.0.0",
"string-replace-loader": "^2.3.0", "string-replace-loader": "^2.3.0",
"styled-components": "^5.1.0", "styled-components": "^5.1.0",
"sudo-prompt": "^9.0.0", "sudo-prompt": "github:zvin/sudo-prompt#workaround-windows-amperstand-in-username",
"sys-class-rgb-led": "^2.1.0", "sys-class-rgb-led": "^2.1.0",
"tmp": "^0.2.1", "tmp": "^0.2.1",
"ts-loader": "^8.0.0", "ts-loader": "^8.0.0",

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';
@ -158,10 +159,13 @@ describe('Model: availableDrives', function () {
selectionState.clear(); selectionState.clear();
selectionState.selectSource({ 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

@ -393,6 +393,7 @@ describe('Model: flashState', function () {
expect(flashResults).to.deep.equal({ expect(flashResults).to.deep.equal({
cancelled: false, cancelled: false,
skip: false,
sourceChecksum: '1234', sourceChecksum: '1234',
}); });
}); });

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,
},
]); ]);
}); });
}); });
@ -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();
@ -446,10 +426,13 @@ describe('Model: selectionState', function () {
describe('.selectImage()', function () { describe('.selectImage()', function () {
it('should override the image', function () { it('should override the image', function () {
selectionState.selectSource({ 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);
it('should be able to set an image', function () { const image: SourceMetadata = {
selectionState.selectSource({ description: 'foo.img',
displayName: 'foo.img',
path: 'foo.img', path: 'foo.img',
extension: 'img', extension: 'img',
size: 999999999, size: 999999999,
isSizeEstimated: false, isSizeEstimated: false,
}); SourceType: File,
recommendedDriveSize: 2000000000,
};
it('should be able to set an image', function () {
selectionState.selectSource(image);
const imagePath = selectionState.getImagePath(); const imagePath = selectionState.getImagePath();
expect(imagePath).to.equal('foo.img'); expect(imagePath).to.equal('foo.img');
@ -491,11 +480,9 @@ describe('Model: selectionState', function () {
it('should be able to set an image with an archive extension', function () { it('should be able to set an image with an archive extension', function () {
selectionState.selectSource({ 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();
@ -504,11 +491,9 @@ 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.selectSource({ 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();
@ -517,53 +502,19 @@ describe('Model: selectionState', function () {
it('should infer a compressed raw image if the penultimate extension is not a file extension', function () { it('should infer a compressed raw image if the penultimate extension is not a file extension', function () {
selectionState.selectSource({ 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.selectSource({
extension: 'img',
size: 999999999,
isSizeEstimated: false,
});
}).to.throw('Missing image fields: path');
});
it('should throw if path is not a string', function () {
expect(function () {
selectionState.selectSource({
path: 123,
extension: 'img',
size: 999999999,
isSizeEstimated: false,
});
}).to.throw('Invalid image path: 123');
});
it('should throw if the original size is not a number', function () {
expect(function () {
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
compressedSize: '999999999',
isSizeEstimated: false,
});
}).to.throw('Invalid image compressed size: 999999999');
});
it('should throw if the original size is a float number', function () { it('should throw if the original size is a float number', function () {
expect(function () { expect(function () {
selectionState.selectSource({ selectionState.selectSource({
...image,
path: 'foo.img', path: 'foo.img',
extension: 'img', extension: 'img',
size: 999999999, size: 999999999,
@ -576,33 +527,17 @@ 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.selectSource({ 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.selectSource({
path: 'foo.img',
extension: 'img',
size: '999999999',
isSizeEstimated: false,
});
}).to.throw('Invalid image size: 999999999');
});
it('should throw if the final size is a float number', function () { it('should throw if the final size is a float number', function () {
expect(function () { expect(function () {
selectionState.selectSource({ 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');
}); });
@ -610,50 +545,12 @@ describe('Model: selectionState', function () {
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.selectSource({ 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.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
isSizeEstimated: false,
url: 1234,
});
}).to.throw('Invalid image url: 1234');
});
it("should throw if name is defined but it's not a string", function () {
expect(function () {
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
isSizeEstimated: false,
name: 1234,
});
}).to.throw('Invalid image name: 1234');
});
it("should throw if logo is defined but it's not a string", function () {
expect(function () {
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
isSizeEstimated: false,
logo: 1234,
});
}).to.throw('Invalid image logo: 1234');
});
it('should de-select a previously selected not-large-enough drive', function () { it('should de-select a previously selected not-large-enough drive', function () {
availableDrives.setDrives([ availableDrives.setDrives([
{ {
@ -668,10 +565,8 @@ describe('Model: selectionState', function () {
expect(selectionState.hasDrive()).to.be.true; expect(selectionState.hasDrive()).to.be.true;
selectionState.selectSource({ 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;
@ -692,10 +587,7 @@ describe('Model: selectionState', function () {
expect(selectionState.hasDrive()).to.be.true; expect(selectionState.hasDrive()).to.be.true;
selectionState.selectSource({ selectionState.selectSource({
path: 'foo.img', ...image,
extension: 'img',
size: 999999999,
isSizeEstimated: false,
recommendedDriveSize: 1500000000, recommendedDriveSize: 1500000000,
}); });
@ -727,10 +619,10 @@ describe('Model: selectionState', function () {
expect(selectionState.hasDrive()).to.be.true; expect(selectionState.hasDrive()).to.be.true;
selectionState.selectSource({ 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.selectSource({ 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.selectSource({ selectionState.selectSource(image);
path: 'foo.img',
extension: 'img',
size: 999999999,
isSizeEstimated: false,
});
}); });
describe('.clear()', function () { describe('.clear()', function () {

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"]
} }