diff --git a/lib/gui/app/components/drive-selector/drive-selector.tsx b/lib/gui/app/components/drive-selector/drive-selector.tsx index 5523c96b..cdcb374f 100644 --- a/lib/gui/app/components/drive-selector/drive-selector.tsx +++ b/lib/gui/app/components/drive-selector/drive-selector.tsx @@ -167,7 +167,7 @@ export class DriveSelector extends React.Component< DriveSelectorState > { private unsubscribe: (() => void) | undefined; - multipleSelection: boolean; + multipleSelection: boolean = true; tableColumns: Array>; 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< {this.props.emptyListLabel} ) : ( - + ) => { if (t !== null) { diff --git a/lib/gui/app/components/source-selector/source-selector.tsx b/lib/gui/app/components/source-selector/source-selector.tsx index 7d8fa78f..69738224 100644 --- a/lib/gui/app/components/source-selector/source-selector.tsx +++ b/lib/gui/app/components/source-selector/source-selector.tsx @@ -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 ( - + flow.onClick(evt)} + icon={flow.icon} + {...props} + > {flow.label} ); @@ -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; cancel: () => void } { + private selectSource( + selected: string | scanner.adapters.DrivelistDrive, + SourceType: Source, + ): { promise: Promise; 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) { 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) { // 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 ( <> ) => this.onDrop(evt)} + onDragEnter={(evt: React.DragEvent) => + this.onDragEnter(evt) + } + onDragOver={(evt: React.DragEvent) => + this.onDragOver(evt) + } > - {hasImage ? ( + {hasSource ? ( <> this.showSelectedImageDetails()} tooltip={imageName || imageBasename} > {middleEllipsis(imageName || imageBasename, 20)} {!flashing && ( - + this.reselectSource()} + > Remove )} @@ -551,7 +579,7 @@ export class SourceSelector extends React.Component< this.openImageSelector(), label: 'Flash from file', icon: , }} @@ -559,11 +587,19 @@ export class SourceSelector extends React.Component< this.openURLSelector(), label: 'Flash from URL', icon: , }} /> + this.openDriveSelector(), + label: 'Clone drive', + icon: , + }} + /> )} @@ -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 && ( + { + 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, + }); + }} + /> + )} ); } diff --git a/lib/gui/app/models/available-drives.ts b/lib/gui/app/models/available-drives.ts index f9b77df9..6389886f 100644 --- a/lib/gui/app/models/available-drives.ts +++ b/lib/gui/app/models/available-drives.ts @@ -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, }); } diff --git a/lib/gui/app/models/selection-state.ts b/lib/gui/app/models/selection-state.ts index 232c3de3..6843e256 100644 --- a/lib/gui/app/models/selection-state.ts +++ b/lib/gui/app/models/selection-state.ts @@ -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: {}, }); } diff --git a/lib/gui/app/models/store.ts b/lib/gui/app/models/store.ts index fe1b3fff..0a1ac58b 100644 --- a/lib/gui/app/models/store.ts +++ b/lib/gui/app/models/store.ts @@ -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 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']); } diff --git a/lib/gui/app/modules/image-writer.ts b/lib/gui/app/modules/image-writer.ts index 76c70fcd..e1ff0670 100644 --- a/lib/gui/app/modules/image-writer.ts +++ b/lib/gui/app/modules/image-writer.ts @@ -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 { +): 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 { @@ -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 }); diff --git a/lib/gui/app/pages/main/Flash.tsx b/lib/gui/app/pages/main/Flash.tsx index b61853d4..58ea0895 100644 --- a/lib/gui/app/pages/main/Flash.tsx +++ b/lib/gui/app/pages/main/Flash.tsx @@ -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 { 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, ), }); } diff --git a/lib/gui/app/pages/main/MainPage.tsx b/lib/gui/app/pages/main/MainPage.tsx index e2306540..aca2b53b 100644 --- a/lib/gui/app/pages/main/MainPage.tsx +++ b/lib/gui/app/pages/main/MainPage.tsx @@ -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 && ( <> - - this.setState({ source }) - } - /> + @@ -316,7 +305,6 @@ export class MainPage extends React.Component< this.setState({ current: 'success' })} shouldFlashStepBeDisabled={shouldFlashStepBeDisabled} - source={this.state.source} isFlashing={this.state.isFlashing} step={state.type} percentage={state.percentage} diff --git a/lib/gui/modules/child-writer.ts b/lib/gui/modules/child-writer.ts index 24052220..61fafe72 100644 --- a/lib/gui/modules/child-writer.ts +++ b/lib/gui/modules/child-writer.ts @@ -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, diff --git a/lib/shared/drive-constraints.ts b/lib/shared/drive-constraints.ts index 8b56ad87..f8630de8 100644 --- a/lib/shared/drive-constraints.ts +++ b/lib/shared/drive-constraints.ts @@ -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(), diff --git a/lib/shared/messages.ts b/lib/shared/messages.ts index d88c1ff3..500c3468 100644 --- a/lib/shared/messages.ts +++ b/lib/shared/messages.ts @@ -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(''); }, diff --git a/tests/gui/models/available-drives.spec.ts b/tests/gui/models/available-drives.spec.ts index a28fc493..126a4640 100644 --- a/tests/gui/models/available-drives.spec.ts +++ b/tests/gui/models/available-drives.spec.ts @@ -157,7 +157,7 @@ describe('Model: availableDrives', function () { } selectionState.clear(); - selectionState.selectImage({ + selectionState.selectSource({ path: this.imagePath, extension: 'img', size: 999999999, diff --git a/tests/gui/models/selection-state.spec.ts b/tests/gui/models/selection-state.spec.ts index 6a690ed9..234dde8b 100644 --- a/tests/gui/models/selection-state.spec.ts +++ b/tests/gui/models/selection-state.spec.ts @@ -359,7 +359,7 @@ describe('Model: selectionState', function () { logo: 'Raspbian', }; - 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, diff --git a/tests/gui/modules/image-writer.spec.ts b/tests/gui/modules/image-writer.spec.ts index ab820cd7..677b2f6c 100644 --- a/tests/gui/modules/image-writer.spec.ts +++ b/tests/gui/modules/image-writer.spec.ts @@ -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'); diff --git a/tests/shared/drive-constraints.spec.ts b/tests/shared/drive-constraints.spec.ts index e5ecf1de..62687fde 100644 --- a/tests/shared/drive-constraints.spec.ts +++ b/tests/shared/drive-constraints.spec.ts @@ -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, }, );