diff --git a/Makefile b/Makefile index 47516079..929d3dc1 100644 --- a/Makefile +++ b/Makefile @@ -149,7 +149,7 @@ sass: node-sass lib/gui/app/scss/main.scss > lib/gui/css/main.css lint-ts: - resin-lint --fix --typescript typings lib tests scripts/clean-shrinkwrap.ts webpack.config.ts + balena-lint --fix --typescript typings lib tests scripts/clean-shrinkwrap.ts webpack.config.ts lint-sass: sass-lint -v lib/gui/app/scss/**/*.scss lib/gui/app/scss/*.scss diff --git a/lib/gui/app/app.ts b/lib/gui/app/app.ts index 8c493267..18a565fa 100644 --- a/lib/gui/app/app.ts +++ b/lib/gui/app/app.ts @@ -33,7 +33,6 @@ import { Actions, observe, store } from './models/store'; import * as analytics from './modules/analytics'; import { scanner as driveScanner } from './modules/drive-scanner'; import * as exceptionReporter from './modules/exception-reporter'; -import { updateLock } from './modules/update-lock'; import * as osDialog from './os/dialog'; import * as windowProgress from './os/window-progress'; import MainPage from './pages/main/MainPage'; @@ -86,33 +85,45 @@ const currentVersion = packageJSON.version; analytics.logEvent('Application start', { packageType: packageJSON.packageType, version: currentVersion, - applicationSessionUuid, }); +const debouncedLog = _.debounce(console.log, 1000, { maxWait: 1000 }); + +function pluralize(word: string, quantity: number) { + return `${quantity} ${word}${quantity === 1 ? '' : 's'}`; +} + observe(() => { if (!flashState.isFlashing()) { return; } - const currentFlashState = flashState.getFlashState(); - const stateType = - !currentFlashState.flashing && currentFlashState.verifying - ? `Verifying ${currentFlashState.verifying}` - : `Flashing ${currentFlashState.flashing}`; + windowProgress.set(currentFlashState); + let eta = ''; + if (currentFlashState.eta !== undefined) { + eta = `eta in ${currentFlashState.eta.toFixed(0)}s`; + } + let active = ''; + if (currentFlashState.type !== 'decompressing') { + active = pluralize('device', currentFlashState.active); + } // NOTE: There is usually a short time period between the `isFlashing()` // property being set, and the flashing actually starting, which // might cause some non-sense flashing state logs including // `undefined` values. - analytics.logDebug( - `${stateType} devices, ` + - `${currentFlashState.percentage}% at ${currentFlashState.speed} MB/s ` + - `(total ${currentFlashState.totalSpeed} MB/s) ` + - `eta in ${currentFlashState.eta}s ` + - `with ${currentFlashState.failed} failed devices`, - ); - - windowProgress.set(currentFlashState); + debouncedLog(outdent({ newline: ' ' })` + ${_.capitalize(currentFlashState.type)} + ${active}, + ${currentFlashState.percentage}% + at + ${(currentFlashState.speed || 0).toFixed(2)} + MB/s + (total ${(currentFlashState.speed * currentFlashState.active).toFixed(2)} MB/s) + ${eta} + with + ${pluralize('failed device', currentFlashState.failed)} + `); }); /** @@ -154,17 +165,16 @@ const COMPUTE_MODULE_DESCRIPTIONS: _.Dictionary = { [USB_PRODUCT_ID_BCM2710_BOOT]: 'Compute Module 3', }; -let BLACKLISTED_DRIVES: string[] = []; - -function driveIsAllowed(drive: { +async function driveIsAllowed(drive: { devicePath: string; device: string; raw: string; }) { + const driveBlacklist = (await settings.get('driveBlacklist')) || []; return !( - BLACKLISTED_DRIVES.includes(drive.devicePath) || - BLACKLISTED_DRIVES.includes(drive.device) || - BLACKLISTED_DRIVES.includes(drive.raw) + driveBlacklist.includes(drive.devicePath) || + driveBlacklist.includes(drive.device) || + driveBlacklist.includes(drive.raw) ); } @@ -185,7 +195,7 @@ function prepareDrive(drive: Drive) { // @ts-ignore drive.progress = 0; drive.disabled = true; - drive.on('progress', progress => { + drive.on('progress', (progress) => { updateDriveProgress(drive, progress); }); return drive; @@ -229,9 +239,9 @@ function getDrives() { return _.keyBy(availableDrives.getDrives() || [], 'device'); } -function addDrive(drive: Drive) { +async function addDrive(drive: Drive) { const preparedDrive = prepareDrive(drive); - if (!driveIsAllowed(preparedDrive)) { + if (!(await driveIsAllowed(preparedDrive))) { return; } const drives = getDrives(); @@ -262,7 +272,7 @@ function updateDriveProgress( driveScanner.on('attach', addDrive); driveScanner.on('detach', removeDrive); -driveScanner.on('error', error => { +driveScanner.on('error', (error) => { // Stop the drive scanning loop in case of errors, // otherwise we risk presenting the same error over // and over again to the user, while also heavily @@ -276,11 +286,10 @@ driveScanner.start(); let popupExists = false; -window.addEventListener('beforeunload', async event => { +window.addEventListener('beforeunload', async (event) => { if (!flashState.isFlashing() || popupExists) { analytics.logEvent('Close application', { isFlashing: flashState.isFlashing(), - applicationSessionUuid, }); return; } @@ -291,10 +300,7 @@ window.addEventListener('beforeunload', async event => { // Don't open any more popups popupExists = true; - analytics.logEvent('Close attempt while flashing', { - applicationSessionUuid, - flashingWorkflowUuid, - }); + analytics.logEvent('Close attempt while flashing'); try { const confirmed = await osDialog.showWarning({ @@ -306,8 +312,6 @@ window.addEventListener('beforeunload', async event => { if (confirmed) { analytics.logEvent('Close confirmed while flashing', { flashInstanceUuid: flashState.getFlashUuid(), - applicationSessionUuid, - flashingWorkflowUuid, }); // This circumvents the 'beforeunload' event unlike @@ -325,24 +329,8 @@ window.addEventListener('beforeunload', async event => { } }); -function extendLock() { - updateLock.extend(); -} - -window.addEventListener('click', extendLock); -window.addEventListener('touchstart', extendLock); - -// Initial update lock acquisition -extendLock(); - -async function main(): Promise { - try { - await settings.load(); - } catch (error) { - exceptionReporter.report(error); - } - BLACKLISTED_DRIVES = settings.get('driveBlacklist') || []; - ledsInit(); +async function main() { + await ledsInit(); ReactDOM.render( React.createElement(MainPage), document.getElementById('main'), diff --git a/lib/gui/app/components/drive-selector/DriveSelectorModal.tsx b/lib/gui/app/components/drive-selector/DriveSelectorModal.tsx index c93f3f68..48f261c2 100644 --- a/lib/gui/app/components/drive-selector/DriveSelectorModal.tsx +++ b/lib/gui/app/components/drive-selector/DriveSelectorModal.tsx @@ -49,8 +49,6 @@ function toggleDrive(drive: DrivelistDrive) { analytics.logEvent('Toggle drive', { drive, previouslySelected: selectionState.isDriveSelected(drive.device), - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, }); selectionState.toggleDrive(drive.device); @@ -113,8 +111,6 @@ export function DriveSelectorModal({ close }: { close: () => void }) { if (drive.link) { analytics.logEvent('Open driver link modal', { url: drive.link, - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, }); setMissingDriversModal({ drive }); } @@ -131,10 +127,7 @@ export function DriveSelectorModal({ close }: { close: () => void }) { if (canChangeDriveSelectionState) { selectionState.selectDrive(drive.device); - analytics.logEvent('Drive selected (double click)', { - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, - }); + analytics.logEvent('Drive selected (double click)'); close(); } @@ -190,7 +183,7 @@ export function DriveSelectorModal({ close }: { close: () => void }) {
keyboardToggleDrive(drive, evt)} + onKeyPress={(evt) => keyboardToggleDrive(drive, evt)} >
{drive.description} diff --git a/lib/gui/app/components/drive-selector/target-selector.tsx b/lib/gui/app/components/drive-selector/target-selector.tsx index f9dd930b..fe35b9b9 100644 --- a/lib/gui/app/components/drive-selector/target-selector.tsx +++ b/lib/gui/app/components/drive-selector/target-selector.tsx @@ -34,15 +34,15 @@ import { } from '../../styled-components'; import { middleEllipsis } from '../../utils/middle-ellipsis'; -const TargetDetail = styled(props => )` +const TargetDetail = styled((props) => )` float: ${({ float }) => float}; `; interface TargetSelectorProps { targets: any[]; disabled: boolean; - openDriveSelector: () => any; - reselectDrive: () => any; + openDriveSelector: () => void; + reselectDrive: () => void; flashing: boolean; show: boolean; tooltip: string; diff --git a/lib/gui/app/components/featured-project/featured-project.tsx b/lib/gui/app/components/featured-project/featured-project.tsx index 98d39d54..9aab2d2c 100644 --- a/lib/gui/app/components/featured-project/featured-project.tsx +++ b/lib/gui/app/components/featured-project/featured-project.tsx @@ -37,10 +37,10 @@ export class FeaturedProject extends React.Component< this.state = { endpoint: null }; } - public componentDidMount() { + public async componentDidMount() { try { const endpoint = - settings.get('featuredProjectEndpoint') || + (await settings.get('featuredProjectEndpoint')) || 'https://assets.balena.io/etcher-featured/index.html'; this.setState({ endpoint }); } catch (error) { diff --git a/lib/gui/app/components/finish/finish.tsx b/lib/gui/app/components/finish/finish.tsx index dc1cb194..c2df38b8 100644 --- a/lib/gui/app/components/finish/finish.tsx +++ b/lib/gui/app/components/finish/finish.tsx @@ -22,29 +22,14 @@ import * as flashState from '../../models/flash-state'; import * as selectionState from '../../models/selection-state'; import { store } from '../../models/store'; import * as analytics from '../../modules/analytics'; -import { updateLock } from '../../modules/update-lock'; import { open as openExternal } from '../../os/open-external/services/open-external'; import { FlashAnother } from '../flash-another/flash-another'; import { FlashResults } from '../flash-results/flash-results'; import { SVGIcon } from '../svg-icon/svg-icon'; -const restart = (options: any, goToMain: () => void) => { - const { - applicationSessionUuid, - flashingWorkflowUuid, - } = store.getState().toJS(); - if (!options.preserveImage) { - selectionState.deselectImage(); - } +function restart(goToMain: () => void) { selectionState.deselectAllDrives(); - analytics.logEvent('Restart', { - ...options, - applicationSessionUuid, - flashingWorkflowUuid, - }); - - // Re-enable lock release on inactivity - updateLock.resume(); + analytics.logEvent('Restart'); // Reset the flashing workflow uuid store.dispatch({ @@ -53,17 +38,17 @@ const restart = (options: any, goToMain: () => void) => { }); goToMain(); -}; +} -const formattedErrors = () => { +function formattedErrors() { const errors = _.map( _.get(flashState.getFlashResults(), ['results', 'errors']), - error => { + (error) => { return `${error.device}: ${error.message || error.code}`; }, ); return errors.join('\n'); -}; +} function FinishPage({ goToMain }: { goToMain: () => void }) { const results = flashState.getFlashResults().results || {}; @@ -74,8 +59,10 @@ function FinishPage({ goToMain }: { goToMain: () => void }) { restart(options, goToMain)} - > + onClick={() => { + restart(goToMain); + }} + />
diff --git a/lib/gui/app/components/flash-another/flash-another.tsx b/lib/gui/app/components/flash-another/flash-another.tsx index 7083ace3..a3c36874 100644 --- a/lib/gui/app/components/flash-another/flash-another.tsx +++ b/lib/gui/app/components/flash-another/flash-another.tsx @@ -26,17 +26,14 @@ const Div = styled.div` `; export interface FlashAnotherProps { - onClick: (options: { preserveImage: boolean }) => void; + onClick: () => void; } export const FlashAnother = (props: FlashAnotherProps) => { return (
- + Flash Another
diff --git a/lib/gui/app/components/progress-button/progress-button.tsx b/lib/gui/app/components/progress-button/progress-button.tsx index 052baa5b..1d2080bb 100644 --- a/lib/gui/app/components/progress-button/progress-button.tsx +++ b/lib/gui/app/components/progress-button/progress-button.tsx @@ -14,39 +14,11 @@ * limitations under the License. */ -import * as Color from 'color'; import * as React from 'react'; import { ProgressBar } from 'rendition'; -import { css, default as styled, keyframes } from 'styled-components'; +import { default as styled } from 'styled-components'; -import { StepButton, StepSelection } from '../../styled-components'; -import { colors } from '../../theme'; - -const darkenForegroundStripes = 0.18; -const desaturateForegroundStripes = 0.2; -const progressButtonStripesForegroundColor = Color(colors.primary.background) - .darken(darkenForegroundStripes) - .desaturate(desaturateForegroundStripes) - .string(); - -const desaturateBackgroundStripes = 0.05; -const progressButtonStripesBackgroundColor = Color(colors.primary.background) - .desaturate(desaturateBackgroundStripes) - .string(); - -const ProgressButtonStripes = keyframes` - 0% { - background-position: 0 0; - } - - 100% { - background-position: 20px 20px; - } -`; - -const ProgressButtonStripesRule = css` - ${ProgressButtonStripes} 1s linear infinite; -`; +import { StepButton } from '../../styled-components'; const FlashProgressBar = styled(ProgressBar)` > div { @@ -54,6 +26,10 @@ const FlashProgressBar = styled(ProgressBar)` height: 48px; color: white !important; text-shadow: none !important; + transition-duration: 0s; + > div { + transition-duration: 0s; + } } width: 200px; @@ -61,86 +37,47 @@ const FlashProgressBar = styled(ProgressBar)` font-size: 16px; line-height: 48px; - background: ${Color(colors.warning.background) - .darken(darkenForegroundStripes) - .string()}; -`; - -const FlashProgressBarValidating = styled(FlashProgressBar)` - // Notice that we add 0.01 to certain gradient stop positions. - // That workarounds a Chrome rendering issue where diagonal - // lines look spiky. - // See https://github.com/balena-io/etcher/issues/472 - - background-image: -webkit-gradient( - linear, - 0 0, - 100% 100%, - color-stop(0.25, ${progressButtonStripesForegroundColor}), - color-stop(0.26, ${progressButtonStripesBackgroundColor}), - color-stop(0.5, ${progressButtonStripesBackgroundColor}), - color-stop(0.51, ${progressButtonStripesForegroundColor}), - color-stop(0.75, ${progressButtonStripesForegroundColor}), - color-stop(0.76, ${progressButtonStripesBackgroundColor}), - to(${progressButtonStripesBackgroundColor}) - ); - - background-color: white; - - animation: ${ProgressButtonStripesRule}; - overflow: hidden; - - background-size: 20px 20px; + background: #2f3033; `; interface ProgressButtonProps { - striped: boolean; + type: 'decompressing' | 'flashing' | 'verifying'; active: boolean; percentage: number; label: string; disabled: boolean; - callback: () => any; + callback: () => void; } +const colors = { + decompressing: '#00aeef', + flashing: '#da60ff', + verifying: '#1ac135', +} as const; + /** * Progress Button component */ export class ProgressButton extends React.Component { public render() { if (this.props.active) { - if (this.props.striped) { - return ( - - - {this.props.label} - - - ); - } - return ( - - - {this.props.label} - - - ); - } - - return ( - - {this.props.label} - - + + ); + } + return ( + + {this.props.label} + ); } } diff --git a/lib/gui/app/components/safe-webview/safe-webview.tsx b/lib/gui/app/components/safe-webview/safe-webview.tsx index 2d192795..234d7f23 100644 --- a/lib/gui/app/components/safe-webview/safe-webview.tsx +++ b/lib/gui/app/components/safe-webview/safe-webview.tsx @@ -20,7 +20,6 @@ import * as React from 'react'; import * as packageJSON from '../../../../../package.json'; import * as settings from '../../models/settings'; -import { store } from '../../models/store'; import * as analytics from '../../modules/analytics'; /** @@ -92,7 +91,7 @@ export class SafeWebview extends React.PureComponent< url.searchParams.set(API_VERSION_PARAM, API_VERSION); url.searchParams.set( OPT_OUT_ANALYTICS_PARAM, - (!settings.get('errorReporting')).toString(), + (!settings.getSync('errorReporting')).toString(), ); this.entryHref = url.href; // Events steal 'this' @@ -182,11 +181,7 @@ export class SafeWebview extends React.PureComponent< // only care about this event if it's a request for the main frame if (event.resourceType === 'mainFrame') { const HTTP_OK = 200; - analytics.logEvent('SafeWebview loaded', { - event, - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, - }); + analytics.logEvent('SafeWebview loaded', { event }); this.setState({ shouldShow: event.statusCode === HTTP_OK, }); @@ -197,15 +192,13 @@ export class SafeWebview extends React.PureComponent< } // Open link in browser if it's opened as a 'foreground-tab' - public static newWindow(event: electron.NewWindowEvent) { + public static async newWindow(event: electron.NewWindowEvent) { const url = new window.URL(event.url); if ( - _.every([ - url.protocol === 'http:' || url.protocol === 'https:', - event.disposition === 'foreground-tab', - // Don't open links if they're disabled by the env var - !settings.get('disableExternalLinks'), - ]) + (url.protocol === 'http:' || url.protocol === 'https:') && + event.disposition === 'foreground-tab' && + // Don't open links if they're disabled by the env var + !(await settings.get('disableExternalLinks')) ) { electron.shell.openExternal(url.href); } diff --git a/lib/gui/app/components/settings/settings.tsx b/lib/gui/app/components/settings/settings.tsx index 3ff61072..1811d0e5 100644 --- a/lib/gui/app/components/settings/settings.tsx +++ b/lib/gui/app/components/settings/settings.tsx @@ -20,15 +20,12 @@ import * as _ from 'lodash'; import * as os from 'os'; import * as React from 'react'; import { Badge, Checkbox, Modal } from 'rendition'; -import styled from 'styled-components'; import { version } from '../../../../../package.json'; import * as settings from '../../models/settings'; -import { store } from '../../models/store'; import * as analytics from '../../modules/analytics'; import { open as openExternal } from '../../os/open-external/services/open-external'; -const { useState } = React; const platform = os.platform(); interface WarningModalProps { @@ -64,159 +61,174 @@ const WarningModal = ({ interface Setting { name: string; label: string | JSX.Element; - options?: any; + options?: { + description: string; + confirmLabel: string; + }; hide?: boolean; } -const settingsList: Setting[] = [ - { - name: 'errorReporting', - label: 'Anonymously report errors and usage statistics to balena.io', - }, - { - name: 'unmountOnSuccess', - /** - * On Windows, "Unmounting" basically means "ejecting". - * On top of that, Windows users are usually not even - * familiar with the meaning of "unmount", which comes - * from the UNIX world. - */ - label: `${platform === 'win32' ? 'Eject' : 'Auto-unmount'} on success`, - }, - { - name: 'validateWriteOnSuccess', - label: 'Validate write on success', - }, - { - name: 'trim', - label: 'Trim ext{2,3,4} partitions before writing (raw images only)', - }, - { - name: 'updatesEnabled', - label: 'Auto-updates enabled', - }, - { - name: 'unsafeMode', - label: ( - - Unsafe mode{' '} - - Dangerous - - - ), - options: { - description: `Are you sure you want to turn this on? - You will be able to overwrite your system drives if you're not careful.`, - confirmLabel: 'Enable unsafe mode', +async function getSettingsList(): Promise { + return [ + { + name: 'errorReporting', + label: 'Anonymously report errors and usage statistics to balena.io', }, - hide: settings.get('disableUnsafeMode'), - }, -]; + { + name: 'unmountOnSuccess', + /** + * On Windows, "Unmounting" basically means "ejecting". + * On top of that, Windows users are usually not even + * familiar with the meaning of "unmount", which comes + * from the UNIX world. + */ + label: `${platform === 'win32' ? 'Eject' : 'Auto-unmount'} on success`, + }, + { + name: 'validateWriteOnSuccess', + label: 'Validate write on success', + }, + { + name: 'updatesEnabled', + label: 'Auto-updates enabled', + }, + { + name: 'unsafeMode', + label: ( + + Unsafe mode{' '} + + Dangerous + + + ), + options: { + description: `Are you sure you want to turn this on? + You will be able to overwrite your system drives if you're not careful.`, + confirmLabel: 'Enable unsafe mode', + }, + hide: await settings.get('disableUnsafeMode'), + }, + ]; +} + +interface Warning { + setting: string; + settingValue: boolean; + description: string; + confirmLabel: string; +} interface SettingsModalProps { toggleModal: (value: boolean) => void; } -export const SettingsModal: any = styled( - ({ toggleModal }: SettingsModalProps) => { - const [currentSettings, setCurrentSettings]: [ - _.Dictionary, - React.Dispatch>>, - ] = useState(settings.getAll()); - const [warning, setWarning]: [ - any, - React.Dispatch>, - ] = useState({}); - - const toggleSetting = async (setting: string, options?: any) => { - const value = currentSettings[setting]; - const dangerous = !_.isUndefined(options); - - analytics.logEvent('Toggle setting', { - setting, - value, - dangerous, - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, - }); - - if (value || !dangerous) { - await settings.set(setting, !value); - setCurrentSettings({ - ...currentSettings, - [setting]: !value, - }); - setWarning({}); - return; +export function SettingsModal({ toggleModal }: SettingsModalProps) { + const [settingsList, setCurrentSettingsList] = React.useState([]); + React.useEffect(() => { + (async () => { + if (settingsList.length === 0) { + setCurrentSettingsList(await getSettingsList()); } + })(); + }); + const [currentSettings, setCurrentSettings] = React.useState< + _.Dictionary + >({}); + React.useEffect(() => { + (async () => { + if (_.isEmpty(currentSettings)) { + setCurrentSettings(await settings.getAll()); + } + })(); + }); + const [warning, setWarning] = React.useState(undefined); + const toggleSetting = async ( + setting: string, + options?: Setting['options'], + ) => { + const value = currentSettings[setting]; + const dangerous = options !== undefined; + + analytics.logEvent('Toggle setting', { + setting, + value, + dangerous, + }); + + if (value || options === undefined) { + await settings.set(setting, !value); + setCurrentSettings({ + ...currentSettings, + [setting]: !value, + }); + setWarning(undefined); + return; + } else { // Show warning since it's a dangerous setting setWarning({ setting, settingValue: value, ...options, }); - }; + } + }; - return ( - toggleModal(false)} - style={{ - width: 780, - height: 420, - }} - > + return ( + toggleModal(false)} + style={{ + width: 780, + height: 420, + }} + > +
+ {_.map(settingsList, (setting: Setting, i: number) => { + return setting.hide ? null : ( +
+ toggleSetting(setting.name, setting.options)} + /> +
+ ); + })}
- {_.map(settingsList, (setting: Setting, i: number) => { - return setting.hide ? null : ( -
- toggleSetting(setting.name, setting.options)} - /> -
- ); - })} -
- - openExternal( - 'https://github.com/balena-io/etcher/blob/master/CHANGELOG.md', - ) - } - > - {version} - -
+ + openExternal( + 'https://github.com/balena-io/etcher/blob/master/CHANGELOG.md', + ) + } + > + {version} +
+
- {_.isEmpty(warning) ? null : ( - { - settings.set(warning.setting, !warning.settingValue); - setCurrentSettings({ - ...currentSettings, - [warning.setting]: true, - }); - setWarning({}); - }} - cancel={() => { - setWarning({}); - }} - /> - )} -
- ); - }, -)` - > div:nth-child(3) { - justify-content: center; - } -`; + {warning === undefined ? null : ( + { + await settings.set(warning.setting, !warning.settingValue); + setCurrentSettings({ + ...currentSettings, + [warning.setting]: true, + }); + setWarning(undefined); + }} + cancel={() => { + setWarning(undefined); + }} + /> + )} +
+ ); +} diff --git a/lib/gui/app/components/source-selector/source-selector.tsx b/lib/gui/app/components/source-selector/source-selector.tsx index 9fe329eb..d192179c 100644 --- a/lib/gui/app/components/source-selector/source-selector.tsx +++ b/lib/gui/app/components/source-selector/source-selector.tsx @@ -29,7 +29,7 @@ import * as messages from '../../../../shared/messages'; import * as supportedFormats from '../../../../shared/supported-formats'; import * as shared from '../../../../shared/units'; import * as selectionState from '../../models/selection-state'; -import { observe, store } from '../../models/store'; +import { observe } from '../../models/store'; import * as analytics from '../../modules/analytics'; import * as exceptionReporter from '../../modules/exception-reporter'; import * as osDialog from '../../os/dialog'; @@ -148,7 +148,7 @@ const URLSelector = ({ done }: { done: (imageURL: string) => void }) => { Recent ( + rows={_.map(recentImages, (recent) => ( { @@ -254,8 +254,6 @@ export class SourceSelector extends React.Component< private reselectImage() { analytics.logEvent('Reselect image', { previousImage: selectionState.getImage(), - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, }); selectionState.deselectImage(); @@ -275,17 +273,7 @@ export class SourceSelector extends React.Component< }); osDialog.showError(invalidImageError); - analytics.logEvent( - 'Invalid image', - _.merge( - { - applicationSessionUuid: store.getState().toJS() - .applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, - }, - image, - ), - ); + analytics.logEvent('Invalid image', image); return; } @@ -294,21 +282,11 @@ export class SourceSelector extends React.Component< let title = null; if (supportedFormats.looksLikeWindowsImage(image.path)) { - analytics.logEvent('Possibly Windows image', { - image, - applicationSessionUuid: store.getState().toJS() - .applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, - }); + analytics.logEvent('Possibly Windows image', { image }); message = messages.warning.looksLikeWindowsImage(); title = 'Possible Windows image detected'; } else if (!image.hasMBR) { - analytics.logEvent('Missing partition table', { - image, - applicationSessionUuid: store.getState().toJS() - .applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, - }); + analytics.logEvent('Missing partition table', { image }); title = 'Missing partition table'; message = messages.warning.missingPartitionTable(); } @@ -331,8 +309,6 @@ export class SourceSelector extends React.Component< logo: Boolean(image.logo), blockMap: Boolean(image.blockMap), }, - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, }); } catch (error) { exceptionReporter.report(error); @@ -375,7 +351,7 @@ export class SourceSelector extends React.Component< analytics.logEvent('Unsupported protocol', { path: imagePath }); return; } - source = new sourceDestination.Http(imagePath); + source = new sourceDestination.Http({ url: imagePath }); } try { @@ -420,21 +396,14 @@ export class SourceSelector extends React.Component< } private async openImageSelector() { - analytics.logEvent('Open image selector', { - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, - }); + analytics.logEvent('Open image selector'); try { const imagePath = await osDialog.selectImage(); // Avoid analytics and selection state changes // if no file was resolved from the dialog. if (!imagePath) { - analytics.logEvent('Image selector closed', { - applicationSessionUuid: store.getState().toJS() - .applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, - }); + analytics.logEvent('Image selector closed'); return; } this.selectImageByPath({ @@ -457,11 +426,7 @@ export class SourceSelector extends React.Component< } private openURLSelector() { - analytics.logEvent('Open image URL selector', { - applicationSessionUuid: - store.getState().toJS().applicationSessionUuid || '', - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, - }); + analytics.logEvent('Open image URL selector'); this.setState({ showURLSelector: true, @@ -481,8 +446,6 @@ export class SourceSelector extends React.Component< private showSelectedImageDetails() { analytics.logEvent('Show selected image tooltip', { imagePath: selectionState.getImagePath(), - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, }); this.setState({ @@ -606,12 +569,7 @@ export class SourceSelector extends React.Component< // Avoid analytics and selection state changes // if no file was resolved from the dialog. if (!imagePath) { - analytics.logEvent('URL selector closed', { - applicationSessionUuid: store.getState().toJS() - .applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS() - .flashingWorkflowUuid, - }); + analytics.logEvent('URL selector closed'); this.setState({ showURLSelector: false, }); diff --git a/lib/gui/app/components/svg-icon/svg-icon.tsx b/lib/gui/app/components/svg-icon/svg-icon.tsx index 61efcebe..200e65df 100644 --- a/lib/gui/app/components/svg-icon/svg-icon.tsx +++ b/lib/gui/app/components/svg-icon/svg-icon.tsx @@ -82,7 +82,7 @@ export class SVGIcon extends React.Component { let svgData = ''; - _.find(this.props.contents, content => { + _.find(this.props.contents, (content) => { const attempt = tryParseSVGContents(content); if (attempt) { @@ -94,7 +94,7 @@ export class SVGIcon extends React.Component { }); if (!svgData) { - _.find(this.props.paths, relativePath => { + _.find(this.props.paths, (relativePath) => { // This means the path to the icon should be // relative to *this directory*. // TODO: There might be a way to compute the path diff --git a/lib/gui/app/models/flash-state.ts b/lib/gui/app/models/flash-state.ts index 4c2c1d56..3894ce6d 100644 --- a/lib/gui/app/models/flash-state.ts +++ b/lib/gui/app/models/flash-state.ts @@ -85,13 +85,6 @@ export function setProgressState( return _.round(bytesToMegabytes(state.speed), PRECISION); } - return null; - }), - totalSpeed: _.attempt(() => { - if (_.isFinite(state.totalSpeed)) { - return _.round(bytesToMegabytes(state.totalSpeed), PRECISION); - } - return null; }), }); @@ -107,10 +100,7 @@ export function getFlashResults() { } export function getFlashState() { - return store - .getState() - .get('flashState') - .toJS(); + return store.getState().get('flashState').toJS(); } export function wasLastFlashCancelled() { diff --git a/lib/gui/app/models/leds.ts b/lib/gui/app/models/leds.ts index 85363138..406c2566 100644 --- a/lib/gui/app/models/leds.ts +++ b/lib/gui/app/models/leds.ts @@ -66,7 +66,7 @@ interface DeviceFromState { device: string; } -export function init() { +export async function init(): Promise { // ledsMapping is something like: // { // 'platform-xhci-hcd.0.auto-usb-0:1.1.1:1.0-scsi-0:0:0:0': [ @@ -77,11 +77,11 @@ export function init() { // ... // } const ledsMapping: _.Dictionary<[string, string, string]> = - settings.get('ledsMapping') || {}; + (await settings.get('ledsMapping')) || {}; for (const [drivePath, ledsNames] of Object.entries(ledsMapping)) { leds.set('/dev/disk/by-path/' + drivePath, new RGBLed(ledsNames)); } - observe(state => { + observe((state) => { const availableDrives = state .get('availableDrives') .toJS() diff --git a/lib/gui/app/models/local-settings.ts b/lib/gui/app/models/local-settings.ts deleted file mode 100644 index 2a9c8439..00000000 --- a/lib/gui/app/models/local-settings.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2017 balena.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as electron from 'electron'; -import { promises as fs } from 'fs'; -import * as path from 'path'; - -const JSON_INDENT = 2; - -/** - * @summary Userdata directory path - * @description - * Defaults to the following: - * - `%APPDATA%/etcher` on Windows - * - `$XDG_CONFIG_HOME/etcher` or `~/.config/etcher` on Linux - * - `~/Library/Application Support/etcher` on macOS - * See https://electronjs.org/docs/api/app#appgetpathname - * - * 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 CONFIG_PATH = path.join(USER_DATA_DIR, 'config.json'); - -async function readConfigFile(filename: string): Promise { - let contents = '{}'; - try { - contents = await fs.readFile(filename, { encoding: 'utf8' }); - } catch (error) { - if (error.code !== 'ENOENT') { - throw error; - } - } - try { - return JSON.parse(contents); - } catch (parseError) { - console.error(parseError); - return {}; - } -} - -async function writeConfigFile(filename: string, data: any): Promise { - await fs.writeFile(filename, JSON.stringify(data, null, JSON_INDENT)); - return data; -} - -export async function readAll(): Promise { - return await readConfigFile(CONFIG_PATH); -} - -export async function writeAll(settings: any): Promise { - return await writeConfigFile(CONFIG_PATH, settings); -} - -export async function clear(): Promise { - await fs.unlink(CONFIG_PATH); -} diff --git a/lib/gui/app/models/selection-state.ts b/lib/gui/app/models/selection-state.ts index caf53b09..6a19f73a 100644 --- a/lib/gui/app/models/selection-state.ts +++ b/lib/gui/app/models/selection-state.ts @@ -51,10 +51,7 @@ export function selectImage(image: any) { * @summary Get all selected drives' devices */ export function getSelectedDevices(): string[] { - return store - .getState() - .getIn(['selection', 'devices']) - .toJS(); + return store.getState().getIn(['selection', 'devices']).toJS(); } /** @@ -62,7 +59,7 @@ export function getSelectedDevices(): string[] { */ export function getSelectedDrives(): any[] { const drives = availableDrives.getDrives(); - return _.map(getSelectedDevices(), device => { + return _.map(getSelectedDevices(), (device) => { return _.find(drives, { device }); }); } diff --git a/lib/gui/app/models/settings.ts b/lib/gui/app/models/settings.ts index 2f3a62df..a4b00eee 100644 --- a/lib/gui/app/models/settings.ts +++ b/lib/gui/app/models/settings.ts @@ -15,71 +15,93 @@ */ import * as _debug from 'debug'; +import * as electron from 'electron'; import * as _ from 'lodash'; +import { promises as fs } from 'fs'; +import { join } from 'path'; import * as packageJSON from '../../../../package.json'; -import * as errors from '../../../shared/errors'; -import * as localSettings from './local-settings'; const debug = _debug('etcher:models:settings'); +const JSON_INDENT = 2; + +/** + * @summary Userdata directory path + * @description + * Defaults to the following: + * - `%APPDATA%/etcher` on Windows + * - `$XDG_CONFIG_HOME/etcher` or `~/.config/etcher` on Linux + * - `~/Library/Application Support/etcher` on macOS + * See https://electronjs.org/docs/api/app#appgetpathname + * + * 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 CONFIG_PATH = join(USER_DATA_DIR, 'config.json'); + +async function readConfigFile(filename: string): Promise<_.Dictionary> { + let contents = '{}'; + try { + contents = await fs.readFile(filename, { encoding: 'utf8' }); + } catch (error) { + // noop + } + try { + return JSON.parse(contents); + } catch (parseError) { + console.error(parseError); + return {}; + } +} + // exported for tests -export const DEFAULT_SETTINGS: _.Dictionary = { +export async function readAll() { + return await readConfigFile(CONFIG_PATH); +} + +// exported for tests +export async function writeConfigFile( + filename: string, + data: _.Dictionary, +): Promise { + await fs.writeFile(filename, JSON.stringify(data, null, JSON_INDENT)); +} + +const DEFAULT_SETTINGS: _.Dictionary = { unsafeMode: false, errorReporting: true, unmountOnSuccess: true, validateWriteOnSuccess: true, - trim: false, - updatesEnabled: - packageJSON.updates.enabled && - !_.includes(['rpm', 'deb'], packageJSON.packageType), - lastSleptUpdateNotifier: null, - lastSleptUpdateNotifierVersion: null, + updatesEnabled: !_.includes(['rpm', 'deb'], packageJSON.packageType), desktopNotifications: true, + autoBlockmapping: true, + decompressFirst: true, }; -let settings = _.cloneDeep(DEFAULT_SETTINGS); +const settings = _.cloneDeep(DEFAULT_SETTINGS); -/** - * @summary Reset settings to their default values - */ -export async function reset(): Promise { - debug('reset'); - // TODO: Remove default settings from config file (?) - settings = _.cloneDeep(DEFAULT_SETTINGS); - return await localSettings.writeAll(settings); -} - -/** - * @summary Extend the application state with the local settings - */ -export async function load(): Promise { +async function load(): Promise { debug('load'); - const loadedSettings = await localSettings.readAll(); + // Use exports.readAll() so it can be mocked in tests + const loadedSettings = await exports.readAll(); _.assign(settings, loadedSettings); } -/** - * @summary Set a setting value - */ +const loaded = load(); + export async function set(key: string, value: any): Promise { debug('set', key, value); - if (_.isNil(key)) { - throw errors.createError({ - title: 'Missing setting key', - }); - } - - if (!_.isString(key)) { - throw errors.createError({ - title: `Invalid setting key: ${key}`, - }); - } - + await loaded; const previousValue = settings[key]; settings[key] = value; try { - await localSettings.writeAll(settings); + // Use exports.writeConfigFile() so it can be mocked in tests + await exports.writeConfigFile(CONFIG_PATH, settings); } catch (error) { // Revert to previous value if persisting settings failed settings[key] = previousValue; @@ -87,24 +109,17 @@ export async function set(key: string, value: any): Promise { } } -/** - * @summary Get a setting value - */ -export function get(key: string): any { - return _.cloneDeep(_.get(settings, [key])); +export async function get(key: string): Promise { + await loaded; + return getSync(key); } -/** - * @summary Check if setting value exists - */ -export function has(key: string): boolean { - return settings[key] != null; +export function getSync(key: string): any { + return _.cloneDeep(settings[key]); } -/** - * @summary Get all setting values - */ -export function getAll() { +export async function getAll() { debug('getAll'); + await loaded; return _.cloneDeep(settings); } diff --git a/lib/gui/app/models/store.ts b/lib/gui/app/models/store.ts index 412e5ba8..b509d292 100644 --- a/lib/gui/app/models/store.ts +++ b/lib/gui/app/models/store.ts @@ -34,7 +34,7 @@ function verifyNoNilFields( fields: string[], name: string, ) { - const nilFields = _.filter(fields, field => { + const nilFields = _.filter(fields, (field) => { return _.isNil(_.get(object, field)); }); if (nilFields.length) { @@ -45,7 +45,7 @@ function verifyNoNilFields( /** * @summary FLASH_STATE fields that can't be nil */ -const flashStateNoNilFields = ['speed', 'totalSpeed']; +const flashStateNoNilFields = ['speed']; /** * @summary SELECT_IMAGE fields that can't be nil @@ -65,14 +65,11 @@ const DEFAULT_STATE = Immutable.fromJS({ isFlashing: false, flashResults: {}, flashState: { - flashing: 0, - verifying: 0, - successful: 0, + active: 0, failed: 0, percentage: 0, speed: null, averageSpeed: null, - totalSpeed: null, }, lastAverageFlashingSpeed: null, }); @@ -136,9 +133,9 @@ function storeReducer( drives = _.sortBy(drives, [ // Devices with no devicePath first (usbboot) - d => !!d.devicePath, + (d) => !!d.devicePath, // Then sort by devicePath (only available on Linux with udev) or device - d => d.devicePath || d.device, + (d) => d.devicePath || d.device, ]); const newState = state.set('availableDrives', Immutable.fromJS(drives)); @@ -168,7 +165,7 @@ function storeReducer( ); const shouldAutoselectAll = Boolean( - settings.get('disableExplicitDriveSelection'), + settings.getSync('disableExplicitDriveSelection'), ); const AUTOSELECT_DRIVE_COUNT = 1; const nonStaleSelectedDevices = nonStaleNewState @@ -234,17 +231,7 @@ function storeReducer( verifyNoNilFields(action.data, flashStateNoNilFields, 'flash'); - if ( - !_.every( - _.pick(action.data, [ - 'flashing', - 'verifying', - 'successful', - 'failed', - ]), - _.isFinite, - ) - ) { + if (!_.every(_.pick(action.data, ['active', 'failed']), _.isFinite)) { throw errors.createError({ title: 'State quantity field(s) not finite number', }); @@ -266,7 +253,7 @@ function storeReducer( } let ret = state.set('flashState', Immutable.fromJS(action.data)); - if (action.data.flashing) { + if (action.data.type === 'flashing') { ret = ret.set('lastAverageFlashingSpeed', action.data.averageSpeed); } return ret; diff --git a/lib/gui/app/modules/analytics.ts b/lib/gui/app/modules/analytics.ts index 9fac2b2d..f62f10ac 100644 --- a/lib/gui/app/modules/analytics.ts +++ b/lib/gui/app/modules/analytics.ts @@ -20,34 +20,31 @@ import * as resinCorvus from 'resin-corvus/browser'; import * as packageJSON from '../../../../package.json'; import { getConfig, hasProps } from '../../../shared/utils'; import * as settings from '../models/settings'; - -const sentryToken = - settings.get('analyticsSentryToken') || - _.get(packageJSON, ['analytics', 'sentry', 'token']); -const mixpanelToken = - settings.get('analyticsMixpanelToken') || - _.get(packageJSON, ['analytics', 'mixpanel', 'token']); - -const configUrl = - settings.get('configUrl') || 'https://balena.io/etcher/static/config.json'; +import { store } from '../models/store'; const DEFAULT_PROBABILITY = 0.1; -const services = { - sentry: sentryToken, - mixpanel: mixpanelToken, -}; - -resinCorvus.install({ - services, - options: { - release: packageJSON.version, - shouldReport: () => { - return settings.get('errorReporting'); +async function installCorvus(): Promise { + const sentryToken = + (await settings.get('analyticsSentryToken')) || + _.get(packageJSON, ['analytics', 'sentry', 'token']); + const mixpanelToken = + (await settings.get('analyticsMixpanelToken')) || + _.get(packageJSON, ['analytics', 'mixpanel', 'token']); + resinCorvus.install({ + services: { + sentry: sentryToken, + mixpanel: mixpanelToken, }, - mixpanelDeferred: true, - }, -}); + options: { + release: packageJSON.version, + shouldReport: () => { + return settings.getSync('errorReporting'); + }, + mixpanelDeferred: true, + }, + }); +} let mixpanelSample = DEFAULT_PROBABILITY; @@ -55,9 +52,10 @@ let mixpanelSample = DEFAULT_PROBABILITY; * @summary Init analytics configurations */ async function initConfig() { + await installCorvus(); let validatedConfig = null; try { - const config = await getConfig(configUrl); + const config = await getConfig(); const mixpanel = _.get(config, ['analytics', 'mixpanel'], {}); mixpanelSample = mixpanel.probability || DEFAULT_PROBABILITY; if (isClientEligible(mixpanelSample)) { @@ -96,22 +94,23 @@ function validateMixpanelConfig(config: { return mixpanelConfig; } -/** - * @summary Log a debug message - * - * @description - * This function sends the debug message to error reporting services. - */ -export const logDebug = resinCorvus.logDebug; - /** * @summary Log an event * * @description * This function sends the debug message to product analytics services. */ -export function logEvent(message: string, data: any) { - resinCorvus.logEvent(message, { ...data, sample: mixpanelSample }); +export function logEvent(message: string, data: _.Dictionary = {}) { + const { + applicationSessionUuid, + flashingWorkflowUuid, + } = store.getState().toJS(); + resinCorvus.logEvent(message, { + ...data, + sample: mixpanelSample, + applicationSessionUuid, + flashingWorkflowUuid, + }); } /** diff --git a/lib/gui/app/modules/drive-scanner.ts b/lib/gui/app/modules/drive-scanner.ts index 5d236e19..bc550433 100644 --- a/lib/gui/app/modules/drive-scanner.ts +++ b/lib/gui/app/modules/drive-scanner.ts @@ -23,7 +23,9 @@ import * as settings from '../models/settings'; * @summary returns true if system drives should be shown */ function includeSystemDrives() { - return settings.get('unsafeMode') && !settings.get('disableUnsafeMode'); + return ( + settings.getSync('unsafeMode') && !settings.getSync('disableUnsafeMode') + ); } const adapters: sdk.scanner.adapters.Adapter[] = [ diff --git a/lib/gui/app/modules/image-writer.ts b/lib/gui/app/modules/image-writer.ts index a78f7d8e..bdf87207 100644 --- a/lib/gui/app/modules/image-writer.ts +++ b/lib/gui/app/modules/image-writer.ts @@ -29,10 +29,8 @@ import { SourceOptions } from '../components/source-selector/source-selector'; import * as flashState from '../models/flash-state'; import * as selectionState from '../models/selection-state'; import * as settings from '../models/settings'; -import { store } from '../models/store'; import * as analytics from '../modules/analytics'; import * as windowProgress from '../os/window-progress'; -import { updateLock } from './update-lock'; const THREADS_PER_CPU = 16; @@ -61,8 +59,6 @@ function handleErrorLogging( ) { const eventData = { ...analyticsData, - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, flashInstanceUuid: flashState.getFlashUuid(), }; @@ -140,7 +136,7 @@ interface FlashResults { * @description * This function is extracted for testing purposes. */ -export function performWrite( +export async function performWrite( image: string, drives: DrivelistDrive[], onProgress: sdk.multiWrite.OnProgressFunction, @@ -148,14 +144,20 @@ export function performWrite( ): Promise<{ cancelled?: boolean }> { let cancelled = false; ipc.serve(); - return new Promise((resolve, reject) => { - ipc.server.on('error', error => { + const { + unmountOnSuccess, + validateWriteOnSuccess, + autoBlockmapping, + decompressFirst, + } = await settings.getAll(); + return await new Promise((resolve, reject) => { + ipc.server.on('error', (error) => { terminateServer(); const errorObject = errors.fromJSON(error); reject(errorObject); }); - ipc.server.on('log', message => { + ipc.server.on('log', (message) => { console.log(message); }); @@ -166,17 +168,16 @@ export function performWrite( driveCount: drives.length, uuid: flashState.getFlashUuid(), flashInstanceUuid: flashState.getFlashUuid(), - unmountOnSuccess: settings.get('unmountOnSuccess'), - validateWriteOnSuccess: settings.get('validateWriteOnSuccess'), - trim: settings.get('trim'), + unmountOnSuccess, + validateWriteOnSuccess, }; ipc.server.on('fail', ({ error }: { error: Error & { code: string } }) => { handleErrorLogging(error, analyticsData); }); - ipc.server.on('done', event => { - event.results.errors = _.map(event.results.errors, data => { + ipc.server.on('done', (event) => { + event.results.errors = _.map(event.results.errors, (data) => { return errors.fromJSON(data); }); _.merge(flashResults, event); @@ -195,9 +196,10 @@ export function performWrite( destinations: drives, source, SourceType: source.SourceType.name, - validateWriteOnSuccess: settings.get('validateWriteOnSuccess'), - trim: settings.get('trim'), - unmountOnSuccess: settings.get('unmountOnSuccess'), + validateWriteOnSuccess, + autoBlockmapping, + unmountOnSuccess, + decompressFirst, }); }); @@ -245,7 +247,6 @@ export function performWrite( // Clear the update lock timer to prevent longer // flashing timing it out, and releasing the lock - updateLock.pause(); ipc.server.start(); }); } @@ -271,11 +272,8 @@ export async function flash( uuid: flashState.getFlashUuid(), status: 'started', flashInstanceUuid: flashState.getFlashUuid(), - unmountOnSuccess: settings.get('unmountOnSuccess'), - validateWriteOnSuccess: settings.get('validateWriteOnSuccess'), - trim: settings.get('trim'), - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, + unmountOnSuccess: await settings.get('unmountOnSuccess'), + validateWriteOnSuccess: await settings.get('validateWriteOnSuccess'), }; analytics.logEvent('Flash', analyticsData); @@ -318,6 +316,8 @@ export async function flash( errors: results.errors, devices: results.devices, status: 'finished', + bytesWritten: results.bytesWritten, + sourceMetadata: results.sourceMetadata, }; analytics.logEvent('Done', eventData); } @@ -326,7 +326,7 @@ export async function flash( /** * @summary Cancel write operation */ -export function cancel() { +export async function cancel() { const drives = selectionState.getSelectedDevices(); const analyticsData = { image: selectionState.getImagePath(), @@ -334,17 +334,13 @@ export function cancel() { driveCount: drives.length, uuid: flashState.getFlashUuid(), flashInstanceUuid: flashState.getFlashUuid(), - unmountOnSuccess: settings.get('unmountOnSuccess'), - validateWriteOnSuccess: settings.get('validateWriteOnSuccess'), - trim: settings.get('trim'), - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, + unmountOnSuccess: await settings.get('unmountOnSuccess'), + validateWriteOnSuccess: await settings.get('validateWriteOnSuccess'), status: 'cancel', }; analytics.logEvent('Cancel', analyticsData); // Re-enable lock release on inactivity - updateLock.resume(); try { // @ts-ignore (no Server.sockets in @types/node-ipc) diff --git a/lib/gui/app/modules/progress-status.ts b/lib/gui/app/modules/progress-status.ts index 789bc815..cdcb958b 100644 --- a/lib/gui/app/modules/progress-status.ts +++ b/lib/gui/app/modules/progress-status.ts @@ -15,16 +15,15 @@ */ import { bytesToClosestUnit } from '../../../shared/units'; -import * as settings from '../models/settings'; +// import * as settings from '../models/settings'; export interface FlashState { - flashing: number; - verifying: number; - successful: number; + active: number; failed: number; percentage?: number; speed: number; position: number; + type?: 'decompressing' | 'flashing' | 'verifying'; } /** @@ -36,45 +35,47 @@ export interface FlashState { * * @example * const status = progressStatus.fromFlashState({ - * flashing: 1, - * verifying: 0, - * successful: 0, + * type: 'flashing' + * active: 1, * failed: 0, * percentage: 55, - * speed: 2049 + * speed: 2049, * }) * * console.log(status) * // '55% Flashing' */ -export function fromFlashState(state: FlashState): string { - const isFlashing = Boolean(state.flashing); - const isValidating = !isFlashing && Boolean(state.verifying); - const shouldValidate = settings.get('validateWriteOnSuccess'); - const shouldUnmount = settings.get('unmountOnSuccess'); - - if (state.percentage === 0 && !state.speed) { - if (isValidating) { - return 'Validating...'; - } - +export function fromFlashState({ + type, + percentage, + position, +}: FlashState): string { + if (type === undefined) { return 'Starting...'; - } else if (state.percentage === 100) { - if ((isValidating || !shouldValidate) && shouldUnmount) { - return 'Unmounting...'; + } else if (type === 'decompressing') { + if (percentage == null) { + return 'Decompressing...'; + } else { + return `${percentage}% Decompressing`; } - - return 'Finishing...'; - } else if (isFlashing) { - if (state.percentage != null) { - return `${state.percentage}% Flashing`; + } else if (type === 'flashing') { + if (percentage != null) { + if (percentage < 100) { + return `${percentage}% Flashing`; + } else { + return 'Finishing...'; + } + } else { + return `${bytesToClosestUnit(position)} flashed`; + } + } else if (type === 'verifying') { + if (percentage == null) { + return 'Validating...'; + } else if (percentage < 100) { + return `${percentage}% Validating`; + } else { + return 'Finishing...'; } - return `${bytesToClosestUnit(state.position)} flashed`; - } else if (isValidating) { - return `${state.percentage}% Validating`; - } else if (!isFlashing && !isValidating) { - return 'Failed'; } - - throw new Error(`Invalid state: ${JSON.stringify(state)}`); + return 'Failed'; } diff --git a/lib/gui/app/modules/update-lock.ts b/lib/gui/app/modules/update-lock.ts deleted file mode 100644 index 978e9c43..00000000 --- a/lib/gui/app/modules/update-lock.ts +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright 2018 balena.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as _debug from 'debug'; -import * as electron from 'electron'; -import { EventEmitter } from 'events'; -import * as createInactivityTimer from 'inactivity-timer'; - -import * as settings from '../models/settings'; -import { logException } from './analytics'; - -const debug = _debug('etcher:update-lock'); - -/** - * Interaction timeout in milliseconds (defaults to 5 minutes) - * @type {Number} - * @constant - */ -const INTERACTION_TIMEOUT_MS = settings.has('interactionTimeout') - ? parseInt(settings.get('interactionTimeout'), 10) - : 5 * 60 * 1000; - -class UpdateLock extends EventEmitter { - private paused: boolean; - private lockTimer: any; - - constructor() { - super(); - this.paused = false; - this.on('inactive', UpdateLock.onInactive); - this.lockTimer = createInactivityTimer(INTERACTION_TIMEOUT_MS, () => { - debug('inactive'); - this.emit('inactive'); - }); - } - - /** - * @summary Inactivity event handler, releases the balena update lock on inactivity - */ - private static onInactive() { - if (settings.get('resinUpdateLock')) { - UpdateLock.check((checkError: Error, isLocked: boolean) => { - debug('inactive-check', Boolean(checkError)); - if (checkError) { - logException(checkError); - } - if (isLocked) { - UpdateLock.release((error?: Error) => { - debug('inactive-release', Boolean(error)); - if (error) { - logException(error); - } - }); - } - }); - } - } - - /** - * @summary Acquire the update lock - */ - private static acquire(callback: (error?: Error) => void) { - debug('lock'); - if (settings.get('resinUpdateLock')) { - electron.ipcRenderer.once('resin-update-lock', (_event, error) => { - callback(error); - }); - electron.ipcRenderer.send('resin-update-lock', 'lock'); - } else { - callback(new Error('Update lock disabled')); - } - } - - /** - * @summary Release the update lock - */ - public static release(callback: (error?: Error) => void) { - debug('unlock'); - if (settings.get('resinUpdateLock')) { - electron.ipcRenderer.once('resin-update-lock', (_event, error) => { - callback(error); - }); - electron.ipcRenderer.send('resin-update-lock', 'unlock'); - } else { - callback(new Error('Update lock disabled')); - } - } - - /** - * @summary Check the state of the update lock - * @param {Function} callback - callback(error, isLocked) - * @example - * UpdateLock.check((error, isLocked) => { - * if (isLocked) { - * // ... - * } - * }) - */ - private static check( - callback: (error: Error | null, isLocked?: boolean) => void, - ) { - debug('check'); - if (settings.get('resinUpdateLock')) { - electron.ipcRenderer.once( - 'resin-update-lock', - (_event, error, isLocked) => { - callback(error, isLocked); - }, - ); - electron.ipcRenderer.send('resin-update-lock', 'check'); - } else { - callback(new Error('Update lock disabled')); - } - } - - /** - * @summary Extend the lock timer - */ - public extend() { - debug('extend'); - - if (this.paused) { - debug('extend:paused'); - return; - } - - this.lockTimer.signal(); - - // When extending, check that we have the lock, - // and acquire it, if not - if (settings.get('resinUpdateLock')) { - UpdateLock.check((checkError, isLocked) => { - if (checkError) { - logException(checkError); - } - if (!isLocked) { - UpdateLock.acquire(error => { - if (error) { - logException(error); - } - debug('extend-acquire', Boolean(error)); - }); - } - }); - } - } - - /** - * @summary Clear the lock timer - */ - private clearTimer() { - debug('clear'); - this.lockTimer.clear(); - } - - /** - * @summary Clear the lock timer, and pause extension, avoiding triggering until resume()d - */ - public pause() { - debug('pause'); - this.paused = true; - this.clearTimer(); - } - - /** - * @summary Un-pause lock extension, and restart the timer - */ - public resume() { - debug('resume'); - this.paused = false; - this.extend(); - } -} - -export const updateLock = new UpdateLock(); diff --git a/lib/gui/app/os/notification.ts b/lib/gui/app/os/notification.ts index 0a074094..2d6b808e 100644 --- a/lib/gui/app/os/notification.ts +++ b/lib/gui/app/os/notification.ts @@ -21,9 +21,9 @@ import * as settings from '../models/settings'; /** * @summary Send a notification */ -export function send(title: string, body: string, icon: string) { +export async function send(title: string, body: string, icon: string) { // Bail out if desktop notifications are disabled - if (!settings.get('desktopNotifications')) { + if (!(await settings.get('desktopNotifications'))) { return; } diff --git a/lib/gui/app/os/open-external/services/open-external.ts b/lib/gui/app/os/open-external/services/open-external.ts index c843bbf9..ef7c99c5 100644 --- a/lib/gui/app/os/open-external/services/open-external.ts +++ b/lib/gui/app/os/open-external/services/open-external.ts @@ -16,22 +16,18 @@ import * as electron from 'electron'; import * as settings from '../../../models/settings'; -import { store } from '../../../models/store'; import { logEvent } from '../../../modules/analytics'; /** * @summary Open an external resource */ -export function open(url: string) { +export async function open(url: string) { // Don't open links if they're disabled by the env var - if (settings.get('disableExternalLinks')) { + if (await settings.get('disableExternalLinks')) { return; } - logEvent('Open external link', { - url, - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, - }); + logEvent('Open external link', { url }); if (url) { electron.shell.openExternal(url); diff --git a/lib/gui/app/os/windows-network-drives.ts b/lib/gui/app/os/windows-network-drives.ts index 6fffb515..1acba6a7 100755 --- a/lib/gui/app/os/windows-network-drives.ts +++ b/lib/gui/app/os/windows-network-drives.ts @@ -88,7 +88,7 @@ async function getWindowsNetworkDrives(): Promise> { trim(str.slice(colonPosition + 1)), ]; }) - .filter(couple => couple[1].length > 0) + .filter((couple) => couple[1].length > 0) .value(); return new Map(couples); } diff --git a/lib/gui/app/pages/main/DriveSelector.tsx b/lib/gui/app/pages/main/DriveSelector.tsx index 3464ec48..fcd02747 100644 --- a/lib/gui/app/pages/main/DriveSelector.tsx +++ b/lib/gui/app/pages/main/DriveSelector.tsx @@ -22,7 +22,7 @@ import { TargetSelector } from '../../components/drive-selector/target-selector' import { SVGIcon } from '../../components/svg-icon/svg-icon'; import { getImage, getSelectedDrives } from '../../models/selection-state'; import * as settings from '../../models/settings'; -import { observe, store } from '../../models/store'; +import { observe } from '../../models/store'; import * as analytics from '../../modules/analytics'; const StepBorder = styled.div<{ @@ -31,7 +31,7 @@ const StepBorder = styled.div<{ right?: boolean; }>` height: 2px; - background-color: ${props => + background-color: ${(props) => props.disabled ? props.theme.customColors.dark.disabled.foreground : props.theme.customColors.dark.foreground}; @@ -39,8 +39,8 @@ const StepBorder = styled.div<{ width: 124px; top: 19px; - left: ${props => (props.left ? '-67px' : undefined)}; - right: ${props => (props.right ? '-67px' : undefined)}; + left: ${(props) => (props.left ? '-67px' : undefined)}; + right: ${(props) => (props.right ? '-67px' : undefined)}; `; const getDriveListLabel = () => { @@ -53,7 +53,7 @@ const getDriveListLabel = () => { }; const shouldShowDrivesButton = () => { - return !settings.get('disableExplicitDriveSelection'); + return !settings.getSync('disableExplicitDriveSelection'); }; const getDriveSelectionStateSlice = () => ({ @@ -117,12 +117,7 @@ export const DriveSelector = ({ setShowDriveSelectorModal(true); }} reselectDrive={() => { - analytics.logEvent('Reselect drive', { - applicationSessionUuid: store.getState().toJS() - .applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS() - .flashingWorkflowUuid, - }); + analytics.logEvent('Reselect drive'); setShowDriveSelectorModal(true); }} flashing={flashing} diff --git a/lib/gui/app/pages/main/Flash.tsx b/lib/gui/app/pages/main/Flash.tsx index c9530235..9a3f33a7 100644 --- a/lib/gui/app/pages/main/Flash.tsx +++ b/lib/gui/app/pages/main/Flash.tsx @@ -27,12 +27,12 @@ import { SVGIcon } from '../../components/svg-icon/svg-icon'; import * as availableDrives from '../../models/available-drives'; import * as flashState from '../../models/flash-state'; import * as selection from '../../models/selection-state'; -import { store } from '../../models/store'; import * as analytics from '../../modules/analytics'; import { scanner as driveScanner } from '../../modules/drive-scanner'; import * as imageWriter from '../../modules/image-writer'; import * as progressStatus from '../../modules/progress-status'; import * as notification from '../../os/notification'; +import { StepSelection } from '../../styled-components'; const COMPLETED_PERCENTAGE = 100; const SPEED_PRECISION = 2; @@ -200,10 +200,7 @@ export const Flash = ({ setErrorMessage(''); flashState.resetState(); if (shouldRetry) { - analytics.logEvent('Restart after failure', { - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, - }); + analytics.logEvent('Restart after failure'); } else { selection.clear(); } @@ -243,14 +240,16 @@ export const Flash = ({
- + + + {isFlashing && (