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.
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
## (2020-08-27)

View File

@ -23,7 +23,11 @@ import * as ReactDOM from 'react-dom';
import { v4 as uuidV4 } from 'uuid';
import * as packageJSON from '../../../package.json';
import { isDriveValid, isSourceDrive } from '../../shared/drive-constraints';
import {
DrivelistDrive,
isDriveValid,
isSourceDrive,
} from '../../shared/drive-constraints';
import * as EXIT_CODES from '../../shared/exit-codes';
import * as messages from '../../shared/messages';
import * as availableDrives from './models/available-drives';
@ -231,12 +235,12 @@ function prepareDrive(drive: Drive) {
}
}
function setDrives(drives: _.Dictionary<any>) {
function setDrives(drives: _.Dictionary<DrivelistDrive>) {
availableDrives.setDrives(_.values(drives));
}
function getDrives() {
return _.keyBy(availableDrives.getDrives() || [], 'device');
return _.keyBy(availableDrives.getDrives(), 'device');
}
async function addDrive(drive: Drive) {
@ -352,6 +356,16 @@ async function main() {
ReactDOM.render(
React.createElement(MainPage),
document.getElementById('main'),
// callback to set the correct zoomFactor for webviews as well
async () => {
const fullscreen = await settings.get('fullscreen');
const width = fullscreen ? window.screen.width : window.outerWidth;
try {
electron.webFrame.setZoomFactor(width / settings.DEFAULT_WIDTH);
} catch (err) {
// noop
}
},
);
}

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 * as sourceDestination from 'etcher-sdk/build/source-destination/';
import * as React from 'react';
import {
Flex,
ModalProps,
Txt,
Badge,
Link,
Table,
TableColumn,
} from 'rendition';
import { Flex, ModalProps, Txt, Badge, Link, TableColumn } from 'rendition';
import styled from 'styled-components';
import {
@ -43,7 +35,12 @@ import { getImage, isDriveSelected } from '../../models/selection-state';
import { store } from '../../models/store';
import { logEvent, logException } from '../../modules/analytics';
import { open as openExternal } from '../../os/open-external/services/open-external';
import { Alert, Modal, ScrollableFlex } from '../../styled-components';
import {
Alert,
GenericTableProps,
Modal,
Table,
} from '../../styled-components';
import DriveSVGIcon from '../../../assets/tgt.svg';
import { SourceMetadata } from '../source-selector/source-selector';
@ -75,74 +72,29 @@ function isDrivelistDrive(drive: Drive): drive is DrivelistDrive {
return typeof (drive as DrivelistDrive).size === 'number';
}
const DrivesTable = styled(({ refFn, ...props }) => (
<div>
<Table<Drive> ref={refFn} {...props} />
</div>
const DrivesTable = styled((props: GenericTableProps<Drive>) => (
<Table<Drive> {...props} />
))`
[data-display='table-head']
> [data-display='table-row']
> [data-display='table-cell'] {
position: sticky;
top: 0;
background-color: ${(props) => props.theme.colors.quartenary.light};
input[type='checkbox'] + div {
display: ${({ multipleSelection }) =>
multipleSelection ? 'flex' : 'none'};
}
&:first-child {
padding-left: 15px;
}
&:nth-child(2) {
width: 38%;
}
&:nth-child(3) {
width: 15%;
}
&:nth-child(4) {
width: 15%;
}
&:nth-child(5) {
width: 32%;
}
}
[data-display='table-body'] > [data-display='table-row'] {
> [data-display='table-cell']:first-child {
padding-left: 15px;
}
> [data-display='table-cell']:last-child {
padding-right: 0;
}
&[data-highlight='true'] {
&.system {
background-color: ${(props) =>
props.showWarnings ? '#fff5e6' : '#e8f5fc'};
[data-display='table-head'],
[data-display='table-body'] {
> [data-display='table-row'] > [data-display='table-cell'] {
&:nth-child(2) {
width: 32%;
}
> [data-display='table-cell']:first-child {
box-shadow: none;
&:nth-child(3) {
width: 15%;
}
&:nth-child(4) {
width: 15%;
}
&:nth-child(5) {
width: 32%;
}
}
}
&& [data-display='table-row'] > [data-display='table-cell'] {
padding: 6px 8px;
color: #2a506f;
}
input[type='checkbox'] + div {
border-radius: ${({ multipleSelection }) =>
multipleSelection ? '4px' : '50%'};
}
`;
function badgeShadeFromStatus(status: string) {
@ -289,8 +241,8 @@ export class DriveSelector extends React.Component<
{
field: 'description',
key: 'extra',
// Space as empty string would use the field name as label
label: <Txt></Txt>,
// We use an empty React fragment otherwise it uses the field name as label
label: <></>,
render: (_description: string, drive: Drive) => {
if (isUsbbootDrive(drive)) {
return this.renderProgress(drive.progress);
@ -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() {
this.unsubscribe = store.subscribe(() => {
const drives = getDrives();
@ -453,95 +415,92 @@ export class DriveSelector extends React.Component<
}}
{...props}
>
<Flex width="100%" height="90%">
{!hasAvailableDrives() ? (
<Flex
flexDirection="column"
justifyContent="center"
alignItems="center"
width="100%"
>
<DriveSVGIcon width="40px" height="90px" />
<b>{this.props.emptyListLabel}</b>
</Flex>
) : (
<ScrollableFlex flexDirection="column" width="100%">
<DrivesTable
refFn={(t: Table<Drive>) => {
if (t !== null) {
t.setRowSelection(selectedList);
}
}}
multipleSelection={this.props.multipleSelection}
columns={this.tableColumns}
data={displayedDrives}
disabledRows={disabledDrives}
getRowClass={(row: Drive) =>
isDrivelistDrive(row) && row.isSystem ? ['system'] : []
{!hasAvailableDrives() ? (
<Flex
flexDirection="column"
justifyContent="center"
alignItems="center"
width="100%"
>
<DriveSVGIcon width="40px" height="90px" />
<b>{this.props.emptyListLabel}</b>
</Flex>
) : (
<>
<DrivesTable
refFn={(t) => {
if (t !== null) {
t.setRowSelection(selectedList);
}
rowKey="displayName"
onCheck={(rows: Drive[]) => {
const newSelection = rows.filter(isDrivelistDrive);
if (this.props.multipleSelection) {
this.setState({
selectedList: newSelection,
});
return;
}}
checkedRowsNumber={selectedList.length}
multipleSelection={this.props.multipleSelection}
columns={this.tableColumns}
data={displayedDrives}
disabledRows={disabledDrives}
getRowClass={(row: Drive) =>
isDrivelistDrive(row) && row.isSystem ? ['system'] : []
}
rowKey="displayName"
onCheck={(rows: Drive[]) => {
let newSelection = rows.filter(isDrivelistDrive);
if (this.props.multipleSelection) {
if (this.deselectingAll(newSelection)) {
newSelection = [];
}
this.setState({
selectedList: newSelection.slice(newSelection.length - 1),
selectedList: newSelection,
});
}}
onRowClick={(row: Drive) => {
if (
!isDrivelistDrive(row) ||
this.driveShouldBeDisabled(row, image)
) {
return;
}
if (this.props.multipleSelection) {
const newList = [...selectedList];
const selectedIndex = selectedList.findIndex(
(drive) => drive.device === row.device,
);
if (selectedIndex === -1) {
newList.push(row);
} else {
// Deselect if selected
newList.splice(selectedIndex, 1);
}
this.setState({
selectedList: newList,
});
return;
}
this.setState({
selectedList: [row],
});
}}
/>
{numberOfHiddenSystemDrives > 0 && (
<Link
mt={15}
mb={15}
fontSize="14px"
onClick={() => this.setState({ showSystemDrives: true })}
>
<Flex alignItems="center">
<ChevronDownSvg height="1em" fill="currentColor" />
<Txt ml={8}>Show {numberOfHiddenSystemDrives} hidden</Txt>
</Flex>
</Link>
)}
</ScrollableFlex>
)}
{this.props.showWarnings && hasSystemDrives ? (
<Alert className="system-drive-alert" style={{ width: '67%' }}>
Selecting your system drive is dangerous and will erase your
drive!
</Alert>
) : null}
</Flex>
return;
}
this.setState({
selectedList: newSelection.slice(newSelection.length - 1),
});
}}
onRowClick={(row: Drive) => {
if (
!isDrivelistDrive(row) ||
this.driveShouldBeDisabled(row, image)
) {
return;
}
const index = selectedList.findIndex(
(d) => d.device === row.device,
);
const newList = this.props.multipleSelection
? [...selectedList]
: [];
if (index === -1) {
newList.push(row);
} else {
// Deselect if selected
newList.splice(index, 1);
}
this.setState({
selectedList: newList,
});
}}
/>
{numberOfHiddenSystemDrives > 0 && (
<Link
mt={15}
mb={15}
fontSize="14px"
onClick={() => this.setState({ showSystemDrives: true })}
>
<Flex alignItems="center">
<ChevronDownSvg height="1em" fill="currentColor" />
<Txt ml={8}>Show {numberOfHiddenSystemDrives} hidden</Txt>
</Flex>
</Link>
)}
</>
)}
{this.props.showWarnings && hasSystemDrives ? (
<Alert className="system-drive-alert" style={{ width: '67%' }}>
Selecting your system drive is dangerous and will erase your drive!
</Alert>
) : null}
{missingDriversModal.drive !== undefined && (
<Modal

View File

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

View File

@ -14,7 +14,6 @@
* limitations under the License.
*/
import * as _ from 'lodash';
import * as React from 'react';
import { Flex } from 'rendition';
import { v4 as uuidV4 } from 'uuid';
@ -23,13 +22,9 @@ import * as flashState from '../../models/flash-state';
import * as selectionState from '../../models/selection-state';
import { Actions, store } from '../../models/store';
import * as analytics from '../../modules/analytics';
import { open as openExternal } from '../../os/open-external/services/open-external';
import { FlashAnother } from '../flash-another/flash-another';
import { FlashResults } from '../flash-results/flash-results';
import EtcherSvg from '../../../assets/etcher.svg';
import LoveSvg from '../../../assets/love.svg';
import BalenaSvg from '../../../assets/balena.svg';
import { FlashResults, FlashError } from '../flash-results/flash-results';
import { SafeWebview } from '../safe-webview/safe-webview';
function restart(goToMain: () => void) {
selectionState.deselectAllDrives();
@ -44,22 +39,59 @@ function restart(goToMain: () => void) {
goToMain();
}
function formattedErrors() {
const errors = _.map(
_.get(flashState.getFlashResults(), ['results', 'errors']),
(error) => {
return `${error.device}: ${error.message || error.code}`;
},
);
return errors.join('\n');
}
function FinishPage({ goToMain }: { goToMain: () => void }) {
const 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 (
<Flex flexDirection="column" width="100%" color="#fff">
<Flex height="160px" alignItems="center" justifyContent="center">
<FlashResults results={results} errors={formattedErrors()} />
<Flex height="100%" justifyContent="space-between">
<Flex
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
onClick={() => {
@ -67,34 +99,18 @@ function FinishPage({ goToMain }: { goToMain: () => void }) {
}}
/>
</Flex>
<Flex
flexDirection="column"
height="320px"
justifyContent="space-between"
alignItems="center"
>
<Flex fontSize="28px" mt="40px">
Thanks for using
<EtcherSvg
width="165px"
style={{ margin: '0 10px', cursor: 'pointer' }}
onClick={() =>
openExternal('https://balena.io/etcher?ref=etcher_offline_banner')
}
/>
</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>
<SafeWebview
src="https://www.balena.io/etcher/success-banner?borderTop=false&darkBackground=true"
onWebviewShow={setWebviewShowing}
style={{
display: webviewShowing ? 'flex' : 'none',
position: 'absolute',
right: 0,
bottom: 0,
width: '63.8vw',
height: '100vh',
}}
/>
</Flex>
);
}

View File

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

View File

@ -16,19 +16,108 @@
import CircleSvg from '@fortawesome/fontawesome-free/svgs/solid/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 outdent from 'outdent';
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 { 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({
goToMain,
image = '',
errors,
results,
skip,
...props
}: {
errors: string;
goToMain: () => void;
image?: string;
errors: FlashError[];
skip: boolean;
results: {
bytesWritten: number;
sourceMetadata: {
@ -38,8 +127,9 @@ export function FlashResults({
averageFlashingSpeed: number;
devices: { failed: number; successful: number };
};
}) {
const allDevicesFailed = results.devices.successful === 0;
} & FlexProps) {
const [showErrorsInfo, setShowErrorsInfo] = React.useState(false);
const allFailed = results.devices.successful === 0;
const effectiveSpeed = _.round(
bytesToMegabytes(
results.sourceMetadata.size /
@ -48,44 +138,56 @@ export function FlashResults({
1,
);
return (
<Flex
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">
<Flex flexDirection="column" {...props}>
<Flex alignItems="center" flexDirection="column">
<Flex
alignItems="center"
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" mr="0" mb="0" ml="40px" color="#7e8085">
<Flex flexDirection="column" color="#7e8085">
{Object.entries(results.devices).map(([type, quantity]) => {
const failedTargets = type === 'failed';
return quantity ? (
<Flex
alignItems="center"
tooltip={type === 'failed' ? errors : undefined}
>
<Flex alignItems="center">
<CircleSvg
width="14px"
fill={type === 'failed' ? '#ff4444' : '#1ac135'}
color={failedTargets ? '#ff4444' : '#1ac135'}
/>
<Txt ml={10}>{quantity}</Txt>
<Txt ml={10}>{progress[type](quantity)}</Txt>
<Txt ml="10px" color="#fff">
{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>
) : null;
})}
{!allDevicesFailed && (
{!allFailed && (
<Txt
fontSize="10px"
style={{
@ -101,6 +203,33 @@ export function FlashResults({
</Txt>
)}
</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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -25,15 +25,7 @@ import { GPTPartition, MBRPartition } from 'partitioninfo';
import * as path from 'path';
import * as prettyBytes from 'pretty-bytes';
import * as React from 'react';
import {
Flex,
ButtonProps,
Modal as SmallModal,
Txt,
Card as BaseCard,
Input,
Spinner,
} from 'rendition';
import { Flex, ButtonProps, Modal as SmallModal, Txt } from 'rendition';
import styled from 'styled-components';
import * as errors from '../../../../shared/errors';
@ -48,62 +40,21 @@ import { replaceWindowsNetworkDriveLetter } from '../../os/windows-network-drive
import {
ChangeButton,
DetailsText,
Modal,
StepButton,
StepNameButton,
ScrollableFlex,
} from '../../styled-components';
import { colors } from '../../theme';
import { middleEllipsis } from '../../utils/middle-ellipsis';
import URLSelector from '../url-selector/url-selector';
import { SVGIcon } from '../svg-icon/svg-icon';
import ImageSvg from '../../../assets/image.svg';
import { DriveSelector } from '../drive-selector/drive-selector';
import { DrivelistDrive } from '../../../../shared/drive-constraints';
const recentUrlImagesKey = 'recentUrlImages';
function normalizeRecentUrlImages(urls: any[]): URL[] {
if (!Array.isArray(urls)) {
urls = [];
}
urls = urls
.map((url) => {
try {
return new URL(url);
} catch (error) {
// Invalid URL, skip
}
})
.filter((url) => url !== undefined);
urls = _.uniqBy(urls, (url) => url.href);
return urls.slice(urls.length - 5);
}
function getRecentUrlImages(): URL[] {
let urls = [];
try {
urls = JSON.parse(localStorage.getItem(recentUrlImagesKey) || '[]');
} catch {
// noop
}
return normalizeRecentUrlImages(urls);
}
function setRecentUrlImages(urls: URL[]) {
const normalized = normalizeRecentUrlImages(urls.map((url: URL) => url.href));
localStorage.setItem(recentUrlImagesKey, JSON.stringify(normalized));
}
const isURL = (imagePath: string) =>
imagePath.startsWith('https://') || imagePath.startsWith('http://');
const Card = styled(BaseCard)`
hr {
margin: 5px 0;
}
`;
// TODO move these styles to rendition
const ModalText = styled.p`
a {
@ -127,85 +78,6 @@ function isString(value: any): value is string {
return typeof value === 'string';
}
const URLSelector = ({
done,
cancel,
}: {
done: (imageURL: string) => void;
cancel: () => void;
}) => {
const [imageURL, setImageURL] = React.useState('');
const [recentImages, setRecentImages] = React.useState<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 {
icon?: JSX.Element;
onClick: (evt: React.MouseEvent) => void;
@ -213,22 +85,28 @@ interface Flow {
}
const FlowSelector = styled(
({ flow, ...props }: { flow: Flow; props?: ButtonProps }) => {
return (
<StepButton
plain
onClick={(evt) => flow.onClick(evt)}
icon={flow.icon}
{...props}
>
{flow.label}
</StepButton>
);
},
({ flow, ...props }: { flow: Flow } & ButtonProps) => (
<StepButton
plain={!props.primary}
primary={props.primary}
onClick={(evt: React.MouseEvent<Element, MouseEvent>) =>
flow.onClick(evt)
}
icon={flow.icon}
{...props}
>
{flow.label}
</StepButton>
),
)`
border-radius: 24px;
color: rgba(255, 255, 255, 0.7);
:enabled:focus,
:enabled:focus svg {
color: ${colors.primary.foreground} !important;
}
:enabled:hover {
background-color: ${colors.primary.background};
color: ${colors.primary.foreground};
@ -254,6 +132,7 @@ export interface SourceMetadata extends sourceDestination.Metadata {
SourceType: Source;
drive?: DrivelistDrive;
extension?: string;
archiveExtension?: string;
}
interface SourceSelectorProps {
@ -262,12 +141,13 @@ interface SourceSelectorProps {
interface SourceSelectorState {
hasImage: boolean;
imageName: string;
imageSize: number;
imageName?: string;
imageSize?: number;
warning: { message: string; title: string | null } | null;
showImageDetails: boolean;
showURLSelector: boolean;
showDriveSelector: boolean;
defaultFlowActive: boolean;
}
export class SourceSelector extends React.Component<
@ -284,7 +164,11 @@ export class SourceSelector extends React.Component<
showImageDetails: false,
showURLSelector: false,
showDriveSelector: false,
defaultFlowActive: true,
};
// Bind `this` since it's used in an event's callback
this.onSelectImage = this.onSelectImage.bind(this);
}
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
public render() {
const { flashing } = this.props;
@ -543,7 +431,7 @@ export class SourceSelector extends React.Component<
const imagePath = image.path || image.displayName || '';
const imageBasename = path.basename(imagePath);
const imageName = image.name || '';
const imageSize = image.size || 0;
const imageSize = image.size;
const imageLogo = image.logo || '';
return (
@ -585,17 +473,22 @@ export class SourceSelector extends React.Component<
Remove
</ChangeButton>
)}
<DetailsText>{prettyBytes(imageSize)}</DetailsText>
{!_.isNil(imageSize) && (
<DetailsText>{prettyBytes(imageSize)}</DetailsText>
)}
</>
) : (
<>
<FlowSelector
primary={this.state.defaultFlowActive}
key="Flash from file"
flow={{
onClick: () => this.openImageSelector(),
label: 'Flash from file',
icon: <FileSvg height="1em" fill="currentColor" />,
}}
onMouseEnter={() => this.setDefaultFlowActive(false)}
onMouseLeave={() => this.setDefaultFlowActive(true)}
/>
<FlowSelector
key="Flash from URL"
@ -604,6 +497,8 @@ export class SourceSelector extends React.Component<
label: 'Flash from URL',
icon: <LinkSvg height="1em" fill="currentColor" />,
}}
onMouseEnter={() => this.setDefaultFlowActive(false)}
onMouseLeave={() => this.setDefaultFlowActive(true)}
/>
<FlowSelector
key="Clone drive"
@ -612,6 +507,8 @@ export class SourceSelector extends React.Component<
label: 'Clone drive',
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 {
// List of embedded SVG contents to be tried in succession if any fails
contents: string;
// Optional string representing the SVG contents to be tried
contents?: string;
// Fallback SVG element to show if `contents` is invalid/undefined
fallback: React.FunctionComponent<React.SVGProps<HTMLOrSVGElement>>;
// SVG image width unit
width?: string;

View File

@ -23,7 +23,7 @@ import {
DriveStatus,
} from '../../../../shared/drive-constraints';
import { compatibility, warning } from '../../../../shared/messages';
import { bytesToClosestUnit } from '../../../../shared/units';
import * as prettyBytes from 'pretty-bytes';
import { getSelectedDrives } from '../../models/selection-state';
import {
ChangeButton,
@ -96,7 +96,9 @@ export function TargetSelectorButton(props: TargetSelectorProps) {
Change
</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(
<DetailsText
key={target.device}
tooltip={`${target.description} ${
target.displayName
} ${bytesToClosestUnit(target.size)}`}
tooltip={`${target.description} ${target.displayName} ${
target.size != null ? prettyBytes(target.size) : ''
}`}
px={21}
>
{warnings.length && (
{warnings.length > 0 ? (
<DriveCompatibilityWarning warnings={warnings} mr={2} />
)}
) : null}
<Txt mr={2}>{middleEllipsis(target.description, 14)}</Txt>
<Txt>{bytesToClosestUnit(target.size)}</Txt>
{target.size != null && <Txt>{prettyBytes(target.size)}</Txt>}
</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.
*/
import { DrivelistDrive } from '../../../shared/drive-constraints';
import { Actions, store } from './store';
export function hasAvailableDrives() {
@ -27,6 +28,6 @@ export function setDrives(drives: any[]) {
});
}
export function getDrives(): any[] {
export function getDrives(): DrivelistDrive[] {
return store.getState().toJS().availableDrives;
}

View File

@ -75,14 +75,25 @@ export function setDevicePaths(devicePaths: string[]) {
});
}
export function addFailedDevicePath(devicePath: string) {
const failedDevicePathsSet = new Set(
export function addFailedDevicePath({
device,
error,
}: {
device: sdk.scanner.adapters.DrivelistDrive;
error: Error;
}) {
const failedDevicePathsMap = new Map(
store.getState().toJS().failedDevicePaths,
);
failedDevicePathsSet.add(devicePath);
failedDevicePathsMap.set(device.device, {
description: device.description,
device: device.device,
devicePath: device.devicePath,
...error,
});
store.dispatch({
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 {
selectedDrivesPaths = s.devicePaths;
}
const failedDevicePaths = s.failedDevicePaths.map(
([devicePath]: [string]) => devicePath,
);
const newLedsState = {
step,
sourceDrive: sourceDrivePath,
availableDrives: availableDrivesPaths,
selectedDrives: selectedDrivesPaths,
failedDrives: s.failedDevicePaths,
failedDrives: failedDevicePaths,
};
if (!_.isEqual(newLedsState, ledsState)) {
updateLeds(newLedsState);

View File

@ -1,3 +1,4 @@
import { DrivelistDrive } from '../../../shared/drive-constraints';
/*
* Copyright 2016 balena.io
*
@ -14,7 +15,6 @@
* limitations under the License.
*/
import * as _ from 'lodash';
import { SourceMetadata } from '../components/source-selector/source-selector';
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({
type: Actions.SELECT_SOURCE,
data: source,
@ -58,50 +58,38 @@ export function getSelectedDevices(): string[] {
/**
* @summary Get all selected drive objects
*/
export function getSelectedDrives(): any[] {
const drives = availableDrives.getDrives();
return _.map(getSelectedDevices(), (device) => {
return _.find(drives, { device });
});
export function getSelectedDrives(): DrivelistDrive[] {
const selectedDevices = getSelectedDevices();
return availableDrives
.getDrives()
.filter((drive) => selectedDevices.includes(drive.device));
}
/**
* @summary Get the selected image
*/
export function getImage(): SourceMetadata {
return _.get(store.getState().toJS(), ['selection', 'image']);
export function getImage(): SourceMetadata | undefined {
return store.getState().toJS().selection.image;
}
export function getImagePath(): string {
return _.get(store.getState().toJS(), ['selection', 'image', 'path']);
export function getImagePath(): string | undefined {
return store.getState().toJS().selection.image?.path;
}
export function getImageSize(): number {
return _.get(store.getState().toJS(), ['selection', 'image', 'size']);
export function getImageSize(): number | undefined {
return store.getState().toJS().selection.image?.size;
}
export function getImageUrl(): string {
return _.get(store.getState().toJS(), ['selection', 'image', 'url']);
export function getImageName(): string | undefined {
return store.getState().toJS().selection.image?.name;
}
export function getImageName(): string {
return _.get(store.getState().toJS(), ['selection', 'image', 'name']);
export function getImageLogo(): string | undefined {
return store.getState().toJS().selection.image?.logo;
}
export function getImageLogo(): string {
return _.get(store.getState().toJS(), ['selection', 'image', 'logo']);
}
export function getImageSupportUrl(): string {
return _.get(store.getState().toJS(), ['selection', 'image', 'supportUrl']);
}
export function getImageRecommendedDriveSize(): number {
return _.get(store.getState().toJS(), [
'selection',
'image',
'recommendedDriveSize',
]);
export function getImageSupportUrl(): string | undefined {
return store.getState().toJS().selection.image?.supportUrl;
}
/**
@ -115,7 +103,7 @@ export function hasDrive(): boolean {
* @summary Check if there is a selected image
*/
export function hasImage(): boolean {
return !_.isEmpty(getImage());
return getImage() !== undefined;
}
/**
@ -136,7 +124,7 @@ export function deselectImage() {
}
export function deselectAllDrives() {
_.each(getSelectedDevices(), deselectDrive);
getSelectedDevices().forEach(deselectDrive);
}
/**
@ -156,5 +144,5 @@ export function isDriveSelected(driveDevice: string) {
}
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;
export const DEFAULT_WIDTH = 800;
export const DEFAULT_HEIGHT = 480;
/**
* @summary Userdata directory path
* @description
@ -38,12 +41,15 @@ const JSON_INDENT = 2;
* NOTE: The ternary is due to this module being loaded both,
* Electron's main process and renderer process
*/
const USER_DATA_DIR = electron.app
? electron.app.getPath('userData')
: electron.remote.app.getPath('userData');
const app = electron.app || electron.remote.app;
const USER_DATA_DIR = app.getPath('userData');
const CONFIG_PATH = join(USER_DATA_DIR, 'config.json');
const DOWNLOADS_DIR = app.getPath('downloads');
async function readConfigFile(filename: string): Promise<_.Dictionary<any>> {
let contents = '{}';
try {
@ -80,6 +86,8 @@ const DEFAULT_SETTINGS: _.Dictionary<any> = {
desktopNotifications: true,
autoBlockmapping: true,
decompressFirst: true,
saveUrlImage: false,
saveUrlImageTo: DOWNLOADS_DIR,
};
const settings = _.cloneDeep(DEFAULT_SETTINGS);

View File

@ -295,6 +295,7 @@ function storeReducer(
_.defaults(action.data, {
cancelled: false,
skip: false,
});
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
.set('isFlashing', false)
.set('flashResults', Immutable.fromJS(action.data))

View File

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

View File

@ -14,7 +14,7 @@
* limitations under the License.
*/
import { bytesToClosestUnit } from '../../../shared/units';
import * as prettyBytes from 'pretty-bytes';
export interface FlashState {
active: number;
@ -22,7 +22,7 @@ export interface FlashState {
percentage?: number;
speed: number;
position: number;
type?: 'decompressing' | 'flashing' | 'verifying';
type?: 'decompressing' | 'flashing' | 'verifying' | 'downloading';
}
export function fromFlashState({
@ -51,7 +51,7 @@ export function fromFlashState({
} else {
return {
status: 'Flashing...',
position: `${position ? bytesToClosestUnit(position) : ''}`,
position: `${position ? prettyBytes(position) : ''}`,
};
}
} else if (type === 'verifying') {
@ -62,6 +62,12 @@ export function fromFlashState({
} else {
return { status: 'Finishing...' };
}
} else if (type === 'downloading') {
if (percentage == null) {
return { status: 'Downloading...' };
} else if (percentage < 100) {
return { position: `${percentage}%`, status: 'Downloading...' };
}
}
return { status: 'Failed' };
}

View File

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

View File

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

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 * as path from 'path';
import * as prettyBytes from 'pretty-bytes';
import * as React from 'react';
import { Flex } from 'rendition';
import styled from 'styled-components';
import FinishPage from '../../components/finish/finish';
import { ReducedFlashingInfos } from '../../components/reduced-flashing-infos/reduced-flashing-infos';
import { SafeWebview } from '../../components/safe-webview/safe-webview';
import { SettingsModal } from '../../components/settings/settings';
import {
SourceMetadata,
@ -40,8 +40,6 @@ import {
ThemedProvider,
} from '../../styled-components';
import { bytesToClosestUnit } from '../../../../shared/units';
import {
TargetSelector,
getDriveListLabel,
@ -49,6 +47,8 @@ import {
import { FlashStep } from './Flash';
import EtcherSvg from '../../../assets/etcher.svg';
import { SafeWebview } from '../../components/safe-webview/safe-webview';
import { colors } from '../../theme';
const Icon = styled(BaseIcon)`
margin-right: 20px;
@ -88,9 +88,7 @@ const StepBorder = styled.div<{
position: relative;
height: 2px;
background-color: ${(props) =>
props.disabled
? props.theme.colors.dark.disabled.foreground
: props.theme.colors.dark.foreground};
props.disabled ? colors.dark.disabled.foreground : colors.dark.foreground};
width: 120px;
top: 19px;
@ -104,9 +102,9 @@ interface MainPageStateFromStore {
isFlashing: boolean;
hasImage: boolean;
hasDrive: boolean;
imageLogo: string;
imageSize: number;
imageName: string;
imageLogo?: string;
imageSize?: number;
imageName?: string;
driveTitle: string;
driveLabel: string;
}
@ -170,7 +168,104 @@ export class MainPage extends React.Component<
const notFlashingOrSplitView =
!this.state.isFlashing || !this.state.isWebviewShowing;
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
justifyContent="space-between"
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.renderMain()
: this.renderSuccess()}

View File

@ -14,6 +14,7 @@
* limitations under the License.
*/
import * as _ from 'lodash';
import * as React from 'react';
import {
Alert as AlertBase,
@ -23,27 +24,16 @@ import {
ButtonProps,
Modal as ModalBase,
Provider,
Table as BaseTable,
TableProps as BaseTableProps,
Txt,
Theme as renditionTheme,
} from 'rendition';
import styled, { css } from 'styled-components';
import { colors, theme } from './theme';
const defaultTheme = {
...renditionTheme,
...theme,
layer: {
extend: () => `
> div:first-child {
background-color: transparent;
}
`,
},
};
export const ThemedProvider = (props: any) => (
<Provider theme={defaultTheme} {...props}></Provider>
<Provider theme={theme} {...props}></Provider>
);
export const BaseButton = styled(Button)`
@ -134,24 +124,23 @@ const modalFooterShadowCss = css`
background-attachment: local, local, scroll, scroll;
`;
export const Modal = styled(({ style, ...props }) => {
export const Modal = styled(({ style, children, ...props }) => {
return (
<Provider
theme={{
...defaultTheme,
theme={_.merge({}, theme, {
header: {
height: '50px',
},
layer: {
extend: () => `
${defaultTheme.layer.extend()}
${theme.layer.extend()}
> div:last-child {
top: 0;
}
`,
> div:last-child {
top: 0;
}
`,
},
}}
})}
>
<ModalBase
position="top"
@ -167,7 +156,11 @@ export const Modal = styled(({ style, ...props }) => {
...style,
}}
{...props}
/>
>
<ScrollableFlex flexDirection="column" width="100%" height="90%">
{...children}
</ScrollableFlex>
</ModalBase>
</Provider>
);
})`
@ -175,6 +168,11 @@ export const Modal = styled(({ style, ...props }) => {
padding: 0;
height: 100%;
> div:first-child {
height: 81%;
padding: 24px 30px 0;
}
> h3 {
margin: 0;
padding: 24px 30px 0;
@ -188,11 +186,8 @@ export const Modal = styled(({ style, ...props }) => {
> div:nth-child(2) {
height: 61%;
> div:not(.system-drive-alert) {
padding: 0 30px;
${modalFooterShadowCss}
}
padding: 0 30px;
${modalFooterShadowCss}
}
> div:last-child {
@ -249,3 +244,99 @@ export const Alert = styled((props) => (
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.
*/
import * as _ from 'lodash';
import { Theme } from 'rendition';
export const colors = {
dark: {
foreground: '#fff',
@ -67,8 +70,7 @@ export const colors = {
const font = 'SourceSansPro';
export const theme = {
colors,
export const theme = _.merge({}, Theme, {
font,
global: {
font: {
@ -109,4 +111,11 @@ export const theme = {
}
`,
},
};
layer: {
extend: () => `
> div:first-child {
background-color: transparent;
}
`,
},
});

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

View File

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

View File

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

View File

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

178
npm-shrinkwrap.json generated
View File

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

View File

@ -2,7 +2,7 @@
"name": "balena-etcher",
"private": true,
"displayName": "balenaEtcher",
"version": "1.5.106",
"version": "1.5.109",
"packageType": "local",
"main": "generated/etcher.js",
"description": "Flash OS images to SD cards and USB drives, safely and easily.",
@ -77,7 +77,7 @@
"electron-notarize": "^1.0.0",
"electron-rebuild": "^1.11.0",
"electron-updater": "^4.3.2",
"etcher-sdk": "^4.1.29",
"etcher-sdk": "^4.1.30",
"file-loader": "^6.0.0",
"husky": "^4.2.5",
"immutable": "^3.8.1",
@ -94,7 +94,7 @@
"react": "^16.8.5",
"react-dom": "^16.8.5",
"redux": "^4.0.5",
"rendition": "^18.4.1",
"rendition": "^18.8.3",
"resin-corvus": "^2.0.5",
"semver": "^7.3.2",
"simple-progress-webpack-plugin": "^1.1.2",
@ -102,7 +102,7 @@
"spectron": "^11.0.0",
"string-replace-loader": "^2.3.0",
"styled-components": "^5.1.0",
"sudo-prompt": "^9.0.0",
"sudo-prompt": "github:zvin/sudo-prompt#workaround-windows-amperstand-in-username",
"sys-class-rgb-led": "^2.1.0",
"tmp": "^0.2.1",
"ts-loader": "^8.0.0",

View File

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

View File

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

View File

@ -15,11 +15,13 @@
*/
import { expect } from 'chai';
import * as _ from 'lodash';
import { File } from 'etcher-sdk/build/source-destination';
import * as path from 'path';
import { SourceMetadata } from '../../../lib/gui/app/components/source-selector/source-selector';
import * as availableDrives from '../../../lib/gui/app/models/available-drives';
import * as selectionState from '../../../lib/gui/app/models/selection-state';
import { DrivelistDrive } from '../../../lib/shared/drive-constraints';
describe('Model: selectionState', function () {
describe('given a clean state', function () {
@ -39,10 +41,6 @@ describe('Model: selectionState', function () {
expect(selectionState.getImageSize()).to.be.undefined;
});
it('getImageUrl() should return undefined', function () {
expect(selectionState.getImageUrl()).to.be.undefined;
});
it('getImageName() should return undefined', function () {
expect(selectionState.getImageName()).to.be.undefined;
});
@ -55,10 +53,6 @@ describe('Model: selectionState', function () {
expect(selectionState.getImageSupportUrl()).to.be.undefined;
});
it('getImageRecommendedDriveSize() should return undefined', function () {
expect(selectionState.getImageRecommendedDriveSize()).to.be.undefined;
});
it('hasDrive() should return false', function () {
const hasDrive = selectionState.hasDrive();
expect(hasDrive).to.be.false;
@ -138,10 +132,10 @@ describe('Model: selectionState', function () {
it('should queue the drive', function () {
selectionState.selectDrive('/dev/disk5');
const drives = selectionState.getSelectedDevices();
const lastDriveDevice = _.last(drives);
const lastDrive = _.find(availableDrives.getDrives(), {
device: lastDriveDevice,
});
const lastDriveDevice = drives.pop();
const lastDrive = availableDrives
.getDrives()
.find((drive) => drive.device === lastDriveDevice);
expect(lastDrive).to.deep.equal({
device: '/dev/disk5',
name: 'USB Drive',
@ -214,7 +208,7 @@ describe('Model: selectionState', function () {
it('should be able to add more drives', function () {
selectionState.selectDrive(this.drives[2].device);
expect(selectionState.getSelectedDevices()).to.deep.equal(
_.map(this.drives, 'device'),
this.drives.map((drive: DrivelistDrive) => drive.device),
);
});
@ -234,13 +228,13 @@ describe('Model: selectionState', function () {
system: true,
};
const newDrives = [..._.initial(this.drives), systemDrive];
const newDrives = [...this.drives.slice(0, -1), systemDrive];
availableDrives.setDrives(newDrives);
selectionState.selectDrive(systemDrive.device);
availableDrives.setDrives(newDrives);
expect(selectionState.getSelectedDevices()).to.deep.equal(
_.map(newDrives, 'device'),
newDrives.map((drive: DrivelistDrive) => drive.device),
);
});
@ -271,6 +265,12 @@ describe('Model: selectionState', function () {
describe('.getSelectedDrives()', function () {
it('should return the selected drives', function () {
expect(selectionState.getSelectedDrives()).to.deep.equal([
{
device: '/dev/disk2',
name: 'USB Drive 2',
size: 999999999,
isReadOnly: false,
},
{
device: '/dev/sdb',
description: 'DataTraveler 2.0',
@ -280,12 +280,6 @@ describe('Model: selectionState', function () {
system: false,
isReadOnly: false,
},
{
device: '/dev/disk2',
name: 'USB Drive 2',
size: 999999999,
isReadOnly: false,
},
]);
});
});
@ -399,13 +393,6 @@ describe('Model: selectionState', function () {
});
});
describe('.getImageUrl()', function () {
it('should return the image url', function () {
const imageUrl = selectionState.getImageUrl();
expect(imageUrl).to.equal('https://www.raspbian.org');
});
});
describe('.getImageName()', function () {
it('should return the image name', function () {
const imageName = selectionState.getImageName();
@ -429,13 +416,6 @@ describe('Model: selectionState', function () {
});
});
describe('.getImageRecommendedDriveSize()', function () {
it('should return the image recommended drive size', function () {
const imageRecommendedDriveSize = selectionState.getImageRecommendedDriveSize();
expect(imageRecommendedDriveSize).to.equal(1000000000);
});
});
describe('.hasImage()', function () {
it('should return true', function () {
const hasImage = selectionState.hasImage();
@ -446,10 +426,13 @@ describe('Model: selectionState', function () {
describe('.selectImage()', function () {
it('should override the image', function () {
selectionState.selectSource({
description: 'bar.img',
displayName: 'bar.img',
path: 'bar.img',
extension: 'img',
size: 999999999,
isSizeEstimated: false,
SourceType: File,
});
const imagePath = selectionState.getImagePath();
@ -475,13 +458,19 @@ describe('Model: selectionState', function () {
describe('.selectImage()', function () {
afterEach(selectionState.clear);
const image: SourceMetadata = {
description: 'foo.img',
displayName: 'foo.img',
path: 'foo.img',
extension: 'img',
size: 999999999,
isSizeEstimated: false,
SourceType: File,
recommendedDriveSize: 2000000000,
};
it('should be able to set an image', function () {
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
isSizeEstimated: false,
});
selectionState.selectSource(image);
const imagePath = selectionState.getImagePath();
expect(imagePath).to.equal('foo.img');
@ -491,11 +480,9 @@ describe('Model: selectionState', function () {
it('should be able to set an image with an archive extension', function () {
selectionState.selectSource({
...image,
path: 'foo.zip',
extension: 'img',
archiveExtension: 'zip',
size: 999999999,
isSizeEstimated: false,
});
const imagePath = selectionState.getImagePath();
@ -504,11 +491,9 @@ describe('Model: selectionState', function () {
it('should infer a compressed raw image if the penultimate extension is missing', function () {
selectionState.selectSource({
...image,
path: 'foo.xz',
extension: 'img',
archiveExtension: 'xz',
size: 999999999,
isSizeEstimated: false,
});
const imagePath = selectionState.getImagePath();
@ -517,53 +502,19 @@ describe('Model: selectionState', function () {
it('should infer a compressed raw image if the penultimate extension is not a file extension', function () {
selectionState.selectSource({
...image,
path: 'something.linux-x86-64.gz',
extension: 'img',
archiveExtension: 'gz',
size: 999999999,
isSizeEstimated: false,
});
const imagePath = selectionState.getImagePath();
expect(imagePath).to.equal('something.linux-x86-64.gz');
});
it('should throw if no path', function () {
expect(function () {
selectionState.selectSource({
extension: 'img',
size: 999999999,
isSizeEstimated: false,
});
}).to.throw('Missing image fields: path');
});
it('should throw if path is not a string', function () {
expect(function () {
selectionState.selectSource({
path: 123,
extension: 'img',
size: 999999999,
isSizeEstimated: false,
});
}).to.throw('Invalid image path: 123');
});
it('should throw if the original size is not a number', function () {
expect(function () {
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
compressedSize: '999999999',
isSizeEstimated: false,
});
}).to.throw('Invalid image compressed size: 999999999');
});
it('should throw if the original size is a float number', function () {
expect(function () {
selectionState.selectSource({
...image,
path: 'foo.img',
extension: 'img',
size: 999999999,
@ -576,33 +527,17 @@ describe('Model: selectionState', function () {
it('should throw if the original size is negative', function () {
expect(function () {
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
...image,
compressedSize: -1,
isSizeEstimated: false,
});
}).to.throw('Invalid image compressed size: -1');
});
it('should throw if the final size is not a number', function () {
expect(function () {
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: '999999999',
isSizeEstimated: false,
});
}).to.throw('Invalid image size: 999999999');
});
it('should throw if the final size is a float number', function () {
expect(function () {
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
...image,
size: 999999999.999,
isSizeEstimated: false,
});
}).to.throw('Invalid image size: 999999999.999');
});
@ -610,50 +545,12 @@ describe('Model: selectionState', function () {
it('should throw if the final size is negative', function () {
expect(function () {
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
...image,
size: -1,
isSizeEstimated: false,
});
}).to.throw('Invalid image size: -1');
});
it("should throw if url is defined but it's not a string", function () {
expect(function () {
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
isSizeEstimated: false,
url: 1234,
});
}).to.throw('Invalid image url: 1234');
});
it("should throw if name is defined but it's not a string", function () {
expect(function () {
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
isSizeEstimated: false,
name: 1234,
});
}).to.throw('Invalid image name: 1234');
});
it("should throw if logo is defined but it's not a string", function () {
expect(function () {
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
isSizeEstimated: false,
logo: 1234,
});
}).to.throw('Invalid image logo: 1234');
});
it('should de-select a previously selected not-large-enough drive', function () {
availableDrives.setDrives([
{
@ -668,10 +565,8 @@ describe('Model: selectionState', function () {
expect(selectionState.hasDrive()).to.be.true;
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
...image,
size: 1234567890,
isSizeEstimated: false,
});
expect(selectionState.hasDrive()).to.be.false;
@ -692,10 +587,7 @@ describe('Model: selectionState', function () {
expect(selectionState.hasDrive()).to.be.true;
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
isSizeEstimated: false,
...image,
recommendedDriveSize: 1500000000,
});
@ -727,10 +619,10 @@ describe('Model: selectionState', function () {
expect(selectionState.hasDrive()).to.be.true;
selectionState.selectSource({
...image,
path: imagePath,
extension: 'img',
size: 999999999,
isSizeEstimated: false,
});
expect(selectionState.hasDrive()).to.be.false;
@ -740,6 +632,16 @@ describe('Model: selectionState', function () {
});
describe('given a drive and an image', function () {
const image: SourceMetadata = {
description: 'foo.img',
displayName: 'foo.img',
path: 'foo.img',
extension: 'img',
size: 999999999,
SourceType: File,
isSizeEstimated: false,
};
beforeEach(function () {
availableDrives.setDrives([
{
@ -752,12 +654,7 @@ describe('Model: selectionState', function () {
selectionState.selectDrive('/dev/disk1');
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
isSizeEstimated: false,
});
selectionState.selectSource(image);
});
describe('.clear()', function () {
@ -824,6 +721,16 @@ describe('Model: selectionState', function () {
});
describe('given several drives', function () {
const image: SourceMetadata = {
description: 'foo.img',
displayName: 'foo.img',
path: 'foo.img',
extension: 'img',
size: 999999999,
SourceType: File,
isSizeEstimated: false,
};
beforeEach(function () {
availableDrives.setDrives([
{
@ -850,12 +757,7 @@ describe('Model: selectionState', function () {
selectionState.selectDrive('/dev/disk2');
selectionState.selectDrive('/dev/disk3');
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
isSizeEstimated: false,
});
selectionState.selectSource(image);
});
describe('.clear()', function () {

View File

@ -15,45 +15,13 @@
*/
import { expect } from 'chai';
import * as units from '../../lib/shared/units';
import { bytesToMegabytes } from '../../lib/shared/units';
describe('Shared: Units', function () {
describe('.bytesToClosestUnit()', function () {
it('should convert bytes to terabytes', function () {
expect(units.bytesToClosestUnit(1000000000000)).to.equal('1 TB');
expect(units.bytesToClosestUnit(2987801405440)).to.equal('2.99 TB');
expect(units.bytesToClosestUnit(999900000000000)).to.equal('1000 TB');
});
it('should convert bytes to gigabytes', function () {
expect(units.bytesToClosestUnit(1000000000)).to.equal('1 GB');
expect(units.bytesToClosestUnit(7801405440)).to.equal('7.8 GB');
expect(units.bytesToClosestUnit(999900000000)).to.equal('1000 GB');
});
it('should convert bytes to megabytes', function () {
expect(units.bytesToClosestUnit(1000000)).to.equal('1 MB');
expect(units.bytesToClosestUnit(801405440)).to.equal('801 MB');
expect(units.bytesToClosestUnit(999900000)).to.equal('1000 MB');
});
it('should convert bytes to kilobytes', function () {
expect(units.bytesToClosestUnit(1000)).to.equal('1 kB');
expect(units.bytesToClosestUnit(5440)).to.equal('5.44 kB');
expect(units.bytesToClosestUnit(999900)).to.equal('1000 kB');
});
it('should keep bytes as bytes', function () {
expect(units.bytesToClosestUnit(1)).to.equal('1 B');
expect(units.bytesToClosestUnit(8)).to.equal('8 B');
expect(units.bytesToClosestUnit(999)).to.equal('999 B');
});
});
describe('.bytesToMegabytes()', function () {
it('should convert bytes to megabytes', function () {
expect(units.bytesToMegabytes(1.2e7)).to.equal(12);
expect(units.bytesToMegabytes(332000)).to.equal(0.332);
expect(bytesToMegabytes(1.2e7)).to.equal(12);
expect(bytesToMegabytes(332000)).to.equal(0.332);
});
});
});

View File

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