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.
*/
import {
faChevronDown,
faExclamationTriangle,
} from '@fortawesome/free-solid-svg-icons';
import { Drive as DrivelistDrive } from 'drivelist';
import * as _ from 'lodash';
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 {
COMPATIBILITY_STATUS_TYPES,
getDriveImageCompatibilityStatuses,
hasListDriveImageCompatibilityStatus,
isDriveValid,
hasDriveImageCompatibilityStatus,
TargetStatus,
} from '../../../../shared/drive-constraints';
import { compatibility } from '../../../../shared/messages';
import { bytesToClosestUnit } from '../../../../shared/units';
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 * as analytics from '../../modules/analytics';
import { open as openExternal } from '../../os/open-external/services/open-external';
import { Modal } from '../../styled-components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
export interface DrivelistTarget extends DrivelistDrive {
displayName: string;
@ -53,9 +63,7 @@ export interface DrivelistTarget extends DrivelistDrive {
* containing the status type (ERROR, WARNING), and accompanying
* status message.
*/
function getDriveStatuses(
drive: DrivelistTarget,
): Array<{ type: number; message: string }> {
function getDriveStatuses(drive: DrivelistTarget): TargetStatus[] {
return getDriveImageCompatibilityStatuses(drive, getImage());
}
@ -95,15 +103,20 @@ interface DriverlessDrive {
linkMessage: string;
}
interface TargetStatus {
message: string;
type: number;
function badgeShadeFromStatus(status: string) {
switch (status) {
case compatibility.containsImage():
return 16;
case compatibility.system():
return 5;
default:
return 14;
}
}
function renderStatuses(statuses: TargetStatus[]) {
return _.map(statuses, (status) => {
const badgeShade =
status.type === COMPATIBILITY_STATUS_TYPES.WARNING ? 14 : 5;
const badgeShade = badgeShadeFromStatus(status.message);
return (
<Badge key={status.message} shade={badgeShade}>
{status.message}
@ -124,7 +137,6 @@ const InitProgress = styled(
},
)`
/* Reset the default appearance */
-webkit-appearance: none;
appearance: none;
::-webkit-progress-bar {
@ -141,7 +153,7 @@ const InitProgress = styled(
`;
function renderProgress(progress: number) {
if (Boolean(progress)) {
if (progress) {
return (
<Flex flexDirection="column">
<Txt fontSize={12}>Initializing device</Txt>
@ -149,149 +161,182 @@ function renderProgress(progress: number) {
</Flex>
);
}
return;
}
interface TableData extends DrivelistTarget {
disabled: boolean;
}
export const TargetSelectorModal = styled(
({
close,
cancel,
}: {
close: (targets: DrivelistTarget[]) => void;
cancel: () => void;
}) => {
const defaultMissingDriversModalState: { drive?: DriverlessDrive } = {};
const [missingDriversModal, setMissingDriversModal] = React.useState(
defaultMissingDriversModalState,
);
const [drives, setDrives] = React.useState(getDrives());
const [selected, setSelected] = React.useState(getSelectedDrives());
const image = getImage();
export const TargetSelectorModal = ({
close,
cancel,
}: {
close: (targets: DrivelistTarget[]) => void;
cancel: () => void;
}) => {
const defaultMissingDriversModalState: { drive?: DriverlessDrive } = {};
const [missingDriversModal, setMissingDriversModal] = React.useState(
defaultMissingDriversModalState,
);
const [drives, setDrives] = React.useState(getDrives());
const [selectedList, setSelected] = React.useState(getSelectedDrives());
const [showSystemDrives, setShowSystemDrives] = React.useState(false);
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) => {
return {
...drive,
extra: drive.progress || getDriveStatuses(drive),
disabled: !isDriveValid(drive, image) || drive.progress,
highlighted: hasDriveImageCompatibilityStatus(drive, image),
};
const columns = [
{
field: 'description',
label: 'Name',
render: (description: string, drive: DrivelistTarget) => {
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(
_.filter(drives, (drive) => {
return !isDriveValid(drive, image) || drive.progress;
}),
'displayName',
);
return unsubscribe;
});
const columns = [
{
field: 'description',
label: 'Name',
},
{
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());
/**
* @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,
});
return unsubscribe;
});
/**
* @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 });
}
setMissingDriversModal({ drive });
}
}
return (
<Modal
titleElement={
return (
<Modal
titleElement={
<Flex alignItems="baseline" mb={18}>
<Txt fontSize={24} align="left">
Select target
</Txt>
}
titleDetails={<Txt fontSize={11}>{getDrives().length} found</Txt>}
cancel={cancel}
done={() => close(selected)}
action="Continue"
style={{
width: '780px',
height: '420px',
}}
primaryButtonProps={{
primary: !hasStatus,
warning: hasStatus,
}}
>
<div>
{!hasAvailableDrives() ? (
<div style={{ textAlign: 'center', margin: '0 auto' }}>
<b>Plug a target drive</b>
</div>
) : (
<Txt
fontSize={11}
ml={12}
color="#5b82a7"
style={{ fontWeight: 600 }}
>
{drives.length} found
</Txt>
</Flex>
}
titleDetails={<Txt fontSize={11}>{getDrives().length} found</Txt>}
cancel={cancel}
done={() => close(selectedList)}
action="Continue"
style={{
width: '780px',
height: '420px',
}}
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
refFn={(t: BaseTable<TableData>) => {
if (!_.isNull(t)) {
t.setRowSelection(selected);
t.setRowSelection(selectedList);
}
}}
columns={columns}
data={tableData}
data={_.uniq(
showSystemDrives
? normalDrives.concat(systemDrives)
: normalDrives,
)}
disabledRows={disabledRows}
rowKey="displayName"
onCheck={(rows: TableData[]) => {
@ -299,77 +344,65 @@ export const TargetSelectorModal = styled(
}}
onRowClick={(row: TableData) => {
if (!row.disabled) {
const selectedIndex = selected.findIndex(
const selectedIndex = selectedList.findIndex(
(target) => target.device === row.device,
);
if (selectedIndex === -1) {
selected.push(row);
setSelected(_.map(selected));
selectedList.push(row);
setSelected(_.map(selectedList));
return;
}
// Deselect if selected
setSelected(
_.reject(
selected,
selectedList,
(drive) =>
selected[selectedIndex].device === drive.device,
selectedList[selectedIndex].device === drive.device,
),
);
}
}}
></TargetsTable>
)}
</div>
{missingDriversModal.drive !== undefined && (
<Modal
width={400}
title={missingDriversModal.drive.linkTitle}
cancel={() => setMissingDriversModal({})}
done={() => {
try {
if (missingDriversModal.drive !== undefined) {
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>
{!showSystemDrives && (
<Link mt={16} onClick={() => setShowSystemDrives(true)}>
<Flex alignItems="center">
<FontAwesomeIcon icon={faChevronDown} />
<Txt ml={8}>
Show {drives.length - normalDrives.length} hidden
</Txt>
</Flex>
</Link>
)}
</Flex>
)}
</Modal>
);
},
)`
> [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;
}
</div>
> [data-display='table-body']
> [data-display='table-row']
> [data-display='table-cell']:first-child {
padding-left: 15px;
}
> [data-display='table-body']
> [data-display='table-row']
> [data-display='table-cell'] {
padding: 10px;
}
`;
{missingDriversModal.drive !== undefined && (
<Modal
width={400}
title={missingDriversModal.drive.linkTitle}
cancel={() => setMissingDriversModal({})}
done={() => {
try {
if (missingDriversModal.drive !== undefined) {
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> = {
unsafeMode: false,
errorReporting: true,
unmountOnSuccess: true,
validateWriteOnSuccess: true,

View File

@ -17,19 +17,10 @@
import * as sdk from 'etcher-sdk';
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[] = [
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

View File

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

View File

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

View File

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

2
npm-shrinkwrap.json generated
View File

@ -18263,4 +18263,4 @@
}
}
}
}
}

View File

@ -1126,7 +1126,7 @@ describe('Shared: DriveConstraints', function () {
});
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.image.path = path.join(this.mountpoint, 'rpi.img');
@ -1135,7 +1135,7 @@ describe('Shared: DriveConstraints', function () {
this.image,
);
// @ts-ignore
const expectedTuples = [['ERROR', 'containsImage']];
const expectedTuples = [['ERROR', 'locked']];
// @ts-ignore
expectStatusTypesAndMessagesToBe(result, expectedTuples);
@ -1303,7 +1303,7 @@ describe('Shared: DriveConstraints', function () {
),
).to.deep.equal([
{
message: 'Drive Mountpoint Contains Image',
message: 'Source drive',
type: 2,
},
]);
@ -1345,7 +1345,7 @@ describe('Shared: DriveConstraints', function () {
),
).to.deep.equal([
{
message: 'System Drive',
message: 'System drive',
type: 1,
},
]);
@ -1359,7 +1359,7 @@ describe('Shared: DriveConstraints', function () {
),
).to.deep.equal([
{
message: 'Large Drive',
message: 'Large drive',
type: 1,
},
]);
@ -1386,7 +1386,7 @@ describe('Shared: DriveConstraints', function () {
constraints.getListDriveImageCompatibilityStatuses(drives, image),
).to.deep.equal([
{
message: 'Drive Mountpoint Contains Image',
message: 'Source drive',
type: 2,
},
{
@ -1398,11 +1398,11 @@ describe('Shared: DriveConstraints', function () {
type: 2,
},
{
message: 'System Drive',
message: 'System drive',
type: 1,
},
{
message: 'Large Drive',
message: 'Large drive',
type: 1,
},
{