diff --git a/core/Cargo.lock b/core/Cargo.lock index f891978..be1002d 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -1274,7 +1274,7 @@ dependencies = [ [[package]] name = "kordophoned" -version = "1.3.0" +version = "1.3.2" dependencies = [ "anyhow", "async-trait", @@ -1313,6 +1313,7 @@ dependencies = [ "block", "dbus", "dbus-codegen", + "keyring", "log", "xpc-connection", "xpc-connection-sys", diff --git a/core/kordophoned-client/Cargo.toml b/core/kordophoned-client/Cargo.toml index fc25bcb..5bb818d 100644 --- a/core/kordophoned-client/Cargo.toml +++ b/core/kordophoned-client/Cargo.toml @@ -10,6 +10,7 @@ log = "0.4.22" # D-Bus dependencies only on Linux [target.'cfg(target_os = "linux")'.dependencies] dbus = "0.9.7" +keyring = { version = "3.6.3", features = ["sync-secret-service"] } # D-Bus codegen only on Linux [target.'cfg(target_os = "linux")'.build-dependencies] diff --git a/core/kordophoned-client/src/lib.rs b/core/kordophoned-client/src/lib.rs index 6e97842..aebd6df 100644 --- a/core/kordophoned-client/src/lib.rs +++ b/core/kordophoned-client/src/lib.rs @@ -1,5 +1,7 @@ mod platform; mod worker; -pub use worker::{spawn_worker, ChatMessage, ConversationSummary, Event, Request}; - +pub use worker::{ + spawn_worker, Attachment, AttachmentInfo, AttachmentMetadata, AttributionInfo, ChatMessage, + ConversationSummary, Event, Request, SettingsSnapshot, +}; diff --git a/core/kordophoned-client/src/platform/linux.rs b/core/kordophoned-client/src/platform/linux.rs index b45f6c9..21d966b 100644 --- a/core/kordophoned-client/src/platform/linux.rs +++ b/core/kordophoned-client/src/platform/linux.rs @@ -1,8 +1,11 @@ #![cfg(target_os = "linux")] -use crate::worker::{ChatMessage, ConversationSummary, DaemonClient, Event}; +use crate::worker::{ + Attachment, AttachmentInfo, AttachmentMetadata, AttributionInfo, ChatMessage, + ConversationSummary, DaemonClient, Event, SettingsSnapshot, +}; use anyhow::Result; -use dbus::arg::{PropMap, RefArg}; +use dbus::arg::{cast, PropMap, RefArg}; use dbus::blocking::{Connection, Proxy}; use dbus::channel::Token; use std::sync::mpsc::Sender; @@ -17,6 +20,7 @@ mod dbus_interface { include!(concat!(env!("OUT_DIR"), "/kordophone-client.rs")); } use dbus_interface::NetBuzzertKordophoneRepository as KordophoneRepository; +use dbus_interface::NetBuzzertKordophoneSettings as KordophoneSettings; pub(crate) struct DBusClient { conn: Connection, @@ -31,7 +35,7 @@ impl DBusClient { }) } - fn proxy(&self) -> Proxy<&Connection> { + fn proxy(&self) -> Proxy<'_, &Connection> { self.conn .with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000)) } @@ -52,6 +56,10 @@ fn get_u32(map: &PropMap, key: &str) -> u32 { get_i64(map, key).try_into().unwrap_or(0) } +fn get_bool(map: &PropMap, key: &str) -> bool { + map.get(key).and_then(|v| v.0.as_i64()).unwrap_or(0) != 0 +} + fn get_vec_string(map: &PropMap, key: &str) -> Vec { map.get(key) .and_then(|v| v.0.as_iter()) @@ -62,6 +70,122 @@ fn get_vec_string(map: &PropMap, key: &str) -> Vec { .unwrap_or_default() } +fn clone_prop_map(map: &PropMap) -> PropMap { + map.iter() + .map(|(key, value)| (key.clone(), dbus::arg::Variant(value.0.box_clone()))) + .collect() +} + +fn get_prop_map(map: &PropMap, key: &str) -> Option { + let value = map.get(key)?; + + if let Some(prop_map) = cast::(&value.0) { + return Some(clone_prop_map(prop_map)); + } + + let mut iter = value.0.as_iter()?; + let mut out = PropMap::new(); + loop { + let Some(key) = iter.next().and_then(|arg| arg.as_str()) else { + break; + }; + let Some(value) = iter.next() else { + break; + }; + + out.insert(key.to_string(), dbus::arg::Variant(value.box_clone())); + } + + Some(out) +} + +fn get_vec_prop_map(map: &PropMap, key: &str) -> Vec { + let Some(value) = map.get(key) else { + return Vec::new(); + }; + + if let Some(items) = cast::>(&value.0) { + return items.iter().map(clone_prop_map).collect(); + } + + value + .0 + .as_iter() + .map(|iter| { + iter.filter_map(|item| { + let mut item_iter = item.as_iter()?; + let mut out = PropMap::new(); + loop { + let Some(key) = item_iter.next().and_then(|arg| arg.as_str()) else { + break; + }; + let Some(value) = item_iter.next() else { + break; + }; + + out.insert(key.to_string(), dbus::arg::Variant(value.box_clone())); + } + Some(out) + }) + .collect() + }) + .unwrap_or_default() +} + +fn attachment_from_prop_map(map: PropMap) -> Attachment { + let metadata = get_prop_map(&map, "metadata").map(|metadata_map| { + let attribution_info = + get_prop_map(&metadata_map, "attribution_info").map(|info_map| AttributionInfo { + width: info_map + .get("width") + .and_then(|v| v.0.as_i64()) + .and_then(|v| v.try_into().ok()), + height: info_map + .get("height") + .and_then(|v| v.0.as_i64()) + .and_then(|v| v.try_into().ok()), + }); + + AttachmentMetadata { attribution_info } + }); + + Attachment { + guid: get_string(&map, "guid"), + downloaded: get_bool(&map, "downloaded"), + preview_downloaded: get_bool(&map, "preview_downloaded"), + metadata, + } +} + +fn password_for_username(username: &str) -> Result { + if username.trim().is_empty() { + return Ok(String::new()); + } + + let entry = keyring::Entry::new("net.buzzert.kordophonecd", username)?; + match entry.get_password() { + Ok(password) => Ok(password), + Err(keyring::Error::NoEntry) => Ok(String::new()), + Err(e) => Err(e.into()), + } +} + +fn set_password_for_username(username: &str, password: &str) -> Result<()> { + if username.trim().is_empty() { + return Ok(()); + } + + let entry = keyring::Entry::new("net.buzzert.kordophonecd", username)?; + if password.is_empty() { + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(e.into()), + } + } else { + entry.set_password(password).map_err(Into::into) + } +} + impl DaemonClient for DBusClient { fn get_conversations(&mut self, limit: i32, offset: i32) -> Result> { let mut items = KordophoneRepository::get_conversations(&self.proxy(), limit, offset)?; @@ -83,6 +207,7 @@ impl DaemonClient for DBusClient { id, title, preview: get_string(&conv, "last_message_preview").replace('\n', " "), + participants, unread_count: get_u32(&conv, "unread_count"), date_unix: get_i64(&conv, "date"), } @@ -107,20 +232,37 @@ impl DaemonClient for DBusClient { Ok(messages .into_iter() .map(|msg| ChatMessage { + id: get_string(&msg, "id"), sender: get_string(&msg, "sender"), text: get_string(&msg, "text"), date_unix: get_i64(&msg, "date"), + attachments: get_vec_prop_map(&msg, "attachments") + .into_iter() + .map(attachment_from_prop_map) + .collect(), }) .collect()) } fn send_message(&mut self, conversation_id: String, text: String) -> Result> { - let attachment_guids: Vec<&str> = vec![]; + self.send_message_with_attachments(conversation_id, text, Vec::new()) + } + + fn send_message_with_attachments( + &mut self, + conversation_id: String, + text: String, + attachment_guids: Vec, + ) -> Result> { + let attachment_guid_refs = attachment_guids + .iter() + .map(String::as_str) + .collect::>(); let outgoing_id = KordophoneRepository::send_message( &self.proxy(), &conversation_id, &text, - attachment_guids, + attachment_guid_refs, )?; Ok(Some(outgoing_id)) } @@ -135,6 +277,61 @@ impl DaemonClient for DBusClient { .map_err(|e| anyhow::anyhow!("Failed to sync conversation: {e}")) } + fn sync_conversation_list(&mut self) -> Result<()> { + KordophoneRepository::sync_conversation_list(&self.proxy()) + .map_err(|e| anyhow::anyhow!("Failed to sync conversation list: {e}")) + } + + fn sync_all_conversations(&mut self) -> Result<()> { + KordophoneRepository::sync_all_conversations(&self.proxy()) + .map_err(|e| anyhow::anyhow!("Failed to sync all conversations: {e}")) + } + + fn download_attachment(&mut self, attachment_guid: String, preview: bool) -> Result<()> { + KordophoneRepository::download_attachment(&self.proxy(), &attachment_guid, preview) + .map_err(|e| anyhow::anyhow!("Failed to download attachment: {e}")) + } + + fn get_attachment_info(&mut self, attachment_guid: String) -> Result { + let (path, preview_path, downloaded, preview_downloaded) = + KordophoneRepository::get_attachment_info(&self.proxy(), &attachment_guid)?; + + Ok(AttachmentInfo { + path, + preview_path, + downloaded, + preview_downloaded, + }) + } + + fn upload_attachment(&mut self, path: String) -> Result { + KordophoneRepository::upload_attachment(&self.proxy(), &path) + .map_err(|e| anyhow::anyhow!("Failed to upload attachment: {e}")) + } + + fn get_settings(&mut self) -> Result { + let server_url = KordophoneSettings::server_url(&self.proxy()).unwrap_or_default(); + let username = KordophoneSettings::username(&self.proxy()).unwrap_or_default(); + let password = password_for_username(&username)?; + + Ok(SettingsSnapshot { + server_url, + username, + password, + }) + } + + fn save_settings( + &mut self, + server_url: String, + username: String, + password: String, + ) -> Result<()> { + KordophoneSettings::set_server(&self.proxy(), &server_url, &username) + .map_err(|e| anyhow::anyhow!("Failed to save daemon settings: {e}"))?; + set_password_for_username(&username, &password) + } + fn install_signal_handlers(&mut self, event_tx: Sender) -> Result<()> { let conversations_tx = event_tx.clone(); let t1 = self @@ -164,7 +361,7 @@ impl DaemonClient for DBusClient { ) .map_err(|e| anyhow::anyhow!("Failed to match MessagesUpdated: {e}"))?; - let reconnected_tx = event_tx; + let reconnected_tx = event_tx.clone(); let t3 = self .proxy() .match_signal( @@ -177,7 +374,54 @@ impl DaemonClient for DBusClient { ) .map_err(|e| anyhow::anyhow!("Failed to match UpdateStreamReconnected: {e}"))?; - self.signal_tokens.extend([t1, t2, t3]); + let download_tx = event_tx.clone(); + let t4 = self + .proxy() + .match_signal( + move |s: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadCompleted, + _: &Connection, + _: &dbus::message::Message| { + let _ = download_tx.send(Event::AttachmentDownloaded { + attachment_guid: s.attachment_id, + }); + true + }, + ) + .map_err(|e| anyhow::anyhow!("Failed to match AttachmentDownloadCompleted: {e}"))?; + + let failed_tx = event_tx.clone(); + let t5 = self + .proxy() + .match_signal( + move |s: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadFailed, + _: &Connection, + _: &dbus::message::Message| { + let _ = failed_tx.send(Event::AttachmentDownloadFailed { + attachment_guid: s.attachment_id, + error: s.error_message, + }); + true + }, + ) + .map_err(|e| anyhow::anyhow!("Failed to match AttachmentDownloadFailed: {e}"))?; + + let upload_tx = event_tx; + let t6 = self + .proxy() + .match_signal( + move |s: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentUploadCompleted, + _: &Connection, + _: &dbus::message::Message| { + let _ = upload_tx.send(Event::AttachmentUploaded { + upload_guid: s.upload_guid, + attachment_guid: s.attachment_guid, + }); + true + }, + ) + .map_err(|e| anyhow::anyhow!("Failed to match AttachmentUploadCompleted: {e}"))?; + + self.signal_tokens.extend([t1, t2, t3, t4, t5, t6]); Ok(()) } @@ -186,4 +430,3 @@ impl DaemonClient for DBusClient { Ok(()) } } - diff --git a/core/kordophoned-client/src/platform/macos.rs b/core/kordophoned-client/src/platform/macos.rs index 94c1de8..292de25 100644 --- a/core/kordophoned-client/src/platform/macos.rs +++ b/core/kordophoned-client/src/platform/macos.rs @@ -142,6 +142,7 @@ impl DaemonClient for XpcClient { id, title, preview: preview.replace('\n', " "), + participants, unread_count, date_unix, }); @@ -180,9 +181,11 @@ impl DaemonClient for XpcClient { for item in items { let Message::Dictionary(msg) = item else { continue }; messages.push(ChatMessage { + id: Self::get_string(msg, "id").unwrap_or_default(), sender: Self::get_string(msg, "sender").unwrap_or_default(), text: Self::get_string(msg, "text").unwrap_or_default(), date_unix: Self::get_i64_from_str(msg, "date"), + attachments: Vec::new(), }); } Ok(messages) @@ -230,4 +233,3 @@ impl DaemonClient for XpcClient { Ok(()) } } - diff --git a/core/kordophoned-client/src/worker.rs b/core/kordophoned-client/src/worker.rs index 2f31a2f..66cb313 100644 --- a/core/kordophoned-client/src/worker.rs +++ b/core/kordophoned-client/src/worker.rs @@ -8,23 +8,98 @@ pub struct ConversationSummary { pub id: String, pub title: String, pub preview: String, + pub participants: Vec, pub unread_count: u32, pub date_unix: i64, } #[derive(Clone, Debug)] pub struct ChatMessage { + pub id: String, pub sender: String, pub text: String, pub date_unix: i64, + pub attachments: Vec, +} + +impl ChatMessage { + pub fn from_me(&self) -> bool { + self.sender == "(Me)" + } +} + +#[derive(Clone, Debug, Default)] +pub struct AttributionInfo { + pub width: Option, + pub height: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct AttachmentMetadata { + pub attribution_info: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct Attachment { + pub guid: String, + pub downloaded: bool, + pub preview_downloaded: bool, + pub metadata: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct AttachmentInfo { + pub path: String, + pub preview_path: String, + pub downloaded: bool, + pub preview_downloaded: bool, +} + +#[derive(Clone, Debug, Default)] +pub struct SettingsSnapshot { + pub server_url: String, + pub username: String, + pub password: String, } pub enum Request { RefreshConversations, - RefreshMessages { conversation_id: String }, - SendMessage { conversation_id: String, text: String }, - MarkRead { conversation_id: String }, - SyncConversation { conversation_id: String }, + RefreshMessages { + conversation_id: String, + }, + SendMessage { + conversation_id: String, + text: String, + }, + SendMessageWithAttachments { + conversation_id: String, + text: String, + attachment_guids: Vec, + }, + MarkRead { + conversation_id: String, + }, + SyncConversation { + conversation_id: String, + }, + SyncConversationList, + SyncAllConversations, + DownloadAttachment { + attachment_guid: String, + preview: bool, + }, + GetAttachmentInfo { + attachment_guid: String, + }, + UploadAttachment { + path: String, + }, + LoadSettings, + SaveSettings { + server_url: String, + username: String, + password: String, + }, } pub enum Event { @@ -38,9 +113,40 @@ pub enum Event { outgoing_id: Option, }, MarkedRead, - ConversationSyncTriggered { conversation_id: String }, + ConversationSyncTriggered { + conversation_id: String, + }, + ConversationListSyncTriggered, + AllConversationsSyncTriggered, + AttachmentDownloadQueued { + attachment_guid: String, + preview: bool, + }, + AttachmentDownloaded { + attachment_guid: String, + }, + AttachmentDownloadFailed { + attachment_guid: String, + error: String, + }, + AttachmentUploaded { + upload_guid: String, + attachment_guid: String, + }, + AttachmentUploadQueued { + upload_guid: String, + path: String, + }, + AttachmentInfo { + attachment_guid: String, + info: AttachmentInfo, + }, + SettingsLoaded(SettingsSnapshot), + SettingsSaved, ConversationsUpdated, - MessagesUpdated { conversation_id: String }, + MessagesUpdated { + conversation_id: String, + }, UpdateStreamReconnected, Error(String), } @@ -59,16 +165,18 @@ pub fn spawn_worker( }; if let Err(e) = client.install_signal_handlers(event_tx.clone()) { - let _ = event_tx.send(Event::Error(format!("Failed to install daemon signals: {e}"))); + let _ = event_tx.send(Event::Error(format!( + "Failed to install daemon signals: {e}" + ))); } loop { match request_rx.recv_timeout(Duration::from_millis(100)) { Ok(req) => { let res = match req { - Request::RefreshConversations => client - .get_conversations(200, 0) - .map(Event::Conversations), + Request::RefreshConversations => { + client.get_conversations(200, 0).map(Event::Conversations) + } Request::RefreshMessages { conversation_id } => client .get_messages(conversation_id.clone(), None) .map(|messages| Event::Messages { @@ -78,8 +186,24 @@ pub fn spawn_worker( Request::SendMessage { conversation_id, text, + } => { + client + .send_message(conversation_id.clone(), text) + .map(|outgoing_id| Event::MessageSent { + conversation_id, + outgoing_id, + }) + } + Request::SendMessageWithAttachments { + conversation_id, + text, + attachment_guids, } => client - .send_message(conversation_id.clone(), text) + .send_message_with_attachments( + conversation_id.clone(), + text, + attachment_guids, + ) .map(|outgoing_id| Event::MessageSent { conversation_id, outgoing_id, @@ -90,6 +214,38 @@ pub fn spawn_worker( Request::SyncConversation { conversation_id } => client .sync_conversation(conversation_id.clone()) .map(|_| Event::ConversationSyncTriggered { conversation_id }), + Request::SyncConversationList => client + .sync_conversation_list() + .map(|_| Event::ConversationListSyncTriggered), + Request::SyncAllConversations => client + .sync_all_conversations() + .map(|_| Event::AllConversationsSyncTriggered), + Request::DownloadAttachment { + attachment_guid, + preview, + } => client + .download_attachment(attachment_guid.clone(), preview) + .map(|_| Event::AttachmentDownloadQueued { + attachment_guid, + preview, + }), + Request::GetAttachmentInfo { attachment_guid } => client + .get_attachment_info(attachment_guid.clone()) + .map(|info| Event::AttachmentInfo { + attachment_guid, + info, + }), + Request::UploadAttachment { path } => client + .upload_attachment(path.clone()) + .map(|upload_guid| Event::AttachmentUploadQueued { upload_guid, path }), + Request::LoadSettings => client.get_settings().map(Event::SettingsLoaded), + Request::SaveSettings { + server_url, + username, + password, + } => client + .save_settings(server_url, username, password) + .map(|_| Event::SettingsSaved), }; match res { @@ -120,8 +276,43 @@ pub(crate) trait DaemonClient { last_message_id: Option, ) -> Result>; fn send_message(&mut self, conversation_id: String, text: String) -> Result>; + fn send_message_with_attachments( + &mut self, + conversation_id: String, + text: String, + attachment_guids: Vec, + ) -> Result> { + let _ = attachment_guids; + self.send_message(conversation_id, text) + } fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()>; fn sync_conversation(&mut self, conversation_id: String) -> Result<()>; + fn sync_conversation_list(&mut self) -> Result<()> { + Ok(()) + } + fn sync_all_conversations(&mut self) -> Result<()> { + Ok(()) + } + fn download_attachment(&mut self, _attachment_guid: String, _preview: bool) -> Result<()> { + anyhow::bail!("Attachment downloads are not supported on this platform") + } + fn get_attachment_info(&mut self, _attachment_guid: String) -> Result { + anyhow::bail!("Attachment info is not supported on this platform") + } + fn upload_attachment(&mut self, _path: String) -> Result { + anyhow::bail!("Attachment uploads are not supported on this platform") + } + fn get_settings(&mut self) -> Result { + anyhow::bail!("Settings are not supported on this platform") + } + fn save_settings( + &mut self, + _server_url: String, + _username: String, + _password: String, + ) -> Result<()> { + anyhow::bail!("Settings are not supported on this platform") + } fn install_signal_handlers(&mut self, _event_tx: mpsc::Sender) -> Result<()> { Ok(()) } @@ -130,4 +321,3 @@ pub(crate) trait DaemonClient { Ok(()) } } -