diff --git a/Cargo.lock b/Cargo.lock index 6c8786d..fcf5c89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1076,6 +1076,7 @@ name = "kpcli" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "clap 4.5.20", "dbus", "dbus-codegen", diff --git a/kpcli/Cargo.toml b/kpcli/Cargo.toml index 4719106..82f1774 100644 --- a/kpcli/Cargo.toml +++ b/kpcli/Cargo.toml @@ -19,6 +19,7 @@ prettytable = "0.10.0" serde_json = "1.0" time = "0.3.37" tokio = "1.41.1" +async-trait = "0.1.80" # D-Bus dependencies only on Linux [target.'cfg(target_os = "linux")'.dependencies] diff --git a/kpcli/src/daemon/dbus.rs b/kpcli/src/daemon/dbus.rs new file mode 100644 index 0000000..96d1178 --- /dev/null +++ b/kpcli/src/daemon/dbus.rs @@ -0,0 +1,212 @@ +//! Linux-only D-Bus implementation of the `DaemonInterface`. +#![cfg(target_os = "linux")] + +use super::{ConfigCommands, DaemonInterface}; +use crate::printers::{ConversationPrinter, MessagePrinter}; +use anyhow::Result; +use async_trait::async_trait; +use dbus::blocking::{Connection, Proxy}; +use prettytable::table; + +const DBUS_NAME: &str = "net.buzzert.kordophonecd"; +const DBUS_PATH: &str = "/net/buzzert/kordophonecd/daemon"; + +#[allow(unused)] +mod dbus_interface { + #![allow(unused)] + include!(concat!(env!("OUT_DIR"), "/kordophone-client.rs")); +} + +use dbus_interface::NetBuzzertKordophoneRepository as KordophoneRepository; +use dbus_interface::NetBuzzertKordophoneSettings as KordophoneSettings; + +pub struct DBusDaemonInterface { + conn: Connection, +} + +impl DBusDaemonInterface { + pub fn new() -> Result { + Ok(Self { + conn: Connection::new_session()?, + }) + } + + fn proxy(&self) -> Proxy<&Connection> { + self.conn + .with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000)) + } + + async fn print_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 table = table!([ + b->"Server URL", &server_url + ], [ + b->"Username", &username + ]); + table.printstd(); + Ok(()) + } + + async fn set_server_url(&mut self, url: String) -> Result<()> { + KordophoneSettings::set_server_url(&self.proxy(), url) + .map_err(|e| anyhow::anyhow!("Failed to set server URL: {}", e)) + } + + async fn set_username(&mut self, username: String) -> Result<()> { + KordophoneSettings::set_username(&self.proxy(), username) + .map_err(|e| anyhow::anyhow!("Failed to set username: {}", e)) + } +} + +#[async_trait] +impl DaemonInterface for DBusDaemonInterface { + async fn print_version(&mut self) -> Result<()> { + let version = KordophoneRepository::get_version(&self.proxy())?; + println!("Server version: {}", version); + Ok(()) + } + + async fn print_conversations(&mut self) -> Result<()> { + let conversations = KordophoneRepository::get_conversations(&self.proxy(), 100, 0)?; + println!("Number of conversations: {}", conversations.len()); + for conversation in conversations { + println!("{}", ConversationPrinter::new(&conversation.into())); + } + Ok(()) + } + + async fn sync_conversations(&mut self, conversation_id: Option) -> Result<()> { + if let Some(conversation_id) = conversation_id { + KordophoneRepository::sync_conversation(&self.proxy(), &conversation_id) + .map_err(|e| anyhow::anyhow!("Failed to sync conversation: {}", e)) + } else { + KordophoneRepository::sync_all_conversations(&self.proxy()) + .map_err(|e| anyhow::anyhow!("Failed to sync conversations: {}", e)) + } + } + + async fn sync_conversations_list(&mut self) -> Result<()> { + KordophoneRepository::sync_conversation_list(&self.proxy()) + .map_err(|e| anyhow::anyhow!("Failed to sync conversations: {}", e)) + } + + async fn print_messages( + &mut self, + conversation_id: String, + last_message_id: Option, + ) -> Result<()> { + let messages = KordophoneRepository::get_messages( + &self.proxy(), + &conversation_id, + &last_message_id.unwrap_or_default(), + )?; + println!("Number of messages: {}", messages.len()); + for message in messages { + println!("{}", MessagePrinter::new(&message.into())); + } + Ok(()) + } + + async fn enqueue_outgoing_message( + &mut self, + conversation_id: String, + text: String, + ) -> Result<()> { + let attachment_guids: Vec<&str> = vec![]; + let outgoing_message_id = KordophoneRepository::send_message( + &self.proxy(), + &conversation_id, + &text, + attachment_guids, + )?; + println!("Outgoing message ID: {}", outgoing_message_id); + Ok(()) + } + + async fn wait_for_signals(&mut self) -> Result<()> { + use dbus::Message; + mod dbus_signals { + pub use super::dbus_interface::NetBuzzertKordophoneRepositoryConversationsUpdated as ConversationsUpdated; + } + + let _id = self.proxy().match_signal( + |_: dbus_signals::ConversationsUpdated, _: &Connection, _: &Message| { + println!("Signal: Conversations updated"); + true + }, + ); + + println!("Waiting for signals..."); + loop { + self.conn.process(std::time::Duration::from_millis(1000))?; + } + } + + async fn config(&mut self, cmd: ConfigCommands) -> Result<()> { + match cmd { + ConfigCommands::Print => self.print_settings().await, + ConfigCommands::SetServerUrl { url } => self.set_server_url(url).await, + ConfigCommands::SetUsername { username } => self.set_username(username).await, + } + } + + async fn delete_all_conversations(&mut self) -> Result<()> { + KordophoneRepository::delete_all_conversations(&self.proxy()) + .map_err(|e| anyhow::anyhow!("Failed to delete all conversations: {}", e)) + } + + async fn download_attachment(&mut self, attachment_id: String) -> Result<()> { + // Trigger download. + KordophoneRepository::download_attachment(&self.proxy(), &attachment_id, false)?; + + // Get attachment info. + let attachment_info = + KordophoneRepository::get_attachment_info(&self.proxy(), &attachment_id)?; + let (path, _preview_path, downloaded, _preview_downloaded) = attachment_info; + + if downloaded { + println!("Attachment already downloaded: {}", path); + return Ok(()); + } + + println!("Downloading attachment: {}", attachment_id); + + // Attach to the signal that the attachment has been downloaded. + let download_path = path.clone(); + let _id = self.proxy().match_signal( + move |_: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadCompleted, + _: &Connection, + _: &dbus::message::Message| { + println!("Signal: Attachment downloaded: {}", download_path); + std::process::exit(0); + }, + ); + + let _id = self.proxy().match_signal( + |h: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadFailed, + _: &Connection, + _: &dbus::message::Message| { + println!("Signal: Attachment download failed: {}", h.attachment_id); + std::process::exit(1); + }, + ); + + // Wait for the signal. + loop { + self.conn.process(std::time::Duration::from_millis(1000))?; + } + } + + async fn upload_attachment(&mut self, path: String) -> Result<()> { + let upload_guid = KordophoneRepository::upload_attachment(&self.proxy(), &path)?; + println!("Upload GUID: {}", upload_guid); + Ok(()) + } + + async fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()> { + KordophoneRepository::mark_conversation_as_read(&self.proxy(), &conversation_id) + .map_err(|e| anyhow::anyhow!("Failed to mark conversation as read: {}", e)) + } +} diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index e154bde..19d2491 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -1,19 +1,92 @@ -use crate::printers::{ConversationPrinter, MessagePrinter}; use anyhow::Result; +use async_trait::async_trait; use clap::Subcommand; -use dbus::blocking::{Connection, Proxy}; -use prettytable::table; -const DBUS_NAME: &str = "net.buzzert.kordophonecd"; -const DBUS_PATH: &str = "/net/buzzert/kordophonecd/daemon"; +// Platform-specific modules +#[cfg(target_os = "linux")] +mod dbus; -mod dbus_interface { - #![allow(unused)] - include!(concat!(env!("OUT_DIR"), "/kordophone-client.rs")); +#[async_trait] +pub trait DaemonInterface { + async fn print_version(&mut self) -> Result<()>; + async fn print_conversations(&mut self) -> Result<()>; + async fn sync_conversations(&mut self, conversation_id: Option) -> Result<()>; + async fn sync_conversations_list(&mut self) -> Result<()>; + async fn print_messages( + &mut self, + conversation_id: String, + last_message_id: Option, + ) -> Result<()>; + async fn enqueue_outgoing_message( + &mut self, + conversation_id: String, + text: String, + ) -> Result<()>; + async fn wait_for_signals(&mut self) -> Result<()>; + async fn config(&mut self, cmd: ConfigCommands) -> Result<()>; + async fn delete_all_conversations(&mut self) -> Result<()>; + async fn download_attachment(&mut self, attachment_id: String) -> Result<()>; + async fn upload_attachment(&mut self, path: String) -> Result<()>; + async fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()>; } -use dbus_interface::NetBuzzertKordophoneRepository as KordophoneRepository; -use dbus_interface::NetBuzzertKordophoneSettings as KordophoneSettings; +struct StubDaemonInterface; +impl StubDaemonInterface { + fn new() -> Result { + Ok(Self) + } +} + +#[async_trait] +impl DaemonInterface for StubDaemonInterface { + async fn print_version(&mut self) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn print_conversations(&mut self) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn sync_conversations(&mut self, _conversation_id: Option) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn sync_conversations_list(&mut self) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn print_messages(&mut self, _conversation_id: String, _last_message_id: Option) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn enqueue_outgoing_message(&mut self, _conversation_id: String, _text: String) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn wait_for_signals(&mut self) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn config(&mut self, _cmd: ConfigCommands) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn delete_all_conversations(&mut self) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn download_attachment(&mut self, _attachment_id: String) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn upload_attachment(&mut self, _path: String) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn mark_conversation_as_read(&mut self, _conversation_id: String) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } +} + +pub fn new_daemon_interface() -> Result> { + #[cfg(target_os = "linux")] + { + Ok(Box::new(dbus::DBusDaemonInterface::new()?)) + } + #[cfg(not(target_os = "linux"))] + { + Ok(Box::new(StubDaemonInterface::new()?)) + } +} #[derive(Subcommand)] pub enum Commands { @@ -77,7 +150,7 @@ pub enum ConfigCommands { impl Commands { pub async fn run(cmd: Commands) -> Result<()> { - let mut client = DaemonCli::new()?; + let mut client = new_daemon_interface()?; match cmd { Commands::Version => client.print_version().await, Commands::Conversations => client.print_conversations().await, @@ -89,9 +162,7 @@ impl Commands { conversation_id, last_message_id, } => { - client - .print_messages(conversation_id, last_message_id) - .await + client.print_messages(conversation_id, last_message_id).await } Commands::DeleteAllConversations => client.delete_all_conversations().await, Commands::SendMessage { @@ -108,196 +179,3 @@ impl Commands { } } } - -struct DaemonCli { - conn: Connection, -} - -impl DaemonCli { - pub fn new() -> Result { - Ok(Self { - conn: Connection::new_session()?, - }) - } - - fn proxy(&self) -> Proxy<&Connection> { - self.conn - .with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000)) - } - - pub async fn print_version(&mut self) -> Result<()> { - let version = KordophoneRepository::get_version(&self.proxy())?; - println!("Server version: {}", version); - Ok(()) - } - - pub async fn print_conversations(&mut self) -> Result<()> { - let conversations = KordophoneRepository::get_conversations(&self.proxy(), 100, 0)?; - println!("Number of conversations: {}", conversations.len()); - - for conversation in conversations { - println!("{}", ConversationPrinter::new(&conversation.into())); - } - - Ok(()) - } - - pub async fn sync_conversations(&mut self, conversation_id: Option) -> Result<()> { - if let Some(conversation_id) = conversation_id { - KordophoneRepository::sync_conversation(&self.proxy(), &conversation_id) - .map_err(|e| anyhow::anyhow!("Failed to sync conversation: {}", e)) - } else { - KordophoneRepository::sync_all_conversations(&self.proxy()) - .map_err(|e| anyhow::anyhow!("Failed to sync conversations: {}", e)) - } - } - - pub async fn sync_conversations_list(&mut self) -> Result<()> { - KordophoneRepository::sync_conversation_list(&self.proxy()) - .map_err(|e| anyhow::anyhow!("Failed to sync conversations: {}", e)) - } - - pub async fn print_messages( - &mut self, - conversation_id: String, - last_message_id: Option, - ) -> Result<()> { - let messages = KordophoneRepository::get_messages( - &self.proxy(), - &conversation_id, - &last_message_id.unwrap_or_default(), - )?; - println!("Number of messages: {}", messages.len()); - - for message in messages { - println!("{}", MessagePrinter::new(&message.into())); - } - - Ok(()) - } - - pub async fn enqueue_outgoing_message( - &mut self, - conversation_id: String, - text: String, - ) -> Result<()> { - let attachment_guids: Vec<&str> = vec![]; - let outgoing_message_id = KordophoneRepository::send_message( - &self.proxy(), - &conversation_id, - &text, - attachment_guids, - )?; - println!("Outgoing message ID: {}", outgoing_message_id); - Ok(()) - } - - pub async fn wait_for_signals(&mut self) -> Result<()> { - use dbus::Message; - mod dbus_signals { - pub use super::dbus_interface::NetBuzzertKordophoneRepositoryConversationsUpdated as ConversationsUpdated; - } - - let _id = self.proxy().match_signal( - |h: dbus_signals::ConversationsUpdated, _: &Connection, _: &Message| { - println!("Signal: Conversations updated"); - true - }, - ); - - println!("Waiting for signals..."); - loop { - self.conn.process(std::time::Duration::from_millis(1000))?; - } - } - - pub async fn config(&mut self, cmd: ConfigCommands) -> Result<()> { - match cmd { - ConfigCommands::Print => self.print_settings().await, - ConfigCommands::SetServerUrl { url } => self.set_server_url(url).await, - ConfigCommands::SetUsername { username } => self.set_username(username).await, - } - } - - pub async fn print_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 table = table!( - [ b->"Server URL", &server_url ], - [ b->"Username", &username ] - ); - table.printstd(); - - Ok(()) - } - - pub async fn set_server_url(&mut self, url: String) -> Result<()> { - KordophoneSettings::set_server_url(&self.proxy(), url) - .map_err(|e| anyhow::anyhow!("Failed to set server URL: {}", e)) - } - - pub async fn set_username(&mut self, username: String) -> Result<()> { - KordophoneSettings::set_username(&self.proxy(), username) - .map_err(|e| anyhow::anyhow!("Failed to set username: {}", e)) - } - - pub async fn delete_all_conversations(&mut self) -> Result<()> { - KordophoneRepository::delete_all_conversations(&self.proxy()) - .map_err(|e| anyhow::anyhow!("Failed to delete all conversations: {}", e)) - } - - pub async fn download_attachment(&mut self, attachment_id: String) -> Result<()> { - // Trigger download. - KordophoneRepository::download_attachment(&self.proxy(), &attachment_id, false)?; - - // Get attachment info. - let attachment_info = - KordophoneRepository::get_attachment_info(&self.proxy(), &attachment_id)?; - let (path, preview_path, downloaded, preview_downloaded) = attachment_info; - - if downloaded { - println!("Attachment already downloaded: {}", path); - return Ok(()); - } - - println!("Downloading attachment: {}", attachment_id); - - // Attach to the signal that the attachment has been downloaded. - let _id = self.proxy().match_signal( - move |h: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadCompleted, - _: &Connection, - _: &dbus::message::Message| { - println!("Signal: Attachment downloaded: {}", path); - std::process::exit(0); - }, - ); - - let _id = self.proxy().match_signal( - |h: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadFailed, - _: &Connection, - _: &dbus::message::Message| { - println!("Signal: Attachment download failed: {}", h.attachment_id); - std::process::exit(1); - }, - ); - - // Wait for the signal. - loop { - self.conn.process(std::time::Duration::from_millis(1000))?; - } - - Ok(()) - } - - pub async fn upload_attachment(&mut self, path: String) -> Result<()> { - let upload_guid = KordophoneRepository::upload_attachment(&self.proxy(), &path)?; - println!("Upload GUID: {}", upload_guid); - Ok(()) - } - - pub async fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()> { - KordophoneRepository::mark_conversation_as_read(&self.proxy(), &conversation_id) - .map_err(|e| anyhow::anyhow!("Failed to mark conversation as read: {}", e)) - } -}