Browse Source

Integrate UI for auto-update

develop
meriadec 6 years ago
parent
commit
ce090e14a5
No known key found for this signature in database GPG Key ID: 1D2FC2305E2CB399
  1. 51
      src/commands/autoUpdate.js
  2. 4
      src/commands/index.js
  3. 30
      src/commands/quitAndInstallElectronUpdate.js
  4. 17
      src/components/App.js
  5. 4
      src/components/DashboardPage/index.js
  6. 9
      src/components/MainSideBar/index.js
  7. 103
      src/components/UpdateNotifier/UpdateDownloaded.js
  8. 48
      src/components/UpdateNotifier/UpdateInstalled.js
  9. 17
      src/components/UpdateNotifier/index.js
  10. 75
      src/components/Updater/Banner.js
  11. 70
      src/components/Updater/DebugUpdater.js
  12. 88
      src/components/Updater/UpdateDot.js
  13. 95
      src/components/Updater/UpdaterContext.js
  14. 4
      src/components/layout/Default.js
  15. 4
      src/reducers/index.js
  16. 48
      src/reducers/update.js
  17. 25
      src/renderer/events.js

51
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<Input, Result> = 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

4
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<Command<any, any>> = [
debugAppInfosForCurrency,
autoUpdate,
getAddress,
getDeviceInfo,
getCurrentFirmware,
@ -61,6 +64,7 @@ const all: Array<Command<any, any>> = [
listCategories,
listenDevices,
ping,
quitAndInstallElectronUpdate,
shouldFlashMcu,
signTransaction,
testApdu,

30
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<Input, Result> = 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

17
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 = ({
<CounterValues.PollingProvider>
<I18nextProvider i18n={i18n} initialLanguage={language}>
<ThemeProvider theme={theme}>
<ThrowBlock>
<ConnectedRouter history={history}>
<Switch>
<Route component={Default} />
</Switch>
</ConnectedRouter>
</ThrowBlock>
<UpdaterProvider>
<ThrowBlock>
<ConnectedRouter history={history}>
<Switch>
<Route component={Default} />
</Switch>
</ConnectedRouter>
</ThrowBlock>
</UpdaterProvider>
</ThemeProvider>
</I18nextProvider>
</CounterValues.PollingProvider>

4
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<Props> {
return (
<Fragment>
<UpdateNotifier />
<UpdateBanner />
<RefreshAccountsOrdering onMount />
<TrackPage
category="Portfolio"

9
src/components/MainSideBar/index.js

@ -11,20 +11,19 @@ import type { Location } from 'react-router'
import type { Account } from '@ledgerhq/live-common/lib/types'
import type { T } from 'types/common'
import type { UpdateStatus } from 'reducers/update'
import { MODAL_RECEIVE, MODAL_SEND, MODAL_ADD_ACCOUNTS } from 'config/constants'
import { i } from 'helpers/staticPath'
import { accountsSelector } from 'reducers/accounts'
import { openModal } from 'reducers/modals'
import { getUpdateStatus } from 'reducers/update'
import { developerModeSelector } from 'reducers/settings'
import { SideBarList, SideBarListItem } from 'components/base/SideBar'
import Box from 'components/base/Box'
import GrowScroll from 'components/base/GrowScroll'
import Space from 'components/base/Space'
import UpdateDot from 'components/Updater/UpdateDot'
import IconManager from 'icons/Manager'
import IconPieChart from 'icons/PieChart'
@ -39,7 +38,6 @@ import KeyboardContent from '../KeyboardContent'
const mapStateToProps = state => ({
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<Props> {
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<Props> {
iconActiveColor="wallet"
onClick={this.handleClickDashboard}
isActive={pathname === '/'}
hasNotif={updateStatus === 'downloaded'}
NotifComponent={UpdateDot}
/>
<SideBarListItem
label={t('send.title')}

103
src/components/UpdateNotifier/UpdateDownloaded.js

@ -1,103 +0,0 @@
// @flow
import React, { PureComponent } from 'react'
import { translate } from 'react-i18next'
import { compose } from 'redux'
import { connect } from 'react-redux'
import styled from 'styled-components'
import { getUpdateStatus, getUpdateData } from 'reducers/update'
import { sendEvent } from 'renderer/events'
import type { State } from 'reducers'
import type { UpdateStatus } from 'reducers/update'
import { radii } from 'styles/theme'
import Box from 'components/base/Box'
import Text from 'components/base/Text'
import UpdateIcon from 'icons/Update'
import type { T } from 'types/common'
type Props = {
t: T,
updateStatus: UpdateStatus,
}
const mapStateToProps = (state: State) => ({
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<Props> {
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 (
<Box horizontal flow={3}>
<UpdateIcon size={16} />
<Box grow>
<NotifText>{t('update.newVersionReady')}</NotifText>
</Box>
<Box>
<NotifText
style={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => sendEvent('updater', 'quitAndInstall')}
>
{t('update.relaunch')}
</NotifText>
</Box>
</Box>
)
default:
return null
}
}
render() {
const { updateStatus, ...props } = this.props
const isToggled = updateStatus === 'downloaded'
if (!isToggled) {
return null
}
return <Container {...props}>{this.renderStatus()}</Container>
}
}
export default compose(
connect(
mapStateToProps,
null,
),
translate(),
)(UpdateDownloaded)

48
src/components/UpdateNotifier/UpdateInstalled.js

@ -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<Props> {
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)

17
src/components/UpdateNotifier/index.js

@ -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 (
<Fragment>
<UpdateDownloaded />
<UpdateInstalled />
</Fragment>
)
}
}

75
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<Props> {
render() {
const { context } = this.props
const { status, quitAndInstall, downloadProgress, error } = context
if (!VISIBLE_STATUS.includes(status)) return null
return (
<Container status={status}>
{status === 'download-progress' && `Downloading update... ${Math.round(downloadProgress)}%`}
{status === 'checking' && `Verifying update...`}
{status === 'error' &&
error && (
<div>
{'Error during update. Please download again.'}
<ErrorContainer>
<TranslatedError error={error} />
</ErrorContainer>
</div>
)}
{status === 'check-success' && (
<div>
{'Update ready to install. '}
<DownloadLink onClick={quitAndInstall}>{'install now'}</DownloadLink>
</div>
)}
</Container>
)
}
}
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)

70
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<Props> {
render() {
const { context } = this.props
const { status, setStatus, quitAndInstall } = context
return (
<div style={styles.root}>
<h1>
DEBUG UPDATE<br />
------------<br />
</h1>
<b>status:</b> {status}
<div style={{ marginTop: 20 }}>
{statusToDebug.map(s => (
<button key={s} style={styles.btn} onClick={() => setStatus(s)}>
{status === s ? `[${s}]` : s}
</button>
))}
</div>
<div style={{ marginTop: 20 }}>
<b>simulate update</b>
</div>
<div style={{ marginTop: 20 }}>
<button style={styles.btn} onClick={quitAndInstall}>
{'quit and install'}
</button>
</div>
</div>
)
}
}
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)

88
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 (
<div style={styles.container}>
{showSpinner && <Spinner />}
<Dot status={status} />
</div>
)
}
const styles = {
container: {
position: 'relative',
},
}
export default withUpdaterContext(UpdateDot)

95
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<UpdaterProviderProps, UpdaterProviderState> {
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 <UpdaterContext.Provider value={value}>{this.props.children}</UpdaterContext.Provider>
}
}
export const withUpdaterContext = (ComponentToDecorate: React$ComponentType<*>) => (props: *) => (
<UpdaterContext.Consumer>
{context => <ComponentToDecorate {...props} context={context} />}
</UpdaterContext.Consumer>
)
export const UpdaterProvider = Provider

4
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<Props> {
<ModalComponent key={name} />
))}
{process.env.DEBUG_UPDATE && <DebugUpdater />}
<SyncContinuouslyPendingOperations priority={20} interval={SYNC_PENDING_INTERVAL} />
<SyncBackground />

4
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,
})

48
src/reducers/update.js

@ -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)

25
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) {

Loading…
Cancel
Save