summaryrefslogtreecommitdiffhomepage
path: root/gui/src
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2021-02-17 18:29:55 +0100
committerOskar Nyberg <oskar@mullvad.net>2021-03-02 17:24:53 +0100
commit382a4a7a001d46646dda3dac7e2b63ae1533d800 (patch)
treea1181a3ab916ae8f720f18300e69d248b92baf00 /gui/src
parente6b23f2424ef5575d6d725eefe18489b01f0fe94 (diff)
downloadmullvadvpn-382a4a7a001d46646dda3dac7e2b63ae1533d800.tar.xz
mullvadvpn-382a4a7a001d46646dda3dac7e2b63ae1533d800.zip
Replace linux-app-list with custom implementation
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/main/linux-desktop-entry.ts175
-rw-r--r--gui/src/main/linux-split-tunneling.ts51
-rw-r--r--gui/src/shared/application-types.ts4
3 files changed, 194 insertions, 36 deletions
diff --git a/gui/src/main/linux-desktop-entry.ts b/gui/src/main/linux-desktop-entry.ts
index 350035d657..03ee359b3a 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..c739ed585e 100644
--- a/gui/src/main/linux-split-tunneling.ts
+++ b/gui/src/main/linux-split-tunneling.ts
@@ -1,10 +1,15 @@
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 {
+ getDesktopEntries,
+ readDesktopEntry,
+ findIconPath,
+ getImageDataUrl,
+ shouldShowApplication,
+ DesktopEntry,
+} from './linux-desktop-entry';
const PROBLEMATIC_APPLICATIONS = {
launchingInExistingProcess: [
@@ -30,15 +35,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 +61,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 +94,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/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;
}