diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2018-08-08 16:53:23 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2018-08-08 16:53:23 +0200 |
| commit | ac98e48c68eadfdd7250eccd38aa492e6f830744 (patch) | |
| tree | 05bfd7a9f05456220f11f40329093b127a5b20a2 | |
| parent | d53fda746465c0bb6525371b9d780cbfac90f942 (diff) | |
| parent | a8f2831cd50e98b1d126b8c3169adcf1ef75b8a2 (diff) | |
| download | mullvadvpn-ac98e48c68eadfdd7250eccd38aa492e6f830744.tar.xz mullvadvpn-ac98e48c68eadfdd7250eccd38aa492e6f830744.zip | |
Merge branch 'remove-auto-login'
47 files changed, 1057 insertions, 661 deletions
@@ -1,9 +1,14 @@ { + "root": true, "extends": [ "eslint:recommended", "plugin:react/recommended", "plugin:promise/recommended", - "prettier" + "plugin:flowtype/recommended", + "prettier", + "prettier/flowtype", + "prettier/react", + "prettier/standard" ], "parser": "babel-eslint", "parserOptions": { @@ -12,15 +17,17 @@ "modules": true } }, - "plugins": ["react", "flowtype", "promise"], + "plugins": [ + "react", + "flowtype", + "promise" + ], "rules": { "prefer-const": "warn", "no-console": "off", "no-loop-func": "warn", - "new-cap": "off", "no-param-reassign": "warn", "func-names": "off", - "comma-spacing": "warn", "no-unused-expressions": "error", "no-unused-vars": [ "error", diff --git a/CHANGELOG.md b/CHANGELOG.md index 319ad014fe..38c14687b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,12 +29,13 @@ Line wrap the file at 100 chars. Th - Account token can be copied to the clipboard by clicking on it in the account settings screen. - Automatically scroll to selected country/city in locations view. - Show system notifications when connection state changes. +- Add launch view displayed when connecting to system service. ### Changed - Format the expiry date and time using the system locale. - Account tokens are now required to have at least ten digits. -### macOS +#### macOS - Rename directores for settings, logs and cache from `mullvad-daemon` to `mullvad-vpn`. #### Windows @@ -46,7 +47,8 @@ Line wrap the file at 100 chars. Th ### Fixed - Ignore empty strings as redaction requests in the problem report tool, to avoid adding redacted markers between every character of the log message. - +- Previously logged in users won't be going through login view when restarting the app, instead + will be taken straight to main view. ## [2018.2-beta2] - 2018-07-18 ### Added diff --git a/app/app.js b/app/app.js index 3171d69b76..369c51be2d 100644 --- a/app/app.js +++ b/app/app.js @@ -3,7 +3,11 @@ import React from 'react'; import { bindActionCreators } from 'redux'; import { Provider } from 'react-redux'; -import { ConnectedRouter, push as pushHistory } from 'connected-react-router'; +import { + ConnectedRouter, + push as pushHistory, + replace as replaceHistory, +} from 'connected-react-router'; import { createMemoryHistory } from 'history'; import { webFrame, ipcRenderer } from 'electron'; @@ -19,10 +23,12 @@ import configureStore from './redux/store'; import accountActions from './redux/account/actions'; import connectionActions from './redux/connection/actions'; import settingsActions from './redux/settings/actions'; +import daemonActions from './redux/daemon/actions'; import type { RpcCredentials } from './lib/rpc-address-file'; import type { DaemonRpcProtocol, + AccountData, ConnectionObserver as DaemonConnectionObserver, } from './lib/daemon-rpc'; import type { ReduxStore } from './redux/store'; @@ -40,6 +46,7 @@ export default class AppRenderer { _memoryHistory = createMemoryHistory(); _reduxStore: ReduxStore; _reduxActions: *; + _accountDataState = new AccountDataState(); constructor() { const store = configureStore(null, this._memoryHistory); @@ -50,7 +57,14 @@ export default class AppRenderer { account: bindActionCreators(accountActions, dispatch), connection: bindActionCreators(connectionActions, dispatch), settings: bindActionCreators(settingsActions, dispatch), - history: bindActionCreators({ push: pushHistory }, dispatch), + daemon: bindActionCreators(daemonActions, dispatch), + history: bindActionCreators( + { + push: pushHistory, + replace: replaceHistory, + }, + dispatch, + ), }; this._openConnectionObserver = this._daemonRpc.addOpenConnectionObserver(() => { @@ -110,23 +124,27 @@ export default class AppRenderer { async login(accountToken: AccountToken) { const actions = this._reduxActions; + const history = this._memoryHistory; actions.account.startLogin(accountToken); - log.debug('Attempting to login'); + log.debug('Logging in'); try { const accountData = await this._daemonRpc.getAccountData(accountToken); await this._daemonRpc.setAccount(accountToken); - actions.account.loginSuccessful(accountData.expiry); + actions.account.updateAccountExpiry(accountData.expiry); + actions.account.loginSuccessful(); // Redirect the user after some time to allow for // the 'Login Successful' screen to be visible setTimeout(async () => { - actions.history.push('/connect'); + if (history.location.pathname === '/login') { + actions.history.replace('/connect'); + } try { - log.debug('Auto-connecting the tunnel...'); + log.debug('Auto-connecting the tunnel'); await this.connectTunnel(); } catch (error) { log.error(`Failed to auto-connect the tunnel: ${error.message}`); @@ -139,33 +157,33 @@ export default class AppRenderer { } } - async _autologin() { + async _restoreSession() { const actions = this._reduxActions; - actions.account.startLogin(); + const history = this._memoryHistory; - log.debug('Attempting to log in automatically'); - - try { - const accountToken = await this._daemonRpc.getAccount(); - if (!accountToken) { - throw new NoAccountError(); - } + log.debug('Restoring session'); - log.debug(`The daemon had an account number stored: ${accountToken}`); - actions.account.startLogin(accountToken); + const accountToken = await this._daemonRpc.getAccount(); - const accountData = await this._daemonRpc.getAccountData(accountToken); - log.debug('The stored account number still exists:', accountData); + if (accountToken) { + log.debug(`Got account token: ${accountToken}`); + actions.account.updateAccountToken(accountToken); + actions.account.loginSuccessful(); - actions.account.loginSuccessful(accountData.expiry); - actions.history.push('/connect'); - } catch (e) { - log.warn('Unable to autologin,', e.message); + // take user to main view if user is still at launch screen `/` + if (history.location.pathname === '/') { + actions.history.replace('/connect'); + } + } else { + log.debug('No account set, showing login view.'); - actions.account.autoLoginFailed(); - actions.history.push('/'); + // show window when account is not set + ipcRenderer.send('show-window'); - throw e; + // take user to `/login` screen if user is at launch screen `/` + if (history.location.pathname === '/') { + actions.history.replace('/login'); + } } } @@ -179,7 +197,10 @@ export default class AppRenderer { this._fetchAccountHistory(), ]); actions.account.loggedOut(); - actions.history.push('/'); + actions.history.replace('/login'); + + // reset account data state on log out + this._accountDataState = new AccountDataState(); } catch (e) { log.info('Failed to logout: ', e.message); } @@ -262,13 +283,29 @@ export default class AppRenderer { async updateAccountExpiry() { const actions = this._reduxActions; + const accountDataState = this._accountDataState; + + // Bail if something else requested an update to account data + // or if account data cache was updated recently. + if (accountDataState.isUpdating() || !accountDataState.needsUpdate()) { + return; + } + try { const accountToken = await this._daemonRpc.getAccount(); if (!accountToken) { throw new NoAccountError(); } - const accountData = await this._daemonRpc.getAccountData(accountToken); - actions.account.updateAccountExpiry(accountData.expiry); + + const accountData = await accountDataState.update(() => { + return this._daemonRpc.getAccountData(accountToken); + }); + + // Check if account token is still the same after receiving account data + const currentAccountToken = this._reduxStore.getState().account.accountToken; + if (currentAccountToken === accountToken) { + actions.account.updateAccountExpiry(accountData.expiry); + } } catch (e) { log.error(`Failed to update account expiry: ${e.message}`); } @@ -370,6 +407,10 @@ export default class AppRenderer { } async _onOpenConnection() { + // save to redux that the app connected to daemon + this._reduxActions.daemon.connected(); + + // reset the reconnect backoff when connection established. this._reconnectBackoff.reset(); // authenticate once connected @@ -383,16 +424,11 @@ export default class AppRenderer { log.error(`Cannot authenticate: ${error.message}`); } - // autologin + // attempt to restore the session try { - await this._autologin(); + await this._restoreSession(); } catch (error) { - if (error instanceof NoAccountError) { - log.debug('No previously configured account set, showing window'); - ipcRenderer.send('show-window'); - } else { - log.error(`Failed to autologin: ${error.message}`); - } + log.error(`Failed to restore session: ${error.message}`); } // make sure to re-subscribe to state notifications when connection is re-established. @@ -422,6 +458,12 @@ export default class AppRenderer { } async _onCloseConnection(error: ?Error) { + const actions = this._reduxActions; + + // save to redux that the app disconnected from daemon + actions.daemon.disconnected(); + + // recover connection on error if (error) { log.debug(`Lost connection to daemon: ${error.message}`); @@ -436,6 +478,9 @@ export default class AppRenderer { this._reconnectBackoff.attempt(() => { recover(); }); + + // take user back to the launch screen `/`. + actions.history.replace('/'); } } @@ -555,3 +600,32 @@ export default class AppRenderer { } } } + +// Helper class to keep track of account data updates +class AccountDataState { + _expiresAt: ?Date; + _isUpdating = false; + + isUpdating() { + return this._isUpdating; + } + + needsUpdate() { + return !this._expiresAt || this._expiresAt < new Date(); + } + + async update(fn: () => Promise<AccountData>): Promise<AccountData> { + this._isUpdating = true; + + try { + const accountData = await fn(); + this._expiresAt = new Date(Date.now() + 60 * 1000); // 60s expiration + + return accountData; + } catch (error) { + throw error; + } finally { + this._isUpdating = false; + } + } +} diff --git a/app/components/Account.js b/app/components/Account.js index 2edf277fa4..700f33af2b 100644 --- a/app/components/Account.js +++ b/app/components/Account.js @@ -1,7 +1,7 @@ // @flow import moment from 'moment'; import * as React from 'react'; -import { Component, Text, View, App, Types } from 'reactxp'; +import { Component, Text, View } from 'reactxp'; import * as AppButton from './AppButton'; import { Layout, Container } from './Layout'; import NavigationBar, { BackBarItem } from './NavigationBar'; @@ -9,10 +9,11 @@ import SettingsHeader, { HeaderTitle } from './SettingsHeader'; import styles from './AccountStyles'; import Img from './Img'; import { formatAccount } from '../lib/formatters'; +import WindowStateObserver from '../lib/window-state-observer'; import type { AccountToken } from '../lib/daemon-rpc'; -export type AccountProps = { +type Props = { accountToken: AccountToken, accountExpiry: string, expiryLocale: string, @@ -28,27 +29,23 @@ type State = { showAccountTokenCopiedMessage: boolean, }; -export default class Account extends Component<AccountProps, State> { +export default class Account extends Component<Props, State> { state = { isRefreshingExpiry: false, showAccountTokenCopiedMessage: false, }; - _activationStateToken: ?Types.SubscriptionToken; - _isMounted = false; - _copyTimer: ?TimeoutID; + _windowStateObserver = new WindowStateObserver(); componentDidMount() { this._isMounted = true; this._refreshAccountExpiry(); - this._activationStateToken = App.activationStateChangedEvent.subscribe((activationState) => { - if (activationState === Types.AppActivationState.Active) { - this._refreshAccountExpiry(); - } - }); + this._windowStateObserver.onShow = () => { + this._refreshAccountExpiry(); + }; } componentWillUnmount() { @@ -58,11 +55,7 @@ export default class Account extends Component<AccountProps, State> { clearTimeout(this._copyTimer); } - const activationStateToken = this._activationStateToken; - if (activationStateToken) { - activationStateToken.unsubscribe(); - this._activationStateToken = null; - } + this._windowStateObserver.dispose(); } onAccountTokenClick() { @@ -119,7 +112,7 @@ export default class Account extends Component<AccountProps, State> { <Text style={styles.account__row_label}>Paid until</Text> {isOutOfTime ? ( <Text style={styles.account__out_of_time} testName="account__out_of_time"> - OUT OF TIME + {'OUT OF TIME'} </Text> ) : ( <Text style={styles.account__row_value}>{formattedExpiry}</Text> diff --git a/app/components/AccountStyles.js b/app/components/AccountStyles.js index 14fc478a54..a96a8fcff6 100644 --- a/app/components/AccountStyles.js +++ b/app/components/AccountStyles.js @@ -56,6 +56,7 @@ export default { account__row_value: { fontFamily: 'Open Sans', fontSize: 16, + lineHeight: 19, fontWeight: '800', color: colors.white, }, diff --git a/app/components/Cell.js b/app/components/Cell.js index 2b3c06a3b1..80b783352f 100644 --- a/app/components/Cell.js +++ b/app/components/Cell.js @@ -19,6 +19,7 @@ const styles = { flexDirection: 'row', alignItems: 'center', alignContent: 'center', + cursor: 'default', }, cellHover: { backgroundColor: colors.blue80, diff --git a/app/components/Connect.js b/app/components/Connect.js index 037ab02495..9d7f52f7b1 100644 --- a/app/components/Connect.js +++ b/app/components/Connect.js @@ -3,19 +3,20 @@ import moment from 'moment'; import * as React from 'react'; import { Layout, Container, Header } from './Layout'; +import { SettingsBarButton, Brand } from './HeaderBar'; import { Component, Text, View, Types } from 'reactxp'; import * as AppButton from './AppButton'; import Img from './Img'; import Accordion from './Accordion'; import styles from './ConnectStyles'; - import { NoCreditError, NoInternetError } from '../errors'; import Map from './Map'; +import WindowStateObserver from '../lib/window-state-observer'; import type { HeaderBarStyle } from './HeaderBar'; import type { ConnectionReduxState } from '../redux/connection/reducers'; -export type ConnectProps = { +type Props = { connection: ConnectionReduxState, accountExpiry: string, selectedRelayName: string, @@ -25,22 +26,24 @@ export type ConnectProps = { onCopyIP: () => void, onDisconnect: () => void, onExternalLink: (type: string) => void, + updateAccountExpiry: () => Promise<void>, }; -type ConnectState = { +type State = { showCopyIPMessage: boolean, mapOffset: [number, number], }; -export default class Connect extends Component<ConnectProps, ConnectState> { +export default class Connect extends Component<Props, State> { state = { showCopyIPMessage: false, mapOffset: [0, 0], }; _copyTimer: ?TimeoutID; + _windowStateObserver = new WindowStateObserver(); - shouldComponentUpdate(nextProps: ConnectProps, nextState: ConnectState) { + shouldComponentUpdate(nextProps: Props, nextState: State) { const { connection: prevConnection, ...otherPrevProps } = this.props; const { connection: nextConnection, ...otherNextProps } = nextProps; @@ -56,10 +59,20 @@ export default class Connect extends Component<ConnectProps, ConnectState> { ); } + componentDidMount() { + this.props.updateAccountExpiry(); + + this._windowStateObserver.onShow = () => { + this.props.updateAccountExpiry(); + }; + } + componentWillUnmount() { if (this._copyTimer) { clearTimeout(this._copyTimer); } + + this._windowStateObserver.dispose(); } render() { @@ -68,12 +81,10 @@ export default class Connect extends Component<ConnectProps, ConnectState> { return ( <Layout> - <Header - style={this.headerStyle()} - showSettings={true} - onSettings={this.props.onSettings} - testName="header" - /> + <Header barStyle={this.headerBarStyle()} testName="header"> + <Brand /> + <SettingsBarButton onPress={this.props.onSettings} /> + </Header> <Container>{child}</Container> </Layout> ); @@ -324,15 +335,17 @@ export default class Connect extends Component<ConnectProps, ConnectState> { // Private - headerStyle(): HeaderBarStyle { - switch (this.props.connection.status) { + headerBarStyle(): HeaderBarStyle { + const { status } = this.props.connection; + switch (status) { case 'disconnected': return 'error'; case 'connecting': case 'connected': return 'success'; + default: + throw new Error(`Invalid ConnectionState: ${(status: empty)}`); } - throw new Error('Invalid ConnectionState'); } networkSecurityStyle(): Types.Style { diff --git a/app/components/CustomScrollbars.js b/app/components/CustomScrollbars.js index be74bfddb5..a149faff75 100644 --- a/app/components/CustomScrollbars.js +++ b/app/components/CustomScrollbars.js @@ -176,7 +176,7 @@ export default class CustomScrollbars extends React.Component<Props, State> { return offsetTop - (scrollable.offsetHeight - child.clientHeight) * 0.5; default: - throw new Error(`Unknown enum type for ScrollPosition: ${scrollPosition}`); + throw new Error(`Unknown enum type for ScrollPosition: ${(scrollPosition: empty)}`); } } diff --git a/app/components/HeaderBar.js b/app/components/HeaderBar.js index a46856eb46..02b1d86e31 100644 --- a/app/components/HeaderBar.js +++ b/app/components/HeaderBar.js @@ -1,55 +1,148 @@ // @flow import React from 'react'; -import { Component, Text, Button, View } from 'reactxp'; - +import { Component, Text, Button, View, Styles } from 'reactxp'; import Img from './Img'; - -import styles from './HeaderBarStyles'; -import platformStyles from './HeaderBarPlatformStyles'; +import { colors } from '../config'; export type HeaderBarStyle = 'default' | 'defaultDark' | 'error' | 'success'; -export type HeaderBarProps = { - style: HeaderBarStyle, - showSettings: boolean, - onSettings: ?() => void, +type HeaderBarProps = { + barStyle: HeaderBarStyle, +}; + +const headerBarStyles = { + container: { + base: Styles.createViewStyle({ + paddingTop: 12, + paddingBottom: 12, + paddingLeft: 12, + paddingRight: 12, + }), + platformOverride: { + darwin: Styles.createViewStyle({ + paddingTop: 24, + }), + linux: Styles.createViewStyle({ + WebkitAppRegion: 'drag', + }), + }, + }, + content: Styles.createViewStyle({ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + // the size of "brand" logo + minHeight: 51, + }), + barStyle: { + default: Styles.createViewStyle({ + backgroundColor: colors.blue, + }), + defaultDark: Styles.createViewStyle({ + backgroundColor: colors.darkBlue, + }), + error: Styles.createViewStyle({ + backgroundColor: colors.red, + }), + success: Styles.createViewStyle({ + backgroundColor: colors.green, + }), + }, }; export default class HeaderBar extends Component<HeaderBarProps> { static defaultProps: HeaderBarProps = { - style: 'default', - showSettings: false, - onSettings: null, + barStyle: 'default', }; render() { - const containerClass = [ - styles['headerbar'], - platformStyles[process.platform], - styles['style_' + this.props.style], + const style = [ + headerBarStyles.container.base, + headerBarStyles.container.platformOverride[process.platform], + headerBarStyles.barStyle[this.props.barStyle], + this.props.style, ]; return ( - <View style={containerClass}> - <View style={styles.container} testName="headerbar__container"> - <Img height={50} width={50} source="logo-icon" /> - <Text style={styles.title}>MULLVAD VPN</Text> - </View> + <View style={style}> + <View style={headerBarStyles.content}>{this.props.children}</View> + </View> + ); + } +} - {this.props.showSettings ? ( - <Button - style={styles.settings} - onPress={this.props.onSettings} - testName="headerbar__settings"> - <Img - height={24} - width={24} - source="icon-settings" - style={[styles.settings_icon, platformStyles.settings_icon]} - hoverStyle={styles.settings_icon_hover} - /> - </Button> - ) : null} +const brandStyles = { + container: Styles.createViewStyle({ + flex: 1, + flexDirection: 'row', + alignItems: 'center', + }), + title: Styles.createTextStyle({ + fontFamily: 'DINPro', + fontSize: 24, + fontWeight: '900', + lineHeight: 30, + letterSpacing: -0.5, + color: colors.white60, + marginLeft: 8, + }), +}; + +export class Brand extends Component { + render() { + return ( + <View style={brandStyles.container} testName="headerbar__container"> + <Img width={50} height={50} source="logo-icon" /> + <Text style={brandStyles.title}>{'MULLVAD VPN'}</Text> </View> ); } } + +type SettingsButtonProps = { + onPress: ?() => void, +}; + +const settingsBarButtonStyles = { + container: { + base: Styles.createViewStyle({ + cursor: 'default', + padding: 0, + marginLeft: 8, + }), + platformOverride: { + linux: Styles.createViewStyle({ + WebkitAppRegion: 'no-drag', + }), + }, + }, + icon: { + normal: Styles.createViewStyle({ + color: colors.white60, + }), + hover: Styles.createViewStyle({ + color: colors.white, + }), + }, +}; + +export class SettingsBarButton extends Component<SettingsButtonProps> { + render() { + return ( + <Button + style={[ + settingsBarButtonStyles.container.base, + settingsBarButtonStyles.container.platformOverride[process.platform], + ]} + onPress={this.props.onPress} + testName="headerbar__settings"> + <Img + height={24} + width={24} + source="icon-settings" + style={settingsBarButtonStyles.icon.normal} + hoverStyle={settingsBarButtonStyles.icon.hover} + /> + </Button> + ); + } +} diff --git a/app/components/HeaderBarPlatformStyles.android.js b/app/components/HeaderBarPlatformStyles.android.js deleted file mode 100644 index f2f97beb65..0000000000 --- a/app/components/HeaderBarPlatformStyles.android.js +++ /dev/null @@ -1,2 +0,0 @@ -import { createViewStyles } from '../lib/styles'; -export default { ...createViewStyles({}) }; diff --git a/app/components/HeaderBarPlatformStyles.js b/app/components/HeaderBarPlatformStyles.js deleted file mode 100644 index c60967797a..0000000000 --- a/app/components/HeaderBarPlatformStyles.js +++ /dev/null @@ -1,16 +0,0 @@ -// @flow -import { createViewStyles } from '../lib/styles'; - -export default { - ...createViewStyles({ - darwin: { - paddingTop: 24, - }, - linux: { - WebkitAppRegion: 'drag', - }, - settings_icon: { - WebkitAppRegion: 'no-drag', - }, - }), -}; diff --git a/app/components/HeaderBarStyles.js b/app/components/HeaderBarStyles.js deleted file mode 100644 index 7d3c94cdc9..0000000000 --- a/app/components/HeaderBarStyles.js +++ /dev/null @@ -1,53 +0,0 @@ -// @flow -import { createTextStyles, createViewStyles } from '../lib/styles'; -import { colors } from '../config'; - -export default { - ...createViewStyles({ - headerbar: { - paddingTop: 12, - paddingBottom: 12, - paddingLeft: 12, - paddingRight: 12, - backgroundColor: colors.blue, - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - style_defaultDark: { - backgroundColor: colors.darkBlue, - }, - style_error: { - backgroundColor: colors.red, - }, - style_success: { - backgroundColor: colors.green, - }, - container: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - }, - settings: { - cursor: 'default', - padding: 0, - }, - settings_icon: { - color: colors.white60, - }, - settings_icon_hover: { - color: colors.white, - }, - }), - ...createTextStyles({ - title: { - fontFamily: 'DINPro', - fontSize: 24, - fontWeight: '900', - lineHeight: 30, - letterSpacing: -0.5, - color: colors.white60, - marginLeft: 8, - }, - }), -}; diff --git a/app/components/Img.js b/app/components/Img.js index 1d6f6613f4..6b34f99360 100644 --- a/app/components/Img.js +++ b/app/components/Img.js @@ -4,6 +4,8 @@ import { View, Component, Types } from 'reactxp'; type Props = { source: string, + width?: number, + heigth?: number, tintColor?: string, hoverStyle?: Types.ViewStyle, disabled?: boolean, @@ -20,7 +22,7 @@ export default class Img extends Component<Props, State> { getHoverStyle = () => (this.state.hovered ? this.props.hoverStyle || null : null); render() { - const { source, style, onMouseEnter, onMouseLeave, ...otherProps } = this.props; + const { source, width, heigth, style, onMouseEnter, onMouseLeave, ...otherProps } = this.props; const tintColor = this.props.tintColor; const url = './assets/images/' + source + '.svg'; let image; @@ -36,6 +38,8 @@ export default class Img extends Component<Props, State> { }}> <img src={url} + width={width} + height={heigth} style={{ visibility: 'hidden', }} @@ -43,7 +47,7 @@ export default class Img extends Component<Props, State> { </div> ); } else { - image = <img src={url} />; + image = <img src={url} width={width} height={heigth} />; } return ( diff --git a/app/components/Launch.js b/app/components/Launch.js new file mode 100644 index 0000000000..bab877dd53 --- /dev/null +++ b/app/components/Launch.js @@ -0,0 +1,58 @@ +// @flow +import * as React from 'react'; +import { Component, Styles, View, Text } from 'reactxp'; +import { Layout, Container, Header } from './Layout'; +import { SettingsBarButton } from './HeaderBar'; +import Img from './Img'; +import { colors } from '../config'; + +const styles = { + container: Styles.createViewStyle({ + flex: 1, + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + marginTop: -150, + }), + logo: Styles.createViewStyle({ + marginBottom: 4, + }), + title: Styles.createTextStyle({ + fontFamily: 'DINPro', + fontSize: 24, + fontWeight: '900', + lineHeight: 30, + letterSpacing: -0.5, + color: colors.white60, + marginBottom: 4, + }), + subtitle: Styles.createTextStyle({ + fontFamily: 'Open Sans', + fontSize: 14, + lineHeight: 20, + color: colors.white40, + }), +}; + +type Props = { + openSettings: () => void, +}; + +export default class Launch extends Component<Props> { + render() { + return ( + <Layout> + <Header> + <SettingsBarButton onPress={this.props.openSettings} /> + </Header> + <Container> + <View style={styles.container} testName="headerbar__container"> + <Img height={120} width={120} source="logo-icon" style={styles.logo} /> + <Text style={styles.title}>{'MULLVAD VPN'}</Text> + <Text style={styles.subtitle}>{'Connecting to daemon...'}</Text> + </View> + </Container> + </Layout> + ); + } +} diff --git a/app/components/Layout.js b/app/components/Layout.js index 2699b3e43d..1eb6984797 100644 --- a/app/components/Layout.js +++ b/app/components/Layout.js @@ -1,19 +1,16 @@ // @flow import * as React from 'react'; -import HeaderBar from './HeaderBar'; import { View, Component } from 'reactxp'; - -import type { HeaderBarProps } from './HeaderBar'; - +import HeaderBar from './HeaderBar'; import styles from './LayoutStyles'; -export class Header extends Component<HeaderBarProps> { +export class Header extends Component<React.ElementProps<typeof HeaderBar>> { static defaultProps = HeaderBar.defaultProps; render() { return ( - <View style={styles.header}> - <HeaderBar {...this.props} /> + <View style={[styles.header, this.props.style]}> + <HeaderBar barStyle={this.props.barStyle}>{this.props.children}</HeaderBar> </View> ); } diff --git a/app/components/Login.js b/app/components/Login.js index d902bba1bc..b410f80553 100644 --- a/app/components/Login.js +++ b/app/components/Login.js @@ -2,6 +2,7 @@ import * as React from 'react'; import { Component, Text, View, Animated, Styles, UserInterface } from 'reactxp'; import { Layout, Container, Header } from './Layout'; +import { SettingsBarButton, Brand } from './HeaderBar'; import AccountInput from './AccountInput'; import Accordion from './Accordion'; import { formatAccount } from '../lib/formatters'; @@ -94,7 +95,10 @@ export default class Login extends Component<Props, State> { render() { return ( <Layout> - <Header showSettings={true} onSettings={this.props.openSettings} /> + <Header> + <Brand /> + <SettingsBarButton onPress={this.props.openSettings} /> + </Header> <Container> <View style={styles.login_form}> {this._getStatusIcon()} diff --git a/app/components/LoginStyles.js b/app/components/LoginStyles.js index d9094a2f13..61e5026a71 100644 --- a/app/components/LoginStyles.js +++ b/app/components/LoginStyles.js @@ -14,9 +14,8 @@ export default { }, status_icon: { flex: 0, - height: 48, marginBottom: 30, - justifyContent: 'center', + alignItems: 'center', }, login_form: { flex: 1, @@ -88,13 +87,15 @@ export default { backgroundColor: colors.darkBlue, }, account_dropdown__item: { - paddingTop: 10, - paddingRight: 12, - paddingLeft: 12, - paddingBottom: 12, + paddingTop: 0, + paddingRight: 0, + paddingLeft: 0, + paddingBottom: 0, marginBottom: 0, flexDirection: 'row', + alignItems: 'stretch', backgroundColor: colors.white60, + cursor: 'default', }, account_dropdown__item_hover: { backgroundColor: colors.white40, @@ -102,6 +103,11 @@ export default { account_dropdown__remove: { justifyContent: 'center', color: colors.blue40, + paddingTop: 10, + paddingRight: 12, + paddingBottom: 12, + paddingLeft: 12, + marginLeft: 0, }, account_dropdown__remove_cell_hover: { color: colors.blue60, @@ -136,6 +142,7 @@ export default { subtitle: { fontFamily: 'Open Sans', fontSize: 13, + lineHeight: 15, fontWeight: '600', letterSpacing: -0.2, color: colors.white80, @@ -165,6 +172,11 @@ export default { borderWidth: 0, textAlign: 'left', marginLeft: 0, + paddingTop: 10, + paddingRight: 0, + paddingLeft: 12, + paddingBottom: 12, + cursor: 'default', }, }), }; diff --git a/app/components/Settings.js b/app/components/Settings.js index 4a8784fe0e..0ed45c7685 100644 --- a/app/components/Settings.js +++ b/app/components/Settings.js @@ -10,14 +10,14 @@ import SettingsHeader, { HeaderTitle } from './SettingsHeader'; import CustomScrollbars from './CustomScrollbars'; import styles from './SettingsStyles'; import Img from './Img'; +import WindowStateObserver from '../lib/window-state-observer'; -import type { AccountReduxState } from '../redux/account/reducers'; -import type { SettingsReduxState } from '../redux/settings/reducers'; +import type { LoginState } from '../redux/account/reducers'; -export type SettingsProps = { - account: AccountReduxState, - settings: SettingsReduxState, - version: string, +type Props = { + loginState: LoginState, + accountExpiry: ?string, + appVersion: string, onQuit: () => void, onClose: () => void, onViewAccount: () => void, @@ -25,9 +25,24 @@ export type SettingsProps = { onViewPreferences: () => void, onViewAdvancedSettings: () => void, onExternalLink: (type: string) => void, + updateAccountExpiry: () => Promise<void>, }; -export default class Settings extends Component<SettingsProps> { +export default class Settings extends Component<Props> { + _windowStateObserver = new WindowStateObserver(); + + componentDidMount() { + this.props.updateAccountExpiry(); + + this._windowStateObserver.onShow = () => { + this.props.updateAccountExpiry(); + }; + } + + componentWillUnmount() { + this._windowStateObserver.dispose(); + } + render() { return ( <Layout> @@ -60,17 +75,17 @@ export default class Settings extends Component<SettingsProps> { } _renderTopButtons() { - const isLoggedIn = this.props.account.status === 'ok'; + const isLoggedIn = this.props.loginState === 'ok'; if (!isLoggedIn) { return null; } - let isOutOfTime = false, - formattedExpiry = ''; - const expiryIso = this.props.account.expiry; + let isOutOfTime = false; + let formattedExpiry = ''; + const expiryIso = this.props.accountExpiry; if (isLoggedIn && expiryIso) { - const expiry = moment(this.props.account.expiry); + const expiry = moment(expiryIso); isOutOfTime = expiry.isSameOrBefore(moment()); formattedExpiry = (expiry.fromNow(true) + ' left').toUpperCase(); } @@ -86,7 +101,7 @@ export default class Settings extends Component<SettingsProps> { <Cell.SubText testName="settings__account_paid_until_subtext" style={styles.settings__account_paid_until_Label__error}> - OUT OF TIME + {'OUT OF TIME'} </Cell.SubText> <Img height={12} width={7} source="icon-chevron" /> </Cell.CellButton> @@ -136,7 +151,7 @@ export default class Settings extends Component<SettingsProps> { // the version in package.json has to be semver, but we use a YEAR.release-channel // version scheme. in package.json we thus have to write YEAR.release.X-channel and // this function is responsible for removing .X part. - return this.props.version + return this.props.appVersion .replace('.0-', '-') // remove the .0 in 2018.1.0-beta9 .replace(/\.0$/, ''); // remove the .0 in 2018.1.0 } diff --git a/app/containers/AccountPage.js b/app/containers/AccountPage.js index 078c893748..712466f191 100644 --- a/app/containers/AccountPage.js +++ b/app/containers/AccountPage.js @@ -3,7 +3,7 @@ import { remote } from 'electron'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; -import { push } from 'connected-react-router'; +import { goBack } from 'connected-react-router'; import Account from '../components/Account'; import accountActions from '../redux/account/actions'; import { links } from '../config'; @@ -19,7 +19,7 @@ const mapStateToProps = (state: ReduxState) => ({ }); const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => { const { copyAccountToken } = bindActionCreators(accountActions, dispatch); - const { push: pushHistory } = bindActionCreators({ push }, dispatch); + const history = bindActionCreators({ goBack }, dispatch); return { updateAccountExpiry: () => props.app.updateAccountExpiry(), onCopyAccountToken: () => copyAccountToken(), @@ -27,7 +27,7 @@ const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => props.app.logout(); }, onClose: () => { - pushHistory('/settings'); + history.goBack(); }, onBuyMore: () => openLink(links['purchase']), }; diff --git a/app/containers/AdvancedSettingsPage.js b/app/containers/AdvancedSettingsPage.js index 9f88228cc3..bf584096b9 100644 --- a/app/containers/AdvancedSettingsPage.js +++ b/app/containers/AdvancedSettingsPage.js @@ -1,7 +1,8 @@ // @flow import { connect } from 'react-redux'; -import { push } from 'connected-react-router'; +import { goBack } from 'connected-react-router'; +import { bindActionCreators } from 'redux'; import { AdvancedSettings } from '../components/AdvancedSettings'; import RelaySettingsBuilder from '../lib/relay-settings-builder'; import { log } from '../lib/platform'; @@ -26,9 +27,11 @@ const mapStateToProps = (state: ReduxState) => { }; const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => { + const history = bindActionCreators({ goBack }, dispatch); return { - onClose: () => dispatch(push('/settings')), - + onClose: () => { + history.goBack(); + }, onUpdate: async (protocol, port) => { const relayUpdate = RelaySettingsBuilder.normal() .tunnel.openvpn((openvpn) => { diff --git a/app/containers/ConnectPage.js b/app/containers/ConnectPage.js index 6f906f1c82..0ad43e9c0b 100644 --- a/app/containers/ConnectPage.js +++ b/app/containers/ConnectPage.js @@ -2,7 +2,7 @@ import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; -import { push as pushHistory } from 'connected-react-router'; +import { push } from 'connected-react-router'; import { links } from '../config'; import Connect from '../components/Connect'; import connectActions from '../redux/connection/actions'; @@ -56,7 +56,7 @@ const mapStateToProps = (state: ReduxState) => { const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => { const { copyIPAddress } = bindActionCreators(connectActions, dispatch); - const history = bindActionCreators({ push: pushHistory }, dispatch); + const history = bindActionCreators({ push }, dispatch); return { onSettings: () => { @@ -83,6 +83,7 @@ const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => } }, onExternalLink: (type) => openLink(links[type]), + updateAccountExpiry: () => props.app.updateAccountExpiry(), }; }; diff --git a/app/containers/LaunchPage.js b/app/containers/LaunchPage.js new file mode 100644 index 0000000000..1b265dd030 --- /dev/null +++ b/app/containers/LaunchPage.js @@ -0,0 +1,23 @@ +// @flow +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import { push } from 'connected-react-router'; +import Launch from '../components/Launch'; + +import type { ReduxState, ReduxDispatch } from '../redux/store'; +import type { SharedRouteProps } from '../routes'; + +const mapStateToProps = (_state: ReduxState) => ({}); +const mapDispatchToProps = (dispatch: ReduxDispatch, _props: SharedRouteProps) => { + const history = bindActionCreators({ push }, dispatch); + return { + openSettings: () => { + history.push('/settings'); + }, + }; +}; + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(Launch); diff --git a/app/containers/LoginPage.js b/app/containers/LoginPage.js index 74823f3925..539f05c5ac 100644 --- a/app/containers/LoginPage.js +++ b/app/containers/LoginPage.js @@ -21,11 +21,11 @@ const mapStateToProps = (state: ReduxState) => { }; }; const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => { - const { push: pushHistory } = bindActionCreators({ push }, dispatch); + const history = bindActionCreators({ push }, dispatch); const { resetLoginError, updateAccountToken } = bindActionCreators(accountActions, dispatch); return { openSettings: () => { - pushHistory('/settings'); + history.push('/settings'); }, login: (account) => { props.app.login(account); diff --git a/app/containers/PreferencesPage.js b/app/containers/PreferencesPage.js index 8534a70d74..ee2709c767 100644 --- a/app/containers/PreferencesPage.js +++ b/app/containers/PreferencesPage.js @@ -2,7 +2,7 @@ import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; -import { push } from 'connected-react-router'; +import { goBack } from 'connected-react-router'; import Preferences from '../components/Preferences'; import { log, getOpenAtLogin, setOpenAtLogin } from '../lib/platform'; @@ -15,9 +15,11 @@ const mapStateToProps = (state: ReduxState) => ({ }); const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => { - const { push: pushHistory } = bindActionCreators({ push }, dispatch); + const history = bindActionCreators({ goBack }, dispatch); return { - onClose: () => pushHistory('/settings'), + onClose: () => { + history.goBack(); + }, getAutoStart: () => { return getOpenAtLogin(); }, diff --git a/app/containers/SelectLocationPage.js b/app/containers/SelectLocationPage.js index 6307e9421b..037dd67a0b 100644 --- a/app/containers/SelectLocationPage.js +++ b/app/containers/SelectLocationPage.js @@ -2,7 +2,7 @@ import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; -import { push } from 'connected-react-router'; +import { goBack } from 'connected-react-router'; import SelectLocation from '../components/SelectLocation'; import RelaySettingsBuilder from '../lib/relay-settings-builder'; import { log } from '../lib/platform'; @@ -14,9 +14,9 @@ const mapStateToProps = (state: ReduxState) => ({ settings: state.settings, }); const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => { - const { push: pushHistory } = bindActionCreators({ push }, dispatch); + const history = bindActionCreators({ goBack }, dispatch); return { - onClose: () => pushHistory('/connect'), + onClose: () => history.goBack(), onSelect: async (relayLocation) => { try { const relayUpdate = RelaySettingsBuilder.normal() @@ -27,9 +27,9 @@ const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => await props.app.fetchRelaySettings(); await props.app.connectTunnel(); - pushHistory('/connect'); + history.goBack(); } catch (e) { - log.error('Failed to select server: ', e.message); + log.error(`Failed to select server: ${e.message}`); } }, }; diff --git a/app/containers/SettingsPage.js b/app/containers/SettingsPage.js index 8ffd0691fd..06c0cd6060 100644 --- a/app/containers/SettingsPage.js +++ b/app/containers/SettingsPage.js @@ -2,7 +2,7 @@ import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; -import { push } from 'connected-react-router'; +import { push, goBack } from 'connected-react-router'; import Settings from '../components/Settings'; import { links } from '../config'; import { getAppVersion, openLink, exit } from '../lib/platform'; @@ -11,20 +11,21 @@ import type { ReduxState, ReduxDispatch } from '../redux/store'; import type { SharedRouteProps } from '../routes'; const mapStateToProps = (state: ReduxState) => ({ - account: state.account, - settings: state.settings, - version: getAppVersion(), + loginState: state.account.status, + accountExpiry: state.account.expiry, + appVersion: getAppVersion(), }); -const mapDispatchToProps = (dispatch: ReduxDispatch, _props: SharedRouteProps) => { - const { push: pushHistory } = bindActionCreators({ push }, dispatch); +const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => { + const history = bindActionCreators({ push, goBack }, dispatch); return { onQuit: () => exit(), - onClose: () => pushHistory('/connect'), - onViewAccount: () => pushHistory('/settings/account'), - onViewSupport: () => pushHistory('/settings/support'), - onViewPreferences: () => pushHistory('/settings/preferences'), - onViewAdvancedSettings: () => pushHistory('/settings/advanced'), + onClose: () => history.goBack(), + onViewAccount: () => history.push('/settings/account'), + onViewSupport: () => history.push('/settings/support'), + onViewPreferences: () => history.push('/settings/preferences'), + onViewAdvancedSettings: () => history.push('/settings/advanced'), onExternalLink: (type) => openLink(links[type]), + updateAccountExpiry: () => props.app.updateAccountExpiry(), }; }; diff --git a/app/containers/SupportPage.js b/app/containers/SupportPage.js index ffab2d2095..78d1b41ebe 100644 --- a/app/containers/SupportPage.js +++ b/app/containers/SupportPage.js @@ -1,7 +1,7 @@ // @flow import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; -import { push } from 'connected-react-router'; +import { goBack } from 'connected-react-router'; import Support from '../components/Support'; import { openItem } from '../lib/platform'; import { collectProblemReport, sendProblemReport } from '../lib/problem-report'; @@ -18,10 +18,10 @@ const mapStateToProps = (state: ReduxState) => ({ const mapDispatchToProps = (dispatch: ReduxDispatch, _props: SharedRouteProps) => { const { saveReportForm, clearReportForm } = bindActionCreators(supportActions, dispatch); - const { push: pushHistory } = bindActionCreators({ push }, dispatch); + const history = bindActionCreators({ goBack }, dispatch); return { - onClose: () => pushHistory('/settings'), + onClose: () => history.goBack(), viewLog: (path) => openItem(path), saveReportForm, clearReportForm, diff --git a/app/lib/daemon-rpc.js b/app/lib/daemon-rpc.js index fb3915e068..163ea170d0 100644 --- a/app/lib/daemon-rpc.js +++ b/app/lib/daemon-rpc.js @@ -327,7 +327,7 @@ export class DaemonRpc implements DaemonRpcProtocol { const validatedObject = validate(RelaySettingsSchema, response); /* $FlowFixMe: - There is no way to express the constraints with string literals, i.e: + There is no way to express constraints with string literals, i.e: RelaySettingsSchema constraint: oneOf(string, object) diff --git a/app/lib/jsonrpc-transport.js b/app/lib/jsonrpc-transport.js index 2fa98df988..cbf1e7dce8 100644 --- a/app/lib/jsonrpc-transport.js +++ b/app/lib/jsonrpc-transport.js @@ -123,7 +123,7 @@ const DEFAULT_TIMEOUT_MILLIS = 5000; export default class JsonRpcTransport extends EventEmitter { _unansweredRequests: Map<string, UnansweredRequest> = new Map(); _subscriptions: Map<string | number, (mixed) => void> = new Map(); - _websocket: ?WebSocket; + _webSocket: ?WebSocket; _websocketFactory: (string) => WebSocket; constructor(websocketFactory: ?(string) => WebSocket) { @@ -133,46 +133,60 @@ export default class JsonRpcTransport extends EventEmitter { } /// Connect websocket - connect(connectionString: string) { - this.disconnect(); + connect(connectionString: string): Promise<void> { + return new Promise((resolve, reject) => { + this.disconnect(); - log.info('Connecting to websocket', connectionString); + log.info('Connecting to websocket', connectionString); - const websocket = this._websocketFactory(connectionString); + const webSocket = this._websocketFactory(connectionString); - websocket.onopen = () => { - log.info('Websocket is connected'); - this.emit('open'); - }; + // A flag used to determine if Promise was resolved. + let isPromiseResolved = false; - websocket.onmessage = (event) => { - const data = event.data; - if (typeof data === 'string') { - this._onMessage(data); - } else { - log.error('Got invalid reply from the server', event); - } - }; + webSocket.onopen = () => { + log.info('Websocket is connected'); + this.emit('open'); + + // Resolve the Promise + resolve(); + isPromiseResolved = true; + }; + + webSocket.onmessage = (event) => { + const data = event.data; + if (typeof data === 'string') { + this._onMessage(data); + } else { + log.error('Got invalid reply from the server', event); + } + }; - websocket.onclose = (event) => { - log.info(`The websocket connection closed with code: ${event.code}`); + webSocket.onclose = (event) => { + log.info(`The websocket connection closed with code: ${event.code}`); - // Remove all subscriptions since they are connection based - this._subscriptions.clear(); + // Remove all subscriptions since they are connection based + this._subscriptions.clear(); - // 1000 is a code used for normal connection closure. - const connectionError = event.code === 1000 ? null : new ConnectionError(event.code); + // 1000 is a code used for normal connection closure. + const connectionError = event.code === 1000 ? null : new ConnectionError(event.code); - this.emit('close', connectionError); - }; + this.emit('close', connectionError); - this._websocket = websocket; + // Prevent rejecting a previously resolved Promise. + if (!isPromiseResolved) { + reject(connectionError); + } + }; + + this._webSocket = webSocket; + }); } disconnect() { - if (this._websocket) { - this._websocket.close(); - this._websocket = null; + if (this._webSocket) { + this._webSocket.close(); + this._webSocket = null; } } @@ -195,19 +209,14 @@ export default class JsonRpcTransport extends EventEmitter { } } - async send( - action: string, - data: mixed, - timeout: number = DEFAULT_TIMEOUT_MILLIS, - ): Promise<mixed> { - let socket: WebSocket; - try { - socket = await this._getWebSocket(); - } catch (error) { - throw error; - } - + send(action: string, data: mixed, timeout: number = DEFAULT_TIMEOUT_MILLIS): Promise<mixed> { return new Promise((resolve, reject) => { + const webSocket = this._webSocket; + if (!webSocket) { + reject(new Error('Websocket is not connected.')); + return; + } + const id = uuid.v4(); const payload = this._prepareParams(data); const timerId = setTimeout(() => this._onTimeout(id), timeout); @@ -221,9 +230,14 @@ export default class JsonRpcTransport extends EventEmitter { try { log.silly('Sending message', id, action); - socket.send(JSON.stringify(message)); + webSocket.send(JSON.stringify(message)); } catch (error) { log.error(`Failed sending RPC message "${action}": ${error.message}`); + + // clean up on error + this._unansweredRequests.delete(id); + clearTimeout(timerId); + throw error; } }); @@ -245,25 +259,6 @@ export default class JsonRpcTransport extends EventEmitter { } } - _getWebSocket(): Promise<WebSocket> { - if (this._websocket && this._websocket.readyState === 1) { - return Promise.resolve(this._websocket); - } else { - return new Promise((resolve, reject) => { - log.debug('Waiting for websocket to connect'); - - this.once('open', () => { - const ws = this._websocket; - if (ws) { - resolve(ws); - } else { - reject(new Error('Internal error')); - } - }); - }); - } - } - _onTimeout(requestId) { const request = this._unansweredRequests.get(requestId); diff --git a/app/lib/reconnection-backoff.js b/app/lib/reconnection-backoff.js index 1cd8203dac..d777e86e57 100644 --- a/app/lib/reconnection-backoff.js +++ b/app/lib/reconnection-backoff.js @@ -1,13 +1,11 @@ +// @flow + /* * Used to calculate the time to wait before reconnecting to the daemon. * It uses a linear backoff function that goes from 500ms to 3000ms. */ export default class ReconnectionBackoff { - _attempt: number; - - constructor() { - this._attempt = 0; - } + _attempt = 0; attempt(handler: () => void) { setTimeout(handler, this._getIncreasedBackoff()); diff --git a/app/lib/window-state-observer.js b/app/lib/window-state-observer.js new file mode 100644 index 0000000000..67252d8f6a --- /dev/null +++ b/app/lib/window-state-observer.js @@ -0,0 +1,64 @@ +// @flow + +import { remote } from 'electron'; + +type EventListener = () => void; + +// Tiny helper for detecting the window state. +export default class WindowStateObserver { + _onShow: ?EventListener; + _onHide: ?EventListener; + + get onShow() { + return this._onShow; + } + + set onShow(listener: ?EventListener) { + const currentWindow = remote.getCurrentWindow(); + const oldListener = this._onShow; + if (oldListener) { + currentWindow.removeListener('show', oldListener); + } + + if (listener) { + currentWindow.addListener('show', listener); + } + + this._onShow = listener; + } + + get onHide() { + return this._onHide; + } + + set onHide(listener: ?EventListener) { + const currentWindow = remote.getCurrentWindow(); + const oldListener = this._onHide; + if (oldListener) { + currentWindow.removeListener('hide', oldListener); + } + + if (listener) { + currentWindow.addListener('hide', listener); + } + + this._onHide = listener; + } + + constructor() { + // Because BrowserWindow persists between page reloads, + // it's important to release event handlers when that happens. + window.addEventListener('beforeunload', this._onBeforeUnload); + } + + _onBeforeUnload = () => { + this.dispose(); + }; + + dispose() { + this.onShow = null; + this.onHide = null; + + window.removeEventListener('beforeunload', this._onBeforeUnload); + } +} diff --git a/app/redux/account/actions.js b/app/redux/account/actions.js index 3cc4b83681..ae42d91b6c 100644 --- a/app/redux/account/actions.js +++ b/app/redux/account/actions.js @@ -4,23 +4,13 @@ import { Clipboard } from 'reactxp'; import type { AccountToken } from '../../lib/daemon-rpc'; import type { ReduxThunk } from '../store'; -const copyAccountToken = (): ReduxThunk => { - return (_, getState) => { - const accountToken = getState().account.accountToken; - if (accountToken) { - Clipboard.setText(accountToken); - } - }; -}; - type StartLoginAction = { type: 'START_LOGIN', - accountToken?: AccountToken, + accountToken: AccountToken, }; type LoginSuccessfulAction = { type: 'LOGIN_SUCCESSFUL', - expiry?: string, }; type LoginFailedAction = { @@ -61,24 +51,23 @@ export type AccountAction = | UpdateAccountHistoryAction | UpdateAccountExpiryAction; -function startLogin(accountToken?: AccountToken): StartLoginAction { +function startLogin(accountToken: AccountToken): StartLoginAction { return { type: 'START_LOGIN', accountToken: accountToken, }; } -function loginSuccessful(expiry: string): LoginSuccessfulAction { +function loginSuccessful(): LoginSuccessfulAction { return { type: 'LOGIN_SUCCESSFUL', - expiry, }; } function loginFailed(error: Error): LoginFailedAction { return { type: 'LOGIN_FAILED', - error: error, + error, }; } @@ -88,10 +77,6 @@ function loggedOut(): LoggedOutAction { }; } -function autoLoginFailed(): LoggedOutAction { - return loggedOut(); -} - function resetLoginError(): ResetLoginErrorAction { return { type: 'RESET_LOGIN_ERROR', @@ -101,14 +86,14 @@ function resetLoginError(): ResetLoginErrorAction { function updateAccountToken(token: AccountToken): UpdateAccountTokenAction { return { type: 'UPDATE_ACCOUNT_TOKEN', - token: token, + token, }; } function updateAccountHistory(accountHistory: Array<AccountToken>): UpdateAccountHistoryAction { return { type: 'UPDATE_ACCOUNT_HISTORY', - accountHistory: accountHistory, + accountHistory, }; } @@ -119,15 +104,23 @@ function updateAccountExpiry(expiry: string): UpdateAccountExpiryAction { }; } +function copyAccountToken(): ReduxThunk { + return (_, getState) => { + const accountToken = getState().account.accountToken; + if (accountToken) { + Clipboard.setText(accountToken); + } + }; +} + export default { - copyAccountToken, startLogin, loginSuccessful, loginFailed, loggedOut, - autoLoginFailed, resetLoginError, updateAccountToken, updateAccountHistory, updateAccountExpiry, + copyAccountToken, }; diff --git a/app/redux/account/reducers.js b/app/redux/account/reducers.js index dbd854a6c0..7263b51166 100644 --- a/app/redux/account/reducers.js +++ b/app/redux/account/reducers.js @@ -40,7 +40,6 @@ export default function( ...{ status: 'ok', error: null, - expiry: action.expiry, }, }; case 'LOGIN_FAILED': diff --git a/app/redux/connection/actions.js b/app/redux/connection/actions.js index bb155aa485..915eedd273 100644 --- a/app/redux/connection/actions.js +++ b/app/redux/connection/actions.js @@ -4,21 +4,14 @@ import { Clipboard } from 'reactxp'; import type { ReduxThunk } from '../store'; import type { Ip } from '../../lib/daemon-rpc'; -const copyIPAddress = (): ReduxThunk => { - return (_, getState) => { - const ip = getState().connection.ip; - if (ip) { - Clipboard.setText(ip); - } - }; -}; - type ConnectingAction = { type: 'CONNECTING', }; + type ConnectedAction = { type: 'CONNECTED', }; + type DisconnectedAction = { type: 'DISCONNECTED', }; @@ -88,12 +81,21 @@ function offline(): OfflineAction { }; } +function copyIPAddress(): ReduxThunk { + return (_, getState) => { + const ip = getState().connection.ip; + if (ip) { + Clipboard.setText(ip); + } + }; +} + export default { - copyIPAddress, newLocation, connecting, connected, disconnected, online, offline, + copyIPAddress, }; diff --git a/app/redux/daemon/actions.js b/app/redux/daemon/actions.js new file mode 100644 index 0000000000..904cb3bd61 --- /dev/null +++ b/app/redux/daemon/actions.js @@ -0,0 +1,24 @@ +// @flow + +export type DaemonConnectedAction = { + type: 'DAEMON_CONNECTED', +}; +export type DaemonDisconnectedAction = { + type: 'DAEMON_DISCONNECTED', +}; + +export type DaemonAction = DaemonConnectedAction | DaemonDisconnectedAction; + +function connected(): DaemonConnectedAction { + return { + type: 'DAEMON_CONNECTED', + }; +} + +function disconnected(): DaemonDisconnectedAction { + return { + type: 'DAEMON_DISCONNECTED', + }; +} + +export default { connected, disconnected }; diff --git a/app/redux/daemon/reducers.js b/app/redux/daemon/reducers.js new file mode 100644 index 0000000000..5e9e707ff6 --- /dev/null +++ b/app/redux/daemon/reducers.js @@ -0,0 +1,33 @@ +// @flow + +import type { ReduxAction } from '../store'; + +export type DaemonReduxState = { + isConnected: boolean, +}; + +const initialState: DaemonReduxState = { + isConnected: false, +}; + +export default function( + state: DaemonReduxState = initialState, + action: ReduxAction, +): DaemonReduxState { + switch (action.type) { + case 'DAEMON_CONNECTED': + return { + ...state, + isConnected: true, + }; + + case 'DAEMON_DISCONNECTED': + return { + ...state, + isConnected: false, + }; + + default: + return state; + } +} diff --git a/app/redux/store.js b/app/redux/store.js index 8afe22d716..e269d9902b 100644 --- a/app/redux/store.js +++ b/app/redux/store.js @@ -11,6 +11,8 @@ import settings from './settings/reducers'; import settingsActions from './settings/actions'; import support from './support/reducers'; import supportActions from './support/actions'; +import daemon from './daemon/reducers'; +import daemonActions from './daemon/actions'; import type { Store, StoreEnhancer } from 'redux'; import type { History } from 'history'; @@ -18,20 +20,28 @@ import type { AccountReduxState } from './account/reducers'; import type { ConnectionReduxState } from './connection/reducers'; import type { SettingsReduxState } from './settings/reducers'; import type { SupportReduxState } from './support/reducers'; +import type { DaemonReduxState } from './daemon/reducers'; import type { AccountAction } from './account/actions'; import type { ConnectionAction } from './connection/actions'; import type { SettingsAction } from './settings/actions'; import type { SupportAction } from './support/actions'; +import type { DaemonAction } from './daemon/actions'; export type ReduxState = { account: AccountReduxState, connection: ConnectionReduxState, settings: SettingsReduxState, support: SupportReduxState, + daemon: DaemonReduxState, }; -export type ReduxAction = AccountAction | ConnectionAction | SettingsAction | SupportAction; +export type ReduxAction = + | AccountAction + | ConnectionAction + | SettingsAction + | SupportAction + | DaemonAction; export type ReduxStore = Store<ReduxState, ReduxAction, ReduxDispatch>; export type ReduxGetState = () => ReduxState; export type ReduxDispatch = (action: ReduxAction | ReduxThunk) => any; @@ -48,6 +58,7 @@ export default function configureStore( ...connectionActions, ...settingsActions, ...supportActions, + ...daemonActions, pushRoute: (route) => push(route), replaceRoute: (route) => replace(route), }; @@ -57,6 +68,7 @@ export default function configureStore( connection, settings, support, + daemon, }; const middlewares = [thunk, router]; diff --git a/app/routes.js b/app/routes.js index 95ad24e876..107ee616be 100644 --- a/app/routes.js +++ b/app/routes.js @@ -4,6 +4,7 @@ import * as React from 'react'; import { Switch, Route, Redirect } from 'react-router'; import TransitionContainer from './components/TransitionContainer'; import PlatformWindow from './components/PlatformWindow'; +import LaunchPage from './containers/LaunchPage'; import LoginPage from './containers/LoginPage'; import ConnectPage from './containers/ConnectPage'; import SettingsPage from './containers/SettingsPage'; @@ -38,7 +39,6 @@ export default function makeRoutes( // example: <PublicRoute path="/" component={ MyComponent } /> const PublicRoute = ({ component, ...otherProps }) => { return ( - // $FlowFixMe: This has been fixed in Flow 0.71 <Route {...otherProps} render={(routeProps) => { @@ -52,7 +52,6 @@ export default function makeRoutes( // example: <PrivateRoute path="/protected" component={ MyComponent } /> const PrivateRoute = ({ component, ...otherProps }) => { return ( - // $FlowFixMe: This has been fixed in Flow 0.71 <Route {...otherProps} render={(routeProps) => { @@ -62,7 +61,7 @@ export default function makeRoutes( if (isLoggedIn) { return renderMergedProps(component, routeProps, otherProps); } else { - return <Redirect to={'/'} />; + return <Redirect to={'/login'} />; } }} /> @@ -74,7 +73,6 @@ export default function makeRoutes( // example: <LoginRoute path="/login" component={ MyComponent } /> const LoginRoute = ({ component, ...otherProps }) => { return ( - // $FlowFixMe: This has been fixed in Flow 0.71 <Route {...otherProps} render={(routeProps) => { @@ -91,11 +89,29 @@ export default function makeRoutes( ); }; + // Renders launch route that is only available when daemon is not connected. + // Otherwise this route redirects user to /login. + // example: <LaunchRoute path="/" component={ MyComponent } /> + const LaunchRoute = ({ component, ...otherProps }) => { + return ( + <Route + {...otherProps} + render={(routeProps) => { + const { daemon } = getState(); + if (daemon.isConnected) { + return <Redirect to={'/login'} />; + } else { + return renderMergedProps(component, routeProps, otherProps); + } + }} + /> + ); + }; + // store previous route let previousRoute: ?string; return ( - // $FlowFixMe: This has been fixed in Flow 0.71 <Route render={({ location }) => { const toRoute = location.pathname; @@ -107,7 +123,8 @@ export default function makeRoutes( <PlatformWindow> <TransitionContainer {...transitionProps}> <Switch key={location.key} location={location}> - <LoginRoute exact path="/" component={LoginPage} /> + <LaunchRoute exact path="/" component={LaunchPage} /> + <LoginRoute exact path="/login" component={LoginPage} /> <PrivateRoute exact path="/connect" component={ConnectPage} /> <PublicRoute exact path="/settings" component={SettingsPage} /> <PrivateRoute exact path="/settings/account" component={AccountPage} /> @@ -1 +1 @@ -require('./build/main');
\ No newline at end of file +require('./build/main'); diff --git a/package.json b/package.json index 084a96b0ca..f2aa5b4425 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "react-redux": "^5.0.7", "react-router": "^4.3.1", "react-simple-maps": "^0.10.1", - "reactxp": "^1.3.0", + "reactxp": "^1.3.3", "redux": "^4.0.0", "redux-thunk": "^2.3.0", "uuid": "^3.0.1", @@ -60,13 +60,13 @@ "enzyme-adapter-react-16": "^1.1.0", "eslint": "^4.19.1", "eslint-config-prettier": "^2.9.0", - "eslint-plugin-flowtype": "^2.49.3", - "eslint-plugin-react": "^7.9.1", + "eslint-plugin-flowtype": "^2.50.0", + "eslint-plugin-react": "^7.10.0", "flow-bin": "^0.78.0", - "flow-typed": "^2.4.0", - "mock-socket": "^7.1.0", + "flow-typed": "^2.5.1", + "mock-socket": "^8.0.2", "npm-run-all": "^4.0.1", - "prettier": "1.13.7", + "prettier": "1.14.0", "rimraf": "^2.5.4" }, "scripts": { @@ -90,6 +90,6 @@ "private:serve": "cross-env BABEL_ENV=electron babel-node scripts/serve.js", "private:compile": "babel app/ --copy-files --out-dir build", "private:clean": "rimraf build", - "private:format": "cross-env prettier 'app/**/*.js' 'test/**/*.js'" + "private:format": "cross-env prettier 'app/**/*.js' 'test/**/*.js' 'scripts/*.js'" } } diff --git a/scripts/serve.js b/scripts/serve.js index b658b0dc70..03abeab2c2 100644 --- a/scripts/serve.js +++ b/scripts/serve.js @@ -15,38 +15,39 @@ const getClientUrl = (options) => { return getRootUrl(options) + pathname; }; -bsync.init({ - ui: false, - // Port 35829 = LiveReload's default port 35729 + 100. - // If the port is occupied, Browsersync uses next free port automatically. - port: 35829, - ghostMode: false, - open: false, - notify: false, - logSnippet: false, - socket: { - // Use the actual port here. - domain: getRootUrl - } -}, (err, bs) => { - if (err) return console.error(err); +bsync.init( + { + ui: false, + // Port 35829 = LiveReload's default port 35729 + 100. + // If the port is occupied, Browsersync uses next free port automatically. + port: 35829, + ghostMode: false, + open: false, + notify: false, + logSnippet: false, + socket: { + // Use the actual port here. + domain: getRootUrl, + }, + }, + (err, bs) => { + if (err) return console.error(err); - const child = spawn(electron, ['.'], { - env: { - ...{ - NODE_ENV: 'development', - BROWSER_SYNC_CLIENT_URL: getClientUrl(bs.options) + const child = spawn(electron, ['.'], { + env: { + ...{ + NODE_ENV: 'development', + BROWSER_SYNC_CLIENT_URL: getClientUrl(bs.options), + }, + ...process.env, }, - ...process.env - }, - stdio: 'inherit' - }); + stdio: 'inherit', + }); - child.on('close', () => { - process.exit(); - }); + child.on('close', () => { + process.exit(); + }); - bsync - .watch('build/**/*') - .on('change', bsync.reload); -}); + bsync.watch('build/**/*').on('change', bsync.reload); + }, +); diff --git a/test/components/Account.spec.js b/test/components/Account.spec.js index a850c6af06..703ce3d27f 100644 --- a/test/components/Account.spec.js +++ b/test/components/Account.spec.js @@ -4,7 +4,8 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import Account from '../../app/components/Account'; import { BackBarItem } from '../../app/components/NavigationBar'; -import type { AccountProps } from '../../app/components/Account'; + +type AccountProps = React.ElementProps<typeof Account>; describe('components/Account', () => { const makeProps = (mergeProps: $Shape<AccountProps>): AccountProps => { diff --git a/test/components/Connect.spec.js b/test/components/Connect.spec.js index d21a8eb510..e445b2c788 100644 --- a/test/components/Connect.spec.js +++ b/test/components/Connect.spec.js @@ -5,7 +5,7 @@ import { shallow } from 'enzyme'; import Connect from '../../app/components/Connect'; -import type { ConnectProps } from '../../app/components/Connect'; +type ConnectProps = React.ElementProps<typeof Connect>; describe('components/Connect', () => { it('shows unsecured hints when disconnected', () => { @@ -19,7 +19,7 @@ describe('components/Connect', () => { const header = getComponent(component, 'header'); const securityMessage = getComponent(component, 'networkSecurityMessage'); const connectButton = getComponent(component, 'secureConnection'); - expect(header.prop('style')).to.equal('error'); + expect(header.prop('barStyle')).to.equal('error'); expect(securityMessage.html()).to.contain('UNSECURED'); expect(connectButton.html()).to.contain('Secure my connection'); }); @@ -35,7 +35,7 @@ describe('components/Connect', () => { const header = getComponent(component, 'header'); const securityMessage = getComponent(component, 'networkSecurityMessage'); const disconnectButton = getComponent(component, 'disconnect'); - expect(header.prop('style')).to.equal('success'); + expect(header.prop('barStyle')).to.equal('success'); expect(securityMessage.html()).to.contain('SECURE '); expect(disconnectButton.html()).to.contain('Disconnect'); }); @@ -125,6 +125,7 @@ const defaultProps: ConnectProps = { country: null, city: null, }, + updateAccountExpiry: () => Promise.resolve(), }; function renderWithProps(customProps: $Shape<ConnectProps>) { diff --git a/test/components/HeaderBar.spec.js b/test/components/HeaderBar.spec.js deleted file mode 100644 index 09eab134ad..0000000000 --- a/test/components/HeaderBar.spec.js +++ /dev/null @@ -1,44 +0,0 @@ -// @flow - -import React from 'react'; -import { shallow } from 'enzyme'; -import HeaderBar from '../../app/components/HeaderBar'; - -describe('components/HeaderBar', () => { - it('should display settings button', () => { - const component = render({ - showSettings: true, - }); - const hasChildMatching = hasChild(component, 'headerbar__settings'); - expect(hasChildMatching).to.be.true; - }); - - it('should not display settings button', () => { - const component = render({ - showSettings: false, - }); - const hasChildMatching = hasChild(component, 'headerbar__settings'); - expect(hasChildMatching).to.be.false; - }); - - it('should call settings callback', (done) => { - const component = render({ - showSettings: true, - onSettings: () => done(), - }); - const settingsButton = getComponent(component, 'headerbar__settings'); - settingsButton.simulate('press'); - }); -}); - -function render(props) { - return shallow(<HeaderBar {...props} />); -} - -function getComponent(container, testName) { - return container.findWhere((n) => n.prop('testName') === testName); -} - -function hasChild(container, testName) { - return getComponent(container, testName).length > 0; -} diff --git a/test/components/Settings.spec.js b/test/components/Settings.spec.js index 9edf46fcc3..a9302a9ea7 100644 --- a/test/components/Settings.spec.js +++ b/test/components/Settings.spec.js @@ -5,180 +5,201 @@ import { shallow } from 'enzyme'; import Settings from '../../app/components/Settings'; import { CloseBarItem } from '../../app/components/NavigationBar'; -import type { AccountReduxState } from '../../app/redux/account/reducers'; -import type { SettingsReduxState } from '../../app/redux/settings/reducers'; -import type { SettingsProps } from '../../app/components/Settings'; +type SettingsProps = React.ElementProps<typeof Settings>; describe('components/Settings', () => { - const loggedOutAccountState: AccountReduxState = { - accountToken: null, - accountHistory: [], - expiry: null, - status: 'none', - error: null, - }; - - const loggedInAccountState: AccountReduxState = { - accountToken: '1234', - accountHistory: [], - expiry: new Date('2038-01-01').toISOString(), - status: 'ok', - error: null, - }; - - const unpaidAccountState: AccountReduxState = { - accountToken: '1234', - accountHistory: [], - expiry: new Date('2001-01-01').toISOString(), - status: 'ok', - error: null, - }; - - const settingsState: SettingsReduxState = { - relaySettings: { - normal: { - location: 'any', - protocol: 'udp', - port: 1301, - }, - }, - relayLocations: [], - autoConnect: false, - allowLan: false, - }; - - const makeProps = ( - anAccountState: AccountReduxState, - aSettingsState: SettingsReduxState, - mergeProps: $Shape<SettingsProps> = {}, - ): SettingsProps => { - const defaultProps: SettingsProps = { - account: anAccountState, - settings: aSettingsState, - version: '', - onQuit: () => {}, - onClose: () => {}, - onViewAccount: () => {}, - onViewSupport: () => {}, - onViewAdvancedSettings: () => {}, - onViewPreferences: () => {}, - onExternalLink: (_type) => {}, - }; - return Object.assign({}, defaultProps, mergeProps); + const defaultProps: SettingsProps = { + loginState: 'none', + accountExpiry: null, + appVersion: '', + onQuit: () => {}, + onClose: () => {}, + onViewAccount: () => {}, + onViewSupport: () => {}, + onViewAdvancedSettings: () => {}, + onViewPreferences: () => {}, + onExternalLink: (_type) => {}, + updateAccountExpiry: () => Promise.resolve(), }; it('should show quit button when logged out', () => { - const props = makeProps(loggedOutAccountState, settingsState); - const component = getComponent(render(props), 'settings__quit'); + const props = { + ...defaultProps, + loginState: 'none', + accountExpiry: null, + }; + const component = getComponent(shallow(<Settings {...props} />), 'settings__quit'); expect(component).to.have.length(1); }); it('should show quit button when logged in', () => { - const props = makeProps(loggedInAccountState, settingsState); - const component = getComponent(render(props), 'settings__quit'); + const props = { + ...defaultProps, + loginState: 'ok', + accountExpiry: new Date('2038-01-01').toISOString(), + }; + const component = getComponent(shallow(<Settings {...props} />), 'settings__quit'); expect(component).to.have.length(1); }); it('should show external links when logged out', () => { - const props = makeProps(loggedOutAccountState, settingsState); - const component = getComponent(render(props), 'settings__external_link'); + const props = { + ...defaultProps, + loginState: 'none', + accountExpiry: null, + }; + const component = getComponent(shallow(<Settings {...props} />), 'settings__external_link'); expect(component.length).to.be.above(0); }); it('should show external links when logged in', () => { - const props = makeProps(loggedInAccountState, settingsState); - const component = getComponent(render(props), 'settings__external_link'); + const props = { + ...defaultProps, + loginState: 'ok', + accountExpiry: new Date('2038-01-01').toISOString(), + }; + const component = getComponent(shallow(<Settings {...props} />), 'settings__external_link'); expect(component.length).to.be.above(0); }); it('should show account section when logged in', () => { - const props = makeProps(loggedInAccountState, settingsState); - const component = getComponent(render(props), 'settings__account'); + const props = { + ...defaultProps, + loginState: 'ok', + accountExpiry: new Date('2038-01-01').toISOString(), + }; + const component = getComponent(shallow(<Settings {...props} />), 'settings__account'); expect(component).to.have.length(1); }); it('should hide account section when logged out', () => { - const props = makeProps(loggedOutAccountState, settingsState); - const elements = getComponent(render(props), 'settings__account'); + const props = { + ...defaultProps, + loginState: 'none', + accountExpiry: null, + }; + const elements = getComponent(shallow(<Settings {...props} />), 'settings__account'); expect(elements).to.have.length(0); }); it('should hide account link when not logged in', () => { - const props = makeProps(loggedOutAccountState, settingsState); - const elements = getComponent(render(props), 'settings__view_account'); + const props = { + ...defaultProps, + loginState: 'none', + accountExpiry: null, + }; + const elements = getComponent(shallow(<Settings {...props} />), 'settings__view_account'); expect(elements).to.have.length(0); }); it('should show out-of-time message for unpaid account', () => { - const props = makeProps(unpaidAccountState, settingsState); - const component = getComponent(render(props), 'settings__account_paid_until_subtext'); + const props = { + ...defaultProps, + loginState: 'ok', + accountExpiry: new Date('2001-01-01').toISOString(), + }; + const component = getComponent( + shallow(<Settings {...props} />), + 'settings__account_paid_until_subtext', + ); expect(component.children().text()).to.equal('OUT OF TIME'); }); it('should hide out-of-time message for paid account', () => { - const props = makeProps(loggedInAccountState, settingsState); - const component = getComponent(render(props), 'settings__account_paid_until_subtext'); + const props = { + ...defaultProps, + loginState: 'ok', + accountExpiry: new Date('2038-01-01').toISOString(), + }; + const component = getComponent( + shallow(<Settings {...props} />), + 'settings__account_paid_until_subtext', + ); expect(component.children().text()).not.to.equal('OUT OF TIME'); }); it('should call close callback', (done) => { - const props = makeProps(loggedOutAccountState, settingsState, { + const props = { + ...defaultProps, + loginState: 'none', + accountExpiry: null, onClose: () => done(), - }); - const component = render(props) + }; + const component = shallow(<Settings {...props} />) .find(CloseBarItem) .dive(); component.simulate('press'); }); it('should call quit callback', (done) => { - const props = makeProps(loggedOutAccountState, settingsState, { + const props = { + ...defaultProps, + loginState: 'none', + accountExpiry: null, onQuit: () => done(), - }); - const component = getComponent(render(props), 'settings__quit'); + }; + const component = getComponent(shallow(<Settings {...props} />), 'settings__quit'); component.simulate('press'); }); it('should call account callback', (done) => { - const props = makeProps(loggedInAccountState, settingsState, { + const props = { + ...defaultProps, + loginState: 'ok', + accountExpiry: new Date('2038-01-01').toISOString(), onViewAccount: () => done(), - }); - const component = getComponent(render(props), 'settings__account_paid_until_button'); + }; + const component = getComponent( + shallow(<Settings {...props} />), + 'settings__account_paid_until_button', + ); component.simulate('press'); }); it('should call advanced settings callback', (done) => { - const props = makeProps(loggedInAccountState, settingsState, { + const props = { + ...defaultProps, + loginState: 'ok', + accountExpiry: new Date('2038-01-01').toISOString(), onViewAdvancedSettings: () => done(), - }); - const component = getComponent(render(props), 'settings__advanced'); + }; + const component = getComponent(shallow(<Settings {...props} />), 'settings__advanced'); component.simulate('press'); }); it('should call preferences callback', (done) => { - const props = makeProps(loggedInAccountState, settingsState, { + const props = { + ...defaultProps, + loginState: 'ok', + accountExpiry: new Date('2038-01-01').toISOString(), onViewPreferences: () => done(), - }); - const component = getComponent(render(props), 'settings__preferences'); + }; + const component = getComponent(shallow(<Settings {...props} />), 'settings__preferences'); component.simulate('press'); }); it('should call support callback', (done) => { - const props = makeProps(loggedInAccountState, settingsState, { + const props = { + ...defaultProps, + loginState: 'ok', + accountExpiry: new Date('2038-01-01').toISOString(), onViewSupport: () => done(), - }); - const component = getComponent(render(props), 'settings__view_support'); + }; + const component = getComponent(shallow(<Settings {...props} />), 'settings__view_support'); component.simulate('press'); }); it('should call external links callback', () => { const collectedExternalLinkTypes: Array<string> = []; - const props = makeProps(loggedOutAccountState, settingsState, { + const props = { + ...defaultProps, + loginState: 'none', + accountExpiry: null, onExternalLink: (type) => { collectedExternalLinkTypes.push(type); }, - }); - const container = getComponent(render(props), 'settings__external_link'); + }; + const container = getComponent(shallow(<Settings {...props} />), 'settings__external_link'); container .find({ testName: 'settings__external_link' }) .forEach((element) => element.simulate('press')); @@ -187,10 +208,6 @@ describe('components/Settings', () => { }); }); -function render(props) { - return shallow(<Settings {...props} />); -} - function getComponent(container, testName) { return container.findWhere((n) => n.prop('testName') === testName); } diff --git a/test/jsonrpc-transport.spec.js b/test/jsonrpc-transport.spec.js index 1f36be83a6..ca66514192 100644 --- a/test/jsonrpc-transport.spec.js +++ b/test/jsonrpc-transport.spec.js @@ -1,10 +1,8 @@ // @flow -import JsonRpcTransport, { - TimeOutError as JsonRpcTransportTimeOutError, -} from '../app/lib/jsonrpc-transport'; import jsonrpc from 'jsonrpc-lite'; import { Server, WebSocket as MockWebSocket } from 'mock-socket'; +import JsonRpcTransport, { TimeOutError } from '../app/lib/jsonrpc-transport'; describe('JSON RPC transport', () => { const WEBSOCKET_URL = 'ws://localhost:8080'; @@ -12,104 +10,94 @@ describe('JSON RPC transport', () => { beforeEach(() => { server = new Server(WEBSOCKET_URL); - transport = new JsonRpcTransport((s) => new MockWebSocket(s)); + transport = new JsonRpcTransport((url) => new MockWebSocket(url)); }); afterEach(() => { server.close(); }); - it('should send as soon as the websocket connects', () => { - server.on('message', (msg) => { - const { payload } = jsonrpc.parse(msg); - - if (payload.method === 'hello') { - server.send(JSON.stringify(jsonrpc.success(payload.id, 'ok'))); - } - }); - - const sendPromise = transport.send('hello'); - - transport.connect(WEBSOCKET_URL); - - return expect(sendPromise).to.eventually.be.fulfilled; - }); - - it('should reject failed jsonrpc requests', () => { - server.on('message', (msg) => { - const { payload } = jsonrpc.parse(msg); - - if (payload.method === 'invalid-method') { - server.send( - JSON.stringify( - jsonrpc.error(payload.id, new jsonrpc.JsonRpcError('Method not found', -32601)), - ), - ); - } + it('should reject failed jsonrpc requests', async () => { + server.on('connection', (socket) => { + socket.on('message', (msg) => { + const { payload } = jsonrpc.parse(msg); + if (payload.method === 'invalid-method') { + socket.send( + JSON.stringify( + jsonrpc.error(payload.id, new jsonrpc.JsonRpcError('Method not found', -32601)), + ), + ); + } + }); }); + await transport.connect(WEBSOCKET_URL); const sendPromise = transport.send('invalid-method'); - transport.connect(WEBSOCKET_URL); - return expect(sendPromise).to.eventually.be.rejectedWith('Method not found'); }); - it('should route reply to correct promise', () => { - server.on('message', (msg) => { - const { payload } = jsonrpc.parse(msg); - - if (payload.method === 'a message') { - server.send(JSON.stringify(jsonrpc.success(payload.id, 'a reply'))); - } + it('should route reply to correct promise', async () => { + server.on('connection', (socket) => { + socket.on('message', (msg) => { + const { payload } = jsonrpc.parse(msg); + if (payload.method === 'a message') { + socket.send(JSON.stringify(jsonrpc.success(payload.id, 'a reply'))); + } + }); }); + await transport.connect(WEBSOCKET_URL); + const decoyPromise = transport.send('a decoy', [], 100); const messagePromise = transport.send('a message', [], 100); - transport.connect(WEBSOCKET_URL); - return Promise.all([ expect(messagePromise).to.eventually.be.equal('a reply'), - expect(decoyPromise).to.eventually.be.rejectedWith(JsonRpcTransportTimeOutError), + expect(decoyPromise).to.eventually.be.rejectedWith(TimeOutError), ]); }); - it('should timeout if no response is returned', () => { + it('should timeout if no response is returned', async () => { + await transport.connect(WEBSOCKET_URL); const sendPromise = transport.send('timeout-message', {}, 1); - transport.connect(WEBSOCKET_URL); - - return expect(sendPromise).to.eventually.be.rejectedWith( - JsonRpcTransportTimeOutError, - 'Request timed out', - ); + return expect(sendPromise).to.eventually.be.rejectedWith(TimeOutError, 'Request timed out'); }); - it('should route notifications', () => { - server.on('message', (msg) => { - const { payload } = jsonrpc.parse(msg); + it('should route notifications', async () => { + server.on('connection', (socket) => { + socket.on('message', (msg) => { + const { payload } = jsonrpc.parse(msg); + if (payload.method === 'event_subscribe') { + socket.send(JSON.stringify(jsonrpc.success(payload.id, 1))); + } + }); + }); + + await transport.connect(WEBSOCKET_URL); - if (payload.method === 'event_subscribe') { - server.send(JSON.stringify(jsonrpc.success(payload.id, 1))); + const eventPromiseHelper = (() => { + let borrowedResolve: ?(mixed) => void; + const promise = new Promise((resolve) => (borrowedResolve = resolve)); + /* Flow does not understand that the body of Promise runs immediately. + see https://github.com/facebook/flow/issues/6711 */ + if (!borrowedResolve) { + throw new Error(); } - }); + return { + resolve: borrowedResolve, + promise, + }; + })(); - transport.connect(WEBSOCKET_URL); + await transport.subscribe('event', eventPromiseHelper.resolve); - let subscribePromise; - const eventPromise = new Promise((resolve) => { - subscribePromise = transport.subscribe('event', resolve).then((value) => { - server.send( - JSON.stringify(jsonrpc.notification('event', { subscription: 1, result: 'beacon' })), - ); - return value; - }); - }); + server.emit( + 'message', + JSON.stringify(jsonrpc.notification('event', { subscription: 1, result: 'beacon' })), + ); - return Promise.all([ - expect(subscribePromise).to.eventually.be.fulfilled, - expect(eventPromise).to.eventually.be.equal('beacon'), - ]); + return expect(eventPromiseHelper.promise).to.eventually.be.equal('beacon'); }); }); @@ -86,6 +86,19 @@ lodash "^4.2.0" to-fast-properties "^2.0.0" +"@octokit/rest@^15.2.6": + version "15.9.5" + resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-15.9.5.tgz#e356d202bd0b517e381f705ad77d98ccb84e0c65" + dependencies: + before-after-hook "^1.1.0" + btoa-lite "^1.0.0" + debug "^3.1.0" + http-proxy-agent "^2.1.0" + https-proxy-agent "^2.2.0" + lodash "^4.17.4" + node-fetch "^2.1.1" + url-template "^2.0.8" + "@types/lodash@4.14.110": version "4.14.110" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.110.tgz#fb07498f84152947f30ea09d89207ca07123461e" @@ -170,6 +183,12 @@ after@0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f" +agent-base@4, agent-base@^4.1.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9" + dependencies: + es6-promisify "^5.0.0" + ajv-keywords@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-2.1.1.tgz#617997fc5f60576894c435f940d819e135b80762" @@ -372,7 +391,7 @@ assert-plus@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-0.2.0.tgz#d74e1b87e7affc0db8aadb7021f3fe48101ab234" -assert@1.4.1: +assert@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/assert/-/assert-1.4.1.tgz#99912d591836b5a6f5b345c0f07eefc08fc65d91" dependencies: @@ -1259,6 +1278,10 @@ beeper@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/beeper/-/beeper-1.1.1.tgz#e6d5ea8c5dad001304a70b22638447f69cb2f809" +before-after-hook@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-1.1.0.tgz#83165e15a59460d13702cb8febd6a1807896db5a" + better-assert@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522" @@ -1458,6 +1481,10 @@ bser@^2.0.0: dependencies: node-int64 "^0.4.0" +btoa-lite@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/btoa-lite/-/btoa-lite-1.0.0.tgz#337766da15801210fdd956c22e9c6891ab9d0337" + buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" @@ -2884,10 +2911,16 @@ es-to-primitive@^1.1.1: is-date-object "^1.0.1" is-symbol "^1.0.1" -es6-promise@^4.0.5: +es6-promise@^4.0.3, es6-promise@^4.0.5: version "4.2.4" resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.4.tgz#dc4221c2b16518760bd8c39a52d8f356fc00ed29" +es6-promisify@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203" + dependencies: + es6-promise "^4.0.3" + escape-html@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.2.tgz#d77d32fa98e38c2f41ae85e9278e0e0e6ba1022c" @@ -2917,9 +2950,9 @@ eslint-config-prettier@^2.9.0: dependencies: get-stdin "^5.0.1" -eslint-plugin-flowtype@^2.49.3: - version "2.49.3" - resolved "https://registry.yarnpkg.com/eslint-plugin-flowtype/-/eslint-plugin-flowtype-2.49.3.tgz#ccca6ee5ba2027eb3ed36bc2ec8c9a842feee841" +eslint-plugin-flowtype@^2.50.0: + version "2.50.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-flowtype/-/eslint-plugin-flowtype-2.50.0.tgz#953e262fa9b5d0fa76e178604892cf60dfb916da" dependencies: lodash "^4.17.10" @@ -2927,14 +2960,14 @@ eslint-plugin-promise@^3.8.0: version "3.8.0" resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-3.8.0.tgz#65ebf27a845e3c1e9d6f6a5622ddd3801694b621" -eslint-plugin-react@^7.9.1: - version "7.9.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.9.1.tgz#101aadd15e7c7b431ed025303ac7b421a8e3dc15" +eslint-plugin-react@^7.10.0: + version "7.10.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.10.0.tgz#af5c1fef31c4704db02098f9be18202993828b50" dependencies: doctrine "^2.1.0" - has "^1.0.2" + has "^1.0.3" jsx-ast-utils "^2.0.1" - prop-types "^15.6.1" + prop-types "^15.6.2" eslint-scope@^3.7.1, eslint-scope@~3.7.1: version "3.7.1" @@ -3354,14 +3387,14 @@ flow-bin@^0.78.0: version "0.78.0" resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.78.0.tgz#df9fe7f9c9a2dfaff39083949fe2d831b41627b7" -flow-typed@^2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/flow-typed/-/flow-typed-2.4.0.tgz#3d2f48cf85df29df3bca6745b623726496ff4788" +flow-typed@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/flow-typed/-/flow-typed-2.5.1.tgz#0ff565cc94d2af8c557744ba364b6f14726a6b9f" dependencies: + "@octokit/rest" "^15.2.6" babel-polyfill "^6.26.0" colors "^1.1.2" fs-extra "^5.0.0" - github "0.2.4" glob "^7.1.2" got "^7.1.0" md5 "^2.1.0" @@ -3603,12 +3636,6 @@ github-username@^2.0.0: gh-got "^2.2.0" meow "^3.5.0" -github@0.2.4: - version "0.2.4" - resolved "https://registry.yarnpkg.com/github/-/github-0.2.4.tgz#24fa7f0e13fa11b946af91134c51982a91ce538b" - dependencies: - mime "^1.2.11" - glob-base@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" @@ -3938,7 +3965,7 @@ has@^1.0.1: dependencies: function-bind "^1.0.2" -has@^1.0.2: +has@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" dependencies: @@ -4063,6 +4090,13 @@ http-errors@~1.6.1: setprototypeof "1.0.3" statuses ">= 1.3.1 < 2" +http-proxy-agent@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz#e4821beef5b2142a2026bd73926fe537631c5405" + dependencies: + agent-base "4" + debug "3.1.0" + http-proxy@1.15.2: version "1.15.2" resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.15.2.tgz#642fdcaffe52d3448d2bda3b0079e9409064da31" @@ -4086,6 +4120,13 @@ http-signature@~1.2.0: jsprim "^1.2.2" sshpk "^1.7.0" +https-proxy-agent@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.1.tgz#51552970fa04d723e04c56d04178c3f92592bbc0" + dependencies: + agent-base "^4.1.0" + debug "^3.1.0" + iconv-lite@0.4.11: version "0.4.11" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.11.tgz#2ecb42fd294744922209a2e7c404dac8793d8ade" @@ -4912,10 +4953,6 @@ lodash.templatesettings@^3.0.0: lodash._reinterpolate "^3.0.0" lodash.escape "^3.0.0" -lodash@4.17.10, lodash@^4.17.10, lodash@^4.17.4, lodash@^4.17.5: - version "4.17.10" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.10.tgz#1b7793cf7259ea38fb3661d4d38b3260af8ae4e7" - lodash@^3.1.0, lodash@^3.10.1, lodash@^3.2.0, lodash@^3.3.1, lodash@^3.5.0: version "3.10.1" resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6" @@ -4928,6 +4965,10 @@ lodash@^4.14.0, lodash@^4.15.0, lodash@^4.16.6, lodash@^4.3.0, lodash@^4.6.1: version "4.17.4" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" +lodash@^4.17.10, lodash@^4.17.4, lodash@^4.17.5: + version "4.17.10" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.10.tgz#1b7793cf7259ea38fb3661d4d38b3260af8ae4e7" + log-symbols@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18" @@ -5194,7 +5235,7 @@ mime@1.3.4: version "1.3.4" resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.4.tgz#115f9e3b6b3daf2959983cb38f149a2d40eb5d53" -mime@^1.2.11, mime@^1.3.4: +mime@^1.3.4: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" @@ -5275,9 +5316,11 @@ mocha@^5.2.0: mkdirp "0.5.1" supports-color "5.4.0" -mock-socket@^7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/mock-socket/-/mock-socket-7.1.0.tgz#482ecccafb0f0e86b8905ba2aa57c1fe73ba262d" +mock-socket@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/mock-socket/-/mock-socket-8.0.2.tgz#899dbe376a33a10165341939e5dd4653532dcd13" + dependencies: + url-parse "^1.2.0" moment@^2.20.1: version "2.20.1" @@ -5386,6 +5429,10 @@ node-fetch@^1.0.1, node-fetch@^1.3.3: encoding "^0.1.11" is-stream "^1.0.1" +node-fetch@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.2.0.tgz#4ee79bde909262f9775f731e3656d0db55ced5b5" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -5998,9 +6045,9 @@ preserve@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" -prettier@1.13.7: - version "1.13.7" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.13.7.tgz#850f3b8af784a49a6ea2d2eaa7ed1428a34b7281" +prettier@1.14.0: + version "1.14.0" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.14.0.tgz#847c235522035fd988100f1f43cf20a7d24f9372" pretty-bytes@^1.0.2: version "1.0.4" @@ -6054,13 +6101,6 @@ promise@^7.1.1: dependencies: asap "~2.0.3" -prop-types@15.6.2: - version "15.6.2" - resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102" - dependencies: - loose-envify "^1.3.1" - object-assign "^4.1.1" - prop-types@^15.5.4, prop-types@^15.5.8, prop-types@^15.6.0: version "15.6.0" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856" @@ -6077,6 +6117,13 @@ prop-types@^15.6.1: loose-envify "^1.3.1" object-assign "^4.1.1" +prop-types@^15.6.2: + version "15.6.2" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102" + dependencies: + loose-envify "^1.3.1" + object-assign "^4.1.1" + ps-tree@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/ps-tree/-/ps-tree-1.1.0.tgz#b421b24140d6203f1ed3c76996b4427b08e8c014" @@ -6111,6 +6158,10 @@ qs@~6.5.1: version "6.5.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" +querystringify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.0.0.tgz#fa3ed6e68eb15159457c89b37bc6472833195755" + quickselect@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/quickselect/-/quickselect-1.0.1.tgz#1e6ceaa9db1ca7c75aafcc863c7bef2037ca62a1" @@ -6375,20 +6426,20 @@ react@^16.0.0: object-assign "^4.1.1" prop-types "^15.6.0" -reactxp@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/reactxp/-/reactxp-1.3.0.tgz#ccb3859b7713ea0cb921fa3155c84f02b36b0f17" +reactxp@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/reactxp/-/reactxp-1.3.3.tgz#fc950b9c63d5f78d2207341514231330f86309f2" dependencies: "@types/lodash" "4.14.110" "@types/react" "16.0.36" "@types/react-dom" "16.0.6" "@types/react-native" "0.55.26" - assert "1.4.1" - lodash "4.17.10" - prop-types "15.6.2" - rebound "0.1.0" - subscribableevent "1.0.0" - synctasks "0.3.3" + assert "^1.4.1" + lodash "^4.17.10" + prop-types "^15.6.2" + rebound "^0.1.0" + subscribableevent "^1.0.0" + synctasks "^0.3.3" read-all-stream@^3.0.0: version "3.1.0" @@ -6545,7 +6596,7 @@ readline2@^1.0.1: is-fullwidth-code-point "^1.0.0" mute-stream "0.0.5" -rebound@0.1.0: +rebound@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/rebound/-/rebound-0.1.0.tgz#0638c61a93666bb515a58a03e1cfb34021e88b72" @@ -6725,7 +6776,7 @@ require-uncached@^1.0.3: caller-path "^0.1.0" resolve-from "^1.0.0" -requires-port@1.x.x: +requires-port@1.x.x, requires-port@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" @@ -7400,7 +7451,7 @@ strip-outer@^1.0.0: dependencies: escape-string-regexp "^1.0.2" -subscribableevent@1.0.0: +subscribableevent@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/subscribableevent/-/subscribableevent-1.0.0.tgz#bde9500fa9009c7740c924109bac6119cd9898e6" dependencies: @@ -7456,7 +7507,7 @@ sync-exec@~0.6.x: version "0.6.2" resolved "https://registry.yarnpkg.com/sync-exec/-/sync-exec-0.6.2.tgz#717d22cc53f0ce1def5594362f3a89a2ebb91105" -synctasks@0.3.3: +synctasks@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/synctasks/-/synctasks-0.3.3.tgz#1e3dde423b39d28bc940fdb7698d8b4b7a741e77" @@ -7841,6 +7892,17 @@ url-parse-lax@^1.0.0: dependencies: prepend-http "^1.0.1" +url-parse@^1.2.0: + version "1.4.3" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.3.tgz#bfaee455c889023219d757e045fa6a684ec36c15" + dependencies: + querystringify "^2.0.0" + requires-port "^1.0.0" + +url-template@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/url-template/-/url-template-2.0.8.tgz#fc565a3cccbff7730c775f5641f9555791439f21" + url-to-options@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9" |
