// SPDX-License-Identifier: AGPL-3.0-or-later use std::{ collections::HashMap, ffi::c_void, ptr, sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, mpsc, }, thread::{self, JoinHandle}, time::Duration, }; use futures_lite::{FutureExt, StreamExt, future}; use napi::{ Env, JsValue, Status, ValueType, bindgen_prelude::{Array, AsyncTask, Function, Object, Result, Task, ToNapiValue, Unknown}, sys, threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode, UnknownReturnValue}, }; use napi_derive::napi; use zbus::{ MatchRule, MessageStream, blocking::{Connection as BlockingConnection, Proxy}, message::Type as MessageType, zvariant::Value, }; const NOTIFY_DEST: &str = "org.freedesktop.Notifications"; const NOTIFY_PATH: &str = "/org/freedesktop/Notifications"; const NOTIFY_IFACE: &str = "org.freedesktop.Notifications"; const CALL_TIMEOUT: Duration = Duration::from_millis(5_000); const SIGNAL_POLL_INTERVAL: Duration = Duration::from_millis(200); const SIGNAL_THREAD_START_TIMEOUT: Duration = Duration::from_secs(5); const EVENT_QUEUE_LIMIT: usize = 1024; const DBUS_SESSION_UNREACHABLE: &str = "DBus session bus unreachable: no $DBUS_SESSION_BUS_ADDRESS and no \ $XDG_RUNTIME_DIR/bus socket — desktop notifications require a \ running session bus (systemd-logind, elogind, or dbus-launch)."; fn has_dbus_session() -> bool { has_dbus_session_from( std::env::var("DBUS_SESSION_BUS_ADDRESS").ok().as_deref(), std::env::var("XDG_RUNTIME_DIR").ok().as_deref(), |path| std::path::Path::new(path).exists(), ) } fn has_dbus_session_from( bus_address: Option<&str>, xdg_runtime_dir: Option<&str>, path_exists: impl Fn(&str) -> bool, ) -> bool { if bus_address.is_some_and(|v| !v.is_empty()) { return true; } if let Some(dir) = xdg_runtime_dir && !dir.is_empty() { let candidate = format!("{}/bus", dir.trim_end_matches('/')); if path_exists(&candidate) { return true; } } false } type EventThreadsafeFunction = Arc< ThreadsafeFunction< NotifyEvent, UnknownReturnValue, NotifyEvent, Status, false, true, EVENT_QUEUE_LIMIT, >, >; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Urgency { Low, Normal, Critical, } impl Urgency { fn parse(value: &str) -> Option { match value { "low" => Some(Self::Low), "normal" => Some(Self::Normal), "critical" => Some(Self::Critical), _ => None, } } fn as_byte(self) -> u8 { match self { Self::Low => 0, Self::Normal => 1, Self::Critical => 2, } } } #[derive(Debug, Clone, PartialEq, Eq)] struct NotifyImageData { width: i32, height: i32, rowstride: i32, has_alpha: bool, bits_per_sample: i32, channels: i32, data: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] struct NotifyHints { urgency: Option, category: Option, desktop_entry: Option, sound_file: Option, suppress_sound: Option, transient: Option, action_icons: Option, image_data: Option, } impl NotifyHints { fn empty() -> Self { Self { urgency: None, category: None, desktop_entry: None, sound_file: None, suppress_sound: None, transient: None, action_icons: None, image_data: None, } } } #[derive(Debug, Clone, PartialEq, Eq)] struct NotifyAction { key: String, label: String, } #[derive(Debug, Clone, PartialEq, Eq)] struct NotifyArgs { app_name: String, replaces_id: u32, app_icon: String, summary: String, body: String, actions: Vec, hints: NotifyHints, expire_timeout_ms: i32, } #[derive(Debug)] pub enum NotifyEvent { ActionInvoked { id: u32, action_key: String }, Closed { id: u32, reason: u32 }, } impl ToNapiValue for NotifyEvent { unsafe fn to_napi_value(raw_env: sys::napi_env, event: Self) -> Result { let env = Env::from_raw(raw_env); let mut object = Object::new(&env)?; match event { Self::ActionInvoked { id, action_key } => { object.set("kind", "actionInvoked")?; object.set("id", id)?; object.set("actionKey", action_key)?; } Self::Closed { id, reason } => { object.set("kind", "closed")?; object.set("id", id)?; object.set("reason", reason)?; } } unsafe { as ToNapiValue>::to_napi_value(raw_env, object) } } } struct Inner { method_conn: Mutex>, stop_signal_thread: Arc, signal_thread: Mutex>>, event_callback: Mutex>, } impl Inner { fn close(&self) { self.stop_signal_thread.store(true, Ordering::Release); if let Ok(mut signal_thread) = self.signal_thread.lock() && let Some(thread) = signal_thread.take() { let _ = thread.join(); } if let Ok(mut method_conn) = self.method_conn.lock() && let Some(conn) = method_conn.take() { let _ = conn.close(); } if let Ok(mut event_callback) = self.event_callback.lock() { let _ = event_callback.take(); } } fn with_connection( &self, label: &'static str, f: impl FnOnce(&BlockingConnection) -> Result, ) -> Result { let guard = self .method_conn .lock() .map_err(|_| generic_error("notifications client lock poisoned"))?; let conn = guard .as_ref() .ok_or_else(|| generic_error("notifications client closed"))?; f(conn).map_err(|err| generic_error(format!("{label}: {}", err.reason))) } } impl Drop for Inner { fn drop(&mut self) { self.close(); } } #[napi] pub struct FreedesktopNotifications { inner: Arc, } #[napi] impl FreedesktopNotifications { #[napi(constructor)] pub fn new(on_event: Function) -> Result { let event_callback = Arc::new( on_event .build_threadsafe_function::() .weak::() .callee_handled::() .max_queue_size::() .build() .map_err(|err| { generic_error(format!( "failed to create notification callback: {}", err.reason )) })?, ); if !has_dbus_session() { return Err(generic_error(DBUS_SESSION_UNREACHABLE)); } let method_conn = open_blocking_connection() .map_err(|err| generic_error(format!("notifications client init failed: {err}")))?; let stop_signal_thread = Arc::new(AtomicBool::new(false)); let signal_thread = spawn_signal_thread(event_callback.clone(), stop_signal_thread.clone()) .map_err(|err| generic_error(format!("notifications client init failed: {err}")))?; Ok(Self { inner: Arc::new(Inner { method_conn: Mutex::new(Some(method_conn)), stop_signal_thread, signal_thread: Mutex::new(Some(signal_thread)), event_callback: Mutex::new(Some(event_callback)), }), }) } #[napi] pub fn notify(&self, payload: Object) -> Result> { let args = parse_notify_payload(&payload)?; Ok(AsyncTask::new(NotifyTask { inner: self.inner.clone(), args, })) } #[napi(js_name = "closeNotification")] pub fn close_notification(&self, id: u32) -> Result> { Ok(AsyncTask::new(CloseNotificationTask { inner: self.inner.clone(), id, })) } #[napi(js_name = "getServerCapabilities")] pub fn get_server_capabilities(&self) -> Result> { Ok(AsyncTask::new(GetCapabilitiesTask { inner: self.inner.clone(), })) } #[napi] pub fn close(&self) -> Result> { Ok(AsyncTask::new(CloseTask { inner: self.inner.clone(), })) } } impl Drop for FreedesktopNotifications { fn drop(&mut self) { self.inner.close(); } } pub struct NotifyTask { inner: Arc, args: NotifyArgs, } impl Task for NotifyTask { type Output = u32; type JsValue = u32; fn compute(&mut self) -> Result { self.inner .with_connection("Notify failed", |conn| notify(conn, &self.args)) } fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { Ok(output) } } pub struct CloseNotificationTask { inner: Arc, id: u32, } impl Task for CloseNotificationTask { type Output = (); type JsValue = (); fn compute(&mut self) -> Result { self.inner .with_connection("CloseNotification failed", |conn| { close_notification(conn, self.id) }) } fn resolve(&mut self, _env: Env, _output: Self::Output) -> Result { Ok(()) } } pub struct GetCapabilitiesTask { inner: Arc, } impl Task for GetCapabilitiesTask { type Output = Vec; type JsValue = Vec; fn compute(&mut self) -> Result { self.inner .with_connection("GetCapabilities failed", get_capabilities) } fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { Ok(output) } } pub struct CloseTask { inner: Arc, } impl Task for CloseTask { type Output = (); type JsValue = (); fn compute(&mut self) -> Result { self.inner.close(); Ok(()) } fn resolve(&mut self, _env: Env, _output: Self::Output) -> Result { Ok(()) } } #[napi(object)] pub struct ServerInformation { pub name: String, pub vendor: String, pub version: String, #[napi(js_name = "specVersion")] pub spec_version: String, } pub struct GetServerInformationTask; impl Task for GetServerInformationTask { type Output = ServerInformation; type JsValue = ServerInformation; fn compute(&mut self) -> Result { if !has_dbus_session() { return Err(generic_error(DBUS_SESSION_UNREACHABLE)); } let conn = open_blocking_connection() .map_err(|err| generic_error(format!("openSessionBus failed: {err}")))?; get_server_information_on(&conn) .map_err(|err| generic_error(format!("GetServerInformation failed: {}", err.reason))) } fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { Ok(output) } } #[napi(js_name = "getServerInformation")] pub fn get_server_information() -> Result> { Ok(AsyncTask::new(GetServerInformationTask)) } fn open_blocking_connection() -> zbus::Result { zbus::blocking::connection::Builder::session()? .method_timeout(CALL_TIMEOUT) .build() } fn notification_proxy(conn: &BlockingConnection) -> Result> { Proxy::new(conn, NOTIFY_DEST, NOTIFY_PATH, NOTIFY_IFACE) .map_err(|err| generic_error(err.to_string())) } fn notify(conn: &BlockingConnection, args: &NotifyArgs) -> Result { let proxy = notification_proxy(conn)?; let actions = flatten_actions(&args.actions); let hints = build_hints(&args.hints); proxy .call( "Notify", &( args.app_name.as_str(), args.replaces_id, args.app_icon.as_str(), args.summary.as_str(), args.body.as_str(), actions, hints, args.expire_timeout_ms, ), ) .map_err(|err| generic_error(err.to_string())) } fn close_notification(conn: &BlockingConnection, id: u32) -> Result<()> { notification_proxy(conn)? .call("CloseNotification", &(id,)) .map_err(|err| generic_error(err.to_string())) } fn get_capabilities(conn: &BlockingConnection) -> Result> { notification_proxy(conn)? .call("GetCapabilities", &()) .map_err(|err| generic_error(err.to_string())) } fn get_server_information_on(conn: &BlockingConnection) -> Result { let (name, vendor, version, spec_version): (String, String, String, String) = notification_proxy(conn)? .call("GetServerInformation", &()) .map_err(|err| generic_error(err.to_string()))?; Ok(ServerInformation { name, vendor, version, spec_version, }) } fn flatten_actions(actions: &[NotifyAction]) -> Vec<&str> { let mut out = Vec::with_capacity(actions.len() * 2); for action in actions { out.push(action.key.as_str()); out.push(action.label.as_str()); } out } fn build_hints(hints: &NotifyHints) -> HashMap<&'static str, Value<'_>> { let mut out = HashMap::new(); if let Some(urgency) = hints.urgency { out.insert("urgency", Value::new(urgency.as_byte())); } if let Some(category) = hints.category.as_deref() { out.insert("category", Value::new(category)); } if let Some(desktop_entry) = hints.desktop_entry.as_deref() { out.insert("desktop-entry", Value::new(desktop_entry)); } if let Some(sound_file) = hints.sound_file.as_deref() { out.insert("sound-file", Value::new(sound_file)); } if let Some(suppress_sound) = hints.suppress_sound { out.insert("suppress-sound", Value::new(suppress_sound)); } if let Some(transient) = hints.transient { out.insert("transient", Value::new(transient)); } if let Some(action_icons) = hints.action_icons { out.insert("action-icons", Value::new(action_icons)); } if let Some(image) = hints.image_data.as_ref() { out.insert( "image-data", Value::new(( image.width, image.height, image.rowstride, image.has_alpha, image.bits_per_sample, image.channels, image.data.as_slice(), )), ); } out } fn spawn_signal_thread( event_callback: EventThreadsafeFunction, stop: Arc, ) -> std::result::Result, String> { let (ready_tx, ready_rx) = mpsc::sync_channel(1); let stop_for_thread = stop.clone(); let thread = thread::Builder::new() .name("fluxer-linux-notifications-signals".to_string()) .spawn(move || { let setup = future::block_on(async { let conn = zbus::Connection::session().await?; let rule = MatchRule::builder() .msg_type(MessageType::Signal) .path(NOTIFY_PATH)? .interface(NOTIFY_IFACE)? .build(); let stream = MessageStream::for_match_rule(rule, &conn, Some(64)).await?; zbus::Result::Ok((conn, stream)) }); let (_conn, mut stream) = match setup { Ok(parts) => { let _ = ready_tx.send(Ok(())); parts } Err(err) => { let _ = ready_tx.send(Err(err.to_string())); return; } }; while !stop_for_thread.load(Ordering::Acquire) { let timeout = async { async_io::Timer::after(SIGNAL_POLL_INTERVAL).await; None }; let next_message = stream.next().or(timeout); match future::block_on(next_message) { Some(Ok(message)) => { if let Some(event) = event_from_signal(&message) { let status = event_callback.call(event, ThreadsafeFunctionCallMode::NonBlocking); if status == Status::Closing { break; } } } Some(Err(_)) => break, None => {} } } }) .map_err(|err| err.to_string())?; match ready_rx.recv_timeout(SIGNAL_THREAD_START_TIMEOUT) { Ok(Ok(())) => Ok(thread), Ok(Err(err)) => { let _ = thread.join(); Err(err) } Err(err) => { stop.store(true, Ordering::Release); if matches!(err, mpsc::RecvTimeoutError::Disconnected) { let _ = thread.join(); } Err(err.to_string()) } } } fn event_from_signal(message: &zbus::Message) -> Option { let header = message.header(); if header.interface().map(|iface| iface.as_str()) != Some(NOTIFY_IFACE) { return None; } match header.member()?.as_str() { "ActionInvoked" => { let (id, action_key): (u32, String) = message.body().deserialize().ok()?; Some(NotifyEvent::ActionInvoked { id, action_key }) } "NotificationClosed" => { let (id, reason): (u32, u32) = message.body().deserialize().ok()?; Some(NotifyEvent::Closed { id, reason }) } _ => None, } } fn parse_notify_payload(payload: &Object) -> Result { Ok(NotifyArgs { app_name: read_string_or_empty(payload, "appName"), replaces_id: read_optional_u32(payload, "replacesId").unwrap_or(0), app_icon: read_string_or_empty(payload, "appIcon"), summary: read_string_or_empty(payload, "summary"), body: read_string_or_empty(payload, "body"), actions: parse_actions(payload), hints: parse_hints(payload)?, expire_timeout_ms: read_optional_i32(payload, "expireTimeoutMs").unwrap_or(-1), }) } fn parse_actions(payload: &Object) -> Vec { let Some(actions) = read_array(payload, "actions") else { return Vec::new(); }; let mut out = Vec::with_capacity(actions.len() as usize); for index in 0..actions.len() { let action = actions.get::(index).ok().flatten(); match action { Some(action) => out.push(NotifyAction { key: read_string_or_empty(&action, "key"), label: read_string_or_empty(&action, "label"), }), None => out.push(NotifyAction { key: String::new(), label: String::new(), }), } } out } fn parse_hints(payload: &Object) -> Result { let Some(hints_object) = read_object(payload, "hints") else { return Ok(NotifyHints::empty()); }; let mut hints = NotifyHints::empty(); if let Some(value) = read_optional_string(&hints_object, "urgency") { hints.urgency = Urgency::parse(&value); } hints.category = read_optional_string(&hints_object, "category"); hints.desktop_entry = read_optional_string(&hints_object, "desktopEntry"); hints.sound_file = read_optional_string(&hints_object, "soundFile"); hints.suppress_sound = read_optional_bool(&hints_object, "suppressSound"); hints.transient = read_optional_bool(&hints_object, "transient"); hints.action_icons = read_optional_bool(&hints_object, "actionIcons"); if let Some(image_object) = read_object(&hints_object, "imageData") { hints.image_data = Some(parse_image_data(&image_object)?); } Ok(hints) } fn parse_image_data(value: &Object) -> Result { let width = read_optional_i32(value, "width").unwrap_or(0); let height = read_optional_i32(value, "height").unwrap_or(0); let channels = read_optional_i32(value, "channels").unwrap_or(4); let mut rowstride = read_optional_i32(value, "rowstride").unwrap_or(0); let bits_per_sample = read_optional_i32(value, "bitsPerSample").unwrap_or(8); let has_alpha = read_optional_bool(value, "hasAlpha").unwrap_or(true); let data_value = read_unknown(value, "data") .ok_or_else(|| invalid_arg("imageData.data must be a Buffer or Uint8Array"))?; let data = read_image_bytes(data_value)?; if width <= 0 || height <= 0 { return Err(invalid_arg("imageData width and height must be positive")); } if rowstride <= 0 { rowstride = width.saturating_mul(channels); } validate_image_data_shape( width, height, rowstride, has_alpha, bits_per_sample, channels, data.len(), )?; Ok(NotifyImageData { width, height, rowstride, has_alpha, bits_per_sample, channels, data, }) } fn validate_image_data_shape( width: i32, height: i32, rowstride: i32, has_alpha: bool, bits_per_sample: i32, channels: i32, data_len: usize, ) -> Result<()> { if bits_per_sample != 8 { return Err(invalid_arg("imageData.bitsPerSample must be 8")); } let expected_channels = if has_alpha { 4 } else { 3 }; if channels != expected_channels { return Err(invalid_arg(format!( "imageData.channels must be {expected_channels} when hasAlpha is {has_alpha}" ))); } let width = usize::try_from(width).map_err(|_| invalid_arg("imageData width is invalid"))?; let height = usize::try_from(height).map_err(|_| invalid_arg("imageData height is invalid"))?; let rowstride = usize::try_from(rowstride).map_err(|_| invalid_arg("imageData.rowstride is invalid"))?; let channels = usize::try_from(channels).map_err(|_| invalid_arg("imageData.channels is invalid"))?; let row_bytes = width .checked_mul(channels) .ok_or_else(|| invalid_arg("imageData row byte count overflows"))?; if rowstride < row_bytes { return Err(invalid_arg( "imageData.rowstride is smaller than one pixel row", )); } let min_data_len = if height == 1 { row_bytes } else { rowstride .checked_mul(height - 1) .and_then(|bytes| bytes.checked_add(row_bytes)) .ok_or_else(|| invalid_arg("imageData byte count overflows"))? }; if data_len < min_data_len { return Err(invalid_arg( "imageData.data is too short for the supplied dimensions", )); } Ok(()) } fn read_unknown<'a>(object: &Object<'a>, key: &str) -> Option> { object.get::(key).ok().flatten() } fn read_string_or_empty(object: &Object, key: &str) -> String { read_optional_string(object, key).unwrap_or_default() } fn read_optional_string(object: &Object, key: &str) -> Option { let value = read_unknown(object, key)?; if value.get_type().ok()? != ValueType::String { return None; } unsafe { value.cast::().ok() } } fn read_optional_u32(object: &Object, key: &str) -> Option { let value = read_unknown(object, key)?; if value.get_type().ok()? != ValueType::Number { return None; } unsafe { value.cast::().ok() } } fn read_optional_i32(object: &Object, key: &str) -> Option { let value = read_unknown(object, key)?; if value.get_type().ok()? != ValueType::Number { return None; } unsafe { value.cast::().ok() } } fn read_optional_bool(object: &Object, key: &str) -> Option { let value = read_unknown(object, key)?; if value.get_type().ok()? != ValueType::Boolean { return None; } unsafe { value.cast::().ok() } } fn read_object<'a>(object: &Object<'a>, key: &str) -> Option> { let value = read_unknown(object, key)?; if value.get_type().ok()? != ValueType::Object { return None; } unsafe { value.cast::().ok() } } fn read_array<'a>(object: &Object<'a>, key: &str) -> Option> { let value = read_unknown(object, key)?; if value.get_type().ok()? != ValueType::Object { return None; } unsafe { value.cast::().ok() } } fn read_image_bytes(value: Unknown) -> Result> { let raw = value.raw(); let raw_env = value.value().env; let mut is_typed_array = false; let typed_status = unsafe { sys::napi_is_typedarray(raw_env, raw, &mut is_typed_array) }; if typed_status == sys::Status::napi_ok && is_typed_array { let mut array_type: sys::napi_typedarray_type = 0; let mut len = 0usize; let mut data_ptr: *mut c_void = ptr::null_mut(); let mut arraybuffer = ptr::null_mut(); let mut byte_offset = 0usize; let status = unsafe { sys::napi_get_typedarray_info( raw_env, raw, &mut array_type, &mut len, &mut data_ptr, &mut arraybuffer, &mut byte_offset, ) }; if status != sys::Status::napi_ok { return Err(invalid_arg("imageData.data must be a readable typed array")); } if !is_uint8_typed_array(array_type) { return Err(invalid_arg("imageData.data must be a Buffer or Uint8Array")); } return copy_non_empty_bytes(data_ptr.cast(), len); } let mut is_buffer = false; let buffer_status = unsafe { sys::napi_is_buffer(raw_env, raw, &mut is_buffer) }; if buffer_status == sys::Status::napi_ok && is_buffer { let mut data_ptr: *mut c_void = ptr::null_mut(); let mut len = 0usize; let status = unsafe { sys::napi_get_buffer_info(raw_env, raw, &mut data_ptr, &mut len) }; if status != sys::Status::napi_ok { return Err(invalid_arg("imageData.data must be a readable Buffer")); } return copy_non_empty_bytes(data_ptr.cast(), len); } Err(invalid_arg("imageData.data must be a Buffer or Uint8Array")) } fn is_uint8_typed_array(array_type: sys::napi_typedarray_type) -> bool { array_type == sys::TypedarrayType::uint8_array } fn copy_non_empty_bytes(data_ptr: *const u8, len: usize) -> Result> { if data_ptr.is_null() || len == 0 { return Err(invalid_arg("imageData.data must not be empty")); } Ok(unsafe { std::slice::from_raw_parts(data_ptr, len).to_vec() }) } fn generic_error(reason: impl Into) -> napi::Error { napi::Error::new(Status::GenericFailure, reason.into()) } fn invalid_arg(reason: impl Into) -> napi::Error { napi::Error::new(Status::InvalidArg, reason.into()) } #[cfg(test)] #[derive(Debug, Clone, PartialEq, Eq)] enum FakeArg<'a> { U32(u32), String(&'a str), } #[cfg(test)] #[derive(Debug, Clone, PartialEq, Eq)] struct NotifyReply { id: u32, } #[cfg(test)] #[derive(Debug, Clone, PartialEq, Eq)] struct ActionInvokedSignal { id: u32, action_key: String, } #[cfg(test)] #[derive(Debug, Clone, PartialEq, Eq)] struct ClosedSignal { id: u32, reason: u32, } #[cfg(test)] #[derive(Debug, Clone, PartialEq, Eq)] enum ParseError { InvalidReply, } #[cfg(test)] fn parse_notify_reply_from_args( args: &[FakeArg<'_>], ) -> std::result::Result { match args.first() { Some(FakeArg::U32(id)) => Ok(NotifyReply { id: *id }), _ => Err(ParseError::InvalidReply), } } #[cfg(test)] fn parse_action_invoked_from_args( args: &[FakeArg<'_>], ) -> std::result::Result { match (args.first(), args.get(1)) { (Some(FakeArg::U32(id)), Some(FakeArg::String(action_key))) => Ok(ActionInvokedSignal { id: *id, action_key: (*action_key).to_string(), }), _ => Err(ParseError::InvalidReply), } } #[cfg(test)] fn parse_closed_from_args(args: &[FakeArg<'_>]) -> std::result::Result { match (args.first(), args.get(1)) { (Some(FakeArg::U32(id)), Some(FakeArg::U32(reason))) => Ok(ClosedSignal { id: *id, reason: *reason, }), _ => Err(ParseError::InvalidReply), } } #[cfg(test)] mod tests { use super::*; #[test] fn notify_reply_valid_u32_yields_id() { let reply = parse_notify_reply_from_args(&[FakeArg::U32(42)]).unwrap(); assert_eq!(reply.id, 42); } #[test] fn notify_reply_rejects_empty_body() { assert_eq!( parse_notify_reply_from_args(&[]), Err(ParseError::InvalidReply) ); } #[test] fn notify_reply_rejects_wrong_type() { assert_eq!( parse_notify_reply_from_args(&[FakeArg::String("wrong")]), Err(ParseError::InvalidReply) ); } #[test] fn action_invoked_valid_body() { let signal = parse_action_invoked_from_args(&[FakeArg::U32(7), FakeArg::String("default")]).unwrap(); assert_eq!( signal, ActionInvokedSignal { id: 7, action_key: "default".to_string() } ); } #[test] fn action_invoked_rejects_swapped_types() { assert_eq!( parse_action_invoked_from_args(&[FakeArg::String("x"), FakeArg::String("y")]), Err(ParseError::InvalidReply) ); } #[test] fn notification_closed_valid_body() { let signal = parse_closed_from_args(&[FakeArg::U32(11), FakeArg::U32(2)]).unwrap(); assert_eq!(signal, ClosedSignal { id: 11, reason: 2 }); } #[test] fn notification_closed_rejects_too_short() { assert_eq!( parse_closed_from_args(&[FakeArg::U32(11)]), Err(ParseError::InvalidReply) ); } #[test] fn actions_are_flattened_as_key_label_pairs() { let actions = vec![ NotifyAction { key: "default".to_string(), label: "Open".to_string(), }, NotifyAction { key: "dismiss".to_string(), label: "Dismiss".to_string(), }, ]; assert_eq!( flatten_actions(&actions), vec!["default", "Open", "dismiss", "Dismiss"] ); } #[test] fn urgency_strings_match_freedesktop_bytes() { assert_eq!(Urgency::parse("low").map(Urgency::as_byte), Some(0)); assert_eq!(Urgency::parse("normal").map(Urgency::as_byte), Some(1)); assert_eq!(Urgency::parse("critical").map(Urgency::as_byte), Some(2)); assert_eq!(Urgency::parse("unknown"), None); } #[test] fn default_notify_args_match_legacy_defaults() { let args = NotifyArgs { app_name: String::new(), replaces_id: 0, app_icon: String::new(), summary: String::new(), body: String::new(), actions: Vec::new(), hints: NotifyHints::empty(), expire_timeout_ms: -1, }; assert_eq!(args.replaces_id, 0); assert_eq!(args.expire_timeout_ms, -1); assert!(flatten_actions(&args.actions).is_empty()); assert!(build_hints(&args.hints).is_empty()); } #[test] fn hints_use_freedesktop_variant_signatures() { let hints = NotifyHints { urgency: Some(Urgency::Critical), category: Some("im.received".to_string()), desktop_entry: Some("fluxer".to_string()), sound_file: Some("/tmp/notify.oga".to_string()), suppress_sound: Some(true), transient: Some(true), action_icons: Some(false), image_data: Some(NotifyImageData { width: 1, height: 1, rowstride: 4, has_alpha: true, bits_per_sample: 8, channels: 4, data: vec![0, 1, 2, 3], }), }; let values = build_hints(&hints); assert_eq!(values["urgency"].value_signature().to_string(), "y"); assert_eq!(values["category"].value_signature().to_string(), "s"); assert_eq!(values["desktop-entry"].value_signature().to_string(), "s"); assert_eq!(values["sound-file"].value_signature().to_string(), "s"); assert_eq!(values["suppress-sound"].value_signature().to_string(), "b"); assert_eq!(values["transient"].value_signature().to_string(), "b"); assert_eq!(values["action-icons"].value_signature().to_string(), "b"); assert_eq!( values["image-data"].value_signature().to_string(), "(iiibiiay)" ); } #[test] fn dbus_session_detected_via_bus_address() { assert!(has_dbus_session_from( Some("unix:path=/run/user/1000/bus"), None, |_| false )); } #[test] fn dbus_session_detected_via_runtime_dir_socket() { assert!(has_dbus_session_from( None, Some("/run/user/1000"), |path| path == "/run/user/1000/bus" )); assert!(has_dbus_session_from( None, Some("/run/user/1000/"), |path| path == "/run/user/1000/bus" )); } #[test] fn dbus_session_absent_in_headless_container() { assert!(!has_dbus_session_from(None, None, |_| false)); assert!(!has_dbus_session_from(Some(""), Some(""), |_| true)); assert!(!has_dbus_session_from(None, Some("/tmp/xdg"), |_| false)); } #[test] fn dbus_session_unreachable_marker_is_stable_for_renderer_matching() { assert!(DBUS_SESSION_UNREACHABLE.starts_with("DBus session bus unreachable")); } #[test] fn image_rowstride_defaults_to_width_times_channels() { let width = 3; let channels = 4; let mut rowstride = 0; if rowstride <= 0 { rowstride = width * channels; } assert_eq!(rowstride, 12); } #[test] fn image_data_shape_matches_freedesktop_pixbuf_contract() { assert!(validate_image_data_shape(2, 2, 8, true, 8, 4, 16).is_ok()); assert!(validate_image_data_shape(2, 2, 6, false, 8, 3, 12).is_ok()); assert!(validate_image_data_shape(2, 2, 8, true, 16, 4, 16).is_err()); assert!(validate_image_data_shape(2, 2, 8, true, 8, 3, 16).is_err()); assert!(validate_image_data_shape(2, 2, 4, true, 8, 4, 16).is_err()); assert!(validate_image_data_shape(2, 2, 8, true, 8, 4, 15).is_err()); } #[test] fn image_data_typed_array_gate_accepts_only_uint8array() { assert!(is_uint8_typed_array(sys::TypedarrayType::uint8_array)); assert!(!is_uint8_typed_array(sys::TypedarrayType::int8_array)); assert!(!is_uint8_typed_array( sys::TypedarrayType::uint8_clamped_array )); assert!(!is_uint8_typed_array(sys::TypedarrayType::float32_array)); } }