From ce090e14a5c7262258464ddc4629d27011249689 Mon Sep 17 00:00:00 2001 From: meriadec Date: Sun, 9 Dec 2018 14:29:02 +0100 Subject: [PATCH] Integrate UI for auto-update --- src/commands/autoUpdate.js | 51 +++++++++ src/commands/index.js | 4 + src/commands/quitAndInstallElectronUpdate.js | 30 +++++ src/components/App.js | 17 +-- src/components/DashboardPage/index.js | 4 +- src/components/MainSideBar/index.js | 9 +- .../UpdateNotifier/UpdateDownloaded.js | 103 ------------------ .../UpdateNotifier/UpdateInstalled.js | 48 -------- src/components/UpdateNotifier/index.js | 17 --- src/components/Updater/Banner.js | 75 +++++++++++++ src/components/Updater/DebugUpdater.js | 70 ++++++++++++ src/components/Updater/UpdateDot.js | 88 +++++++++++++++ src/components/Updater/UpdaterContext.js | 95 ++++++++++++++++ src/components/layout/Default.js | 4 + src/reducers/index.js | 4 - src/reducers/update.js | 48 -------- src/renderer/events.js | 25 ----- 17 files changed, 432 insertions(+), 260 deletions(-) create mode 100644 src/commands/autoUpdate.js create mode 100644 src/commands/quitAndInstallElectronUpdate.js delete mode 100644 src/components/UpdateNotifier/UpdateDownloaded.js delete mode 100644 src/components/UpdateNotifier/UpdateInstalled.js delete mode 100644 src/components/UpdateNotifier/index.js create mode 100644 src/components/Updater/Banner.js create mode 100644 src/components/Updater/DebugUpdater.js create mode 100644 src/components/Updater/UpdateDot.js create mode 100644 src/components/Updater/UpdaterContext.js delete mode 100644 src/reducers/update.js diff --git a/src/commands/autoUpdate.js b/src/commands/autoUpdate.js new file mode 100644 index 00000000..9dd08d0b --- /dev/null +++ b/src/commands/autoUpdate.js @@ -0,0 +1,51 @@ +// @flow + +import { createCommand, Command } from 'helpers/ipc' +import { Observable } from 'rxjs' + +import createElectronAppUpdater from 'main/updater/createElectronAppUpdater' +import type { UpdateStatus } from 'components/Updater/UpdaterContext' + +type Input = {} +type Result = { + status: UpdateStatus, + payload?: *, +} + +const cmd: Command = createCommand('main:autoUpdate', () => + Observable.create(o => { + const { autoUpdater } = require('electron-updater') + + const sendStatus = (status, payload) => { + o.next({ status, payload }) + } + + const handleDownload = async info => { + try { + const appUpdater = await createElectronAppUpdater({ + feedURL: process.env.LL_UPDATE_FEED || 'https://insert.feed.here', + updateVersion: info.version, + }) + await appUpdater.verify() + sendStatus('check-success') + } catch (err) { + // todo delete update file + o.error(err) + } + } + + autoUpdater.on('checking-for-update', () => sendStatus('checking-for-update')) + autoUpdater.on('update-available', info => sendStatus('update-available', info)) + autoUpdater.on('update-not-available', info => sendStatus('update-not-available', info)) + autoUpdater.on('download-progress', p => sendStatus('download-progress', p)) + autoUpdater.on('update-downloaded', handleDownload) + autoUpdater.on('error', err => o.error(err)) + + autoUpdater.autoInstallOnAppQuit = false + autoUpdater.checkForUpdates() + + return () => {} + }), +) + +export default cmd diff --git a/src/commands/index.js b/src/commands/index.js index 1022021c..07f5733a 100644 --- a/src/commands/index.js +++ b/src/commands/index.js @@ -4,6 +4,7 @@ import invariant from 'invariant' import type { Command } from 'helpers/ipc' import debugAppInfosForCurrency from 'commands/debugAppInfosForCurrency' +import autoUpdate from 'commands/autoUpdate' import getAddress from 'commands/getAddress' import getDeviceInfo from 'commands/getDeviceInfo' import getCurrentFirmware from 'commands/getCurrentFirmware' @@ -28,6 +29,7 @@ import listAppVersions from 'commands/listAppVersions' import listCategories from 'commands/listCategories' import listenDevices from 'commands/listenDevices' import ping from 'commands/ping' +import quitAndInstallElectronUpdate from 'commands/quitAndInstallElectronUpdate' import shouldFlashMcu from 'commands/shouldFlashMcu' import signTransaction from 'commands/signTransaction' import testApdu from 'commands/testApdu' @@ -37,6 +39,7 @@ import uninstallApp from 'commands/uninstallApp' const all: Array> = [ debugAppInfosForCurrency, + autoUpdate, getAddress, getDeviceInfo, getCurrentFirmware, @@ -61,6 +64,7 @@ const all: Array> = [ listCategories, listenDevices, ping, + quitAndInstallElectronUpdate, shouldFlashMcu, signTransaction, testApdu, diff --git a/src/commands/quitAndInstallElectronUpdate.js b/src/commands/quitAndInstallElectronUpdate.js new file mode 100644 index 00000000..4ed02af8 --- /dev/null +++ b/src/commands/quitAndInstallElectronUpdate.js @@ -0,0 +1,30 @@ +// @flow + +import { createCommand, Command } from 'helpers/ipc' +import { Observable } from 'rxjs' + +type Input = void +type Result = void + +const cmd: Command = createCommand('main:quitAndInstallElectronUpdate', () => + Observable.create(o => { + const { app, BrowserWindow } = require('electron') + const { autoUpdater } = require('electron-updater') + const browserWindows = BrowserWindow.getAllWindows() + + // Fixes quitAndInstall not quitting on macOS, as suggested on + // https://github.com/electron-userland/electron-builder/issues/1604#issuecomment-306709572 + app.removeAllListeners('window-all-closed') + browserWindows.forEach(browserWindow => { + browserWindow.removeAllListeners('close') + }) + + // couldn't find a way to catch if fail ¯\_(ツ)_/¯ + autoUpdater.quitAndInstall(false) + + o.complete() + return () => {} + }), +) + +export default cmd diff --git a/src/components/App.js b/src/components/App.js index a6b3d186..4db32693 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -16,6 +16,7 @@ import ThrowBlock from 'components/ThrowBlock' import Default from 'components/layout/Default' import CounterValues from 'helpers/countervalues' import { BridgeSyncProvider } from 'bridge/BridgeSyncContext' +import { UpdaterProvider } from 'components/Updater/UpdaterContext' const App = ({ store, @@ -31,13 +32,15 @@ const App = ({ - - - - - - - + + + + + + + + + diff --git a/src/components/DashboardPage/index.js b/src/components/DashboardPage/index.js index 203651b7..82692d8b 100644 --- a/src/components/DashboardPage/index.js +++ b/src/components/DashboardPage/index.js @@ -24,7 +24,7 @@ import { saveSettings } from 'actions/settings' import TrackPage from 'analytics/TrackPage' import RefreshAccountsOrdering from 'components/RefreshAccountsOrdering' -import UpdateNotifier from 'components/UpdateNotifier' +import UpdateBanner from 'components/Updater/Banner' import BalanceInfos from 'components/BalanceSummary/BalanceInfos' import BalanceSummary from 'components/BalanceSummary' import Box from 'components/base/Box' @@ -84,7 +84,7 @@ class DashboardPage extends PureComponent { return ( - + ({ accounts: accountsSelector(state), - updateStatus: getUpdateStatus(state), developerMode: developerModeSelector(state), }) @@ -54,7 +52,6 @@ type Props = { location: Location, push: string => void, openModal: string => void, - updateStatus: UpdateStatus, developerMode: boolean, } @@ -96,7 +93,7 @@ class MainSideBar extends PureComponent { handleOpenImportModal = () => this.props.openModal(MODAL_ADD_ACCOUNTS) render() { - const { t, accounts, location, updateStatus, developerMode } = this.props + const { t, accounts, location, developerMode } = this.props const { pathname } = location const addAccountButton = ( @@ -122,7 +119,7 @@ class MainSideBar extends PureComponent { iconActiveColor="wallet" onClick={this.handleClickDashboard} isActive={pathname === '/'} - hasNotif={updateStatus === 'downloaded'} + NotifComponent={UpdateDot} /> ({ - updateStatus: getUpdateStatus(state), - updateData: getUpdateData(state), -}) - -const Container = styled(Box).attrs({ - py: '8px', - px: 3, - bg: 'wallet', - color: 'white', - mt: '-50px', - mb: '35px', - style: p => ({ - transform: `translate3d(0, ${p.offset}%, 0)`, - }), -})` - border-radius: ${radii[1]}px; -` - -const NotifText = styled(Text).attrs({ - ff: 'Open Sans|SemiBold', - fontSize: 4, -})`` - -class UpdateDownloaded extends PureComponent { - renderStatus() { - const { updateStatus, t } = this.props - switch (updateStatus) { - case 'idle': - case 'checking': - case 'unavailable': - case 'error': - case 'available': - case 'progress': - return null - case 'downloaded': - return ( - - - - {t('update.newVersionReady')} - - - sendEvent('updater', 'quitAndInstall')} - > - {t('update.relaunch')} - - - - ) - default: - return null - } - } - - render() { - const { updateStatus, ...props } = this.props - - const isToggled = updateStatus === 'downloaded' - - if (!isToggled) { - return null - } - return {this.renderStatus()} - } -} - -export default compose( - connect( - mapStateToProps, - null, - ), - translate(), -)(UpdateDownloaded) diff --git a/src/components/UpdateNotifier/UpdateInstalled.js b/src/components/UpdateNotifier/UpdateInstalled.js deleted file mode 100644 index b0fbcdc5..00000000 --- a/src/components/UpdateNotifier/UpdateInstalled.js +++ /dev/null @@ -1,48 +0,0 @@ -// @flow - -import { PureComponent } from 'react' -import { connect } from 'react-redux' -import semver from 'semver' - -import { openModal } from 'reducers/modals' -import { lastUsedVersionSelector } from 'reducers/settings' -import { saveSettings } from 'actions/settings' -import { MODAL_RELEASES_NOTES } from 'config/constants' - -import type { State } from 'reducers' - -type Props = { - openModal: Function, - saveSettings: Function, - lastUsedVersion: string, -} - -const mapStateToProps = (state: State) => ({ - lastUsedVersion: lastUsedVersionSelector(state), -}) - -const mapDispatchToProps = { - openModal, - saveSettings, -} - -class UpdateInstalled extends PureComponent { - componentDidMount() { - const { lastUsedVersion, saveSettings, openModal } = this.props - const currentVersion = __APP_VERSION__ - - if (semver.gt(currentVersion, lastUsedVersion)) { - openModal(MODAL_RELEASES_NOTES, currentVersion) - saveSettings({ lastUsedVersion: currentVersion }) - } - } - - render() { - return null - } -} - -export default connect( - mapStateToProps, - mapDispatchToProps, -)(UpdateInstalled) diff --git a/src/components/UpdateNotifier/index.js b/src/components/UpdateNotifier/index.js deleted file mode 100644 index 6ea65461..00000000 --- a/src/components/UpdateNotifier/index.js +++ /dev/null @@ -1,17 +0,0 @@ -// @flow - -import React, { PureComponent, Fragment } from 'react' - -import UpdateDownloaded from './UpdateDownloaded' -import UpdateInstalled from './UpdateInstalled' - -export default class UpdateNotifier extends PureComponent<{}> { - render() { - return ( - - - - - ) - } -} diff --git a/src/components/Updater/Banner.js b/src/components/Updater/Banner.js new file mode 100644 index 00000000..d0d0321e --- /dev/null +++ b/src/components/Updater/Banner.js @@ -0,0 +1,75 @@ +// @flow + +import React, { PureComponent } from 'react' +import styled from 'styled-components' + +import { radii } from 'styles/theme' + +import TranslatedError from 'components/TranslatedError' +import Box from 'components/base/Box' + +import { withUpdaterContext } from './UpdaterContext' +import type { UpdaterContextType } from './UpdaterContext' + +type Props = { + context: UpdaterContextType, +} + +export const VISIBLE_STATUS = ['download-progress', 'checking', 'check-success', 'error'] + +class UpdaterTopBanner extends PureComponent { + render() { + const { context } = this.props + const { status, quitAndInstall, downloadProgress, error } = context + + if (!VISIBLE_STATUS.includes(status)) return null + + return ( + + {status === 'download-progress' && `Downloading update... ${Math.round(downloadProgress)}%`} + {status === 'checking' && `Verifying update...`} + {status === 'error' && + error && ( +
+ {'Error during update. Please download again.'} + + + +
+ )} + {status === 'check-success' && ( +
+ {'Update ready to install. '} + {'install now'} +
+ )} +
+ ) + } +} + +const Container = styled(Box).attrs({ + py: '8px', + px: 3, + bg: p => (p.status === 'error' ? 'alertRed' : 'wallet'), + color: 'white', + mt: -20, + mb: 20, + fontSize: 4, +})` + border-radius: ${radii[1]}px; +` + +const DownloadLink = styled.span` + color: white; + text-decoration: underline; + cursor: pointer; +` + +const ErrorContainer = styled.div` + margin-top: 10px; + font-family: monospace; + font-size: 10px; +` + +export default withUpdaterContext(UpdaterTopBanner) diff --git a/src/components/Updater/DebugUpdater.js b/src/components/Updater/DebugUpdater.js new file mode 100644 index 00000000..32cf4c19 --- /dev/null +++ b/src/components/Updater/DebugUpdater.js @@ -0,0 +1,70 @@ +// @flow +/* eslint-disable react/jsx-no-literals */ + +import React, { Component } from 'react' + +import { withUpdaterContext } from './UpdaterContext' +import type { UpdaterContextType } from './UpdaterContext' + +const statusToDebug = ['idle', 'download-progress', 'checking', 'check-success', 'error'] + +type Props = { + context: UpdaterContextType, +} + +class DebugUpdater extends Component { + render() { + const { context } = this.props + const { status, setStatus, quitAndInstall } = context + return ( +
+

