"""org.freedesktop.Notifications D-Bus service — the well-known name dunst used
to own. Parses each Notify call and hands it to the stack window (window.py) to
render as a holographic card; owns per-notification expiry timers and emits the
spec's NotificationClosed / ActionInvoked signals.
Also owns two custom interfaces colocated at the same object path (mirrors
dunst parking org.dunstproject.cmd0 alongside its own freedesktop interface):
eu.abdelbaki.mnotifd.History1 disk-persisted notification history —
List/Get/Pop/PopLatest/Remove/Clear/
InvokeAction + HistoryAdded/Removed/Cleared
signals. See history.py for the storage side.
eu.abdelbaki.mnotifd.Controls1 push-only: ControlChanged(id, control_id,
value) for the rich embedded controls apps
can opt into via the x-mnotifd-controls hint
(see controls.py).
Deliberately small: the visible/interactive spec subset (body markup, actions,
icons, urgency, replaces_id, timeouts, persistence) — no sound/markup-hint
gymnastics.
"""
from __future__ import annotations
import time
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gio, GLib # noqa: E402
import config
from controls import parse_controls
from history import HistoryStore
from paths import CONTROLS_IFACE, FDN_IFACE, FDN_PATH, HISTORY_IFACE
# reasons per the spec: 1 expired, 2 dismissed by user, 3 closed by call, 4 undefined
_INTROSPECTION_XML = """
"""
# a{sv} per entry (not a fixed tuple shape) so new fields can be added later
# without breaking existing clients — the same shape dunst's own
# NotificationListHistory uses.
_HISTORY_INTROSPECTION_XML = """
"""
_CONTROLS_INTROSPECTION_XML = """
"""
# server default timeouts (ms) by urgency when the client passes -1
_DEFAULT_TIMEOUT = {0: 5000, 1: 8000, 2: 0} # low / normal / critical(never)
class NotificationServer:
def __init__(self, connection: Gio.DBusConnection, window) -> None:
self._conn = connection
self._window = window
self._next_id = 1
self._timers: dict[int, int] = {}
# metadata for still-visible notifications, keyed by id — snapshotted at
# Notify() time (before render) so emit_closed() can hand a full record
# to history without window.py/notification.py needing to know about it.
self._live_meta: dict[int, dict] = {}
self._history_enabled = config.history_enabled()
self._history = HistoryStore(maxlen=config.history_length(),
persist=config.history_persist())
node = Gio.DBusNodeInfo.new_for_xml(_INTROSPECTION_XML)
self._iface = node.interfaces[0]
connection.register_object(
FDN_PATH, self._iface, self._on_method_call, None, None)
hnode = Gio.DBusNodeInfo.new_for_xml(_HISTORY_INTROSPECTION_XML)
connection.register_object(
FDN_PATH, hnode.interfaces[0], self._on_history_method_call, None, None)
cnode = Gio.DBusNodeInfo.new_for_xml(_CONTROLS_INTROSPECTION_XML)
connection.register_object(
FDN_PATH, cnode.interfaces[0], self._on_controls_method_call, None, None)
# -- D-Bus dispatch: org.freedesktop.Notifications -------------------------
def _on_method_call(self, _conn, _sender, _path, _iface, method, params, invocation):
if method == "Notify":
invocation.return_value(GLib.Variant("(u)", (self._notify(params),)))
elif method == "CloseNotification":
(nid,) = params.unpack()
self._window.close_notification(int(nid), reason=3)
self._cancel_timer(int(nid))
invocation.return_value(None)
elif method == "GetCapabilities":
invocation.return_value(GLib.Variant("(as)", (
["body", "body-markup", "icon-static", "actions", "persistence",
"x-mnotifd-controls"],)))
elif method == "GetServerInformation":
invocation.return_value(GLib.Variant("(ssss)", (
"mnotifd", "abdelbaki.eu", "1.0", "1.2")))
else:
invocation.return_error_literal(
Gio.dbus_error_quark(), Gio.DBusError.UNKNOWN_METHOD, method)
def _notify(self, params) -> int:
(app_name, replaces_id, app_icon, summary, body,
actions, hints, expire_timeout) = params.unpack()
nid = int(replaces_id) if replaces_id else self._next_id
if not replaces_id:
self._next_id += 1
urgency = int(hints.get("urgency", 1))
image_data = (hints.get("image-data") or hints.get("image_data")
or hints.get("icon_data"))
image_path = hints.get("image-path") or hints.get("image_path")
icon = image_path or app_icon or ""
controls_raw = hints.get("x-mnotifd-controls")
controls = parse_controls(controls_raw)
self._live_meta[nid] = {
"id": nid, "app_name": app_name or "", "summary": summary,
"body": body, "urgency": urgency, "icon": icon,
"actions": list(actions), "controls": controls_raw,
"timestamp": time.time(),
}
self._window.show_notification(
nid, summary, body, urgency, icon, image_data, list(actions),
controls, self._emit_action, self._emit_control)
self._arm_timer(nid, int(expire_timeout), urgency)
return nid
# -- expiry timers --------------------------------------------------------
def _arm_timer(self, nid: int, expire_timeout: int, urgency: int) -> None:
self._cancel_timer(nid)
if expire_timeout < 0:
ms = _DEFAULT_TIMEOUT.get(urgency, 8000)
else:
ms = expire_timeout
if ms <= 0:
return # 0 = never expire (or critical default)
self._timers[nid] = GLib.timeout_add(ms, self._on_expire, nid)
def _on_expire(self, nid: int) -> bool:
self._timers.pop(nid, None)
self._window.close_notification(nid, reason=1) # 1 = expired
return False
def _cancel_timer(self, nid: int) -> None:
tid = self._timers.pop(nid, None)
if tid is not None:
GLib.source_remove(tid)
# -- signals (also the window's on_closed / on_action / on_control callbacks)
def emit_closed(self, nid: int, reason: int) -> None:
self._cancel_timer(nid)
self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "NotificationClosed",
GLib.Variant("(uu)", (nid, reason)))
meta = self._live_meta.pop(nid, None)
if meta is not None and self._history_enabled:
entry = {**meta, "reason": reason}
evicted = self._history.record(entry)
self._emit_history_signal("HistoryAdded", GLib.Variant(
"(a{sv})", (_entry_dict(entry),)))
if evicted is not None:
self._emit_history_signal("HistoryRemoved", GLib.Variant(
"(u)", (int(evicted["id"]),)))
def _emit_action(self, nid: int, key: str) -> None:
self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "ActionInvoked",
GLib.Variant("(us)", (nid, key)))
# dismiss the card once its action fired, matching typical daemon behaviour
self._window.close_notification(nid, reason=2)
def _emit_control(self, nid: int, control_id: str, value) -> None:
self._conn.emit_signal(None, FDN_PATH, CONTROLS_IFACE, "ControlChanged",
GLib.Variant("(usv)", (nid, control_id, _variant_for(value))))
def _emit_history_signal(self, name: str, payload: GLib.Variant) -> None:
self._conn.emit_signal(None, FDN_PATH, HISTORY_IFACE, name, payload)
# -- D-Bus dispatch: eu.abdelbaki.mnotifd.History1 --------------------------
def _on_history_method_call(self, _conn, _sender, _path, _iface, method, params, invocation):
if method == "List":
entries = [_entry_dict(e) for e in self._history.list_all()]
invocation.return_value(GLib.Variant("(aa{sv})", (entries,)))
elif method == "Get":
(nid,) = params.unpack()
entry = self._history.get(int(nid))
if entry is None:
invocation.return_error_literal(
Gio.dbus_error_quark(), Gio.DBusError.FAILED,
f"no history entry with id {nid}")
else:
invocation.return_value(GLib.Variant("(a{sv})", (_entry_dict(entry),)))
elif method == "Pop":
(nid,) = params.unpack()
entry = self._history.pop(int(nid))
if entry is None:
invocation.return_error_literal(
Gio.dbus_error_quark(), Gio.DBusError.FAILED,
f"no history entry with id {nid}")
else:
self._emit_history_signal("HistoryRemoved", GLib.Variant("(u)", (int(nid),)))
invocation.return_value(GLib.Variant("(u)", (self._redisplay(entry),)))
elif method == "PopLatest":
entry = self._history.pop_latest()
if entry is None:
invocation.return_error_literal(
Gio.dbus_error_quark(), Gio.DBusError.FAILED, "history is empty")
else:
self._emit_history_signal(
"HistoryRemoved", GLib.Variant("(u)", (int(entry["id"]),)))
invocation.return_value(GLib.Variant("(u)", (self._redisplay(entry),)))
elif method == "Remove":
(nid,) = params.unpack()
if self._history.remove(int(nid)):
self._emit_history_signal("HistoryRemoved", GLib.Variant("(u)", (int(nid),)))
invocation.return_value(None)
elif method == "Clear":
removed = self._history.clear()
self._emit_history_signal("HistoryCleared", GLib.Variant("(u)", (len(removed),)))
invocation.return_value(None)
elif method == "InvokeAction":
(nid, key) = params.unpack()
entry = self._history.get(int(nid))
if entry is None:
invocation.return_error_literal(
Gio.dbus_error_quark(), Gio.DBusError.FAILED,
f"no history entry with id {nid}")
else:
# Re-emit ActionInvoked for a *historical* entry without bringing the
# card back on-screen — mnotifd (unlike dunst) doesn't void actions once
# a notification leaves the queue, since invoking one has always just
# meant "emit the signal," independent of whether a card widget exists.
self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "ActionInvoked",
GLib.Variant("(us)", (int(nid), key)))
invocation.return_value(None)
else:
invocation.return_error_literal(
Gio.dbus_error_quark(), Gio.DBusError.UNKNOWN_METHOD, method)
def _redisplay(self, entry: dict) -> int:
"""Re-render a history entry as a live card (History1.Pop/PopLatest)."""
new_nid = self._next_id
self._next_id += 1
controls = parse_controls(entry.get("controls"))
self._window.show_notification(
new_nid, entry.get("summary", ""), entry.get("body", ""),
int(entry.get("urgency", 1)), entry.get("icon", ""), None,
list(entry.get("actions") or []), controls,
self._emit_action, self._emit_control)
self._live_meta[new_nid] = {
"id": new_nid, "app_name": entry.get("app_name", ""),
"summary": entry.get("summary", ""), "body": entry.get("body", ""),
"urgency": int(entry.get("urgency", 1)), "icon": entry.get("icon", ""),
"actions": list(entry.get("actions") or []),
"controls": entry.get("controls"), "timestamp": time.time(),
}
self._arm_timer(new_nid, -1, int(entry.get("urgency", 1)))
return new_nid
# -- D-Bus dispatch: eu.abdelbaki.mnotifd.Controls1 (signal-only, no methods)
def _on_controls_method_call(self, _conn, _sender, _path, _iface, method, _params, invocation):
invocation.return_error_literal(
Gio.dbus_error_quark(), Gio.DBusError.UNKNOWN_METHOD, method)
def _entry_dict(entry: dict) -> dict:
"""A plain dict with GLib.Variant leaf values, ready to embed in an a{sv}."""
return {
"id": GLib.Variant("u", int(entry["id"])),
"app_name": GLib.Variant("s", entry.get("app_name") or ""),
"summary": GLib.Variant("s", entry.get("summary") or ""),
"body": GLib.Variant("s", entry.get("body") or ""),
"icon": GLib.Variant("s", entry.get("icon") or ""),
"urgency": GLib.Variant("i", int(entry.get("urgency", 1))),
"timestamp": GLib.Variant("d", float(entry.get("timestamp", 0.0))),
"reason": GLib.Variant("i", int(entry.get("reason", 0))),
"actions": GLib.Variant("as", list(entry.get("actions") or [])),
"controls": GLib.Variant("s", entry.get("controls") or ""),
}
def _variant_for(value) -> GLib.Variant:
if isinstance(value, bool):
return GLib.Variant("b", value)
if isinstance(value, (int, float)):
return GLib.Variant("d", float(value))
return GLib.Variant("s", str(value))