import React, { Component } from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' import PubNub from 'pubnub' import qrCode from 'qrcode-generator' import Button from '../../components/ui/button' import LoadingScreen from '../../components/ui/loading-screen' const PASSWORD_PROMPT_SCREEN = 'PASSWORD_PROMPT_SCREEN' const REVEAL_SEED_SCREEN = 'REVEAL_SEED_SCREEN' const KEYS_GENERATION_TIME = 30000 const IDLE_TIME = KEYS_GENERATION_TIME * 4 export default class MobileSyncPage extends Component { static contextTypes = { t: PropTypes.func, } static propTypes = { history: PropTypes.object.isRequired, selectedAddress: PropTypes.string.isRequired, displayWarning: PropTypes.func.isRequired, fetchInfoToSync: PropTypes.func.isRequired, mostRecentOverviewPage: PropTypes.string.isRequired, requestRevealSeedWords: PropTypes.func.isRequired, exportAccounts: PropTypes.func.isRequired, keyrings: PropTypes.array, } state = { screen: PASSWORD_PROMPT_SCREEN, password: '', seedWords: null, importedAccounts: [], error: null, syncing: false, completed: false, channelName: undefined, cipherKey: undefined, } syncing = false componentDidMount () { const passwordBox = document.getElementById('password-box') if (passwordBox) { passwordBox.focus() } } startIdleTimeout () { this.idleTimeout = setTimeout(() => { this.clearTimeouts() this.goBack() }, IDLE_TIME) } handleSubmit (event) { event.preventDefault() this.setState({ seedWords: null, error: null }) this.props.requestRevealSeedWords(this.state.password) .then((seedWords) => { this.startKeysGeneration() this.startIdleTimeout() this.exportAccounts() .then((importedAccounts) => { this.setState({ seedWords, importedAccounts, screen: REVEAL_SEED_SCREEN }) }) }) .catch((error) => this.setState({ error: error.message })) } async exportAccounts () { const addresses = [] this.props.keyrings.forEach((keyring) => { if (keyring.type === 'Simple Key Pair') { addresses.push(keyring.accounts[0]) } }) const importedAccounts = await this.props.exportAccounts(this.state.password, addresses) return importedAccounts } startKeysGeneration () { this.keysGenerationTimeout && clearTimeout(this.keysGenerationTimeout) this.disconnectWebsockets() this.generateCipherKeyAndChannelName() this.initWebsockets() this.keysGenerationTimeout = setTimeout(() => { this.startKeysGeneration() }, KEYS_GENERATION_TIME) } goBack () { const { history, mostRecentOverviewPage } = this.props history.push(mostRecentOverviewPage) } clearTimeouts () { this.keysGenerationTimeout && clearTimeout(this.keysGenerationTimeout) this.idleTimeout && clearTimeout(this.idleTimeout) } generateCipherKeyAndChannelName () { this.cipherKey = `${this.props.selectedAddress.substr(-4)}-${PubNub.generateUUID()}` this.channelName = `mm-${PubNub.generateUUID()}` this.setState({ cipherKey: this.cipherKey, channelName: this.channelName }) } initWithCipherKeyAndChannelName (cipherKey, channelName) { this.cipherKey = cipherKey this.channelName = channelName } initWebsockets () { // Make sure there are no existing listeners this.disconnectWebsockets() this.pubnub = new PubNub({ subscribeKey: process.env.PUBNUB_SUB_KEY, publishKey: process.env.PUBNUB_PUB_KEY, cipherKey: this.cipherKey, ssl: true, }) this.pubnubListener = { message: (data) => { const { channel, message } = data // handle message if (channel !== this.channelName || !message) { return false } if (message.event === 'start-sync') { this.startSyncing() } else if (message.event === 'connection-info') { this.keysGenerationTimeout && clearTimeout(this.keysGenerationTimeout) this.disconnectWebsockets() this.initWithCipherKeyAndChannelName(message.cipher, message.channel) this.initWebsockets() } else if (message.event === 'end-sync') { this.disconnectWebsockets() this.setState({ syncing: false, completed: true }) } }, } this.pubnub.addListener(this.pubnubListener) this.pubnub.subscribe({ channels: [this.channelName], withPresence: false, }) } disconnectWebsockets () { if (this.pubnub && this.pubnubListener) { this.pubnub.removeListener(this.pubnubListener) } } // Calculating a PubNub Message Payload Size. calculatePayloadSize (channel, message) { return encodeURIComponent( channel + JSON.stringify(message) ).length + 100 } chunkString (str, size) { const numChunks = Math.ceil(str.length / size) const chunks = new Array(numChunks) for (let i = 0, o = 0; i < numChunks; ++i, o += size) { chunks[i] = str.substr(o, size) } return chunks } notifyError (errorMsg) { return new Promise((resolve, reject) => { this.pubnub.publish( { message: { event: 'error-sync', data: errorMsg, }, channel: this.channelName, sendByPost: false, // true to send via post storeInHistory: false, }, (status, response) => { if (!status.error) { resolve() } else { reject(response) } }) }) } async startSyncing () { if (this.syncing) { return false } this.syncing = true this.setState({ syncing: true }) const { accounts, network, preferences, transactions } = await this.props.fetchInfoToSync() const allDataStr = JSON.stringify({ accounts, network, preferences, transactions, udata: { pwd: this.state.password, seed: this.state.seedWords, importedAccounts: this.state.importedAccounts, }, }) const chunks = this.chunkString(allDataStr, 17000) const totalChunks = chunks.length try { for (let i = 0; i < totalChunks; i++) { await this.sendMessage(chunks[i], i + 1, totalChunks) } } catch (e) { this.props.displayWarning('Sync failed :(') this.setState({ syncing: false }) this.syncing = false this.notifyError(e.toString()) } } sendMessage (data, pkg, count) { return new Promise((resolve, reject) => { this.pubnub.publish( { message: { event: 'syncing-data', data, totalPkg: count, currentPkg: pkg, }, channel: this.channelName, sendByPost: false, // true to send via post storeInHistory: false, }, (status, response) => { if (!status.error) { resolve() } else { reject(response) } } ) }) } componentWillUnmount () { this.clearTimeouts() this.disconnectWebsockets() } renderWarning (text) { return (
{text}
) } renderContent () { const { syncing, completed, screen } = this.state const { t } = this.context if (syncing) { return ( ) } if (completed) { return (
) } return screen === PASSWORD_PROMPT_SCREEN ? (
{this.renderWarning(this.context.t('mobileSyncText'))}
{this.renderPasswordPromptContent()}
) : (
{this.renderWarning(this.context.t('syncWithMobileBeCareful'))}
{this.renderRevealSeedContent()}
) } renderPasswordPromptContent () { const { t } = this.context return (
this.handleSubmit(event)}>
this.setState({ password: event.target.value })} className={classnames('form-control', { 'form-control--error': this.state.error, })} />
{this.state.error && (
{this.state.error}
)}
) } renderRevealSeedContent () { const qrImage = qrCode(0, 'M') qrImage.addData(`metamask-sync:${this.state.channelName}|@|${this.state.cipherKey}`) qrImage.make() const { t } = this.context return (
) } renderFooter () { return this.state.screen === PASSWORD_PROMPT_SCREEN ? this.renderPasswordPromptFooter() : this.renderRevealSeedFooter() } renderPasswordPromptFooter () { const { t } = this.context const { password } = this.state return (
) } renderRevealSeedFooter () { const { t } = this.context return (
) } render () { const { t } = this.context const { screen } = this.state return (
{t('syncWithMobileTitle')}
{ screen === PASSWORD_PROMPT_SCREEN ? (
{t('syncWithMobileDesc')}
) : null } { screen === PASSWORD_PROMPT_SCREEN ? (
{t('syncWithMobileDescNewUsers')}
) : null }
{this.renderContent()}
{this.renderFooter()}
) } }