mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
1173 lines
35 KiB
Rust
1173 lines
35 KiB
Rust
// 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<Self> {
|
|
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<u8>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct NotifyHints {
|
|
urgency: Option<Urgency>,
|
|
category: Option<String>,
|
|
desktop_entry: Option<String>,
|
|
sound_file: Option<String>,
|
|
suppress_sound: Option<bool>,
|
|
transient: Option<bool>,
|
|
action_icons: Option<bool>,
|
|
image_data: Option<NotifyImageData>,
|
|
}
|
|
|
|
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<NotifyAction>,
|
|
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<sys::napi_value> {
|
|
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 { <Object<'_> as ToNapiValue>::to_napi_value(raw_env, object) }
|
|
}
|
|
}
|
|
|
|
struct Inner {
|
|
method_conn: Mutex<Option<BlockingConnection>>,
|
|
stop_signal_thread: Arc<AtomicBool>,
|
|
signal_thread: Mutex<Option<JoinHandle<()>>>,
|
|
event_callback: Mutex<Option<EventThreadsafeFunction>>,
|
|
}
|
|
|
|
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<T>(
|
|
&self,
|
|
label: &'static str,
|
|
f: impl FnOnce(&BlockingConnection) -> Result<T>,
|
|
) -> Result<T> {
|
|
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<Inner>,
|
|
}
|
|
|
|
#[napi]
|
|
impl FreedesktopNotifications {
|
|
#[napi(constructor)]
|
|
pub fn new(on_event: Function<NotifyEvent, UnknownReturnValue>) -> Result<Self> {
|
|
let event_callback = Arc::new(
|
|
on_event
|
|
.build_threadsafe_function::<NotifyEvent>()
|
|
.weak::<true>()
|
|
.callee_handled::<false>()
|
|
.max_queue_size::<EVENT_QUEUE_LIMIT>()
|
|
.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<AsyncTask<NotifyTask>> {
|
|
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<AsyncTask<CloseNotificationTask>> {
|
|
Ok(AsyncTask::new(CloseNotificationTask {
|
|
inner: self.inner.clone(),
|
|
id,
|
|
}))
|
|
}
|
|
|
|
#[napi(js_name = "getServerCapabilities")]
|
|
pub fn get_server_capabilities(&self) -> Result<AsyncTask<GetCapabilitiesTask>> {
|
|
Ok(AsyncTask::new(GetCapabilitiesTask {
|
|
inner: self.inner.clone(),
|
|
}))
|
|
}
|
|
|
|
#[napi]
|
|
pub fn close(&self) -> Result<AsyncTask<CloseTask>> {
|
|
Ok(AsyncTask::new(CloseTask {
|
|
inner: self.inner.clone(),
|
|
}))
|
|
}
|
|
}
|
|
|
|
impl Drop for FreedesktopNotifications {
|
|
fn drop(&mut self) {
|
|
self.inner.close();
|
|
}
|
|
}
|
|
|
|
pub struct NotifyTask {
|
|
inner: Arc<Inner>,
|
|
args: NotifyArgs,
|
|
}
|
|
|
|
impl Task for NotifyTask {
|
|
type Output = u32;
|
|
type JsValue = u32;
|
|
|
|
fn compute(&mut self) -> Result<Self::Output> {
|
|
self.inner
|
|
.with_connection("Notify failed", |conn| notify(conn, &self.args))
|
|
}
|
|
|
|
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
|
|
Ok(output)
|
|
}
|
|
}
|
|
|
|
pub struct CloseNotificationTask {
|
|
inner: Arc<Inner>,
|
|
id: u32,
|
|
}
|
|
|
|
impl Task for CloseNotificationTask {
|
|
type Output = ();
|
|
type JsValue = ();
|
|
|
|
fn compute(&mut self) -> Result<Self::Output> {
|
|
self.inner
|
|
.with_connection("CloseNotification failed", |conn| {
|
|
close_notification(conn, self.id)
|
|
})
|
|
}
|
|
|
|
fn resolve(&mut self, _env: Env, _output: Self::Output) -> Result<Self::JsValue> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub struct GetCapabilitiesTask {
|
|
inner: Arc<Inner>,
|
|
}
|
|
|
|
impl Task for GetCapabilitiesTask {
|
|
type Output = Vec<String>;
|
|
type JsValue = Vec<String>;
|
|
|
|
fn compute(&mut self) -> Result<Self::Output> {
|
|
self.inner
|
|
.with_connection("GetCapabilities failed", get_capabilities)
|
|
}
|
|
|
|
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
|
|
Ok(output)
|
|
}
|
|
}
|
|
|
|
pub struct CloseTask {
|
|
inner: Arc<Inner>,
|
|
}
|
|
|
|
impl Task for CloseTask {
|
|
type Output = ();
|
|
type JsValue = ();
|
|
|
|
fn compute(&mut self) -> Result<Self::Output> {
|
|
self.inner.close();
|
|
Ok(())
|
|
}
|
|
|
|
fn resolve(&mut self, _env: Env, _output: Self::Output) -> Result<Self::JsValue> {
|
|
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<Self::Output> {
|
|
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<Self::JsValue> {
|
|
Ok(output)
|
|
}
|
|
}
|
|
|
|
#[napi(js_name = "getServerInformation")]
|
|
pub fn get_server_information() -> Result<AsyncTask<GetServerInformationTask>> {
|
|
Ok(AsyncTask::new(GetServerInformationTask))
|
|
}
|
|
|
|
fn open_blocking_connection() -> zbus::Result<BlockingConnection> {
|
|
zbus::blocking::connection::Builder::session()?
|
|
.method_timeout(CALL_TIMEOUT)
|
|
.build()
|
|
}
|
|
|
|
fn notification_proxy(conn: &BlockingConnection) -> Result<Proxy<'_>> {
|
|
Proxy::new(conn, NOTIFY_DEST, NOTIFY_PATH, NOTIFY_IFACE)
|
|
.map_err(|err| generic_error(err.to_string()))
|
|
}
|
|
|
|
fn notify(conn: &BlockingConnection, args: &NotifyArgs) -> Result<u32> {
|
|
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<Vec<String>> {
|
|
notification_proxy(conn)?
|
|
.call("GetCapabilities", &())
|
|
.map_err(|err| generic_error(err.to_string()))
|
|
}
|
|
|
|
fn get_server_information_on(conn: &BlockingConnection) -> Result<ServerInformation> {
|
|
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<AtomicBool>,
|
|
) -> std::result::Result<JoinHandle<()>, 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<NotifyEvent> {
|
|
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<NotifyArgs> {
|
|
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<NotifyAction> {
|
|
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::<Object>(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<NotifyHints> {
|
|
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<NotifyImageData> {
|
|
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<Unknown<'a>> {
|
|
object.get::<Unknown>(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<String> {
|
|
let value = read_unknown(object, key)?;
|
|
if value.get_type().ok()? != ValueType::String {
|
|
return None;
|
|
}
|
|
unsafe { value.cast::<String>().ok() }
|
|
}
|
|
|
|
fn read_optional_u32(object: &Object, key: &str) -> Option<u32> {
|
|
let value = read_unknown(object, key)?;
|
|
if value.get_type().ok()? != ValueType::Number {
|
|
return None;
|
|
}
|
|
unsafe { value.cast::<u32>().ok() }
|
|
}
|
|
|
|
fn read_optional_i32(object: &Object, key: &str) -> Option<i32> {
|
|
let value = read_unknown(object, key)?;
|
|
if value.get_type().ok()? != ValueType::Number {
|
|
return None;
|
|
}
|
|
unsafe { value.cast::<i32>().ok() }
|
|
}
|
|
|
|
fn read_optional_bool(object: &Object, key: &str) -> Option<bool> {
|
|
let value = read_unknown(object, key)?;
|
|
if value.get_type().ok()? != ValueType::Boolean {
|
|
return None;
|
|
}
|
|
unsafe { value.cast::<bool>().ok() }
|
|
}
|
|
|
|
fn read_object<'a>(object: &Object<'a>, key: &str) -> Option<Object<'a>> {
|
|
let value = read_unknown(object, key)?;
|
|
if value.get_type().ok()? != ValueType::Object {
|
|
return None;
|
|
}
|
|
unsafe { value.cast::<Object>().ok() }
|
|
}
|
|
|
|
fn read_array<'a>(object: &Object<'a>, key: &str) -> Option<Array<'a>> {
|
|
let value = read_unknown(object, key)?;
|
|
if value.get_type().ok()? != ValueType::Object {
|
|
return None;
|
|
}
|
|
unsafe { value.cast::<Array>().ok() }
|
|
}
|
|
|
|
fn read_image_bytes(value: Unknown) -> Result<Vec<u8>> {
|
|
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<Vec<u8>> {
|
|
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<String>) -> napi::Error {
|
|
napi::Error::new(Status::GenericFailure, reason.into())
|
|
}
|
|
|
|
fn invalid_arg(reason: impl Into<String>) -> 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<NotifyReply, ParseError> {
|
|
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<ActionInvokedSignal, ParseError> {
|
|
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<ClosedSignal, ParseError> {
|
|
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));
|
|
}
|
|
}
|