Browse Source

Merge pull request #605 from LedgerHQ/develop

prepare for alpha.12
master
Gaëtan Renaudeau 7 years ago
committed by GitHub
parent
commit
0b9746709f
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 156
      README.md
  2. 3
      package.json
  3. 5
      src/api/Ledger.js
  4. 12
      src/api/Ripple.js
  5. 33
      src/bridge/RippleJSBridge.js
  6. 3
      src/bridge/types.js
  7. 2
      src/commands/getIsGenuine.js
  8. 43
      src/components/AccountPage/index.js
  9. 10
      src/components/BalanceSummary/index.js
  10. 5
      src/components/CalculateBalance.js
  11. 44
      src/components/DashboardPage/index.js
  12. 2
      src/components/ExchangePage/index.js
  13. 4
      src/components/FeesField/RippleKind.js
  14. 46
      src/components/GenuineCheckModal/index.js
  15. 3
      src/components/Onboarding/helperComponents.js
  16. 6
      src/components/Onboarding/steps/Analytics.js
  17. 61
      src/components/Onboarding/steps/GenuineCheck.js
  18. 6
      src/components/Onboarding/steps/SelectDevice.js
  19. 5
      src/components/Onboarding/steps/SetPassword.js
  20. 16
      src/components/OperationsList/AccountCell.js
  21. 25
      src/components/PillsDaysCount.js
  22. 11
      src/components/StickyBackToTop.js
  23. 7
      src/components/Workflow/EnsureGenuine.js
  24. 5
      src/components/Workflow/index.js
  25. 4
      src/components/base/Chart/refreshDraw.js
  26. 7
      src/components/base/FakeLink.js
  27. 4
      src/components/base/GrowScroll/index.js
  28. 11
      src/components/layout/Default.js
  29. 90
      src/components/modals/AccountSettingRenderBody.js
  30. 45
      src/config/constants.js
  31. 3
      src/config/languages.js
  32. 2
      src/helpers/apps/installApp.js
  33. 4
      src/helpers/apps/listApps.js
  34. 2
      src/helpers/apps/uninstallApp.js
  35. 164
      src/helpers/common.js
  36. 13
      src/helpers/constants.js
  37. 3
      src/helpers/deviceAccess.js
  38. 9
      src/helpers/devices/getFirmwareInfo.js
  39. 5
      src/helpers/devices/getIsGenuine.js
  40. 6
      src/helpers/devices/getLatestFirmwareForDevice.js
  41. 3
      src/helpers/libcore.js
  42. 128
      src/helpers/socket.js
  43. 3
      src/internals/index.js
  44. 27
      src/logger.js
  45. 5
      src/reducers/onboarding.js
  46. 11
      src/reducers/settings.js
  47. 3
      src/renderer/i18n/instanciate.js
  48. 3
      src/renderer/init.js
  49. BIN
      static/docs/architecture.jpg
  50. BIN
      static/docs/ledgerLogo.png
  51. 4
      static/i18n/en/app.yml
  52. 5
      static/i18n/en/errors.yml
  53. 10
      yarn.lock

156
README.md

