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

@@ -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,
});
}}
/>
)}
</>
);
}