Merge unsafe mode with new target selector

Change-type: patch
Changelog-entry: Merge unsafe mode with new target selector
Signed-off-by: Lorenzo Alberto Maria Ambrosi <lorenzothunder.ambrosi@gmail.com>
This commit is contained in:
Lorenzo Alberto Maria Ambrosi 2020-06-04 19:16:01 +02:00
parent 71c7fbd3a2
commit b0c71b21b3
8 changed files with 258 additions and 223 deletions

View File

@ -14,26 +14,36 @@
* limitations under the License. * limitations under the License.
*/ */
import {
faChevronDown,
faExclamationTriangle,
} from '@fortawesome/free-solid-svg-icons';
import { Drive as DrivelistDrive } from 'drivelist'; import { Drive as DrivelistDrive } from 'drivelist';
import * as _ from 'lodash'; import * as _ from 'lodash';
import * as React from 'react'; import * as React from 'react';
import { Badge, Table as BaseTable, Txt, Flex } from 'rendition'; import { Badge, Table as BaseTable, Txt, Flex, Link } from 'rendition';
import styled from 'styled-components'; import styled from 'styled-components';
import { import {
COMPATIBILITY_STATUS_TYPES,
getDriveImageCompatibilityStatuses, getDriveImageCompatibilityStatuses,
hasListDriveImageCompatibilityStatus, hasListDriveImageCompatibilityStatus,
isDriveValid, isDriveValid,
hasDriveImageCompatibilityStatus, hasDriveImageCompatibilityStatus,
TargetStatus,
} from '../../../../shared/drive-constraints'; } from '../../../../shared/drive-constraints';
import { compatibility } from '../../../../shared/messages';
import { bytesToClosestUnit } from '../../../../shared/units'; import { bytesToClosestUnit } from '../../../../shared/units';
import { getDrives, hasAvailableDrives } from '../../models/available-drives'; import { getDrives, hasAvailableDrives } from '../../models/available-drives';
import { getImage, getSelectedDrives } from '../../models/selection-state'; import {
getImage,
getSelectedDrives,
isDriveSelected,
} from '../../models/selection-state';
import { store } from '../../models/store'; import { store } from '../../models/store';
import * as analytics from '../../modules/analytics'; import * as analytics from '../../modules/analytics';
import { open as openExternal } from '../../os/open-external/services/open-external'; import { open as openExternal } from '../../os/open-external/services/open-external';
import { Modal } from '../../styled-components'; import { Modal } from '../../styled-components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
export interface DrivelistTarget extends DrivelistDrive { export interface DrivelistTarget extends DrivelistDrive {
displayName: string; displayName: string;
@ -53,9 +63,7 @@ export interface DrivelistTarget extends DrivelistDrive {
* containing the status type (ERROR, WARNING), and accompanying * containing the status type (ERROR, WARNING), and accompanying
* status message. * status message.
*/ */
function getDriveStatuses( function getDriveStatuses(drive: DrivelistTarget): TargetStatus[] {
drive: DrivelistTarget,
): Array<{ type: number; message: string }> {
return getDriveImageCompatibilityStatuses(drive, getImage()); return getDriveImageCompatibilityStatuses(drive, getImage());
} }
@ -95,15 +103,20 @@ interface DriverlessDrive {
linkMessage: string; linkMessage: string;
} }
interface TargetStatus { function badgeShadeFromStatus(status: string) {
message: string; switch (status) {
type: number; case compatibility.containsImage():
return 16;
case compatibility.system():
return 5;
default:
return 14;
}
} }
function renderStatuses(statuses: TargetStatus[]) { function renderStatuses(statuses: TargetStatus[]) {
return _.map(statuses, (status) => { return _.map(statuses, (status) => {
const badgeShade = const badgeShade = badgeShadeFromStatus(status.message);
status.type === COMPATIBILITY_STATUS_TYPES.WARNING ? 14 : 5;
return ( return (
<Badge key={status.message} shade={badgeShade}> <Badge key={status.message} shade={badgeShade}>
{status.message} {status.message}
@ -124,7 +137,6 @@ const InitProgress = styled(
}, },
)` )`
/* Reset the default appearance */ /* Reset the default appearance */
-webkit-appearance: none;
appearance: none; appearance: none;
::-webkit-progress-bar { ::-webkit-progress-bar {
@ -141,7 +153,7 @@ const InitProgress = styled(
`; `;
function renderProgress(progress: number) { function renderProgress(progress: number) {
if (Boolean(progress)) { if (progress) {
return ( return (
<Flex flexDirection="column"> <Flex flexDirection="column">
<Txt fontSize={12}>Initializing device</Txt> <Txt fontSize={12}>Initializing device</Txt>
@ -149,149 +161,182 @@ function renderProgress(progress: number) {
</Flex> </Flex>
); );
} }
return;
} }
interface TableData extends DrivelistTarget { interface TableData extends DrivelistTarget {
disabled: boolean; disabled: boolean;
} }
export const TargetSelectorModal = styled( export const TargetSelectorModal = ({
({ close,
close, cancel,
cancel, }: {
}: { close: (targets: DrivelistTarget[]) => void;
close: (targets: DrivelistTarget[]) => void; cancel: () => void;
cancel: () => void; }) => {
}) => { const defaultMissingDriversModalState: { drive?: DriverlessDrive } = {};
const defaultMissingDriversModalState: { drive?: DriverlessDrive } = {}; const [missingDriversModal, setMissingDriversModal] = React.useState(
const [missingDriversModal, setMissingDriversModal] = React.useState( defaultMissingDriversModalState,
defaultMissingDriversModalState, );
); const [drives, setDrives] = React.useState(getDrives());
const [drives, setDrives] = React.useState(getDrives()); const [selectedList, setSelected] = React.useState(getSelectedDrives());
const [selected, setSelected] = React.useState(getSelectedDrives()); const [showSystemDrives, setShowSystemDrives] = React.useState(false);
const image = getImage(); const image = getImage();
const hasStatus = hasListDriveImageCompatibilityStatus(selectedList, image);
const hasStatus = hasListDriveImageCompatibilityStatus(selected, image); const enrichedDrivesData = _.map(drives, (drive) => {
return {
...drive,
extra: drive.progress || getDriveStatuses(drive),
disabled: !isDriveValid(drive, image) || drive.progress,
highlighted: hasDriveImageCompatibilityStatus(drive, image),
};
});
const normalDrives = _.reject(
enrichedDrivesData,
(drive) => drive.isSystem && !isDriveSelected(drive.device),
);
const systemDrives = _.filter(enrichedDrivesData, 'isSystem');
const disabledRows = _.map(
_.filter(drives, (drive) => {
return !isDriveValid(drive, image) || drive.progress;
}),
'displayName',
);
const tableData = _.map(drives, (drive) => { const columns = [
return { {
...drive, field: 'description',
extra: drive.progress || getDriveStatuses(drive), label: 'Name',
disabled: !isDriveValid(drive, image) || drive.progress, render: (description: string, drive: DrivelistTarget) => {
highlighted: hasDriveImageCompatibilityStatus(drive, image), return drive.isSystem ? (
}; <Flex alignItems="center">
<FontAwesomeIcon
style={{ color: '#fca321' }}
icon={faExclamationTriangle}
/>
<Txt ml={8}>{description}</Txt>
</Flex>
) : (
<Txt>{description}</Txt>
);
},
},
{
field: 'size',
label: 'Size',
render: (size: number) => {
return bytesToClosestUnit(size);
},
},
{
field: 'link',
label: 'Location',
render: (link: string, drive: DrivelistTarget) => {
return !link ? (
<Txt>{drive.displayName}</Txt>
) : (
<Txt>
{drive.displayName} -{' '}
<b>
<a onClick={() => installMissingDrivers(drive)}>
{drive.linkCTA}
</a>
</b>
</Txt>
);
},
},
{
field: 'extra',
label: ' ',
render: (extra: TargetStatus[] | number) => {
if (typeof extra === 'number') {
return renderProgress(extra);
}
return renderStatuses(extra);
},
},
];
React.useEffect(() => {
const unsubscribe = store.subscribe(() => {
setDrives(getDrives());
setSelected(getSelectedDrives());
}); });
const disabledRows = _.map( return unsubscribe;
_.filter(drives, (drive) => { });
return !isDriveValid(drive, image) || drive.progress;
}),
'displayName',
);
const columns = [ /**
{ * @summary Prompt the user to install missing usbboot drivers
field: 'description', */
label: 'Name', function installMissingDrivers(drive: {
}, link: string;
{ linkTitle: string;
field: 'size', linkMessage: string;
label: 'Size', }) {
render: (size: number) => { if (drive.link) {
return bytesToClosestUnit(size); analytics.logEvent('Open driver link modal', {
}, url: drive.link,
}, applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
{ flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
field: 'link',
label: 'Location',
render: (link: string, drive: DrivelistTarget) => {
return !link ? (
<Txt>{drive.displayName}</Txt>
) : (
<Txt>
{drive.displayName} -{' '}
<b>
<a onClick={() => installMissingDrivers(drive)}>
{drive.linkCTA}
</a>
</b>
</Txt>
);
},
},
{
field: 'extra',
label: ' ',
render: (extra: TargetStatus[] | number) => {
if (typeof extra === 'number') {
return renderProgress(extra);
}
return renderStatuses(extra);
},
},
];
React.useEffect(() => {
const unsubscribe = store.subscribe(() => {
setDrives(getDrives());
setSelected(getSelectedDrives());
}); });
return unsubscribe; setMissingDriversModal({ drive });
});
/**
* @summary Prompt the user to install missing usbboot drivers
*/
function installMissingDrivers(drive: {
link: string;
linkTitle: string;
linkMessage: string;
}) {
if (drive.link) {
analytics.logEvent('Open driver link modal', {
url: drive.link,
applicationSessionUuid: store.getState().toJS()
.applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid,
});
setMissingDriversModal({ drive });
}
} }
}
return ( return (
<Modal <Modal
titleElement={ titleElement={
<Flex alignItems="baseline" mb={18}>
<Txt fontSize={24} align="left"> <Txt fontSize={24} align="left">
Select target Select target
</Txt> </Txt>
} <Txt
titleDetails={<Txt fontSize={11}>{getDrives().length} found</Txt>} fontSize={11}
cancel={cancel} ml={12}
done={() => close(selected)} color="#5b82a7"
action="Continue" style={{ fontWeight: 600 }}
style={{ >
width: '780px', {drives.length} found
height: '420px', </Txt>
}} </Flex>
primaryButtonProps={{ }
primary: !hasStatus, titleDetails={<Txt fontSize={11}>{getDrives().length} found</Txt>}
warning: hasStatus, cancel={cancel}
}} done={() => close(selectedList)}
> action="Continue"
<div> style={{
{!hasAvailableDrives() ? ( width: '780px',
<div style={{ textAlign: 'center', margin: '0 auto' }}> height: '420px',
<b>Plug a target drive</b> }}
</div> primaryButtonProps={{
) : ( primary: !hasStatus,
warning: hasStatus,
}}
>
<div>
{!hasAvailableDrives() ? (
<div style={{ textAlign: 'center', margin: '0 auto' }}>
<b>Plug a target drive</b>
</div>
) : (
<Flex
flexDirection="column"
style={{ maxHeight: !showSystemDrives ? 250 : 265 }}
>
<TargetsTable <TargetsTable
refFn={(t: BaseTable<TableData>) => { refFn={(t: BaseTable<TableData>) => {
if (!_.isNull(t)) { if (!_.isNull(t)) {
t.setRowSelection(selected); t.setRowSelection(selectedList);
} }
}} }}
columns={columns} columns={columns}
data={tableData} data={_.uniq(
showSystemDrives
? normalDrives.concat(systemDrives)
: normalDrives,
)}
disabledRows={disabledRows} disabledRows={disabledRows}
rowKey="displayName" rowKey="displayName"
onCheck={(rows: TableData[]) => { onCheck={(rows: TableData[]) => {
@ -299,77 +344,65 @@ export const TargetSelectorModal = styled(
}} }}
onRowClick={(row: TableData) => { onRowClick={(row: TableData) => {
if (!row.disabled) { if (!row.disabled) {
const selectedIndex = selected.findIndex( const selectedIndex = selectedList.findIndex(
(target) => target.device === row.device, (target) => target.device === row.device,
); );
if (selectedIndex === -1) { if (selectedIndex === -1) {
selected.push(row); selectedList.push(row);
setSelected(_.map(selected)); setSelected(_.map(selectedList));
return; return;
} }
// Deselect if selected // Deselect if selected
setSelected( setSelected(
_.reject( _.reject(
selected, selectedList,
(drive) => (drive) =>
selected[selectedIndex].device === drive.device, selectedList[selectedIndex].device === drive.device,
), ),
); );
} }
}} }}
></TargetsTable> ></TargetsTable>
)} {!showSystemDrives && (
</div> <Link mt={16} onClick={() => setShowSystemDrives(true)}>
<Flex alignItems="center">
{missingDriversModal.drive !== undefined && ( <FontAwesomeIcon icon={faChevronDown} />
<Modal <Txt ml={8}>
width={400} Show {drives.length - normalDrives.length} hidden
title={missingDriversModal.drive.linkTitle} </Txt>
cancel={() => setMissingDriversModal({})} </Flex>
done={() => { </Link>
try { )}
if (missingDriversModal.drive !== undefined) { </Flex>
openExternal(missingDriversModal.drive.link);
}
} catch (error) {
analytics.logException(error);
} finally {
setMissingDriversModal({});
}
}}
action={'Yes, continue'}
cancelButtonProps={{
children: 'Cancel',
}}
children={
missingDriversModal.drive.linkMessage ||
`Etcher will open ${missingDriversModal.drive.link} in your browser`
}
></Modal>
)} )}
</Modal> </div>
);
},
)`
> [data-display='table-head']
> [data-display='table-row']
> [data-display='table-cell']:first-child {
padding-left: 15px;
}
> [data-display='table-head']
> [data-display='table-row']
> [data-display='table-cell'] {
padding: 10px;
}
> [data-display='table-body'] {missingDriversModal.drive !== undefined && (
> [data-display='table-row'] <Modal
> [data-display='table-cell']:first-child { width={400}
padding-left: 15px; title={missingDriversModal.drive.linkTitle}
} cancel={() => setMissingDriversModal({})}
> [data-display='table-body'] done={() => {
> [data-display='table-row'] try {
> [data-display='table-cell'] { if (missingDriversModal.drive !== undefined) {
padding: 10px; openExternal(missingDriversModal.drive.link);
} }
`; } catch (error) {
analytics.logException(error);
} finally {
setMissingDriversModal({});
}
}}
action={'Yes, continue'}
cancelButtonProps={{
children: 'Cancel',
}}
children={
missingDriversModal.drive.linkMessage ||
`Etcher will open ${missingDriversModal.drive.link} in your browser`
}
></Modal>
)}
</Modal>
);
};

View File

@ -73,7 +73,6 @@ export async function writeConfigFile(
} }
const DEFAULT_SETTINGS: _.Dictionary<any> = { const DEFAULT_SETTINGS: _.Dictionary<any> = {
unsafeMode: false,
errorReporting: true, errorReporting: true,
unmountOnSuccess: true, unmountOnSuccess: true,
validateWriteOnSuccess: true, validateWriteOnSuccess: true,

View File

@ -17,19 +17,10 @@
import * as sdk from 'etcher-sdk'; import * as sdk from 'etcher-sdk';
import { geteuid, platform } from 'process'; import { geteuid, platform } from 'process';
import * as settings from '../models/settings';
/**
* @summary returns true if system drives should be shown
*/
function includeSystemDrives() {
return (
settings.getSync('unsafeMode') && !settings.getSync('disableUnsafeMode')
);
}
const adapters: sdk.scanner.adapters.Adapter[] = [ const adapters: sdk.scanner.adapters.Adapter[] = [
new sdk.scanner.adapters.BlockDeviceAdapter({ includeSystemDrives }), new sdk.scanner.adapters.BlockDeviceAdapter({
includeSystemDrives: () => true,
}),
]; ];
// Can't use permissions.isElevated() here as it returns a promise and we need to set // Can't use permissions.isElevated() here as it returns a promise and we need to set

View File

@ -131,8 +131,13 @@ export const Modal = styled((props) => {
> div { > div {
padding: 30px; padding: 30px;
> h3 {
margin: 0;
}
> div:last-child { > div:last-child {
height: 80px; height: 80px;
background-color: #fff;
justify-content: center; justify-content: center;
position: absolute; position: absolute;
bottom: 0; bottom: 0;

View File

@ -172,12 +172,7 @@ export function getDriveImageCompatibilityStatuses(
const statusList = []; const statusList = [];
// Mind the order of the if-statements if you modify. // Mind the order of the if-statements if you modify.
if (isSourceDrive(drive, image)) { if (isDriveLocked(drive)) {
statusList.push({
type: COMPATIBILITY_STATUS_TYPES.ERROR,
message: messages.compatibility.containsImage(),
});
} else if (isDriveLocked(drive)) {
statusList.push({ statusList.push({
type: COMPATIBILITY_STATUS_TYPES.ERROR, type: COMPATIBILITY_STATUS_TYPES.ERROR,
message: messages.compatibility.locked(), message: messages.compatibility.locked(),
@ -196,6 +191,13 @@ export function getDriveImageCompatibilityStatuses(
message: messages.compatibility.tooSmall(prettyBytes(relativeBytes)), message: messages.compatibility.tooSmall(prettyBytes(relativeBytes)),
}); });
} else { } else {
if (isSourceDrive(drive, image)) {
statusList.push({
type: COMPATIBILITY_STATUS_TYPES.ERROR,
message: messages.compatibility.containsImage(),
});
}
if (isSystemDrive(drive)) { if (isSystemDrive(drive)) {
statusList.push({ statusList.push({
type: COMPATIBILITY_STATUS_TYPES.WARNING, type: COMPATIBILITY_STATUS_TYPES.WARNING,
@ -273,3 +275,8 @@ export function hasListDriveImageCompatibilityStatus(
) { ) {
return Boolean(getListDriveImageCompatibilityStatuses(drives, image).length); return Boolean(getListDriveImageCompatibilityStatuses(drives, image).length);
} }
export interface TargetStatus {
message: string;
type: number;
}

View File

@ -65,16 +65,16 @@ export const compatibility = {
}, },
system: () => { system: () => {
return 'System Drive'; return 'System drive';
}, },
containsImage: () => { containsImage: () => {
return 'Drive Mountpoint Contains Image'; return 'Source drive';
}, },
// The drive is large and therefore likely not a medium you want to write to. // The drive is large and therefore likely not a medium you want to write to.
largeDrive: () => { largeDrive: () => {
return 'Large Drive'; return 'Large drive';
}, },
} as const; } as const;

View File

@ -1126,7 +1126,7 @@ describe('Shared: DriveConstraints', function () {
}); });
describe('given the drive contains the image and the drive is locked', () => { describe('given the drive contains the image and the drive is locked', () => {
it('should return the contains-image drive error by precedence', function () { it('should return the locked error by precedence', function () {
this.drive.isReadOnly = true; this.drive.isReadOnly = true;
this.image.path = path.join(this.mountpoint, 'rpi.img'); this.image.path = path.join(this.mountpoint, 'rpi.img');
@ -1135,7 +1135,7 @@ describe('Shared: DriveConstraints', function () {
this.image, this.image,
); );
// @ts-ignore // @ts-ignore
const expectedTuples = [['ERROR', 'containsImage']]; const expectedTuples = [['ERROR', 'locked']];
// @ts-ignore // @ts-ignore
expectStatusTypesAndMessagesToBe(result, expectedTuples); expectStatusTypesAndMessagesToBe(result, expectedTuples);
@ -1303,7 +1303,7 @@ describe('Shared: DriveConstraints', function () {
), ),
).to.deep.equal([ ).to.deep.equal([
{ {
message: 'Drive Mountpoint Contains Image', message: 'Source drive',
type: 2, type: 2,
}, },
]); ]);
@ -1345,7 +1345,7 @@ describe('Shared: DriveConstraints', function () {
), ),
).to.deep.equal([ ).to.deep.equal([
{ {
message: 'System Drive', message: 'System drive',
type: 1, type: 1,
}, },
]); ]);
@ -1359,7 +1359,7 @@ describe('Shared: DriveConstraints', function () {
), ),
).to.deep.equal([ ).to.deep.equal([
{ {
message: 'Large Drive', message: 'Large drive',
type: 1, type: 1,
}, },
]); ]);
@ -1386,7 +1386,7 @@ describe('Shared: DriveConstraints', function () {
constraints.getListDriveImageCompatibilityStatuses(drives, image), constraints.getListDriveImageCompatibilityStatuses(drives, image),
).to.deep.equal([ ).to.deep.equal([
{ {
message: 'Drive Mountpoint Contains Image', message: 'Source drive',
type: 2, type: 2,
}, },
{ {
@ -1398,11 +1398,11 @@ describe('Shared: DriveConstraints', function () {
type: 2, type: 2,
}, },
{ {
message: 'System Drive', message: 'System drive',
type: 1, type: 1,
}, },
{ {
message: 'Large Drive', message: 'Large drive',
type: 1, type: 1,
}, },
{ {