summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorErik Larkö <erik@mullvad.net>2017-11-02 10:51:12 +0100
committerErik Larkö <erik@mullvad.net>2017-11-09 12:42:56 +0100
commitae8e86bb81cc722d9667c439d3508660398da707 (patch)
tree05a467e918755c290194df119d5977862d1e6f4b
parent01d617bab821f25412f4874354468afea30bd7b9 (diff)
downloadmullvadvpn-ae8e86bb81cc722d9667c439d3508660398da707.tar.xz
mullvadvpn-ae8e86bb81cc722d9667c439d3508660398da707.zip
Problem report
-rw-r--r--app/components/Settings.js2
-rw-r--r--app/components/Support.css45
-rw-r--r--app/components/Support.js232
-rw-r--r--app/containers/SupportPage.js75
-rw-r--r--app/lib/ipc-facade.js8
-rw-r--r--app/lib/proc.js27
-rw-r--r--app/main.js55
-rw-r--r--electron-builder.yml6
-rw-r--r--flow-libs/electron.js.flow4
-rw-r--r--test/components/Support.spec.js58
10 files changed, 432 insertions, 80 deletions
diff --git a/app/components/Settings.js b/app/components/Settings.js
index fd9fbebebd..95506cddc2 100644
--- a/app/components/Settings.js
+++ b/app/components/Settings.js
@@ -96,7 +96,7 @@ export default class Settings extends Component {
<img className="settings__cell-icon" src="./assets/images/icon-extLink.svg" />
</div>
<div className="settings__view-support settings__cell settings__cell--active" onClick={ this.props.onViewSupport }>
- <div className="settings__cell-label">Contact support</div>
+ <div className="settings__cell-label">Report a problem</div>
<img className="settings__cell-disclosure" src="assets/images/icon-chevron.svg" />
</div>
</div>
diff --git a/app/components/Support.css b/app/components/Support.css
index 58f4df3a20..8e8198742c 100644
--- a/app/components/Support.css
+++ b/app/components/Support.css
@@ -77,7 +77,7 @@
margin-top: 8px;
}
-.support__form-row--description {
+.support__form-row-message {
display: flex;
flex: 1 1 auto;
}
@@ -100,14 +100,14 @@
color: rgba(41,77,115,0.4);
}
-.support__form-description-scroll-wrap {
+.support__form-message-scroll-wrap {
width: 100%;
display: flex;
border-radius: 4px;
overflow: hidden;
}
-.support__form-description {
+.support__form-message {
width: 100%;
border: 0;
overflow-y: scroll;
@@ -121,7 +121,7 @@
background-color: #fff;
}
-.support__form-description::-webkit-input-placeholder {
+.support__form-message::-webkit-input-placeholder {
color: rgba(41,77,115,0.4);
}
@@ -131,4 +131,39 @@
.support__footer .button + .button {
margin-top: 16px;
-} \ No newline at end of file
+}
+
+.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
index 783133568f..2dcb6ad1ee 100644
--- a/app/components/Support.js
+++ b/app/components/Support.js
@@ -5,25 +5,34 @@ import ExternalLinkSVG from '../assets/images/icon-extLink.svg';
export type SupportReport = {
email: string,
- description: string
+ message: string,
+ savedReport: ?string,
};
-export type SupportState = SupportReport;
+export type SupportState = {
+ email: string,
+ message: string,
+ savedReport: ?string,
+ sendState: 'INITIAL' | 'LOADING' | 'SUCCESS' | 'FAILED',
+};
export type SupportProps = {
onClose: () => void;
- onViewLogs: () => void;
- onSend: (report: SupportReport) => void;
+ onViewLog: (string) => void;
+ onCollectLog: () => Promise<string>;
+ onSend: (email: string, message: string, savedReport: string) => void;
};
export default class Support extends Component {
props: SupportProps;
state: SupportState = {
email: '',
- description: ''
+ message: '',
+ savedReport: null,
+ sendState: 'INITIAL',
}
validate() {
- return this.state.description.trim().length > 0;
+ return this.state.message.trim().length > 0;
}
onChangeEmail = (e: Event) => {
@@ -39,14 +48,61 @@ export default class Support extends Component {
if(!(input instanceof HTMLTextAreaElement)) {
throw new Error('input must be an instance of HTMLTextAreaElement');
}
- this.setState({ description: input.value });
+ this.setState({ message: input.value });
+ }
+
+ onViewLog = () => {
+
+ this._getLog()
+ .then((path) => {
+ this.props.onViewLog(path);
+ });
+ }
+
+ _getLog() {
+ const { savedReport } = this.state;
+ return savedReport ?
+ Promise.resolve(savedReport) :
+ this.props.onCollectLog()
+ .then( path => {
+ return new Promise(resolve => this.setState({ savedReport: path }, () => resolve(path)));
+ });
}
onSend = () => {
- this.props.onSend(this.state);
+ 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' } />
@@ -58,46 +114,9 @@ export default class Support extends Component {
</div>
<div className="support__container">
- <div className="support__header">
- <h2 className="support__title">Contact support</h2>
- <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>
+ { header }
- <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--description">
- <div className="support__form-description-scroll-wrap">
- <textarea className="support__form-description"
- placeholder="Describe your problem"
- value={ this.state.description }
- onChange={ this.onChangeDescription } />
- </div>
- </div>
- <div className="support__footer">
- <button type="button"
- className="button button--primary"
- onClick={ this.props.onViewLogs }>
- <span className="support__form-view-logs 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>
+ { content }
</div>
</div>
@@ -105,4 +124,123 @@ export default class Support extends Component {
</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/containers/SupportPage.js b/app/containers/SupportPage.js
index fe579395f6..e57985d7d3 100644
--- a/app/containers/SupportPage.js
+++ b/app/containers/SupportPage.js
@@ -1,16 +1,87 @@
+import log from 'electron-log';
+import { shell, ipcRenderer } from 'electron';
import { connect } from 'react-redux';
import { push } from 'react-router-redux';
import Support from '../components/Support';
+import { resolveBin } from '../lib/proc';
+import { execFile } from 'child_process';
+import uuid from 'uuid';
const mapStateToProps = (state) => {
return state;
};
+const unAnsweredIpcCalls = new Map();
+function reapIpcCall(id) {
+ const promise = unAnsweredIpcCalls.get(id);
+ unAnsweredIpcCalls.delete(id);
+
+ if (promise) {
+ promise.reject(new Error('Timed out'));
+ }
+}
+ipcRenderer.on('collect-logs-reply', (_event, id, err, reportId) => {
+ const promise = unAnsweredIpcCalls.get(id);
+ unAnsweredIpcCalls.delete(id);
+
+ if (err) {
+ promise.reject(err);
+ } else if (promise) {
+ promise.resolve(reportId);
+ }
+});
+
const mapDispatchToProps = (dispatch, _props) => {
return {
onClose: () => dispatch(push('/settings')),
- onViewLogs: () => {},
- onSend: (_report) => {}
+
+ onCollectLog: () => {
+ return new Promise((resolve, reject) => {
+
+ const id = uuid.v4();
+ unAnsweredIpcCalls.set(id, { resolve, reject });
+ ipcRenderer.send('collect-logs', id);
+ 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;
+ });
+ }
};
};
diff --git a/app/lib/ipc-facade.js b/app/lib/ipc-facade.js
index 7a71861ae5..5b67dcb76f 100644
--- a/app/lib/ipc-facade.js
+++ b/app/lib/ipc-facade.js
@@ -210,12 +210,12 @@ export class RealIpc implements IpcFacade {
});
}
+ setCloseConnectionHandler(handler: () => void) {
+ this._ipc.setCloseConnectionHandler(handler);
+ }
+
authenticate(sharedSecret: string): Promise<void> {
return this._ipc.send('auth', sharedSecret)
.then(this._ignoreResponse);
}
-
- setCloseConnectionHandler(handler: () => void) {
- this._ipc.setCloseConnectionHandler(handler);
- }
}
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/main.js b/app/main.js
index 3af6a4863e..73218a055d 100644
--- a/app/main.js
+++ b/app/main.js
@@ -9,21 +9,25 @@ 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');
-const isWindows = (process.platform === 'win32');
// The name for application directory used for
// scoping logs and user data in platform special folders
const appDirectoryName = 'MullvadVPN';
-const rpcAddressFile = isMacOS || isLinux
- ? path.join('/tmp', '.mullvad_rpc_address')
- : path.join(app.getPath('temp'), '.mullvad_rpc_address');
+const writableDirectory = isMacOS || isLinux
+ ? '/tmp'
+ : app.getPath('temp');
+
+const rpcAddressFile = path.join(writableDirectory, '.mullvad_rpc_address');
let browserWindowReady = false;
@@ -127,7 +131,7 @@ const appDelegate = {
return;
}
- const pathToBackend = appDelegate._findPathToBackend();
+ const pathToBackend = resolveBin('mullvadd');
log.info('Starting the mullvad backend at', pathToBackend);
const options = {
@@ -148,18 +152,6 @@ const appDelegate = {
_rpcAddressFileExists: () => {
return fs.existsSync(rpcAddressFile);
},
- _findPathToBackend: () => {
- if (isDevelopment) {
- return path.resolve(process.env.MULLVAD_BACKEND || '../talpid_core/target/debug/mullvadd');
-
- } else if (isMacOS || isLinux) {
- return path.join(process.resourcesPath, 'mullvadd');
-
- } else if (isWindows) {
- // TODO: Decide
- return '';
- }
- },
_setupBackendProcessListeners: (p) => {
// electron-sudo writes all output to some buffers in memory.
// For long-running processes such as this one that would
@@ -390,6 +382,35 @@ const appDelegate = {
// add IPC handler to change tray icon from renderer
ipcMain.on('changeTrayIcon', (_: Event, type: TrayIconType) => trayIconManager.iconType = type);
+ ipcMain.on('collect-logs', (event, id) => {
+ 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');
+ const args = [
+ 'collect',
+ '--output', reportPath,
+ ...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());
diff --git a/electron-builder.yml b/electron-builder.yml
index 84c2999932..a69dd685a4 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -43,6 +43,8 @@ mac:
extraResources:
- from: ../talpid_core/target/release/mullvad
to: .
+ - from: ../talpid_core/target/release/problem-report
+ to: .
- from: ../talpid_core/target/release/mullvadd
to: .
- from: ../talpid_core/target/release/libtalpid_openvpn_plugin.dylib
@@ -57,6 +59,8 @@ win:
extraResources:
- from: ../talpid_core/target/release/mullvad.exe
to: .
+ - from: ../talpid_core/target/release/problem-report.exe
+ to: .
- from: ../talpid_core/target/release/mullvadd.exe
to: .
- from: ../talpid_core/target/release/libtalpid_openvpn_plugin.dll
@@ -69,6 +73,8 @@ linux:
extraResources:
- from: ../talpid_core/target/release/mullvad
to: .
+ - from: ../talpid_core/target/release/problem-report
+ to: .
- from: ../talpid_core/target/release/mullvadd
to: .
- from: ../talpid_core/target/release/libtalpid_openvpn_plugin.so
diff --git a/flow-libs/electron.js.flow b/flow-libs/electron.js.flow
index c417abc907..631e99559a 100644
--- a/flow-libs/electron.js.flow
+++ b/flow-libs/electron.js.flow
@@ -41,7 +41,11 @@ declare module 'electron' {
}
declare class Shell {
+ showItemInFolder(fullPath: string): boolean;
+ openItem(fullPath: string): boolean;
openExternal(url: string, options?: OpenExternalOptions, callback: (error: Error) => void): boolean;
+ moveItemToTrash(fullPath: string): boolean;
+ beep(): void;
}
// http://electron.atom.io/docs/api/remote
diff --git a/test/components/Support.spec.js b/test/components/Support.spec.js
index bcd871855b..2352cbcef2 100644
--- a/test/components/Support.spec.js
+++ b/test/components/Support.spec.js
@@ -1,6 +1,7 @@
// @flow
import { expect } from 'chai';
+import sinon from 'sinon';
import React from 'react';
import ReactTestUtils, { Simulate } from 'react-dom/test-utils';
import Support from '../../app/components/Support';
@@ -12,7 +13,8 @@ describe('components/Support', () => {
const makeProps = (mergeProps: $Shape<SupportProps> = {}): SupportProps => {
const defaultProps: SupportProps = {
onClose: () => {},
- onViewLogs: () => {},
+ onViewLog: (_path) => {},
+ onCollectLog: () => Promise.resolve('/tmp/mullvad_problem_report.log'),
onSend: (_report) => {}
};
return Object.assign({}, defaultProps, mergeProps);
@@ -34,7 +36,7 @@ describe('components/Support', () => {
it('should call view logs callback', (done) => {
const props = makeProps({
- onViewLogs: () => done()
+ onViewLog: (_path) => done()
});
const domNode = ReactTestUtils.findRenderedDOMComponentWithClass(render(props), 'support__form-view-logs');
Simulate.click(domNode);
@@ -47,7 +49,7 @@ describe('components/Support', () => {
const component = render(props);
- const descriptionField = ReactTestUtils.findRenderedDOMComponentWithClass(component, 'support__form-description');
+ const descriptionField = ReactTestUtils.findRenderedDOMComponentWithClass(component, 'support__form-message');
descriptionField.value = 'Lorem Ipsum';
Simulate.change(descriptionField);
@@ -59,7 +61,7 @@ describe('components/Support', () => {
it('should not call send callback when description is empty', () => {
const component = render(makeProps());
- const descriptionField = ReactTestUtils.findRenderedDOMComponentWithClass(component, 'support__form-description');
+ const descriptionField = ReactTestUtils.findRenderedDOMComponentWithClass(component, 'support__form-message');
descriptionField.value = '';
Simulate.change(descriptionField);
@@ -67,4 +69,52 @@ describe('components/Support', () => {
expect(sendButton.disabled).to.be.true;
});
+ it('should not collect report twice', (done) => {
+ const collectCallback = sinon.spy(() => Promise.resolve('non-falsy'));
+ const props = makeProps({
+ onCollectLog: collectCallback
+ });
+
+ const component = render(props);
+ const viewLogButton = ReactTestUtils.findRenderedDOMComponentWithClass(component, 'support__form-view-logs');
+ Simulate.click(viewLogButton);
+
+ setTimeout(() => {
+ Simulate.click(viewLogButton);
+ });
+
+ setTimeout(() => {
+ try {
+ expect(collectCallback.callCount).to.equal(1);
+ done();
+ } catch (e) {
+ done(e);
+ }
+ });
+ });
+
+ it('should collect report on submission', (done) => {
+ const collectCallback = sinon.spy(() => Promise.resolve(''));
+ const props = makeProps({
+ onCollectLog: collectCallback,
+ onSend: (_report) => {
+ try {
+ expect(collectCallback.calledOnce).to.be.true;
+ done();
+ } catch (e) {
+ done(e);
+ }
+ }
+ });
+
+ const component = render(props);
+
+ const descriptionField = ReactTestUtils.findRenderedDOMComponentWithClass(component, 'support__form-message');
+ descriptionField.value = 'Lorem Ipsum';
+ Simulate.change(descriptionField);
+
+ const sendButton = ReactTestUtils.findRenderedDOMComponentWithClass(component, 'support__form-send');
+ Simulate.click(sendButton);
+ });
+
});