/* * Copyright 2016 balena.io * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * 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 { ipcRenderer, IpcRendererEvent } from 'electron'; import * as _ from 'lodash'; import { GPTPartition, MBRPartition } from 'partitioninfo'; import * as path from 'path'; import * as prettyBytes from 'pretty-bytes'; import * as React from 'react'; import { Flex, ButtonProps, Modal as SmallModal, Txt, Card as BaseCard, Input, Spinner, } from 'rendition'; import styled from 'styled-components'; import * as errors from '../../../../shared/errors'; import * as messages from '../../../../shared/messages'; import * as supportedFormats from '../../../../shared/supported-formats'; import * as selectionState from '../../models/selection-state'; import { observe } from '../../models/store'; import * as analytics from '../../modules/analytics'; import * as exceptionReporter from '../../modules/exception-reporter'; import * as osDialog from '../../os/dialog'; import { replaceWindowsNetworkDriveLetter } from '../../os/windows-network-drives'; import { ChangeButton, DetailsText, Modal, StepButton, StepNameButton, ScrollableFlex, } from '../../styled-components'; import { colors } from '../../theme'; 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'; import { DrivelistDrive } from '../../../../shared/drive-constraints'; const recentUrlImagesKey = 'recentUrlImages'; function normalizeRecentUrlImages(urls: any[]): URL[] { if (!Array.isArray(urls)) { urls = []; } urls = urls .map((url) => { try { return new URL(url); } catch (error) { // Invalid URL, skip } }) .filter((url) => url !== undefined); urls = _.uniqBy(urls, (url) => url.href); return urls.slice(urls.length - 5); } function getRecentUrlImages(): URL[] { let urls = []; try { urls = JSON.parse(localStorage.getItem(recentUrlImagesKey) || '[]'); } catch { // noop } return normalizeRecentUrlImages(urls); } function setRecentUrlImages(urls: URL[]) { const normalized = normalizeRecentUrlImages(urls.map((url: URL) => url.href)); localStorage.setItem(recentUrlImagesKey, JSON.stringify(normalized)); } const isURL = (imagePath: string) => imagePath.startsWith('https://') || imagePath.startsWith('http://'); const Card = styled(BaseCard)` hr { margin: 5px 0; } `; // TODO move these styles to rendition const ModalText = styled.p` a { color: rgb(0, 174, 239); &:hover { color: rgb(0, 139, 191); } } `; function getState() { return { hasImage: selectionState.hasImage(), imageName: selectionState.getImageName(), imageSize: selectionState.getImageSize(), }; } function isString(value: any): value is string { return typeof value === 'string'; } const URLSelector = ({ done, cancel, }: { done: (imageURL: string) => void; cancel: () => void; }) => { const [imageURL, setImageURL] = React.useState(''); const [recentImages, setRecentImages] = React.useState([]); const [loading, setLoading] = React.useState(false); React.useEffect(() => { const fetchRecentUrlImages = async () => { const recentUrlImages: URL[] = await getRecentUrlImages(); setRecentImages(recentUrlImages); }; fetchRecentUrlImages(); }, []); return ( : 'OK'} done={async () => { setLoading(true); const urlStrings = recentImages.map((url: URL) => url.href); const normalizedRecentUrls = normalizeRecentUrlImages([ ...urlStrings, imageURL, ]); setRecentUrlImages(normalizedRecentUrls); await done(imageURL); }} > Use Image URL ) => setImageURL(evt.target.value) } /> {recentImages.length > 0 && ( Recent ( { setImageURL(recent.href); }} style={{ overflowWrap: 'break-word', }} > {recent.pathname.split('/').pop()} - {recent.href} )) .reverse()} /> )} ); }; interface Flow { icon?: JSX.Element; onClick: (evt: React.MouseEvent) => void; label: string; } const FlowSelector = styled( ({ flow, ...props }: { flow: Flow; props?: ButtonProps }) => ( flow.onClick(evt)} icon={flow.icon} {...props} > {flow.label} ), )` border-radius: 24px; color: rgba(255, 255, 255, 0.7); :enabled:focus, :enabled:focus svg { color: ${colors.primary.foreground} !important; } :enabled:hover { background-color: ${colors.primary.background}; color: ${colors.primary.foreground}; font-weight: 600; svg { color: ${colors.primary.foreground}!important; } } `; export type Source = | typeof sourceDestination.File | typeof sourceDestination.BlockDevice | typeof sourceDestination.Http; export interface SourceMetadata extends sourceDestination.Metadata { hasMBR?: boolean; partitions?: MBRPartition[] | GPTPartition[]; path: string; displayName: string; description: string; SourceType: Source; drive?: DrivelistDrive; extension?: string; archiveExtension?: string; } interface SourceSelectorProps { flashing: boolean; } interface SourceSelectorState { hasImage: boolean; imageName?: string; imageSize?: number; warning: { message: string; title: string | null } | null; showImageDetails: boolean; showURLSelector: boolean; showDriveSelector: boolean; } export class SourceSelector extends React.Component< SourceSelectorProps, SourceSelectorState > { private unsubscribe: (() => void) | undefined; constructor(props: SourceSelectorProps) { super(props); this.state = { ...getState(), warning: null, showImageDetails: false, showURLSelector: false, showDriveSelector: false, }; } public componentDidMount() { this.unsubscribe = observe(() => { this.setState(getState()); }); ipcRenderer.on('select-image', this.onSelectImage); ipcRenderer.send('source-selector-ready'); } public componentWillUnmount() { this.unsubscribe?.(); ipcRenderer.removeListener('select-image', this.onSelectImage); } private async onSelectImage(_event: IpcRendererEvent, imagePath: string) { await this.selectSource( imagePath, isURL(imagePath) ? sourceDestination.Http : sourceDestination.File, ).promise; } 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(), }); selectionState.deselectImage(); } private selectSource( selected: string | DrivelistDrive, SourceType: Source, ): { promise: Promise; cancel: () => void } { let cancelled = false; return { cancel: () => { cancelled = true; }, promise: (async () => { const sourcePath = isString(selected) ? selected : selected.device; let source; let metadata: SourceMetadata | undefined; if (isString(selected)) { 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', }, }); } source = await this.createSource(selected, SourceType); if (cancelled) { return; } try { const innerSource = await source.getInnerSource(); if (cancelled) { return; } metadata = await this.getMetadata(innerSource, selected); if (cancelled) { return; } metadata.SourceType = SourceType; if (!metadata.hasMBR) { analytics.logEvent('Missing partition table', { metadata }); this.setState({ warning: { message: messages.warning.missingPartitionTable(), title: 'Missing partition table', }, }); } } catch (error) { 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, displayName: selected.displayName, description: selected.displayName, size: selected.size as SourceMetadata['size'], 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?: Error, ) { 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, selected: string | DrivelistDrive, ) { 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; } if (isString(selected)) { metadata.extension = path.extname(selected).slice(1); metadata.path = selected; } return metadata; } private async openImageSelector() { analytics.logEvent('Open image selector'); try { const imagePath = await osDialog.selectImage(); // Avoid analytics and selection state changes // if no file was resolved from the dialog. if (!imagePath) { analytics.logEvent('Image selector closed'); return; } await this.selectSource(imagePath, sourceDestination.File).promise; } catch (error) { exceptionReporter.report(error); } } private async onDrop(event: React.DragEvent) { const [file] = event.dataTransfer.files; if (file) { await this.selectSource(file.path, sourceDestination.File).promise; } } private openURLSelector() { analytics.logEvent('Open image URL selector'); this.setState({ showURLSelector: true, }); } 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(); } private onDragEnter(event: React.DragEvent) { // Needed to get onDrop events on div elements event.preventDefault(); } private showSelectedImageDetails() { analytics.logEvent('Show selected image tooltip', { imagePath: selectionState.getImagePath(), }); this.setState({ showImageDetails: true, }); } // TODO add a visual change when dragging a file over the selector public render() { const { flashing } = this.props; const { showImageDetails, showURLSelector, showDriveSelector } = this.state; const selectionImage = selectionState.getImage(); let image: SourceMetadata | DrivelistDrive = selectionImage !== undefined ? selectionImage : ({} as SourceMetadata); image = image.drive ?? image; let cancelURLSelection = () => { // noop }; image.name = image.description || image.name; const imagePath = image.path || image.displayName || ''; const imageBasename = path.basename(imagePath); 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) } > {selectionImage !== undefined ? ( <> this.showSelectedImageDetails()} tooltip={imageName || imageBasename} > {middleEllipsis(imageName || imageBasename, 20)} {!flashing && ( this.reselectSource()} > Remove )} {!_.isNil(imageSize) && ( {prettyBytes(imageSize)} )} ) : ( <> this.openImageSelector(), label: 'Flash from file', icon: , }} /> this.openURLSelector(), label: 'Flash from URL', icon: , }} /> this.openDriveSelector(), label: 'Clone drive', icon: , }} /> )} {this.state.warning != null && ( {' '} {this.state.warning.title} } action="Continue" cancel={() => { this.setState({ warning: null }); this.reselectSource(); }} done={() => { this.setState({ warning: null }); }} primaryButtonProps={{ warning: true, primary: false }} > )} {showImageDetails && ( { this.setState({ showImageDetails: false }); }} > Name: {imageName || imageBasename} Path: {imagePath} )} {showURLSelector && ( { cancelURLSelection(); this.setState({ showURLSelector: false, }); }} done={async (imageURL: string) => { // Avoid analytics and selection state changes // if no file was resolved from the dialog. if (!imageURL) { analytics.logEvent('URL selector closed'); } else { let promise; ({ promise, cancel: cancelURLSelection } = this.selectSource( imageURL, sourceDestination.Http, )); await promise; } this.setState({ showURLSelector: false, }); }} /> )} {showDriveSelector && ( { this.setState({ showDriveSelector: false, }); }} done={async (drives: DrivelistDrive[]) => { if (drives.length) { await this.selectSource( drives[0], sourceDestination.BlockDevice, ); } this.setState({ showDriveSelector: false, }); }} /> )} ); } }