diff --git a/lib/gui/app/components/progress-button/progress-button.tsx b/lib/gui/app/components/progress-button/progress-button.tsx index 9e328eea..e84eada6 100644 --- a/lib/gui/app/components/progress-button/progress-button.tsx +++ b/lib/gui/app/components/progress-button/progress-button.tsx @@ -18,7 +18,7 @@ import * as React from 'react'; import { Flex, Button, ProgressBar, Txt } from 'rendition'; import { default as styled } from 'styled-components'; -import { fromFlashState } from '../../modules/progress-status'; +import { fromFlashState, FlashState } from '../../modules/progress-status'; import { StepButton } from '../../styled-components'; const FlashProgressBar = styled(ProgressBar)` @@ -44,7 +44,7 @@ const FlashProgressBar = styled(ProgressBar)` `; interface ProgressButtonProps { - type: 'decompressing' | 'flashing' | 'verifying'; + type: FlashState['type']; active: boolean; percentage: number; position: number; @@ -58,6 +58,8 @@ const colors = { decompressing: '#00aeef', flashing: '#da60ff', verifying: '#1ac135', + downloading: '#00aeef', + default: '#00aeef', } as const; const CancelButton = styled(({ type, onClick, ...props }) => { @@ -78,11 +80,11 @@ const CancelButton = styled(({ type, onClick, ...props }) => { export class ProgressButton extends React.PureComponent { public render() { - const type = this.props.type; + const type = this.props.type || 'default'; const percentage = this.props.percentage; const warning = this.props.warning; const { status, position } = fromFlashState({ - type, + type: this.props.type, percentage, position: this.props.position, }); diff --git a/lib/gui/app/components/source-selector/source-selector.tsx b/lib/gui/app/components/source-selector/source-selector.tsx index 437efcf4..b159f3d9 100644 --- a/lib/gui/app/components/source-selector/source-selector.tsx +++ b/lib/gui/app/components/source-selector/source-selector.tsx @@ -25,15 +25,7 @@ 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 { Flex, ButtonProps, Modal as SmallModal, Txt } from 'rendition'; import styled from 'styled-components'; import * as errors from '../../../../shared/errors'; @@ -48,62 +40,21 @@ import { replaceWindowsNetworkDriveLetter } from '../../os/windows-network-drive import { ChangeButton, DetailsText, - Modal, StepButton, StepNameButton, - ScrollableFlex, } from '../../styled-components'; import { colors } from '../../theme'; import { middleEllipsis } from '../../utils/middle-ellipsis'; +import URLSelector from '../url-selector/url-selector'; 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 { @@ -127,85 +78,6 @@ 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; diff --git a/lib/gui/app/components/url-selector/url-selector.tsx b/lib/gui/app/components/url-selector/url-selector.tsx new file mode 100644 index 00000000..2a013af0 --- /dev/null +++ b/lib/gui/app/components/url-selector/url-selector.tsx @@ -0,0 +1,167 @@ +import { uniqBy } from 'lodash'; +import * as React from 'react'; +import Checkbox from 'rendition/dist_esm5/components/Checkbox'; +import { Flex } from 'rendition/dist_esm5/components/Flex'; +import Input from 'rendition/dist_esm5/components/Input'; +import Link from 'rendition/dist_esm5/components/Link'; +import RadioButton from 'rendition/dist_esm5/components/RadioButton'; +import Txt from 'rendition/dist_esm5/components/Txt'; + +import * as settings from '../../models/settings'; +import { Modal, ScrollableFlex } from '../../styled-components'; +import { openDialog } from '../../os/dialog'; +import { startEllipsis } from '../../utils/start-ellipsis'; + +const RECENT_URL_IMAGES_KEY = 'recentUrlImages'; +const SAVE_IMAGE_AFTER_FLASH_KEY = 'saveUrlImage'; +const SAVE_IMAGE_AFTER_FLASH_PATH_KEY = 'saveUrlImageTo'; + +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(-5); +} + +function getRecentUrlImages(): URL[] { + let urls = []; + try { + urls = JSON.parse(localStorage.getItem(RECENT_URL_IMAGES_KEY) || '[]'); + } catch { + // noop + } + return normalizeRecentUrlImages(urls); +} + +function setRecentUrlImages(urls: string[]) { + localStorage.setItem(RECENT_URL_IMAGES_KEY, JSON.stringify(urls)); +} + +export 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); + const [saveImage, setSaveImage] = React.useState(false); + const [saveImagePath, setSaveImagePath] = React.useState(''); + React.useEffect(() => { + const fetchRecentUrlImages = async () => { + const recentUrlImages: URL[] = await getRecentUrlImages(); + setRecentImages(recentUrlImages); + }; + const getSaveImageSettings = async () => { + const saveUrlImage: boolean = await settings.get( + SAVE_IMAGE_AFTER_FLASH_KEY, + ); + const saveUrlImageToPath: string = await settings.get( + SAVE_IMAGE_AFTER_FLASH_PATH_KEY, + ); + setSaveImage(saveUrlImage); + setSaveImagePath(saveUrlImageToPath); + }; + fetchRecentUrlImages(); + getSaveImageSettings(); + }, []); + return ( + { + setLoading(true); + const urlStrings = recentImages + .map((url: URL) => url.href) + .concat(imageURL); + setRecentUrlImages(urlStrings); + await done(imageURL); + }} + > + + + ) => + setImageURL(evt.target.value) + } + /> + + { + const value = evt.target.checked; + setSaveImage(value); + settings + .set(SAVE_IMAGE_AFTER_FLASH_KEY, value) + .then(() => setSaveImage(value)); + }} + label={<>Save file to: } + /> + { + if (saveImage) { + const folder = await openDialog('openDirectory'); + if (folder) { + await settings.set(SAVE_IMAGE_AFTER_FLASH_PATH_KEY, folder); + setSaveImagePath(folder); + } + } + }} + > + {startEllipsis(saveImagePath, 20)} + + + + {recentImages.length > 0 && ( + + + Recent + + + {recentImages + .map((recent, i) => ( + { + setImageURL(recent.href); + }} + style={{ + overflowWrap: 'break-word', + }} + /> + )) + .reverse()} + + + )} + + + ); +}; + +export default URLSelector; diff --git a/lib/gui/app/models/settings.ts b/lib/gui/app/models/settings.ts index 8bfc9106..ecbf157b 100644 --- a/lib/gui/app/models/settings.ts +++ b/lib/gui/app/models/settings.ts @@ -41,12 +41,15 @@ export const DEFAULT_HEIGHT = 480; * NOTE: The ternary is due to this module being loaded both, * Electron's main process and renderer process */ -const USER_DATA_DIR = electron.app - ? electron.app.getPath('userData') - : electron.remote.app.getPath('userData'); + +const app = electron.app || electron.remote.app; + +const USER_DATA_DIR = app.getPath('userData'); const CONFIG_PATH = join(USER_DATA_DIR, 'config.json'); +const DOWNLOADS_DIR = app.getPath('downloads'); + async function readConfigFile(filename: string): Promise<_.Dictionary> { let contents = '{}'; try { @@ -83,6 +86,8 @@ const DEFAULT_SETTINGS: _.Dictionary = { desktopNotifications: true, autoBlockmapping: true, decompressFirst: true, + saveUrlImage: false, + saveUrlImageTo: DOWNLOADS_DIR, }; const settings = _.cloneDeep(DEFAULT_SETTINGS); diff --git a/lib/gui/app/modules/image-writer.ts b/lib/gui/app/modules/image-writer.ts index 175de132..8720f99b 100644 --- a/lib/gui/app/modules/image-writer.ts +++ b/lib/gui/app/modules/image-writer.ts @@ -148,6 +148,8 @@ async function performWrite( validateWriteOnSuccess, autoBlockmapping, decompressFirst, + saveUrlImage, + saveUrlImageTo, } = await settings.getAll(); return await new Promise((resolve, reject) => { ipc.server.on('error', (error) => { @@ -206,6 +208,8 @@ async function performWrite( autoBlockmapping, unmountOnSuccess, decompressFirst, + saveUrlImage, + saveUrlImageTo, }); }); diff --git a/lib/gui/app/modules/progress-status.ts b/lib/gui/app/modules/progress-status.ts index 6c48b2c2..9c6b0c01 100644 --- a/lib/gui/app/modules/progress-status.ts +++ b/lib/gui/app/modules/progress-status.ts @@ -22,7 +22,7 @@ export interface FlashState { percentage?: number; speed: number; position: number; - type?: 'decompressing' | 'flashing' | 'verifying'; + type?: 'decompressing' | 'flashing' | 'verifying' | 'downloading'; } export function fromFlashState({ @@ -62,6 +62,12 @@ export function fromFlashState({ } else { return { status: 'Finishing...' }; } + } else if (type === 'downloading') { + if (percentage == null) { + return { status: 'Downloading...' }; + } else if (percentage < 100) { + return { position: `${percentage}%`, status: 'Downloading...' }; + } } return { status: 'Failed' }; } diff --git a/lib/gui/app/os/dialog.ts b/lib/gui/app/os/dialog.ts index ce906265..506b31b3 100644 --- a/lib/gui/app/os/dialog.ts +++ b/lib/gui/app/os/dialog.ts @@ -40,6 +40,12 @@ async function mountSourceDrive() { * Notice that by image, we mean *.img/*.iso/*.zip/etc files. */ export async function selectImage(): Promise { + return await openDialog(); +} + +export async function openDialog( + type: 'openFile' | 'openDirectory' = 'openFile', +) { await mountSourceDrive(); const options: electron.OpenDialogOptions = { // This variable is set when running in GNU/Linux from @@ -50,23 +56,26 @@ export async function selectImage(): Promise { // // See: https://github.com/probonopd/AppImageKit/commit/1569d6f8540aa6c2c618dbdb5d6fcbf0003952b7 defaultPath: process.env.OWD, - properties: ['openFile', 'treatPackageAsDirectory'], - filters: [ - { - name: 'OS Images', - extensions: SUPPORTED_EXTENSIONS, - }, - { - name: 'All', - extensions: ['*'], - }, - ], + properties: [type, 'treatPackageAsDirectory'], + filters: + type === 'openFile' + ? [ + { + name: 'OS Images', + extensions: SUPPORTED_EXTENSIONS, + }, + { + name: 'All', + extensions: ['*'], + }, + ] + : undefined, }; const currentWindow = electron.remote.getCurrentWindow(); - const [file] = ( + const [path] = ( await electron.remote.dialog.showOpenDialog(currentWindow, options) ).filePaths; - return file; + return path; } /** diff --git a/lib/gui/app/utils/start-ellipsis.ts b/lib/gui/app/utils/start-ellipsis.ts new file mode 100644 index 00000000..45d0710a --- /dev/null +++ b/lib/gui/app/utils/start-ellipsis.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2020 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. + */ + +/** + * @summary Truncate text from the start with an ellipsis + */ +export function startEllipsis(input: string, limit: number): string { + // Do nothing, the string doesn't need truncation. + if (input.length <= limit) { + return input; + } + + const lastPart = input.slice(input.length - limit, input.length); + return `…${lastPart}`; +} diff --git a/lib/gui/modules/child-writer.ts b/lib/gui/modules/child-writer.ts index dda3c326..bf466342 100644 --- a/lib/gui/modules/child-writer.ts +++ b/lib/gui/modules/child-writer.ts @@ -17,6 +17,8 @@ import { Drive as DrivelistDrive } from 'drivelist'; import * as sdk from 'etcher-sdk'; import { cleanupTmpFiles } from 'etcher-sdk/build/tmp'; +import { promises as fs } from 'fs'; +import * as _ from 'lodash'; import * as ipc from 'node-ipc'; import { totalmem } from 'os'; @@ -154,6 +156,13 @@ interface WriteOptions { autoBlockmapping: boolean; decompressFirst: boolean; SourceType: string; + saveUrlImage: boolean; + saveUrlImageTo: string; +} + +interface ProgressState + extends Omit { + type: sdk.multiWrite.MultiDestinationProgress['type'] | 'downloading'; } ipc.connectTo(IPC_SERVER_ID, () => { @@ -191,7 +200,7 @@ ipc.connectTo(IPC_SERVER_ID, () => { * @example * writer.on('progress', onProgress) */ - const onProgress = (state: sdk.multiWrite.MultiDestinationProgress) => { + const onProgress = (state: ProgressState) => { ipc.of[IPC_SERVER_ID].emit('state', state); }; @@ -269,7 +278,16 @@ ipc.connectTo(IPC_SERVER_ID, () => { path: imagePath, }); } else { - source = new Http({ url: imagePath, avoidRandomAccess: true }); + if (options.saveUrlImage) { + source = await saveFileBeforeFlash( + imagePath, + options.saveUrlImageTo, + onProgress, + onFail, + ); + } else { + source = new Http({ url: imagePath, avoidRandomAccess: true }); + } } } const results = await writeAndValidate({ @@ -302,3 +320,43 @@ ipc.connectTo(IPC_SERVER_ID, () => { ipc.of[IPC_SERVER_ID].emit('ready', {}); }); }); + +async function saveFileBeforeFlash( + imagePath: string, + saveUrlImageTo: string, + onProgress: (state: ProgressState) => void, + onFail: ( + destination: sdk.sourceDestination.SourceDestination, + error: Error, + ) => void, +) { + const urlImage = new Http({ url: imagePath, avoidRandomAccess: true }); + const source = await urlImage.getInnerSource(); + const metadata = await source.getMetadata(); + const fileName = `${saveUrlImageTo}/${metadata.name}`; + let alreadyDownloaded = false; + try { + alreadyDownloaded = (await fs.stat(fileName)).isFile(); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + if (!alreadyDownloaded) { + await sdk.multiWrite.decompressThenFlash({ + source, + destinations: [new File({ path: fileName, write: true })], + onProgress: (progress) => { + onProgress({ + ...progress, + type: 'downloading', + }); + }, + onFail: (...args) => { + onFail(...args); + }, + verify: true, + }); + } + return new File({ path: fileName }); +}