summaryrefslogtreecommitdiffhomepage
path: root/gui/src
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2021-03-03 11:18:47 +0100
committerOskar Nyberg <oskar@mullvad.net>2021-03-03 11:18:47 +0100
commit51f65df92aa311f934ce0d642dd5fa6a0cf934e5 (patch)
tree2ba685f3d459dd0bd84bfd1e468724df9d22935b /gui/src
parent8bb44773424a1139dc7e72a804899ea877b9ff3e (diff)
parent8f48eb4bc95a1439644936214c849573234fe7a7 (diff)
downloadmullvadvpn-51f65df92aa311f934ce0d642dd5fa6a0cf934e5.tar.xz
mullvadvpn-51f65df92aa311f934ce0d642dd5fa6a0cf934e5.zip
Merge branch 'improve-linux-split-tunnel'
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/main/index.ts3
-rw-r--r--gui/src/main/linux-desktop-entry.ts175
-rw-r--r--gui/src/main/linux-split-tunneling.ts126
-rw-r--r--gui/src/main/transform-object-keys.ts47
-rw-r--r--gui/src/renderer/app.tsx8
-rw-r--r--gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx37
-rw-r--r--gui/src/shared/application-types.ts4
-rw-r--r--gui/src/shared/ipc-schema.ts4
8 files changed, 310 insertions, 94 deletions
diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts
index 53a572ede1..190f575e94 100644
--- a/gui/src/main/index.ts
+++ b/gui/src/main/index.ts
@@ -1132,8 +1132,7 @@ class ApplicationMain {
});
IpcMainEventChannel.splitTunneling.handleLaunchApplication((application) => {
if (linuxSplitTunneling) {
- linuxSplitTunneling.launchApplication(application);
- return Promise.resolve();
+ return linuxSplitTunneling.launchApplication(application);
} else {
throw Error('linuxSplitTunneling called without being imported');
}
diff --git a/gui/src/main/linux-desktop-entry.ts b/gui/src/main/linux-desktop-entry.ts
index 350035d657..291079aea8 100644
--- a/gui/src/main/linux-desktop-entry.ts
+++ b/gui/src/main/linux-desktop-entry.ts
@@ -7,10 +7,172 @@ import log from '../shared/logging';
type DirectoryDescription = string | RegExp;
+export interface DesktopEntry {
+ absolutepath: string;
+ name: string;
+ type: string;
+ icon?: string;
+ exec?: string;
+ terminal?: string;
+ noDisplay?: string;
+ hidden?: string;
+ onlyShowIn?: string[];
+ notShowIn?: string[];
+ tryExec?: string;
+}
+
+const DESKTOP_ENTRY_KEYS = [
+ 'name',
+ 'type',
+ 'icon',
+ 'exec',
+ 'terminal',
+ 'noDisplay',
+ 'hidden',
+ 'onlyShowIn',
+ 'notShowIn',
+ 'tryExec',
+];
+
+const LIST_KEYS = ['onlyShowIn', 'notShowIn'];
+
+// Parses a desktop entry at a specific path. Implemented in accordance with the freedesktop.org's
+// Desktop Entry Specification:
+// https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html
+export async function readDesktopEntry(entryPath: string, locale?: string): Promise<DesktopEntry> {
+ // First the lines corresponding to desktop entry group is extracted from the file
+ const contents = (await fs.promises.readFile(entryPath)).toString().split('\n');
+ // The group start is indicated by `[Desktop Entry]`
+ const startIndex = contents.indexOf('[Desktop Entry]') + 1;
+ const contentsFromDesktopEntry = contents.slice(startIndex);
+ // The group ens when the next group start
+ const endIndex = contentsFromDesktopEntry.findIndex((line) => /^\[.*\]$/.test(line));
+ const desktopEntry = contentsFromDesktopEntry.slice(0, endIndex);
+
+ return parseDesktopEntry(entryPath, desktopEntry, locale);
+}
+
+// Parses the values within the desktop entry group in a desktop entry file
+function parseDesktopEntry(
+ absolutepath: string,
+ desktopEntry: string[],
+ locale?: string,
+): DesktopEntry {
+ const parsed: Partial<DesktopEntry> = desktopEntry.reduce(
+ (entry, line) => parseDesktopEntryLine(entry, line, locale),
+ { absolutepath } as Partial<DesktopEntry>,
+ );
+
+ // If the dekstop entry is lacking some of the required keys it's invalid
+ if (isDesktopEntry(parsed)) {
+ return parsed;
+ } else {
+ throw new Error('Not a correctly formatted desktop entry');
+ }
+}
+
+// Parses a line in a desktop entry
+function parseDesktopEntryLine(
+ entry: Partial<DesktopEntry>,
+ line: string,
+ locale?: string,
+): Partial<DesktopEntry> {
+ // Comments start with `#` and keys and values are seperated by a `=`
+ if (!line.startsWith('#') && line.includes('=')) {
+ const firstEqualSign = line.indexOf('=');
+ const keyWithLocale = line.slice(0, firstEqualSign).replace(' ', '');
+ const value = line.slice(firstEqualSign + 1).trim();
+
+ // Key values can be suffixed by a locale enclosed in `[]`
+ const pascalCaseKey = keyWithLocale.replace(/\[.*\]/, '');
+ const key = pascalCaseKey[0].toLowerCase() + pascalCaseKey.slice(1);
+ const keyLocale = keyWithLocale.match(/\[.*\]/)?.[0].replace(/(\[|\])/g, '');
+
+ // If the key locale match the provided locale the value is used, otherwise it's only used if
+ // there isn't a value already
+ if (isDesktopEntryKey(key) && (keyLocale ? keyLocale === locale : entry[key] === undefined)) {
+ // Some values are lists of values and they have to be split on `;` and ofter contain a
+ // trailing `;`
+ if (LIST_KEYS.includes(key)) {
+ const arrayValue = value.replace(/;$/, '').split(';');
+ return { ...entry, [key]: arrayValue };
+ } else {
+ return { ...entry, [key]: value };
+ }
+ }
+ }
+
+ return entry;
+}
+
+function isDesktopEntryKey(key: string): key is keyof DesktopEntry {
+ return DESKTOP_ENTRY_KEYS.includes(key);
+}
+
+function isDesktopEntry(entry: Partial<DesktopEntry>): entry is DesktopEntry {
+ return entry.absolutepath !== undefined && entry.name !== undefined && entry.type !== undefined;
+}
+
+// Scans for desktop entries in accordance with the Desktop Entry Specification
+export async function getDesktopEntries(): Promise<string[]> {
+ const directories = getDesktopEntryDirectories();
+
+ const entries = await directories.reduce(
+ async (entries, directory) => getDesktopEntriesInDirectory(directory, await entries),
+ Promise.resolve({}),
+ );
+
+ return Object.values(entries);
+}
+
+async function getDesktopEntriesInDirectory(
+ directory: string,
+ previousEntries: { [id: string]: string },
+ prefix = '',
+): Promise<{ [id: string]: string }> {
+ let currentEntries = { ...previousEntries };
+ try {
+ const contents = await fs.promises.readdir(directory);
+
+ for (const item of contents) {
+ const id = prefix + item;
+ if (path.extname(item) === '.desktop') {
+ if (currentEntries[id] === undefined) {
+ currentEntries[id] = path.join(directory, item);
+ }
+ } else {
+ const nextDirectory = path.join(directory, item);
+ currentEntries = await getDesktopEntriesInDirectory(
+ nextDirectory,
+ currentEntries,
+ `${prefix}${item}-`,
+ );
+ }
+ }
+ } catch (e) {
+ // no-op
+ }
+
+ return currentEntries;
+}
+
+function getDesktopEntryDirectories() {
+ const directories: string[] = [];
+
+ if (process.env.HOME) {
+ directories.push(path.join(process.env.HOME, '.local', 'share', 'applications'));
+ }
+
+ const xdgDataDirs = getXdgDataDirs().map((dir) => path.join(dir, 'applications'));
+ directories.push(...xdgDataDirs);
+
+ return directories;
+}
+
// Implemented according to freedesktop specification
// https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html
// TODO: Respect "TryExec"
-export function shouldShowApplication(application: ILinuxApplication): boolean {
+export function shouldShowApplication(application: DesktopEntry): application is ILinuxApplication {
const originalXdgCurrentDesktop = process.env.ORIGINAL_XDG_CURRENT_DESKTOP?.split(':') ?? [];
const xdgCurrentDesktop = process.env.XDG_CURRENT_DESKTOP?.split(':') ?? [];
const desktopEnvironments = originalXdgCurrentDesktop.concat(xdgCurrentDesktop);
@@ -79,16 +241,17 @@ function getIconDirectories() {
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);
- }
-
+ const xdgDataDirs = getXdgDataDirs().map((dir) => path.join(dir, 'icons'));
+ directories.push(...xdgDataDirs);
directories.push('/usr/share/pixmaps');
return directories;
}
+function getXdgDataDirs(): string[] {
+ return process.env.XDG_DATA_DIRS?.split(':') ?? ['/usr/local/share/', '/usr/share/'];
+}
+
function getGtkThemeDirectories(): Promise<DirectoryDescription[]> {
// "hicolor" is fallback theme and should always be checked. If no icon is found search is
// continued in other themes.
diff --git a/gui/src/main/linux-split-tunneling.ts b/gui/src/main/linux-split-tunneling.ts
index 8103fc4d0e..9c41740776 100644
--- a/gui/src/main/linux-split-tunneling.ts
+++ b/gui/src/main/linux-split-tunneling.ts
@@ -1,10 +1,18 @@
import argvSplit from 'argv-split';
import child_process from 'child_process';
-import linuxAppList, { AppData } from 'linux-app-list';
import path from 'path';
-import { pascalCaseToCamelCase } from './transform-object-keys';
import { ILinuxSplitTunnelingApplication } from '../shared/application-types';
-import { findIconPath, getImageDataUrl, shouldShowApplication } from './linux-desktop-entry';
+import { messages } from '../shared/gettext';
+import { LaunchApplicationResult } from '../shared/ipc-schema';
+import { Scheduler } from '../shared/scheduler';
+import {
+ getDesktopEntries,
+ readDesktopEntry,
+ findIconPath,
+ getImageDataUrl,
+ shouldShowApplication,
+ DesktopEntry,
+} from './linux-desktop-entry';
const PROBLEMATIC_APPLICATIONS = {
launchingInExistingProcess: [
@@ -20,9 +28,75 @@ const PROBLEMATIC_APPLICATIONS = {
launchingElsewhere: ['gnome-terminal'],
};
-export function launchApplication(app: ILinuxSplitTunnelingApplication | string) {
- const excludeArguments = typeof app === 'string' ? [app] : formatExec(app.exec);
- child_process.spawn('mullvad-exclude', excludeArguments, { detached: true });
+// Launches an application. The application parameter could be a path the an executable or .desktop
+// file or an object representing an application
+export async function launchApplication(
+ app: ILinuxSplitTunnelingApplication | string,
+): Promise<LaunchApplicationResult> {
+ let excludeArguments: string[];
+ try {
+ excludeArguments = await getLaunchCommand(app);
+ } catch (e) {
+ return { error: e.message };
+ }
+
+ return new Promise((resolve, _reject) => {
+ const scheduler = new Scheduler();
+ const proc = child_process.spawn('mullvad-exclude', excludeArguments, { detached: true });
+
+ // If the process exits within 200 milliseconds the user is notified that it failed to launch.
+ scheduler.schedule(() => {
+ proc.removeAllListeners();
+ resolve({ success: true });
+ }, 200);
+
+ proc.stderr.on('data', (data) => {
+ if (data.includes('Failed to launch the process') && data.includes('ENOENT')) {
+ scheduler.cancel();
+ proc.removeAllListeners();
+ resolve({
+ error:
+ // TRANSLATORS: This error message is shown if the user tries to launch an app that
+ // TRANSLATORS: doesn't exist.
+ messages.pgettext('split-tunneling-view', 'Please try again or contact support.'),
+ });
+ }
+ });
+ proc.once('exit', (code) => {
+ scheduler.cancel();
+ proc.removeAllListeners();
+
+ if (code === 1) {
+ resolve({
+ error:
+ // TRANSLATORS: This error message is shown if an application failes during startup.
+ messages.pgettext('split-tunneling-view', 'Please try again or contact support.'),
+ });
+ } else {
+ resolve({ success: true });
+ }
+ });
+ });
+}
+
+// Takes the same argument as launchApplication and returns the command to run
+async function getLaunchCommand(app: ILinuxSplitTunnelingApplication | string): Promise<string[]> {
+ if (typeof app === 'object') {
+ return formatExec(app.exec);
+ } else if (path.extname(app) === '.desktop') {
+ const entry = await readDesktopEntry(app);
+ if (entry.exec !== undefined) {
+ return formatExec(entry.exec);
+ } else {
+ throw new Error(
+ // TRANSLATORS: This error message is shown if the user tries to launch a Linux desktop
+ // TRANSLATORS: entry file that doesn't contain the required 'Exec' value.
+ messages.pgettext('split-tunneling-view', 'Please contact support.'),
+ );
+ }
+ } else {
+ return [app];
+ }
}
// Removes placeholder arguments and separates command into list of strings
@@ -30,15 +104,19 @@ 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<ILinuxSplitTunnelingApplication[]> {
- const appList = linuxAppList();
- const applications = appList
- .list()
- .map((filename) => {
- const applications = localizeNameAndIcon(appList.data(filename), locale);
- return pascalCaseToCamelCase<ILinuxSplitTunnelingApplication>(applications);
- })
+export async function getApplications(locale: string): Promise<ILinuxSplitTunnelingApplication[]> {
+ const desktopEntryPaths = await getDesktopEntries();
+ const desktopEntries: DesktopEntry[] = [];
+
+ for (const entryPath of desktopEntryPaths) {
+ try {
+ desktopEntries.push(await readDesktopEntry(entryPath, locale));
+ } catch (e) {
+ // no-op
+ }
+ }
+
+ const applications = desktopEntries
.filter(shouldShowApplication)
.map(addApplicationWarnings)
.sort((a, b) => a.name.localeCompare(b.name))
@@ -52,11 +130,11 @@ async function replaceIconNameWithDataUrl(
): Promise<ILinuxSplitTunnelingApplication> {
try {
// Either the app has no icon or it's already an absolute path.
- if (app.icon === undefined || path.isAbsolute(app.icon)) {
+ if (app.icon === undefined) {
return app;
}
- const iconPath = await findIconPath(app.icon);
+ const iconPath = path.isAbsolute(app.icon) ? app.icon : await findIconPath(app.icon);
if (iconPath === undefined) {
return app;
}
@@ -85,17 +163,3 @@ function addApplicationWarnings(
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;
-}
diff --git a/gui/src/main/transform-object-keys.ts b/gui/src/main/transform-object-keys.ts
deleted file mode 100644
index 82b82793ec..0000000000
--- a/gui/src/main/transform-object-keys.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-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;
-}
diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx
index 9ba1e61252..4c79cc2192 100644
--- a/gui/src/renderer/app.tsx
+++ b/gui/src/renderer/app.tsx
@@ -20,7 +20,7 @@ import { ILinuxSplitTunnelingApplication } from '../shared/application-types';
import { messages, relayLocations } from '../shared/gettext';
import { IGuiSettingsState, SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state';
import log, { ConsoleOutput } from '../shared/logging';
-import { IRelayListPair } from '../shared/ipc-schema';
+import { IRelayListPair, LaunchApplicationResult } from '../shared/ipc-schema';
import consumePromise from '../shared/promise';
import History from './lib/history';
import { loadTranslations } from './lib/load-translations';
@@ -419,8 +419,10 @@ export default class AppRenderer {
return IpcRendererEventChannel.splitTunneling.getApplications();
}
- public launchExcludedApplication(application: ILinuxSplitTunnelingApplication | string) {
- consumePromise(IpcRendererEventChannel.splitTunneling.launchApplication(application));
+ public launchExcludedApplication(
+ application: ILinuxSplitTunnelingApplication | string,
+ ): Promise<LaunchApplicationResult> {
+ return IpcRendererEventChannel.splitTunneling.launchApplication(application);
}
public collectProblemReport(toRedact: string[]): Promise<string> {
diff --git a/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx b/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx
index 3c07e7eca0..cb7c497011 100644
--- a/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx
+++ b/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx
@@ -107,9 +107,20 @@ export default function LinuxSplitTunnelingSettings() {
const [applications, setApplications] = useState<ILinuxSplitTunnelingApplication[]>();
const [applicationListHeight, setApplicationListHeight] = useState<number>();
const [browsing, setBrowsing] = useState(false);
+ const [browseError, setBrowseError] = useState<string>();
const applicationListRef = useRef() as React.RefObject<HTMLDivElement>;
+ const launchApplication = useCallback(
+ async (application: ILinuxSplitTunnelingApplication | string) => {
+ const result = await launchExcludedApplication(application);
+ if ('error' in result) {
+ setBrowseError(result.error);
+ }
+ },
+ [],
+ );
+
const launchWithFilePicker = useCallback(async () => {
setBrowsing(true);
const file = await showOpenDialog({
@@ -119,10 +130,12 @@ export default function LinuxSplitTunnelingSettings() {
setBrowsing(false);
if (file.filePaths[0]) {
- launchExcludedApplication(file.filePaths[0]);
+ await launchApplication(file.filePaths[0]);
}
}, []);
+ const hideBrowseFailureDialog = useCallback(() => setBrowseError(undefined), []);
+
useEffect(() => {
consumePromise(getSplitTunnelingApplications().then(setApplications));
}, []);
@@ -181,7 +194,7 @@ export default function LinuxSplitTunnelingSettings() {
<ApplicationRow
key={application.absolutepath}
application={application}
- launchApplication={launchExcludedApplication}
+ launchApplication={launchApplication}
/>
))
)}
@@ -196,6 +209,26 @@ export default function LinuxSplitTunnelingSettings() {
</NavigationContainer>
</StyledContainer>
</Layout>
+ {browseError && (
+ <ModalAlert
+ type={ModalAlertType.warning}
+ iconColor={colors.red}
+ message={sprintf(
+ // TRANSLATORS: Error message showed in a dialog when an application failes to launch.
+ messages.pgettext(
+ 'split-tunneling-view',
+ 'Unable to launch selection. %(detailedErrorMessage)s',
+ ),
+ { detailedErrorMessage: browseError },
+ )}
+ buttons={[
+ <AppButton.BlueButton key="close" onClick={hideBrowseFailureDialog}>
+ {messages.gettext('Close')}
+ </AppButton.BlueButton>,
+ ]}
+ close={hideBrowseFailureDialog}
+ />
+ )}
</ModalContainer>
</>
);
diff --git a/gui/src/shared/application-types.ts b/gui/src/shared/application-types.ts
index 91b8068072..07cea190e3 100644
--- a/gui/src/shared/application-types.ts
+++ b/gui/src/shared/application-types.ts
@@ -12,8 +12,8 @@ export interface ILinuxApplication extends IApplication {
terminal?: string;
noDisplay?: string;
hidden?: string;
- onlyShowIn?: string | string[];
- notShowIn?: string | string[];
+ onlyShowIn?: string[];
+ notShowIn?: string[];
tryExec?: string;
}
diff --git a/gui/src/shared/ipc-schema.ts b/gui/src/shared/ipc-schema.ts
index a31745c01f..62a3afc948 100644
--- a/gui/src/shared/ipc-schema.ts
+++ b/gui/src/shared/ipc-schema.ts
@@ -37,6 +37,8 @@ export interface IRelayListPair {
bridges: IRelayList;
}
+export type LaunchApplicationResult = { success: true } | { error: string };
+
export interface IAppStateSnapshot {
locale: string;
isConnected: boolean;
@@ -181,7 +183,7 @@ export const ipcSchema = {
},
splitTunneling: {
getApplications: invoke<void, ILinuxSplitTunnelingApplication[]>(),
- launchApplication: invoke<ILinuxSplitTunnelingApplication | string, void>(),
+ launchApplication: invoke<ILinuxSplitTunnelingApplication | string, LaunchApplicationResult>(),
},
problemReport: {
collectLogs: invoke<string[], string>(),