diff --git a/src/Bubble.vala b/src/Bubble.vala index 5fd8c994..2ed47eac 100644 --- a/src/Bubble.vala +++ b/src/Bubble.vala @@ -81,34 +81,29 @@ public class Notifications.Bubble : AbstractBubble { } construct { - var app_image = new Gtk.Image () { - gicon = notification.primary_icon + var image_overlay = new Gtk.Overlay () { + valign = START }; - var image_overlay = new Gtk.Overlay (); - image_overlay.valign = Gtk.Align.START; + if (notification.image is LoadableIcon) { + image_overlay.child = new MaskedImage ((LoadableIcon) notification.image); + } else { + image_overlay.child = new Gtk.Image.from_gicon (notification.image, DIALOG) { + pixel_size = 48 + }; + } - if (notification.image != null) { - app_image.pixel_size = 24; - app_image.halign = app_image.valign = Gtk.Align.END; + if (notification.badge != null) { + var badge_image = new Gtk.Image.from_gicon (notification.badge, LARGE_TOOLBAR) { + pixel_size = 24, + halign = END, + valign = END + }; - image_overlay.add (notification.image); - image_overlay.add_overlay (app_image); - } else { - app_image.pixel_size = 48; - image_overlay.add (app_image); - - if (notification.badge_icon != null) { - var badge_image = new Gtk.Image.from_gicon (notification.badge_icon, Gtk.IconSize.LARGE_TOOLBAR) { - halign = Gtk.Align.END, - valign = Gtk.Align.END, - pixel_size = 24 - }; - image_overlay.add_overlay (badge_image); - } + image_overlay.add_overlay (badge_image); } - var title_label = new Gtk.Label (notification.summary) { + var title_label = new Gtk.Label (notification.title) { ellipsize = Pango.EllipsizeMode.END, max_width_chars = 33, valign = Gtk.Align.END, @@ -119,7 +114,7 @@ public class Notifications.Bubble : AbstractBubble { var body_label = new Gtk.Label (notification.body) { ellipsize = Pango.EllipsizeMode.END, - lines = 2, + lines = "\n" in notification.body ? 1 : 2, max_width_chars = 33, use_markup = true, valign = Gtk.Align.START, @@ -129,17 +124,6 @@ public class Notifications.Bubble : AbstractBubble { xalign = 0 }; - if ("\n" in notification.body) { - string[] lines = notification.body.split ("\n"); - string stripped_body = lines[0] + "\n"; - for (int i = 1; i < lines.length; i++) { - stripped_body += lines[i].strip () + ""; - } - - body_label.label = stripped_body.strip (); - body_label.lines = 1; - } - column_spacing = 6; attach (image_overlay, 0, 0, 1, 2); attach (title_label, 1, 0); diff --git a/src/DBus.vala b/src/DBus.vala index 3ebcf2ec..999c7bc3 100644 --- a/src/DBus.vala +++ b/src/DBus.vala @@ -24,6 +24,8 @@ public class Notifications.Server : Object { private Gee.HashMap bubbles; + private static VariantType variant_type_pixbuf = new VariantType ("(iiibiiay)"); + construct { settings = new GLib.Settings ("io.elementary.notifications"); bubbles = new Gee.HashMap (); @@ -74,26 +76,63 @@ public class Notifications.Server : Object { int32 expire_timeout, BusName sender ) throws DBusError, IOError { + NotificationPriority urgency = NORMAL; + string? app_id = null; + + if ("desktop-entry" in hints && hints["desktop-entry"].is_of_type (VariantType.STRING)) { + app_id = hints["desktop-entry"].get_string (); + } + + if ("urgency" in hints && hints["urgency"].is_of_type (VariantType.BYTE)) { + urgency = priority_from_urgency (hints["urgency"].get_byte ()); + } + // Silence "Automatic suspend. Suspending soon because of inactivity." notifications // These values and hints are taken from gnome-settings-daemon source code // See: https://gitlab.gnome.org/GNOME/gnome-settings-daemon/-/blob/master/plugins/power/gsd-power-manager.c#L356 // We must check for app_icon == "" to not block low power notifications - if ("desktop-entry" in hints && hints["desktop-entry"].get_string () == "gnome-power-panel" - && "urgency" in hints && hints["urgency"].get_byte () == 2 - && app_icon == "" - && expire_timeout == 0 - ) { + if (app_id == "gnome-power-panel" && urgency == URGENT && app_icon == "" && expire_timeout == 0) { debug ("Blocked GSD notification"); throw new DBusError.FAILED ("Notification Blocked"); } - var id = (replaces_id != 0 ? replaces_id : ++id_counter); + var id = replaces_id; if (hints.contains (X_CANONICAL_PRIVATE_SYNCHRONOUS)) { send_confirmation (app_icon, hints); } else { - var notification = new Notifications.Notification (app_name, app_icon, summary, body, actions, hints); - if (!settings.get_boolean ("do-not-disturb") || notification.priority == GLib.NotificationPriority.URGENT) { + // Only summary is required, so try to set a title when body is empty + if (body._strip () == "") { + body = summary._strip (); + summary = app_name._strip (); + } else if (summary._strip () == "") { + summary = app_name._strip (); + } + + if (body == "" || summary == "" && app_id == null) { + throw new DBusError.INVALID_ARGS ("summary must not be empty"); + } + + var notification = new Notification (app_id, summary, body, actions) { + image = parse_image_string (app_icon), + priority = urgency + }; + + if (id == 0) { + id = ++id_counter; + } + + var image = search_image (hints); + if (image != null) { + if (image is LoadableIcon) { + notification.badge = notification.image; + notification.image = image; + } else { + notification.badge = image; + } + } + + if (!settings.get_boolean ("do-not-disturb") || notification.priority == URGENT) { var app_settings = new Settings.with_path ( "io.elementary.notifications.applications", settings.path.concat ("applications", "/", notification.app_id, "/") @@ -182,7 +221,79 @@ public class Notifications.Server : Object { CanberraGtk.context_get ().play_full (0, props); } - static unowned string category_to_sound_name (string category) { + // convert between freedesktop urgency levels and GLib.NotificationPriority levels + // See: https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html#urgency-levels + private static NotificationPriority priority_from_urgency (uint8 urgency) { + switch (urgency) { + case 0: return LOW; + case 1: return NORMAL; + case 2: return URGENT; + default: + warning ("unknown urgency value: %u, ignoring", urgency); + return NORMAL; + } + } + + // search for a image hint, in priority order. + // See: https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html#icons-and-images + private static Icon? search_image (HashTable hints) { + const string[] IMAGE_HINTS = { "image-data", "image_data", "image-path", "image_path", "icon_data" }; + Icon? image = null; + + foreach (unowned var hint in IMAGE_HINTS) { + if (!hints.contains (hint)) { + continue; + } + + if (hints[hint].is_of_type (VariantType.STRING)) { + image = parse_image_string (hints[hint].get_string ()); + } else if (hints[hint].is_of_type (variant_type_pixbuf)) { + image = parse_image_pixbuf (hints[hint]); + } + + if (image != null) { + break; + } + + warning ("wrong type for hint '%s': %s. ignoring", hint, hints[hint].get_type_string ()); + } + + return image; + } + + private static Gdk.Pixbuf? parse_image_pixbuf (Variant variant) + requires (variant.is_of_type (variant_type_pixbuf)) { + int width, height, rowstride, bps; + bool has_alpha; + Bytes data; + + variant.get ("(iiibiiay)", out width, out height, out rowstride, out has_alpha, out bps, null, null); + data = variant.get_child_value (6).get_data_as_bytes (); + + return new Gdk.Pixbuf.from_bytes (data, RGB, has_alpha, bps, width, height, rowstride); + } + + private static Icon? parse_image_string (string image) { + if (Gtk.IconTheme.get_default ().has_icon (image)) { + return new ThemedIcon (image); + } + + File? file = null; + + if (image.has_prefix ("file:")) { + file = File.new_for_uri (image); + } else if (image.has_prefix ("/")) { + file = File.new_for_path (image); + } + + if (file != null && file.query_exists ()) { + return new FileIcon (file); + } + + return null; + } + + private static unowned string category_to_sound_name (string category) { unowned string sound; switch (category) { diff --git a/src/Notification.vala b/src/Notification.vala index 99267741..4ce0b67f 100644 --- a/src/Notification.vala +++ b/src/Notification.vala @@ -1,168 +1,96 @@ /* -* Copyright 2020 elementary, Inc. (https://elementary.io) -* -* This program is free software; you can redistribute it and/or -* modify it under the terms of the GNU General Public -* License as published by the Free Software Foundation; either -* version 3 of the License, or (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -* General Public License for more details. -* -* You should have received a copy of the GNU General Public -* License along with this program; if not, write to the -* Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, -* Boston, MA 02110-1301 USA -* -*/ + * Copyright 2020-2023 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + */ public class Notifications.Notification : GLib.Object { - private const string OTHER_APP_ID = "gala-other"; - - public GLib.DesktopAppInfo? app_info { get; private set; default = null; } - public GLib.NotificationPriority priority { get; private set; default = GLib.NotificationPriority.NORMAL; } - public HashTable hints { get; construct; } - public string[] actions { get; construct; } - public string app_icon { get; construct; } - public string app_id { get; private set; default = OTHER_APP_ID; } - public string app_name { get; construct; } - public string body { get; construct set; } - public string summary { get; construct set; } - - public GLib.Icon? primary_icon { get; set; default = null; } - public GLib.Icon? badge_icon { get; set; default = null; } - public MaskedImage? image { get; set; default = null; } - - private static Regex entity_regex; - private static Regex tag_regex; - - public Notification (string app_name, string app_icon, string summary, string body, string[] actions, HashTable hints) { - Object ( - app_name: app_name, - app_icon: app_icon, - summary: summary, - body: body, - actions: actions, - hints: hints - ); - } + public DesktopAppInfo? app_info { get; construct; } + public NotificationPriority priority { get; set; default = NORMAL; } + + public string app_id { + get { + if (_app_id == null) { + if (app_info != null && app_info.get_boolean ("X-GNOME-UsesNotifications")) { + _app_id = app_info.get_id (); + // GLib.DesktopAppInfo.get_id() always include the .desktop suffix. + _app_id = _app_id.substring (0, _app_id.last_index_of (".desktop")); + } else { + _app_id = "gala-other"; + } + } - static construct { - try { - entity_regex = new Regex ("&(?!amp;|quot;|apos;|lt;|gt;|nbsp;|#39)"); - tag_regex = new Regex ("<(?!\\/?[biu]>)"); - } catch (Error e) { - warning ("Invalid regex: %s", e.message); + return _app_id; } } - construct { - unowned Variant? variant = null; - - // GLib.Notification.set_priority () - // convert between freedesktop urgency levels and GLib.NotificationPriority levels - // See: https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html#urgency-levels - if ("urgency" in hints && hints["urgency"].is_of_type (VariantType.BYTE)) { - switch (hints["urgency"].get_byte ()) { - case 0: - priority = LOW; - break; - case 1: - priority = NORMAL; - break; - case 2: - priority = URGENT; - break; - default: - warning ("unknown urgency value: %i, ignoring", hints["urgency"].get_byte ()); - break; + public string title { + get { + // GLib.Notifications only requires the title, when that's the case, we use it as the body. + // So, use the applications's display name as title if we have one. + if (_title == null && app_info != null) { + return app_info.get_display_name (); } + + return _title ?? ""; } - if ("desktop-entry" in hints && hints["desktop-entry"].is_of_type (VariantType.STRING)) { - app_info = new DesktopAppInfo ("%s.desktop".printf (hints["desktop-entry"].get_string ())); - - if (app_info != null && app_info.get_boolean ("X-GNOME-UsesNotifications")) { - var app_info_id = app_info.get_id (); - if (app_info_id != null) { - if (app_info_id.has_suffix (".desktop")) { - app_id = app_info_id.substring (0, app_info_id.length - ".desktop".length); - } else { - app_id = app_info_id; - } - } - } + construct set { + _title = (value == null || value == "") ? null : fix_markup (value); } + } - // Always "" if sent by GLib.Notification - if (app_icon == "" && app_info != null) { - primary_icon = app_info.get_icon (); - } else if (app_icon.contains ("/")) { - var file = File.new_for_uri (app_icon); - if (file.query_exists ()) { - primary_icon = new FileIcon (file); - } - } else { - // Icon name set directly, such as by Notify.Notification - primary_icon = new ThemedIcon (app_icon); + public string body { + get { + return _body ?? ""; } - // GLib.Notification.set_icon () - if ((variant = hints.lookup ("image-path")) != null || (variant = hints.lookup ("image_path")) != null) { - var image_path = variant.get_string (); - - // GLib.Notification also sends icon names via this hint - if (Gtk.IconTheme.get_default ().has_icon (image_path) && image_path != app_icon) { - badge_icon = new ThemedIcon (image_path); - } else if (image_path.has_prefix ("/") || image_path.has_prefix ("file://")) { - try { - var pixbuf = new Gdk.Pixbuf.from_file (image_path); - image = new Notifications.MaskedImage (pixbuf); - } catch (Error e) { - critical ("Unable to mask image: %s", e.message); - } - } + construct set { + _body = (value == null || value == "") ? null : fix_markup (sanitize_body (value)); } + } - // Raw image data sent within a variant - if ((variant = hints.lookup ("image-data")) != null || (variant = hints.lookup ("image_data")) != null || (variant = hints.lookup ("icon_data")) != null) { - var pixbuf = image_data_variant_to_pixbuf (variant); - if (pixbuf != null) { - image = new Notifications.MaskedImage (pixbuf); + public Icon image { + get { + if (_image == null) { + return app_info != null ? app_info.get_icon () : fallback_icon; } - } - // Display a generic notification icon if there is no notification image - if (image == null && primary_icon == null) { - primary_icon = new ThemedIcon ("dialog-information"); + return _image; } - // Always "" if sent by GLib.Notification - if (app_name == "" && app_info != null) { - app_name = app_info.get_display_name (); + set { + _image = value; } + } - /*Only summary is required by GLib.Notification, so try to set a title when body is empty*/ - if (body == "") { - body = fix_markup (summary); - summary = app_name; - } else { - body = fix_markup (body); - summary = fix_markup (summary); - } + public Icon? badge { get; set; } + + public string[] actions { get; construct; } + + private Icon _image; + private string _app_id; + private string _title; + private string _body; + + // used when no icon was provided + private static Icon fallback_icon = new ThemedIcon ("dialog-information"); + + public Notification (string? app_id, string summary, string body, string[] actions) { + Object ( + app_info: app_id != null ? new DesktopAppInfo (app_id + ".desktop") : null, + title: summary, + body: body, + actions: actions + ); } - /** - * Copied from gnome-shell, fixes the mess of markup that is sent to us - */ - private string fix_markup (string markup) { + // Copied from gnome-shell, fixes the mess of markup that is sent to us + private static string fix_markup (string markup) { var text = markup; try { - text = entity_regex.replace (markup, markup.length, 0, "&"); - text = tag_regex.replace (text, text.length, 0, "<"); + text = /&(?!amp;|quot;|apos;|lt;|gt;|nbsp;|#39)/.replace (markup, markup.length, 0, "&"); //vala-lint=space-before-paren + text = /<(?!\/?[biu]>)/.replace (text, text.length, 0, "<"); //vala-lint=space-before-paren } catch (Error e) { warning ("Invalid regex: %s", e.message); } @@ -170,22 +98,22 @@ public class Notifications.Notification : GLib.Object { return text; } - private Gdk.Pixbuf? image_data_variant_to_pixbuf (Variant img) { - if (img.get_type_string () != "(iiibiiay)") { - warning ("Invalid type string: %s", img.get_type_string ()); - return null; + // remove sequences of whitespaces and newlines. + private static string sanitize_body (string body) { + var lines = body.delimit ("\f\r\n", '\n')._delimit ("\t\v", ' ').split ("\n"); + foreach (unowned var line in lines) { + line._strip (); } - int width = img.get_child_value (0).get_int32 (); - int height = img.get_child_value (1).get_int32 (); - int rowstride = img.get_child_value (2).get_int32 (); - bool has_alpha = img.get_child_value (3).get_boolean (); - int bits_per_sample = img.get_child_value (4).get_int32 (); - unowned uint8[] raw = (uint8[]) img.get_child_value (6).get_data (); - - // Build the pixbuf from the unowned buffer, and copy it to maintain our own instance. - Gdk.Pixbuf pixbuf = new Gdk.Pixbuf.with_unowned_data (raw, Gdk.Colorspace.RGB, - has_alpha, bits_per_sample, width, height, rowstride, null); - return pixbuf.copy (); - } + var sanitized = string.joinv ("\n", lines); + while (" " in sanitized) { + sanitized = sanitized.replace (" ", " "); + } + + while ("\n\n" in sanitized) { + sanitized = sanitized.replace ("\n\n", "\n"); + } + + return sanitized; + } } diff --git a/src/Widgets/MaskedImage.vala b/src/Widgets/MaskedImage.vala index 336e4119..6d6b6221 100644 --- a/src/Widgets/MaskedImage.vala +++ b/src/Widgets/MaskedImage.vala @@ -1,56 +1,48 @@ /* -* Copyright 2019 elementary, Inc. (https://elementary.io) -* -* This program is free software; you can redistribute it and/or -* modify it under the terms of the GNU General Public -* License as published by the Free Software Foundation; either -* version 3 of the License, or (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -* General Public License for more details. -* -* You should have received a copy of the GNU General Public -* License along with this program; if not, write to the -* Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, -* Boston, MA 02110-1301 USA -* -*/ + * Copyright 2019 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ public class Notifications.MaskedImage : Gtk.Overlay { - private const int ICON_SIZE = 48; + public LoadableIcon gicon { get; construct; } - public Gdk.Pixbuf pixbuf { get; construct; } + private const int ICON_SIZE = 48; - public MaskedImage (Gdk.Pixbuf pixbuf) { - Object (pixbuf: pixbuf); + public MaskedImage (LoadableIcon gicon) { + Object (gicon: gicon); } construct { - var mask = new Gtk.Image.from_resource ("/io/elementary/notifications/image-mask.svg"); - mask.pixel_size = ICON_SIZE; - - var scale = get_style_context ().get_scale (); + child = new Gtk.Image.from_gicon (mask_icon (gicon, get_style_context ().get_scale ()), DIALOG) { + pixel_size = ICON_SIZE + }; - var image = new Gtk.Image (); - image.gicon = mask_pixbuf (pixbuf, scale); - image.pixel_size = ICON_SIZE; - - add (image); + var mask = new Gtk.Image.from_resource ("/io/elementary/notifications/image-mask.svg") { + pixel_size = ICON_SIZE + }; add_overlay (mask); } - private static Gdk.Pixbuf? mask_pixbuf (Gdk.Pixbuf pixbuf, int scale) { - var size = ICON_SIZE * scale; + private static Icon? mask_icon (LoadableIcon icon, int scale) { var mask_offset = 4 * scale; var mask_size_offset = mask_offset * 2; + var size = ICON_SIZE * scale - mask_size_offset; + Gdk.Pixbuf input; + + if (icon is Gdk.Pixbuf) { + input = ((Gdk.Pixbuf) icon).scale_simple (size, size, BILINEAR); + } else try { + input = new Gdk.Pixbuf.from_stream_at_scale (icon.load (ICON_SIZE, null), size, size, false); + } catch (Error e) { + warning ("failed to scale icon: %s", e.message); + return new ThemedIcon ("image-missing"); + } + var mask_size = ICON_SIZE * scale; var offset_x = mask_offset; var offset_y = mask_offset + scale; - size = size - mask_size_offset; - var input = pixbuf.scale_simple (size, size, Gdk.InterpType.BILINEAR); var surface = new Cairo.ImageSurface (Cairo.Format.ARGB32, mask_size, mask_size); var cr = new Cairo.Context (surface);