diff --git a/pyproject.toml b/pyproject.toml index 47711f35b..0ededc249 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "bashlex==0.18", "pathspec==1.1.1", "notify-py==0.3.43", + "jeepney==0.9.0; sys_platform == 'linux'", "pyperclip==1.11.0", "watchdog==6.0.0", "setproctitle==1.3.7", diff --git a/src/toad/setting_choices.py b/src/toad/setting_choices.py index fea6365ab..0d12bd17e 100644 --- a/src/toad/setting_choices.py +++ b/src/toad/setting_choices.py @@ -6,9 +6,11 @@ from dataclasses import dataclass from typing import ClassVar from random import choice +from threading import Lock from textual.content import Content from toad.widgets.future_text import FutureText +import toad from agent_comms.declared_family import DeclaredFamily from toad.setting_declarations import Choice @@ -96,24 +98,90 @@ def shown(cls, count: int) -> bool: return False +class DesktopService: + """The platform's notification service, as notifypy detects it.""" + + def notification(self): + from notifypy import Notify + + return Notify() + + def send(self, app, notification) -> DesktopService: + notification.send() + return self + + +class DBusDesktop(DesktopService): + """notifypy's notify-send path inherits Toad's terminal as stderr; its D-Bus + notifier starts no child process that could write over the TUI.""" + + def notification(self): + from notifypy import Notify + from notifypy.os_notifiers.linux import LinuxNotifier + + return Notify(use_custom_notifier=LinuxNotifier) + + +class UncheckedDBusDesktop(DBusDesktop): + """Asks the session bus once whether anything serves notifications, because + notifypy's D-Bus notifier reports an error reply as a delivery.""" + + name: ClassVar[str] = "org.freedesktop.Notifications" + + def send(self, app, notification) -> DesktopService: + return self.served(app).send(app, notification) + + def served(self, app) -> DesktopService: + from jeepney import DBusAddress, new_method_call + from jeepney.io.blocking import open_dbus_connection + + bus = DBusAddress("/org/freedesktop/DBus", bus_name="org.freedesktop.DBus", + interface="org.freedesktop.DBus") + try: + with open_dbus_connection(bus="SESSION") as connection: + owned = connection.send_and_get_reply( + new_method_call(bus, "NameHasOwner", "s", (self.name,)), timeout=2).body[0] + activatable = self.name in connection.send_and_get_reply( + new_method_call(bus, "ListActivatableNames"), timeout=2).body[0] + except OSError: + owned = activatable = False + if owned or activatable: + return DBusDesktop() + app.call_from_thread( + app.notify, + "Desktop notifications are off for this session: no notification daemon is running.", + severity="warning", + ) + return AbsentDesktop() + + +class AbsentDesktop(DesktopService): + """Nothing serves notifications; asking again per message only fails again.""" + + def send(self, app, notification) -> DesktopService: + return self + + class NotificationPolicy(Choice, DeclaredFamily, affix="Notification"): + desktop: ClassVar[DesktopService] = UncheckedDBusDesktop() if toad.os == "linux" else DesktopService() + sending: ClassVar[Lock] = Lock() + @classmethod def deliver(cls, app, message: str, *, title: str = "", sound: str | None = None) -> None: """Shared delivery belongs to the policy that admits it.""" if not cls.enabled(app.app_focus): return from importlib.resources import files - from notifypy import Notify - import toad - notification = Notify() + notification = cls.desktop.notification() notification.message = message notification.title = title notification.application_name = "🐸 Toad" if toad.os == "macos" else "Toad" if sound and app.settings.notifications.enable_sounds: notification.audio = str(files("toad.data").joinpath(f"sounds/{sound}.wav")) notification.icon = str(files("toad.data").joinpath("images/frog.png")) - notification.send() + with cls.sending: + NotificationPolicy.desktop = NotificationPolicy.desktop.send(app, notification) @classmethod @abstractmethod diff --git a/uv.lock b/uv.lock index fcc024882..3526c5756 100644 --- a/uv.lock +++ b/uv.lock @@ -255,6 +255,7 @@ dependencies = [ { name = "format-currency" }, { name = "formbridge" }, { name = "httpx" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, { name = "notify-py" }, { name = "objectstate" }, { name = "packaging" }, @@ -298,6 +299,7 @@ requires-dist = [ { name = "format-currency", specifier = ">=0.0.10" }, { name = "formbridge", git = "https://github.com/OpenHCSDev/formbridge.git?rev=ebe2fa9173428e2bd7154a0de23698757677c3c2" }, { name = "httpx", specifier = "==0.28.1" }, + { name = "jeepney", marker = "sys_platform == 'linux'", specifier = "==0.9.0" }, { name = "notify-py", specifier = "==0.3.43" }, { name = "objectstate", git = "https://github.com/OpenHCSDev/objectstate.git?rev=b5dd9f78b9293df145d7629f21deb89b042b9400" }, { name = "packaging", specifier = "==26.2" },