diff --git a/package.json b/package.json index 34b948e1..d4763e23 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,8 @@ "styled-components": "^3.2.3", "styled-system": "^2.2.1", "tippy.js": "^2.4.0", - "victory": "^0.25.6" + "victory": "^0.25.6", + "ws": "^5.1.0" }, "devDependencies": { "@storybook/addon-actions": "^3.3.15", diff --git a/src/components/DeviceMonit/index.js b/src/components/DeviceMonit/index.js index 82b47a9f..7b0496e6 100644 --- a/src/components/DeviceMonit/index.js +++ b/src/components/DeviceMonit/index.js @@ -16,8 +16,9 @@ type DeviceStatus = 'unconnected' | 'connected' | 'appOpened' type Props = { currentDevice: Device | null, - account: Account, - onStatusChange: DeviceStatus => void, + account?: Account, + onStatusChange?: DeviceStatus => void, + render?: Function, } type State = { @@ -68,7 +69,7 @@ class DeviceMonit extends PureComponent { checkAppOpened = () => { const { currentDevice, account } = this.props - if (currentDevice === null || account.currency === null) { + if (currentDevice === null || !account || account.currency === null) { return } @@ -82,8 +83,9 @@ class DeviceMonit extends PureComponent { _timeout: any = null handleStatusChange = status => { + const { onStatusChange } = this.props this.setState({ status }) - this.props.onStatusChange(status) + onStatusChange && onStatusChange(status) } handleMsgEvent = (e, { type }) => { @@ -99,6 +101,10 @@ class DeviceMonit extends PureComponent { render() { const { status } = this.state + const { render } = this.props + if (render) { + return render(status) + } return (
device connected {status !== 'unconnected' ? 'TRUE' : 'FALSE'}
diff --git a/src/components/ManagerPage/ManagerApp.js b/src/components/ManagerPage/ManagerApp.js new file mode 100644 index 00000000..4376d156 --- /dev/null +++ b/src/components/ManagerPage/ManagerApp.js @@ -0,0 +1,45 @@ +// @flow + +import React from 'react' +import styled from 'styled-components' +import { getIconByCoinType } from '@ledgerhq/currencies/react' + +import type { Currency } from '@ledgerhq/currencies' + +import Box, { Tabbable } from 'components/base/Box' +import Text from 'components/base/Text' + +const Container = styled(Box).attrs({ + align: 'center', + justify: 'center', + m: 1, +})` + width: 150px; + height: 150px; + background: rgba(0, 0, 0, 0.05); +` + +const ActionBtn = styled(Tabbable).attrs({ + fontSize: 3, +})`` + +type Props = { + currency: Currency, + onInstall: Function, + onUninstall: Function, +} + +export default function ManagerApp(props: Props) { + const { currency, onInstall, onUninstall } = props + const Icon = getIconByCoinType(currency.coinType) + return ( + + {Icon && } + {currency.name} + + {'Install'} + {'Remove'} + + + ) +} diff --git a/src/components/ManagerPage/index.js b/src/components/ManagerPage/index.js new file mode 100644 index 00000000..2f455686 --- /dev/null +++ b/src/components/ManagerPage/index.js @@ -0,0 +1,132 @@ +// @flow + +import React, { PureComponent } from 'react' +import { connect } from 'react-redux' +import styled from 'styled-components' +import { translate } from 'react-i18next' +import { compose } from 'redux' +import { listCurrencies } from '@ledgerhq/currencies' + +import type { Currency } from '@ledgerhq/currencies' +import type { Device } from 'types/common' + +import { runJob } from 'renderer/events' +import { getCurrentDevice } from 'reducers/devices' + +import DeviceMonit from 'components/DeviceMonit' +import Box from 'components/base/Box' +import Modal, { ModalBody } from 'components/base/Modal' + +import ManagerApp from './ManagerApp' + +const CURRENCIES = listCurrencies() + +const List = styled(Box).attrs({ + horizontal: true, + m: -1, +})` + flex-wrap: wrap; +` + +const mapStateToProps = state => ({ + device: getCurrentDevice(state), +}) + +type Props = { + device: Device, +} + +type Status = 'idle' | 'busy' | 'success' | 'error' + +type State = { + status: Status, + error: string | null, +} + +class ManagerPage extends PureComponent { + state = { + status: 'idle', + error: null, + } + + createDeviceJobHandler = options => (currency: Currency) => async () => { + this.setState({ status: 'busy' }) + try { + const { job, successResponse, errorResponse } = options + const { device: { path: devicePath } } = this.props + const data = { appName: currency.name.toLowerCase(), 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' }) + + renderList = () => ( + + {CURRENCIES.map(c => ( + + ))} + + ) + + render() { + const { status, error } = this.state + return ( + ( + + {deviceStatus === 'unconnected' && ( + + {'Connect your device'} + + )} + {deviceStatus === 'connected' && this.renderList()} + ( + + {status === 'busy' ? ( + {'Loading...'} + ) : status === 'error' ? ( + +
{'error happened'}
+ {error} + +
+ ) : status === 'success' ? ( + + {'success'} + + + ) : null} +
+ )} + /> +
+ )} + /> + ) + } +} + +export default compose(translate(), connect(mapStateToProps))(ManagerPage) diff --git a/src/components/SideBar/index.js b/src/components/SideBar/index.js index ed61fb53..5b247251 100644 --- a/src/components/SideBar/index.js +++ b/src/components/SideBar/index.js @@ -17,6 +17,7 @@ import { getVisibleAccounts } from 'reducers/accounts' import IconPieChart from 'icons/PieChart' import IconArrowDown from 'icons/ArrowDown' import IconArrowUp from 'icons/ArrowUp' +import IconQrCode from 'icons/QrCode' import IconSettings from 'icons/Settings' import IconPlus from 'icons/Plus' @@ -83,6 +84,9 @@ class SideBar extends PureComponent { } modal={MODAL_RECEIVE}> {t('receive:title')} + } linkTo="/manager"> + {t('sidebar:manager')} + } linkTo="/settings"> {t('settings:title')} diff --git a/src/components/layout/Default.js b/src/components/layout/Default.js index 684a7a90..2bcf6560 100644 --- a/src/components/layout/Default.js +++ b/src/components/layout/Default.js @@ -13,6 +13,7 @@ import GrowScroll from 'components/base/GrowScroll' import AccountPage from 'components/AccountPage' import DashboardPage from 'components/DashboardPage' +import ManagerPage from 'components/ManagerPage' import SettingsPage from 'components/SettingsPage' import AppRegionDrag from 'components/AppRegionDrag' @@ -81,6 +82,7 @@ class Default extends Component { (this._scrollContainer = n)} onScroll={this.handleScroll}> + diff --git a/src/internals/usb/index.js b/src/internals/usb/index.js index 582551f1..460de078 100644 --- a/src/internals/usb/index.js +++ b/src/internals/usb/index.js @@ -1,2 +1,3 @@ export devices from './devices' export wallet from './wallet' +export manager from './manager' diff --git a/src/internals/usb/manager/constants.js b/src/internals/usb/manager/constants.js new file mode 100644 index 00000000..29796025 --- /dev/null +++ b/src/internals/usb/manager/constants.js @@ -0,0 +1,18 @@ +// Socket endpoint +export const BASE_SOCKET_URL = 'ws://api.ledgerwallet.com/update/install' + +// Apparently params we need to add to websocket requests +// +// see https://github.com/LedgerHQ/ledger-manager-chrome +// > controllers/manager/ApplyUpdateController.scala +// +// @TODO: Get rid of them. +export const DEFAULT_SOCKET_PARAMS = { + perso: 'perso_11', + hash: '0000000000000000000000000000000000000000000000000000000000000000', +} + +// List of APDUS +export const APDUS = { + GET_FIRMWARE: [0xe0, 0x01, 0x00, 0x00], +} diff --git a/src/internals/usb/manager/helpers.js b/src/internals/usb/manager/helpers.js new file mode 100644 index 00000000..4b8e580a --- /dev/null +++ b/src/internals/usb/manager/helpers.js @@ -0,0 +1,240 @@ +// @flow + +import CommNodeHid from '@ledgerhq/hw-transport-node-hid' +import chalk from 'chalk' +import Websocket from 'ws' +import qs from 'query-string' +import noop from 'lodash/noop' +import type Transport from '@ledgerhq/hw-transport' + +import type { IPCSend } from 'types/electron' +import { BASE_SOCKET_URL, DEFAULT_SOCKET_PARAMS, APDUS } from './constants' + +type WebsocketType = { + send: (string, any) => void, + on: (string, Function) => void, +} + +type Message = { + nonce: number, + query?: string, + response?: string, + data: any, +} + +/** + * Generate handler which create transport with given + * `devicePath` then call action with it + */ +export function createTransportHandler( + send: IPCSend, + { + action, + successResponse, + errorResponse, + }: { + action: (Transport<*>, ...any) => Promise, + successResponse: string, + errorResponse: string, + }, +) { + return async function transportHandler({ + devicePath, + ...params + }: { + devicePath: string, + }): Promise { + try { + const transport: Transport<*> = await CommNodeHid.open(devicePath) + // $FlowFixMe + await action(transport, params) + send(successResponse) + } catch (err) { + if (!err) { + send(errorResponse, { message: 'Unknown error...' }) + } + send(errorResponse, { message: err.message, stack: err.stack }) + } + } +} + +/** + * Install an app on the device + */ +export async function installApp( + transport: Transport<*>, + { appName }: { appName: string }, +): Promise { + log('INSTALL', `Request to install ${appName} app`) + return createSocketDialog(transport, ({ version }) => ({ + firmware: `nanos/${version}/${appName}/app_latest`, + firmwareKey: `nanos/${version}/${appName}/app_latest_key`, + delete: `nanos/${version}/${appName}/app_del`, + deleteKey: `nanos/${version}/${appName}/app_del_key`, + })) +} + +/** + * Uninstall an app on the device + */ +export async function uninstallApp( + transport: Transport<*>, + { appName }: { appName: string }, +): Promise { + log('INSTALL', `Request to uninstall ${appName} app`) + return createSocketDialog(transport, ({ version }) => ({ + firmware: `nanos/${version}/${appName}/app_del`, + firmwareKey: `nanos/${version}/${appName}/app_del_key`, + delete: `nanos/${version}/${appName}/app_del`, + deleteKey: `nanos/${version}/${appName}/app_del_key`, + })) +} + +/** + * Send data through ws + */ +function socketSend(ws: WebsocketType, msg: Message) { + logWS('SEND', msg) + const strMsg = JSON.stringify(msg) + ws.send(strMsg) +} + +/** + * Exchange data on transport + */ +async function exchange(ws: WebsocketType, transport: Transport<*>, msg: Message): Promise { + const { data, nonce } = msg + const r: Buffer = await transport.exchange(Buffer.from(data, 'hex')) + const status = r.slice(r.length - 2) + const buffer = r.slice(0, r.length - 2) + const strStatus = status.toString('hex') + socketSend(ws, { + nonce, + response: strStatus === '9000' ? 'success' : 'error', + data: buffer.toString('hex'), + }) +} + +/** + * Bulk update on transport + */ +async function bulk(ws: WebsocketType, transport: Transport<*>, msg: Message) { + const { data, nonce } = msg + + // Execute all apdus and collect last status + let lastStatus = null + for (const apdu of data) { + const r: Buffer = await transport.exchange(Buffer.from(apdu, 'hex')) + lastStatus = r.slice(r.length - 2) + } + if (!lastStatus) { + throw new Error('No status collected from bulk') + } + + const strStatus = lastStatus.toString('hex') + socketSend(ws, { + nonce, + response: strStatus === '9000' ? 'success' : 'error', + data: strStatus === '9000' ? '' : strStatus, + }) +} + +/** + * Open socket connection with firmware api, and init a dialog + * with the device + */ +function createSocketDialog(transport: Transport<*>, buildParams: Function) { + return new Promise(async (resolve, reject) => { + try { + const { targetId, version } = await getFirmwareInfo(transport) + const fullParams = qs.stringify({ + targetId, + ...DEFAULT_SOCKET_PARAMS, + ...buildParams({ targetId, version }), + }) + const url = `${BASE_SOCKET_URL}?${fullParams}` + + log('WS CONNECTING', url) + const ws: WebsocketType = new Websocket(url) + + ws.on('open', () => log('WS CONNECTED')) + + ws.on('close', () => { + log('WS CLOSED') + resolve() + }) + + ws.on('message', async rawMsg => { + const handlers = { + exchange: msg => exchange(ws, transport, msg), + bulk: msg => bulk(ws, transport, msg), + success: noop, + error: msg => { + log('WS ERROR', ':(') + throw new Error(msg.data) + }, + } + try { + const msg = JSON.parse(rawMsg) + if (!(msg.query in handlers)) { + throw new Error(`Cannot handle msg of type ${msg.query}`) + } + logWS('RECEIVE', msg) + await handlers[msg.query](msg) + } catch (err) { + log('ERROR', err.toString()) + reject(err) + } + }) + } catch (err) { + reject(err) + } + }) +} + +/** + * Retrieve targetId and firmware version from device + */ +async function getFirmwareInfo(transport: Transport<*>) { + const res = await transport.send(...APDUS.GET_FIRMWARE) + const byteArray = [...res] + const data = byteArray.slice(0, byteArray.length - 2) + const targetIdStr = Buffer.from(data.slice(0, 4)) + const targetId = targetIdStr.readUIntBE(0, 4) + const versionLength = data[4] + const version = Buffer.from(data.slice(5, 5 + versionLength)).toString() + return { targetId, version } +} + +/** + * Debug helper + */ +function log(namespace: string, str: string = '', color?: string) { + namespace = namespace.padEnd(15) + const coloredNamespace = color ? chalk[color](namespace) : namespace + console.log(`${chalk.bold(`> ${coloredNamespace}`)} ${str}`) // eslint-disable-line no-console +} + +/** + * Log a socket send/receive + */ +function logWS(type: string, msg: Message) { + const arrow = type === 'SEND' ? '↑' : '↓' + const namespace = `${arrow} WS ${type}` + const color = type === 'SEND' ? 'blue' : 'red' + if (msg.nonce) { + let d = '' + if (msg.query === 'exchange') { + d = msg.data.length > 100 ? `${msg.data.substr(0, 97)}...` : msg.data + } else if (msg.query === 'bulk') { + d = `[bulk x ${msg.data.length}]` + } + log( + namespace, + `${String(msg.nonce).padEnd(2)} ${(msg.response || msg.query || '').padEnd(10)} ${d}`, + color, + ) + } else { + log(namespace, JSON.stringify(msg), color) + } +} diff --git a/src/internals/usb/manager/index.js b/src/internals/usb/manager/index.js new file mode 100644 index 00000000..a4af2a75 --- /dev/null +++ b/src/internals/usb/manager/index.js @@ -0,0 +1,34 @@ +// @flow + +/** + * Manager + * ------- + * + * xXx + * xXx + * xXx + * xxxXxxx + * xxXxx + * xXx + * xX x Xx + * xX Xx + * xxXXXXXXXxx + * + */ + +import type { IPCSend } from 'types/electron' +import { createTransportHandler, installApp, uninstallApp } from './helpers' + +export default (send: IPCSend) => ({ + installApp: createTransportHandler(send, { + action: installApp, + successResponse: 'device.appInstalled', + errorResponse: 'device.appInstallError', + }), + + uninstallApp: createTransportHandler(send, { + action: uninstallApp, + successResponse: 'device.appUninstalled', + errorResponse: 'device.appUninstallError', + }), +}) diff --git a/src/renderer/events.js b/src/renderer/events.js index 27b112cc..34ac611d 100644 --- a/src/renderer/events.js +++ b/src/renderer/events.js @@ -45,6 +45,37 @@ export function sendEvent(channel: string, msgType: string, data: any) { }) } +export function runJob({ + channel, + job, + successResponse, + errorResponse, + data, +}: { + channel: string, + job: string, + successResponse: string, + errorResponse: string, + data: any, +}): Promise { + return new Promise((resolve, reject) => { + ipcRenderer.send(channel, { type: job, data }) + ipcRenderer.on('msg', handler) + function handler(e, res) { + const { type, data } = res + if (![successResponse, errorResponse].includes(type)) { + return + } + ipcRenderer.removeListener('msg', handler) + if (type === successResponse) { + resolve(data) + } else if (type === errorResponse) { + reject(data) + } + } + }) +} + export function sendSyncEvent(channel: string, msgType: string, data: any): any { return ipcRenderer.sendSync(`${channel}:sync`, { type: msgType, diff --git a/src/types/electron.js b/src/types/electron.js new file mode 100644 index 00000000..3c81cc0c --- /dev/null +++ b/src/types/electron.js @@ -0,0 +1,3 @@ +// @flow + +export type IPCSend = (string, any) => void diff --git a/static/i18n/en/sidebar.yml b/static/i18n/en/sidebar.yml index ff99b4c8..61f29185 100644 --- a/static/i18n/en/sidebar.yml +++ b/static/i18n/en/sidebar.yml @@ -1,2 +1,3 @@ menu: Menu accounts: Accounts +manager: Manage apps diff --git a/yarn.lock b/yarn.lock index d23dfa58..fb16432a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10883,6 +10883,12 @@ ws@^4.0.0: async-limiter "~1.0.0" safe-buffer "~5.1.0" +ws@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-5.1.0.tgz#ad7f95a65c625d47c24f2b8e5928018cf965e2a6" + dependencies: + async-limiter "~1.0.0" + xdg-basedir@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4"