nordvpntray/venv/lib/python3.12/site-packages/desktop_notifier/backends/dbus.py

300 lines
11 KiB
Python
Raw Permalink Normal View History

2024-12-23 11:03:07 +01:00
# -*- coding: utf-8 -*-
"""
Notification backend for Linux
Includes an implementation to send desktop notifications over Dbus. Responding to user
interaction with a notification requires a running asyncio event loop.
"""
from __future__ import annotations
import logging
from typing import TypeVar
from bidict import bidict
from dbus_fast.aio.message_bus import MessageBus
from dbus_fast.aio.proxy_object import ProxyInterface
from dbus_fast.errors import DBusError
from dbus_fast.signature import Variant
from ..common import Capability, Notification, Urgency
from .base import DesktopNotifierBackend
__all__ = ["DBusDesktopNotifier"]
logger = logging.getLogger(__name__)
T = TypeVar("T")
NOTIFICATION_CLOSED_EXPIRED = 1
NOTIFICATION_CLOSED_DISMISSED = 2
NOTIFICATION_CLOSED_PROGRAMMATICALLY = 3
NOTIFICATION_CLOSED_UNDEFINED = 4
class DBusDesktopNotifier(DesktopNotifierBackend):
"""DBus notification backend for Linux
This implements the org.freedesktop.Notifications standard. The DBUS connection is
created in a thread with a running asyncio loop to handle clicked notifications.
:param app_name: The name of the app.
"""
to_native_urgency = {
Urgency.Low: Variant("y", 0),
Urgency.Normal: Variant("y", 1),
Urgency.Critical: Variant("y", 2),
}
supported_hint_signatures = {"a{sv}", "a{ss}"}
def __init__(self, app_name: str) -> None:
super().__init__(app_name)
self.interface: ProxyInterface | None = None
self._platform_to_interface_notification_identifier: bidict[int, str] = bidict()
async def request_authorisation(self) -> bool:
"""
Request authorisation to send notifications.
:returns: Whether authorisation has been granted.
"""
return True
async def has_authorisation(self) -> bool:
"""
Whether we have authorisation to send notifications.
"""
return True
async def _init_dbus(self) -> ProxyInterface:
self.bus = await MessageBus().connect()
introspection = await self.bus.introspect(
"org.freedesktop.Notifications", "/org/freedesktop/Notifications"
)
self.proxy_object = self.bus.get_proxy_object(
"org.freedesktop.Notifications",
"/org/freedesktop/Notifications",
introspection,
)
self.interface = self.proxy_object.get_interface(
"org.freedesktop.Notifications"
)
# Some older interfaces may not support notification actions.
if hasattr(self.interface, "on_notification_closed"):
self.interface.on_notification_closed(self._on_closed)
if hasattr(self.interface, "on_action_invoked"):
self.interface.on_action_invoked(self._on_action)
return self.interface
async def _send(self, notification: Notification) -> None:
"""
Asynchronously sends a notification via the Dbus interface.
:param notification: Notification to send.
"""
if not self.interface:
self.interface = await self._init_dbus()
# The "default" action is typically invoked when clicking on the
# notification body itself, see
# https://specifications.freedesktop.org/notification-spec. There are some
# exceptions though, such as XFCE, where this will result in a separate
# button. If no label name is provided in XFCE, it will result in a default
# symbol being used. We therefore don't specify a label name.
actions = ["default", ""]
for button in notification.buttons:
actions += [button.identifier, button.title]
hints_v: dict[str, Variant] = dict()
hints_v["urgency"] = self.to_native_urgency[notification.urgency]
if notification.sound:
if notification.sound.is_named():
hints_v["sound-name"] = Variant("s", "message-new-instant")
else:
hints_v["sound-file"] = Variant("s", notification.sound.as_uri())
if notification.attachment:
hints_v["image-path"] = Variant("s", notification.attachment.as_uri())
# The current notification spec defines hints as a Dbus dictionary type 'a{sv}',
# represented in Python as dict[str, Variant]. However, some older notification
# servers expect 'a{ss}' (Python dict[str, str]). We therefore check the
# expected argument type at runtime and cast arguments accordingly.
# See https://github.com/samschott/desktop-notifier/issues/143.
hints_signature = get_hints_signature(self.interface)
if hints_signature == "":
logger.warning("Notification server not supported")
return
hints: dict[str, str] | dict[str, Variant]
if hints_signature == "a{sv}":
hints = hints_v
elif hints_signature == "a{ss}":
hints = {k: str(v.value) for k, v in hints_v.items()}
else:
hints = {}
timeout = notification.timeout * 1000 if notification.timeout != -1 else -1
if notification.icon:
if notification.icon.is_named():
icon = notification.icon.name
else:
icon = notification.icon.as_uri()
else:
icon = ""
# dbus_next proxy APIs are generated at runtime. Silence the type checker but
# raise an AttributeError if required.
platform_id = await self.interface.call_notify( # type:ignore[attr-defined]
self.app_name,
0,
icon,
notification.title,
notification.message,
actions,
hints,
timeout,
)
self._platform_to_interface_notification_identifier[platform_id] = (
notification.identifier
)
async def _clear(self, identifier: str) -> None:
"""
Asynchronously removes a notification from the notification center
"""
if not self.interface:
return
platform_id = self._platform_to_interface_notification_identifier.inverse[
identifier
]
try:
# dbus_next proxy APIs are generated at runtime. Silence the type checker
# but raise an AttributeError if required.
await self.interface.call_close_notification( # type:ignore[attr-defined]
platform_id
)
except DBusError:
# Notification was already closed.
# See https://specifications.freedesktop.org/notification-spec/latest/protocol.html#command-close-notification
pass
try:
del self._platform_to_interface_notification_identifier.inverse[identifier]
except KeyError:
# Popping may have been handled already by _on_close callback.
pass
async def _clear_all(self) -> None:
"""
Asynchronously clears all notifications from notification center
"""
if not self.interface:
return
for identifier in list(
self._platform_to_interface_notification_identifier.values()
):
await self._clear(identifier)
# Note that _on_action and _on_closed might be called for the same notification
# with some notification servers. This is not a problem because the _on_action
# call will come first, in which case we are no longer interested in calling the
# _on_closed callback.
def _on_action(self, nid: int, action_key: str) -> None:
"""
Called when the user performs a notification action. This will invoke the
handler callback.
:param nid: The platform's notification ID as an integer.
:param action_key: A string identifying the action to take. We choose those keys
ourselves when scheduling the notification.
"""
identifier = self._platform_to_interface_notification_identifier.pop(nid, "")
notification = self._clear_notification_from_cache(identifier)
if not notification:
return
if action_key == "default":
self.handle_clicked(identifier, notification)
return
self.handle_button(identifier, action_key, notification)
def _on_closed(self, nid: int, reason: int) -> None:
"""
Called when the user closes a notification. This will invoke the registered
callback.
:param nid: The platform's notification ID as an integer.
:param reason: An integer describing the reason why the notification was closed.
"""
identifier = self._platform_to_interface_notification_identifier.pop(nid, "")
notification = self._clear_notification_from_cache(identifier)
if not notification:
return
if reason == NOTIFICATION_CLOSED_DISMISSED:
self.handle_dismissed(identifier, notification)
async def get_capabilities(self) -> frozenset[Capability]:
if not self.interface:
self.interface = await self._init_dbus()
capabilities = {
Capability.APP_NAME,
Capability.ICON,
Capability.TITLE,
Capability.TIMEOUT,
Capability.URGENCY,
}
# Capabilities supported by some notification servers.
# See https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html#protocol.
if hasattr(self.interface, "on_notification_closed"):
capabilities.add(Capability.ON_CLICKED)
capabilities.add(Capability.ON_DISMISSED)
cps = await self.interface.call_get_capabilities() # type:ignore[attr-defined]
if "actions" in cps:
capabilities.add(Capability.BUTTONS)
if "body" in cps:
capabilities.add(Capability.MESSAGE)
if "sound" in cps:
capabilities.add(Capability.SOUND)
capabilities.add(Capability.SOUND_NAME)
hints_signature = get_hints_signature(self.interface)
if hints_signature not in self.supported_hint_signatures:
# Any hint-based capabilities are not supported because we got an unexpected
# DBus interface.
capabilities.discard(Capability.SOUND)
capabilities.discard(Capability.SOUND_NAME)
capabilities.discard(Capability.URGENCY)
return frozenset(capabilities)
def get_hints_signature(interface: ProxyInterface) -> str:
"""Returns the dbus type signature for the hints argument"""
methods = interface.introspection.methods
notify_method = next(m for m in methods if m.name == "Notify")
try:
return str(notify_method.in_args[6].signature)
except IndexError:
return ""