diff --git a/meson.build b/meson.build index ae529d4b..fe7e863e 100644 --- a/meson.build +++ b/meson.build @@ -22,6 +22,7 @@ executable( 'src/Bubble.vala', 'src/Confirmation.vala', 'src/DBus.vala', + 'src/FdoActionGroup.vala', 'src/Notification.vala', 'src/Widgets/MaskedImage.vala', css_gresource, @@ -30,6 +31,7 @@ executable( dependency ('libcanberra-gtk3'), dependency ('glib-2.0'), dependency ('gobject-2.0'), + dependency ('gio-2.0'), dependency ('granite', version: '>=5.4.0'), dependency ('gtk+-3.0'), dependency ('libhandy-1') diff --git a/src/Application.vala b/src/Application.vala index 565e2818..ad3d2b1d 100644 --- a/src/Application.vala +++ b/src/Application.vala @@ -1,5 +1,5 @@ /* -* Copyright 2019-2022 elementary, Inc. (https://elementary.io) +* Copyright 2019-2023 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 @@ -30,12 +30,10 @@ public class Notifications.Application : Gtk.Application { } protected override bool dbus_register (DBusConnection connection, string object_path) throws Error { - var server = new Notifications.Server (); - try { - connection.register_object ("/org/freedesktop/Notifications", server); + new Notifications.Server (connection); } catch (Error e) { - warning ("Registring notification server failed: %s", e.message); + Error.prefix_literal (out e, "Registring notification server failed: "); throw e; } diff --git a/src/Bubble.vala b/src/Bubble.vala index 5fd8c994..f91f635b 100644 --- a/src/Bubble.vala +++ b/src/Bubble.vala @@ -3,8 +3,16 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -public class Notifications.Bubble : AbstractBubble { - public signal void action_invoked (string action_key); +public class Notifications.Bubble : AbstractBubble, Gtk.Actionable { + public new string action_name { + get { return get_action_name (); } + set { set_action_name (value); } + } + + public new Variant action_target { + get { return get_action_target_value (); } + set { set_action_target_value (value); } + } public Notification notification { get { @@ -15,15 +23,7 @@ public class Notifications.Bubble : AbstractBubble { _notification = value; timeout = 0; - for (int i = 0; i < notification.actions.length; i += 2) { - if (notification.actions[i] == "default") { - _has_default = true; - break; - } - } - var contents = new Contents (value); - contents.action_invoked.connect ((a) => action_invoked (a)); contents.show_all (); if (value.priority == URGENT) { @@ -39,7 +39,6 @@ public class Notifications.Bubble : AbstractBubble { private Notification _notification; private Gtk.GestureMultiPress press_gesture; - private bool _has_default; public Bubble (Notification notification) { Object (notification: notification); @@ -50,20 +49,30 @@ public class Notifications.Bubble : AbstractBubble { propagation_phase = BUBBLE }; press_gesture.released.connect (released); - - action_invoked.connect (close); } private void released () { - if (_has_default) { - action_invoked ("default"); - } else if (notification.app_info != null) { + if (action_name != null) { + foreach (unowned var prefix in list_action_prefixes ()) { + if (!action_name.has_prefix (prefix)) { + continue; + } + + get_action_group (prefix).activate_action (action_name[prefix.length + 1:], action_target); + press_gesture.set_state (CLAIMED); + return; + } + + warning ("cannot activate action '%s': no action group match prefix.", action_name); + } + + if (notification.app_info != null) { notification.app_info.launch_uris_async.begin (null, null, null, (obj, res) => { try { ((AppInfo) obj).launch_uris_async.end (res); - close (); + closed (Server.CloseReason.UNDEFINED); } catch (Error e) { - critical ("Unable to launch app: %s", e.message); + warning ("Unable to launch app: %s", e.message); } }); } @@ -71,9 +80,23 @@ public class Notifications.Bubble : AbstractBubble { press_gesture.set_state (CLAIMED); } - private class Contents : Gtk.Grid { - public signal void action_invoked (string action_key); + // Gtk.Actionable impl + public unowned string? get_action_name () { + return notification.default_action_name; + } + + public unowned Variant get_action_target_value () { + return notification.default_action_target; + } + + // we ignore the set methods because we query the notification model instead. + public void set_action_name (string? @value) { + } + + public void set_action_target_value (Variant? @value) { + } + private class Contents : Gtk.Grid { public Notifications.Notification notification { get; construct; } public Contents (Notifications.Notification notification) { @@ -145,34 +168,20 @@ public class Notifications.Bubble : AbstractBubble { attach (title_label, 1, 0); attach (body_label, 1, 1); - if (notification.actions.length > 0) { + if (notification.buttons.length > 0) { var action_area = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 0) { halign = Gtk.Align.END, homogeneous = true }; action_area.get_style_context ().add_class ("buttonbox"); - bool action_area_packed = false; - - for (int i = 0; i < notification.actions.length; i += 2) { - if (notification.actions[i] != "default") { - var button = new Gtk.Button.with_label (notification.actions[i + 1]); - var action = notification.actions[i].dup (); - - button.clicked.connect (() => { - action_invoked (action); - }); - - action_area.pack_end (button); - - if (!action_area_packed) { - attach (action_area, 0, 2, 2); - action_area_packed = true; - } - } else { - i += 2; - } + foreach (var button in notification.buttons) { + action_area.pack_end (new Gtk.Button.with_label (button.label) { + action_name = button.action_name + }); } + + attach (action_area, 0, 2, 2); } } } diff --git a/src/DBus.vala b/src/DBus.vala index 3ebcf2ec..8db97ba1 100644 --- a/src/DBus.vala +++ b/src/DBus.vala @@ -18,27 +18,52 @@ public class Notifications.Server : Object { private const string X_CANONICAL_PRIVATE_SYNCHRONOUS = "x-canonical-private-synchronous"; private uint32 id_counter = 0; - private Notifications.Confirmation? confirmation = null; - private GLib.Settings settings; + private unowned DBusConnection connection; + private Fdo.ActionGroup action_group; - private Gee.HashMap bubbles; + private Gee.Map bubbles; + private Confirmation? confirmation; - construct { - settings = new GLib.Settings ("io.elementary.notifications"); - bubbles = new Gee.HashMap (); + private Settings settings; + + private uint action_group_id; + private uint server_id; + + public Server (DBusConnection connection) throws Error { + settings = new Settings ("io.elementary.notifications"); + bubbles = new Gee.HashMap (); + action_group = new Fdo.ActionGroup (this); + + server_id = connection.register_object ("/org/freedesktop/Notifications", this); + action_group_id = connection.export_action_group ("/org/freedesktop/Notifications", action_group); + this.connection = connection; + + action_invoked.connect ((id) => close_bubble (id)); + notification_closed.connect ((id) => close_bubble (id)); + } + + ~Server () { + connection.unexport_action_group (action_group_id); + connection.unregister_object (server_id); + } + + private void close_bubble (uint32 id) { + Bubble bubble; + + if (bubbles.unset (id, out bubble)) { + action_group.remove_actions (id); + bubble.close (); + } } public void close_notification (uint32 id) throws DBusError, IOError { - if (bubbles.has_key (id)) { - bubbles[id].close (); - closed_callback (id, CloseReason.CLOSE_NOTIFICATION_CALL); - return; + if (!bubbles.has_key (id)) { + // according to spec, an empty dbus error should be sent if the notification doesn't exist (anymore) + throw new DBusError.FAILED (""); } - // according to spec, an empty dbus error should be sent if the notification - // doesn't exist (anymore) - throw new DBusError.FAILED (""); + notification_closed (id, CloseReason.CLOSE_NOTIFICATION_CALL); } public string [] get_capabilities () throws DBusError, IOError { @@ -92,7 +117,30 @@ public class Notifications.Server : Object { 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); + var notification = new Notification (app_name, app_icon, summary, body, hints); + notification.buttons = new GenericArray (actions.length / 2); + + // validate actions + for (var i = 0; i < actions.length; i += 2) { + if (actions[i] == "") { + continue; + } + + var action_name = "fdo." + action_group.add_action (id, actions[i]); + if (actions[i] == "default") { + notification.default_action_name = action_name; + continue; + } + + var label = actions[i + 1].strip (); + if (label == "") { + warning ("action '%s' sent without a label, skipping…", actions[i]); + continue; + } + + notification.buttons.add ({ label, action_name }); + } + if (!settings.get_boolean ("do-not-disturb") || notification.priority == GLib.NotificationPriority.URGENT) { var app_settings = new Settings.with_path ( "io.elementary.notifications.applications", @@ -100,18 +148,12 @@ public class Notifications.Server : Object { ); if (app_settings.get_boolean ("bubbles")) { - if (bubbles.has_key (id) && bubbles[id] != null) { + if (bubbles.has_key (id)) { bubbles[id].notification = notification; } else { bubbles[id] = new Bubble (notification); - - bubbles[id].action_invoked.connect ((action_key) => { - action_invoked (id, action_key); - }); - - bubbles[id].closed.connect ((reason) => { - closed_callback (id, reason); - }); + bubbles[id].insert_action_group ("fdo", action_group); + bubbles[id].closed.connect ((res) => notification_closed (id, res)); } bubbles[id].present (); @@ -131,11 +173,6 @@ public class Notifications.Server : Object { return id; } - private void closed_callback (uint32 id, uint32 reason) { - bubbles.unset (id); - notification_closed (id, reason); - } - private void send_confirmation (string icon_name, HashTable hints) { double progress_value; Variant? val = hints.lookup ("value"); diff --git a/src/FdoActionGroup.vala b/src/FdoActionGroup.vala new file mode 100644 index 00000000..6bc4edbb --- /dev/null +++ b/src/FdoActionGroup.vala @@ -0,0 +1,152 @@ +/* + * Copyright 2023 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Author: Gustavo Marques + */ + +// RefString don't subclass string in vala, however they ctype is char* +namespace GLib { + [CCode (cname = "g_str_hash", cheader_filename = "glib.h")] + public extern uint ref_str_hash (RefString v); + [CCode (cname = "g_str_equal", cheader_filename = "glib.h")] + public extern bool ref_str_equal (RefString v1, RefString v2); +} + +/** + * A implementation of GLib.ActionGroup meant to be used by external programs + * to trigger the signals in the org.freedesktop.Notifications interface. + * + * notification actions follow the "id.action" format, a "close(@au ids)" action + * exist to trigger the NotificationClosed signal. + */ +public sealed class Notifications.Fdo.ActionGroup : Object, GLib.ActionGroup { + private Gee.Collection actions; + private unowned Server server; + + private static VariantType close_parameter_type = new VariantType.array (VariantType.UINT32); + + public ActionGroup (Server server) { + this.actions = new Gee.HashSet (ref_str_hash, ref_str_equal); + this.server = server; + + actions.add (new RefString.intern ("close")); + } + + public unowned string add_action (uint32 id, string action) { + var action_name = new RefString.intern (@"$id.$action"); + + if (actions.add (action_name)) { + action_added (action_name.to_string ()); + } + + return action_name.to_string (); + } + + public void remove_actions (uint32 id) { + var iter = actions.iterator (); + var prefix = id.to_string (); + + while (iter.next ()) { + var action = iter.get ().to_string (); + if (!action.has_prefix (prefix)) { + continue; + } + + action_removed (action); + iter.remove (); + } + } + + // GLib.ActionGroup impl + public override bool query_action ( + string action_name, + out bool enabled, + out VariantType parameter_type, + out VariantType state_type, + out Variant state_hint, + out Variant state + ) { + parameter_type = state_type = null; + state_hint = state = null; + enabled = action_name in (Gee.Collection) actions; + + if (action_name == "close") { + parameter_type = close_parameter_type; + } + + return enabled; + } + + public string[] list_actions () { + var builder = new StrvBuilder (); + + foreach (var action in actions) { + builder.add (action.to_string ().dup ()); + } + + return builder.end (); + } + + public void activate_action (string action, Variant? target) + requires (has_action (action)) { + if (action == "close") { + var iter = target.iterator (); + uint32 id; + + while (iter.next ("u", out id)) { + server.notification_closed (id, Server.CloseReason.DISMISSED); + } + + return; + } + + string action_name; + uint32 id; + + uint.try_parse (action, out id, out action_name); + if (id == 0) { + warning ("failed to activate action '%s': failed to parse notification id", action); + return; + } + + debug ("activating action '%s' for notification id '%u'", action_name[1:], id); + server.action_invoked (id, action_name[1:]); + } + + public void change_action_state (string action_name, Variant @value) { + } + + /* GLib says that we are only meant to override list_actions and query_actions, + * however, the gio bindings only have query_action marked as virtual. + * + * FIXME: remove everthing below when we have valac 0.58 as minimal version. + */ + public bool has_action (string action_name) { + return action_name in (Gee.Collection) actions; + } + + public bool get_action_enabled (string action_name) { + return has_action (action_name); + } + + public unowned VariantType? get_action_parameter_type (string action_name) { + if (action_name == "close") { + return close_parameter_type; + } + + return null; + } + + public unowned VariantType? get_action_state_type (string action_name) { + return null; + } + + public Variant? get_action_state_hint (string action_name) { + return null; + } + + public Variant? get_action_state (string action_name) { + return null; + } +} diff --git a/src/Notification.vala b/src/Notification.vala index 99267741..6c645f69 100644 --- a/src/Notification.vala +++ b/src/Notification.vala @@ -1,5 +1,5 @@ /* -* Copyright 2020 elementary, Inc. (https://elementary.io) +* Copyright 2020-2023 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 @@ -24,7 +24,6 @@ public class Notifications.Notification : GLib.Object { 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; } @@ -35,16 +34,25 @@ public class Notifications.Notification : GLib.Object { public GLib.Icon? badge_icon { get; set; default = null; } public MaskedImage? image { get; set; default = null; } + public string default_action_name { get; set; } + public Variant default_action_target { get; set; } + + public GenericArray buttons { get; set; } + 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) { + public struct Button { + string label; + string action_name; + } + + public Notification (string app_name, string app_icon, string summary, string body, HashTable hints) { Object ( app_name: app_name, app_icon: app_icon, summary: summary, body: body, - actions: actions, hints: hints ); } @@ -187,5 +195,4 @@ public class Notifications.Notification : GLib.Object { has_alpha, bits_per_sample, width, height, rowstride, null); return pixbuf.copy (); } - }