diff options
Diffstat (limited to 'gui/src')
| -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 |
4 files changed, 258 insertions, 0 deletions
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; +} |
