diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2021-03-03 11:18:47 +0100 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2021-03-03 11:18:47 +0100 |
| commit | 51f65df92aa311f934ce0d642dd5fa6a0cf934e5 (patch) | |
| tree | 2ba685f3d459dd0bd84bfd1e468724df9d22935b /gui/src | |
| parent | 8bb44773424a1139dc7e72a804899ea877b9ff3e (diff) | |
| parent | 8f48eb4bc95a1439644936214c849573234fe7a7 (diff) | |
| download | mullvadvpn-51f65df92aa311f934ce0d642dd5fa6a0cf934e5.tar.xz mullvadvpn-51f65df92aa311f934ce0d642dd5fa6a0cf934e5.zip | |
Merge branch 'improve-linux-split-tunnel'
Diffstat (limited to 'gui/src')
| -rw-r--r-- | gui/src/main/index.ts | 3 | ||||
| -rw-r--r-- | gui/src/main/linux-desktop-entry.ts | 175 | ||||
| -rw-r--r-- | gui/src/main/linux-split-tunneling.ts | 126 | ||||
| -rw-r--r-- | gui/src/main/transform-object-keys.ts | 47 | ||||
| -rw-r--r-- | gui/src/renderer/app.tsx | 8 | ||||
| -rw-r--r-- | gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx | 37 | ||||
| -rw-r--r-- | gui/src/shared/application-types.ts | 4 | ||||
| -rw-r--r-- | gui/src/shared/ipc-schema.ts | 4 |
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>(), |
