diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2020-08-14 12:41:50 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2020-08-14 12:41:50 +0200 |
| commit | d94f70ec81d291b5d8d348c40058e25e81e07562 (patch) | |
| tree | d5bf20348b6c7e502ba4d735f939ff2f618c1d10 | |
| parent | faf933331f93e1a8d771babb31f3ac37a2f9851f (diff) | |
| parent | 9ecbf3a72e2d4f8a4fb2ce6794beb49cda03bf34 (diff) | |
| download | mullvadvpn-d94f70ec81d291b5d8d348c40058e25e81e07562.tar.xz mullvadvpn-d94f70ec81d291b5d8d348c40058e25e81e07562.zip | |
Merge branch 'linux-split-tunneling-gui' into master
22 files changed, 719 insertions, 55 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index a86a24913f..a0f48dd9f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Line wrap the file at 100 chars. Th - Add fish shell completions for the mullvad CLI. - Reconnect with a new key when WireGuard key is rotated automatically, previously the tunnel would time out before reconnecting. +- Add split tunneling menu under advanced settings in Linux app. #### Android - Add split-tunnelling, allowing apps to be configured to be excluded from the tunnel. diff --git a/gui/package-lock.json b/gui/package-lock.json index 5483118558..0e52689192 100644 --- a/gui/package-lock.json +++ b/gui/package-lock.json @@ -1143,6 +1143,11 @@ } } }, + "argv-split": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argv-split/-/argv-split-2.0.1.tgz", + "integrity": "sha1-viZBF3kNvVzNY+w/RJoYBIFKxMU=" + }, "arr-diff": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", @@ -6782,6 +6787,11 @@ "integrity": "sha512-XCpr5bElgDI65vVgstP8TWjv6/QKWm9GU5UG0Pr5sLQ3QLo8NVKsioe+Jed5/3vFOe3IQuqE7DKwTvKQkjTHvg==", "dev": true }, + "linux-app-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/linux-app-list/-/linux-app-list-1.0.1.tgz", + "integrity": "sha1-w76XF+Ngg0KTmR06Ju2DtPXqAn0=" + }, "load-json-file": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", diff --git a/gui/package.json b/gui/package.json index 2431d7d181..5e01303197 100644 --- a/gui/package.json +++ b/gui/package.json @@ -12,12 +12,14 @@ "repository": "https://github.com/mullvad/mullvadvpn-app", "license": "GPL-3.0", "dependencies": { + "argv-split": "^2.0.1", "connected-react-router": "^6.8.0", "d3-geo-projection": "^2.7.0", "electron-log": "^4.1.1", "gettext-parser": "^4.0.3", "history": "^4.6.1", "jsonrpc-lite": "^2.0.7", + "linux-app-list": "^1.0.1", "mkdirp": "^1.0.3", "moment": "^2.24.0", "node-gettext": "^3.0.0", diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts index 6071ce515f..c3d3fe33a6 100644 --- a/gui/src/main/daemon-rpc.ts +++ b/gui/src/main/daemon-rpc.ts @@ -21,6 +21,7 @@ import JsonRpcClient, { SocketTransport, TimeOutError as JsonRpcTimeOutError, } from './jsonrpc-client'; +import { camelCaseToSnakeCase, snakeCaseToCamelCase } from './transform-object-keys'; import { validate } from 'validated/object'; import { @@ -491,7 +492,7 @@ export class DaemonRpc { public async getRelayLocations(): Promise<IRelayList> { const response = await this.transport.send('get_relay_locations'); try { - return camelCaseObjectKeys(validate(relayListSchema, response)); + return snakeCaseToCamelCase(validate(relayListSchema, response)); } catch (error) { throw new ResponseParseError(`Invalid response from get_relay_locations: ${error}`, error); } @@ -507,7 +508,7 @@ export class DaemonRpc { } public async updateRelaySettings(relaySettings: RelaySettingsUpdate): Promise<void> { - await this.transport.send('update_relay_settings', [underscoreObjectKeys(relaySettings)]); + await this.transport.send('update_relay_settings', [camelCaseToSnakeCase(relaySettings)]); } public async setAllowLan(allowLan: boolean): Promise<void> { @@ -563,7 +564,7 @@ export class DaemonRpc { try { const validatedObject = validate(locationSchema, response); if (validatedObject) { - return camelCaseObjectKeys(validatedObject); + return snakeCaseToCamelCase(validatedObject); } else { return undefined; } @@ -575,7 +576,7 @@ export class DaemonRpc { public async getState(): Promise<TunnelState> { const response = await this.transport.send('get_state'); try { - return camelCaseObjectKeys(validate(tunnelStateSchema, response)); + return snakeCaseToCamelCase(validate(tunnelStateSchema, response)); } catch (error) { throw new ResponseParseError('Invalid response from get_state', error); } @@ -584,7 +585,7 @@ export class DaemonRpc { public async getSettings(): Promise<ISettings> { const response = await this.transport.send('get_settings'); try { - return camelCaseObjectKeys(validate(settingsSchema, response)); + return snakeCaseToCamelCase(validate(settingsSchema, response)); } catch (error) { throw new ResponseParseError('Invalid response from get_settings', error); } @@ -597,7 +598,7 @@ export class DaemonRpc { let daemonEvent: DaemonEvent; try { - daemonEvent = camelCaseObjectKeys(validate(daemonEventSchema, payload)); + daemonEvent = snakeCaseToCamelCase(validate(daemonEventSchema, payload)); } catch (error) { listener.onError(new ResponseParseError('Invalid payload from daemon_event', error)); return; @@ -648,7 +649,7 @@ export class DaemonRpc { case 'generation_failure': return validatedResponse; default: - return camelCaseObjectKeys(validatedResponse as object); + return snakeCaseToCamelCase(validatedResponse as object); } } catch (error) { throw new ResponseParseError(`Invalid response from generate_wireguard_key ${error}`); @@ -676,48 +677,9 @@ export class DaemonRpc { public async getVersionInfo(): Promise<IAppVersionInfo> { const response = await this.transport.send('get_version_info', [], NETWORK_CALL_TIMEOUT); try { - return camelCaseObjectKeys(validate(appVersionInfoSchema, response)); + return snakeCaseToCamelCase(validate(appVersionInfoSchema, response)); } catch (error) { throw new ResponseParseError('Invalid response from get_version_info'); } } } - -function underscoreToCamelCase(str: string): string { - return str.replace(/_([a-z])/gi, (matches) => matches[1].toUpperCase()); -} - -function camelCaseToUnderscore(str: string): string { - return str - .replace(/[a-z0-9][A-Z]/g, (matches) => `${matches[0]}_${matches[1].toLowerCase()}`) - .toLowerCase(); -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function camelCaseObjectKeys<T>(anObject: { [key: string]: any }): T { - return transformObjectKeys(anObject, underscoreToCamelCase) as T; -} - -function underscoreObjectKeys<T>(anObject: T): Record<string, unknown> { - return transformObjectKeys(anObject, camelCaseToUnderscore); -} - -function transformObjectKeys( - anObject: { [key: string]: any }, // eslint-disable-line @typescript-eslint/no-explicit-any - keyTransformer: (key: string) => string, -) { - for (const sourceKey of Object.keys(anObject)) { - const targetKey = keyTransformer(sourceKey); - const sourceValue = anObject[sourceKey]; - - anObject[targetKey] = - sourceValue !== null && typeof sourceValue === 'object' - ? transformObjectKeys(sourceValue, keyTransformer) - : sourceValue; - - if (sourceKey !== targetKey) { - delete anObject[sourceKey]; - } - } - return anObject; -} diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts index f375de7eca..53ee113410 100644 --- a/gui/src/main/index.ts +++ b/gui/src/main/index.ts @@ -59,6 +59,9 @@ import ReconnectionBackoff from './reconnection-backoff'; import TrayIconController, { TrayIconType } from './tray-icon-controller'; import WindowController from './window-controller'; +// Only import when running app on Linux. +const linuxSplitTunneling = process.platform === 'linux' && require('./linux-split-tunneling'); + const DAEMON_RPC_PATH = process.platform === 'win32' ? '//./pipe/Mullvad VPN' : '/var/run/mullvad-vpn'; @@ -1005,6 +1008,22 @@ class ApplicationMain { }); IpcMainEventChannel.wireguardKeys.handleVerifyKey(() => this.daemonRpc.verifyWireguardKey()); + IpcMainEventChannel.splitTunneling.handleGetApplications(() => { + if (linuxSplitTunneling) { + return linuxSplitTunneling.getApplications(this.locale); + } else { + throw Error('linuxSplitTunneling called without being imported'); + } + }); + IpcMainEventChannel.splitTunneling.handleLaunchApplication((application) => { + if (linuxSplitTunneling) { + linuxSplitTunneling.launchApplication(application); + return Promise.resolve(); + } else { + throw Error('linuxSplitTunneling called without being imported'); + } + }); + ipcMain.on('show-window', () => { const windowController = this.windowController; if (windowController) { diff --git a/gui/src/main/linux-split-tunneling.ts b/gui/src/main/linux-split-tunneling.ts new file mode 100644 index 0000000000..b2b8c54de8 --- /dev/null +++ b/gui/src/main/linux-split-tunneling.ts @@ -0,0 +1,239 @@ +import argvSplit from 'argv-split'; +import child_process from 'child_process'; +import log from 'electron-log'; +import fs from 'fs'; +import linuxAppList, { AppData } from 'linux-app-list'; +import path from 'path'; +import ISplitTunnelingApplication from '../shared/linux-split-tunneling-application'; +import { pascalCaseToCamelCase } from './transform-object-keys'; + +const PROBLEMATIC_APPLICATIONS = { + launchingInExistingProcess: [ + 'brave-browser-stable', + 'chromium-browser', + 'firefox', + 'firefox-esr', + 'google-chrome-stable', + 'mate-terminal', + 'opera', + 'xfce4-terminal', + ], + launchingElsewhere: ['gnome-terminal'], +}; + +type DirectoryDescription = string | RegExp; + +interface IApplication extends ISplitTunnelingApplication { + type: string; + terminal?: string; + noDisplay?: string; + hidden?: string; + onlyShowIn?: string | string[]; + notShowIn?: string | string[]; + tryExec?: string; +} + +export function launchApplication(app: ISplitTunnelingApplication | string) { + const excludeArguments = typeof app === 'string' ? [app] : formatExec(app.exec); + child_process.spawn('mullvad-exclude', excludeArguments, { detached: true }); +} + +// Removes placeholder arguments and separates command into list of strings +function formatExec(exec: string) { + return argvSplit(exec).filter((argument: string) => !/%[cdDfFikmnNuUv]/.test(argument)); +} + +// TODO: Switch to asyncronous reading of .desktop files +export function getApplications(locale: string): Promise<ISplitTunnelingApplication[]> { + const appList = linuxAppList(); + const applications = appList + .list() + .map((filename) => { + const applications = localizeNameAndIcon(appList.data(filename), locale); + return pascalCaseToCamelCase<IApplication>(applications); + }) + .filter(shouldShowApplication) + .map(addApplicationWarnings) + .sort((a, b) => a.name.localeCompare(b.name)) + .map(async (app) => ({ ...app, icon: await replaceWithIconPath(app.icon) })); + + return Promise.all(applications); +} + +// Implemented according to freedesktop specification +// https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html +// TODO: Respect "TryExec" +function shouldShowApplication(application: IApplication): boolean { + const originalXdgCurrentDesktop = process.env.ORIGINAL_XDG_CURRENT_DESKTOP?.split(':') ?? []; + const xdgCurrentDesktop = process.env.XDG_CURRENT_DESKTOP?.split(':') ?? []; + const desktopEnvironments = originalXdgCurrentDesktop.concat(xdgCurrentDesktop); + + const notShowIn = + typeof application.notShowIn === 'string' ? [application.notShowIn] : application.notShowIn; + const onlyShowIn = + typeof application.onlyShowIn === 'string' ? [application.onlyShowIn] : application.onlyShowIn; + + const notShowInMatch = notShowIn?.some((desktopEnvironment) => + desktopEnvironments?.includes(desktopEnvironment), + ); + const onlyShowInMatch = + onlyShowIn?.some((desktopEnvironment) => desktopEnvironments?.includes(desktopEnvironment)) ?? + false; + + return ( + application.type === 'Application' && + application.name !== 'Mullvad VPN' && + application.exec !== undefined && + application.noDisplay !== 'true' && + application.terminal !== 'true' && + application.hidden !== 'true' && + !notShowInMatch && + (!application.onlyShowIn || onlyShowInMatch) + ); +} + +function addApplicationWarnings(application: IApplication): IApplication { + const binaryBasename = path.basename(application.exec!.split(' ')[0]); + if (PROBLEMATIC_APPLICATIONS.launchingInExistingProcess.includes(binaryBasename)) { + return { + ...application, + warning: 'launches-in-existing-process', + }; + } else if (PROBLEMATIC_APPLICATIONS.launchingElsewhere.includes(binaryBasename)) { + return { + ...application, + warning: 'launches-elsewhere', + }; + } else { + return application; + } +} + +function localizeNameAndIcon(application: AppData, locale: string) { + if (application.lang) { + // linux-app-list prefixes and suffixes the locale keys with a space for some reason. + const lang = Object.fromEntries( + Object.entries(application.lang).map(([locale, value]) => [locale.trim(), value]), + )[locale]; + + application.Name = lang?.Name ?? application.Name; + application.Icon = lang?.Icon ?? application.Icon; + } + + return application; +} + +function getGtkThemeDirectories(): Promise<DirectoryDescription[]> { + // "hicolor" is fallback theme and should always be checked. If no icon is found search is + // continued in other themes. + const themes = ['hicolor', /.*/]; + return new Promise((resolve, _reject) => { + // Electron modifies XDG_CURRENT_DESKTOP and saves the old value in ORIGINAL_XDG_CURRENT_DESKTOP + const xdgCurrentDesktop = + process.env.ORIGINAL_XDG_CURRENT_DESKTOP ?? process.env.XDG_CURRENT_DESKTOP ?? ''; + child_process.exec( + 'gsettings get org.gnome.desktop.interface icon-theme', + { env: { XDG_CURRENT_DESKTOP: xdgCurrentDesktop } }, + (error, stdout) => { + if (error) { + log.error('Error while retrieving theme', error); + resolve(themes); + } else { + const theme = stdout.trim().replace(new RegExp("^'|'$", 'g'), ''); + resolve(theme === '' ? themes : [theme, ...themes]); + } + }, + ); + }); +} + +// Implemented according to freedesktop specification. +// https://specifications.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html +function getIconDirectories() { + const directories: string[] = []; + + if (process.env.HOME) { + directories.push(path.join(process.env.HOME, '.icons')); + directories.push(path.join(process.env.HOME, '.local', 'share', 'icons')); // For KDE Plasma + } + + if (process.env.XDG_DATA_DIRS) { + const dataDirs = process.env.XDG_DATA_DIRS.split(':').map((dir) => path.join(dir, 'icons')); + directories.push(...dataDirs); + } + + directories.push('/usr/share/pixmaps'); + + return directories; +} + +async function replaceWithIconPath(name?: string) { + if (!name || path.isAbsolute(name)) { + return name; + } + + // Chrome doesn't support .xpm files + const extensions = ['svg', 'png']; + return findIcon(name, extensions, [ + getIconDirectories(), + await getGtkThemeDirectories(), + // Begin with preferred sized but if nothing matches other sizes should be considered as well. + ['scalable', '256x256', '512x512', '256x256@2x', '128x128@2x', '128x128', /^\d+x\d+(@2x)?$/], + // Search in all categories of icons. + [/.*/], + ]); +} + +// Searches through a directory tree according to the directory lists supplied. E.g. The arguments +// ('mullvad', ['svg', 'png'], [['a', 'b'], ['c', 'd']]) will search for mullvad.svg and mullvad.png +// in the directories a, a/c, a/d, b, b/c and b/d. +async function findIcon( + name: string, + extensions: string[], + [directories, ...restDirectories]: [string[], ...DirectoryDescription[][]], +): Promise<string | undefined> { + for (const directory of directories) { + let contents: string[] | undefined; + try { + contents = await fs.promises.readdir(directory); + } catch (error) { + // Non-existent directories and files (not a directory) are expected. + if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') { + log.error(`Failed to open directory while searching for ${name} icon`, error); + } + } + + if (contents) { + const iconPath = contents.find((item) => + extensions.some((extension) => item === `${name}.${extension}`), + ); + + if (iconPath) { + return path.join(directory, iconPath); + } else if (restDirectories.length > 0) { + const nextDirectories = matchDirectories(restDirectories[0], contents); + const iconPath = await findIcon(name, extensions, [ + nextDirectories.map((nextDirectory) => path.join(directory, nextDirectory)), + ...restDirectories.slice(1), + ]); + + if (iconPath) { + return iconPath; + } + } + } + } + + return undefined; +} + +function matchDirectories(directories: DirectoryDescription[], contents: string[]) { + const matches = directories + .map((directory) => + directory instanceof RegExp ? contents.filter((item) => directory.test(item)) : directory, + ) + .flat(); + + // Remove duplicates + return [...new Set(matches)]; +} diff --git a/gui/src/main/transform-object-keys.ts b/gui/src/main/transform-object-keys.ts new file mode 100644 index 0000000000..82b82793ec --- /dev/null +++ b/gui/src/main/transform-object-keys.ts @@ -0,0 +1,47 @@ +function pascalCaseToCamelCaseImpl(str: string): string { + return str.charAt(0).toLowerCase() + str.slice(1); +} + +function snakeCaseToCamelCaseImpl(str: string): string { + return str.replace(/_([a-z])/gi, (matches) => matches[1].toUpperCase()); +} + +function camelCaseToSnakeCaseImpl(str: string): string { + return str + .replace(/[a-z0-9][A-Z]/g, (matches) => `${matches[0]}_${matches[1].toLowerCase()}`) + .toLowerCase(); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function pascalCaseToCamelCase<T>(anObject: { [key: string]: any }): T { + return transformObjectKeys(anObject, pascalCaseToCamelCaseImpl) as T; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function snakeCaseToCamelCase<T>(anObject: { [key: string]: any }): T { + return transformObjectKeys(anObject, snakeCaseToCamelCaseImpl) as T; +} + +export function camelCaseToSnakeCase<T>(anObject: T): Record<string, unknown> { + return transformObjectKeys(anObject, camelCaseToSnakeCaseImpl); +} + +function transformObjectKeys( + anObject: { [key: string]: any }, // eslint-disable-line @typescript-eslint/no-explicit-any + keyTransformer: (key: string) => string, +) { + for (const sourceKey of Object.keys(anObject)) { + const targetKey = keyTransformer(sourceKey); + const sourceValue = anObject[sourceKey]; + + anObject[targetKey] = + sourceValue !== null && typeof sourceValue === 'object' + ? transformObjectKeys(sourceValue, keyTransformer) + : sourceValue; + + if (sourceKey !== targetKey) { + delete anObject[sourceKey]; + } + } + return anObject; +} diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index c09ca39049..cc65289a26 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -26,6 +26,7 @@ import { ICurrentAppVersionInfo } from '../main'; import { loadTranslations, messages, relayLocations } from '../shared/gettext'; import { IGuiSettingsState, SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state'; import { IpcRendererEventChannel, IRelayListPair } from '../shared/ipc-event-channel'; +import ISplitTunnelingApplication from '../shared/linux-split-tunneling-application'; import { getRendererLogFile, setupLogging } from '../shared/logging'; import consumePromise from '../shared/promise'; @@ -413,6 +414,14 @@ export default class AppRenderer { actions.settings.setWireguardKeygenEvent(keygenEvent); } + public getSplitTunnelingApplications() { + return IpcRendererEventChannel.splitTunneling.getApplications(); + } + + public launchExcludedApplication(application: ISplitTunnelingApplication | string) { + consumePromise(IpcRendererEventChannel.splitTunneling.launchApplication(application)); + } + public getPreferredLocaleList(): IPreferredLocaleDescriptor[] { return [ { diff --git a/gui/src/renderer/components/AdvancedSettings.tsx b/gui/src/renderer/components/AdvancedSettings.tsx index 21ab4ae88d..7d6724b001 100644 --- a/gui/src/renderer/components/AdvancedSettings.tsx +++ b/gui/src/renderer/components/AdvancedSettings.tsx @@ -62,6 +62,7 @@ interface IProps { setOpenVpnRelayProtocolAndPort: (protocol?: RelayProtocol, port?: number) => void; setWireguardRelayPort: (port?: number) => void; onViewWireguardKeys: () => void; + onViewLinuxSplitTunneling: () => void; onClose: () => void; } @@ -204,7 +205,7 @@ export default class AdvancedSettings extends Component<IProps, IState> { <View style={[ styles.advanced_settings__content, - styles.advanced_settings__tunnel_protocol, + styles.advanced_settings__cell_bottom_margin, ]}> <TunnelProtocolSelector title={messages.pgettext('advanced-settings-view', 'Tunnel protocol')} @@ -350,7 +351,12 @@ export default class AdvancedSettings extends Component<IProps, IState> { </Cell.FooterText> </Cell.Footer> - <View style={styles.advanced_settings__wgkeys_cell}> + <View + style={ + process.platform !== 'linux' + ? styles.advanced_settings__last_cell_bottom_margin + : undefined + }> <Cell.CellButton onClick={this.props.onViewWireguardKeys}> <Cell.Label> {messages.pgettext('advanced-settings-view', 'WireGuard key')} @@ -358,6 +364,17 @@ export default class AdvancedSettings extends Component<IProps, IState> { <Cell.Icon height={12} width={7} source="icon-chevron" /> </Cell.CellButton> </View> + + {process.platform === 'linux' && ( + <View style={styles.advanced_settings__last_cell_bottom_margin}> + <Cell.CellButton onClick={this.props.onViewLinuxSplitTunneling}> + <Cell.Label> + {messages.pgettext('advanced-settings-view', 'Split tunneling')} + </Cell.Label> + <Cell.Icon height={12} width={7} source="icon-chevron" /> + </Cell.CellButton> + </View> + )} </StyledNavigationScrollbars> </View> </NavigationContainer> diff --git a/gui/src/renderer/components/AdvancedSettingsStyles.tsx b/gui/src/renderer/components/AdvancedSettingsStyles.tsx index 421cb86a64..e39a7b4ed7 100644 --- a/gui/src/renderer/components/AdvancedSettingsStyles.tsx +++ b/gui/src/renderer/components/AdvancedSettingsStyles.tsx @@ -28,10 +28,10 @@ export default { advanced_settings__content: Styles.createViewStyle({ flex: 0, }), - advanced_settings__tunnel_protocol: Styles.createViewStyle({ + advanced_settings__cell_bottom_margin: Styles.createViewStyle({ marginBottom: 20, }), - advanced_settings__wgkeys_cell: Styles.createViewStyle({ + advanced_settings__last_cell_bottom_margin: Styles.createViewStyle({ marginBottom: 22, }), advanced_settings__wg_no_key: Styles.createTextStyle({ diff --git a/gui/src/renderer/components/ImageView.tsx b/gui/src/renderer/components/ImageView.tsx index 3b7ea88e84..6765b89a53 100644 --- a/gui/src/renderer/components/ImageView.tsx +++ b/gui/src/renderer/components/ImageView.tsx @@ -1,3 +1,4 @@ +import path from 'path'; import * as React from 'react'; import styled from 'styled-components'; @@ -40,7 +41,9 @@ const ImageMask = styled.div((props: IImageMaskProps) => { const HiddenImage = styled.img({ visibility: 'hidden' }); export default function ImageView(props: IImageViewProps) { - const url = `../../assets/images/${props.source}.svg`; + const url = path.isAbsolute(props.source) + ? props.source + : `../../assets/images/${props.source}.svg`; if (props.tintColor) { const { source: _source, ...otherProps } = props; diff --git a/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx b/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx new file mode 100644 index 0000000000..b018221932 --- /dev/null +++ b/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx @@ -0,0 +1,279 @@ +import { remote } from 'electron'; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useHistory } from 'react-router'; +import { sprintf } from 'sprintf-js'; +import styled from 'styled-components'; +import { colors } from '../../config.json'; +import { messages } from '../../shared/gettext'; +import ISplitTunnelingApplication from '../../shared/linux-split-tunneling-application'; +import consumePromise from '../../shared/promise'; +import { useAppContext } from '../context'; +import * as AppButton from './AppButton'; +import * as Cell from './Cell'; +import ImageView from './ImageView'; +import { Container, Layout } from './Layout'; +import { ModalContainer, ModalAlert, ModalAlertType } from './Modal'; +import { + BackBarItem, + NavigationBar, + NavigationContainer, + NavigationItems, + NavigationScrollbars, + TitleBarItem, +} from './NavigationBar'; +import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader'; + +const StyledPageCover = styled.div({}, (props: { show: boolean }) => ({ + position: 'absolute', + zIndex: 2, + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: '#000000', + opacity: 0.6, + display: props.show ? 'block' : 'none', +})); + +const StyledContainer = styled(Container)({ + backgroundColor: colors.darkBlue, +}); + +const StyledNavigationScrollbars = styled(NavigationScrollbars)({ + flex: 1, +}); + +const StyledContent = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 1, +}); + +const StyledCellButton = styled(Cell.CellButton)((props: { lookDisabled: boolean }) => ({ + ':not(:disabled):hover': { + backgroundColor: props.lookDisabled ? colors.blue : undefined, + }, +})); + +const disabledApplication = (props: { lookDisabled: boolean }) => ({ + opacity: props.lookDisabled ? 0.6 : undefined, +}); + +const StyledIcon = styled(Cell.UntintedIcon)(disabledApplication, { + marginRight: '12px', +}); + +const StyledCellLabel = styled(Cell.Label)(disabledApplication, { + fontWeight: 'normal', +}); + +const StyledIconPlaceholder = styled.div({ + width: '35px', + marginRight: '12px', +}); + +const StyledApplicationListContent = styled.div({ + display: 'flex', + flexDirection: 'column', +}); + +const StyledApplicationListAnimation = styled.div({}, (props: { height?: number }) => ({ + overflow: 'hidden', + height: props.height ? `${props.height}px` : 'auto', + transition: 'height 500ms ease-in-out', + marginBottom: '20px', +})); + +const StyledSpinnerRow = styled.div({ + display: 'flex', + justifyContent: 'center', + padding: '8px 0', + background: colors.blue40, +}); + +const StyledBrowseButton = styled(AppButton.BlueButton)({ + margin: '0 22px 22px', +}); + +export default function LinuxSplitTunnelingSettings() { + const { getSplitTunnelingApplications, launchExcludedApplication } = useAppContext(); + const history = useHistory(); + + const [applications, setApplications] = useState<ISplitTunnelingApplication[]>(); + const [applicationListHeight, setApplicationListHeight] = useState<number>(); + const [browsing, setBrowsing] = useState(false); + + const applicationListRef = useRef() as React.RefObject<HTMLDivElement>; + + const launchWithFilePicker = useCallback(async () => { + setBrowsing(true); + const file = await remote.dialog.showOpenDialog({ + properties: ['openFile'], + buttonLabel: messages.pgettext('split-tunneling-view', 'Launch application'), + }); + setBrowsing(false); + + if (file.filePaths[0]) { + launchExcludedApplication(file.filePaths[0]); + } + }, []); + + useEffect(() => { + consumePromise(getSplitTunnelingApplications().then(setApplications)); + }, []); + + useLayoutEffect(() => { + const height = applicationListRef.current?.getBoundingClientRect().height; + setApplicationListHeight(height); + }, [applications]); + + return ( + <> + <StyledPageCover show={browsing} /> + <ModalContainer> + <Layout> + <StyledContainer> + <NavigationContainer> + <NavigationBar> + <NavigationItems> + <BackBarItem action={history.goBack}> + { + // TRANSLATORS: Back button in navigation bar + messages.pgettext('navigation-bar', 'Advanced') + } + </BackBarItem> + <TitleBarItem> + { + // TRANSLATORS: Title label in navigation bar + messages.pgettext('split-tunneling-nav', 'Split tunneling') + } + </TitleBarItem> + </NavigationItems> + </NavigationBar> + + <StyledNavigationScrollbars> + <StyledContent> + <SettingsHeader> + <HeaderTitle> + {messages.pgettext('split-tunneling-view', 'Split tunneling')} + </HeaderTitle> + <HeaderSubTitle> + {messages.pgettext( + 'split-tunneling-view', + 'Click on an app to launch it. Its traffic will bypass the VPN tunnel until you close it.', + )} + </HeaderSubTitle> + </SettingsHeader> + + <StyledApplicationListAnimation height={applicationListHeight}> + <StyledApplicationListContent ref={applicationListRef}> + {applications === undefined ? ( + <StyledSpinnerRow> + <ImageView source="icon-spinner" height={60} width={60} /> + </StyledSpinnerRow> + ) : ( + applications.map((application) => ( + <ApplicationRow + key={application.absolutepath} + application={application} + launchApplication={launchExcludedApplication} + /> + )) + )} + </StyledApplicationListContent> + </StyledApplicationListAnimation> + + <StyledBrowseButton onClick={launchWithFilePicker}> + {messages.pgettext('split-tunneling-view', 'Browse')} + </StyledBrowseButton> + </StyledContent> + </StyledNavigationScrollbars> + </NavigationContainer> + </StyledContainer> + </Layout> + </ModalContainer> + </> + ); +} + +interface IApplicationRowProps { + application: ISplitTunnelingApplication; + launchApplication: (application: ISplitTunnelingApplication) => void; +} + +function ApplicationRow(props: IApplicationRowProps) { + const [showWarning, setShowWarning] = useState(false); + + const launch = useCallback(() => { + setShowWarning(false); + props.launchApplication(props.application); + }, [props.launchApplication, props.application]); + + const showWarningDialog = useCallback(() => setShowWarning(true), []); + const hideWarningDialog = useCallback(() => setShowWarning(false), []); + + const disabled = props.application.warning === 'launches-elsewhere'; + const warningColor = disabled ? colors.red : colors.yellow; + const warningMessage = disabled + ? sprintf( + messages.pgettext( + 'split-tunneling-view', + '%(applicationName)s is problematic and cannot be excluded from the VPN tunnel.', + ), + { + applicationName: props.application.name, + }, + ) + : sprintf( + messages.pgettext( + 'split-tunneling-view', + '%(applicationName)s is problematic and might not be excluded from the VPN tunnel. Try closing all existing instances of %(applicationName)s before starting it from here.', + ), + { + applicationName: props.application.name, + }, + ); + const warningDialogButtons = disabled + ? [ + <AppButton.BlueButton key="cancel" onClick={hideWarningDialog}> + {messages.gettext('Back')} + </AppButton.BlueButton>, + ] + : [ + <AppButton.BlueButton key="launch" onClick={launch}> + {messages.pgettext('split-tunneling-view', 'Launch')} + </AppButton.BlueButton>, + <AppButton.BlueButton key="cancel" onClick={hideWarningDialog}> + {messages.gettext('Cancel')} + </AppButton.BlueButton>, + ]; + + return ( + <> + <StyledCellButton + onClick={props.application.warning ? showWarningDialog : launch} + lookDisabled={disabled}> + {props.application.icon ? ( + <StyledIcon + source={props.application.icon} + width={35} + height={35} + lookDisabled={disabled} + /> + ) : ( + <StyledIconPlaceholder /> + )} + <StyledCellLabel lookDisabled={disabled}>{props.application.name}</StyledCellLabel> + {props.application.warning && <Cell.Icon source="icon-alert" tintColor={warningColor} />} + </StyledCellButton> + {showWarning && ( + <ModalAlert + type={ModalAlertType.Warning} + iconColor={warningColor} + message={warningMessage} + buttons={warningDialogButtons} + /> + )} + </> + ); +} diff --git a/gui/src/renderer/components/Modal.tsx b/gui/src/renderer/components/Modal.tsx index ae46c414fc..3db4dcd626 100644 --- a/gui/src/renderer/components/Modal.tsx +++ b/gui/src/renderer/components/Modal.tsx @@ -82,6 +82,7 @@ const ModalAlertButtonContainer = styled.div({ interface IModalAlertProps { type?: ModalAlertType; + iconColor?: string; message?: string; buttons: React.ReactNode[]; children?: React.ReactNode; @@ -152,7 +153,9 @@ export class ModalAlert extends React.Component<IModalAlertProps> { color = colors.red; break; } - return <ImageView height={44} width={44} source={source} tintColor={color} />; + return ( + <ImageView height={44} width={44} source={source} tintColor={this.props.iconColor ?? color} /> + ); } } diff --git a/gui/src/renderer/containers/AdvancedSettingsPage.tsx b/gui/src/renderer/containers/AdvancedSettingsPage.tsx index c9f33f3d63..ddadca983d 100644 --- a/gui/src/renderer/containers/AdvancedSettingsPage.tsx +++ b/gui/src/renderer/containers/AdvancedSettingsPage.tsx @@ -155,6 +155,7 @@ const mapDispatchToProps = (dispatch: ReduxDispatch, props: IAppContext) => { } }, onViewWireguardKeys: () => history.push('/settings/advanced/wireguard-keys'), + onViewLinuxSplitTunneling: () => history.push('/settings/advanced/linux-split-tunneling'), }; }; diff --git a/gui/src/renderer/routes.tsx b/gui/src/renderer/routes.tsx index c3aed29a7c..63a5a3a90e 100644 --- a/gui/src/renderer/routes.tsx +++ b/gui/src/renderer/routes.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { Route, RouteComponentProps, Switch, withRouter } from 'react-router'; import Launch from './components/Launch'; +import LinuxSplitTunnelingSettings from './components/LinuxSplitTunnelingSettings'; import TransitionContainer, { TransitionView } from './components/TransitionContainer'; import AccountPage from './containers/AccountPage'; import AdvancedSettingsPage from './containers/AdvancedSettingsPage'; @@ -73,6 +74,11 @@ class AppRoutes extends React.Component<RouteComponentProps, IAppRoutesState> { path="/settings/advanced/wireguard-keys" component={WireguardKeysPage} /> + <Route + exact={true} + path="/settings/advanced/linux-split-tunneling" + component={LinuxSplitTunnelingSettings} + /> <Route exact={true} path="/settings/support" component={SupportPage} /> <Route exact={true} path="/select-location" component={SelectLocationPage} /> </Switch> diff --git a/gui/src/renderer/transitions.ts b/gui/src/renderer/transitions.ts index 23ada748aa..da1a45ac01 100644 --- a/gui/src/renderer/transitions.ts +++ b/gui/src/renderer/transitions.ts @@ -45,6 +45,7 @@ const transitionRules = [ r('/settings', '/settings/preferences', transitions.push), r('/settings', '/settings/advanced', transitions.push), r('/settings/advanced', '/settings/advanced/wireguard-keys', transitions.push), + r('/settings/advanced', '/settings/advanced/linux-split-tunneling', transitions.push), r('/settings', '/settings/support', transitions.push), r(null, '/settings', transitions.slide), r(null, '/select-location', transitions.slide), diff --git a/gui/src/shared/ipc-event-channel.ts b/gui/src/shared/ipc-event-channel.ts index b020808e76..3c4cd55cfb 100644 --- a/gui/src/shared/ipc-event-channel.ts +++ b/gui/src/shared/ipc-event-channel.ts @@ -6,6 +6,7 @@ import { IGuiSettingsState } from './gui-settings-state'; import { ICurrentAppVersionInfo } from '../main/index'; import { IWindowShapeParameters } from '../main/window-controller'; +import ISplitTunnelingApplication from '../shared/linux-split-tunneling-application'; import { AccountToken, BridgeSettings, @@ -151,6 +152,18 @@ interface IWireguardKeyHandlers extends ISender<IWireguardPublicKey | undefined> handleVerifyKey(fn: () => Promise<boolean>): void; } +interface ISplitTunnelingMethods { + getApplications(): Promise<ISplitTunnelingApplication[]>; + launchApplication(application: ISplitTunnelingApplication | string): Promise<void>; +} + +interface ISplitTunnelingHandlers { + handleGetApplications(fn: () => Promise<ISplitTunnelingApplication[]>): void; + handleLaunchApplication( + fn: (application: ISplitTunnelingApplication | string) => Promise<void>, + ): void; +} + /// Events names const LOCALE_CHANGED = 'locale-changed'; @@ -207,6 +220,9 @@ const WIREGUARD_KEYGEN_EVENT = 'wireguard-keygen-event'; const GENERATE_WIREGUARD_KEY = 'generate-wireguard-key'; const VERIFY_WIREGUARD_KEY = 'verify-wireguard-key'; +const SPLIT_TUNNELING_GET_APPLICATIONS = 'split-tunneling-get-applications'; +const SPLIT_TUNNELING_LAUNCH_APPLICATION = 'split-tunneling-launch-application'; + /// Typed IPC event channel /// /// Static methods are meant to be provide the way to send the events from a renderer process, while @@ -306,6 +322,11 @@ export class IpcRendererEventChannel { generateKey: requestSender(GENERATE_WIREGUARD_KEY), verifyKey: requestSender(VERIFY_WIREGUARD_KEY), }; + + public static splitTunneling: ISplitTunnelingMethods = { + getApplications: requestSender(SPLIT_TUNNELING_GET_APPLICATIONS), + launchApplication: requestSender(SPLIT_TUNNELING_LAUNCH_APPLICATION), + }; } export class IpcMainEventChannel { @@ -403,6 +424,11 @@ export class IpcMainEventChannel { handleGenerateKey: requestHandler(GENERATE_WIREGUARD_KEY), handleVerifyKey: requestHandler(VERIFY_WIREGUARD_KEY), }; + + public static splitTunneling: ISplitTunnelingHandlers = { + handleGetApplications: requestHandler(SPLIT_TUNNELING_GET_APPLICATIONS), + handleLaunchApplication: requestHandler(SPLIT_TUNNELING_LAUNCH_APPLICATION), + }; } function listen<T>(event: string): (fn: (value: T) => void) => void { diff --git a/gui/src/shared/linux-split-tunneling-application.ts b/gui/src/shared/linux-split-tunneling-application.ts new file mode 100644 index 0000000000..a96152b70d --- /dev/null +++ b/gui/src/shared/linux-split-tunneling-application.ts @@ -0,0 +1,9 @@ +type Warning = 'launches-in-existing-process' | 'launches-elsewhere'; + +export default interface ISplitTunnelingApplication { + absolutepath: string; + name: string; + exec: string; + icon?: string; + warning?: Warning; +} diff --git a/gui/src/shared/localization-contexts.ts b/gui/src/shared/localization-contexts.ts index 7f408c0a83..73cdfed11b 100644 --- a/gui/src/shared/localization-contexts.ts +++ b/gui/src/shared/localization-contexts.ts @@ -25,5 +25,7 @@ export type LocalizationContexts = | 'advanced-settings-view-wireguard' | 'wireguard-key-view' | 'wireguard-keys-nav' + | 'split-tunneling-view' + | 'split-tunneling-nav' | 'support-view' | 'select-language-nav'; diff --git a/gui/tsconfig.json b/gui/tsconfig.json index 99a71c07e2..fc393219d2 100644 --- a/gui/tsconfig.json +++ b/gui/tsconfig.json @@ -15,8 +15,8 @@ ], "skipLibCheck": true, "strict": true, - "target": "es2017", - "lib": ["es2017", "dom"], + "target": "es2020", + "lib": ["es2020", "dom"], "typeRoots": [ "./types", "node_modules/@types" diff --git a/gui/types/argv-split/index.d.ts b/gui/types/argv-split/index.d.ts new file mode 100644 index 0000000000..625a0c383f --- /dev/null +++ b/gui/types/argv-split/index.d.ts @@ -0,0 +1,3 @@ +declare module 'argv-split' { + export default function split(arg: string): string[]; +} diff --git a/gui/types/linux-app-list/index.d.ts b/gui/types/linux-app-list/index.d.ts new file mode 100644 index 0000000000..80ad1ebac0 --- /dev/null +++ b/gui/types/linux-app-list/index.d.ts @@ -0,0 +1,25 @@ +// Implemented in accordance with this specification: +// https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html +declare module 'linux-app-list' { + export interface AppData { + absolutepath: string; + Name: string; + Type: string; + Icon?: string; + Exec?: string; + lang?: Record<string, { Name: string; Icon: string }>; + Terminal?: string; + NoDisplay?: string; + Hidden?: string; + OnlyShowIn?: string | string[]; + NotShowIn?: string | string[]; + TryExec?: string; + } + + export interface AppList { + list(): string[]; + data(app: string): AppData; + } + + export default function indexItems(): AppList; +} |
