diff options
| author | Erik Larkö <erik@mullvad.net> | 2017-11-02 10:51:12 +0100 |
|---|---|---|
| committer | Erik Larkö <erik@mullvad.net> | 2017-11-09 12:42:56 +0100 |
| commit | ae8e86bb81cc722d9667c439d3508660398da707 (patch) | |
| tree | 05a467e918755c290194df119d5977862d1e6f4b | |
| parent | 01d617bab821f25412f4874354468afea30bd7b9 (diff) | |
| download | mullvadvpn-ae8e86bb81cc722d9667c439d3508660398da707.tar.xz mullvadvpn-ae8e86bb81cc722d9667c439d3508660398da707.zip | |
Problem report
| -rw-r--r-- | app/components/Settings.js | 2 | ||||
| -rw-r--r-- | app/components/Support.css | 45 | ||||
| -rw-r--r-- | app/components/Support.js | 232 | ||||
| -rw-r--r-- | app/containers/SupportPage.js | 75 | ||||
| -rw-r--r-- | app/lib/ipc-facade.js | 8 | ||||
| -rw-r--r-- | app/lib/proc.js | 27 | ||||
| -rw-r--r-- | app/main.js | 55 | ||||
| -rw-r--r-- | electron-builder.yml | 6 | ||||
| -rw-r--r-- | flow-libs/electron.js.flow | 4 | ||||
| -rw-r--r-- | test/components/Support.spec.js | 58 |
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); + }); + }); |
