use crate::config::NotificationConfig;
use crate::notification::{Notification, Urgency, clean_markup, parse_actions};
use crate::state::NotificationState;
use gtk4::gio;
use gtk4::glib;
use std::cell::RefCell;
use std::rc::Rc;
use std::time::SystemTime;
const INTROSPECT_XML: &str = r#"
<node>
<interface name="org.freedesktop.Notifications">
<method name="Notify">
<arg name="app_name" type="s" direction="in"/>
<arg name="replaces_id" type="u" direction="in"/>
<arg name="app_icon" type="s" direction="in"/>
<arg name="summary" type="s" direction="in"/>
<arg name="body" type="s" direction="in"/>
<arg name="actions" type="as" direction="in"/>
<arg name="hints" type="a{sv}" direction="in"/>
<arg name="expire_timeout" type="i" direction="in"/>
<arg name="id" type="u" direction="out"/>
</method>
<method name="CloseNotification">
<arg name="id" type="u" direction="in"/>
</method>
<method name="GetCapabilities">
<arg name="capabilities" type="as" direction="out"/>
</method>
<method name="GetServerInformation">
<arg name="name" type="s" direction="out"/>
<arg name="vendor" type="s" direction="out"/>
<arg name="version" type="s" direction="out"/>
<arg name="spec_version" type="s" direction="out"/>
</method>
<signal name="NotificationClosed">
<arg name="id" type="u"/>
<arg name="reason" type="u"/>
</signal>
<signal name="ActionInvoked">
<arg name="id" type="u"/>
<arg name="action_key" type="s"/>
</signal>
</interface>
</node>
"#;
const NWG_COUNT_INTROSPECT_XML: &str = r#"
<node>
<interface name="org.nwg.Notifications">
<method name="GetCount">
<arg name="count" type="u" direction="out"/>
</method>
<signal name="CountChanged">
<arg name="count" type="u"/>
</signal>
<method name="SetPopupPosition">
<arg name="position" type="s" direction="in"/>
</method>
<method name="SetPopupWidth">
<arg name="width" type="u" direction="in"/>
</method>
<method name="SetPanelWidth">
<arg name="width" type="u" direction="in"/>
</method>
<method name="SetPopupTimeout">
<arg name="timeout_ms" type="u" direction="in"/>
</method>
<method name="SetMaxPopups">
<arg name="max" type="u" direction="in"/>
</method>
<method name="SetMaxHistory">
<arg name="max" type="u" direction="in"/>
</method>
</interface>
</node>
"#;
pub(crate) const NWG_COUNT_BUS_NAME: &str = "org.nwg.Notifications";
pub(crate) const NWG_COUNT_OBJECT_PATH: &str = "/org/nwg/Notifications";
pub(crate) type OnNotify = Rc<dyn Fn(&Notification)>;
pub(crate) type OnClose = Rc<dyn Fn(u32)>;
pub(crate) fn register_server(
state: &Rc<RefCell<NotificationState>>,
config: &Rc<RefCell<NotificationConfig>>,
on_state_change: Rc<dyn Fn()>,
on_notify: OnNotify,
on_close: OnClose,
) {
let state_fdo = Rc::clone(state);
let on_notify_fdo = Rc::clone(&on_notify);
let on_close_fdo = Rc::clone(&on_close);
gio::bus_own_name(
gio::BusType::Session,
"org.freedesktop.Notifications",
gio::BusNameOwnerFlags::REPLACE,
move |connection, _name| {
log::info!("Acquired D-Bus name: org.freedesktop.Notifications");
state_fdo.borrow_mut().dbus_connection = Some(connection.clone());
register_object(&connection, &state_fdo, &on_notify_fdo, &on_close_fdo);
},
|_connection, _name| {
log::debug!("D-Bus name acquired callback");
},
|_connection, _name| {
log::error!(
"Lost D-Bus name org.freedesktop.Notifications — is another daemon running?"
);
},
);
let state_nwg = Rc::clone(state);
let config_nwg = Rc::clone(config);
let on_change_nwg = Rc::clone(&on_state_change);
gio::bus_own_name(
gio::BusType::Session,
NWG_COUNT_BUS_NAME,
gio::BusNameOwnerFlags::REPLACE,
move |connection, _name| {
log::info!("Acquired D-Bus name: {NWG_COUNT_BUS_NAME}");
register_nwg_count_object(&connection, &state_nwg, &config_nwg, &on_change_nwg);
},
|_connection, _name| {
log::debug!("nwg-count D-Bus name acquired callback");
},
|_connection, _name| {
log::error!("Lost D-Bus name {NWG_COUNT_BUS_NAME} — another daemon?");
},
);
}
fn register_object(
connection: &gio::DBusConnection,
state: &Rc<RefCell<NotificationState>>,
on_notify: &OnNotify,
on_close: &OnClose,
) {
let node_info = gio::DBusNodeInfo::for_xml(INTROSPECT_XML)
.expect("Failed to parse notification introspection XML");
let interface_info = node_info
.lookup_interface("org.freedesktop.Notifications")
.expect("Interface not found in XML");
let state = Rc::clone(state);
let on_notify = Rc::clone(on_notify);
let on_close = Rc::clone(on_close);
connection
.register_object("/org/freedesktop/Notifications", &interface_info)
.method_call(
move |_conn, _sender, _path, _iface, method, params, invocation| {
handle_method(method, params, invocation, &state, &on_notify, &on_close);
},
)
.build()
.expect("Failed to register D-Bus object");
}
fn handle_method(
method: &str,
params: glib::Variant,
invocation: gio::DBusMethodInvocation,
state: &Rc<RefCell<NotificationState>>,
on_notify: &OnNotify,
on_close: &OnClose,
) {
match method {
"Notify" => handle_notify(¶ms, invocation, state, on_notify),
"CloseNotification" => handle_close(¶ms, invocation, state, on_close),
"GetCapabilities" => handle_capabilities(invocation),
"GetServerInformation" => handle_server_info(invocation),
_ => {
log::warn!("Unknown D-Bus method: {method}");
invocation.return_dbus_error(
"org.freedesktop.DBus.Error.UnknownMethod",
&format!("Unknown method: {method}"),
);
}
}
}
fn register_nwg_count_object(
connection: &gio::DBusConnection,
state: &Rc<RefCell<NotificationState>>,
config: &Rc<RefCell<NotificationConfig>>,
on_state_change: &Rc<dyn Fn()>,
) {
let node_info = gio::DBusNodeInfo::for_xml(NWG_COUNT_INTROSPECT_XML)
.expect("Failed to parse nwg-count introspection XML");
let interface_info = node_info
.lookup_interface(NWG_COUNT_BUS_NAME)
.expect("nwg-count interface not found in XML");
let state = Rc::clone(state);
let config = Rc::clone(config);
let on_state_change = Rc::clone(on_state_change);
connection
.register_object(NWG_COUNT_OBJECT_PATH, &interface_info)
.method_call(
move |_conn, _sender, _path, _iface, method, params, invocation| {
handle_nwg_count_method(
method,
¶ms,
invocation,
&state,
&config,
&on_state_change,
);
},
)
.build()
.expect("Failed to register nwg-count D-Bus object");
}
fn handle_nwg_count_method(
method: &str,
params: &glib::Variant,
invocation: gio::DBusMethodInvocation,
state: &Rc<RefCell<NotificationState>>,
config: &Rc<RefCell<NotificationConfig>>,
on_state_change: &Rc<dyn Fn()>,
) {
match method {
"GetCount" => {
let count = unread_count_to_u32(state.borrow().unread_count());
let result = glib::Variant::from((count,));
invocation.return_value(Some(&result));
}
"SetPopupPosition" => {
handle_set_popup_position(params, invocation, config, on_state_change)
}
"SetPopupWidth" => handle_set_u32(
params,
invocation,
config,
on_state_change,
"SetPopupWidth",
|raw, cfg| {
let v = i32::try_from(raw)
.map_err(|_| format!("popup-width {raw} exceeds i32::MAX"))?;
if !(crate::ui::constants::POPUP_WIDTH_MIN..=crate::ui::constants::POPUP_WIDTH_MAX)
.contains(&v)
{
return Err(format!(
"popup-width {v} is not in {min}..={max}",
min = crate::ui::constants::POPUP_WIDTH_MIN,
max = crate::ui::constants::POPUP_WIDTH_MAX,
));
}
cfg.popup_width = v;
Ok(())
},
),
"SetPanelWidth" => handle_set_u32(
params,
invocation,
config,
on_state_change,
"SetPanelWidth",
|raw, cfg| {
let v = i32::try_from(raw)
.map_err(|_| format!("panel-width {raw} exceeds i32::MAX"))?;
if !(crate::ui::constants::PANEL_WIDTH_MIN..=crate::ui::constants::PANEL_WIDTH_MAX)
.contains(&v)
{
return Err(format!(
"panel-width {v} is not in {min}..={max}",
min = crate::ui::constants::PANEL_WIDTH_MIN,
max = crate::ui::constants::PANEL_WIDTH_MAX,
));
}
cfg.panel_width = v;
Ok(())
},
),
"SetPopupTimeout" => handle_set_u32(
params,
invocation,
config,
on_state_change,
"SetPopupTimeout",
|raw, cfg| {
cfg.popup_timeout = u64::from(raw);
Ok(())
},
),
"SetMaxPopups" => handle_set_u32(
params,
invocation,
config,
on_state_change,
"SetMaxPopups",
|raw, cfg| {
if raw == 0 {
return Err("max-popups must be >= 1".to_string());
}
cfg.max_popups =
usize::try_from(raw).expect("u32 fits in usize on every supported target");
Ok(())
},
),
"SetMaxHistory" => handle_set_u32(
params,
invocation,
config,
on_state_change,
"SetMaxHistory",
|raw, cfg| {
if raw == 0 {
return Err("max-history must be >= 1".to_string());
}
cfg.max_history =
usize::try_from(raw).expect("u32 fits in usize on every supported target");
Ok(())
},
),
_ => {
log::warn!("Unknown nwg-count D-Bus method: {method}");
invocation.return_dbus_error(
"org.freedesktop.DBus.Error.UnknownMethod",
&format!("Unknown method: {method}"),
);
}
}
}
fn return_invalid_args(invocation: gio::DBusMethodInvocation, msg: &str) {
invocation.return_dbus_error("org.freedesktop.DBus.Error.InvalidArgs", msg);
}
fn handle_set_u32(
params: &glib::Variant,
invocation: gio::DBusMethodInvocation,
config: &Rc<RefCell<NotificationConfig>>,
on_state_change: &Rc<dyn Fn()>,
method_name: &str,
apply: impl FnOnce(u32, &mut NotificationConfig) -> Result<(), String>,
) {
let raw: u32 = match params.child_value(0).get() {
Some(v) => v,
None => {
return_invalid_args(
invocation,
&format!("{method_name} expects a uint32 argument"),
);
return;
}
};
let result = {
let mut cfg = config.borrow_mut();
apply(raw, &mut cfg)
};
match result {
Ok(()) => {
invocation.return_value(None);
on_state_change();
}
Err(msg) => return_invalid_args(invocation, &msg),
}
}
fn handle_set_popup_position(
params: &glib::Variant,
invocation: gio::DBusMethodInvocation,
config: &Rc<RefCell<NotificationConfig>>,
on_state_change: &Rc<dyn Fn()>,
) {
let raw: String = match params.child_value(0).get() {
Some(s) => s,
None => {
return_invalid_args(invocation, "SetPopupPosition expects a string argument");
return;
}
};
use clap::ValueEnum;
match crate::config::PopupPosition::from_str(&raw, true) {
Ok(pos) => {
config.borrow_mut().popup_position = pos;
invocation.return_value(None);
on_state_change();
}
Err(_) => {
return_invalid_args(
invocation,
&format!(
"Invalid popup-position '{raw}'. Expected one of: top-right, top-center, top-left, bottom-right, bottom-center, bottom-left."
),
);
}
}
}
fn handle_notify(
params: &glib::Variant,
invocation: gio::DBusMethodInvocation,
state: &Rc<RefCell<NotificationState>>,
on_notify: &OnNotify,
) {
let app_name: String = params.child_value(0).get().unwrap_or_default();
let replaces_id: u32 = params.child_value(1).get().unwrap_or(0);
let app_icon: String = params.child_value(2).get().unwrap_or_default();
let summary: String = params.child_value(3).get().unwrap_or_default();
let body: String = params.child_value(4).get().unwrap_or_default();
let timeout: i32 = params.child_value(7).get().unwrap_or(-1);
let actions_variant = params.child_value(5);
let actions: Vec<String> = (0..actions_variant.n_children())
.filter_map(|i| actions_variant.child_value(i).get::<String>())
.collect();
let hints_variant = params.child_value(6);
let urgency = extract_urgency(&hints_variant);
let desktop_entry = extract_hint::<String>(&hints_variant, "desktop-entry");
let notif = Notification {
id: 0, app_name,
app_icon,
summary: clean_markup(&summary),
body: clean_markup(&body),
actions: parse_actions(&actions),
urgency,
timeout_ms: timeout,
timestamp: SystemTime::now(),
read: false,
desktop_entry,
};
log::debug!(
"Notify: app={}, summary={}, urgency={:?}",
notif.app_name,
notif.summary,
notif.urgency
);
let id = state.borrow_mut().replace(replaces_id, notif.clone());
let mut notif_with_id = notif;
notif_with_id.id = id;
on_notify(¬if_with_id);
let result = glib::Variant::from((id,));
invocation.return_value(Some(&result));
}
fn handle_close(
params: &glib::Variant,
invocation: gio::DBusMethodInvocation,
state: &Rc<RefCell<NotificationState>>,
on_close: &OnClose,
) {
let id: u32 = params.child_value(0).get().unwrap_or(0);
state.borrow_mut().remove(id);
on_close(id);
invocation.return_value(None);
}
fn handle_capabilities(invocation: gio::DBusMethodInvocation) {
let caps = vec!["body", "body-markup", "actions", "icon-static"];
let variant = glib::Variant::from((caps,));
invocation.return_value(Some(&variant));
}
fn server_info_tuple() -> (&'static str, &'static str, &'static str, &'static str) {
(
"nwg-notifications",
"nwg-notifications",
env!("CARGO_PKG_VERSION"),
"1.2",
)
}
fn handle_server_info(invocation: gio::DBusMethodInvocation) {
let info = server_info_tuple();
let variant = glib::Variant::from(info);
invocation.return_value(Some(&variant));
}
pub(crate) fn emit_action_invoked(connection: &gio::DBusConnection, id: u32, action_key: &str) {
let params = glib::Variant::from((id, action_key));
if let Err(e) = connection.emit_signal(
None::<&str>,
"/org/freedesktop/Notifications",
"org.freedesktop.Notifications",
"ActionInvoked",
Some(¶ms),
) {
log::warn!("Failed to emit ActionInvoked: {e}");
}
}
pub(crate) fn unread_count_to_u32(unread: usize) -> u32 {
u32::try_from(unread).unwrap_or_else(|_| {
log::error!("Unread count {unread} exceeds u32::MAX; clamping for D-Bus payload");
u32::MAX
})
}
const QUERY_COUNT_TIMEOUT_MS: i32 = 2_000;
pub(crate) fn query_count_via_dbus() -> Result<u32, glib::Error> {
let connection = gio::bus_get_sync(gio::BusType::Session, gio::Cancellable::NONE)?;
let result = connection.call_sync(
Some(NWG_COUNT_BUS_NAME),
NWG_COUNT_OBJECT_PATH,
NWG_COUNT_BUS_NAME,
"GetCount",
None,
None,
gio::DBusCallFlags::NO_AUTO_START,
QUERY_COUNT_TIMEOUT_MS,
gio::Cancellable::NONE,
)?;
result.child_value(0).get::<u32>().ok_or_else(|| {
glib::Error::new(
gio::IOErrorEnum::InvalidData,
"GetCount returned unexpected payload type",
)
})
}
fn call_setter_sync(method: &str, payload: glib::Variant) -> Result<(), glib::Error> {
let connection = gio::bus_get_sync(gio::BusType::Session, gio::Cancellable::NONE)?;
connection.call_sync(
Some(NWG_COUNT_BUS_NAME),
NWG_COUNT_OBJECT_PATH,
NWG_COUNT_BUS_NAME,
method,
Some(&payload),
None,
gio::DBusCallFlags::NO_AUTO_START,
QUERY_COUNT_TIMEOUT_MS,
gio::Cancellable::NONE,
)?;
Ok(())
}
pub(crate) fn push_popup_position(value: &str) -> Result<(), glib::Error> {
call_setter_sync("SetPopupPosition", glib::Variant::from((value,)))
}
pub(crate) fn push_popup_width(value: u32) -> Result<(), glib::Error> {
call_setter_sync("SetPopupWidth", glib::Variant::from((value,)))
}
pub(crate) fn push_panel_width(value: u32) -> Result<(), glib::Error> {
call_setter_sync("SetPanelWidth", glib::Variant::from((value,)))
}
pub(crate) fn push_popup_timeout(value: u32) -> Result<(), glib::Error> {
call_setter_sync("SetPopupTimeout", glib::Variant::from((value,)))
}
pub(crate) fn push_max_popups(value: u32) -> Result<(), glib::Error> {
call_setter_sync("SetMaxPopups", glib::Variant::from((value,)))
}
pub(crate) fn push_max_history(value: u32) -> Result<(), glib::Error> {
call_setter_sync("SetMaxHistory", glib::Variant::from((value,)))
}
pub(crate) fn is_unknown_method_error(err: &glib::Error) -> bool {
err.matches(gio::DBusError::UnknownMethod)
}
pub(crate) fn emit_count_changed(connection: &gio::DBusConnection, count: u32) {
let params = glib::Variant::from((count,));
if let Err(e) = connection.emit_signal(
None::<&str>,
NWG_COUNT_OBJECT_PATH,
NWG_COUNT_BUS_NAME,
"CountChanged",
Some(¶ms),
) {
log::warn!("Failed to emit CountChanged: {e}");
}
}
fn extract_hint<T>(hints: &glib::Variant, key_name: &str) -> Option<T>
where
T: glib::variant::FromVariant,
{
for i in 0..hints.n_children() {
let entry = hints.child_value(i);
let key: Option<String> = entry.child_value(0).get();
if key.as_deref() == Some(key_name) {
return entry.child_value(1).child_value(0).get::<T>();
}
}
None
}
fn extract_urgency(hints: &glib::Variant) -> Urgency {
extract_hint::<u8>(hints, "urgency")
.map(Urgency::from)
.unwrap_or(Urgency::Normal)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unread_count_to_u32_passes_through_small_values() {
assert_eq!(unread_count_to_u32(0), 0);
assert_eq!(unread_count_to_u32(1), 1);
assert_eq!(unread_count_to_u32(42), 42);
}
#[test]
fn unread_count_to_u32_passes_through_u32_max() {
assert_eq!(unread_count_to_u32(u32::MAX as usize), u32::MAX);
}
#[cfg(target_pointer_width = "64")]
#[test]
fn unread_count_to_u32_clamps_on_overflow() {
assert_eq!(unread_count_to_u32(u32::MAX as usize + 1), u32::MAX);
assert_eq!(unread_count_to_u32(usize::MAX), u32::MAX);
}
#[test]
fn is_unknown_method_error_recognises_dbus_unknown_method() {
let err = glib::Error::new(gio::DBusError::UnknownMethod, "method missing");
assert!(is_unknown_method_error(&err));
}
#[test]
fn is_unknown_method_error_rejects_other_dbus_errors() {
let err = glib::Error::new(gio::DBusError::NoMemory, "out of memory");
assert!(!is_unknown_method_error(&err));
}
#[test]
fn server_info_tuple_uses_cargo_pkg_version() {
let (name, vendor, version, spec) = server_info_tuple();
assert_eq!(name, "nwg-notifications");
assert_eq!(vendor, "nwg-notifications");
assert_eq!(version, env!("CARGO_PKG_VERSION"));
assert_eq!(spec, "1.2");
}
fn build_hints_variant(entries: &[(&str, glib::Variant)]) -> glib::Variant {
let dict = glib::VariantDict::new(None);
for (key, value) in entries {
dict.insert_value(key, value);
}
dict.end()
}
#[test]
fn extract_hint_returns_none_for_missing_key() {
let hints = build_hints_variant(&[]);
assert_eq!(extract_hint::<u8>(&hints, "urgency"), None);
assert_eq!(extract_hint::<String>(&hints, "desktop-entry"), None);
}
#[test]
fn extract_hint_returns_none_for_wrong_value_type() {
let hints = build_hints_variant(&[("urgency", glib::Variant::from("high"))]);
assert_eq!(extract_hint::<u8>(&hints, "urgency"), None);
}
#[test]
fn extract_urgency_recognises_low_normal_critical() {
let low = build_hints_variant(&[("urgency", glib::Variant::from(0u8))]);
let normal = build_hints_variant(&[("urgency", glib::Variant::from(1u8))]);
let critical = build_hints_variant(&[("urgency", glib::Variant::from(2u8))]);
assert_eq!(extract_urgency(&low), Urgency::Low);
assert_eq!(extract_urgency(&normal), Urgency::Normal);
assert_eq!(extract_urgency(&critical), Urgency::Critical);
let empty = build_hints_variant(&[]);
assert_eq!(extract_urgency(&empty), Urgency::Normal);
}
#[test]
fn extract_hint_string_returns_well_formed_desktop_entry() {
let hints = build_hints_variant(&[("desktop-entry", glib::Variant::from("firefox"))]);
assert_eq!(
extract_hint::<String>(&hints, "desktop-entry"),
Some("firefox".to_string())
);
}
}