diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2020-07-20 22:25:00 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2020-08-14 12:32:08 +0200 |
| commit | 50e5c00f7c045b9517e6302face111ae477d16b9 (patch) | |
| tree | b992307c7e7c3150434f44d281fdb753f9b3bf15 | |
| parent | a43de9a29a1f5a130d4d7ff081dac8cf37a9a876 (diff) | |
| download | mullvadvpn-50e5c00f7c045b9517e6302face111ae477d16b9.tar.xz mullvadvpn-50e5c00f7c045b9517e6302face111ae477d16b9.zip | |
Add function for retrieving installed applications on Linux
| -rw-r--r-- | gui/package-lock.json | 10 | ||||
| -rw-r--r-- | gui/package.json | 2 | ||||
| -rw-r--r-- | gui/src/main/index.ts | 19 | ||||
| -rw-r--r-- | gui/src/main/linux-split-tunneling.ts | 207 | ||||
| -rw-r--r-- | gui/src/shared/ipc-event-channel.ts | 26 | ||||
| -rw-r--r-- | gui/src/shared/linux-split-tunneling-application.ts | 6 | ||||
| -rw-r--r-- | gui/types/argv-split/index.d.ts | 3 | ||||
| -rw-r--r-- | gui/types/linux-app-list/index.d.ts | 25 |
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; +} |
