summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2018-06-12 15:55:52 +0200
committerAndrej Mihajlov <and@mullvad.net>2018-06-12 15:55:52 +0200
commit55b5d390bc377b9ff6cdf5a0b1641ea2fae23719 (patch)
treefaeece30342d7f2688da64f74a1e983b284f04fa
parentbb4304f397dc954d3aec6cb2139ca5ab9cc0bf06 (diff)
parent068ff217d75ec578aacb01fd441e23898919913b (diff)
downloadmullvadvpn-55b5d390bc377b9ff6cdf5a0b1641ea2fae23719.tar.xz
mullvadvpn-55b5d390bc377b9ff6cdf5a0b1641ea2fae23719.zip
Merge branch 'modernize-app'
-rw-r--r--app/app.js19
-rw-r--r--app/lib/jsonrpc-ws-ipc.js58
-rw-r--r--app/lib/problem-report.js71
-rw-r--r--app/main.js442
-rw-r--r--app/tray-icon-controller.js (renamed from app/lib/tray-icon-manager.js)31
-rw-r--r--app/window-controller.js160
-rw-r--r--flow-libs/electron.js.flow4
-rw-r--r--package.json2
-rw-r--r--scripts/serve.js3
-rw-r--r--yarn.lock60
10 files changed, 473 insertions, 377 deletions
diff --git a/app/app.js b/app/app.js
index 0a23db9b86..8e9cc76fb7 100644
--- a/app/app.js
+++ b/app/app.js
@@ -12,7 +12,7 @@ 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';
+import type { TrayIconType } from './tray-icon-controller';
const initialState = null;
const memoryHistory = createMemoryHistory();
@@ -54,14 +54,15 @@ ipcRenderer.on('disconnect', () => {
});
});
-ipcRenderer.on('app-shutdown', () => {
+ipcRenderer.on('app-shutdown', async () => {
log.info('Been told by the renderer process that the app is shutting down');
+
// The shutdown behaviour may have to be different on mobile platforms
- const shutdown_func =
- process.platform === 'darwin' ? () => backend.shutdown() : () => backend.disconnect();
- shutdown_func().catch((e) => {
- log.error('Failed to shutdown tunnel: ', e);
- });
+ try {
+ await backend.disconnect();
+ } catch (e) {
+ log.error(`Failed to shutdown tunnel: ${e.message}`);
+ }
// no matter what, don't block the frontend from shutting down, I guess.
ipcRenderer.send('daemon-shutdown', true);
@@ -93,9 +94,11 @@ const getIconType = (s: ConnectionState): TrayIconType => {
*/
const updateTrayIcon = () => {
const { connection } = store.getState();
+
// TODO: Only update the tray icon if the connection status changed
- ipcRenderer.send('changeTrayIcon', getIconType(connection.status));
+ ipcRenderer.send('change-tray-icon', getIconType(connection.status));
};
+
store.subscribe(updateTrayIcon);
// force update tray
diff --git a/app/lib/jsonrpc-ws-ipc.js b/app/lib/jsonrpc-ws-ipc.js
index eb84607be1..ff73f4cc88 100644
--- a/app/lib/jsonrpc-ws-ipc.js
+++ b/app/lib/jsonrpc-ws-ipc.js
@@ -96,26 +96,22 @@ export default class Ipc {
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);
- });
+ async on(event: string, listener: (mixed) => void): Promise<*> {
+ log.silly(`Adding a listener to ${event}`);
+ try {
+ const subscriptionId = await this.send(`${event}_subscribe`);
+ 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.message}`);
+ }
}
send(action: string, data: mixed, timeout: number = DEFAULT_TIMEOUT_MILLIS): Promise<mixed> {
- return new Promise((resolve, reject) => {
+ return new Promise(async (resolve, reject) => {
const id = uuid.v4();
const params = this._prepareParams(data);
@@ -128,15 +124,14 @@ export default class Ipc {
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);
- });
+ try {
+ const ws = await this._getWebSocket();
+ log.silly('Sending message', id, action);
+ ws.send(jsonrpcMessage);
+ } catch (e) {
+ log.error(`Failed sending RPC message "${action}": ${e.message}`);
+ reject(e);
+ }
});
}
@@ -159,7 +154,6 @@ export default class Ipc {
_getWebSocket(): Promise<WebSocket> {
return new Promise((resolve) => {
if (this._websocket && this._websocket.readyState === 1) {
- // Connected
resolve(this._websocket);
} else {
log.debug('Waiting for websocket to connect');
@@ -175,11 +169,11 @@ export default class Ipc {
this._unansweredRequests.delete(requestId);
if (!request) {
- log.debug(requestId, 'timed out but it seems to already have been answered');
+ log.warn(requestId, 'timed out but it seems to already have been answered');
return;
}
- log.debug(request.message, 'timed out');
+ log.warn(request.message, 'timed out');
request.reject(new TimeOutError(request.message));
}
@@ -199,7 +193,7 @@ export default class Ipc {
const listener = this._subscriptions.get(subscriptionId);
if (listener) {
- log.debug('Got notification', message.payload.method, message.payload.params.result);
+ log.silly('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');
@@ -216,7 +210,7 @@ export default class Ipc {
return;
}
- log.debug('Got answer to', id, message.type);
+ log.silly('Got answer to', id, message.type);
clearTimeout(request.timerId);
@@ -236,7 +230,7 @@ export default class Ipc {
this._websocket = this._websocketFactory(connectionString);
this._websocket.onopen = () => {
- log.debug('Websocket is connected');
+ log.info('Websocket is connected');
this._backoff.successfullyConnected();
while (this._onConnect.length > 0) {
diff --git a/app/lib/problem-report.js b/app/lib/problem-report.js
index 32acb32b93..df302bd908 100644
--- a/app/lib/problem-report.js
+++ b/app/lib/problem-report.js
@@ -1,71 +1,42 @@
// @flow
-import { resolveBin } from './proc';
-import { execFile } from 'child_process';
import { ipcRenderer } from 'electron';
-import { log } from './platform';
import uuid from 'uuid';
const collectProblemReport = (toRedact: Array<string>): Promise<string> => {
return new Promise((resolve, reject) => {
const requestId = uuid.v4();
- let responseListener: Function;
-
- const removeResponseListener = () => {
- ipcRenderer.removeListener('collect-logs-reply', responseListener);
- };
-
- // timeout after 10 seconds if no ipc response received
- const requestTimeout = setTimeout(() => {
- removeResponseListener();
- log.error('Timed out when collecting a problem report');
- reject(new Error('Timed out'));
- }, 10000);
-
- responseListener = (_event, id, error, reportPath) => {
- if (id !== requestId) {
- return;
- }
-
- clearTimeout(requestTimeout);
- removeResponseListener();
-
- if (error) {
- log.error(`Cannot collect a problem report: ${error.err}`);
- log.error(`Stdout: ${error.stdout}`);
- reject(error);
- } else {
- resolve(reportPath);
+ const responseListener = (_event, responseId, result) => {
+ if (responseId === requestId) {
+ ipcRenderer.removeListener('collect-logs-reply', responseListener);
+ if (result.success) {
+ resolve(result.reportPath);
+ } else {
+ reject(new Error(result.error));
+ }
}
};
- // add ipc response listener
ipcRenderer.on('collect-logs-reply', responseListener);
-
- // send ipc request
ipcRenderer.send('collect-logs', requestId, toRedact);
});
};
-const sendProblemReport = (email: string, message: string, savedReport: string) => {
- const args = ['send', '--email', email, '--message', message, '--report', savedReport];
-
- const binPath = resolveBin('problem-report');
-
+const sendProblemReport = (email: string, message: string, savedReport: string): Promise<void> => {
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();
+ const requestId = uuid.v4();
+ const responseListener = (_event, responseId, result) => {
+ if (requestId === responseId) {
+ ipcRenderer.removeListener('send-problem-report-reply', responseListener);
+ if (result.success) {
+ resolve();
+ } else {
+ reject(new Error(result.error));
+ }
}
- });
- }).catch((e) => {
- const { err, stdout } = e;
- log.error('Failed sending problem report', err);
- log.error(' stdout: ' + stdout);
+ };
- throw e;
+ ipcRenderer.on('send-problem-report-reply', responseListener);
+ ipcRenderer.send('send-problem-report', requestId, email, message, savedReport);
});
};
diff --git a/app/main.js b/app/main.js
index d96b80a5e9..2ac7f0a8d1 100644
--- a/app/main.js
+++ b/app/main.js
@@ -3,8 +3,9 @@ import path from 'path';
import fs from 'fs';
import mkdirp from 'mkdirp';
import { log } from './lib/platform';
-import electron, { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage } from 'electron';
-import TrayIconManager from './lib/tray-icon-manager';
+import { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage } from 'electron';
+import TrayIconController from './tray-icon-controller';
+import WindowController from './window-controller';
import { version } from '../package.json';
import { parseIpcCredentials } from './lib/backend';
import { resolveBin } from './lib/proc';
@@ -13,44 +14,63 @@ import { canTrustRpcAddressFile } from './lib/rpc-file-security';
import { execFile } from 'child_process';
import uuid from 'uuid';
-import type { TrayIconType } from './lib/tray-icon-manager';
-
-const isDevelopment = process.env.NODE_ENV === 'development';
+import type { TrayIconType } from './tray-icon-controller';
// The name for application directory used for
// scoping logs and user data in platform special folders
const appDirectoryName = 'Mullvad VPN';
-let browserWindowReady = false;
+const ApplicationMain = {
+ _windowController: (null: ?WindowController),
+ _trayIconController: (null: ?TrayIconController),
-const appDelegate = {
- _window: (null: ?BrowserWindow),
- _tray: (null: ?Tray),
- _logFilePath: '',
_readyToQuit: false,
- connectionFilePollInterval: (null: ?IntervalID),
+ _logFilePath: '',
+ _connectionFilePollInterval: (null: ?IntervalID),
+
+ run() {
+ if (this._ensureSingleInstance()) {
+ return;
+ }
- setup: () => {
// Override userData path, i.e on macOS: ~/Library/Application Support/Mullvad VPN
app.setPath('userData', path.join(app.getPath('appData'), appDirectoryName));
-
- appDelegate._initLogging();
+ this._initLogging();
log.info('Running version', version);
- app.on('window-all-closed', () => appDelegate.onAllWindowsClosed());
- app.on('ready', () => appDelegate.onReady());
+ app.on('ready', () => this._onReady());
+ app.on('window-all-closed', () => app.quit());
+ },
+
+ _ensureSingleInstance() {
+ // This callback is guaranteed to be excuted after 'ready' events have been
+ // sent to the app.
+ const shouldQuit = app.makeSingleInstance((_args, _workingDirectory) => {
+ log.debug('Another instance was spawned, showing window');
+
+ if (this._windowController) {
+ this._windowController.show();
+ }
+ });
+
+ if (shouldQuit) {
+ log.info('Another instance already exists, shutting down');
+ app.exit();
+ }
+
+ return shouldQuit;
},
- _initLogging: () => {
- const logDirectory = appDelegate._getLogsDirectory();
+ _initLogging() {
+ const logDirectory = this._getLogsDirectory();
const format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}][{level}] {text}';
- appDelegate._logFilePath = path.join(logDirectory, 'frontend.log');
+ this._logFilePath = path.join(logDirectory, 'frontend.log');
log.transports.console.format = format;
log.transports.file.format = format;
- if (isDevelopment) {
+ if (process.env.NODE_ENV === 'development') {
log.transports.console.level = 'debug';
// Disable log file in development
@@ -58,7 +78,7 @@ const appDelegate = {
} else {
log.transports.console.level = 'debug';
log.transports.file.level = 'debug';
- log.transports.file.file = appDelegate._logFilePath;
+ log.transports.file.file = this._logFilePath;
}
// create log folder
@@ -69,7 +89,7 @@ const appDelegate = {
// See open issue and PR on Github:
// 1. https://github.com/electron/electron/issues/10118
// 2. https://github.com/electron/electron/pull/10191
- _getLogsDirectory: () => {
+ _getLogsDirectory() {
switch (process.platform) {
case 'darwin':
// macOS: ~/Library/Logs/{appname}
@@ -81,120 +101,181 @@ const appDelegate = {
}
},
- onTunnelShutdown: (isTunnelDown: boolean) => {
- appDelegate._readyToQuit = isTunnelDown;
- app.quit();
- },
+ async _onReady() {
+ const window = this._createWindow();
+ const tray = this._createTray();
- onReady: async () => {
- const window = (appDelegate._window = appDelegate._createWindow());
+ const windowController = new WindowController(window, tray);
+ const trayIconController = new TrayIconController(tray, 'unsecured');
- ipcMain.on('on-browser-window-ready', () => {
- browserWindowReady = true;
- appDelegate._pollForConnectionInfoFile();
- });
+ tray.on('click', () => windowController.toggle());
- ipcMain.on('show-window', () => appDelegate._showWindow(window, appDelegate._tray));
- ipcMain.on('hide-window', () => window.hide());
- ipcMain.on('daemon-shutdown', appDelegate.onTunnelShutdown);
+ this._registerIpcEvents();
+ this._setAppMenu();
+ this._addContextMenu(window);
- window.loadURL('file://' + path.join(__dirname, 'index.html'));
+ this._windowController = windowController;
+ this._trayIconController = trayIconController;
app.on('before-quit', (event) => {
- if (!appDelegate._readyToQuit) {
+ if (!this._readyToQuit) {
event.preventDefault();
window.webContents.send('app-shutdown');
}
});
- ipcMain.on('collect-logs', (event, id, toRedact) => {
- const reportPath = path.join(app.getPath('temp'), uuid.v4() + '.log');
+ if (process.env.NODE_ENV === 'development') {
+ await this._installDevTools();
+
+ window.on('close', () => window.closeDevTools());
+ window.openDevTools({ mode: 'detach' });
+ }
- const binPath = resolveBin('problem-report');
- let args = ['collect', '--output', reportPath];
+ if (this._isMenubarApp()) {
+ this._installMenubarAppEventHandlers(windowController);
+ } else {
+ windowController.show();
+ }
+
+ window.loadFile('build/index.html');
+ },
+
+ _registerIpcEvents() {
+ ipcMain.on('on-browser-window-ready', () => {
+ this._pollConnectionInfoFile();
+ });
+
+ ipcMain.on('daemon-shutdown', (isTunnelDown: boolean) => {
+ this._readyToQuit = isTunnelDown;
+ app.quit();
+ });
+ ipcMain.on('show-window', () => {
+ const windowController = this._windowController;
+ if (windowController) {
+ windowController.show();
+ }
+ });
+
+ ipcMain.on('hide-window', () => {
+ const windowController = this._windowController;
+ if (windowController) {
+ windowController.hide();
+ }
+ });
+
+ ipcMain.on('change-tray-icon', (_event: any, type: TrayIconType) => {
+ const trayIconController = this._trayIconController;
+ if (trayIconController) {
+ trayIconController.animateToIcon(type);
+ }
+ });
+
+ ipcMain.on('collect-logs', (event, requestId, toRedact) => {
+ const reportPath = path.join(app.getPath('temp'), uuid.v4() + '.log');
+ const executable = resolveBin('problem-report');
+ const args = ['collect', '--output', reportPath];
if (toRedact.length > 0) {
- args = args.concat(['--redact', ...toRedact, '--']);
+ args.push('--redact', ...toRedact, '--');
}
+ args.push(this._logFilePath);
- args = args.concat([appDelegate._logFilePath]);
+ execFile(executable, args, { windowsHide: true }, (error, stdout, stderr) => {
+ if (error) {
+ log.error(
+ `Failed to collect a problem report: ${error.message}
+ Stdout: ${stdout.toString()}
+ Stderr: ${stderr.toString()}`,
+ );
- execFile(binPath, args, { windowsHide: true }, (err) => {
- if (err) {
- event.sender.send('collect-logs-reply', id, err);
+ event.sender.send('collect-logs-reply', requestId, {
+ success: false,
+ error: error.message,
+ });
} else {
- log.debug('Report written to', reportPath);
- event.sender.send('collect-logs-reply', id, null, reportPath);
+ log.debug(`Problem report was written to ${reportPath}`);
+
+ event.sender.send('collect-logs-reply', requestId, {
+ success: true,
+ reportPath,
+ });
}
});
});
- // create tray icon
- appDelegate._tray = appDelegate._createTray(window);
- appDelegate._setAppMenu();
- appDelegate._addContextMenu(window);
+ ipcMain.on(
+ 'send-problem-report',
+ (event, requestId, email: string, message: string, savedReport: string) => {
+ const executable = resolveBin('problem-report');
+ const args = ['send', '--email', email, '--message', message, '--report', savedReport];
- if (isDevelopment) {
- await appDelegate._installDevTools();
- window.openDevTools({ mode: 'detach' });
- }
+ execFile(executable, args, { windowsHide: true }, (error, stdout, stderr) => {
+ if (error) {
+ log.error(
+ `Failed to send a problem report: ${error.message}
+ Stdout: ${stdout.toString()}
+ Stderr: ${stderr.toString()}`,
+ );
- // Tray icon might not be supported on all linux distributions
- if (process.platform === 'linux') {
- window.show();
- }
- },
+ event.sender.send('send-problem-report-reply', requestId, {
+ success: false,
+ error: error.message,
+ });
+ } else {
+ log.info('Problem report was sent.');
- onAllWindowsClosed: () => {
- app.quit();
+ event.sender.send('send-problem-report-reply', requestId, {
+ success: true,
+ });
+ }
+ });
+ },
+ );
},
- _getRpcAddressFilePath: () => {
+
+ _getRpcAddressFilePath() {
const rpcAddressFileName = '.mullvad_rpc_address';
switch (process.platform) {
case 'win32': {
// Windows: %ALLUSERSPROFILE%\{appname}
let programDataDirectory = process.env.ALLUSERSPROFILE;
- if (typeof programDataDirectory === 'undefined' || programDataDirectory === null) {
- throw new Error('Missing %ALLUSERSPROFILE% environment variable');
- } else {
+ if (programDataDirectory) {
let appDataDirectory = path.join(programDataDirectory, appDirectoryName);
return path.join(appDataDirectory, rpcAddressFileName);
+ } else {
+ throw new Error('Missing %ALLUSERSPROFILE% environment variable');
}
}
default:
return path.join(getSystemTemporaryDirectory(), rpcAddressFileName);
}
},
- _pollForConnectionInfoFile: () => {
- if (appDelegate.connectionFilePollInterval) {
+
+ _pollConnectionInfoFile() {
+ if (this._connectionFilePollInterval) {
log.warn(
'Attempted to start polling for the RPC connection info file while another polling was already running',
);
return;
}
- const rpcAddressFile = appDelegate._getRpcAddressFilePath();
-
const pollIntervalMs = 200;
- appDelegate.connectionFilePollInterval = setInterval(() => {
- if (browserWindowReady && fs.existsSync(rpcAddressFile)) {
- if (appDelegate.connectionFilePollInterval) {
- clearInterval(appDelegate.connectionFilePollInterval);
- appDelegate.connectionFilePollInterval = null;
+ const rpcAddressFile = this._getRpcAddressFilePath();
+
+ this._connectionFilePollInterval = setInterval(() => {
+ if (fs.existsSync(rpcAddressFile)) {
+ if (this._connectionFilePollInterval) {
+ clearInterval(this._connectionFilePollInterval);
+ this._connectionFilePollInterval = null;
}
- appDelegate._sendBackendInfo(rpcAddressFile);
+ this._sendDaemonConnectionInfo(rpcAddressFile);
}
}, pollIntervalMs);
},
- _sendBackendInfo: (rpcAddressFile: string) => {
- const window = appDelegate._window;
- if (!window) {
- log.error('Attempted to send backend rpc address before the window was ready');
- return;
- }
+ _sendDaemonConnectionInfo(rpcAddressFile: string) {
log.debug(`Reading the ipc connection info from "${rpcAddressFile}"`);
try {
@@ -212,7 +293,7 @@ const appDelegate = {
// 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) {
+ fs.readFile(rpcAddressFile, 'utf8', (err, data) => {
if (err) {
return log.error('Could not find backend connection info', err);
}
@@ -220,14 +301,17 @@ const appDelegate = {
const credentials = parseIpcCredentials(data);
if (credentials) {
log.debug('Read IPC connection info', credentials.connectionString);
- window.webContents.send('backend-info', { credentials });
+ const windowController = this._windowController;
+ if (windowController) {
+ windowController.window.webContents.send('backend-info', { credentials });
+ }
} else {
log.error('Could not parse IPC credentials.');
}
});
},
- _installDevTools: async () => {
+ async _installDevTools() {
const installer = require('electron-devtools-installer');
const extensions = ['REACT_DEVELOPER_TOOLS', 'REDUX_DEVTOOLS'];
const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
@@ -240,9 +324,12 @@ const appDelegate = {
}
},
- _createWindow: (): BrowserWindow => {
- log.debug('Main process PID - ', process.pid);
+ _createWindow(): BrowserWindow {
const contentHeight = 568;
+
+ // the size of transparent area around arrow on macOS
+ const headerBarArrowHeight = 12;
+
const options = {
width: 320,
minWidth: 320,
@@ -266,9 +353,8 @@ const appDelegate = {
// setup window flags to mimic popover on macOS
const appWindow = new BrowserWindow({
...options,
- // 12 is the size of transparent area around arrow
- height: contentHeight + 12,
- minHeight: contentHeight + 12,
+ height: contentHeight + headerBarArrowHeight,
+ minHeight: contentHeight + headerBarArrowHeight,
transparent: true,
});
@@ -285,18 +371,12 @@ const appDelegate = {
transparent: true,
});
- case 'linux':
- return new BrowserWindow({
- ...options,
- show: true,
- });
-
default:
return new BrowserWindow(options);
}
},
- _setAppMenu: () => {
+ _setAppMenu() {
const template = [
{
label: 'Mullvad',
@@ -316,7 +396,7 @@ const appDelegate = {
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
},
- _addContextMenu: (window: BrowserWindow) => {
+ _addContextMenu(window: BrowserWindow) {
let menuTemplate = [
{ role: 'cut' },
{ role: 'copy' },
@@ -341,12 +421,12 @@ const appDelegate = {
let inputMenu = menuTemplate;
// mixin 'inspect element' into standard menu when in development mode
- if (isDevelopment) {
+ if (process.env.NODE_ENV === 'development') {
inputMenu = menuTemplate.concat([{ type: 'separator' }], inspectTemplate);
}
Menu.buildFromTemplate(inputMenu).popup(window);
- } else if (isDevelopment) {
+ } else if (process.env.NODE_ENV === 'development') {
// display inspect element for all non-editable
// elements when in development mode
Menu.buildFromTemplate(inspectTemplate).popup(window);
@@ -354,169 +434,51 @@ const appDelegate = {
});
},
- _toggleWindow: (window: BrowserWindow, tray: ?Tray) => {
- if (window.isVisible()) {
- window.hide();
- } else {
- appDelegate._showWindow(window, tray);
- }
- },
-
- _updateWindowPosition: (window: BrowserWindow, tray: Tray) => {
- const { x, y } = appDelegate._getWindowPosition(window, tray);
- window.setPosition(x, y, false);
- },
+ _createTray(): Tray {
+ const tray = new Tray(nativeImage.createEmpty());
+ tray.setToolTip('Mullvad VPN');
- _showWindow: (window: BrowserWindow, tray: ?Tray) => {
- if (tray) {
- appDelegate._updateWindowPosition(window, tray);
+ // disable icon highlight on macOS
+ if (process.platform === 'darwin') {
+ tray.setHighlightMode('never');
}
- window.show();
- window.focus();
+ return tray;
},
- _getTrayPlacement: () => {
- switch (process.platform) {
- case 'darwin':
- // macOS has menubar always placed at the top
- return 'top';
-
- case 'win32': {
- // taskbar occupies some part of the screen excluded from work area
- const primaryDisplay = electron.screen.getPrimaryDisplay();
- const displaySize = primaryDisplay.size;
- const workArea = primaryDisplay.workArea;
+ _isMenubarApp() {
+ const platform = process.platform;
- if (workArea.width < displaySize.width) {
- return workArea.x > 0 ? 'left' : 'right';
- } else if (workArea.height < displaySize.height) {
- return workArea.y > 0 ? 'top' : 'bottom';
- } else {
- return 'none';
- }
- }
-
- default:
- return 'none';
- }
+ return platform === 'windows' || platform === 'darwin';
},
- _getWindowPosition: (window: BrowserWindow, tray: Tray): { x: number, y: number } => {
- const windowBounds = window.getBounds();
- const trayBounds = tray.getBounds();
-
- const primaryDisplay = electron.screen.getPrimaryDisplay();
- const workArea = primaryDisplay.workArea;
- const placement = appDelegate._getTrayPlacement();
- const maxX = workArea.x + workArea.width - windowBounds.width;
- const maxY = workArea.y + workArea.height - windowBounds.height;
-
- let x = 0,
- y = 0;
- switch (placement) {
- case 'top':
- x = trayBounds.x + (trayBounds.width - windowBounds.width) * 0.5;
- y = workArea.y;
- break;
-
- case 'bottom':
- x = trayBounds.x + (trayBounds.width - windowBounds.width) * 0.5;
- y = workArea.y + workArea.height - windowBounds.height;
- break;
-
- case 'left':
- x = workArea.x;
- y = trayBounds.y + (trayBounds.height - windowBounds.height) * 0.5;
+ _installMenubarAppEventHandlers(windowController: WindowController) {
+ switch (process.platform) {
+ case 'windows':
+ windowController.window.on('blur', () => windowController.hide());
break;
- case 'right':
- x = workArea.width - windowBounds.width;
- y = trayBounds.y + (trayBounds.height - windowBounds.height) * 0.5;
+ case 'darwin':
+ this._installMacOsMenubarAppWindowHandlers(windowController);
break;
- case 'none':
- x = workArea.x + (workArea.width - windowBounds.width) * 0.5;
- y = workArea.y + (workArea.height - windowBounds.height) * 0.5;
+ default:
break;
}
-
- x = Math.min(Math.max(x, workArea.x), maxX);
- y = Math.min(Math.max(y, workArea.y), maxY);
-
- return {
- x: Math.round(x),
- y: Math.round(y),
- };
- },
-
- _createTray: (window: BrowserWindow): Tray => {
- const tray = new Tray(nativeImage.createEmpty());
-
- // configure tray icon
- tray.setToolTip('Mullvad VPN');
- tray.on('click', () => appDelegate._toggleWindow(window, tray));
-
- // add display metrics change handler
- electron.screen.addListener('display-metrics-changed', (_event, _display, changedMetrics) => {
- if (changedMetrics.includes('workArea') && window.isVisible()) {
- appDelegate._updateWindowPosition(window, tray);
- }
- });
-
- // add IPC handler to change tray icon from renderer
- const trayIconManager = new TrayIconManager(tray, 'unsecured');
- ipcMain.on(
- 'changeTrayIcon',
- (_: Event, type: TrayIconType) => (trayIconManager.iconType = type),
- );
-
- // setup event handlers
- window.on('close', () => window.closeDevTools());
- if (process.platform !== 'linux') {
- window.on('blur', () => !window.isDevToolsOpened() && window.hide());
- }
-
- if (process.platform === 'darwin') {
- // disable icon highlight on macOS
- tray.setHighlightMode('never');
-
- // apply macOS patch for windows.blur
- appDelegate._macOSFixInconsistentWindowBlur(window);
- }
-
- return tray;
},
// setup NSEvent monitor to fix inconsistent window.blur on macOS
// see https://github.com/electron/electron/issues/8689
- _macOSFixInconsistentWindowBlur: (window: BrowserWindow) => {
+ _installMacOsMenubarAppWindowHandlers(windowController: WindowController) {
// $FlowFixMe: this module is only available on macOS
const { NSEventMonitor, NSEventMask } = require('nseventmonitor');
const macEventMonitor = new NSEventMonitor();
const eventMask = NSEventMask.leftMouseDown | NSEventMask.rightMouseDown;
+ const window = windowController.window;
- window.on('show', () => macEventMonitor.start(eventMask, () => window.hide()));
+ window.on('show', () => macEventMonitor.start(eventMask, () => windowController.hide()));
window.on('hide', () => macEventMonitor.stop());
},
};
-try {
- // This callback is guaranteed to be excuted after 'ready' events have been
- // sent to the app.
- const notFirstInstance = app.makeSingleInstance((_args, _workingDirectory) => {
- log.debug('Another instance was spawned, showing window');
- const window = appDelegate._window;
- if (window != null) {
- appDelegate._showWindow(window, appDelegate._tray);
- }
- });
-
- if (notFirstInstance) {
- log.info('Another instance already exists, shutting down');
- app.exit();
- }
-} catch (e) {
- log.error('Failed to check if another instance is running: ', e);
-}
-appDelegate.setup();
+ApplicationMain.run();
diff --git a/app/lib/tray-icon-manager.js b/app/tray-icon-controller.js
index 915bd2c8b8..b4871c42d2 100644
--- a/app/lib/tray-icon-manager.js
+++ b/app/tray-icon-controller.js
@@ -1,12 +1,11 @@
// @flow
import path from 'path';
-import KeyframeAnimation from './keyframe-animation';
-
+import KeyframeAnimation from './lib/keyframe-animation';
import type { Tray } from 'electron';
export type TrayIconType = 'unsecured' | 'securing' | 'secured';
-export default class TrayIconManager {
+export default class TrayIconController {
_animation: ?KeyframeAnimation;
_iconType: TrayIconType;
@@ -27,23 +26,11 @@ export default class TrayIconManager {
}
}
- _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, 10]);
- animation.speed = 100;
- return animation;
- }
-
- _isReverseAnimation(type: TrayIconType): boolean {
- return type === 'unsecured';
- }
-
get iconType(): TrayIconType {
return this._iconType;
}
- set iconType(type: TrayIconType) {
+ animateToIcon(type: TrayIconType) {
if (this._iconType === type || !this._animation) {
return;
}
@@ -59,4 +46,16 @@ export default class TrayIconManager {
this._iconType = type;
}
+
+ _createAnimation(): KeyframeAnimation {
+ const basePath = path.join(__dirname, 'assets/images/menubar icons');
+ const filePath = path.join(basePath, 'lock-{}.png');
+ const animation = KeyframeAnimation.fromFilePattern(filePath, [1, 10]);
+ animation.speed = 100;
+ return animation;
+ }
+
+ _isReverseAnimation(type: TrayIconType): boolean {
+ return type === 'unsecured';
+ }
}
diff --git a/app/window-controller.js b/app/window-controller.js
new file mode 100644
index 0000000000..849da007cf
--- /dev/null
+++ b/app/window-controller.js
@@ -0,0 +1,160 @@
+// @flow
+
+import electron, { screen } from 'electron';
+import type { BrowserWindow, Tray, Display } from 'electron';
+
+export default class WindowController {
+ _window: BrowserWindow;
+ _tray: Tray;
+ _isWindowReady = false;
+
+ get window(): BrowserWindow {
+ return this._window;
+ }
+
+ constructor(window: BrowserWindow, tray: Tray) {
+ this._window = window;
+ this._tray = tray;
+
+ this._installDisplayMetricsHandler();
+ this._installWindowReadyHandlers();
+ }
+
+ show(whenReady: boolean = true) {
+ if (whenReady) {
+ this._executeWhenWindowIsReady(() => this._showImmediately());
+ } else {
+ this._showImmediately();
+ }
+ }
+
+ hide() {
+ this._window.hide();
+ }
+
+ toggle() {
+ if (this._window.isVisible()) {
+ this.hide();
+ } else {
+ this.show();
+ }
+ }
+
+ _showImmediately() {
+ const window = this._window;
+
+ this._updatePosition();
+
+ window.show();
+ window.focus();
+ }
+
+ _updatePosition() {
+ const { x, y } = this._getWindowPosition();
+ this._window.setPosition(x, y, false);
+ }
+
+ _getTrayPlacement() {
+ switch (process.platform) {
+ case 'darwin':
+ // macOS has menubar always placed at the top
+ return 'top';
+
+ case 'win32': {
+ // taskbar occupies some part of the screen excluded from work area
+ const primaryDisplay = electron.screen.getPrimaryDisplay();
+ const displaySize = primaryDisplay.size;
+ const workArea = primaryDisplay.workArea;
+
+ if (workArea.width < displaySize.width) {
+ return workArea.x > 0 ? 'left' : 'right';
+ } else if (workArea.height < displaySize.height) {
+ return workArea.y > 0 ? 'top' : 'bottom';
+ } else {
+ return 'none';
+ }
+ }
+
+ default:
+ return 'none';
+ }
+ }
+
+ _getWindowPosition(): { x: number, y: number } {
+ const windowBounds = this._window.getBounds();
+ const trayBounds = this._tray.getBounds();
+
+ const primaryDisplay = electron.screen.getPrimaryDisplay();
+ const workArea = primaryDisplay.workArea;
+ const placement = this._getTrayPlacement();
+ const maxX = workArea.x + workArea.width - windowBounds.width;
+ const maxY = workArea.y + workArea.height - windowBounds.height;
+
+ let x = 0,
+ y = 0;
+ switch (placement) {
+ case 'top':
+ x = trayBounds.x + (trayBounds.width - windowBounds.width) * 0.5;
+ y = workArea.y;
+ break;
+
+ case 'bottom':
+ x = trayBounds.x + (trayBounds.width - windowBounds.width) * 0.5;
+ y = workArea.y + workArea.height - windowBounds.height;
+ break;
+
+ case 'left':
+ x = workArea.x;
+ y = trayBounds.y + (trayBounds.height - windowBounds.height) * 0.5;
+ break;
+
+ case 'right':
+ x = workArea.width - windowBounds.width;
+ y = trayBounds.y + (trayBounds.height - windowBounds.height) * 0.5;
+ break;
+
+ case 'none':
+ x = workArea.x + (workArea.width - windowBounds.width) * 0.5;
+ y = workArea.y + (workArea.height - windowBounds.height) * 0.5;
+ break;
+ }
+
+ x = Math.min(Math.max(x, workArea.x), maxX);
+ y = Math.min(Math.max(y, workArea.y), maxY);
+
+ return {
+ x: Math.round(x),
+ y: Math.round(y),
+ };
+ }
+
+ // Installs display event handlers to update the window position on any changes in the display or workarea dimensions.
+ _installDisplayMetricsHandler() {
+ screen.addListener('display-metrics-changed', this._onDisplayMetricsChanged);
+ this._window.once('closed', () => {
+ screen.removeListener('display-metrics-changed', this._onDisplayMetricsChanged);
+ });
+ }
+
+ _onDisplayMetricsChanged = (_event: any, _display: Display, changedMetrics: Array<string>) => {
+ if (changedMetrics.includes('workArea') && this._window.isVisible()) {
+ this._updatePosition();
+ }
+ };
+
+ _installWindowReadyHandlers() {
+ this._window.once('ready-to-show', () => {
+ this._isWindowReady = true;
+ });
+ }
+
+ _executeWhenWindowIsReady(closure: () => any) {
+ if (this._isWindowReady) {
+ closure();
+ } else {
+ this._window.once('ready-to-show', () => {
+ closure();
+ });
+ }
+ }
+}
diff --git a/flow-libs/electron.js.flow b/flow-libs/electron.js.flow
index 9ff6242389..b0facb6c7e 100644
--- a/flow-libs/electron.js.flow
+++ b/flow-libs/electron.js.flow
@@ -122,7 +122,9 @@ declare module 'electron' {
declare class IpcMain extends EventEmitter {}
- declare class WebContents extends EventEmitter {}
+ declare class WebContents extends EventEmitter {
+ send(channel: string, ...args: Array<mixed>): void;
+ }
// http://electron.atom.io/docs/api/browser-window
diff --git a/package.json b/package.json
index 6e2d0020ac..de77df50c8 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,7 @@
"react-router": "^4.2.0",
"react-router-redux": "^5.0.0-alpha.9",
"react-simple-maps": "^0.10.1",
- "reactxp": "^1.1.0-rc.2",
+ "reactxp": "1.3.0-rc.0",
"redux": "^3.0.0",
"redux-thunk": "^2.2.0",
"uuid": "^3.0.1",
diff --git a/scripts/serve.js b/scripts/serve.js
index 1701a34103..a3d0bb6421 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -1,5 +1,4 @@
import { spawn } from 'child_process';
-import electron from 'electron';
import browserSync from 'browser-sync';
import browserSyncConnectUtils from 'browser-sync/dist/connect-utils';
@@ -31,7 +30,7 @@ bsync.init({
}, (err, bs) => {
if (err) return console.error(err);
- const child = spawn(electron, ['.', '--enable-logging'], {
+ const child = spawn('electron', ['.'], {
env: {
...{
NODE_ENV: 'development',
diff --git a/yarn.lock b/yarn.lock
index 39b1c31d7c..ae0dcff3e3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -111,9 +111,9 @@
version "8.9.4"
resolved "https://registry.yarnpkg.com/@types/node/-/node-8.9.4.tgz#dfd327582a06c114eb6e0441fa3d6fab35edad48"
-"@types/react-dom@^16.0.3":
- version "16.0.4"
- resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.0.4.tgz#2e8fd45f5443780ed49bf2cdd9809e6091177a7d"
+"@types/react-dom@^16.0.6":
+ version "16.0.6"
+ resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.0.6.tgz#f1a65a4e7be8ed5d123f8b3b9eacc913e35a1a3c"
dependencies:
"@types/node" "*"
"@types/react" "*"
@@ -122,9 +122,11 @@
version "16.0.31"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.0.31.tgz#5285da62f3ac62b797f6d0729a1d6181f3180c3e"
-"@types/react@^16.0.36":
- version "16.0.38"
- resolved "https://registry.yarnpkg.com/@types/react/-/react-16.0.38.tgz#76617433ea10274505f60bb86eddfdd0476ffdc2"
+"@types/react@^16.3.17":
+ version "16.3.17"
+ resolved "https://registry.yarnpkg.com/@types/react/-/react-16.3.17.tgz#d59d1a632570b0713946ed9c2949d994773633c5"
+ dependencies:
+ csstype "^2.2.0"
abbrev@1:
version "1.1.1"
@@ -2199,6 +2201,10 @@ cssmin@0.3.x:
version "0.3.2"
resolved "https://registry.yarnpkg.com/cssmin/-/cssmin-0.3.2.tgz#ddce4c547b510ae0d594a8f1fbf8aaf8e2c5c00d"
+csstype@^2.2.0:
+ version "2.5.3"
+ resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.5.3.tgz#2504152e6e1cc59b32098b7f5d6a63f16294c1f7"
+
csurf@~1.8.3:
version "1.8.3"
resolved "https://registry.yarnpkg.com/csurf/-/csurf-1.8.3.tgz#23f2a13bf1d8fce1d0c996588394442cba86a56a"
@@ -2696,8 +2702,8 @@ electron-builder@^19.37.2:
yargs "^10.1.1"
electron-devtools-installer@^2.2.1:
- version "2.2.3"
- resolved "https://registry.yarnpkg.com/electron-devtools-installer/-/electron-devtools-installer-2.2.3.tgz#58b9a4ec507377bc46e091cd43714188e0c369be"
+ version "2.2.4"
+ resolved "https://registry.yarnpkg.com/electron-devtools-installer/-/electron-devtools-installer-2.2.4.tgz#261a50337e37121d338b966f07922eb4939a8763"
dependencies:
"7zip" "0.0.6"
cross-unzip "0.0.2"
@@ -5591,9 +5597,9 @@ npmlog@^4.0.2:
gauge "~2.7.3"
set-blocking "~2.0.0"
-"nseventmonitor@https://github.com/mullvad/NSEventMonitor.git#0.0.8":
- version "0.0.8"
- resolved "https://github.com/mullvad/NSEventMonitor.git#23d8d017282877b1863bb4f96676aead7fb72892"
+"nseventmonitor@https://github.com/mullvad/NSEventMonitor.git#0.0.9":
+ version "0.0.9"
+ resolved "https://github.com/mullvad/NSEventMonitor.git#ff5bad00d954b6cba7b16a1ebd013fea0bb79292"
dependencies:
node-pre-gyp "^0.10.0"
@@ -6426,20 +6432,20 @@ react@^16.0.0:
object-assign "^4.1.1"
prop-types "^15.6.0"
-reactxp@^1.1.0-rc.2:
- version "1.1.0-rc.2"
- resolved "https://registry.yarnpkg.com/reactxp/-/reactxp-1.1.0-rc.2.tgz#ab50c67e534b4890031016e44921a79fa3f65e23"
+reactxp@1.3.0-rc.0:
+ version "1.3.0-rc.0"
+ resolved "https://registry.yarnpkg.com/reactxp/-/reactxp-1.3.0-rc.0.tgz#13c59bbeb8d986db5f3fece0e3f0a421ea53f6ac"
dependencies:
"@types/lodash" "^4.14.80"
- "@types/react" "^16.0.36"
- "@types/react-dom" "^16.0.3"
+ "@types/react" "^16.3.17"
+ "@types/react-dom" "^16.0.6"
assert "^1.3.0"
ifvisible "^1.1.0"
lodash "^4.17.4"
prop-types "^15.5.9"
- rebound "^0.0.13"
+ rebound "^0.1.0"
subscribableevent "^1.0.0"
- synctasks "^0.3.1"
+ synctasks "^0.3.3"
read-all-stream@^3.0.0:
version "3.1.0"
@@ -6584,9 +6590,9 @@ readline2@^1.0.1:
is-fullwidth-code-point "^1.0.0"
mute-stream "0.0.5"
-rebound@^0.0.13:
- version "0.0.13"
- resolved "https://registry.yarnpkg.com/rebound/-/rebound-0.0.13.tgz#4a225254caf7da756797b19c5817bf7a7941fac1"
+rebound@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/rebound/-/rebound-0.1.0.tgz#0638c61a93666bb515a58a03e1cfb34021e88b72"
rechoir@^0.6.2:
version "0.6.2"
@@ -7532,9 +7538,9 @@ sync-exec@~0.6.x:
version "0.6.2"
resolved "https://registry.yarnpkg.com/sync-exec/-/sync-exec-0.6.2.tgz#717d22cc53f0ce1def5594362f3a89a2ebb91105"
-synctasks@^0.3.1:
- version "0.3.1"
- resolved "https://registry.yarnpkg.com/synctasks/-/synctasks-0.3.1.tgz#1f9012b23792ad775ba2693e0cafcfcd65b80d97"
+synctasks@^0.3.3:
+ version "0.3.3"
+ resolved "https://registry.yarnpkg.com/synctasks/-/synctasks-0.3.3.tgz#1e3dde423b39d28bc940fdb7698d8b4b7a741e77"
table@4.0.2, table@^4.0.2:
version "4.0.2"
@@ -8165,9 +8171,9 @@ window-size@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.2.0.tgz#b4315bb4214a3d7058ebeee892e13fa24d98b075"
-"windows-security@https://github.com/mullvad/windows-security.git#0.0.4":
- version "0.0.4"
- resolved "https://github.com/mullvad/windows-security.git#066f2915054c55e1ac7cc671b9c8de6b16ccdead"
+"windows-security@https://github.com/mullvad/windows-security.git#0.0.5":
+ version "0.0.5"
+ resolved "https://github.com/mullvad/windows-security.git#311fcfeb137e9e10b25929b0615f9188e118004f"
dependencies:
node-pre-gyp "^0.10.0"