diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2020-08-14 12:41:50 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2020-08-14 12:41:50 +0200 |
| commit | d94f70ec81d291b5d8d348c40058e25e81e07562 (patch) | |
| tree | d5bf20348b6c7e502ba4d735f939ff2f618c1d10 /gui/src/main | |
| parent | faf933331f93e1a8d771babb31f3ac37a2f9851f (diff) | |
| parent | 9ecbf3a72e2d4f8a4fb2ce6794beb49cda03bf34 (diff) | |
| download | mullvadvpn-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.ts | 56 | ||||
| -rw-r--r-- | gui/src/main/index.ts | 19 | ||||
| -rw-r--r-- | gui/src/main/linux-split-tunneling.ts | 239 | ||||
| -rw-r--r-- | gui/src/main/transform-object-keys.ts | 47 |
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; +} |
