mirror of
https://github.com/balena-io/etcher.git
synced 2025-07-29 06:06:33 +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 { Flex, Button, ProgressBar, Txt } from 'rendition';
|
||||||
import { default as styled } from 'styled-components';
|
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';
|
import { StepButton } from '../../styled-components';
|
||||||
|
|
||||||
const FlashProgressBar = styled(ProgressBar)`
|
const FlashProgressBar = styled(ProgressBar)`
|
||||||
@ -44,7 +44,7 @@ const FlashProgressBar = styled(ProgressBar)`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
interface ProgressButtonProps {
|
interface ProgressButtonProps {
|
||||||
type: 'decompressing' | 'flashing' | 'verifying';
|
type: FlashState['type'];
|
||||||
active: boolean;
|
active: boolean;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
position: number;
|
position: number;
|
||||||
@ -58,6 +58,8 @@ const colors = {
|
|||||||
decompressing: '#00aeef',
|
decompressing: '#00aeef',
|
||||||
flashing: '#da60ff',
|
flashing: '#da60ff',
|
||||||
verifying: '#1ac135',
|
verifying: '#1ac135',
|
||||||
|
downloading: '#00aeef',
|
||||||
|
default: '#00aeef',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const CancelButton = styled(({ type, onClick, ...props }) => {
|
const CancelButton = styled(({ type, onClick, ...props }) => {
|
||||||
@ -78,11 +80,11 @@ const CancelButton = styled(({ type, onClick, ...props }) => {
|
|||||||
|
|
||||||
export class ProgressButton extends React.PureComponent<ProgressButtonProps> {
|
export class ProgressButton extends React.PureComponent<ProgressButtonProps> {
|
||||||
public render() {
|
public render() {
|
||||||
const type = this.props.type;
|
const type = this.props.type || 'default';
|
||||||
const percentage = this.props.percentage;
|
const percentage = this.props.percentage;
|
||||||
const warning = this.props.warning;
|
const warning = this.props.warning;
|
||||||
const { status, position } = fromFlashState({
|
const { status, position } = fromFlashState({
|
||||||
type,
|
type: this.props.type,
|
||||||
percentage,
|
percentage,
|
||||||
position: this.props.position,
|
position: this.props.position,
|
||||||
});
|
});
|
||||||
|
@ -25,15 +25,7 @@ import { GPTPartition, MBRPartition } from 'partitioninfo';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as prettyBytes from 'pretty-bytes';
|
import * as prettyBytes from 'pretty-bytes';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {
|
import { Flex, ButtonProps, Modal as SmallModal, Txt } from 'rendition';
|
||||||
Flex,
|
|
||||||
ButtonProps,
|
|
||||||
Modal as SmallModal,
|
|
||||||
Txt,
|
|
||||||
Card as BaseCard,
|
|
||||||
Input,
|
|
||||||
Spinner,
|
|
||||||
} from 'rendition';
|
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
import * as errors from '../../../../shared/errors';
|
import * as errors from '../../../../shared/errors';
|
||||||
@ -48,62 +40,21 @@ import { replaceWindowsNetworkDriveLetter } from '../../os/windows-network-drive
|
|||||||
import {
|
import {
|
||||||
ChangeButton,
|
ChangeButton,
|
||||||
DetailsText,
|
DetailsText,
|
||||||
Modal,
|
|
||||||
StepButton,
|
StepButton,
|
||||||
StepNameButton,
|
StepNameButton,
|
||||||
ScrollableFlex,
|
|
||||||
} from '../../styled-components';
|
} from '../../styled-components';
|
||||||
import { colors } from '../../theme';
|
import { colors } from '../../theme';
|
||||||
import { middleEllipsis } from '../../utils/middle-ellipsis';
|
import { middleEllipsis } from '../../utils/middle-ellipsis';
|
||||||
|
import URLSelector from '../url-selector/url-selector';
|
||||||
import { SVGIcon } from '../svg-icon/svg-icon';
|
import { SVGIcon } from '../svg-icon/svg-icon';
|
||||||
|
|
||||||
import ImageSvg from '../../../assets/image.svg';
|
import ImageSvg from '../../../assets/image.svg';
|
||||||
import { DriveSelector } from '../drive-selector/drive-selector';
|
import { DriveSelector } from '../drive-selector/drive-selector';
|
||||||
import { DrivelistDrive } from '../../../../shared/drive-constraints';
|
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) =>
|
const isURL = (imagePath: string) =>
|
||||||
imagePath.startsWith('https://') || imagePath.startsWith('http://');
|
imagePath.startsWith('https://') || imagePath.startsWith('http://');
|
||||||
|
|
||||||
const Card = styled(BaseCard)`
|
|
||||||
hr {
|
|
||||||
margin: 5px 0;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// TODO move these styles to rendition
|
// TODO move these styles to rendition
|
||||||
const ModalText = styled.p`
|
const ModalText = styled.p`
|
||||||
a {
|
a {
|
||||||
@ -127,85 +78,6 @@ function isString(value: any): value is string {
|
|||||||
return typeof value === '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 {
|
interface Flow {
|
||||||
icon?: JSX.Element;
|
icon?: JSX.Element;
|
||||||
onClick: (evt: React.MouseEvent) => void;
|
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,
|
* NOTE: The ternary is due to this module being loaded both,
|
||||||
* Electron's main process and renderer process
|
* Electron's main process and renderer process
|
||||||
*/
|
*/
|
||||||
const USER_DATA_DIR = electron.app
|
|
||||||
? electron.app.getPath('userData')
|
const app = electron.app || electron.remote.app;
|
||||||
: electron.remote.app.getPath('userData');
|
|
||||||
|
const USER_DATA_DIR = app.getPath('userData');
|
||||||
|
|
||||||
const CONFIG_PATH = join(USER_DATA_DIR, 'config.json');
|
const CONFIG_PATH = join(USER_DATA_DIR, 'config.json');
|
||||||
|
|
||||||
|
const DOWNLOADS_DIR = app.getPath('downloads');
|
||||||
|
|
||||||
async function readConfigFile(filename: string): Promise<_.Dictionary<any>> {
|
async function readConfigFile(filename: string): Promise<_.Dictionary<any>> {
|
||||||
let contents = '{}';
|
let contents = '{}';
|
||||||
try {
|
try {
|
||||||
@ -83,6 +86,8 @@ const DEFAULT_SETTINGS: _.Dictionary<any> = {
|
|||||||
desktopNotifications: true,
|
desktopNotifications: true,
|
||||||
autoBlockmapping: true,
|
autoBlockmapping: true,
|
||||||
decompressFirst: true,
|
decompressFirst: true,
|
||||||
|
saveUrlImage: false,
|
||||||
|
saveUrlImageTo: DOWNLOADS_DIR,
|
||||||
};
|
};
|
||||||
|
|
||||||
const settings = _.cloneDeep(DEFAULT_SETTINGS);
|
const settings = _.cloneDeep(DEFAULT_SETTINGS);
|
||||||
|
@ -148,6 +148,8 @@ async function performWrite(
|
|||||||
validateWriteOnSuccess,
|
validateWriteOnSuccess,
|
||||||
autoBlockmapping,
|
autoBlockmapping,
|
||||||
decompressFirst,
|
decompressFirst,
|
||||||
|
saveUrlImage,
|
||||||
|
saveUrlImageTo,
|
||||||
} = await settings.getAll();
|
} = await settings.getAll();
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
ipc.server.on('error', (error) => {
|
ipc.server.on('error', (error) => {
|
||||||
@ -206,6 +208,8 @@ async function performWrite(
|
|||||||
autoBlockmapping,
|
autoBlockmapping,
|
||||||
unmountOnSuccess,
|
unmountOnSuccess,
|
||||||
decompressFirst,
|
decompressFirst,
|
||||||
|
saveUrlImage,
|
||||||
|
saveUrlImageTo,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ export interface FlashState {
|
|||||||
percentage?: number;
|
percentage?: number;
|
||||||
speed: number;
|
speed: number;
|
||||||
position: number;
|
position: number;
|
||||||
type?: 'decompressing' | 'flashing' | 'verifying';
|
type?: 'decompressing' | 'flashing' | 'verifying' | 'downloading';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fromFlashState({
|
export function fromFlashState({
|
||||||
@ -62,6 +62,12 @@ export function fromFlashState({
|
|||||||
} else {
|
} else {
|
||||||
return { status: 'Finishing...' };
|
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' };
|
return { status: 'Failed' };
|
||||||
}
|
}
|
||||||
|
@ -40,6 +40,12 @@ async function mountSourceDrive() {
|
|||||||
* Notice that by image, we mean *.img/*.iso/*.zip/etc files.
|
* Notice that by image, we mean *.img/*.iso/*.zip/etc files.
|
||||||
*/
|
*/
|
||||||
export async function selectImage(): Promise<string | undefined> {
|
export async function selectImage(): Promise<string | undefined> {
|
||||||
|
return await openDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openDialog(
|
||||||
|
type: 'openFile' | 'openDirectory' = 'openFile',
|
||||||
|
) {
|
||||||
await mountSourceDrive();
|
await mountSourceDrive();
|
||||||
const options: electron.OpenDialogOptions = {
|
const options: electron.OpenDialogOptions = {
|
||||||
// This variable is set when running in GNU/Linux from
|
// 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
|
// See: https://github.com/probonopd/AppImageKit/commit/1569d6f8540aa6c2c618dbdb5d6fcbf0003952b7
|
||||||
defaultPath: process.env.OWD,
|
defaultPath: process.env.OWD,
|
||||||
properties: ['openFile', 'treatPackageAsDirectory'],
|
properties: [type, 'treatPackageAsDirectory'],
|
||||||
filters: [
|
filters:
|
||||||
{
|
type === 'openFile'
|
||||||
name: 'OS Images',
|
? [
|
||||||
extensions: SUPPORTED_EXTENSIONS,
|
{
|
||||||
},
|
name: 'OS Images',
|
||||||
{
|
extensions: SUPPORTED_EXTENSIONS,
|
||||||
name: 'All',
|
},
|
||||||
extensions: ['*'],
|
{
|
||||||
},
|
name: 'All',
|
||||||
],
|
extensions: ['*'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
const currentWindow = electron.remote.getCurrentWindow();
|
const currentWindow = electron.remote.getCurrentWindow();
|
||||||
const [file] = (
|
const [path] = (
|
||||||
await electron.remote.dialog.showOpenDialog(currentWindow, options)
|
await electron.remote.dialog.showOpenDialog(currentWindow, options)
|
||||||
).filePaths;
|
).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 { Drive as DrivelistDrive } from 'drivelist';
|
||||||
import * as sdk from 'etcher-sdk';
|
import * as sdk from 'etcher-sdk';
|
||||||
import { cleanupTmpFiles } from 'etcher-sdk/build/tmp';
|
import { cleanupTmpFiles } from 'etcher-sdk/build/tmp';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import * as _ from 'lodash';
|
||||||
import * as ipc from 'node-ipc';
|
import * as ipc from 'node-ipc';
|
||||||
import { totalmem } from 'os';
|
import { totalmem } from 'os';
|
||||||
|
|
||||||
@ -154,6 +156,13 @@ interface WriteOptions {
|
|||||||
autoBlockmapping: boolean;
|
autoBlockmapping: boolean;
|
||||||
decompressFirst: boolean;
|
decompressFirst: boolean;
|
||||||
SourceType: string;
|
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, () => {
|
ipc.connectTo(IPC_SERVER_ID, () => {
|
||||||
@ -191,7 +200,7 @@ ipc.connectTo(IPC_SERVER_ID, () => {
|
|||||||
* @example
|
* @example
|
||||||
* writer.on('progress', onProgress)
|
* writer.on('progress', onProgress)
|
||||||
*/
|
*/
|
||||||
const onProgress = (state: sdk.multiWrite.MultiDestinationProgress) => {
|
const onProgress = (state: ProgressState) => {
|
||||||
ipc.of[IPC_SERVER_ID].emit('state', state);
|
ipc.of[IPC_SERVER_ID].emit('state', state);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -269,7 +278,16 @@ ipc.connectTo(IPC_SERVER_ID, () => {
|
|||||||
path: imagePath,
|
path: imagePath,
|
||||||
});
|
});
|
||||||
} else {
|
} 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({
|
const results = await writeAndValidate({
|
||||||
@ -302,3 +320,43 @@ ipc.connectTo(IPC_SERVER_ID, () => {
|
|||||||
ipc.of[IPC_SERVER_ID].emit('ready', {});
|
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