mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-20 23:36:21 +08:00
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:
parent
33da5465c4
commit
0f52f13618
|
@ -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",
|
||||
|
|
305
app/src/linux-theme-utils.ts
Normal file
305
app/src/linux-theme-utils.ts
Normal 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,
|
||||
}
|
|
@ -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
23
package-lock.json
generated
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue