summaryrefslogtreecommitdiffhomepage
path: root/gui/src/main
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 /gui/src/main
parentfaf933331f93e1a8d771babb31f3ac37a2f9851f (diff)
parent9ecbf3a72e2d4f8a4fb2ce6794beb49cda03bf34 (diff)
downloadmullvadvpn-d94f70ec81d291b5d8d348c40058e25e81e07562.tar.xz
mullvadvpn-d94f70ec81d291b5d8d348c40058e25e81e07562.zip
Merge branch 'linux-split-tunneling-gui' into master
Diffstat (limited to 'gui/src/main')
-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
4 files changed, 314 insertions, 47 deletions
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;
+}