// 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.poweredUp.title')}

{i18n.translate('selectHubPanel.notOnListButton.info.poweredUp.intro')}

); }; 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 }) => ( )} /> )}
{i18n.translate( 'selectHubPanel.advanced.customFirmwareDropzone.label', )}
); }; 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'), }} /> } /> } /> ); };