314 lines
10 KiB
Python
314 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Notification backend for Windows
|
|
|
|
Unlike other platforms, sending rich "toast" notifications cannot be done via FFI /
|
|
ctypes because the C winapi only supports basic notifications with a title and message.
|
|
This backend therefore requires interaction with the Windows Runtime and uses the winrt
|
|
package with compiled components.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import sys
|
|
import winreg
|
|
from typing import TypeVar
|
|
from xml.etree.ElementTree import Element, SubElement, tostring
|
|
|
|
from winrt.system import Object as WinRTObject
|
|
from winrt.windows.applicationmodel.core import CoreApplication
|
|
from winrt.windows.data.xml.dom import XmlDocument
|
|
from winrt.windows.foundation.interop import unbox
|
|
from winrt.windows.ui.notifications import (
|
|
NotificationSetting,
|
|
ToastActivatedEventArgs,
|
|
ToastDismissalReason,
|
|
ToastDismissedEventArgs,
|
|
ToastFailedEventArgs,
|
|
ToastNotification,
|
|
ToastNotificationManager,
|
|
ToastNotificationPriority,
|
|
)
|
|
|
|
# local imports
|
|
from ..common import DEFAULT_SOUND, Capability, Notification, Urgency
|
|
from .base import DesktopNotifierBackend
|
|
|
|
__all__ = ["WinRTDesktopNotifier"]
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
T = TypeVar("T")
|
|
|
|
DEFAULT_ACTION = "default"
|
|
REPLY_ACTION = "action=reply&"
|
|
BUTTON_ACTION_PREFIX = "action=button&id="
|
|
REPLY_TEXTBOX_NAME = "textBox"
|
|
|
|
|
|
def register_hkey(app_id: str, app_name: str) -> None:
|
|
# mypy type guard
|
|
if not sys.platform == "win32":
|
|
return
|
|
|
|
winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER)
|
|
key_path = f"SOFTWARE\\Classes\\AppUserModelId\\{app_id}"
|
|
with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, key_path) as master_key:
|
|
winreg.SetValueEx(master_key, "DisplayName", 0, winreg.REG_SZ, app_name)
|
|
|
|
|
|
class WinRTDesktopNotifier(DesktopNotifierBackend):
|
|
"""Notification backend for the Windows Runtime
|
|
|
|
:param app_name: The name of the app.
|
|
"""
|
|
|
|
_to_native_urgency = {
|
|
Urgency.Low: ToastNotificationPriority.DEFAULT,
|
|
Urgency.Normal: ToastNotificationPriority.DEFAULT,
|
|
Urgency.Critical: ToastNotificationPriority.HIGH,
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
app_name: str,
|
|
) -> None:
|
|
super().__init__(app_name)
|
|
|
|
manager = ToastNotificationManager.get_default()
|
|
|
|
if not manager:
|
|
raise RuntimeError("Could not get ToastNotificationManagerForUser")
|
|
|
|
self.manager = manager
|
|
|
|
# Prefer using the real App ID if detected, fall back to user-provided name
|
|
# and icon otherwise.
|
|
if CoreApplication.id != "":
|
|
self.app_id = CoreApplication.id
|
|
else:
|
|
self.app_id = app_name
|
|
register_hkey(app_id=app_name, app_name=app_name)
|
|
|
|
notifier = self.manager.create_toast_notifier(self.app_id)
|
|
|
|
if not notifier:
|
|
raise RuntimeError(f"Could not get ToastNotifier for app_id: {self.app_id}")
|
|
|
|
self.notifier = notifier
|
|
|
|
async def request_authorisation(self) -> bool:
|
|
"""
|
|
Request authorisation to send notifications.
|
|
|
|
:returns: Whether authorisation has been granted.
|
|
"""
|
|
return await self.has_authorisation()
|
|
|
|
async def has_authorisation(self) -> bool:
|
|
"""
|
|
Whether we have authorisation to send notifications.
|
|
"""
|
|
try:
|
|
return bool(self.notifier.setting == NotificationSetting.ENABLED)
|
|
except OSError:
|
|
# See https://github.com/samschott/desktop-notifier/issues/95.
|
|
return True
|
|
|
|
async def _send(self, notification: Notification) -> None:
|
|
"""
|
|
Asynchronously sends a notification.
|
|
|
|
:param notification: Notification to send.
|
|
"""
|
|
toast_xml = Element("toast", {"launch": DEFAULT_ACTION})
|
|
visual_xml = SubElement(toast_xml, "visual")
|
|
actions_xml = SubElement(toast_xml, "actions")
|
|
|
|
if notification.thread:
|
|
SubElement(
|
|
toast_xml,
|
|
"header",
|
|
{
|
|
"id": notification.thread,
|
|
"title": notification.thread,
|
|
"arguments": DEFAULT_ACTION,
|
|
"activationType": "background",
|
|
},
|
|
)
|
|
|
|
binding = SubElement(visual_xml, "binding", {"template": "ToastGeneric"})
|
|
|
|
title_xml = SubElement(binding, "text")
|
|
title_xml.text = notification.title
|
|
|
|
message_xml = SubElement(binding, "text")
|
|
message_xml.text = notification.message
|
|
|
|
if notification.icon and notification.icon.is_file():
|
|
SubElement(
|
|
binding,
|
|
"image",
|
|
{
|
|
"placement": "appLogoOverride",
|
|
"src": notification.icon.as_uri(),
|
|
},
|
|
)
|
|
|
|
if notification.attachment:
|
|
SubElement(
|
|
binding,
|
|
"image",
|
|
{"placement": "hero", "src": notification.attachment.as_uri()},
|
|
)
|
|
|
|
if notification.reply_field:
|
|
SubElement(
|
|
actions_xml,
|
|
"input",
|
|
{"id": REPLY_TEXTBOX_NAME, "type": "text"},
|
|
)
|
|
reply_button_xml = SubElement(
|
|
actions_xml,
|
|
"action",
|
|
{
|
|
"content": notification.reply_field.button_title,
|
|
"activationType": "background",
|
|
"arguments": "action=reply&",
|
|
},
|
|
)
|
|
|
|
# If there are no other buttons, show the reply button next to the text
|
|
# field. Otherwise, show it above other buttons.
|
|
if not notification.buttons:
|
|
reply_button_xml.set("hint-inputId", REPLY_TEXTBOX_NAME)
|
|
|
|
for button in notification.buttons:
|
|
SubElement(
|
|
actions_xml,
|
|
"action",
|
|
{
|
|
"content": button.title,
|
|
"activationType": "background",
|
|
"arguments": BUTTON_ACTION_PREFIX + button.identifier,
|
|
},
|
|
)
|
|
|
|
if notification.sound:
|
|
if notification.sound == DEFAULT_SOUND:
|
|
sound_attr = {"src": "ms-winsoundevent:Notification.Default"}
|
|
elif notification.sound.name:
|
|
sound_attr = {"src": notification.sound.name}
|
|
else:
|
|
sound_attr = {"src": notification.sound.as_uri()}
|
|
else:
|
|
sound_attr = {"silent": "true"}
|
|
|
|
SubElement(toast_xml, "audio", sound_attr)
|
|
|
|
xml_document = XmlDocument()
|
|
xml_document.load_xml(tostring(toast_xml, encoding="unicode"))
|
|
|
|
native = ToastNotification(xml_document)
|
|
native.tag = notification.identifier
|
|
native.priority = self._to_native_urgency[notification.urgency]
|
|
|
|
native.add_activated(self._on_activated)
|
|
native.add_dismissed(self._on_dismissed)
|
|
native.add_failed(self._on_failed)
|
|
|
|
self.notifier.show(native)
|
|
|
|
def _on_activated(
|
|
self, sender: ToastNotification | None, boxed_activated_args: WinRTObject | None
|
|
) -> None:
|
|
if not sender:
|
|
return
|
|
|
|
notification = self._clear_notification_from_cache(sender.tag)
|
|
|
|
if not boxed_activated_args:
|
|
return
|
|
|
|
activated_args = ToastActivatedEventArgs._from(boxed_activated_args)
|
|
action_id = activated_args.arguments
|
|
|
|
if action_id == DEFAULT_ACTION:
|
|
self.handle_clicked(sender.tag, notification)
|
|
|
|
elif action_id == REPLY_ACTION and activated_args.user_input:
|
|
boxed_reply = activated_args.user_input[REPLY_TEXTBOX_NAME]
|
|
reply = unbox(boxed_reply)
|
|
self.handle_replied(sender.tag, reply, notification)
|
|
|
|
elif action_id.startswith(BUTTON_ACTION_PREFIX):
|
|
button_id = action_id.replace(BUTTON_ACTION_PREFIX, "")
|
|
self.handle_button(sender.tag, button_id, notification)
|
|
|
|
def _on_dismissed(
|
|
self,
|
|
sender: ToastNotification | None,
|
|
dismissed_args: ToastDismissedEventArgs | None,
|
|
) -> None:
|
|
if not sender:
|
|
return
|
|
|
|
notification = self._clear_notification_from_cache(sender.tag)
|
|
|
|
if (
|
|
dismissed_args
|
|
and dismissed_args.reason == ToastDismissalReason.USER_CANCELED
|
|
):
|
|
self.handle_dismissed(sender.tag, notification)
|
|
|
|
def _on_failed(
|
|
self, sender: ToastNotification | None, failed_args: ToastFailedEventArgs | None
|
|
) -> None:
|
|
if not sender:
|
|
return
|
|
|
|
self._clear_notification_from_cache(sender.tag)
|
|
if failed_args:
|
|
logger.warning(
|
|
"Notification '%s' failed with error code %s",
|
|
sender.tag,
|
|
failed_args.error_code.value,
|
|
)
|
|
else:
|
|
logger.warning("Notification '%s' failed with unknown error", sender.tag)
|
|
|
|
async def _clear(self, identifier: str) -> None:
|
|
"""
|
|
Asynchronously removes a notification from the notification center.
|
|
"""
|
|
if self.manager.history:
|
|
self.manager.history.remove(identifier)
|
|
|
|
async def _clear_all(self) -> None:
|
|
"""
|
|
Asynchronously clears all notifications from notification center.
|
|
"""
|
|
if self.manager.history:
|
|
self.manager.history.clear(self.app_id)
|
|
|
|
async def get_capabilities(self) -> frozenset[Capability]:
|
|
capabilities = {
|
|
Capability.TITLE,
|
|
Capability.MESSAGE,
|
|
Capability.ICON,
|
|
Capability.BUTTONS,
|
|
Capability.REPLY_FIELD,
|
|
Capability.ON_CLICKED,
|
|
Capability.ON_DISMISSED,
|
|
Capability.THREAD,
|
|
Capability.ATTACHMENT,
|
|
Capability.SOUND,
|
|
Capability.SOUND_NAME,
|
|
}
|
|
# Custom audio is support only starting with the Windows 10 Anniversary update.
|
|
# See https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/custom-audio-on-toasts#add-the-custom-audio.
|
|
if sys.getwindowsversion().build >= 1607: # type:ignore[attr-defined]
|
|
capabilities.add(Capability.SOUND_FILE)
|
|
|
|
return frozenset(capabilities)
|