summaryrefslogtreecommitdiffhomepage
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/app.js121
-rw-r--r--app/assets/css/buttons.css131
-rw-r--r--app/assets/css/fonts.css23
-rw-r--r--app/assets/css/global.css19
-rw-r--r--app/assets/css/reset.css48
-rw-r--r--app/assets/css/style.css19
-rw-r--r--app/assets/css/transitions.css91
-rw-r--r--app/assets/css/uiswitch.css135
-rwxr-xr-xapp/assets/fonts/DINPro-Black.otfbin0 -> 88940 bytes
-rwxr-xr-xapp/assets/fonts/DINPro-Bold.otfbin0 -> 90228 bytes
-rwxr-xr-xapp/assets/fonts/OpenSans-ExtraBold.ttfbin0 -> 222584 bytes
-rwxr-xr-xapp/assets/fonts/OpenSans-Semibold.ttfbin0 -> 221328 bytes
-rw-r--r--app/assets/images/app-header-backdrop.svg5
-rw-r--r--app/assets/images/app-triangle.svg5
-rwxr-xr-xapp/assets/images/icon-arrow.svg7
-rw-r--r--app/assets/images/icon-back.svg8
-rw-r--r--app/assets/images/icon-chevron.svg6
-rw-r--r--app/assets/images/icon-close.svg8
-rw-r--r--app/assets/images/icon-extLink.svg6
-rwxr-xr-xapp/assets/images/icon-fail.svg8
-rwxr-xr-xapp/assets/images/icon-fastest.svg7
-rw-r--r--app/assets/images/icon-nearest.svg6
-rwxr-xr-xapp/assets/images/icon-settings.svg9
-rw-r--r--app/assets/images/icon-spinner.svg17
-rwxr-xr-xapp/assets/images/icon-success.svg8
-rwxr-xr-xapp/assets/images/icon-tick.svg14
-rwxr-xr-xapp/assets/images/location-marker-secure.svg19
-rwxr-xr-xapp/assets/images/location-marker-unsecure.svg19
-rw-r--r--app/assets/images/logo-icon.svg1
-rw-r--r--app/assets/images/menubar icons/lock-1.pngbin0 -> 131 bytes
-rw-r--r--app/assets/images/menubar icons/lock-1@2x.pngbin0 -> 181 bytes
-rw-r--r--app/assets/images/menubar icons/lock-2.pngbin0 -> 143 bytes
-rw-r--r--app/assets/images/menubar icons/lock-2@2x.pngbin0 -> 193 bytes
-rw-r--r--app/assets/images/menubar icons/lock-3.pngbin0 -> 129 bytes
-rw-r--r--app/assets/images/menubar icons/lock-3@2x.pngbin0 -> 183 bytes
-rw-r--r--app/assets/images/menubar icons/lock-4.pngbin0 -> 138 bytes
-rw-r--r--app/assets/images/menubar icons/lock-4@2x.pngbin0 -> 189 bytes
-rw-r--r--app/assets/images/menubar icons/lock-5.pngbin0 -> 121 bytes
-rw-r--r--app/assets/images/menubar icons/lock-5@2x.pngbin0 -> 165 bytes
-rw-r--r--app/assets/images/menubar icons/lock-6.pngbin0 -> 128 bytes
-rw-r--r--app/assets/images/menubar icons/lock-6@2x.pngbin0 -> 168 bytes
-rw-r--r--app/assets/images/menubar icons/lock-7.pngbin0 -> 127 bytes
-rw-r--r--app/assets/images/menubar icons/lock-7@2x.pngbin0 -> 172 bytes
-rw-r--r--app/assets/images/menubar icons/lock-8.pngbin0 -> 127 bytes
-rw-r--r--app/assets/images/menubar icons/lock-8@2x.pngbin0 -> 167 bytes
-rw-r--r--app/assets/images/menubar icons/lock-9.pngbin0 -> 128 bytes
-rw-r--r--app/assets/images/menubar icons/lock-9@2x.pngbin0 -> 163 bytes
-rw-r--r--app/components/Account.css98
-rw-r--r--app/components/Account.js78
-rw-r--r--app/components/AccountInput.js317
-rw-r--r--app/components/AdvancedSettings.js145
-rw-r--r--app/components/Connect.css169
-rw-r--r--app/components/Connect.js369
-rw-r--r--app/components/CustomScrollbars.css4
-rw-r--r--app/components/CustomScrollbars.js19
-rw-r--r--app/components/HeaderBar.css61
-rw-r--r--app/components/HeaderBar.js51
-rw-r--r--app/components/Layout.css15
-rw-r--r--app/components/Layout.js46
-rw-r--r--app/components/Login.css154
-rw-r--r--app/components/Login.js212
-rw-r--r--app/components/Map.js51
-rw-r--r--app/components/SelectLocation.css106
-rw-r--r--app/components/SelectLocation.js112
-rw-r--r--app/components/Settings.css140
-rw-r--r--app/components/Settings.js117
-rw-r--r--app/components/Support.css169
-rw-r--r--app/components/Support.js254
-rw-r--r--app/components/Switch.css44
-rw-r--r--app/components/Switch.js142
-rw-r--r--app/components/WindowChrome.css6
-rw-r--r--app/components/WindowChrome.js16
-rw-r--r--app/config.json220
-rw-r--r--app/containers/AccountPage.js22
-rw-r--r--app/containers/AdvancedSettingsPage.js55
-rw-r--r--app/containers/ConnectPage.js32
-rw-r--r--app/containers/LoginPage.js22
-rw-r--r--app/containers/SelectLocationPage.js36
-rw-r--r--app/containers/SettingsPage.js22
-rw-r--r--app/containers/SupportPage.js88
-rw-r--r--app/index.html20
-rw-r--r--app/lib/backend.js424
-rw-r--r--app/lib/formatters.js9
-rw-r--r--app/lib/ipc-facade.js221
-rw-r--r--app/lib/jsonrpc-ws-ipc.js290
-rw-r--r--app/lib/keyframe-animation.js226
-rw-r--r--app/lib/proc.js27
-rw-r--r--app/lib/transition-rule.js47
-rw-r--r--app/lib/tray-icon-manager.js58
-rw-r--r--app/main.js442
-rw-r--r--app/redux/account/actions.js94
-rw-r--r--app/redux/account/reducers.js63
-rw-r--r--app/redux/connection/actions.js118
-rw-r--r--app/redux/connection/reducers.js69
-rw-r--r--app/redux/settings/actions.js19
-rw-r--r--app/redux/settings/reducers.js37
-rw-r--r--app/redux/store.js69
-rw-r--r--app/routes.js109
-rw-r--r--app/tilecache.sw.js27
-rw-r--r--app/transitions.js113
-rw-r--r--app/types.js6
101 files changed, 6620 insertions, 0 deletions
diff --git a/app/app.js b/app/app.js
new file mode 100644
index 0000000000..6536de4983
--- /dev/null
+++ b/app/app.js
@@ -0,0 +1,121 @@
+// @flow
+
+import path from 'path';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { Provider } from 'react-redux';
+import { ConnectedRouter } from 'react-router-redux';
+import { createMemoryHistory } from 'history';
+import { webFrame, ipcRenderer } from 'electron';
+import log from 'electron-log';
+import makeRoutes from './routes';
+import configureStore from './redux/store';
+import { Backend } from './lib/backend';
+
+import type { ConnectionState } from './redux/connection/reducers';
+import type { TrayIconType } from './lib/tray-icon-manager';
+
+const initialState = null;
+const memoryHistory = createMemoryHistory();
+const store = configureStore(initialState, memoryHistory);
+
+//////////////////////////////////////////////////////////////////////////
+// Backend
+//////////////////////////////////////////////////////////////////////////
+const backend = new Backend(store);
+ipcRenderer.on('backend-info', (_event, args) => {
+ backend.setCredentials(args.credentials);
+ backend.sync();
+ backend.autologin()
+ .then( () => {
+ return backend.syncRelayConstraints();
+ })
+ .catch( e => {
+ if (e.type === 'NO_ACCOUNT') {
+ log.debug('No user set in the backend, showing window');
+ ipcRenderer.send('show-window');
+ }
+ });
+});
+ipcRenderer.on('shutdown', () => {
+ log.info('Been told by the node process to shutdown');
+ backend.shutdown()
+ .catch( e => {
+ log.warn('Unable to shut down the backend', e.message);
+ });
+});
+//////////////////////////////////////////////////////////////////////////
+//////////////////////////////////////////////////////////////////////////
+
+//////////////////////////////////////////////////////////////////////////
+// Tray icon
+//////////////////////////////////////////////////////////////////////////
+
+/**
+ * Get tray icon type based on connection state
+ */
+const getIconType = (s: ConnectionState): TrayIconType => {
+ switch(s) {
+ case 'connected': return 'secured';
+ case 'connecting': return 'securing';
+ default: return 'unsecured';
+ }
+};
+
+/**
+ * Update tray icon via IPC call
+ */
+const updateTrayIcon = () => {
+ const { connection } = store.getState();
+ // TODO: Only update the tray icon if the connection status changed
+ ipcRenderer.send('changeTrayIcon', getIconType(connection.status));
+};
+store.subscribe(updateTrayIcon);
+
+// force update tray
+updateTrayIcon();
+//////////////////////////////////////////////////////////////////////////
+//////////////////////////////////////////////////////////////////////////
+
+// disable smart pinch.
+webFrame.setZoomLevelLimits(1, 1);
+
+if(navigator.serviceWorker) {
+ navigator.serviceWorker.register(path.join(__dirname, 'tilecache.sw.js'))
+ .then((registration) => {
+ log.info('ServiceWorker registration successful with scope: ', registration.scope);
+ }).catch((err) => {
+ log.info('ServiceWorker registration failed: ', err);
+ });
+}
+
+ipcRenderer.send('on-browser-window-ready');
+
+function getRootElement() {
+ const currentScript = document.currentScript;
+ if (!currentScript) {
+ throw new Error('Missing document.currentScript');
+ }
+
+ const containerId = currentScript.getAttribute('data-container');
+ if(!containerId) {
+ throw new Error('Missing data-container attribute.');
+ }
+
+ const rootElement = document.querySelector(containerId);
+ if(!rootElement) {
+ throw new Error('Missing root element.');
+ }
+
+ return rootElement;
+}
+
+
+ReactDOM.render(
+ <Provider store={ store }>
+ <ConnectedRouter history={ memoryHistory }>
+ { makeRoutes(store.getState, { backend }) }
+ </ConnectedRouter>
+ </Provider>,
+ getRootElement()
+);
diff --git a/app/assets/css/buttons.css b/app/assets/css/buttons.css
new file mode 100644
index 0000000000..805d9e9a74
--- /dev/null
+++ b/app/assets/css/buttons.css
@@ -0,0 +1,131 @@
+.button {
+ display: flex;
+ width: 100%;
+ padding: 7px 12px 9px;
+ border-radius: 4px;
+ border: 0;
+ font-family: DINPro;
+ font-size: 20px;
+ font-weight: 900;
+ line-height: 26px;
+ justify-content: center;
+ align-items: center;
+ transition: 0.25s opacity;
+}
+
+.button:disabled {
+ opacity: 0.5;
+}
+
+.button-label {
+ margin: 0 auto;
+}
+
+/* make negative margin to center label within button */
+.button-label + .button-icon--16 {
+ margin-left: -16px;
+}
+
+.button--blur {
+ backdrop-filter: blur(4px);
+}
+
+.button--primary {
+ background-color: rgba(41,71,115,1);
+ color: rgba(255,255,255,0.8);
+}
+
+.button--primary .button-icon path {
+ fill: rgba(255,255,255,0.8);
+}
+
+.button--primary:not(:disabled):hover {
+ background-color: rgba(41,71,115,0.9);
+ color: rgba(255,255,255,1);
+}
+
+.button--primary:not(:disabled):hover .button-icon path {
+ fill: rgba(255,255,255,1);
+}
+
+.button--primary:active {
+ background-color: rgba(41,71,115,1);
+}
+
+.button--secondary {
+ background-color: rgba(41,71,115,0.4);
+ color: rgba(255,255,255,0.6);
+}
+
+.button--secondary:not(:disabled):hover {
+ background-color: rgba(41,71,115,0.5);
+ color: rgba(255,255,255,0.8);
+}
+
+.button--secondary:active {
+ background-color: rgba(41,71,115,0.4);
+}
+
+.button--negative {
+ background-color: rgba(208,2,27,1);
+ color: rgba(255,255,255,0.8);
+}
+
+.button--negative:not(:disabled):hover {
+ background-color: rgba(208,2,27,0.95);
+ color: rgba(255,255,255,1);
+}
+
+.button--negative:active {
+ background-color: rgba(208,2,27,1);
+}
+
+.button--negative-light {
+ background-color: rgba(208,2,27,0.4);
+ color: rgba(255,255,255,0.6);
+}
+
+.button--negative-light:not(:disabled):hover {
+ background-color: rgba(208,2,27,0.45);
+ color: rgba(255,255,255,0.8);
+}
+
+.button--negative-light:active {
+ background-color: rgba(208,2,27,0.4);
+}
+
+.button--positive {
+ background-color: rgba(63,173,77,1);
+ color: rgba(255,255,255,0.8);
+}
+
+.button--positive .button-icon path {
+ fill: rgba(255,255,255,0.8);
+}
+
+.button--positive:not(:disabled):hover {
+ background-color: rgba(63,173,77,0.9);
+ color: rgba(255,255,255,1);
+}
+
+.button--positive:not(:disabled):hover .button-icon path {
+ fill: rgba(255,255,255,1);
+}
+
+.button--positive:active {
+ background-color: rgba(63,173,77,1);
+}
+
+.button--neutral {
+ background-color: rgba(255,255,255,0.2);
+ color: rgba(255,255,255,0.8);
+}
+
+.button--neutral:not(:disabled):hover {
+ background-color: rgba(255,255,255,0.25);
+ color: rgba(255,255,255,1);
+}
+
+.button--neutral:active {
+ background-color: rgba(255,255,255,0.2);
+}
diff --git a/app/assets/css/fonts.css b/app/assets/css/fonts.css
new file mode 100644
index 0000000000..22b97c7f2e
--- /dev/null
+++ b/app/assets/css/fonts.css
@@ -0,0 +1,23 @@
+@font-face {
+ font-family: DINPro;
+ font-weight: bold;
+ src: url("../fonts/DINPro-Bold.otf") format("opentype");
+}
+
+@font-face {
+ font-family: DINPro;
+ font-weight: 900;
+ src: url("../fonts/DINPro-Black.otf") format("opentype");
+}
+
+@font-face {
+ font-family: "Open Sans";
+ font-weight: 800;
+ src: url("../fonts/OpenSans-ExtraBold.ttf") format("truetype");
+}
+
+@font-face {
+ font-family: "Open Sans";
+ font-weight: 600;
+ src: url("../fonts/OpenSans-Semibold.ttf") format("truetype");
+}
diff --git a/app/assets/css/global.css b/app/assets/css/global.css
new file mode 100644
index 0000000000..f724a6b5ff
--- /dev/null
+++ b/app/assets/css/global.css
@@ -0,0 +1,19 @@
+* { box-sizing: border-box; }
+
+:focus {
+ outline: 0;
+}
+
+html {
+ -webkit-font-smoothing: antialiased;
+ user-select: none;
+ cursor: default;
+}
+
+img {
+ -webkit-user-drag: none;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, sans-serif;
+}
diff --git a/app/assets/css/reset.css b/app/assets/css/reset.css
new file mode 100644
index 0000000000..cf3d1dd178
--- /dev/null
+++ b/app/assets/css/reset.css
@@ -0,0 +1,48 @@
+/* http://meyerweb.com/eric/tools/css/reset/
+ v2.0 | 20110126
+ License: none (public domain)
+*/
+
+html, body, div, span, applet, object, iframe,
+h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+a, abbr, acronym, address, big, cite, code,
+del, dfn, em, img, ins, kbd, q, s, samp,
+small, strike, strong, sub, sup, tt, var,
+b, u, i, center,
+dl, dt, dd, ol, ul, li,
+fieldset, form, label, legend,
+table, caption, tbody, tfoot, thead, tr, th, td,
+article, aside, canvas, details, embed,
+figure, figcaption, footer, header, hgroup,
+menu, nav, output, ruby, section, summary,
+time, mark, audio, video {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ font-size: 100%;
+ font: inherit;
+ vertical-align: baseline;
+}
+/* HTML5 display-role reset for older browsers */
+article, aside, details, figcaption, figure,
+footer, header, hgroup, menu, nav, section {
+ display: block;
+}
+body {
+ line-height: 1;
+}
+ol, ul {
+ list-style: none;
+}
+blockquote, q {
+ quotes: none;
+}
+blockquote:before, blockquote:after,
+q:before, q:after {
+ content: '';
+ content: none;
+}
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+} \ No newline at end of file
diff --git a/app/assets/css/style.css b/app/assets/css/style.css
new file mode 100644
index 0000000000..8b0bd30f23
--- /dev/null
+++ b/app/assets/css/style.css
@@ -0,0 +1,19 @@
+/* global */
+@import 'reset.css';
+@import 'fonts.css';
+@import 'global.css';
+@import 'buttons.css';
+@import 'transitions.css';
+
+/* app */
+@import '../../components/WindowChrome.css';
+@import '../../components/CustomScrollbars.css';
+@import '../../components/Login.css';
+@import '../../components/Connect.css';
+@import '../../components/Settings.css';
+@import '../../components/Account.css';
+@import '../../components/Support.css';
+@import '../../components/SelectLocation.css';
+@import '../../components/HeaderBar.css';
+@import '../../components/Layout.css';
+@import '../../components/Switch.css';
diff --git a/app/assets/css/transitions.css b/app/assets/css/transitions.css
new file mode 100644
index 0000000000..239be5b63b
--- /dev/null
+++ b/app/assets/css/transitions.css
@@ -0,0 +1,91 @@
+/**
+ * CSS rules for transitions using React-router and CSSTransitionGroup
+ */
+
+div[class*="-transition-leave"], div[class*="-transition-enter"] {
+ /* keep animated .layout divs pinned to viewport boundaries */
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100vw;
+
+ /* disable UI interaction during transitions */
+ pointer-events: none;
+}
+
+.transition-container {
+ position: relative;
+ width: 100vw;
+ height: 100vh;
+ overflow: hidden;
+}
+
+/* New view slides bottom top */
+
+.slide-up-transition-leave { z-index: 0; }
+.slide-up-transition-enter {
+ transform: translateY(100vh);
+ z-index: 1;
+}
+
+.slide-up-transition-enter.slide-up-transition-enter-active {
+ transform: translateY(0);
+ transition: transform 450ms ease;
+}
+
+/* New view slides top bottom */
+
+.slide-down-transition-enter { z-index: 0; }
+.slide-down-transition-leave {
+ transform: translateY(0);
+ z-index: 1;
+}
+
+.slide-down-transition-leave.slide-down-transition-leave-active {
+ transform: translateY(100vh);
+ transition: transform 450ms ease;
+}
+
+/* New view slids right to left */
+
+.push-transition-leave {
+ transform: translateX(0vw);
+ z-index: 0;
+}
+
+.push-transition-enter {
+ transform: translateX(100vw);
+ z-index: 1;
+}
+
+.push-transition-leave.push-transition-leave-active {
+ transform: translateX(-50vw);
+ transition: transform 450ms ease;
+}
+
+.push-transition-enter.push-transition-enter-active {
+ transform: translateX(0);
+ transition: transform 450ms ease;
+}
+
+/* New view slides left to right */
+
+.pop-transition-enter {
+ transform: translateX(-50vw);
+ z-index: 0;
+}
+
+.pop-transition-leave {
+ transform: translateX(0);
+ z-index: 1;
+}
+
+.pop-transition-enter.pop-transition-enter-active {
+ transform: translateX(0vw);
+ transition: transform 450ms ease;
+}
+
+.pop-transition-leave.pop-transition-leave-active {
+ transform: translateX(100vw);
+ transition: transform 450ms ease;
+} \ No newline at end of file
diff --git a/app/assets/css/uiswitch.css b/app/assets/css/uiswitch.css
new file mode 100644
index 0000000000..e8c037bf1e
--- /dev/null
+++ b/app/assets/css/uiswitch.css
@@ -0,0 +1,135 @@
+/* https://github.com/fnky/css3-uiswitch */
+.uiswitch {
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ -ms-appearance: none;
+ -o-appearance: none;
+ appearance: none;
+ height: 31px;
+ width: 51px;
+ position: relative;
+ border-radius: 16px;
+ cursor: pointer;
+ outline: 0;
+ z-index: 0;
+ margin: 0;
+ padding: 0;
+ border: none;
+ background-color: #e5e5e5;
+ -webkit-transition-duration: 600ms;
+ -moz-transition-duration: 600ms;
+ transition-duration: 600ms;
+ -webkit-transition-timing-function: ease-in-out;
+ -moz-transition-timing-function: ease-in-out;
+ transition-timing-function: ease-in-out;
+ -webkit-touch-callout: none;
+ -webkit-text-size-adjust: none;
+ -webkit-tap-highlight-color: transparent;
+ -webkit-user-select: none; }
+ .uiswitch::before {
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ height: 27px;
+ width: 47px;
+ content: ' ';
+ position: absolute;
+ left: 2px;
+ top: 2px;
+ background-color: white;
+ border-radius: 16px;
+ z-index: 1;
+ -webkit-transition-duration: 300ms;
+ -moz-transition-duration: 300ms;
+ transition-duration: 300ms;
+ -webkit-transform: scale(1);
+ -moz-transform: scale(1);
+ -ms-transform: scale(1);
+ -o-transform: scale(1);
+ transform: scale(1); }
+ .uiswitch::after {
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ height: 27px;
+ width: 27px;
+ content: ' ';
+ position: absolute;
+ border-radius: 27px;
+ background: white;
+ z-index: 2;
+ top: 2px;
+ left: 2px;
+ box-shadow: 0px 0px 1px 0px rgba(0, 0, 0, 0.25), 0px 4px 11px 0px rgba(0, 0, 0, 0.08), -1px 3px 3px 0px rgba(0, 0, 0, 0.14);
+ -webkit-transition: -webkit-transform 300ms, width 280ms;
+ -moz-transition: -moz-transform 300ms, width 280ms;
+ transition: transform 300ms, width 280ms;
+ -webkit-transform: translate3d(0, 0, 0);
+ -moz-transform: translate3d(0, 0, 0);
+ -ms-transform: translate3d(0, 0, 0);
+ -o-transform: translate3d(0, 0, 0);
+ transform: translate3d(0, 0, 0);
+ -webkit-transition-timing-function: cubic-bezier(0.42, 0.8, 0.58, 1.2);
+ -moz-transition-timing-function: cubic-bezier(0.42, 0.8, 0.58, 1.2);
+ transition-timing-function: cubic-bezier(0.42, 0.8, 0.58, 1.2); }
+ .uiswitch:checked {
+ background-color: #4cd964;
+ background-image: -webkit-linear-gradient(-90deg, #4cd964 0%, #4dd865 100%);
+ background-image: linear-gradient(-180deg,#4cd964 0%, #4dd865 100%); }
+ .uiswitch:checked::after {
+ -webkit-transform: translate3d(16px, 0, 0);
+ -moz-transform: translate3d(16px, 0, 0);
+ -ms-transform: translate3d(16px, 0, 0);
+ -o-transform: translate3d(16px, 0, 0);
+ transform: translate3d(16px, 0, 0);
+ right: 18px;
+ left: inherit; }
+ .uiswitch:active::after {
+ width: 35px; }
+ .uiswitch:checked::before, .uiswitch:active::before {
+ -webkit-transform: scale(0);
+ -moz-transform: scale(0);
+ -ms-transform: scale(0);
+ -o-transform: scale(0);
+ transform: scale(0); }
+ .uiswitch:disabled {
+ opacity: 0.5;
+ cursor: default;
+ -webkit-transition: none;
+ -moz-transition: none;
+ transition: none; }
+ .uiswitch:disabled:active::before, .uiswitch:disabled:active::after, .uiswitch:disabled:checked:active::before, .uiswitch:disabled:checked::before {
+ width: 27px;
+ -webkit-transition: none;
+ -moz-transition: none;
+ transition: none; }
+ .uiswitch:disabled:active::before {
+ height: 27px;
+ width: 41px;
+ -webkit-transform: translate3d(6px, 0, 0);
+ -moz-transform: translate3d(6px, 0, 0);
+ -ms-transform: translate3d(6px, 0, 0);
+ -o-transform: translate3d(6px, 0, 0);
+ transform: translate3d(6px, 0, 0); }
+ .uiswitch:disabled:checked:active::before {
+ height: 27px;
+ width: 27px;
+ -webkit-transform: scale(0);
+ -moz-transform: scale(0);
+ -ms-transform: scale(0);
+ -o-transform: scale(0);
+ transform: scale(0); }
+
+.uiswitch {
+ background-color: #e5e5e5; }
+ .uiswitch::before {
+ background-color: white; }
+ .uiswitch::after {
+ background: white; }
+ .uiswitch:checked {
+ background-color: #4cd964;
+ background-image: -webkit-linear-gradient(-90deg, #4cd964 0%, #4dd865 100%);
+ background-image: linear-gradient(-180deg,#4cd964 0%, #4dd865 100%); } \ No newline at end of file
diff --git a/app/assets/fonts/DINPro-Black.otf b/app/assets/fonts/DINPro-Black.otf
new file mode 100755
index 0000000000..2092a7bbdc
--- /dev/null
+++ b/app/assets/fonts/DINPro-Black.otf
Binary files differ
diff --git a/app/assets/fonts/DINPro-Bold.otf b/app/assets/fonts/DINPro-Bold.otf
new file mode 100755
index 0000000000..7c83953648
--- /dev/null
+++ b/app/assets/fonts/DINPro-Bold.otf
Binary files differ
diff --git a/app/assets/fonts/OpenSans-ExtraBold.ttf b/app/assets/fonts/OpenSans-ExtraBold.ttf
new file mode 100755
index 0000000000..21f6f84a07
--- /dev/null
+++ b/app/assets/fonts/OpenSans-ExtraBold.ttf
Binary files differ
diff --git a/app/assets/fonts/OpenSans-Semibold.ttf b/app/assets/fonts/OpenSans-Semibold.ttf
new file mode 100755
index 0000000000..1a7679e394
--- /dev/null
+++ b/app/assets/fonts/OpenSans-Semibold.ttf
Binary files differ
diff --git a/app/assets/images/app-header-backdrop.svg b/app/assets/images/app-header-backdrop.svg
new file mode 100644
index 0000000000..4c811518bf
--- /dev/null
+++ b/app/assets/images/app-header-backdrop.svg
@@ -0,0 +1,5 @@
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <title>app-triangle-extended</title>
+ <desc>Mullvad VPN app</desc>
+ <rect x="0" y="12" width="100%" height="100%" rx="8" ry="8" />
+</svg> \ No newline at end of file
diff --git a/app/assets/images/app-triangle.svg b/app/assets/images/app-triangle.svg
new file mode 100644
index 0000000000..e2f7ca044b
--- /dev/null
+++ b/app/assets/images/app-triangle.svg
@@ -0,0 +1,5 @@
+<svg width="30px" height="13px" viewBox="0 0 30 13" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <title>app-triangle-extended</title>
+ <desc>Mullvad VPN app</desc>
+ <path fill="#294D73" d="M0,12 L30,12 L30,13 L0,13 L0,12 Z M0,12 C7.24137931,12 12.9310345,1.0135008e-16 15,0 C17.0689655,0 23.7931034,12 30,12 L0,12 Z" id="app-triangle-extended"></path>
+</svg> \ No newline at end of file
diff --git a/app/assets/images/icon-arrow.svg b/app/assets/images/icon-arrow.svg
new file mode 100755
index 0000000000..96f1356fbe
--- /dev/null
+++ b/app/assets/images/icon-arrow.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="24px" height="16px" viewBox="0 0 24 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <title>icon-arrow</title>
+ <desc>Mullvad VPN app</desc>
+ <defs></defs>
+ <path fill="#FFFFFF" d="M18.7015867,9 L14.4331381,12.762659 C13.851665,13.2752305 13.8579999,14.1003943 14.4392669,14.612784 C15.0245863,15.1287461 15.9602099,15.1275926 16.5380921,14.6181865 L23.5668627,8.42228969 C23.8565791,8.16690324 24.000373,7.83391619 23.999837,7.50067932 L24,7.4966702 C23.999589,7.16348359 23.8547954,6.83138119 23.5668627,6.57756713 L16.5380921,0.381670278 C15.956619,-0.130901228 15.0205338,-0.125317014 14.4392669,0.387072772 C13.8539474,0.903034846 13.8552559,1.72779176 14.4331381,2.23719784 L18.7017491,6 L1.50909424,6 C0.66354084,6 0,6.67157288 0,7.5 C0,8.33420277 0.675644504,9 1.50909424,9 L18.7015867,9 Z" id="icon-arrow"></path>
+</svg> \ No newline at end of file
diff --git a/app/assets/images/icon-back.svg b/app/assets/images/icon-back.svg
new file mode 100644
index 0000000000..5ec98cbee6
--- /dev/null
+++ b/app/assets/images/icon-back.svg
@@ -0,0 +1,8 @@
+<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <title>icon-back</title>
+ <desc>Mullvad VPN app</desc>
+ <defs></defs>
+ <g id="icon" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" fill-opacity="0.6">
+ <path d="M12,24 C5.37312,24 -3.2900871e-16,18.62688 -7.34788079e-16,12 C-1.14056745e-15,5.37312 5.37312,0 12,0 C18.62688,0 24,5.37312 24,12 C24,18.62688 18.62688,24 12,24 Z M7.00548958,11.9978652 C6.97547323,12.2732292 7.06852694,12.5603856 7.28524783,12.7773547 L13.2129013,18.7117979 C13.5936146,19.0929473 14.2231287,19.090784 14.6233317,18.7027276 L14.6942341,18.6339771 C15.09248,18.2478183 15.1055305,17.6195657 14.7108992,17.2180331 L9.58045095,11.9978652 L14.7108992,6.77769718 C15.1055305,6.37616462 15.09248,5.74791199 14.6942341,5.36175323 L14.6233317,5.29300272 C14.2231287,4.90494629 13.5936146,4.90278303 13.2129013,5.28393238 L7.28524783,11.2183756 C7.06852694,11.4353448 6.97547323,11.7225011 7.00548958,11.9978652 L7.00548958,11.9978652 Z" id="path" fill="#FFFFFF"></path>
+ </g>
+</svg> \ No newline at end of file
diff --git a/app/assets/images/icon-chevron.svg b/app/assets/images/icon-chevron.svg
new file mode 100644
index 0000000000..5437127c33
--- /dev/null
+++ b/app/assets/images/icon-chevron.svg
@@ -0,0 +1,6 @@
+<svg width="7px" height="12px" viewBox="0 0 7 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <title>icon-cheveron</title>
+ <desc>Mullvad VPN app</desc>
+ <defs></defs>
+ <path d="M0.335204989,1.95371785 L4.23669259,6 L0.335204989,10.0462822 C-0.111734996,10.4932221 -0.111734996,11.217855 0.335204989,11.664795 C0.782144974,12.111735 1.49826561,12.111735 1.9452056,11.664795 L6.66818642,6.80553188 C6.88657769,6.58714061 6.99779844,6.29559541 6.99881099,6.00303766 C6.99779844,5.70440459 6.88657769,5.41285939 6.66818642,5.19446812 L1.9452056,0.335204989 C1.49826561,-0.111734996 0.782144974,-0.111734996 0.335204989,0.335204989 C-0.111734996,0.782144974 -0.111734996,1.50677786 0.335204989,1.95371785 Z" id="path" fill-opacity="0.8" fill="#FFFFFF" fill-rule="evenodd"></path>
+</svg> \ No newline at end of file
diff --git a/app/assets/images/icon-close.svg b/app/assets/images/icon-close.svg
new file mode 100644
index 0000000000..df23259a13
--- /dev/null
+++ b/app/assets/images/icon-close.svg
@@ -0,0 +1,8 @@
+<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <title>icon-close</title>
+ <desc>Mullvad VPN app</desc>
+ <defs></defs>
+ <g id="icon" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" fill-opacity="0.6">
+ <path d="M12,24 C5.37312,24 -3.2900871e-16,18.62688 -7.34788079e-16,12 C-1.14056745e-15,5.37312 5.37312,0 12,0 C18.62688,0 24,5.37312 24,12 C24,18.62688 18.62688,24 12,24 Z M13.5,12 L17.2947612,8.20523878 C17.6857559,7.81424414 17.6838785,7.18387854 17.293923,6.79392296 L17.206077,6.70607704 C16.8181114,6.31811142 16.1842538,6.31574616 15.7947612,6.70523878 L12,10.5 L8.20523878,6.70523878 C7.81574616,6.31574616 7.18188858,6.31811142 6.79392296,6.70607704 L6.70607704,6.79392296 C6.31612146,7.18387854 6.31424414,7.81424414 6.70523878,8.20523878 L10.5,12 L6.70523878,15.7947612 C6.31424414,16.1857559 6.31612146,16.8161215 6.70607704,17.206077 L6.79392296,17.293923 C7.18188858,17.6818886 7.81574616,17.6842538 8.20523878,17.2947612 L12,13.5 L15.7947612,17.2947612 C16.1842538,17.6842538 16.8181114,17.6818886 17.206077,17.293923 L17.293923,17.206077 C17.6838785,16.8161215 17.6857559,16.1857559 17.2947612,15.7947612 L13.5,12 L13.5,12 Z" id="path" fill="#FFFFFF"></path>
+ </g>
+</svg> \ No newline at end of file
diff --git a/app/assets/images/icon-extLink.svg b/app/assets/images/icon-extLink.svg
new file mode 100644
index 0000000000..2da2e2f932
--- /dev/null
+++ b/app/assets/images/icon-extLink.svg
@@ -0,0 +1,6 @@
+<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <title>icon-extLink</title>
+ <desc>Mullvad VPN app</desc>
+ <defs></defs>
+ <path d="M12.5857864,2 L8.99077797,2 C8.45097518,2 8,1.55228475 8,1 C8,0.443864822 8.4463856,0 8.99703014,0 L15.0029699,0 C15.5469637,0 16,0.446385598 16,0.997030139 L16,7.00296986 C16,7.54696369 15.5522847,8 15,8 C14.4438648,8 14,7.55641359 14,7.00922203 L14,3.41421356 L6.70710678,10.7071068 C6.31658249,11.0976311 5.68341751,11.0976311 5.29289322,10.7071068 C4.90236893,10.3165825 4.90236893,9.68341751 5.29289322,9.29289322 L12.5857864,2 Z M8.46446609,4 L6.46446609,6 L2,6 L2,14 L10,14 L10,9.53553391 L12,7.53553391 L12,14.9975267 C12,15.5511774 11.544239,16 10.9975267,16 L1.00247329,16 C0.448822582,16 0,15.544239 0,14.9975267 L0,5.00247329 C0,4.44882258 0.455760956,4 1.00247329,4 L8.46446609,4 Z" id="path" fill="#FFFFFF" fill-rule="evenodd"></path>
+</svg> \ No newline at end of file
diff --git a/app/assets/images/icon-fail.svg b/app/assets/images/icon-fail.svg
new file mode 100755
index 0000000000..3467374198
--- /dev/null
+++ b/app/assets/images/icon-fail.svg
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="60px" height="60px" viewBox="0 0 60 60" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <title>icon-fail</title>
+ <desc>Mullvad VPN app</desc>
+ <defs></defs>
+ <circle id="bg-circle" fill="#FFFFFF" fill-rule="nonzero" cx="30" cy="30" r="22"></circle>
+ <path d="M33.2371523,30 L41.337119,21.9033278 C42.2203329,21.020473 42.223948,19.5681264 41.3300331,18.6745751 C40.429886,17.774794 38.9899682,17.7778525 38.0999667,18.6674921 L30,26.7641643 L21.9000333,18.6674921 C21.0100318,17.7778525 19.570114,17.774794 18.6699669,18.6745751 C17.776052,19.5681264 17.7796671,21.020473 18.662881,21.9033278 L26.7628477,30 L18.662881,38.0966722 C17.7796671,38.979527 17.776052,40.4318736 18.6699669,41.3254249 C19.570114,42.225206 21.0100318,42.2221475 21.9000333,41.3325079 L30,33.2358357 L38.0999667,41.3325079 C38.9899682,42.2221475 40.429886,42.225206 41.3300331,41.3254249 C42.223948,40.4318736 42.2203329,38.979527 41.337119,38.0966722 L33.2371523,30 Z" id="icon" fill="#D0021B" fill-rule="nonzero"></path>
+</svg> \ No newline at end of file
diff --git a/app/assets/images/icon-fastest.svg b/app/assets/images/icon-fastest.svg
new file mode 100755
index 0000000000..4c915da6d6
--- /dev/null
+++ b/app/assets/images/icon-fastest.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <title>icon-fastest</title>
+ <desc>Mullvad VPN app</desc>
+ <defs></defs>
+ <path fill="#FFFFFF" d="M21.1507481,6.83280665 C24.8128031,10.5070481 24.963038,16.3056835 21.4959142,20.1552329 C18.0287905,24.0047824 12.1077149,24.6135147 7.89218032,21.5538019 C7.56927452,21.3111241 7.40378985,20.9190527 7.4580628,20.5252773 C7.51233574,20.131502 7.77812095,19.7958465 8.15529969,19.644749 C8.53247843,19.4936516 8.96374829,19.5500675 9.28665406,19.7927454 C11.5190533,21.5357122 14.5521978,21.9470531 17.1883493,20.8643366 C19.8245009,19.7816201 21.6398874,17.3788972 21.917643,14.604963 C22.1953986,11.8310288 20.890873,9.13180475 18.5192016,7.57316921 C17.4963669,6.86336234 16.3093972,6.41068921 15.0637596,6.25537178 C13.0068849,5.98676172 10.9251308,6.53023229 9.28173528,7.76484883 C8.78601466,8.13151903 8.08013307,8.04356746 7.69509115,7.56715586 C7.31004923,7.09074425 7.38788476,6.40161565 7.87004581,6.01816824 C9.32618858,4.91172269 11.0690124,4.22078431 12.9044157,4.02230413 L12.9044157,2.72367467 L11.7017744,2.72367467 C11.3810313,2.7211743 11.0761771,2.58728319 10.861863,2.354788 C10.647549,2.12229281 10.543565,1.81266216 10.5753741,1.50171705 C10.6494879,0.921669603 11.1609089,0.489587303 11.7607997,0.500191003 L16.3672359,0.500191003 C16.6892225,0.500064398 16.9962796,0.632463774 17.2126847,0.864738483 C17.4290898,1.09701319 17.5347009,1.40754432 17.5034738,1.71975262 C17.42936,2.29980007 16.917939,2.73188237 16.3180481,2.72127867 L15.1744321,2.72127867 L15.1744321,4.01990814 C17.4372686,4.26696772 19.5456874,5.2593463 21.1507481,6.83280665 Z M18.3814758,10.6663992 C18.6486569,10.3452379 18.6236955,9.87935381 18.323645,9.58703751 C18.0235945,9.29472121 17.5453839,9.27040316 17.2157252,9.53069739 L14.3111864,11.804497 C13.4337266,11.681255 12.5738342,12.1191557 12.1768555,12.8914065 C11.7798767,13.6636572 11.9349913,14.5967747 12.561864,15.2074889 C13.1887368,15.8182032 14.1465429,15.9693195 14.9392261,15.5825734 C15.7319094,15.1958273 16.1813962,14.3580998 16.0548935,13.5032576 L18.3888539,10.6663992 L18.3814758,10.6663992 Z M8.48981192,11.3252979 C9.03312521,11.3252979 9.47356765,10.8962084 9.47356765,10.3668998 C9.47356765,9.83759109 9.03312521,9.40850163 8.48981192,9.40850163 L0.983755727,9.40850163 C0.440442441,9.40850163 5.46094129e-17,9.83759109 0,10.3668998 C-5.46094129e-17,10.8962084 0.440442441,11.3252979 0.983755727,11.3252979 L8.48981192,11.3252979 Z M8.48981192,12.822795 L2.83321649,12.822795 C2.28990321,12.822795 1.84946077,13.2518844 1.84946077,13.7811931 C1.84946077,14.3105018 2.28990321,14.7395912 2.83321649,14.7395912 L8.48981192,14.7395912 C9.03312521,14.7395912 9.47356765,14.3105018 9.47356765,13.7811931 C9.47356765,13.2518844 9.03312521,12.822795 8.48981192,12.822795 Z M9.47356765,17.1859025 C9.47356765,16.6565938 9.03312521,16.2275043 8.48981192,16.2275043 L4.36541604,16.2275043 C3.82210275,16.2275043 3.38166031,16.6565938 3.38166031,17.1859025 C3.38166031,17.7152111 3.82210275,18.1443006 4.36541604,18.1443006 L8.48981192,18.1443006 C8.75072118,18.1430328 9.00042697,18.0408412 9.18399521,17.8602073 C9.36756345,17.6795734 9.46995668,17.4352944 9.46864887,17.1811105 L9.47356765,17.1859025 Z" fill-rule="nonzero"></path>
+</svg> \ No newline at end of file
diff --git a/app/assets/images/icon-nearest.svg b/app/assets/images/icon-nearest.svg
new file mode 100644
index 0000000000..badbf3674d
--- /dev/null
+++ b/app/assets/images/icon-nearest.svg
@@ -0,0 +1,6 @@
+<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <title>icon-nearest</title>
+ <desc>Mullvad VPN app</desc>
+ <defs></defs>
+ <path d="M20.9450712,11 L23,11 C23.5522847,11 24,11.4477153 24,12 C24,12.5522847 23.5522847,13 23,13 L20.9450712,13 C20.4839224,17.1716166 17.1716166,20.4839224 13,20.9450712 L13,23 C13,23.5522847 12.5522847,24 12,24 C11.4477153,24 11,23.5522847 11,23 L11,20.9450712 C6.82838339,20.4839224 3.5160776,17.1716166 3.05492878,13 L1,13 C0.44771525,13 0,12.5522847 0,12 C0,11.4477153 0.44771525,11 1,11 L3.05492878,11 C3.5160776,6.82838339 6.82838339,3.5160776 11,3.05492878 L11,1 C11,0.44771525 11.4477153,0 12,0 C12.5522847,0 13,0.44771525 13,1 L13,3.05492878 C17.1716166,3.5160776 20.4839224,6.82838339 20.9450712,11 Z M18.9291111,11 C18.4905984,7.93430884 16.0656912,5.50940162 13,5.07088886 L13,7 C13,7.55228475 12.5522847,8 12,8 C11.4477153,8 11,7.55228475 11,7 L11,5.07088886 C7.93430884,5.50940162 5.50940162,7.93430884 5.07088886,11 L7,11 C7.55228475,11 8,11.4477153 8,12 C8,12.5522847 7.55228475,13 7,13 L5.07088886,13 C5.50940162,16.0656912 7.93430884,18.4905984 11,18.9291111 L11,17 C11,16.4477153 11.4477153,16 12,16 C12.5522847,16 13,16.4477153 13,17 L13,18.9291111 C16.0656912,18.4905984 18.4905984,16.0656912 18.9291111,13 L17,13 C16.4477153,13 16,12.5522847 16,12 C16,11.4477153 16.4477153,11 17,11 L18.9291111,11 Z" id="icon-nearest" fill="#FFFFFF" fill-rule="nonzero"></path>
+</svg>
diff --git a/app/assets/images/icon-settings.svg b/app/assets/images/icon-settings.svg
new file mode 100755
index 0000000000..fbaec4e52c
--- /dev/null
+++ b/app/assets/images/icon-settings.svg
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <title>icon-settings</title>
+ <desc>Mullvad VPN app</desc>
+ <defs></defs>
+ <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" fill-opacity="0.6">
+ <path d="M21.2552027,12.0000012 C21.2552027,12.4080002 21.2181943,12.792001 21.1688441,13.1760007 L23.7718756,15.1560004 C24.0062723,15.3360012 24.0679568,15.6600006 23.9199162,15.924001 L21.4525887,20.0760014 C21.304548,20.3400006 20.9837951,20.4480008 20.700053,20.3400006 L17.6282305,19.1400005 C16.9867248,19.6079998 16.2958725,20.016 15.5433381,20.316 L15.0745457,23.4959999 C15.037536,23.7839996 14.7784667,24 14.4700507,24 L9.53539566,24 C9.22697972,24 8.96791028,23.7839996 8.9309007,23.4959999 L8.46210833,20.316 C7.70957385,20.016 7.01872221,19.62 6.37721707,19.1400005 L3.30539454,20.3400006 C3.03398819,20.4360005 2.700899,20.3400006 2.55285948,20.0760014 L0.0855319329,15.924001 C-0.062507572,15.6600006 -0.000824444944,15.336 0.233571734,15.1560004 L2.83660217,13.1760007 C2.78725567,12.792001 2.7502455,12.3960011 2.7502455,12.0000012 C2.7502455,11.6040012 2.78725567,11.2080012 2.83660217,10.8240016 L0.23357145,8.84400074 C-0.000824444944,8.66400004 -0.0748444934,8.34000059 0.0855319329,8.07600026 L2.55285948,3.92400037 C2.70089958,3.66000059 3.02165186,3.55200039 3.30539454,3.66000059 L6.37721765,4.86000071 C7.01872279,4.39200082 7.70957502,3.9840006 8.4621095,3.68400057 L8.93090187,0.504000134 C8.96791146,0.216000094 9.2269809,0 9.53539685,0 L14.4700519,0 C14.7784679,0 15.0375373,0.216000094 15.0745481,0.50400071 L15.5433404,3.68400114 C16.2958761,3.98400117 16.9867272,4.38000112 17.6282328,4.86000128 L20.7000554,3.66000116 C20.9714629,3.56400067 21.3045504,3.66000116 21.452591,3.92400093 L23.9199186,8.0760014 C24.0679568,8.34000175 24.0062746,8.66400233 23.7718779,8.84400188 L21.1688466,10.8240016 C21.2181919,11.2080012 21.2552027,11.5920021 21.2552027,12.0000012 Z M12,17 C14.7571433,17 17,14.7571433 17,12 C17,9.24285657 14.7571433,7 12,7 C9.24285657,7 7,9.24285657 7,12 C7,14.7571433 9.24285657,17 12,17 Z" id="icon" fill="#FFFFFF"></path>
+ </g>
+</svg> \ No newline at end of file
diff --git a/app/assets/images/icon-spinner.svg b/app/assets/images/icon-spinner.svg
new file mode 100644
index 0000000000..580d56a349
--- /dev/null
+++ b/app/assets/images/icon-spinner.svg
@@ -0,0 +1,17 @@
+<svg width="60px" height="60px" viewBox="0 0 60 60" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <title>Spinner</title>
+ <desc>Mulvad VPN app</desc>
+ <defs></defs>
+ <g id="container" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+ <path d="M27.6038221,6.11991768 C40.7924274,4.79654517 52.5567098,14.4152168 53.8800823,27.6038221 C55.2034548,40.7924274 45.5847832,52.5567098 32.3961779,53.8800823 C19.2075726,55.2034548 7.4432902,45.5847832 6.11991768,32.3961779 C4.79654517,19.2075726 14.4152168,7.4432902 27.6038221,6.11991768 Z M28.4025481,14.0799451 C19.6101445,14.9621935 13.1976968,22.8050484 14.0799451,31.5974519 C14.9621935,40.3898555 22.8050484,46.8023032 31.5974519,45.9200549 C40.3898555,45.0378065 46.8023032,37.1949516 45.9200549,28.4025481 C45.0378065,19.6101445 37.1949516,13.1976968 28.4025481,14.0799451 Z" id="track" fill-opacity="0.2" fill="#FFFFFF" fill-rule="nonzero"></path>
+ <path d="M25.2028561,6.48431564 C12.2155023,9.13370504 3.83492624,21.80979 6.48431564,34.7971439 C9.13370504,47.7844977 21.80979,56.1650738 34.7971439,53.5156844 C44.2988591,51.577357 51.5941458,44.163762 53.514681,34.8276709 C53.9598043,32.6638409 52.5665172,30.5488664 50.4026872,30.1037431 C48.2388572,29.6586198 46.1238826,31.0519068 45.6787593,33.2157369 C44.3979534,39.441981 39.5342463,44.3845633 33.1980959,45.6771229 C24.53986,47.4433825 16.0891367,41.8563318 14.3228771,33.1980959 C12.5566175,24.53986 18.1436682,16.0891367 26.8019041,14.3228771 C28.9664631,13.8813122 30.3632257,11.7686314 29.9216608,9.60407239 C29.4800959,7.43951342 27.3674151,6.04275074 25.2028561,6.48431564 Z" id="rotator" fill="#FFFFFF" fill-rule="nonzero">
+ <animateTransform attributeType="xml"
+ attributeName="transform"
+ type="rotate"
+ from="0 30 30"
+ to="360 30 30"
+ dur="0.6s"
+ repeatCount="indefinite"/>
+ </path>
+ </g>
+</svg> \ No newline at end of file
diff --git a/app/assets/images/icon-success.svg b/app/assets/images/icon-success.svg
new file mode 100755
index 0000000000..5a9e943406
--- /dev/null
+++ b/app/assets/images/icon-success.svg
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="60px" height="60px" viewBox="0 0 60 60" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <title>icon-success</title>
+ <desc>Mullvad VPN app</desc>
+ <defs></defs>
+ <circle id="bg-circle" fill="#FFFFFF" fill-rule="nonzero" cx="30" cy="30" r="22"></circle>
+ <path d="M19.4142136,28.5857864 C18.633165,27.8047379 17.366835,27.8047379 16.5857864,28.5857864 C15.8047379,29.366835 15.8047379,30.633165 16.5857864,31.4142136 L24.5857864,39.4142136 C25.366835,40.1952621 26.633165,40.1952621 27.4142136,39.4142136 L43.4142136,23.4142136 C44.1952621,22.633165 44.1952621,21.366835 43.4142136,20.5857864 C42.633165,19.8047379 41.366835,19.8047379 40.5857864,20.5857864 L26,35.1715729 L19.4142136,28.5857864 Z" id="icon-tick" fill="#44AD4D" fill-rule="nonzero"></path>
+</svg> \ No newline at end of file
diff --git a/app/assets/images/icon-tick.svg b/app/assets/images/icon-tick.svg
new file mode 100755
index 0000000000..65e354e2a3
--- /dev/null
+++ b/app/assets/images/icon-tick.svg
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="24px" height="17px" viewBox="0 0 24 17" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <title>icon-tick</title>
+ <desc>Mullvad VPN app</desc>
+ <defs>
+ <path d="M2.92646877,7.29791847 C2.25699855,6.63402718 1.17157288,6.63402718 0.502102661,7.29791847 C-0.167367554,7.96180977 -0.167367554,9.03819023 0.502102661,9.70208153 L7.35924552,16.5020815 C8.02871573,17.1659728 9.11414141,17.1659728 9.78361162,16.5020815 L23.4978973,2.90208153 C24.1673676,2.23819023 24.1673676,1.16180977 23.4978973,0.497918472 C22.8284271,-0.165972824 21.7430014,-0.165972824 21.0735312,0.497918472 L8.57142857,12.8958369 L2.92646877,7.29791847 Z" id="path-1"></path>
+ </defs>
+ <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+ <mask id="mask-2" fill="white">
+ <use xlink:href="#path-1"></use>
+ </mask>
+ <use id="path" fill="#FFFFFF" fill-rule="nonzero" xlink:href="#path-1"></use>
+ </g>
+</svg> \ No newline at end of file
diff --git a/app/assets/images/location-marker-secure.svg b/app/assets/images/location-marker-secure.svg
new file mode 100755
index 0000000000..087fe5d0d4
--- /dev/null
+++ b/app/assets/images/location-marker-secure.svg
@@ -0,0 +1,19 @@
+<svg width="60px" height="60px" viewBox="0 0 60 60" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <title>location-marker-secure</title>
+ <desc>Mullvad VPN app</desc>
+ <defs>
+ <circle id="shadow-path" cx="30" cy="30" r="10"></circle>
+ <filter x="-50%" y="-50%" width="200%" height="200%" filterUnits="objectBoundingBox" id="shadow">
+ <feMorphology radius="1" operator="dilate" in="SourceAlpha" result="shadowSpreadOuter1"></feMorphology>
+ <feOffset dx="0" dy="4" in="shadowSpreadOuter1" result="shadowOffsetOuter1"></feOffset>
+ <feGaussianBlur stdDeviation="4" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
+ <feComposite in="shadowBlurOuter1" in2="SourceAlpha" operator="out" result="shadowBlurOuter1"></feComposite>
+ <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0" type="matrix" in="shadowBlurOuter1"></feColorMatrix>
+ </filter>
+ </defs>
+ <circle id="outer-circle" fill-opacity="0.4" fill="#44AD4D" cx="30" cy="30" r="30"></circle>
+ <g id="inner-circle">
+ <use fill="black" fill-opacity="1" filter="url(#shadow)" xlink:href="#shadow-path"></use>
+ <use stroke="#FFFFFF" stroke-width="2" fill="#44AD4D" fill-rule="evenodd" xlink:href="#shadow-path"></use>
+ </g>
+</svg> \ No newline at end of file
diff --git a/app/assets/images/location-marker-unsecure.svg b/app/assets/images/location-marker-unsecure.svg
new file mode 100755
index 0000000000..509b8022f2
--- /dev/null
+++ b/app/assets/images/location-marker-unsecure.svg
@@ -0,0 +1,19 @@
+<svg width="60px" height="60px" viewBox="0 0 60 60" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <title>location-marker-unsecure</title>
+ <desc>Mullvad VPN app</desc>
+ <defs>
+ <circle id="shadow-path" cx="30" cy="30" r="10"></circle>
+ <filter x="-50%" y="-50%" width="200%" height="200%" filterUnits="objectBoundingBox" id="shadow">
+ <feMorphology radius="1" operator="dilate" in="SourceAlpha" result="shadowSpreadOuter1"></feMorphology>
+ <feOffset dx="0" dy="4" in="shadowSpreadOuter1" result="shadowOffsetOuter1"></feOffset>
+ <feGaussianBlur stdDeviation="4" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
+ <feComposite in="shadowBlurOuter1" in2="SourceAlpha" operator="out" result="shadowBlurOuter1"></feComposite>
+ <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0" type="matrix" in="shadowBlurOuter1"></feColorMatrix>
+ </filter>
+ </defs>
+ <circle id="outer-circle" fill-opacity="0.4" fill="#D0021B" cx="30" cy="30" r="30"></circle>
+ <g id="inner-circle">
+ <use fill="black" fill-opacity="1" filter="url(#shadow)" xlink:href="#shadow-path"></use>
+ <use stroke="#FFFFFF" stroke-width="2" fill="#D0021B" fill-rule="evenodd" xlink:href="#shadow-path"></use>
+ </g>
+</svg> \ No newline at end of file
diff --git a/app/assets/images/logo-icon.svg b/app/assets/images/logo-icon.svg
new file mode 100644
index 0000000000..c00cd67fb5
--- /dev/null
+++ b/app/assets/images/logo-icon.svg
@@ -0,0 +1 @@
+<svg width="49" height="50" viewBox="0 0 49 50" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>Elements/Logo Icon</title><defs><path d="M.1 6.833l1.467-2.047c.004.01-.092 2.947-.092 2.947l.413-2.22c1.225 2.474 4.217 5.897 6.96 7.718.296.197.528.406.707.625.346.133.703.212 1.059.27.187.034.379.048.565.065a7.42 7.42 0 0 0 1.12-.016 6.92 6.92 0 0 0 .554-.077 6.959 6.959 0 0 0 1.075-.273c.175-.053.35-.119.52-.186.173-.07.341-.144.508-.224.167-.084.333-.162.496-.252.166-.082.325-.178.49-.269.166-.084.318-.192.482-.283.163-.092.317-.2.481-.293.159-.102.317-.205.482-.303.158-.105.322-.205.491-.302l.152-.09.08.054 1.107.73-1.117-.29a7.07 7.07 0 0 1-.331.367c-.14.142-.288.277-.436.409-.154.129-.31.252-.471.373-.164.114-.329.232-.502.334a6.948 6.948 0 0 1-1.08.548 9.333 9.333 0 0 1-1.155.379c-.195.05-.397.084-.596.117-.201.03-.403.051-.604.07-.405.02-.813.01-1.211-.042a6.27 6.27 0 0 1-.595-.11 5.09 5.09 0 0 1-.573-.173 4.14 4.14 0 0 1-.936-.463c-.002.004-1.06.148-.633.941.428.79 1.073.72.769 1.651a7.54 7.54 0 0 1-.856 1.446c-.697.952-1.8 1.782-1.692 2.283 5.01 6.149 16.283 5.293 20.565-.191-.065-.794-1.314-1.173-2.19-3.11.241.075.607.185.607.171 0-.014-1.045-1.694-1.08-1.862l.669.045s-.895-1.1-.911-1.202l.898-.114s-1.13-1.298-1.15-1.401l1.15.182-1.258-1.513h.592l-.707-1.025a30.02 30.02 0 0 0-.36-.113c-.154-.05-.308-.096-.46-.144-1.722-.53-3.331-1.03-4.894-2.003-2.188-1.366-4.15-3.023-5.621-4.323L10.09 2.212c-2.831-.21-5.498-.141-7.116.179L4.015.613 2.424 2.522c-.113-.025-.14-.096-.14-.096l.11-2.35L1.891 2.2A1.274 1.274 0 0 0 .1 3.36c0 .64.477 1.172 1.098 1.258L.1 6.833z" id="a"/><path d="M1.887.156A1.274 1.274 0 0 0 .1 1.319c0 .617.443 1.133 1.031 1.246a.26.26 0 0 0 .027-.007C1.542 2.431 2.309 1.41 2.18.772a1.38 1.38 0 0 0-.294-.616z" id="c"/><path d="M4.594 5.277c-.228-.626-.172-1.442.156-2.197.457-1.048 1.325-1.75 2.157-1.75.169 0 .33.03.481.087A5.507 5.507 0 0 1 9.023.392c3.378-1.34 8.319 1.054 9.59 4.366.61 1.597.423 3.363-.1 4.955-.433 1.316-1.99 3.213-1.414 4.645-.236-.056-5.062-1.565-6.413-2.406-2.152-1.345-4.096-2.992-5.55-4.276l-.042-.04-4.91-2.328c-.058-.03-.115-.055-.173-.084.705 0 3.387.311 4.583.053" id="e"/><path d="M.199 3.717c.138.348.368.6.664.728.136.061.29.092.452.092.743 0 1.55-.669 1.966-1.626.255-.58.34-1.194.238-1.725C3.421.653 3.15.267 2.756.097a1.154 1.154 0 0 0-.454-.092C1.562.005.754.673.334 1.63.02 2.355-.03 3.134.2 3.717z" id="g"/><linearGradient x1="93.215%" y1="62.465%" x2="22.664%" y2="38.215%" id="i"><stop stop-color="#FFF" offset="0%"/><stop stop-color="#FFF" stop-opacity="0" offset="100%"/></linearGradient></defs><g transform="translate(0 .5)" fill="none" fill-rule="evenodd"><path d="M6 27c0 11.598 9.402 21 21 21s21-9.402 21-21S38.598 6 27 6 6 15.402 6 27zm-1 0C5 14.85 14.85 5 27 5s22 9.85 22 22-9.85 22-22 22S5 39.15 5 27z" fill="#192E45" fill-rule="nonzero"/><path d="M8 27c0 10.493 8.507 19 19 19s19-8.507 19-19S37.493 8 27 8 8 16.507 8 27z" fill="#192E45"/><g transform="translate(9.851 18.375)"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><path fill="#D0933A" fill-rule="nonzero" mask="url(#b)" d="M-0.54 -0.562H28.404V25.160999999999998H-0.54z"/></g><g transform="translate(9.851 20.417)"><mask id="d" fill="#fff"><use xlink:href="#c"/></mask><path fill="#FFCC86" fill-rule="nonzero" mask="url(#d)" d="M-0.54 -0.59H2.835V3.204H-0.54z"/></g><g transform="translate(18.295 13.654)"><mask id="f" fill="#fff"><use xlink:href="#e"/></mask><path fill="#FDD321" fill-rule="nonzero" mask="url(#f)" d="M-0.628 -0.625H19.635V14.995H-0.628z"/></g><g transform="translate(23.029 15.185)"><mask id="h" fill="#fff"><use xlink:href="#g"/></mask><g mask="url(#h)" fill-rule="nonzero"><path d="M1.315 4.346a.898.898 0 0 1-.373-.075C.69 4.162.496 3.947.377 3.647c-.211-.536-.161-1.26.134-1.94C.9.817 1.637.197 2.302.197c.132 0 .26.025.379.076.332.144.563.48.65.948.094.495.013 1.068-.226 1.613-.386.89-1.122 1.512-1.79 1.512z" fill="#FFF"/><path d="M2.302.388c.106 0 .208.02.302.06.272.119.463.405.538.81.087.456.011.989-.214 1.5-.35.81-1.029 1.396-1.613 1.396a.718.718 0 0 1-.294-.058l-.003-.001-.003-.002c-.2-.086-.36-.265-.459-.517-.193-.489-.144-1.159.13-1.792C1.041.975 1.72.388 2.302.388zm0-.383C1.562.005.754.673.334 1.63.02 2.355-.03 3.134.2 3.717c.138.348.368.6.664.728.136.061.29.092.452.092.742 0 1.55-.669 1.966-1.626.255-.58.339-1.194.238-1.725-.098-.533-.37-.919-.763-1.089a1.152 1.152 0 0 0-.454-.092z" fill="#1D2A3A"/></g></g><path d="M26.62 15.114s1.094 1.924-.024 3.583c-1.12 1.659-2.52 1.66-2.52 1.66L.25 12.672s-.952-4.115.85-7.526C2.899 1.733 6.837 0 6.837 0L26.62 15.114z" fill="url(#i)" fill-rule="nonzero" opacity=".3"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/menubar icons/lock-1.png b/app/assets/images/menubar icons/lock-1.png
new file mode 100644
index 0000000000..b20bc19515
--- /dev/null
+++ b/app/assets/images/menubar icons/lock-1.png
Binary files differ
diff --git a/app/assets/images/menubar icons/lock-1@2x.png b/app/assets/images/menubar icons/lock-1@2x.png
new file mode 100644
index 0000000000..1c512d12bd
--- /dev/null
+++ b/app/assets/images/menubar icons/lock-1@2x.png
Binary files differ
diff --git a/app/assets/images/menubar icons/lock-2.png b/app/assets/images/menubar icons/lock-2.png
new file mode 100644
index 0000000000..d98d05d951
--- /dev/null
+++ b/app/assets/images/menubar icons/lock-2.png
Binary files differ
diff --git a/app/assets/images/menubar icons/lock-2@2x.png b/app/assets/images/menubar icons/lock-2@2x.png
new file mode 100644
index 0000000000..c92dec19ec
--- /dev/null
+++ b/app/assets/images/menubar icons/lock-2@2x.png
Binary files differ
diff --git a/app/assets/images/menubar icons/lock-3.png b/app/assets/images/menubar icons/lock-3.png
new file mode 100644
index 0000000000..871f8402bf
--- /dev/null
+++ b/app/assets/images/menubar icons/lock-3.png
Binary files differ
diff --git a/app/assets/images/menubar icons/lock-3@2x.png b/app/assets/images/menubar icons/lock-3@2x.png
new file mode 100644
index 0000000000..d961af45f3
--- /dev/null
+++ b/app/assets/images/menubar icons/lock-3@2x.png
Binary files differ
diff --git a/app/assets/images/menubar icons/lock-4.png b/app/assets/images/menubar icons/lock-4.png
new file mode 100644
index 0000000000..6fcb60c663
--- /dev/null
+++ b/app/assets/images/menubar icons/lock-4.png
Binary files differ
diff --git a/app/assets/images/menubar icons/lock-4@2x.png b/app/assets/images/menubar icons/lock-4@2x.png
new file mode 100644
index 0000000000..f67b4c0921
--- /dev/null
+++ b/app/assets/images/menubar icons/lock-4@2x.png
Binary files differ
diff --git a/app/assets/images/menubar icons/lock-5.png b/app/assets/images/menubar icons/lock-5.png
new file mode 100644
index 0000000000..43d02ab1e0
--- /dev/null
+++ b/app/assets/images/menubar icons/lock-5.png
Binary files differ
diff --git a/app/assets/images/menubar icons/lock-5@2x.png b/app/assets/images/menubar icons/lock-5@2x.png
new file mode 100644
index 0000000000..1f05adf802
--- /dev/null
+++ b/app/assets/images/menubar icons/lock-5@2x.png
Binary files differ
diff --git a/app/assets/images/menubar icons/lock-6.png b/app/assets/images/menubar icons/lock-6.png
new file mode 100644
index 0000000000..e282ff8dad
--- /dev/null
+++ b/app/assets/images/menubar icons/lock-6.png
Binary files differ
diff --git a/app/assets/images/menubar icons/lock-6@2x.png b/app/assets/images/menubar icons/lock-6@2x.png
new file mode 100644
index 0000000000..f76ab999f1
--- /dev/null
+++ b/app/assets/images/menubar icons/lock-6@2x.png
Binary files differ
diff --git a/app/assets/images/menubar icons/lock-7.png b/app/assets/images/menubar icons/lock-7.png
new file mode 100644
index 0000000000..1299817a53
--- /dev/null
+++ b/app/assets/images/menubar icons/lock-7.png
Binary files differ
diff --git a/app/assets/images/menubar icons/lock-7@2x.png b/app/assets/images/menubar icons/lock-7@2x.png
new file mode 100644
index 0000000000..f3a1428ad4
--- /dev/null
+++ b/app/assets/images/menubar icons/lock-7@2x.png
Binary files differ
diff --git a/app/assets/images/menubar icons/lock-8.png b/app/assets/images/menubar icons/lock-8.png
new file mode 100644
index 0000000000..161e4f5e82
--- /dev/null
+++ b/app/assets/images/menubar icons/lock-8.png
Binary files differ
diff --git a/app/assets/images/menubar icons/lock-8@2x.png b/app/assets/images/menubar icons/lock-8@2x.png
new file mode 100644
index 0000000000..5b12910e4a
--- /dev/null
+++ b/app/assets/images/menubar icons/lock-8@2x.png
Binary files differ
diff --git a/app/assets/images/menubar icons/lock-9.png b/app/assets/images/menubar icons/lock-9.png
new file mode 100644
index 0000000000..c823aa87a1
--- /dev/null
+++ b/app/assets/images/menubar icons/lock-9.png
Binary files differ
diff --git a/app/assets/images/menubar icons/lock-9@2x.png b/app/assets/images/menubar icons/lock-9@2x.png
new file mode 100644
index 0000000000..e4dc28a192
--- /dev/null
+++ b/app/assets/images/menubar icons/lock-9@2x.png
Binary files differ
diff --git a/app/components/Account.css b/app/components/Account.css
new file mode 100644
index 0000000000..2f450e3828
--- /dev/null
+++ b/app/components/Account.css
@@ -0,0 +1,98 @@
+.account {
+ background: #192E45;
+ height: 100%;
+}
+
+.account__container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.account__header {
+ flex: 0 0 auto;
+ padding: 40px 24px 24px;
+ position: relative; /* anchor for close button */
+}
+
+.account__close {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ border: 0;
+ padding: 0;
+ margin: 0;
+ top: 24px;
+ left: 12px;
+ z-index: 1; /* part of .account__container covers the button */
+}
+
+.account__close-icon {
+ opacity: 0.6;
+ margin-right: 8px;
+}
+
+.account__close-title {
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.account__title {
+ font-family: DINPro;
+ font-size: 32px;
+ font-weight: 900;
+ line-height: 40px;
+ color: #FFFFFF;
+}
+
+.account__content {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+}
+
+.account__main {
+ margin-bottom: 24px;
+}
+
+.account__footer {
+ padding: 24px;
+}
+
+.account__row {
+ padding: 0 24px;
+}
+
+.account__row + .account__row {
+ margin-top: 24px;
+}
+
+.account__row-label {
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.8);
+ margin-bottom: 8px;
+}
+
+.account__row-value {
+ font-family: "Open Sans";
+ font-size: 16px;
+ font-weight: 800;
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.account__row-value--error {
+ color: #d0021b;
+}
+
+.account__footer .button + .button {
+ margin-top: 24px;
+}
+
+.account__id {
+ user-select: text;
+}
diff --git a/app/components/Account.js b/app/components/Account.js
new file mode 100644
index 0000000000..215014e543
--- /dev/null
+++ b/app/components/Account.js
@@ -0,0 +1,78 @@
+// @flow
+import moment from 'moment';
+import React, { Component } from 'react';
+import { If, Then, Else } from 'react-if';
+import { Layout, Container, Header } from './Layout';
+import { formatAccount } from '../lib/formatters';
+import ExternalLinkSVG from '../assets/images/icon-extLink.svg';
+
+import type { AccountReduxState } from '../redux/account/reducers';
+
+export type AccountProps = {
+ account: AccountReduxState;
+ onLogout: () => void;
+ onClose: () => void;
+ onBuyMore: () => void;
+};
+
+export default class Account extends Component {
+ props: AccountProps;
+
+ render(): React.Element<*> {
+ const expiry = moment(this.props.account.expiry);
+ const formattedAccountToken = formatAccount(this.props.account.accountToken || '');
+ const formattedExpiry = expiry.format('hA, D MMMM YYYY').toUpperCase();
+ const isOutOfTime = expiry.isSameOrBefore(moment());
+
+ return (
+ <Layout>
+ <Header hidden={ true } style={ 'defaultDark' } />
+ <Container>
+ <div className="account">
+ <div className="account__close" onClick={ this.props.onClose }>
+ <img className="account__close-icon" src="./assets/images/icon-back.svg" />
+ <span className="account__close-title">Settings</span>
+ </div>
+ <div className="account__container">
+
+ <div className="account__header">
+ <h2 className="account__title">Account</h2>
+ </div>
+
+ <div className="account__content">
+ <div className="account__main">
+
+ <div className="account__row">
+ <div className="account__row-label">Account ID</div>
+ <div className="account__row-value account__id">{ formattedAccountToken }</div>
+ </div>
+
+ <div className="account__row">
+ <div className="account__row-label">Paid until</div>
+ <If condition={ isOutOfTime }>
+ <Then>
+ <div className="account__out-of-time account__row-value account__row-value--error">OUT OF TIME</div>
+ </Then>
+ <Else>
+ <div className="account__row-value">{ formattedExpiry }</div>
+ </Else>
+ </If>
+ </div>
+
+ <div className="account__footer">
+ <button className="account__buymore button button--positive" onClick={ this.props.onBuyMore }>
+ <span className="button-label">Buy more time</span>
+ <ExternalLinkSVG className="button-icon button-icon--16" />
+ </button>
+ <button className="account__logout button button--negative" onClick={ this.props.onLogout }>Logout</button>
+ </div>
+
+ </div>
+ </div>
+ </div>
+ </div>
+ </Container>
+ </Layout>
+ );
+ }
+}
diff --git a/app/components/AccountInput.js b/app/components/AccountInput.js
new file mode 100644
index 0000000000..1759423437
--- /dev/null
+++ b/app/components/AccountInput.js
@@ -0,0 +1,317 @@
+// @flow
+import React, { Component } from 'react';
+import { formatAccount } from '../lib/formatters';
+
+// @TODO: move it into types.js
+
+// ESLint issue: https://github.com/babel/babel-eslint/issues/445
+declare class ClipboardData { // eslint-disable-line no-unused-vars
+ setData(type: string, data: string): void;
+ getData(type: string): string;
+}
+
+declare class ClipboardEvent extends Event {
+ clipboardData: ClipboardData;
+}
+
+export type AccountInputProps = {
+ value: string;
+ onEnter: ?(() => void);
+ onChange: ?((newValue: string) => void);
+};
+
+export type AccountInputState = {
+ value: string;
+ selectionRange: SelectionRange;
+};
+
+export type SelectionRange = [number, number];
+
+export default class AccountInput extends Component {
+ props: AccountInputProps;
+
+ static defaultProps: AccountInputProps = {
+ value: '',
+ onEnter: null,
+ onChange: null
+ };
+
+ state: AccountInputState = {
+ value: '',
+ selectionRange: [0, 0]
+ };
+
+ _ref: ?HTMLInputElement;
+ _ignoreSelect = false;
+
+ constructor(props: AccountInputProps) {
+ super(props);
+
+ // selection range holds selection converted from DOM selection range to
+ // internal unformatted representation of account number
+ const val = this.sanitize(props.value);
+
+ this.state = {
+ value: val,
+ selectionRange: [val.length, val.length]
+ };
+ }
+
+ componentWillReceiveProps(nextProps: AccountInputProps) {
+ const nextVal = this.sanitize(nextProps.value);
+ if(nextVal !== this.state.value) {
+ const len = nextVal.length;
+ this.setState({ value: nextVal, selectionRange: [len, len] });
+ }
+ }
+
+ shouldComponentUpdate(nextProps: AccountInputProps, nextState: AccountInputState) {
+ return (this.props.value !== nextProps.value ||
+ this.props.onEnter !== nextProps.onEnter ||
+ this.props.onChange !== nextProps.onChange ||
+ this.state.value !== nextState.value ||
+ this.state.selectionRange[0] !== nextState.selectionRange[0] ||
+ this.state.selectionRange[1] !== nextState.selectionRange[1]);
+ }
+
+ render(): React.Element<*> {
+ const displayString = formatAccount(this.state.value || '');
+ const { value, onChange, onEnter, ...otherProps } = this.props; // eslint-disable-line no-unused-vars
+ return (
+ <input { ...otherProps }
+ type="text"
+ value={ displayString }
+ onChange={ () => {} }
+ onSelect={ this.onSelect }
+ onKeyUp={ this.onKeyUp }
+ onKeyDown={ this.onKeyDown }
+ onPaste={ this.onPaste }
+ onCut={ this.onCut }
+ ref={ this.onRef } />
+ );
+ }
+
+ // Private
+
+ /**
+ * Modify original string inserting substring using selection range
+ */
+ sanitize(val: ?string): string {
+ return (val || '').replace(/[^0-9]/g, '');
+ }
+
+ /**
+ * Modify original string inserting substring using selection range
+ *
+ * @private
+ * @param {String} val original string
+ * @param {String} insert insertion string
+ * @param {Array} selRange selection range ([x,y])
+ * @returns {Object}
+ */
+ insert(val: string, insert: string, selRange: SelectionRange): AccountInputState {
+ const head = val.slice(0, selRange[0]);
+ const tail = val.slice(selRange[1], val.length);
+ const newVal = head + insert + tail;
+ const selectionOffset = head.length + insert.length;
+
+ return { value: newVal, selectionRange: [selectionOffset, selectionOffset] };
+ }
+
+
+ /**
+ * Modify string by removing single character or range of characters based on selection range.
+ *
+ * @private
+ * @param {String} val original string
+ * @param {Array} selRange selection range ([x,y])
+ * @returns {Object}
+ *
+ * @memberOf AccountInput
+ */
+ remove(val: string, selRange: SelectionRange): AccountInputState {
+ let newVal, selectionOffset;
+
+ if(selRange[0] === selRange[1]) {
+ const oneOff = Math.max(0, selRange[0] - 1);
+ const head = val.slice(0, oneOff);
+ const tail = val.slice(selRange[0], val.length);
+ newVal = head + tail;
+ selectionOffset = head.length;
+ } else {
+ const head = val.slice(0, selRange[0]);
+ const tail = val.slice(selRange[1], val.length);
+ newVal = head + tail;
+ selectionOffset = head.length;
+ }
+
+ return { value: newVal, selectionRange: [selectionOffset, selectionOffset] };
+ }
+
+
+ /**
+ * Convert DOM selection range to internal selection range
+ *
+ * @private
+ * @param {String} val original string
+ * @param {Array} domRange selection range from DOM
+ * @returns {Object}
+ *
+ * @memberOf AccountInput
+ */
+ toInternalSelectionRange(val: string, domRange: SelectionRange): SelectionRange {
+ const countSpaces = (val) => {
+ return (val.match(/\s/g) || []).length;
+ };
+
+ const fmt = formatAccount(val || '');
+ let start = domRange[0];
+ let end = domRange[1];
+ const before = countSpaces(fmt.slice(0, start));
+ const within = countSpaces(fmt.slice(start, end));
+
+ start -= before;
+ end -= (before + within);
+
+ return [ start, end ];
+ }
+
+
+ /**
+ * Convert internal selection range to DOM selection range
+ *
+ * @private
+ * @param {String} val original string
+ * @param {Array} selRange selection range
+ * @returns {Object}
+ *
+ * @memberOf AccountInput
+ */
+ toDomSelection(val: string, selRange: SelectionRange): SelectionRange {
+ const countSpaces = (val, untilIndex) => {
+ if(val.length > 12) { return 0; }
+ return Math.floor(untilIndex / 4); // groups of 4 digits
+ };
+
+ let start = selRange[0];
+ let end = selRange[1];
+ const startSpaces = countSpaces(val, start);
+ const endSpaces = countSpaces(val, end);
+
+ start += startSpaces;
+ end += startSpaces + (endSpaces - startSpaces);
+
+ return [ start, end ];
+ }
+
+ // Events
+
+ onKeyDown = (e: KeyboardEvent) => {
+ const { value, selectionRange } = this.state;
+
+ if(e.which === 8) { // backspace
+ const result = this.remove(value, selectionRange);
+ e.preventDefault();
+
+ this._ignoreSelect = true;
+
+ this.setState(result, () => {
+ if(this.props.onChange) {
+ this.props.onChange(result.value);
+ }
+ });
+ } else if(/^[0-9]$/.test(e.key)) { // digits or cmd+v
+ const result = this.insert(value, e.key, selectionRange);
+ e.preventDefault();
+
+ this._ignoreSelect = true;
+
+ this.setState(result, () => {
+ if(this.props.onChange) {
+ this.props.onChange(result.value);
+ }
+ });
+ }
+ }
+
+ onKeyUp = (e: KeyboardEvent) => {
+ this._ignoreSelect = false;
+
+ if(e.which === 13 && this.props.onEnter) {
+ this.props.onEnter();
+ }
+ }
+
+ onSelect = (e: Event) => {
+ const ref = e.target;
+ if(!(ref instanceof HTMLInputElement)) {
+ throw new Error('ref must be an instance of HTMLInputElement');
+ }
+
+ if(this._ignoreSelect) {
+ return;
+ }
+
+ const start = ref.selectionStart;
+ const end = ref.selectionEnd;
+ const selRange = this.toInternalSelectionRange(this.sanitize(ref.value), [start, end]);
+ this.setState({ selectionRange: selRange });
+ }
+
+ onPaste = (e: ClipboardEvent) => {
+ const { value, selectionRange } = this.state;
+ const pastedData = e.clipboardData.getData('text');
+ const filteredData = this.sanitize(pastedData);
+ const result = this.insert(value, filteredData, selectionRange);
+ e.preventDefault();
+ this.setState(result, () => {
+ if(this.props.onChange) {
+ this.props.onChange(result.value);
+ }
+ });
+ }
+
+ onCut = (e: ClipboardEvent) => {
+ const target = e.target;
+ if(!(target instanceof HTMLInputElement)) {
+ throw new Error('ref must be an instance of HTMLInputElement');
+ }
+
+ const { value, selectionRange } = this.state;
+
+ e.preventDefault();
+
+ // range is not empty?
+ if(selectionRange[0] !== selectionRange[1]) {
+ const result = this.remove(value, selectionRange);
+ const domSelectionRange = this.toDomSelection(value, selectionRange);
+ const slice = target.value.slice(domSelectionRange[0], domSelectionRange[1]);
+
+ e.clipboardData.setData('text', slice);
+
+ this.setState(result, () => {
+ if(this.props.onChange) {
+ this.props.onChange(result.value);
+ }
+ });
+ }
+ }
+
+ onRef = (ref: HTMLInputElement) => {
+ this._ref = ref;
+ if(!ref) { return; }
+
+ const { value, selectionRange } = this.state;
+ const domRange = this.toDomSelection(value, selectionRange);
+
+ ref.selectionStart = domRange[0];
+ ref.selectionEnd = domRange[1];
+ }
+
+ focus() {
+ if(this._ref) {
+ this._ref.focus();
+ }
+ }
+
+} \ No newline at end of file
diff --git a/app/components/AdvancedSettings.js b/app/components/AdvancedSettings.js
new file mode 100644
index 0000000000..227499a1a8
--- /dev/null
+++ b/app/components/AdvancedSettings.js
@@ -0,0 +1,145 @@
+// @flow
+
+import React from 'react';
+import { Layout, Container, Header } from './Layout';
+import CustomScrollbars from './CustomScrollbars';
+
+export class AdvancedSettings extends React.Component {
+
+ props: {
+ onClose: () => void,
+ protocol: string,
+ port: string|number,
+ updateConstraints: (string, string|number) => void,
+ };
+
+ render() {
+ let portSelector = null;
+ let protocol = this.props.protocol.toUpperCase();
+
+ if (protocol === 'AUTOMATIC') {
+ protocol = 'Automatic';
+ } else {
+ portSelector = this._createPortSelector();
+ }
+
+ return <BaseLayout onClose={ this.props.onClose }>
+
+ <Selector
+ title={ 'Network protocols' }
+ values={ ['Automatic', 'UDP', 'TCP'] }
+ value={ protocol }
+ onSelect={ protocol => {
+ this.props.updateConstraints(protocol, 'Automatic');
+ }}/>
+
+ <div className="settings__cell-spacer"></div>
+
+ { portSelector }
+
+ </BaseLayout>;
+ }
+
+ _createPortSelector() {
+ const protocol = this.props.protocol.toUpperCase();
+ const ports = protocol === 'TCP'
+ ? ['Automatic', 80, 443]
+ : ['Automatic', 1194, 1195, 1196, 1197, 1300, 1301, 1302];
+
+ return <Selector
+ title={ protocol + ' port' }
+ values={ ports }
+ value={ this.props.port }
+ onSelect={ port => {
+ this.props.updateConstraints(protocol, port);
+ }} />;
+ }
+}
+
+
+class Selector extends React.Component {
+
+ props: {
+ title: string,
+ values: Array<*>,
+ value: *,
+ onSelect: (*) => void,
+ }
+
+ render() {
+ return <div>
+ <div className="settings__cell">
+ <div className="settings__cell-label">{ this.props.title }</div>
+ </div>
+
+ { this.props.values.map(value => this._renderCell(value)) }
+ </div>;
+ }
+
+ _renderCell(value) {
+ const selected = value === this.props.value;
+ if (selected) {
+ return this._renderSelectedCell(value);
+ } else {
+ return this._renderUnselectedCell(value);
+ }
+ }
+
+ _renderSelectedCell(value) {
+ const onCellClick = () => this.props.onSelect(value);
+
+ return <div
+ key={ value }
+ className={ 'settings__cell--selected settings__cell' }
+ onClick={ onCellClick } >
+ <div className="settings__cell-label">
+ <div className={ 'settings__sub-cell--label' }>
+ <img src='./assets/images/icon-tick.svg' />
+ { value }
+ </div>
+ </div>
+ </div>;
+ }
+
+ _renderUnselectedCell(value) {
+ const onCellClick = () => this.props.onSelect(value);
+
+ return <div
+ key={ value }
+ className={ 'settings__cell settings__sub-cell' }
+ onClick={ onCellClick } >
+ <div className="settings__cell-label">
+ <div className="settings__sub-cell--label">{ value }</div>
+ </div>
+ </div>;
+ }
+}
+
+function BaseLayout(props) {
+ return <Layout>
+ <Header hidden={ true } style={ 'defaultDark' } />
+ <Container>
+ <div className="settings">
+ <div className="support__close" onClick={ props.onClose }>
+ <img className="support__close-icon" src="./assets/images/icon-back.svg" />
+ <span className="support__close-title">Settings</span>
+ </div>
+ <div className="settings__container">
+ <div className="settings__header">
+ <h2 className="settings__title">Advanced</h2>
+ </div>
+ <CustomScrollbars autoHide={ true }>
+ <div className="settings__content">
+ <div className="settings__main">
+ <div className="settings__advanced">
+ { props.children }
+ </div>
+ </div>
+ </div>
+ </CustomScrollbars>
+ </div>
+ </div>
+ </Container>
+ </Layout>;
+}
+
diff --git a/app/components/Connect.css b/app/components/Connect.css
new file mode 100644
index 0000000000..dc9a070c99
--- /dev/null
+++ b/app/components/Connect.css
@@ -0,0 +1,169 @@
+.connect {
+ height: 100%;
+ position: relative;
+}
+
+.connect__map {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 0;
+}
+
+.connect__container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ position: relative; /* need this for z-index to work to cover map */
+ z-index: 1;
+}
+
+.connect__footer {
+ display: flex;
+ flex-direction: column;
+ padding: 42px 24px 24px;
+}
+
+.connect__row + .connect__row{
+ margin-top: 16px;
+}
+
+.connect__server {
+ padding: 7px 12px 9px;
+ background-color: rgba(255,255,255,0.2);
+ border-radius: 4px;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ backdrop-filter: blur(4px);
+}
+
+.connect__server-label {
+ font-family: "Open Sans";
+ font-size: 12px;
+ font-weight: 800;
+ line-height: 17px;
+ color: rgba(255,255,255,0.8);
+ text-transform: uppercase;
+ flex: 0 0 auto;
+}
+
+.connect__server-value {
+ flex: 1 1 auto;
+ margin-left: 8px;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: flex-end;
+}
+
+.connect__server-name {
+ font-family: DINPro;
+ font-size: 20px;
+ font-weight: 900;
+ line-height: 26px;
+ color: #FFFFFF;
+ text-align: right;
+}
+
+.connect__server-icon + .connect__server-name {
+ margin-left: 8px;
+}
+
+.connect__footer-button {
+ display: block;
+ width:100%;
+ border: 0;
+ padding: 7px 12px 9px;
+ border-radius: 4px;
+ font-family: DINPro;
+ font-size: 20px;
+ font-weight: 900;
+ text-align: center;
+ line-height: 26px;
+ color: #FFFFFF;
+}
+
+.connect__status {
+ padding: 0 24px;
+ margin-top: 96px;
+ margin-bottom: auto;
+}
+
+.connect__error-title {
+ font-family: DINPro;
+ font-size: 32px;
+ font-weight: 900;
+ line-height: 1.25;
+ color: #fff;
+ margin-bottom: 8px;
+}
+
+.connect__error-message {
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ line-height: normal;
+ color: #fff;
+ margin-bottom: 24px;
+}
+
+.connect__status-security {
+ font-family: "Open Sans";
+ font-size: 16px;
+ font-weight: 800;
+ line-height: 22px;
+ margin-bottom: 4px;
+ color: #FFFFFF;
+ text-transform: uppercase;
+}
+
+.connect__status-security--unsecured {
+ color: #D0021B;
+}
+
+.connect__status-security--secure {
+ color: #44AD4D;
+}
+
+.connect__status-location {
+ font-family: DINPro;
+ font-size: 38px;
+ font-weight: 900;
+ line-height: 1.16em;
+ max-height: calc(1.16em * 2);
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ letter-spacing: -0.9px;
+ color: #FFFFFF;
+ margin-bottom: 4px;
+}
+
+.connect__status-location-icon {
+ display: inline-block;
+ margin-right: 8px;
+}
+
+.connect__status-ipaddress {
+ font-family: "Open Sans";
+ font-size: 16px;
+ font-weight: 800;
+ line-height: normal;
+ color: #FFFFFF;
+}
+
+.connect__status-ipaddress--invisible {
+ visibility: hidden;
+}
+
+.connect__status-icon {
+ text-align: center;
+ margin-bottom: 32px;
+}
+
+.connect__status-icon--hidden {
+ visibility: hidden;
+}
diff --git a/app/components/Connect.js b/app/components/Connect.js
new file mode 100644
index 0000000000..1dc135c745
--- /dev/null
+++ b/app/components/Connect.js
@@ -0,0 +1,369 @@
+// @flow
+
+import moment from 'moment';
+import React, { Component } from 'react';
+import { If, Then } from 'react-if';
+import { Layout, Container, Header } from './Layout';
+import { BackendError } from '../lib/backend';
+import ExternalLinkSVG from '../assets/images/icon-extLink.svg';
+
+import type { ServerInfo } from '../lib/backend';
+import type { HeaderBarStyle } from './HeaderBar';
+import type { ConnectionReduxState } from '../redux/connection/reducers';
+import type { SettingsReduxState } from '../redux/settings/reducers';
+
+export type ConnectProps = {
+ accountExpiry: string,
+ connection: ConnectionReduxState,
+ settings: SettingsReduxState,
+ onSettings: () => void,
+ onSelectLocation: () => void,
+ onConnect: (host: string) => void,
+ onCopyIP: () => void,
+ onDisconnect: () => void,
+ onExternalLink: (type: string) => void,
+ getServerInfo: (identifier: string) => ?ServerInfo
+};
+
+
+export default class Connect extends Component {
+ props: ConnectProps;
+ state = {
+ isFirstPass: true,
+ showCopyIPMessage: false
+ };
+
+ _copyTimer: ?number;
+
+ componentDidMount() {
+ this.setState({ isFirstPass: false });
+ }
+
+ componentWillUnmount() {
+ if(this._copyTimer) {
+ clearTimeout(this._copyTimer);
+ this._copyTimer = null;
+ }
+
+ this.setState({
+ isFirstPass: true,
+ showCopyIPMessage: false
+ });
+ }
+
+ render(): React.Element<*> {
+ const error = this.displayError();
+ const child = error ? this.renderError(error) : this.renderMap();
+
+ return (
+ <Layout>
+ <Header style={ this.headerStyle() } showSettings={ true } onSettings={ this.props.onSettings } />
+ <Container>
+ { child }
+ </Container>
+ </Layout>
+ );
+ }
+
+ renderError(error: BackendError): React.Element<*> {
+ return (
+ <div className="connect">
+ <div className="connect__status">
+ <div className="connect__status-icon">
+ <img src="./assets/images/icon-fail.svg" alt="" />
+ </div>
+ <div className="connect__error-title">
+ { error.title }
+ </div>
+ <div className="connect__error-message">
+ { error.message }
+ </div>
+ <If condition={ error.type === 'NO_CREDIT' }>
+ <Then>
+ <div>
+ <button className="button button--positive" onClick={ this.onExternalLink.bind(this, 'purchase') }>
+ <span className="button-label">Buy more time</span>
+ <ExternalLinkSVG className="button-icon button-icon--16" />
+ </button>
+ </div>
+ </Then>
+ </If>
+ </div>
+ </div>
+ );
+ }
+
+ _getServerInfo() {
+ const { relayConstraints } = this.props.settings;
+ if (relayConstraints.host === 'any') {
+ return {
+ name: 'Automatic',
+ country: 'Automatic',
+ city: 'Automatic',
+ address: '',
+ };
+ }
+
+ return this.props.getServerInfo(relayConstraints.host);
+ }
+
+ renderMap(): React.Element<*> {
+ const serverInfo = this._getServerInfo();
+
+ let isConnecting = false;
+ let isConnected = false;
+ let isDisconnected = false;
+ switch(this.props.connection.status) {
+ case 'connecting': isConnecting = true; break;
+ case 'connected': isConnected = true; break;
+ case 'disconnected': isDisconnected = true; break;
+ }
+
+ const { city, country } = serverInfo && (isConnecting || isConnected)
+ ? serverInfo
+ : { city: '\u2003', country: '\u2002' };
+ const ip = serverInfo && isConnected
+ ? serverInfo.address
+ : '\u2003'; //this.props.connection.clientIp;
+ const serverName = serverInfo
+ ? serverInfo.name
+ : '\u2003';
+
+ // We decided to not include the map in the first beta release to customers
+ // but it MUST be included in the following releases. Therefore we choose
+ // to just comment it out
+ const map = undefined;
+ /*
+ const altitude = (isConnecting ? 300 : 100) * 1000;
+ const { location } = this.props.connection;
+ const map = <Map animate={ !this.state.isFirstPass }
+ location={ location || [0, 0] }
+ altitude= { altitude }
+ markerImagePath= { isConnected
+ ? './assets/images/location-marker-secure.svg'
+ : './assets/images/location-marker-unsecure.svg' } />
+ */
+
+ let ipComponent = undefined;
+ if (isConnected || isDisconnected) {
+ if (this.state.showCopyIPMessage) {
+ ipComponent = <span>{ 'IP copied to clipboard!' }</span>;
+ } else {
+ ipComponent = <span>{ ip }</span>;
+ }
+ }
+ return (
+ <div className="connect">
+ <div className="connect__map">
+ { map }
+ </div>
+ <div className="connect__container">
+
+ <div className="connect__status">
+ { /* show spinner when connecting */ }
+ <div className={ this.spinnerClass() }>
+ <img src="./assets/images/icon-spinner.svg" alt="" />
+ </div>
+
+ <div className={ this.networkSecurityClass() }>{ this.networkSecurityMessage() }</div>
+
+ { /*
+ **********************************
+ Begin: Location block
+ **********************************
+ */ }
+
+ { /* location when connecting */ }
+ <If condition={ isConnecting }>
+ <Then>
+ <div className="connect__status-location">
+ <span>{ country }</span>
+ </div>
+ </Then>
+ </If>
+
+ { /* location when connected */ }
+ <If condition={ isConnected }>
+ <Then>
+ <div className="connect__status-location">
+ { city }<br/>{ country }
+ </div>
+ </Then>
+ </If>
+
+ { /* location when disconnected */ }
+ <If condition={ isDisconnected }>
+ <Then>
+ <div className="connect__status-location">
+ { country }
+ </div>
+ </Then>
+ </If>
+
+ { /*
+ **********************************
+ End: Location block
+ **********************************
+ */ }
+
+ <div className={ this.ipAddressClass() } onClick={ this.onIPAddressClick.bind(this) }>
+ { ipComponent }
+ </div>
+ </div>
+
+
+ { /*
+ **********************************
+ Begin: Footer block
+ **********************************
+ */ }
+
+ { /* footer when disconnected */ }
+ <If condition={ isDisconnected }>
+ <Then>
+ <div className="connect__footer">
+ <div className="connect__row">
+
+ <div className="connect__server" onClick={ this.props.onSelectLocation }>
+ <div className="connect__server-label">Connect to</div>
+ <div className="connect__server-value">
+
+ <div className="connect__server-name">{ serverName }</div>
+
+ </div>
+ </div>
+ </div>
+
+ <div className="connect__row">
+ <button className="button button--positive" onClick={ this.onConnect.bind(this) }>Secure my connection</button>
+ </div>
+ </div>
+ </Then>
+ </If>
+
+ { /* footer when connecting */ }
+ <If condition={ isConnecting }>
+ <Then>
+ <div className="connect__footer">
+ <div className="connect__row">
+ <button className="button button--neutral button--blur" onClick={ this.props.onSelectLocation }>Switch location</button>
+ </div>
+
+ <div className="connect__row">
+ <button className="button button--negative-light button--blur" onClick={ this.props.onDisconnect }>Cancel</button>
+ </div>
+ </div>
+ </Then>
+ </If>
+
+ { /* footer when connected */ }
+ <If condition={ isConnected }>
+ <Then>
+ <div className="connect__footer">
+ <div className="connect__row">
+ <button className="button button--neutral button--blur" onClick={ this.props.onSelectLocation }>Switch location</button>
+ </div>
+
+ <div className="connect__row">
+ <button className="button button--negative-light button--blur" onClick={ this.props.onDisconnect }>Disconnect</button>
+ </div>
+ </div>
+ </Then>
+ </If>
+
+ { /*
+ **********************************
+ End: Footer block
+ **********************************
+ */ }
+
+ </div>
+ </div>
+ );
+ }
+
+ // Handlers
+
+ onConnect() {
+ const serverInfo = this._getServerInfo();
+ if(!serverInfo) {
+ return;
+ }
+
+ this.props.onConnect(serverInfo.address);
+ }
+
+ onExternalLink(type: string) {
+ this.props.onExternalLink(type);
+ }
+
+ onIPAddressClick() {
+ this._copyTimer && clearTimeout(this._copyTimer);
+ this._copyTimer = setTimeout(() => this.setState({ showCopyIPMessage: false }), 3000);
+ this.setState({ showCopyIPMessage: true });
+ this.props.onCopyIP();
+ }
+
+ // Private
+
+ headerStyle(): HeaderBarStyle {
+ switch(this.props.connection.status) {
+ case 'connecting':
+ case 'disconnected':
+ return 'error';
+ case 'connected':
+ return 'success';
+ }
+ throw new Error('Invalid ConnectionState');
+ }
+
+ networkSecurityClass(): string {
+ let classes = ['connect__status-security'];
+ if(this.props.connection.status === 'connected') {
+ classes.push('connect__status-security--secure');
+ } else if(this.props.connection.status === 'disconnected') {
+ classes.push('connect__status-security--unsecured');
+ }
+
+ return classes.join(' ');
+ }
+
+ networkSecurityMessage(): string {
+ switch(this.props.connection.status) {
+ case 'connected': return 'Secure connection';
+ case 'connecting': return 'Creating secure connection';
+ default: return 'Unsecured connection';
+ }
+ }
+
+ spinnerClass(): string {
+ var classes = ['connect__status-icon'];
+ if(this.props.connection.status !== 'connecting') {
+ classes.push('connect__status-icon--hidden');
+ }
+ return classes.join(' ');
+ }
+
+ ipAddressClass(): string {
+ var classes = ['connect__status-ipaddress'];
+ if(this.props.connection.status === 'connecting') {
+ classes.push('connect__status-ipaddress--invisible');
+ }
+ return classes.join(' ');
+ }
+
+ displayError(): ?BackendError {
+ // Offline?
+ if(!this.props.connection.isOnline) {
+ return new BackendError('NO_INTERNET');
+ }
+
+ // No credit?
+ const expiry = this.props.accountExpiry;
+ if(expiry && moment(expiry).isSameOrBefore(moment())) {
+ return new BackendError('NO_CREDIT');
+ }
+
+ return null;
+ }
+}
diff --git a/app/components/CustomScrollbars.css b/app/components/CustomScrollbars.css
new file mode 100644
index 0000000000..24788230b8
--- /dev/null
+++ b/app/components/CustomScrollbars.css
@@ -0,0 +1,4 @@
+.custom-scrollbars__thumb-vertical {
+ background-color: rgba(255, 255, 255, 0.2);
+ border-radius: 3px;
+}
diff --git a/app/components/CustomScrollbars.js b/app/components/CustomScrollbars.js
new file mode 100644
index 0000000000..4a0899c1be
--- /dev/null
+++ b/app/components/CustomScrollbars.js
@@ -0,0 +1,19 @@
+// @flow
+import React, { Component } from 'react';
+import { Scrollbars } from 'react-custom-scrollbars';
+
+export default class CustomScrollbars extends Component {
+ props: {
+ children: ?React.Element<*>
+ }
+
+ render(): React.Element<*> {
+ return (
+ <Scrollbars
+ { ...this.props }
+ renderThumbVertical={ () => <div className="custom-scrollbars__thumb-vertical"/> }>
+ { this.props.children }
+ </Scrollbars>
+ );
+ }
+}
diff --git a/app/components/HeaderBar.css b/app/components/HeaderBar.css
new file mode 100644
index 0000000000..9fcc65047b
--- /dev/null
+++ b/app/components/HeaderBar.css
@@ -0,0 +1,61 @@
+.headerbar {
+ padding: 12px;
+ background-color: #294D73;
+ transition: 0.5s background-color ease-in-out;
+}
+
+/* macOS app runs as menubar app so add extra padding */
+.headerbar--darwin {
+ padding-top: 24px;
+}
+
+.headerbar--hidden {
+ padding: 24px 0 0 0;
+}
+
+.headerbar--style-defaultDark {
+ background-color: #192E45;
+}
+
+.headerbar--style-error {
+ background-color: #D0021B;
+}
+
+.headerbar--style-success {
+ background-color: #44AD4D;
+}
+
+.headerbar__container {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+
+.headerbar__title {
+ font-family: DINPro;
+ font-size: 24px;
+ font-weight: 900;
+ line-height: 30px;
+ letter-spacing: -0.5px;
+ color: rgba(255,255,255,0.6);
+ display: inline-block;
+ vertical-align: middle;
+ margin-left: 8px;
+}
+
+.headerbar__logo {
+ height: 50px;
+ display: inline-block;
+ vertical-align: middle;
+}
+
+.headerbar__settings {
+ display: block;
+ border: 0;
+ padding: 0;
+ margin: 0 0 0 auto;
+ width: 24px;
+ height: 24px;
+ background-color: transparent;
+ background-image: url(../assets/images/icon-settings.svg);
+}
diff --git a/app/components/HeaderBar.js b/app/components/HeaderBar.js
new file mode 100644
index 0000000000..26e937ad23
--- /dev/null
+++ b/app/components/HeaderBar.js
@@ -0,0 +1,51 @@
+// @flow
+import React, { Component } from 'react';
+import { If, Then } from 'react-if';
+
+export type HeaderBarStyle = 'default' | 'defaultDark' | 'error' | 'success';
+export type HeaderBarProps = {
+ style: HeaderBarStyle;
+ hidden: boolean;
+ showSettings: boolean;
+ onSettings: ?(() => void);
+};
+
+export default class HeaderBar extends Component {
+ props: HeaderBarProps;
+ static defaultProps: $Shape<HeaderBarProps> = {
+ style: 'default',
+ hidden: false,
+ showSettings: false,
+ onSettings: null
+ };
+
+ render(): React.Element<*> {
+ let containerClass = [
+ 'headerbar',
+ 'headerbar--' + process.platform,
+ 'headerbar--style-' + this.props.style
+ ];
+
+ if(this.props.hidden) {
+ containerClass.push('headerbar--hidden');
+ }
+
+ return (
+ <div className={ containerClass.join(' ') }>
+ <If condition={ !this.props.hidden }>
+ <Then>
+ <div className="headerbar__container">
+ <img className="headerbar__logo" src="./assets/images/logo-icon.svg" />
+ <h2 className="headerbar__title">MULLVAD VPN</h2>
+ <If condition={ !!this.props.showSettings }>
+ <Then>
+ <button className="headerbar__settings" onClick={ this.props.onSettings } />
+ </Then>
+ </If>
+ </div>
+ </Then>
+ </If>
+ </div>
+ );
+ }
+}
diff --git a/app/components/Layout.css b/app/components/Layout.css
new file mode 100644
index 0000000000..14acf93769
--- /dev/null
+++ b/app/components/Layout.css
@@ -0,0 +1,15 @@
+.layout {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+.layout__header {
+ flex: 0 0 auto;
+}
+
+.layout__container {
+ flex: 1 1 100%;
+ background: #294D73;
+ overflow: hidden; /* needed for flex boxes with overflow: auto to work */
+} \ No newline at end of file
diff --git a/app/components/Layout.js b/app/components/Layout.js
new file mode 100644
index 0000000000..5c0e1f5bcb
--- /dev/null
+++ b/app/components/Layout.js
@@ -0,0 +1,46 @@
+// @flow
+import React, { Component } from 'react';
+import HeaderBar from './HeaderBar';
+
+import type { HeaderBarProps } from './HeaderBar';
+
+export class Header extends Component {
+ props: HeaderBarProps;
+ static defaultProps = HeaderBar.defaultProps;
+
+ render(): React.Element<*> {
+ return (
+ <div className="layout__header">
+ <HeaderBar { ...this.props } />
+ </div>
+ );
+ }
+}
+
+export class Container extends Component {
+ props: {
+ children: React.Element<*>
+ }
+
+ render(): React.Element<*> {
+ return (
+ <div className="layout__container">
+ { this.props.children }
+ </div>
+ );
+ }
+}
+
+export class Layout extends Component {
+ props: {
+ children: Array<React.Element<*>> | React.Element<*>
+ }
+
+ render(): React.Element<*> {
+ return (
+ <div className="layout">
+ { this.props.children }
+ </div>
+ );
+ }
+}
diff --git a/app/components/Login.css b/app/components/Login.css
new file mode 100644
index 0000000000..8793337f0c
--- /dev/null
+++ b/app/components/Login.css
@@ -0,0 +1,154 @@
+.login {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.login-footer {
+ background-color: #192E45;
+ padding: 18px 24px 24px;
+ flex: 0 0 auto;
+}
+
+.login-footer--invisible {
+ visibility: hidden;
+}
+
+.login-form__status-icon {
+ text-align: center;
+ margin-bottom: 44px;
+}
+
+.login-footer__prompt {
+ font-family: "Open Sans";
+ font-size: 13px;
+ line-height: 18px;
+ font-weight: 600;
+ color: rgba(255,255,255,0.8);
+ margin-bottom: 8px;
+}
+
+.login-form {
+ display: flex;
+ flex-direction: column;
+ padding: 0 24px;
+ margin: auto 0 70px;
+}
+
+.login-form__title {
+ font-family: DINPro;
+ font-size: 32px;
+ font-weight: 900;
+ line-height: 1.25em;
+ color: #FFFFFF;
+ margin-bottom: 7px;
+}
+
+.login-form__subtitle {
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ line-height: normal;
+ color: rgba(255,255,255,0.8);
+ margin-bottom: 8px;
+}
+
+.login-form__input-wrap {
+ border: 2px solid transparent;
+ background-color: #FFFFFF;
+ background-clip: content-box;
+ border-radius: 8px;
+ margin-left: -2px;
+ margin-right: -2px;
+ display: flex;
+ flex-direction: row;
+ overflow: hidden;
+ transition-duration: 0.3s;
+ transition-property: background-color, border-color;
+ transition-timing-function: ease-in-out;
+
+ /* this fixes border-radius clipping bug when button animates opacity */
+ -webkit-backface-visibility: hidden;
+ -webkit-transform: translate3d(0, 0, 0);
+}
+
+.login-form__input-wrap--active {
+ border-color: rgba(25,46,69,0.4);
+}
+
+.login-form__input-wrap--inactive {
+ opacity: 0.6;
+}
+
+.login-form__input-field::-webkit-input-placeholder {
+ color: rgba(41,77,115,0.4);
+}
+
+.login-form__fields--invisible {
+ visibility: hidden;
+}
+
+.login-form__input-field {
+ width: 100%;
+ border-radius: 8px;
+ border: 0;
+ overflow: hidden;
+ padding: 10px 12px 12px 12px;
+ font-family: DINPro;
+ font-size: 20px;
+ font-weight: 900;
+ line-height: 26px;
+ color: #294D73;
+ background-color: transparent;
+ flex: 1 1 auto;
+ transition: 0.3s color ease-in-out;
+}
+
+.login-form__input-field--inactive {
+ background-color: rgba(255,255,255,0.6);
+}
+
+.login-form__input-wrap--error {
+ border-color: rgba(208,2,27,0.4);
+}
+
+.login-form__input-wrap--error .login-form__input-field {
+ color: #D0021B;
+}
+
+.login-form__submit {
+ flex: 0 0 auto;
+ border: 0;
+ width: 48px;
+ background-color: transparent;
+ transition-duration: 0.3s;
+ transition-property: background-color, opacity;
+ transition-timing-function: ease-in-out;
+}
+
+.login-form__submit-icon {
+ display: inline-block;
+ vertical-align: middle;
+}
+
+.login-form__submit-icon path {
+ fill: rgba(41,77,115,0.2);
+ transition: fill 0.3s ease-in-out;
+}
+
+.login-form__submit--active {
+ background-color: #44ad4d;
+}
+
+.login-form__submit--active .login-form__submit-icon path {
+ fill: #fff;
+}
+
+.login-form__submit--active:hover {
+ background-color: rgba(68, 173, 76, 0.9);
+}
+
+.login-form__submit--invisible {
+ visibility: hidden;
+ opacity: 0;
+}
diff --git a/app/components/Login.js b/app/components/Login.js
new file mode 100644
index 0000000000..784d92daa8
--- /dev/null
+++ b/app/components/Login.js
@@ -0,0 +1,212 @@
+// @flow
+import React, { Component } from 'react';
+import { Layout, Container, Header } from './Layout';
+import AccountInput from './AccountInput';
+import ExternalLinkSVG from '../assets/images/icon-extLink.svg';
+import LoginArrowSVG from '../assets/images/icon-arrow.svg';
+
+import type { AccountReduxState, LoginState } from '../redux/account/reducers';
+
+export type LoginPropTypes = {
+ account: AccountReduxState,
+ onLogin: (accountToken: string) => void,
+ onSettings: ?(() => void),
+ onFirstChangeAfterFailure: () => void,
+ onExternalLink: (type: string) => void,
+ onAccountTokenChange: (string) => void,
+};
+
+export default class Login extends Component {
+ props: LoginPropTypes;
+ state = {
+ notifyOnFirstChangeAfterFailure: false,
+ isActive: false,
+ };
+
+ onCreateAccount = () => this.props.onExternalLink('createAccount');
+ onFocus = () => this.setState({ isActive: true });
+ onBlur = () => this.setState({ isActive: false });
+ onLogin = () => {
+ const accountToken = this.props.account.accountToken;
+ if(accountToken && accountToken.length > 0) {
+ this.props.onLogin(accountToken);
+ }
+ }
+
+ onInputChange = (val: string) => {
+ // notify delegate on first change after login failure
+ if(this.state.notifyOnFirstChangeAfterFailure) {
+ this.setState({ notifyOnFirstChangeAfterFailure: false });
+ this.props.onFirstChangeAfterFailure();
+ }
+ this.props.onAccountTokenChange(val);
+ }
+
+ formTitle(s: LoginState): string {
+ switch(s) {
+ case 'logging in': return 'Logging in...';
+ case 'failed': return 'Login failed';
+ case 'ok': return 'Login successful';
+ default: return 'Login';
+ }
+ }
+
+ formSubtitle(s: LoginState, e: ?Error): string {
+ switch(s) {
+ case 'failed': return (e && e.message) || 'Unknown error';
+ case 'logging in': return 'Checking account number';
+ default: return 'Enter your account number';
+ }
+ }
+
+ inputWrapClass(s: LoginState): string {
+ const classes = ['login-form__input-wrap'];
+
+ if(this.state.isActive) {
+ classes.push('login-form__input-wrap--active');
+ }
+
+ switch(s) {
+ case 'logging in':
+ classes.push('login-form__input-wrap--inactive');
+ break;
+ case 'failed':
+ classes.push('login-form__input-wrap--error');
+ break;
+ }
+
+ return classes.join(' ');
+ }
+
+ submitClass(s: LoginState, accountToken: ?string): string {
+ const classes = ['login-form__submit'];
+
+ if(accountToken && accountToken.length > 0) {
+ classes.push('login-form__submit--active');
+ }
+
+ if(s === 'logging in') {
+ classes.push('login-form__submit--invisible');
+ }
+
+ return classes.join(' ');
+ }
+
+ componentWillReceiveProps(nextProps: LoginPropTypes) {
+ const prev = this.props.account || {};
+ const next = nextProps.account || {};
+
+ if(prev.status !== next.status && next.status === 'failed') {
+ this.setState({ notifyOnFirstChangeAfterFailure: true });
+ }
+ }
+
+ render(): React.Element<*> {
+ const { status } = this.props.account;
+ const title = this.formTitle(status);
+
+ const shouldShowLoginForm = status !== 'ok';
+ const shouldShowFooter = status === 'none' || status === 'failed';
+
+ const statusIcon = this._getStatusIcon();
+
+ const loginFormClass = shouldShowLoginForm ? '' : 'login-form__fields--invisible';
+ const loginForm = this._createLoginForm();
+
+ const footerClass = shouldShowFooter ? '' : 'login-footer--invisible';
+ const footer = this._createFooter();
+
+ return (
+ <Layout>
+ <Header showSettings={ true } onSettings={ this.props.onSettings } />
+ <Container>
+ <div className="login">
+ <div className="login-form">
+ { statusIcon }
+
+ <div className="login-form__title">{ title }</div>
+
+ <div className={ 'login-form__fields ' + loginFormClass }>
+ { loginForm }
+ </div>
+ </div>
+
+ <div className={ 'login-footer ' + footerClass }>
+ { footer }
+ </div>
+ </div>
+ </Container>
+ </Layout>
+ );
+ }
+
+ _getStatusIcon(): React.Element<*> {
+ const statusIconPath = this._getStatusIconPath();
+
+ return <div className="login-form__status-icon">
+ <img src={ statusIconPath } alt="" />
+ </div>;
+ }
+
+ _getStatusIconPath(): ?string {
+ switch(this.props.account.status) {
+ case 'logging in':
+ return './assets/images/icon-spinner.svg';
+ case 'failed':
+ return './assets/images/icon-fail.svg';
+ case 'ok':
+ return './assets/images/icon-success.svg';
+ default:
+ return undefined;
+ }
+ }
+
+ _createLoginForm(): React.Element<*> {
+ const { status, error } = this.props.account;
+ const accountToken = this.props.account.accountToken;
+
+ const inputDisabled = status === 'logging in';
+
+ const subtitle = this.formSubtitle(status, error);
+
+ const inputWrapClass = this.inputWrapClass(status);
+ const submitClass = this.submitClass(status, accountToken);
+
+ const autoFocusRef = input => {
+ if(status === 'failed' && input) {
+ input.focus();
+ }
+ };
+
+ return <div>
+ <div className="login-form__subtitle">{ subtitle }</div>
+ <div className={ inputWrapClass }>
+ <AccountInput className="login-form__input-field"
+ type="text"
+ placeholder="e.g 0000 0000 0000"
+ onFocus={ this.onFocus }
+ onBlur={ this.onBlur }
+ onChange={ this.onInputChange }
+ onEnter={ this.onLogin }
+ value={ accountToken || '' }
+ disabled={ inputDisabled }
+ autoFocus={ true }
+ ref={ autoFocusRef } />
+ <button className={ submitClass } onClick={ this.onLogin }>
+ <LoginArrowSVG className="login-form__submit-icon" />
+ </button>
+ </div>
+ </div>;
+ }
+
+ _createFooter(): React.Element<*> {
+ return <div>
+ <div className="login-footer__prompt">{ 'Don\'t have an account number?' }</div>
+ <button className="button button--primary" onClick={ this.onCreateAccount }>
+ <span className="button-label">Create account</span>
+ <ExternalLinkSVG className="button-icon button-icon--16" />
+ </button>
+ </div>;
+ }
+}
+
diff --git a/app/components/Map.js b/app/components/Map.js
new file mode 100644
index 0000000000..337d5c812b
--- /dev/null
+++ b/app/components/Map.js
@@ -0,0 +1,51 @@
+// @flow
+
+import React, { Component } from 'react';
+import ReactMapboxGl, { Marker } from 'react-mapbox-gl';
+import { mapbox as mapboxConfig } from '../config';
+import cheapRuler from 'cheap-ruler';
+
+import type { Coordinate2d } from '../types';
+
+const ReactMap = ReactMapboxGl({
+ accessToken: mapboxConfig.accessToken,
+ attributionControl: false,
+ interactive: false,
+});
+
+export default class Map extends Component {
+ props: {
+ animate: boolean,
+ location: Coordinate2d,
+ altitude: number,
+ markerImagePath: string,
+ }
+
+ render() {
+
+ const mapBounds = this.calculateMapBounds(this.props.location, this.props.altitude);
+
+ const mapBoundsOptions = { offset: [0, -113], animate: this.props.animate };
+
+ return <ReactMap style={ mapboxConfig.styleURL }
+ containerStyle={{ height: '100%' }}
+ fitBounds={ mapBounds }
+ fitBoundsOptions={ mapBoundsOptions }>
+
+ <Marker coordinates={ this.convertToMapCoordinate(this.props.location) } offset={ [0, -10] }>
+ <img src={ this.props.markerImagePath } />
+ </Marker>
+ </ReactMap>;
+ }
+
+ calculateMapBounds(center: Coordinate2d, altitude: number): [Coordinate2d, Coordinate2d] {
+ const bounds = cheapRuler(center[0], 'meters').bufferPoint(center, altitude);
+ // convert [lat,lng] bounds to [lng,lat]
+ return [ [bounds[1], bounds[0]], [bounds[3], bounds[2]] ];
+ }
+
+ convertToMapCoordinate(pos: Coordinate2d): Coordinate2d {
+ // convert [lat,lng] bounds to [lng,lat]
+ return [pos[1], pos[0]];
+ }
+}
diff --git a/app/components/SelectLocation.css b/app/components/SelectLocation.css
new file mode 100644
index 0000000000..cb63f61115
--- /dev/null
+++ b/app/components/SelectLocation.css
@@ -0,0 +1,106 @@
+.select-location {
+ background: #192E45;
+ height: 100%;
+}
+
+.select-location__container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.select-location__header {
+ flex: 0 0 auto;
+ padding: 40px 24px 16px;
+ position: relative; /* anchor for close button */
+}
+
+.select-location__close {
+ position: absolute;
+ display: block;
+ border: 0;
+ padding: 0;
+ margin: 0;
+ width: 24px;
+ height: 24px;
+ top: 24px;
+ left: 12px;
+ background-color: transparent;
+ background-image: url(../assets/images/icon-close.svg);
+ opacity: 0.6;
+ z-index: 1; /* part of .select-location__container covers the button */
+}
+
+.select-location__title {
+ font-family: DINPro;
+ font-size: 32px;
+ font-weight: 900;
+ line-height: 1.25em;
+ color: #FFFFFF;
+}
+
+.select-location__subtitle {
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ line-height: normal;
+ color: rgba(255,255,255,0.8);
+ padding: 0 24px 24px;
+}
+
+.select-location__separator {
+ height: 24px;
+}
+
+.select-location__cell {
+ background-color: rgba(41,71,115,1);
+ padding: 15px 24px;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+
+.select-location__cell:hover {
+ background-color:rgba(41,71,115,0.9);
+}
+
+.select-location__cell--selected,
+.select-location__cell--selected:hover {
+ background-color: #44AD4D;
+}
+
+.select-location__cell + .select-location__cell {
+ margin-top: 1px;
+}
+
+.select-location__cell-label {
+ font-family: DINPro;
+ font-size: 20px;
+ font-weight: 900;
+ line-height: 26px;
+ color: #FFFFFF;
+}
+
+.select-location__cell-icon {
+ width: 24px;
+ height: 24px;
+ flex: 0 0 auto;
+ margin-right: 8px;
+}
+
+.select-location__cell-value {
+ flex: 0 0 auto;
+}
+
+.select-location__cell-accessory {
+ margin-left: auto;
+}
+
+.select-location__cell-footer {
+ padding: 8px 24px 24px;
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 20px;
+ color: rgba(255,255,255,0.8);
+}
diff --git a/app/components/SelectLocation.js b/app/components/SelectLocation.js
new file mode 100644
index 0000000000..c6325ae2a5
--- /dev/null
+++ b/app/components/SelectLocation.js
@@ -0,0 +1,112 @@
+// @flow
+import React, { Component } from 'react';
+import { If, Then } from 'react-if';
+import { Layout, Container, Header } from './Layout';
+import { servers } from '../config';
+import CustomScrollbars from './CustomScrollbars';
+
+import type { SettingsReduxState } from '../redux/settings/reducers';
+
+export type SelectLocationProps = {
+ settings: SettingsReduxState,
+ onClose: () => void;
+ onSelect: (server: string) => void;
+};
+
+export default class SelectLocation extends Component {
+ props: SelectLocationProps;
+ _selectedCell: ?HTMLElement;
+
+ onSelect(name: string) {
+ if (!this.isSelected(name)) {
+ this.props.onSelect(name);
+ }
+ }
+
+ isSelected(server: string) {
+ const { host } = this.props.settings.relayConstraints;
+ return server === host;
+ }
+
+ drawCell(key: string, name: string, icon: ?string, onClick: (e: Event) => void): React.Element<*> {
+ const classes = ['select-location__cell'];
+ const selected = this.isSelected(key);
+
+ if(selected) {
+ classes.push('select-location__cell--selected');
+ }
+
+ const cellClass = classes.join(' ');
+
+ return (
+ <div key={ key } className={ cellClass } onClick={ onClick } ref={ (e) => this.onCellRef(key, e) }>
+
+ <If condition={ !!icon }>
+ <Then>
+ <img className="select-location__cell-icon" src={ icon } />
+ </Then>
+ </If>
+
+ <div className="select-location__cell-label">{ name }</div>
+
+ <If condition={ selected } >
+ <Then>
+ <img className="select-location__cell-accessory" src="./assets/images/icon-tick.svg" />
+ </Then>
+ </If>
+
+ </div>
+ );
+ }
+
+ onCellRef(key: string, element: HTMLElement) {
+ // save reference to selected cell
+ if(this.isSelected(key)) {
+ this._selectedCell = element;
+ }
+ }
+
+ componentDidMount() {
+ // restore scroll to selected cell
+ const cell = this._selectedCell;
+ if(cell) {
+ // this is non-standard webkit method but it works great!
+ if(typeof(cell.scrollIntoViewIfNeeded) !== 'function') {
+ console.warn('HTMLElement.scrollIntoViewIfNeeded() is not available anymore! Please replace it with viable alternative.');
+ return;
+ }
+ cell.scrollIntoViewIfNeeded(true);
+ }
+ }
+
+ render(): React.Element<*> {
+ return (
+ <Layout>
+ <Header hidden={ true } style={ 'defaultDark' } />
+ <Container>
+ <div className="select-location">
+ <button className="select-location__close" onClick={ this.props.onClose } />
+ <div className="select-location__container">
+ <div className="select-location__header">
+ <h2 className="select-location__title">Select location</h2>
+ </div>
+
+ <CustomScrollbars autoHide={ true }>
+ <div>
+ <div className="select-location__subtitle">
+ While connected, your real location is masked with a private and secure location in the selected region
+ </div>
+
+ <div className="select-location__separator"></div>
+
+ { Object.keys(servers).map((key) => this.drawCell(key, servers[key].name, null, this.onSelect.bind(this, key))) }
+
+ </div>
+ </CustomScrollbars>
+ </div>
+ </div>
+ </Container>
+ </Layout>
+ );
+ }
+}
diff --git a/app/components/Settings.css b/app/components/Settings.css
new file mode 100644
index 0000000000..ee4a90b6b9
--- /dev/null
+++ b/app/components/Settings.css
@@ -0,0 +1,140 @@
+.settings {
+ background: #192E45;
+ height: 100%;
+}
+
+.settings__container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.settings__header {
+ flex: 0 0 auto;
+ padding: 40px 24px 24px;
+ position: relative; /* anchor for close button */
+}
+
+.settings__content {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+}
+
+.settings__close {
+ position: absolute;
+ display: block;
+ border: 0;
+ padding: 0;
+ margin: 0;
+ width: 24px;
+ height: 24px;
+ top: 24px;
+ left: 12px;
+ background-color: transparent;
+ background-image: url(../assets/images/icon-close.svg);
+ opacity: 0.6;
+ z-index: 1; /* part of .settings__container covers the button */
+}
+
+.settings__title {
+ font-family: DINPro;
+ font-size: 32px;
+ font-weight: 900;
+ line-height: 40px;
+ color: #FFFFFF;
+}
+
+.settings__cell {
+ background-color:rgba(41,71,115,1);
+ padding: 15px 24px;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+
+.settings__cell-disclosure {
+ display: block;
+ margin-left: 8px;
+}
+
+.settings__cell-spacer {
+ height: 24px;
+}
+
+.settings__cell--selected,
+.settings__cell--selected:hover {
+ background-color: #44AD4D;
+}
+
+.settings__cell--active:hover {
+ background-color: rgba(41,71,115,0.9);
+}
+
+.settings__cell + .settings__cell {
+ margin-top: 1px;
+}
+
+.settings__cell-label {
+ font-family: DINPro;
+ font-size: 20px;
+ font-weight: 900;
+ line-height: 26px;
+ color: #FFFFFF;
+ flex: 1 0 auto;
+}
+
+.settings__cell-icon {
+ width: 16px;
+ flex: 0 0 auto;
+ margin-right: 8px;
+ opacity: 0.8;
+}
+
+.settings__cell-value {
+ flex: 0 0 auto;
+}
+
+.settings__sub-cell {
+ background-color: rgb(36, 57, 84);
+}
+.settings__sub-cell:hover {
+ background-color: rgba(41,71,115,0.9);
+}
+
+.settings__sub-cell--label {
+ padding-left: 15px;
+}
+
+.settings__sub-cell--label img {
+ padding-right: 8px;
+}
+
+.settings__account-paid-until-label {
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 800;
+ line-height: 26px; /* matches .cell-label */
+ color: rgba(255, 255, 255, 0.8);
+ text-transform: uppercase;
+}
+
+.settings__account-paid-until-label--error {
+ color: #d0021b;
+}
+
+.settings__cell-footer {
+ padding: 8px 24px 24px;
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 20px;
+ color: rgba(255,255,255,0.8);
+}
+
+.settings__footer {
+ padding: 24px;
+}
+
+.settings__footer .button + .button { margin-top: 16px; }
diff --git a/app/components/Settings.js b/app/components/Settings.js
new file mode 100644
index 0000000000..95506cddc2
--- /dev/null
+++ b/app/components/Settings.js
@@ -0,0 +1,117 @@
+// @flow
+import moment from 'moment';
+import React, { Component } from 'react';
+import { If, Then, Else } from 'react-if';
+import { Layout, Container, Header } from './Layout';
+import CustomScrollbars from './CustomScrollbars';
+
+import type { AccountReduxState } from '../redux/account/reducers';
+import type { SettingsReduxState } from '../redux/settings/reducers';
+
+export type SettingsProps = {
+ account: AccountReduxState,
+ settings: SettingsReduxState,
+ onQuit: () => void,
+ onClose: () => void,
+ onViewAccount: () => void,
+ onViewSupport: () => void,
+ onViewAdvancedSettings: () => void,
+ onExternalLink: (type: string) => void
+};
+
+export default class Settings extends Component {
+
+ props: SettingsProps;
+
+ render() {
+ const isLoggedIn = this.props.account.status === 'ok';
+ let isOutOfTime = false, formattedExpiry = '';
+ let expiryIso = this.props.account.expiry;
+
+ if(isLoggedIn && expiryIso) {
+ let expiry = moment(this.props.account.expiry);
+ isOutOfTime = expiry.isSameOrBefore(moment());
+ formattedExpiry = expiry.fromNow(true) + ' left';
+ }
+
+ return (
+ <Layout>
+ <Header hidden={ true } style={ 'defaultDark' } />
+ <Container>
+ <div className="settings">
+ <button className="settings__close" onClick={ this.props.onClose } />
+ <div className="settings__container">
+ <div className="settings__header">
+ <h2 className="settings__title">Settings</h2>
+ </div>
+ <CustomScrollbars autoHide={ true }>
+ <div className="settings__content">
+ <div className="settings__main">
+
+ { /* show account options when logged in */ }
+ <If condition={ isLoggedIn }>
+ <Then>
+ <div className="settings__account">
+
+ <div className="settings__view-account settings__cell settings__cell--active" onClick={ this.props.onViewAccount }>
+ <div className="settings__cell-label">Account</div>
+ <div className="settings__cell-value">
+ <If condition={ isOutOfTime }>
+ <Then>
+ <span className="settings__account-paid-until-label settings__account-paid-until-label--error">OUT OF TIME</span>
+ </Then>
+ <Else>
+ <span className="settings__account-paid-until-label">{ formattedExpiry }</span>
+ </Else>
+ </If>
+ </div>
+ <img className="settings__cell-disclosure" src="assets/images/icon-chevron.svg" />
+ </div>
+ <div className="settings__cell-spacer"></div>
+ </div>
+ </Then>
+ </If>
+
+ <If condition={ isLoggedIn }>
+ <Then>
+ <div className="settings__advanced">
+ <div className="settings__cell settings__cell--active" onClick={ this.props.onViewAdvancedSettings }>
+ <div className="settings__cell-label">Advanced</div>
+ <div className="settings__cell-value">
+ <img className="settings__cell-disclosure" src="assets/images/icon-chevron.svg" />
+ </div>
+ </div>
+ <div className="settings__cell-spacer"></div>
+ </div>
+ </Then>
+ </If>
+
+ <div className="settings__external">
+ <div className="settings__cell settings__cell--active" onClick={ this.props.onExternalLink.bind(this, 'faq') }>
+ <div className="settings__cell-label">FAQs</div>
+ <img className="settings__cell-icon" src="./assets/images/icon-extLink.svg" />
+ </div>
+ <div className="settings__cell settings__cell--active" onClick={ this.props.onExternalLink.bind(this, 'guides') }>
+ <div className="settings__cell-label">Guides</div>
+ <img className="settings__cell-icon" src="./assets/images/icon-extLink.svg" />
+ </div>
+ <div className="settings__view-support settings__cell settings__cell--active" onClick={ this.props.onViewSupport }>
+ <div className="settings__cell-label">Report a problem</div>
+ <img className="settings__cell-disclosure" src="assets/images/icon-chevron.svg" />
+ </div>
+ </div>
+ </div>
+
+ <div className="settings__footer">
+ <button className="settings__quit button button--negative" onClick={ this.props.onQuit }>Quit app</button>
+ </div>
+
+ </div>
+ </CustomScrollbars>
+ </div>
+ </div>
+ </Container>
+ </Layout>
+ );
+ }
+}
diff --git a/app/components/Support.css b/app/components/Support.css
new file mode 100644
index 0000000000..8e8198742c
--- /dev/null
+++ b/app/components/Support.css
@@ -0,0 +1,169 @@
+.support {
+ background: #192E45;
+ height: 100%;
+}
+
+.support__container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.support__header {
+ flex: 0 0 auto;
+ padding: 40px 24px 24px;
+ position: relative; /* anchor for close button */
+}
+
+.support__close {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ border: 0;
+ padding: 0;
+ margin: 0;
+ top: 24px;
+ left: 12px;
+ z-index: 1; /* part of .support__container covers the button */
+}
+
+.support__close-icon {
+ opacity: 0.6;
+ margin-right: 8px;
+}
+
+.support__close-title {
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.support__title {
+ font-family: DINPro;
+ font-size: 32px;
+ font-weight: 900;
+ line-height: 40px;
+ color: #FFFFFF;
+ margin-bottom: 16px;
+}
+
+.support__subtitle {
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ line-height: normal;
+ color: rgba(255,255,255,0.8);
+}
+
+.support__content {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+}
+
+.support__form {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+}
+
+.support__form-row {
+ padding: 0 24px;
+}
+
+.support__form-row + .support__form-row {
+ margin-top: 8px;
+}
+
+.support__form-row-message {
+ display: flex;
+ flex: 1 1 auto;
+}
+
+.support__form-email {
+ width: 100%;
+ border-radius: 4px;
+ border: 0;
+ overflow: hidden;
+ padding: 10px 12px 12px 12px;
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 26px;
+ color: #294D73;
+ background-color: #fff;
+}
+
+.support__form-email::-webkit-input-placeholder {
+ color: rgba(41,77,115,0.4);
+}
+
+.support__form-message-scroll-wrap {
+ width: 100%;
+ display: flex;
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.support__form-message {
+ width: 100%;
+ border: 0;
+ overflow-y: scroll;
+ resize: none;
+ padding: 10px 12px 12px 12px;
+ font-family: "Open Sans";
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 1.4em;
+ color: #294D73;
+ background-color: #fff;
+}
+
+.support__form-message::-webkit-input-placeholder {
+ color: rgba(41,77,115,0.4);
+}
+
+.support__footer {
+ padding: 16px 24px 24px;
+}
+
+.support__footer .button + .button {
+ margin-top: 16px;
+}
+
+.support__sent-email {
+ display: inline;
+ font-weight: 900;
+ color: white;
+}
+
+.support__status-security--secure {
+ font-family: "Open Sans";
+ font-size: 16px;
+ font-weight: 800;
+ line-height: 22px;
+ margin-bottom: 4px;
+ color: #44AD4D;
+ text-transform: uppercase;
+}
+
+.support__send-status {
+ font-family: DINPro;
+ font-size: 38px;
+ font-weight: 900;
+ line-height: 1.16em;
+ max-height: calc(1.16em * 2);
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ letter-spacing: -0.9px;
+ color: #FFFFFF;
+ margin-bottom: 4px;
+}
+
+.support__status-icon {
+ text-align: center;
+ margin-bottom: 32px;
+}
diff --git a/app/components/Support.js b/app/components/Support.js
new file mode 100644
index 0000000000..04e6c7392d
--- /dev/null
+++ b/app/components/Support.js
@@ -0,0 +1,254 @@
+// @flow
+import React, { Component } from 'react';
+import { Layout, Container, Header } from './Layout';
+import ExternalLinkSVG from '../assets/images/icon-extLink.svg';
+
+import type { AccountReduxState } from '../redux/account/reducers';
+
+export type SupportReport = {
+ email: string,
+ message: string,
+ savedReport: ?string,
+};
+
+export type SupportState = {
+ email: string,
+ message: string,
+ savedReport: ?string,
+ sendState: 'INITIAL' | 'LOADING' | 'SUCCESS' | 'FAILED',
+};
+export type SupportProps = {
+ account: AccountReduxState,
+ onClose: () => void;
+ onViewLog: (string) => void;
+ onCollectLog: (Array<string>) => Promise<string>;
+ onSend: (email: string, message: string, savedReport: string) => void;
+};
+
+export default class Support extends Component {
+ props: SupportProps;
+ state: SupportState = {
+ email: '',
+ message: '',
+ savedReport: null,
+ sendState: 'INITIAL',
+ }
+
+ validate() {
+ return this.state.message.trim().length > 0;
+ }
+
+ onChangeEmail = (e: Event) => {
+ const input = e.target;
+ if(!(input instanceof HTMLInputElement)) {
+ throw new Error('input must be an instance of HTMLInputElement');
+ }
+ this.setState({ email: input.value });
+ }
+
+ onChangeDescription = (e: Event) => {
+ const input = e.target;
+ if(!(input instanceof HTMLTextAreaElement)) {
+ throw new Error('input must be an instance of HTMLTextAreaElement');
+ }
+ this.setState({ message: input.value });
+ }
+
+ onViewLog = () => {
+
+ this._getLog()
+ .then((path) => {
+ this.props.onViewLog(path);
+ });
+ }
+
+ _getLog() {
+ const toRedact = [];
+ if (this.props.account.accountToken) {
+ toRedact.push(this.props.account.accountToken.toString());
+ }
+
+ const { savedReport } = this.state;
+ return savedReport ?
+ Promise.resolve(savedReport) :
+ this.props.onCollectLog(toRedact)
+ .then( path => {
+ return new Promise(resolve => this.setState({ savedReport: path }, () => resolve(path)));
+ });
+ }
+
+ onSend = () => {
+ this.setState({
+ sendState: 'LOADING',
+ }, () => {
+ this._getLog()
+ .then((path) => {
+ return this.props.onSend(this.state.email, this.state.message, path);
+ })
+ .then( () => {
+ this.setState({
+ sendState: 'SUCCESS',
+ });
+ })
+ .catch( () => {
+ this.setState({
+ sendState: 'FAILED',
+ });
+ });
+ });
+ }
+
+ render() {
+
+ const header = <div className="support__header">
+ <h2 className="support__title">Report a problem</h2>
+ { this.state.sendState === 'INITIAL' && <div className="support__subtitle">
+ { `To help you more effectively, your app's log file will be attached to this message.
+ Your data will remain secure and private, as it is encrypted & anonymised before sending.` }
+ </div>
+ }
+ </div>;
+
+ const content = this._renderContent();
+
+ return (
+ <Layout>
+ <Header hidden={ true } style={ 'defaultDark' } />
+ <Container>
+ <div className="support">
+ <div className="support__close" onClick={ this.props.onClose }>
+ <img className="support__close-icon" src="./assets/images/icon-back.svg" />
+ <span className="support__close-title">Settings</span>
+ </div>
+ <div className="support__container">
+
+ { header }
+
+ { content }
+
+ </div>
+ </div>
+ </Container>
+ </Layout>
+ );
+ }
+
+ _renderContent() {
+ switch(this.state.sendState) {
+ case 'INITIAL':
+ return this._renderForm();
+ case 'LOADING':
+ return this._renderLoading();
+ case 'SUCCESS':
+ return this._renderSent();
+ case 'FAILED':
+ return this._renderFailed();
+ default:
+ return null;
+ }
+ }
+
+ _renderForm() {
+ return <div className="support__content">
+ <div className="support__form">
+ <div className="support__form-row">
+ <input className="support__form-email"
+ type="email"
+ placeholder="Your email"
+ value={ this.state.email }
+ onChange={ this.onChangeEmail }
+ autoFocus={ true } />
+ </div>
+ <div className="support__form-row support__form-row-message">
+ <div className="support__form-message-scroll-wrap">
+ <textarea className="support__form-message"
+ placeholder="Describe your problem"
+ value={ this.state.message }
+ onChange={ this.onChangeDescription } />
+ </div>
+ </div>
+ <div className="support__footer">
+ <button type="button"
+ className="support__form-view-logs button button--primary"
+ onClick={ this.onViewLog }>
+ <span className="button-label">View app logs</span>
+ <ExternalLinkSVG className="button-icon button-icon--16" />
+ </button>
+ <button type="button"
+ className="support__form-send button button--positive"
+ disabled={ !this.validate() }
+ onClick={ this.onSend }>Send</button>
+ </div>
+ </div>
+ </div>;
+ }
+
+ _renderLoading() {
+ return <div className="support__content">
+
+ <div className="support__form">
+ <div className="support__form-row">
+ <div className="support__status-icon">
+ <img src="./assets/images/icon-spinner.svg" alt="" />
+ </div>
+ <div className="support__status-security--secure">
+ Secure Connection
+ </div>
+ <div className="support__send-status">
+ <span>Sending...</span>
+ </div>
+ </div>
+ </div>
+ </div>;
+ }
+
+ _renderSent() {
+ return <div className="support__content">
+ <div className="support__form">
+ <div className="support__form-row">
+ <div className="support__status-icon">
+ <img src="./assets/images/icon-success.svg" alt="" />
+ </div>
+ <div className="support__status-security--secure">
+ Secure Connection
+ </div>
+ <div className="support__send-status">
+ <span>Sent</span>
+ </div>
+ <div className="support__subtitle">
+ Thanks! We will look into this. If needed we will contact you on {'\u00A0'}
+ <div className="support__sent-email">{ this.state.email }</div>
+ </div>
+ </div>
+ </div>
+ </div>;
+ }
+
+ _renderFailed() {
+ return <div className="support__content">
+ <div className="support__form">
+ <div className="support__form-row">
+ <div className="support__status-icon">
+ <img src="./assets/images/icon-fail.svg" alt="" />
+ </div>
+ <div className="support__status-security--secure">
+ Secure Connection
+ </div>
+ <div className="support__send-status">
+ <span>Failed to send</span>
+ </div>
+ </div>
+ </div>
+ <div className="support__footer">
+ <button type="button"
+ className="support__form-view-logs button button--primary"
+ onClick={ () => this.setState({ sendState: 'INITIAL' }) }>
+ <span className="button-label">Edit message</span>
+ </button>
+ <button type="button"
+ className="support__form-send button button--positive"
+ onClick={ this.onSend }>Try again</button>
+ </div>
+ </div>;
+ }
+}
diff --git a/app/components/Switch.css b/app/components/Switch.css
new file mode 100644
index 0000000000..aaa3821c34
--- /dev/null
+++ b/app/components/Switch.css
@@ -0,0 +1,44 @@
+.switch {
+ display: block;
+ position: relative;
+ -webkit-appearance: none;
+ border-radius: 16px;
+ width: 52px;
+ height: 32px;
+ border: 2px solid white;
+ background-color: transparent;
+ transition: 300ms ease-in-out all;
+}
+
+.switch:checked {
+ text-align: right;
+}
+
+.switch::after {
+ position: absolute;
+ left: 2px;
+ top: 2px;
+ display: block;
+ content: '';
+ width: 24px;
+ height: 24px;
+ border-radius: 24px;
+ background-color: #D0021B;
+ transition: 200ms ease-in-out all;
+ transform: translate3d(0, 0, 0);
+}
+
+.switch:active::after {
+ width: 28px;
+}
+
+.switch:active:checked::after {
+ transform: translate3d(0, 0, 0);
+ left: 18px;
+}
+
+.switch:checked::after {
+ background-color: #44AD4D;
+ transform: translate3d(0, 0, 0);
+ left: 22px;
+} \ No newline at end of file
diff --git a/app/components/Switch.js b/app/components/Switch.js
new file mode 100644
index 0000000000..f0ad3b41bc
--- /dev/null
+++ b/app/components/Switch.js
@@ -0,0 +1,142 @@
+// @flow
+import React, { Component } from 'react';
+
+import type { Point2d } from '../types';
+
+const CLICK_TIMEOUT = 1000;
+const MOVE_THRESHOLD = 10;
+
+export type SwitchProps = {
+ isOn: boolean;
+ onChange: ?((isOn: boolean) => void);
+};
+
+export default class Switch extends Component {
+ props: SwitchProps;
+ static defaultProps: SwitchProps = {
+ isOn: false,
+ onChange: null
+ }
+
+ isCapturingMouseEvents = false;
+ ref: ?HTMLInputElement;
+ onRef = (e: HTMLInputElement) => this.ref = e;
+
+ state = {
+ ignoreChange: false,
+ initialPos: ({x: 0, y: 0}: Point2d),
+ startTime: (null: ?number)
+ }
+
+ handleMouseDown = (e: MouseEvent) => {
+ const { clientX: x, clientY: y } = e;
+ this.startCapturingMouseEvents();
+ this.setState({
+ initialPos: { x, y },
+ startTime: e.timeStamp
+ });
+ }
+
+ handleMouseMove = (e: MouseEvent) => {
+ const inputElement = this.ref;
+ const { x: x0 } = this.state.initialPos;
+ const { clientX: x, clientY: y } = e;
+ const dx = Math.abs(x0 - x);
+
+ if(dx < MOVE_THRESHOLD) {
+ return;
+ }
+
+ const isOn = !!this.props.isOn;
+ let nextOn = isOn;
+
+ if(x < x0 && isOn) {
+ nextOn = false;
+ } else if(x > x0 && !isOn) {
+ nextOn = true;
+ }
+
+ if(isOn !== nextOn) {
+ this.setState({
+ initialPos: { x, y },
+ ignoreChange: true
+ });
+
+ if(inputElement) {
+ inputElement.checked = nextOn;
+ }
+
+ this.notify(nextOn);
+ }
+ }
+
+ handleMouseUp = () => {
+ this.stopCapturingMouseEvents();
+ }
+
+ handleChange = (e: Event) => {
+ const startTime = this.state.startTime;
+ const eventTarget: Object = e.target;
+
+ if(typeof(startTime) !== 'number') {
+ throw new Error('startTime must be a number.');
+ }
+
+ const dt = e.timeStamp - startTime;
+
+ if(this.state.ignoreChange) {
+ this.setState({ ignoreChange: false });
+ e.preventDefault();
+ } else if(dt > CLICK_TIMEOUT) {
+ e.preventDefault();
+ } else {
+ this.notify(eventTarget.checked);
+ }
+ }
+
+ notify(isOn: boolean) {
+ const onChange = this.props.onChange;
+ if(onChange) {
+ onChange(isOn);
+ }
+ }
+
+ startCapturingMouseEvents() {
+ if(this.isCapturingMouseEvents) {
+ throw new Error('startCapturingMouseEvents() is called out of order.');
+ }
+ document.addEventListener('mousemove', this.handleMouseMove);
+ document.addEventListener('mouseup', this.handleMouseUp);
+ this.isCapturingMouseEvents = true;
+ }
+
+ stopCapturingMouseEvents() {
+ if(!this.isCapturingMouseEvents) {
+ throw new Error('stopCapturingMouseEvents() is called out of order.');
+ }
+ document.removeEventListener('mousemove', this.handleMouseMove);
+ document.removeEventListener('mouseup', this.handleMouseUp);
+ this.isCapturingMouseEvents = false;
+ }
+
+ componentWillUnmount() {
+ // guard from abrupt programmatic unmount
+ if(this.isCapturingMouseEvents) {
+ this.stopCapturingMouseEvents();
+ }
+ }
+
+ render(): React.Element<*> {
+ const { isOn, onChange, ...otherProps } = this.props; // eslint-disable-line no-unused-vars
+ let className = ('switch' + ' ' + (otherProps.className || '')).trim();
+ return (
+ <input { ...otherProps }
+ type="checkbox"
+ ref={ this.onRef }
+ className={ className }
+ checked={ isOn }
+ onMouseDown={ this.handleMouseDown }
+ onChange={ this.handleChange } />
+ );
+ }
+}
diff --git a/app/components/WindowChrome.css b/app/components/WindowChrome.css
new file mode 100644
index 0000000000..8f3fcdb52c
--- /dev/null
+++ b/app/components/WindowChrome.css
@@ -0,0 +1,6 @@
+/* macOS app runs as menubar; create an app chrome with arrow using mask */
+.window-chrome--darwin {
+ -webkit-mask:
+ url(../assets/images/app-triangle.svg) 50% 0% no-repeat,
+ url(../assets/images/app-header-backdrop.svg) no-repeat;
+} \ No newline at end of file
diff --git a/app/components/WindowChrome.js b/app/components/WindowChrome.js
new file mode 100644
index 0000000000..d7094813db
--- /dev/null
+++ b/app/components/WindowChrome.js
@@ -0,0 +1,16 @@
+// @flow
+import React, { Component } from 'react';
+
+export default class WindowChrome extends Component {
+ props: {
+ children: Array<React.Element<*>> | React.Element<*>
+ }
+ render(): React.Element<*> {
+ const chromeClass = ['window-chrome', 'window-chrome--' + process.platform];
+ return (
+ <div className={ chromeClass.join(' ') }>
+ { this.props.children }
+ </div>
+ );
+ }
+} \ No newline at end of file
diff --git a/app/config.json b/app/config.json
new file mode 100644
index 0000000000..ed984c0642
--- /dev/null
+++ b/app/config.json
@@ -0,0 +1,220 @@
+{
+ "mapbox": {
+ "accessToken": "pk.eyJ1IjoibWpob21lciIsImEiOiJjaXd3NmdmNHEwMGtvMnlvMGl3b3R5aGcwIn0.SqIPBcCP6-b9yjxCD32CNg",
+ "styleURL": "mapbox://styles/mjhomer/cizjoenga006f2smnm9z52u8e"
+ },
+ "links": {
+ "createAccount": "https://mullvad.net/account/create/",
+ "purchase": "https://mullvad.net/account/",
+ "faq": "https://mullvad.net/faq/",
+ "guides": "https://mullvad.net/guides/",
+ "supportEmail": "mailto:support@mullvad.net"
+ },
+ "defaultServer": "193.138.218.135",
+ "servers": {
+ "168.1.6.5": {
+ "address": "168.1.6.5",
+ "name": "Australia",
+ "city": "Sydney",
+ "country": "Australia"
+ },
+ "217.64.127.138": {
+ "address": "217.64.127.138",
+ "name": "Austria",
+ "city": "Wien",
+ "country": "Austria"
+ },
+ "185.104.186.202": {
+ "address": "185.104.186.202",
+ "name": "Belgium",
+ "city": "Brussels",
+ "country": "Belgium"
+ },
+ "185.94.192.42": {
+ "address": "185.94.192.42",
+ "name": "Bulgaria",
+ "city": "Sofia",
+ "country": "Bulgaria"
+ },
+ "162.219.176.250": {
+ "address": "162.219.176.250",
+ "name": "Canada",
+ "city": "Toronto",
+ "country": "Canada",
+ "location": [
+ 45.42153,
+ -75.697193
+ ]
+ },
+ "185.156.174.146": {
+ "address": "185.156.174.146",
+ "name": "Czech Republic",
+ "city": "Prague",
+ "country": "Czech Republic"
+ },
+ "82.103.140.213": {
+ "address": "82.103.140.213",
+ "name": "Denmark",
+ "city": "Copenhagen",
+ "country": "Denmark",
+ "location": [
+ 55.6760968,
+ 12.5683371
+ ]
+ },
+ "185.103.110.69": {
+ "address": "185.103.110.69",
+ "name": "Finland",
+ "city": "Helsinki",
+ "country": "Finland"
+ },
+ "185.156.173.218": {
+ "address": "185.156.173.218",
+ "name": "France",
+ "city": "Paris",
+ "country": "France"
+ },
+ "185.104.184.178": {
+ "address": "185.104.184.178",
+ "name": "Germany",
+ "city": "Berlin",
+ "country": "Germany",
+ "location": [
+ 52.52000659999999,
+ 13.404954
+ ]
+ },
+ "161.202.48.245": {
+ "address": "161.202.48.245",
+ "name": "Hong Kong",
+ "city": "Hong Kong",
+ "country": "Hong Kong"
+ },
+ "185.189.114.10": {
+ "address": "185.189.114.10",
+ "name": "Hungary",
+ "city": "Budapest",
+ "country": "Hungary"
+ },
+ "213.184.122.34": {
+ "address": "213.184.122.34",
+ "name": "Israel",
+ "city": "Petach-Tikva",
+ "country": "Israel"
+ },
+ "217.64.113.180": {
+ "address": "217.64.113.180",
+ "name": "Italy",
+ "city": "Milan",
+ "country": "Italy"
+ },
+ "161.202.144.203": {
+ "address": "161.202.144.203",
+ "name": "Japan",
+ "city": "Tokyo",
+ "country": "Japan"
+ },
+ "185.65.134.140": {
+ "address": "185.65.134.140",
+ "name": "Netherlands",
+ "city": "Amsterdam",
+ "country": "Netherlands",
+ "location": [
+ 52.3702157,
+ 4.895167900000001
+ ]
+ },
+ "31.169.51.154": {
+ "address": "31.169.51.154",
+ "name": "Norway",
+ "city": "Oslo",
+ "country": "Norway",
+ "location": [
+ 59.9138688,
+ 10.7522454
+ ]
+ },
+ "212.7.217.30": {
+ "address": "212.7.217.30",
+ "name": "Poland",
+ "city": "Warsaw",
+ "country": "Poland"
+ },
+ "185.45.13.10": {
+ "address": "185.45.13.10",
+ "name": "Romania",
+ "city": "Bucharest",
+ "country": "Romania",
+ "location": [
+ 44.4267674,
+ 26.1025384
+ ]
+ },
+ "103.57.72.30": {
+ "address": "103.57.72.30",
+ "name": "Singapore",
+ "city": "Singapore",
+ "country": "Singapore",
+ "location": [
+ 1.352083,
+ 103.819836
+ ]
+ },
+ "89.238.178.34": {
+ "address": "89.238.178.34",
+ "name": "Spain",
+ "city": "Madrid",
+ "country": "Spain",
+ "location": [
+ 40.4167754,
+ -3.7037902
+ ]
+ },
+ "185.213.152.132": {
+ "address": "185.213.152.132",
+ "name": "Sweden - Helsingborg",
+ "city": "Helsingborg",
+ "country": "Sweden"
+ },
+ "193.138.218.135": {
+ "address": "193.138.218.135",
+ "name": "Sweden - Malmö",
+ "city": "Malmö",
+ "country": "Sweden"
+ },
+ "185.65.135.143": {
+ "address": "185.65.135.143",
+ "name": "Sweden - Stockholm",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ "179.43.128.170": {
+ "address": "179.43.128.170",
+ "name": "Switzerland",
+ "city": "Zürich",
+ "country": "Switzerland"
+ },
+ "185.16.85.170": {
+ "address": "185.16.85.170",
+ "name": "United Kingdom",
+ "city": "London",
+ "country": "United Kingdom",
+ "location": [
+ 51.5073509,
+ -0.1277583
+ ]
+ },
+ "173.199.80.130": {
+ "address": "173.199.80.130",
+ "name": "USA - Los Angeles",
+ "city": "Los Angeles",
+ "country": "USA"
+ },
+ "38.132.107.138": {
+ "address": "38.132.107.138",
+ "name": "USA - New York",
+ "city": "New York",
+ "country": "USA"
+ }
+ }
+}
diff --git a/app/containers/AccountPage.js b/app/containers/AccountPage.js
new file mode 100644
index 0000000000..1727c1e5be
--- /dev/null
+++ b/app/containers/AccountPage.js
@@ -0,0 +1,22 @@
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import { push } from 'react-router-redux';
+import Account from '../components/Account';
+import accountActions from '../redux/account/actions';
+import { shell } from 'electron';
+import { links } from '../config';
+
+const mapStateToProps = (state) => {
+ return state;
+};
+
+const mapDispatchToProps = (dispatch, props) => {
+ const { logout } = bindActionCreators(accountActions, dispatch);
+ return {
+ onLogout: () => logout(props.backend),
+ onClose: () => dispatch(push('/settings')),
+ onBuyMore: () => shell.openExternal(links['purchase'])
+ };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(Account);
diff --git a/app/containers/AdvancedSettingsPage.js b/app/containers/AdvancedSettingsPage.js
new file mode 100644
index 0000000000..ecb78eb218
--- /dev/null
+++ b/app/containers/AdvancedSettingsPage.js
@@ -0,0 +1,55 @@
+import { connect } from 'react-redux';
+import { push } from 'react-router-redux';
+import { AdvancedSettings } from '../components/AdvancedSettings';
+import settingsActions from '../redux/settings/actions';
+import log from 'electron-log';
+
+const mapStateToProps = (state) => {
+ const contraints = state.settings.relayConstraints;
+ return {
+ protocol: anyToAuto(contraints.protocol),
+ port: anyToAuto(contraints.port),
+ };
+};
+
+function anyToAuto(constraint) {
+ if (constraint === 'any') {
+ return 'Automatic';
+ } else {
+ return constraint;
+ }
+}
+
+const mapDispatchToProps = (dispatch, props) => {
+ const { backend } = props;
+ return {
+ onClose: () => dispatch(push('/settings')),
+
+ updateConstraints: (protocol, port) => {
+
+ const protConstraint = protocol === 'Automatic'
+ ? 'any'
+ : { only: protocol.toLowerCase() };
+
+ const portConstraint = port === 'Automatic'
+ ? 'any'
+ : { only: port };
+
+ const update = {
+ tunnel: { openvpn: {
+ protocol: protConstraint,
+ port: portConstraint,
+ }},
+ };
+
+ backend.updateRelayConstraints(update)
+ .then( () => dispatch(settingsActions.updateRelay({
+ port: typeof(portConstraint) === 'object' ? portConstraint.only : portConstraint,
+ protocol: typeof(protConstraint) === 'object' ? protConstraint.only : protConstraint,
+ })))
+ .catch( e => log.error('Failed updating relay constraints', e.message));
+ },
+ };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(AdvancedSettings);
diff --git a/app/containers/ConnectPage.js b/app/containers/ConnectPage.js
new file mode 100644
index 0000000000..5c6dc1fa60
--- /dev/null
+++ b/app/containers/ConnectPage.js
@@ -0,0 +1,32 @@
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import { push } from 'react-router-redux';
+import { shell } from 'electron';
+import { links } from '../config';
+import Connect from '../components/Connect';
+import connectActions from '../redux/connection/actions';
+
+const mapStateToProps = (state) => {
+ return {
+ accountExpiry: state.account.expiry,
+ connection: state.connection,
+ settings: state.settings,
+ };
+};
+
+const mapDispatchToProps = (dispatch, props) => {
+ const { connect, disconnect, copyIPAddress } = bindActionCreators(connectActions, dispatch);
+ const { backend } = props;
+
+ return {
+ onSettings: () => dispatch(push('/settings')),
+ onSelectLocation: () => dispatch(push('/select-location')),
+ onConnect: (relayEndpoint) => connect(backend, relayEndpoint),
+ onCopyIP: () => copyIPAddress(),
+ onDisconnect: () => disconnect(backend),
+ onExternalLink: (type) => shell.openExternal(links[type]),
+ getServerInfo: (key) => backend.serverInfo(key)
+ };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(Connect);
diff --git a/app/containers/LoginPage.js b/app/containers/LoginPage.js
new file mode 100644
index 0000000000..457be7a6f2
--- /dev/null
+++ b/app/containers/LoginPage.js
@@ -0,0 +1,22 @@
+import { shell } from 'electron';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import { push } from 'react-router-redux';
+import Login from '../components/Login';
+import accountActions from '../redux/account/actions';
+import { links } from '../config';
+
+const mapStateToProps = (state) => state;
+const mapDispatchToProps = (dispatch, props) => {
+ const { login } = bindActionCreators(accountActions, dispatch);
+ const { backend } = props;
+ return {
+ onSettings: () => dispatch(push('/settings')),
+ onLogin: (account) => login(backend, account),
+ onFirstChangeAfterFailure: () => dispatch(accountActions.resetLoginError()),
+ onExternalLink: (type) => shell.openExternal(links[type]),
+ onAccountTokenChange: (token) => dispatch(accountActions.updateAccountToken(token)),
+ };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(Login);
diff --git a/app/containers/SelectLocationPage.js b/app/containers/SelectLocationPage.js
new file mode 100644
index 0000000000..7ca2f1eb0a
--- /dev/null
+++ b/app/containers/SelectLocationPage.js
@@ -0,0 +1,36 @@
+import { connect } from 'react-redux';
+import { push } from 'react-router-redux';
+import SelectLocation from '../components/SelectLocation';
+import settingsActions from '../redux/settings/actions';
+import log from 'electron-log';
+
+const mapStateToProps = (state) => state;
+const mapDispatchToProps = (dispatch, props) => {
+ const { backend } = props;
+ return {
+ onClose: () => dispatch(push('/connect')),
+ onSelect: (preferredServer) => {
+
+ dispatch(push('/connect'));
+
+ // add delay to let the map load
+ setTimeout(() => {
+ const update = {
+ host: { only: preferredServer },
+ tunnel: { openvpn: {
+ }},
+ };
+
+ backend.updateRelayConstraints(update)
+ .then( () => dispatch(settingsActions.updateRelay({
+ host: preferredServer,
+ })))
+ .then( () => backend.connect())
+ .catch( e => log.error('Failed updating relay constraints', e.message));
+
+ }, 600);
+ }
+ };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(SelectLocation);
diff --git a/app/containers/SettingsPage.js b/app/containers/SettingsPage.js
new file mode 100644
index 0000000000..72eafb9431
--- /dev/null
+++ b/app/containers/SettingsPage.js
@@ -0,0 +1,22 @@
+import { connect } from 'react-redux';
+import { push } from 'react-router-redux';
+import Settings from '../components/Settings';
+import { remote, shell } from 'electron';
+import { links } from '../config';
+
+const mapStateToProps = (state) => {
+ return state;
+};
+
+const mapDispatchToProps = (dispatch, _props) => {
+ return {
+ onQuit: () => remote.app.quit(),
+ onClose: () => dispatch(push('/connect')),
+ onViewAccount: () => dispatch(push('/settings/account')),
+ onViewSupport: () => dispatch(push('/settings/support')),
+ onViewAdvancedSettings: () => dispatch(push('/settings/advanced')),
+ onExternalLink: (type) => shell.openExternal(links[type]),
+ };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(Settings);
diff --git a/app/containers/SupportPage.js b/app/containers/SupportPage.js
new file mode 100644
index 0000000000..4d802183d7
--- /dev/null
+++ b/app/containers/SupportPage.js
@@ -0,0 +1,88 @@
+import log from 'electron-log';
+import { shell, ipcRenderer } from 'electron';
+import { connect } from 'react-redux';
+import { push } from 'react-router-redux';
+import Support from '../components/Support';
+import { resolveBin } from '../lib/proc';
+import { execFile } from 'child_process';
+import uuid from 'uuid';
+
+const mapStateToProps = (state) => {
+ return state;
+};
+
+const unAnsweredIpcCalls = new Map();
+function reapIpcCall(id) {
+ const promise = unAnsweredIpcCalls.get(id);
+ unAnsweredIpcCalls.delete(id);
+
+ if (promise) {
+ promise.reject(new Error('Timed out'));
+ }
+}
+ipcRenderer.on('collect-logs-reply', (_event, id, err, reportId) => {
+ const promise = unAnsweredIpcCalls.get(id);
+ unAnsweredIpcCalls.delete(id);
+
+ if (err) {
+ promise.reject(err);
+ } else if (promise) {
+ promise.resolve(reportId);
+ }
+});
+
+const mapDispatchToProps = (dispatch, _props) => {
+ return {
+ onClose: () => dispatch(push('/settings')),
+
+ onCollectLog: (toRedact) => {
+ return new Promise((resolve, reject) => {
+
+ const id = uuid.v4();
+ unAnsweredIpcCalls.set(id, { resolve, reject });
+ ipcRenderer.send('collect-logs', id, toRedact);
+ setTimeout(() => reapIpcCall(id), 1000);
+ })
+ .catch((e) => {
+ const { err, stdout } = e;
+ log.error('Failed collecting problem report', err);
+ log.error(' stdout: ' + stdout);
+
+ throw e;
+ });
+ },
+
+ onViewLog: (path) => shell.openItem(path),
+
+ onSend: (email, message, savedReport) => {
+
+ const args = ['send',
+ '--email', email,
+ '--message', message,
+ '--report', savedReport,
+ ];
+
+ const binPath = resolveBin('problem-report');
+
+ return new Promise((resolve, reject) => {
+ execFile(binPath, args, { windowsHide: true }, (err, stdout, stderr) => {
+ if (err) {
+ reject({ err, stdout, stderr });
+ } else {
+ log.debug('Report sent');
+ resolve();
+ }
+ });
+ })
+ .catch((e) => {
+ const { err, stdout } = e;
+ log.error('Failed sending problem report', err);
+ log.error(' stdout: ' + stdout);
+
+ throw e;
+ });
+ }
+ };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(Support);
diff --git a/app/index.html b/app/index.html
new file mode 100644
index 0000000000..e0ac17cd31
--- /dev/null
+++ b/app/index.html
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Mullvad VPN</title>
+ <link rel="stylesheet" href="./assets/css/style.css" />
+ </head>
+ <body>
+ <div id="app"></div>
+ <script src="./app.js" data-container="#app"></script>
+ <script>
+ if (process.env.BROWSER_SYNC_CLIENT_URL) {
+ const current = document.currentScript;
+ const script = document.createElement('script');
+ script.src = process.env.BROWSER_SYNC_CLIENT_URL;
+ script.async = true;
+ current.parentNode.insertBefore(script, current);
+ }
+ </script>
+ </body>
+</html>
diff --git a/app/lib/backend.js b/app/lib/backend.js
new file mode 100644
index 0000000000..7e5e21708f
--- /dev/null
+++ b/app/lib/backend.js
@@ -0,0 +1,424 @@
+// @flow
+
+import log from 'electron-log';
+import EventEmitter from 'events';
+import { servers } from '../config';
+import { IpcFacade, RealIpc } from './ipc-facade';
+import accountActions from '../redux/account/actions';
+import connectionActions from '../redux/connection/actions';
+import settingsActions from '../redux/settings/actions';
+import { push } from 'react-router-redux';
+import { defaultServer } from '../config';
+
+import type { ReduxStore } from '../redux/store';
+import type { BackendState, RelayConstraintsUpdate } from './ipc-facade';
+import type { ConnectionState } from '../redux/connection/reducers';
+
+export type EventType = 'connect' | 'connecting' | 'disconnect' | 'login' | 'logging' | 'logout' | 'updatedIp' | 'updatedLocation' | 'updatedReachability';
+export type ErrorType = 'NO_CREDIT' | 'NO_INTERNET' | 'INVALID_ACCOUNT' | 'NO_ACCOUNT';
+
+export type ServerInfo = {
+ address: string,
+ name: string,
+ city: string,
+ country: string,
+ location: [number, number]
+};
+
+export type ServerInfoList = { [string]: ServerInfo };
+
+export class BackendError extends Error {
+ type: ErrorType;
+ title: string;
+ message: string;
+
+ constructor(type: ErrorType) {
+ super('');
+ this.type = type;
+ this.title = BackendError.localizedTitle(type);
+ this.message = BackendError.localizedMessage(type);
+ }
+
+ static localizedTitle(type: ErrorType): string {
+ switch(type) {
+ case 'NO_CREDIT':
+ return 'Out of time';
+ case 'NO_INTERNET':
+ return 'Offline';
+ default:
+ return 'Something went wrong';
+ }
+ }
+
+ static localizedMessage(type: ErrorType): string {
+ switch(type) {
+ case 'NO_CREDIT':
+ return 'Buy more time, so you can continue using the internet securely';
+ case 'NO_INTERNET':
+ return 'Your internet connection will be secured when you get back online';
+ case 'INVALID_ACCOUNT':
+ return 'Invalid account number';
+ case 'NO_ACCOUNT':
+ return 'No account was set';
+ default:
+ return '';
+ }
+ }
+
+}
+
+
+export type IpcCredentials = {
+ connectionString: string,
+ sharedSecret: string,
+};
+export function parseIpcCredentials(data: string): ?IpcCredentials {
+ const [connectionString, sharedSecret] = data.split('\n', 2);
+ if(connectionString && sharedSecret) {
+ return {
+ connectionString,
+ sharedSecret,
+ };
+ } else {
+ return null;
+ }
+}
+
+
+/**
+ * Backend implementation
+ */
+export class Backend {
+
+ _ipc: IpcFacade;
+ _credentials: ?IpcCredentials;
+ _authenticationPromise: ?Promise<void>;
+ _store: ReduxStore;
+ _eventEmitter = new EventEmitter();
+
+ constructor(store: ReduxStore, credentials?: IpcCredentials, ipc: ?IpcFacade) {
+ this._store = store;
+ this._credentials = credentials;
+
+
+ if(ipc) {
+ this._ipc = ipc;
+
+ // force to re-authenticate when connection closed
+ this._ipc.setCloseConnectionHandler(() => {
+ this._authenticationPromise = null;
+ });
+
+ this._registerIpcListeners();
+ this._startReachability();
+ }
+ }
+
+ setCredentials(credentials: IpcCredentials) {
+ log.debug('Got connection info to backend', credentials.connectionString);
+ this._credentials = credentials;
+
+ if (this._ipc) {
+ this._credentials = credentials;
+ } else {
+ this._ipc = new RealIpc(credentials.connectionString);
+
+ // force to re-authenticate when connection closed
+ this._ipc.setCloseConnectionHandler(() => {
+ this._authenticationPromise = null;
+ });
+ }
+ this._registerIpcListeners();
+ }
+
+ sync() {
+ log.info('Syncing with the backend...');
+
+ this._ensureAuthenticated()
+ .then( () => {
+ this._ipc.getIp()
+ .then( ip => {
+ log.info('Got ip', ip);
+ this._store.dispatch(connectionActions.newPublicIp(ip));
+ })
+ .catch(e => {
+ log.info('Failed syncing with the backend,', e.message);
+ });
+ });
+
+ this._ensureAuthenticated()
+ .then( () => {
+ this._ipc.getLocation()
+ .then( location => {
+ log.info('Got location', location);
+ const newLocation = {
+ location: location.latlong,
+ country: location.country,
+ city: location.city
+ };
+ this._store.dispatch(connectionActions.newLocation(newLocation));
+ })
+ .catch(e => {
+ log.info('Failed getting new location,', e.message);
+ });
+ });
+ }
+
+ serverInfo(identifier: string): ?ServerInfo {
+ return (servers: ServerInfoList)[identifier];
+ }
+
+ login(accountToken: string): Promise<void> {
+ log.debug('Attempting to login with account number', accountToken);
+
+ this._store.dispatch(accountActions.startLogin(accountToken));
+
+ return this._ensureAuthenticated()
+ .then( () => {
+ return this._ipc.getAccountData(accountToken)
+ .then( response => {
+ log.debug('Account exists', response);
+
+ return this._ipc.setAccount(accountToken)
+ .then( () => response );
+
+ }).then( accountData => {
+ log.info('Log in complete');
+
+ this._store.dispatch(accountActions.loginSuccessful(accountData.expiry));
+
+ // Redirect the user after some time to allow for
+ // the 'Login Successful' screen to be visible
+ setTimeout(() => {
+ this._store.dispatch(push('/connect'));
+ this.connect();
+ }, 1000);
+ }).catch(e => {
+ log.error('Failed to log in,', e.message);
+
+ // TODO: This is not true. If there is a communication link failure the promise will be rejected too
+ const err = new BackendError('INVALID_ACCOUNT');
+ this._store.dispatch(accountActions.loginFailed(err));
+ });
+ });
+ }
+
+ autologin() {
+ log.debug('Attempting to log in automatically');
+
+ this._store.dispatch(accountActions.startLogin());
+
+ return this._ensureAuthenticated()
+ .then( () => {
+ return this._ipc.getAccount()
+ .then( accountToken => {
+ if (!accountToken) {
+ throw new BackendError('NO_ACCOUNT');
+ }
+ log.debug('The backend had an account number stored:', accountToken);
+ this._store.dispatch(accountActions.startLogin(accountToken));
+
+ return this._ipc.getAccountData(accountToken);
+ })
+ .then( accountData => {
+ log.debug('The stored account number still exists', accountData);
+
+ this._store.dispatch(accountActions.loginSuccessful(accountData.expiry));
+
+ this._store.dispatch(push('/connect'));
+ this.connect();
+ })
+ .catch( e => {
+ log.warn('Unable to autologin,', e.message);
+
+ this._store.dispatch(accountActions.autoLoginFailed());
+ this._store.dispatch(push('/'));
+
+ throw e;
+ });
+ });
+ }
+
+ logout() {
+ // @TODO: What does it mean for a logout to be successful or failed?
+ return this._ensureAuthenticated()
+ .then( () => {
+ return this._ipc.setAccount(null)
+ .then(() => {
+
+ this._store.dispatch(accountActions.loggedOut());
+
+ // disconnect user during logout
+ return this.disconnect()
+ .then( () => {
+ this._store.dispatch(push('/'));
+ });
+ })
+ .catch(e => {
+ log.info('Failed to logout,', e.message);
+ });
+ });
+ }
+
+ connect(aHost?: string): Promise<void> {
+ const host = aHost;
+
+ let setHostPromise = () => Promise.resolve();
+ if (host) {
+ this._store.dispatch(connectionActions.connectingTo(host || 'unknown'));
+ setHostPromise = () => this._ipc.updateRelayConstraints({
+ host: { only: host },
+ tunnel: { openvpn: {
+ }},
+ });
+ }
+
+ return this._ensureAuthenticated()
+ .then( setHostPromise )
+ .then( () => this._ipc.connect() )
+ .catch(e => {
+ log.info('Failed connecting to the relay set in the backend, ', e.message);
+ this._store.dispatch(connectionActions.disconnected());
+ });
+ }
+
+ disconnect(): Promise<void> {
+ // @TODO: Failure modes
+ return this._ensureAuthenticated()
+ .then( () => {
+ return this._ipc.disconnect()
+ .catch(e => {
+ log.info('Failed to disconnect,', e.message);
+ });
+ });
+ }
+
+ shutdown(): Promise<void> {
+ return this._ensureAuthenticated()
+ .then( () => {
+ return this._ipc.shutdown();
+ });
+ }
+
+ updateRelayConstraints(relayConstraints: RelayConstraintsUpdate): Promise<void> {
+ return this._ensureAuthenticated()
+ .then( () => {
+ return this._ipc.updateRelayConstraints(relayConstraints);
+ });
+ }
+
+ syncRelayConstraints(): Promise<void> {
+ return this._ensureAuthenticated()
+ .then( () => {
+ return this._ipc.getRelayContraints();
+ })
+ .then( constraints => {
+ log.debug('Got constraints from backend', constraints);
+
+ const host = constraints.host === 'any'
+ ? defaultServer
+ : constraints.host.only || defaultServer;
+
+ const openvpn = constraints.tunnel.openvpn;
+ this._store.dispatch(settingsActions.updateRelay({
+ host: host,
+ port: this._apiToReduxConstraints(openvpn.port),
+ protocol: this._apiToReduxConstraints(openvpn.protocol),
+ }));
+ })
+ .catch( e => {
+ log.error('Failed getting relay constraints', e);
+ });
+ }
+
+ _apiToReduxConstraints(constraint: *): * {
+ if (typeof(constraint) === 'object') {
+ return constraint.only;
+ } else {
+ return constraint;
+ }
+ }
+
+ /**
+ * Start reachability monitoring for online/offline detection
+ * This is currently done via HTML5 APIs but will be replaced later
+ * with proper backend integration.
+ */
+ _startReachability() {
+ window.addEventListener('online', () => {
+ this._store.dispatch(connectionActions.online());
+ });
+ window.addEventListener('offline', () => {
+ // force disconnect since there is no real connection anyway.
+ this.disconnect();
+ this._store.dispatch(connectionActions.offline());
+ });
+
+ // update online status in background
+ setTimeout(() => {
+ const action = navigator.onLine
+ ? connectionActions.online()
+ : connectionActions.offline();
+
+ this._store.dispatch(action);
+ }, 0);
+ }
+
+ _registerIpcListeners() {
+ return this._ensureAuthenticated()
+ .then( () => {
+ return this._ipc.registerStateListener(newState => {
+ log.debug('Got new state from backend', newState);
+
+ const newStatus = this._securityStateToConnectionState(newState);
+ switch(newStatus) {
+ case 'connecting':
+ this._store.dispatch(connectionActions.connecting());
+ break;
+ case 'connected':
+ this._store.dispatch(connectionActions.connected());
+ break;
+ case 'disconnected':
+ this._store.dispatch(connectionActions.disconnected());
+ break;
+ }
+
+ this.sync();
+ });
+ });
+ }
+
+ _securityStateToConnectionState(backendState: BackendState): ConnectionState {
+ if (backendState.state === 'unsecured' && backendState.target_state === 'secured') {
+ return 'connecting';
+ } else if (backendState.state === 'secured' && backendState.target_state === 'secured') {
+ return 'connected';
+ } else if (backendState.target_state === 'unsecured') {
+ return 'disconnected';
+ }
+ throw new Error('Unsupported state/target state combination: ' + JSON.stringify(backendState));
+ }
+
+ _ensureAuthenticated(): Promise<void> {
+ const credentials = this._credentials;
+ if(credentials) {
+ if(!this._authenticationPromise) {
+ this._authenticationPromise = this._authenticate(credentials.sharedSecret);
+ }
+ return this._authenticationPromise;
+ } else {
+ return Promise.reject(new Error('Missing authentication credentials.'));
+ }
+ }
+
+ _authenticate(sharedSecret: string): Promise<void> {
+ return this._ipc.authenticate(sharedSecret)
+ .then(() => {
+ log.info('Authenticated with backend');
+ })
+ .catch((e) => {
+ log.error('Failed to authenticate with backend: ', e.message);
+ throw e;
+ });
+ }
+}
diff --git a/app/lib/formatters.js b/app/lib/formatters.js
new file mode 100644
index 0000000000..89d45d44a0
--- /dev/null
+++ b/app/lib/formatters.js
@@ -0,0 +1,9 @@
+// @flow
+export const formatAccount = (val: string): string => {
+ // display number altogether when longer than 12
+ if(val.length > 12) {
+ return val;
+ }
+ // display quartets
+ return val.replace(/([0-9]{4})/g, '$1 ').trim();
+}; \ No newline at end of file
diff --git a/app/lib/ipc-facade.js b/app/lib/ipc-facade.js
new file mode 100644
index 0000000000..5b67dcb76f
--- /dev/null
+++ b/app/lib/ipc-facade.js
@@ -0,0 +1,221 @@
+// @flow
+
+import JsonRpcWs, { InvalidReply } from './jsonrpc-ws-ipc';
+import { object, string, arrayOf, number, enumeration, oneOf } from 'validated/schema';
+import { validate } from 'validated/object';
+
+import type { Coordinate2d } from '../types';
+
+export type AccountData = {expiry: string};
+export type AccountToken = string;
+export type Ip = string;
+export type Location = {
+ latlong: Coordinate2d,
+ country: string,
+ city: string,
+};
+const LocationSchema = object({
+ latlong: arrayOf(number),
+ country: string,
+ city: string,
+});
+
+export type SecurityState = 'secured' | 'unsecured';
+export type BackendState = {
+ state: SecurityState,
+ target_state: SecurityState,
+};
+type RelayConstraints = {
+ host: 'any' | { only: string },
+ tunnel: {
+ openvpn: {
+ port: 'any' | { only: number },
+ protocol: 'any' | { only: 'tcp' | 'udp' },
+ },
+ },
+};
+export type RelayConstraintsUpdate = {
+ host?: 'any' | { only: string },
+ tunnel: {
+ openvpn: {
+ port?: 'any' | { only: number },
+ protocol?: 'any' | { only: 'tcp' | 'udp' },
+ },
+ },
+};
+const Constraint = (v) => oneOf(string, object({
+ only: v,
+}));
+const RelayConstraintsSchema = object({
+ host: Constraint(string),
+ tunnel: object({
+ openvpn: object({
+ port: Constraint(number),
+ protocol: Constraint(enumeration('udp', 'tcp')),
+ }),
+ }),
+});
+
+
+export interface IpcFacade {
+ setConnectionString(string): void,
+ getAccountData(AccountToken): Promise<AccountData>,
+ getAccount(): Promise<?AccountToken>,
+ setAccount(accountToken: ?AccountToken): Promise<void>,
+ updateRelayConstraints(RelayConstraintsUpdate): Promise<void>,
+ getRelayContraints(): Promise<RelayConstraints>,
+ connect(): Promise<void>,
+ disconnect(): Promise<void>,
+ shutdown(): Promise<void>,
+ getIp(): Promise<Ip>,
+ getLocation(): Promise<Location>,
+ getState(): Promise<BackendState>,
+ registerStateListener((BackendState) => void): void,
+ setCloseConnectionHandler(() => void): void,
+ authenticate(sharedSecret: string): Promise<void>,
+}
+
+export class RealIpc implements IpcFacade {
+
+ _ipc: JsonRpcWs;
+
+ constructor(connectionString: string) {
+ this._ipc = new JsonRpcWs(connectionString);
+ }
+
+ setConnectionString(str: string) {
+ this._ipc.setConnectionString(str);
+ }
+
+ getAccountData(accountToken: AccountToken): Promise<AccountData> {
+ // send the IPC with 30s timeout since the backend will wait
+ // for a HTTP request before replying
+
+ return this._ipc.send('get_account_data', accountToken, 30000)
+ .then(raw => {
+ if (typeof raw === 'object' && raw && raw.expiry) {
+ return raw;
+ } else {
+ throw new InvalidReply(raw, 'Expected an object with expiry');
+ }
+ });
+ }
+
+ getAccount(): Promise<?AccountToken> {
+ return this._ipc.send('get_account')
+ .then( raw => {
+ if (raw === undefined || raw === null || typeof raw === 'string') {
+ return raw;
+ } else {
+ throw new InvalidReply(raw);
+ }
+ });
+ }
+
+ setAccount(accountToken: ?AccountToken): Promise<void> {
+ return this._ipc.send('set_account', accountToken)
+ .then(this._ignoreResponse);
+ }
+
+ _ignoreResponse(_response: mixed): void {
+ return;
+ }
+
+ updateRelayConstraints(relayConstraints: RelayConstraintsUpdate): Promise<void> {
+ return this._ipc.send('update_relay_constraints', [relayConstraints])
+ .then(this._ignoreResponse);
+ }
+
+ getRelayContraints(): Promise<RelayConstraints> {
+ return this._ipc.send('get_relay_constraints')
+ .then( raw => {
+ try {
+ const validated: any = validate(RelayConstraintsSchema, raw);
+ return (validated: RelayConstraints);
+ } catch (e) {
+ throw new InvalidReply(raw, e);
+ }
+ });
+ }
+
+ connect(): Promise<void> {
+ return this._ipc.send('connect')
+ .then(this._ignoreResponse);
+ }
+
+ disconnect(): Promise<void> {
+ return this._ipc.send('disconnect')
+ .then(this._ignoreResponse);
+ }
+
+ shutdown(): Promise<void> {
+ return this._ipc.send('shutdown')
+ .then(this._ignoreResponse);
+ }
+
+ getIp(): Promise<Ip> {
+ return this._ipc.send('get_ip')
+ .then(raw => {
+ if (typeof raw === 'string' && raw) {
+ return raw;
+ } else {
+ throw new InvalidReply(raw, 'Expected a string');
+ }
+ });
+ }
+
+ getLocation(): Promise<Location> {
+ return this._ipc.send('get_location')
+ .then(raw => {
+ try {
+ const validated: any = validate(LocationSchema, raw);
+ return (validated: Location);
+ } catch (e) {
+ throw new InvalidReply(raw, e);
+ }
+ });
+ }
+
+ getState(): Promise<BackendState> {
+ return this._ipc.send('get_state')
+ .then(raw => {
+ return this._parseBackendState(raw);
+ });
+ }
+
+ _parseBackendState(raw: mixed): BackendState {
+ if (raw && raw.state && raw.target_state) {
+
+ const uncheckedRaw: any = raw;
+
+ const states: Array<SecurityState> = ['secured', 'unsecured'];
+ const correctState = states.includes(uncheckedRaw.state);
+ const correctTargetState = states.includes(uncheckedRaw.target_state);
+
+ if (!correctState || !correctTargetState) {
+ throw new InvalidReply(raw);
+ }
+
+ return (uncheckedRaw: BackendState);
+ } else {
+ throw new InvalidReply(raw);
+ }
+ }
+
+ registerStateListener(listener: (BackendState) => void) {
+ this._ipc.on('new_state', (rawEvent) => {
+ const parsedEvent : BackendState = this._parseBackendState(rawEvent);
+
+ listener(parsedEvent);
+ });
+ }
+
+ setCloseConnectionHandler(handler: () => void) {
+ this._ipc.setCloseConnectionHandler(handler);
+ }
+
+ authenticate(sharedSecret: string): Promise<void> {
+ return this._ipc.send('auth', sharedSecret)
+ .then(this._ignoreResponse);
+ }
+}
diff --git a/app/lib/jsonrpc-ws-ipc.js b/app/lib/jsonrpc-ws-ipc.js
new file mode 100644
index 0000000000..909b4b0775
--- /dev/null
+++ b/app/lib/jsonrpc-ws-ipc.js
@@ -0,0 +1,290 @@
+// @flow
+
+import jsonrpc from 'jsonrpc-lite';
+import uuid from 'uuid';
+import log from 'electron-log';
+
+export type UnansweredRequest = {
+ resolve: (mixed) => void,
+ reject: (mixed) => void,
+ timerId: number,
+ message: Object,
+}
+
+export type JsonRpcError = {
+ type: 'error',
+ payload: {
+ id: string,
+ error: {
+ message: string,
+ }
+ }
+}
+export type JsonRpcNotification = {
+ type: 'notification',
+ payload: {
+ method: string,
+ params: {
+ subscription: string,
+ result: mixed,
+ }
+ }
+}
+export type JsonRpcSuccess = {
+ type: 'success',
+ payload: {
+ id: string,
+ result: mixed,
+ }
+}
+export type JsonRpcMessage = JsonRpcError | JsonRpcNotification | JsonRpcSuccess;
+
+export class TimeOutError extends Error {
+ jsonRpcMessage: Object;
+
+ constructor(jsonRpcMessage: Object) {
+ super('Request timed out');
+ this.name = 'TimeOutError';
+ this.jsonRpcMessage = jsonRpcMessage;
+ }
+}
+
+export class InvalidReply extends Error {
+ reply: mixed;
+
+ constructor(reply: mixed, msg: ?string) {
+ super(msg);
+ this.name = 'InvalidReply';
+ this.reply = reply;
+
+ if(msg) {
+ this.message = msg + ' - ';
+ }
+ this.message += JSON.stringify(reply);
+ }
+}
+
+const DEFAULT_TIMEOUT_MILLIS = 5000;
+
+export default class Ipc {
+
+ _connectionString: ?string;
+ _onConnect: Array<{resolve: ()=>void}>;
+ _unansweredRequests: Map<string, UnansweredRequest>;
+ _subscriptions: Map<string|number, (mixed) => void>;
+ _websocket: WebSocket;
+ _backoff: ReconnectionBackoff;
+ _websocketFactory: (string) => WebSocket;
+ _closeConnectionHandler: ?() => void;
+
+ constructor(connectionString: string, websocketFactory: ?(string)=>WebSocket) {
+ this._connectionString = connectionString;
+ this._onConnect = [];
+ this._unansweredRequests = new Map();
+ this._subscriptions = new Map();
+ this._websocketFactory = websocketFactory || (connectionString => new WebSocket(connectionString));
+
+ this._backoff = new ReconnectionBackoff();
+ this._reconnect();
+ }
+
+ setConnectionString(str: string) {
+ this._connectionString = str;
+ }
+
+ setCloseConnectionHandler(handler: ?() => void) {
+ this._closeConnectionHandler = handler;
+ }
+
+ on(event: string, listener: (mixed) => void): Promise<*> {
+
+ log.debug('Adding a listener to', event);
+ return this.send(event + '_subscribe')
+ .then(subscriptionId => {
+ if (typeof subscriptionId === 'string' || typeof subscriptionId === 'number') {
+ this._subscriptions.set(subscriptionId, listener);
+ } else {
+ throw new InvalidReply(subscriptionId, 'The subscription id was not a string or a number');
+ }
+ })
+ .catch(e => {
+ log.error('Failed adding listener to', event, ':', e);
+ });
+ }
+
+ send(action: string, data: mixed, timeout: number = DEFAULT_TIMEOUT_MILLIS): Promise<mixed> {
+ return new Promise((resolve, reject) => {
+ const id = uuid.v4();
+
+ const params = this._prepareParams(data);
+ const timerId = setTimeout(() => this._onTimeout(id), timeout);
+ const jsonrpcMessage = jsonrpc.request(id, action, params);
+ this._unansweredRequests.set(id, {
+ resolve: resolve,
+ reject: reject,
+ timerId: timerId,
+ message: jsonrpcMessage,
+ });
+
+ this._getWebSocket()
+ .then(ws => {
+ log.debug('Sending message', id, action);
+ ws.send(jsonrpcMessage);
+ })
+ .catch(e => {
+ log.error('Failed sending RPC message "' + action + '":', e);
+ reject(e);
+ });
+ });
+ }
+
+ _prepareParams(data: mixed): Array<mixed>|Object {
+ // JSONRPC only accepts arrays and objects as params, but
+ // this isn't very nice to use, so this method wraps other
+ // types in an array. The choice of array is based on try-and-error
+
+ if(data === undefined) {
+ return [];
+ } else if (data === null) {
+ return [null];
+ } else if (Array.isArray(data) || typeof(data) === 'object') {
+ return data;
+ } else {
+ return [data];
+ }
+ }
+
+ _getWebSocket() {
+ return new Promise(resolve => {
+ if (this._websocket && this._websocket.readyState === 1) { // Connected
+ resolve(this._websocket);
+ } else {
+ log.debug('Waiting for websocket to connect');
+ this._onConnect.push({
+ resolve: () => resolve(this._websocket),
+ });
+ }
+ });
+ }
+
+ _onTimeout(requestId) {
+ const request = this._unansweredRequests.get(requestId);
+ this._unansweredRequests.delete(requestId);
+
+ if (!request) {
+ log.debug(requestId, 'timed out but it seems to already have been answered');
+ return;
+ }
+
+ log.debug(request.message, 'timed out');
+ request.reject(new TimeOutError(request.message));
+ }
+
+ _onMessage(message: string) {
+ const json = JSON.parse(message);
+ const c = jsonrpc.parseObject(json);
+
+ if (c.type === 'notification') {
+ this._onNotification(c);
+ } else {
+ this._onReply(c);
+ }
+ }
+
+ _onNotification(message: JsonRpcNotification) {
+ const subscriptionId = message.payload.params.subscription;
+ const listener = this._subscriptions.get(subscriptionId);
+
+ if (listener) {
+ log.debug('Got notification', message.payload.method, message.payload.params.result);
+ listener(message.payload.params.result);
+ } else {
+ log.warn('Got notification for', message.payload.method, 'but no one is listening for it');
+ }
+ }
+
+ _onReply(message: JsonRpcError | JsonRpcSuccess) {
+ const id = message.payload.id;
+ const request = this._unansweredRequests.get(id);
+ this._unansweredRequests.delete(id);
+
+ if (!request) {
+ log.warn('Got reply to', id, 'but no one was waiting for it');
+ return;
+ }
+
+ log.debug('Got answer to', id, message.type);
+
+ clearTimeout(request.timerId);
+
+ if (message.type === 'error') {
+ request.reject(message.payload.error);
+ } else {
+ const reply = message.payload.result;
+ request.resolve(reply);
+ }
+ }
+
+ _reconnect() {
+ const connectionString = this._connectionString;
+ if (!connectionString) return;
+
+ log.info('Connecting to websocket', connectionString);
+ this._websocket = this._websocketFactory(connectionString);
+
+ this._websocket.onopen = () => {
+ log.debug('Websocket is connected');
+ this._backoff.successfullyConnected();
+
+ while(this._onConnect.length > 0) {
+ this._onConnect.pop().resolve();
+ }
+ };
+
+ this._websocket.onmessage = (evt) => {
+ const data = evt.data;
+ if (typeof data === 'string') {
+ this._onMessage(data);
+ } else {
+ log.error('Got invalid reply from the server', evt);
+ }
+ };
+
+ this._websocket.onclose = () => {
+ if(this._closeConnectionHandler) {
+ this._closeConnectionHandler();
+ }
+
+ const delay = this._backoff.getIncreasedBackoff();
+ log.warn('The websocket connetion closed, attempting to reconnect it in', delay, 'milliseconds');
+ setTimeout(() => this._reconnect(), delay);
+ };
+ }
+}
+
+/*
+ * Used to calculate the time to wait before reconnecting
+ * the websocket.
+ *
+ * It uses a linear backoff function that goes from 500ms
+ * to 3000ms
+ */
+class ReconnectionBackoff {
+ _attempt: number;
+
+ constructor() {
+ this._attempt = 0;
+ }
+
+ successfullyConnected() {
+ this._attempt = 0;
+ }
+
+ getIncreasedBackoff() {
+ if (this._attempt < 6) {
+ this._attempt++;
+ }
+
+ return this._attempt * 500;
+ }
+}
diff --git a/app/lib/keyframe-animation.js b/app/lib/keyframe-animation.js
new file mode 100644
index 0000000000..62d9be6cd8
--- /dev/null
+++ b/app/lib/keyframe-animation.js
@@ -0,0 +1,226 @@
+// @flow
+import { nativeImage } from 'electron';
+import type { NativeImage } from 'electron';
+
+export type OnFrameFn = (image: NativeImage) => void;
+export type OnFinishFn = (void) => void;
+export type KeyframeAnimationOptions = {
+ startFrame?: number,
+ endFrame?: number,
+ beginFromCurrentState?: boolean,
+ advanceTo?: 'end'
+};
+export type KeyframeAnimationRange = [number, number];
+
+export default class KeyframeAnimation {
+
+ _speed: number = 200; // ms
+ _repeat: boolean = false;
+ _reverse: boolean = false;
+ _alternate: boolean = false;
+
+ _onFrame: ?OnFrameFn;
+ _onFinish: ?OnFinishFn;
+
+ _nativeImages: Array<NativeImage>;
+ _frameRange: KeyframeAnimationRange;
+ _numFrames: number;
+ _currentFrame: number = 0;
+
+ _isRunning: boolean = false;
+ _isFinished: boolean = false;
+ _isFirstRun: boolean = true;
+
+ _timeout = null;
+
+ set onFrame(newValue: ?OnFrameFn) { this._onFrame = newValue; }
+ get onFrame(): ?OnFrameFn { this._onFrame; }
+
+ // called when animation finished for non-repeating animations.
+ set onFinish(newValue: ?OnFinishFn) { this._onFinish = newValue; }
+ get onFinish(): ?OnFinishFn { this._onFinish; }
+
+ // pace per frame in ms
+ set speed(newValue: number) { this._speed = parseInt(newValue); }
+ get speed(): number { return this._speed; }
+
+ set repeat(newValue: boolean) { this._repeat = newValue; }
+ get repeat(): boolean { return this._repeat; }
+
+ set reverse(newValue: boolean) { this._reverse = newValue; }
+ get reverse(): boolean { return this._repeat; }
+
+ // alternates the animation direction when it reaches the end
+ // only for repeating animations
+ set alternate(newValue: boolean) { this._alternate = !!newValue; }
+ get alternate(): boolean { return this._alternate; }
+
+ get nativeImages(): Array<NativeImage> { return this._nativeImages.slice(); }
+ get isFinished(): boolean { return this._isFinished; }
+
+ // create animation from files matching filename pattern. i.e (bubble-frame-{}.png)
+ static fromFilePattern(filePattern: string, range: KeyframeAnimationRange): KeyframeAnimation {
+ const images: Array<NativeImage> = [];
+
+ if(range.length !== 2 || range[0] > range[1]) {
+ throw new Error('the animation range is invalid');
+ }
+
+ for(let i = range[0]; i <= range[1]; i++) {
+ const filePath = filePattern.replace('{}', i.toString());
+ const image = nativeImage.createFromPath(filePath);
+ images.push(image);
+ }
+ return new KeyframeAnimation(images);
+ }
+
+ static fromFileSequence(files: Array<string>): KeyframeAnimation {
+ const images: Array<NativeImage> = files.map(filePath => nativeImage.createFromPath(filePath));
+ return new KeyframeAnimation(images);
+ }
+
+ constructor(images: Array<NativeImage>) {
+ const len = images.length;
+ if(len < 1) {
+ throw new Error('too few images in animation');
+ }
+
+ this._nativeImages = images.slice();
+ this._numFrames = len;
+ this._frameRange = [0, len];
+ }
+
+ get currentImage(): NativeImage {
+ return this._nativeImages[this._currentFrame];
+ }
+
+ play(options: KeyframeAnimationOptions = {}) {
+ let { startFrame, endFrame, beginFromCurrentState, advanceTo } = options;
+
+ if(startFrame !== undefined && endFrame !== undefined) {
+ if(startFrame < 0 || startFrame >= this._numFrames) {
+ throw new Error('Invalid start frame');
+ }
+
+ if(endFrame < 0 || endFrame >= this._numFrames) {
+ throw new Error('Invalid end frame');
+ }
+
+ if(startFrame < endFrame) {
+ this._frameRange = [ startFrame, endFrame ];
+ } else {
+ this._frameRange = [ endFrame, startFrame ];
+ }
+ } else {
+ this._frameRange = [ 0, this._numFrames - 1 ];
+ }
+
+ if(!beginFromCurrentState || this._isFirstRun) {
+ this._currentFrame = this._frameRange[this._reverse ? 1 : 0];
+ }
+
+ if(this._isFirstRun) {
+ this._isFirstRun = false;
+ }
+
+ if(advanceTo === 'end') {
+ this._currentFrame = this._frameRange[this._reverse ? 0 : 1];
+ }
+
+ this._isRunning = true;
+ this._isFinished = false;
+
+ this._unscheduleUpdate();
+
+ this._render();
+ this._scheduleUpdate();
+ }
+
+ stop() {
+ this._isRunning = false;
+ this._unscheduleUpdate();
+ }
+
+ _unscheduleUpdate() {
+ if(this._timeout) {
+ clearTimeout(this._timeout);
+ this._timeout = null;
+ }
+ }
+
+ _scheduleUpdate() {
+ this._timeout = setTimeout(() => this._onUpdateFrame(), this._speed);
+ }
+
+ _render() {
+ if(this._onFrame) {
+ this._onFrame(this._nativeImages[this._currentFrame]);
+ }
+ }
+
+ _didFinish() {
+ this._isFinished = true;
+
+ if(this._onFinish) {
+ this._onFinish();
+ }
+ }
+
+ _onUpdateFrame() {
+ this._advanceFrame();
+
+ if(this._isFinished) {
+ // mark animation as not running when finished
+ this._isRunning = false;
+ } else {
+ this._render();
+
+ // check once again since onFrame() may stop animation
+ if(this._isRunning) {
+ this._scheduleUpdate();
+ }
+ }
+ }
+
+ _advanceFrame() {
+ if(this._isFinished) { return; }
+
+ let lastFrame = this._frameRange[this._reverse ? 0 : 1];
+ if(this._currentFrame === lastFrame) {
+ // mark animation as finished if it's not repeating
+ if(!this._repeat) {
+ this._didFinish();
+ return;
+ }
+
+ // change animation direction if marked for alternation
+ if(this._alternate) {
+ this._reverse = !this._reverse;
+
+ this._currentFrame = this._nextFrame(this._currentFrame, this._frameRange, this._reverse);
+ } else {
+ this._currentFrame = this._frameRange[this._reverse ? 1 : 0];
+ }
+ } else {
+ this._currentFrame = this._nextFrame(this._currentFrame, this._frameRange, this._reverse);
+ }
+ }
+
+ _nextFrame(cur: number, frameRange: KeyframeAnimationRange, isReverse: boolean): number {
+ if(isReverse) {
+ if(cur < frameRange[0]) {
+ return cur + 1;
+ } else if(cur > frameRange[0]) {
+ return cur - 1;
+ }
+ } else {
+ if(cur > frameRange[1]) {
+ return cur - 1;
+ } else if(cur < frameRange[1]) {
+ return cur + 1;
+ }
+ }
+ return cur;
+ }
+
+}
diff --git a/app/lib/proc.js b/app/lib/proc.js
new file mode 100644
index 0000000000..d11fa392a5
--- /dev/null
+++ b/app/lib/proc.js
@@ -0,0 +1,27 @@
+// @flow
+
+import path from 'path';
+
+export function resolveBin(binaryName: string) {
+ const basepath = getBasePath();
+ return path.resolve(basepath, binaryName + getExtension());
+}
+
+function getBasePath() {
+ if (process.env.NODE_ENV === 'development') {
+ return process.env.MULLVAD_BACKEND || '../talpid_core/target/debug';
+
+ } else {
+ return process.resourcesPath;
+ }
+}
+
+function getExtension() {
+ switch (process.platform) {
+ case 'win32':
+ return '.exe';
+
+ default:
+ return '';
+ }
+}
diff --git a/app/lib/transition-rule.js b/app/lib/transition-rule.js
new file mode 100644
index 0000000000..a91ba4da66
--- /dev/null
+++ b/app/lib/transition-rule.js
@@ -0,0 +1,47 @@
+// @flow
+
+export type TransitionDescriptor = {
+ name: string,
+ duration: number
+};
+
+export type TransitionFork = {
+ forward: TransitionDescriptor,
+ backward: TransitionDescriptor
+};
+
+export type TransitionMatch = {
+ direction: 'forward' | 'backward',
+ descriptor: TransitionDescriptor
+};
+
+export default class TransitionRule {
+
+ _from: ?string;
+ _to: string;
+ _fork: TransitionFork;
+
+ constructor(from: ?string, to: string, fork: TransitionFork) {
+ this._from = from;
+ this._to = to;
+ this._fork = fork;
+ }
+
+ match(fromRoute: ?string, toRoute: string): ?TransitionMatch {
+ if((!this._from || this._from === fromRoute) && this._to === toRoute) {
+ return {
+ direction: 'forward',
+ descriptor: this._fork['forward']
+ };
+ }
+
+ if((!this._from || this._from === toRoute) && this._to === fromRoute) {
+ return {
+ direction: 'backward',
+ descriptor: this._fork['backward']
+ };
+ }
+
+ return null;
+ }
+} \ No newline at end of file
diff --git a/app/lib/tray-icon-manager.js b/app/lib/tray-icon-manager.js
new file mode 100644
index 0000000000..73424cb7ef
--- /dev/null
+++ b/app/lib/tray-icon-manager.js
@@ -0,0 +1,58 @@
+// @flow
+import path from 'path';
+import KeyframeAnimation from './keyframe-animation';
+
+import type { Tray } from 'electron';
+
+export type TrayIconType = 'unsecured' | 'securing' | 'secured';
+
+export default class TrayIconManager {
+
+ _animation: ?KeyframeAnimation;
+ _iconType: TrayIconType;
+
+ constructor(tray: Tray, initialType: TrayIconType) {
+ const animation = this._createAnimation();
+ animation.onFrame = (img) => tray.setImage(img);
+ animation.reverse = this._isReverseAnimation(initialType);
+ animation.play({ advanceTo: 'end' });
+
+ this._animation = animation;
+ this._iconType = initialType;
+ }
+
+ destroy() {
+ if(this._animation) {
+ this._animation.stop();
+ this._animation = null;
+ }
+ }
+
+ _createAnimation(): KeyframeAnimation {
+ const basePath = path.join(path.resolve(__dirname, '..'), 'assets/images/menubar icons');
+ const filePath = path.join(basePath, 'lock-{}.png');
+ const animation = KeyframeAnimation.fromFilePattern(filePath, [1, 9]);
+ animation.speed = 100;
+ return animation;
+ }
+
+ _isReverseAnimation(type: TrayIconType): bool {
+ // unsecured & securing are treated as one
+ return type !== 'secured';
+ }
+
+ get iconType(): TrayIconType {
+ return this._iconType;
+ }
+
+ set iconType(type: TrayIconType) {
+ if(this._iconType === type || !this._animation) { return; }
+
+ const animation = this._animation;
+ animation.reverse = this._isReverseAnimation(type);
+ animation.play({ beginFromCurrentState: true });
+
+ this._iconType = type;
+ }
+
+}
diff --git a/app/main.js b/app/main.js
new file mode 100644
index 0000000000..d9a3358e1f
--- /dev/null
+++ b/app/main.js
@@ -0,0 +1,442 @@
+// @flow
+import path from 'path';
+import fs from 'fs';
+import mkdirp from 'mkdirp';
+import log from 'electron-log';
+import { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage } from 'electron';
+import TrayIconManager from './lib/tray-icon-manager';
+import ElectronSudo from 'electron-sudo';
+import shellescape from 'shell-escape';
+import { version } from '../package.json';
+import { parseIpcCredentials } from './lib/backend';
+import { resolveBin } from './lib/proc';
+import { execFile } from 'child_process';
+import uuid from 'uuid';
+
+import type { TrayIconType } from './lib/tray-icon-manager';
+
+const isDevelopment = (process.env.NODE_ENV === 'development');
+const isMacOS = (process.platform === 'darwin');
+const isLinux = (process.platform === 'linux');
+
+// The name for application directory used for
+// scoping logs and user data in platform special folders
+const appDirectoryName = 'MullvadVPN';
+
+const writableDirectory = isMacOS || isLinux
+ ? '/tmp'
+ : app.getPath('temp');
+
+const rpcAddressFile = path.join(writableDirectory, '.mullvad_rpc_address');
+
+let browserWindowReady = false;
+
+const appDelegate = {
+ _window: (null: ?BrowserWindow),
+ _tray: (null: ?Tray),
+ _logFileLocation: '',
+ connectionFilePollInterval: (null: ?number),
+
+ setup: () => {
+ // Override userData path, i.e on macOS: ~/Library/Application Support/MullvadVPN
+ app.setPath('userData', path.join(app.getPath('appData'), appDirectoryName));
+
+ appDelegate._logFileLocation = appDelegate._getLogsDirectory();
+ appDelegate._initLogging();
+
+ log.info('Running version', version);
+
+ appDelegate._startBackend();
+
+ app.on('window-all-closed', () => appDelegate.onAllWindowsClosed());
+ app.on('ready', () => appDelegate.onReady());
+ },
+
+ _initLogging: () => {
+
+ const format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}][{level}] {text}';
+ log.transports.console.format = format;
+ log.transports.file.format = format;
+ if (isDevelopment) {
+ log.transports.console.level = 'debug';
+
+ // Disable log file in development
+ log.transports.file.level = false;
+ } else {
+ log.transports.console.level = 'debug';
+ log.transports.file.level = 'debug';
+ log.transports.file.file = path.join(appDelegate._logFileLocation, 'frontend.log');
+ }
+
+ // create log folder
+ mkdirp.sync(appDelegate._logFileLocation);
+ },
+
+ // Returns platform specific logs folder for application
+ // See open issue and PR on Github:
+ // 1. https://github.com/electron/electron/issues/10118
+ // 2. https://github.com/electron/electron/pull/10191
+ _getLogsDirectory: () => {
+ // macOS: ~/Library/Logs/{appname}
+ if(isMacOS) {
+ return path.join(app.getPath('home'), 'Library/Logs', appDirectoryName);
+ }
+ // Linux: ~/.config/{appname}/logs
+ // Windows: ~\AppData\Roaming\{appname}\logs
+ return path.join(app.getPath('userData'), 'logs');
+ },
+
+ onReady: async () => {
+ const window = appDelegate._window = appDelegate._createWindow();
+
+ ipcMain.on('on-browser-window-ready', () => {
+ browserWindowReady = true;
+ appDelegate._pollForConnectionInfoFile();
+ });
+
+ ipcMain.on('show-window', () => {
+ appDelegate._showWindow(window, appDelegate._tray);
+ });
+
+ window.loadURL('file://' + path.join(__dirname, 'index.html'));
+ window.on('close', () => {
+ log.debug('The browser window is closing, shutting down the tunnel...');
+ window.webContents.send('shutdown');
+ });
+
+ // create tray icon on macOS
+ if(isMacOS) {
+ appDelegate._tray = appDelegate._createTray(window);
+ } else {
+ appDelegate._showWindow(window, null);
+ }
+
+ appDelegate._setAppMenu();
+ appDelegate._addContextMenu(window);
+
+ if(isDevelopment) {
+ await appDelegate._installDevTools();
+ window.openDevTools({ mode: 'detach' });
+ }
+ },
+
+ onAllWindowsClosed: () => {
+ app.quit();
+ },
+
+ _startBackend: () => {
+ const backendIsRunning = appDelegate._rpcAddressFileExists();
+ if (backendIsRunning) {
+ log.info('Not starting the backend as it appears to already be running');
+ return;
+ }
+
+ const pathToBackend = resolveBin('mullvadd');
+ log.info('Starting the mullvad backend at', pathToBackend);
+
+ const options = {
+ name: 'Mullvad',
+ };
+ const sudo = new ElectronSudo(options);
+ const backendCommand = shellescape([
+ pathToBackend, '-v',
+ '--log', path.join(appDelegate._logFileLocation, 'backend.log'),
+ '--tunnel-log', path.join(appDelegate._logFileLocation, 'openvpn.log')
+ ]);
+ sudo.spawn(backendCommand, [])
+ .then( p => {
+ appDelegate._setupBackendProcessListeners(p);
+ return p;
+ });
+ },
+ _rpcAddressFileExists: () => {
+ return fs.existsSync(rpcAddressFile);
+ },
+ _setupBackendProcessListeners: (p) => {
+ // electron-sudo writes all output to some buffers in memory.
+ // For long-running processes such as this one that would
+ // cause a memory leak.
+ p.stdout.removeAllListeners('data');
+ p.stderr.removeAllListeners('data');
+
+ p.stdout.on('data', (data) => {
+ console.log('BACKEND stdout:', data.toString());
+ });
+ p.stderr.on('data', (data) => {
+ console.warn('BACKEND stderr:', data.toString());
+ });
+
+ p.on('error', (err) => {
+ log.error('Failed to start or kill the backend', err);
+ });
+
+ p.on('exit', (code) => {
+ const timeoutMs = 500;
+ log.info('The backend exited with code', code + '. Attempting to restart it in', timeoutMs, 'milliseconds...');
+ setTimeout( () => appDelegate._startBackend(), timeoutMs);
+ });
+ },
+ _pollForConnectionInfoFile: () => {
+
+ if (appDelegate.connectionFilePollInterval) {
+ log.warn('Attempted to start polling for the RPC connection info file while another polling was already running');
+ return;
+ }
+
+ const pollIntervalMs = 200;
+ appDelegate.connectionFilePollInterval = setInterval(() => {
+
+ if (browserWindowReady && appDelegate._rpcAddressFileExists()) {
+
+ if (appDelegate.connectionFilePollInterval) {
+ clearInterval(appDelegate.connectionFilePollInterval);
+ appDelegate.connectionFilePollInterval = null;
+ }
+
+ appDelegate._sendBackendInfo();
+ }
+
+ }, pollIntervalMs);
+ },
+ _sendBackendInfo: () => {
+ const window = appDelegate._window;
+ if (!window) {
+ log.error('Attempted to send backend rpc address before the window was ready');
+ return;
+ }
+
+ log.debug('Reading the ipc connection info from', rpcAddressFile);
+
+ const isSecureEnough = isOwnedAndOnlyWritableByRoot(rpcAddressFile);
+ if (!isSecureEnough) {
+ log.error('Not trusting the contents of', rpcAddressFile, 'as it was not owned and only writable by root.');
+ return;
+ }
+
+ // There is a race condition here where the owner and permissions of
+ // the file can change in the time between we validate the owner and
+ // permissions and read the contents of the file. We deem the chance
+ // of that to be small enough to ignore.
+
+ fs.readFile(rpcAddressFile, 'utf8', function (err, data) {
+ if (err) {
+ return log.error('Could not find backend connection info', err);
+ }
+
+ const credentials = parseIpcCredentials(data);
+ if(credentials) {
+ log.debug('Read IPC connection info', credentials.connectionString);
+ window.webContents.send('backend-info', { credentials });
+ } else {
+ log.error('Could not parse IPC credentials.');
+ }
+ });
+ },
+
+ _installDevTools: async () => {
+ const installer = require('electron-devtools-installer');
+ const extensions = ['REACT_DEVELOPER_TOOLS', 'REDUX_DEVTOOLS'];
+ const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
+ for(const name of extensions) {
+ try {
+ await installer.default(installer[name], forceDownload);
+ } catch (e) {
+ log.info(`Error installing ${name} extension: ${e.message}`);
+ }
+ }
+ },
+
+ _createWindow: (): BrowserWindow => {
+ const contentHeight = 568;
+ let options = {
+ width: 320,
+ height: contentHeight,
+ resizable: false,
+ maximizable: false,
+ fullscreenable: false,
+ show: false,
+ webPreferences: {
+ // prevents renderer process code from not running when window is hidden
+ backgroundThrottling: false,
+ // Enable experimental features
+ blinkFeatures: 'CSSBackdropFilter'
+ }
+ };
+
+ // setup window flags to mimic popover on macOS
+ if(isMacOS) {
+ options = Object.assign({}, options, {
+ height: contentHeight + 12, // 12 is the size of transparent area around arrow
+ frame: false,
+ transparent: true
+ });
+ }
+
+ return new BrowserWindow(options);
+ },
+
+ _setAppMenu: () => {
+ const template = [
+ {
+ label: 'Mullvad',
+ submenu: [
+ { role: 'about' },
+ { type: 'separator' },
+ { role: 'quit' }
+ ]
+ },
+ {
+ label: 'Edit',
+ submenu: [
+ { role: 'cut' },
+ { role: 'copy' },
+ { role: 'paste' },
+ { type: 'separator' },
+ { role: 'selectall' }
+ ]
+ }
+ ];
+ Menu.setApplicationMenu(Menu.buildFromTemplate(template));
+ },
+
+ _addContextMenu: (window: BrowserWindow) => {
+ let menuTemplate = [
+ { role: 'cut' },
+ { role: 'copy' },
+ { role: 'paste' },
+ { type: 'separator' },
+ { role: 'selectall' }
+ ];
+
+ // add inspect element on right click menu
+ window.webContents.on('context-menu', (_e: Event, props: { x: number, y: number }) => {
+ let inspectTemplate = [{
+ label: 'Inspect element',
+ click() {
+ window.openDevTools({ mode: 'detach' });
+ window.inspectElement(props.x, props.y);
+ }
+ }];
+
+ if(props.isEditable) {
+ let inputMenu = menuTemplate;
+
+ // mixin 'inspect element' into standard menu when in development mode
+ if(isDevelopment) {
+ inputMenu = menuTemplate.concat([{type: 'separator'}], inspectTemplate);
+ }
+
+ Menu.buildFromTemplate(inputMenu).popup(window);
+ } else if(isDevelopment) {
+ // display inspect element for all non-editable
+ // elements when in development mode
+ Menu.buildFromTemplate(inspectTemplate).popup(window);
+ }
+ });
+ },
+
+ _toggleWindow: (window: BrowserWindow, tray: ?Tray) => {
+ if(window.isVisible()) {
+ window.hide();
+ } else {
+ appDelegate._showWindow(window, tray);
+ }
+ },
+
+ _showWindow: (window: BrowserWindow, tray: ?Tray) => {
+ // position window based on tray icon location
+ if(tray) {
+ const { x, y } = appDelegate._getWindowPosition(window, tray);
+ window.setPosition(x, y, false);
+ }
+
+ window.show();
+ window.focus();
+ },
+
+ _getWindowPosition: (window: BrowserWindow, tray: Tray): { x: number, y: number } => {
+ const windowBounds = window.getBounds();
+ const trayBounds = tray.getBounds();
+
+ // center window horizontally below the tray icon
+ const x = Math.round(trayBounds.x + (trayBounds.width / 2) - (windowBounds.width / 2));
+
+ // position window vertically below the tray icon
+ const y = Math.round(trayBounds.y + trayBounds.height);
+
+ return { x, y };
+ },
+
+ _createTray: (window: BrowserWindow): Tray => {
+ const tray = new Tray(nativeImage.createEmpty());
+ tray.setHighlightMode('never');
+ tray.on('click', () => appDelegate._toggleWindow(window, tray));
+
+ // setup NSEvent monitor to fix inconsistent window.blur
+ // see https://github.com/electron/electron/issues/8689
+ const { NSEventMonitor, NSEventMask } = require('nseventmonitor');
+ const trayIconManager = new TrayIconManager(tray, 'unsecured');
+ const macEventMonitor = new NSEventMonitor();
+ const eventMask = NSEventMask.leftMouseDown | NSEventMask.rightMouseDown;
+
+ // add IPC handler to change tray icon from renderer
+ ipcMain.on('changeTrayIcon', (_: Event, type: TrayIconType) => trayIconManager.iconType = type);
+
+ ipcMain.on('collect-logs', (event, id, toRedact) => {
+ log.info('Collecting logs in', appDelegate._logFileLocation);
+ fs.readdir(appDelegate._logFileLocation, (err, files) => {
+ if (err) {
+ event.sender.send('collect-logs-reply', id, err);
+ return;
+ }
+
+ const logFiles = files.filter(file => file.endsWith('.log'))
+ .filter(file => !file.startsWith('openvpn'))
+ .map(f => path.join(appDelegate._logFileLocation, f));
+ const reportPath = path.join(writableDirectory, uuid.v4() + '.report');
+
+ const binPath = resolveBin('problem-report');
+ let args = [
+ 'collect',
+ '--output', reportPath,
+ ];
+
+ if (toRedact.length > 0) {
+ args = args.concat([
+ '--redact', ...toRedact,
+ '--',
+ ]);
+ }
+
+ args = args.concat(logFiles);
+
+ execFile(binPath, args, {windowsHide: true}, (err) => {
+ if (err) {
+ event.sender.send('collect-logs-reply', id, err);
+ } else {
+ log.debug('Report written to', reportPath);
+ event.sender.send('collect-logs-reply', id, null, reportPath);
+ }
+ });
+ });
+ });
+
+ // setup event handlers
+ window.on('show', () => macEventMonitor.start(eventMask, () => window.hide()));
+ window.on('hide', () => macEventMonitor.stop());
+ window.on('close', () => window.closeDevTools());
+ window.on('blur', () => !window.isDevToolsOpened() && window.hide());
+
+ return tray;
+ }
+};
+
+appDelegate.setup();
+
+function isOwnedAndOnlyWritableByRoot(path) {
+ const stat = fs.statSync(path);
+ const isOwnedByRoot = stat.uid === 0;
+ const isOnlyWritableByOwner = (stat.mode & parseInt('022', 8)) === 0;
+
+ return isOwnedByRoot && isOnlyWritableByOwner;
+}
diff --git a/app/redux/account/actions.js b/app/redux/account/actions.js
new file mode 100644
index 0000000000..d528f4f3d0
--- /dev/null
+++ b/app/redux/account/actions.js
@@ -0,0 +1,94 @@
+// @flow
+
+import type { Backend, BackendError } from '../../lib/backend';
+
+type StartLoginAction = {
+ type: 'START_LOGIN',
+ accountToken?: string,
+};
+type LoginSuccessfulAction = {
+ type: 'LOGIN_SUCCESSFUL',
+ expiry: string,
+};
+type LoginFailedAction = {
+ type: 'LOGIN_FAILED',
+ error: BackendError,
+};
+
+type LoggedOutAction = {
+ type: 'LOGGED_OUT',
+};
+
+type ResetLoginErrorAction = {
+ type: 'RESET_LOGIN_ERROR',
+};
+
+type UpdateAccountTokenAction = {
+ type: 'UPDATE_ACCOUNT_TOKEN',
+ token: string,
+};
+
+export type AccountAction = StartLoginAction
+ | LoginSuccessfulAction
+ | LoginFailedAction
+ | LoggedOutAction
+ | ResetLoginErrorAction;
+
+function startLogin(accountToken?: string): StartLoginAction {
+ return {
+ type: 'START_LOGIN',
+ accountToken: accountToken,
+ };
+}
+
+function loginSuccessful(expiry: string): LoginSuccessfulAction {
+ return {
+ type: 'LOGIN_SUCCESSFUL',
+ expiry: expiry,
+ };
+}
+
+function loginFailed(error: BackendError): LoginFailedAction {
+ return {
+ type: 'LOGIN_FAILED',
+ error: error,
+ };
+}
+
+function loggedOut(): LoggedOutAction {
+ return {
+ type: 'LOGGED_OUT',
+ };
+}
+
+function autoLoginFailed(): LoggedOutAction {
+ return loggedOut();
+}
+
+function resetLoginError(): ResetLoginErrorAction {
+ return {
+ type: 'RESET_LOGIN_ERROR',
+ };
+}
+
+function updateAccountToken(token: string): UpdateAccountTokenAction {
+ return {
+ type: 'UPDATE_ACCOUNT_TOKEN',
+ token: token,
+ };
+}
+
+const login = (backend: Backend, account: string) => () => backend.login(account);
+const logout = (backend: Backend) => () => backend.logout();
+
+export default {
+ login,
+ logout,
+ startLogin,
+ loginSuccessful,
+ loginFailed,
+ loggedOut,
+ autoLoginFailed,
+ resetLoginError,
+ updateAccountToken,
+};
diff --git a/app/redux/account/reducers.js b/app/redux/account/reducers.js
new file mode 100644
index 0000000000..27530499c9
--- /dev/null
+++ b/app/redux/account/reducers.js
@@ -0,0 +1,63 @@
+// @flow
+
+import type { ReduxAction } from '../store';
+import type { BackendError } from '../../lib/backend';
+
+export type LoginState = 'none' | 'logging in' | 'failed' | 'ok';
+export type AccountReduxState = {
+ accountToken: ?string,
+ expiry: ?string, // ISO8601
+ status: LoginState,
+ error: ?BackendError
+};
+
+const initialState: AccountReduxState = {
+ accountToken: null,
+ expiry: null,
+ status: 'none',
+ error: null
+};
+
+export default function(state: AccountReduxState = initialState, action: ReduxAction): AccountReduxState {
+
+ switch (action.type) {
+ case 'LOGIN_CHANGE':
+ return { ...state, ...action.newData };
+ case 'START_LOGIN':
+ return { ...state, ...{
+ status: 'logging in',
+ accountToken: action.accountToken,
+ error: null,
+ }};
+ case 'LOGIN_SUCCESSFUL':
+ return { ...state, ...{
+ status: 'ok',
+ error: null,
+ expiry: action.expiry,
+ }};
+ case 'LOGIN_FAILED':
+ return { ...state, ...{
+ status: 'failed',
+ accountToken: null,
+ error: action.error,
+ }};
+ case 'LOGGED_OUT':
+ return { ...state, ...{
+ status: 'none',
+ accountToken: null,
+ expiry: null,
+ error: null,
+ }};
+ case 'RESET_LOGIN_ERROR':
+ return { ...state, ...{
+ status: 'none',
+ error: null,
+ }};
+ case 'UPDATE_ACCOUNT_TOKEN':
+ return { ...state, ...{
+ accountToken: action.token,
+ }};
+ }
+
+ return state;
+}
diff --git a/app/redux/connection/actions.js b/app/redux/connection/actions.js
new file mode 100644
index 0000000000..dcc90f7273
--- /dev/null
+++ b/app/redux/connection/actions.js
@@ -0,0 +1,118 @@
+// @flow
+
+import { clipboard } from 'electron';
+
+import type { Backend } from '../../lib/backend';
+import type { ReduxGetState, ReduxDispatch } from '../store';
+import type { Coordinate2d } from '../../types';
+
+
+const connect = (backend: Backend, relay: string) => () => backend.connect(relay);
+const disconnect = (backend: Backend) => () => backend.disconnect();
+const copyIPAddress = () => {
+ return (_dispatch: ReduxDispatch, getState: ReduxGetState) => {
+ const ip: ?string = getState().connection.clientIp;
+ if(ip) {
+ clipboard.writeText(ip);
+ }
+ };
+};
+
+
+type ConnectingAction = {
+ type: 'CONNECTING',
+ host?: string,
+};
+type ConnectedAction = {
+ type: 'CONNECTED',
+};
+type DisconnectedAction = {
+ type: 'DISCONNECTED',
+};
+
+type NewPublicIpAction = {
+ type: 'NEW_PUBLIC_IP',
+ ip: string,
+};
+
+type Location = {
+ location: Coordinate2d,
+ country: string,
+ city: string,
+};
+
+type NewLocationAction = {
+ type: 'NEW_LOCATION',
+ newLocation: Location,
+};
+
+type OnlineAction = {
+ type: 'ONLINE',
+};
+
+type OfflineAction = {
+ type: 'OFFLINE',
+};
+
+export type ConnectionAction = NewPublicIpAction
+ | NewLocationAction
+ | ConnectingAction
+ | ConnectedAction
+ | DisconnectedAction
+ | OnlineAction
+ | OfflineAction;
+
+function connectingTo(host: string): ConnectingAction {
+ return {
+ type: 'CONNECTING',
+ host: host,
+ };
+}
+
+function connecting(): ConnectingAction {
+ return {
+ type: 'CONNECTING',
+ };
+}
+
+function connected(): ConnectedAction {
+ return {
+ type: 'CONNECTED',
+ };
+}
+
+function disconnected(): DisconnectedAction {
+ return {
+ type: 'DISCONNECTED',
+ };
+}
+
+function newPublicIp(ip: string): NewPublicIpAction {
+ return {
+ type: 'NEW_PUBLIC_IP',
+ ip: ip,
+ };
+}
+
+function newLocation(newLoc: Location): NewLocationAction {
+ return {
+ type: 'NEW_LOCATION',
+ newLocation: newLoc,
+ };
+}
+
+function online(): OnlineAction {
+ return {
+ type: 'ONLINE',
+ };
+}
+
+function offline(): OfflineAction {
+ return {
+ type: 'OFFLINE',
+ };
+}
+
+
+export default { connect, disconnect, copyIPAddress, newPublicIp, newLocation, connectingTo, connecting, connected, disconnected, online, offline };
+
diff --git a/app/redux/connection/reducers.js b/app/redux/connection/reducers.js
new file mode 100644
index 0000000000..ce4cb79344
--- /dev/null
+++ b/app/redux/connection/reducers.js
@@ -0,0 +1,69 @@
+// @flow
+
+import type { ReduxAction } from '../store';
+import type { Coordinate2d } from '../../types';
+
+export type ConnectionState = 'disconnected' | 'connecting' | 'connected';
+export type ConnectionReduxState = {
+ status: ConnectionState,
+ isOnline: boolean,
+ serverAddress: ?string,
+ clientIp: ?string,
+ location: ?Coordinate2d,
+ country: ?string,
+ city: ?string,
+};
+
+const initialState: ConnectionReduxState = {
+ status: 'disconnected',
+ isOnline: true,
+ serverAddress: null,
+ clientIp: null,
+ location: null,
+ country: null,
+ city: null,
+};
+
+
+export default function(state: ConnectionReduxState = initialState, action: ReduxAction): ConnectionReduxState {
+
+ switch (action.type) {
+ case 'CONNECTION_CHANGE':
+ return { ...state, ...action.newData };
+
+ case 'NEW_PUBLIC_IP':
+ return { ...state, ...{ clientIp: action.ip }};
+
+ case 'NEW_LOCATION':
+ return { ...state, ...action.newLocation };
+
+ case 'CONNECTING':
+ return onConnecting(state, action);
+
+ case 'CONNECTED':
+ return { ...state, ...{ status: 'connected' }};
+
+ case 'DISCONNECTED':
+ return { ...state, ...{ status: 'disconnected' }};
+
+ case 'ONLINE':
+ return { ...state, ...{ isOnline: true }};
+
+ case 'OFFLINE':
+ return { ...state, ...{ isOnline: false }};
+
+ default:
+ return state;
+ }
+}
+
+function onConnecting(state, action) {
+ const newState: $Shape<ConnectionReduxState> = {
+ status: 'connecting',
+ };
+
+ if (action.host) {
+ newState.serverAddress = action.host;
+ }
+ return { ...state, ...newState};
+}
diff --git a/app/redux/settings/actions.js b/app/redux/settings/actions.js
new file mode 100644
index 0000000000..6bb6b0ed71
--- /dev/null
+++ b/app/redux/settings/actions.js
@@ -0,0 +1,19 @@
+// @flow
+
+import type { RelayConstraints } from './reducers';
+
+export type UpdateRelayAction = {
+ type: 'UPDATE_RELAY',
+ relay: RelayConstraints,
+};
+
+export type SettingsAction = UpdateRelayAction;
+
+function updateRelay(relay: RelayConstraints): UpdateRelayAction {
+ return {
+ type: 'UPDATE_RELAY',
+ relay: relay,
+ };
+}
+
+export default { updateRelay };
diff --git a/app/redux/settings/reducers.js b/app/redux/settings/reducers.js
new file mode 100644
index 0000000000..e16f25a068
--- /dev/null
+++ b/app/redux/settings/reducers.js
@@ -0,0 +1,37 @@
+// @flow
+
+import { defaultServer } from '../../config';
+
+import type { ReduxAction } from '../store';
+
+export type RelayConstraints = {
+ host: string,
+ port: 'any' | number,
+ protocol: 'any' | 'tcp' | 'udp',
+};
+
+export type SettingsReduxState = {
+ relayConstraints: RelayConstraints
+};
+
+const initialState: SettingsReduxState = {
+ relayConstraints: {
+ host: defaultServer,
+ port: 'any',
+ protocol: 'any',
+ },
+};
+
+export default function(state: SettingsReduxState = initialState, action: ReduxAction): SettingsReduxState {
+
+ if (action.type === 'UPDATE_RELAY') {
+ return { ...state,
+ relayConstraints: {
+ ...state.relayConstraints,
+ ...action.relay,
+ },
+ };
+ }
+
+ return state;
+}
diff --git a/app/redux/store.js b/app/redux/store.js
new file mode 100644
index 0000000000..3f73574103
--- /dev/null
+++ b/app/redux/store.js
@@ -0,0 +1,69 @@
+// @flow
+import { createStore, applyMiddleware, combineReducers, compose } from 'redux';
+import { routerMiddleware, routerReducer, push, replace } from 'react-router-redux';
+import thunk from 'redux-thunk';
+
+import account from './account/reducers.js';
+import accountActions from './account/actions.js';
+import connection from './connection/reducers.js';
+import connectionActions from './connection/actions.js';
+import settings from './settings/reducers.js';
+import settingsActions from './settings/actions.js';
+
+import type { Store } from 'redux';
+import type { History } from 'history';
+import type { AccountReduxState } from './account/reducers.js';
+import type { ConnectionReduxState } from './connection/reducers.js';
+import type { SettingsReduxState } from './settings/reducers.js';
+
+import type { ConnectionAction } from './connection/actions.js';
+import type { AccountAction } from './account/actions.js';
+import type { SettingsAction } from './settings/actions.js';
+
+export type ReduxState = {
+ account: AccountReduxState,
+ connection: ConnectionReduxState,
+ settings: SettingsReduxState
+};
+
+export type ReduxAction = AccountAction
+ | SettingsAction
+ | ConnectionAction;
+
+export type ReduxStore = Store<ReduxState, ReduxAction, ReduxDispatch>;
+export type ReduxGetState = () => ReduxState;
+export type ReduxDispatch = (action: ReduxAction | ReduxThunk) => any;
+export type ReduxThunk = (dispatch: ReduxDispatch, getState: ReduxGetState) => any;
+
+export default function configureStore(initialState: ?ReduxState, routerHistory: History): ReduxStore {
+ const router = routerMiddleware(routerHistory);
+
+ const actionCreators: { [string]: Function } = {
+ ...accountActions,
+ ...connectionActions,
+ ...settingsActions,
+ pushRoute: (route) => push(route),
+ replaceRoute: (route) => replace(route),
+ };
+
+ const reducers = {
+ account, connection, settings, router: routerReducer
+ };
+
+ const middlewares = [ thunk, router ];
+
+ const composeEnhancers = (() => {
+ const reduxCompose = window && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__;
+ if(process.env.NODE_ENV === 'development' && reduxCompose) {
+ return reduxCompose({ actionCreators });
+ }
+ return compose;
+ })();
+
+ const enhancer = composeEnhancers(applyMiddleware(...middlewares));
+ const rootReducer = combineReducers(reducers);
+ if(initialState) {
+ return createStore(rootReducer, initialState, enhancer);
+ }
+ return createStore(rootReducer, enhancer);
+}
diff --git a/app/routes.js b/app/routes.js
new file mode 100644
index 0000000000..f428c8093b
--- /dev/null
+++ b/app/routes.js
@@ -0,0 +1,109 @@
+// @flow
+
+import React from 'react';
+import { Switch, Route, Redirect } from 'react-router';
+import { CSSTransitionGroup } from 'react-transition-group';
+import WindowChrome from './components/WindowChrome';
+import LoginPage from './containers/LoginPage';
+import ConnectPage from './containers/ConnectPage';
+import SettingsPage from './containers/SettingsPage';
+import AdvancedSettingsPage from './containers/AdvancedSettingsPage';
+import AccountPage from './containers/AccountPage';
+import SupportPage from './containers/SupportPage';
+import SelectLocationPage from './containers/SelectLocationPage';
+import { getTransitionProps } from './transitions';
+
+import type { ReduxGetState } from './redux/store';
+import type { Backend } from './lib/backend';
+
+export type SharedRouteProps = {
+ backend: Backend
+};
+
+type CustomRouteProps = {
+ component: ReactClass<*>
+};
+
+export default function makeRoutes(getState: ReduxGetState, componentProps: SharedRouteProps): React.Element<*> {
+
+ // Merge props and render component
+ const renderMergedProps = (ComponentClass: ReactClass<*>, ...rest: Array<Object>): React.Element<*> => {
+ const finalProps = Object.assign({}, componentProps, ...rest);
+ return (
+ <ComponentClass { ...finalProps } />
+ );
+ };
+
+ // Renders public route
+ // example: <PublicRoute path="/" component={ MyComponent } />
+ const PublicRoute = ({ component, ...otherProps }: CustomRouteProps) => {
+ return (
+ <Route { ...otherProps } render={ (routeProps) => {
+ return renderMergedProps(component, routeProps, otherProps);
+ }} />
+ );
+ };
+
+ // Renders protected route that requires authentication, otherwise redirects to /
+ // example: <PrivateRoute path="/protected" component={ MyComponent } />
+ const PrivateRoute = ({ component, ...otherProps }: CustomRouteProps) => {
+ return (
+ <Route { ...otherProps } render={ (routeProps) => {
+ const { account } = getState();
+ const isLoggedIn = account.status === 'ok';
+
+ if(isLoggedIn) {
+ return renderMergedProps(component, routeProps, otherProps);
+ } else {
+ return (<Redirect to={ '/' } />);
+ }
+ }} />
+ );
+ };
+
+ // Renders login route that is only available to non-authenticated
+ // users. Otherwise this route redirects user to /connect.
+ // example: <LoginRoute path="/login" component={ MyComponent } />
+ const LoginRoute = ({ component, ...otherProps }: CustomRouteProps) => {
+ return (
+ <Route { ...otherProps } render={ (routeProps) => {
+ const { account } = getState();
+ const isLoggedIn = account.status === 'ok';
+
+ if(isLoggedIn) {
+ return (<Redirect to={ '/connect' } />);
+ } else {
+ return renderMergedProps(component, routeProps, otherProps);
+ }
+ }} />
+ );
+ };
+
+ // store previous route
+ let previousRoute: ?string;
+
+ return (
+ <Route render={({ location }) => {
+ const toRoute = location.pathname;
+ const fromRoute = previousRoute;
+ const transitionProps = getTransitionProps(fromRoute, toRoute);
+ previousRoute = toRoute;
+
+ return (
+ <WindowChrome>
+ <CSSTransitionGroup component="div" className="transition-container" { ...transitionProps }>
+ <Switch key={ location.key } location={ location }>
+ <LoginRoute exact path="/" component={ LoginPage } />
+ <PrivateRoute exact path="/connect" component={ ConnectPage } />
+ <PublicRoute exact path="/settings" component={ SettingsPage } />
+ <PrivateRoute exact path="/settings/account" component={ AccountPage } />
+ <PublicRoute exact path="/settings/support" component={ SupportPage } />
+ <PublicRoute exact path="/settings/advanced" component={ AdvancedSettingsPage } />
+ <PrivateRoute exact path="/select-location" component={ SelectLocationPage } />
+ </Switch>
+ </CSSTransitionGroup>
+ </WindowChrome>
+ );
+ }} />
+ );
+}
diff --git a/app/tilecache.sw.js b/app/tilecache.sw.js
new file mode 100644
index 0000000000..15ec8f9493
--- /dev/null
+++ b/app/tilecache.sw.js
@@ -0,0 +1,27 @@
+
+this.addEventListener('install', function (event) {
+ console.log('Installing Service Worker');
+ event.waitUntil(this.skipWaiting());
+});
+
+this.addEventListener('activate', function (event) {
+ event.waitUntil(this.clients.claim());
+});
+
+this.addEventListener('fetch', function(event) {
+ var url = event.request.url;
+
+ if(url.startsWith('https://') && (url.includes('tiles.mapbox.com') || url.includes('api.mapbox.com'))) {
+ event.respondWith(
+ caches.match(event.request).then(function(resp) {
+ return resp || fetch(event.request).then(function(response) {
+ var cacheResponse = response.clone();
+ caches.open('mapbox').then(function(cache) {
+ cache.put(event.request, cacheResponse);
+ });
+ return response;
+ });
+ })
+ );
+ }
+});
diff --git a/app/transitions.js b/app/transitions.js
new file mode 100644
index 0000000000..fe6da53563
--- /dev/null
+++ b/app/transitions.js
@@ -0,0 +1,113 @@
+// @flow
+
+import TransitionRule from './lib/transition-rule';
+import type { TransitionFork, TransitionDescriptor } from './lib/transition-rule';
+
+export type CSSTransitionGroupProps = {
+ transitionName: string,
+ transitionEnterTimeout: number,
+ transitionLeaveTimeout: number,
+ transitionEnter: boolean,
+ transitionLeave: boolean,
+ transitionAppear?: boolean,
+ transitionAppearTimeout?: number
+};
+
+type TransitionMap = {
+ [name: string]: TransitionFork
+};
+
+/**
+ * Transition descriptors
+ */
+const transitions: TransitionMap = {
+ slide: {
+ forward: {
+ name: 'slide-up-transition',
+ duration: 450
+ },
+ backward: {
+ name: 'slide-down-transition',
+ duration: 450
+ }
+ },
+ push: {
+ forward: {
+ name: 'push-transition',
+ duration: 450
+ },
+ backward: {
+ name: 'pop-transition',
+ duration: 450
+ }
+ }
+};
+
+/**
+ * Transition rules
+ * (null) is used to indicate any route.
+ */
+const transitionRules = [
+ r('/settings', '/settings/account', transitions.push),
+ r('/settings', '/settings/support', transitions.push),
+ r('/settings', '/settings/advanced', transitions.push),
+ r(null, '/settings', transitions.slide),
+ r(null, '/select-location', transitions.slide)
+];
+
+/**
+ * Calculate CSSTransitionGroup props.
+ *
+ * @param {string} [fromRoute] - source route
+ * @param {string} toRoute - target route
+ */
+export function getTransitionProps(fromRoute: ?string, toRoute: string): CSSTransitionGroupProps {
+ // ignore initial transition and transition between the same routes
+ if(!fromRoute || fromRoute === toRoute) {
+ return noTransitionProps();
+ }
+
+ for(const rule of transitionRules) {
+ const match = rule.match(fromRoute, toRoute);
+ if(match) {
+ return toCSSTransitionGroupProps(match.descriptor);
+ }
+ }
+
+ return noTransitionProps();
+}
+
+/**
+ * Integrate TransitionDescriptor into CSSTransitionGroupProps
+ * @param {TransitionDescriptor} descriptor
+ */
+function toCSSTransitionGroupProps(descriptor: TransitionDescriptor): CSSTransitionGroupProps {
+ const {name, duration} = descriptor;
+ return {
+ transitionName: name,
+ transitionEnterTimeout: duration,
+ transitionLeaveTimeout: duration,
+ transitionEnter: true,
+ transitionLeave: true
+ };
+}
+
+/**
+ * Returns default props with animations disabled
+ */
+function noTransitionProps(): CSSTransitionGroupProps {
+ return {
+ transitionName: '',
+ transitionEnterTimeout: 0,
+ transitionLeaveTimeout: 0,
+ transitionEnter: false,
+ transitionLeave: false
+ };
+}
+
+/**
+ * Shortcut to create TransitionRule
+ */
+function r(from: ?string, to: string, fork: TransitionFork): TransitionRule {
+ return new TransitionRule(from, to, fork);
+}
diff --git a/app/types.js b/app/types.js
new file mode 100644
index 0000000000..3e247a875a
--- /dev/null
+++ b/app/types.js
@@ -0,0 +1,6 @@
+// @flow
+export type Point2d = {
+ x: number;
+ y: number;
+};
+export type Coordinate2d = [number, number];