diff options
| author | Linus Färnstrand <linus@mullvad.net> | 2017-12-20 11:32:55 +0100 |
|---|---|---|
| committer | Linus Färnstrand <linus@mullvad.net> | 2017-12-20 11:34:21 +0100 |
| commit | 2aae380b0af018bf0187bb31fb0fedf6a457ebf1 (patch) | |
| tree | a8ad6ee12956d92e6257bea07dedc44063f3017f /app | |
| parent | 7b47ddf735af7f3d6065fb6c3ffea6e9ddfd86cb (diff) | |
| parent | 8b146934260739ae609791a1fb676d48ceb954c0 (diff) | |
| download | mullvadvpn-2aae380b0af018bf0187bb31fb0fedf6a457ebf1.tar.xz mullvadvpn-2aae380b0af018bf0187bb31fb0fedf6a457ebf1.zip | |
Merge backend and frontend repo master branches
Conflicts:
.gitignore
.travis.yml
README.md
Diffstat (limited to 'app')
111 files changed, 7530 insertions, 0 deletions
diff --git a/app/app.js b/app/app.js new file mode 100644 index 0000000000..e84a577223 --- /dev/null +++ b/app/app.js @@ -0,0 +1,98 @@ +// @flow + +import React from 'react'; +import { Component} from 'reactxp'; +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, BackendError } 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', async (_event, args) => { + backend.setCredentials(args.credentials); + backend.sync(); + try { + await backend.autologin(); + await backend.fetchRelaySettings(); + await backend.connect(); + } catch (e) { + if(e instanceof BackendError) { + 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); + +ipcRenderer.send('on-browser-window-ready'); + + +export default class App extends Component{ + render() { + return ( + <Provider store={ store }> + <ConnectedRouter history={ memoryHistory }> + { makeRoutes(store.getState, { backend }) } + </ConnectedRouter> + </Provider> + ); + } +} 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..355ddd1bde --- /dev/null +++ b/app/assets/css/global.css @@ -0,0 +1,30 @@ +* { box-sizing: border-box; } + +:focus { + outline: 0; +} + +html { + -webkit-font-smoothing: antialiased; + user-select: none; + cursor: default; + height: 100%; + width: 100%; +} + +img { + -webkit-user-drag: none; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + height: 100%; + width: 100%; + display: flex; +} + +#app { + height: 100%; + width: 100%; + display: flex; +} 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..eb4353b67e --- /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/AdvancedSettings.css'; +@import '../../components/Account.css'; +@import '../../components/Support.css'; +@import '../../components/SelectLocation.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 Binary files differnew file mode 100755 index 0000000000..2092a7bbdc --- /dev/null +++ b/app/assets/fonts/DINPro-Black.otf diff --git a/app/assets/fonts/DINPro-Bold.otf b/app/assets/fonts/DINPro-Bold.otf Binary files differnew file mode 100755 index 0000000000..7c83953648 --- /dev/null +++ b/app/assets/fonts/DINPro-Bold.otf diff --git a/app/assets/fonts/OpenSans-ExtraBold.ttf b/app/assets/fonts/OpenSans-ExtraBold.ttf Binary files differnew file mode 100755 index 0000000000..21f6f84a07 --- /dev/null +++ b/app/assets/fonts/OpenSans-ExtraBold.ttf diff --git a/app/assets/fonts/OpenSans-Semibold.ttf b/app/assets/fonts/OpenSans-Semibold.ttf Binary files differnew file mode 100755 index 0000000000..1a7679e394 --- /dev/null +++ b/app/assets/fonts/OpenSans-Semibold.ttf 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-down.svg b/app/assets/images/icon-chevron-down.svg new file mode 100644 index 0000000000..e1e7af1104 --- /dev/null +++ b/app/assets/images/icon-chevron-down.svg @@ -0,0 +1,4 @@ +<svg width="24" height="24" 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-cheveron-down</title> + <path d="M11.9497475,15.9497475 C11.6938252,15.9497475 11.4379028,15.8521164 11.2426407,15.6568542 L6.29289322,10.7071068 C5.90236893,10.3165825 5.90236893,9.68341751 6.29289322,9.29289322 C6.68341751,8.90236893 7.31658249,8.90236893 7.70710678,9.29289322 L11.9497475,13.5355339 L16.1923882,9.29289322 C16.5829124,8.90236893 17.2160774,8.90236893 17.6066017,9.29289322 C17.997126,9.68341751 17.997126,10.3165825 17.6066017,10.7071068 L12.6568542,15.6568542 C12.4615921,15.8521164 12.2056698,15.9497475 11.9497475,15.9497475 Z" fill="currentColor"/> +</svg>
\ No newline at end of file diff --git a/app/assets/images/icon-chevron-up.svg b/app/assets/images/icon-chevron-up.svg new file mode 100644 index 0000000000..84d8b56350 --- /dev/null +++ b/app/assets/images/icon-chevron-up.svg @@ -0,0 +1,4 @@ +<svg width="24" height="24" 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-cheveron-up</title> + <path d="M11.9497475,9 C12.2056698,9 12.4615921,9.09763107 12.6568542,9.29289322 L17.6066017,14.2426407 C17.997126,14.633165 17.997126,15.26633 17.6066017,15.6568542 C17.2160774,16.0473785 16.5829124,16.0473785 16.1923882,15.6568542 L11.9497475,11.4142136 L7.70710678,15.6568542 C7.31658249,16.0473785 6.68341751,16.0473785 6.29289322,15.6568542 C5.90236893,15.26633 5.90236893,14.633165 6.29289322,14.2426407 L11.2426407,9.29289322 C11.4379028,9.09763107 11.6938252,9 11.9497475,9 Z" fill="currentColor"/> +</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..923532cd28 --- /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="currentColor" fill-rule="evenodd"></path> +</svg>
\ No newline at end of file diff --git a/app/assets/images/icon-close-sml.svg b/app/assets/images/icon-close-sml.svg new file mode 100644 index 0000000000..d06479241b --- /dev/null +++ b/app/assets/images/icon-close-sml.svg @@ -0,0 +1 @@ +<svg width="16" height="16" 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 close sml</title><desc>Created with Sketch.</desc><g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" fill-opacity=".2"><g id="Views/Login-Inputted-filltered-prev-IDs" transform="translate(-268.000000, -403.000000)" fill="#294D73"><g id="Input" transform="translate(24.000000, 338.000000)"><g id="suggestions" transform="translate(0.000000, 49.000000)"><g id="1"><path d="M253,24 L255.290281,21.7097195 C255.681035,21.3189651 255.684193,20.6841926 255.294624,20.2946243 L255.705376,20.7053757 C255.316406,20.3164062 254.682248,20.3177522 254.290281,20.7097195 L252,23 L249.709719,20.7097195 C249.317752,20.3177522 248.683594,20.3164062 248.294624,20.7053757 L248.705376,20.2946243 C248.315807,20.6841926 248.318965,21.3189651 248.709719,21.7097195 L251,24 L248.709719,26.2902805 C248.318965,26.6810349 248.315807,27.3158074 248.705376,27.7053757 L248.294624,27.2946243 C248.683594,27.6835938 249.317752,27.6822478 249.709719,27.2902805 L252,25 L254.290281,27.2902805 C254.682248,27.6822478 255.316406,27.6835938 255.705376,27.2946243 L255.294624,27.7053757 C255.684193,27.3158074 255.681035,26.6810349 255.290281,26.2902805 L253,24 Z M252,32 C247.58208,32 244,28.41792 244,24 C244,19.58208 247.58208,16 252,16 C256.41792,16 260,19.58208 260,24 C260,28.41792 256.41792,32 252,32 Z" id="icon-close-sml"/></g></g></g></g></g></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..bed3cb37b3 --- /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="currentColor" 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..16c701574a --- /dev/null +++ b/app/assets/images/icon-tick.svg @@ -0,0 +1,4 @@ +<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-tick</title> + <path d="M2.92646877,10.7979185 C2.25699855,10.1340272 1.17157288,10.1340272 0.502102661,10.7979185 C-0.167367554,11.4618098 -0.167367554,12.5381902 0.502102661,13.2020815 L7.35924552,20.0020815 C8.02871573,20.6659728 9.11414141,20.6659728 9.78361162,20.0020815 L23.4978973,6.40208153 C24.1673676,5.73819023 24.1673676,4.66180977 23.4978973,3.99791847 C22.8284271,3.33402718 21.7430014,3.33402718 21.0735312,3.99791847 L8.57142857,16.3958369 L2.92646877,10.7979185 Z" fill="currentColor"/> +</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 Binary files differnew file mode 100644 index 0000000000..b20bc19515 --- /dev/null +++ b/app/assets/images/menubar icons/lock-1.png diff --git a/app/assets/images/menubar icons/lock-1@2x.png b/app/assets/images/menubar icons/lock-1@2x.png Binary files differnew file mode 100644 index 0000000000..1c512d12bd --- /dev/null +++ b/app/assets/images/menubar icons/lock-1@2x.png diff --git a/app/assets/images/menubar icons/lock-2.png b/app/assets/images/menubar icons/lock-2.png Binary files differnew file mode 100644 index 0000000000..d98d05d951 --- /dev/null +++ b/app/assets/images/menubar icons/lock-2.png diff --git a/app/assets/images/menubar icons/lock-2@2x.png b/app/assets/images/menubar icons/lock-2@2x.png Binary files differnew file mode 100644 index 0000000000..c92dec19ec --- /dev/null +++ b/app/assets/images/menubar icons/lock-2@2x.png diff --git a/app/assets/images/menubar icons/lock-3.png b/app/assets/images/menubar icons/lock-3.png Binary files differnew file mode 100644 index 0000000000..871f8402bf --- /dev/null +++ b/app/assets/images/menubar icons/lock-3.png diff --git a/app/assets/images/menubar icons/lock-3@2x.png b/app/assets/images/menubar icons/lock-3@2x.png Binary files differnew file mode 100644 index 0000000000..d961af45f3 --- /dev/null +++ b/app/assets/images/menubar icons/lock-3@2x.png diff --git a/app/assets/images/menubar icons/lock-4.png b/app/assets/images/menubar icons/lock-4.png Binary files differnew file mode 100644 index 0000000000..6fcb60c663 --- /dev/null +++ b/app/assets/images/menubar icons/lock-4.png diff --git a/app/assets/images/menubar icons/lock-4@2x.png b/app/assets/images/menubar icons/lock-4@2x.png Binary files differnew file mode 100644 index 0000000000..f67b4c0921 --- /dev/null +++ b/app/assets/images/menubar icons/lock-4@2x.png diff --git a/app/assets/images/menubar icons/lock-5.png b/app/assets/images/menubar icons/lock-5.png Binary files differnew file mode 100644 index 0000000000..43d02ab1e0 --- /dev/null +++ b/app/assets/images/menubar icons/lock-5.png diff --git a/app/assets/images/menubar icons/lock-5@2x.png b/app/assets/images/menubar icons/lock-5@2x.png Binary files differnew file mode 100644 index 0000000000..1f05adf802 --- /dev/null +++ b/app/assets/images/menubar icons/lock-5@2x.png diff --git a/app/assets/images/menubar icons/lock-6.png b/app/assets/images/menubar icons/lock-6.png Binary files differnew file mode 100644 index 0000000000..e282ff8dad --- /dev/null +++ b/app/assets/images/menubar icons/lock-6.png diff --git a/app/assets/images/menubar icons/lock-6@2x.png b/app/assets/images/menubar icons/lock-6@2x.png Binary files differnew file mode 100644 index 0000000000..f76ab999f1 --- /dev/null +++ b/app/assets/images/menubar icons/lock-6@2x.png diff --git a/app/assets/images/menubar icons/lock-7.png b/app/assets/images/menubar icons/lock-7.png Binary files differnew file mode 100644 index 0000000000..1299817a53 --- /dev/null +++ b/app/assets/images/menubar icons/lock-7.png diff --git a/app/assets/images/menubar icons/lock-7@2x.png b/app/assets/images/menubar icons/lock-7@2x.png Binary files differnew file mode 100644 index 0000000000..f3a1428ad4 --- /dev/null +++ b/app/assets/images/menubar icons/lock-7@2x.png diff --git a/app/assets/images/menubar icons/lock-8.png b/app/assets/images/menubar icons/lock-8.png Binary files differnew file mode 100644 index 0000000000..161e4f5e82 --- /dev/null +++ b/app/assets/images/menubar icons/lock-8.png diff --git a/app/assets/images/menubar icons/lock-8@2x.png b/app/assets/images/menubar icons/lock-8@2x.png Binary files differnew file mode 100644 index 0000000000..5b12910e4a --- /dev/null +++ b/app/assets/images/menubar icons/lock-8@2x.png diff --git a/app/assets/images/menubar icons/lock-9.png b/app/assets/images/menubar icons/lock-9.png Binary files differnew file mode 100644 index 0000000000..c823aa87a1 --- /dev/null +++ b/app/assets/images/menubar icons/lock-9.png diff --git a/app/assets/images/menubar icons/lock-9@2x.png b/app/assets/images/menubar icons/lock-9@2x.png Binary files differnew file mode 100644 index 0000000000..e4dc28a192 --- /dev/null +++ b/app/assets/images/menubar icons/lock-9@2x.png diff --git a/app/components/Accordion.js b/app/components/Accordion.js new file mode 100644 index 0000000000..456a95fe05 --- /dev/null +++ b/app/components/Accordion.js @@ -0,0 +1,146 @@ +// @flow + +import React, { Component } from 'react'; + +export type AccordionProps = { + height?: number | string, + transitionStyle?: string, + children?: Array<React.Element<*>> | React.Element<*> // see https://github.com/facebook/flow/issues/1964 +}; + +export type AccordionState = { + computedHeight: ?number | ?string, +}; + +export default class Accordion extends Component { + props: AccordionProps; + static defaultProps: $Shape<AccordionProps> = { + height: 'auto', + transitionStyle: 'height 0.25s ease-in-out' + }; + + state: AccordionState = { + computedHeight: null, + }; + + _containerElement: ?HTMLElement; + _contentElement: ?HTMLElement; + + componentDidMount() { + const containerElement = this._containerElement; + if(!containerElement) { + throw new Error('containerElement cannot be null'); + } + + // update initial state + if(this.props.height !== Accordion.defaultProps.height) { + this._updateHeight(); + } + + containerElement.addEventListener('transitionend', this._onTransitionEnd); + } + + componentWillUnmount() { + const containerElement = this._containerElement; + if(!containerElement) { + throw new Error('containerElement cannot be null'); + } + containerElement.removeEventListener('transitionend', this._onTransitionEnd); + } + + componentDidUpdate(prevProps: AccordionProps, _prevState: AccordionState) { + if(prevProps.height !== this.props.height) { + (async () => { + const { transitionStyle } = this.props; + + // make sure to warm up CSS transition before updating height + // do not warm up transitions if they are not expected to run + if(transitionStyle && transitionStyle.toLowerCase() !== 'none') { + await this._warmupTransition(); + this._updateHeight(); + } else { + this._updateHeight(); + this._onTransitionEnd(); + } + + })(); + } + } + + render() { + const { height: _height, children, transitionStyle, ...otherProps } = this.props; + let style = { + transition: transitionStyle, + }; + + if(typeof(this.state.computedHeight) === 'number') { + style = { + ...style, + overflow: 'hidden', + height: this.state.computedHeight.toString() + 'px', + }; + } + + return ( + <div { ...otherProps } style={ style } ref={ this._onContainerRef }> + <div ref={ this._onContentRef }> + { children } + </div> + </div> + ); + } + + // Sets initial height and delays transition until next runloop + // to make sure CSS transitions properly kick in. + // This method resolves immediately if the height is already set. + _warmupTransition() { + const contentElement = this._contentElement; + if(!contentElement) { + throw new Error('contentElement cannot be null'); + } + return new Promise((resolve, _) => { + // CSS transition always needs the initial height + // to perform the animation + if(this.state.computedHeight === null) { + this.setState({ + computedHeight: contentElement.clientHeight + }, () => { + // important to skip a run loop + // for CSS transition to kick in + setTimeout(resolve, 0); + }); + } else { + resolve(); + } + }); + } + + _updateHeight() { + const contentElement = this._contentElement; + if(!contentElement) { + throw new Error('contentElement cannot be null'); + } + this.setState({ + computedHeight: this.props.height === 'auto' ? + contentElement.clientHeight : + this.props.height + }); + } + + _onTransitionEnd = () => { + // reset height after transition to let element layout naturally + if(this.props.height === 'auto') { + this.setState({ + computedHeight: null, + }); + } + } + + _onContainerRef = (element) => { + this._containerElement = element; + } + + _onContentRef = (element) => { + this._contentElement = element; + } +}
\ No newline at end of file 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..4ff99e13fd --- /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() { + 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.css b/app/components/AdvancedSettings.css new file mode 100644 index 0000000000..8f6c352bd7 --- /dev/null +++ b/app/components/AdvancedSettings.css @@ -0,0 +1,55 @@ +.advanced-settings__section-title { + background-color:rgb(41, 71, 115); + padding: 15px 24px; + font-family: DINPro; + font-size: 20px; + font-weight: 900; + line-height: 26px; + color: #fff; +} + +.advanced-settings__cell { + background-color:rgb(41, 71, 115); + padding: 15px 24px; + display: flex; + flex-direction: row; + align-items: center; +} + +.advanced-settings__cell--dimmed { + background-color: rgb(36, 57, 84); +} + +.advanced-settings__cell--dimmed:hover { + background-color: rgba(41, 71, 115, 0.9); +} + +.advanced-settings__cell--selected, +.advanced-settings__cell--selected:hover { + background-color: #44AD4D; +} + +.advanced-settings__cell--active:hover { + background-color: rgba(41, 71, 115, 0.9); +} + +.advanced-settings__section-title + .advanced-settings__cell, +.advanced-settings__cell + .advanced-settings__cell { + margin-top: 1px; +} + +.advanced-settings__cell-label { + font-family: DINPro; + font-size: 20px; + font-weight: 900; + line-height: 26px; + color: #FFFFFF; + flex: 1 0 auto; +} + +.advanced-settings__cell-icon { + width: 24px; + flex: 0 0 auto; + margin-right: 8px; + color: #fff; +}
\ No newline at end of file diff --git a/app/components/AdvancedSettings.js b/app/components/AdvancedSettings.js new file mode 100644 index 0000000000..56c5844346 --- /dev/null +++ b/app/components/AdvancedSettings.js @@ -0,0 +1,136 @@ +// @flow + +import React from 'react'; +import { Layout, Container, Header } from './Layout'; +import CustomScrollbars from './CustomScrollbars'; + +import TickSVG from '../assets/images/icon-tick.svg'; + +export class AdvancedSettings extends React.Component { + + props: { + protocol: string, + port: string | number, + onUpdate: (protocol: string, port: string | number) => void, + onClose: () => 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.onUpdate(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.onUpdate(protocol, port); + }} />; + } +} + + +class Selector extends React.Component { + + props: { + title: string, + values: Array<*>, + value: *, + onSelect: (*) => void, + } + + render() { + return <div> + <div className="advanced-settings__section-title"> + { this.props.title } + </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) { + return <div + key={ value } + className="advanced-settings__cell advanced-settings__cell--selected" + onClick={ () => this.props.onSelect(value) } > + <div className="advanced-settings__cell-icon"><TickSVG /></div> + <div className="advanced-settings__cell-label">{ value }</div> + </div>; + } + + _renderUnselectedCell(value) { + return <div + key={ value } + className="advanced-settings__cell advanced-settings__cell--dimmed" + onClick={ () => this.props.onSelect(value) }> + <div className="advanced-settings__cell-icon"></div> + <div className="advanced-settings__cell-label">{ value }</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"> + { props.children } + </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..41c3c61894 --- /dev/null +++ b/app/components/Connect.css @@ -0,0 +1,138 @@ +.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-label { + flex: 1 1 auto; + text-align: center; +} + +.connect__server-chevron { + flex: 0 0 auto; + width: 7px; + margin-left: -7px; /* let .connect__server-label extend to occupy the entire space */ +} + +.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: 94px; + 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..93ac19a4eb --- /dev/null +++ b/app/components/Connect.js @@ -0,0 +1,373 @@ +// @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 ChevronRightSVG from '../assets/images/icon-chevron.svg'; + +import type { HeaderBarStyle } from './HeaderBar'; +import type { ConnectionReduxState } from '../redux/connection/reducers'; +import type { SettingsReduxState } from '../redux/settings/reducers'; +import type { RelayLocation } from '../lib/ipc-facade'; + +export type ConnectProps = { + accountExpiry: string, + connection: ConnectionReduxState, + settings: SettingsReduxState, + onSettings: () => void, + onSelectLocation: () => void, + onConnect: () => void, + onCopyIP: () => void, + onDisconnect: () => void, + onExternalLink: (type: string) => void, +}; + + +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> + ); + } + + _findRelayName(relay: RelayLocation): ?string { + const countries = this.props.settings.relayLocations; + const countryPredicate = (countryCode) => (country) => country.code === countryCode; + + if(relay.country) { + const country = countries.find(countryPredicate(relay.country)); + if(country) { + return country.name; + } + } else if(relay.city) { + const [countryCode, cityCode] = relay.city; + const country = countries.find(countryPredicate(countryCode)); + if(country) { + const city = country.cities.find((city) => city.code === cityCode); + if(city) { + return city.name; + } + } + } + return null; + } + + _getLocationName(): string { + const { relaySettings } = this.props.settings; + if(relaySettings.normal) { + const location = relaySettings.normal.location; + if(location === 'any') { + return 'Automatic'; + } else { + return this._findRelayName(location) || 'Unknown'; + } + } else if(relaySettings.custom_tunnel_endpoint) { + return 'Custom'; + } else { + throw new Error('Unsupported relay settings.'); + } + } + + renderMap(): React.Element<*> { + let [ isConnecting, isConnected, isDisconnected ] = [false, false, false]; + switch(this.props.connection.status) { + case 'connecting': isConnecting = true; break; + case 'connected': isConnected = true; break; + case 'disconnected': isDisconnected = true; break; + } + + // 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 { + // TODO: remove empty IP placeholder when implemented in backend. + if(isDisconnected) { + ipComponent = (<span>{ '\u2003' }</span>); + } else { + ipComponent = (<span>{ this.props.connection.clientIp }</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 disconnected. + TODO: merge with the isConnecting block below when implemented in backend. + */ } + <If condition={ isDisconnected }> + <Then> + <div className="connect__status-location"> + <span>{ '\u2002' }</span> + </div> + </Then> + </If> + + { /* location when connecting */ } + <If condition={ isConnecting }> + <Then> + <div className="connect__status-location"> + <span>{ this.props.connection.country }</span> + </div> + </Then> + </If> + + { /* location when connected */ } + <If condition={ isConnected }> + <Then> + <div className="connect__status-location"> + { this.props.connection.city }<br/>{ this.props.connection.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"> + <button className="connect__server button button--neutral button--blur" onClick={ this.props.onSelectLocation }> + <div className="connect__server-label">{ this._getLocationName() }</div> + <div className="connect__server-chevron"><ChevronRightSVG /></div> + </button> + </div> + + <div className="connect__row"> + <button className="button button--positive" onClick={ this.props.onConnect }>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 + + 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..074c081209 --- /dev/null +++ b/app/components/CustomScrollbars.css @@ -0,0 +1,22 @@ +.custom-scrollbars { + display: flex; + flex-direction: column; + position: relative; +} + +.custom-scrollbars__scrollable { + width: 100%; + height: 100%; +} + +.custom-scrollbars__scrollable::-webkit-scrollbar { + display: none; +} + +.custom-scrollbars__thumb { + background-color: rgba(255, 255, 255, 0.2); + border-radius: 4px; + width: 8px; + transition: height 0.25s ease-in-out, opacity 0.25s ease-in-out; + pointer-events: none; +}
\ No newline at end of file diff --git a/app/components/CustomScrollbars.js b/app/components/CustomScrollbars.js new file mode 100644 index 0000000000..6d0ce96daa --- /dev/null +++ b/app/components/CustomScrollbars.js @@ -0,0 +1,133 @@ +// @flow +import React, { Component } from 'react'; + +type ScrollbarUpdateContext = { + size: boolean, + position: boolean, +}; + +export default class CustomScrollbars extends Component { + props: { + thumbInset: { x: number, y: number }, + children: ?React.Element<*>, + }; + + static defaultProps = { + thumbInset: { x: 2, y: 2 }, + }; + + _scrollableElement: ?HTMLElement; + _thumbElement: ?HTMLElement; + + componentDidMount() { + this._updateScrollbarsHelper({ + position: true, + size: true + }); + } + + componentDidUpdate() { + this._updateScrollbarsHelper({ + position: true, + size: true + }); + } + + render() { + return ( + <div className="custom-scrollbars"> + <div className="custom-scrollbars__thumb" + style={{ position: 'absolute', top: 0, right: 0 }} + ref={ this._onThumbRef }></div> + <div className="custom-scrollbars__scrollable" + style={{ overflow: 'auto' }} + onScroll={ this._onScroll } + ref={ this._onScrollableRef }> + { this.props.children } + </div> + </div> + ); + } + + + _onScrollableRef = (ref) => { + this._scrollableElement = ref; + } + + _onThumbRef = (ref) => { + this._thumbElement = ref; + } + + _onScroll = () => { + this._updateScrollbarsHelper({ position: true }); + } + + _computeThumbPosition(scrollable: HTMLElement, thumb: HTMLElement) { + // the content height of the scroll view + const scrollHeight = scrollable.scrollHeight; + + // the visible height of the scroll view + const visibleHeight = scrollable.offsetHeight; + + // scroll offset + const scrollTop = scrollable.scrollTop; + + // lowest point of scrollTop + const maxScrollTop = scrollHeight - visibleHeight; + + // calculate scroll position within 0..1 range + const scrollPosition = scrollHeight > 0 ? scrollTop / maxScrollTop : 0; + + const thumbHeight = thumb.clientHeight; + + // calculate the thumb boundary to make sure that the visual appearance of + // a thumb at lowest point matches the bottom of scrollable view + const thumbBoundary = visibleHeight - thumbHeight - (this.props.thumbInset.y * 2); + + // calculate thumb position based on scroll progress and thumb boundary + // adding vertical inset to adjust the thumb's appearance + const thumbPosition = (thumbBoundary * scrollPosition) + this.props.thumbInset.y; + + return { + x: -this.props.thumbInset.x, + y: thumbPosition, + }; + } + + _computeThumbHeight(scrollable: HTMLElement) { + const scrollHeight = scrollable.scrollHeight; + const visibleHeight = scrollable.offsetHeight; + + const thumbHeight = (visibleHeight / scrollHeight) * visibleHeight; + + // ensure that the scroll thumb doesn't shrink to nano size + return Math.max(thumbHeight, 8); + } + + _updateScrollbarsHelper(updateFlags: $Shape<ScrollbarUpdateContext>) { + const scrollable = this._scrollableElement; + const thumb = this._thumbElement; + if(scrollable && thumb) { + this._updateScrollbars(scrollable, thumb, updateFlags); + } + } + + _updateScrollbars(scrollable: HTMLElement, thumb: HTMLElement, context: $Shape<ScrollbarUpdateContext>) { + if(context.size) { + const thumbHeight = this._computeThumbHeight(scrollable); + thumb.style.setProperty('height', thumbHeight + 'px'); + + // hide thumb when there is nothing to scroll + if(thumbHeight < scrollable.offsetHeight) { + thumb.style.setProperty('opacity', '1'); + } else { + thumb.style.setProperty('opacity', '0'); + } + } + + if(context.position) { + const { x, y } = this._computeThumbPosition(scrollable, thumb); + thumb.style.setProperty('transform', `translate(${x}px, ${y}px)`); + } + } +} diff --git a/app/components/HeaderBar.js b/app/components/HeaderBar.js new file mode 100644 index 0000000000..c2e02cc35c --- /dev/null +++ b/app/components/HeaderBar.js @@ -0,0 +1,61 @@ +// @flow +import React from 'react'; +import { + Component, + Text, + Button, + View +} from 'reactxp'; + +import Img from './Img'; + +import styles from './HeaderBarStyles'; + +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() { + let containerClass = [ + styles['headerbar'], + styles['headerbar__' + process.platform], + styles['headerbar__style_' + this.props.style] + ]; + + if(this.props.hidden) { + containerClass.push(styles['headerbar__hidden']); + } + + return ( + <View style={ containerClass }> + {!this.props.hidden ? + <View style={styles.headerbar__container} testName="headerbar__container"> + <Img style={ styles.headerbar__logo } source='logo-icon'/> + <Text style={styles.headerbar__title}>MULLVAD VPN</Text> + </View> + : null} + + {this.props.showSettings ? + <View style={styles.headerbar__settings}> + <Button onPress={ this.props.onSettings } testName="headerbar__settings"> + <Img style={ styles.headerbar__settings } source='icon-settings'/> + </Button> + </View> + : null} + </View> + ); + } +} diff --git a/app/components/HeaderBarStyles.js b/app/components/HeaderBarStyles.js new file mode 100644 index 0000000000..5e80f4f635 --- /dev/null +++ b/app/components/HeaderBarStyles.js @@ -0,0 +1,69 @@ +// @flow +import { Styles } from 'reactxp'; + +const styles = { + headerbar: + Styles.createViewStyle({ + paddingTop: 12, + paddingBottom: 12, + paddingLeft: 12, + paddingRight: 12, + backgroundColor: '#294D73', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }), + headerbar__hidden: + Styles.createViewStyle({ + paddingTop: 24, + paddingBottom: 0, + paddingLeft: 0, + paddingRight: 0, + }), + headerbar__darwin: Styles.createViewStyle({ + paddingTop: 24, + }), + headerbar__style_defaultDark: + Styles.createViewStyle({ + backgroundColor: '#192E45', + }), + headerbar__style_error: + Styles.createViewStyle({ + backgroundColor: '#D0021B', + }), + headerbar__style_success: + Styles.createViewStyle({ + backgroundColor: '#44AD4D', + }), + headerbar__container: + Styles.createViewStyle({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }), + headerbar__title: + Styles.createTextStyle({ + fontFamily: 'DINPro', + fontSize: 24, + fontWeight: '900', + lineHeight: 30, + letterSpacing: -0.5, + color: 'rgba(255,255,255,0.6)', + marginLeft: 8, + }), + headerbar__logo: + Styles.createViewStyle({ + height: 50, + width: 50, + }), + headerbar__settings: + Styles.createViewStyle({ + width: 24, + height: 24, + backgroundColor: 'transparent', + marginLeft: -6, //Because of button.css, when removed remove this + marginTop: -1, //Because of button.css, when removed remove this + }) +}; + +module.exports = styles; diff --git a/app/components/Img.android.js b/app/components/Img.android.js new file mode 100644 index 0000000000..303fe4e5cb --- /dev/null +++ b/app/components/Img.android.js @@ -0,0 +1,9 @@ +// @flow +import React from 'react'; +import { Image, Component } from 'reactxp'; + +export default class Img extends Component { + render(){ + return (<Image style={ this.props.style } source={ this.props.source }/>); + } +} diff --git a/app/components/Img.js b/app/components/Img.js new file mode 100644 index 0000000000..0c687ff654 --- /dev/null +++ b/app/components/Img.js @@ -0,0 +1,13 @@ +// @flow +import React from 'react'; +import { View, Component } from 'reactxp'; + +export default class Img extends Component { + render() { + const url = './assets/images/' + this.props.source + '.svg'; + + const style = this.props.style; + + return (<View style={ style }> <img src={ url } /> </View>); + } +} 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..9311212ec9 --- /dev/null +++ b/app/components/Login.css @@ -0,0 +1,211 @@ +.login { + height: 100%; + display: flex; + flex-direction: column; +} + +.login-footer { + background-color: #192E45; + padding: 18px 24px 24px; + flex: 0 0 auto; + transition: transform 0.25s ease-in-out; +} + +.login-footer--invisible { + transform: translateY(100%); +} + +.login-form__status-icon { + flex: 0 0 auto; /* never collapse or grow */ + text-align: center; + margin-bottom: 44px; + + /* use fixed size to make space and avoid jitter when changing <img> visibility */ + height: 60px; +} + +.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: 83px 0 auto; +} + +.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__account-input-container { + /* + Provides an anchor for input-container + to be properly positioned when it gains absolute + position to overlap the footer view. + */ + position: relative; + + /* push border to the outer side of the layout grid */ + margin-left: -2px; + margin-right: -2px; +} + +.login-form__account-input-group { + border: 2px solid transparent; + border-radius: 8px; + transition: border 0.25s ease-in-out; + overflow: hidden; +} + +.login-form__account-input-group--active { + position: absolute; + border-color: rgba(25,46,69,0.4); +} + +.login-form__account-input-group--inactive { + opacity: 0.6; +} + +.login-form__account-input-group--error { + border-color: rgba(208,2,27,0.4); +} + +.login-form__account-input-group--error .login-form__account-input-textfield { + color: #D0021B; +} + +.login-form__account-input-backdrop { + background-color: #FFFFFF; + display: flex; + flex-direction: row; + transition: background-color 0.25s ease-in-out; +} + +.login-form__account-input-textfield::-webkit-input-placeholder { + color: rgba(41,77,115,0.4); +} + +.login-form__account-input-textfield { + width: 100%; + border: 0; + 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__account-input-textfield--inactive { + background-color: rgba(255,255,255,0.6); +} + +.login-form__account-input-button { + 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__account-input-button-icon { + display: inline-block; + vertical-align: middle; +} + +.login-form__account-input-button-icon path { + fill: rgba(41,77,115,0.2); + transition: fill 0.3s ease-in-out; +} + +.login-form__account-input-button--active { + background-color: #44ad4d; +} + +.login-form__account-input-button--active .login-form__account-input-button-icon path { + fill: #fff; +} + +.login-form__account-input-button--active:hover { + background-color: rgba(68, 173, 76, 0.9); +} + +.login-form__account-input-button--invisible { + visibility: hidden; + opacity: 0; +} + +.login-form__account-dropdown-container { + overflow: hidden; + transition: height 0.25s ease-in-out; +} + +.login-form__account-dropdown__item { + display: flex; + flex-direction: row; + border-top: 1px solid rgba(25,46,69,0.4); + background-color: rgba(255,255,255,0.6); + background-clip: padding-box; + transition: background-color 0.25s ease-in-out; +} + +.login-form__account-dropdown__item:hover { + background-color: rgba(255,255,255,0.65); +} + +.login-form__account-dropdown__label { + flex: 1 1 auto; + font-family: DINPro; + font-size: 20px; + font-weight: 900; + line-height: 26px; + color: #294D73; + border: 0; + background: transparent; + padding: 10px 12px 12px 12px; + text-align: left; +} + +.login-form__account-dropdown__remove { + background: transparent; + border: 0; + padding: 10px 12px 12px 12px; + + /* center SVG within button */ + display: flex; + justify-content: center; +} + +.login-form__account-dropdown__remove path { + transition: fill-opacity 0.25s ease-in-out; +} + +.login-form__account-dropdown__remove:hover path { + fill-opacity: 1; +}
\ No newline at end of file diff --git a/app/components/Login.js b/app/components/Login.js new file mode 100644 index 0000000000..c133954eed --- /dev/null +++ b/app/components/Login.js @@ -0,0 +1,339 @@ +// @flow +import React, { Component } from 'react'; +import { Layout, Container, Header } from './Layout'; +import AccountInput from './AccountInput'; +import { formatAccount } from '../lib/formatters'; +import ExternalLinkSVG from '../assets/images/icon-extLink.svg'; +import LoginArrowSVG from '../assets/images/icon-arrow.svg'; +import RemoveAccountSVG from '../assets/images/icon-close-sml.svg'; + +import type { AccountReduxState } from '../redux/account/reducers'; +import type { AccountToken } from '../lib/ipc-facade'; + +export type LoginPropTypes = { + account: AccountReduxState, + onLogin: (accountToken: AccountToken) => void, + onSettings: ?(() => void), + onFirstChangeAfterFailure: () => void, + onExternalLink: (type: string) => void, + onAccountTokenChange: (accountToken: AccountToken) => void, + onRemoveAccountTokenFromHistory: (accountToken: AccountToken) => void, +}; + +export default class Login extends Component { + props: LoginPropTypes; + state = { + notifyOnFirstChangeAfterFailure: false, + isActive: false, + dropdownHeight: 0 + }; + + constructor(props: LoginPropTypes) { + super(props); + if(props.account.status === 'failed') { + this.state.notifyOnFirstChangeAfterFailure = true; + } + } + + componentDidMount() { + this._updateDropdownHeight(); + } + + componentDidUpdate() { + this._updateDropdownHeight(); + } + + 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() { + const footerClass = this._shouldShowFooter() ? '' : 'login-footer--invisible'; + return ( + <Layout> + <Header showSettings={ true } onSettings={ this.props.onSettings } /> + <Container> + <div className="login"> + <div className="login-form"> + { this._getStatusIcon() } + + <div className="login-form__title">{ this._formTitle() }</div> + + {this._shouldShowLoginForm() && <div className='login-form__fields'> + { this._createLoginForm() } + </div>} + </div> + + <div className={ 'login-footer ' + footerClass }> + { this._createFooter() } + </div> + </div> + </Container> + </Layout> + ); + } + + _onCreateAccount = () => this.props.onExternalLink('createAccount'); + _onFocus = () => this.setState({ isActive: true }); + _onBlur = (e) => { + const relatedTarget = e.relatedTarget; + + // restore focus if click happened within dropdown + if(relatedTarget && this._isWithinDropdown(relatedTarget)) { + e.target.focus(); + return; + } + + this.setState({ isActive: false }); + } + + _onLogin = () => { + const accountToken = this.props.account.accountToken; + if(accountToken && accountToken.length > 0) { + this.props.onLogin(accountToken); + } + } + + _onInputChange = (value: string) => { + // notify delegate on first change after login failure + if(this.state.notifyOnFirstChangeAfterFailure) { + this.setState({ notifyOnFirstChangeAfterFailure: false }); + this.props.onFirstChangeAfterFailure(); + } + this.props.onAccountTokenChange(value); + } + + _formTitle() { + switch(this.props.account.status) { + case 'logging in': + return 'Logging in...'; + case 'failed': + return 'Login failed'; + case 'ok': + return 'Login successful'; + default: + return 'Login'; + } + } + + _formSubtitle() { + const { status, error } = this.props.account; + switch(status) { + case 'failed': + return (error && error.message) || 'Unknown error'; + case 'logging in': + return 'Checking account number'; + default: + return 'Enter your account number'; + } + } + + _getStatusIcon() { + const statusIconPath = this._getStatusIconPath(); + return <div className="login-form__status-icon"> + { statusIconPath ? + <img src={ statusIconPath } alt="" /> : + null } + </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; + } + } + + _accountInputGroupClass(): string { + const classes = ['login-form__account-input-group']; + if(this.state.isActive) { + classes.push('login-form__account-input-group--active'); + } + + switch(this.props.account.status) { + case 'logging in': + classes.push('login-form__account-input-group--inactive'); + break; + case 'failed': + classes.push('login-form__account-input-group--error'); + break; + } + + return classes.join(' '); + } + + _accountInputButtonClass(): string { + const { accountToken, status } = this.props.account; + const classes = ['login-form__account-input-button']; + + if(accountToken && accountToken.length > 0) { + classes.push('login-form__account-input-button--active'); + } + + if(status === 'logging in') { + classes.push('login-form__account-input-button--invisible'); + } + + return classes.join(' '); + } + + _shouldEnableAccountInput() { + // enable account input always except when "logging in" + return this.props.account.status !== 'logging in'; + } + + _shouldShowAccountHistory() { + return this._shouldEnableAccountInput() && + this.state.isActive && + this.props.account.accountHistory.length > 0; + } + + _shouldShowLoginForm() { + return this.props.account.status !== 'ok'; + } + + _shouldShowFooter() { + const { status } = this.props.account; + return (status === 'none' || status === 'failed') && !this._shouldShowAccountHistory(); + } + + // helper function to calculate and save dropdown element's height + // this is a no-op of the height didn't change since last update + _updateDropdownHeight() { + const element = this._accountDropdownElement; + if(element && this.state.dropdownHeight !== element.clientHeight) { + this.setState({ + dropdownHeight: element.clientHeight + }); + } + } + + // returns true if DOM node is within dropdown hierarchy + _isWithinDropdown(relatedTarget) { + const dropdownElement = this._accountDropdownElement; + return dropdownElement && dropdownElement.contains(relatedTarget); + } + + // container element used for measuring the height of the accounts dropdown + _accountDropdownElement: ?HTMLElement; + _onAccountDropdownContainerRef = ref => this._accountDropdownElement = ref; + + _onSelectAccountFromHistory = (accountToken) => { + this.props.onAccountTokenChange(accountToken); + this.props.onLogin(accountToken); + } + + _createLoginForm() { + const { accountHistory, accountToken } = this.props.account; + const dropdownStyles = { + height: this._shouldShowAccountHistory() ? this.state.dropdownHeight : 0 + }; + + // auto-focus on account input when failed to log in + // do not refactor this into instance method, + // it has to be new function each time to be called on each render + const autoFocusOnFailure = (input) => { + if(this.props.account.status === 'failed' && input) { + input.focus(); + } + }; + + return <div> + <div className="login-form__subtitle">{ this._formSubtitle() }</div> + <div className="login-form__account-input-container"> + <div className={ this._accountInputGroupClass() }> + <div className="login-form__account-input-backdrop"> + <AccountInput className="login-form__account-input-textfield" + type="text" + placeholder="e.g 0000 0000 0000" + onFocus={ this._onFocus } + onBlur={ this._onBlur } + onChange={ this._onInputChange } + onEnter={ this._onLogin } + value={ accountToken || '' } + disabled={ !this._shouldEnableAccountInput() } + autoFocus={ true } + ref={ autoFocusOnFailure } /> + <button className={ this._accountInputButtonClass() } onClick={ this._onLogin }> + <LoginArrowSVG className="login-form__account-input-button-icon" /> + </button> + </div> + <div style={ dropdownStyles } className="login-form__account-dropdown-container"> + <div ref={ this._onAccountDropdownContainerRef }> + { <AccountDropdown + items={ accountHistory.slice().reverse() } + onSelect={ this._onSelectAccountFromHistory } + onRemove={ this.props.onRemoveAccountTokenFromHistory } /> } + </div> + </div> + </div> + </div> + </div>; + } + + _createFooter() { + 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>; + } +} + +class AccountDropdown extends Component { + props: { + items: Array<AccountToken>, + onSelect: ((value: AccountToken) => void), + onRemove: ((value: AccountToken) => void) + }; + + render() { + const uniqueItems = [...new Set(this.props.items)]; + return ( + <div className="login-form__account-dropdown"> + { uniqueItems.map(token => ( + <AccountDropdownItem key={ token } + value={ token } + label={ formatAccount(token) } + onSelect={ this.props.onSelect } + onRemove={ this.props.onRemove } /> + )) } + </div> + ); + } +} + +class AccountDropdownItem extends Component { + props: { + label: string, + value: AccountToken, + onRemove: (value: AccountToken) => void, + onSelect: (value: AccountToken) => void + }; + + render() { + return ( + <div className="login-form__account-dropdown__item"> + <button className="login-form__account-dropdown__label" + onClick={ () => this.props.onSelect(this.props.value) }>{ this.props.label }</button> + <button className="login-form__account-dropdown__remove" + onClick={ () => this.props.onRemove(this.props.value) }> + <RemoveAccountSVG /> + </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..7e107f237f --- /dev/null +++ b/app/components/SelectLocation.css @@ -0,0 +1,146 @@ +.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__cell { + background-color: rgba(41,71,115,1); + display: flex; + flex-direction: row; + align-items: stretch; +} + +.select-location__cell-content { + padding: 15px 24px; + display: flex; + flex: 1 1 auto; + flex-direction: row; + align-items: center; +} + +.select-location__cell--selectable:hover { + background-color:rgba(41,71,115,0.9); +} + +.select-location__cell--selected, +.select-location__cell--selected:hover { + background-color: #44AD4D; +} + +.select-location__country + .select-location__country { + margin-top: 1px; +} + +.select-location__cell-label { + font-family: DINPro; + font-size: 20px; + font-weight: 900; + line-height: 26px; + color: #FFFFFF; +} + +.select-location__cell-label--inactive { + color: rgba(255, 255, 255, 0.2); +} + +.select-location__cell-icon { + width: 24px; + height: 24px; + flex: 0 0 auto; + margin-right: 8px; + align-items: center; + justify-content: center; + display: flex; + color: #fff; +} + +.select-location__collapse-button { + border: 0; + background: transparent; + padding: 0; + margin: 0 0 0 auto; + display: flex; + align-items: stretch; + padding: 12px; +} + +.select-location__collapse-icon { + color: #fff; +} + +.select-location__sub-cell { + background-color: rgb(36, 57, 84); + padding: 15px 24px 15px 40px; + display: flex; + flex-direction: row; + align-items: center; + margin-top: 1px; +} + +.select-location__sub-cell--selectable:hover { + background-color: rgba(41,71,115,0.9); +} + +.select-location__sub-cell--selected, +.select-location__sub-cell--selected:hover { + background-color: #44AD4D; +} + +.select-location-relay-status { + width: 16px; + height: 16px; + border-radius: 8px; +} + +.select-location-relay-status--inactive { + background: rgba(208, 2, 27, 0.95); +} + +.select-location-relay-status--active { + background: rgba(68, 173, 77, 0.9); +}
\ No newline at end of file diff --git a/app/components/SelectLocation.js b/app/components/SelectLocation.js new file mode 100644 index 0000000000..7fb124bf33 --- /dev/null +++ b/app/components/SelectLocation.js @@ -0,0 +1,225 @@ +// @flow +import React, { Component } from 'react'; +import { Layout, Container, Header } from './Layout'; +import CustomScrollbars from './CustomScrollbars'; + +import Accordion from './Accordion'; +import ChevronDownSVG from '../assets/images/icon-chevron-down.svg'; +import ChevronUpSVG from '../assets/images/icon-chevron-up.svg'; +import TickSVG from '../assets/images/icon-tick.svg'; + +import type { SettingsReduxState, RelayLocationRedux, RelayLocationCityRedux } from '../redux/settings/reducers'; +import type { RelayLocation } from '../lib/ipc-facade'; + +export type SelectLocationProps = { + settings: SettingsReduxState, + onClose: () => void; + onSelect: (location: RelayLocation) => void; +}; + +export default class SelectLocation extends Component { + props: SelectLocationProps; + _selectedCell: ?HTMLElement; + + state = { + expanded: ([]: Array<string>), + }; + + constructor(props: SelectLocationProps, context?: any) { + super(props, context); + + // set initially expanded country based on relaySettings + const relaySettings = this.props.settings.relaySettings; + if(relaySettings.normal) { + const { location } = relaySettings.normal; + if(location === 'any') { + // no-op + } else if(location.country) { + this.state.expanded.push(location.country); + } else if(location.city) { + this.state.expanded.push(location.city[0]); + } + } + } + + 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() { + 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> + + { this.props.settings.relayLocations.map((relayCountry) => { + return this._renderCountry(relayCountry); + }) } + + </div> + </CustomScrollbars> + </div> + </div> + </Container> + </Layout> + ); + } + + _isSelected(selectedLocation: RelayLocation) { + const { relaySettings } = this.props.settings; + if(relaySettings.normal) { + const otherLocation = relaySettings.normal.location; + + if(selectedLocation.country && otherLocation.country && + selectedLocation.country === otherLocation.country) { + return true; + } + + if(Array.isArray(selectedLocation.city) && Array.isArray(otherLocation.city)) { + const selectedCity = selectedLocation.city; + const otherCity = otherLocation.city; + + return selectedCity.length === otherCity.length && + selectedCity.every((v, i) => v === otherCity[i]); + } + } + return false; + } + + _toggleCollapse = (countryCode: string) => { + this.setState((state) => { + const expanded = state.expanded.slice(); + const index = expanded.indexOf(countryCode); + if(index === -1) { + expanded.push(countryCode); + } else { + expanded.splice(index, 1); + } + return { expanded }; + }); + } + + _relayStatusIndicator(active: boolean) { + const statusClass = active ? 'select-location-relay-status--active' : 'select-location-relay-status--inactive'; + + return (<div className={ 'select-location-relay-status ' + statusClass }></div>); + } + + _renderCountry(relayCountry: RelayLocationRedux) { + const isSelected = this._isSelected({ country: relayCountry.code }); + + // either expanded by user or when the city selected within the country + const isExpanded = this.state.expanded.includes(relayCountry.code); + + const handleSelect = (relayCountry.hasActiveRelays && !isSelected) ? () => { + this.props.onSelect({ country: relayCountry.code }); + } : undefined; + + const handleCollapse = (e) => { + this._toggleCollapse(relayCountry.code); + e.stopPropagation(); + }; + + const countryClass = 'select-location__cell' + + (isSelected ? ' select-location__cell--selected' : '') + + (relayCountry.hasActiveRelays ? ' select-location__cell--selectable' : ''); + + const onRef = isSelected ? (element) => { + this._selectedCell = element; + } : undefined; + + return ( + <div key={ relayCountry.code } className="select-location__country"> + <div className={ countryClass } + onClick={ handleSelect } + ref={ onRef }> + <div className="select-location__cell-content"> + + <div className="select-location__cell-icon"> + { isSelected ? + <TickSVG /> : + this._relayStatusIndicator(relayCountry.hasActiveRelays) } + </div> + + <div className={ 'select-location__cell-label' + + (relayCountry.hasActiveRelays ? '' : ' select-location__cell-label--inactive') }> + { relayCountry.name } + </div> + </div> + + { relayCountry.hasActiveRelays && <button type="button" className="select-location__collapse-button" onClick={ handleCollapse }> + { isExpanded ? + <ChevronUpSVG className="select-location__collapse-icon" /> : + <ChevronDownSVG className="select-location__collapse-icon" /> } + </button> } + + </div> + + { relayCountry.hasActiveRelays && relayCountry.cities.length > 0 && + (<Accordion className="select-location__cities" height={ isExpanded ? 'auto' : 0 }> + { relayCountry.cities.map((relayCity) => this._renderCity(relayCountry.code, relayCity)) } + </Accordion>) + } + </div> + ); + } + + _renderCity(countryCode: string, relayCity: RelayLocationCityRedux) { + const relayLocation: RelayLocation = { city: [countryCode, relayCity.code] }; + + const isSelected = this._isSelected(relayLocation); + + const cityClass = 'select-location__sub-cell' + + (isSelected ? ' select-location__sub-cell--selected' : '') + + (relayCity.hasActiveRelays ? ' select-location__sub-cell--selectable' : ''); + + const handleSelect = (relayCity.hasActiveRelays && !isSelected) ? () => { + this.props.onSelect(relayLocation); + } : undefined; + + const onRef = isSelected ? (element) => { + this._selectedCell = element; + } : undefined; + + return ( + <div key={ `${countryCode}_${relayCity.code}` } + className={ cityClass } + onClick={ handleSelect } + ref={ onRef }> + + <div className="select-location__cell-icon"> + { isSelected ? + <TickSVG /> : + this._relayStatusIndicator(relayCity.hasActiveRelays) } + </div> + + <div className={ 'select-location__cell-label' + + (relayCity.hasActiveRelays ? '' : ' select-location__cell-label--inactive') }> + { relayCity.name } + </div> + </div> + ); + } + +} diff --git a/app/components/Settings.css b/app/components/Settings.css new file mode 100644 index 0000000000..9e49c814f9 --- /dev/null +++ b/app/components/Settings.css @@ -0,0 +1,125 @@ +.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; + color: rgba(255, 255, 255, 0.8); +} + +.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; + color: rgba(255, 255, 255, 0.8); +} + +.settings__cell-value { + flex: 0 0 auto; +} + +.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..492b2bd403 --- /dev/null +++ b/app/components/Settings.js @@ -0,0 +1,120 @@ +// @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 ChevronRightSVG from '../assets/images/icon-chevron.svg'; +import ExternalLinkSVG from '../assets/images/icon-extLink.svg'; + +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> + <div className="settings__cell-disclosure"><ChevronRightSVG /></div> + </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"> + <div className="settings__cell-disclosure"><ChevronRightSVG /></div> + </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> + <div className="settings__cell-icon"><ExternalLinkSVG /></div> + </div> + <div className="settings__cell settings__cell--active" onClick={ this.props.onExternalLink.bind(this, 'guides') }> + <div className="settings__cell-label">Guides</div> + <div className="settings__cell-icon"><ExternalLinkSVG /></div> + </div> + <div className="settings__view-support settings__cell settings__cell--active" onClick={ this.props.onViewSupport }> + <div className="settings__cell-label">Report a problem</div> + <div className="settings__cell-disclosure"><ChevronRightSVG /></div> + </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..2c6b820a40 --- /dev/null +++ b/app/components/WindowChrome.css @@ -0,0 +1,13 @@ +/* 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; +} + +.window-chrome { + flex: 1 1 auto; + height: 100%; + width: 100%; + display: flex; +}
\ 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..b897b5da4a --- /dev/null +++ b/app/config.json @@ -0,0 +1,13 @@ +{ + "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" + } +} diff --git a/app/containers/AccountPage.js b/app/containers/AccountPage.js new file mode 100644 index 0000000000..4ef7de5d83 --- /dev/null +++ b/app/containers/AccountPage.js @@ -0,0 +1,29 @@ +// @flow + +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 { links } from '../config'; +import { open } from '../lib/platform'; + +import type { ReduxState, ReduxDispatch } from '../redux/store'; +import type { SharedRouteProps } from '../routes'; + +const mapStateToProps = (state: ReduxState) => state; +const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => { + const { push: pushHistory } = bindActionCreators({ push }, dispatch); + const { logout } = bindActionCreators(accountActions, dispatch); + return { + onLogout: () => { + logout(props.backend); + }, + onClose: () => { + pushHistory('/settings'); + }, + onBuyMore: () => open(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..6f456abcea --- /dev/null +++ b/app/containers/AdvancedSettingsPage.js @@ -0,0 +1,58 @@ +// @flow + +import { connect } from 'react-redux'; +import { push } from 'react-router-redux'; +import { AdvancedSettings } from '../components/AdvancedSettings'; +import RelaySettingsBuilder from '../lib/relay-settings-builder'; +import log from 'electron-log'; + +import type { ReduxState, ReduxDispatch } from '../redux/store'; +import type { SharedRouteProps } from '../routes'; + +const mapStateToProps = (state: ReduxState) => { + const relaySettings = state.settings.relaySettings; + if(relaySettings.normal) { + const { protocol, port } = relaySettings.normal; + return { + protocol: protocol === 'any' ? 'Automatic' : protocol, + port: port === 'any' ? 'Automatic' : port, + }; + } else if(relaySettings.custom_tunnel_endpoint) { + const { protocol, port } = relaySettings.custom_tunnel_endpoint; + return { protocol, port }; + } else { + throw new Error('Unknown type of relay settings.'); + } +}; + +const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => { + const { backend } = props; + return { + onClose: () => dispatch(push('/settings')), + + onUpdate: async (protocol, port) => { + const relayUpdate = RelaySettingsBuilder.normal() + .tunnel.openvpn((openvpn) => { + if(protocol === 'Automatic') { + openvpn.protocol.any(); + } else { + openvpn.protocol.exact(protocol.toLowerCase()); + } + if(port === 'Automatic') { + openvpn.port.any(); + } else { + openvpn.port.exact(port); + } + }).build(); + + try { + await backend.updateRelaySettings(relayUpdate); + await backend.fetchRelaySettings(); + } catch(e) { + log.error('Failed to update relay settings', 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..6bcfc40cfe --- /dev/null +++ b/app/containers/ConnectPage.js @@ -0,0 +1,47 @@ +// @flow + +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { push } from 'react-router-redux'; +import { links } from '../config'; +import Connect from '../components/Connect'; +import connectActions from '../redux/connection/actions'; +import { open } from '../lib/platform'; + +import type { ReduxState, ReduxDispatch } from '../redux/store'; +import type { SharedRouteProps } from '../routes'; + +const mapStateToProps = (state: ReduxState) => { + return { + accountExpiry: state.account.expiry, + connection: state.connection, + settings: state.settings, + }; +}; + +const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => { + const { connect, disconnect, copyIPAddress } = bindActionCreators(connectActions, dispatch); + const { push: pushHistory } = bindActionCreators({ push }, dispatch); + const { backend } = props; + + return { + onSettings: () => { + pushHistory('/settings'); + }, + onSelectLocation: () => { + pushHistory('/select-location'); + }, + onConnect: () => { + connect(backend); + }, + onCopyIP: () => { + copyIPAddress(); + }, + onDisconnect: () => { + disconnect(backend); + }, + onExternalLink: (type) => open(links[type]), + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(Connect); diff --git a/app/containers/LoginPage.js b/app/containers/LoginPage.js new file mode 100644 index 0000000000..bf1a7fdc70 --- /dev/null +++ b/app/containers/LoginPage.js @@ -0,0 +1,37 @@ +// @flow + +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'; +import { open } from '../lib/platform'; + +import type { ReduxState, ReduxDispatch } from '../redux/store'; +import type { SharedRouteProps } from '../routes'; + +const mapStateToProps = (state: ReduxState) => state; +const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => { + const { push: pushHistory } = bindActionCreators({ push }, dispatch); + const { login, resetLoginError, updateAccountToken } = bindActionCreators(accountActions, dispatch); + const { backend } = props; + return { + onSettings: () => { + pushHistory('/settings'); + }, + onLogin: (account) => { + login(backend, account); + }, + onFirstChangeAfterFailure: () => { + resetLoginError(); + }, + onExternalLink: (type) => open(links[type]), + onAccountTokenChange: (token) => { + updateAccountToken(token); + }, + onRemoveAccountTokenFromHistory: (token) => backend.removeAccountFromHistory(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..ae06ec7fd6 --- /dev/null +++ b/app/containers/SelectLocationPage.js @@ -0,0 +1,38 @@ +// @flow + +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { push } from 'react-router-redux'; +import SelectLocation from '../components/SelectLocation'; +import RelaySettingsBuilder from '../lib/relay-settings-builder'; +import log from 'electron-log'; + +import type { ReduxState, ReduxDispatch } from '../redux/store'; +import type { SharedRouteProps } from '../routes'; + +const mapStateToProps = (state: ReduxState) => state; +const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => { + const { push: pushHistory } = bindActionCreators({ push }, dispatch); + const { backend } = props; + return { + onClose: () => pushHistory('/connect'), + onSelect: async (relayLocation) => { + try { + const relayUpdate = RelaySettingsBuilder.normal() + .location + .fromRaw(relayLocation) + .build(); + + await backend.updateRelaySettings(relayUpdate); + await backend.fetchRelaySettings(); + await backend.connect(); + + pushHistory('/connect'); + } catch (e) { + log.error('Failed to select server: ', e.message); + } + } + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(SelectLocation); diff --git a/app/containers/SettingsPage.js b/app/containers/SettingsPage.js new file mode 100644 index 0000000000..938d306fee --- /dev/null +++ b/app/containers/SettingsPage.js @@ -0,0 +1,26 @@ +// @flow + +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { push } from 'react-router-redux'; +import Settings from '../components/Settings'; +import { links } from '../config'; +import { open, exit } from '../lib/platform'; + +import type { ReduxState, ReduxDispatch } from '../redux/store'; +import type { SharedRouteProps } from '../routes'; + +const mapStateToProps = (state: ReduxState) => state; +const mapDispatchToProps = (dispatch: ReduxDispatch, _props: SharedRouteProps) => { + const { push: pushHistory } = bindActionCreators({ push }, dispatch); + return { + onQuit: () => exit(), + onClose: () => pushHistory('/connect'), + onViewAccount: () => pushHistory('/settings/account'), + onViewSupport: () => pushHistory('/settings/support'), + onViewAdvancedSettings: () => pushHistory('/settings/advanced'), + onExternalLink: (type) => open(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..d482c94637 --- /dev/null +++ b/app/containers/SupportPage.js @@ -0,0 +1,95 @@ +// @flow + +import log from 'electron-log'; +import { shell, ipcRenderer } from 'electron'; +import { connect } from 'react-redux'; +import { bindActionCreators } from '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'; + +import type { ReduxState, ReduxDispatch } from '../redux/store'; +import type { SharedRouteProps } from '../routes'; + +const mapStateToProps = (state: ReduxState) => 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(promise) { + if(err) { + promise.reject(err); + } else { + promise.resolve(reportId); + } + } +}); + +const mapDispatchToProps = (dispatch: ReduxDispatch, _props: SharedRouteProps) => { + const { push: pushHistory } = bindActionCreators({ push }, dispatch); + + return { + onClose: () => pushHistory('/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..e56209a20f --- /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" class="app-container"></div> + <script src="./index.js"></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/index.js b/app/index.js new file mode 100644 index 0000000000..3f17282c34 --- /dev/null +++ b/app/index.js @@ -0,0 +1,7 @@ +import React from 'react'; +import RX from 'reactxp'; +import App from './app'; + +RX.App.initialize(true, true); +RX.UserInterface.setMainView(<App />); +RX.UserInterface.useCustomScrollbars(true); diff --git a/app/lib/backend.js b/app/lib/backend.js new file mode 100644 index 0000000000..f78d359926 --- /dev/null +++ b/app/lib/backend.js @@ -0,0 +1,476 @@ +// @flow + +import log from 'electron-log'; +import EventEmitter from 'events'; +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 type { ReduxStore } from '../redux/store'; +import type { AccountToken, BackendState, RelaySettingsUpdate } from './ipc-facade'; +import type { ConnectionState } from '../redux/connection/reducers'; + +export type ErrorType = 'NO_CREDIT' | 'NO_INTERNET' | 'INVALID_ACCOUNT' | 'NO_ACCOUNT'; + +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(); + } + + async sync() { + log.info('Syncing with the backend...'); + + try { + await this._fetchRelayLocations(); + } catch(e) { + log.error('Failed to fetch the relay locations: ', e.message); + } + + try { + await this._fetchPublicIP(); + } catch(e) { + log.error('Failed to fetch the public IP: ', e.message); + } + + try { + await this._fetchLocation(); + } catch(e) { + log.error('Failed to fetch the location: ', e.message); + } + + await this._fetchAccountHistory(); + } + + async login(accountToken: AccountToken) { + log.debug('Attempting to login'); + + this._store.dispatch(accountActions.startLogin(accountToken)); + + try { + await this._ensureAuthenticated(); + + const accountData = await this._ipc.getAccountData(accountToken); + + log.debug('Account exists', accountData); + + await this._ipc.setAccount(accountToken); + + log.info('Log in complete'); + + this._store.dispatch( + accountActions.loginSuccessful(accountData.expiry) + ); + await this.fetchRelaySettings(); + + // Redirect the user after some time to allow for + // the 'Login Successful' screen to be visible + setTimeout(() => { + this._store.dispatch(push('/connect')); + log.debug('Autoconnecting...'); + this.connect(); + }, 1000); + + await this._fetchAccountHistory(); + + } 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)); + } + } + + async autologin() { + try { + log.debug('Attempting to log in automatically'); + + await this._ensureAuthenticated(); + + this._store.dispatch(accountActions.startLogin()); + + const accountToken = await this._ipc.getAccount(); + if(!accountToken) { + throw new BackendError('NO_ACCOUNT'); + } + + log.debug('The backend had an account number stored: ', accountToken); + this._store.dispatch(accountActions.startLogin(accountToken)); + + const accountData = await this._ipc.getAccountData(accountToken); + log.debug('The stored account number still exists', accountData); + + this._store.dispatch(accountActions.loginSuccessful(accountData.expiry)); + this._store.dispatch(push('/connect')); + } catch (e) { + log.warn('Unable to autologin,', e.message); + + this._store.dispatch(accountActions.autoLoginFailed()); + this._store.dispatch(push('/')); + + throw e; + } + } + + async logout() { + // @TODO: What does it mean for a logout to be successful or failed? + try { + await this._ensureAuthenticated(); + await this._ipc.setAccount(null); + + this._store.dispatch(accountActions.loggedOut()); + + // disconnect user during logout + await this.disconnect(); + + this._store.dispatch(push('/')); + } catch (e) { + log.info('Failed to logout: ', e.message); + } + } + + async connect() { + try { + this._store.dispatch(connectionActions.connecting()); + + await this._ensureAuthenticated(); + await this._ipc.connect(); + } catch (e) { + log.error('Failed to connect: ', e.message); + this._store.dispatch(connectionActions.disconnected()); + } + } + + async disconnect() { + // @TODO: Failure modes + try { + await this._ensureAuthenticated(); + await this._ipc.disconnect(); + } catch (e) { + log.error('Failed to disconnect: ', e.message); + } + } + + async shutdown() { + try { + await this._ensureAuthenticated(); + await this._ipc.shutdown(); + } catch (e) { + log.error('Failed to shutdown: ', e.message); + } + } + + async updateRelaySettings(relaySettings: RelaySettingsUpdate) { + try { + await this._ensureAuthenticated(); + await this._ipc.updateRelaySettings(relaySettings); + } catch (e) { + log.error('Failed to update relay settings: ', e.message); + } + } + + async fetchRelaySettings() { + await this._ensureAuthenticated(); + + const relaySettings = await this._ipc.getRelaySettings(); + log.debug('Got relay settings from backend', JSON.stringify(relaySettings)); + + if(relaySettings.normal) { + const payload = {}; + const normal = relaySettings.normal; + const tunnel = normal.tunnel; + const location = normal.location; + + if(location === 'any') { + payload.location = 'any'; + } else { + payload.location = location.only; + } + + if(tunnel === 'any') { + payload.port = 'any'; + payload.protocol = 'any'; + } else { + const { port, protocol } = tunnel.only.openvpn; + payload.port = port === 'any' ? port : port.only; + payload.protocol = protocol === 'any' ? protocol : protocol.only; + } + + this._store.dispatch( + settingsActions.updateRelay({ + normal: payload + }) + ); + } else if(relaySettings.custom_tunnel_endpoint) { + const custom_tunnel_endpoint = relaySettings.custom_tunnel_endpoint; + const { host, tunnel: { openvpn: { port, protocol } } } = custom_tunnel_endpoint; + + this._store.dispatch( + settingsActions.updateRelay({ + custom_tunnel_endpoint: { + host, port, protocol + } + }) + ); + } + } + + async removeAccountFromHistory(accountToken: AccountToken) { + try { + await this._ensureAuthenticated(); + await this._ipc.removeAccountFromHistory(accountToken); + await this._fetchAccountHistory(); + } catch(e) { + log.error('Failed to remove account token from history', e.message); + } + } + + async _fetchAccountHistory() { + try { + await this._ensureAuthenticated(); + const accountHistory = await this._ipc.getAccountHistory(); + this._store.dispatch( + accountActions.updateAccountHistory(accountHistory) + ); + } catch(e) { + log.info('Failed to fetch account history,', e.message); + throw e; + } + } + + + + async _fetchRelayLocations() { + await this._ensureAuthenticated(); + + const locations = await this._ipc.getRelayLocations(); + + log.info('Got relay locations'); + + const storedLocations = locations.countries.map((country) => ({ + name: country.name, + code: country.code, + hasActiveRelays: country.cities.some((city) => city.has_active_relays), + cities: country.cities.map((city) => ({ + name: city.name, + code: city.code, + position: city.position, + hasActiveRelays: city.has_active_relays, + })) + })); + + this._store.dispatch( + settingsActions.updateRelayLocations(storedLocations) + ); + } + + async _fetchPublicIP() { + await this._ensureAuthenticated(); + + const publicIp = await this._ipc.getPublicIp(); + + log.info('Got public IP: ', publicIp); + + this._store.dispatch( + connectionActions.newPublicIp(publicIp) + ); + } + + async _fetchLocation() { + await this._ensureAuthenticated(); + + const location = await this._ipc.getLocation(); + + log.info('Got location: ', location); + + const locationUpdate = { + country: location.country, + city: location.city, + location: location.position + }; + + this._store.dispatch( + connectionActions.newLocation(locationUpdate) + ); + } + + /** + * 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); + } + + async _registerIpcListeners() { + await this._ensureAuthenticated(); + 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() { + 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.')); + } + } + + async _authenticate(sharedSecret: string) { + try { + await this._ipc.authenticate(sharedSecret); + 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..567a6ab8f1 --- /dev/null +++ b/app/lib/ipc-facade.js @@ -0,0 +1,333 @@ +// @flow + +import JsonRpcWs, { InvalidReply } from './jsonrpc-ws-ipc'; +import { object, string, number, boolean, enumeration, arrayOf, 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 = { + country: string, + city: string, + position: Coordinate2d, +}; +const LocationSchema = object({ + country: string, + country_code: string, + city: string, + city_code: string, + position: arrayOf(number), +}); + +export type SecurityState = 'secured' | 'unsecured'; +export type BackendState = { + state: SecurityState, + target_state: SecurityState, +}; + +export type RelayProtocol = 'tcp' | 'udp'; +export type RelayLocation = {| city: [string, string] |} | {| country: string |}; + +type OpenVpnParameters = { + port: 'any' | { only: number }, + protocol: 'any' | { only: RelayProtocol }, +}; + +type TunnelOptions<TOpenVpnParameters> = { + openvpn: TOpenVpnParameters, +}; + +type RelaySettingsNormal<TTunnelOptions> = { + location: 'any' | { + only: RelayLocation, + }, + tunnel: 'any' | { + only: TTunnelOptions + }, +}; + +// types describing the structure of RelaySettings +export type RelaySettingsCustom = { + host: string, + tunnel: { + openvpn: { + port: number, + protocol: RelayProtocol + } + } +}; +export type RelaySettings = {| + normal: RelaySettingsNormal<TunnelOptions<OpenVpnParameters>> +|} | {| + custom_tunnel_endpoint: RelaySettingsCustom +|}; + +// types describing the partial update of RelaySettings +export type RelaySettingsNormalUpdate = $Shape< RelaySettingsNormal< TunnelOptions<$Shape<OpenVpnParameters> > > >; +export type RelaySettingsUpdate = {| + normal: RelaySettingsNormalUpdate +|} | {| + custom_tunnel_endpoint: RelaySettingsCustom +|}; + +const Constraint = (v) => oneOf(string, object({ + only: v, +})); +const RelaySettingsSchema = oneOf( + object({ + normal: object({ + location: Constraint(oneOf( + object({ + city: arrayOf(string), + }), + object({ + country: string + }), + )), + tunnel: Constraint(object({ + openvpn: object({ + port: Constraint(number), + protocol: Constraint(enumeration('udp', 'tcp')), + }), + })), + }) + }), + object({ + custom_tunnel_endpoint: object({ + host: string, + tunnel: object({ + openvpn: object({ + port: number, + protocol: enumeration('udp', 'tcp'), + }) + }) + }) + }) +); + +export type RelayList = { + countries: Array<RelayListCountry>, +}; + +export type RelayListCountry = { + name: string, + code: string, + cities: Array<RelayListCity>, +}; + +export type RelayListCity = { + name: string, + code: string, + position: [number, number], + has_active_relays: boolean, +}; + +const RelayListSchema = object({ + countries: arrayOf(object({ + name: string, + code: string, + cities: arrayOf(object({ + name: string, + code: string, + position: arrayOf(number), + has_active_relays: boolean, + })), + })), +}); + + +export interface IpcFacade { + setConnectionString(string): void, + getAccountData(AccountToken): Promise<AccountData>, + getAccount(): Promise<?AccountToken>, + setAccount(accountToken: ?AccountToken): Promise<void>, + updateRelaySettings(RelaySettingsUpdate): Promise<void>, + getRelaySettings(): Promise<RelaySettings>, + getRelayLocations(): Promise<RelayList>, + connect(): Promise<void>, + disconnect(): Promise<void>, + shutdown(): Promise<void>, + getPublicIp(): Promise<Ip>, + getLocation(): Promise<Location>, + getState(): Promise<BackendState>, + registerStateListener((BackendState) => void): void, + setCloseConnectionHandler(() => void): void, + authenticate(sharedSecret: string): Promise<void>, + getAccountHistory(): Promise<Array<AccountToken>>, + removeAccountFromHistory(accountToken: AccountToken): 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; + } + + updateRelaySettings(relaySettings: RelaySettingsUpdate): Promise<void> { + return this._ipc.send('update_relay_settings', [relaySettings]) + .then(this._ignoreResponse); + } + + getRelaySettings(): Promise<RelaySettings> { + return this._ipc.send('get_relay_settings') + .then( raw => { + try { + const validated: any = validate(RelaySettingsSchema, raw); + return (validated: RelaySettings); + } catch (e) { + throw new InvalidReply(raw, e); + } + }); + } + + async getRelayLocations(): Promise<RelayList> { + const raw = await this._ipc.send('get_relay_locations'); + try { + const validated: any = validate(RelayListSchema, raw); + return (validated: RelayList); + } 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); + } + + getPublicIp(): Promise<Ip> { + return this._ipc.send('get_public_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_current_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); + } + + getAccountHistory(): Promise<Array<AccountToken>> { + return this._ipc.send('get_account_history') + .then(raw => { + if(Array.isArray(raw) && raw.every(i => typeof i === 'string')) { + const checked: any = raw; + return (checked: Array<AccountToken>); + } else { + throw new InvalidReply(raw, 'Expected an array of strings'); + } + }); + } + + removeAccountFromHistory(accountToken: AccountToken): Promise<void> { + return this._ipc.send('remove_account_from_history', accountToken) + .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/platform.android.js b/app/lib/platform.android.js new file mode 100644 index 0000000000..d7bd39b867 --- /dev/null +++ b/app/lib/platform.android.js @@ -0,0 +1,13 @@ +// @flow +import { BackHandler } from 'react-native'; +import { Linking } from 'react-native'; + +const exit = () => { + BackHandler.exitApp(); +}; + +const open = (link: string) => { + Linking.openURL(link); +}; + +export {exit, open}; diff --git a/app/lib/platform.js b/app/lib/platform.js new file mode 100644 index 0000000000..66e5a099ba --- /dev/null +++ b/app/lib/platform.js @@ -0,0 +1,13 @@ +// @flow +import { remote } from 'electron'; +import { shell } from 'electron'; + +const exit = () => { + remote.app.quit(); +}; + +const open = (link: string) => { + shell.openExternal(link); +}; + +export {exit, open}; 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/relay-settings-builder.js b/app/lib/relay-settings-builder.js new file mode 100644 index 0000000000..4d83956a00 --- /dev/null +++ b/app/lib/relay-settings-builder.js @@ -0,0 +1,195 @@ +// @flow + +import type { + RelayLocation, + RelayProtocol, + RelaySettingsUpdate, + RelaySettingsNormalUpdate, + RelaySettingsCustom +} from './ipc-facade'; + +type LocationBuilder<Self> = { + country: (country: string) => Self, + city: (country: string, city: string) => Self, + any: () => Self, + fromRaw: (location: 'any' | RelayLocation) => Self, +}; + +type OpenVPNConfigurator<Self> = { + port: { + exact: (port: number) => Self, + any: () => Self + }, + protocol: { + exact: (protocol: RelayProtocol) => Self, + any: () => Self + } +}; + +type TunnelBuilder<Self> = { + openvpn: (configurator: (OpenVPNConfigurator<*>) => void) => Self +}; + +class NormalRelaySettingsBuilder { + _payload: RelaySettingsNormalUpdate = {}; + + build(): RelaySettingsUpdate { + return { + normal: this._payload + }; + } + + get location(): LocationBuilder<NormalRelaySettingsBuilder> { + return { + country: (country: string) => { + this._payload.location = { only: { country } }; + return this; + }, + city: (country: string, city: string) => { + this._payload.location = { only: { city: [country, city] } }; + return this; + }, + any: () => { + this._payload.location = 'any'; + return this; + }, + fromRaw: function (location: 'any' | RelayLocation) { + if(location === 'any') { + return this.any(); + } + + if(location.city) { + const [country, city] = location.city; + return this.city(country, city); + } + + if(location.country) { + return this.country(location.country); + } + + throw new Error('Unsupported value of RelayLocation' + + (location && JSON.stringify(location)) ); + }, + }; + } + + get tunnel(): TunnelBuilder<NormalRelaySettingsBuilder> { + const updateOpenvpn = (next) => { + const tunnel = this._payload.tunnel; + if(typeof(tunnel) === 'string' || typeof(tunnel) === 'undefined') { + this._payload.tunnel = { + only: { + openvpn: next + } + }; + } else if(typeof(tunnel) === 'object') { + const prev = (tunnel.only && tunnel.only.openvpn) || {}; + this._payload.tunnel = { + only: { + openvpn: { ...prev, ...next } + } + }; + } + }; + + return { + openvpn: (configurator) => { + const openvpnBuilder = { + get port() { + const apply = (port) => { + updateOpenvpn({ port }); + return this; + }; + return { + exact: (value: number) => apply({ only: value }), + any: () => apply('any'), + }; + }, + get protocol() { + const apply = (protocol) => { + updateOpenvpn({ protocol }); + return this; + }; + return { + exact: (value: RelayProtocol) => apply({ only: value }), + any: () => apply('any'), + }; + } + }; + + configurator(openvpnBuilder); + + return this; + }, + any: () => { + this._payload.tunnel = 'any'; + return this; + } + }; + } + +} + + +type CustomOpenVPNConfigurator<Self> = { + port: (port: number) => Self, + protocol: (protocol: RelayProtocol) => Self +}; + +type CustomTunnelBuilder<Self> = { + openvpn: (configurator: (CustomOpenVPNConfigurator<*>) => void) => Self +}; + +class CustomRelaySettingsBuilder { + _payload: RelaySettingsCustom = { + host: '', + tunnel: { + openvpn: { + port: 0, + protocol: 'udp' + } + } + }; + + build(): RelaySettingsUpdate { + return { + custom_tunnel_endpoint: this._payload + }; + } + + host(value: string) { + this._payload.host = value; + return this; + } + + get tunnel(): CustomTunnelBuilder<CustomRelaySettingsBuilder> { + const updateOpenvpn = (next) => { + const tunnel = this._payload.tunnel || {}; + const prev = tunnel.openvpn || {}; + this._payload.tunnel = { + openvpn: { ...prev, ...next } + }; + }; + + return { + openvpn: (configurator) => { + configurator({ + port: function (port: number) { + updateOpenvpn({ port }); + return this; + }, + protocol: function (protocol: RelayProtocol) { + updateOpenvpn({ protocol }); + return this; + } + }); + return this; + } + }; + } +} + +export default { + normal: () => new NormalRelaySettingsBuilder(), + custom: () => new CustomRelaySettingsBuilder(), +};
\ No newline at end of file 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..b2612de4d9 --- /dev/null +++ b/app/main.js @@ -0,0 +1,444 @@ +// @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('mullvad-daemon'); + 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; + const 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) { + const win = new BrowserWindow({ + ...options, + height: contentHeight + 12, // 12 is the size of transparent area around arrow + frame: false, + transparent: true + }); + win.setVisibleOnAllWorkspaces(true); + return win; + } else { + 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')) + .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..8932e6ae37 --- /dev/null +++ b/app/redux/account/actions.js @@ -0,0 +1,112 @@ +// @flow + +import type { AccountToken } from '../../lib/ipc-facade'; +import type { Backend, BackendError } from '../../lib/backend'; + +type StartLoginAction = { + type: 'START_LOGIN', + accountToken?: AccountToken, +}; + +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: AccountToken, +}; + +type UpdateAccountHistoryAction = { + type: 'UPDATE_ACCOUNT_HISTORY', + accountHistory: Array<AccountToken>, +}; + +export type AccountAction = StartLoginAction + | LoginSuccessfulAction + | LoginFailedAction + | LoggedOutAction + | ResetLoginErrorAction + | UpdateAccountTokenAction + | UpdateAccountHistoryAction; + +function startLogin(accountToken?: AccountToken): 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: AccountToken): UpdateAccountTokenAction { + return { + type: 'UPDATE_ACCOUNT_TOKEN', + token: token, + }; +} + +function updateAccountHistory(accountHistory: Array<AccountToken>): UpdateAccountHistoryAction { + return { + type: 'UPDATE_ACCOUNT_HISTORY', + accountHistory: accountHistory, + }; +} + +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, + updateAccountHistory, +}; diff --git a/app/redux/account/reducers.js b/app/redux/account/reducers.js new file mode 100644 index 0000000000..95909e3d74 --- /dev/null +++ b/app/redux/account/reducers.js @@ -0,0 +1,70 @@ +// @flow + +import type { ReduxAction } from '../store'; +import type { BackendError } from '../../lib/backend'; +import type { AccountToken } from '../../lib/ipc-facade'; + +export type LoginState = 'none' | 'logging in' | 'failed' | 'ok'; +export type AccountReduxState = { + accountToken: ?AccountToken, + accountHistory: Array<AccountToken>, + expiry: ?string, // ISO8601 + status: LoginState, + error: ?BackendError +}; + +const initialState: AccountReduxState = { + accountToken: null, + accountHistory: [], + 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, + }}; + case 'UPDATE_ACCOUNT_HISTORY': + return { ...state, ...{ + accountHistory: action.accountHistory, + }}; + } + + return state; +} diff --git a/app/redux/connection/actions.js b/app/redux/connection/actions.js new file mode 100644 index 0000000000..f2ebf2c7ba --- /dev/null +++ b/app/redux/connection/actions.js @@ -0,0 +1,109 @@ +// @flow + +import { clipboard } from 'electron'; + +import type { Backend } from '../../lib/backend'; +import type { ReduxThunk } from '../store'; +import type { Coordinate2d } from '../../types'; + +const connect = (backend: Backend): ReduxThunk => () => backend.connect(); +const disconnect = (backend: Backend) => () => backend.disconnect(); +const copyIPAddress = (): ReduxThunk => { + return (_, getState) => { + const { connection: { clientIp } } = getState(); + if(clientIp) { + clipboard.writeText(clientIp); + } + }; +}; + + +type ConnectingAction = { + type: 'CONNECTING', +}; +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 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, 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..52c1d648c1 --- /dev/null +++ b/app/redux/connection/reducers.js @@ -0,0 +1,56 @@ +// @flow + +import type { ReduxAction } from '../store'; +import type { Coordinate2d } from '../../types'; + +export type ConnectionState = 'disconnected' | 'connecting' | 'connected'; +export type ConnectionReduxState = { + status: ConnectionState, + isOnline: boolean, + clientIp: ?string, + location: ?Coordinate2d, + country: ?string, + city: ?string, +}; + +const initialState: ConnectionReduxState = { + status: 'disconnected', + isOnline: true, + 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 { ...state, ...{ status: 'connecting' }}; + + 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; + } +} diff --git a/app/redux/settings/actions.js b/app/redux/settings/actions.js new file mode 100644 index 0000000000..783eb042d2 --- /dev/null +++ b/app/redux/settings/actions.js @@ -0,0 +1,31 @@ +// @flow + +import type { RelaySettingsRedux, RelayLocationRedux } from './reducers'; + +export type UpdateRelayAction = { + type: 'UPDATE_RELAY', + relay: RelaySettingsRedux, +}; + +export type UpdateRelayLocationsAction = { + type: 'UPDATE_RELAY_LOCATIONS', + relayLocations: Array<RelayLocationRedux> +} + +export type SettingsAction = UpdateRelayAction | UpdateRelayLocationsAction; + +function updateRelay(relay: RelaySettingsRedux): UpdateRelayAction { + return { + type: 'UPDATE_RELAY', + relay: relay, + }; +} + +function updateRelayLocations(relayLocations: Array<RelayLocationRedux>): UpdateRelayLocationsAction { + return { + type: 'UPDATE_RELAY_LOCATIONS', + relayLocations: relayLocations, + }; +} + +export default { updateRelay, updateRelayLocations }; diff --git a/app/redux/settings/reducers.js b/app/redux/settings/reducers.js new file mode 100644 index 0000000000..21258a80a7 --- /dev/null +++ b/app/redux/settings/reducers.js @@ -0,0 +1,66 @@ +// @flow + +import type { ReduxAction } from '../store'; +import type { RelayProtocol, RelayLocation } from '../../lib/ipc-facade'; + +export type RelaySettingsRedux = {| + normal: { + location: 'any' | RelayLocation, + port: 'any' | number, + protocol: 'any' | RelayProtocol, + } +|} | {| + custom_tunnel_endpoint: { + host: string, + port: number, + protocol: RelayProtocol, + } +|}; + +export type RelayLocationCityRedux = { + name: string, + code: string, + position: [number, number], + hasActiveRelays: boolean, +}; + +export type RelayLocationRedux = { + name: string, + code: string, + hasActiveRelays: boolean, + cities: Array<RelayLocationCityRedux>, +}; + +export type SettingsReduxState = { + relaySettings: RelaySettingsRedux, + relayLocations: Array<RelayLocationRedux>, +}; + +const initialState: SettingsReduxState = { + relaySettings: { + normal: { + location: 'any', + port: 'any', + protocol: 'any', + } + }, + relayLocations: [], +}; + +export default function(state: SettingsReduxState = initialState, action: ReduxAction): SettingsReduxState { + + switch(action.type) { + case 'UPDATE_RELAY': + return { ...state, + relaySettings: action.relay, + }; + + case 'UPDATE_RELAY_LOCATIONS': + return { ...state, + relayLocations: action.relayLocations, + }; + + default: + return state; + } +} diff --git a/app/redux/store.js b/app/redux/store.js new file mode 100644 index 0000000000..d03849c0c4 --- /dev/null +++ b/app/redux/store.js @@ -0,0 +1,66 @@ +// @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/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]; |
