Browse Source

Merge pull request #547 from mrfelton/feat/keep-lnd-running-on-window-close

feat(ux): keep app running when main window closed
renovate/lint-staged-8.x
JimmyMow 7 years ago
committed by GitHub
parent
commit
eac4749706
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 16
      app/lnd/index.js
  2. 140
      app/lnd/lib/lightning.js
  3. 8
      app/lnd/subscribe/channelgraph.js
  4. 9
      app/lnd/subscribe/index.js
  5. 13
      app/lnd/subscribe/invoices.js
  6. 7
      app/lnd/subscribe/transactions.js
  7. 2
      app/lnd/walletUnlockerMethods/index.js
  8. 44
      app/main.dev.js
  9. 4
      app/reducers/channels.js
  10. 4
      app/reducers/ipc.js
  11. 2
      app/reducers/onboarding.js
  12. 2
      app/reducers/requestform.js
  13. 294
      app/zap.js
  14. 1
      package.json
  15. 4
      yarn.lock

16
app/lnd/index.js

@ -1,24 +1,9 @@
import config from './config'
import lightning from './lib/lightning'
import walletUnlocker from './lib/walletUnlocker'
import subscribe from './subscribe'
import methods from './methods'
import walletUnlockerMethods from './walletUnlockerMethods'
// use mainLog because lndLog is reserved for the lnd binary itself
import { mainLog } from '../utils/log'
const initLnd = async () => {
const lnd = await lightning()
const lndSubscribe = mainWindow => subscribe(mainWindow, lnd, mainLog)
const lndMethods = (event, msg, data) => methods(lnd, mainLog, event, msg, data)
return Promise.resolve({
lndSubscribe,
lndMethods
})
}
const initWalletUnlocker = () => {
const lndConfig = config.lnd()
const walletUnlockerObj = walletUnlocker(lndConfig.rpcProtoPath, lndConfig.host)
@ -29,6 +14,5 @@ const initWalletUnlocker = () => {
}
export default {
initLnd,
initWalletUnlocker
}

140
app/lnd/lib/lightning.js

@ -2,52 +2,112 @@ import grpc from 'grpc'
import { loadSync } from '@grpc/proto-loader'
import config from '../config'
import { getDeadline, validateHost, createSslCreds, createMacaroonCreds } from './util'
import methods from '../methods'
import { mainLog } from '../../utils/log'
import subscribeToTransactions from '../subscribe/transactions'
import subscribeToInvoices from '../subscribe/invoices'
import subscribeToChannelGraph from '../subscribe/channelgraph'
/**
* Creates an LND grpc client lightning service.
* @returns {rpc.lnrpc.Lightning}
* @returns {Lightning}
*/
const lightning = async () => {
const lndConfig = config.lnd()
const { host, rpcProtoPath, cert, macaroon } = lndConfig
// Verify that the host is valid before creating a gRPC client that is connected to it.
return await validateHost(host).then(async () => {
// Load the gRPC proto file.
// The following options object closely approximates the existing behavior of grpc.load.
// See https://github.com/grpc/grpc-node/blob/master/packages/grpc-protobufjs/README.md
const options = {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true
class Lightning {
constructor() {
this.mainWindow = null
this.lnd = null
this.subscriptions = {
channelGraph: null,
invoices: null,
transactions: null
}
const packageDefinition = loadSync(rpcProtoPath, options)
// Load gRPC package definition as a gRPC object hierarchy.
const rpc = grpc.loadPackageDefinition(packageDefinition)
// Create ssl and macaroon credentials to use with the gRPC client.
const [sslCreds, macaroonCreds] = await Promise.all([
createSslCreds(cert),
createMacaroonCreds(macaroon)
])
const credentials = grpc.credentials.combineChannelCredentials(sslCreds, macaroonCreds)
// Instantiate a new connection to the Lightning interface.
const lnd = new rpc.lnrpc.Lightning(host, credentials)
// Call the getInfo method to ensure that we can make successful calls to the gRPC interface.
return new Promise((resolve, reject) => {
lnd.getInfo({}, { deadline: getDeadline(2) }, err => {
if (err) {
return reject(err)
}
return resolve(lnd)
}
/**
* Connect to the gRPC interface and verify it is functional.
* @return {Promise<rpc.lnrpc.Lightning>}
*/
async connect() {
const lndConfig = config.lnd()
const { host, rpcProtoPath, cert, macaroon } = lndConfig
// Verify that the host is valid before creating a gRPC client that is connected to it.
return await validateHost(host).then(async () => {
// Load the gRPC proto file.
// The following options object closely approximates the existing behavior of grpc.load.
// See https://github.com/grpc/grpc-node/blob/master/packages/grpc-protobufjs/README.md
const options = {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true
}
const packageDefinition = loadSync(rpcProtoPath, options)
// Load gRPC package definition as a gRPC object hierarchy.
const rpc = grpc.loadPackageDefinition(packageDefinition)
// Create ssl and macaroon credentials to use with the gRPC client.
const [sslCreds, macaroonCreds] = await Promise.all([
createSslCreds(cert),
createMacaroonCreds(macaroon)
])
const credentials = grpc.credentials.combineChannelCredentials(sslCreds, macaroonCreds)
// Create a new gRPC client instance.
const lnd = new rpc.lnrpc.Lightning(host, credentials)
// Call the getInfo method to ensure that we can make successful calls to the gRPC interface.
return new Promise((resolve, reject) => {
lnd.getInfo({}, { deadline: getDeadline(2) }, err => {
if (err) {
return reject(err)
}
this.lnd = lnd
return resolve(lnd)
})
})
})
})
}
/**
* Discomnnect the gRPC service.
*/
disconnect() {
this.unsubscribe()
this.lnd.close()
}
/**
* Hook up lnd restful methods.
*/
lndMethods(event, msg, data) {
return methods(this.lnd, mainLog, event, msg, data)
}
/**
* Subscribe to all bi-directional streams.
*/
subscribe(mainWindow) {
this.mainWindow = mainWindow
this.subscriptions.channelGraph = subscribeToChannelGraph(this.mainWindow, this.lnd, mainLog)
this.subscriptions.invoices = subscribeToInvoices(this.mainWindow, this.lnd, mainLog)
this.subscriptions.transactions = subscribeToTransactions(this.mainWindow, this.lnd, mainLog)
}
/**
* Unsubscribe from all bi-directional streams.
*/
unsubscribe() {
Object.keys(this.subscriptions).forEach(subscription => {
if (this.subscriptions[subscription]) {
this.subscriptions[subscription].cancel()
this.subscriptions[subscription] = null
}
})
this.mainWindow = null
}
}
export default lightning
export default Lightning

8
app/lnd/subscribe/channelgraph.js

@ -1,8 +1,14 @@
export default function subscribeToChannelGraph(mainWindow, lnd) {
import { status } from 'grpc'
export default function subscribeToChannelGraph(mainWindow, lnd, log) {
const call = lnd.subscribeChannelGraph({})
call.on('data', channelGraphData => mainWindow.send('channelGraphData', { channelGraphData }))
call.on('end', () => log.info('end'))
call.on('error', error => error.code !== status.CANCELLED && log.error(error))
call.on('status', channelGraphStatus =>
mainWindow.send('channelGraphStatus', { channelGraphStatus })
)
return call
}

9
app/lnd/subscribe/index.js

@ -1,9 +0,0 @@
import subscribeToTransactions from './transactions'
import subscribeToInvoices from './invoices'
import subscribeToChannelGraph from './channelgraph'
export default (mainWindow, lnd, log) => {
subscribeToTransactions(mainWindow, lnd, log)
subscribeToInvoices(mainWindow, lnd, log)
subscribeToChannelGraph(mainWindow, lnd)
}

13
app/lnd/subscribe/invoices.js

@ -1,8 +1,15 @@
import { status } from 'grpc'
export default function subscribeToInvoices(mainWindow, lnd, log) {
const call = lnd.subscribeInvoices({})
call.on('data', invoice => mainWindow.send('invoiceUpdate', { invoice }))
call.on('data', invoice => {
log.info('INVOICE:', invoice)
mainWindow.send('invoiceUpdate', { invoice })
})
call.on('end', () => log.info('end'))
call.on('error', error => log.error(error))
call.on('status', status => log.info('status:', status))
call.on('error', error => error.code !== status.CANCELLED && log.error(error))
call.on('status', status => log.info('INVOICE STATUS:', status))
return call
}

7
app/lnd/subscribe/transactions.js

@ -1,10 +1,15 @@
import { status } from 'grpc'
export default function subscribeToTransactions(mainWindow, lnd, log) {
const call = lnd.subscribeTransactions({})
call.on('data', transaction => {
log.info('TRANSACTION:', transaction)
mainWindow.send('newTransaction', { transaction })
})
call.on('end', () => log.info('end'))
call.on('error', error => log.error('error: ', error))
call.on('error', error => error.code !== status.CANCELLED && log.error(error))
call.on('status', status => log.info('TRANSACTION STATUS: ', status))
return call
}

2
app/lnd/walletUnlockerMethods/index.js

@ -33,7 +33,7 @@ export default function(walletUnlocker, log, event, msg, data) {
case 'initWallet':
walletController
.initWallet(walletUnlocker, data)
.then(() => event.sender.send('successfullyCreatedWallet'))
.then(() => event.sender.send('finishOnboarding'))
.catch(error => log.error('initWallet:', error))
break
default:

44
app/main.dev.js

@ -14,7 +14,6 @@ import ZapUpdater from './updater'
// Set up a couple of timers to track the app startup progress.
mainLog.time('Time until app is ready')
mainLog.time('Time until lnd process lookup finished')
/**
* Initialize Zap as soon as electron is ready.
@ -22,11 +21,7 @@ mainLog.time('Time until lnd process lookup finished')
app.on('ready', () => {
mainLog.timeEnd('Time until app is ready')
// Start a couple more timers to track the app loading time.
mainLog.time('Time until app is visible')
mainLog.time('Time until onboarding has started')
// Create the electron browser window.
// Create a new browser window.
const mainWindow = new BrowserWindow({
show: false,
titleBarStyle: 'hidden',
@ -71,22 +66,11 @@ app.on('ready', () => {
mainLog.error
)
mainWindow.webContents.once('dom-ready', () => {
mainWindow.openDevTools()
zap.mainWindow.webContents.once('dom-ready', () => {
zap.mainWindow.openDevTools()
})
}
/**
* Add application event listener:
* - Kill lnd process is killed when the app quits.
*/
app.on('quit', () => {
mainLog.debug('app.quit')
if (zap.neutrino) {
zap.neutrino.stop()
}
})
/**
* Add application event listener:
* - Open zap payment form when lightning url is opened
@ -97,7 +81,7 @@ app.on('ready', () => {
event.preventDefault()
const payreq = url.split(':')[1]
zap.sendMessage('lightningPaymentUri', { payreq })
mainWindow.show()
zap.mainWindow.show()
})
// HACK: patch webrequest to fix devtools incompatibility with electron 2.x.
@ -126,4 +110,24 @@ app.on('ready', () => {
app.quit()
}
})
/**
* Add application event listener:
* - Stop gRPC and kill lnd process before the app windows are closed and the app quits.
*/
app.on('before-quit', async event => {
if (zap.state !== 'terminated') {
event.preventDefault()
zap.terminate()
} else {
zap.mainWindow.forceClose = true
}
})
/**
* On OS X it's common to re-open a window in the app when the dock icon is clicked.
*/
app.on('activate', () => {
zap.mainWindow.show()
})
})

4
app/reducers/channels.js

@ -1,7 +1,7 @@
import { createSelector } from 'reselect'
import { ipcRenderer } from 'electron'
import { btc } from 'utils'
import { showNotification } from 'notifications'
import { showNotification } from '../notifications'
import { btc } from '../utils'
import { requestSuggestedNodes } from '../api'
import { setError } from './error'
// ------------------------------------

4
app/reducers/ipc.js

@ -44,7 +44,7 @@ import {
walletUnlockerGrpcActive,
receiveSeed,
receiveSeedError,
successfullyCreatedWallet,
finishOnboarding,
walletUnlocked,
unlockWalletError
} from './onboarding'
@ -111,7 +111,7 @@ const ipc = createIpc({
walletUnlockerGrpcActive,
receiveSeed,
receiveSeedError,
successfullyCreatedWallet,
finishOnboarding,
walletUnlocked,
unlockWalletError
})

2
app/reducers/onboarding.js

@ -260,7 +260,7 @@ export const createWallet = () => dispatch => {
dispatch({ type: CHANGE_STEP, step: 4 })
}
export const successfullyCreatedWallet = () => dispatch => dispatch({ type: ONBOARDING_FINISHED })
export const finishOnboarding = () => dispatch => dispatch({ type: ONBOARDING_FINISHED })
// Listener for when LND creates and sends us a generated seed
export const receiveSeed = (event, { cipher_seed_mnemonic }) => dispatch => {

2
app/reducers/requestform.js

@ -1,5 +1,5 @@
import { createSelector } from 'reselect'
import { btc } from 'utils'
import { btc } from '../utils'
import { tickerSelectors } from './ticker'
// Initial State

294
app/zap.js

@ -1,7 +1,9 @@
import { app, ipcMain, dialog } from 'electron'
import Store from 'electron-store'
import StateMachine from 'javascript-state-machine'
import lnd from './lnd'
import Neutrino from './lnd/lib/neutrino'
import Lightning from './lnd/lib/lightning'
import { mainLog } from './utils/log'
import { isLndRunning } from './lnd/lib/util'
@ -37,7 +39,7 @@ const grpcSslCipherSuites = connectionType =>
class ZapController {
/**
* Create a new ZapController instance.
* @param {BrowserWindow} mainWindow BrowserWindow instance to interact with.
* @param {BrowserWindow} mainWindow BrowserWindow instance to interact with.
*/
constructor(mainWindow) {
// Variable to hold the main window instance.
@ -46,14 +48,24 @@ class ZapController {
// Keep a reference any neutrino process started by us.
this.neutrino = null
// Keep a reference to the lightning gRPC instance.
this.lightning = null
// Time for the splash screen to remain visible.
this.splashScreenTime = 500
// Boolean indicating wether the lightning grpc is connected ot not.
this.lightningGrpcConnected = false
// Initialize the state machine.
this._fsm()
}
/**
* Initialize the application.
* Initialize the controller.
*/
init() {
// Load the application into the main window.
if (process.env.HOT) {
const port = process.env.PORT || 1212
this.mainWindow.loadURL(`http://localhost:${port}/dist/index.html`)
@ -68,51 +80,123 @@ class ZapController {
this.mainWindow.webContents.on('did-finish-load', () => {
this.mainWindow.show()
this.mainWindow.focus()
mainLog.timeEnd('Time until app is visible')
return setTimeout(async () => {
this.sendMessage('startOnboarding')
}, this.splashScreenTime)
// Show the splash screen and then start onboarding.
setTimeout(() => this.startOnboarding(), this.splashScreenTime)
})
this.mainWindow.on('closed', () => {
this.mainWindow = null
// When the window is closed, just hide it unless we are force closing.
this.mainWindow.on('close', e => {
if (this.mainWindow.forceClose) {
return
}
e.preventDefault()
this.mainWindow.hide()
})
}
// shut down zap when a user closes the window
app.quit()
// ------------------------------------
// FSM Callbacks
// ------------------------------------
onStartOnboarding() {
mainLog.debug('[FSM] onStartOnboarding...')
this.sendMessage('startOnboarding')
}
onStartLnd(options) {
mainLog.debug('[FSM] onStartLnd...')
return isLndRunning().then(res => {
if (res) {
mainLog.error('lnd already running: %s', res)
dialog.showMessageBox({
type: 'error',
message: 'Unable to start lnd because it is already running.'
})
return app.quit()
}
mainLog.info('Starting new lnd instance')
mainLog.info(' > alias:', options.alias)
mainLog.info(' > autopilot:', options.autopilot)
return this.startNeutrino(options.alias, options.autopilot)
})
}
onConnectLnd(options) {
mainLog.debug('[FSM] onConnectLnd...')
mainLog.info('Connecting to custom lnd instance')
mainLog.info(' > host:', options.host)
mainLog.info(' > cert:', options.cert)
mainLog.info(' > macaroon:', options.macaroon)
this.startLightningWallet()
.then(() => this.sendMessage('finishOnboarding'))
.catch(e => {
const errors = {}
// There was a problem connectig to the host.
if (e.code === 'LND_GRPC_HOST_ERROR') {
errors.host = e.message
}
// There was a problem accessing loading the ssl cert.
if (e.code === 'LND_GRPC_CERT_ERROR') {
errors.cert = e.message
}
// There was a problem accessing loading the macaroon file.
else if (e.code === 'LND_GRPC_MACAROON_ERROR') {
errors.macaroon = e.message
}
// Other error codes such as UNAVAILABLE most likely indicate that there is a problem with the host.
else {
errors.host = `Unable to connect to host: ${e.details || e.message}`
}
// Notify the app of errors.
return this.sendMessage('startLndError', errors)
})
}
onTerminated() {
mainLog.debug('[FSM] onTerminated...')
// Unsubscribe the gRPC streams before thhe window closes. This ensures that we can properly reestablish a fresh
// connection when a new window is opened.
this.disconnectLightningWallet()
// If Neutrino is running, kill it.
if (this.neutrino) {
this.neutrino.stop()
}
// Give the grpc connections a chance to be properly closed out.
return new Promise(resolve => setTimeout(resolve, 200))
}
onTerminate() {
mainLog.debug('[FSM] onTerminate...')
app.quit()
}
// ------------------------------------
// Helpers
// ------------------------------------
/**
* Send a message to the main window.
* @param {string} msg message to send.
* @param {[type]} data additional data to acompany the message.
*/
sendMessage(msg, data) {
mainLog.info('Sending message to renderer process: %o', { msg, data })
this.mainWindow.webContents.send(msg, data)
}
/**
* Create and subscribe to the Lightning grpc object.
*/
async startLightningWallet() {
mainLog.info('Starting lightning wallet...')
const { lndSubscribe, lndMethods } = await lnd.initLnd()
// Subscribe to bi-directional streams
lndSubscribe(this.mainWindow)
// Listen for all gRPC restful methods
ipcMain.on('lnd', (event, { msg, data }) => {
lndMethods(event, msg, data)
})
this.sendMessage('lightningGrpcActive')
if (this.mainWindow) {
mainLog.info('Sending message to renderer process: %o', { msg, data })
this.mainWindow.webContents.send(msg, data)
} else {
mainLog.warn('Unable to send message to renderer process (main window not available): %o', {
msg,
data
})
}
}
/**
* Create and subscribe to the WalletUnlocker grpc object.
* Start the wallet unlocker.
*/
startWalletUnlocker() {
mainLog.info('Starting wallet unlocker...')
@ -124,6 +208,7 @@ class ZapController {
walletUnlockerMethods(event, msg, data)
})
// Notify the renderer that the wallet unlocker is active.
this.sendMessage('walletUnlockerGrpcActive')
} catch (error) {
dialog.showMessageBox({
@ -134,13 +219,54 @@ class ZapController {
}
}
/**
* Create and subscribe to the Lightning service.
*/
async startLightningWallet() {
mainLog.info('Starting lightning wallet...')
this.lightning = new Lightning()
// Connect to the Lightning interface.
await this.lightning.connect()
// Subscribe the main window to receive streams.
this.lightning.subscribe(this.mainWindow)
// Listen for all gRPC restful methods and pass to gRPC.
ipcMain.on('lnd', (event, { msg, data }) => this.lightning.lndMethods(event, msg, data))
// Let the renderer know that we are connected.
this.sendMessage('lightningGrpcActive')
// Update our internal state.
this.lightningGrpcConnected = true
}
/**
* Unsubscribe from the Lightning service.
*/
disconnectLightningWallet() {
if (!this.lightningGrpcConnected) {
return
}
mainLog.info('Disconnecting lightning Wallet...')
// Disconnect streams.
this.lightning.unsubscribe()
// Update our internal state.
this.lightningGrpcConnected = false
}
/**
/**
* Starts the LND node and attach event listeners.
* @param {string} alias Alias to assign to the lnd node.
* @param {boolean} autopilot True if autopilot should be enabled.
* @return {Neutrino} Neutrino instance.
*/
startLnd(alias, autopilot) {
startNeutrino(alias, autopilot) {
mainLog.info('Starting Neutrino...')
this.neutrino = new Neutrino(alias, autopilot)
this.neutrino.on('error', error => {
@ -153,7 +279,13 @@ class ZapController {
this.neutrino.on('close', code => {
mainLog.info(`Lnd process has shut down (code ${code})`)
app.quit()
if (['running', 'connected'].includes(this.state)) {
dialog.showMessageBox({
type: 'error',
message: `Lnd has unexpectadly quit`
})
this.terminate()
}
})
this.neutrino.on('wallet-unlocker-grpc-active', () => {
@ -192,75 +324,43 @@ class ZapController {
this.neutrino.start()
}
finishOnboarding(options) {
// Trim any user supplied strings.
const cleanOptions = Object.keys(options).reduce((previous, current) => {
previous[current] =
typeof options[current] === 'string' ? options[current].trim() : options[current]
return previous
}, {})
// Save the options.
const store = new Store({ name: 'connection' })
store.store = cleanOptions
mainLog.info('Saved lnd config to %s: %o', store.path, store.store)
// Set up SSL with the cypher suits that we need based on the connection type.
process.env.GRPC_SSL_CIPHER_SUITES =
process.env.GRPC_SSL_CIPHER_SUITES || grpcSslCipherSuites(options.type)
// If the requested connection type is a local one then start up a new lnd instance.
// // Otherwise attempt to connect to an lnd instance using user supplied connection details.
cleanOptions.type === 'local' ? this.startLnd() : this.connectLnd()
}
/**
* Add IPC event listeners...
*/
_registerIpcListeners() {
ipcMain.on('startLnd', (event, options = {}) => {
// Trim any user supplied strings.
const cleanOptions = Object.keys(options).reduce((previous, current) => {
previous[current] =
typeof options[current] === 'string' ? options[current].trim() : options[current]
return previous
}, {})
// Save the options.
const store = new Store({ name: 'connection' })
store.store = cleanOptions
mainLog.info('Saved lnd config to %s: %o', store.path, store.store)
process.env.GRPC_SSL_CIPHER_SUITES =
process.env.GRPC_SSL_CIPHER_SUITES || grpcSslCipherSuites(options.type)
// If the requested connection type is a local one then start up a new lnd instance.
if (cleanOptions.type === 'local') {
return isLndRunning().then(res => {
if (res) {
mainLog.error('lnd already running: %s', res)
dialog.showMessageBox({
type: 'error',
message: 'Unable to start lnd because it is already running.'
})
return app.quit()
}
mainLog.info('Starting new lnd instance')
mainLog.info(' > alias:', cleanOptions.alias)
mainLog.info(' > autopilot:', cleanOptions.autopilot)
return this.startLnd(cleanOptions.alias, cleanOptions.autopilot)
})
}
// Otherwise attempt to connect to an lnd instance using user supplied connection details.
mainLog.info('Connecting to custom lnd instance')
mainLog.info(' > host:', cleanOptions.host)
mainLog.info(' > cert:', cleanOptions.cert)
mainLog.info(' > macaroon:', cleanOptions.macaroon)
this.startLightningWallet()
.then(() => this.sendMessage('successfullyCreatedWallet'))
.catch(e => {
const errors = {}
// There was a problem connectig to the host.
if (e.code === 'LND_GRPC_HOST_ERROR') {
errors.host = e.message
}
// There was a problem accessing loading the ssl cert.
if (e.code === 'LND_GRPC_CERT_ERROR') {
errors.cert = e.message
}
// There was a problem accessing loading the macaroon file.
else if (e.code === 'LND_GRPC_MACAROON_ERROR') {
errors.macaroon = e.message
}
// Other error codes such as UNAVAILABLE most likely indicate that there is a problem with the host.
else {
errors.host = `Unable to connect to host: ${e.details || e.message}`
}
// Notify the app of errors.
return this.sendMessage('startLndError', errors)
})
})
ipcMain.on('startLnd', (event, options = {}) => this.finishOnboarding(options))
}
}
StateMachine.factory(ZapController, {
transitions: [
{ name: 'startOnboarding', from: 'none', to: 'onboarding' },
{ name: 'startLnd', from: 'onboarding', to: 'running' },
{ name: 'connectLnd', from: 'onboarding', to: 'connected' },
{ name: 'terminate', from: '*', to: 'terminated' }
]
})
export default ZapController

1
package.json

@ -282,6 +282,7 @@
"electron-store": "^2.0.0",
"font-awesome": "^4.7.0",
"history": "^4.6.3",
"javascript-state-machine": "^3.1.0",
"lodash.get": "^4.4.2",
"moment": "^2.22.2",
"prop-types": "^15.5.10",

4
yarn.lock

@ -6634,6 +6634,10 @@ isurl@^1.0.0-alpha5:
has-to-string-tag-x "^1.2.0"
is-object "^1.0.1"
javascript-state-machine@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/javascript-state-machine/-/javascript-state-machine-3.1.0.tgz#06eeb2136a6a19ae1b56105c25caec283dd5cd14"
jest-changed-files@^23.0.1:
version "23.0.1"
resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-23.0.1.tgz#f79572d0720844ea5df84c2a448e862c2254f60c"

Loading…
Cancel
Save