+ DEBUG UPDATE
+ ------------
+

+ status: {status} +
+ {statusToDebug.map(s => ( + + ))} +
+
+ simulate update +
+
+ +
+
+ ) + } +} + +const styles = { + root: { + position: 'fixed', + bottom: 0, + right: 0, + padding: 10, + fontSize: 10, + background: 'black', + color: 'white', + fontFamily: 'monospace', + zIndex: 1000, + maxWidth: 250, + }, + btn: { + cursor: 'pointer', + background: 'lightgreen', + color: 'black', + border: 'none', + marginRight: 10, + marginTop: 10, + padding: '0px 10px', + }, +} + +export default withUpdaterContext(DebugUpdater) diff --git a/src/components/Updater/UpdateDot.js b/src/components/Updater/UpdateDot.js new file mode 100644 index 00000000..01a34bb5 --- /dev/null +++ b/src/components/Updater/UpdateDot.js @@ -0,0 +1,88 @@ +// @flow + +import React from 'react' +import styled, { keyframes } from 'styled-components' + +import { colors } from 'styles/theme' + +import { withUpdaterContext } from './UpdaterContext' +import { VISIBLE_STATUS } from './Banner' +import type { UpdaterContextType, UpdateStatus } from './UpdaterContext' + +type Props = { + context: UpdaterContextType, +} + +const rotate = keyframes` + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +` + +const getColor = ({ status }: { status: UpdateStatus }) => + status === 'error' ? colors.alertRed : colors.wallet + +const getOpacity = ({ status }: { status: UpdateStatus }) => + status === 'download-progress' || status === 'checking' ? 0.5 : 1 + +const Dot = styled.div` + opacity: ${getOpacity}; + width: 8px; + height: 8px; + background-color: ${getColor}; + border-radius: 50%; +` + +const Spinner = styled.div` + opacity: 0.5; + position: absolute; + top: -3px; + left: -3px; + animation: ${rotate} 1.5s linear infinite; + width: 14px; + height: 14px; + + &:before { + content: ''; + position: absolute; + right: 0; + bottom: 0; + width: 4px; + height: 4px; + background-color: ${colors.wallet}; + border-radius: 50%; + } + + &:after { + content: ''; + position: absolute; + width: 4px; + height: 4px; + background-color: ${colors.wallet}; + border-radius: 50%; + } +` + +function UpdateDot(props: Props) { + const { context } = props + const { status } = context + if (!VISIBLE_STATUS.includes(status)) return null + const showSpinner = status === 'download-progress' || status === 'checking' + return ( +
+ {showSpinner && } + +
+ ) +} + +const styles = { + container: { + position: 'relative', + }, +} + +export default withUpdaterContext(UpdateDot) diff --git a/src/components/Updater/UpdaterContext.js b/src/components/Updater/UpdaterContext.js new file mode 100644 index 00000000..91351a48 --- /dev/null +++ b/src/components/Updater/UpdaterContext.js @@ -0,0 +1,95 @@ +// @flow + +import React, { Component } from 'react' + +import autoUpdate from 'commands/autoUpdate' +import quitAndInstallElectronUpdate from 'commands/quitAndInstallElectronUpdate' + +export type UpdateStatus = + | 'idle' + | 'checking-for-update' + | 'update-available' + | 'update-not-available' + | 'download-progress' + | 'update-downloaded' + | 'checking' + | 'check-success' + | 'error' + +export type UpdaterContextType = { + status: UpdateStatus, + downloadProgress: number, + quitAndInstall: () => void, + setStatus: UpdateStatus => void, + error: ?Error, +} + +type UpdaterProviderProps = { + children: *, +} + +type UpdaterProviderState = { + status: UpdateStatus, + downloadProgress: number, + error: ?Error, +} + +const UpdaterContext = React.createContext() + +class Provider extends Component { + constructor() { + super() + + if (__PROD__) { + this.sub = autoUpdate.send({}).subscribe({ + next: e => { + if (e.status === 'download-progress') { + const downloadProgress = e.payload && e.payload.percent ? e.payload.percent : 0 + this.setState({ status: e.status, downloadProgress }) + } else { + this.setStatus(e.status) + } + }, + error: error => this.setState({ status: 'error', error }), + }) + } + + this.state = { + status: 'idle', + downloadProgress: 0, + error: null, + } + } + + componentWillUnmount() { + if (this.sub) { + this.sub.unsubscribe() + } + } + + sub = null + + setStatus = (status: UpdateStatus) => this.setState({ status }) + setDownloadProgress = (downloadProgress: number) => this.setState({ downloadProgress }) + quitAndInstall = () => quitAndInstallElectronUpdate.send().toPromise() + + render() { + const { status, downloadProgress, error } = this.state + const value = { + status, + downloadProgress, + error, + setStatus: this.setStatus, + quitAndInstall: this.quitAndInstall, + } + return {this.props.children} + } +} + +export const withUpdaterContext = (ComponentToDecorate: React$ComponentType<*>) => (props: *) => ( + + {context => } + +) + +export const UpdaterProvider = Provider diff --git a/src/components/layout/Default.js b/src/components/layout/Default.js index f79744ac..63ccd83c 100644 --- a/src/components/layout/Default.js +++ b/src/components/layout/Default.js @@ -34,6 +34,8 @@ import IsUnlocked from 'components/IsUnlocked' import SideBar from 'components/MainSideBar' import TopBar from 'components/TopBar' import SyncBackground from 'components/SyncBackground' +import DebugUpdater from 'components/Updater/DebugUpdater' + import SyncContinuouslyPendingOperations from '../SyncContinouslyPendingOperations' const Main = styled(GrowScroll).attrs({ @@ -97,6 +99,8 @@ class Default extends Component { ))} + {process.env.DEBUG_UPDATE && } + diff --git a/src/reducers/index.js b/src/reducers/index.js index a84f2d3e..4a1d03ab 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -13,7 +13,6 @@ import currenciesStatus from './currenciesStatus' import devices from './devices' import modals from './modals' import settings from './settings' -import update from './update' import onboarding from './onboarding' import bridgeSync from './bridgeSync' @@ -22,7 +21,6 @@ import type { ApplicationState } from './application' import type { DevicesState } from './devices' import type { ModalsState } from './modals' import type { SettingsState } from './settings' -import type { UpdateState } from './update' import type { OnboardingState } from './onboarding' import type { BridgeSyncState } from './bridgeSync' import type { CurrenciesStatusState } from './currenciesStatus' @@ -36,7 +34,6 @@ export type State = { modals: ModalsState, router: LocationShape, settings: SettingsState, - update: UpdateState, onboarding: OnboardingState, bridgeSync: BridgeSyncState, } @@ -50,7 +47,6 @@ export default combineReducers({ modals, router, settings, - update, onboarding, bridgeSync, }) diff --git a/src/reducers/update.js b/src/reducers/update.js deleted file mode 100644 index 5eac36b5..00000000 --- a/src/reducers/update.js +++ /dev/null @@ -1,48 +0,0 @@ -// @flow - -import { handleActions, createAction } from 'redux-actions' - -export type UpdateStatus = - | 'idle' - | 'checking' - | 'available' - | 'progress' - | 'unavailable' - | 'error' - | 'downloaded' - -export type UpdateState = { - status: UpdateStatus, - data?: Object, -} - -const state: UpdateState = { - status: 'idle', - data: {}, -} - -const handlers = { - UPDATE_SET_STATUS: (state: UpdateState, { payload }: { payload: UpdateState }): UpdateState => - payload, -} - -// Actions - -export const setUpdateStatus = createAction( - 'UPDATE_SET_STATUS', - (status: UpdateStatus, data?: Object): UpdateState => ({ status, data }), -) - -// Selectors - -export function getUpdateStatus(state: { update: UpdateState }): UpdateStatus { - return state.update.status -} - -export function getUpdateData(state: { update: UpdateState }): Object { - return state.update.data || {} -} - -// Default export - -export default handleActions(handlers, state) diff --git a/src/renderer/events.js b/src/renderer/events.js index 59324cd1..f233913c 100644 --- a/src/renderer/events.js +++ b/src/renderer/events.js @@ -21,7 +21,6 @@ import { onSetDeviceBusy } from 'components/DeviceBusyIndicator' import { onSetLibcoreBusy } from 'components/LibcoreBusyIndicator' import { lock } from 'reducers/application' -import { setUpdateStatus } from 'reducers/update' import { addDevice, removeDevice, resetDevices } from 'actions/devices' import listenDevices from 'commands/listenDevices' @@ -32,11 +31,6 @@ const d = { update: debug('lwd:update'), } -type MsgPayload = { - type: string, - data: any, -} - // TODO port remaining to command pattern export function sendEvent(channel: string, msgType: string, data: any) { ipcRenderer.send(channel, { @@ -109,25 +103,6 @@ export default ({ store }: { store: Object }) => { onSetDeviceBusy(busy) }) } - - if (__PROD__) { - // TODO move this to "command" pattern - const updaterHandlers = { - checking: () => store.dispatch(setUpdateStatus('checking')), - updateAvailable: info => store.dispatch(setUpdateStatus('available', info)), - updateNotAvailable: () => store.dispatch(setUpdateStatus('unavailable')), - error: err => store.dispatch(setUpdateStatus('error', err)), - downloadProgress: progress => store.dispatch(setUpdateStatus('progress', progress)), - downloaded: () => store.dispatch(setUpdateStatus('downloaded')), - } - ipcRenderer.on('updater', (event: any, payload: MsgPayload) => { - const { type, data } = payload - updaterHandlers[type](data) - }) - - // Start check of eventual updates - checkUpdates() - } } if (module.hot) {