mirror of
https://github.com/balena-io/etcher.git
synced 2025-07-28 21:56:32 +00:00
Add UI option to save images flashed from URLs
Change-type: patch Signed-off-by: Lorenzo Alberto Maria Ambrosi <lorenzothunder.ambrosi@gmail.com>
This commit is contained in:
parent
b4e6970119
commit
b80a6b2feb
@ -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<ProgressButtonProps> {
|
||||
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,
|
||||
});
|
||||
|
@ -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<URL[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
const fetchRecentUrlImages = async () => {
|
||||
const recentUrlImages: URL[] = await getRecentUrlImages();
|
||||
setRecentImages(recentUrlImages);
|
||||
};
|
||||
fetchRecentUrlImages();
|
||||
}, []);
|
||||
return (
|
||||
<Modal
|
||||
cancel={cancel}
|
||||
primaryButtonProps={{
|
||||
disabled: loading || !imageURL,
|
||||
}}
|
||||
action={loading ? <Spinner /> : 'OK'}
|
||||
done={async () => {
|
||||
setLoading(true);
|
||||
const urlStrings = recentImages.map((url: URL) => url.href);
|
||||
const normalizedRecentUrls = normalizeRecentUrlImages([
|
||||
...urlStrings,
|
||||
imageURL,
|
||||
]);
|
||||
setRecentUrlImages(normalizedRecentUrls);
|
||||
await done(imageURL);
|
||||
}}
|
||||
>
|
||||
<Flex flexDirection="column">
|
||||
<Flex style={{ width: '100%' }} flexDirection="column">
|
||||
<Txt mb="10px" fontSize="24px">
|
||||
Use Image URL
|
||||
</Txt>
|
||||
<Input
|
||||
value={imageURL}
|
||||
placeholder="Enter a valid URL"
|
||||
type="text"
|
||||
onChange={(evt: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setImageURL(evt.target.value)
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
{recentImages.length > 0 && (
|
||||
<Flex flexDirection="column" height="78.6%">
|
||||
<Txt fontSize={18}>Recent</Txt>
|
||||
<ScrollableFlex flexDirection="column">
|
||||
<Card
|
||||
p="10px 15px"
|
||||
rows={recentImages
|
||||
.map((recent) => (
|
||||
<Txt
|
||||
key={recent.href}
|
||||
onClick={() => {
|
||||
setImageURL(recent.href);
|
||||
}}
|
||||
style={{
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
>
|
||||
{recent.pathname.split('/').pop()} - {recent.href}
|
||||
</Txt>
|
||||
))
|
||||
.reverse()}
|
||||
/>
|
||||
</ScrollableFlex>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface Flow {
|
||||
icon?: JSX.Element;
|
||||
onClick: (evt: React.MouseEvent) => void;
|
||||
|
167
lib/gui/app/components/url-selector/url-selector.tsx
Normal file
167
lib/gui/app/components/url-selector/url-selector.tsx
Normal file
@ -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<URL[]>([]);
|
||||
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 (
|
||||
<Modal
|
||||
title="Use Image URL"
|
||||
cancel={cancel}
|
||||
primaryButtonProps={{
|
||||
className: loading || !imageURL ? 'disabled' : '',
|
||||
}}
|
||||
done={async () => {
|
||||
setLoading(true);
|
||||
const urlStrings = recentImages
|
||||
.map((url: URL) => url.href)
|
||||
.concat(imageURL);
|
||||
setRecentUrlImages(urlStrings);
|
||||
await done(imageURL);
|
||||
}}
|
||||
>
|
||||
<Flex flexDirection="column">
|
||||
<Flex mb="16px" width="100%" height="auto" flexDirection="column">
|
||||
<Input
|
||||
value={imageURL}
|
||||
placeholder="Enter a valid URL"
|
||||
type="text"
|
||||
onChange={(evt: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setImageURL(evt.target.value)
|
||||
}
|
||||
/>
|
||||
<Flex alignItems="flex-end">
|
||||
<Checkbox
|
||||
mt="16px"
|
||||
checked={saveImage}
|
||||
onChange={(evt) => {
|
||||
const value = evt.target.checked;
|
||||
setSaveImage(value);
|
||||
settings
|
||||
.set(SAVE_IMAGE_AFTER_FLASH_KEY, value)
|
||||
.then(() => setSaveImage(value));
|
||||
}}
|
||||
label={<>Save file to: </>}
|
||||
/>
|
||||
<Link
|
||||
disabled={!saveImage}
|
||||
onClick={async () => {
|
||||
if (saveImage) {
|
||||
const folder = await openDialog('openDirectory');
|
||||
if (folder) {
|
||||
await settings.set(SAVE_IMAGE_AFTER_FLASH_PATH_KEY, folder);
|
||||
setSaveImagePath(folder);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{startEllipsis(saveImagePath, 20)}
|
||||
</Link>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{recentImages.length > 0 && (
|
||||
<Flex flexDirection="column" height="58%">
|
||||
<Txt fontSize={18} mb="10px">
|
||||
Recent
|
||||
</Txt>
|
||||
<ScrollableFlex flexDirection="column" p="0">
|
||||
{recentImages
|
||||
.map((recent, i) => (
|
||||
<RadioButton
|
||||
mb={i !== 0 ? '6px' : '0'}
|
||||
key={recent.href}
|
||||
checked={imageURL === recent.href}
|
||||
label={`${recent.pathname.split('/').pop()} - ${
|
||||
recent.href
|
||||
}`}
|
||||
onChange={() => {
|
||||
setImageURL(recent.href);
|
||||
}}
|
||||
style={{
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
/>
|
||||
))
|
||||
.reverse()}
|
||||
</ScrollableFlex>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default URLSelector;
|
@ -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<any>> {
|
||||
let contents = '{}';
|
||||
try {
|
||||
@ -83,6 +86,8 @@ const DEFAULT_SETTINGS: _.Dictionary<any> = {
|
||||
desktopNotifications: true,
|
||||
autoBlockmapping: true,
|
||||
decompressFirst: true,
|
||||
saveUrlImage: false,
|
||||
saveUrlImageTo: DOWNLOADS_DIR,
|
||||
};
|
||||
|
||||
const settings = _.cloneDeep(DEFAULT_SETTINGS);
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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' };
|
||||
}
|
||||
|
@ -40,6 +40,12 @@ async function mountSourceDrive() {
|
||||
* Notice that by image, we mean *.img/*.iso/*.zip/etc files.
|
||||
*/
|
||||
export async function selectImage(): Promise<string | undefined> {
|
||||
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<string | undefined> {
|
||||
//
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
28
lib/gui/app/utils/start-ellipsis.ts
Normal file
28
lib/gui/app/utils/start-ellipsis.ts
Normal file
@ -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}`;
|
||||
}
|
@ -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<sdk.multiWrite.MultiDestinationProgress, 'type'> {
|
||||
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 });
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user