summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2020-08-14 12:41:50 +0200
committerOskar Nyberg <oskar@mullvad.net>2020-08-14 12:41:50 +0200
commitd94f70ec81d291b5d8d348c40058e25e81e07562 (patch)
treed5bf20348b6c7e502ba4d735f939ff2f618c1d10
parentfaf933331f93e1a8d771babb31f3ac37a2f9851f (diff)
parent9ecbf3a72e2d4f8a4fb2ce6794beb49cda03bf34 (diff)
downloadmullvadvpn-d94f70ec81d291b5d8d348c40058e25e81e07562.tar.xz
mullvadvpn-d94f70ec81d291b5d8d348c40058e25e81e07562.zip
Merge branch 'linux-split-tunneling-gui' into master
-rw-r--r--CHANGELOG.md1
-rw-r--r--gui/package-lock.json10
-rw-r--r--gui/package.json2
-rw-r--r--gui/src/main/daemon-rpc.ts56
-rw-r--r--gui/src/main/index.ts19
-rw-r--r--gui/src/main/linux-split-tunneling.ts239
-rw-r--r--gui/src/main/transform-object-keys.ts47
-rw-r--r--gui/src/renderer/app.tsx9
-rw-r--r--gui/src/renderer/components/AdvancedSettings.tsx21
-rw-r--r--gui/src/renderer/components/AdvancedSettingsStyles.tsx4
-rw-r--r--gui/src/renderer/components/ImageView.tsx5
-rw-r--r--gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx279
-rw-r--r--gui/src/renderer/components/Modal.tsx5
-rw-r--r--gui/src/renderer/containers/AdvancedSettingsPage.tsx1
-rw-r--r--gui/src/renderer/routes.tsx6
-rw-r--r--gui/src/renderer/transitions.ts1
-rw-r--r--gui/src/shared/ipc-event-channel.ts26
-rw-r--r--gui/src/shared/linux-split-tunneling-application.ts9
-rw-r--r--gui/src/shared/localization-contexts.ts2
-rw-r--r--gui/tsconfig.json4
-rw-r--r--gui/types/argv-split/index.d.ts3
-rw-r--r--gui/types/linux-app-list/index.d.ts25
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;
+}