Browse Source

Ability to install and remove applications on device

master
meriadec 7 years ago
parent
commit
c5d12c2efb
No known key found for this signature in database GPG Key ID: 1D2FC2305E2CB399
  1. 3
      package.json
  2. 14
      src/components/DeviceMonit/index.js
  3. 45
      src/components/ManagerPage/ManagerApp.js
  4. 132
      src/components/ManagerPage/index.js
  5. 4
      src/components/SideBar/index.js
  6. 2
      src/components/layout/Default.js
  7. 1
      src/internals/usb/index.js
  8. 18
      src/internals/usb/manager/constants.js
  9. 240
      src/internals/usb/manager/helpers.js
  10. 34
      src/internals/usb/manager/index.js
  11. 31
      src/renderer/events.js
  12. 3
      src/types/electron.js
  13. 1
      static/i18n/en/sidebar.yml
  14. 6
      yarn.lock

3
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",

14
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<Props, State> {
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<Props, State> {
_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<Props, State> {
render() {
const { status } = this.state
const { render } = this.props
if (render) {
return render(status)
}
return (
<div>
<div>device connected {status !== 'unconnected' ? 'TRUE' : 'FALSE'}</div>

45
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 (
<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>
)
}

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

4
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<Props> {
<Item icon={<IconArrowDown size={16} />} modal={MODAL_RECEIVE}>
{t('receive:title')}
</Item>
<Item icon={<IconQrCode size={16} />} linkTo="/manager">
{t('sidebar:manager')}
</Item>
<Item icon={<IconSettings size={16} />} linkTo="/settings">
{t('settings:title')}
</Item>

2
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<Props> {
<Container innerRef={n => (this._scrollContainer = n)} onScroll={this.handleScroll}>
<Route path="/" exact component={DashboardPage} />
<Route path="/settings" component={SettingsPage} />
<Route path="/manager" component={ManagerPage} />
<Route path="/account/:id" component={AccountPage} />
</Container>
</Box>

1
src/internals/usb/index.js

@ -1,2 +1,3 @@
export devices from './devices'
export wallet from './wallet'
export manager from './manager'

18
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],
}

240
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<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)
}
}

34
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',
}),
})

31
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<void> {
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,

3
src/types/electron.js

@ -0,0 +1,3 @@
// @flow
export type IPCSend = (string, any) => void

1
static/i18n/en/sidebar.yml

@ -1,2 +1,3 @@
menu: Menu
accounts: Accounts
manager: Manage apps

6
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"

Loading…
Cancel
Save