diff --git a/lib/naughty/core.lua b/lib/naughty/core.lua index 4985ade5e..ac4d29a41 100644 --- a/lib/naughty/core.lua +++ b/lib/naughty/core.lua @@ -134,10 +134,36 @@ gtable.crush(naughty, require("naughty.constants")) -- @property auto_reset_timeout -- @tparam[opt=true] boolean auto_reset_timeout +--- Enable or disable naughty ability to claim to support animations. +-- +-- When this is true, applications which query `naughty` feature support +-- will see that animations are supported. Note that there is *very little* +-- support for this and enabled it will cause bugs. +-- +-- @property image_animations_enabled +-- @param[opt=false] boolean + +--- Enable or disable the persistent notifications. +-- +-- This is very annoying when using `naughty.layout.box` popups, but tolerable +-- when using `naughty.list.notifications`. +-- +-- Note that enabling this **does nothing** in `naughty` itself. The timeouts +-- are still honored and notifications still destroyed. It is the user +-- responsibility to disable the dismiss timer. However, this tells the +-- applications that notification persistence is supported so they might +-- stop using systray icons for the sake of displaying or other changes like +-- that. +-- +-- @property persistence_enabled +-- @param[opt=false] boolean + local properties = { - suspended = false, - expiration_paused = false, - auto_reset_timeout= true, + suspended = false, + expiration_paused = false, + auto_reset_timeout = true, + image_animations_enabled = false, + persistence_enabled = false, } --TODO v5 Deprecate the public `naughty.notifications` (to make it private) @@ -514,6 +540,18 @@ end -- including, but not limited to, all `naughty.notification` properties. -- @signal request::preset +--- Emitted when an action requires an icon it doesn't know. +-- +-- The implementation should look in the icon theme for an action icon or +-- provide something natively. +-- +-- If an icon is found, the handler must set the `icon` property on the `action` +-- object to a path or a `gears.surface`. +-- +-- @signal request::icon +-- @tparam naughty.action action The action. +-- @tparam string icon_name The icon name. + -- Register a new notification object. local function register(notification, args) -- Add the some more properties @@ -564,6 +602,8 @@ local function set_index_miss(_, key, value) if not value then resume() end + + naughty.emit_signal("property::"..key) else rawset(naughty, key, value) end diff --git a/lib/naughty/dbus.lua b/lib/naughty/dbus.lua index 6672de971..27c83b3fd 100644 --- a/lib/naughty/dbus.lua +++ b/lib/naughty/dbus.lua @@ -14,6 +14,7 @@ local string = string local capi = { awesome = awesome } local gtable = require("gears.table") local gsurface = require("gears.surface") +local gdebug = require("gears.debug") local protected_call = require("gears.protected_call") local lgi = require("lgi") local cairo, Gio, GLib, GObject = lgi.cairo, lgi.Gio, lgi.GLib, lgi.GObject @@ -28,6 +29,10 @@ local cst = require("naughty.constants") local nnotif = require("naughty.notification") local naction = require("naughty.action") +local capabilities = { + "body", "body-markup", "icon-static", "actions", "action-icons" +} + --- Notification library, dbus bindings local dbus = { config = {} } @@ -37,8 +42,8 @@ local bus_connection -- DBUS Notification constants -- https://developer.gnome.org/notification-spec/#urgency-levels local urgency = { - low = "\0", - normal = "\1", + low = "\0", + normal = "\1", critical = "\2" } @@ -123,7 +128,7 @@ end local notif_methods = {} function notif_methods.Notify(sender, object_path, interface, method, parameters, invocation) - local appname, replaces_id, icon, title, text, actions, hints, expire = + local appname, replaces_id, app_icon, title, text, actions, hints, expire = unpack(parameters.value) local args = {} @@ -167,14 +172,26 @@ function notif_methods.Notify(sender, object_path, interface, method, parameters notification:destroy(cst.notification_closed_reason.dismissed_by_user) end elseif action_id ~= nil and action_text ~= nil then + local a = naction { name = action_text, - position = action_id, + id = action_id, + position = (i - 1)/2 + 1, } + -- Right now `gears` doesn't have a great icon implementation + -- and `naughty` doesn't depend on `menubar`, so delegate the + -- icon "somewhere" using a request. + if hints["action-icons"] and action_id ~= "" then + naughty.emit_signal("request::icon", a, action_id) + end + a:connect_signal("invoked", function() sendActionInvoked(notification.id, action_id) - notification:destroy(cst.notification_closed_reason.dismissed_by_user) + + if not notification.resident then + notification:destroy(cst.notification_closed_reason.dismissed_by_user) + end end) table.insert(args.actions, a) @@ -189,22 +206,34 @@ function notif_methods.Notify(sender, object_path, interface, method, parameters member = method, sender = sender, bus = "session" } if not preset.callback or (type(preset.callback) == "function" and - preset.callback(legacy_data, appname, replaces_id, icon, title, text, actions, hints, expire)) then - if icon ~= "" then - args.icon = icon - elseif hints.icon_data or hints.image_data then + preset.callback(legacy_data, appname, replaces_id, app_icon, title, text, actions, hints, expire)) then + + + if app_icon ~= "" then + args.app_icon = app_icon + end + + if hints.icon_data or hints.image_data or hints["image-data"] then -- Icon data is a bit complex and hence needs special care: -- .value breaks with the array of bytes (ay) that we get here. -- So, bypass it and look up the needed value differently - local icon_data + local icon_condidates = {} for k, v in parameters:get_child_value(7 - 1):pairs() do - if k == "icon_data" then - icon_data = v - elseif k == "image_data" and icon_data == nil then - icon_data = v + if k == "image-data" then + icon_condidates[1] = v -- not deprecated + break + elseif k == "image_data" then -- deprecated + icon_condidates[2] = v + elseif k == "icon_data" then -- deprecated + icon_condidates[3] = v end end + -- The order is mandated by the spec. + local icon_data = icon_condidates[1] + or icon_condidates[2] + or icon_condidates[3] + -- icon_data is an array: -- 1 -> width -- 2 -> height @@ -218,20 +247,87 @@ function notif_methods.Notify(sender, object_path, interface, method, parameters -- GVariant.data to get that as an LGI byte buffer. That one can -- then by converted to a string via its __tostring metamethod. local data = tostring(icon_data:get_child_value(7 - 1).data) - args.icon = convert_icon(icon_data[1], icon_data[2], icon_data[3], icon_data[6], data) + args.image = convert_icon(icon_data[1], icon_data[2], icon_data[3], icon_data[6], data) + + -- Convert all animation frames. + if naughty.image_animations_enabled then + args.images = {args.image} + + if #icon_data > 7 then + for frame=8, #icon_data do + data = tostring(icon_data:get_child_value(frame-1).data) + + table.insert( + args.images, + convert_icon( + icon_data[1], + icon_data[2], + icon_data[3], + icon_data[6], + data + ) + ) + end + end + end end + + -- Alternate ways to set the icon. The specs recommends to allow both + -- the icon and image to co-exist since they serve different purpose. + -- However in case the icon isn't specified, use the image. + args.image = args.image + or hints["image-path"] -- not deprecated + or hints["image_path"] -- deprecated + + if naughty.image_animations_enabled then + args.images = args.images or {} + end + if replaces_id and replaces_id ~= "" and replaces_id ~= 0 then args.replaces_id = replaces_id end if expire and expire > -1 then args.timeout = expire / 1000 end + args.freedesktop_hints = hints + -- Not very pretty, but given the current format is documented in the + -- public API... well, whatever... + if hints and hints.urgency then + for name, key in pairs(urgency) do + local b = string.char(hints.urgency) + if key == b then + args.urgency = name + end + end + end + + args.urgency = args.urgency or "normal" + -- Try to update existing objects when possible notification = naughty.get_by_id(replaces_id) if notification then + if not notification._private._unique_sender then + -- If this happens, the notification is either trying to + -- highjack content created within AwesomeWM or it is garbage + -- to begin with. + gdebug.print_warning( + "A notification has been received, but tried to update ".. + "the content of a notification it does not own." + ) + elseif notification._private._unique_sender ~= sender then + -- Nothing says you cannot and some scripts may do it + -- accidentally, but this is rather unexpected. + gdebug.print_warning( + "Notification "..notification.title.." is being updated".. + "by a different DBus connection ("..sender.."), this is ".. + "suspicious. The original connection was ".. + notification._private._unique_sender + ) + end + for k, v in pairs(args) do if k == "destroy" then k = "destroy_cb" end notification[k] = v @@ -240,6 +336,9 @@ function notif_methods.Notify(sender, object_path, interface, method, parameters -- Even if no property changed, restart the timeout. notification:reset_timeout() else + -- Only set the sender for new notifications. + args._unique_sender = sender + notification = nnotif(args) end @@ -261,16 +360,14 @@ end function notif_methods.GetServerInformation(_, _, _, _, _, invocation) -- name of notification app, name of vender, version, specification version invocation:return_value(GLib.Variant("(ssss)", { - "naughty", "awesome", capi.awesome.version, "1.0" + "naughty", "awesome", capi.awesome.version, "1.2" })) end function notif_methods.GetCapabilities(_, _, _, _, _, invocation) -- We actually do display the body of the message, we support , -- and in the body and we handle static (non-animated) icons. - invocation:return_value(GLib.Variant("(as)", { - { "body", "body-markup", "icon-static", "actions" } - })) + invocation:return_value(GLib.Variant("(as)", {capabilities})) end local function method_call(_, sender, object_path, interface, method, parameters, invocation) @@ -330,6 +427,101 @@ local function on_bus_acquire(conn, _) GObject.Closure(method_call)) end +local bus_proxy, pid_for_unique_name = nil, {} + +Gio.DBusProxy.new_for_bus( + Gio.BusType.SESSION, + Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES, + nil, + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + nil, + function(proxy) + bus_proxy = proxy + end, + nil +) + +--- Get the clients associated with a notification. +-- +-- Note that is based on the process PID. PIDs roll over, so don't use this +-- with very old notifications. +-- +-- Also note that some multi-process application can use a different process +-- for the clients and the service used to send the notifications. +-- +-- Since this is based on PIDs, it is impossible to know which client sent the +-- notification if the process has multiple clients (windows). Using the +-- `client.type` can be used to further filter this list into more probable +-- candidates (tooltips, menus and dialogs are unlikely to send notifications). +-- +-- @tparam naughty.notification notif A notification object. +-- @treturn table A table with all associated clients. +function dbus.get_clients(notif) + -- First, the trivial case, but I never found an app that implements it. + -- It isn't standardized, but mentioned as possible. + local win_id = notif.freedesktop_hints and (notif.freedesktop_hints.window_ID + or notif.freedesktop_hints["window-id"] + or notif.freedesktop_hints.windowID + or notif.freedesktop_hints.windowid) + + if win_id then + for _, c in ipairs(client.get()) do + if c.window_id == win_id then + return {win_id} + end + end + end + + -- Less trivial, but mentioned in the spec. Note that this isn't + -- recommended by the spec, let alone mandatory. It is mentioned it can + -- exist. This wont work with Flatpak or Snaps. + local pid = notif.freedesktop_hints and ( + notif.freedesktop_hints.PID or notif.freedesktop_hints.pid + ) + + if ((not bus_proxy) or not notif._private._unique_sender) and not pid then + return {} + end + + if (not pid) and (not pid_for_unique_name[notif._private._unique_sender]) then + local owner = GLib.Variant("(s)", {notif._private._unique_sender}) + + -- It is sync, but this isn't done very often and since it is DBus + -- daemon itself, it is very responsive. Doing this using the async + -- variant would cause the clients to be unavailable in the notification + -- rules. + pid = bus_proxy:call_sync("GetConnectionUnixProcessID", + owner, + Gio.DBusCallFlags.NONE, + -1 + ) + + if (not pid) or (not pid.value) then return {} end + + pid = pid.value and pid.value[1] + + if not pid then return {} end + + pid_for_unique_name[notif._private._unique_sender] = pid + end + + pid = pid or pid_for_unique_name[notif._private._unique_sender] + + if not pid then return {} end + + local ret = {} + + for _, c in ipairs(client.get()) do + if c.pid == pid then + table.insert(ret, c) + end + end + + return ret +end + local function on_name_acquired(conn, _) bus_connection = conn end @@ -345,6 +537,35 @@ Gio.bus_own_name(Gio.BusType.SESSION, "org.freedesktop.Notifications", -- For testing dbus._notif_methods = notif_methods +local function remove_capability(cap) + for k, v in ipairs(capabilities) do + if v == cap then + table.remove(capabilities, k) + break + end + end +end + +-- Update the capabilities. +naughty.connect_signal("property::persistence_enabled", function() + remove_capability("persistence") + + if naughty.persistence_enabled then + table.insert(capabilities, "persistence") + end +end) +naughty.connect_signal("property::image_animations_enabled", function() + remove_capability("icon-multi") + remove_capability("icon-static") + + table.insert(capabilities, naughty.persistence_enabled + and "icon-multi" or "icon-static" + ) +end) + +-- For the tests. +dbus._capabilities = capabilities + return dbus -- vim: filetype=lua:expandtab:shiftwidth=4:tabstop=8:softtabstop=4:textwidth=80 diff --git a/lib/naughty/notification.lua b/lib/naughty/notification.lua index b00b880bf..56f7b22a0 100644 --- a/lib/naughty/notification.lua +++ b/lib/naughty/notification.lua @@ -15,12 +15,13 @@ -- @copyright 2017 Emmanuel Lepage Vallee -- @coreclassmod naughty.notification --------------------------------------------------------------------------- -local gobject = require("gears.object") -local gtable = require("gears.table") -local timer = require("gears.timer") -local cst = require("naughty.constants") -local naughty = require("naughty.core") -local gdebug = require("gears.debug") +local gobject = require("gears.object") +local gtable = require("gears.table") +local gsurface = require("gears.surface") +local timer = require("gears.timer") +local cst = require("naughty.constants") +local naughty = require("naughty.core") +local gdebug = require("gears.debug") local notification = {} @@ -95,6 +96,72 @@ local notification = {} -- @property timeout -- @param number +--- The notification urgency level. +-- +-- The default urgency levels are: +-- +-- * low +-- * normal +-- * critical +-- +-- @property urgency +-- @param string + +--- The notification category. +-- +-- The category should be named using the `x-vendor.class.name` naming scheme or +-- use one of the default categories: +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +--
NameDescription
deviceA generic device-related notification that +-- doesn't fit into any other category.
device.addedA device, such as a USB device, was added to the system.
device.errorA device had some kind of error.
device.removedA device, such as a USB device, was removed from the system.
emailA generic e-mail-related notification that doesn't fit into +-- any other category.
email.arrivedA new e-mail notification.
email.bouncedA notification stating that an e-mail has bounced.
imA generic instant message-related notification that doesn't fit into +-- any other category.
im.errorAn instant message error notification.
im.receivedA received instant message notification.
networkA generic network notification that doesn't fit into any other +-- category.
network.connectedA network connection notification, such as successful +-- sign-on to a network service.
+-- This should not be confused with device.added for new network devices.
network.disconnectedA network disconnected notification. This should not +-- be confused with
+-- device.removed for disconnected network devices.
network.errorA network-related or connection-related error.
presenceA generic presence change notification that doesn't fit into any +-- other category,
+-- such as going away or idle.
presence.offlineAn offline presence change notification.
presence.onlineAn online presence change notification.
transferA generic file transfer or download notification that doesn't +-- fit into any other category.
transfer.completeA file transfer or download complete notification.
transfer.errorA file transfer or download error.
+-- +-- @property category +-- @tparam string|nil category + +--- True if the notification should be kept when an action is pressed. +-- +-- By default, invoking an action will destroy the notification. Some actions, +-- like the "Snooze" action of alarm clock, will cause the notification to +-- be updated with a date further in the future. +-- +-- @property resident +-- @param[opt=false] boolean + --- Delay in seconds after which hovered popup disappears. -- @property hover_timeout -- @param number @@ -143,14 +210,59 @@ local notification = {} -- @property font -- @param string ---- Path to icon. +--- "All in one" way to access the default image or icon. +-- +-- A notification can provide a combination of an icon, a static image, or if +-- enabled, a looping animation. Add to that the ability to import the icon +-- information from the client or from a `.desktop` file, there is multiple +-- conflicting sources of "icons". +-- +-- On the other hand, the vast majority of notifications don't multiple or +-- ambiguous sources of icons. This property will pick the first of the +-- following. +-- +-- * The `image`. +-- * The `app_icon`. +-- * The `icon` from a client with `normal` type. +-- * The `icon` of a client with `dialog` type. +-- -- @property icon -- @tparam string|surface icon +-- @see app_icon +-- @see image --- Desired icon size in px. -- @property icon_size -- @param number +--- The icon provided in the `app_icon` field of the DBus notification. +-- +-- This should always be either the URI (path) to an icon or a valid XDG +-- icon name to be fetched from the theme. +-- +-- @property app_icon +-- @param string + +--- The notification image. +-- +-- This is usually provided as a `gears.surface` object. The image is used +-- instead of the `app_icon` by notification assets which are auto-generated +-- or stored elsewhere than the filesystem (databases, web, Android phones, etc). +-- +-- @property image +-- @tparam string|surface image + +--- The notification (animated) images. +-- +-- Note that calling this without first setting +-- `naughty.image_animations_enabled` to true will throw an exception. +-- +-- Also note that there is *zero* support for this anywhere else in `naughty` +-- and very, very few applications support this. +-- +-- @property images +-- @tparam nil|table images + --- Foreground color. -- --@DOC_awful_notification_fg_EXAMPLE@ @@ -284,11 +396,20 @@ local notification = {} -- @property ignore_suspend If set to true this notification -- will be shown even if notifications are suspended via `naughty.suspend`. +--- A list of clients associated with this notification. +-- +-- When used with DBus notifications, this returns all clients sharing the PID +-- of the notification sender. Note that this is highly unreliable. +-- Applications that use a different process to send the notification or +-- applications (and scripts) calling the `notify-send` command wont have any +-- client. +-- +-- @property clients +-- @param table + --FIXME remove the screen attribute, let the handlers decide -- document all handler extra properties ---FIXME add methods such as persist - --- Destroy notification by notification object. -- -- @method destroy @@ -398,7 +519,8 @@ local properties = { "fg" , "bg" , "height" , "border_color" , "shape" , "opacity" , "margin" , "ignore_suspend", "destroy" , "preset" , "callback", "actions" , - "run" , "id" , "ignore" , "auto_reset_timeout" + "run" , "id" , "ignore" , "auto_reset_timeout", + "urgency" , "image" , "images" , } for _, prop in ipairs(properties) do @@ -425,12 +547,68 @@ for _, prop in ipairs(properties) do if reset then self:reset_timeout() end - - return end end +local hints_default = { + urgency = "normal", + resident = false, +} + +for _, prop in ipairs { "category", "resident" } do + notification["get_"..prop] = notification["get_"..prop] or function(self) + return self._private[prop] or ( + self._private.freedesktop_hints and self._private.freedesktop_hints[prop] + ) or hints_default[prop] + end + + notification["set_"..prop] = notification["set_"..prop] or function(self, value) + self._private[prop] = value + self:emit_signal("property::"..prop, value) + end +end + +function notification.get_icon(self) + if self._private.icon then + return self._private.icon == "" and nil or self._private.icon + elseif self.image and self.image ~= "" then + return self.image + elseif self._private.app_icon and self._private.app_icon ~= "" then + return self._private.app_icon + end + + local clients = notification.get_clients(self) + + for _, c in ipairs(clients) do + if c.type == "normal" then + self._private.icon = gsurface(c.icon) + return self._private.icon + end + end + + for _, c in ipairs(clients) do + if c.type == "dialog" then + self._private.icon = gsurface(c.icon) + return self._private.icon + end + end + + return nil +end + +function notification.get_clients(self) + -- Clients from the future don't send notification, it's useless to reload + -- the list over and over. + if self._private.clients then return self._private.clients end + + if not self._private._unique_sender then return {} end + + self._private.clients = require("naughty.dbus").get_clients(self) + + return self._private.clients +end + --TODO v6: remove this local function convert_actions(actions) gdebug.deprecate( diff --git a/tests/run.sh b/tests/run.sh index 99cb2e2b5..bfb85a03e 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -250,7 +250,7 @@ for f in $tests; do error="$(echo "$error" | grep -vE ".{19} W: awesome: (Can't read color .* from GTK)" || true)" if [[ $fail_on_warning ]]; then # Filter out ignored warnings. - error="$(echo "$error" | grep -vE ".{19} W: awesome: (a_glib_poll|Cannot reliably detect EOF|beautiful: can't get colorscheme from xrdb|Can't read color .* from GTK+3 theme)" || true)" + error="$(echo "$error" | grep -vE ".{19} W: awesome: (a_glib_poll|Cannot reliably detect EOF|beautiful: can't get colorscheme from xrdb|Can't read color .* from GTK+3 theme|A notification|Notification)" || true)" fi if [[ -n "$error" ]]; then color_red