summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2020-07-20 22:25:00 +0200
committerOskar Nyberg <oskar@mullvad.net>2020-08-14 12:32:08 +0200
commit50e5c00f7c045b9517e6302face111ae477d16b9 (patch)
treeb992307c7e7c3150434f44d281fdb753f9b3bf15
parenta43de9a29a1f5a130d4d7ff081dac8cf37a9a876 (diff)
downloadmullvadvpn-50e5c00f7c045b9517e6302face111ae477d16b9.tar.xz
mullvadvpn-50e5c00f7c045b9517e6302face111ae477d16b9.zip
Add function for retrieving installed applications on Linux
-rw-r--r--gui/package-lock.json10
-rw-r--r--gui/package.json2
-rw-r--r--gui/src/main/index.ts19
-rw-r--r--gui/src/main/linux-split-tunneling.ts207
-rw-r--r--gui/src/shared/ipc-event-channel.ts26
-rw-r--r--gui/src/shared/linux-split-tunneling-application.ts6
-rw-r--r--gui/types/argv-split/index.d.ts3
-rw-r--r--gui/types/linux-app-list/index.d.ts25
8 files changed, 298 insertions, 0 deletions
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/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..f2ea40da13
--- /dev/null
+++ b/gui/src/main/linux-split-tunneling.ts
@@ -0,0 +1,207 @@
+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';
+
+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)
+ .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 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/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..f75406b301
--- /dev/null
+++ b/gui/src/shared/linux-split-tunneling-application.ts
@@ -0,0 +1,6 @@
+export default interface ISplitTunnelingApplication {
+ absolutepath: string;
+ name: string;
+ exec: string;
+ icon?: string;
+}
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;
+}