Private
Public Access
1
0

first attempt: notification code is in dbus::agent

This commit is contained in:
2025-11-01 21:39:53 -07:00
parent e650cffde7
commit 717138b371
15 changed files with 1222 additions and 120 deletions

View File

@@ -23,6 +23,7 @@ tokio-condvar = "0.3.0"
uuid = "1.16.0"
once_cell = "1.19.0"
mime_guess = "2.0"
notify = { package = "notify-rust", version = "4.10.0" }
# D-Bus dependencies only on Linux
[target.'cfg(target_os = "linux")'.dependencies]

View File

@@ -103,6 +103,13 @@
"/>
</method>
<method name="TestNotification">
<arg type="s" name="summary" direction="in"/>
<arg type="s" name="body" direction="in"/>
<annotation name="org.freedesktop.DBus.DocString"
value="Displays a test desktop notification with the provided summary and body."/>
</method>
<signal name="MessagesUpdated">
<arg type="s" name="conversation_id" direction="in"/>
<annotation name="org.freedesktop.DBus.DocString"

View File

@@ -41,6 +41,12 @@ pub enum Event {
/// - offset: The offset into the conversation list to start returning conversations from.
GetAllConversations(i32, i32, Reply<Vec<Conversation>>),
/// Returns a conversation by its ID.
GetConversation(String, Reply<Option<Conversation>>),
/// Returns the most recent message for a conversation.
GetLastMessage(String, Reply<Option<Message>>),
/// Returns all known settings from the database.
GetAllSettings(Reply<Settings>),

View File

@@ -271,6 +271,16 @@ impl Daemon {
reply.send(conversations).unwrap();
}
Event::GetConversation(conversation_id, reply) => {
let conversation = self.get_conversation(conversation_id).await;
reply.send(conversation).unwrap();
}
Event::GetLastMessage(conversation_id, reply) => {
let message = self.get_last_message(conversation_id).await;
reply.send(message).unwrap();
}
Event::GetAllSettings(reply) => {
let settings = self.get_settings().await.unwrap_or_else(|e| {
log::error!(target: target::SETTINGS, "Failed to get settings: {:#?}", e);
@@ -433,6 +443,14 @@ impl Daemon {
self.signal_receiver.take().unwrap()
}
async fn get_conversation(&mut self, conversation_id: String) -> Option<Conversation> {
self.database
.lock()
.await
.with_repository(|r| r.get_conversation_by_guid(&conversation_id).unwrap())
.await
}
async fn get_conversations_limit_offset(
&mut self,
limit: i32,
@@ -445,6 +463,18 @@ impl Daemon {
.await
}
async fn get_last_message(&mut self, conversation_id: String) -> Option<Message> {
self.database
.lock()
.await
.with_repository(|r| {
r.get_last_message_for_conversation(&conversation_id)
.unwrap()
.map(|message| message.into())
})
.await
}
async fn get_messages(
&mut self,
conversation_id: String,
@@ -471,9 +501,8 @@ impl Daemon {
.await;
// Convert DB messages to daemon model, substituting local_id when an alias exists.
let mut result: Vec<Message> = Vec::with_capacity(
db_messages.len() + outgoing_messages.len(),
);
let mut result: Vec<Message> =
Vec::with_capacity(db_messages.len() + outgoing_messages.len());
for m in db_messages.into_iter() {
let server_id = m.id.clone();
let mut dm: Message = m.into();

View File

@@ -25,15 +25,14 @@ impl Attachment {
// Prefer common, user-friendly extensions over obscure ones
match normalized {
"image/jpeg" | "image/pjpeg" => Some("jpg"),
_ => mime_guess::get_mime_extensions_str(normalized)
.and_then(|list| {
// If jpg is one of the candidates, prefer it
if list.iter().any(|e| *e == "jpg") {
Some("jpg")
} else {
list.first().copied()
}
}),
_ => mime_guess::get_mime_extensions_str(normalized).and_then(|list| {
// If jpg is one of the candidates, prefer it
if list.iter().any(|e| *e == "jpg") {
Some("jpg")
} else {
list.first().copied()
}
}),
}
}
pub fn get_path(&self) -> PathBuf {

View File

@@ -1,6 +1,7 @@
use dbus::arg;
use dbus_tree::MethodErr;
use std::sync::Arc;
use notify::Notification;
use std::sync::{Arc, Mutex as StdMutex};
use std::{future::Future, thread};
use tokio::sync::{mpsc, oneshot, Mutex};
@@ -9,10 +10,11 @@ use kordophoned::daemon::{
events::{Event, Reply},
settings::Settings,
signals::Signal,
DaemonResult,
DaemonResult, Message,
};
use kordophone_db::models::participant::Participant;
use kordophone_db::models::Conversation;
use crate::dbus::endpoint::DbusRegistry;
use crate::dbus::interface;
@@ -23,7 +25,7 @@ use dbus_tokio::connection;
pub struct DBusAgent {
event_sink: mpsc::Sender<Event>,
signal_receiver: Arc<Mutex<Option<mpsc::Receiver<Signal>>>>,
contact_resolver: ContactResolver<DefaultContactResolverBackend>,
contact_resolver: Arc<StdMutex<ContactResolver<DefaultContactResolverBackend>>>,
}
impl DBusAgent {
@@ -31,7 +33,9 @@ impl DBusAgent {
Self {
event_sink,
signal_receiver: Arc::new(Mutex::new(Some(signal_receiver))),
contact_resolver: ContactResolver::new(DefaultContactResolverBackend::default()),
contact_resolver: Arc::new(StdMutex::new(ContactResolver::new(
DefaultContactResolverBackend::default(),
))),
}
}
@@ -68,6 +72,7 @@ impl DBusAgent {
{
let registry = dbus_registry.clone();
let receiver_arc = self.signal_receiver.clone();
let agent_clone = self.clone();
tokio::spawn(async move {
let mut receiver = receiver_arc
.lock()
@@ -94,6 +99,7 @@ impl DBusAgent {
"Sending signal: MessagesUpdated for conversation {}",
conversation_id
);
let conversation_id_for_notification = conversation_id.clone();
registry
.send_signal(
interface::OBJECT_PATH,
@@ -103,6 +109,10 @@ impl DBusAgent {
log::error!("Failed to send signal");
0
});
agent_clone
.maybe_notify_on_messages_updated(&conversation_id_for_notification)
.await;
}
Signal::AttachmentDownloaded(attachment_id) => {
log::debug!(
@@ -181,7 +191,7 @@ impl DBusAgent {
.map_err(|e| MethodErr::failed(&format!("Daemon error: {}", e)))
}
fn resolve_participant_display_name(&mut self, participant: &Participant) -> String {
fn resolve_participant_display_name(&self, participant: &Participant) -> String {
match participant {
// Me (we should use a special string here...)
Participant::Me => "(Me)".to_string(),
@@ -191,10 +201,15 @@ impl DBusAgent {
handle,
contact_id: Some(contact_id),
..
} => self
.contact_resolver
.get_contact_display_name(contact_id)
.unwrap_or_else(|| handle.clone()),
} => {
if let Ok(mut resolver) = self.contact_resolver.lock() {
resolver
.get_contact_display_name(contact_id)
.unwrap_or_else(|| handle.clone())
} else {
handle.clone()
}
}
// Remote participant without a resolved contact_id
Participant::Remote { handle, .. } => handle.clone(),
@@ -202,6 +217,113 @@ impl DBusAgent {
}
}
impl DBusAgent {
fn conversation_display_name(&self, conversation: &Conversation) -> String {
if let Some(display_name) = &conversation.display_name {
if !display_name.trim().is_empty() {
return display_name.clone();
}
}
let names: Vec<String> = conversation
.participants
.iter()
.filter(|participant| !matches!(participant, Participant::Me))
.map(|participant| self.resolve_participant_display_name(participant))
.collect();
if names.is_empty() {
"Kordophone".to_string()
} else {
names.join(", ")
}
}
async fn prepare_incoming_message_notification(
&self,
conversation_id: &str,
) -> DaemonResult<Option<(String, String)>> {
let conversation = match self
.send_event(|reply| Event::GetConversation(conversation_id.to_string(), reply))
.await?
{
Some(conv) => conv,
None => return Ok(None),
};
if conversation.unread_count == 0 {
return Ok(None);
}
let last_message: Option<Message> = self
.send_event(|reply| Event::GetLastMessage(conversation_id.to_string(), reply))
.await?;
let last_message = match last_message {
Some(message) => message,
None => return Ok(None),
};
let sender_participant: Participant = Participant::from(last_message.sender.clone());
if matches!(sender_participant, Participant::Me) {
return Ok(None);
}
let summary = self.conversation_display_name(&conversation);
let sender_display_name = self.resolve_participant_display_name(&sender_participant);
let mut message_text = last_message.text.replace('\u{FFFC}', "");
if message_text.trim().is_empty() {
if !last_message.attachments.is_empty() {
message_text = "Sent an attachment".to_string();
} else {
message_text = "Sent a message".to_string();
}
}
let body = if sender_display_name.is_empty() {
message_text
} else {
format!("{}: {}", sender_display_name, message_text)
};
Ok(Some((summary, body)))
}
fn show_notification(&self, summary: &str, body: &str) -> Result<(), notify::error::Error> {
Notification::new()
.appname("Kordophone")
.summary(summary)
.body(body)
.show()
.map(|_| ())
}
async fn maybe_notify_on_messages_updated(&self, conversation_id: &str) {
match self
.prepare_incoming_message_notification(conversation_id)
.await
{
Ok(Some((summary, body))) => {
if let Err(error) = self.show_notification(&summary, &body) {
log::warn!(
"Failed to display notification for conversation {}: {}",
conversation_id,
error
);
}
}
Ok(None) => {}
Err(error) => {
log::warn!(
"Unable to prepare notification for conversation {}: {}",
conversation_id,
error
);
}
}
}
}
//
// D-Bus repository interface implementation
//
@@ -398,6 +520,11 @@ impl DbusRepository for DBusAgent {
.map(|uuid| uuid.to_string())
}
fn test_notification(&mut self, summary: String, body: String) -> Result<(), MethodErr> {
self.show_notification(&summary, &body)
.map_err(|e| MethodErr::failed(&format!("Failed to display notification: {}", e)))
}
fn get_attachment_info(
&mut self,
attachment_id: String,

View File

@@ -15,10 +15,16 @@ pub struct DispatchResult {
impl DispatchResult {
pub fn new(message: Message) -> Self {
Self { message, cleanup: None }
Self {
message,
cleanup: None,
}
}
pub fn with_cleanup<T: Any + Send + 'static>(message: Message, cleanup: T) -> Self {
Self { message, cleanup: Some(Box::new(cleanup)) }
Self {
message,
cleanup: Some(Box::new(cleanup)),
}
}
}

View File

@@ -105,7 +105,12 @@ pub async fn dispatch(
.and_then(|m| dict_get_str(m, "conversation_id"))
{
Some(id) => id,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing conversation_id",
))
}
};
match agent
.send_event(|r| Event::SyncConversation(conversation_id, r))
@@ -122,7 +127,12 @@ pub async fn dispatch(
.and_then(|m| dict_get_str(m, "conversation_id"))
{
Some(id) => id,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing conversation_id",
))
}
};
match agent
.send_event(|r| Event::MarkConversationAsRead(conversation_id, r))
@@ -137,11 +147,21 @@ pub async fn dispatch(
"GetMessages" => {
let args = match get_dictionary_field(root, "arguments") {
Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
};
let conversation_id = match dict_get_str(args, "conversation_id") {
Some(id) => id,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing conversation_id",
))
}
};
let last_message_id = dict_get_str(args, "last_message_id");
match agent
@@ -158,13 +178,10 @@ pub async fn dispatch(
dict_put_str(&mut m, "sender", &msg.sender.display_name());
// Include attachment GUIDs for the client to resolve/download
let attachment_guids: Vec<String> = msg
.attachments
.iter()
.map(|a| a.guid.clone())
.collect();
let attachment_guids: Vec<String> =
msg.attachments.iter().map(|a| a.guid.clone()).collect();
m.insert(cstr("attachment_guids"), array_from_strs(attachment_guids));
// Full attachments array with metadata (mirrors DBus fields)
let mut attachments_items: Vec<Message> = Vec::new();
for attachment in msg.attachments.iter() {
@@ -193,12 +210,23 @@ pub async fn dispatch(
if let Some(attribution_info) = &metadata.attribution_info {
let mut attribution_map: XpcMap = HashMap::new();
if let Some(width) = attribution_info.width {
dict_put_i64_as_str(&mut attribution_map, "width", width as i64);
dict_put_i64_as_str(
&mut attribution_map,
"width",
width as i64,
);
}
if let Some(height) = attribution_info.height {
dict_put_i64_as_str(&mut attribution_map, "height", height as i64);
dict_put_i64_as_str(
&mut attribution_map,
"height",
height as i64,
);
}
metadata_map.insert(cstr("attribution_info"), Message::Dictionary(attribution_map));
metadata_map.insert(
cstr("attribution_info"),
Message::Dictionary(attribution_map),
);
}
if !metadata_map.is_empty() {
a.insert(cstr("metadata"), Message::Dictionary(metadata_map));
@@ -208,7 +236,7 @@ pub async fn dispatch(
attachments_items.push(Message::Dictionary(a));
}
m.insert(cstr("attachments"), Message::Array(attachments_items));
items.push(Message::Dictionary(m));
}
let mut reply: XpcMap = HashMap::new();
@@ -230,11 +258,21 @@ pub async fn dispatch(
"SendMessage" => {
let args = match get_dictionary_field(root, "arguments") {
Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
};
let conversation_id = match dict_get_str(args, "conversation_id") {
Some(v) => v,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing conversation_id",
))
}
};
let text = dict_get_str(args, "text").unwrap_or_default();
let attachment_guids: Vec<String> = match args.get(&cstr("attachment_guids")) {
@@ -265,11 +303,21 @@ pub async fn dispatch(
"GetAttachmentInfo" => {
let args = match get_dictionary_field(root, "arguments") {
Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
};
let attachment_id = match dict_get_str(args, "attachment_id") {
Some(v) => v,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing attachment_id")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing attachment_id",
))
}
};
match agent
.send_event(|r| Event::GetAttachment(attachment_id, r))
@@ -308,11 +356,21 @@ pub async fn dispatch(
"OpenAttachmentFd" => {
let args = match get_dictionary_field(root, "arguments") {
Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
};
let attachment_id = match dict_get_str(args, "attachment_id") {
Some(v) => v,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing attachment_id")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing attachment_id",
))
}
};
let preview = dict_get_str(args, "preview")
.map(|s| s == "true")
@@ -324,7 +382,7 @@ pub async fn dispatch(
{
Ok(attachment) => {
use std::os::fd::AsRawFd;
let path = attachment.get_path_for_preview(preview);
match std::fs::OpenOptions::new().read(true).open(&path) {
Ok(file) => {
@@ -335,9 +393,14 @@ pub async fn dispatch(
dict_put_str(&mut reply, "type", "OpenAttachmentFdResponse");
reply.insert(cstr("fd"), Message::Fd(fd));
DispatchResult { message: Message::Dictionary(reply), cleanup: Some(Box::new(file)) }
DispatchResult {
message: Message::Dictionary(reply),
cleanup: Some(Box::new(file)),
}
}
Err(e) => {
DispatchResult::new(make_error_reply("OpenFailed", &format!("{}", e)))
}
Err(e) => DispatchResult::new(make_error_reply("OpenFailed", &format!("{}", e))),
}
}
Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))),
@@ -348,11 +411,21 @@ pub async fn dispatch(
"DownloadAttachment" => {
let args = match get_dictionary_field(root, "arguments") {
Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
};
let attachment_id = match dict_get_str(args, "attachment_id") {
Some(v) => v,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing attachment_id")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing attachment_id",
))
}
};
let preview = dict_get_str(args, "preview")
.map(|s| s == "true")
@@ -371,11 +444,18 @@ pub async fn dispatch(
use std::path::PathBuf;
let args = match get_dictionary_field(root, "arguments") {
Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
};
let path = match dict_get_str(args, "path") {
Some(v) => v,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing path")),
None => {
return DispatchResult::new(make_error_reply("InvalidRequest", "Missing path"))
}
};
match agent
.send_event(|r| Event::UploadAttachment(PathBuf::from(path), r))
@@ -413,7 +493,12 @@ pub async fn dispatch(
"UpdateSettings" => {
let args = match get_dictionary_field(root, "arguments") {
Some(a) => a,
None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")),
None => {
return DispatchResult::new(make_error_reply(
"InvalidRequest",
"Missing arguments",
))
}
};
let server_url = dict_get_str(args, "server_url");
let username = dict_get_str(args, "username");