// @flow import path from 'path'; import fs from 'fs'; import mkdirp from 'mkdirp'; import { log } from './lib/platform'; 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'; import { getSystemTemporaryDirectory } from './lib/tempdir'; import { canTrustRpcAddressFile } from './lib/rpc-file-security'; import { execFile } from 'child_process'; import uuid from 'uuid'; import { ShutdownCoordinator } from './shutdown-handler'; 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'; const ApplicationMain = { _windowController: (null: ?WindowController), _trayIconController: (null: ?TrayIconController), _logFilePath: '', _connectionFilePollInterval: (null: ?IntervalID), run() { if (this._ensureSingleInstance()) { return; } // Override userData path, i.e on macOS: ~/Library/Application Support/Mullvad VPN app.setPath('userData', path.join(app.getPath('appData'), appDirectoryName)); this._initLogging(); log.info('Running version', version); 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 = this._getLogsDirectory(); const format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}][{level}] {text}'; this._logFilePath = path.join(logDirectory, 'frontend.log'); log.transports.console.format = format; log.transports.file.format = format; if (process.env.NODE_ENV === 'development') { 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 = this._logFilePath; } // create log folder mkdirp.sync(logDirectory); }, // 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() { switch (process.platform) { case 'darwin': // macOS: ~/Library/Logs/{appname} return path.join(app.getPath('home'), 'Library/Logs', appDirectoryName); default: // Windows: %APPDATA%\{appname}\logs // Linux: ~/.config/{appname}/logs return path.join(app.getPath('userData'), 'logs'); } }, async _onReady() { const window = this._createWindow(); const tray = this._createTray(); const _shutdownCoordinator = new ShutdownCoordinator(window.webContents); const windowController = new WindowController(window, tray); const trayIconController = new TrayIconController(tray, 'unsecured'); tray.on('click', () => windowController.toggle()); this._registerWindowIpcEvents(window); this._registerIpcListeners(); this._setAppMenu(); this._addContextMenu(window); this._windowController = windowController; this._trayIconController = trayIconController; if (process.env.NODE_ENV === 'development') { await this._installDevTools(); window.on('close', () => window.closeDevTools()); window.openDevTools({ mode: 'detach' }); } if (this._isMenubarApp()) { this._installMenubarAppEventHandlers(windowController); } else { windowController.show(); } window.loadFile('build/index.html'); }, _registerWindowIpcEvents(window: BrowserWindow) { // Notify renderer when window visibility changes. window.on('show', () => window.webContents.send('show-window')); window.on('hide', () => window.webContents.send('hide-window')); }, _registerIpcListeners() { ipcMain.on('on-browser-window-ready', () => { this._pollConnectionInfoFile(); }); 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.push('--redact', ...toRedact, '--'); } args.push(this._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()}`, ); event.sender.send('collect-logs-reply', requestId, { success: false, error: error.message, }); } else { log.debug(`Problem report was written to ${reportPath}`); event.sender.send('collect-logs-reply', requestId, { success: true, reportPath, }); } }); }); 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]; 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()}`, ); event.sender.send('send-problem-report-reply', requestId, { success: false, error: error.message, }); } else { log.info('Problem report was sent.'); event.sender.send('send-problem-report-reply', requestId, { success: true, }); } }); }, ); }, _getRpcAddressFilePath() { const rpcAddressFileName = '.mullvad_rpc_address'; switch (process.platform) { case 'win32': { // Windows: %ALLUSERSPROFILE%\{appname} let programDataDirectory = process.env.ALLUSERSPROFILE; 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); } }, _pollConnectionInfoFile() { if (this._connectionFilePollInterval) { log.warn( 'Attempted to start polling for the RPC connection info file while another polling was already running', ); return; } const pollIntervalMs = 200; const rpcAddressFile = this._getRpcAddressFilePath(); this._connectionFilePollInterval = setInterval(() => { if (fs.existsSync(rpcAddressFile)) { if (this._connectionFilePollInterval) { clearInterval(this._connectionFilePollInterval); this._connectionFilePollInterval = null; } this._sendDaemonConnectionInfo(rpcAddressFile); } }, pollIntervalMs); }, _sendDaemonConnectionInfo(rpcAddressFile: string) { log.debug(`Reading the ipc connection info from "${rpcAddressFile}"`); try { if (!canTrustRpcAddressFile(rpcAddressFile)) { log.error(`Not trusting the contents of "${rpcAddressFile}".`); return; } } catch (e) { log.error(`Cannot verify the credibility of RPC address file: ${e.message}`); 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', (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); const windowController = this._windowController; if (windowController) { windowController.window.webContents.send('backend-info', { credentials }); } } else { log.error('Could not parse IPC credentials.'); } }); }, async _installDevTools() { 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; // the size of transparent area around arrow on macOS const headerBarArrowHeight = 12; const options = { width: 320, minWidth: 320, height: contentHeight, minHeight: contentHeight, resizable: false, maximizable: false, fullscreenable: false, show: false, frame: false, webPreferences: { // prevents renderer process code from not running when window is hidden backgroundThrottling: false, // Enable experimental features blinkFeatures: 'CSSBackdropFilter', }, }; switch (process.platform) { case 'darwin': { // setup window flags to mimic popover on macOS const appWindow = new BrowserWindow({ ...options, height: contentHeight + headerBarArrowHeight, minHeight: contentHeight + headerBarArrowHeight, transparent: true, }); // make the window visible on all workspaces appWindow.setVisibleOnAllWorkspaces(true); return appWindow; } case 'win32': // setup window flags to mimic an overlay window return new BrowserWindow({ ...options, transparent: true, }); default: 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, isEditable: boolean }) => { 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 (process.env.NODE_ENV === 'development') { inputMenu = menuTemplate.concat([{ type: 'separator' }], inspectTemplate); } Menu.buildFromTemplate(inputMenu).popup(window); } else if (process.env.NODE_ENV === 'development') { // display inspect element for all non-editable // elements when in development mode Menu.buildFromTemplate(inspectTemplate).popup(window); } }, ); }, _createTray(): Tray { const tray = new Tray(nativeImage.createEmpty()); tray.setToolTip('Mullvad VPN'); // disable icon highlight on macOS if (process.platform === 'darwin') { tray.setHighlightMode('never'); } return tray; }, _isMenubarApp() { const platform = process.platform; return platform === 'windows' || platform === 'darwin'; }, _installMenubarAppEventHandlers(windowController: WindowController) { switch (process.platform) { case 'windows': windowController.window.on('blur', () => windowController.hide()); break; case 'darwin': this._installMacOsMenubarAppWindowHandlers(windowController); break; default: break; } }, // setup NSEvent monitor to fix inconsistent window.blur on macOS // see https://github.com/electron/electron/issues/8689 _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, () => windowController.hide())); window.on('hide', () => macEventMonitor.stop()); }, }; ApplicationMain.run();