feat: make i18n and add Chinese support

- make i18n using i18next
- add Chinese (Simplified) support
This commit is contained in:
r-q 2022-07-19 23:33:42 +08:00 committed by Anton Belodedenko
parent 4b786b8a9f
commit db1bf7e488
23 changed files with 597 additions and 171 deletions

View File

@ -7,7 +7,8 @@ afterSign: ./afterSignHook.js
asar: false asar: false
files: files:
- generated - generated
- lib/shared/catalina-sudo/sudo-askpass.osascript.js - lib/shared/catalina-sudo/sudo-askpass.osascript-zh.js
- lib/shared/catalina-sudo/sudo-askpass.osascript-en.js
mac: mac:
icon: assets/icon.icns icon: assets/icon.icns
category: public.app-category.developer-tools category: public.app-category.developer-tools

View File

@ -38,6 +38,7 @@ import * as osDialog from './os/dialog';
import * as windowProgress from './os/window-progress'; import * as windowProgress from './os/window-progress';
import MainPage from './pages/main/MainPage'; import MainPage from './pages/main/MainPage';
import './css/main.css'; import './css/main.css';
import i18next from 'i18next';
window.addEventListener( window.addEventListener(
'unhandledrejection', 'unhandledrejection',
@ -313,9 +314,9 @@ window.addEventListener('beforeunload', async (event) => {
try { try {
const confirmed = await osDialog.showWarning({ const confirmed = await osDialog.showWarning({
confirmationLabel: 'Yes, quit', confirmationLabel: i18next.t('yesExit'),
rejectionLabel: 'Cancel', rejectionLabel: i18next.t('cancel'),
title: 'Are you sure you want to close Etcher?', title: i18next.t('reallyExit'),
description: messages.warning.exitWhileFlashing(), description: messages.warning.exitWhileFlashing(),
}); });
if (confirmed) { if (confirmed) {

View File

@ -44,6 +44,7 @@ import {
import { SourceMetadata } from '../source-selector/source-selector'; import { SourceMetadata } from '../source-selector/source-selector';
import { middleEllipsis } from '../../utils/middle-ellipsis'; import { middleEllipsis } from '../../utils/middle-ellipsis';
import i18next from 'i18next';
interface UsbbootDrive extends sourceDestination.UsbbootDrive { interface UsbbootDrive extends sourceDestination.UsbbootDrive {
progress: number; progress: number;
@ -189,7 +190,7 @@ export class DriveSelector extends React.Component<
this.tableColumns = [ this.tableColumns = [
{ {
field: 'description', field: 'description',
label: 'Name', label: i18next.t('drives.name'),
render: (description: string, drive: Drive) => { render: (description: string, drive: Drive) => {
if (isDrivelistDrive(drive)) { if (isDrivelistDrive(drive)) {
const isLargeDrive = isDriveSizeLarge(drive); const isLargeDrive = isDriveSizeLarge(drive);
@ -215,7 +216,7 @@ export class DriveSelector extends React.Component<
{ {
field: 'description', field: 'description',
key: 'size', key: 'size',
label: 'Size', label: i18next.t('drives.size'),
render: (_description: string, drive: Drive) => { render: (_description: string, drive: Drive) => {
if (isDrivelistDrive(drive) && drive.size !== null) { if (isDrivelistDrive(drive) && drive.size !== null) {
return prettyBytes(drive.size); return prettyBytes(drive.size);
@ -225,7 +226,7 @@ export class DriveSelector extends React.Component<
{ {
field: 'description', field: 'description',
key: 'link', key: 'link',
label: 'Location', label: i18next.t('drives.location'),
render: (_description: string, drive: Drive) => { render: (_description: string, drive: Drive) => {
return ( return (
<Txt> <Txt>
@ -399,14 +400,14 @@ export class DriveSelector extends React.Component<
color="#5b82a7" color="#5b82a7"
style={{ fontWeight: 600 }} style={{ fontWeight: 600 }}
> >
{drives.length} found {i18next.t('drives.find', { length: drives.length })}
</Txt> </Txt>
</Flex> </Flex>
} }
titleDetails={<Txt fontSize={11}>{getDrives().length} found</Txt>} titleDetails={<Txt fontSize={11}>{getDrives().length} found</Txt>}
cancel={() => cancel(this.originalList)} cancel={() => cancel(this.originalList)}
done={() => done(selectedList)} done={() => done(selectedList)}
action={`Select (${selectedList.length})`} action={i18next.t('drives.select', { select: selectedList.length })}
primaryButtonProps={{ primaryButtonProps={{
primary: !showWarnings, primary: !showWarnings,
warning: showWarnings, warning: showWarnings,
@ -512,7 +513,11 @@ export class DriveSelector extends React.Component<
> >
<Flex alignItems="center"> <Flex alignItems="center">
<ChevronDownSvg height="1em" fill="currentColor" /> <ChevronDownSvg height="1em" fill="currentColor" />
<Txt ml={8}>Show {numberOfHiddenSystemDrives} hidden</Txt> <Txt ml={8}>
{i18next.t('drives.showHidden', {
num: numberOfHiddenSystemDrives,
})}
</Txt>
</Flex> </Flex>
</Link> </Link>
)} )}
@ -520,7 +525,7 @@ export class DriveSelector extends React.Component<
)} )}
{this.props.showWarnings && hasSystemDrives ? ( {this.props.showWarnings && hasSystemDrives ? (
<Alert className="system-drive-alert" style={{ width: '67%' }}> <Alert className="system-drive-alert" style={{ width: '67%' }}>
Selecting your system drive is dangerous and will erase your drive! {i18next.t('drives.systemDriveDanger')}
</Alert> </Alert>
) : null} ) : null}
@ -540,13 +545,15 @@ export class DriveSelector extends React.Component<
this.setState({ missingDriversModal: {} }); this.setState({ missingDriversModal: {} });
} }
}} }}
action="Yes, continue" action={i18next.t('yesContinue')}
cancelButtonProps={{ cancelButtonProps={{
children: 'Cancel', children: i18next.t('cancel'),
}} }}
children={ children={
missingDriversModal.drive.linkMessage || missingDriversModal.drive.linkMessage ||
`Etcher will open ${missingDriversModal.drive.link} in your browser` i18next.t('drives.openInBrowser', {
link: missingDriversModal.drive.link,
})
} }
/> />
)} )}

View File

@ -7,6 +7,7 @@ import { middleEllipsis } from '../../utils/middle-ellipsis';
import * as prettyBytes from 'pretty-bytes'; import * as prettyBytes from 'pretty-bytes';
import { DriveWithWarnings } from '../../pages/main/Flash'; import { DriveWithWarnings } from '../../pages/main/Flash';
import i18next from 'i18next';
const DriveStatusWarningModal = ({ const DriveStatusWarningModal = ({
done, done,
@ -17,12 +18,12 @@ const DriveStatusWarningModal = ({
isSystem: boolean; isSystem: boolean;
drivesWithWarnings: DriveWithWarnings[]; drivesWithWarnings: DriveWithWarnings[];
}) => { }) => {
let warningSubtitle = 'You are about to erase an unusually large drive'; let warningSubtitle = i18next.t('drives.largeDriveWarning');
let warningCta = 'Are you sure the selected drive is not a storage drive?'; let warningCta = i18next.t('drives.largeDriveWarningMsg');
if (isSystem) { if (isSystem) {
warningSubtitle = "You are about to erase your computer's drives"; warningSubtitle = i18next.t('drives.systemDriveWarning');
warningCta = 'Are you sure you want to flash your system drive?'; warningCta = i18next.t('drives.systemDriveWarningMsg');
} }
return ( return (
<Modal <Modal
@ -33,9 +34,9 @@ const DriveStatusWarningModal = ({
cancelButtonProps={{ cancelButtonProps={{
primary: false, primary: false,
warning: true, warning: true,
children: 'Change target', children: i18next.t('drives.changeTarget'),
}} }}
action={"Yes, I'm sure"} action={i18next.t('sure')}
primaryButtonProps={{ primaryButtonProps={{
primary: false, primary: false,
outline: true, outline: true,
@ -50,7 +51,7 @@ const DriveStatusWarningModal = ({
<Flex flexDirection="column"> <Flex flexDirection="column">
<ExclamationTriangleSvg height="2em" fill="#fca321" /> <ExclamationTriangleSvg height="2em" fill="#fca321" />
<Txt fontSize="24px" color="#fca321"> <Txt fontSize="24px" color="#fca321">
WARNING! {i18next.t('warning')}
</Txt> </Txt>
</Flex> </Flex>
<Txt fontSize="24px">{warningSubtitle}</Txt> <Txt fontSize="24px">{warningSubtitle}</Txt>

View File

@ -17,6 +17,7 @@
import * as React from 'react'; import * as React from 'react';
import { BaseButton } from '../../styled-components'; import { BaseButton } from '../../styled-components';
import i18next from 'i18next';
export interface FlashAnotherProps { export interface FlashAnotherProps {
onClick: () => void; onClick: () => void;
@ -25,7 +26,7 @@ export interface FlashAnotherProps {
export const FlashAnother = (props: FlashAnotherProps) => { export const FlashAnother = (props: FlashAnotherProps) => {
return ( return (
<BaseButton primary onClick={props.onClick}> <BaseButton primary onClick={props.onClick}>
Flash another {i18next.t('flash.another')}
</BaseButton> </BaseButton>
); );
}; };

View File

@ -31,6 +31,7 @@ import { resetState } from '../../models/flash-state';
import * as selection from '../../models/selection-state'; import * as selection from '../../models/selection-state';
import { middleEllipsis } from '../../utils/middle-ellipsis'; import { middleEllipsis } from '../../utils/middle-ellipsis';
import { Modal, Table } from '../../styled-components'; import { Modal, Table } from '../../styled-components';
import i18next from 'i18next';
const ErrorsTable = styled((props) => <Table<FlashError> {...props} />)` const ErrorsTable = styled((props) => <Table<FlashError> {...props} />)`
&&& [data-display='table-head'], &&& [data-display='table-head'],
@ -88,15 +89,15 @@ function formattedErrors(errors: FlashError[]) {
const columns: Array<TableColumn<FlashError>> = [ const columns: Array<TableColumn<FlashError>> = [
{ {
field: 'description', field: 'description',
label: 'Target', label: i18next.t('flash.target'),
}, },
{ {
field: 'device', field: 'device',
label: 'Location', label: i18next.t('flash.location'),
}, },
{ {
field: 'message', field: 'message',
label: 'Error', label: i18next.t('flash.error'),
render: (message: string, { code }: FlashError) => { render: (message: string, { code }: FlashError) => {
return message ?? code; return message ?? code;
}, },
@ -162,9 +163,10 @@ export function FlashResults({
<Txt>{middleEllipsis(image, 24)}</Txt> <Txt>{middleEllipsis(image, 24)}</Txt>
</Flex> </Flex>
<Txt fontSize={24} color="#fff" mb="17px"> <Txt fontSize={24} color="#fff" mb="17px">
Flash {allFailed ? 'Failed' : 'Complete'}! {i18next.t('flash.flash')}{' '}
{allFailed ? i18next.t('failed') : i18next.t('completed')}!
</Txt> </Txt>
{skip ? <Txt color="#7e8085">Validation has been skipped</Txt> : null} {skip ? <Txt color="#7e8085">{i18next.t('flash.skip')}</Txt> : null}
</Flex> </Flex>
<Flex flexDirection="column" color="#7e8085"> <Flex flexDirection="column" color="#7e8085">
{results.devices.successful !== 0 ? ( {results.devices.successful !== 0 ? (
@ -188,7 +190,7 @@ export function FlashResults({
{progress.failed(errors.length)} {progress.failed(errors.length)}
</Txt> </Txt>
<Link ml="10px" onClick={() => setShowErrorsInfo(true)}> <Link ml="10px" onClick={() => setShowErrorsInfo(true)}>
more info {i18next.t('flash.moreInfo')}
</Link> </Link>
</Flex> </Flex>
) : null} ) : null}
@ -199,12 +201,9 @@ export function FlashResults({
fontWeight: 500, fontWeight: 500,
textAlign: 'center', textAlign: 'center',
}} }}
tooltip={outdent({ newline: ' ' })` tooltip={i18next.t('flash.speedTip')}
The speed is calculated by dividing the image size by the flashing time.
Disk images with ext partitions flash faster as we are able to skip unused parts.
`}
> >
Effective speed: {effectiveSpeed} MB/s {i18next.t('flash.speed')} {effectiveSpeed} MB/s
</Txt> </Txt>
)} )}
</Flex> </Flex>
@ -214,11 +213,11 @@ export function FlashResults({
titleElement={ titleElement={
<Flex alignItems="baseline" mb={18}> <Flex alignItems="baseline" mb={18}>
<Txt fontSize={24} align="left"> <Txt fontSize={24} align="left">
Failed targets {i18next.t('failedTarget')}
</Txt> </Txt>
</Flex> </Flex>
} }
action="Retry failed targets" action={i18next.t('failedRetry')}
cancel={() => setShowErrorsInfo(false)} cancel={() => setShowErrorsInfo(false)}
done={() => { done={() => {
setShowErrorsInfo(false); setShowErrorsInfo(false);

View File

@ -20,6 +20,7 @@ import { default as styled } from 'styled-components';
import { fromFlashState } from '../../modules/progress-status'; import { fromFlashState } from '../../modules/progress-status';
import { StepButton } from '../../styled-components'; import { StepButton } from '../../styled-components';
import i18next from 'i18next';
const FlashProgressBar = styled(ProgressBar)` const FlashProgressBar = styled(ProgressBar)`
> div { > div {
@ -28,6 +29,7 @@ const FlashProgressBar = styled(ProgressBar)`
color: white !important; color: white !important;
text-shadow: none !important; text-shadow: none !important;
transition-duration: 0s; transition-duration: 0s;
> div { > div {
transition-duration: 0s; transition-duration: 0s;
} }
@ -61,7 +63,7 @@ const colors = {
} as const; } as const;
const CancelButton = styled(({ type, onClick, ...props }) => { const CancelButton = styled(({ type, onClick, ...props }) => {
const status = type === 'verifying' ? 'Skip' : 'Cancel'; const status = type === 'verifying' ? i18next.t('skip') : i18next.t('cancel');
return ( return (
<Button plain onClick={() => onClick(status)} {...props}> <Button plain onClick={() => onClick(status)} {...props}>
{status} {status}
@ -69,6 +71,7 @@ const CancelButton = styled(({ type, onClick, ...props }) => {
); );
})` })`
font-weight: 600; font-weight: 600;
&&& { &&& {
width: auto; width: auto;
height: auto; height: auto;
@ -126,7 +129,7 @@ export class ProgressButton extends React.PureComponent<ProgressButtonProps> {
marginTop: 30, marginTop: 30,
}} }}
> >
Flash! {i18next.t('flash.flashNow')}
</StepButton> </StepButton>
); );
} }

View File

@ -24,6 +24,7 @@ import * as settings from '../../models/settings';
import * as analytics from '../../modules/analytics'; import * as analytics from '../../modules/analytics';
import { open as openExternal } from '../../os/open-external/services/open-external'; import { open as openExternal } from '../../os/open-external/services/open-external';
import { Modal } from '../../styled-components'; import { Modal } from '../../styled-components';
import i18next from 'i18next';
interface Setting { interface Setting {
name: string; name: string;
@ -34,13 +35,13 @@ async function getSettingsList(): Promise<Setting[]> {
const list: Setting[] = [ const list: Setting[] = [
{ {
name: 'errorReporting', name: 'errorReporting',
label: 'Anonymously report errors and usage statistics to balena.io', label: i18next.t('settings.errorReporting'),
}, },
]; ];
if (['appimage', 'nsis', 'dmg'].includes(packageType)) { if (['appimage', 'nsis', 'dmg'].includes(packageType)) {
list.push({ list.push({
name: 'updatesEnabled', name: 'updatesEnabled',
label: 'Auto-updates enabled', label: i18next.t('settings.autoUpdate'),
}); });
} }
return list; return list;
@ -58,6 +59,7 @@ const InfoBox = (props: any) => (
<TextWithCopy code text={props.value} copy={props.value} /> <TextWithCopy code text={props.value} copy={props.value} />
</Box> </Box>
); );
export function SettingsModal({ toggleModal }: SettingsModalProps) { export function SettingsModal({ toggleModal }: SettingsModalProps) {
const [settingsList, setCurrentSettingsList] = React.useState<Setting[]>([]); const [settingsList, setCurrentSettingsList] = React.useState<Setting[]>([]);
React.useEffect(() => { React.useEffect(() => {
@ -92,7 +94,7 @@ export function SettingsModal({ toggleModal }: SettingsModalProps) {
<Modal <Modal
titleElement={ titleElement={
<Txt fontSize={24} mb={24}> <Txt fontSize={24} mb={24}>
Settings {i18next.t('settings.settings')}
</Txt> </Txt>
} }
done={() => toggleModal(false)} done={() => toggleModal(false)}
@ -113,7 +115,7 @@ export function SettingsModal({ toggleModal }: SettingsModalProps) {
})} })}
{UUID !== undefined && ( {UUID !== undefined && (
<Flex flexDirection="column"> <Flex flexDirection="column">
<Txt fontSize={24}>System Information</Txt> <Txt fontSize={24}>{i18next.t('settings.systemInformation')}</Txt>
<InfoBox label="UUID" value={UUID.substr(0, 7)} /> <InfoBox label="UUID" value={UUID.substr(0, 7)} />
</Flex> </Flex>
)} )}

View File

@ -66,6 +66,7 @@ import { DriveSelector } from '../drive-selector/drive-selector';
import { DrivelistDrive } from '../../../../shared/drive-constraints'; import { DrivelistDrive } from '../../../../shared/drive-constraints';
import axios, { AxiosRequestConfig } from 'axios'; import axios, { AxiosRequestConfig } from 'axios';
import { isJson } from '../../../../shared/utils'; import { isJson } from '../../../../shared/utils';
import i18next from 'i18next';
const recentUrlImagesKey = 'recentUrlImages'; const recentUrlImagesKey = 'recentUrlImages';
@ -160,7 +161,7 @@ const URLSelector = ({
primaryButtonProps={{ primaryButtonProps={{
disabled: loading || !imageURL, disabled: loading || !imageURL,
}} }}
action={loading ? <Spinner /> : 'OK'} action={loading ? <Spinner /> : i18next.t('ok')}
done={async () => { done={async () => {
setLoading(true); setLoading(true);
const urlStrings = recentImages.map((url: URL) => url.href); const urlStrings = recentImages.map((url: URL) => url.href);
@ -176,11 +177,11 @@ const URLSelector = ({
<Flex flexDirection="column"> <Flex flexDirection="column">
<Flex mb={15} style={{ width: '100%' }} flexDirection="column"> <Flex mb={15} style={{ width: '100%' }} flexDirection="column">
<Txt mb="10px" fontSize="24px"> <Txt mb="10px" fontSize="24px">
Use Image URL {i18next.t('source.useSourceURL')}
</Txt> </Txt>
<Input <Input
value={imageURL} value={imageURL}
placeholder="Enter a valid URL" placeholder={i18next.t('source.enterValidURL')}
type="text" type="text"
onChange={(evt: React.ChangeEvent<HTMLInputElement>) => onChange={(evt: React.ChangeEvent<HTMLInputElement>) =>
setImageURL(evt.target.value) setImageURL(evt.target.value)
@ -205,7 +206,7 @@ const URLSelector = ({
{!showBasicAuth && ( {!showBasicAuth && (
<ChevronRightSvg height="1em" fill="currentColor" /> <ChevronRightSvg height="1em" fill="currentColor" />
)} )}
<Txt ml={8}>Authentication</Txt> <Txt ml={8}>{i18next.t('source.auth')}</Txt>
</Flex> </Flex>
</Link> </Link>
{showBasicAuth && ( {showBasicAuth && (
@ -213,7 +214,7 @@ const URLSelector = ({
<Input <Input
mb={15} mb={15}
value={username} value={username}
placeholder="Enter username" placeholder={i18next.t('source.username')}
type="text" type="text"
onChange={(evt: React.ChangeEvent<HTMLInputElement>) => onChange={(evt: React.ChangeEvent<HTMLInputElement>) =>
setUsername(evt.target.value) setUsername(evt.target.value)
@ -221,7 +222,7 @@ const URLSelector = ({
/> />
<Input <Input
value={password} value={password}
placeholder="Enter password" placeholder={i18next.t('source.password')}
type="password" type="password"
onChange={(evt: React.ChangeEvent<HTMLInputElement>) => onChange={(evt: React.ChangeEvent<HTMLInputElement>) =>
setPassword(evt.target.value) setPassword(evt.target.value)
@ -295,7 +296,7 @@ const FlowSelector = styled(
font-weight: 600; font-weight: 600;
svg { svg {
color: ${colors.primary.foreground}!important; color: ${colors.primary.foreground} !important;
} }
} }
`; `;
@ -453,7 +454,7 @@ export class SourceSelector extends React.Component<
!isURL(this.normalizeImagePath(selected)) !isURL(this.normalizeImagePath(selected))
) { ) {
this.handleError( this.handleError(
'Unsupported protocol', i18next.t('source.unsupportedProtocol'),
selected, selected,
messages.error.unsupportedProtocol(), messages.error.unsupportedProtocol(),
); );
@ -465,7 +466,7 @@ export class SourceSelector extends React.Component<
this.setState({ this.setState({
warning: { warning: {
message: messages.warning.looksLikeWindowsImage(), message: messages.warning.looksLikeWindowsImage(),
title: 'Possible Windows image detected', title: i18next.t('source.windowsImage'),
}, },
}); });
} }
@ -491,13 +492,13 @@ export class SourceSelector extends React.Component<
this.setState({ this.setState({
warning: { warning: {
message: messages.warning.missingPartitionTable(), message: messages.warning.missingPartitionTable(),
title: 'Missing partition table', title: i18next.t('source.partitionTable'),
}, },
}); });
} }
} catch (error: any) { } catch (error: any) {
this.handleError( this.handleError(
'Error opening source', i18next.t('source.errorOpen'),
sourcePath, sourcePath,
messages.error.openSource(sourcePath, error.message), messages.error.openSource(sourcePath, error.message),
error, error,
@ -515,7 +516,7 @@ export class SourceSelector extends React.Component<
this.setState({ this.setState({
warning: { warning: {
message: messages.warning.driveMissingPartitionTable(), message: messages.warning.driveMissingPartitionTable(),
title: 'Missing partition table', title: i18next.t('source.partitionTable'),
}, },
}); });
} }
@ -719,7 +720,7 @@ export class SourceSelector extends React.Component<
mb={14} mb={14}
onClick={() => this.reselectSource()} onClick={() => this.reselectSource()}
> >
Remove {i18next.t('cancel')}
</ChangeButton> </ChangeButton>
)} )}
{!_.isNil(imageSize) && !imageLoading && ( {!_.isNil(imageSize) && !imageLoading && (
@ -734,7 +735,7 @@ export class SourceSelector extends React.Component<
key="Flash from file" key="Flash from file"
flow={{ flow={{
onClick: () => this.openImageSelector(), onClick: () => this.openImageSelector(),
label: 'Flash from file', label: i18next.t('source.fromFile'),
icon: <FileSvg height="1em" fill="currentColor" />, icon: <FileSvg height="1em" fill="currentColor" />,
}} }}
onMouseEnter={() => this.setDefaultFlowActive(false)} onMouseEnter={() => this.setDefaultFlowActive(false)}
@ -744,7 +745,7 @@ export class SourceSelector extends React.Component<
key="Flash from URL" key="Flash from URL"
flow={{ flow={{
onClick: () => this.openURLSelector(), onClick: () => this.openURLSelector(),
label: 'Flash from URL', label: i18next.t('source.fromURL'),
icon: <LinkSvg height="1em" fill="currentColor" />, icon: <LinkSvg height="1em" fill="currentColor" />,
}} }}
onMouseEnter={() => this.setDefaultFlowActive(false)} onMouseEnter={() => this.setDefaultFlowActive(false)}
@ -754,7 +755,7 @@ export class SourceSelector extends React.Component<
key="Clone drive" key="Clone drive"
flow={{ flow={{
onClick: () => this.openDriveSelector(), onClick: () => this.openDriveSelector(),
label: 'Clone drive', label: i18next.t('source.clone'),
icon: <CopySvg height="1em" fill="currentColor" />, icon: <CopySvg height="1em" fill="currentColor" />,
}} }}
onMouseEnter={() => this.setDefaultFlowActive(false)} onMouseEnter={() => this.setDefaultFlowActive(false)}
@ -775,7 +776,7 @@ export class SourceSelector extends React.Component<
<span>{this.state.warning.title}</span> <span>{this.state.warning.title}</span>
</span> </span>
} }
action="Continue" action={i18next.t('continue')}
cancel={() => { cancel={() => {
this.setState({ warning: null }); this.setState({ warning: null });
this.reselectSource(); this.reselectSource();
@ -793,17 +794,17 @@ export class SourceSelector extends React.Component<
{showImageDetails && ( {showImageDetails && (
<SmallModal <SmallModal
title="Image" title={i18next.t('source.image')}
done={() => { done={() => {
this.setState({ showImageDetails: false }); this.setState({ showImageDetails: false });
}} }}
> >
<Txt.p> <Txt.p>
<Txt.span bold>Name: </Txt.span> <Txt.span bold>{i18next.t('source.name')}</Txt.span>
<Txt.span>{imageName || imageBasename}</Txt.span> <Txt.span>{imageName || imageBasename}</Txt.span>
</Txt.p> </Txt.p>
<Txt.p> <Txt.p>
<Txt.span bold>Path: </Txt.span> <Txt.span bold>{i18next.t('source.path')}</Txt.span>
<Txt.span>{imagePath}</Txt.span> <Txt.span>{imagePath}</Txt.span>
</Txt.p> </Txt.p>
</SmallModal> </SmallModal>
@ -842,8 +843,8 @@ export class SourceSelector extends React.Component<
<DriveSelector <DriveSelector
write={false} write={false}
multipleSelection={false} multipleSelection={false}
titleLabel="Select source" titleLabel={i18next.t('source.selectSource')}
emptyListLabel="Plug a source drive" emptyListLabel={i18next.t('source.plugSource')}
emptyListIcon={<SrcSvg width="40px" />} emptyListIcon={<SrcSvg width="40px" />}
cancel={(originalList) => { cancel={(originalList) => {
if (originalList.length) { if (originalList.length) {

View File

@ -32,6 +32,7 @@ import {
StepNameButton, StepNameButton,
} from '../../styled-components'; } from '../../styled-components';
import { middleEllipsis } from '../../utils/middle-ellipsis'; import { middleEllipsis } from '../../utils/middle-ellipsis';
import i18next from 'i18next';
interface TargetSelectorProps { interface TargetSelectorProps {
targets: any[]; targets: any[];
@ -95,7 +96,7 @@ export function TargetSelectorButton(props: TargetSelectorProps) {
</StepNameButton> </StepNameButton>
{!props.flashing && ( {!props.flashing && (
<ChangeButton plain mb={14} onClick={props.reselectDrive}> <ChangeButton plain mb={14} onClick={props.reselectDrive}>
Change {i18next.t('target.change')}
</ChangeButton> </ChangeButton>
)} )}
{target.size != null && ( {target.size != null && (
@ -132,11 +133,11 @@ export function TargetSelectorButton(props: TargetSelectorProps) {
return ( return (
<> <>
<StepNameButton plain tooltip={props.tooltip}> <StepNameButton plain tooltip={props.tooltip}>
{targets.length} Targets {targets.length} {i18next.t('target.targets')}
</StepNameButton> </StepNameButton>
{!props.flashing && ( {!props.flashing && (
<ChangeButton plain onClick={props.reselectDrive} mb={14}> <ChangeButton plain onClick={props.reselectDrive} mb={14}>
Change {i18next.t('target.change')}
</ChangeButton> </ChangeButton>
)} )}
{targetsTemplate} {targetsTemplate}
@ -151,7 +152,7 @@ export function TargetSelectorButton(props: TargetSelectorProps) {
disabled={props.disabled} disabled={props.disabled}
onClick={props.openDriveSelector} onClick={props.openDriveSelector}
> >
Select target {i18next.t('target.selectTarget')}
</StepButton> </StepButton>
); );
} }

View File

@ -37,6 +37,7 @@ import TgtSvg from '../../../assets/tgt.svg';
import DriveSvg from '../../../assets/drive.svg'; import DriveSvg from '../../../assets/drive.svg';
import { warning } from '../../../../shared/messages'; import { warning } from '../../../../shared/messages';
import { DrivelistDrive } from '../../../../shared/drive-constraints'; import { DrivelistDrive } from '../../../../shared/drive-constraints';
import i18next from 'i18next';
export const getDriveListLabel = () => { export const getDriveListLabel = () => {
return getSelectedDrives() return getSelectedDrives()
@ -60,8 +61,8 @@ export const TargetSelectorModal = (
) => ( ) => (
<DriveSelector <DriveSelector
multipleSelection={true} multipleSelection={true}
titleLabel="Select target" titleLabel={i18next.t('target.selectTarget')}
emptyListLabel="Plug a target drive" emptyListLabel={i18next.t('target.plugTarget')}
emptyListIcon={<TgtSvg width="40px" />} emptyListIcon={<TgtSvg width="40px" />}
showWarnings={true} showWarnings={true}
selectedList={getSelectedDrives()} selectedList={getSelectedDrives()}

318
lib/gui/app/i18n.ts Normal file
View File

@ -0,0 +1,318 @@
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
let lang = Intl.DateTimeFormat().resolvedOptions().locale;
lang = lang.substr(0, 2);
i18next.use(initReactI18next).init({
lng: lang,
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
resources: {
zh: {
translation: {
ok: '好',
cancel: '取消',
continue: '继续',
skip: '跳过',
sure: '我确定',
warning: '请注意!',
attention: '请注意',
failed: '失败',
completed: '完毕',
yesExit: '是的,可以退出',
reallyExit: '真的要现在退出 Etcher 吗?',
yesContinue: '是的,继续',
progress: {
starting: '正在启动……',
decompressing: '正在解压……',
flashing: '正在烧录……',
finishing: '正在结束……',
verifying: '正在验证……',
failing: '失败……',
},
message: {
sizeNotRecommended: '大小不推荐',
tooSmall: '空间太小',
locked: '被锁定',
system: '系统盘',
containsImage: '存放源镜像',
largeDrive: '很大的磁盘',
sourceLarger: '所选的镜像比目标盘大了 {{byte}} 比特。',
flashSucceed_one: '烧录成功',
flashSucceed_other: '烧录成功',
flashFail_one: '烧录失败',
flashFail_other: '烧录失败',
to: '到 ',
andFail: '并烧录失败了 ',
target_one: ' 个目标',
target_other: ' 个目标',
succeedTo: '被成功烧录',
exitWhileFlashing:
'您当前正在刷机。 关闭 Etcher 可能会导致您的磁盘无法使用。',
looksLikeWindowsImage:
'看起来您正在尝试刻录 Windows 镜像。\n\n与其他镜像不同Windows 镜像需要特殊处理才能使其可启动。 我们建议您使用专门为此目的设计的工具,例如 <a href="https://rufus.akeo.ie">Rufus</a> (Windows)、<a href="https://github. com/slacka/WoeUSB">WoeUSB</a> (Linux) 或 Boot Camp 助理 (macOS)。',
missingPartitionTable:
'看起来这不是一个可启动的镜像。\n\n这个镜像似乎不包含分区表因此您的设备可能无法识别或无法正确启动。',
driveMissingPartitionTable:
'看起来这不是可引导磁盘。\n这个磁盘似乎不包含分区表\n因此您的设备可能无法识别或无法正确启动。',
largeDriveSize:
'这是个很大的磁盘!请检查并确认它不包含对您很重要的信息',
systemDrive: '选择系统盘很危险,因为这将会删除你的系统',
sourceDrive: '源镜像位于这个分区中',
noSpace: '磁盘空间不足。 请插入另一个较大的磁盘并重试。',
genericFlashError:
'出了点问题。如果源镜像曾被压缩过,请检查它是否已损坏。',
validation:
'写入已成功完成,但 Etcher 在从磁盘读取镜像时检测到潜在的损坏问题。 \n\n请考虑将镜像写入其他磁盘。',
openError: '打开 {{source}} 时出错。\n\n错误信息 {{error}}',
flashError: '烧录 {{image}} 到 {{target}} 失败。',
unplug:
'看起来 Etcher 失去了对磁盘的连接。 它是不是被意外拔掉了?\n\n有时这个错误是因为读卡器出了故障。',
cannotWrite:
'看起来 Etcher 无法写入磁盘的这个位置。 此错误通常是由故障的磁盘、读取器或端口引起的。 \n\n请使用其他磁盘、读卡器或端口重试。',
childWriterDied:
'写入进程意外崩溃。请再试一次,如果问题仍然存在,请联系 Etcher 团队。',
badProtocol: '仅支持 http:// 和 https:// 开头的网址。',
},
target: {
selectTarget: '选择目标磁盘',
plugTarget: '请插入目标磁盘',
targets: '个目标',
change: '更改',
},
menu: {
edit: '编辑',
view: '视图',
devTool: '打开开发者工具',
window: '窗口',
help: '帮助',
pro: 'Etcher 专业版',
website: 'Etcher 的官网',
issue: '提交一个 issue',
about: '关于 Etcher',
hide: '隐藏 Etcher',
hideOthers: '隐藏其它窗口',
unhide: '取消隐藏',
quit: '退出 Etcher',
},
source: {
useSourceURL: '使用镜像网络地址',
auth: '验证',
username: '输入用户名',
password: '输入密码',
unsupportedProtocol: '不支持的协议',
windowsImage: '这可能是 Windows 系统镜像',
partitionTable: '找不到分区表',
errorOpen: '打开源镜像时出错',
fromFile: '从文件烧录',
fromURL: '从在线地址烧录',
clone: '克隆磁盘',
image: '镜像信息',
name: '名称:',
path: '路径:',
selectSource: '选择源',
plugSource: '请插入源磁盘',
osImages: '系统镜像格式',
allFiles: '任何文件格式',
enterValidURL: '请输入一个正确的地址',
},
drives: {
name: '名称',
size: '大小',
location: '位置',
find: '找到 {{length}} 个',
select: '选定 {{select}}',
showHidden: '显示 {{num}} 个隐藏的磁盘',
systemDriveDanger: '选择系统盘很危险,因为这将会删除你的系统!',
openInBrowser: 'Etcher 会在浏览器中打开 {{link}}',
changeTarget: '改变目标',
largeDriveWarning: '您即将擦除一个非常大的磁盘',
largeDriveWarningMsg: '您确定所选磁盘不是存储磁盘吗?',
systemDriveWarning: '您将要擦除系统盘',
systemDriveWarningMsg: '您确定要烧录到系统盘吗?',
},
flash: {
another: '烧录另一目标',
target: '目标',
location: '位置',
error: '错误',
flash: '烧录',
flashNow: '现在烧录!',
skip: '跳过了验证',
moreInfo: '更多信息',
speedTip:
'通过将图像大小除以烧录时间来计算速度。\n由于我们能够跳过未使用的部分因此具有EXT分区的磁盘镜像烧录速度更快。',
speed: '速度:',
failedTarget: '失败的烧录目标',
failedRetry: '重试烧录失败目标',
},
settings: {
errorReporting: '匿名地向 balena.io 报告运行错误和使用统计',
autoUpdate: '自动更新',
settings: '软件设置',
systemInformation: '系统信息',
},
},
},
en: {
translation: {
continue: 'Continue',
ok: 'OK',
cancel: 'Cancel',
skip: 'Skip',
sure: "Yes, I'm sure",
warning: 'WARNING! ',
attention: 'Attention',
failed: 'Failed',
completed: 'Completed',
yesContinue: 'Yes, continue',
reallyExit: 'Are you sure you want to close Etcher?',
yesExit: 'Yes, quit',
progress: {
starting: 'Starting...',
decompressing: 'Decompressing...',
flashing: 'Flashing...',
finishing: 'Finishing...',
verifying: 'Verifying...',
failing: 'Failed',
},
message: {
sizeNotRecommended: 'Not recommended',
tooSmall: 'Too small',
locked: 'Locked',
system: 'System drive',
containsImage: 'Source drive',
largeDrive: 'Large drive',
sourceLarger:
'The selected source is {{byte}} larger than this drive.',
flashSucceed_one: 'Successful target',
flashSucceed_other: 'Successful targets',
flashFail_one: 'Failed target',
flashFail_other: 'Failed targets',
to: 'to ',
andFail: 'and failed to be flashed to ',
target_one: ' target',
target_other: ' targets',
succeedTo: 'was successfully flashed',
exitWhileFlashing:
'You are currently flashing a drive. Closing Etcher may leave your drive in an unusable state.',
looksLikeWindowsImage:
'It looks like you are trying to burn a Windows image.\n\nUnlike other images, Windows images require special processing to be made bootable. We suggest you use a tool specially designed for this purpose, such as <a href="https://rufus.akeo.ie">Rufus</a> (Windows), <a href="https://github.com/slacka/WoeUSB">WoeUSB</a> (Linux), or Boot Camp Assistant (macOS).',
missingPartitionTable:
'It looks like this is not a bootable image.\n\nThe image does not appear to contain a partition table, and might not be recognized or bootable by your device.',
driveMissingPartitionTable:
'It looks like this is not a bootable drive.\nThe drive does not appear to contain a partition table,\nand might not be recognized or bootable by your device.',
largeDriveSize:
"This is a large drive! Make sure it doesn't contain files that you want to keep.",
systemDrive:
'Selecting your system drive is dangerous and will erase your drive!',
sourceDrive: 'Contains the image you chose to flash',
noSpace:
'Not enough space on the drive. Please insert larger one and try again.',
genericFlashError:
'Something went wrong. If it is a compressed image, please check that the archive is not corrupted.',
validation:
'The write has been completed successfully but Etcher detected potential corruption issues when reading the image back from the drive. \n\nPlease consider writing the image to a different drive.',
openError:
'Something went wrong while opening {{source}}.\n\nError: {{error}}',
flashError:
'Something went wrong while writing {{image}} to {{target}}.',
unplug:
"Looks like Etcher lost access to the drive. Did it get unplugged accidentally?\n\nSometimes this error is caused by faulty readers that don't provide stable access to the drive.",
cannotWrite:
'Looks like Etcher is not able to write to this location of the drive. This error is usually caused by a faulty drive, reader, or port. \n\nPlease try again with another drive, reader, or port.',
childWriterDied:
'The writer process ended unexpectedly. Please try again, and contact the Etcher team if the problem persists.',
badProtocol: 'Only http:// and https:// URLs are supported.',
},
target: {
selectTarget: 'Select target',
plugTarget: 'Plug a target drive',
targets: 'Targets',
change: 'Change',
},
source: {
useSourceURL: 'Use Image URL',
auth: 'Authentication',
username: 'Enter username',
password: 'Enter password',
unsupportedProtocol: 'Unsupported protocol',
windowsImage: 'Possible Windows image detected',
partitionTable: 'Missing partition table',
errorOpen: 'Error opening source',
fromFile: 'Flash from file',
fromURL: 'Flash from URL',
clone: 'Clone drive',
image: 'Image',
name: 'Name: ',
path: 'Path: ',
selectSource: 'Select source',
plugSource: 'Plug a source drive',
osImages: 'OS Images',
allFiles: 'All',
enterValidURL: 'Enter a valid URL',
},
drives: {
name: 'Name',
size: 'Size',
location: 'Location',
find: '{{length}} found',
select: 'Select {{select}}',
showHidden: 'Show {{num}} hidden',
systemDriveDanger:
'Selecting your system drive is dangerous and will erase your drive!',
openInBrowser: '`Etcher will open {{link}} in your browser`',
changeTarget: 'Change target',
largeDriveWarning: 'You are about to erase an unusually large drive',
largeDriveWarningMsg:
'Are you sure the selected drive is not a storage drive?',
systemDriveWarning: "You are about to erase your computer's drives",
systemDriveWarningMsg:
'Are you sure you want to flash your system drive?',
},
flash: {
another: 'Flash another',
target: 'Target',
location: 'Location',
error: 'Error',
flash: 'Flash',
flashNow: 'Flash!',
skip: 'Validation has been skipped',
moreInfo: 'more info',
speedTip:
'The speed is calculated by dividing the image size by the flashing time.\nDisk images with ext partitions flash faster as we are able to skip unused parts.',
speed: 'Effective speed: ',
failedTarget: 'Failed targets',
failedRetry: 'Retry failed targets',
},
settings: {
errorReporting:
'Anonymously report errors and usage statistics to balena.io',
autoUpdate: 'Auto-updates enabled',
settings: 'Settings',
systemInformation: 'System Information',
},
menu: {
edit: 'Edit',
view: 'View',
devTool: 'Toggle Developer Tools',
window: 'Window',
help: 'Help',
pro: 'Etcher Pro',
website: 'Etcher Website',
issue: 'Report an issue',
about: 'About Etcher',
hide: 'Hide Etcher',
hideOthers: 'Hide Others',
unhide: 'Unhide All',
quit: 'Quit Etcher',
},
},
},
},
});
export default i18next;

View File

@ -15,6 +15,7 @@
*/ */
import * as prettyBytes from 'pretty-bytes'; import * as prettyBytes from 'pretty-bytes';
import i18next from 'i18next';
export interface FlashState { export interface FlashState {
active: number; active: number;
@ -34,36 +35,45 @@ export function fromFlashState({
position?: string; position?: string;
} { } {
if (type === undefined) { if (type === undefined) {
return { status: 'Starting...' }; return { status: i18next.t('progress.starting') };
} else if (type === 'decompressing') { } else if (type === 'decompressing') {
if (percentage == null) { if (percentage == null) {
return { status: 'Decompressing...' }; return { status: i18next.t('progress.decompressing') };
} else { } else {
return { position: `${percentage}%`, status: 'Decompressing...' }; return {
position: `${percentage}%`,
status: i18next.t('progress.decompressing'),
};
} }
} else if (type === 'flashing') { } else if (type === 'flashing') {
if (percentage != null) { if (percentage != null) {
if (percentage < 100) { if (percentage < 100) {
return { position: `${percentage}%`, status: 'Flashing...' }; return {
position: `${percentage}%`,
status: i18next.t('progress.flashing'),
};
} else { } else {
return { status: 'Finishing...' }; return { status: i18next.t('progress.finishing') };
} }
} else { } else {
return { return {
status: 'Flashing...', status: i18next.t('progress.flashing'),
position: `${position ? prettyBytes(position) : ''}`, position: `${position ? prettyBytes(position) : ''}`,
}; };
} }
} else if (type === 'verifying') { } else if (type === 'verifying') {
if (percentage == null) { if (percentage == null) {
return { status: 'Validating...' }; return { status: i18next.t('progress.verifying') };
} else if (percentage < 100) { } else if (percentage < 100) {
return { position: `${percentage}%`, status: 'Validating...' }; return {
position: `${percentage}%`,
status: i18next.t('progress.verifying'),
};
} else { } else {
return { status: 'Finishing...' }; return { status: i18next.t('progress.finishing') };
} }
} }
return { status: 'Failed' }; return { status: i18next.t('progress.failing') };
} }
export function titleFromFlashState( export function titleFromFlashState(

View File

@ -20,6 +20,7 @@ import * as _ from 'lodash';
import * as errors from '../../../shared/errors'; import * as errors from '../../../shared/errors';
import * as settings from '../../../gui/app/models/settings'; import * as settings from '../../../gui/app/models/settings';
import { SUPPORTED_EXTENSIONS } from '../../../shared/supported-formats'; import { SUPPORTED_EXTENSIONS } from '../../../shared/supported-formats';
import i18next from 'i18next';
async function mountSourceDrive() { async function mountSourceDrive() {
// sourceDrivePath is the name of the link in /dev/disk/by-path // sourceDrivePath is the name of the link in /dev/disk/by-path
@ -53,11 +54,11 @@ export async function selectImage(): Promise<string | undefined> {
properties: ['openFile', 'treatPackageAsDirectory'], properties: ['openFile', 'treatPackageAsDirectory'],
filters: [ filters: [
{ {
name: 'OS Images', name: i18next.t('source.osImages'),
extensions: SUPPORTED_EXTENSIONS, extensions: SUPPORTED_EXTENSIONS,
}, },
{ {
name: 'All', name: i18next.t('source.allFiles'),
extensions: ['*'], extensions: ['*'],
}, },
], ],
@ -79,8 +80,8 @@ export async function showWarning(options: {
description: string; description: string;
}): Promise<boolean> { }): Promise<boolean> {
_.defaults(options, { _.defaults(options, {
confirmationLabel: 'OK', confirmationLabel: i18next.t('ok'),
rejectionLabel: 'Cancel', rejectionLabel: i18next.t('cancel'),
}); });
const BUTTONS = [options.confirmationLabel, options.rejectionLabel]; const BUTTONS = [options.confirmationLabel, options.rejectionLabel];
@ -98,7 +99,7 @@ export async function showWarning(options: {
buttons: BUTTONS, buttons: BUTTONS,
defaultId: BUTTON_REJECTION_INDEX, defaultId: BUTTON_REJECTION_INDEX,
cancelId: BUTTON_REJECTION_INDEX, cancelId: BUTTON_REJECTION_INDEX,
title: 'Attention', title: i18next.t('attention'),
message: options.title, message: options.title,
detail: options.description, detail: options.description,
}, },

View File

@ -1,5 +1,11 @@
// @ts-nocheck // @ts-nocheck
import { main } from './app'; import { main } from './app';
import './i18n';
import { ipcRenderer } from 'electron';
let lang = Intl.DateTimeFormat().resolvedOptions().locale;
lang = lang.substr(0, 2);
ipcRenderer.send('change-lng', lang);
if (module.hot) { if (module.hot) {
module.hot.accept('./app', () => { module.hot.accept('./app', () => {

View File

@ -21,18 +21,22 @@ import { platform } from 'os';
import * as path from 'path'; import * as path from 'path';
import * as semver from 'semver'; import * as semver from 'semver';
import './app/i18n';
import { packageType, version } from '../../package.json'; import { packageType, version } from '../../package.json';
import * as EXIT_CODES from '../shared/exit-codes'; import * as EXIT_CODES from '../shared/exit-codes';
import { delay, getConfig } from '../shared/utils'; import { delay, getConfig } from '../shared/utils';
import * as settings from './app/models/settings'; import * as settings from './app/models/settings';
import { logException } from './app/modules/analytics'; import { logException } from './app/modules/analytics';
import { buildWindowMenu } from './menu'; import { buildWindowMenu } from './menu';
import i18n from 'i18next';
const customProtocol = 'etcher'; const customProtocol = 'etcher';
const scheme = `${customProtocol}://`; const scheme = `${customProtocol}://`;
const updatablePackageTypes = ['appimage', 'nsis', 'dmg']; const updatablePackageTypes = ['appimage', 'nsis', 'dmg'];
const packageUpdatable = updatablePackageTypes.includes(packageType); const packageUpdatable = updatablePackageTypes.includes(packageType);
let packageUpdated = false; let packageUpdated = false;
let mainWindow: any = null;
async function checkForUpdates(interval: number) { async function checkForUpdates(interval: number) {
// We use a while loop instead of a setInterval to preserve // We use a while loop instead of a setInterval to preserve
@ -130,7 +134,7 @@ async function createMainWindow() {
if (fullscreen) { if (fullscreen) {
({ width, height } = electron.screen.getPrimaryDisplay().bounds); ({ width, height } = electron.screen.getPrimaryDisplay().bounds);
} }
const mainWindow = new electron.BrowserWindow({ mainWindow = new electron.BrowserWindow({
width, width,
height, height,
frame: !fullscreen, frame: !fullscreen,
@ -157,7 +161,6 @@ async function createMainWindow() {
electron.app.setAsDefaultProtocolClient(customProtocol); electron.app.setAsDefaultProtocolClient(customProtocol);
buildWindowMenu(mainWindow);
mainWindow.setFullScreen(true); mainWindow.setFullScreen(true);
// Prevent flash of white when starting the application // Prevent flash of white when starting the application
@ -240,6 +243,17 @@ async function main(): Promise<void> {
await selectImageURL(await getCommandLineURL(argv)); await selectImageURL(await getCommandLineURL(argv));
}); });
await selectImageURL(await getCommandLineURL(process.argv)); await selectImageURL(await getCommandLineURL(process.argv));
electron.ipcMain.on('change-lng', function (event, args) {
i18n.changeLanguage(args, () => {
console.log('Language changed to: ' + args);
});
if (mainWindow != null) {
buildWindowMenu(mainWindow);
} else {
console.log('Build menu failed. ');
}
});
} }
} }

View File

@ -17,6 +17,8 @@
import * as electron from 'electron'; import * as electron from 'electron';
import { displayName } from '../../package.json'; import { displayName } from '../../package.json';
import i18next from 'i18next';
/** /**
* @summary Builds a native application menu for a given window * @summary Builds a native application menu for a given window
*/ */
@ -42,12 +44,13 @@ export function buildWindowMenu(window: electron.BrowserWindow) {
const menuTemplate: electron.MenuItemConstructorOptions[] = [ const menuTemplate: electron.MenuItemConstructorOptions[] = [
{ {
role: 'editMenu', role: 'editMenu',
label: i18next.t('menu.edit'),
}, },
{ {
label: 'View', label: i18next.t('menu.view'),
submenu: [ submenu: [
{ {
label: 'Toggle Developer Tools', label: i18next.t('menu.devTool'),
accelerator: accelerator:
process.platform === 'darwin' ? 'Command+Alt+I' : 'Control+Shift+I', process.platform === 'darwin' ? 'Command+Alt+I' : 'Control+Shift+I',
click: toggleDevTools, click: toggleDevTools,
@ -56,12 +59,14 @@ export function buildWindowMenu(window: electron.BrowserWindow) {
}, },
{ {
role: 'windowMenu', role: 'windowMenu',
label: i18next.t('menu.window'),
}, },
{ {
role: 'help', role: 'help',
label: i18next.t('menu.help'),
submenu: [ submenu: [
{ {
label: 'Etcher Pro', label: i18next.t('menu.pro'),
click() { click() {
electron.shell.openExternal( electron.shell.openExternal(
'https://etcher.io/pro?utm_source=etcher_menu&ref=etcher_menu', 'https://etcher.io/pro?utm_source=etcher_menu&ref=etcher_menu',
@ -69,13 +74,13 @@ export function buildWindowMenu(window: electron.BrowserWindow) {
}, },
}, },
{ {
label: 'Etcher Website', label: i18next.t('menu.website'),
click() { click() {
electron.shell.openExternal('https://etcher.io?ref=etcher_menu'); electron.shell.openExternal('https://etcher.io?ref=etcher_menu');
}, },
}, },
{ {
label: 'Report an issue', label: i18next.t('menu.issue'),
click() { click() {
electron.shell.openExternal( electron.shell.openExternal(
'https://github.com/balena-io/etcher/issues', 'https://github.com/balena-io/etcher/issues',
@ -92,25 +97,29 @@ export function buildWindowMenu(window: electron.BrowserWindow) {
submenu: [ submenu: [
{ {
role: 'about' as const, role: 'about' as const,
label: 'About Etcher', label: i18next.t('menu.about'),
}, },
{ {
type: 'separator' as const, type: 'separator' as const,
}, },
{ {
role: 'hide' as const, role: 'hide' as const,
label: i18next.t('menu.hide'),
}, },
{ {
role: 'hideOthers' as const, role: 'hideOthers' as const,
label: i18next.t('menu.hideOthers'),
}, },
{ {
role: 'unhide' as const, role: 'unhide' as const,
label: i18next.t('menu.unhide'),
}, },
{ {
type: 'separator' as const, type: 'separator' as const,
}, },
{ {
role: 'quit' as const, role: 'quit' as const,
label: i18next.t('menu.quit'),
}, },
], ],
}); });

View File

@ -0,0 +1,21 @@
#!/usr/bin/env osascript -l JavaScript
ObjC.import('stdlib')
const app = Application.currentApplication()
app.includeStandardAdditions = true
const result = app.displayDialog('balenaEtcher 需要来自管理员的权限才能烧录镜像到磁盘。\n\n输入您的密码以允许此操作。', {
defaultAnswer: '',
withIcon: 'caution',
buttons: ['取消', '好'],
defaultButton: '好',
hiddenAnswer: true,
})
if (result.buttonReturned === '好') {
result.textReturned
} else {
$.exit(255)
}

View File

@ -30,6 +30,9 @@ export async function sudo(
command: string, command: string,
): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> { ): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> {
try { try {
let lang = Intl.DateTimeFormat().resolvedOptions().locale;
lang = lang.substr(0, 2);
const { stdout, stderr } = await execFileAsync( const { stdout, stderr } = await execFileAsync(
'sudo', 'sudo',
['--askpass', 'sh', '-c', `echo ${SUCCESSFUL_AUTH_MARKER} && ${command}`], ['--askpass', 'sh', '-c', `echo ${SUCCESSFUL_AUTH_MARKER} && ${command}`],
@ -40,7 +43,7 @@ export async function sudo(
SUDO_ASKPASS: join( SUDO_ASKPASS: join(
getAppPath(), getAppPath(),
__dirname, __dirname,
'sudo-askpass.osascript.js', 'sudo-askpass.osascript-' + lang + '.js',
), ),
}, },
}, },

View File

@ -17,16 +17,16 @@
import { Dictionary } from 'lodash'; import { Dictionary } from 'lodash';
import { outdent } from 'outdent'; import { outdent } from 'outdent';
import * as prettyBytes from 'pretty-bytes'; import * as prettyBytes from 'pretty-bytes';
import '../gui/app/i18n';
import i18next from 'i18next';
export const progress: Dictionary<(quantity: number) => string> = { export const progress: Dictionary<(quantity: number) => string> = {
successful: (quantity: number) => { successful: (quantity: number) => {
const plural = quantity === 1 ? '' : 's'; return i18next.t('message.flashSucceed', { count: quantity });
return `Successful target${plural}`;
}, },
failed: (quantity: number) => { failed: (quantity: number) => {
const plural = quantity === 1 ? '' : 's'; return i18next.t('message.flashFail', { count: quantity });
return `Failed target${plural}`;
}, },
}; };
@ -38,129 +38,116 @@ export const info = {
) => { ) => {
const targets = []; const targets = [];
if (failed + successful === 1) { if (failed + successful === 1) {
targets.push(`to ${drive.description} (${drive.displayName})`); targets.push(
i18next.t('message.to') + `${drive.description} (${drive.displayName})`,
);
} else { } else {
if (successful) { if (successful) {
const plural = successful === 1 ? '' : 's'; targets.push(
targets.push(`to ${successful} target${plural}`); i18next.t('message.to') +
successful +
i18next.t('message.target', { count: successful }),
);
} }
if (failed) { if (failed) {
const plural = failed === 1 ? '' : 's'; targets.push(
targets.push(`and failed to be flashed to ${failed} target${plural}`); i18next.t('message.andFail') +
failed +
i18next.t('message.target', { count: failed }),
);
} }
} }
return `${imageBasename} was successfully flashed ${targets.join(' ')}`; return (
`${imageBasename} ` +
i18next.t('message.succeedTo') +
` ${targets.join(' ')}`
);
}, },
}; };
export const compatibility = { export const compatibility = {
sizeNotRecommended: () => { sizeNotRecommended: () => {
return 'Not recommended'; return i18next.t('message.sizeNotRecommended');
}, },
tooSmall: () => { tooSmall: () => {
return 'Too small'; return i18next.t('message.tooSmall');
}, },
locked: () => { locked: () => {
return 'Locked'; return i18next.t('message.locked');
}, },
system: () => { system: () => {
return 'System drive'; return i18next.t('message.system');
}, },
containsImage: () => { containsImage: () => {
return 'Source drive'; return i18next.t('message.containsImage');
}, },
// The drive is large and therefore likely not a medium you want to write to. // The drive is large and therefore likely not a medium you want to write to.
largeDrive: () => { largeDrive: () => {
return 'Large drive'; return i18next.t('message.largeDrive');
}, },
} as const; } as const;
export const warning = { export const warning = {
tooSmall: (source: { size: number }, target: { size: number }) => { tooSmall: (source: { size: number }, target: { size: number }) => {
return outdent({ newline: ' ' })` return outdent({ newline: ' ' })`
The selected source is ${prettyBytes(source.size - target.size)} ${i18next.t('message.sourceLarger', {
larger than this drive. byte: prettyBytes(source.size - target.size),
})}
`; `;
}, },
exitWhileFlashing: () => { exitWhileFlashing: () => {
return [ return i18next.t('message.exitWhileFlashing');
'You are currently flashing a drive.',
'Closing Etcher may leave your drive in an unusable state.',
].join(' ');
}, },
looksLikeWindowsImage: () => { looksLikeWindowsImage: () => {
return [ return i18next.t('message.looksLikeWindowsImage');
'It looks like you are trying to burn a Windows image.\n\n',
'Unlike other images, Windows images require special processing to be made bootable.',
'We suggest you use a tool specially designed for this purpose, such as',
'<a href="https://rufus.akeo.ie">Rufus</a> (Windows),',
'<a href="https://github.com/slacka/WoeUSB">WoeUSB</a> (Linux),',
'or Boot Camp Assistant (macOS).',
].join(' ');
}, },
missingPartitionTable: () => { missingPartitionTable: () => {
return [ return i18next.t('message.missingPartitionTable');
'It looks like this is not a bootable image.\n\n',
'The image does not appear to contain a partition table,',
'and might not be recognized or bootable by your device.',
].join(' ');
}, },
driveMissingPartitionTable: () => { driveMissingPartitionTable: () => {
return outdent({ newline: ' ' })` return i18next.t('message.driveMissingPartitionTable');
It looks like this is not a bootable drive.
The drive does not appear to contain a partition table,
and might not be recognized or bootable by your device.
`;
}, },
largeDriveSize: () => { largeDriveSize: () => {
return "This is a large drive! Make sure it doesn't contain files that you want to keep."; return i18next.t('message.largeDriveSize');
}, },
systemDrive: () => { systemDrive: () => {
return 'Selecting your system drive is dangerous and will erase your drive!'; return i18next.t('message.systemDrive');
}, },
sourceDrive: () => { sourceDrive: () => {
return 'Contains the image you chose to flash'; return i18next.t('message.sourceDrive');
}, },
}; };
export const error = { export const error = {
notEnoughSpaceInDrive: () => { notEnoughSpaceInDrive: () => {
return [ return i18next.t('message.noSpace');
'Not enough space on the drive.',
'Please insert larger one and try again.',
].join(' ');
}, },
genericFlashError: (err: Error) => { genericFlashError: (err: Error) => {
return `Something went wrong. If it is a compressed image, please check that the archive is not corrupted.\n${err.message}`; return i18next.t('message.genericFlashError') + `\n${err.message}`;
}, },
validation: () => { validation: () => {
return [ return i18next.t('message.validation');
'The write has been completed successfully but Etcher detected potential',
'corruption issues when reading the image back from the drive.',
'\n\nPlease consider writing the image to a different drive.',
].join(' ');
}, },
openSource: (sourceName: string, errorMessage: string) => { openSource: (sourceName: string, errorMessage: string) => {
return outdent` return i18next.t('message.openError', {
Something went wrong while opening ${sourceName} source: sourceName,
error: errorMessage,
Error: ${errorMessage} });
`;
}, },
flashFailure: ( flashFailure: (
@ -170,34 +157,24 @@ export const error = {
const target = const target =
drives.length === 1 drives.length === 1
? `${drives[0].description} (${drives[0].displayName})` ? `${drives[0].description} (${drives[0].displayName})`
: `${drives.length} targets`; : `${drives.length}` +
return `Something went wrong while writing ${imageBasename} to ${target}.`; i18next.t('message.target', { count: drives.length });
return i18next.t('message.flashError', { image: imageBasename, target });
}, },
driveUnplugged: () => { driveUnplugged: () => {
return [ return i18next.t('message.unplug');
'Looks like Etcher lost access to the drive.',
'Did it get unplugged accidentally?',
"\n\nSometimes this error is caused by faulty readers that don't provide stable access to the drive.",
].join(' ');
}, },
inputOutput: () => { inputOutput: () => {
return [ return i18next.t('message.cannotWrite');
'Looks like Etcher is not able to write to this location of the drive.',
'This error is usually caused by a faulty drive, reader, or port.',
'\n\nPlease try again with another drive, reader, or port.',
].join(' ');
}, },
childWriterDied: () => { childWriterDied: () => {
return [ return i18next.t('message.childWriterDied');
'The writer process ended unexpectedly.',
'Please try again, and contact the Etcher team if the problem persists.',
].join(' ');
}, },
unsupportedProtocol: () => { unsupportedProtocol: () => {
return 'Only http:// and https:// URLs are supported.'; return i18next.t('message.badProtocol');
}, },
}; };

54
package-lock.json generated
View File

@ -2353,7 +2353,6 @@
"version": "7.16.3", "version": "7.16.3",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.3.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.3.tgz",
"integrity": "sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==", "integrity": "sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==",
"dev": true,
"requires": { "requires": {
"regenerator-runtime": "^0.13.4" "regenerator-runtime": "^0.13.4"
} }
@ -9888,6 +9887,14 @@
"integrity": "sha512-c3Ab/url5ksaT0WyleslpBEthOzWhrjQbg75y7XUsfSzi3Dgzt0l8w5e7DylRn15MTlMMD58dTfzddNS2kcAjQ==", "integrity": "sha512-c3Ab/url5ksaT0WyleslpBEthOzWhrjQbg75y7XUsfSzi3Dgzt0l8w5e7DylRn15MTlMMD58dTfzddNS2kcAjQ==",
"dev": true "dev": true
}, },
"html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"requires": {
"void-elements": "3.1.0"
}
},
"html-void-elements": { "html-void-elements": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-1.0.5.tgz", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-1.0.5.tgz",
@ -10108,6 +10115,24 @@
} }
} }
}, },
"i18next": {
"version": "21.8.14",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/i18next/-/i18next-21.8.14.tgz",
"integrity": "sha512-4Yi+DtexvMm/Yw3Q9fllzY12SgLk+Mcmar+rCAccsOPul/2UmnBzoHbTGn/L48IPkFcmrNaH7xTLboBWIbH6pw==",
"requires": {
"@babel/runtime": "^7.17.2"
},
"dependencies": {
"@babel/runtime": {
"version": "7.18.6",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@babel/runtime/-/runtime-7.18.6.tgz",
"integrity": "sha512-t9wi7/AW6XtKahAe20Yw0/mMljKq0B1r2fPdvaAdV/KPDZewFXdaaa6K7lxmZBZ8FBNpCiAT6iHPmd6QO9bKfQ==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
}
}
},
"iconv-corefoundation": { "iconv-corefoundation": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
@ -13967,6 +13992,16 @@
"buffer": "^5.2.1", "buffer": "^5.2.1",
"through": "^2.3.8" "through": "^2.3.8"
} }
},
"unbzip2-stream": {
"version": "1.4.3",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz",
"integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==",
"dev": true,
"requires": {
"buffer": "^5.2.1",
"through": "^2.3.8"
}
} }
} }
}, },
@ -14193,6 +14228,15 @@
"react-side-effect": "^2.1.0" "react-side-effect": "^2.1.0"
} }
}, },
"react-i18next": {
"version": "11.18.1",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/react-i18next/-/react-i18next-11.18.1.tgz",
"integrity": "sha512-S8cl4mvIOSA7OQCE5jNy2yhv705Vwi+7PinpqKIYcBmX/trJtHKqrf6CL67WJSA8crr2JU+oxE9jn9DQIrQezg==",
"requires": {
"@babel/runtime": "^7.14.5",
"html-parse-stringify": "^3.0.1"
}
},
"react-is": { "react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -14386,8 +14430,7 @@
"regenerator-runtime": { "regenerator-runtime": {
"version": "0.13.9", "version": "0.13.9",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
"integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==", "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="
"dev": true
}, },
"regenerator-transform": { "regenerator-transform": {
"version": "0.14.5", "version": "0.14.5",
@ -17098,6 +17141,11 @@
"unist-util-stringify-position": "^2.0.0" "unist-util-stringify-position": "^2.0.0"
} }
}, },
"void-elements": {
"version": "3.1.0",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="
},
"watchpack": { "watchpack": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",

View File

@ -127,3 +127,4 @@
"publishedAt": "2022-12-13T02:27:42.254Z" "publishedAt": "2022-12-13T02:27:42.254Z"
} }
} }