@ -5,81 +5,161 @@
:warning: Disclaimer: this project is under active development. Use at your own risks.
## Installation
<img src="/static/docs/ledgerLogo.png" width="200"/>
#### Requirements
> Ledger Live Desktop is a new generation Ledger Wallet application build with React, Redux and Electron to run natively on the web. The main goal of the app is to provide our users with a single wallet for all crypto currencies supported by our devices. To learn more check out [Ledger](https://www.ledgerwallet.com/?utm_source=redirection&utm_medium=variable)
Project has been tested with
## Architecture
- [NodeJS](https://nodejs.org) v9.3.0
- [Yarn](https://yarnpkg.com) v1.3.0
From one side Ledger Desktop app connected to the Blockchain via the in-house written C++ library - LibCore and from the other it communicates to the Ledger Hardware Device to securely sign all transactions.
<p align="center">
<img src="/static/docs/architecture.jpg" width="550"/>
</p>
## Setup
### Requirements
- [NodeJS](https://nodejs.org) LTS
- [Yarn](https://yarnpkg.com) LTS
- [Python](https://www.python.org/) v2.7.10 (used by [node-gyp](https://github.com/nodejs/node-gyp) to build native addons)
- You will also need a C++ compiler
#### Optional
### Optional
- `Museo Sans` font - for Ledger guys, [follow that link](https://drive.google.com/drive/folders/14R6kGFtx53DuqTyIOjnT7BGogzeyMSzN), download `museosans.zip` and extract it inside the `static/fonts/museosans` directory
- In the application we use `Museo Sans` font. To include it in the app, you need to have a zip file `museosans.zip` which you should extract and place inside the `static/fonts/museosans` directory
#### Setup
## Install
1. Install dependencies
1. Clone or fork the repo
```bash
git clone git@github.com:LedgerHQ/ledger-live-desktop.git
```
2. Install dependencies
```bash
yarn
```
2. Create `.env` file
## Run
Launch the app
```bash
# ENV VARIABLES
# -------------
yarn start
```
## Build
# Where errors will be tracked (you may not want to edit this line)
# SENTRY_URL=
```bash
# Build & package the whole app
# Creates a .dmg for Mac, .exe installer for Windows, or .AppImage for Linux
# Output files will be created in dist/ folder
yarn dist
```
# OPTIONAL ENV VARIABLES
# ----------------------
**Note:** Use `yarn dist:dir` to speed up the process: it will skip the packaging step. Handy for debugging builds. You can also use `BUNDLE_ANALYZER=1 yarn dist:dir` to generate [webpack-bundle-analyzer](https://github.com/webpack-contrib/webpack-bundle-analyzer) report.
# API base url, fallback to our API if not set
API_BASE_URL=http://...
---
# Setup device debug mode
DEBUG_DEVICE=0
## Config (optional helpers)
# Developer tools position (used only in dev)
# can be one of: right, bottom, undocked, detach
DEV_TOOLS_MODE=bottom
### Environment variables
# Filter debug output
DEBUG=lwd*,-lwd:syncb
(you can use a .env or export environment variables)
# hide the dev window
```bash
DEV_TOOLS_MODE=bottom # devtools position Options: right, bottom, undocked, detach
HIDE_DEV_WINDOW=0
## flags for development purpose
DEBUG_DEVICE=1
DEBUG_NETWORK=1
DEBUG_COMMANDS=1
DEBUG_DB=1
DEBUG_ACTION=1
DEBUG_TAB_KEY=1
DEBUG_LIBCORE=1
DEBUG_WS=1
LEDGER_RESET_ALL=1
LEDGER_DEBUG_ALL_LANGS=1
SKIP_GENUINE=1
SKIP_ONBOARDING=1
SHOW_LEGACY_NEW_ACCOUNT=1
HIGHLIGHT_I18N=1
## constants
GET_CALLS_TIMEOUT=30000
GET_CALLS_RETRY=2
SYNC_MAX_CONCURRENT=6
SYNC_BOOT_DELAY=2000
SYNC_ALL_INTERVAL=60000
CHECK_APP_INTERVAL_WHEN_INVALID=600
CHECK_APP_INTERVAL_WHEN_VALID=1200
CHECK_UPDATE_DELAY=5000
DEVICE_DISCONNECT_DEBOUNCE=500
```
#### Development commands
### Launch storybook
```bash
# Launch the app
yarn start
We use [storybook](https://storybook.js.org/) for UI development.
# Launch the storybook
```bash
yarn storybook
```
# Code quality checks
### Run code quality checks
```bash
yarn lint # launch eslint
yarn prettier # launch prettier
yarn flow # launch flow
yarn test # launch unit tests
```
#### Building from source
### Programmaically reset hard the app
Stop the app and to clean accounts, settings, etc, run
```bash
# Build & package the whole app
# Creates a .dmg for Mac, .exe installer for Windows, or .AppImage for Linux
# Output files will be created in dist/ folder
yarn dist
rm -rf ~/Library/Application\ Support/Electron/
```
**Note:** Use `yarn dist:dir` to speed up the process: it will skip the packaging step. Handy for debugging builds. You can also use `BUNDLE_ANALYZER=1 yarn dist:dir` to generate [webpack-bundle-analyzer](https://github.com/webpack-contrib/webpack-bundle-analyzer) report.
## File structure
```
.
├── dist : output folder generate by the build
├── scripts : commands (for building, releasing,...)
├── src
│   ├── internals : code that run on the 'internal' thread.
│   ├── main : code that run on the 'main' thread.
│   ├── renderer : code that run on the 'renderer' thread
│   ├── components : all the React components
| └── modals : sub levels for the modals
│   ├── api : related to HTTP APIs
│   ├── bridge : an abstraction on top of blockchains apis (libcore / js impls)
│   ├── commands : an abstraction to run code over the internal thread
│   ├── icons : all the icons of our app, as React components.
│   ├── config : contains the constants,...
│   ├── helpers : generic folder for our business logic (might be reorganized in the future)
│   ├── middlewares : redux middlewares
│   ├── actions : redux actions
│   ├── reducers : redux reducers
│   ├── sentry : for our bug tracker
│   ├── stories : for storybook
│   ├── styles : theme
│   ├── logger.js : abstraction for all our console.log s
│   └── types : global flow types
├── static
│   ├── docs
│   ├── fonts
│   ├── i18n
│   ├── images
│   └── videos
├── webpack : build configuration
└── yarn.lock
```

3
package.json

@ -42,7 +42,7 @@
"@ledgerhq/hw-transport": "^4.13.0",
"@ledgerhq/hw-transport-node-hid": "^4.13.0",
"@ledgerhq/ledger-core": "2.0.0-rc.1",
"@ledgerhq/live-common": "2.30.0",
"@ledgerhq/live-common": "2.31.0",
"async": "^2.6.1",
"axios": "^0.18.0",
"babel-runtime": "^6.26.0",
@ -96,6 +96,7 @@
"secp256k1": "3.3.1",
"semaphore": "^1.1.0",
"semver": "^5.5.0",
"smoothscroll-polyfill": "^0.4.3",
"source-map": "0.7.3",
"source-map-support": "^0.5.4",
"styled-components": "^3.3.2",

5
src/api/Ledger.js

@ -1,7 +1,6 @@
// @flow
import type { Currency } from '@ledgerhq/live-common/lib/types'
const BASE_URL = process.env.LEDGER_REST_API_BASE || 'https://api.ledgerwallet.com/'
import { LEDGER_REST_API_BASE } from 'config/constants'
export const blockchainBaseURL = ({ ledgerExplorerId }: Currency): ?string =>
ledgerExplorerId ? `${BASE_URL}blockchain/v2/${ledgerExplorerId}` : null
ledgerExplorerId ? `${LEDGER_REST_API_BASE}blockchain/v2/${ledgerExplorerId}` : null

12
src/api/Ripple.js

@ -1,7 +1,6 @@
// @flow
import logger from 'logger'
import { RippleAPI } from 'ripple-lib'
import type { CryptoCurrency } from '@ledgerhq/live-common/lib/types'
import {
parseCurrencyUnit,
getCryptoCurrencyById,
@ -10,14 +9,11 @@ import {
const rippleUnit = getCryptoCurrencyById('ripple').units[0]
const apiEndpoint = {
ripple: 'wss://s1.ripple.com',
}
export const defaultEndpoint = 'wss://s2.ripple.com'
export const apiForCurrency = (currency: CryptoCurrency) => {
const api = new RippleAPI({
server: apiEndpoint[currency.id],
})
export const apiForEndpointConfig = (endpointConfig: ?string = null) => {
const server = endpointConfig || defaultEndpoint
const api = new RippleAPI({ server })
api.on('error', (errorCode, errorMessage) => {
logger.warn(`Ripple API error: ${errorCode}: ${errorMessage}`)
})

33
src/bridge/RippleJSBridge.js

@ -10,7 +10,8 @@ import { getDerivations } from 'helpers/derivations'
import getAddress from 'commands/getAddress'
import signTransaction from 'commands/signTransaction'
import {
apiForCurrency,
apiForEndpointConfig,
defaultEndpoint,
parseAPIValue,
parseAPICurrencyObject,
formatAPICurrencyXRP,
@ -47,7 +48,7 @@ const EditAdvancedOptions = ({ onChange, value }: EditProps<Transaction>) => (
)
async function signAndBroadcast({ a, t, deviceId, isCancelled, onSigned, onOperationBroadcasted }) {
const api = apiForCurrency(a.currency)
const api = apiForEndpointConfig(a.endpointConfig)
try {
await api.connect()
const amount = formatAPICurrencyXRP(t.amount)
@ -217,10 +218,11 @@ const txToOperation = (account: Account) => ({
return op
}
const getServerInfo = (perCurrencyId => currency => {
if (perCurrencyId[currency.id]) return perCurrencyId[currency.id]()
const getServerInfo = (map => endpointConfig => {
if (!endpointConfig) endpointConfig = ''
if (map[endpointConfig]) return map[endpointConfig]()
const f = throttle(async () => {
const api = apiForCurrency(currency)
const api = apiForEndpointConfig(endpointConfig)
try {
await api.connect()
const res = await api.getServerInfo()
@ -232,7 +234,7 @@ const getServerInfo = (perCurrencyId => currency => {
api.disconnect()
}
}, 60000)
perCurrencyId[currency.id] = f
map[endpointConfig] = f
return f()
})({})
@ -244,10 +246,10 @@ const RippleJSBridge: WalletBridge<Transaction> = {
}
async function main() {
const api = apiForCurrency(currency)
const api = apiForEndpointConfig()
try {
await api.connect()
const serverInfo = await getServerInfo(currency)
const serverInfo = await getServerInfo()
const ledgers = serverInfo.completeLedgers.split('-')
const minLedgerVersion = Number(ledgers[0])
const maxLedgerVersion = Number(ledgers[1])
@ -342,7 +344,7 @@ const RippleJSBridge: WalletBridge<Transaction> = {
return { unsubscribe }
},
synchronize: ({ currency, freshAddress, blockHeight }) =>
synchronize: ({ endpointConfig, freshAddress, blockHeight }) =>
Observable.create(o => {
let finished = false
const unsubscribe = () => {
@ -350,11 +352,11 @@ const RippleJSBridge: WalletBridge<Transaction> = {
}
async function main() {
const api = apiForCurrency(currency)
const api = apiForEndpointConfig(endpointConfig)
try {
await api.connect()
if (finished) return
const serverInfo = await getServerInfo(currency)
const serverInfo = await getServerInfo(endpointConfig)
if (finished) return
const ledgers = serverInfo.completeLedgers.split('-')
const minLedgerVersion = Number(ledgers[0])
@ -456,7 +458,7 @@ const RippleJSBridge: WalletBridge<Transaction> = {
isValidTransaction: (a, t) => (t.amount > 0 && t.recipient && true) || false,
canBeSpent: async (a, t) => {
const r = await getServerInfo(a.currency)
const r = await getServerInfo(a.endpointConfig)
return t.amount + t.fee + parseAPIValue(r.validatedLedger.reserveBaseXRP) <= a.balance
},
@ -495,6 +497,13 @@ const RippleJSBridge: WalletBridge<Transaction> = {
),
),
}),
getDefaultEndpointConfig: () => defaultEndpoint,
validateEndpointConfig: async endpointConfig => {
const api = apiForEndpointConfig(endpointConfig)
await api.connect()
},
}
export default RippleJSBridge

3
src/bridge/types.js

@ -111,4 +111,7 @@ export interface WalletBridge<Transaction> {
// Implement an optimistic response for signAndBroadcast.
// you likely should add the operation in account.pendingOperations but maybe you want to clean it (because maybe some are replaced / cancelled by this one?)
addPendingOperation?: (account: Account, optimisticOperation: Operation) => Account;
getDefaultEndpointConfig?: () => string;
validateEndpointConfig?: (endpointConfig: string) => Promise<void>;
}

2
src/commands/getIsGenuine.js

@ -6,7 +6,7 @@ import { fromPromise } from 'rxjs/observable/fromPromise'
import getIsGenuine from 'helpers/devices/getIsGenuine'
import { withDevice } from 'helpers/deviceAccess'
type Input = *
type Input = * // FIXME !
type Result = string
const cmd: Command<Input, Result> = createCommand('getIsGenuine', ({ devicePath, targetId }) =>

43
src/components/AccountPage/index.js

@ -16,8 +16,15 @@ import type { T } from 'types/common'
import { rgba } from 'styles/helpers'
import { saveSettings } from 'actions/settings'
import { accountSelector } from 'reducers/accounts'
import { counterValueCurrencySelector, localeSelector } from 'reducers/settings'
import {
counterValueCurrencySelector,
localeSelector,
selectedTimeRangeSelector,
timeRangeDaysByKey,
} from 'reducers/settings'
import type { TimeRange } from 'reducers/settings'
import { openModal } from 'reducers/modals'
import IconAccountSettings from 'icons/AccountSettings'
@ -63,10 +70,12 @@ const mapStateToProps = (state, props) => ({
account: accountSelector(state, { accountId: props.match.params.id }),
counterValue: counterValueCurrencySelector(state),
settings: localeSelector(state),
selectedTimeRange: selectedTimeRangeSelector(state),
})
const mapDispatchToProps = {
openModal,
saveSettings,
}
type Props = {
@ -74,30 +83,20 @@ type Props = {
t: T,
account?: Account,
openModal: Function,
saveSettings: ({ selectedTimeRange: TimeRange }) => *,
selectedTimeRange: TimeRange,
}
type State = {
selectedTime: string,
daysCount: number,
}
class AccountPage extends PureComponent<Props, State> {
state = {
selectedTime: 'month',
daysCount: 30,
class AccountPage extends PureComponent<Props> {
handleChangeSelectedTime = item => {
this.props.saveSettings({ selectedTimeRange: item.key })
}
handleChangeSelectedTime = item =>
this.setState({
selectedTime: item.key,
daysCount: item.value,
})
_cacheBalance = null
render() {
const { account, openModal, t, counterValue } = this.props
const { selectedTime, daysCount } = this.state
const { account, openModal, t, counterValue, selectedTimeRange } = this.props
const daysCount = timeRangeDaysByKey[selectedTimeRange]
// Don't even throw if we jumped in wrong account route
if (!account) {
@ -148,7 +147,7 @@ class AccountPage extends PureComponent<Props, State> {
chartId={`account-chart-${account.id}`}
counterValue={counterValue}
daysCount={daysCount}
selectedTime={selectedTime}
selectedTimeRange={selectedTimeRange}
renderHeader={({ totalBalance, sinceBalance, refBalance }) => (
<Box flow={4} mb={2}>
<Box horizontal>
@ -165,7 +164,7 @@ class AccountPage extends PureComponent<Props, State> {
</BalanceTotal>
<Box>
<PillsDaysCount
selectedTime={selectedTime}
selected={selectedTimeRange}
onChange={this.handleChangeSelectedTime}
/>
</Box>
@ -177,7 +176,7 @@ class AccountPage extends PureComponent<Props, State> {
totalBalance={totalBalance}
sinceBalance={sinceBalance}
refBalance={refBalance}
since={selectedTime}
since={selectedTimeRange}
/>
<BalanceSinceDiff
t={t}
@ -186,7 +185,7 @@ class AccountPage extends PureComponent<Props, State> {
totalBalance={totalBalance}
sinceBalance={sinceBalance}
refBalance={refBalance}
since={selectedTime}
since={selectedTimeRange}
/>
</Box>
</Box>

10
src/components/BalanceSummary/index.js

@ -14,10 +14,10 @@ type Props = {
chartColor: string,
chartId: string,
accounts: Account[],
selectedTime: string,
selectedTimeRange: string,
daysCount: number,
renderHeader?: ({
selectedTime: *,
selectedTimeRange: *,
totalBalance: number,
sinceBalance: number,
refBalance: number,
@ -31,7 +31,7 @@ const BalanceSummary = ({
counterValue,
daysCount,
renderHeader,
selectedTime,
selectedTimeRange,
}: Props) => {
const account = accounts.length === 1 ? accounts[0] : undefined
return (
@ -43,7 +43,7 @@ const BalanceSummary = ({
{renderHeader ? (
<Box px={6}>
{renderHeader({
selectedTime,
selectedTimeRange,
// FIXME refactor these
totalBalance: balanceEnd,
sinceBalance: balanceStart,
@ -59,7 +59,7 @@ const BalanceSummary = ({
data={balanceHistory}
height={200}
currency={counterValue}
tickXScale={selectedTime}
tickXScale={selectedTimeRange}
renderTickY={val => formatShort(counterValue.units[0], val)}
renderTooltip={
isAvailable && !account

5
src/components/CalculateBalance.js

@ -79,7 +79,12 @@ const mapStateToProps = (state: State, props: OwnProps) => {
}
}
const hash = ({ balanceHistory, balanceEnd }) => `${balanceHistory.length}_${balanceEnd}`
class CalculateBalance extends Component<Props> {
shouldComponentUpdate(nextProps) {
return hash(nextProps) !== hash(this.props)
}
render() {
const { children } = this.props
return children(this.props)

44
src/components/DashboardPage/index.js

@ -13,7 +13,13 @@ import type { T } from 'types/common'
import { colors } from 'styles/theme'
import { accountsSelector } from 'reducers/accounts'
import { counterValueCurrencySelector, localeSelector } from 'reducers/settings'
import {
counterValueCurrencySelector,
localeSelector,
selectedTimeRangeSelector,
timeRangeDaysByKey,
} from 'reducers/settings'
import type { TimeRange } from 'reducers/settings'
import { reorderAccounts } from 'actions/accounts'
import { saveSettings } from 'actions/settings'
@ -35,6 +41,7 @@ const mapStateToProps = createStructuredSelector({
accounts: accountsSelector,
counterValue: counterValueCurrencySelector,
locale: localeSelector,
selectedTimeRange: selectedTimeRangeSelector,
})
const mapDispatchToProps = {
@ -48,20 +55,11 @@ type Props = {
accounts: Account[],
push: Function,
counterValue: Currency,
selectedTimeRange: TimeRange,
saveSettings: ({ selectedTimeRange: TimeRange }) => *,
}
type State = {
selectedTime: string,
daysCount: number,
}
class DashboardPage extends PureComponent<Props, State> {
state = {
// save to user preference?
selectedTime: 'month',
daysCount: 30,
}
class DashboardPage extends PureComponent<Props> {
onAccountClick = account => this.props.push(`/account/${account.id}`)
handleGreeting = () => {
@ -77,17 +75,15 @@ class DashboardPage extends PureComponent<Props, State> {
return 'app:dashboard.greeting.morning'
}
handleChangeSelectedTime = item =>
this.setState({
selectedTime: item.key,
daysCount: item.value,
})
handleChangeSelectedTime = item => {
this.props.saveSettings({ selectedTimeRange: item.key })
}
_cacheBalance = null
render() {
const { accounts, t, counterValue } = this.props
const { selectedTime, daysCount } = this.state
const { accounts, t, counterValue, selectedTimeRange } = this.props
const daysCount = timeRangeDaysByKey[selectedTimeRange]
const timeFrame = this.handleGreeting()
const totalAccounts = accounts.length
@ -111,7 +107,7 @@ class DashboardPage extends PureComponent<Props, State> {
</Box>
<Box>
<PillsDaysCount
selectedTime={selectedTime}
selected={selectedTimeRange}
onChange={this.handleChangeSelectedTime}
/>
</Box>
@ -122,14 +118,14 @@ class DashboardPage extends PureComponent<Props, State> {
chartId="dashboard-chart"
chartColor={colors.wallet}
accounts={accounts}
selectedTime={selectedTime}
selectedTimeRange={selectedTimeRange}
daysCount={daysCount}
renderHeader={({ totalBalance, selectedTime, sinceBalance, refBalance }) => (
renderHeader={({ totalBalance, selectedTimeRange, sinceBalance, refBalance }) => (
<BalanceInfos
t={t}
counterValue={counterValue}
totalBalance={totalBalance}
since={selectedTime}
since={selectedTimeRange}
sinceBalance={sinceBalance}
refBalance={refBalance}
/>

2
src/components/ExchangePage/index.js

@ -41,7 +41,7 @@ class ExchangePage extends PureComponent<Props> {
]
return (
<Box>
<Box pb={6}>
<Box ff="Museo Sans|Regular" color="dark" fontSize={7} mb={5}>
{t('app:exchange.title')}
</Box>

4
src/components/FeesField/RippleKind.js

@ -2,7 +2,7 @@
import React, { Component } from 'react'
import type { Account } from '@ledgerhq/live-common/lib/types'
import { apiForCurrency, parseAPIValue } from 'api/Ripple'
import { apiForEndpointConfig, parseAPIValue } from 'api/Ripple'
import InputCurrency from 'components/base/InputCurrency'
import GenericContainer from './GenericContainer'
@ -24,7 +24,7 @@ class FeesField extends Component<Props, State> {
this.sync()
}
async sync() {
const api = apiForCurrency(this.props.account.currency)
const api = apiForEndpointConfig(this.props.account.endpointConfig)
try {
await api.connect()
const info = await api.getServerInfo()

46
src/components/GenuineCheckModal/index.js

@ -1,6 +1,6 @@
// @flow
import React, { PureComponent } from 'react'
import React, { PureComponent, Fragment } from 'react'
import { translate } from 'react-i18next'
import type { T } from 'types/common'
@ -11,14 +11,44 @@ import WorkflowDefault from 'components/Workflow/WorkflowDefault'
type Props = {
t: T,
onGenuineCheck: (isGenuine: boolean) => void,
onGenuineCheckPass: () => void,
onGenuineCheckFailed: () => void,
onGenuineCheckUnavailable: Error => void,
}
type State = {}
class GenuineCheckStatus extends PureComponent<*> {
componentDidUpdate() {
this.sideEffect()
}
sideEffect() {
const {
isGenuine,
error,
onGenuineCheckPass,
onGenuineCheckFailed,
onGenuineCheckUnavailable,
} = this.props
if (isGenuine !== null) {
if (isGenuine) {
onGenuineCheckPass()
} else {
onGenuineCheckFailed()
}
} else if (error) {
onGenuineCheckUnavailable(error)
}
}
render() {
return null
}
}
/* eslint-disable react/no-multi-comp */
class GenuineCheck extends PureComponent<Props, State> {
renderBody = ({ onClose }) => {
const { t, onGenuineCheck } = this.props
const { t, onGenuineCheckPass, onGenuineCheckFailed, onGenuineCheckUnavailable } = this.props
// TODO: use the real devices list. for now we force choosing only
// the current device because we don't handle multi device in MVP
@ -28,14 +58,22 @@ class GenuineCheck extends PureComponent<Props, State> {
<ModalTitle>{t('app:genuinecheck.modal.title')}</ModalTitle>
<ModalContent>
<Workflow
onGenuineCheck={isGenuine => onGenuineCheck(isGenuine)}
renderDefault={(device, deviceInfo, isGenuine, errors) => (
<Fragment>
<GenuineCheckStatus
isGenuine={isGenuine}
error={errors.genuineError}
onGenuineCheckPass={onGenuineCheckPass}
onGenuineCheckFailed={onGenuineCheckFailed}
onGenuineCheckUnavailable={onGenuineCheckUnavailable}
/>
<WorkflowDefault
device={device}
deviceInfo={deviceInfo}
isGenuine={isGenuine}
errors={errors} // TODO: FIX ERRORS
/>
</Fragment>
)}
/>
</ModalContent>

3
src/components/Onboarding/helperComponents.js

@ -4,6 +4,7 @@ import styled from 'styled-components'
import { radii } from 'styles/theme'
import Box from 'components/base/Box'
import GrowScroll from 'components/base/GrowScroll'
import IconSensitiveOperationShield from 'icons/illustrations/SensitiveOperationShield'
// GENERAL
@ -16,6 +17,8 @@ export const Title = styled(Box).attrs({
text-align: center;
`
export const StepContainerInner = styled(GrowScroll).attrs({ pb: 6, align: 'center' })``
export const Description = styled(Box).attrs({
ff: 'Museo Sans|Light',
fontSize: 5,

6
src/components/Onboarding/steps/Analytics.js

@ -6,7 +6,7 @@ import { connect } from 'react-redux'
import { saveSettings } from 'actions/settings'
import Box from 'components/base/Box'
import CheckBox from 'components/base/CheckBox'
import { Title, Description, FixedTopContainer } from '../helperComponents'
import { Title, Description, FixedTopContainer, StepContainerInner } from '../helperComponents'
import OnboardingFooter from '../OnboardingFooter'
import type { StepProps } from '..'
@ -51,7 +51,7 @@ class Analytics extends PureComponent<StepProps, State> {
return (
<FixedTopContainer>
<Box grow alignItems="center">
<StepContainerInner>
<Title>{t('onboarding:analytics.title')}</Title>
<Description>{t('onboarding:analytics.desc')}</Description>
<Box mt={5}>
@ -74,7 +74,7 @@ class Analytics extends PureComponent<StepProps, State> {
</Box>
</Container>
</Box>
</Box>
</StepContainerInner>
<OnboardingFooter
horizontal
align="center"

61
src/components/Onboarding/steps/GenuineCheck.js

@ -11,19 +11,23 @@ import type { T } from 'types/common'
import { updateGenuineCheck } from 'reducers/onboarding'
import Box from 'components/base/Box'
import FakeLink from 'components/base/FakeLink'
import Button from 'components/base/Button'
import RadioGroup from 'components/base/RadioGroup'
import GenuineCheckModal from 'components/GenuineCheckModal'
import TranslatedError from 'components/TranslatedError'
import IconLedgerNanoError from 'icons/illustrations/LedgerNanoError'
import IconLedgerBlueError from 'icons/illustrations/LedgerBlueError'
import IconCheck from 'icons/Check'
import IconCross from 'icons/Cross'
import {
Title,
Description,
IconOptionRow,
FixedTopContainer,
StepContainerInner,
OnboardingFooterWrapper,
} from '../helperComponents'
@ -88,12 +92,39 @@ class GenuineCheck extends PureComponent<StepProps, State> {
handleOpenGenuineCheckModal = () => this.setState({ isGenuineCheckModalOpened: true })
handleCloseGenuineCheckModal = (cb?: Function) =>
this.setState(state => ({ ...state, isGenuineCheckModalOpened: false }), () => cb && cb())
this.setState(
state => ({ ...state, isGenuineCheckModalOpened: false }),
() => {
// FIXME: meh
if (cb && typeof cb === 'function') {
cb()
}
},
)
handleGenuineCheck = isGenuine => {
handleGenuineCheckPass = () => {
this.handleCloseGenuineCheckModal(() => {
this.props.updateGenuineCheck({
isDeviceGenuine: true,
genuineCheckUnavailable: null,
})
})
}
handleGenuineCheckFailed = () => {
this.handleCloseGenuineCheckModal(() => {
this.props.updateGenuineCheck({
isDeviceGenuine: isGenuine,
isGenuineFail: true,
isDeviceGenuine: false,
genuineCheckUnavailable: null,
})
})
}
handleGenuineCheckUnavailable = error => {
this.handleCloseGenuineCheckModal(() => {
this.props.updateGenuineCheck({
isDeviceGenuine: false,
genuineCheckUnavailable: error,
})
})
}
@ -128,7 +159,7 @@ class GenuineCheck extends PureComponent<StepProps, State> {
return (
<FixedTopContainer>
<Box grow alignItems="center">
<StepContainerInner>
<Title>{t('onboarding:genuineCheck.title')}</Title>
{onboarding.isLedgerNano ? (
<Description>{t('onboarding:genuineCheck.descNano')}</Description>
@ -193,6 +224,20 @@ class GenuineCheck extends PureComponent<StepProps, State> {
{t('onboarding:genuineCheck.isGenuinePassed')}
</GenuineSuccessText>
</Box>
) : genuine.genuineCheckUnavailable ? (
<Box horizontal align="center" flow={1} color={colors.alertRed}>
<IconCross size={16} />
<Box ff="Open Sans|Regular" fontSize={4} style={{ maxWidth: '200px' }}>
<TranslatedError error={genuine.genuineCheckUnavailable} />
<FakeLink
color="alertRed"
underline
onClick={this.handleOpenGenuineCheckModal}
>
{t('app:common.retry')}
</FakeLink>
</Box>
</Box>
) : (
<Button
primary
@ -206,7 +251,7 @@ class GenuineCheck extends PureComponent<StepProps, State> {
)}
</CardWrapper>
</Box>
</Box>
</StepContainerInner>
<OnboardingFooter
horizontal
align="center"
@ -219,7 +264,9 @@ class GenuineCheck extends PureComponent<StepProps, State> {
<GenuineCheckModal
isOpened={isGenuineCheckModalOpened}
onClose={this.handleCloseGenuineCheckModal}
onGenuineCheck={this.handleGenuineCheck}
onGenuineCheckPass={this.handleGenuineCheckPass}
onGenuineCheckFailed={this.handleGenuineCheckFailed}
onGenuineCheckUnavailable={this.handleGenuineCheckUnavailable}
/>
</FixedTopContainer>
)
@ -281,6 +328,7 @@ export const GenuineSuccessText = styled(Box).attrs({
ff: 'Open Sans|Regular',
fontSize: 4,
})``
export const CardTitle = styled(Box).attrs({
ff: 'Open Sans|SemiBold',
fontSize: 4,
@ -303,7 +351,6 @@ const CardWrapper = styled(Box).attrs({
background-color: ${p => (p.isDisabled ? p.theme.colors.lightGrey : p.theme.colors.white)};
opacity: ${p => (p.isDisabled ? 0.7 : 1)};
&:hover {
cursor: pointer;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.05);
}
`

6
src/components/Onboarding/steps/SelectDevice.js

@ -11,7 +11,7 @@ import Box from 'components/base/Box'
import IconCheckCirle from 'icons/Check'
import IconLedgerNano from 'icons/illustrations/LedgerNano'
import IconLedgerBlue from 'icons/illustrations/LedgerBlue'
import { Title, Inner, FixedTopContainer } from '../helperComponents'
import { Title, Inner, FixedTopContainer, StepContainerInner } from '../helperComponents'
import OnboardingFooter from '../OnboardingFooter'
import type { StepProps } from '..'
@ -35,7 +35,7 @@ class SelectDevice extends PureComponent<StepProps, {}> {
const { t, onboarding, jumpStep } = this.props
return (
<FixedTopContainer>
<Box grow alignItems="center">
<StepContainerInner>
<Box mb={5}>
<Title>{t('onboarding:selectDevice.title')}</Title>
</Box>
@ -63,7 +63,7 @@ class SelectDevice extends PureComponent<StepProps, {}> {
</DeviceContainer>
</Inner>
</Box>
</Box>
</StepContainerInner>
<OnboardingFooter
horizontal
t={t}

5
src/components/Onboarding/steps/SetPassword.js

@ -20,6 +20,7 @@ import {
DisclaimerBox,
FixedTopContainer,
OnboardingFooterWrapper,
StepContainerInner,
} from '../helperComponents'
type State = {
@ -96,7 +97,7 @@ class SetPassword extends PureComponent<StepProps, State> {
return (
<FixedTopContainer>
<Box grow alignItems="center">
<StepContainerInner>
<Fragment>
<Box alignItems="center">
<Title>{t('onboarding:setPassword.title')}</Title>
@ -120,7 +121,7 @@ class SetPassword extends PureComponent<StepProps, State> {
<DisclaimerBox mt={7} disclaimerNotes={disclaimerNotes} />
</Box>
</Fragment>
</Box>
</StepContainerInner>
<OnboardingFooterWrapper>
<Button padded outlineGrey onClick={() => prevStep()}>

16
src/components/OperationsList/AccountCell.js

@ -29,12 +29,22 @@ class AccountCell extends PureComponent<Props> {
<Box alignItems="center" justifyContent="center" style={{ color: currency.color }}>
{Icon && <Icon size={16} />}
</Box>
<Box ff="Open Sans|SemiBold" fontSize={3} color="dark">
{accountName}
</Box>
<AccountNameEllipsis>{accountName}</AccountNameEllipsis>
</Cell>
)
}
}
export default AccountCell
const AccountNameEllipsis = styled(Box).attrs({
ff: 'Open Sans|SemiBold',
fontSize: 3,
color: 'dark',
flexShrink: 1,
})`
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`

25
src/components/PillsDaysCount.js

@ -2,33 +2,28 @@
import React, { PureComponent } from 'react'
import { translate } from 'react-i18next'
import type { T } from 'types/common'
import Pills from 'components/base/Pills'
import { timeRangeDaysByKey } from 'reducers/settings'
import type { TimeRange } from 'reducers/settings'
type Props = {
selectedTime: string,
onChange: ({ key: string, value: *, label: string }) => void,
selected: string,
onChange: ({ key: string, value: *, label: string }) => *,
t: T,
}
const itemsTimes = [
{ key: 'week', value: 7 },
{ key: 'month', value: 30 },
{ key: 'year', value: 365 },
]
class PillsDaysCount extends PureComponent<Props> {
render() {
const { selectedTime, onChange, t } = this.props
const { selected, onChange, t } = this.props
return (
<Pills
items={itemsTimes.map(item => ({
...item,
label: t(`app:time.${item.key}`),
items={Object.keys(timeRangeDaysByKey).map((key: TimeRange) => ({
key,
value: timeRangeDaysByKey[key],
label: t(`app:time.${key}`),
}))}
activeKey={selectedTime}
activeKey={selected}
onChange={onChange}
/>
)

11
src/components/StickyBackToTop.js

@ -2,10 +2,13 @@
import React, { PureComponent } from 'react'
import ReactDOM from 'react-dom'
import styled from 'styled-components'
import smoothscroll from 'smoothscroll-polyfill'
import Box from 'components/base/Box'
import AngleUp from 'icons/AngleUp'
import { GrowScrollContext } from './base/GrowScroll'
smoothscroll.polyfill()
const Container = styled(Box)`
position: fixed;
z-index: 10;
@ -49,6 +52,7 @@ class StickyBackToTop extends PureComponent<Props, State> {
const { scrollContainer } = this.props.getGrowScroll()
if (scrollContainer) {
const listener = () => {
if (this._unmounted) return
const { scrollTop } = scrollContainer
const visible = scrollTop > this.props.scrollThreshold
this.setState(previous => {
@ -59,19 +63,22 @@ class StickyBackToTop extends PureComponent<Props, State> {
})
}
scrollContainer.addEventListener('scroll', listener)
this.releaseListener = () => scrollContainer.addEventListener('scroll', listener)
this.releaseListener = () => scrollContainer.removeEventListener('scroll', listener)
}
}
componentWillUnmount() {
this._unmounted = true
this.releaseListener()
}
_unmounted = false
onClick = () => {
const { scrollContainer } = this.props.getGrowScroll()
if (scrollContainer) {
// $FlowFixMe seems to be missing in flow
scrollContainer.scrollTo(0, 0)
scrollContainer.scrollTo({ top: 0, behavior: 'smooth' })
}
}

7
src/components/Workflow/EnsureGenuine.js

@ -61,13 +61,14 @@ class EnsureGenuine extends PureComponent<Props, State> {
const res = await getIsGenuine
.send({ devicePath: device.path, targetId: infos.targetId })
.toPromise()
if (this._unmounting) return
const isGenuine = res === '0000'
if ((!this.state.genuine || this.state.error) && isGenuine) {
!this._unmounting && this.setState({ genuine: isGenuine, error: null })
if (!this.state.genuine || this.state.error) {
this.setState({ genuine: isGenuine, error: null })
}
} catch (err) {
if (!isEqual(this.state.error, err)) {
!this._unmounting && this.setState({ genuine: false, error: err })
this.setState({ genuine: null, error: err })
}
}
this._checking = false

5
src/components/Workflow/index.js

@ -34,11 +34,11 @@ type Props = {
renderMcuUpdate?: (deviceInfo: DeviceInfo) => Node,
renderFinalUpdate?: (deviceInfo: DeviceInfo) => Node,
renderDashboard?: (device: Device, deviceInfo: DeviceInfo, isGenuine: boolean) => Node,
onGenuineCheck?: (isGenuine: boolean) => void,
renderError?: (dashboardError: ?Error, genuineError: ?Error) => Node,
}
type State = {}
// In future, move to meri's approach; this code is way too much specific
class Workflow extends PureComponent<Props, State> {
render() {
const {
@ -47,7 +47,6 @@ class Workflow extends PureComponent<Props, State> {
renderMcuUpdate,
renderError,
renderDefault,
onGenuineCheck,
} = this.props
return (
<EnsureDevice>
@ -74,8 +73,6 @@ class Workflow extends PureComponent<Props, State> {
}
if (isGenuine && deviceInfo && device && !dashboardError && !genuineError) {
if (onGenuineCheck) onGenuineCheck(isGenuine)
if (renderDashboard) return renderDashboard(device, deviceInfo, isGenuine)
}

4
src/components/base/Chart/refreshDraw.js

@ -24,8 +24,8 @@ const RENDER_TICK_X = {
default: 'MMM D',
}
function getRenderTickX(selectedTime) {
return t => moment(t).format(RENDER_TICK_X[selectedTime] || RENDER_TICK_X.default)
function getRenderTickX(selectedTimeRange) {
return t => moment(t).format(RENDER_TICK_X[selectedTimeRange] || RENDER_TICK_X.default)
}
export default function refreshDraw({ ctx, props }: { ctx: CTX, props: Props }) {

7
src/components/base/FakeLink.js

@ -5,14 +5,15 @@ import { darken, lighten } from 'styles/helpers'
export default styled(Box).attrs({
cursor: 'pointer',
color: 'wallet',
color: p => p.color || 'wallet',
})`
text-decoration: ${p => (p.underline ? 'underline' : 'none')};
&:hover {
text-decoration: underline;
color: ${p => lighten(p.theme.colors.wallet, 0.05)};
color: ${p => lighten(p.theme.colors[p.color] || p.color || p.theme.colors.wallet, 0.05)};
}
&:active {
color: ${p => darken(p.theme.colors.wallet, 0.1)};
color: ${p => darken(p.theme.colors[p.color] || p.color || p.theme.colors.wallet, 0.1)};
}
`

4
src/components/base/GrowScroll/index.js

@ -41,12 +41,12 @@ class GrowScroll extends PureComponent<Props> {
: {
display: 'flex',
flex: 1,
positoin: 'relative',
position: 'relative',
}),
}
const scrollContainerStyles = {
overflowY: 'auto',
overflowY: 'scroll',
marginRight: `-80px`,
paddingRight: `80px`,
...(maxHeight

11
src/components/layout/Default.js

@ -32,11 +32,15 @@ const Main = styled(GrowScroll).attrs({
type Props = {
location: Location,
i18n: {
reloadResources: Function,
},
}
class Default extends Component<Props> {
componentDidMount() {
window.requestAnimationFrame(() => (this._timeout = setTimeout(() => window.onAppReady(), 300)))
window.addEventListener('keydown', this.kbShortcut)
}
componentDidUpdate(prevProps) {
@ -54,6 +58,13 @@ class Default extends Component<Props> {
componentWillUnmount() {
clearTimeout(this._timeout)
window.removeEventListener('keydown', this.kbShortcut) // Prevents adding multiple listeners when hot reloading
}
kbShortcut = event => {
if (event.ctrlKey && event.key === 'l') {
this.props.i18n.reloadResources()
}
}
_timeout = undefined

90
src/components/modals/AccountSettingRenderBody.js

@ -14,6 +14,8 @@ import { MODAL_SETTINGS_ACCOUNT } from 'config/constants'
import { updateAccount, removeAccount } from 'actions/accounts'
import { setDataModal } from 'reducers/modals'
import { getBridgeForCurrency } from 'bridge'
import Spoiler from 'components/base/Spoiler'
import CryptoCurrencyIcon from 'components/CryptoCurrencyIcon'
import Box from 'components/base/Box'
@ -30,9 +32,11 @@ import {
} from 'components/base/Modal'
type State = {
accountName: string | null,
accountUnit: Unit | null,
accountName: ?string,
accountUnit: ?Unit,
endpointConfig: ?string,
accountNameError: boolean,
endpointConfigError: ?Error,
isRemoveAccountModalOpen: boolean,
}
@ -45,6 +49,8 @@ type Props = {
data: any,
}
const canConfigureEndpointConfig = account => account.currency.id === 'ripple'
const unitGetOptionValue = unit => unit.magnitude
const renderUnitItemCode = item => item.data.code
@ -57,7 +63,9 @@ const mapDispatchToProps = {
const defaultState = {
accountName: null,
accountUnit: null,
endpointConfig: null,
accountNameError: false,
endpointConfigError: null,
isRemoveAccountModalOpen: false,
}
@ -66,7 +74,12 @@ class HelperComp extends PureComponent<Props, State> {
...defaultState,
}
componentWillUnmount() {
this.handleChangeEndpointConfig_id++
}
getAccount(data: Object): Account {
// FIXME this should be a selector
const { accountName } = this.state
const account = get(data, 'account', {})
@ -80,6 +93,31 @@ class HelperComp extends PureComponent<Props, State> {
}
}
handleChangeEndpointConfig_id = 0
handleChangeEndpointConfig = async (endpointConfig: string) => {
const bridge = getBridgeForCurrency(this.getAccount(this.props.data).currency)
this.handleChangeEndpointConfig_id++
const { handleChangeEndpointConfig_id } = this
this.setState({
endpointConfig,
endpointConfigError: null,
})
try {
if (bridge.validateEndpointConfig) {
await bridge.validateEndpointConfig(endpointConfig)
}
if (handleChangeEndpointConfig_id === this.handleChangeEndpointConfig_id) {
this.setState({
endpointConfigError: null,
})
}
} catch (endpointConfigError) {
if (handleChangeEndpointConfig_id === this.handleChangeEndpointConfig_id) {
this.setState({ endpointConfigError })
}
}
}
handleChangeName = (value: string) =>
this.setState({
accountName: value,
@ -91,10 +129,18 @@ class HelperComp extends PureComponent<Props, State> {
e.preventDefault()
const { updateAccount, setDataModal } = this.props
const { accountName, accountUnit } = this.state
const { accountName, accountUnit, endpointConfig, endpointConfigError } = this.state
const sanitizedAccountName = accountName ? accountName.replace(/\s+/g, ' ').trim() : null
if (accountName !== '') {
account = { ...account, unit: accountUnit || account.unit }
if (account.name || sanitizedAccountName) {
account = {
...account,
unit: accountUnit || account.unit,
name: sanitizedAccountName || account.name,
}
if (endpointConfig && !endpointConfigError) {
account.endpointConfig = endpointConfig
}
updateAccount(account)
setDataModal(MODAL_SETTINGS_ACCOUNT, { account })
onClose()
@ -118,7 +164,9 @@ class HelperComp extends PureComponent<Props, State> {
handleChangeUnit = (value: Unit) => {
this.setState({ accountUnit: value })
}
handleOpenRemoveAccountModal = () => this.setState({ isRemoveAccountModalOpen: true })
handleCloseRemoveAccountModal = () => this.setState({ isRemoveAccountModalOpen: false })
handleRemoveAccount = (account: Account) => {
@ -129,10 +177,17 @@ class HelperComp extends PureComponent<Props, State> {
}
render() {
const { accountUnit, accountNameError, isRemoveAccountModalOpen } = this.state
const {
accountUnit,
endpointConfig,
accountNameError,
isRemoveAccountModalOpen,
endpointConfigError,
} = this.state
const { t, onClose, data } = this.props
const account = this.getAccount(data)
const bridge = getBridgeForCurrency(account.currency)
const usefulData = {
xpub: account.xpub || undefined,
@ -179,6 +234,29 @@ class HelperComp extends PureComponent<Props, State> {
/>
</Box>
</Container>
{canConfigureEndpointConfig(account) ? (
<Container>
<Box>
<OptionRowTitle>{t('app:account.settings.endpointConfig.title')}</OptionRowTitle>
<OptionRowDesc>{t('app:account.settings.endpointConfig.desc')}</OptionRowDesc>
</Box>
<Box>
<Input
value={
endpointConfig ||
account.endpointConfig ||
(bridge.getDefaultEndpointConfig && bridge.getDefaultEndpointConfig()) ||
''
}
onChange={this.handleChangeEndpointConfig}
onFocus={e => this.handleFocus(e, 'endpointConfig')}
error={
endpointConfigError ? t('app:account.settings.endpointConfig.error') : false
}
/>
</Box>
</Container>
) : null}
<Spoiler title={t('app:account.settings.advancedLogs')}>
<textarea
readOnly

45
src/config/constants.js

@ -1,10 +1,18 @@
// @flow
const intFromEnv = (key: string, def: number) => {
const intFromEnv = (key: string, def: number): number => {
const v = process.env[key]
if (!isNaN(v)) return parseInt(v, 10)
return def
}
const boolFromEnv = (key: string): boolean => {
const v = process.env[key]
return (v && v !== '0' && v !== 'false') || false
}
const stringFromEnv = (key: string, def: string): string => process.env[key] || def
// time and delays...
export const GET_CALLS_TIMEOUT = intFromEnv('GET_CALLS_TIMEOUT', 30 * 1000)
export const GET_CALLS_RETRY = intFromEnv('GET_CALLS_RETRY', 2)
@ -19,6 +27,41 @@ export const CHECK_UPDATE_DELAY = 5e3
export const DEVICE_DISCONNECT_DEBOUNCE = intFromEnv('LEDGER_DEVICE_DISCONNECT_DEBOUNCE', 500)
// Endpoints...
export const LEDGER_REST_API_BASE = stringFromEnv(
'LEDGER_REST_API_BASE',
'https://api.ledgerwallet.com/',
)
export const MANAGER_API_BASE = stringFromEnv(
'MANAGER_API_BASE',
'https://beta.manager.live.ledger.fr/api',
)
export const BASE_SOCKET_URL = stringFromEnv('BASE_SOCKET_URL', 'ws://api.ledgerwallet.com/update')
export const BASE_SOCKET_URL_SECURE = stringFromEnv(
'BASE_SOCKET_URL',
'wss://api.ledgerwallet.com/update',
)
// Flags
export const DEBUG_DEVICE = boolFromEnv('DEBUG_DEVICE')
export const DEBUG_NETWORK = boolFromEnv('DEBUG_NETWORK')
export const DEBUG_COMMANDS = boolFromEnv('DEBUG_COMMANDS')
export const DEBUG_DB = boolFromEnv('DEBUG_DB')
export const DEBUG_ACTION = boolFromEnv('DEBUG_ACTION')
export const DEBUG_TAB_KEY = boolFromEnv('DEBUG_TAB_KEY')
export const DEBUG_LIBCORE = boolFromEnv('DEBUG_LIBCORE')
export const DEBUG_WS = boolFromEnv('DEBUG_WS')
export const LEDGER_RESET_ALL = boolFromEnv('LEDGER_RESET_ALL')
export const LEDGER_DEBUG_ALL_LANGS = boolFromEnv('LEDGER_DEBUG_ALL_LANGS')
export const SKIP_GENUINE = boolFromEnv('SKIP_GENUINE')
export const SKIP_ONBOARDING = boolFromEnv('SKIP_ONBOARDING')
export const SHOW_LEGACY_NEW_ACCOUNT = boolFromEnv('SHOW_LEGACY_NEW_ACCOUNT')
export const HIGHLIGHT_I18N = boolFromEnv('HIGHLIGHT_I18N')
// Other constants
export const MODAL_ADD_ACCOUNTS = 'MODAL_ADD_ACCOUNTS'
export const MODAL_OPERATION_DETAILS = 'MODAL_OPERATION_DETAILS'
export const MODAL_RECEIVE = 'MODAL_RECEIVE'

3
src/config/languages.js

@ -1,6 +1,7 @@
// @flow
import { LEDGER_DEBUG_ALL_LANGS } from 'config/constants'
const allLanguages = ['en', 'fr']
const prodStableLanguages = ['en']
const languages = process.env.LEDGER_DEBUG_ALL_LANGS ? allLanguages : prodStableLanguages
const languages = LEDGER_DEBUG_ALL_LANGS ? allLanguages : prodStableLanguages
export default languages

2
src/helpers/apps/installApp.js

@ -11,6 +11,6 @@ import type { LedgerScriptParams } from 'helpers/common'
export default async function installApp(
transport: Transport<*>,
{ appParams }: { appParams: LedgerScriptParams },
): Promise<void> {
): Promise<*> {
return createSocketDialog(transport, '/install', appParams)
}

4
src/helpers/apps/listApps.js

@ -1,12 +1,12 @@
// @flow
import axios from 'axios'
import { API_BASE_URL } from 'helpers/constants'
import { MANAGER_API_BASE } from 'config/constants'
export default async (targetId: string | number) => {
try {
const { data: deviceData } = await axios.get(
`${API_BASE_URL}/device_versions_target_id/${targetId}`,
`${MANAGER_API_BASE}/device_versions_target_id/${targetId}`,
)
const { data } = await axios.get('https://api.ledgerwallet.com/update/applications')

2
src/helpers/apps/uninstallApp.js

@ -11,7 +11,7 @@ import type { LedgerScriptParams } from 'helpers/common'
export default async function uninstallApp(
transport: Transport<*>,
{ appParams }: { appParams: LedgerScriptParams },
): Promise<void> {
): Promise<*> {
const params = {
...appParams,
firmware: appParams.delete,

164
src/helpers/common.js

@ -1,22 +1,17 @@
// @flow
import chalk from 'chalk'
import Websocket from 'ws'
// FIXME remove this file! 'helpers/common.js' RLY? :P
import qs from 'qs'
import type Transport from '@ledgerhq/hw-transport'
import { BASE_SOCKET_URL, BASE_SOCKET_URL_SECURE } from 'config/constants'
import { createDeviceSocket } from './socket'
import { BASE_SOCKET_URL, APDUS, MANAGER_API_URL } from './constants'
type WebsocketType = {
send: (string, any) => void,
on: (string, Function) => void,
}
type Message = {
nonce: number,
query?: string,
response?: string,
data: any,
const APDUS = {
GET_FIRMWARE: [0xe0, 0x01, 0x00, 0x00],
// we dont have common call that works inside app & dashboard
// TODO: this should disappear.
GET_FIRMWARE_FALLBACK: [0xe0, 0xc4, 0x00, 0x00],
}
export type LedgerScriptParams = {
@ -35,59 +30,6 @@ export async function getMemInfos(transport: Transport<*>): Promise<Object> {
return createSocketDialog(transport, '/get-mem-infos', { targetId, perso: 'perso_11' })
}
/**
* 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
*/
export 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
*/
export 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
@ -97,56 +39,12 @@ export async function createSocketDialog(
endpoint: string,
params: LedgerScriptParams,
managerUrl: boolean = false,
) {
return new Promise(async (resolve, reject) => {
try {
let lastData
const url = `${managerUrl ? MANAGER_API_URL : BASE_SOCKET_URL}${endpoint}?${qs.stringify(
): Promise<string> {
console.warn('DEPRECATED createSocketDialog: use createDeviceSocket') // eslint-disable-line
const url = `${managerUrl ? BASE_SOCKET_URL_SECURE : BASE_SOCKET_URL}${endpoint}?${qs.stringify(
params,
)}`
log('WS CONNECTING', url)
const ws: WebsocketType = new Websocket(url)
ws.on('open', () => log('WS CONNECTED'))
ws.on('close', () => {
log('WS CLOSED')
resolve(lastData)
})
ws.on('message', async rawMsg => {
const handlers = {
exchange: msg => exchange(ws, transport, msg),
bulk: msg => bulk(ws, transport, msg),
success: msg => {
if (msg.data) {
lastData = msg.data
} else if (msg.result) {
lastData = msg.result
}
},
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)
}
})
return createDeviceSocket(transport, url).toPromise()
}
/**
@ -169,42 +67,6 @@ export async function getFirmwareInfo(transport: Transport<*>) {
}
}
/**
* Debug helper
*/
export function log(namespace: string, str: string = '', color?: string) {
namespace = namespace.padEnd(15)
// $FlowFixMe
const coloredNamespace = color ? chalk[color](namespace) : namespace
if (__DEV__) {
console.log(`${chalk.bold(`> ${coloredNamespace}`)} ${str}`) // eslint-disable-line no-console
}
}
/**
* Log a socket send/receive
*/
export 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)
}
}
/**
* Helpers to build OSU and Final firmware params
*/

13
src/helpers/constants.js

@ -1,13 +0,0 @@
// Socket endpoint
export const BASE_SOCKET_URL = 'ws://api.ledgerwallet.com/update'
export const MANAGER_API_URL = 'wss://api.ledgerwallet.com/update'
export const API_BASE_URL = process.env.API_BASE_URL || 'https://beta.manager.live.ledger.fr/api'
// List of APDUS
export const APDUS = {
GET_FIRMWARE: [0xe0, 0x01, 0x00, 0x00],
// we dont have common call that works inside app & dashboard
// TODO: this should disappear.
GET_FIRMWARE_FALLBACK: [0xe0, 0xc4, 0x00, 0x00],
}

3
src/helpers/deviceAccess.js

@ -2,6 +2,7 @@
import createSemaphore from 'semaphore'
import type Transport from '@ledgerhq/hw-transport'
import TransportNodeHid from '@ledgerhq/hw-transport-node-hid'
import { DEBUG_DEVICE } from 'config/constants'
import { retry } from './promise'
// all open to device must use openDevice so we can prevent race conditions
@ -20,7 +21,7 @@ export const withDevice: WithDevice = devicePath => {
takeSemaphorePromise(sem, async () => {
const t = await retry(() => TransportNodeHid.open(devicePath), { maxRetry: 1 })
if (process.env.DEBUG_DEVICE > 0) t.setDebugMode(true)
if (DEBUG_DEVICE) t.setDebugMode(true)
try {
const res = await job(t)
// $FlowFixMe

9
src/helpers/devices/getFirmwareInfo.js

@ -2,7 +2,7 @@
import axios from 'axios'
import isEmpty from 'lodash/isEmpty'
import { API_BASE_URL } from 'helpers/constants'
import { MANAGER_API_BASE } from 'config/constants'
type Input = {
version: string,
@ -12,10 +12,13 @@ type Input = {
let error
export default async (data: Input) => {
try {
const { data: seFirmwareVersion } = await axios.post(`${API_BASE_URL}/firmware_versions_name`, {
const { data: seFirmwareVersion } = await axios.post(
`${MANAGER_API_BASE}/firmware_versions_name`,
{
se_firmware_name: data.version,
target_id: data.targetId,
})
},
)
if (!isEmpty(seFirmwareVersion)) {
return seFirmwareVersion

5
src/helpers/devices/getIsGenuine.js

@ -1,11 +1,12 @@
// @flow
import type Transport from '@ledgerhq/hw-transport'
import { createSocketDialog } from 'helpers/common'
import { SKIP_GENUINE } from 'config/constants'
export default async (
transport: Transport<*>,
{ targetId }: { targetId: string | number },
): Promise<*> =>
process.env.SKIP_GENUINE > 0
): Promise<string> =>
SKIP_GENUINE
? new Promise(resolve => setTimeout(() => resolve('0000'), 1000))
: createSocketDialog(transport, '/genuine', { targetId }, true)

6
src/helpers/devices/getLatestFirmwareForDevice.js

@ -1,7 +1,7 @@
// @flow
import axios from 'axios'
import isEmpty from 'lodash/isEmpty'
import { API_BASE_URL } from 'helpers/constants'
import { MANAGER_API_BASE } from 'config/constants'
import getFirmwareInfo from './getFirmwareInfo'
@ -17,11 +17,11 @@ export default async (data: Input) => {
// Get device infos from targetId
const { data: deviceVersion } = await axios.get(
`${API_BASE_URL}/device_versions_target_id/${data.targetId}`,
`${MANAGER_API_BASE}/device_versions_target_id/${data.targetId}`,
)
// Fetch next possible firmware
const { data: serverData } = await axios.post(`${API_BASE_URL}/get_latest_firmware`, {
const { data: serverData } = await axios.post(`${MANAGER_API_BASE}/get_latest_firmware`, {
current_se_firmware_version: seFirmwareVersion.id,
device_version: deviceVersion.id,
providers: [1],

3
src/helpers/libcore.js

@ -4,6 +4,7 @@ import logger from 'logger'
import Btc from '@ledgerhq/hw-app-btc'
import { withDevice } from 'helpers/deviceAccess'
import { getCryptoCurrencyById } from '@ledgerhq/live-common/lib/helpers/currencies'
import { SHOW_LEGACY_NEW_ACCOUNT } from 'config/constants'
import type { AccountRaw, OperationRaw, OperationType } from '@ledgerhq/live-common/lib/types'
import type { NJSAccount, NJSOperation } from '@ledgerhq/ledger-core/src/ledgercore_doc'
@ -28,8 +29,6 @@ type Props = {
onAccountScanned: AccountRaw => void,
}
const { SHOW_LEGACY_NEW_ACCOUNT } = process.env
export function scanAccountsOnDevice(props: Props): Promise<AccountRaw[]> {
const { devicePath, currencyId, onAccountScanned, core } = props
const currency = getCryptoCurrencyById(currencyId)

128
src/helpers/socket.js

@ -0,0 +1,128 @@
// @flow
import invariant from 'invariant'
import logger from 'logger'
import Websocket from 'ws'
import type Transport from '@ledgerhq/hw-transport'
import { Observable } from 'rxjs'
import createCustomErrorClass from './createCustomErrorClass'
const WebsocketConnectionError = createCustomErrorClass('WebsocketConnectionError')
const WebsocketConnectionFailed = createCustomErrorClass('WebsocketConnectionFailed')
const DeviceSocketFail = createCustomErrorClass('DeviceSocketFail')
const DeviceSocketNoBulkStatus = createCustomErrorClass('DeviceSocketNoBulkStatus')
const DeviceSocketNoHandler = createCustomErrorClass('DeviceSocketNoHandler')
/**
* use Ledger WebSocket API to exchange data with the device
* Returns an Observable of the final result
*/
export const createDeviceSocket = (transport: Transport<*>, url: string) =>
Observable.create(o => {
let ws
let lastMessage: ?string
try {
ws = new Websocket(url)
} catch (err) {
o.error(new WebsocketConnectionFailed(err.message))
return () => {}
}
invariant(ws, 'websocket is available')
ws.on('open', () => {
logger.websocket('OPENED', url)
})
ws.on('error', e => {
logger.websocket('ERROR', e)
o.error(new WebsocketConnectionError(e.message))
})
ws.on('close', () => {
logger.websocket('CLOSE')
o.next(lastMessage || '')
o.complete()
})
const send = (nonce, response, data) => {
const msg = {
nonce,
response,
data,
}
logger.websocket('SEND', msg)
const strMsg = JSON.stringify(msg)
ws.send(strMsg)
}
const handlers = {
exchange: async input => {
const { data, nonce } = input
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')
send(nonce, strStatus === '9000' ? 'success' : 'error', buffer.toString('hex'))
},
bulk: async input => {
const { data, nonce } = input
// 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 DeviceSocketNoBulkStatus()
}
const strStatus = lastStatus.toString('hex')
send(
nonce,
strStatus === '9000' ? 'success' : 'error',
strStatus === '9000' ? '' : strStatus,
)
},
success: msg => {
lastMessage = msg.data || msg.result
ws.close()
},
error: msg => {
logger.websocket('ERROR', msg.data)
throw new DeviceSocketFail(msg.data)
},
}
const stackMessage = async rawMsg => {
try {
const msg = JSON.parse(rawMsg)
if (!(msg.query in handlers)) {
throw new DeviceSocketNoHandler(`Cannot handle msg of type ${msg.query}`, {
query: msg.query,
})
}
logger.websocket('RECEIVE', msg)
await handlers[msg.query](msg)
} catch (err) {
logger.websocket('ERROR', err.toString())
o.error(err)
}
}
ws.on('message', async rawMsg => {
stackMessage(rawMsg)
})
return () => {
if (ws.readyState === 1) {
lastMessage = null
ws.close()
}
}
})

3
src/internals/index.js

@ -4,6 +4,7 @@ import logger from 'logger'
import uuid from 'uuid/v4'
import { setImplementation } from 'api/network'
import sentry from 'sentry/node'
import { DEBUG_NETWORK } from 'config/constants'
require('../env')
@ -15,7 +16,7 @@ let sentryEnabled = process.env.INITIAL_SENTRY_ENABLED || false
sentry(() => sentryEnabled, process.env.SENTRY_USER_ID)
if (process.env.DEBUG_NETWORK) {
if (DEBUG_NETWORK) {
setImplementation(networkArg => {
const id = uuid()
return new Promise((resolve, reject) => {

27
src/logger.js

@ -9,6 +9,15 @@
* - for analytics in the future
*/
import {
DEBUG_COMMANDS,
DEBUG_DB,
DEBUG_ACTION,
DEBUG_TAB_KEY,
DEBUG_LIBCORE,
DEBUG_WS,
} from 'config/constants'
const logs = []
const MAX_LOG_LENGTH = 500
@ -47,11 +56,12 @@ const makeSerializableLog = (o: mixed) => {
return String(o)
}
const logCmds = !__DEV__ || process.env.DEBUG_COMMANDS
const logDb = !__DEV__ || process.env.DEBUG_DB
const logRedux = !__DEV__ || process.env.DEBUG_ACTION
const logTabkey = !__DEV__ || process.env.DEBUG_TAB_KEY
const logLibcore = !__DEV__ || process.env.DEBUG_LIBCORE
const logCmds = !__DEV__ || DEBUG_COMMANDS
const logDb = !__DEV__ || DEBUG_DB
const logRedux = !__DEV__ || DEBUG_ACTION
const logTabkey = !__DEV__ || DEBUG_TAB_KEY
const logLibcore = !__DEV__ || DEBUG_LIBCORE
const logWS = !__DEV__ || DEBUG_WS
export default {
onCmd: (type: string, id: string, spentTime: number, data?: any) => {
@ -104,6 +114,13 @@ export default {
addLog('keydown', msg)
},
websocket: (type: string, msg: *) => {
if (logWS) {
console.log(`~ ${type}:`, msg)
}
addLog('ws', `~ ${type}`, msg)
},
libcore: (level: string, msg: string) => {
if (logLibcore) {
console.log(`🛠 ${level}: ${msg}`)

5
src/reducers/onboarding.js

@ -1,5 +1,6 @@
// @flow
import { SKIP_ONBOARDING } from 'config/constants'
import { handleActions, createAction } from 'redux-actions'
type Step = {
@ -22,6 +23,7 @@ export type OnboardingState = {
recoveryStepPass: boolean,
isGenuineFail: boolean,
isDeviceGenuine: boolean,
genuineCheckUnavailable: ?Error,
},
isLedgerNano: boolean | null,
flowType: string,
@ -29,12 +31,13 @@ export type OnboardingState = {
const state: OnboardingState = {
stepIndex: 0,
stepName: process.env.SKIP_ONBOARDING ? 'finish' : 'start',
stepName: SKIP_ONBOARDING ? 'finish' : 'start',
genuine: {
pinStepPass: false,
recoveryStepPass: false,
isGenuineFail: false,
isDeviceGenuine: false,
genuineCheckUnavailable: null,
},
isLedgerNano: null,
flowType: '',

11
src/reducers/settings.js

@ -18,6 +18,14 @@ import type { State } from 'reducers'
export const intermediaryCurrency = getCryptoCurrencyById('bitcoin')
export const timeRangeDaysByKey = {
week: 7,
month: 30,
year: 365,
}
export type TimeRange = $Keys<typeof timeRangeDaysByKey>
export type SettingsState = {
loaded: boolean, // is the settings loaded from db (it not we don't save them)
hasCompletedOnboarding: boolean,
@ -29,6 +37,7 @@ export type SettingsState = {
isEnabled: boolean,
value: string,
},
selectedTimeRange: TimeRange,
marketIndicator: 'eastern' | 'western',
currenciesSettings: {
[currencyId: string]: CurrencySettings,
@ -67,6 +76,7 @@ const INITIAL_STATE: SettingsState = {
isEnabled: false,
value: '',
},
selectedTimeRange: 'month',
marketIndicator: 'western',
currenciesSettings: {},
region,
@ -207,5 +217,6 @@ export const exchangeSettingsForAccountSelector: ESFAS = createSelector(
export const marketIndicatorSelector = (state: State) => state.settings.marketIndicator
export const sentryLogsBooleanSelector = (state: State) => state.settings.sentryLogs
export const selectedTimeRangeSelector = (state: State) => state.settings.selectedTimeRange
export default handleActions(handlers, INITIAL_STATE)

3
src/renderer/i18n/instanciate.js

@ -1,3 +1,4 @@
import { HIGHLIGHT_I18N } from 'config/constants'
import i18n from 'i18next'
const commonConfig = {
@ -29,7 +30,7 @@ export function createWithBackend(backend, backendOpts) {
...backendOpts,
}
if (process.env.HIGHLIGHT_I18N) {
if (HIGHLIGHT_I18N) {
config.postProcess = 'highlight'
}

3
src/renderer/init.js

@ -11,6 +11,7 @@ import moment from 'moment'
import createStore from 'renderer/createStore'
import events from 'renderer/events'
import { LEDGER_RESET_ALL } from 'config/constants'
import { enableGlobalTab, disableGlobalTab, isGlobalTabEnabled } from 'config/global-tab'
import { fetchAccounts } from 'actions/accounts'
@ -34,7 +35,7 @@ const rootNode = document.getElementById('app')
const TAB_KEY = 9
async function init() {
if (process.env.LEDGER_RESET_ALL) {
if (LEDGER_RESET_ALL) {
await hardReset()
}

BIN
static/docs/architecture.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

BIN
static/docs/ledgerLogo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

4
static/i18n/en/app.yml

@ -78,6 +78,10 @@ account:
unit:
title: Unit
desc: Lorem ipsum dolort amet
endpointConfig:
title: Node
desc: The API node to use
error: Invalid endpoint
dashboard:
title: Dashboard
accounts:

5
static/i18n/en/errors.yml

@ -12,3 +12,8 @@ LedgerAPIError: 'A problem occurred with Ledger API. Please try again later. (HT
NetworkDown: 'Your internet connection seems down. Please try again later.'
NoAddressesFound: 'No accounts found'
UserRefusedOnDevice: Transaction have been aborted
WebsocketConnectionError: An error occurred with the socket connection
WebsocketConnectionFailed: Failed to establish a socket connection
DeviceSocketFail: Device socket failure
DeviceSocketNoBulkStatus: Device socket failure (bulk)
DeviceSocketNoHandler: Device socket failure (handler {{query}})

10
yarn.lock

@ -1515,9 +1515,9 @@
npm "^5.7.1"
prebuild-install "^2.2.2"
"@ledgerhq/live-common@2.30.0":
version "2.30.0"
resolved "https://registry.yarnpkg.com/@ledgerhq/live-common/-/live-common-2.30.0.tgz#c46fbb1fef3347b6ae9a693bfc4f792c20c9ee9b"
"@ledgerhq/live-common@2.31.0":
version "2.31.0"
resolved "https://registry.yarnpkg.com/@ledgerhq/live-common/-/live-common-2.31.0.tgz#0f599a1e23b64d9ed74a845d3a9c82f0696f1df3"
dependencies:
axios "^0.18.0"
invariant "^2.2.2"
@ -12808,6 +12808,10 @@ smart-buffer@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.0.1.tgz#07ea1ca8d4db24eb4cac86537d7d18995221ace3"
smoothscroll-polyfill@^0.4.3:
version "0.4.3"
resolved "https://registry.yarnpkg.com/smoothscroll-polyfill/-/smoothscroll-polyfill-0.4.3.tgz#94e5f2d604efcceb53f23ff0380d7ea7280d4bff"
snapdragon-node@^2.0.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"

Loading…
Cancel
Save