// SPDX-License-Identifier: MIT
// Copyright (c) 2022-2023 The Pybricks Authors
import './installPybricksDialog.scss';
import {
Button,
Checkbox,
Classes,
Collapse,
ControlGroup,
DialogStep,
FormGroup,
Icon,
InputGroup,
Intent,
MultistepDialog,
NonIdealState,
Pre,
Spinner,
} from '@blueprintjs/core';
import { Classes as Classes2, Popover2 } from '@blueprintjs/popover2';
import { FirmwareMetadata, HubType } from '@pybricks/firmware';
import { fileOpen } from 'browser-fs-access';
import classNames from 'classnames';
import React, { useCallback, useState } from 'react';
import { VisuallyHidden } from 'react-aria';
import { useDropzone } from 'react-dropzone';
import { useDispatch } from 'react-redux';
import { useLocalStorage } from 'usehooks-ts';
import { alertsShowAlert } from '../../alerts/actions';
import { appName, legoMindstormsRegisteredTrademark } from '../../app/constants';
import HelpButton from '../../components/HelpButton';
import { Hub, hubBootloaderType } from '../../components/hubPicker';
import { HubPicker } from '../../components/hubPicker/HubPicker';
import { useHubPickerSelectedHub } from '../../components/hubPicker/hooks';
import { useSelector } from '../../reducers';
import { ensureError } from '../../utils';
import BootloaderInstructions from '../bootloaderInstructions/BootloaderInstructions';
import {
firmwareInstallPybricksDialogAccept,
firmwareInstallPybricksDialogCancel,
} from './actions';
import { FirmwareData, useCustomFirmware, useFirmware } from './hooks';
import { useI18n } from './i18n';
import { validateHubName } from '.';
const dialogBody = classNames(
Classes.DIALOG_BODY,
Classes.RUNNING_TEXT,
'pb-firmware-installPybricksDialog-body',
);
/** Translates hub type from firmware metadata to local hub type. */
function getHubTypeFromMetadata(
metadata: FirmwareMetadata | undefined,
fallback: Hub,
): Hub {
switch (metadata?.['device-id']) {
case HubType.MoveHub:
return Hub.Move;
case HubType.CityHub:
return Hub.City;
case HubType.TechnicHub:
return Hub.Technic;
case HubType.PrimeHub:
return Hub.Prime;
case HubType.EssentialHub:
return Hub.Essential;
default:
return fallback;
}
}
function getHubTypeNameFromMetadata(metadata: FirmwareMetadata | undefined): string {
switch (metadata?.['device-id']) {
case HubType.MoveHub:
return 'BOOST Move Hub';
case HubType.CityHub:
return 'City Hub';
case HubType.TechnicHub:
return 'Technic Hub';
case HubType.PrimeHub:
return 'SPIKE Prime/MINDSTORMS Robot Inventor hub';
case HubType.EssentialHub:
return 'SPIKE Essential hub';
default:
return '?';
}
}
const UnsupportedHubs: React.VoidFunctionComponent = () => {
const i18n = useI18n();
return (
{i18n.translate(
'selectHubPanel.notOnListButton.info.mindstorms.title',
{ legoMindstormsRegisteredTrademark },
)}
{i18n.translate(
'selectHubPanel.notOnListButton.info.mindstorms.intro',
{
appName,
legoMindstormsRegisteredTrademark,
},
)}
{i18n.translate(
'selectHubPanel.notOnListButton.info.mindstorms.help.message',
{
sponsor: (
<>
{i18n.translate(
'selectHubPanel.notOnListButton.info.mindstorms.help.sponsor',
)}
>
),
},
)}
-
{i18n.translate(
'selectHubPanel.notOnListButton.info.mindstorms.rcx',
)}
-
{i18n.translate(
'selectHubPanel.notOnListButton.info.mindstorms.nxt',
)}
-
{i18n.translate(
'selectHubPanel.notOnListButton.info.mindstorms.ev3',
)}
{i18n.translate('selectHubPanel.notOnListButton.info.poweredUp.title')}
{i18n.translate('selectHubPanel.notOnListButton.info.poweredUp.intro')}
-
{i18n.translate(
'selectHubPanel.notOnListButton.info.poweredUp.wedo2',
)}
-
{i18n.translate(
'selectHubPanel.notOnListButton.info.poweredUp.duploTrain',
)}
-
{i18n.translate(
'selectHubPanel.notOnListButton.info.poweredUp.mario',
)}
);
};
type SelectHubPanelProps = {
isCustomFirmwareRequested: boolean;
customFirmwareData: FirmwareData | undefined;
onCustomFirmwareZip: (firmwareZip: File | undefined) => void;
};
const SelectHubPanel: React.VoidFunctionComponent = ({
isCustomFirmwareRequested,
customFirmwareData,
onCustomFirmwareZip,
}) => {
const [isAdvancedOpen, setIsAdvancedOpen] = useLocalStorage(
'installPybricksDialog.isAdvancedOpen',
false,
);
const i18n = useI18n();
const dispatch = useDispatch();
const onDrop = useCallback(
(acceptedFiles: File[]) => {
// should only be one file since multiple={false}
acceptedFiles.forEach((f) => {
onCustomFirmwareZip(f);
});
},
[onCustomFirmwareZip],
);
const onClick = useCallback(async () => {
try {
const file = await fileOpen({
id: 'customFirmware',
mimeTypes: ['application/zip'],
extensions: ['.zip'],
// TODO: translate description
description: 'Zip Files',
excludeAcceptAllOption: true,
startIn: 'downloads',
});
onCustomFirmwareZip(file);
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
// user cancelled, nothing to do
} else {
dispatch(
alertsShowAlert('alerts', 'unexpectedError', {
error: ensureError(err),
}),
);
}
}
}, [dispatch, onCustomFirmwareZip]);
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key !== 'Enter' && e.key !== ' ') {
return;
}
e.stopPropagation();
onClick();
},
[onClick],
);
const { getRootProps, getInputProps } = useDropzone({
accept: { 'application/zip': ['.zip'] },
multiple: false,
// react-dropzone doesn't allow full control of File System API, so we
// implement our own using browser-fs-access instead.
noClick: true,
onDrop,
});
return (
{isCustomFirmwareRequested ? (
<>
{i18n.translate('selectHubPanel.customFirmware.message')}
{i18n.translate('selectHubPanel.customFirmware.hubType', {
hubTypeName: getHubTypeNameFromMetadata(
customFirmwareData?.metadata,
),
})}
{i18n.translate(
'selectHubPanel.customFirmware.firmwareVersion',
{
version:
customFirmwareData?.metadata['firmware-version'],
},
)}
>
) : (
<>
{i18n.translate('selectHubPanel.message')}
}
renderTarget={({ isOpen: _isOpen, ref, ...targetProps }) => (
)}
/>
>
)}
);
};
type AcceptLicensePanelProps = {
licenseAccepted: boolean;
firmwareData: FirmwareData | undefined;
firmwareError: Error | undefined;
isCustomFirmwareRequested: boolean;
customFirmwareData: FirmwareData | undefined;
customFirmwareError: Error | undefined;
onLicenseAcceptedChanged: (accepted: boolean) => void;
};
const AcceptLicensePanel: React.VoidFunctionComponent = ({
licenseAccepted,
firmwareData,
firmwareError,
isCustomFirmwareRequested,
customFirmwareData,
customFirmwareError,
onLicenseAcceptedChanged,
}) => {
const i18n = useI18n();
const selectedFirmwareData = isCustomFirmwareRequested
? customFirmwareData
: firmwareData;
const selectedFirmwareError = isCustomFirmwareRequested
? customFirmwareError
: firmwareError;
return (
{selectedFirmwareData ? (
{selectedFirmwareData.licenseText}
) : (
}
description={
selectedFirmwareError
? i18n.translate('licensePanel.licenseText.error')
: undefined
}
/>
)}
onLicenseAcceptedChanged(e.currentTarget.checked)}
disabled={!selectedFirmwareData}
/>
);
};
type SelectOptionsPanelProps = {
hubName: string;
metadata: FirmwareMetadata | undefined;
onChangeHubName(hubName: string): void;
};
const ConfigureOptionsPanel: React.VoidFunctionComponent = ({
hubName,
metadata,
onChangeHubName,
}) => {
const i18n = useI18n();
const isHubNameValid = metadata ? validateHubName(hubName, metadata) : true;
return (
onChangeHubName(e.currentTarget.value)}
onMouseOver={(e) => e.preventDefault()}
onMouseDown={(e) => e.stopPropagation()}
intent={isHubNameValid ? Intent.NONE : Intent.DANGER}
placeholder="Pybricks Hub"
rightElement={
isHubNameValid ? undefined : (
)
}
/>
);
};
type BootloaderModePanelProps = {
hubType: Hub;
};
const BootloaderModePanel: React.VoidFunctionComponent = ({
hubType,
}) => {
const i18n = useI18n();
return (
);
};
export const InstallPybricksDialog: React.VoidFunctionComponent = () => {
const { isOpen } = useSelector((s) => s.firmware.installPybricksDialog);
const dispatch = useDispatch();
const [hubName, setHubName] = useState('');
const [licenseAccepted, setLicenseAccepted] = useState(false);
const [hubType] = useHubPickerSelectedHub();
const { firmwareData, firmwareError } = useFirmware(hubType);
const [customFirmwareZip, setCustomFirmwareZip] = useState();
const { isCustomFirmwareRequested, customFirmwareData, customFirmwareError } =
useCustomFirmware(customFirmwareZip);
const i18n = useI18n();
const selectedFirmwareData = isCustomFirmwareRequested
? customFirmwareData
: firmwareData;
const selectedHubType = isCustomFirmwareRequested
? getHubTypeFromMetadata(customFirmwareData?.metadata, hubType)
: hubType;
return (
dispatch(firmwareInstallPybricksDialogCancel())}
backButtonProps={{ text: i18n.translate('backButton.label') }}
nextButtonProps={{ text: i18n.translate('nextButton.label') }}
finalButtonProps={{
text: i18n.translate('flashFirmwareButton.label'),
onClick: () =>
dispatch(
firmwareInstallPybricksDialogAccept(
hubBootloaderType(selectedHubType),
selectedFirmwareData?.firmwareZip ?? new ArrayBuffer(0),
hubName,
),
),
}}
>
}
/>
}
nextButtonProps={{
disabled: !licenseAccepted,
text: i18n.translate('nextButton.label'),
}}
/>
}
/>
}
/>
);
};