summaryrefslogtreecommitdiffhomepage
path: root/app
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2018-08-08 16:53:23 +0200
committerAndrej Mihajlov <and@mullvad.net>2018-08-08 16:53:23 +0200
commitac98e48c68eadfdd7250eccd38aa492e6f830744 (patch)
tree05bfd7a9f05456220f11f40329093b127a5b20a2 /app
parentd53fda746465c0bb6525371b9d780cbfac90f942 (diff)
parenta8f2831cd50e98b1d126b8c3169adcf1ef75b8a2 (diff)
downloadmullvadvpn-ac98e48c68eadfdd7250eccd38aa492e6f830744.tar.xz
mullvadvpn-ac98e48c68eadfdd7250eccd38aa492e6f830744.zip
Merge branch 'remove-auto-login'
Diffstat (limited to 'app')
-rw-r--r--app/app.js148
-rw-r--r--app/components/Account.js27
-rw-r--r--app/components/AccountStyles.js1
-rw-r--r--app/components/Cell.js1
-rw-r--r--app/components/Connect.js41
-rw-r--r--app/components/CustomScrollbars.js2
-rw-r--r--app/components/HeaderBar.js163
-rw-r--r--app/components/HeaderBarPlatformStyles.android.js2
-rw-r--r--app/components/HeaderBarPlatformStyles.js16
-rw-r--r--app/components/HeaderBarStyles.js53
-rw-r--r--app/components/Img.js8
-rw-r--r--app/components/Launch.js58
-rw-r--r--app/components/Layout.js11
-rw-r--r--app/components/Login.js6
-rw-r--r--app/components/LoginStyles.js24
-rw-r--r--app/components/Settings.js43
-rw-r--r--app/containers/AccountPage.js6
-rw-r--r--app/containers/AdvancedSettingsPage.js9
-rw-r--r--app/containers/ConnectPage.js5
-rw-r--r--app/containers/LaunchPage.js23
-rw-r--r--app/containers/LoginPage.js4
-rw-r--r--app/containers/PreferencesPage.js8
-rw-r--r--app/containers/SelectLocationPage.js10
-rw-r--r--app/containers/SettingsPage.js23
-rw-r--r--app/containers/SupportPage.js6
-rw-r--r--app/lib/daemon-rpc.js2
-rw-r--r--app/lib/jsonrpc-transport.js117
-rw-r--r--app/lib/reconnection-backoff.js8
-rw-r--r--app/lib/window-state-observer.js64
-rw-r--r--app/redux/account/actions.js39
-rw-r--r--app/redux/account/reducers.js1
-rw-r--r--app/redux/connection/actions.js22
-rw-r--r--app/redux/daemon/actions.js24
-rw-r--r--app/redux/daemon/reducers.js33
-rw-r--r--app/redux/store.js14
-rw-r--r--app/routes.js29
36 files changed, 706 insertions, 345 deletions
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} />