diff --git a/src/components/ManagerPage/AppsList.js b/src/components/ManagerPage/AppsList.js new file mode 100644 index 00000000..98653d0e --- /dev/null +++ b/src/components/ManagerPage/AppsList.js @@ -0,0 +1,151 @@ +// @flow + +import React, { PureComponent } from 'react' +import styled from 'styled-components' + +import { runJob } from 'renderer/events' + +import Box from 'components/base/Box' +import Modal, { ModalBody } from 'components/base/Modal' + +import type { Device } from 'types/common' + +import ManagerApp from './ManagerApp' + +const List = styled(Box).attrs({ + horizontal: true, + m: -2, +})` + flex-wrap: wrap; +` + +let CACHED_APPS = null + +const ICONS_FALLBACK = { + bitcoin_testnet: 'bitcoin', +} + +type Status = 'loading' | 'idle' | 'busy' | 'success' | 'error' + +type jobHandlerOptions = { + job: string, + successResponse: string, + errorResponse: string, +} + +type LedgerApp = { + name: string, + icon: string, + app: Object, +} + +type Props = { + device: Device, +} + +type State = { + status: Status, + error: string | null, + appsList: LedgerApp[], +} + +class AppsList extends PureComponent { + state = { + status: 'loading', + error: null, + appsList: [], + } + + componentDidMount() { + this.fetchList() + } + + componentWillUnmount() { + this._unmounted = true + } + + _unmounted = false + + async fetchList() { + const appsList = + CACHED_APPS || + (await runJob({ + channel: 'usb', + job: 'manager.listApps', + successResponse: 'manager.listAppsSuccess', + errorResponse: 'manager.listAppsError', + })) + CACHED_APPS = appsList + if (!this._unmounted) { + this.setState({ appsList, status: 'idle' }) + } + } + + createDeviceJobHandler = (options: jobHandlerOptions) => (args: { app: any }) => async () => { + const appParams = args.app + this.setState({ status: 'busy' }) + try { + const { job, successResponse, errorResponse } = options + const { device: { path: devicePath } } = this.props + const data = { appParams, devicePath } + await runJob({ channel: 'usb', job, successResponse, errorResponse, data }) + this.setState({ status: 'success' }) + } catch (err) { + this.setState({ status: 'error', error: err.message }) + } + } + + handleInstall = this.createDeviceJobHandler({ + job: 'manager.installApp', + successResponse: 'device.appInstalled', + errorResponse: 'device.appInstallError', + }) + + handleUninstall = this.createDeviceJobHandler({ + job: 'manager.uninstallApp', + successResponse: 'device.appUninstalled', + errorResponse: 'device.appUninstallError', + }) + + handleCloseModal = () => this.setState({ status: 'idle' }) + + render() { + const { status, error } = this.state + return ( + + {this.state.appsList.map(c => ( + + ))} + ( + + {status === 'busy' ? ( + {'Loading...'} + ) : status === 'error' ? ( + +
{'error happened'}
+ {error} + +
+ ) : status === 'success' ? ( + + {'success'} + + + ) : null} +
+ )} + /> +
+ ) + } +} + +export default AppsList diff --git a/src/components/ManagerPage/DeviceInfos.js b/src/components/ManagerPage/DeviceInfos.js new file mode 100644 index 00000000..71ff1f36 --- /dev/null +++ b/src/components/ManagerPage/DeviceInfos.js @@ -0,0 +1,75 @@ +// @flow + +import React, { PureComponent } from 'react' + +import { runJob } from 'renderer/events' +import Text from 'components/base/Text' +import Box, { Card } from 'components/base/Box' +import Button from 'components/base/Button' + +import type { Device, MemoryInfos } from 'types/common' + +import MemInfos from './MemInfos' + +type Props = { + device: Device, +} + +type State = { + memoryInfos: ?MemoryInfos, + isLoading: boolean, +} + +class DeviceInfos extends PureComponent { + state = { + isLoading: false, + memoryInfos: null, + } + + handleGetMemInfos = async () => { + try { + this.setState({ isLoading: true }) + const { device: { path: devicePath } } = this.props + const memoryInfos = await runJob({ + channel: 'usb', + job: 'manager.getMemInfos', + successResponse: 'device.getMemInfosSuccess', + errorResponse: 'device.getMemInfosError', + data: { devicePath }, + }) + this.setState({ memoryInfos, isLoading: false }) + } catch (err) { + this.setState({ isLoading: false }) + } + } + + render() { + const { device } = this.props + const { memoryInfos, isLoading } = this.state + + const title = ( + + {device.manufacturer} + {` ${device.product}`} + + ) + return ( + + {memoryInfos ? ( + + ) : ( + + + + + {isLoading && {'If asked, confirm operation on device'}} + + )} + + ) + } +} + +export default DeviceInfos diff --git a/src/components/ManagerPage/MemInfos.js b/src/components/ManagerPage/MemInfos.js new file mode 100644 index 00000000..c1eb8dc7 --- /dev/null +++ b/src/components/ManagerPage/MemInfos.js @@ -0,0 +1,41 @@ +// @flow + +import React from 'react' +import styled from 'styled-components' + +import Box from 'components/base/Box' + +import type { MemoryInfos } from 'types/common' + +const Container = styled(Box).attrs({ + bg: 'lightgrey', + horizontal: true, +})` + border-radius: ${p => p.theme.radii[1]}px; + overflow: hidden; + height: 24px; +` + +const Step = styled(Box).attrs({ + bg: p => p.theme.colors[p.c || 'grey'], + px: 1, + color: 'white', +})` + width: ${p => (p.last ? '' : `${p.percent}%`)}; + flex-grow: ${p => (p.last ? '1' : '')}; + text-align: ${p => (p.last ? 'right' : '')}; +` + +export default function MemInfos(props: { memoryInfos: MemoryInfos }) { + const { memoryInfos: infos } = props + const totalSize = infos.applicationsSize + infos.systemSize + const appPercent = infos.applicationsSize * 100 / totalSize + return ( + + {`${Math.round( + infos.applicationsSize / 1000, + )}kb`} + {`${Math.round(infos.freeSize / 1000)}kb`} + + ) +} diff --git a/src/components/ManagerPage/index.js b/src/components/ManagerPage/index.js index 35c00828..4317f51e 100644 --- a/src/components/ManagerPage/index.js +++ b/src/components/ManagerPage/index.js @@ -1,171 +1,67 @@ // @flow -import React, { PureComponent } from 'react' +import React, { PureComponent, Fragment } from 'react' import { connect } from 'react-redux' -import styled from 'styled-components' import { translate } from 'react-i18next' import { compose } from 'redux' -import type { Device } from 'types/common' +import type { Device, T } from 'types/common' -import { runJob } from 'renderer/events' -import { getCurrentDevice } from 'reducers/devices' +import { getCurrentDevice, getDevices } from 'reducers/devices' -import DeviceMonit from 'components/DeviceMonit' import Box from 'components/base/Box' -import Modal, { ModalBody } from 'components/base/Modal' +import Pills from 'components/base/Pills' -import ManagerApp from './ManagerApp' - -const ICONS_FALLBACK = { - bitcoin_testnet: 'bitcoin', -} - -const List = styled(Box).attrs({ - horizontal: true, - m: -2, -})` - flex-wrap: wrap; -` +import AppsList from './AppsList' +import DeviceInfos from './DeviceInfos' const mapStateToProps = state => ({ device: getCurrentDevice(state), + nbDevices: getDevices(state).length, }) +const TABS = [{ key: 'apps', value: 'apps' }, { key: 'device', value: 'device' }] + type Props = { + t: T, device: Device, -} - -type Status = 'loading' | 'idle' | 'busy' | 'success' | 'error' - -type LedgerApp = { - name: string, - icon: string, - app: Object, + nbDevices: number, } type State = { - status: Status, - error: string | null, - appsList: LedgerApp[], + currentTab: 'apps' | 'device', } class ManagerPage extends PureComponent { state = { - status: 'loading', - error: null, - appsList: [], - } - - componentDidMount() { - this.fetchList() - } - - componentWillUnmount() { - this._unmounted = true - } - - _unmounted = false - - async fetchList() { - const appsList = await runJob({ - channel: 'usb', - job: 'manager.listApps', - successResponse: 'manager.listAppsSuccess', - errorResponse: 'manager.listAppsError', - }) - if (!this._unmounted) { - this.setState({ appsList, status: 'idle' }) - } - } - - createDeviceJobHandler = options => ({ app: appParams }) => async () => { - this.setState({ status: 'busy' }) - try { - const { job, successResponse, errorResponse } = options - const { - device: { path: devicePath }, - } = this.props - const data = { appParams, devicePath } - await runJob({ channel: 'usb', job, successResponse, errorResponse, data }) - this.setState({ status: 'success' }) - } catch (err) { - this.setState({ status: 'error', error: err.message }) - } + currentTab: 'device', } - handleInstall = this.createDeviceJobHandler({ - job: 'manager.installApp', - successResponse: 'device.appInstalled', - errorResponse: 'device.appInstallError', - }) - - handleUninstall = this.createDeviceJobHandler({ - job: 'manager.uninstallApp', - successResponse: 'device.appUninstalled', - errorResponse: 'device.appUninstallError', - }) - - handleCloseModal = () => this.setState({ status: 'idle' }) - - renderList = () => ( - - {this.state.appsList.map(c => ( - - ))} - - ) + handleTabChange = t => this.setState({ currentTab: t.value }) render() { - const { status, error } = this.state + const { device, t, nbDevices } = this.props + const { currentTab } = this.state + if (!device) { + return 'eu... connecte ton device?' + } + const tabs = TABS.map(i => { + let label = t(`manager:tabs.${i.key}`) + if (i.key === 'device') { + label += ` (${nbDevices})` + } + return { ...i, label } + }) return ( - ( - - {deviceStatus === 'unconnected' && ( - - {'Connect your device'} - - )} - {deviceStatus === 'connected' && ( - - {status === 'loading' ? ( - {'Loading app list...'} - ) : ( - this.renderList() - )} - - )} - ( - - {status === 'busy' ? ( - {'Loading...'} - ) : status === 'error' ? ( - -
{'error happened'}
- {error} - -
- ) : status === 'success' ? ( - - {'success'} - - - ) : null} -
- )} - /> + + + {currentTab === 'apps' && } + {currentTab === 'device' && ( + + )} - /> + ) } } diff --git a/src/components/ManagerPage/stories/MemInfos.stories.js b/src/components/ManagerPage/stories/MemInfos.stories.js new file mode 100644 index 00000000..5edc97e3 --- /dev/null +++ b/src/components/ManagerPage/stories/MemInfos.stories.js @@ -0,0 +1,19 @@ +// @flow + +import React from 'react' + +import { storiesOf } from '@storybook/react' + +import MemInfos from '../MemInfos' + +const memoryInfos = { + applicationsSize: 36862, + freeSize: 118784, + systemSize: 171776, + totalAppSlots: 30, + usedAppSlots: 2, +} + +const stories = storiesOf('Components', module) + +stories.add('MemInfos', () => ) diff --git a/src/components/base/Box/index.js b/src/components/base/Box/index.js index 2f1f492a..bfa54a6c 100644 --- a/src/components/base/Box/index.js +++ b/src/components/base/Box/index.js @@ -53,7 +53,7 @@ const Box = styled.div` const RawCard = styled(Box).attrs({ bg: 'white', p: 3, boxShadow: 0, borderRadius: 1 })`` -export const Card = ({ title, ...props }: { title?: string }) => { +export const Card = ({ title, ...props }: { title?: any }) => { if (title) { return ( diff --git a/src/internals/usb/manager/constants.js b/src/internals/usb/manager/constants.js index 834a7b03..d4562412 100644 --- a/src/internals/usb/manager/constants.js +++ b/src/internals/usb/manager/constants.js @@ -1,5 +1,8 @@ // Socket endpoint + export const BASE_SOCKET_URL = 'ws://api.ledgerwallet.com/update/install' +// If you want to test locally with https://github.com/LedgerHQ/ledger-update-python-api +// export const BASE_SOCKET_URL = 'ws://localhost:3001/update' // List of APDUS export const APDUS = { diff --git a/src/internals/usb/manager/helpers.js b/src/internals/usb/manager/helpers.js index 28890fb9..9c488c65 100644 --- a/src/internals/usb/manager/helpers.js +++ b/src/internals/usb/manager/helpers.js @@ -4,7 +4,6 @@ import CommNodeHid from '@ledgerhq/hw-transport-node-hid' import chalk from 'chalk' import Websocket from 'ws' import qs from 'qs' -import noop from 'lodash/noop' import type Transport from '@ledgerhq/hw-transport' import type { IPCSend } from 'types/electron' @@ -23,10 +22,10 @@ type Message = { } type LedgerAppParams = { - firmware: string, - firmwareKey: string, - delete: string, - deleteKey: string, + firmware?: string, + firmwareKey?: string, + delete?: string, + deleteKey?: string, } /** @@ -54,8 +53,8 @@ export function createTransportHandler( try { const transport: Transport<*> = await CommNodeHid.open(devicePath) // $FlowFixMe - await action(transport, params) - send(successResponse) + const data = await action(transport, params) + send(successResponse, data) } catch (err) { if (!err) { send(errorResponse, { message: 'Unknown error...' }) @@ -72,7 +71,7 @@ export async function installApp( transport: Transport<*>, { appParams }: { appParams: LedgerAppParams }, ): Promise { - return createSocketDialog(transport, appParams) + return createSocketDialog(transport, '/install', appParams) } /** @@ -82,13 +81,19 @@ export async function uninstallApp( transport: Transport<*>, { appParams }: { appParams: LedgerAppParams }, ): Promise { - return createSocketDialog(transport, { + return createSocketDialog(transport, '/install', { ...appParams, firmware: appParams.delete, firmwareKey: appParams.deleteKey, }) } +export async function getMemInfos(transport: Transport<*>): Promise { + const { targetId } = await getFirmwareInfo(transport) + // Dont ask me about this `perso_11`: I don't know. But we need it. + return createSocketDialog(transport, '/get-mem-infos', { targetId, perso: 'perso_11' }) +} + /** * Send data through ws */ @@ -142,10 +147,11 @@ async function bulk(ws: WebsocketType, transport: Transport<*>, msg: Message) { * Open socket connection with firmware api, and init a dialog * with the device */ -function createSocketDialog(transport: Transport<*>, appParams: LedgerAppParams) { +function createSocketDialog(transport: Transport<*>, endpoint: string, appParams: LedgerAppParams) { return new Promise(async (resolve, reject) => { try { - const url = `${BASE_SOCKET_URL}?${qs.stringify(appParams)}` + let lastData + const url = `${BASE_SOCKET_URL}${endpoint}?${qs.stringify(appParams)}` log('WS CONNECTING', url) const ws: WebsocketType = new Websocket(url) @@ -154,14 +160,18 @@ function createSocketDialog(transport: Transport<*>, appParams: LedgerAppParams) ws.on('close', () => { log('WS CLOSED') - resolve() + resolve(lastData) }) ws.on('message', async rawMsg => { const handlers = { exchange: msg => exchange(ws, transport, msg), bulk: msg => bulk(ws, transport, msg), - success: noop, + success: msg => { + if (msg.data) { + lastData = msg.data + } + }, error: msg => { log('WS ERROR', ':(') throw new Error(msg.data) diff --git a/src/internals/usb/manager/index.js b/src/internals/usb/manager/index.js index bde6aac6..cac154a0 100644 --- a/src/internals/usb/manager/index.js +++ b/src/internals/usb/manager/index.js @@ -18,7 +18,7 @@ import type { IPCSend } from 'types/electron' import axios from 'axios' -import { createTransportHandler, installApp, uninstallApp } from './helpers' +import { createTransportHandler, installApp, uninstallApp, getMemInfos } from './helpers' export default (send: IPCSend) => ({ installApp: createTransportHandler(send, { @@ -33,6 +33,12 @@ export default (send: IPCSend) => ({ errorResponse: 'device.appUninstallError', }), + getMemInfos: createTransportHandler(send, { + action: getMemInfos, + successResponse: 'device.getMemInfosSuccess', + errorResponse: 'device.getMemInfosError', + }), + listApps: async () => { try { const { data } = await axios.get('https://api.ledgerwallet.com/update/applications') diff --git a/src/types/common.js b/src/types/common.js index e8ddea58..11181284 100644 --- a/src/types/common.js +++ b/src/types/common.js @@ -30,3 +30,13 @@ export type SettingsMoney = { export type Settings = SettingsProfile & SettingsDisplay & SettingsMoney export type T = (?string, ?Object) => string + +// -------------------- Manager + +export type MemoryInfos = { + applicationsSize: number, + freeSize: number, + systemSize: number, + totalAppSlots: number, + usedAppSlots: number, +} diff --git a/static/i18n/en/manager.yml b/static/i18n/en/manager.yml new file mode 100644 index 00000000..1ebabc12 --- /dev/null +++ b/static/i18n/en/manager.yml @@ -0,0 +1,3 @@ +tabs: + apps: Apps + device: My device diff --git a/yarn.lock b/yarn.lock index 4ebe7b6a..44ea7bc3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7984,9 +7984,6 @@ ledger-test-library@KhalilBellakrid/ledger-test-library-nodejs#7d37482: dependencies: axios "^0.17.1" bindings "^1.3.0" - electron "^1.8.2" - electron-builder "^20.0.4" - electron-rebuild "^1.7.3" nan "^2.6.2" prebuild-install "^2.2.2"