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 devices from './devices' |
||||
export wallet from './wallet' |
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 |
menu: Menu |
||||
accounts: Accounts |
accounts: Accounts |
||||
|
manager: Manage apps |
||||
|
Loading…
Reference in new issue