diff --git a/app/package.json b/app/package.json index 18d0e4558..7611bff75 100644 --- a/app/package.json +++ b/app/package.json @@ -25,6 +25,7 @@ "ical-expander": "^2.0.0", "ical.js": "^1.3.0", "immutable": "^3.8.2", + "ini": "^1.3.5", "is-online": "7.0.0", "jasmine-json": "~0.0", "jasmine-react-helpers": "^0.2", diff --git a/app/src/linux-theme-utils.ts b/app/src/linux-theme-utils.ts new file mode 100644 index 000000000..226d8c59b --- /dev/null +++ b/app/src/linux-theme-utils.ts @@ -0,0 +1,305 @@ +import temp from 'temp'; +import os from 'os'; +import path from 'path'; +import fs from 'fs'; +import { execSync } from 'child_process'; +import ini from 'ini'; + +const Context = { + ACTIONS: 'actions', + ANIMATIONS: 'animations', + APPLICATIONS: 'applications', + CATEGORIES: 'categories', + DEVICES: 'devices', + EMBLEMS: 'emblems', + EMOTES: 'emotes', + INTERNATIONAL: 'international', + MIMETYPES: 'mimetypes', + PANEL: 'panel', + PLACES: 'places', + STATUS: 'status', +}; + +const HOME = os.homedir(); + +const ICON_THEME_PATHS = [ + path.join(HOME, '.local/share/icons'), + path.join(HOME, '.icons'), + '/usr/share/icons', + '/usr/local/share/icons', +]; + +const DESKTOP = process.env.XDG_CURRENT_DESKTOP; + +const ICON_EXTENSION = ['.png', '.svg']; + +/** + * Create a gsettings command string for icons themes + * + * @returns {string} command + * @private + */ +function __getGsettingsIconThemeCMD(): string { + const path = __getDesktopSettingsPath(); + const key = 'icon-theme'; + return path != null ? `gsettings get ${path} ${key}` : null; +} + +/** + * Create a gsettings command string for themes + * + * @returns {string} command + * @private + */ +function __getGsettingsGtkThemeCMD(): string { + const path = __getDesktopSettingsPath(); + const key = 'gtk-theme'; + return path != null ? `gsettings ${path} ${key}` : null; +} + +/** + * Returns the path for the gsettings command. Currently only GNOME and MATE are supported + * + * @returns {*} + * @private + */ +function __getDesktopSettingsPath(): string { + switch (DESKTOP) { + case 'MATE': + return 'org.mate.interface'; + case 'GNOME': + case 'Budgie:GNOME': + return 'org.gnome.desktop.interface'; + default: + return null; + } +} + +/** + * Execute a command and return the string result + * + * @param {string} cmd to execute + * @returns {string} result of the command + * @private + */ +function __exec(cmd: string): string { + try { + return cmd == null ? null : execSync(cmd) + .toString() + .trim() + .replace(/'/g, ''); + } catch (error) { + console.warn(error); + return null; + } +} + +/** + * Get the current icon theme + * + * @returns {string} name of the icon + */ +function getIconThemeName(): string { + return __exec(__getGsettingsIconThemeCMD()); +} + +/** + * Get the current GTK theme + * + * @returns {string} name of the theme + */ +function getThemeName(): string { + return __exec(__getGsettingsGtkThemeCMD()); +} + +/** + * Parse the icon themes index.theme file + * + * @param {string} themePath path to the themes root + * @returns {object} parsed ini file + * @private + */ +function __parseIconTheme(themePath: string): object { + const themeIndex = path.join(themePath, 'index.theme'); + if (fs.existsSync(themeIndex)) { + return ini.parse(fs.readFileSync(themeIndex, 'utf-8')); + } + return null; +} + +/** + * Find the path to the icon theme and parse it + * + * @param {string} themeName to parse + * @returns {IconTheme} containing the parsed index.theme file and path to the theme + */ +function getIconTheme(themeName): IconTheme { + if (themeName != null) { + for (const themesPath of ICON_THEME_PATHS) { + const themePath = path.join(themesPath, themeName); + const parsed = __parseIconTheme(themePath); + if (parsed != null) { + return { + themePath: themePath, + data: parsed, + }; + } + } + } + return null; +} + +type IconTheme = { + themePath: string; + data: object; +} + +/** + * Get all possible icon paths + * + * @param {IconTheme} theme data + * @param {string} iconName name of the icon + * @param {number} size of the icon + * @param {string|string[]} contexts of the icon + * @param {1|2} [scale=1] of the icon + * @returns {string[]} with all possibilities of the icon in one theme + * @private + */ +function __getAllIconPaths(theme: IconTheme, iconName: string, size: number, contexts: string|string[], scale: 1|2 = 1): string[] { + const icons = []; + + if (!(contexts instanceof Array)) { + contexts = [contexts]; + } + + for (const [sectionName, section] of Object.entries(theme.data)) { + if (sectionName !== 'Icon Theme') { + const _context = section.Context.toLowerCase(); + const _minSize = parseInt(section.MinSize, 10); + const _maxSize = parseInt(section.MaxSize, 10); + const _size = parseInt(section.Size, 10); + const _scale = parseInt(section.Scale, 10); + + if ( + contexts.indexOf(_context) > -1 && + (_size === size || (_minSize <= size && size <= _maxSize)) && + (scale == null || scale === _scale) + ) { + const iconDir = path.join(theme.themePath, sectionName); + for (const extension of ICON_EXTENSION) { + const iconPath = path.join(iconDir, iconName + extension); + if (fs.existsSync(iconPath)) { + icons.push(iconPath); + } + } + } + } + } + return icons; +} + +/** + * Get the icon from a theme + * + * @param {IconTheme} theme name + * @param {string} iconName name of the icon + * @param {number} size of the icon + * @param {string|string[]} contexts to search in + * @param {1|2} [scale=1] of the icon + * @returns {string} path to the icon or null + */ +function getIconFromTheme(theme: IconTheme, iconName: string, size: number, contexts: string|string[], scale: 1|2 = 1) { + const icons = __getAllIconPaths(theme, iconName, size, contexts, scale); + for (let path of icons) { + if (fs.existsSync(path)) { + return fs.realpathSync(path); + } + } + return null; +} + +/** + * Get the absolute path to the icon. If the icon is not found in the theme itself, the inherited + * themes will be searched + * + * @param {string} iconName to search for + * @param {number} size of the icon + * @param {array|string} context the icons context + * @param {1|2} [scale=1] 1 = normal, 2 = HiDPI version + * @returns {string} absolute path of the icon + */ +function getIconPath(iconName: string, size: number, context: string|string[], scale: 1|2 = 1) { + let defaultTheme = getIconTheme(getIconThemeName()); + if (defaultTheme != null) { + let inherits = defaultTheme.data['Icon Theme']['Inherits'].split(','); + + let icon = getIconFromTheme(defaultTheme, iconName, size, context, scale); + + if (icon !== null) { + return icon; + } + + // in case the icon was not found in the theme, we search the inherited themes + for (let key of inherits) { + let inheritsTheme = getIconTheme(inherits[key]); + icon = getIconFromTheme(inheritsTheme, iconName, size, context); + if (icon !== null) { + return icon; + } + } + } + + return null; +} + +/** + * Return an icon from the current icon theme + * + * @param {string} iconName name of the icon you want to search for (i.e. mailspring) + * @param {number} [size=22] size of the icon, if no exact size is found, the next possible one will be chosen + * @param {array|string} [context=Context.APPLICATIONS] icon context to search in, defaults to APPLICATIONS + * @param {number} [scale=2] icon scale, defaults to HiDPI version + * @returns {string} path to the icon + */ +function getIcon(iconName, size = 22, context: string|string[] = [Context.APPLICATIONS], scale: 1|2 = 2) { + if (process.platform !== 'linux') { + throw Error('getIcon only works on linux'); + } + + return getIconPath(iconName, size, context, scale); +} + +/** + * Convert any icon to a png using ImageMagick. If ImageMagick is not present the icon cannot be + * converted. + * + * @param {string} iconName to name the tmp file + * @param {string} iconPath to the original icon to be converted + * @returns {string} path to the converted tmp file + */ +function convertToPNG(iconName: string, iconPath: string) { + try { + const version = execSync('convert --version') + .toString() + .trim(); + if (!version) { + console.warn('Cannot find ImageMagick'); + return null; + } + const tmpFile = temp.openSync({ prefix: iconName, suffix: '.png' }); + execSync(`convert ${iconPath} -transparent white ${tmpFile.path}`); + return tmpFile.path; + } catch (error) { + console.warn(error); + } + return null; +} + + +export { + convertToPNG, + getIcon, + getIconThemeName, + getThemeName, + Context, +} \ No newline at end of file diff --git a/app/src/native-notifications.ts b/app/src/native-notifications.ts index d6aa4ee42..5a6507dc6 100644 --- a/app/src/native-notifications.ts +++ b/app/src/native-notifications.ts @@ -1,5 +1,11 @@ /* eslint global-require: 0 */ +import { convertToPNG, getIcon, Context } from './linux-theme-utils'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; + const platform = process.platform; +const DEFAULT_ICON = path.resolve(__dirname, '..', 'build', 'resources', 'mailspring.png'); let MacNotifierNotification = null; if (platform === 'darwin') { @@ -7,17 +13,27 @@ if (platform === 'darwin') { MacNotifierNotification = require('node-mac-notifier'); } catch (err) { console.error( - 'node-mac-notifier (a platform-specific optionalDependency) was not installed correctly! Check the Travis build log for errors.' + 'node-mac-notifier (a platform-specific optionalDependency) was not installed correctly! Check the Travis build log for errors.', ); } } type INotificationCallback = ( - args: { response: string | null; activationType: 'replied' | 'clicked' } + args: { response: string | null; activationType: 'replied' | 'clicked' }, ) => any; +type INotificationOptions = { + title?: string; + subtitle?: string; + body?: string; + tag?: string; + canReply?: boolean; + onActivate?: INotificationCallback; +} + class NativeNotifications { _macNotificationsByTag = {}; + private resolvedIcon: string = null; constructor() { if (MacNotifierNotification) { @@ -28,6 +44,7 @@ class NativeNotifications { return true; }); } + this.resolvedIcon = this.getIcon(); } doNotDisturb() { @@ -40,21 +57,78 @@ class NativeNotifications { return false; } + /** + * Check if the desktop file exists and parse the desktop file for the icon. + * + * @param {string} filePath to the desktop file + * @returns {string} icon from the desktop file + * @private + */ + private readIconFromDesktopFile(filePath) { + if (fs.existsSync(filePath)) { + const ini = require('ini'); + const content = ini.parse(fs.readFileSync(filePath, 'utf-8')); + return content['Desktop Entry']['Icon']; + } + return null; + } + + /** + * Get notification icon. Only works on linux, otherwise the Mailspring default icon wil be read + * from resources. + * + * Reading the icon name from the desktop file of Mailspring. If the icon is a name, reads the + * icon theme directory for this icon. As the notification only works with PNG files, SVG files + * must be converted to PNG + * + * @returns {string} path to the icon + * @private + */ + private getIcon() { + if (platform === 'linux') { + const desktopBaseDirs = [ + os.homedir() + '/.local/share/applications/', + '/usr/share/applications/', + ]; + const desktopFileNames = ['mailspring.desktop', 'Mailspring.desktop']; + // check the applications directories, the user directory has a higher priority + for (const baseDir of desktopBaseDirs) { + // check multiple spellings + for (const fileName of desktopFileNames) { + const filePath = path.join(baseDir, fileName); + const desktopIcon = this.readIconFromDesktopFile(filePath); + if (desktopIcon != null) { + if (fs.existsSync(desktopIcon)) { + // icon is a file and can be returned + return desktopIcon; + } + // icon is a name and we need to get it from the icon theme + const iconPath = getIcon(desktopIcon, 64, Context.APPLICATIONS, 2); + if (iconPath != null) { + // only .png icons work with notifications + if (path.extname(iconPath) === '.png') { + return iconPath; + } + const converted = convertToPNG(desktopIcon, iconPath); + if (converted != null) { + return converted; + } + } + } + } + } + } + return DEFAULT_ICON; + } + displayNotification({ title, subtitle, body, tag, canReply, - onActivate = args => {}, - }: { - title?: string; - subtitle?: string; - body?: string; - tag?: string; - canReply?: boolean; - onActivate?: INotificationCallback; - } = {}) { + onActivate = args => { }, + }: INotificationOptions = {}) { let notif = null; if (this.doNotDisturb()) { @@ -71,6 +145,7 @@ class NativeNotifications { subtitle: subtitle, body: body, id: tag, + icon: this.resolvedIcon, }); notif.addEventListener('reply', ({ response }) => { onActivate({ response, activationType: 'replied' }); @@ -86,6 +161,7 @@ class NativeNotifications { silent: true, body: subtitle, tag: tag, + icon: this.resolvedIcon, }); notif.onclick = onActivate; } diff --git a/package-lock.json b/package-lock.json index 4d20dc781..5b51da931 100644 --- a/package-lock.json +++ b/package-lock.json @@ -604,7 +604,6 @@ "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=", "dev": true, - "optional": true, "requires": { "kind-of": "^3.0.2", "longest": "^1.0.1", @@ -1372,8 +1371,7 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.2.tgz", "integrity": "sha1-uANhcMefB6kP8vFuIihAJ6JDhIs=", - "dev": true, - "optional": true + "dev": true }, "cssstyle": { "version": "0.2.37", @@ -3921,8 +3919,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/imul/-/imul-1.0.1.tgz", "integrity": "sha1-nVhnFh6LPelsLDjV3HyxAvNeKsk=", - "dev": true, - "optional": true + "dev": true }, "imurmurhash": { "version": "0.1.4", @@ -4109,8 +4106,7 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "dev": true, - "optional": true + "dev": true }, "is-builtin-module": { "version": "1.0.0", @@ -4401,7 +4397,6 @@ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", "dev": true, - "optional": true, "requires": { "is-buffer": "^1.1.5" } @@ -4647,8 +4642,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", - "dev": true, - "optional": true + "dev": true }, "loose-envify": { "version": "1.4.0", @@ -5072,8 +5066,7 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz", "integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==", - "dev": true, - "optional": true + "dev": true }, "natural-compare": { "version": "1.4.0", @@ -9434,8 +9427,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/path-key/-/path-key-1.0.0.tgz", "integrity": "sha1-XVPVeAGWRsDWiADbThRua9wqx68=", - "dev": true, - "optional": true + "dev": true }, "path-parse": { "version": "1.0.5", @@ -9774,8 +9766,7 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", - "dev": true, - "optional": true + "dev": true }, "repeating": { "version": "2.0.1",