Add icon to notifications (#1405)

* adds icon to the notifications

on windows and mac the default icon is used (from resources), on linux
the one set in the .desktop file is searched and used

* add budgie gnome to supported desktop environments

* converted to typescript and removed cache, icon is now loaded once at the beginning

* don't execute the gsettings command if the path is null
This commit is contained in:
Artur Kraft 2019-03-13 05:16:57 +01:00 committed by Ben Gotow
parent 33da5465c4
commit 0f52f13618
4 changed files with 400 additions and 27 deletions

View file

@ -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",

View file

@ -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,
}

View file

@ -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;
}

23
package-lock.json generated
View file

@ -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",