mirror of
https://github.com/balena-io/etcher.git
synced 2025-08-11 12:19:22 +00:00
Compare commits
68 Commits
v1.5.103
...
save-url-i
Author | SHA1 | Date | |
---|---|---|---|
![]() |
f6ce9a217d | ||
![]() |
fce2d94df7 | ||
![]() |
3feb22ee66 | ||
![]() |
b80a6b2feb | ||
![]() |
b4e6970119 | ||
![]() |
2e3978b3c9 | ||
![]() |
c6cd421f17 | ||
![]() |
c3296eed54 | ||
![]() |
153e37b9dc | ||
![]() |
78aca6a19f | ||
![]() |
27695babfd | ||
![]() |
06a96db72d | ||
![]() |
6584cef774 | ||
![]() |
3c77800b1d | ||
![]() |
74a78076cf | ||
![]() |
8ff8b02f37 | ||
![]() |
e9603505d2 | ||
![]() |
0f45f6aca1 | ||
![]() |
0a28a7794d | ||
![]() |
7c2644ec51 | ||
![]() |
ae62812c61 | ||
![]() |
68e24df52b | ||
![]() |
b9076d01af | ||
![]() |
78a5339e3e | ||
![]() |
b099770cb1 | ||
![]() |
b76366a514 | ||
![]() |
eeab351636 | ||
![]() |
3e45691d0b | ||
![]() |
f9d79521a1 | ||
![]() |
14a89b3b8a | ||
![]() |
8fa6e618c4 | ||
![]() |
093008dee7 | ||
![]() |
42838eba09 | ||
![]() |
aa72c5d3bb | ||
![]() |
bb04098062 | ||
![]() |
dda022df37 | ||
![]() |
377dfb8e22 | ||
![]() |
07befd0bd1 | ||
![]() |
2635a410df | ||
![]() |
5e5f82c4b5 | ||
![]() |
991cbf6b7f | ||
![]() |
688d697a99 | ||
![]() |
7894a67719 | ||
![]() |
7a7ea74984 | ||
![]() |
12cd8a39c1 | ||
![]() |
2c07538f8f | ||
![]() |
c9bfd350ed | ||
![]() |
a485d2b4df | ||
![]() |
8ed5ff25a5 | ||
![]() |
a17a919c37 | ||
![]() |
55cafb9268 | ||
![]() |
92dfdc6edd | ||
![]() |
fff9452509 | ||
![]() |
27e560c961 | ||
![]() |
34489f0d66 | ||
![]() |
b7f8c8368c | ||
![]() |
f383f0be6c | ||
![]() |
ff08cb44f9 | ||
![]() |
6cb914e969 | ||
![]() |
a24be20e95 | ||
![]() |
08716efbd5 | ||
![]() |
24c8ede746 | ||
![]() |
548475996c | ||
![]() |
7f9add3f1e | ||
![]() |
6eab47259e | ||
![]() |
46663e3a6f | ||
![]() |
9797a2152d | ||
![]() |
a7c3431556 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -47,3 +47,7 @@ node_modules
|
||||
# OSX files
|
||||
|
||||
.DS_Store
|
||||
|
||||
# VSCode files
|
||||
|
||||
.vscode
|
||||
|
37
CHANGELOG.md
37
CHANGELOG.md
@@ -3,6 +3,43 @@
|
||||
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)
|
||||
|
||||
* Disable ext partitions trimming on 32 bit windows until it is fixed [Alexis Svinartchouk]
|
||||
* Fix opening zip files from servers accepting Range headers [Alexis Svinartchouk]
|
||||
|
||||
# v1.5.105
|
||||
## (2020-08-25)
|
||||
|
||||
* Update etcher-sdk to 4.1.26 [Alexis Svinartchouk]
|
||||
* URL selector cancel button cancels ongoing url selection [Alexis Svinartchouk]
|
||||
* Spinner for URL selector modal [Alexis Svinartchouk]
|
||||
|
||||
# v1.5.104
|
||||
## (2020-08-20)
|
||||
|
||||
* Fix writing config file [Alexis Svinartchouk]
|
||||
* Update electron to v9.2.1 [Alexis Svinartchouk]
|
||||
|
||||
# v1.5.103
|
||||
## (2020-08-18)
|
||||
|
||||
|
8
Makefile
8
Makefile
@@ -9,12 +9,6 @@ S3_BUCKET = artifacts.ci.balena-cloud.com
|
||||
# This directory will be completely deleted by the `clean` rule
|
||||
BUILD_DIRECTORY ?= dist
|
||||
|
||||
# See http://stackoverflow.com/a/20763842/1641422
|
||||
BUILD_DIRECTORY_PARENT = $(dir $(BUILD_DIRECTORY))
|
||||
ifeq ($(wildcard $(BUILD_DIRECTORY_PARENT).),)
|
||||
$(error $(BUILD_DIRECTORY_PARENT) does not exist)
|
||||
endif
|
||||
|
||||
BUILD_TEMPORARY_DIRECTORY = $(BUILD_DIRECTORY)/.tmp
|
||||
|
||||
$(BUILD_DIRECTORY):
|
||||
@@ -91,7 +85,7 @@ TARGET_ARCH ?= $(HOST_ARCH)
|
||||
# ---------------------------------------------------------------------
|
||||
# Electron
|
||||
# ---------------------------------------------------------------------
|
||||
electron-develop: | $(BUILD_TEMPORARY_DIRECTORY)
|
||||
electron-develop:
|
||||
$(RESIN_SCRIPTS)/electron/install.sh \
|
||||
-b $(shell pwd) \
|
||||
-r $(TARGET_ARCH) \
|
||||
|
@@ -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
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
534
lib/gui/app/components/drive-selector/drive-selector.tsx
Normal file
534
lib/gui/app/components/drive-selector/drive-selector.tsx
Normal file
@@ -0,0 +1,534 @@
|
||||
/*
|
||||
* Copyright 2019 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.
|
||||
*/
|
||||
|
||||
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg';
|
||||
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, TableColumn } from 'rendition';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import {
|
||||
getDriveImageCompatibilityStatuses,
|
||||
isDriveValid,
|
||||
DriveStatus,
|
||||
DrivelistDrive,
|
||||
isDriveSizeLarge,
|
||||
} from '../../../../shared/drive-constraints';
|
||||
import { compatibility, warning } from '../../../../shared/messages';
|
||||
import * as prettyBytes from 'pretty-bytes';
|
||||
import { getDrives, hasAvailableDrives } from '../../models/available-drives';
|
||||
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,
|
||||
GenericTableProps,
|
||||
Modal,
|
||||
Table,
|
||||
} from '../../styled-components';
|
||||
|
||||
import DriveSVGIcon from '../../../assets/tgt.svg';
|
||||
import { SourceMetadata } from '../source-selector/source-selector';
|
||||
|
||||
interface UsbbootDrive extends sourceDestination.UsbbootDrive {
|
||||
progress: number;
|
||||
}
|
||||
|
||||
interface DriverlessDrive {
|
||||
displayName: string; // added in app.ts
|
||||
description: string;
|
||||
link: string;
|
||||
linkTitle: string;
|
||||
linkMessage: string;
|
||||
linkCTA: string;
|
||||
}
|
||||
|
||||
type Drive = DrivelistDrive | DriverlessDrive | UsbbootDrive;
|
||||
|
||||
function isUsbbootDrive(drive: Drive): drive is UsbbootDrive {
|
||||
return (drive as UsbbootDrive).progress !== undefined;
|
||||
}
|
||||
|
||||
function isDriverlessDrive(drive: Drive): drive is DriverlessDrive {
|
||||
return (drive as DriverlessDrive).link !== undefined;
|
||||
}
|
||||
|
||||
function isDrivelistDrive(drive: Drive): drive is DrivelistDrive {
|
||||
return typeof (drive as DrivelistDrive).size === 'number';
|
||||
}
|
||||
|
||||
const DrivesTable = styled((props: GenericTableProps<Drive>) => (
|
||||
<Table<Drive> {...props} />
|
||||
))`
|
||||
[data-display='table-head'],
|
||||
[data-display='table-body'] {
|
||||
> [data-display='table-row'] > [data-display='table-cell'] {
|
||||
&:nth-child(2) {
|
||||
width: 32%;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
&:nth-child(5) {
|
||||
width: 32%;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function badgeShadeFromStatus(status: string) {
|
||||
switch (status) {
|
||||
case compatibility.containsImage():
|
||||
return 16;
|
||||
case compatibility.system():
|
||||
case compatibility.tooSmall():
|
||||
return 5;
|
||||
default:
|
||||
return 14;
|
||||
}
|
||||
}
|
||||
|
||||
const InitProgress = styled(
|
||||
({
|
||||
value,
|
||||
...props
|
||||
}: {
|
||||
value: number;
|
||||
props?: React.ProgressHTMLAttributes<Element>;
|
||||
}) => {
|
||||
return <progress max="100" value={value} {...props} />;
|
||||
},
|
||||
)`
|
||||
/* Reset the default appearance */
|
||||
appearance: none;
|
||||
|
||||
::-webkit-progress-bar {
|
||||
width: 130px;
|
||||
height: 4px;
|
||||
background-color: #dde1f0;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
::-webkit-progress-value {
|
||||
background-color: #1496e1;
|
||||
border-radius: 14px;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface DriveSelectorProps
|
||||
extends Omit<ModalProps, 'done' | 'cancel'> {
|
||||
multipleSelection: boolean;
|
||||
showWarnings?: boolean;
|
||||
cancel: () => void;
|
||||
done: (drives: DrivelistDrive[]) => void;
|
||||
titleLabel: string;
|
||||
emptyListLabel: string;
|
||||
selectedList?: DrivelistDrive[];
|
||||
updateSelectedList?: () => DrivelistDrive[];
|
||||
}
|
||||
|
||||
interface DriveSelectorState {
|
||||
drives: Drive[];
|
||||
image?: SourceMetadata;
|
||||
missingDriversModal: { drive?: DriverlessDrive };
|
||||
selectedList: DrivelistDrive[];
|
||||
showSystemDrives: boolean;
|
||||
}
|
||||
|
||||
function isSystemDrive(drive: Drive) {
|
||||
return isDrivelistDrive(drive) && drive.isSystem;
|
||||
}
|
||||
|
||||
export class DriveSelector extends React.Component<
|
||||
DriveSelectorProps,
|
||||
DriveSelectorState
|
||||
> {
|
||||
private unsubscribe: (() => void) | undefined;
|
||||
tableColumns: Array<TableColumn<Drive>>;
|
||||
|
||||
constructor(props: DriveSelectorProps) {
|
||||
super(props);
|
||||
|
||||
const defaultMissingDriversModalState: { drive?: DriverlessDrive } = {};
|
||||
const selectedList = this.props.selectedList || [];
|
||||
|
||||
this.state = {
|
||||
drives: getDrives(),
|
||||
image: getImage(),
|
||||
missingDriversModal: defaultMissingDriversModalState,
|
||||
selectedList,
|
||||
showSystemDrives: false,
|
||||
};
|
||||
|
||||
this.tableColumns = [
|
||||
{
|
||||
field: 'description',
|
||||
label: 'Name',
|
||||
render: (description: string, drive: Drive) => {
|
||||
if (isDrivelistDrive(drive)) {
|
||||
const isLargeDrive = isDriveSizeLarge(drive);
|
||||
const hasWarnings =
|
||||
this.props.showWarnings && (isLargeDrive || drive.isSystem);
|
||||
return (
|
||||
<Flex alignItems="center">
|
||||
{hasWarnings && (
|
||||
<ExclamationTriangleSvg
|
||||
height="1em"
|
||||
fill={drive.isSystem ? '#fca321' : '#8f9297'}
|
||||
/>
|
||||
)}
|
||||
<Txt ml={(hasWarnings && 8) || 0}>{description}</Txt>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
return <Txt>{description}</Txt>;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
key: 'size',
|
||||
label: 'Size',
|
||||
render: (_description: string, drive: Drive) => {
|
||||
if (isDrivelistDrive(drive) && drive.size !== null) {
|
||||
return prettyBytes(drive.size);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
key: 'link',
|
||||
label: 'Location',
|
||||
render: (_description: string, drive: Drive) => {
|
||||
return (
|
||||
<Txt>
|
||||
{drive.displayName}
|
||||
{isDriverlessDrive(drive) && (
|
||||
<>
|
||||
{' '}
|
||||
-{' '}
|
||||
<b>
|
||||
<a onClick={() => this.installMissingDrivers(drive)}>
|
||||
{drive.linkCTA}
|
||||
</a>
|
||||
</b>
|
||||
</>
|
||||
)}
|
||||
</Txt>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
key: 'extra',
|
||||
// We use an empty React fragment otherwise it uses the field name as label
|
||||
label: <></>,
|
||||
render: (_description: string, drive: Drive) => {
|
||||
if (isUsbbootDrive(drive)) {
|
||||
return this.renderProgress(drive.progress);
|
||||
} else if (isDrivelistDrive(drive)) {
|
||||
return this.renderStatuses(drive);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private driveShouldBeDisabled(drive: Drive, image?: SourceMetadata) {
|
||||
return (
|
||||
isUsbbootDrive(drive) ||
|
||||
isDriverlessDrive(drive) ||
|
||||
!isDriveValid(drive, image)
|
||||
);
|
||||
}
|
||||
|
||||
private getDisplayedDrives(drives: Drive[]): Drive[] {
|
||||
return drives.filter((drive) => {
|
||||
return (
|
||||
isUsbbootDrive(drive) ||
|
||||
isDriverlessDrive(drive) ||
|
||||
isDriveSelected(drive.device) ||
|
||||
this.state.showSystemDrives ||
|
||||
!drive.isSystem
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private getDisabledDrives(drives: Drive[], image?: SourceMetadata): string[] {
|
||||
return drives
|
||||
.filter((drive) => this.driveShouldBeDisabled(drive, image))
|
||||
.map((drive) => drive.displayName);
|
||||
}
|
||||
|
||||
private renderProgress(progress: number) {
|
||||
return (
|
||||
<Flex flexDirection="column">
|
||||
<Txt fontSize={12}>Initializing device</Txt>
|
||||
<InitProgress value={progress} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
private warningFromStatus(
|
||||
status: string,
|
||||
drive: { device: string; size: number },
|
||||
) {
|
||||
switch (status) {
|
||||
case compatibility.containsImage():
|
||||
return warning.sourceDrive();
|
||||
case compatibility.largeDrive():
|
||||
return warning.largeDriveSize();
|
||||
case compatibility.system():
|
||||
return warning.systemDrive();
|
||||
case compatibility.tooSmall():
|
||||
const recommendedDriveSize =
|
||||
this.state.image?.recommendedDriveSize || this.state.image?.size || 0;
|
||||
return warning.unrecommendedDriveSize({ recommendedDriveSize }, drive);
|
||||
}
|
||||
}
|
||||
|
||||
private renderStatuses(drive: DrivelistDrive) {
|
||||
const statuses: DriveStatus[] = getDriveImageCompatibilityStatuses(
|
||||
drive,
|
||||
this.state.image,
|
||||
).slice(0, 2);
|
||||
return (
|
||||
// the column render fn expects a single Element
|
||||
<>
|
||||
{statuses.map((status) => {
|
||||
const badgeShade = badgeShadeFromStatus(status.message);
|
||||
const warningMessage = this.warningFromStatus(status.message, {
|
||||
device: drive.device,
|
||||
size: drive.size || 0,
|
||||
});
|
||||
return (
|
||||
<Badge
|
||||
key={status.message}
|
||||
shade={badgeShade}
|
||||
mr="8px"
|
||||
tooltip={this.props.showWarnings ? warningMessage : ''}
|
||||
>
|
||||
{status.message}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
private installMissingDrivers(drive: DriverlessDrive) {
|
||||
if (drive.link) {
|
||||
logEvent('Open driver link modal', {
|
||||
url: drive.link,
|
||||
});
|
||||
this.setState({ missingDriversModal: { drive } });
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
const image = getImage();
|
||||
this.setState({
|
||||
drives,
|
||||
image,
|
||||
selectedList:
|
||||
(this.props.updateSelectedList && this.props.updateSelectedList()) ||
|
||||
[],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubscribe?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { cancel, done, ...props } = this.props;
|
||||
const { selectedList, drives, image, missingDriversModal } = this.state;
|
||||
|
||||
const displayedDrives = this.getDisplayedDrives(drives);
|
||||
const disabledDrives = this.getDisabledDrives(drives, image);
|
||||
const numberOfSystemDrives = drives.filter(isSystemDrive).length;
|
||||
const numberOfDisplayedSystemDrives = displayedDrives.filter(isSystemDrive)
|
||||
.length;
|
||||
const numberOfHiddenSystemDrives =
|
||||
numberOfSystemDrives - numberOfDisplayedSystemDrives;
|
||||
const hasSystemDrives = selectedList.filter(isSystemDrive).length;
|
||||
const showWarnings = this.props.showWarnings && hasSystemDrives;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
titleElement={
|
||||
<Flex alignItems="baseline" mb={18}>
|
||||
<Txt fontSize={24} align="left">
|
||||
{this.props.titleLabel}
|
||||
</Txt>
|
||||
<Txt
|
||||
fontSize={11}
|
||||
ml={12}
|
||||
color="#5b82a7"
|
||||
style={{ fontWeight: 600 }}
|
||||
>
|
||||
{drives.length} found
|
||||
</Txt>
|
||||
</Flex>
|
||||
}
|
||||
titleDetails={<Txt fontSize={11}>{getDrives().length} found</Txt>}
|
||||
cancel={cancel}
|
||||
done={() => done(selectedList)}
|
||||
action={`Select (${selectedList.length})`}
|
||||
primaryButtonProps={{
|
||||
primary: !showWarnings,
|
||||
warning: showWarnings,
|
||||
disabled: !hasAvailableDrives(),
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{!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);
|
||||
}
|
||||
}}
|
||||
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,
|
||||
});
|
||||
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
|
||||
width={400}
|
||||
title={missingDriversModal.drive.linkTitle}
|
||||
cancel={() => this.setState({ missingDriversModal: {} })}
|
||||
done={() => {
|
||||
try {
|
||||
if (missingDriversModal.drive !== undefined) {
|
||||
openExternal(missingDriversModal.drive.link);
|
||||
}
|
||||
} catch (error) {
|
||||
logException(error);
|
||||
} finally {
|
||||
this.setState({ missingDriversModal: {} });
|
||||
}
|
||||
}}
|
||||
action="Yes, continue"
|
||||
cancelButtonProps={{
|
||||
children: 'Cancel',
|
||||
}}
|
||||
children={
|
||||
missingDriversModal.drive.linkMessage ||
|
||||
`Etcher will open ${missingDriversModal.drive.link} in your browser`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
@@ -0,0 +1,82 @@
|
||||
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg';
|
||||
import * as _ from 'lodash';
|
||||
import * as React from 'react';
|
||||
import { Badge, Flex, Txt, ModalProps } from 'rendition';
|
||||
import { Modal, ScrollableFlex } from '../../styled-components';
|
||||
import { middleEllipsis } from '../../utils/middle-ellipsis';
|
||||
|
||||
import * as prettyBytes from 'pretty-bytes';
|
||||
import { DriveWithWarnings } from '../../pages/main/Flash';
|
||||
|
||||
const DriveStatusWarningModal = ({
|
||||
done,
|
||||
cancel,
|
||||
isSystem,
|
||||
drivesWithWarnings,
|
||||
}: ModalProps & {
|
||||
isSystem: boolean;
|
||||
drivesWithWarnings: DriveWithWarnings[];
|
||||
}) => {
|
||||
let warningSubtitle = 'You are about to erase an unusually large drive';
|
||||
let warningCta = 'Are you sure the selected drive is not a storage drive?';
|
||||
|
||||
if (isSystem) {
|
||||
warningSubtitle = "You are about to erase your computer's drives";
|
||||
warningCta = 'Are you sure you want to flash your system drive?';
|
||||
}
|
||||
return (
|
||||
<Modal
|
||||
footerShadow={false}
|
||||
reverseFooterButtons={true}
|
||||
done={done}
|
||||
cancel={cancel}
|
||||
cancelButtonProps={{
|
||||
primary: false,
|
||||
warning: true,
|
||||
children: 'Change target',
|
||||
}}
|
||||
action={"Yes, I'm sure"}
|
||||
primaryButtonProps={{
|
||||
primary: false,
|
||||
outline: true,
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
width="100%"
|
||||
>
|
||||
<Flex flexDirection="column">
|
||||
<ExclamationTriangleSvg height="2em" fill="#fca321" />
|
||||
<Txt fontSize="24px" color="#fca321">
|
||||
WARNING!
|
||||
</Txt>
|
||||
</Flex>
|
||||
<Txt fontSize="24px">{warningSubtitle}</Txt>
|
||||
<ScrollableFlex
|
||||
flexDirection="column"
|
||||
backgroundColor="#fff5e6"
|
||||
m="2em 0"
|
||||
p="1em 2em"
|
||||
width="420px"
|
||||
maxHeight="100px"
|
||||
>
|
||||
{drivesWithWarnings.map((drive, i, array) => (
|
||||
<>
|
||||
<Flex justifyContent="space-between" alignItems="baseline">
|
||||
<strong>{middleEllipsis(drive.description, 28)}</strong>{' '}
|
||||
{drive.size && prettyBytes(drive.size) + ' '}
|
||||
<Badge shade={5}>{drive.statuses[0].message}</Badge>
|
||||
</Flex>
|
||||
{i !== array.length - 1 ? <hr style={{ width: '100%' }} /> : null}
|
||||
</>
|
||||
))}
|
||||
</ScrollableFlex>
|
||||
<Txt style={{ fontWeight: 600 }}>{warningCta}</Txt>
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DriveStatusWarningModal;
|
@@ -1,73 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 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.
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import * as settings from '../../models/settings';
|
||||
import * as analytics from '../../modules/analytics';
|
||||
import { SafeWebview } from '../safe-webview/safe-webview';
|
||||
|
||||
interface FeaturedProjectProps {
|
||||
shouldShow: boolean;
|
||||
onWebviewShow: (isWebviewShowing: boolean) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
interface FeaturedProjectState {
|
||||
endpoint: string | null;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export class FeaturedProject extends React.Component<
|
||||
FeaturedProjectProps,
|
||||
FeaturedProjectState
|
||||
> {
|
||||
constructor(props: FeaturedProjectProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
endpoint: null,
|
||||
show: false,
|
||||
};
|
||||
}
|
||||
|
||||
public async componentDidMount() {
|
||||
try {
|
||||
const url = new URL(
|
||||
(await settings.get('featuredProjectEndpoint')) ||
|
||||
'https://assets.balena.io/etcher-featured/index.html',
|
||||
);
|
||||
url.searchParams.append('borderRight', 'false');
|
||||
url.searchParams.append('darkBackground', 'true');
|
||||
this.setState({ endpoint: url.toString() });
|
||||
} catch (error) {
|
||||
analytics.logException(error);
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { style = {} } = this.props;
|
||||
return this.state.endpoint ? (
|
||||
<SafeWebview
|
||||
src={this.state.endpoint}
|
||||
style={{
|
||||
display: this.state.show ? 'block' : 'none',
|
||||
...style,
|
||||
}}
|
||||
{...this.props}
|
||||
></SafeWebview>
|
||||
) : null;
|
||||
}
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -25,7 +25,7 @@ export interface FlashAnotherProps {
|
||||
export const FlashAnother = (props: FlashAnotherProps) => {
|
||||
return (
|
||||
<BaseButton primary onClick={props.onClick}>
|
||||
Flash Another
|
||||
Flash another
|
||||
</BaseButton>
|
||||
);
|
||||
};
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -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} </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={{
|
||||
|
@@ -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>
|
||||
|
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import * as electron from 'electron';
|
||||
import * as _ from 'lodash';
|
||||
import * as React from 'react';
|
||||
|
||||
import * as packageJSON from '../../../../../package.json';
|
||||
@@ -58,8 +57,6 @@ const API_VERSION = '2';
|
||||
interface SafeWebviewProps {
|
||||
// The website source URL
|
||||
src: string;
|
||||
// @summary Refresh the webview
|
||||
refreshNow?: boolean;
|
||||
// Webview lifecycle event
|
||||
onWebviewShow?: (isWebviewShowing: boolean) => void;
|
||||
style?: React.CSSProperties;
|
||||
@@ -96,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
|
||||
|
@@ -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(
|
||||
|
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import CopySvg from '@fortawesome/fontawesome-free/svgs/solid/copy.svg';
|
||||
import FileSvg from '@fortawesome/fontawesome-free/svgs/solid/file.svg';
|
||||
import LinkSvg from '@fortawesome/fontawesome-free/svgs/solid/link.svg';
|
||||
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg';
|
||||
@@ -22,21 +23,14 @@ import { ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
import * as _ from 'lodash';
|
||||
import { GPTPartition, MBRPartition } from 'partitioninfo';
|
||||
import * as path from 'path';
|
||||
import * as prettyBytes from 'pretty-bytes';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Flex,
|
||||
ButtonProps,
|
||||
Modal as SmallModal,
|
||||
Txt,
|
||||
Card as BaseCard,
|
||||
Input,
|
||||
} from 'rendition';
|
||||
import { Flex, ButtonProps, Modal as SmallModal, Txt } from 'rendition';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import * as errors from '../../../../shared/errors';
|
||||
import * as messages from '../../../../shared/messages';
|
||||
import * as supportedFormats from '../../../../shared/supported-formats';
|
||||
import * as shared from '../../../../shared/units';
|
||||
import * as selectionState from '../../models/selection-state';
|
||||
import { observe } from '../../models/store';
|
||||
import * as analytics from '../../modules/analytics';
|
||||
@@ -46,56 +40,20 @@ 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 Card = styled(BaseCard)`
|
||||
hr {
|
||||
margin: 5px 0;
|
||||
}
|
||||
`;
|
||||
const isURL = (imagePath: string) =>
|
||||
imagePath.startsWith('https://') || imagePath.startsWith('http://');
|
||||
|
||||
// TODO move these styles to rendition
|
||||
const ModalText = styled.p`
|
||||
@@ -116,81 +74,9 @@ function getState() {
|
||||
};
|
||||
}
|
||||
|
||||
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={{
|
||||
className: loading || !imageURL ? 'disabled' : '',
|
||||
}}
|
||||
done={async () => {
|
||||
setLoading(true);
|
||||
const urlStrings = recentImages.map((url: URL) => url.href);
|
||||
const normalizedRecentUrls = normalizeRecentUrlImages([
|
||||
...urlStrings,
|
||||
imageURL,
|
||||
]);
|
||||
setRecentUrlImages(normalizedRecentUrls);
|
||||
await done(imageURL);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
function isString(value: any): value is string {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
|
||||
interface Flow {
|
||||
icon?: JSX.Element;
|
||||
@@ -199,17 +85,28 @@ interface Flow {
|
||||
}
|
||||
|
||||
const FlowSelector = styled(
|
||||
({ flow, ...props }: { flow: Flow; props?: ButtonProps }) => {
|
||||
return (
|
||||
<StepButton plain onClick={flow.onClick} 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};
|
||||
@@ -223,25 +120,34 @@ const FlowSelector = styled(
|
||||
|
||||
export type Source =
|
||||
| typeof sourceDestination.File
|
||||
| typeof sourceDestination.BlockDevice
|
||||
| typeof sourceDestination.Http;
|
||||
|
||||
export interface SourceOptions {
|
||||
imagePath: string;
|
||||
export interface SourceMetadata extends sourceDestination.Metadata {
|
||||
hasMBR?: boolean;
|
||||
partitions?: MBRPartition[] | GPTPartition[];
|
||||
path: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
SourceType: Source;
|
||||
drive?: DrivelistDrive;
|
||||
extension?: string;
|
||||
archiveExtension?: string;
|
||||
}
|
||||
|
||||
interface SourceSelectorProps {
|
||||
flashing: boolean;
|
||||
afterSelected: (options: SourceOptions) => void;
|
||||
}
|
||||
|
||||
interface SourceSelectorState {
|
||||
hasImage: boolean;
|
||||
imageName: string;
|
||||
imageSize: number;
|
||||
imageName?: string;
|
||||
imageSize?: number;
|
||||
warning: { message: string; title: string | null } | null;
|
||||
showImageDetails: boolean;
|
||||
showURLSelector: boolean;
|
||||
showDriveSelector: boolean;
|
||||
defaultFlowActive: boolean;
|
||||
}
|
||||
|
||||
export class SourceSelector extends React.Component<
|
||||
@@ -249,7 +155,6 @@ export class SourceSelector extends React.Component<
|
||||
SourceSelectorState
|
||||
> {
|
||||
private unsubscribe: (() => void) | undefined;
|
||||
private afterSelected: SourceSelectorProps['afterSelected'];
|
||||
|
||||
constructor(props: SourceSelectorProps) {
|
||||
super(props);
|
||||
@@ -258,15 +163,12 @@ export class SourceSelector extends React.Component<
|
||||
warning: null,
|
||||
showImageDetails: false,
|
||||
showURLSelector: false,
|
||||
showDriveSelector: false,
|
||||
defaultFlowActive: true,
|
||||
};
|
||||
|
||||
this.openImageSelector = this.openImageSelector.bind(this);
|
||||
this.openURLSelector = this.openURLSelector.bind(this);
|
||||
this.reselectImage = this.reselectImage.bind(this);
|
||||
// Bind `this` since it's used in an event's callback
|
||||
this.onSelectImage = this.onSelectImage.bind(this);
|
||||
this.onDrop = this.onDrop.bind(this);
|
||||
this.showSelectedImageDetails = this.showSelectedImageDetails.bind(this);
|
||||
this.afterSelected = props.afterSelected.bind(this);
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
@@ -283,15 +185,28 @@ export class SourceSelector extends React.Component<
|
||||
}
|
||||
|
||||
private async onSelectImage(_event: IpcRendererEvent, imagePath: string) {
|
||||
const isURL =
|
||||
imagePath.startsWith('https://') || imagePath.startsWith('http://');
|
||||
await this.selectImageByPath({
|
||||
await this.selectSource(
|
||||
imagePath,
|
||||
SourceType: isURL ? sourceDestination.Http : sourceDestination.File,
|
||||
});
|
||||
isURL(imagePath) ? sourceDestination.Http : sourceDestination.File,
|
||||
).promise;
|
||||
}
|
||||
|
||||
private reselectImage() {
|
||||
private async createSource(selected: string, SourceType: Source) {
|
||||
try {
|
||||
selected = await replaceWindowsNetworkDriveLetter(selected);
|
||||
} catch (error) {
|
||||
analytics.logException(error);
|
||||
}
|
||||
|
||||
if (SourceType === sourceDestination.File) {
|
||||
return new sourceDestination.File({
|
||||
path: selected,
|
||||
});
|
||||
}
|
||||
return new sourceDestination.Http({ url: selected });
|
||||
}
|
||||
|
||||
private reselectSource() {
|
||||
analytics.logEvent('Reselect image', {
|
||||
previousImage: selectionState.getImage(),
|
||||
});
|
||||
@@ -299,119 +214,140 @@ export class SourceSelector extends React.Component<
|
||||
selectionState.deselectImage();
|
||||
}
|
||||
|
||||
private selectImage(
|
||||
image: sourceDestination.Metadata & {
|
||||
path: string;
|
||||
extension: string;
|
||||
hasMBR: boolean;
|
||||
},
|
||||
) {
|
||||
try {
|
||||
let message = null;
|
||||
let title = null;
|
||||
private selectSource(
|
||||
selected: string | DrivelistDrive,
|
||||
SourceType: Source,
|
||||
): { promise: Promise<void>; cancel: () => void } {
|
||||
let cancelled = false;
|
||||
return {
|
||||
cancel: () => {
|
||||
cancelled = true;
|
||||
},
|
||||
promise: (async () => {
|
||||
const sourcePath = isString(selected) ? selected : selected.device;
|
||||
let source;
|
||||
let metadata: SourceMetadata | undefined;
|
||||
if (isString(selected)) {
|
||||
if (SourceType === sourceDestination.Http && !isURL(selected)) {
|
||||
this.handleError(
|
||||
'Unsupported protocol',
|
||||
selected,
|
||||
messages.error.unsupportedProtocol(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (supportedFormats.looksLikeWindowsImage(image.path)) {
|
||||
analytics.logEvent('Possibly Windows image', { image });
|
||||
message = messages.warning.looksLikeWindowsImage();
|
||||
title = 'Possible Windows image detected';
|
||||
} else if (!image.hasMBR) {
|
||||
analytics.logEvent('Missing partition table', { image });
|
||||
title = 'Missing partition table';
|
||||
message = messages.warning.missingPartitionTable();
|
||||
}
|
||||
if (supportedFormats.looksLikeWindowsImage(selected)) {
|
||||
analytics.logEvent('Possibly Windows image', { image: selected });
|
||||
this.setState({
|
||||
warning: {
|
||||
message: messages.warning.looksLikeWindowsImage(),
|
||||
title: 'Possible Windows image detected',
|
||||
},
|
||||
});
|
||||
}
|
||||
source = await this.createSource(selected, SourceType);
|
||||
|
||||
if (message) {
|
||||
this.setState({
|
||||
warning: {
|
||||
message,
|
||||
title,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectionState.selectImage(image);
|
||||
analytics.logEvent('Select image', {
|
||||
// An easy way so we can quickly identify if we're making use of
|
||||
// certain features without printing pages of text to DevTools.
|
||||
image: {
|
||||
...image,
|
||||
logo: Boolean(image.logo),
|
||||
blockMap: Boolean(image.blockMap),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
exceptionReporter.report(error);
|
||||
}
|
||||
try {
|
||||
const innerSource = await source.getInnerSource();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
metadata = await this.getMetadata(innerSource, selected);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
metadata.SourceType = SourceType;
|
||||
|
||||
if (!metadata.hasMBR) {
|
||||
analytics.logEvent('Missing partition table', { metadata });
|
||||
this.setState({
|
||||
warning: {
|
||||
message: messages.warning.missingPartitionTable(),
|
||||
title: 'Missing partition table',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleError(
|
||||
'Error opening source',
|
||||
sourcePath,
|
||||
messages.error.openSource(sourcePath, error.message),
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await source.close();
|
||||
} catch (error) {
|
||||
// Noop
|
||||
}
|
||||
}
|
||||
} else {
|
||||
metadata = {
|
||||
path: selected.device,
|
||||
displayName: selected.displayName,
|
||||
description: selected.displayName,
|
||||
size: selected.size as SourceMetadata['size'],
|
||||
SourceType: sourceDestination.BlockDevice,
|
||||
drive: selected,
|
||||
};
|
||||
}
|
||||
|
||||
if (metadata !== undefined) {
|
||||
selectionState.selectSource(metadata);
|
||||
analytics.logEvent('Select image', {
|
||||
// An easy way so we can quickly identify if we're making use of
|
||||
// certain features without printing pages of text to DevTools.
|
||||
image: {
|
||||
...metadata,
|
||||
logo: Boolean(metadata.logo),
|
||||
blockMap: Boolean(metadata.blockMap),
|
||||
},
|
||||
});
|
||||
}
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
private async selectImageByPath({ imagePath, SourceType }: SourceOptions) {
|
||||
try {
|
||||
imagePath = await replaceWindowsNetworkDriveLetter(imagePath);
|
||||
} catch (error) {
|
||||
private handleError(
|
||||
title: string,
|
||||
sourcePath: string,
|
||||
description: string,
|
||||
error?: Error,
|
||||
) {
|
||||
const imageError = errors.createUserError({
|
||||
title,
|
||||
description,
|
||||
});
|
||||
osDialog.showError(imageError);
|
||||
if (error) {
|
||||
analytics.logException(error);
|
||||
return;
|
||||
}
|
||||
analytics.logEvent(title, { path: sourcePath });
|
||||
}
|
||||
|
||||
let source;
|
||||
if (SourceType === sourceDestination.File) {
|
||||
source = new sourceDestination.File({
|
||||
path: imagePath,
|
||||
});
|
||||
private async getMetadata(
|
||||
source: sourceDestination.SourceDestination,
|
||||
selected: string | DrivelistDrive,
|
||||
) {
|
||||
const metadata = (await source.getMetadata()) as SourceMetadata;
|
||||
const partitionTable = await source.getPartitionTable();
|
||||
if (partitionTable) {
|
||||
metadata.hasMBR = true;
|
||||
metadata.partitions = partitionTable.partitions;
|
||||
} else {
|
||||
if (
|
||||
!imagePath.startsWith('https://') &&
|
||||
!imagePath.startsWith('http://')
|
||||
) {
|
||||
const invalidImageError = errors.createUserError({
|
||||
title: 'Unsupported protocol',
|
||||
description: messages.error.unsupportedProtocol(),
|
||||
});
|
||||
|
||||
osDialog.showError(invalidImageError);
|
||||
analytics.logEvent('Unsupported protocol', { path: imagePath });
|
||||
return;
|
||||
}
|
||||
source = new sourceDestination.Http({ url: imagePath });
|
||||
metadata.hasMBR = false;
|
||||
}
|
||||
|
||||
try {
|
||||
const innerSource = await source.getInnerSource();
|
||||
const metadata = (await innerSource.getMetadata()) as sourceDestination.Metadata & {
|
||||
hasMBR: boolean;
|
||||
partitions: MBRPartition[] | GPTPartition[];
|
||||
path: string;
|
||||
extension: string;
|
||||
};
|
||||
const partitionTable = await innerSource.getPartitionTable();
|
||||
if (partitionTable) {
|
||||
metadata.hasMBR = true;
|
||||
metadata.partitions = partitionTable.partitions;
|
||||
} else {
|
||||
metadata.hasMBR = false;
|
||||
}
|
||||
metadata.path = imagePath;
|
||||
metadata.extension = path.extname(imagePath).slice(1);
|
||||
this.selectImage(metadata);
|
||||
this.afterSelected({
|
||||
imagePath,
|
||||
SourceType,
|
||||
});
|
||||
} catch (error) {
|
||||
const imageError = errors.createUserError({
|
||||
title: 'Error opening image',
|
||||
description: messages.error.openImage(
|
||||
path.basename(imagePath),
|
||||
error.message,
|
||||
),
|
||||
});
|
||||
osDialog.showError(imageError);
|
||||
analytics.logException(error);
|
||||
} finally {
|
||||
try {
|
||||
await source.close();
|
||||
} catch (error) {
|
||||
// Noop
|
||||
}
|
||||
if (isString(selected)) {
|
||||
metadata.extension = path.extname(selected).slice(1);
|
||||
metadata.path = selected;
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private async openImageSelector() {
|
||||
@@ -425,22 +361,16 @@ export class SourceSelector extends React.Component<
|
||||
analytics.logEvent('Image selector closed');
|
||||
return;
|
||||
}
|
||||
this.selectImageByPath({
|
||||
imagePath,
|
||||
SourceType: sourceDestination.File,
|
||||
});
|
||||
await this.selectSource(imagePath, sourceDestination.File).promise;
|
||||
} catch (error) {
|
||||
exceptionReporter.report(error);
|
||||
}
|
||||
}
|
||||
|
||||
private onDrop(event: React.DragEvent<HTMLDivElement>) {
|
||||
private async onDrop(event: React.DragEvent<HTMLDivElement>) {
|
||||
const [file] = event.dataTransfer.files;
|
||||
if (file) {
|
||||
this.selectImageByPath({
|
||||
imagePath: file.path,
|
||||
SourceType: sourceDestination.File,
|
||||
});
|
||||
await this.selectSource(file.path, sourceDestination.File).promise;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,6 +382,14 @@ export class SourceSelector extends React.Component<
|
||||
});
|
||||
}
|
||||
|
||||
private openDriveSelector() {
|
||||
analytics.logEvent('Open drive selector');
|
||||
|
||||
this.setState({
|
||||
showDriveSelector: true,
|
||||
});
|
||||
}
|
||||
|
||||
private onDragOver(event: React.DragEvent<HTMLDivElement>) {
|
||||
// Needed to get onDrop events on div elements
|
||||
event.preventDefault();
|
||||
@@ -472,27 +410,42 @@ 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;
|
||||
const { showImageDetails, showURLSelector } = this.state;
|
||||
const { showImageDetails, showURLSelector, showDriveSelector } = this.state;
|
||||
const selectionImage = selectionState.getImage();
|
||||
let image: SourceMetadata | DrivelistDrive =
|
||||
selectionImage !== undefined ? selectionImage : ({} as SourceMetadata);
|
||||
|
||||
const hasImage = selectionState.hasImage();
|
||||
image = image.drive ?? image;
|
||||
|
||||
const imagePath = selectionState.getImagePath();
|
||||
const imageBasename = hasImage ? path.basename(imagePath) : '';
|
||||
const imageName = selectionState.getImageName();
|
||||
const imageSize = selectionState.getImageSize();
|
||||
const imageLogo = selectionState.getImageLogo();
|
||||
let cancelURLSelection = () => {
|
||||
// noop
|
||||
};
|
||||
image.name = image.description || image.name;
|
||||
const imagePath = image.path || image.displayName || '';
|
||||
const imageBasename = path.basename(imagePath);
|
||||
const imageName = image.name || '';
|
||||
const imageSize = image.size;
|
||||
const imageLogo = image.logo || '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
onDrop={this.onDrop}
|
||||
onDragEnter={this.onDragEnter}
|
||||
onDragOver={this.onDragOver}
|
||||
onDrop={(evt: React.DragEvent<HTMLDivElement>) => this.onDrop(evt)}
|
||||
onDragEnter={(evt: React.DragEvent<HTMLDivElement>) =>
|
||||
this.onDragEnter(evt)
|
||||
}
|
||||
onDragOver={(evt: React.DragEvent<HTMLDivElement>) =>
|
||||
this.onDragOver(evt)
|
||||
}
|
||||
>
|
||||
<SVGIcon
|
||||
contents={imageLogo}
|
||||
@@ -502,39 +455,60 @@ export class SourceSelector extends React.Component<
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasImage ? (
|
||||
{selectionImage !== undefined ? (
|
||||
<>
|
||||
<StepNameButton
|
||||
plain
|
||||
onClick={this.showSelectedImageDetails}
|
||||
onClick={() => this.showSelectedImageDetails()}
|
||||
tooltip={imageName || imageBasename}
|
||||
>
|
||||
{middleEllipsis(imageName || imageBasename, 20)}
|
||||
</StepNameButton>
|
||||
{!flashing && (
|
||||
<ChangeButton plain mb={14} onClick={this.reselectImage}>
|
||||
<ChangeButton
|
||||
plain
|
||||
mb={14}
|
||||
onClick={() => this.reselectSource()}
|
||||
>
|
||||
Remove
|
||||
</ChangeButton>
|
||||
)}
|
||||
<DetailsText>{shared.bytesToClosestUnit(imageSize)}</DetailsText>
|
||||
{!_.isNil(imageSize) && (
|
||||
<DetailsText>{prettyBytes(imageSize)}</DetailsText>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FlowSelector
|
||||
primary={this.state.defaultFlowActive}
|
||||
key="Flash from file"
|
||||
flow={{
|
||||
onClick: this.openImageSelector,
|
||||
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"
|
||||
flow={{
|
||||
onClick: this.openURLSelector,
|
||||
onClick: () => this.openURLSelector(),
|
||||
label: 'Flash from URL',
|
||||
icon: <LinkSvg height="1em" fill="currentColor" />,
|
||||
}}
|
||||
onMouseEnter={() => this.setDefaultFlowActive(false)}
|
||||
onMouseLeave={() => this.setDefaultFlowActive(true)}
|
||||
/>
|
||||
<FlowSelector
|
||||
key="Clone drive"
|
||||
flow={{
|
||||
onClick: () => this.openDriveSelector(),
|
||||
label: 'Clone drive',
|
||||
icon: <CopySvg height="1em" fill="currentColor" />,
|
||||
}}
|
||||
onMouseEnter={() => this.setDefaultFlowActive(false)}
|
||||
onMouseLeave={() => this.setDefaultFlowActive(true)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -551,7 +525,7 @@ export class SourceSelector extends React.Component<
|
||||
action="Continue"
|
||||
cancel={() => {
|
||||
this.setState({ warning: null });
|
||||
this.reselectImage();
|
||||
this.reselectSource();
|
||||
}}
|
||||
done={() => {
|
||||
this.setState({ warning: null });
|
||||
@@ -585,6 +559,7 @@ export class SourceSelector extends React.Component<
|
||||
{showURLSelector && (
|
||||
<URLSelector
|
||||
cancel={() => {
|
||||
cancelURLSelection();
|
||||
this.setState({
|
||||
showURLSelector: false,
|
||||
});
|
||||
@@ -594,22 +569,44 @@ export class SourceSelector extends React.Component<
|
||||
// if no file was resolved from the dialog.
|
||||
if (!imageURL) {
|
||||
analytics.logEvent('URL selector closed');
|
||||
this.setState({
|
||||
showURLSelector: false,
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
let promise;
|
||||
({ promise, cancel: cancelURLSelection } = this.selectSource(
|
||||
imageURL,
|
||||
sourceDestination.Http,
|
||||
));
|
||||
await promise;
|
||||
}
|
||||
|
||||
await this.selectImageByPath({
|
||||
imagePath: imageURL,
|
||||
SourceType: sourceDestination.Http,
|
||||
});
|
||||
this.setState({
|
||||
showURLSelector: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDriveSelector && (
|
||||
<DriveSelector
|
||||
multipleSelection={false}
|
||||
titleLabel="Select source"
|
||||
emptyListLabel="Plug a source"
|
||||
cancel={() => {
|
||||
this.setState({
|
||||
showDriveSelector: false,
|
||||
});
|
||||
}}
|
||||
done={async (drives: DrivelistDrive[]) => {
|
||||
if (drives.length) {
|
||||
await this.selectSource(
|
||||
drives[0],
|
||||
sourceDestination.BlockDevice,
|
||||
);
|
||||
}
|
||||
this.setState({
|
||||
showDriveSelector: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -15,15 +15,15 @@
|
||||
*/
|
||||
|
||||
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg';
|
||||
import { Drive as DrivelistDrive } from 'drivelist';
|
||||
import * as React from 'react';
|
||||
import { Flex, FlexProps, Txt } from 'rendition';
|
||||
|
||||
import {
|
||||
getDriveImageCompatibilityStatuses,
|
||||
Image,
|
||||
DriveStatus,
|
||||
} from '../../../../shared/drive-constraints';
|
||||
import { bytesToClosestUnit } from '../../../../shared/units';
|
||||
import { compatibility, warning } from '../../../../shared/messages';
|
||||
import * as prettyBytes from 'pretty-bytes';
|
||||
import { getSelectedDrives } from '../../models/selection-state';
|
||||
import {
|
||||
ChangeButton,
|
||||
@@ -41,40 +41,54 @@ interface TargetSelectorProps {
|
||||
flashing: boolean;
|
||||
show: boolean;
|
||||
tooltip: string;
|
||||
image: Image;
|
||||
}
|
||||
|
||||
function DriveCompatibilityWarning({
|
||||
drive,
|
||||
image,
|
||||
function getDriveWarning(status: DriveStatus) {
|
||||
switch (status.message) {
|
||||
case compatibility.containsImage():
|
||||
return warning.sourceDrive();
|
||||
case compatibility.largeDrive():
|
||||
return warning.largeDriveSize();
|
||||
case compatibility.system():
|
||||
return warning.systemDrive();
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const DriveCompatibilityWarning = ({
|
||||
warnings,
|
||||
...props
|
||||
}: {
|
||||
drive: DrivelistDrive;
|
||||
image: Image;
|
||||
} & FlexProps) {
|
||||
const compatibilityWarnings = getDriveImageCompatibilityStatuses(
|
||||
drive,
|
||||
image,
|
||||
warnings: string[];
|
||||
} & FlexProps) => {
|
||||
const systemDrive = warnings.find(
|
||||
(message) => message === warning.systemDrive(),
|
||||
);
|
||||
if (compatibilityWarnings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const messages = compatibilityWarnings.map((warning) => warning.message);
|
||||
return (
|
||||
<Flex tooltip={messages.join(', ')} {...props}>
|
||||
<ExclamationTriangleSvg fill="currentColor" height="1em" />
|
||||
<Flex tooltip={warnings.join(', ')} {...props}>
|
||||
<ExclamationTriangleSvg
|
||||
fill={systemDrive ? '#fca321' : '#8f9297'}
|
||||
height="1em"
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function TargetSelector(props: TargetSelectorProps) {
|
||||
export function TargetSelectorButton(props: TargetSelectorProps) {
|
||||
const targets = getSelectedDrives();
|
||||
|
||||
if (targets.length === 1) {
|
||||
const target = targets[0];
|
||||
const warnings = getDriveImageCompatibilityStatuses(target).map(
|
||||
getDriveWarning,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<StepNameButton plain tooltip={props.tooltip}>
|
||||
{warnings.length > 0 && (
|
||||
<DriveCompatibilityWarning warnings={warnings} mr={2} />
|
||||
)}
|
||||
{middleEllipsis(target.description, 20)}
|
||||
</StepNameButton>
|
||||
{!props.flashing && (
|
||||
@@ -82,14 +96,9 @@ export function TargetSelector(props: TargetSelectorProps) {
|
||||
Change
|
||||
</ChangeButton>
|
||||
)}
|
||||
<DetailsText>
|
||||
<DriveCompatibilityWarning
|
||||
drive={target}
|
||||
image={props.image}
|
||||
mr={2}
|
||||
/>
|
||||
{bytesToClosestUnit(target.size)}
|
||||
</DetailsText>
|
||||
{target.size != null && (
|
||||
<DetailsText>{prettyBytes(target.size)}</DetailsText>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -97,21 +106,22 @@ export function TargetSelector(props: TargetSelectorProps) {
|
||||
if (targets.length > 1) {
|
||||
const targetsTemplate = [];
|
||||
for (const target of targets) {
|
||||
const warnings = getDriveImageCompatibilityStatuses(target).map(
|
||||
getDriveWarning,
|
||||
);
|
||||
targetsTemplate.push(
|
||||
<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}
|
||||
>
|
||||
<DriveCompatibilityWarning
|
||||
drive={target}
|
||||
image={props.image}
|
||||
mr={2}
|
||||
/>
|
||||
{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>,
|
||||
);
|
||||
}
|
||||
|
@@ -1,462 +0,0 @@
|
||||
/*
|
||||
* Copyright 2019 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.
|
||||
*/
|
||||
|
||||
import ExclamationTriangleSvg from '@fortawesome/fontawesome-free/svgs/solid/exclamation-triangle.svg';
|
||||
import ChevronDownSvg from '@fortawesome/fontawesome-free/svgs/solid/chevron-down.svg';
|
||||
import { scanner, sourceDestination } from 'etcher-sdk';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Flex,
|
||||
ModalProps,
|
||||
Txt,
|
||||
Badge,
|
||||
Link,
|
||||
Table,
|
||||
TableColumn,
|
||||
} from 'rendition';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import {
|
||||
getDriveImageCompatibilityStatuses,
|
||||
hasListDriveImageCompatibilityStatus,
|
||||
isDriveValid,
|
||||
TargetStatus,
|
||||
Image,
|
||||
} from '../../../../shared/drive-constraints';
|
||||
import { compatibility } from '../../../../shared/messages';
|
||||
import { bytesToClosestUnit } from '../../../../shared/units';
|
||||
import { getDrives, hasAvailableDrives } from '../../models/available-drives';
|
||||
import {
|
||||
getImage,
|
||||
getSelectedDrives,
|
||||
isDriveSelected,
|
||||
} from '../../models/selection-state';
|
||||
import { store } from '../../models/store';
|
||||
import { logEvent, logException } from '../../modules/analytics';
|
||||
import { open as openExternal } from '../../os/open-external/services/open-external';
|
||||
import { Modal, ScrollableFlex } from '../../styled-components';
|
||||
|
||||
import TargetSVGIcon from '../../../assets/tgt.svg';
|
||||
|
||||
interface UsbbootDrive extends sourceDestination.UsbbootDrive {
|
||||
progress: number;
|
||||
}
|
||||
|
||||
interface DriverlessDrive {
|
||||
displayName: string; // added in app.ts
|
||||
description: string;
|
||||
link: string;
|
||||
linkTitle: string;
|
||||
linkMessage: string;
|
||||
linkCTA: string;
|
||||
}
|
||||
|
||||
type Target = scanner.adapters.DrivelistDrive | DriverlessDrive | UsbbootDrive;
|
||||
|
||||
function isUsbbootDrive(drive: Target): drive is UsbbootDrive {
|
||||
return (drive as UsbbootDrive).progress !== undefined;
|
||||
}
|
||||
|
||||
function isDriverlessDrive(drive: Target): drive is DriverlessDrive {
|
||||
return (drive as DriverlessDrive).link !== undefined;
|
||||
}
|
||||
|
||||
function isDrivelistDrive(
|
||||
drive: Target,
|
||||
): drive is scanner.adapters.DrivelistDrive {
|
||||
return typeof (drive as scanner.adapters.DrivelistDrive).size === 'number';
|
||||
}
|
||||
|
||||
const TargetsTable = styled(({ refFn, ...props }) => {
|
||||
return (
|
||||
<div>
|
||||
<Table<Target> ref={refFn} {...props} />
|
||||
</div>
|
||||
);
|
||||
})`
|
||||
[data-display='table-head'] [data-display='table-cell'] {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: ${(props) => props.theme.colors.quartenary.light};
|
||||
}
|
||||
|
||||
[data-display='table-cell']:first-child {
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
[data-display='table-cell']:last-child {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
&& [data-display='table-row'] > [data-display='table-cell'] {
|
||||
padding: 6px 8px;
|
||||
color: #2a506f;
|
||||
}
|
||||
`;
|
||||
|
||||
function badgeShadeFromStatus(status: string) {
|
||||
switch (status) {
|
||||
case compatibility.containsImage():
|
||||
return 16;
|
||||
case compatibility.system():
|
||||
return 5;
|
||||
default:
|
||||
return 14;
|
||||
}
|
||||
}
|
||||
|
||||
const InitProgress = styled(
|
||||
({
|
||||
value,
|
||||
...props
|
||||
}: {
|
||||
value: number;
|
||||
props?: React.ProgressHTMLAttributes<Element>;
|
||||
}) => {
|
||||
return <progress max="100" value={value} {...props} />;
|
||||
},
|
||||
)`
|
||||
/* Reset the default appearance */
|
||||
appearance: none;
|
||||
|
||||
::-webkit-progress-bar {
|
||||
width: 130px;
|
||||
height: 4px;
|
||||
background-color: #dde1f0;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
::-webkit-progress-value {
|
||||
background-color: #1496e1;
|
||||
border-radius: 14px;
|
||||
}
|
||||
`;
|
||||
|
||||
interface TargetSelectorModalProps extends Omit<ModalProps, 'done'> {
|
||||
done: (targets: scanner.adapters.DrivelistDrive[]) => void;
|
||||
}
|
||||
|
||||
interface TargetSelectorModalState {
|
||||
drives: Target[];
|
||||
image: Image;
|
||||
missingDriversModal: { drive?: DriverlessDrive };
|
||||
selectedList: scanner.adapters.DrivelistDrive[];
|
||||
showSystemDrives: boolean;
|
||||
}
|
||||
|
||||
export class TargetSelectorModal extends React.Component<
|
||||
TargetSelectorModalProps,
|
||||
TargetSelectorModalState
|
||||
> {
|
||||
private unsubscribe: (() => void) | undefined;
|
||||
tableColumns: Array<TableColumn<Target>>;
|
||||
|
||||
constructor(props: TargetSelectorModalProps) {
|
||||
super(props);
|
||||
|
||||
const defaultMissingDriversModalState: { drive?: DriverlessDrive } = {};
|
||||
const selectedList = getSelectedDrives();
|
||||
|
||||
this.state = {
|
||||
drives: getDrives(),
|
||||
image: getImage(),
|
||||
missingDriversModal: defaultMissingDriversModalState,
|
||||
selectedList,
|
||||
showSystemDrives: false,
|
||||
};
|
||||
|
||||
this.tableColumns = [
|
||||
{
|
||||
field: 'description',
|
||||
label: 'Name',
|
||||
render: (description: string, drive: Target) => {
|
||||
return isDrivelistDrive(drive) && drive.isSystem ? (
|
||||
<Flex alignItems="center">
|
||||
<ExclamationTriangleSvg height="1em" fill="#fca321" />
|
||||
<Txt ml={8}>{description}</Txt>
|
||||
</Flex>
|
||||
) : (
|
||||
<Txt>{description}</Txt>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
key: 'size',
|
||||
label: 'Size',
|
||||
render: (_description: string, drive: Target) => {
|
||||
if (isDrivelistDrive(drive) && drive.size !== null) {
|
||||
return bytesToClosestUnit(drive.size);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
key: 'link',
|
||||
label: 'Location',
|
||||
render: (_description: string, drive: Target) => {
|
||||
return (
|
||||
<Txt>
|
||||
{drive.displayName}
|
||||
{isDriverlessDrive(drive) && (
|
||||
<>
|
||||
{' '}
|
||||
-{' '}
|
||||
<b>
|
||||
<a onClick={() => this.installMissingDrivers(drive)}>
|
||||
{drive.linkCTA}
|
||||
</a>
|
||||
</b>
|
||||
</>
|
||||
)}
|
||||
</Txt>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
key: 'extra',
|
||||
// Space as empty string would use the field name as label
|
||||
label: ' ',
|
||||
render: (_description: string, drive: Target) => {
|
||||
if (isUsbbootDrive(drive)) {
|
||||
return this.renderProgress(drive.progress);
|
||||
} else if (isDrivelistDrive(drive)) {
|
||||
return this.renderStatuses(
|
||||
getDriveImageCompatibilityStatuses(drive, this.state.image),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private driveShouldBeDisabled(drive: Target, image: any) {
|
||||
return (
|
||||
isUsbbootDrive(drive) ||
|
||||
isDriverlessDrive(drive) ||
|
||||
!isDriveValid(drive, image)
|
||||
);
|
||||
}
|
||||
|
||||
private getDisplayedTargets(targets: Target[]): Target[] {
|
||||
return targets.filter((drive) => {
|
||||
return (
|
||||
isUsbbootDrive(drive) ||
|
||||
isDriverlessDrive(drive) ||
|
||||
isDriveSelected(drive.device) ||
|
||||
this.state.showSystemDrives ||
|
||||
!drive.isSystem
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private getDisabledTargets(drives: Target[], image: any): string[] {
|
||||
return drives
|
||||
.filter((drive) => this.driveShouldBeDisabled(drive, image))
|
||||
.map((drive) => drive.displayName);
|
||||
}
|
||||
|
||||
private renderProgress(progress: number) {
|
||||
return (
|
||||
<Flex flexDirection="column">
|
||||
<Txt fontSize={12}>Initializing device</Txt>
|
||||
<InitProgress value={progress} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
private renderStatuses(statuses: TargetStatus[]) {
|
||||
return (
|
||||
// the column render fn expects a single Element
|
||||
<>
|
||||
{statuses.map((status) => {
|
||||
const badgeShade = badgeShadeFromStatus(status.message);
|
||||
return (
|
||||
<Badge key={status.message} shade={badgeShade}>
|
||||
{status.message}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
private installMissingDrivers(drive: DriverlessDrive) {
|
||||
if (drive.link) {
|
||||
logEvent('Open driver link modal', {
|
||||
url: drive.link,
|
||||
});
|
||||
this.setState({ missingDriversModal: { drive } });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubscribe = store.subscribe(() => {
|
||||
const drives = getDrives();
|
||||
const image = getImage();
|
||||
this.setState({
|
||||
drives,
|
||||
image,
|
||||
selectedList: getSelectedDrives(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubscribe?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { cancel, done, ...props } = this.props;
|
||||
const { selectedList, drives, image, missingDriversModal } = this.state;
|
||||
|
||||
const displayedTargets = this.getDisplayedTargets(drives);
|
||||
const disabledTargets = this.getDisabledTargets(drives, image);
|
||||
const numberOfSystemDrives = drives.filter(
|
||||
(drive) => isDrivelistDrive(drive) && drive.isSystem,
|
||||
).length;
|
||||
const numberOfDisplayedSystemDrives = displayedTargets.filter(
|
||||
(drive) => isDrivelistDrive(drive) && drive.isSystem,
|
||||
).length;
|
||||
const numberOfHiddenSystemDrives =
|
||||
numberOfSystemDrives - numberOfDisplayedSystemDrives;
|
||||
const hasStatus = hasListDriveImageCompatibilityStatus(selectedList, image);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
titleElement={
|
||||
<Flex alignItems="baseline" mb={18}>
|
||||
<Txt fontSize={24} align="left">
|
||||
Select target
|
||||
</Txt>
|
||||
<Txt
|
||||
fontSize={11}
|
||||
ml={12}
|
||||
color="#5b82a7"
|
||||
style={{ fontWeight: 600 }}
|
||||
>
|
||||
{drives.length} found
|
||||
</Txt>
|
||||
</Flex>
|
||||
}
|
||||
titleDetails={<Txt fontSize={11}>{getDrives().length} found</Txt>}
|
||||
cancel={cancel}
|
||||
done={() => done(selectedList)}
|
||||
action={`Select (${selectedList.length})`}
|
||||
primaryButtonProps={{
|
||||
primary: !hasStatus,
|
||||
warning: hasStatus,
|
||||
disabled: !hasAvailableDrives(),
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<Flex width="100%" height="90%">
|
||||
{!hasAvailableDrives() ? (
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
width="100%"
|
||||
>
|
||||
<TargetSVGIcon width="40px" height="90px" />
|
||||
<b>Plug a target drive</b>
|
||||
</Flex>
|
||||
) : (
|
||||
<ScrollableFlex flexDirection="column" width="100%">
|
||||
<TargetsTable
|
||||
refFn={(t: Table<Target>) => {
|
||||
if (t !== null) {
|
||||
t.setRowSelection(selectedList);
|
||||
}
|
||||
}}
|
||||
columns={this.tableColumns}
|
||||
data={displayedTargets}
|
||||
disabledRows={disabledTargets}
|
||||
rowKey="displayName"
|
||||
onCheck={(rows: Target[]) => {
|
||||
this.setState({
|
||||
selectedList: rows.filter(isDrivelistDrive),
|
||||
});
|
||||
}}
|
||||
onRowClick={(row: Target) => {
|
||||
if (
|
||||
!isDrivelistDrive(row) ||
|
||||
this.driveShouldBeDisabled(row, image)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const newList = [...selectedList];
|
||||
const selectedIndex = selectedList.findIndex(
|
||||
(target) => target.device === row.device,
|
||||
);
|
||||
if (selectedIndex === -1) {
|
||||
newList.push(row);
|
||||
} else {
|
||||
// Deselect if selected
|
||||
newList.splice(selectedIndex, 1);
|
||||
}
|
||||
this.setState({
|
||||
selectedList: newList,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{numberOfHiddenSystemDrives > 0 && (
|
||||
<Link
|
||||
mt={15}
|
||||
mb={15}
|
||||
onClick={() => this.setState({ showSystemDrives: true })}
|
||||
>
|
||||
<Flex alignItems="center">
|
||||
<ChevronDownSvg height="1em" fill="currentColor" />
|
||||
<Txt ml={8}>Show {numberOfHiddenSystemDrives} hidden</Txt>
|
||||
</Flex>
|
||||
</Link>
|
||||
)}
|
||||
</ScrollableFlex>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{missingDriversModal.drive !== undefined && (
|
||||
<Modal
|
||||
width={400}
|
||||
title={missingDriversModal.drive.linkTitle}
|
||||
cancel={() => this.setState({ missingDriversModal: {} })}
|
||||
done={() => {
|
||||
try {
|
||||
if (missingDriversModal.drive !== undefined) {
|
||||
openExternal(missingDriversModal.drive.link);
|
||||
}
|
||||
} catch (error) {
|
||||
logException(error);
|
||||
} finally {
|
||||
this.setState({ missingDriversModal: {} });
|
||||
}
|
||||
}}
|
||||
action="Yes, continue"
|
||||
cancelButtonProps={{
|
||||
children: 'Cancel',
|
||||
}}
|
||||
children={
|
||||
missingDriversModal.drive.linkMessage ||
|
||||
`Etcher will open ${missingDriversModal.drive.link} in your browser`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
@@ -16,9 +16,12 @@
|
||||
|
||||
import { scanner } from 'etcher-sdk';
|
||||
import * as React from 'react';
|
||||
import { Flex } from 'rendition';
|
||||
import { TargetSelector } from '../../components/target-selector/target-selector-button';
|
||||
import { TargetSelectorModal } from '../../components/target-selector/target-selector-modal';
|
||||
import { Flex, Txt } from 'rendition';
|
||||
|
||||
import {
|
||||
DriveSelector,
|
||||
DriveSelectorProps,
|
||||
} from '../drive-selector/drive-selector';
|
||||
import {
|
||||
isDriveSelected,
|
||||
getImage,
|
||||
@@ -29,7 +32,10 @@ import {
|
||||
import * as settings from '../../models/settings';
|
||||
import { observe } from '../../models/store';
|
||||
import * as analytics from '../../modules/analytics';
|
||||
import { TargetSelectorButton } from './target-selector-button';
|
||||
|
||||
import DriveSvg from '../../../assets/drive.svg';
|
||||
import { warning } from '../../../../shared/messages';
|
||||
|
||||
export const getDriveListLabel = () => {
|
||||
return getSelectedDrives()
|
||||
@@ -50,6 +56,23 @@ const getDriveSelectionStateSlice = () => ({
|
||||
image: getImage(),
|
||||
});
|
||||
|
||||
export const TargetSelectorModal = (
|
||||
props: Omit<
|
||||
DriveSelectorProps,
|
||||
'titleLabel' | 'emptyListLabel' | 'multipleSelection'
|
||||
>,
|
||||
) => (
|
||||
<DriveSelector
|
||||
multipleSelection={true}
|
||||
titleLabel="Select target"
|
||||
emptyListLabel="Plug a target drive"
|
||||
showWarnings={true}
|
||||
selectedList={getSelectedDrives()}
|
||||
updateSelectedList={getSelectedDrives}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const selectAllTargets = (
|
||||
modalTargets: scanner.adapters.DrivelistDrive[],
|
||||
) => {
|
||||
@@ -79,20 +102,20 @@ export const selectAllTargets = (
|
||||
});
|
||||
};
|
||||
|
||||
interface DriveSelectorProps {
|
||||
interface TargetSelectorProps {
|
||||
disabled: boolean;
|
||||
hasDrive: boolean;
|
||||
flashing: boolean;
|
||||
}
|
||||
|
||||
export const DriveSelector = ({
|
||||
export const TargetSelector = ({
|
||||
disabled,
|
||||
hasDrive,
|
||||
flashing,
|
||||
}: DriveSelectorProps) => {
|
||||
}: TargetSelectorProps) => {
|
||||
// TODO: inject these from redux-connector
|
||||
const [
|
||||
{ showDrivesButton, driveListLabel, targets, image },
|
||||
{ showDrivesButton, driveListLabel, targets },
|
||||
setStateSlice,
|
||||
] = React.useState(getDriveSelectionStateSlice());
|
||||
const [showTargetSelectorModal, setShowTargetSelectorModal] = React.useState(
|
||||
@@ -105,6 +128,7 @@ export const DriveSelector = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const hasSystemDrives = targets.some((target) => target.isSystem);
|
||||
return (
|
||||
<Flex flexDirection="column" alignItems="center">
|
||||
<DriveSvg
|
||||
@@ -115,7 +139,7 @@ export const DriveSelector = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
<TargetSelector
|
||||
<TargetSelectorButton
|
||||
disabled={disabled}
|
||||
show={!hasDrive && showDrivesButton}
|
||||
tooltip={driveListLabel}
|
||||
@@ -128,9 +152,20 @@ export const DriveSelector = ({
|
||||
}}
|
||||
flashing={flashing}
|
||||
targets={targets}
|
||||
image={image}
|
||||
/>
|
||||
|
||||
{hasSystemDrives ? (
|
||||
<Txt
|
||||
color="#fca321"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '25px',
|
||||
}}
|
||||
>
|
||||
Warning: {warning.systemDrive()}
|
||||
</Txt>
|
||||
) : null}
|
||||
|
||||
{showTargetSelectorModal && (
|
||||
<TargetSelectorModal
|
||||
cancel={() => setShowTargetSelectorModal(false)}
|
||||
@@ -138,7 +173,7 @@ export const DriveSelector = ({
|
||||
selectAllTargets(modalTargets);
|
||||
setShowTargetSelectorModal(false);
|
||||
}}
|
||||
></TargetSelectorModal>
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
167
lib/gui/app/components/url-selector/url-selector.tsx
Normal file
167
lib/gui/app/components/url-selector/url-selector.tsx
Normal 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: </>}
|
||||
/>
|
||||
<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;
|
@@ -19,7 +19,6 @@
|
||||
src: url("./fonts/SourceSansPro-Regular.ttf") format("truetype");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
@@ -27,7 +26,6 @@
|
||||
src: url("./fonts/SourceSansPro-SemiBold.ttf") format("truetype");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -49,19 +47,20 @@ body {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Allow window to be dragged from header */
|
||||
#app-header {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
/* Prevent blue outline */
|
||||
a:focus,
|
||||
input:focus,
|
||||
button:focus,
|
||||
[tabindex]:focus {
|
||||
[tabindex]:focus,
|
||||
input[type="checkbox"] + div {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
#rendition-tooltip-root > div {
|
||||
font-family: "SourceSansPro", sans-serif;
|
||||
}
|
||||
|
@@ -14,21 +14,20 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as _ from 'lodash';
|
||||
|
||||
import { DrivelistDrive } from '../../../shared/drive-constraints';
|
||||
import { Actions, store } from './store';
|
||||
|
||||
export function hasAvailableDrives() {
|
||||
return !_.isEmpty(getDrives());
|
||||
return getDrives().length > 0;
|
||||
}
|
||||
|
||||
export function setDrives(drives: any[]) {
|
||||
store.dispatch({
|
||||
type: Actions.SET_AVAILABLE_DRIVES,
|
||||
type: Actions.SET_AVAILABLE_TARGETS,
|
||||
data: drives,
|
||||
});
|
||||
}
|
||||
|
||||
export function getDrives(): any[] {
|
||||
export function getDrives(): DrivelistDrive[] {
|
||||
return store.getState().toJS().availableDrives;
|
||||
}
|
||||
|
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -14,11 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Drive as DrivelistDrive } from 'drivelist';
|
||||
import * as _ from 'lodash';
|
||||
import { AnimationFunction, Color, RGBLed } from 'sys-class-rgb-led';
|
||||
|
||||
import { isSourceDrive } from '../../../shared/drive-constraints';
|
||||
import {
|
||||
isSourceDrive,
|
||||
DrivelistDrive,
|
||||
} from '../../../shared/drive-constraints';
|
||||
import * as settings from './settings';
|
||||
import { DEFAULT_STATE, observe } from './store';
|
||||
|
||||
@@ -186,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);
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { DrivelistDrive } from '../../../shared/drive-constraints';
|
||||
/*
|
||||
* Copyright 2016 balena.io
|
||||
*
|
||||
@@ -14,7 +15,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as _ from 'lodash';
|
||||
import { SourceMetadata } from '../components/source-selector/source-selector';
|
||||
|
||||
import * as availableDrives from './available-drives';
|
||||
import { Actions, store } from './store';
|
||||
@@ -24,7 +25,7 @@ import { Actions, store } from './store';
|
||||
*/
|
||||
export function selectDrive(driveDevice: string) {
|
||||
store.dispatch({
|
||||
type: Actions.SELECT_DRIVE,
|
||||
type: Actions.SELECT_TARGET,
|
||||
data: driveDevice,
|
||||
});
|
||||
}
|
||||
@@ -40,10 +41,10 @@ export function toggleDrive(driveDevice: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function selectImage(image: any) {
|
||||
export function selectSource(source: SourceMetadata) {
|
||||
store.dispatch({
|
||||
type: Actions.SELECT_IMAGE,
|
||||
data: image,
|
||||
type: Actions.SELECT_SOURCE,
|
||||
data: source,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,50 +58,38 @@ export function getSelectedDevices(): string[] {
|
||||
/**
|
||||
* @summary Get all selected drive objects
|
||||
*/
|
||||
export function getSelectedDrives(): any[] {
|
||||
const drives = availableDrives.getDrives();
|
||||
return _.map(getSelectedDevices(), (device) => {
|
||||
return _.find(drives, { device });
|
||||
});
|
||||
export function getSelectedDrives(): DrivelistDrive[] {
|
||||
const selectedDevices = getSelectedDevices();
|
||||
return availableDrives
|
||||
.getDrives()
|
||||
.filter((drive) => selectedDevices.includes(drive.device));
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get the selected image
|
||||
*/
|
||||
export function getImage() {
|
||||
return _.get(store.getState().toJS(), ['selection', 'image']);
|
||||
export function getImage(): SourceMetadata | undefined {
|
||||
return store.getState().toJS().selection.image;
|
||||
}
|
||||
|
||||
export function getImagePath(): string {
|
||||
return _.get(store.getState().toJS(), ['selection', 'image', 'path']);
|
||||
export function getImagePath(): 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,7 +103,7 @@ export function hasDrive(): boolean {
|
||||
* @summary Check if there is a selected image
|
||||
*/
|
||||
export function hasImage(): boolean {
|
||||
return Boolean(getImage());
|
||||
return getImage() !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,20 +111,20 @@ export function hasImage(): boolean {
|
||||
*/
|
||||
export function deselectDrive(driveDevice: string) {
|
||||
store.dispatch({
|
||||
type: Actions.DESELECT_DRIVE,
|
||||
type: Actions.DESELECT_TARGET,
|
||||
data: driveDevice,
|
||||
});
|
||||
}
|
||||
|
||||
export function deselectImage() {
|
||||
store.dispatch({
|
||||
type: Actions.DESELECT_IMAGE,
|
||||
type: Actions.DESELECT_SOURCE,
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
|
||||
export function deselectAllDrives() {
|
||||
_.each(getSelectedDevices(), deselectDrive);
|
||||
getSelectedDevices().forEach(deselectDrive);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,5 +144,5 @@ export function isDriveSelected(driveDevice: string) {
|
||||
}
|
||||
|
||||
const selectedDriveDevices = getSelectedDevices();
|
||||
return _.includes(selectedDriveDevices, driveDevice);
|
||||
return selectedDriveDevices.includes(driveDevice);
|
||||
}
|
||||
|
@@ -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);
|
||||
@@ -92,14 +100,17 @@ async function load(): Promise<void> {
|
||||
|
||||
const loaded = load();
|
||||
|
||||
export async function set(key: string, value: any): Promise<void> {
|
||||
export async function set(
|
||||
key: string,
|
||||
value: any,
|
||||
writeConfigFileFn = writeConfigFile,
|
||||
): Promise<void> {
|
||||
debug('set', key, value);
|
||||
await loaded;
|
||||
const previousValue = settings[key];
|
||||
settings[key] = value;
|
||||
try {
|
||||
// Use exports.writeConfigFile() so it can be mocked in tests
|
||||
await exports.writeConfigFile(CONFIG_PATH, settings);
|
||||
await writeConfigFileFn(CONFIG_PATH, settings);
|
||||
} catch (error) {
|
||||
// Revert to previous value if persisting settings failed
|
||||
settings[key] = previousValue;
|
||||
|
@@ -80,15 +80,15 @@ export const DEFAULT_STATE = Immutable.fromJS({
|
||||
export enum Actions {
|
||||
SET_DEVICE_PATHS,
|
||||
SET_FAILED_DEVICE_PATHS,
|
||||
SET_AVAILABLE_DRIVES,
|
||||
SET_AVAILABLE_TARGETS,
|
||||
SET_FLASH_STATE,
|
||||
RESET_FLASH_STATE,
|
||||
SET_FLASHING_FLAG,
|
||||
UNSET_FLASHING_FLAG,
|
||||
SELECT_DRIVE,
|
||||
SELECT_IMAGE,
|
||||
DESELECT_DRIVE,
|
||||
DESELECT_IMAGE,
|
||||
SELECT_TARGET,
|
||||
SELECT_SOURCE,
|
||||
DESELECT_TARGET,
|
||||
DESELECT_SOURCE,
|
||||
SET_APPLICATION_SESSION_UUID,
|
||||
SET_FLASHING_WORKFLOW_UUID,
|
||||
}
|
||||
@@ -116,7 +116,7 @@ function storeReducer(
|
||||
action: Action,
|
||||
): typeof DEFAULT_STATE {
|
||||
switch (action.type) {
|
||||
case Actions.SET_AVAILABLE_DRIVES: {
|
||||
case Actions.SET_AVAILABLE_TARGETS: {
|
||||
// Type: action.data : Array<DriveObject>
|
||||
|
||||
if (!action.data) {
|
||||
@@ -158,7 +158,7 @@ function storeReducer(
|
||||
) {
|
||||
// Deselect this drive gone from availableDrives
|
||||
return storeReducer(accState, {
|
||||
type: Actions.DESELECT_DRIVE,
|
||||
type: Actions.DESELECT_TARGET,
|
||||
data: device,
|
||||
});
|
||||
}
|
||||
@@ -206,14 +206,14 @@ function storeReducer(
|
||||
) {
|
||||
// Auto-select this drive
|
||||
return storeReducer(accState, {
|
||||
type: Actions.SELECT_DRIVE,
|
||||
type: Actions.SELECT_TARGET,
|
||||
data: drive.device,
|
||||
});
|
||||
}
|
||||
|
||||
// Deselect this drive in case it still is selected
|
||||
return storeReducer(accState, {
|
||||
type: Actions.DESELECT_DRIVE,
|
||||
type: Actions.DESELECT_TARGET,
|
||||
data: drive.device,
|
||||
});
|
||||
},
|
||||
@@ -295,6 +295,7 @@ function storeReducer(
|
||||
|
||||
_.defaults(action.data, {
|
||||
cancelled: false,
|
||||
skip: false,
|
||||
});
|
||||
|
||||
if (!_.isBoolean(action.data.cancelled)) {
|
||||
@@ -335,13 +336,19 @@ 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))
|
||||
.set('flashState', DEFAULT_STATE.get('flashState'));
|
||||
}
|
||||
|
||||
case Actions.SELECT_DRIVE: {
|
||||
case Actions.SELECT_TARGET: {
|
||||
// Type: action.data : String
|
||||
|
||||
const device = action.data;
|
||||
@@ -391,10 +398,12 @@ function storeReducer(
|
||||
// with image-stream / supported-formats, and have *one*
|
||||
// place where all the image extension / format handling
|
||||
// takes place, to avoid having to check 2+ locations with different logic
|
||||
case Actions.SELECT_IMAGE: {
|
||||
case Actions.SELECT_SOURCE: {
|
||||
// Type: action.data : ImageObject
|
||||
|
||||
verifyNoNilFields(action.data, selectImageNoNilFields, 'image');
|
||||
if (!action.data.drive) {
|
||||
verifyNoNilFields(action.data, selectImageNoNilFields, 'image');
|
||||
}
|
||||
|
||||
if (!_.isString(action.data.path)) {
|
||||
throw errors.createError({
|
||||
@@ -456,7 +465,7 @@ function storeReducer(
|
||||
!constraints.isDriveSizeRecommended(drive, action.data)
|
||||
) {
|
||||
return storeReducer(accState, {
|
||||
type: Actions.DESELECT_DRIVE,
|
||||
type: Actions.DESELECT_TARGET,
|
||||
data: device,
|
||||
});
|
||||
}
|
||||
@@ -467,7 +476,7 @@ function storeReducer(
|
||||
).setIn(['selection', 'image'], Immutable.fromJS(action.data));
|
||||
}
|
||||
|
||||
case Actions.DESELECT_DRIVE: {
|
||||
case Actions.DESELECT_TARGET: {
|
||||
// Type: action.data : String
|
||||
|
||||
if (!action.data) {
|
||||
@@ -491,7 +500,7 @@ function storeReducer(
|
||||
);
|
||||
}
|
||||
|
||||
case Actions.DESELECT_IMAGE: {
|
||||
case Actions.DESELECT_SOURCE: {
|
||||
return state.deleteIn(['selection', 'image']);
|
||||
}
|
||||
|
||||
|
@@ -25,7 +25,7 @@ import * as path from 'path';
|
||||
import * as packageJSON from '../../../../package.json';
|
||||
import * as errors from '../../../shared/errors';
|
||||
import * as permissions from '../../../shared/permissions';
|
||||
import { SourceOptions } from '../components/source-selector/source-selector';
|
||||
import { SourceMetadata } from '../components/source-selector/source-selector';
|
||||
import * as flashState from '../models/flash-state';
|
||||
import * as selectionState from '../models/selection-state';
|
||||
import * as settings from '../models/settings';
|
||||
@@ -131,25 +131,25 @@ function writerEnv() {
|
||||
}
|
||||
|
||||
interface FlashResults {
|
||||
skip?: boolean;
|
||||
cancelled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Perform write operation
|
||||
*/
|
||||
async function performWrite(
|
||||
image: string,
|
||||
image: SourceMetadata,
|
||||
drives: DrivelistDrive[],
|
||||
onProgress: sdk.multiWrite.OnProgressFunction,
|
||||
source: SourceOptions,
|
||||
): Promise<FlashResults> {
|
||||
): 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) => {
|
||||
@@ -175,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);
|
||||
});
|
||||
@@ -192,18 +192,24 @@ async function performWrite(
|
||||
cancelled = true;
|
||||
});
|
||||
|
||||
ipc.server.on('skip', () => {
|
||||
terminateServer();
|
||||
skip = true;
|
||||
});
|
||||
|
||||
ipc.server.on('state', onProgress);
|
||||
|
||||
ipc.server.on('ready', (_data, socket) => {
|
||||
ipc.server.emit(socket, 'write', {
|
||||
imagePath: image,
|
||||
image,
|
||||
destinations: drives,
|
||||
source,
|
||||
SourceType: source.SourceType.name,
|
||||
SourceType: image.SourceType.name,
|
||||
validateWriteOnSuccess,
|
||||
autoBlockmapping,
|
||||
unmountOnSuccess,
|
||||
decompressFirst,
|
||||
saveUrlImage,
|
||||
saveUrlImageTo,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -218,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;
|
||||
@@ -234,6 +241,7 @@ async function performWrite(
|
||||
// This likely means the child died halfway through
|
||||
if (
|
||||
!flashResults.cancelled &&
|
||||
!flashResults.skip &&
|
||||
!_.get(flashResults, ['results', 'bytesWritten'])
|
||||
) {
|
||||
reject(
|
||||
@@ -258,9 +266,8 @@ async function performWrite(
|
||||
* @summary Flash an image to drives
|
||||
*/
|
||||
export async function flash(
|
||||
image: string,
|
||||
image: SourceMetadata,
|
||||
drives: DrivelistDrive[],
|
||||
source: SourceOptions,
|
||||
// This function is a parameter so it can be mocked in tests
|
||||
write = performWrite,
|
||||
): Promise<void> {
|
||||
@@ -287,18 +294,12 @@ export async function flash(
|
||||
analytics.logEvent('Flash', analyticsData);
|
||||
|
||||
try {
|
||||
const result = await write(
|
||||
image,
|
||||
drives,
|
||||
flashState.setProgressState,
|
||||
source,
|
||||
);
|
||||
const result = await write(image, drives, flashState.setProgressState);
|
||||
flashState.unsetFlashingFlag(result);
|
||||
} catch (error) {
|
||||
flashState.unsetFlashingFlag({ cancelled: false, errorCode: error.code });
|
||||
windowProgress.clear();
|
||||
let { results } = flashState.getFlashResults();
|
||||
results = results || {};
|
||||
const { results = {} } = flashState.getFlashResults();
|
||||
const eventData = {
|
||||
...analyticsData,
|
||||
errors: results.errors,
|
||||
@@ -317,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,
|
||||
@@ -333,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(),
|
||||
@@ -343,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);
|
||||
|
||||
@@ -353,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);
|
||||
|
@@ -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: `${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' };
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -18,13 +18,11 @@ import CircleSvg from '@fortawesome/fontawesome-free/svgs/solid/circle.svg';
|
||||
import * as _ from 'lodash';
|
||||
import * as path from 'path';
|
||||
import * as React from 'react';
|
||||
import { Flex, Modal, Txt } from 'rendition';
|
||||
import { Flex, Modal as SmallModal, Txt } from 'rendition';
|
||||
|
||||
import * as constraints from '../../../../shared/drive-constraints';
|
||||
import * as messages from '../../../../shared/messages';
|
||||
import { ProgressButton } from '../../components/progress-button/progress-button';
|
||||
import { SourceOptions } from '../../components/source-selector/source-selector';
|
||||
import { TargetSelectorModal } from '../../components/target-selector/target-selector-modal';
|
||||
import * as availableDrives from '../../models/available-drives';
|
||||
import * as flashState from '../../models/flash-state';
|
||||
import * as selection from '../../models/selection-state';
|
||||
@@ -32,30 +30,17 @@ import * as analytics from '../../modules/analytics';
|
||||
import { scanner as driveScanner } from '../../modules/drive-scanner';
|
||||
import * as imageWriter from '../../modules/image-writer';
|
||||
import * as notification from '../../os/notification';
|
||||
import { selectAllTargets } from './DriveSelector';
|
||||
import {
|
||||
selectAllTargets,
|
||||
TargetSelectorModal,
|
||||
} from '../../components/target-selector/target-selector';
|
||||
|
||||
import FlashSvg from '../../../assets/flash.svg';
|
||||
import DriveStatusWarningModal from '../../components/drive-status-warning-modal/drive-status-warning-modal';
|
||||
|
||||
const COMPLETED_PERCENTAGE = 100;
|
||||
const SPEED_PRECISION = 2;
|
||||
|
||||
const getWarningMessages = (drives: any, image: any) => {
|
||||
const warningMessages = [];
|
||||
for (const drive of drives) {
|
||||
if (constraints.isDriveSizeLarge(drive)) {
|
||||
warningMessages.push(messages.warning.largeDriveSize(drive));
|
||||
} else if (!constraints.isDriveSizeRecommended(drive, image)) {
|
||||
warningMessages.push(
|
||||
messages.warning.unrecommendedDriveSize(image, drive),
|
||||
);
|
||||
}
|
||||
|
||||
// TODO(Shou): we should consider adding the same warning dialog for system drives and remove unsafe mode
|
||||
}
|
||||
|
||||
return warningMessages;
|
||||
};
|
||||
|
||||
const getErrorMessageFromCode = (errorCode: string) => {
|
||||
// TODO: All these error codes to messages translations
|
||||
// should go away if the writer emitted user friendly
|
||||
@@ -77,12 +62,11 @@ const getErrorMessageFromCode = (errorCode: string) => {
|
||||
async function flashImageToDrive(
|
||||
isFlashing: boolean,
|
||||
goToSuccess: () => void,
|
||||
sourceOptions: SourceOptions,
|
||||
): Promise<string> {
|
||||
const devices = selection.getSelectedDevices();
|
||||
const image: any = selection.getImage();
|
||||
const drives = _.filter(availableDrives.getDrives(), (drive: any) => {
|
||||
return _.includes(devices, drive.device);
|
||||
const drives = availableDrives.getDrives().filter((drive: any) => {
|
||||
return devices.includes(drive.device);
|
||||
});
|
||||
|
||||
if (drives.length === 0 || isFlashing) {
|
||||
@@ -96,16 +80,14 @@ async function flashImageToDrive(
|
||||
const iconPath = path.join('media', 'icon.png');
|
||||
const basename = path.basename(image.path);
|
||||
try {
|
||||
await imageWriter.flash(image.path, drives, sourceOptions);
|
||||
await imageWriter.flash(image, drives);
|
||||
if (!flashState.wasLastFlashCancelled()) {
|
||||
const flashResults: any = flashState.getFlashResults();
|
||||
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();
|
||||
@@ -132,7 +114,7 @@ async function flashImageToDrive(
|
||||
}
|
||||
|
||||
const formatSeconds = (totalSeconds: number) => {
|
||||
if (!totalSeconds && !_.isNumber(totalSeconds)) {
|
||||
if (typeof totalSeconds !== 'number' || !Number.isFinite(totalSeconds)) {
|
||||
return '';
|
||||
}
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
@@ -144,9 +126,7 @@ const formatSeconds = (totalSeconds: number) => {
|
||||
interface FlashStepProps {
|
||||
shouldFlashStepBeDisabled: boolean;
|
||||
goToSuccess: () => void;
|
||||
source: SourceOptions;
|
||||
isFlashing: boolean;
|
||||
isWebviewShowing: boolean;
|
||||
style?: React.CSSProperties;
|
||||
// TODO: factorize
|
||||
step: 'decompressing' | 'flashing' | 'verifying';
|
||||
@@ -157,10 +137,16 @@ interface FlashStepProps {
|
||||
eta?: number;
|
||||
}
|
||||
|
||||
export interface DriveWithWarnings extends constraints.DrivelistDrive {
|
||||
statuses: constraints.DriveStatus[];
|
||||
}
|
||||
|
||||
interface FlashStepState {
|
||||
warningMessages: string[];
|
||||
warningMessage: boolean;
|
||||
errorMessage: string;
|
||||
showDriveSelectorModal: boolean;
|
||||
systemDrives: boolean;
|
||||
drivesWithWarnings: DriveWithWarnings[];
|
||||
}
|
||||
|
||||
export class FlashStep extends React.PureComponent<
|
||||
@@ -170,14 +156,16 @@ export class FlashStep extends React.PureComponent<
|
||||
constructor(props: FlashStepProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
warningMessages: [],
|
||||
warningMessage: false,
|
||||
errorMessage: '',
|
||||
showDriveSelectorModal: false,
|
||||
systemDrives: false,
|
||||
drivesWithWarnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
private async handleWarningResponse(shouldContinue: boolean) {
|
||||
this.setState({ warningMessages: [] });
|
||||
this.setState({ warningMessage: false });
|
||||
if (!shouldContinue) {
|
||||
this.setState({ showDriveSelectorModal: true });
|
||||
return;
|
||||
@@ -186,7 +174,6 @@ export class FlashStep extends React.PureComponent<
|
||||
errorMessage: await flashImageToDrive(
|
||||
this.props.isFlashing,
|
||||
this.props.goToSuccess,
|
||||
this.props.source,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -201,35 +188,45 @@ export class FlashStep extends React.PureComponent<
|
||||
}
|
||||
}
|
||||
|
||||
private hasListWarnings(drives: any[], image: any) {
|
||||
private hasListWarnings(drives: any[]) {
|
||||
if (drives.length === 0 || flashState.isFlashing()) {
|
||||
return;
|
||||
}
|
||||
return constraints.hasListDriveImageCompatibilityStatus(drives, image);
|
||||
return drives.filter((drive) => drive.isSystem).length > 0;
|
||||
}
|
||||
|
||||
private async tryFlash() {
|
||||
const devices = selection.getSelectedDevices();
|
||||
const image = selection.getImage();
|
||||
const drives = _.filter(
|
||||
availableDrives.getDrives(),
|
||||
(drive: { device: string }) => {
|
||||
return _.includes(devices, drive.device);
|
||||
},
|
||||
);
|
||||
const drives = selection.getSelectedDrives().map((drive) => {
|
||||
return {
|
||||
...drive,
|
||||
statuses: constraints.getDriveImageCompatibilityStatuses(drive),
|
||||
};
|
||||
});
|
||||
if (drives.length === 0 || this.props.isFlashing) {
|
||||
return;
|
||||
}
|
||||
const hasDangerStatus = this.hasListWarnings(drives, image);
|
||||
const hasDangerStatus = drives.some((drive) => drive.statuses.length > 0);
|
||||
if (hasDangerStatus) {
|
||||
this.setState({ warningMessages: getWarningMessages(drives, image) });
|
||||
const systemDrives = drives.some((drive) =>
|
||||
drive.statuses.includes(constraints.statuses.system),
|
||||
);
|
||||
this.setState({
|
||||
systemDrives,
|
||||
drivesWithWarnings: drives.filter((driveWithWarnings) => {
|
||||
return (
|
||||
driveWithWarnings.isSystem ||
|
||||
(!systemDrives &&
|
||||
driveWithWarnings.statuses.includes(constraints.statuses.large))
|
||||
);
|
||||
}),
|
||||
warningMessage: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
errorMessage: await flashImageToDrive(
|
||||
this.props.isFlashing,
|
||||
this.props.goToSuccess,
|
||||
this.props.source,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -257,13 +254,8 @@ export class FlashStep extends React.PureComponent<
|
||||
position={this.props.position}
|
||||
disabled={this.props.shouldFlashStepBeDisabled}
|
||||
cancel={imageWriter.cancel}
|
||||
warning={this.hasListWarnings(
|
||||
selection.getSelectedDrives(),
|
||||
selection.getImage(),
|
||||
)}
|
||||
callback={() => {
|
||||
this.tryFlash();
|
||||
}}
|
||||
warning={this.hasListWarnings(selection.getSelectedDrives())}
|
||||
callback={() => this.tryFlash()}
|
||||
/>
|
||||
|
||||
{!_.isNil(this.props.speed) &&
|
||||
@@ -274,9 +266,7 @@ export class FlashStep extends React.PureComponent<
|
||||
color="#7e8085"
|
||||
width="100%"
|
||||
>
|
||||
{!_.isNil(this.props.speed) && (
|
||||
<Txt>{this.props.speed.toFixed(SPEED_PRECISION)} MB/s</Txt>
|
||||
)}
|
||||
<Txt>{this.props.speed.toFixed(SPEED_PRECISION)} MB/s</Txt>
|
||||
{!_.isNil(this.props.eta) && (
|
||||
<Txt>ETA: {formatSeconds(this.props.eta)}</Txt>
|
||||
)}
|
||||
@@ -292,28 +282,17 @@ export class FlashStep extends React.PureComponent<
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{this.state.warningMessages.length > 0 && (
|
||||
<Modal
|
||||
width={400}
|
||||
titleElement={'Attention'}
|
||||
cancel={() => this.handleWarningResponse(false)}
|
||||
{this.state.warningMessage && (
|
||||
<DriveStatusWarningModal
|
||||
done={() => this.handleWarningResponse(true)}
|
||||
cancelButtonProps={{
|
||||
children: 'Change',
|
||||
}}
|
||||
action={'Continue'}
|
||||
primaryButtonProps={{ primary: false, warning: true }}
|
||||
>
|
||||
{_.map(this.state.warningMessages, (message, key) => (
|
||||
<Txt key={key} whitespace="pre-line" mt={2}>
|
||||
{message}
|
||||
</Txt>
|
||||
))}
|
||||
</Modal>
|
||||
cancel={() => this.handleWarningResponse(false)}
|
||||
isSystem={this.state.systemDrives}
|
||||
drivesWithWarnings={this.state.drivesWithWarnings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{this.state.errorMessage && (
|
||||
<Modal
|
||||
<SmallModal
|
||||
width={400}
|
||||
titleElement={'Attention'}
|
||||
cancel={() => this.handleFlashErrorResponse(false)}
|
||||
@@ -321,11 +300,11 @@ export class FlashStep extends React.PureComponent<
|
||||
action={'Retry'}
|
||||
>
|
||||
<Txt>
|
||||
{_.map(this.state.errorMessage.split('\n'), (message, key) => (
|
||||
{this.state.errorMessage.split('\n').map((message, key) => (
|
||||
<p key={key}>{message}</p>
|
||||
))}
|
||||
</Txt>
|
||||
</Modal>
|
||||
</SmallModal>
|
||||
)}
|
||||
{this.state.showDriveSelectorModal && (
|
||||
<TargetSelectorModal
|
||||
@@ -334,7 +313,7 @@ export class FlashStep extends React.PureComponent<
|
||||
selectAllTargets(modalTargets);
|
||||
this.setState({ showDriveSelectorModal: false });
|
||||
}}
|
||||
></TargetSelectorModal>
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@@ -17,20 +17,17 @@
|
||||
import CogSvg from '@fortawesome/fontawesome-free/svgs/solid/cog.svg';
|
||||
import QuestionCircleSvg from '@fortawesome/fontawesome-free/svgs/solid/question-circle.svg';
|
||||
|
||||
import { sourceDestination } from 'etcher-sdk';
|
||||
import * as _ from 'lodash';
|
||||
import * as path from 'path';
|
||||
import * as prettyBytes from 'pretty-bytes';
|
||||
import * as React from 'react';
|
||||
import { Flex } from 'rendition';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { FeaturedProject } from '../../components/featured-project/featured-project';
|
||||
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 {
|
||||
SourceOptions,
|
||||
SourceMetadata,
|
||||
SourceSelector,
|
||||
} from '../../components/source-selector/source-selector';
|
||||
import * as flashState from '../../models/flash-state';
|
||||
@@ -43,12 +40,15 @@ import {
|
||||
ThemedProvider,
|
||||
} from '../../styled-components';
|
||||
|
||||
import { bytesToClosestUnit } from '../../../../shared/units';
|
||||
|
||||
import { DriveSelector, getDriveListLabel } from './DriveSelector';
|
||||
import {
|
||||
TargetSelector,
|
||||
getDriveListLabel,
|
||||
} from '../../components/target-selector/target-selector';
|
||||
import { FlashStep } from './Flash';
|
||||
|
||||
import EtcherSvg from '../../../assets/etcher.svg';
|
||||
import { SafeWebview } from '../../components/safe-webview/safe-webview';
|
||||
import { colors } from '../../theme';
|
||||
|
||||
const Icon = styled(BaseIcon)`
|
||||
margin-right: 20px;
|
||||
@@ -68,14 +68,16 @@ function getDrivesTitle() {
|
||||
return `${drives.length} Targets`;
|
||||
}
|
||||
|
||||
function getImageBasename() {
|
||||
if (!selectionState.hasImage()) {
|
||||
function getImageBasename(image?: SourceMetadata) {
|
||||
if (image === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const selectionImageName = selectionState.getImageName();
|
||||
const imageBasename = path.basename(selectionState.getImagePath());
|
||||
return selectionImageName || imageBasename;
|
||||
if (image.drive) {
|
||||
return image.drive.description;
|
||||
}
|
||||
const imageBasename = path.basename(image.path);
|
||||
return image.name || imageBasename;
|
||||
}
|
||||
|
||||
const StepBorder = styled.div<{
|
||||
@@ -86,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;
|
||||
|
||||
@@ -102,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;
|
||||
}
|
||||
@@ -113,7 +113,7 @@ interface MainPageState {
|
||||
current: 'main' | 'success';
|
||||
isWebviewShowing: boolean;
|
||||
hideSettings: boolean;
|
||||
source: SourceOptions;
|
||||
featuredProjectURL?: string;
|
||||
}
|
||||
|
||||
export class MainPage extends React.Component<
|
||||
@@ -126,10 +126,6 @@ export class MainPage extends React.Component<
|
||||
current: 'main',
|
||||
isWebviewShowing: false,
|
||||
hideSettings: true,
|
||||
source: {
|
||||
imagePath: '',
|
||||
SourceType: sourceDestination.File,
|
||||
},
|
||||
...this.stateHelper(),
|
||||
};
|
||||
}
|
||||
@@ -141,16 +137,27 @@ export class MainPage extends React.Component<
|
||||
hasDrive: selectionState.hasDrive(),
|
||||
imageLogo: selectionState.getImageLogo(),
|
||||
imageSize: selectionState.getImageSize(),
|
||||
imageName: getImageBasename(),
|
||||
imageName: getImageBasename(selectionState.getImage()),
|
||||
driveTitle: getDrivesTitle(),
|
||||
driveLabel: getDriveListLabel(),
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
private async getFeaturedProjectURL() {
|
||||
const url = new URL(
|
||||
(await settings.get('featuredProjectEndpoint')) ||
|
||||
'https://assets.balena.io/etcher-featured/index.html',
|
||||
);
|
||||
url.searchParams.append('borderRight', 'false');
|
||||
url.searchParams.append('darkBackground', 'true');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
public async componentDidMount() {
|
||||
observe(() => {
|
||||
this.setState(this.stateHelper());
|
||||
});
|
||||
this.setState({ featuredProjectURL: await this.getFeaturedProjectURL() });
|
||||
}
|
||||
|
||||
private renderMain() {
|
||||
@@ -161,43 +168,141 @@ 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
|
||||
id="app-header"
|
||||
justifyContent="center"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
paddingTop="14px"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '50px',
|
||||
padding: '13px 14px',
|
||||
textAlign: 'center',
|
||||
// Allow window to be dragged from header
|
||||
// @ts-ignore
|
||||
'-webkit-app-region': 'drag',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<EtcherSvg
|
||||
width="123px"
|
||||
height="22px"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() =>
|
||||
openExternal('https://www.balena.io/etcher?ref=etcher_footer')
|
||||
}
|
||||
tabIndex={100}
|
||||
/>
|
||||
<Flex width="100%" />
|
||||
<Flex width="100%" alignItems="center" justifyContent="center">
|
||||
<EtcherSvg
|
||||
width="123px"
|
||||
height="22px"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() =>
|
||||
openExternal('https://www.balena.io/etcher?ref=etcher_footer')
|
||||
}
|
||||
tabIndex={100}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
style={{
|
||||
float: 'right',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
}}
|
||||
>
|
||||
<Flex width="100%" alignItems="center" justifyContent="flex-end">
|
||||
<Icon
|
||||
icon={<CogSvg height="1em" fill="currentColor" />}
|
||||
plain
|
||||
tabIndex={5}
|
||||
onClick={() => this.setState({ hideSettings: false })}
|
||||
style={{
|
||||
// Make touch events click instead of dragging
|
||||
'-webkit-app-region': 'no-drag',
|
||||
}}
|
||||
/>
|
||||
{!settings.getSync('disableExternalLinks') && (
|
||||
<Icon
|
||||
@@ -209,6 +314,10 @@ export class MainPage extends React.Component<
|
||||
)
|
||||
}
|
||||
tabIndex={6}
|
||||
style={{
|
||||
// Make touch events click instead of dragging
|
||||
'-webkit-app-region': 'no-drag',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
@@ -220,132 +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}
|
||||
afterSelected={(source: SourceOptions) =>
|
||||
this.setState({ source })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{notFlashingOrSplitView && (
|
||||
<Flex>
|
||||
<StepBorder disabled={shouldDriveStepBeDisabled} left />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{notFlashingOrSplitView && (
|
||||
<DriveSelector
|
||||
disabled={shouldDriveStepBeDisabled}
|
||||
hasDrive={this.state.hasDrive}
|
||||
flashing={this.state.isFlashing}
|
||||
/>
|
||||
)}
|
||||
|
||||
{notFlashingOrSplitView && (
|
||||
<Flex>
|
||||
<StepBorder disabled={shouldFlashStepBeDisabled} right />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{this.state.isFlashing && (
|
||||
<>
|
||||
<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)',
|
||||
display: this.state.isWebviewShowing ? 'block' : 'none',
|
||||
}}
|
||||
>
|
||||
<ReducedFlashingInfos
|
||||
imageLogo={this.state.imageLogo}
|
||||
imageName={this.state.imageName}
|
||||
imageSize={
|
||||
_.isNumber(this.state.imageSize)
|
||||
? (bytesToClosestUnit(this.state.imageSize) as string)
|
||||
: ''
|
||||
}
|
||||
driveTitle={this.state.driveTitle}
|
||||
driveLabel={this.state.driveLabel}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
color: '#fff',
|
||||
left: 35,
|
||||
top: 72,
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
<FeaturedProject
|
||||
shouldShow={this.state.isWebviewShowing}
|
||||
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}
|
||||
source={this.state.source}
|
||||
isFlashing={this.state.isFlashing}
|
||||
isWebviewShowing={this.state.isWebviewShowing}
|
||||
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()}
|
||||
|
@@ -14,36 +14,26 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as _ from 'lodash';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Alert as AlertBase,
|
||||
Flex,
|
||||
FlexProps,
|
||||
Button,
|
||||
ButtonProps,
|
||||
Modal as ModalBase,
|
||||
Provider,
|
||||
Table as BaseTable,
|
||||
TableProps as BaseTableProps,
|
||||
Txt,
|
||||
Theme as renditionTheme,
|
||||
} from 'rendition';
|
||||
import styled from 'styled-components';
|
||||
import { space } from 'styled-system';
|
||||
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)`
|
||||
@@ -69,6 +59,7 @@ export const StepButton = styled((props: ButtonProps) => (
|
||||
<BaseButton {...props}></BaseButton>
|
||||
))`
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
export const ChangeButton = styled(Button)`
|
||||
@@ -86,7 +77,6 @@ export const ChangeButton = styled(Button)`
|
||||
color: #8f9297;
|
||||
}
|
||||
}
|
||||
${space}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -95,7 +85,7 @@ export const StepNameButton = styled(BaseButton)`
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
font-weight: bold;
|
||||
font-weight: normal;
|
||||
color: ${colors.dark.foreground};
|
||||
|
||||
&:enabled {
|
||||
@@ -121,28 +111,40 @@ export const DetailsText = (props: FlexProps) => (
|
||||
/>
|
||||
);
|
||||
|
||||
export const Modal = styled(({ style, ...props }) => {
|
||||
const modalFooterShadowCss = css`
|
||||
overflow: auto;
|
||||
background: 0, linear-gradient(rgba(255, 255, 255, 0), white 70%) 0 100%, 0,
|
||||
linear-gradient(rgba(255, 255, 255, 0), rgba(221, 225, 240, 0.5) 70%) 0 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 40px, 100% 40px, 100% 8px, 100% 8px;
|
||||
|
||||
background-repeat: no-repeat;
|
||||
background-color: white;
|
||||
background-size: 100% 40px, 100% 40px, 100% 8px, 100% 8px;
|
||||
background-attachment: local, local, scroll, scroll;
|
||||
`;
|
||||
|
||||
export const Modal = styled(({ style, 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"
|
||||
width="96vw"
|
||||
width="97vw"
|
||||
cancelButtonProps={{
|
||||
style: {
|
||||
marginRight: '20px',
|
||||
@@ -150,36 +152,57 @@ export const Modal = styled(({ style, ...props }) => {
|
||||
},
|
||||
}}
|
||||
style={{
|
||||
height: '86.5vh',
|
||||
height: '87.5vh',
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
<ScrollableFlex flexDirection="column" width="100%" height="90%">
|
||||
{...children}
|
||||
</ScrollableFlex>
|
||||
</ModalBase>
|
||||
</Provider>
|
||||
);
|
||||
})`
|
||||
> div {
|
||||
padding: 24px 30px;
|
||||
height: calc(100% - 80px);
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
> div:first-child {
|
||||
height: 81%;
|
||||
padding: 24px 30px 0;
|
||||
}
|
||||
|
||||
> h3 {
|
||||
margin: 0;
|
||||
padding: 24px 30px 0;
|
||||
height: 14.3%;
|
||||
}
|
||||
|
||||
> div:first-child {
|
||||
height: 81%;
|
||||
padding: 24px 30px 0;
|
||||
}
|
||||
|
||||
> div:nth-child(2) {
|
||||
height: 61%;
|
||||
padding: 0 30px;
|
||||
${modalFooterShadowCss}
|
||||
}
|
||||
|
||||
> div:last-child {
|
||||
margin: 0;
|
||||
flex-direction: ${(props) =>
|
||||
props.reverseFooterButtons ? 'row-reverse' : 'row'};
|
||||
border-radius: 0 0 7px 7px;
|
||||
height: 80px;
|
||||
background-color: #fff;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
box-shadow: 0 -2px 10px 0 rgba(221, 225, 240, 0.5), 0 -1px 0 0 #dde1f0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -196,3 +219,124 @@ export const ScrollableFlex = styled(Flex)`
|
||||
overflow-x: visible;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Alert = styled((props) => (
|
||||
<AlertBase warning emphasized {...props}></AlertBase>
|
||||
))`
|
||||
position: fixed;
|
||||
top: -40px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0px);
|
||||
height: 30px;
|
||||
min-width: 50%;
|
||||
padding: 0px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
background-color: #fca321;
|
||||
text-align: center;
|
||||
|
||||
* {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
> div:first-child {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
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} />;
|
||||
};
|
||||
|
@@ -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: {
|
||||
@@ -90,22 +92,30 @@ export const theme = {
|
||||
opacity: 1,
|
||||
},
|
||||
extend: () => `
|
||||
&& {
|
||||
width: 200px;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
width: 200px;
|
||||
font-size: 16px;
|
||||
|
||||
:disabled {
|
||||
&& {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
:disabled {
|
||||
background-color: ${colors.dark.disabled.background};
|
||||
color: ${colors.dark.disabled.foreground};
|
||||
opacity: 1;
|
||||
|
||||
:hover {
|
||||
background-color: ${colors.dark.disabled.background};
|
||||
color: ${colors.dark.disabled.foreground};
|
||||
opacity: 1;
|
||||
|
||||
:hover {
|
||||
background-color: ${colors.dark.disabled.background};
|
||||
color: ${colors.dark.disabled.foreground};
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
};
|
||||
layer: {
|
||||
extend: () => `
|
||||
> div:first-child {
|
||||
background-color: transparent;
|
||||
}
|
||||
`,
|
||||
},
|
||||
});
|
||||
|
28
lib/gui/app/utils/start-ellipsis.ts
Normal file
28
lib/gui/app/utils/start-ellipsis.ts
Normal 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}`;
|
||||
}
|
@@ -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) {
|
||||
@@ -161,6 +161,9 @@ async function createMainWindow() {
|
||||
// Prevent flash of white when starting the application
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
console.timeEnd('ready-to-show');
|
||||
// Electron sometimes caches the zoomFactor
|
||||
// making it obnoxious to switch back-and-forth
|
||||
mainWindow.webContents.setZoomFactor(width / defaultWidth);
|
||||
mainWindow.show();
|
||||
});
|
||||
|
||||
@@ -171,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;
|
||||
|
||||
|
@@ -17,13 +17,16 @@
|
||||
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';
|
||||
|
||||
import { BlockDevice, File, Http } from 'etcher-sdk/build/source-destination';
|
||||
import { toJSON } from '../../shared/errors';
|
||||
import { GENERAL_ERROR, SUCCESS } from '../../shared/exit-codes';
|
||||
import { delay } from '../../shared/utils';
|
||||
import { SourceOptions } from '../app/components/source-selector/source-selector';
|
||||
import { SourceMetadata } from '../app/components/source-selector/source-selector';
|
||||
|
||||
ipc.config.id = process.env.IPC_CLIENT_ID as string;
|
||||
ipc.config.socketRoot = process.env.IPC_SOCKET_ROOT as string;
|
||||
@@ -54,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);
|
||||
});
|
||||
@@ -67,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 {
|
||||
@@ -135,22 +139,30 @@ 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;
|
||||
}
|
||||
|
||||
interface WriteOptions {
|
||||
imagePath: string;
|
||||
image: SourceMetadata;
|
||||
destinations: DrivelistDrive[];
|
||||
unmountOnSuccess: boolean;
|
||||
validateWriteOnSuccess: boolean;
|
||||
autoBlockmapping: boolean;
|
||||
decompressFirst: boolean;
|
||||
source: SourceOptions;
|
||||
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
|
||||
@@ -228,7 +249,8 @@ ipc.connectTo(IPC_SERVER_ID, () => {
|
||||
};
|
||||
|
||||
const destinations = options.destinations.map((d) => d.device);
|
||||
log(`Image: ${options.imagePath}`);
|
||||
const imagePath = options.image.path;
|
||||
log(`Image: ${imagePath}`);
|
||||
log(`Devices: ${destinations.join(', ')}`);
|
||||
log(`Umount on success: ${options.unmountOnSuccess}`);
|
||||
log(`Validate on success: ${options.validateWriteOnSuccess}`);
|
||||
@@ -243,18 +265,31 @@ ipc.connectTo(IPC_SERVER_ID, () => {
|
||||
});
|
||||
});
|
||||
const { SourceType } = options;
|
||||
let source;
|
||||
if (SourceType === sdk.sourceDestination.File.name) {
|
||||
source = new sdk.sourceDestination.File({
|
||||
path: options.imagePath,
|
||||
});
|
||||
} else {
|
||||
source = new sdk.sourceDestination.Http({
|
||||
url: options.imagePath,
|
||||
avoidRandomAccess: true,
|
||||
});
|
||||
}
|
||||
try {
|
||||
let source;
|
||||
if (options.image.drive) {
|
||||
source = new BlockDevice({
|
||||
drive: options.image.drive,
|
||||
direct: !options.autoBlockmapping,
|
||||
});
|
||||
} else {
|
||||
if (SourceType === File.name) {
|
||||
source = new File({
|
||||
path: imagePath,
|
||||
});
|
||||
} else {
|
||||
if (options.saveUrlImage) {
|
||||
source = await saveFileBeforeFlash(
|
||||
imagePath,
|
||||
options.saveUrlImageTo,
|
||||
onProgress,
|
||||
onFail,
|
||||
);
|
||||
} else {
|
||||
source = new Http({ url: imagePath, avoidRandomAccess: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
const results = await writeAndValidate({
|
||||
source,
|
||||
destinations: dests,
|
||||
@@ -270,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;
|
||||
@@ -285,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 });
|
||||
}
|
||||
|
@@ -5,9 +5,9 @@ ObjC.import('stdlib')
|
||||
const app = Application.currentApplication()
|
||||
app.includeStandardAdditions = true
|
||||
|
||||
const result = app.displayDialog('balenaEtcher wants to make changes. Type your password to allow this.', {
|
||||
const result = app.displayDialog('balenaEtcher needs privileged access in order to flash disks.\n\nType your password to allow this.', {
|
||||
defaultAnswer: '',
|
||||
withIcon: 'stop',
|
||||
withIcon: 'caution',
|
||||
buttons: ['Cancel', 'Ok'],
|
||||
defaultButton: 'Ok',
|
||||
hiddenAnswer: true,
|
||||
|
@@ -14,18 +14,26 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Drive as DrivelistDrive } from 'drivelist';
|
||||
import { Drive } from 'drivelist';
|
||||
import * as _ from 'lodash';
|
||||
import * as pathIsInside from 'path-is-inside';
|
||||
import * as prettyBytes from 'pretty-bytes';
|
||||
|
||||
import * as messages from './messages';
|
||||
import { SourceMetadata } from '../gui/app/components/source-selector/source-selector';
|
||||
|
||||
/**
|
||||
* @summary The default unknown size for things such as images and drives
|
||||
*/
|
||||
const UNKNOWN_SIZE = 0;
|
||||
|
||||
export type DrivelistDrive = Drive & {
|
||||
disabled: boolean;
|
||||
name: string;
|
||||
path: string;
|
||||
logo: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Check if a drive is locked
|
||||
*
|
||||
@@ -33,22 +41,23 @@ const UNKNOWN_SIZE = 0;
|
||||
* This usually points out a locked SD Card.
|
||||
*/
|
||||
export function isDriveLocked(drive: DrivelistDrive): boolean {
|
||||
return Boolean(_.get(drive, ['isReadOnly'], false));
|
||||
return Boolean(drive.isReadOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Check if a drive is a system drive
|
||||
*/
|
||||
export function isSystemDrive(drive: DrivelistDrive): boolean {
|
||||
return Boolean(_.get(drive, ['isSystem'], false));
|
||||
return Boolean(drive.isSystem);
|
||||
}
|
||||
|
||||
export interface Image {
|
||||
path?: string;
|
||||
isSizeEstimated?: boolean;
|
||||
compressedSize?: number;
|
||||
recommendedDriveSize?: number;
|
||||
size?: number;
|
||||
function sourceIsInsideDrive(source: string, drive: DrivelistDrive) {
|
||||
for (const mountpoint of drive.mountpoints || []) {
|
||||
if (pathIsInside(source, mountpoint.path)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,11 +69,16 @@ export interface Image {
|
||||
*/
|
||||
export function isSourceDrive(
|
||||
drive: DrivelistDrive,
|
||||
image: Image = {},
|
||||
selection?: SourceMetadata,
|
||||
): boolean {
|
||||
for (const mountpoint of drive.mountpoints || []) {
|
||||
if (image.path !== undefined && pathIsInside(image.path, mountpoint.path)) {
|
||||
return true;
|
||||
if (selection) {
|
||||
if (selection.drive) {
|
||||
const sourcePath = selection.drive.devicePath || selection.drive.device;
|
||||
const drivePath = drive.devicePath || drive.device;
|
||||
return pathIsInside(sourcePath, drivePath);
|
||||
}
|
||||
if (selection.path) {
|
||||
return sourceIsInsideDrive(selection.path, drive);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -74,17 +88,21 @@ export function isSourceDrive(
|
||||
* @summary Check if a drive is large enough for an image
|
||||
*/
|
||||
export function isDriveLargeEnough(
|
||||
drive: DrivelistDrive | undefined,
|
||||
image: Image,
|
||||
drive: DrivelistDrive,
|
||||
image?: SourceMetadata,
|
||||
): boolean {
|
||||
const driveSize = _.get(drive, 'size') || UNKNOWN_SIZE;
|
||||
const driveSize = drive.size || UNKNOWN_SIZE;
|
||||
|
||||
if (_.get(image, ['isSizeEstimated'])) {
|
||||
if (image === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (image.isSizeEstimated) {
|
||||
// If the drive size is smaller than the original image size, and
|
||||
// the final image size is just an estimation, then we stop right
|
||||
// here, based on the assumption that the final size will never
|
||||
// be less than the original size.
|
||||
if (driveSize < _.get(image, ['compressedSize'], UNKNOWN_SIZE)) {
|
||||
if (driveSize < (image.compressedSize || UNKNOWN_SIZE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -95,24 +113,27 @@ export function isDriveLargeEnough(
|
||||
return true;
|
||||
}
|
||||
|
||||
return driveSize >= _.get(image, ['size'], UNKNOWN_SIZE);
|
||||
return driveSize >= (image.size || UNKNOWN_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Check if a drive is disabled (i.e. not ready for selection)
|
||||
*/
|
||||
export function isDriveDisabled(drive: DrivelistDrive): boolean {
|
||||
return _.get(drive, ['disabled'], false);
|
||||
return drive.disabled || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Check if a drive is valid, i.e. not locked and large enough for an image
|
||||
*/
|
||||
export function isDriveValid(drive: DrivelistDrive, image: Image): boolean {
|
||||
export function isDriveValid(
|
||||
drive: DrivelistDrive,
|
||||
image?: SourceMetadata,
|
||||
): boolean {
|
||||
return (
|
||||
!isDriveLocked(drive) &&
|
||||
isDriveLargeEnough(drive, image) &&
|
||||
!isSourceDrive(drive, image) &&
|
||||
!isSourceDrive(drive, image as SourceMetadata) &&
|
||||
!isDriveDisabled(drive)
|
||||
);
|
||||
}
|
||||
@@ -124,23 +145,23 @@ export function isDriveValid(drive: DrivelistDrive, image: Image): boolean {
|
||||
* If the image doesn't have a recommended size, this function returns true.
|
||||
*/
|
||||
export function isDriveSizeRecommended(
|
||||
drive: DrivelistDrive | undefined,
|
||||
image: Image,
|
||||
drive: DrivelistDrive,
|
||||
image?: SourceMetadata,
|
||||
): boolean {
|
||||
const driveSize = _.get(drive, 'size') || UNKNOWN_SIZE;
|
||||
return driveSize >= _.get(image, ['recommendedDriveSize'], UNKNOWN_SIZE);
|
||||
const driveSize = drive.size || UNKNOWN_SIZE;
|
||||
return driveSize >= (image?.recommendedDriveSize || UNKNOWN_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary 64GB
|
||||
* @summary 128GB
|
||||
*/
|
||||
export const LARGE_DRIVE_SIZE = 64e9;
|
||||
export const LARGE_DRIVE_SIZE = 128e9;
|
||||
|
||||
/**
|
||||
* @summary Check whether a drive's size is 'large'
|
||||
*/
|
||||
export function isDriveSizeLarge(drive?: DrivelistDrive): boolean {
|
||||
const driveSize = _.get(drive, 'size') || UNKNOWN_SIZE;
|
||||
export function isDriveSizeLarge(drive: DrivelistDrive): boolean {
|
||||
const driveSize = drive.size || UNKNOWN_SIZE;
|
||||
return driveSize > LARGE_DRIVE_SIZE;
|
||||
}
|
||||
|
||||
@@ -155,6 +176,33 @@ export const COMPATIBILITY_STATUS_TYPES = {
|
||||
ERROR: 2,
|
||||
};
|
||||
|
||||
export const statuses = {
|
||||
locked: {
|
||||
type: COMPATIBILITY_STATUS_TYPES.ERROR,
|
||||
message: messages.compatibility.locked(),
|
||||
},
|
||||
system: {
|
||||
type: COMPATIBILITY_STATUS_TYPES.WARNING,
|
||||
message: messages.compatibility.system(),
|
||||
},
|
||||
containsImage: {
|
||||
type: COMPATIBILITY_STATUS_TYPES.ERROR,
|
||||
message: messages.compatibility.containsImage(),
|
||||
},
|
||||
large: {
|
||||
type: COMPATIBILITY_STATUS_TYPES.WARNING,
|
||||
message: messages.compatibility.largeDrive(),
|
||||
},
|
||||
small: {
|
||||
type: COMPATIBILITY_STATUS_TYPES.ERROR,
|
||||
message: messages.compatibility.tooSmall(),
|
||||
},
|
||||
sizeNotRecommended: {
|
||||
type: COMPATIBILITY_STATUS_TYPES.WARNING,
|
||||
message: messages.compatibility.sizeNotRecommended(),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Get drive/image compatibility in an object
|
||||
*
|
||||
@@ -167,7 +215,7 @@ export const COMPATIBILITY_STATUS_TYPES = {
|
||||
*/
|
||||
export function getDriveImageCompatibilityStatuses(
|
||||
drive: DrivelistDrive,
|
||||
image: Image = {},
|
||||
image?: SourceMetadata,
|
||||
) {
|
||||
const statusList = [];
|
||||
|
||||
@@ -182,41 +230,25 @@ export function getDriveImageCompatibilityStatuses(
|
||||
!_.isNil(drive.size) &&
|
||||
!isDriveLargeEnough(drive, image)
|
||||
) {
|
||||
const imageSize = (image.isSizeEstimated
|
||||
? image.compressedSize
|
||||
: image.size) as number;
|
||||
const relativeBytes = imageSize - drive.size;
|
||||
statusList.push({
|
||||
type: COMPATIBILITY_STATUS_TYPES.ERROR,
|
||||
message: messages.compatibility.tooSmall(prettyBytes(relativeBytes)),
|
||||
});
|
||||
statusList.push(statuses.small);
|
||||
} else {
|
||||
if (isSourceDrive(drive, image)) {
|
||||
statusList.push({
|
||||
type: COMPATIBILITY_STATUS_TYPES.ERROR,
|
||||
message: messages.compatibility.containsImage(),
|
||||
});
|
||||
}
|
||||
|
||||
// Avoid showing "large drive" with "system drive" status
|
||||
if (isSystemDrive(drive)) {
|
||||
statusList.push({
|
||||
type: COMPATIBILITY_STATUS_TYPES.WARNING,
|
||||
message: messages.compatibility.system(),
|
||||
});
|
||||
statusList.push(statuses.system);
|
||||
} else if (isDriveSizeLarge(drive)) {
|
||||
statusList.push(statuses.large);
|
||||
}
|
||||
|
||||
if (isDriveSizeLarge(drive)) {
|
||||
statusList.push({
|
||||
type: COMPATIBILITY_STATUS_TYPES.WARNING,
|
||||
message: messages.compatibility.largeDrive(),
|
||||
});
|
||||
if (isSourceDrive(drive, image as SourceMetadata)) {
|
||||
statusList.push(statuses.containsImage);
|
||||
}
|
||||
|
||||
if (!_.isNil(drive) && !isDriveSizeRecommended(drive, image)) {
|
||||
statusList.push({
|
||||
type: COMPATIBILITY_STATUS_TYPES.WARNING,
|
||||
message: messages.compatibility.sizeNotRecommended(),
|
||||
});
|
||||
if (
|
||||
image !== undefined &&
|
||||
!_.isNil(drive) &&
|
||||
!isDriveSizeRecommended(drive, image)
|
||||
) {
|
||||
statusList.push(statuses.sizeNotRecommended);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,9 +264,9 @@ export function getDriveImageCompatibilityStatuses(
|
||||
*/
|
||||
export function getListDriveImageCompatibilityStatuses(
|
||||
drives: DrivelistDrive[],
|
||||
image: Image,
|
||||
image: SourceMetadata,
|
||||
) {
|
||||
return _.flatMap(drives, (drive) => {
|
||||
return drives.flatMap((drive) => {
|
||||
return getDriveImageCompatibilityStatuses(drive, image);
|
||||
});
|
||||
}
|
||||
@@ -247,36 +279,12 @@ export function getListDriveImageCompatibilityStatuses(
|
||||
*/
|
||||
export function hasDriveImageCompatibilityStatus(
|
||||
drive: DrivelistDrive,
|
||||
image: Image,
|
||||
image: SourceMetadata,
|
||||
) {
|
||||
return Boolean(getDriveImageCompatibilityStatuses(drive, image).length);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Does any drive/image pair have at least one compatibility status?
|
||||
* @function
|
||||
* @public
|
||||
*
|
||||
* @description
|
||||
* Given an image and a drive, return whether they have a connected compatibility status object.
|
||||
*
|
||||
* @param {Object[]} drives - drives
|
||||
* @param {Object} image - image
|
||||
* @returns {Boolean}
|
||||
*
|
||||
* @example
|
||||
* if (constraints.hasDriveImageCompatibilityStatus(drive, image)) {
|
||||
* console.log('This drive-image pair has a compatibility status message!')
|
||||
* }
|
||||
*/
|
||||
export function hasListDriveImageCompatibilityStatus(
|
||||
drives: DrivelistDrive[],
|
||||
image: Image,
|
||||
) {
|
||||
return Boolean(getListDriveImageCompatibilityStatuses(drives, image).length);
|
||||
}
|
||||
|
||||
export interface TargetStatus {
|
||||
export interface DriveStatus {
|
||||
message: string;
|
||||
type: number;
|
||||
}
|
||||
|
@@ -15,6 +15,8 @@
|
||||
*/
|
||||
|
||||
import { Dictionary } from 'lodash';
|
||||
import { outdent } from 'outdent';
|
||||
import * as prettyBytes from 'pretty-bytes';
|
||||
|
||||
export const progress: Dictionary<(quantity: number) => string> = {
|
||||
successful: (quantity: number) => {
|
||||
@@ -53,11 +55,11 @@ export const info = {
|
||||
|
||||
export const compatibility = {
|
||||
sizeNotRecommended: () => {
|
||||
return 'Not Recommended';
|
||||
return 'Not recommended';
|
||||
},
|
||||
|
||||
tooSmall: (additionalSpace: string) => {
|
||||
return `Insufficient space, additional ${additionalSpace} required`;
|
||||
tooSmall: () => {
|
||||
return 'Too small';
|
||||
},
|
||||
|
||||
locked: () => {
|
||||
@@ -83,10 +85,10 @@ export const warning = {
|
||||
image: { recommendedDriveSize: number },
|
||||
drive: { device: string; size: number },
|
||||
) => {
|
||||
return [
|
||||
`This image recommends a ${image.recommendedDriveSize}`,
|
||||
`bytes drive, however ${drive.device} is only ${drive.size} bytes.`,
|
||||
].join(' ');
|
||||
return outdent({ newline: ' ' })`
|
||||
This image recommends a ${prettyBytes(image.recommendedDriveSize)}
|
||||
drive, however ${drive.device} is only ${prettyBytes(drive.size)}.
|
||||
`;
|
||||
},
|
||||
|
||||
exitWhileFlashing: () => {
|
||||
@@ -115,11 +117,16 @@ export const warning = {
|
||||
].join(' ');
|
||||
},
|
||||
|
||||
largeDriveSize: (drive: { description: string; device: string }) => {
|
||||
return [
|
||||
`Drive ${drive.description} (${drive.device}) is unusually large for an SD card or USB stick.`,
|
||||
'\n\nAre you sure you want to flash this drive?',
|
||||
].join(' ');
|
||||
largeDriveSize: () => {
|
||||
return 'This is a large drive! Make sure it doesn\'t contain files that you want to keep.';
|
||||
},
|
||||
|
||||
systemDrive: () => {
|
||||
return 'Selecting your system drive is dangerous and will erase your drive!';
|
||||
},
|
||||
|
||||
sourceDrive: () => {
|
||||
return 'Contains the image you chose to flash';
|
||||
},
|
||||
};
|
||||
|
||||
@@ -143,15 +150,12 @@ export const error = {
|
||||
].join(' ');
|
||||
},
|
||||
|
||||
openImage: (imageBasename: string, errorMessage: string) => {
|
||||
return [
|
||||
`Something went wrong while opening ${imageBasename}\n\n`,
|
||||
`Error: ${errorMessage}`,
|
||||
].join('');
|
||||
},
|
||||
openSource: (sourceName: string, errorMessage: string) => {
|
||||
return outdent`
|
||||
Something went wrong while opening ${sourceName}
|
||||
|
||||
elevationRequired: () => {
|
||||
return 'This should should be run with root/administrator permissions.';
|
||||
Error: ${errorMessage}
|
||||
`;
|
||||
},
|
||||
|
||||
flashFailure: (
|
||||
|
@@ -14,18 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as _ from 'lodash';
|
||||
import * as prettyBytes from 'pretty-bytes';
|
||||
|
||||
const MEGABYTE_TO_BYTE_RATIO = 1000000;
|
||||
|
||||
export function bytesToMegabytes(bytes: number): number {
|
||||
return bytes / MEGABYTE_TO_BYTE_RATIO;
|
||||
}
|
||||
|
||||
export function bytesToClosestUnit(bytes: number): string | null {
|
||||
if (_.isNumber(bytes)) {
|
||||
return prettyBytes(bytes);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
1179
npm-shrinkwrap.json
generated
1179
npm-shrinkwrap.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "balena-etcher",
|
||||
"private": true,
|
||||
"displayName": "balenaEtcher",
|
||||
"version": "1.5.103",
|
||||
"version": "1.5.109",
|
||||
"packageType": "local",
|
||||
"main": "generated/etcher.js",
|
||||
"description": "Flash OS images to SD cards and USB drives, safely and easily.",
|
||||
@@ -53,7 +53,6 @@
|
||||
"@balena/lint": "^5.0.4",
|
||||
"@fortawesome/fontawesome-free": "^5.13.1",
|
||||
"@svgr/webpack": "^5.4.0",
|
||||
"@types/bluebird": "^3.5.30",
|
||||
"@types/chai": "^4.2.7",
|
||||
"@types/copy-webpack-plugin": "^6.0.0",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
@@ -62,7 +61,6 @@
|
||||
"@types/node": "^12.12.39",
|
||||
"@types/node-ipc": "^9.1.2",
|
||||
"@types/react-dom": "^16.8.4",
|
||||
"@types/request": "^2.48.4",
|
||||
"@types/semver": "^7.1.0",
|
||||
"@types/sinon": "^9.0.0",
|
||||
"@types/terser-webpack-plugin": "^4.1.0",
|
||||
@@ -73,13 +71,13 @@
|
||||
"css-loader": "^4.2.1",
|
||||
"d3": "^4.13.0",
|
||||
"debug": "^4.2.0",
|
||||
"electron": "9.2.0",
|
||||
"electron": "9.2.1",
|
||||
"electron-builder": "^22.7.0",
|
||||
"electron-mocha": "^9.1.0",
|
||||
"electron-notarize": "^1.0.0",
|
||||
"electron-rebuild": "^1.11.0",
|
||||
"electron-updater": "^4.3.2",
|
||||
"etcher-sdk": "^4.1.23",
|
||||
"etcher-sdk": "^4.1.30",
|
||||
"file-loader": "^6.0.0",
|
||||
"husky": "^4.2.5",
|
||||
"immutable": "^3.8.1",
|
||||
@@ -87,7 +85,6 @@
|
||||
"lodash": "^4.17.10",
|
||||
"mini-css-extract-plugin": "^0.10.0",
|
||||
"mocha": "^8.0.1",
|
||||
"nan": "^2.14.0",
|
||||
"native-addon-loader": "^2.0.1",
|
||||
"node-ipc": "^9.1.1",
|
||||
"omit-deep-lodash": "1.1.4",
|
||||
@@ -97,7 +94,7 @@
|
||||
"react": "^16.8.5",
|
||||
"react-dom": "^16.8.5",
|
||||
"redux": "^4.0.5",
|
||||
"rendition": "^17.0.0",
|
||||
"rendition": "^18.8.3",
|
||||
"resin-corvus": "^2.0.5",
|
||||
"semver": "^7.3.2",
|
||||
"simple-progress-webpack-plugin": "^1.1.2",
|
||||
@@ -105,14 +102,13 @@
|
||||
"spectron": "^11.0.0",
|
||||
"string-replace-loader": "^2.3.0",
|
||||
"styled-components": "^5.1.0",
|
||||
"styled-system": "^5.1.5",
|
||||
"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",
|
||||
"ts-node": "^8.3.0",
|
||||
"ts-node": "^9.0.0",
|
||||
"tslib": "^2.0.0",
|
||||
"typescript": "^3.5.3",
|
||||
"typescript": "^4.0.2",
|
||||
"uuid": "^8.1.0",
|
||||
"webpack": "^4.40.2",
|
||||
"webpack-cli": "^3.3.9"
|
||||
|
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { expect } from 'chai';
|
||||
import { File } from 'etcher-sdk/build/source-destination';
|
||||
import * as path from 'path';
|
||||
|
||||
import * as availableDrives from '../../../lib/gui/app/models/available-drives';
|
||||
@@ -157,11 +158,14 @@ describe('Model: availableDrives', function () {
|
||||
}
|
||||
|
||||
selectionState.clear();
|
||||
selectionState.selectImage({
|
||||
selectionState.selectSource({
|
||||
description: this.imagePath.split('/').pop(),
|
||||
displayName: this.imagePath,
|
||||
path: this.imagePath,
|
||||
extension: 'img',
|
||||
size: 999999999,
|
||||
isSizeEstimated: false,
|
||||
SourceType: File,
|
||||
recommendedDriveSize: 2000000000,
|
||||
});
|
||||
});
|
||||
|
@@ -393,6 +393,7 @@ describe('Model: flashState', function () {
|
||||
|
||||
expect(flashResults).to.deep.equal({
|
||||
cancelled: false,
|
||||
skip: false,
|
||||
sourceChecksum: '1234',
|
||||
});
|
||||
});
|
||||
|
@@ -15,11 +15,13 @@
|
||||
*/
|
||||
|
||||
import { expect } from 'chai';
|
||||
import * as _ from 'lodash';
|
||||
import { File } from 'etcher-sdk/build/source-destination';
|
||||
import * as path from 'path';
|
||||
import { SourceMetadata } from '../../../lib/gui/app/components/source-selector/source-selector';
|
||||
|
||||
import * as availableDrives from '../../../lib/gui/app/models/available-drives';
|
||||
import * as selectionState from '../../../lib/gui/app/models/selection-state';
|
||||
import { DrivelistDrive } from '../../../lib/shared/drive-constraints';
|
||||
|
||||
describe('Model: selectionState', function () {
|
||||
describe('given a clean state', function () {
|
||||
@@ -39,10 +41,6 @@ describe('Model: selectionState', function () {
|
||||
expect(selectionState.getImageSize()).to.be.undefined;
|
||||
});
|
||||
|
||||
it('getImageUrl() should return undefined', function () {
|
||||
expect(selectionState.getImageUrl()).to.be.undefined;
|
||||
});
|
||||
|
||||
it('getImageName() should return undefined', function () {
|
||||
expect(selectionState.getImageName()).to.be.undefined;
|
||||
});
|
||||
@@ -55,10 +53,6 @@ describe('Model: selectionState', function () {
|
||||
expect(selectionState.getImageSupportUrl()).to.be.undefined;
|
||||
});
|
||||
|
||||
it('getImageRecommendedDriveSize() should return undefined', function () {
|
||||
expect(selectionState.getImageRecommendedDriveSize()).to.be.undefined;
|
||||
});
|
||||
|
||||
it('hasDrive() should return false', function () {
|
||||
const hasDrive = selectionState.hasDrive();
|
||||
expect(hasDrive).to.be.false;
|
||||
@@ -138,10 +132,10 @@ describe('Model: selectionState', function () {
|
||||
it('should queue the drive', function () {
|
||||
selectionState.selectDrive('/dev/disk5');
|
||||
const drives = selectionState.getSelectedDevices();
|
||||
const lastDriveDevice = _.last(drives);
|
||||
const lastDrive = _.find(availableDrives.getDrives(), {
|
||||
device: lastDriveDevice,
|
||||
});
|
||||
const lastDriveDevice = drives.pop();
|
||||
const lastDrive = availableDrives
|
||||
.getDrives()
|
||||
.find((drive) => drive.device === lastDriveDevice);
|
||||
expect(lastDrive).to.deep.equal({
|
||||
device: '/dev/disk5',
|
||||
name: 'USB Drive',
|
||||
@@ -214,7 +208,7 @@ describe('Model: selectionState', function () {
|
||||
it('should be able to add more drives', function () {
|
||||
selectionState.selectDrive(this.drives[2].device);
|
||||
expect(selectionState.getSelectedDevices()).to.deep.equal(
|
||||
_.map(this.drives, 'device'),
|
||||
this.drives.map((drive: DrivelistDrive) => drive.device),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -234,13 +228,13 @@ describe('Model: selectionState', function () {
|
||||
system: true,
|
||||
};
|
||||
|
||||
const newDrives = [..._.initial(this.drives), systemDrive];
|
||||
const newDrives = [...this.drives.slice(0, -1), systemDrive];
|
||||
availableDrives.setDrives(newDrives);
|
||||
|
||||
selectionState.selectDrive(systemDrive.device);
|
||||
availableDrives.setDrives(newDrives);
|
||||
expect(selectionState.getSelectedDevices()).to.deep.equal(
|
||||
_.map(newDrives, 'device'),
|
||||
newDrives.map((drive: DrivelistDrive) => drive.device),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -271,6 +265,12 @@ describe('Model: selectionState', function () {
|
||||
describe('.getSelectedDrives()', function () {
|
||||
it('should return the selected drives', function () {
|
||||
expect(selectionState.getSelectedDrives()).to.deep.equal([
|
||||
{
|
||||
device: '/dev/disk2',
|
||||
name: 'USB Drive 2',
|
||||
size: 999999999,
|
||||
isReadOnly: false,
|
||||
},
|
||||
{
|
||||
device: '/dev/sdb',
|
||||
description: 'DataTraveler 2.0',
|
||||
@@ -280,12 +280,6 @@ describe('Model: selectionState', function () {
|
||||
system: false,
|
||||
isReadOnly: false,
|
||||
},
|
||||
{
|
||||
device: '/dev/disk2',
|
||||
name: 'USB Drive 2',
|
||||
size: 999999999,
|
||||
isReadOnly: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -359,7 +353,7 @@ describe('Model: selectionState', function () {
|
||||
logo: '<svg><text fill="red">Raspbian</text></svg>',
|
||||
};
|
||||
|
||||
selectionState.selectImage(this.image);
|
||||
selectionState.selectSource(this.image);
|
||||
});
|
||||
|
||||
describe('.selectDrive()', function () {
|
||||
@@ -399,13 +393,6 @@ describe('Model: selectionState', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('.getImageUrl()', function () {
|
||||
it('should return the image url', function () {
|
||||
const imageUrl = selectionState.getImageUrl();
|
||||
expect(imageUrl).to.equal('https://www.raspbian.org');
|
||||
});
|
||||
});
|
||||
|
||||
describe('.getImageName()', function () {
|
||||
it('should return the image name', function () {
|
||||
const imageName = selectionState.getImageName();
|
||||
@@ -429,13 +416,6 @@ describe('Model: selectionState', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('.getImageRecommendedDriveSize()', function () {
|
||||
it('should return the image recommended drive size', function () {
|
||||
const imageRecommendedDriveSize = selectionState.getImageRecommendedDriveSize();
|
||||
expect(imageRecommendedDriveSize).to.equal(1000000000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('.hasImage()', function () {
|
||||
it('should return true', function () {
|
||||
const hasImage = selectionState.hasImage();
|
||||
@@ -445,11 +425,14 @@ describe('Model: selectionState', function () {
|
||||
|
||||
describe('.selectImage()', function () {
|
||||
it('should override the image', function () {
|
||||
selectionState.selectImage({
|
||||
selectionState.selectSource({
|
||||
description: 'bar.img',
|
||||
displayName: 'bar.img',
|
||||
path: 'bar.img',
|
||||
extension: 'img',
|
||||
size: 999999999,
|
||||
isSizeEstimated: false,
|
||||
SourceType: File,
|
||||
});
|
||||
|
||||
const imagePath = selectionState.getImagePath();
|
||||
@@ -475,13 +458,19 @@ describe('Model: selectionState', function () {
|
||||
describe('.selectImage()', function () {
|
||||
afterEach(selectionState.clear);
|
||||
|
||||
const image: SourceMetadata = {
|
||||
description: 'foo.img',
|
||||
displayName: 'foo.img',
|
||||
path: 'foo.img',
|
||||
extension: 'img',
|
||||
size: 999999999,
|
||||
isSizeEstimated: false,
|
||||
SourceType: File,
|
||||
recommendedDriveSize: 2000000000,
|
||||
};
|
||||
|
||||
it('should be able to set an image', function () {
|
||||
selectionState.selectImage({
|
||||
path: 'foo.img',
|
||||
extension: 'img',
|
||||
size: 999999999,
|
||||
isSizeEstimated: false,
|
||||
});
|
||||
selectionState.selectSource(image);
|
||||
|
||||
const imagePath = selectionState.getImagePath();
|
||||
expect(imagePath).to.equal('foo.img');
|
||||
@@ -490,12 +479,10 @@ describe('Model: selectionState', function () {
|
||||
});
|
||||
|
||||
it('should be able to set an image with an archive extension', function () {
|
||||
selectionState.selectImage({
|
||||
selectionState.selectSource({
|
||||
...image,
|
||||
path: 'foo.zip',
|
||||
extension: 'img',
|
||||
archiveExtension: 'zip',
|
||||
size: 999999999,
|
||||
isSizeEstimated: false,
|
||||
});
|
||||
|
||||
const imagePath = selectionState.getImagePath();
|
||||
@@ -503,12 +490,10 @@ describe('Model: selectionState', function () {
|
||||
});
|
||||
|
||||
it('should infer a compressed raw image if the penultimate extension is missing', function () {
|
||||
selectionState.selectImage({
|
||||
selectionState.selectSource({
|
||||
...image,
|
||||
path: 'foo.xz',
|
||||
extension: 'img',
|
||||
archiveExtension: 'xz',
|
||||
size: 999999999,
|
||||
isSizeEstimated: false,
|
||||
});
|
||||
|
||||
const imagePath = selectionState.getImagePath();
|
||||
@@ -516,54 +501,20 @@ describe('Model: selectionState', function () {
|
||||
});
|
||||
|
||||
it('should infer a compressed raw image if the penultimate extension is not a file extension', function () {
|
||||
selectionState.selectImage({
|
||||
selectionState.selectSource({
|
||||
...image,
|
||||
path: 'something.linux-x86-64.gz',
|
||||
extension: 'img',
|
||||
archiveExtension: 'gz',
|
||||
size: 999999999,
|
||||
isSizeEstimated: false,
|
||||
});
|
||||
|
||||
const imagePath = selectionState.getImagePath();
|
||||
expect(imagePath).to.equal('something.linux-x86-64.gz');
|
||||
});
|
||||
|
||||
it('should throw if no path', function () {
|
||||
expect(function () {
|
||||
selectionState.selectImage({
|
||||
extension: 'img',
|
||||
size: 999999999,
|
||||
isSizeEstimated: false,
|
||||
});
|
||||
}).to.throw('Missing image fields: path');
|
||||
});
|
||||
|
||||
it('should throw if path is not a string', function () {
|
||||
expect(function () {
|
||||
selectionState.selectImage({
|
||||
path: 123,
|
||||
extension: 'img',
|
||||
size: 999999999,
|
||||
isSizeEstimated: false,
|
||||
});
|
||||
}).to.throw('Invalid image path: 123');
|
||||
});
|
||||
|
||||
it('should throw if the original size is not a number', function () {
|
||||
expect(function () {
|
||||
selectionState.selectImage({
|
||||
path: 'foo.img',
|
||||
extension: 'img',
|
||||
size: 999999999,
|
||||
compressedSize: '999999999',
|
||||
isSizeEstimated: false,
|
||||
});
|
||||
}).to.throw('Invalid image compressed size: 999999999');
|
||||
});
|
||||
|
||||
it('should throw if the original size is a float number', function () {
|
||||
expect(function () {
|
||||
selectionState.selectImage({
|
||||
selectionState.selectSource({
|
||||
...image,
|
||||
path: 'foo.img',
|
||||
extension: 'img',
|
||||
size: 999999999,
|
||||
@@ -575,85 +526,31 @@ describe('Model: selectionState', function () {
|
||||
|
||||
it('should throw if the original size is negative', function () {
|
||||
expect(function () {
|
||||
selectionState.selectImage({
|
||||
path: 'foo.img',
|
||||
extension: 'img',
|
||||
size: 999999999,
|
||||
selectionState.selectSource({
|
||||
...image,
|
||||
compressedSize: -1,
|
||||
isSizeEstimated: false,
|
||||
});
|
||||
}).to.throw('Invalid image compressed size: -1');
|
||||
});
|
||||
|
||||
it('should throw if the final size is not a number', function () {
|
||||
expect(function () {
|
||||
selectionState.selectImage({
|
||||
path: 'foo.img',
|
||||
extension: 'img',
|
||||
size: '999999999',
|
||||
isSizeEstimated: false,
|
||||
});
|
||||
}).to.throw('Invalid image size: 999999999');
|
||||
});
|
||||
|
||||
it('should throw if the final size is a float number', function () {
|
||||
expect(function () {
|
||||
selectionState.selectImage({
|
||||
path: 'foo.img',
|
||||
extension: 'img',
|
||||
selectionState.selectSource({
|
||||
...image,
|
||||
size: 999999999.999,
|
||||
isSizeEstimated: false,
|
||||
});
|
||||
}).to.throw('Invalid image size: 999999999.999');
|
||||
});
|
||||
|
||||
it('should throw if the final size is negative', function () {
|
||||
expect(function () {
|
||||
selectionState.selectImage({
|
||||
path: 'foo.img',
|
||||
extension: 'img',
|
||||
selectionState.selectSource({
|
||||
...image,
|
||||
size: -1,
|
||||
isSizeEstimated: false,
|
||||
});
|
||||
}).to.throw('Invalid image size: -1');
|
||||
});
|
||||
|
||||
it("should throw if url is defined but it's not a string", function () {
|
||||
expect(function () {
|
||||
selectionState.selectImage({
|
||||
path: 'foo.img',
|
||||
extension: 'img',
|
||||
size: 999999999,
|
||||
isSizeEstimated: false,
|
||||
url: 1234,
|
||||
});
|
||||
}).to.throw('Invalid image url: 1234');
|
||||
});
|
||||
|
||||
it("should throw if name is defined but it's not a string", function () {
|
||||
expect(function () {
|
||||
selectionState.selectImage({
|
||||
path: 'foo.img',
|
||||
extension: 'img',
|
||||
size: 999999999,
|
||||
isSizeEstimated: false,
|
||||
name: 1234,
|
||||
});
|
||||
}).to.throw('Invalid image name: 1234');
|
||||
});
|
||||
|
||||
it("should throw if logo is defined but it's not a string", function () {
|
||||
expect(function () {
|
||||
selectionState.selectImage({
|
||||
path: 'foo.img',
|
||||
extension: 'img',
|
||||
size: 999999999,
|
||||
isSizeEstimated: false,
|
||||
logo: 1234,
|
||||
});
|
||||
}).to.throw('Invalid image logo: 1234');
|
||||
});
|
||||
|
||||
it('should de-select a previously selected not-large-enough drive', function () {
|
||||
availableDrives.setDrives([
|
||||
{
|
||||
@@ -667,11 +564,9 @@ describe('Model: selectionState', function () {
|
||||
selectionState.selectDrive('/dev/disk1');
|
||||
expect(selectionState.hasDrive()).to.be.true;
|
||||
|
||||
selectionState.selectImage({
|
||||
path: 'foo.img',
|
||||
extension: 'img',
|
||||
selectionState.selectSource({
|
||||
...image,
|
||||
size: 1234567890,
|
||||
isSizeEstimated: false,
|
||||
});
|
||||
|
||||
expect(selectionState.hasDrive()).to.be.false;
|
||||
@@ -691,11 +586,8 @@ describe('Model: selectionState', function () {
|
||||
selectionState.selectDrive('/dev/disk1');
|
||||
expect(selectionState.hasDrive()).to.be.true;
|
||||
|
||||
selectionState.selectImage({
|
||||
path: 'foo.img',
|
||||
extension: 'img',
|
||||
size: 999999999,
|
||||
isSizeEstimated: false,
|
||||
selectionState.selectSource({
|
||||
...image,
|
||||
recommendedDriveSize: 1500000000,
|
||||
});
|
||||
|
||||
@@ -726,11 +618,11 @@ describe('Model: selectionState', function () {
|
||||
selectionState.selectDrive('/dev/disk1');
|
||||
expect(selectionState.hasDrive()).to.be.true;
|
||||
|
||||
selectionState.selectImage({
|
||||
selectionState.selectSource({
|
||||
...image,
|
||||
path: imagePath,
|
||||
extension: 'img',
|
||||
size: 999999999,
|
||||
isSizeEstimated: false,
|
||||
});
|
||||
|
||||
expect(selectionState.hasDrive()).to.be.false;
|
||||
@@ -740,6 +632,16 @@ describe('Model: selectionState', function () {
|
||||
});
|
||||
|
||||
describe('given a drive and an image', function () {
|
||||
const image: SourceMetadata = {
|
||||
description: 'foo.img',
|
||||
displayName: 'foo.img',
|
||||
path: 'foo.img',
|
||||
extension: 'img',
|
||||
size: 999999999,
|
||||
SourceType: File,
|
||||
isSizeEstimated: false,
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
availableDrives.setDrives([
|
||||
{
|
||||
@@ -752,12 +654,7 @@ describe('Model: selectionState', function () {
|
||||
|
||||
selectionState.selectDrive('/dev/disk1');
|
||||
|
||||
selectionState.selectImage({
|
||||
path: 'foo.img',
|
||||
extension: 'img',
|
||||
size: 999999999,
|
||||
isSizeEstimated: false,
|
||||
});
|
||||
selectionState.selectSource(image);
|
||||
});
|
||||
|
||||
describe('.clear()', function () {
|
||||
@@ -824,6 +721,16 @@ describe('Model: selectionState', function () {
|
||||
});
|
||||
|
||||
describe('given several drives', function () {
|
||||
const image: SourceMetadata = {
|
||||
description: 'foo.img',
|
||||
displayName: 'foo.img',
|
||||
path: 'foo.img',
|
||||
extension: 'img',
|
||||
size: 999999999,
|
||||
SourceType: File,
|
||||
isSizeEstimated: false,
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
availableDrives.setDrives([
|
||||
{
|
||||
@@ -850,12 +757,7 @@ describe('Model: selectionState', function () {
|
||||
selectionState.selectDrive('/dev/disk2');
|
||||
selectionState.selectDrive('/dev/disk3');
|
||||
|
||||
selectionState.selectImage({
|
||||
path: 'foo.img',
|
||||
extension: 'img',
|
||||
size: 999999999,
|
||||
isSizeEstimated: false,
|
||||
});
|
||||
selectionState.selectSource(image);
|
||||
});
|
||||
|
||||
describe('.clear()', function () {
|
||||
|
@@ -44,16 +44,15 @@ describe('Browser: settings', () => {
|
||||
await settings.set('foo', 'bar');
|
||||
expect(await settings.get('foo')).to.equal('bar');
|
||||
|
||||
const writeConfigFileStub = stub(settings, 'writeConfigFile');
|
||||
const writeConfigFileStub = stub();
|
||||
writeConfigFileStub.returns(Promise.reject(new Error('settings error')));
|
||||
|
||||
const p = settings.set('foo', 'baz');
|
||||
const p = settings.set('foo', 'baz', writeConfigFileStub);
|
||||
await checkError(p, async (error) => {
|
||||
expect(error).to.be.an.instanceof(Error);
|
||||
expect(error.message).to.equal('settings error');
|
||||
expect(await settings.get('foo')).to.equal('bar');
|
||||
});
|
||||
writeConfigFileStub.restore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,15 +82,17 @@ describe('Browser: settings', () => {
|
||||
await settings.set('foo', 'bar');
|
||||
expect(await settings.get('foo')).to.equal('bar');
|
||||
|
||||
const writeConfigFileStub = stub(settings, 'writeConfigFile');
|
||||
const writeConfigFileStub = stub();
|
||||
writeConfigFileStub.returns(Promise.reject(new Error('settings error')));
|
||||
|
||||
await checkError(settings.set('foo', 'baz'), async (error) => {
|
||||
expect(error).to.be.an.instanceof(Error);
|
||||
expect(error.message).to.equal('settings error');
|
||||
expect(await settings.get('foo')).to.equal('bar');
|
||||
});
|
||||
writeConfigFileStub.restore();
|
||||
await checkError(
|
||||
settings.set('foo', 'baz', writeConfigFileStub),
|
||||
async (error) => {
|
||||
expect(error).to.be.an.instanceof(Error);
|
||||
expect(error.message).to.equal('settings error');
|
||||
expect(await settings.get('foo')).to.equal('bar');
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -20,6 +20,7 @@ import { sourceDestination } from 'etcher-sdk';
|
||||
import * as ipc from 'node-ipc';
|
||||
import { assert, SinonStub, stub } from 'sinon';
|
||||
|
||||
import { SourceMetadata } from '../../../lib/gui/app/components/source-selector/source-selector';
|
||||
import * as flashState from '../../../lib/gui/app/models/flash-state';
|
||||
import * as imageWriter from '../../../lib/gui/app/modules/image-writer';
|
||||
|
||||
@@ -28,10 +29,14 @@ const fakeDrive: DrivelistDrive = {};
|
||||
|
||||
describe('Browser: imageWriter', () => {
|
||||
describe('.flash()', () => {
|
||||
const imagePath = 'foo.img';
|
||||
const sourceOptions = {
|
||||
imagePath,
|
||||
const image: SourceMetadata = {
|
||||
hasMBR: false,
|
||||
partitions: [],
|
||||
description: 'foo.img',
|
||||
displayName: 'foo.img',
|
||||
path: 'foo.img',
|
||||
SourceType: sourceDestination.File,
|
||||
extension: 'img',
|
||||
};
|
||||
|
||||
describe('given a successful write', () => {
|
||||
@@ -58,12 +63,7 @@ describe('Browser: imageWriter', () => {
|
||||
});
|
||||
|
||||
try {
|
||||
await imageWriter.flash(
|
||||
imagePath,
|
||||
[fakeDrive],
|
||||
sourceOptions,
|
||||
performWriteStub,
|
||||
);
|
||||
await imageWriter.flash(image, [fakeDrive], performWriteStub);
|
||||
} catch {
|
||||
// noop
|
||||
} finally {
|
||||
@@ -79,18 +79,8 @@ describe('Browser: imageWriter', () => {
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
imageWriter.flash(
|
||||
imagePath,
|
||||
[fakeDrive],
|
||||
sourceOptions,
|
||||
performWriteStub,
|
||||
),
|
||||
imageWriter.flash(
|
||||
imagePath,
|
||||
[fakeDrive],
|
||||
sourceOptions,
|
||||
performWriteStub,
|
||||
),
|
||||
imageWriter.flash(image, [fakeDrive], performWriteStub),
|
||||
imageWriter.flash(image, [fakeDrive], performWriteStub),
|
||||
]);
|
||||
assert.fail('Writing twice should fail');
|
||||
} catch (error) {
|
||||
@@ -117,12 +107,7 @@ describe('Browser: imageWriter', () => {
|
||||
|
||||
it('should set flashing to false when done', async () => {
|
||||
try {
|
||||
await imageWriter.flash(
|
||||
imagePath,
|
||||
[fakeDrive],
|
||||
sourceOptions,
|
||||
performWriteStub,
|
||||
);
|
||||
await imageWriter.flash(image, [fakeDrive], performWriteStub);
|
||||
} catch {
|
||||
// noop
|
||||
} finally {
|
||||
@@ -132,12 +117,7 @@ describe('Browser: imageWriter', () => {
|
||||
|
||||
it('should set the error code in the flash results', async () => {
|
||||
try {
|
||||
await imageWriter.flash(
|
||||
imagePath,
|
||||
[fakeDrive],
|
||||
sourceOptions,
|
||||
performWriteStub,
|
||||
);
|
||||
await imageWriter.flash(image, [fakeDrive], performWriteStub);
|
||||
} catch {
|
||||
// noop
|
||||
} finally {
|
||||
@@ -152,12 +132,7 @@ describe('Browser: imageWriter', () => {
|
||||
sourceChecksum: '1234',
|
||||
});
|
||||
try {
|
||||
await imageWriter.flash(
|
||||
imagePath,
|
||||
[fakeDrive],
|
||||
sourceOptions,
|
||||
performWriteStub,
|
||||
);
|
||||
await imageWriter.flash(image, [fakeDrive], performWriteStub);
|
||||
} catch (error) {
|
||||
expect(error).to.be.an.instanceof(Error);
|
||||
expect(error.message).to.equal('write error');
|
||||
|
@@ -15,9 +15,9 @@
|
||||
*/
|
||||
|
||||
import { expect } from 'chai';
|
||||
import { Drive as DrivelistDrive } from 'drivelist';
|
||||
import * as _ from 'lodash';
|
||||
import { sourceDestination } from 'etcher-sdk';
|
||||
import * as path from 'path';
|
||||
import { SourceMetadata } from '../../lib/gui/app/components/source-selector/source-selector';
|
||||
|
||||
import * as constraints from '../../lib/shared/drive-constraints';
|
||||
import * as messages from '../../lib/shared/messages';
|
||||
@@ -29,7 +29,7 @@ describe('Shared: DriveConstraints', function () {
|
||||
device: '/dev/disk2',
|
||||
size: 999999999,
|
||||
isReadOnly: true,
|
||||
} as DrivelistDrive);
|
||||
} as constraints.DrivelistDrive);
|
||||
|
||||
expect(result).to.be.true;
|
||||
});
|
||||
@@ -39,7 +39,7 @@ describe('Shared: DriveConstraints', function () {
|
||||
device: '/dev/disk2',
|
||||
size: 999999999,
|
||||
isReadOnly: false,
|
||||
} as DrivelistDrive);
|
||||
} as constraints.DrivelistDrive);
|
||||
|
||||
expect(result).to.be.false;
|
||||
});
|
||||
@@ -48,16 +48,10 @@ describe('Shared: DriveConstraints', function () {
|
||||
const result = constraints.isDriveLocked({
|
||||
device: '/dev/disk2',
|
||||
size: 999999999,
|
||||
} as DrivelistDrive);
|
||||
} as constraints.DrivelistDrive);
|
||||
|
||||
expect(result).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false if the drive is undefined', function () {
|
||||
// @ts-ignore
|
||||
const result = constraints.isDriveLocked(undefined);
|
||||
expect(result).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('.isSystemDrive()', function () {
|
||||
@@ -67,7 +61,7 @@ describe('Shared: DriveConstraints', function () {
|
||||
size: 999999999,
|
||||
isReadOnly: true,
|
||||
isSystem: true,
|
||||
} as DrivelistDrive);
|
||||
} as constraints.DrivelistDrive);
|
||||
|
||||
expect(result).to.be.true;
|
||||
});
|
||||
@@ -77,7 +71,7 @@ describe('Shared: DriveConstraints', function () {
|
||||
device: '/dev/disk2',
|
||||
size: 999999999,
|
||||
isReadOnly: true,
|
||||
} as DrivelistDrive);
|
||||
} as constraints.DrivelistDrive);
|
||||
|
||||
expect(result).to.be.false;
|
||||
});
|
||||
@@ -88,16 +82,10 @@ describe('Shared: DriveConstraints', function () {
|
||||
size: 999999999,
|
||||
isReadOnly: true,
|
||||
isSystem: false,
|
||||
} as DrivelistDrive);
|
||||
} as constraints.DrivelistDrive);
|
||||
|
||||
expect(result).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false if the drive is undefined', function () {
|
||||
// @ts-ignore
|
||||
const result = constraints.isSystemDrive(undefined);
|
||||
expect(result).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('.isSourceDrive()', function () {
|
||||
@@ -108,7 +96,7 @@ describe('Shared: DriveConstraints', function () {
|
||||
size: 999999999,
|
||||
isReadOnly: true,
|
||||
isSystem: false,
|
||||
} as DrivelistDrive,
|
||||
} as constraints.DrivelistDrive,
|
||||
// @ts-ignore
|
||||
undefined,
|
||||
);
|
||||
@@ -123,9 +111,14 @@ describe('Shared: DriveConstraints', function () {
|
||||
size: 999999999,
|
||||
isReadOnly: true,
|
||||
isSystem: false,
|
||||
} as DrivelistDrive,
|
||||
} as constraints.DrivelistDrive,
|
||||
{
|
||||
description: 'image.img',
|
||||
displayName: 'image.img',
|
||||
path: '/Volumes/Untitled/image.img',
|
||||
hasMBR: false,
|
||||
partitions: [],
|
||||
SourceType: sourceDestination.File,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -133,6 +126,14 @@ describe('Shared: DriveConstraints', function () {
|
||||
});
|
||||
|
||||
describe('given Windows paths', function () {
|
||||
const windowsImage: SourceMetadata = {
|
||||
description: 'image.img',
|
||||
displayName: 'image.img',
|
||||
path: 'E:\\image.img',
|
||||
hasMBR: false,
|
||||
partitions: [],
|
||||
SourceType: sourceDestination.File,
|
||||
};
|
||||
beforeEach(function () {
|
||||
this.separator = path.sep;
|
||||
// @ts-ignore
|
||||
@@ -157,10 +158,8 @@ describe('Shared: DriveConstraints', function () {
|
||||
path: 'F:',
|
||||
},
|
||||
],
|
||||
} as DrivelistDrive,
|
||||
{
|
||||
path: 'E:\\image.img',
|
||||
},
|
||||
} as constraints.DrivelistDrive,
|
||||
windowsImage,
|
||||
);
|
||||
|
||||
expect(result).to.be.true;
|
||||
@@ -179,8 +178,9 @@ describe('Shared: DriveConstraints', function () {
|
||||
path: 'F:',
|
||||
},
|
||||
],
|
||||
} as DrivelistDrive,
|
||||
} as constraints.DrivelistDrive,
|
||||
{
|
||||
...windowsImage,
|
||||
path: 'E:\\foo\\bar\\image.img',
|
||||
},
|
||||
);
|
||||
@@ -201,8 +201,9 @@ describe('Shared: DriveConstraints', function () {
|
||||
path: 'F:',
|
||||
},
|
||||
],
|
||||
} as DrivelistDrive,
|
||||
} as constraints.DrivelistDrive,
|
||||
{
|
||||
...windowsImage,
|
||||
path: 'G:\\image.img',
|
||||
},
|
||||
);
|
||||
@@ -219,8 +220,9 @@ describe('Shared: DriveConstraints', function () {
|
||||
path: 'E:\\fo',
|
||||
},
|
||||
],
|
||||
} as DrivelistDrive,
|
||||
} as constraints.DrivelistDrive,
|
||||
{
|
||||
...windowsImage,
|
||||
path: 'E:\\foo/image.img',
|
||||
},
|
||||
);
|
||||
@@ -230,6 +232,14 @@ describe('Shared: DriveConstraints', function () {
|
||||
});
|
||||
|
||||
describe('given UNIX paths', function () {
|
||||
const image: SourceMetadata = {
|
||||
description: 'image.img',
|
||||
displayName: 'image.img',
|
||||
path: '/Volumes/Untitled/image.img',
|
||||
hasMBR: false,
|
||||
partitions: [],
|
||||
SourceType: sourceDestination.File,
|
||||
};
|
||||
beforeEach(function () {
|
||||
this.separator = path.sep;
|
||||
// @ts-ignore
|
||||
@@ -249,8 +259,9 @@ describe('Shared: DriveConstraints', function () {
|
||||
path: '/',
|
||||
},
|
||||
],
|
||||
} as DrivelistDrive,
|
||||
} as constraints.DrivelistDrive,
|
||||
{
|
||||
...image,
|
||||
path: '/image.img',
|
||||
},
|
||||
);
|
||||
@@ -269,8 +280,9 @@ describe('Shared: DriveConstraints', function () {
|
||||
path: '/Volumes/B',
|
||||
},
|
||||
],
|
||||
} as DrivelistDrive,
|
||||
} as constraints.DrivelistDrive,
|
||||
{
|
||||
...image,
|
||||
path: '/Volumes/A/image.img',
|
||||
},
|
||||
);
|
||||
@@ -289,8 +301,9 @@ describe('Shared: DriveConstraints', function () {
|
||||
path: '/Volumes/B',
|
||||
},
|
||||
],
|
||||
} as DrivelistDrive,
|
||||
} as constraints.DrivelistDrive,
|
||||
{
|
||||
...image,
|
||||
path: '/Volumes/A/foo/bar/image.img',
|
||||
},
|
||||
);
|
||||
@@ -309,8 +322,9 @@ describe('Shared: DriveConstraints', function () {
|
||||
path: '/Volumes/B',
|
||||
},
|
||||
],
|
||||
} as DrivelistDrive,
|
||||
} as constraints.DrivelistDrive,
|
||||
{
|
||||
...image,
|
||||
path: '/Volumes/C/image.img',
|
||||
},
|
||||
);
|
||||
@@ -326,8 +340,9 @@ describe('Shared: DriveConstraints', function () {
|
||||
path: '/Volumes/fo',
|
||||
},
|
||||
],
|
||||
} as DrivelistDrive,
|
||||
} as constraints.DrivelistDrive,
|
||||
{
|
||||
...image,
|
||||
path: '/Volumes/foo/image.img',
|
||||
},
|
||||
);
|
||||
@@ -515,35 +530,19 @@ describe('Shared: DriveConstraints', function () {
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false if the drive is undefined', function () {
|
||||
const result = constraints.isDriveLargeEnough(undefined, {
|
||||
path: path.join(__dirname, 'rpi.img'),
|
||||
size: 1000000000,
|
||||
isSizeEstimated: false,
|
||||
});
|
||||
|
||||
expect(result).to.be.false;
|
||||
});
|
||||
|
||||
it('should return true if the image is undefined', function () {
|
||||
const result = constraints.isDriveLargeEnough(
|
||||
{
|
||||
device: '/dev/disk1',
|
||||
size: 1000000000,
|
||||
isReadOnly: false,
|
||||
} as DrivelistDrive,
|
||||
} as constraints.DrivelistDrive,
|
||||
// @ts-ignore
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(result).to.be.true;
|
||||
});
|
||||
|
||||
it('should return false if the drive and image are undefined', function () {
|
||||
// @ts-ignore
|
||||
const result = constraints.isDriveLargeEnough(undefined, undefined);
|
||||
expect(result).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe('.isDriveDisabled()', function () {
|
||||
@@ -553,7 +552,7 @@ describe('Shared: DriveConstraints', function () {
|
||||
size: 1000000000,
|
||||
isReadOnly: false,
|
||||
disabled: true,
|
||||
} as unknown) as DrivelistDrive);
|
||||
} as unknown) as constraints.DrivelistDrive);
|
||||
|
||||
expect(result).to.be.true;
|
||||
});
|
||||
@@ -564,7 +563,7 @@ describe('Shared: DriveConstraints', function () {
|
||||
size: 1000000000,
|
||||
isReadOnly: false,
|
||||
disabled: false,
|
||||
} as unknown) as DrivelistDrive);
|
||||
} as unknown) as constraints.DrivelistDrive);
|
||||
|
||||
expect(result).to.be.false;
|
||||
});
|
||||
@@ -574,26 +573,30 @@ describe('Shared: DriveConstraints', function () {
|
||||
device: '/dev/disk1',
|
||||
size: 1000000000,
|
||||
isReadOnly: false,
|
||||
} as DrivelistDrive);
|
||||
} as constraints.DrivelistDrive);
|
||||
|
||||
expect(result).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('.isDriveSizeRecommended()', function () {
|
||||
const image: SourceMetadata = {
|
||||
description: 'rpi.img',
|
||||
displayName: 'rpi.img',
|
||||
path: path.join(__dirname, 'rpi.img'),
|
||||
size: 1000000000,
|
||||
isSizeEstimated: false,
|
||||
recommendedDriveSize: 2000000000,
|
||||
SourceType: sourceDestination.File,
|
||||
};
|
||||
it('should return true if the drive size is greater than the recommended size ', function () {
|
||||
const result = constraints.isDriveSizeRecommended(
|
||||
{
|
||||
device: '/dev/disk1',
|
||||
size: 2000000001,
|
||||
isReadOnly: false,
|
||||
} as DrivelistDrive,
|
||||
{
|
||||
path: path.join(__dirname, 'rpi.img'),
|
||||
size: 1000000000,
|
||||
isSizeEstimated: false,
|
||||
recommendedDriveSize: 2000000000,
|
||||
},
|
||||
} as constraints.DrivelistDrive,
|
||||
image,
|
||||
);
|
||||
|
||||
expect(result).to.be.true;
|
||||
@@ -605,13 +608,8 @@ describe('Shared: DriveConstraints', function () {
|
||||
device: '/dev/disk1',
|
||||
size: 2000000000,
|
||||
isReadOnly: false,
|
||||
} as DrivelistDrive,
|
||||
{
|
||||
path: path.join(__dirname, 'rpi.img'),
|
||||
size: 1000000000,
|
||||
isSizeEstimated: false,
|
||||
recommendedDriveSize: 2000000000,
|
||||
},
|
||||
} as constraints.DrivelistDrive,
|
||||
image,
|
||||
);
|
||||
|
||||
expect(result).to.be.true;
|
||||
@@ -623,11 +621,9 @@ describe('Shared: DriveConstraints', function () {
|
||||
device: '/dev/disk1',
|
||||
size: 2000000000,
|
||||
isReadOnly: false,
|
||||
} as DrivelistDrive,
|
||||
} as constraints.DrivelistDrive,
|
||||
{
|
||||
path: path.join(__dirname, 'rpi.img'),
|
||||
size: 1000000000,
|
||||
isSizeEstimated: false,
|
||||
...image,
|
||||
recommendedDriveSize: 2000000001,
|
||||
},
|
||||
);
|
||||
@@ -641,47 +637,29 @@ describe('Shared: DriveConstraints', function () {
|
||||
device: '/dev/disk1',
|
||||
size: 2000000000,
|
||||
isReadOnly: false,
|
||||
} as DrivelistDrive,
|
||||
} as constraints.DrivelistDrive,
|
||||
{
|
||||
path: path.join(__dirname, 'rpi.img'),
|
||||
size: 1000000000,
|
||||
isSizeEstimated: false,
|
||||
...image,
|
||||
recommendedDriveSize: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).to.be.true;
|
||||
});
|
||||
|
||||
it('should return false if the drive is undefined', function () {
|
||||
const result = constraints.isDriveSizeRecommended(undefined, {
|
||||
path: path.join(__dirname, 'rpi.img'),
|
||||
size: 1000000000,
|
||||
isSizeEstimated: false,
|
||||
recommendedDriveSize: 1000000000,
|
||||
});
|
||||
|
||||
expect(result).to.be.false;
|
||||
});
|
||||
|
||||
it('should return true if the image is undefined', function () {
|
||||
const result = constraints.isDriveSizeRecommended(
|
||||
{
|
||||
device: '/dev/disk1',
|
||||
size: 2000000000,
|
||||
isReadOnly: false,
|
||||
} as DrivelistDrive,
|
||||
} as constraints.DrivelistDrive,
|
||||
// @ts-ignore
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(result).to.be.true;
|
||||
});
|
||||
|
||||
it('should return false if the drive and image are undefined', function () {
|
||||
// @ts-ignore
|
||||
const result = constraints.isDriveSizeRecommended(undefined, undefined);
|
||||
expect(result).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe('.isDriveValid()', function () {
|
||||
@@ -709,6 +687,14 @@ describe('Shared: DriveConstraints', function () {
|
||||
});
|
||||
|
||||
describe('given the drive is disabled', function () {
|
||||
const image: SourceMetadata = {
|
||||
description: 'rpi.img',
|
||||
displayName: 'rpi.img',
|
||||
path: '',
|
||||
SourceType: sourceDestination.File,
|
||||
size: 2000000000,
|
||||
isSizeEstimated: false,
|
||||
};
|
||||
beforeEach(function () {
|
||||
this.drive.disabled = true;
|
||||
});
|
||||
@@ -716,9 +702,9 @@ describe('Shared: DriveConstraints', function () {
|
||||
it('should return false if the drive is not large enough and is a source drive', function () {
|
||||
expect(
|
||||
constraints.isDriveValid(this.drive, {
|
||||
...image,
|
||||
path: path.join(this.mountpoint, 'rpi.img'),
|
||||
size: 5000000000,
|
||||
isSizeEstimated: false,
|
||||
}),
|
||||
).to.be.false;
|
||||
});
|
||||
@@ -726,35 +712,35 @@ describe('Shared: DriveConstraints', function () {
|
||||
it('should return false if the drive is not large enough and is not a source drive', function () {
|
||||
expect(
|
||||
constraints.isDriveValid(this.drive, {
|
||||
...image,
|
||||
path: path.resolve(this.mountpoint, '../bar/rpi.img'),
|
||||
size: 5000000000,
|
||||
isSizeEstimated: false,
|
||||
}),
|
||||
).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false if the drive is large enough and is a source drive', function () {
|
||||
expect(
|
||||
constraints.isDriveValid(this.drive, {
|
||||
path: path.join(this.mountpoint, 'rpi.img'),
|
||||
size: 2000000000,
|
||||
isSizeEstimated: false,
|
||||
}),
|
||||
).to.be.false;
|
||||
expect(constraints.isDriveValid(this.drive, image)).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false if the drive is large enough and is not a source drive', function () {
|
||||
expect(
|
||||
constraints.isDriveValid(this.drive, {
|
||||
...image,
|
||||
path: path.resolve(this.mountpoint, '../bar/rpi.img'),
|
||||
size: 2000000000,
|
||||
isSizeEstimated: false,
|
||||
}),
|
||||
).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('given the drive is not disabled', function () {
|
||||
const image: SourceMetadata = {
|
||||
description: 'rpi.img',
|
||||
displayName: 'rpi.img',
|
||||
path: '',
|
||||
SourceType: sourceDestination.File,
|
||||
size: 2000000000,
|
||||
isSizeEstimated: false,
|
||||
};
|
||||
beforeEach(function () {
|
||||
this.drive.disabled = false;
|
||||
});
|
||||
@@ -762,9 +748,9 @@ describe('Shared: DriveConstraints', function () {
|
||||
it('should return false if the drive is not large enough and is a source drive', function () {
|
||||
expect(
|
||||
constraints.isDriveValid(this.drive, {
|
||||
...image,
|
||||
path: path.join(this.mountpoint, 'rpi.img'),
|
||||
size: 5000000000,
|
||||
isSizeEstimated: false,
|
||||
}),
|
||||
).to.be.false;
|
||||
});
|
||||
@@ -772,29 +758,22 @@ describe('Shared: DriveConstraints', function () {
|
||||
it('should return false if the drive is not large enough and is not a source drive', function () {
|
||||
expect(
|
||||
constraints.isDriveValid(this.drive, {
|
||||
...image,
|
||||
path: path.resolve(this.mountpoint, '../bar/rpi.img'),
|
||||
size: 5000000000,
|
||||
isSizeEstimated: false,
|
||||
}),
|
||||
).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false if the drive is large enough and is a source drive', function () {
|
||||
expect(
|
||||
constraints.isDriveValid(this.drive, {
|
||||
path: path.join(this.mountpoint, 'rpi.img'),
|
||||
size: 2000000000,
|
||||
isSizeEstimated: false,
|
||||
}),
|
||||
).to.be.false;
|
||||
expect(constraints.isDriveValid(this.drive, image)).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false if the drive is large enough and is not a source drive', function () {
|
||||
expect(
|
||||
constraints.isDriveValid(this.drive, {
|
||||
...image,
|
||||
path: path.resolve(this.mountpoint, '../bar/rpi.img'),
|
||||
size: 2000000000,
|
||||
isSizeEstimated: false,
|
||||
}),
|
||||
).to.be.false;
|
||||
});
|
||||
@@ -802,6 +781,14 @@ describe('Shared: DriveConstraints', function () {
|
||||
});
|
||||
|
||||
describe('given the drive is not locked', function () {
|
||||
const image: SourceMetadata = {
|
||||
description: 'rpi.img',
|
||||
displayName: 'rpi.img',
|
||||
path: '',
|
||||
SourceType: sourceDestination.File,
|
||||
size: 2000000000,
|
||||
isSizeEstimated: false,
|
||||
};
|
||||
beforeEach(function () {
|
||||
this.drive.isReadOnly = false;
|
||||
});
|
||||
@@ -814,9 +801,9 @@ describe('Shared: DriveConstraints', function () {
|
||||
it('should return false if the drive is not large enough and is a source drive', function () {
|
||||
expect(
|
||||
constraints.isDriveValid(this.drive, {
|
||||
...image,
|
||||
path: path.join(this.mountpoint, 'rpi.img'),
|
||||
size: 5000000000,
|
||||
isSizeEstimated: false,
|
||||
}),
|
||||
).to.be.false;
|
||||
});
|
||||
@@ -824,29 +811,22 @@ describe('Shared: DriveConstraints', function () {
|
||||
it('should return false if the drive is not large enough and is not a source drive', function () {
|
||||
expect(
|
||||
constraints.isDriveValid(this.drive, {
|
||||
...image,
|
||||
path: path.resolve(this.mountpoint, '../bar/rpi.img'),
|
||||
size: 5000000000,
|
||||
isSizeEstimated: false,
|
||||
}),
|
||||
).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false if the drive is large enough and is a source drive', function () {
|
||||
expect(
|
||||
constraints.isDriveValid(this.drive, {
|
||||
path: path.join(this.mountpoint, 'rpi.img'),
|
||||
size: 2000000000,
|
||||
isSizeEstimated: false,
|
||||
}),
|
||||
).to.be.false;
|
||||
expect(constraints.isDriveValid(this.drive, image)).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false if the drive is large enough and is not a source drive', function () {
|
||||
expect(
|
||||
constraints.isDriveValid(this.drive, {
|
||||
...image,
|
||||
path: path.resolve(this.mountpoint, '../bar/rpi.img'),
|
||||
size: 2000000000,
|
||||
isSizeEstimated: false,
|
||||
}),
|
||||
).to.be.false;
|
||||
});
|
||||
@@ -860,9 +840,9 @@ describe('Shared: DriveConstraints', function () {
|
||||
it('should return false if the drive is not large enough and is a source drive', function () {
|
||||
expect(
|
||||
constraints.isDriveValid(this.drive, {
|
||||
...image,
|
||||
path: path.join(this.mountpoint, 'rpi.img'),
|
||||
size: 5000000000,
|
||||
isSizeEstimated: false,
|
||||
}),
|
||||
).to.be.false;
|
||||
});
|
||||
@@ -870,9 +850,9 @@ describe('Shared: DriveConstraints', function () {
|
||||
it('should return false if the drive is not large enough and is not a source drive', function () {
|
||||
expect(
|
||||
constraints.isDriveValid(this.drive, {
|
||||
...image,
|
||||
path: path.resolve(this.mountpoint, '../bar/rpi.img'),
|
||||
size: 5000000000,
|
||||
isSizeEstimated: false,
|
||||
}),
|
||||
).to.be.false;
|
||||
});
|
||||
@@ -880,9 +860,8 @@ describe('Shared: DriveConstraints', function () {
|
||||
it('should return false if the drive is large enough and is a source drive', function () {
|
||||
expect(
|
||||
constraints.isDriveValid(this.drive, {
|
||||
...image,
|
||||
path: path.join(this.mountpoint, 'rpi.img'),
|
||||
size: 2000000000,
|
||||
isSizeEstimated: false,
|
||||
}),
|
||||
).to.be.false;
|
||||
});
|
||||
@@ -890,9 +869,8 @@ describe('Shared: DriveConstraints', function () {
|
||||
it('should return true if the drive is large enough and is not a source drive', function () {
|
||||
expect(
|
||||
constraints.isDriveValid(this.drive, {
|
||||
...image,
|
||||
path: path.resolve(this.mountpoint, '../bar/rpi.img'),
|
||||
size: 2000000000,
|
||||
isSizeEstimated: false,
|
||||
}),
|
||||
).to.be.true;
|
||||
});
|
||||
@@ -916,6 +894,7 @@ describe('Shared: DriveConstraints', function () {
|
||||
};
|
||||
|
||||
this.image = {
|
||||
SourceType: sourceDestination.File,
|
||||
path: path.join(__dirname, 'rpi.img'),
|
||||
size: this.drive.size - 1,
|
||||
isSizeEstimated: false,
|
||||
@@ -960,28 +939,41 @@ describe('Shared: DriveConstraints', function () {
|
||||
};
|
||||
|
||||
this.image = {
|
||||
SourceType: sourceDestination.File,
|
||||
path: path.join(__dirname, 'rpi.img'),
|
||||
size: this.drive.size - 1,
|
||||
isSizeEstimated: false,
|
||||
};
|
||||
});
|
||||
|
||||
const compareTuplesMessages = (
|
||||
tuple1: { message: string },
|
||||
tuple2: { message: string },
|
||||
) => {
|
||||
if (tuple1.message.toLowerCase() === tuple2.message.toLowerCase()) {
|
||||
return 0;
|
||||
}
|
||||
return tuple1.message.toLowerCase() > tuple2.message.toLowerCase()
|
||||
? 1
|
||||
: -1;
|
||||
};
|
||||
|
||||
const expectStatusTypesAndMessagesToBe = (
|
||||
resultList: Array<{ message: string }>,
|
||||
expectedTuples: Array<['WARNING' | 'ERROR', string]>,
|
||||
params?: number,
|
||||
) => {
|
||||
// Sort so that order doesn't matter
|
||||
const expectedTuplesSorted = _.sortBy(
|
||||
_.map(expectedTuples, (tuple) => {
|
||||
const expectedTuplesSorted = expectedTuples
|
||||
.map((tuple) => {
|
||||
return {
|
||||
type: constraints.COMPATIBILITY_STATUS_TYPES[tuple[0]],
|
||||
// @ts-ignore
|
||||
message: messages.compatibility[tuple[1]](),
|
||||
message: messages.compatibility[tuple[1]](params),
|
||||
};
|
||||
}),
|
||||
['message'],
|
||||
);
|
||||
const resultTuplesSorted = _.sortBy(resultList, ['message']);
|
||||
})
|
||||
.sort(compareTuplesMessages);
|
||||
const resultTuplesSorted = resultList.sort(compareTuplesMessages);
|
||||
|
||||
expect(resultTuplesSorted).to.deep.equal(expectedTuplesSorted);
|
||||
};
|
||||
@@ -1051,7 +1043,7 @@ describe('Shared: DriveConstraints', function () {
|
||||
);
|
||||
const expected = [
|
||||
{
|
||||
message: messages.compatibility.tooSmall('1 B'),
|
||||
message: messages.compatibility.tooSmall(),
|
||||
type: constraints.COMPATIBILITY_STATUS_TYPES.ERROR,
|
||||
},
|
||||
];
|
||||
@@ -1117,11 +1109,14 @@ describe('Shared: DriveConstraints', function () {
|
||||
this.drive,
|
||||
this.image,
|
||||
);
|
||||
// @ts-ignore
|
||||
const expectedTuples = [['WARNING', 'largeDrive']];
|
||||
|
||||
// @ts-ignore
|
||||
expectStatusTypesAndMessagesToBe(result, expectedTuples);
|
||||
expectStatusTypesAndMessagesToBe(
|
||||
result,
|
||||
// @ts-ignore
|
||||
expectedTuples,
|
||||
this.drive.size,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1169,7 +1164,7 @@ describe('Shared: DriveConstraints', function () {
|
||||
);
|
||||
const expected = [
|
||||
{
|
||||
message: messages.compatibility.tooSmall('1 B'),
|
||||
message: messages.compatibility.tooSmall(),
|
||||
type: constraints.COMPATIBILITY_STATUS_TYPES.ERROR,
|
||||
},
|
||||
];
|
||||
@@ -1220,7 +1215,7 @@ describe('Shared: DriveConstraints', function () {
|
||||
mountpoints: [{ path: __dirname }],
|
||||
isSystem: false,
|
||||
isReadOnly: false,
|
||||
} as unknown) as DrivelistDrive,
|
||||
} as unknown) as constraints.DrivelistDrive,
|
||||
({
|
||||
device: drivePaths[1],
|
||||
description: 'My Other Drive',
|
||||
@@ -1229,7 +1224,7 @@ describe('Shared: DriveConstraints', function () {
|
||||
mountpoints: [],
|
||||
isSystem: false,
|
||||
isReadOnly: true,
|
||||
} as unknown) as DrivelistDrive,
|
||||
} as unknown) as constraints.DrivelistDrive,
|
||||
({
|
||||
device: drivePaths[2],
|
||||
description: 'My Drive',
|
||||
@@ -1238,7 +1233,7 @@ describe('Shared: DriveConstraints', function () {
|
||||
mountpoints: [],
|
||||
isSystem: false,
|
||||
isReadOnly: false,
|
||||
} as unknown) as DrivelistDrive,
|
||||
} as unknown) as constraints.DrivelistDrive,
|
||||
({
|
||||
device: drivePaths[3],
|
||||
description: 'My Drive',
|
||||
@@ -1247,16 +1242,16 @@ describe('Shared: DriveConstraints', function () {
|
||||
mountpoints: [],
|
||||
isSystem: true,
|
||||
isReadOnly: false,
|
||||
} as unknown) as DrivelistDrive,
|
||||
} as unknown) as constraints.DrivelistDrive,
|
||||
({
|
||||
device: drivePaths[4],
|
||||
description: 'My Drive',
|
||||
size: 64000000001,
|
||||
size: 128000000001,
|
||||
displayName: drivePaths[4],
|
||||
mountpoints: [],
|
||||
isSystem: false,
|
||||
isReadOnly: false,
|
||||
} as unknown) as DrivelistDrive,
|
||||
} as unknown) as constraints.DrivelistDrive,
|
||||
({
|
||||
device: drivePaths[5],
|
||||
description: 'My Drive',
|
||||
@@ -1265,7 +1260,7 @@ describe('Shared: DriveConstraints', function () {
|
||||
mountpoints: [],
|
||||
isSystem: false,
|
||||
isReadOnly: false,
|
||||
} as unknown) as DrivelistDrive,
|
||||
} as unknown) as constraints.DrivelistDrive,
|
||||
({
|
||||
device: drivePaths[6],
|
||||
description: 'My Drive',
|
||||
@@ -1274,11 +1269,14 @@ describe('Shared: DriveConstraints', function () {
|
||||
mountpoints: [],
|
||||
isSystem: false,
|
||||
isReadOnly: false,
|
||||
} as unknown) as DrivelistDrive,
|
||||
} as unknown) as constraints.DrivelistDrive,
|
||||
];
|
||||
|
||||
const image = {
|
||||
const image: SourceMetadata = {
|
||||
description: 'rpi.img',
|
||||
displayName: 'rpi.img',
|
||||
path: path.join(__dirname, 'rpi.img'),
|
||||
SourceType: sourceDestination.File,
|
||||
// @ts-ignore
|
||||
size: drives[2].size + 1,
|
||||
isSizeEstimated: false,
|
||||
@@ -1331,7 +1329,7 @@ describe('Shared: DriveConstraints', function () {
|
||||
),
|
||||
).to.deep.equal([
|
||||
{
|
||||
message: 'Insufficient space, additional 1 B required',
|
||||
message: 'Too small',
|
||||
type: 2,
|
||||
},
|
||||
]);
|
||||
@@ -1373,7 +1371,7 @@ describe('Shared: DriveConstraints', function () {
|
||||
),
|
||||
).to.deep.equal([
|
||||
{
|
||||
message: 'Not Recommended',
|
||||
message: 'Not recommended',
|
||||
type: 1,
|
||||
},
|
||||
]);
|
||||
@@ -1394,7 +1392,7 @@ describe('Shared: DriveConstraints', function () {
|
||||
type: 2,
|
||||
},
|
||||
{
|
||||
message: 'Insufficient space, additional 1 B required',
|
||||
message: 'Too small',
|
||||
type: 2,
|
||||
},
|
||||
{
|
||||
@@ -1406,157 +1404,11 @@ describe('Shared: DriveConstraints', function () {
|
||||
type: 1,
|
||||
},
|
||||
{
|
||||
message: 'Not Recommended',
|
||||
message: 'Not recommended',
|
||||
type: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('.hasListDriveImageCompatibilityStatus()', function () {
|
||||
const drivePaths =
|
||||
process.platform === 'win32'
|
||||
? ['E:\\', 'F:\\', 'G:\\', 'H:\\', 'J:\\', 'K:\\']
|
||||
: [
|
||||
'/dev/disk1',
|
||||
'/dev/disk2',
|
||||
'/dev/disk3',
|
||||
'/dev/disk4',
|
||||
'/dev/disk5',
|
||||
'/dev/disk6',
|
||||
];
|
||||
const drives = [
|
||||
({
|
||||
device: drivePaths[0],
|
||||
description: 'My Drive',
|
||||
size: 123456789,
|
||||
displayName: drivePaths[0],
|
||||
mountpoints: [{ path: __dirname }],
|
||||
isSystem: false,
|
||||
isReadOnly: false,
|
||||
} as unknown) as DrivelistDrive,
|
||||
({
|
||||
device: drivePaths[1],
|
||||
description: 'My Other Drive',
|
||||
size: 123456789,
|
||||
displayName: drivePaths[1],
|
||||
mountpoints: [],
|
||||
isSystem: false,
|
||||
isReadOnly: true,
|
||||
} as unknown) as DrivelistDrive,
|
||||
({
|
||||
device: drivePaths[2],
|
||||
description: 'My Drive',
|
||||
size: 1234567,
|
||||
displayName: drivePaths[2],
|
||||
mountpoints: [],
|
||||
isSystem: false,
|
||||
isReadOnly: false,
|
||||
} as unknown) as DrivelistDrive,
|
||||
({
|
||||
device: drivePaths[3],
|
||||
description: 'My Drive',
|
||||
size: 123456789,
|
||||
displayName: drivePaths[3],
|
||||
mountpoints: [],
|
||||
isSystem: true,
|
||||
isReadOnly: false,
|
||||
} as unknown) as DrivelistDrive,
|
||||
({
|
||||
device: drivePaths[4],
|
||||
description: 'My Drive',
|
||||
size: 64000000001,
|
||||
displayName: drivePaths[4],
|
||||
mountpoints: [],
|
||||
isSystem: false,
|
||||
isReadOnly: false,
|
||||
} as unknown) as DrivelistDrive,
|
||||
({
|
||||
device: drivePaths[5],
|
||||
description: 'My Drive',
|
||||
size: 12345678,
|
||||
displayName: drivePaths[5],
|
||||
mountpoints: [],
|
||||
isSystem: false,
|
||||
isReadOnly: false,
|
||||
} as unknown) as DrivelistDrive,
|
||||
({
|
||||
device: drivePaths[6],
|
||||
description: 'My Drive',
|
||||
size: 123456789,
|
||||
displayName: drivePaths[6],
|
||||
mountpoints: [],
|
||||
isSystem: false,
|
||||
isReadOnly: false,
|
||||
} as unknown) as DrivelistDrive,
|
||||
];
|
||||
|
||||
const image = {
|
||||
path: path.join(__dirname, 'rpi.img'),
|
||||
// @ts-ignore
|
||||
size: drives[2].size + 1,
|
||||
isSizeEstimated: false,
|
||||
// @ts-ignore
|
||||
recommendedDriveSize: drives[5].size + 1,
|
||||
};
|
||||
|
||||
describe('given no drives', function () {
|
||||
it('should return false', function () {
|
||||
expect(constraints.hasListDriveImageCompatibilityStatus([], image)).to
|
||||
.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('given one drive', function () {
|
||||
it('should return true given a drive that contains the image', function () {
|
||||
expect(
|
||||
constraints.hasListDriveImageCompatibilityStatus([drives[0]], image),
|
||||
).to.be.true;
|
||||
});
|
||||
|
||||
it('should return true given a drive that is locked', function () {
|
||||
expect(
|
||||
constraints.hasListDriveImageCompatibilityStatus([drives[1]], image),
|
||||
).to.be.true;
|
||||
});
|
||||
|
||||
it('should return true given a drive that is too small for the image', function () {
|
||||
expect(
|
||||
constraints.hasListDriveImageCompatibilityStatus([drives[2]], image),
|
||||
).to.be.true;
|
||||
});
|
||||
|
||||
it('should return true given a drive that is a system drive', function () {
|
||||
expect(
|
||||
constraints.hasListDriveImageCompatibilityStatus([drives[3]], image),
|
||||
).to.be.true;
|
||||
});
|
||||
|
||||
it('should return true given a drive that is large', function () {
|
||||
expect(
|
||||
constraints.hasListDriveImageCompatibilityStatus([drives[4]], image),
|
||||
).to.be.true;
|
||||
});
|
||||
|
||||
it('should return true given a drive that is not recommended', function () {
|
||||
expect(
|
||||
constraints.hasListDriveImageCompatibilityStatus([drives[5]], image),
|
||||
).to.be.true;
|
||||
});
|
||||
|
||||
it('should return false given a drive with no warnings or errors', function () {
|
||||
expect(
|
||||
constraints.hasListDriveImageCompatibilityStatus([drives[6]], image),
|
||||
).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('given many drives', function () {
|
||||
it('should return true given some drives with errors or warnings', function () {
|
||||
expect(constraints.hasListDriveImageCompatibilityStatus(drives, image))
|
||||
.to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -4,6 +4,8 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"resolveJsonModule": true,
|
||||
"target": "es2019",
|
||||
"moduleResolution": "node",
|
||||
"jsx": "react",
|
||||
"typeRoots": ["./node_modules/@types", "./typings"]
|
||||
}
|
||||
|
@@ -129,6 +129,10 @@ const commonConfig = {
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: 'css-loader',
|
||||
},
|
||||
{
|
||||
test: /\.svg$/,
|
||||
use: '@svgr/webpack',
|
||||
|
Reference in New Issue
Block a user