diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2020-12-21 15:46:15 +0100 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2020-12-21 15:46:15 +0100 |
| commit | ed63441bfc8b660aa57a6941a0f08cf2097a8291 (patch) | |
| tree | d6dce1767c5ed8e1999f17b3d4ab45e9ecd93f38 /gui | |
| parent | fad33b93c1677cef630423715141389eabf837b5 (diff) | |
| parent | 5b90106269ed93c8f936bda87814d6fba7c3b537 (diff) | |
| download | mullvadvpn-ed63441bfc8b660aa57a6941a0f08cf2097a8291.tar.xz mullvadvpn-ed63441bfc8b660aa57a6941a0f08cf2097a8291.zip | |
Merge branch 'fix-xfce-window-icon'
Diffstat (limited to 'gui')
| -rw-r--r-- | gui/src/main/index.ts | 13 | ||||
| -rw-r--r-- | gui/src/main/linux-desktop-entry.ts | 155 | ||||
| -rw-r--r-- | gui/src/main/linux-split-tunneling.ts | 177 | ||||
| -rw-r--r-- | gui/src/renderer/app.tsx | 4 | ||||
| -rw-r--r-- | gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx | 8 | ||||
| -rw-r--r-- | gui/src/shared/application-types.ts | 22 | ||||
| -rw-r--r-- | gui/src/shared/ipc-event-channel.ts | 6 | ||||
| -rw-r--r-- | gui/src/shared/linux-split-tunneling-application.ts | 9 |
8 files changed, 205 insertions, 189 deletions
diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts index 2db95816a0..535d893bb8 100644 --- a/gui/src/main/index.ts +++ b/gui/src/main/index.ts @@ -62,6 +62,7 @@ import { ConnectionObserver, DaemonRpc, SubscriptionListener } from './daemon-rp import { InvalidAccountError } from './errors'; import Expectation from './expectation'; import GuiSettings from './gui-settings'; +import { getAppIcon } from './linux-desktop-entry'; import NotificationController from './notification-controller'; import { resolveBin } from './proc'; import ReconnectionBackoff from './reconnection-backoff'; @@ -365,7 +366,7 @@ class ApplicationMain { ); this.connectToDaemon(); - const window = this.createWindow(); + const window = await this.createWindow(); const tray = this.createTray(); const windowController = new WindowController(window, tray, this.guiSettings.unpinnedWindow); @@ -1340,7 +1341,7 @@ class ApplicationMain { if (this.tray && this.windowController) { this.tray.removeAllListeners(); - const window = this.createWindow(); + const window = await this.createWindow(); this.windowController.replaceWindow(window, unpinnedWindow); await this.initializeWindow(); @@ -1409,7 +1410,7 @@ class ApplicationMain { } } - private createWindow(): BrowserWindow { + private async createWindow(): Promise<BrowserWindow> { const contentHeight = 568; // the size of transparent area around arrow on macOS const headerBarArrowHeight = 12; @@ -1475,6 +1476,12 @@ class ApplicationMain { autoHideMenuBar: true, }); + case 'linux': + return new BrowserWindow({ + ...options, + icon: await getAppIcon('mullvad-vpn'), + }); + default: { return new BrowserWindow(options); } diff --git a/gui/src/main/linux-desktop-entry.ts b/gui/src/main/linux-desktop-entry.ts new file mode 100644 index 0000000000..02edd8eb0f --- /dev/null +++ b/gui/src/main/linux-desktop-entry.ts @@ -0,0 +1,155 @@ +import child_process from 'child_process'; +import log from 'electron-log'; +import fs from 'fs'; +import path from 'path'; +import { ILinuxApplication } from '../shared/application-types'; + +type DirectoryDescription = string | RegExp; + +// 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 { + 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) + ); +} + +export async function getAppIcon(name?: string) { + if (!name || path.isAbsolute(name)) { + return name; + } + + // Chromium 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. + [/.*/], + ]); +} + +// 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; +} + +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', + // eslint-disable-next-line @typescript-eslint/naming-convention + { 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]); + } + }, + ); + }); +} + +// 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/linux-split-tunneling.ts b/gui/src/main/linux-split-tunneling.ts index 71f9f76eba..0552a41ff9 100644 --- a/gui/src/main/linux-split-tunneling.ts +++ b/gui/src/main/linux-split-tunneling.ts @@ -1,11 +1,10 @@ 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'; +import { ILinuxSplitTunnelingApplication } from '../shared/application-types'; +import { getAppIcon, shouldShowApplication } from './linux-desktop-entry'; const PROBLEMATIC_APPLICATIONS = { launchingInExistingProcess: [ @@ -21,19 +20,7 @@ const PROBLEMATIC_APPLICATIONS = { 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) { +export function launchApplication(app: ILinuxSplitTunnelingApplication | string) { const excludeArguments = typeof app === 'string' ? [app] : formatExec(app.exec); child_process.spawn('mullvad-exclude', excludeArguments, { detached: true }); } @@ -44,55 +31,25 @@ function formatExec(exec: string) { } // TODO: Switch to asyncronous reading of .desktop files -export function getApplications(locale: string): Promise<ISplitTunnelingApplication[]> { +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<IApplication>(applications); + return pascalCaseToCamelCase<ILinuxSplitTunnelingApplication>(applications); }) .filter(shouldShowApplication) .map(addApplicationWarnings) .sort((a, b) => a.name.localeCompare(b.name)) - .map(async (app) => ({ ...app, icon: await replaceWithIconPath(app.icon) })); + .map(async (app) => ({ ...app, icon: await getAppIcon(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 { +function addApplicationWarnings( + application: ILinuxSplitTunnelingApplication, +): ILinuxSplitTunnelingApplication { const binaryBasename = path.basename(application.exec!.split(' ')[0]); if (PROBLEMATIC_APPLICATIONS.launchingInExistingProcess.includes(binaryBasename)) { return { @@ -122,119 +79,3 @@ function localizeNameAndIcon(application: AppData, locale: string) { 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', - // eslint-disable-next-line @typescript-eslint/naming-convention - { 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/renderer/app.tsx b/gui/src/renderer/app.tsx index f9cff36663..73019b1e14 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -20,7 +20,7 @@ import { ICurrentAppVersionInfo } from '../main'; import { loadTranslations, messages, relayLocations } from '../shared/gettext'; import { IGuiSettingsState, SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state'; import { IpcRendererEventChannel, IRelayListPair } from '../shared/ipc-event-channel'; -import ISplitTunnelingApplication from '../shared/linux-split-tunneling-application'; +import { ILinuxSplitTunnelingApplication } from '../shared/application-types'; import { getRendererLogFile, setupLogging } from '../shared/logging'; import consumePromise from '../shared/promise'; import History from './lib/history'; @@ -416,7 +416,7 @@ export default class AppRenderer { return IpcRendererEventChannel.splitTunneling.getApplications(); } - public launchExcludedApplication(application: ISplitTunnelingApplication | string) { + public launchExcludedApplication(application: ILinuxSplitTunnelingApplication | string) { consumePromise(IpcRendererEventChannel.splitTunneling.launchApplication(application)); } diff --git a/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx b/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx index 145fb5a032..3c07e7eca0 100644 --- a/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx +++ b/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx @@ -4,7 +4,7 @@ import { sprintf } from 'sprintf-js'; import styled from 'styled-components'; import { colors } from '../../config.json'; import { messages } from '../../shared/gettext'; -import ISplitTunnelingApplication from '../../shared/linux-split-tunneling-application'; +import { ILinuxSplitTunnelingApplication } from '../../shared/application-types'; import consumePromise from '../../shared/promise'; import { useAppContext } from '../context'; import * as AppButton from './AppButton'; @@ -104,7 +104,7 @@ export default function LinuxSplitTunnelingSettings() { } = useAppContext(); const history = useHistory(); - const [applications, setApplications] = useState<ISplitTunnelingApplication[]>(); + const [applications, setApplications] = useState<ILinuxSplitTunnelingApplication[]>(); const [applicationListHeight, setApplicationListHeight] = useState<number>(); const [browsing, setBrowsing] = useState(false); @@ -202,8 +202,8 @@ export default function LinuxSplitTunnelingSettings() { } interface IApplicationRowProps { - application: ISplitTunnelingApplication; - launchApplication: (application: ISplitTunnelingApplication) => void; + application: ILinuxSplitTunnelingApplication; + launchApplication: (application: ILinuxSplitTunnelingApplication) => void; } function ApplicationRow(props: IApplicationRowProps) { diff --git a/gui/src/shared/application-types.ts b/gui/src/shared/application-types.ts new file mode 100644 index 0000000000..91b8068072 --- /dev/null +++ b/gui/src/shared/application-types.ts @@ -0,0 +1,22 @@ +type Warning = 'launches-in-existing-process' | 'launches-elsewhere'; + +export interface IApplication { + absolutepath: string; + name: string; + icon?: string; +} + +export interface ILinuxApplication extends IApplication { + exec: string; + type: string; + terminal?: string; + noDisplay?: string; + hidden?: string; + onlyShowIn?: string | string[]; + notShowIn?: string | string[]; + tryExec?: string; +} + +export interface ILinuxSplitTunnelingApplication extends ILinuxApplication { + warning?: Warning; +} diff --git a/gui/src/shared/ipc-event-channel.ts b/gui/src/shared/ipc-event-channel.ts index 1524e24bba..58186a822d 100644 --- a/gui/src/shared/ipc-event-channel.ts +++ b/gui/src/shared/ipc-event-channel.ts @@ -1,6 +1,6 @@ import { ICurrentAppVersionInfo } from '../main/index'; import { IWindowShapeParameters } from '../main/window-controller'; -import ISplitTunnelingApplication from '../shared/linux-split-tunneling-application'; +import { ILinuxSplitTunnelingApplication } from '../shared/application-types'; import { AccountToken, BridgeSettings, @@ -175,8 +175,8 @@ const ipc = { verifyKey: invoke<void, boolean>(), }, splitTunneling: { - getApplications: invoke<void, ISplitTunnelingApplication[]>(), - launchApplication: invoke<ISplitTunnelingApplication | string, void>(), + getApplications: invoke<void, ILinuxSplitTunnelingApplication[]>(), + launchApplication: invoke<ILinuxSplitTunnelingApplication | string, void>(), }, problemReport: { collectLogs: invoke<string[], string>(), diff --git a/gui/src/shared/linux-split-tunneling-application.ts b/gui/src/shared/linux-split-tunneling-application.ts deleted file mode 100644 index a96152b70d..0000000000 --- a/gui/src/shared/linux-split-tunneling-application.ts +++ /dev/null @@ -1,9 +0,0 @@ -type Warning = 'launches-in-existing-process' | 'launches-elsewhere'; - -export default interface ISplitTunnelingApplication { - absolutepath: string; - name: string; - exec: string; - icon?: string; - warning?: Warning; -} |
