meriadec
7 years ago
14 changed files with 529 additions and 5 deletions
@ -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 ( |
|||
<Container flow={3}> |
|||
{Icon && <Icon size={24} />} |
|||
<Text>{currency.name}</Text> |
|||
<Box horizontal flow={2}> |
|||
<ActionBtn onClick={onInstall}>{'Install'}</ActionBtn> |
|||
<ActionBtn onClick={onUninstall}>{'Remove'}</ActionBtn> |
|||
</Box> |
|||
</Container> |
|||
) |
|||
} |
@ -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<Props, State> { |
|||
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 = () => ( |
|||
<List> |
|||
{CURRENCIES.map(c => ( |
|||
<ManagerApp |
|||
key={c.coinType} |
|||
currency={c} |
|||
onInstall={this.handleInstall(c)} |
|||
onUninstall={this.handleUninstall(c)} |
|||
/> |
|||
))} |
|||
</List> |
|||
) |
|||
|
|||
render() { |
|||
const { status, error } = this.state |
|||
return ( |
|||
<DeviceMonit |
|||
render={deviceStatus => ( |
|||
<Box> |
|||
{deviceStatus === 'unconnected' && ( |
|||
<Box style={{ height: 500 }} align="center" justify="center"> |
|||
<Box fontSize={8}>{'Connect your device'}</Box> |
|||
</Box> |
|||
)} |
|||
{deviceStatus === 'connected' && this.renderList()} |
|||
<Modal |
|||
isOpened={status !== 'idle'} |
|||
render={() => ( |
|||
<ModalBody p={6} align="center" justify="center" style={{ height: 300 }}> |
|||
{status === 'busy' ? ( |
|||
<Box>{'Loading...'}</Box> |
|||
) : status === 'error' ? ( |
|||
<Box> |
|||
<div>{'error happened'}</div> |
|||
{error} |
|||
<button onClick={this.handleCloseModal}>close</button> |
|||
</Box> |
|||
) : status === 'success' ? ( |
|||
<Box> |
|||
{'success'} |
|||
<button onClick={this.handleCloseModal}>close</button> |
|||
</Box> |
|||
) : null} |
|||
</ModalBody> |
|||
)} |
|||
/> |
|||
</Box> |
|||
)} |
|||
/> |
|||
) |
|||
} |
|||
} |
|||
|
|||
export default compose(translate(), connect(mapStateToProps))(ManagerPage) |
@ -1,2 +1,3 @@ |
|||
export devices from './devices' |
|||
export wallet from './wallet' |
|||
export manager from './manager' |
|||
|
@ -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], |
|||
} |
@ -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<any>, |
|||
successResponse: string, |
|||
errorResponse: string, |
|||
}, |
|||
) { |
|||
return async function transportHandler({ |
|||
devicePath, |
|||
...params |
|||
}: { |
|||
devicePath: string, |
|||
}): Promise<void> { |
|||
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<void> { |
|||
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<void> { |
|||
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<void> { |
|||
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) |
|||
} |
|||
} |
@ -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', |
|||
}), |
|||
}) |
@ -0,0 +1,3 @@ |
|||
// @flow
|
|||
|
|||
export type IPCSend = (string, any) => void |
@ -1,2 +1,3 @@ |
|||
menu: Menu |
|||
accounts: Accounts |
|||
manager: Manage apps |
|||
|
Loading…
Reference in new issue