Add clone-drive workflow

Change-type: patch
Changelog-entry: Add clone-drive workflow
Signed-off-by: Lorenzo Alberto Maria Ambrosi <lorenzothunder.ambrosi@gmail.com>
This commit is contained in:
Lorenzo Alberto Maria Ambrosi 2020-06-22 17:16:41 +02:00
parent 377dfb8e22
commit dda022df37
15 changed files with 389 additions and 332 deletions

View File

@ -167,7 +167,7 @@ export class DriveSelector extends React.Component<
DriveSelectorState
> {
private unsubscribe: (() => void) | undefined;
multipleSelection: boolean;
multipleSelection: boolean = true;
tableColumns: Array<TableColumn<Drive>>;
constructor(props: DriveSelectorProps) {
@ -175,7 +175,11 @@ export class DriveSelector extends React.Component<
const defaultMissingDriversModalState: { drive?: DriverlessDrive } = {};
const selectedList = getSelectedDrives();
this.multipleSelection = !!this.props.multipleSelection;
const multipleSelection = this.props.multipleSelection;
this.multipleSelection =
multipleSelection !== undefined
? !!multipleSelection
: this.multipleSelection;
this.state = {
drives: getDrives(),
@ -383,10 +387,7 @@ export class DriveSelector extends React.Component<
<b>{this.props.emptyListLabel}</b>
</Flex>
) : (
<ScrollableFlex
flexDirection="column"
width="100%"
>
<ScrollableFlex flexDirection="column" width="100%">
<DrivesTable
refFn={(t: Table<Drive>) => {
if (t !== null) {

View File

@ -14,10 +14,11 @@
* 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';
import { sourceDestination } from 'etcher-sdk';
import { sourceDestination, scanner } from 'etcher-sdk';
import { ipcRenderer, IpcRendererEvent } from 'electron';
import * as _ from 'lodash';
import { GPTPartition, MBRPartition } from 'partitioninfo';
@ -57,6 +58,7 @@ import { middleEllipsis } from '../../utils/middle-ellipsis';
import { SVGIcon } from '../svg-icon/svg-icon';
import ImageSvg from '../../../assets/image.svg';
import { DriveSelector } from '../drive-selector/drive-selector';
const recentUrlImagesKey = 'recentUrlImages';
@ -92,6 +94,9 @@ function setRecentUrlImages(urls: URL[]) {
localStorage.setItem(recentUrlImagesKey, JSON.stringify(normalized));
}
const isURL = (imagePath: string) =>
imagePath.startsWith('https://') || imagePath.startsWith('http://');
const Card = styled(BaseCard)`
hr {
margin: 5px 0;
@ -117,6 +122,10 @@ function getState() {
};
}
function isString(value: any): value is string {
return typeof value === 'string';
}
const URLSelector = ({
done,
cancel,
@ -203,7 +212,12 @@ interface Flow {
const FlowSelector = styled(
({ flow, ...props }: { flow: Flow; props?: ButtonProps }) => {
return (
<StepButton plain onClick={flow.onClick} icon={flow.icon} {...props}>
<StepButton
plain
onClick={(evt) => flow.onClick(evt)}
icon={flow.icon}
{...props}
>
{flow.label}
</StepButton>
);
@ -225,16 +239,20 @@ 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;
SourceType: Source;
drive?: scanner.adapters.DrivelistDrive;
extension?: string;
}
interface SourceSelectorProps {
flashing: boolean;
afterSelected: (options: SourceOptions) => void;
}
interface SourceSelectorState {
@ -244,6 +262,7 @@ interface SourceSelectorState {
warning: { message: string; title: string | null } | null;
showImageDetails: boolean;
showURLSelector: boolean;
showDriveSelector: boolean;
}
export class SourceSelector extends React.Component<
@ -251,7 +270,6 @@ export class SourceSelector extends React.Component<
SourceSelectorState
> {
private unsubscribe: (() => void) | undefined;
private afterSelected: SourceSelectorProps['afterSelected'];
constructor(props: SourceSelectorProps) {
super(props);
@ -260,15 +278,8 @@ export class SourceSelector extends React.Component<
warning: null,
showImageDetails: false,
showURLSelector: false,
showDriveSelector: false,
};
this.openImageSelector = this.openImageSelector.bind(this);
this.openURLSelector = this.openURLSelector.bind(this);
this.reselectImage = this.reselectImage.bind(this);
this.onSelectImage = this.onSelectImage.bind(this);
this.onDrop = this.onDrop.bind(this);
this.showSelectedImageDetails = this.showSelectedImageDetails.bind(this);
this.afterSelected = props.afterSelected.bind(this);
}
public componentDidMount() {
@ -285,15 +296,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,
}).promise;
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(),
});
@ -301,144 +325,134 @@ 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;
if (supportedFormats.looksLikeWindowsImage(image.path)) {
analytics.logEvent('Possibly Windows image', { image });
message = messages.warning.looksLikeWindowsImage();
title = 'Possible Windows image detected';
} else if (!image.hasMBR) {
analytics.logEvent('Missing partition table', { image });
title = 'Missing partition table';
message = messages.warning.missingPartitionTable();
}
if (message) {
this.setState({
warning: {
message,
title,
},
});
}
selectionState.selectImage(image);
analytics.logEvent('Select image', {
// An easy way so we can quickly identify if we're making use of
// certain features without printing pages of text to DevTools.
image: {
...image,
logo: Boolean(image.logo),
blockMap: Boolean(image.blockMap),
},
});
} catch (error) {
exceptionReporter.report(error);
}
}
private selectImageByPath({
imagePath,
SourceType,
}: SourceOptions): { promise: Promise<void>; cancel: () => void } {
private selectSource(
selected: string | scanner.adapters.DrivelistDrive,
SourceType: Source,
): { promise: Promise<void>; cancel: () => void } {
let cancelled = false;
return {
cancel: () => {
cancelled = true;
},
promise: (async () => {
try {
imagePath = await replaceWindowsNetworkDriveLetter(imagePath);
} catch (error) {
analytics.logException(error);
}
if (cancelled) {
return;
}
let source;
if (SourceType === sourceDestination.File) {
source = new sourceDestination.File({
path: imagePath,
});
} 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 });
}
try {
const innerSource = await source.getInnerSource();
const sourcePath = isString(selected) ? selected : selected.device;
let metadata: SourceMetadata | undefined;
if (isString(selected)) {
const source = await this.createSource(selected, SourceType);
if (cancelled) {
return;
}
const metadata = (await innerSource.getMetadata()) as sourceDestination.Metadata & {
hasMBR: boolean;
partitions: MBRPartition[] | GPTPartition[];
path: string;
extension: string;
};
if (cancelled) {
return;
}
const partitionTable = await innerSource.getPartitionTable();
if (cancelled) {
return;
}
if (partitionTable) {
metadata.hasMBR = true;
metadata.partitions = partitionTable.partitions;
} else {
metadata.hasMBR = false;
}
metadata.path = imagePath;
metadata.extension = path.extname(imagePath).slice(1);
this.selectImage(metadata);
this.afterSelected({
imagePath,
SourceType,
});
} catch (error) {
const imageError = errors.createUserError({
title: 'Error opening image',
description: messages.error.openImage(
path.basename(imagePath),
error.message,
),
});
osDialog.showError(imageError);
analytics.logException(error);
} finally {
try {
await source.close();
const innerSource = await source.getInnerSource();
if (cancelled) {
return;
}
metadata = await this.getMetadata(innerSource);
if (cancelled) {
return;
}
if (SourceType === sourceDestination.Http && !isURL(selected)) {
this.handleError(
'Unsupported protocol',
selected,
messages.error.unsupportedProtocol(),
);
return;
}
if (supportedFormats.looksLikeWindowsImage(selected)) {
analytics.logEvent('Possibly Windows image', { image: selected });
this.setState({
warning: {
message: messages.warning.looksLikeWindowsImage(),
title: 'Possible Windows image detected',
},
});
}
metadata.extension = path.extname(selected).slice(1);
metadata.path = selected;
if (!metadata.hasMBR) {
analytics.logEvent('Missing partition table', { metadata });
this.setState({
warning: {
message: messages.warning.missingPartitionTable(),
title: 'Missing partition table',
},
});
}
} catch (error) {
// Noop
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,
size: selected.size as SourceMetadata['size'],
hasMBR: false,
partitions: [],
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 handleError(
title: string,
sourcePath: string,
description: string,
error?: any,
) {
const imageError = errors.createUserError({
title,
description,
});
osDialog.showError(imageError);
if (error) {
analytics.logException(error);
return;
}
analytics.logEvent(title, { path: sourcePath });
}
private async getMetadata(
source: sourceDestination.SourceDestination | sourceDestination.BlockDevice,
) {
const metadata = (await source.getMetadata()) as SourceMetadata;
const partitionTable = await source.getPartitionTable();
if (partitionTable) {
metadata.hasMBR = true;
metadata.partitions = partitionTable.partitions;
} else {
metadata.hasMBR = false;
}
return metadata;
}
private async openImageSelector() {
analytics.logEvent('Open image selector');
@ -450,10 +464,7 @@ export class SourceSelector extends React.Component<
analytics.logEvent('Image selector closed');
return;
}
await this.selectImageByPath({
imagePath,
SourceType: sourceDestination.File,
}).promise;
await this.selectSource(imagePath, sourceDestination.File).promise;
} catch (error) {
exceptionReporter.report(error);
}
@ -462,10 +473,7 @@ export class SourceSelector extends React.Component<
private async onDrop(event: React.DragEvent<HTMLDivElement>) {
const [file] = event.dataTransfer.files;
if (file) {
await this.selectImageByPath({
imagePath: file.path,
SourceType: sourceDestination.File,
}).promise;
await this.selectSource(file.path, sourceDestination.File).promise;
}
}
@ -477,6 +485,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();
@ -500,27 +516,35 @@ export class SourceSelector extends React.Component<
// 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 hasImage = selectionState.hasImage();
const hasSource = selectionState.hasImage();
let image = hasSource ? selectionState.getImage() : {};
image = image.drive ? 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 || '';
const imageBasename = path.basename(image.path || '');
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}
@ -530,17 +554,21 @@ export class SourceSelector extends React.Component<
}}
/>
{hasImage ? (
{hasSource ? (
<>
<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>
)}
@ -551,7 +579,7 @@ export class SourceSelector extends React.Component<
<FlowSelector
key="Flash from file"
flow={{
onClick: this.openImageSelector,
onClick: () => this.openImageSelector(),
label: 'Flash from file',
icon: <FileSvg height="1em" fill="currentColor" />,
}}
@ -559,11 +587,19 @@ export class SourceSelector extends React.Component<
<FlowSelector
key="Flash from URL"
flow={{
onClick: this.openURLSelector,
onClick: () => this.openURLSelector(),
label: 'Flash from URL',
icon: <LinkSvg height="1em" fill="currentColor" />,
}}
/>
<FlowSelector
key="Clone drive"
flow={{
onClick: () => this.openDriveSelector(),
label: 'Clone drive',
icon: <CopySvg height="1em" fill="currentColor" />,
}}
/>
</>
)}
</Flex>
@ -579,7 +615,7 @@ export class SourceSelector extends React.Component<
action="Continue"
cancel={() => {
this.setState({ warning: null });
this.reselectImage();
this.reselectSource();
}}
done={() => {
this.setState({ warning: null });
@ -625,13 +661,10 @@ export class SourceSelector extends React.Component<
analytics.logEvent('URL selector closed');
} else {
let promise;
({
promise,
cancel: cancelURLSelection,
} = this.selectImageByPath({
imagePath: imageURL,
SourceType: sourceDestination.Http,
}));
({ promise, cancel: cancelURLSelection } = this.selectSource(
imageURL,
sourceDestination.Http,
));
await promise;
}
this.setState({
@ -640,6 +673,32 @@ export class SourceSelector extends React.Component<
}}
/>
)}
{showDriveSelector && (
<DriveSelector
multipleSelection={false}
titleLabel="Select source"
emptyListLabel="Plug a source"
cancel={() => {
this.setState({
showDriveSelector: false,
});
}}
done={async (drives: scanner.adapters.DrivelistDrive[]) => {
if (!drives.length) {
analytics.logEvent('Drive selector closed');
this.setState({
showDriveSelector: false,
});
return;
}
await this.selectSource(drives[0], sourceDestination.BlockDevice);
this.setState({
showDriveSelector: false,
});
}}
/>
)}
</>
);
}

View File

@ -24,7 +24,7 @@ export function hasAvailableDrives() {
export function setDrives(drives: any[]) {
store.dispatch({
type: Actions.SET_AVAILABLE_DRIVES,
type: Actions.SET_AVAILABLE_TARGETS,
data: drives,
});
}

View File

@ -24,7 +24,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 +40,10 @@ export function toggleDrive(driveDevice: string) {
}
}
export function selectImage(image: any) {
export function selectSource(source: any) {
store.dispatch({
type: Actions.SELECT_IMAGE,
data: image,
type: Actions.SELECT_SOURCE,
data: source,
});
}
@ -122,14 +122,14 @@ 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: {},
});
}

View File

@ -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,
});
},
@ -341,7 +341,7 @@ function storeReducer(
.set('flashState', DEFAULT_STATE.get('flashState'));
}
case Actions.SELECT_DRIVE: {
case Actions.SELECT_TARGET: {
// Type: action.data : String
const device = action.data;
@ -391,10 +391,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 +458,7 @@ function storeReducer(
!constraints.isDriveSizeRecommended(drive, action.data)
) {
return storeReducer(accState, {
type: Actions.DESELECT_DRIVE,
type: Actions.DESELECT_TARGET,
data: device,
});
}
@ -467,7 +469,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 +493,7 @@ function storeReducer(
);
}
case Actions.DESELECT_IMAGE: {
case Actions.DESELECT_SOURCE: {
return state.deleteIn(['selection', 'image']);
}

View File

@ -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';
@ -134,15 +134,11 @@ interface FlashResults {
cancelled?: boolean;
}
/**
* @summary Perform write operation
*/
async function performWrite(
image: string,
export async function performWrite(
image: SourceMetadata,
drives: DrivelistDrive[],
onProgress: sdk.multiWrite.OnProgressFunction,
source: SourceOptions,
): Promise<FlashResults> {
): Promise<{ cancelled?: boolean }> {
let cancelled = false;
ipc.serve();
const {
@ -196,10 +192,9 @@ async function performWrite(
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,
@ -258,9 +253,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,12 +281,7 @@ 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 });

View File

@ -23,7 +23,6 @@ import { Flex, Modal, 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 * as availableDrives from '../../models/available-drives';
import * as flashState from '../../models/flash-state';
import * as selection from '../../models/selection-state';
@ -79,7 +78,6 @@ const getErrorMessageFromCode = (errorCode: string) => {
async function flashImageToDrive(
isFlashing: boolean,
goToSuccess: () => void,
sourceOptions: SourceOptions,
): Promise<string> {
const devices = selection.getSelectedDevices();
const image: any = selection.getImage();
@ -98,7 +96,7 @@ 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();
notification.send(
@ -146,7 +144,6 @@ const formatSeconds = (totalSeconds: number) => {
interface FlashStepProps {
shouldFlashStepBeDisabled: boolean;
goToSuccess: () => void;
source: SourceOptions;
isFlashing: boolean;
style?: React.CSSProperties;
// TODO: factorize
@ -187,7 +184,6 @@ export class FlashStep extends React.PureComponent<
errorMessage: await flashImageToDrive(
this.props.isFlashing,
this.props.goToSuccess,
this.props.source,
),
});
}
@ -230,7 +226,6 @@ export class FlashStep extends React.PureComponent<
errorMessage: await flashImageToDrive(
this.props.isFlashing,
this.props.goToSuccess,
this.props.source,
),
});
}

View File

@ -17,7 +17,6 @@
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 React from 'react';
@ -28,10 +27,7 @@ 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,
SourceSelector,
} from '../../components/source-selector/source-selector';
import { SourceSelector } 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';
@ -75,9 +71,12 @@ function getImageBasename() {
return '';
}
const selectionImageName = selectionState.getImageName();
const imageBasename = path.basename(selectionState.getImagePath());
return selectionImageName || imageBasename;
const image = selectionState.getImage();
if (image.drive) {
return image.drive.description;
}
const imageBasename = path.basename(image.path);
return image.name || imageBasename;
}
const StepBorder = styled.div<{
@ -115,7 +114,6 @@ interface MainPageState {
current: 'main' | 'success';
isWebviewShowing: boolean;
hideSettings: boolean;
source: SourceOptions;
featuredProjectURL?: string;
}
@ -129,10 +127,6 @@ export class MainPage extends React.Component<
current: 'main',
isWebviewShowing: false,
hideSettings: true,
source: {
imagePath: '',
SourceType: sourceDestination.File,
},
...this.stateHelper(),
};
}
@ -246,12 +240,7 @@ export class MainPage extends React.Component<
>
{notFlashingOrSplitView && (
<>
<SourceSelector
flashing={this.state.isFlashing}
afterSelected={(source: SourceOptions) =>
this.setState({ source })
}
/>
<SourceSelector flashing={this.state.isFlashing} />
<Flex>
<StepBorder disabled={shouldDriveStepBeDisabled} left />
</Flex>
@ -316,7 +305,6 @@ export class MainPage extends React.Component<
<FlashStep
goToSuccess={() => this.setState({ current: 'success' })}
shouldFlashStepBeDisabled={shouldFlashStepBeDisabled}
source={this.state.source}
isFlashing={this.state.isFlashing}
step={state.type}
percentage={state.percentage}

View File

@ -20,10 +20,11 @@ import { cleanupTmpFiles } from 'etcher-sdk/build/tmp';
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;
@ -143,13 +144,12 @@ async function writeAndValidate({
}
interface WriteOptions {
imagePath: string;
image: SourceMetadata;
destinations: DrivelistDrive[];
unmountOnSuccess: boolean;
validateWriteOnSuccess: boolean;
autoBlockmapping: boolean;
decompressFirst: boolean;
source: SourceOptions;
SourceType: string;
}
@ -228,7 +228,7 @@ ipc.connectTo(IPC_SERVER_ID, () => {
};
const destinations = options.destinations.map((d) => d.device);
log(`Image: ${options.imagePath}`);
const imagePath = options.image.path;
log(`Devices: ${destinations.join(', ')}`);
log(`Umount on success: ${options.unmountOnSuccess}`);
log(`Validate on success: ${options.validateWriteOnSuccess}`);
@ -243,18 +243,23 @@ 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,
write: false,
direct: !options.autoBlockmapping,
});
} else {
if (SourceType === File.name) {
source = new File({
path: imagePath,
});
} else {
source = new Http({ url: imagePath, avoidRandomAccess: true });
}
}
const results = await writeAndValidate({
source,
destinations: dests,

View File

@ -20,6 +20,7 @@ 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
@ -44,13 +45,22 @@ export function isSystemDrive(drive: DrivelistDrive): boolean {
}
export interface Image {
path?: string;
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;
}
/**
* @summary Check if a drive is source drive
*
@ -60,11 +70,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;
@ -112,7 +127,7 @@ export function isDriveValid(drive: DrivelistDrive, image: Image): boolean {
return (
!isDriveLocked(drive) &&
isDriveLargeEnough(drive, image) &&
!isSourceDrive(drive, image) &&
!isSourceDrive(drive, image as SourceMetadata) &&
!isDriveDisabled(drive)
);
}
@ -167,7 +182,7 @@ export const COMPATIBILITY_STATUS_TYPES = {
*/
export function getDriveImageCompatibilityStatuses(
drive: DrivelistDrive,
image: Image = {},
image: Image,
) {
const statusList = [];
@ -191,7 +206,7 @@ export function getDriveImageCompatibilityStatuses(
message: messages.compatibility.tooSmall(prettyBytes(relativeBytes)),
});
} else {
if (isSourceDrive(drive, image)) {
if (isSourceDrive(drive, image as SourceMetadata)) {
statusList.push({
type: COMPATIBILITY_STATUS_TYPES.ERROR,
message: messages.compatibility.containsImage(),

View File

@ -143,9 +143,9 @@ export const error = {
].join(' ');
},
openImage: (imageBasename: string, errorMessage: string) => {
openSource: (sourceName: string, errorMessage: string) => {
return [
`Something went wrong while opening ${imageBasename}\n\n`,
`Something went wrong while opening ${sourceName}\n\n`,
`Error: ${errorMessage}`,
].join('');
},

View File

@ -157,7 +157,7 @@ describe('Model: availableDrives', function () {
}
selectionState.clear();
selectionState.selectImage({
selectionState.selectSource({
path: this.imagePath,
extension: 'img',
size: 999999999,

View File

@ -359,7 +359,7 @@ describe('Model: selectionState', function () {
logo: '<svg><text fill="red">Raspbian</text></svg>',
};
selectionState.selectImage(this.image);
selectionState.selectSource(this.image);
});
describe('.selectDrive()', function () {
@ -445,7 +445,7 @@ describe('Model: selectionState', function () {
describe('.selectImage()', function () {
it('should override the image', function () {
selectionState.selectImage({
selectionState.selectSource({
path: 'bar.img',
extension: 'img',
size: 999999999,
@ -476,7 +476,7 @@ describe('Model: selectionState', function () {
afterEach(selectionState.clear);
it('should be able to set an image', function () {
selectionState.selectImage({
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
@ -490,7 +490,7 @@ describe('Model: selectionState', function () {
});
it('should be able to set an image with an archive extension', function () {
selectionState.selectImage({
selectionState.selectSource({
path: 'foo.zip',
extension: 'img',
archiveExtension: 'zip',
@ -503,7 +503,7 @@ describe('Model: selectionState', function () {
});
it('should infer a compressed raw image if the penultimate extension is missing', function () {
selectionState.selectImage({
selectionState.selectSource({
path: 'foo.xz',
extension: 'img',
archiveExtension: 'xz',
@ -516,7 +516,7 @@ 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({
path: 'something.linux-x86-64.gz',
extension: 'img',
archiveExtension: 'gz',
@ -530,7 +530,7 @@ describe('Model: selectionState', function () {
it('should throw if no path', function () {
expect(function () {
selectionState.selectImage({
selectionState.selectSource({
extension: 'img',
size: 999999999,
isSizeEstimated: false,
@ -540,7 +540,7 @@ describe('Model: selectionState', function () {
it('should throw if path is not a string', function () {
expect(function () {
selectionState.selectImage({
selectionState.selectSource({
path: 123,
extension: 'img',
size: 999999999,
@ -551,7 +551,7 @@ describe('Model: selectionState', function () {
it('should throw if the original size is not a number', function () {
expect(function () {
selectionState.selectImage({
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
@ -563,7 +563,7 @@ describe('Model: selectionState', function () {
it('should throw if the original size is a float number', function () {
expect(function () {
selectionState.selectImage({
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
@ -575,7 +575,7 @@ describe('Model: selectionState', function () {
it('should throw if the original size is negative', function () {
expect(function () {
selectionState.selectImage({
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
@ -587,7 +587,7 @@ describe('Model: selectionState', function () {
it('should throw if the final size is not a number', function () {
expect(function () {
selectionState.selectImage({
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: '999999999',
@ -598,7 +598,7 @@ describe('Model: selectionState', function () {
it('should throw if the final size is a float number', function () {
expect(function () {
selectionState.selectImage({
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999.999,
@ -609,7 +609,7 @@ describe('Model: selectionState', function () {
it('should throw if the final size is negative', function () {
expect(function () {
selectionState.selectImage({
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: -1,
@ -620,7 +620,7 @@ describe('Model: selectionState', function () {
it("should throw if url is defined but it's not a string", function () {
expect(function () {
selectionState.selectImage({
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
@ -632,7 +632,7 @@ describe('Model: selectionState', function () {
it("should throw if name is defined but it's not a string", function () {
expect(function () {
selectionState.selectImage({
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
@ -644,7 +644,7 @@ describe('Model: selectionState', function () {
it("should throw if logo is defined but it's not a string", function () {
expect(function () {
selectionState.selectImage({
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
@ -667,7 +667,7 @@ describe('Model: selectionState', function () {
selectionState.selectDrive('/dev/disk1');
expect(selectionState.hasDrive()).to.be.true;
selectionState.selectImage({
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 1234567890,
@ -691,7 +691,7 @@ describe('Model: selectionState', function () {
selectionState.selectDrive('/dev/disk1');
expect(selectionState.hasDrive()).to.be.true;
selectionState.selectImage({
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
@ -726,7 +726,7 @@ describe('Model: selectionState', function () {
selectionState.selectDrive('/dev/disk1');
expect(selectionState.hasDrive()).to.be.true;
selectionState.selectImage({
selectionState.selectSource({
path: imagePath,
extension: 'img',
size: 999999999,
@ -752,7 +752,7 @@ describe('Model: selectionState', function () {
selectionState.selectDrive('/dev/disk1');
selectionState.selectImage({
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,
@ -850,7 +850,7 @@ describe('Model: selectionState', function () {
selectionState.selectDrive('/dev/disk2');
selectionState.selectDrive('/dev/disk3');
selectionState.selectImage({
selectionState.selectSource({
path: 'foo.img',
extension: 'img',
size: 999999999,

View File

@ -28,10 +28,12 @@ const fakeDrive: DrivelistDrive = {};
describe('Browser: imageWriter', () => {
describe('.flash()', () => {
const imagePath = 'foo.img';
const sourceOptions = {
imagePath,
const image = {
hasMBR: false,
partitions: [],
path: 'foo.img',
SourceType: sourceDestination.File,
extension: 'img',
};
describe('given a successful write', () => {
@ -58,12 +60,7 @@ describe('Browser: imageWriter', () => {
});
try {
await imageWriter.flash(
imagePath,
[fakeDrive],
sourceOptions,
performWriteStub,
);
imageWriter.flash(image, [fakeDrive], performWriteStub);
} catch {
// noop
} finally {
@ -79,18 +76,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 +104,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 +114,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 +129,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');

View File

@ -16,6 +16,7 @@
import { expect } from 'chai';
import { Drive as DrivelistDrive } from 'drivelist';
import { sourceDestination } from 'etcher-sdk';
import * as _ from 'lodash';
import * as path from 'path';
@ -126,6 +127,9 @@ describe('Shared: DriveConstraints', function () {
} as DrivelistDrive,
{
path: '/Volumes/Untitled/image.img',
hasMBR: false,
partitions: [],
SourceType: sourceDestination.File,
},
);
@ -160,6 +164,9 @@ describe('Shared: DriveConstraints', function () {
} as DrivelistDrive,
{
path: 'E:\\image.img',
hasMBR: false,
partitions: [],
SourceType: sourceDestination.File,
},
);
@ -182,6 +189,9 @@ describe('Shared: DriveConstraints', function () {
} as DrivelistDrive,
{
path: 'E:\\foo\\bar\\image.img',
hasMBR: false,
partitions: [],
SourceType: sourceDestination.File,
},
);
@ -204,6 +214,9 @@ describe('Shared: DriveConstraints', function () {
} as DrivelistDrive,
{
path: 'G:\\image.img',
hasMBR: false,
partitions: [],
SourceType: sourceDestination.File,
},
);
@ -222,6 +235,9 @@ describe('Shared: DriveConstraints', function () {
} as DrivelistDrive,
{
path: 'E:\\foo/image.img',
hasMBR: false,
partitions: [],
SourceType: sourceDestination.File,
},
);
@ -252,6 +268,9 @@ describe('Shared: DriveConstraints', function () {
} as DrivelistDrive,
{
path: '/image.img',
hasMBR: false,
partitions: [],
SourceType: sourceDestination.File,
},
);
@ -272,6 +291,9 @@ describe('Shared: DriveConstraints', function () {
} as DrivelistDrive,
{
path: '/Volumes/A/image.img',
hasMBR: false,
partitions: [],
SourceType: sourceDestination.File,
},
);
@ -292,6 +314,9 @@ describe('Shared: DriveConstraints', function () {
} as DrivelistDrive,
{
path: '/Volumes/A/foo/bar/image.img',
hasMBR: false,
partitions: [],
SourceType: sourceDestination.File,
},
);
@ -312,6 +337,9 @@ describe('Shared: DriveConstraints', function () {
} as DrivelistDrive,
{
path: '/Volumes/C/image.img',
hasMBR: false,
partitions: [],
SourceType: sourceDestination.File,
},
);
@ -329,6 +357,9 @@ describe('Shared: DriveConstraints', function () {
} as DrivelistDrive,
{
path: '/Volumes/foo/image.img',
hasMBR: false,
partitions: [],
SourceType: sourceDestination.File,
},
);