Private
Public Access
1
0

Add 'core/' from commit 'b0dfc4146ca0da535a87f8509aec68817fb2ab14'

git-subtree-dir: core
git-subtree-mainline: a07f3dcd23
git-subtree-split: b0dfc4146c
This commit is contained in:
2025-09-06 19:33:33 -07:00
83 changed files with 12352 additions and 0 deletions

View File

@@ -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<Self> {
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<String>) -> 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<String>,
) -> 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))
}
}

View File

@@ -0,0 +1,224 @@
use anyhow::Result;
use async_trait::async_trait;
use clap::Subcommand;
// Platform-specific modules
#[cfg(target_os = "linux")]
mod dbus;
#[cfg(target_os = "macos")]
mod xpc;
#[cfg_attr(target_os = "macos", async_trait(?Send))]
#[cfg_attr(not(target_os = "macos"), 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<String>) -> Result<()>;
async fn sync_conversations_list(&mut self) -> Result<()>;
async fn print_messages(
&mut self,
conversation_id: String,
last_message_id: Option<String>,
) -> 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<()>;
}
struct StubDaemonInterface;
impl StubDaemonInterface {
fn new() -> Result<Self> {
Ok(Self)
}
}
#[cfg_attr(target_os = "macos", async_trait(?Send))]
#[cfg_attr(not(target_os = "macos"), 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<String>) -> 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<String>,
) -> 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<Box<dyn DaemonInterface>> {
#[cfg(target_os = "linux")]
{
Ok(Box::new(dbus::DBusDaemonInterface::new()?))
}
#[cfg(target_os = "macos")]
{
Ok(Box::new(xpc::XpcDaemonInterface::new()?))
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
Ok(Box::new(StubDaemonInterface::new()?))
}
}
#[derive(Subcommand)]
pub enum Commands {
/// Gets all known conversations.
Conversations,
/// Runs a full sync operation for a conversation and its messages.
Sync { conversation_id: Option<String> },
/// Runs a sync operation for the conversation list.
SyncList,
/// Prints the server Kordophone version.
Version,
/// Configuration options
Config {
#[command(subcommand)]
command: ConfigCommands,
},
/// Waits for signals from the daemon.
Signals,
/// Prints the messages for a conversation.
Messages {
conversation_id: String,
last_message_id: Option<String>,
},
/// Deletes all conversations.
DeleteAllConversations,
/// Enqueues an outgoing message to be sent to a conversation.
SendMessage {
conversation_id: String,
text: String,
},
/// Downloads an attachment from the server to the attachment store. Returns the path to the attachment.
DownloadAttachment { attachment_id: String },
/// Uploads an attachment to the server, returns upload guid.
UploadAttachment { path: String },
/// Marks a conversation as read.
MarkConversationAsRead { conversation_id: String },
}
#[derive(Subcommand)]
pub enum ConfigCommands {
/// Prints the current settings.
Print,
/// Sets the server URL.
SetServerUrl { url: String },
/// Sets the username.
SetUsername { username: String },
}
impl Commands {
pub async fn run(cmd: Commands) -> Result<()> {
let mut client = new_daemon_interface()?;
match cmd {
Commands::Version => client.print_version().await,
Commands::Conversations => client.print_conversations().await,
Commands::Sync { conversation_id } => client.sync_conversations(conversation_id).await,
Commands::SyncList => client.sync_conversations_list().await,
Commands::Config { command } => client.config(command).await,
Commands::Signals => client.wait_for_signals().await,
Commands::Messages {
conversation_id,
last_message_id,
} => {
client
.print_messages(conversation_id, last_message_id)
.await
}
Commands::DeleteAllConversations => client.delete_all_conversations().await,
Commands::SendMessage {
conversation_id,
text,
} => client.enqueue_outgoing_message(conversation_id, text).await,
Commands::UploadAttachment { path } => client.upload_attachment(path).await,
Commands::DownloadAttachment { attachment_id } => {
client.download_attachment(attachment_id).await
}
Commands::MarkConversationAsRead { conversation_id } => {
client.mark_conversation_as_read(conversation_id).await
}
}
}
}

View File

@@ -0,0 +1,604 @@
use super::{ConfigCommands, DaemonInterface};
use anyhow::Result;
use async_trait::async_trait;
use futures_util::StreamExt;
use std::collections::HashMap;
use std::ffi::{CStr, CString};
use std::ops::Deref;
use std::{pin::Pin, task::Poll};
use xpc_connection::Message;
use futures::{
channel::mpsc::{unbounded as unbounded_channel, UnboundedReceiver, UnboundedSender},
Stream,
};
const SERVICE_NAME: &str = "net.buzzert.kordophonecd\0";
const GET_VERSION_METHOD: &str = "GetVersion";
const GET_CONVERSATIONS_METHOD: &str = "GetConversations";
// We can't use XPCClient from xpc-connection because of some strange decisions with which flags
// are passed to xpc_connection_create_mach_service.
struct XPCClient {
connection: xpc_connection_sys::xpc_connection_t,
receiver: UnboundedReceiver<Message>,
sender: UnboundedSender<Message>,
event_handler_is_running: bool,
}
impl XPCClient {
pub fn connect(name: impl AsRef<CStr>) -> Self {
use block::ConcreteBlock;
use xpc_connection::xpc_object_to_message;
use xpc_connection_sys::xpc_connection_resume;
use xpc_connection_sys::xpc_connection_set_event_handler;
let name = name.as_ref();
let connection = unsafe {
xpc_connection_sys::xpc_connection_create_mach_service(
name.as_ptr(),
std::ptr::null_mut(),
0,
)
};
let (sender, receiver) = unbounded_channel();
let sender_clone = sender.clone();
let block = ConcreteBlock::new(move |event| {
let message = xpc_object_to_message(event);
sender_clone.unbounded_send(message).ok()
});
let block = block.copy();
unsafe {
xpc_connection_set_event_handler(connection, block.deref() as *const _ as *mut _);
xpc_connection_resume(connection);
}
Self {
connection,
receiver,
sender,
event_handler_is_running: true,
}
}
pub fn send_message(&self, message: Message) {
use xpc_connection::message_to_xpc_object;
use xpc_connection_sys::xpc_connection_send_message;
use xpc_connection_sys::xpc_release;
let xpc_object = message_to_xpc_object(message);
unsafe {
xpc_connection_send_message(self.connection, xpc_object);
xpc_release(xpc_object);
}
}
pub fn send_message_with_reply(&self, message: Message) -> Message {
use xpc_connection::message_to_xpc_object;
use xpc_connection::xpc_object_to_message;
use xpc_connection_sys::{xpc_connection_send_message_with_reply_sync, xpc_release};
unsafe {
let xobj = message_to_xpc_object(message);
let reply = xpc_connection_send_message_with_reply_sync(self.connection, xobj);
xpc_release(xobj);
let msg = xpc_object_to_message(reply);
if !reply.is_null() {
xpc_release(reply);
}
msg
}
}
}
impl Drop for XPCClient {
fn drop(&mut self) {
use xpc_connection_sys::xpc_object_t;
use xpc_connection_sys::xpc_release;
unsafe { xpc_release(self.connection as xpc_object_t) };
}
}
impl Stream for XPCClient {
type Item = Message;
fn poll_next(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> Poll<Option<Self::Item>> {
match Stream::poll_next(Pin::new(&mut self.receiver), cx) {
Poll::Ready(Some(Message::Error(xpc_connection::MessageError::ConnectionInvalid))) => {
self.event_handler_is_running = false;
Poll::Ready(None)
}
v => v,
}
}
}
unsafe impl Send for XPCClient {}
/// XPC-based implementation of DaemonInterface that sends method calls to the daemon over libxpc.
pub struct XpcDaemonInterface;
impl XpcDaemonInterface {
/// Create a new XpcDaemonInterface. No state is held.
pub fn new() -> Result<Self> {
Ok(Self)
}
fn build_service_name() -> Result<CString> {
let service_name = SERVICE_NAME.trim_end_matches('\0');
Ok(CString::new(service_name)?)
}
fn build_request(
method: &str,
args: Option<HashMap<CString, Message>>,
) -> HashMap<CString, Message> {
let mut request = HashMap::new();
request.insert(
CString::new("method").unwrap(),
Message::String(CString::new(method).unwrap()),
);
if let Some(arguments) = args {
request.insert(
CString::new("arguments").unwrap(),
Message::Dictionary(arguments),
);
}
request
}
async fn call_method(
&self,
client: &mut XPCClient,
method: &str,
args: Option<HashMap<CString, Message>>,
) -> anyhow::Result<HashMap<CString, Message>> {
let request = Self::build_request(method, args);
let reply = client.send_message_with_reply(Message::Dictionary(request));
match reply {
Message::Dictionary(map) => Ok(map),
other => Err(anyhow::anyhow!("Unexpected XPC reply: {:?}", other)),
}
}
fn key(k: &str) -> CString {
CString::new(k).unwrap()
}
fn get_string<'a>(map: &'a HashMap<CString, Message>, key: &str) -> Option<&'a CStr> {
map.get(&Self::key(key)).and_then(|v| match v {
Message::String(s) => Some(s.as_c_str()),
_ => None,
})
}
fn get_i64_from_str(map: &HashMap<CString, Message>, key: &str) -> Option<i64> {
Self::get_string(map, key).and_then(|s| s.to_string_lossy().parse().ok())
}
}
#[async_trait(?Send)]
impl DaemonInterface for XpcDaemonInterface {
async fn print_version(&mut self) -> Result<()> {
// Build service name and connect
let mach_port_name = Self::build_service_name()?;
let mut client = XPCClient::connect(&mach_port_name);
// Call generic method and parse reply
let map = self
.call_method(&mut client, GET_VERSION_METHOD, None)
.await?;
if let Some(ver) = Self::get_string(&map, "version") {
println!("Server version: {}", ver.to_string_lossy());
Ok(())
} else if let Some(ty) = Self::get_string(&map, "type") {
println!("XPC replied with type: {}", ty.to_string_lossy());
Ok(())
} else {
Err(anyhow::anyhow!(
"Unexpected XPC reply payload for GetVersion"
))
}
}
// Remaining methods unimplemented on macOS
async fn print_conversations(&mut self) -> Result<()> {
// Connect
let mach_port_name = Self::build_service_name()?;
let mut client = XPCClient::connect(&mach_port_name);
// Build arguments: limit=100, offset=0 (string-encoded for portability)
let mut args = HashMap::new();
args.insert(
CString::new("limit").unwrap(),
Message::String(CString::new("100").unwrap()),
);
args.insert(
CString::new("offset").unwrap(),
Message::String(CString::new("0").unwrap()),
);
// Call
let reply = self
.call_method(&mut client, GET_CONVERSATIONS_METHOD, Some(args))
.await?;
// Expect an array under "conversations"
match reply.get(&Self::key("conversations")) {
Some(Message::Array(items)) => {
println!("Number of conversations: {}", items.len());
for item in items {
if let Message::Dictionary(map) = item {
// Convert to PrintableConversation
let guid = Self::get_string(map, "guid")
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let display_name = Self::get_string(map, "display_name")
.map(|s| s.to_string_lossy().into_owned());
let last_preview = Self::get_string(map, "last_message_preview")
.map(|s| s.to_string_lossy().into_owned());
let unread_count =
Self::get_i64_from_str(map, "unread_count").unwrap_or(0) as i32;
let date_ts: i64 = Self::get_i64_from_str(map, "date").unwrap_or(0);
let participants: Vec<String> = match map.get(&Self::key("participants")) {
Some(Message::Array(arr)) => arr
.iter()
.filter_map(|m| match m {
Message::String(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
})
.collect(),
_ => Vec::new(),
};
// Build PrintableConversation directly
let conv = crate::printers::PrintableConversation {
guid,
display_name,
last_message_preview: last_preview,
unread_count,
date: time::OffsetDateTime::from_unix_timestamp(date_ts)
.unwrap_or_else(|_| time::OffsetDateTime::UNIX_EPOCH),
participants,
};
println!("{}", crate::printers::ConversationPrinter::new(&conv));
}
}
Ok(())
}
Some(other) => Err(anyhow::anyhow!(
"Unexpected conversations payload: {:?}",
other
)),
None => Err(anyhow::anyhow!("Missing conversations in reply")),
}
}
async fn sync_conversations(&mut self, _conversation_id: Option<String>) -> Result<()> {
let mach_port_name = Self::build_service_name()?;
let mut client = XPCClient::connect(&mach_port_name);
if let Some(id) = _conversation_id {
let mut args = HashMap::new();
args.insert(
Self::key("conversation_id"),
Message::String(CString::new(id).unwrap()),
);
let _ = self
.call_method(&mut client, "SyncConversation", Some(args))
.await?;
return Ok(());
}
let _ = self
.call_method(&mut client, "SyncAllConversations", None)
.await?;
Ok(())
}
async fn sync_conversations_list(&mut self) -> Result<()> {
let mach_port_name = Self::build_service_name()?;
let mut client = XPCClient::connect(&mach_port_name);
let _ = self
.call_method(&mut client, "SyncConversationList", None)
.await?;
Ok(())
}
async fn print_messages(
&mut self,
_conversation_id: String,
_last_message_id: Option<String>,
) -> Result<()> {
let mach_port_name = Self::build_service_name()?;
let mut client = XPCClient::connect(&mach_port_name);
let mut args = HashMap::new();
args.insert(
Self::key("conversation_id"),
Message::String(CString::new(_conversation_id).unwrap()),
);
if let Some(last) = _last_message_id {
args.insert(
Self::key("last_message_id"),
Message::String(CString::new(last).unwrap()),
);
}
let reply = self
.call_method(&mut client, "GetMessages", Some(args))
.await?;
match reply.get(&Self::key("messages")) {
Some(Message::Array(items)) => {
println!("Number of messages: {}", items.len());
for item in items {
if let Message::Dictionary(map) = item {
let guid = Self::get_string(map, "id")
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let sender = Self::get_string(map, "sender")
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let text = Self::get_string(map, "text")
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let date_ts = Self::get_i64_from_str(map, "date").unwrap_or(0);
let msg = crate::printers::PrintableMessage {
guid,
date: time::OffsetDateTime::from_unix_timestamp(date_ts)
.unwrap_or_else(|_| time::OffsetDateTime::UNIX_EPOCH),
sender,
text,
file_transfer_guids: vec![],
attachment_metadata: None,
};
println!("{}", crate::printers::MessagePrinter::new(&msg));
}
}
Ok(())
}
_ => Err(anyhow::anyhow!("Unexpected messages payload")),
}
}
async fn enqueue_outgoing_message(
&mut self,
_conversation_id: String,
_text: String,
) -> Result<()> {
let mach_port_name = Self::build_service_name()?;
let mut client = XPCClient::connect(&mach_port_name);
let mut args = HashMap::new();
args.insert(
Self::key("conversation_id"),
Message::String(CString::new(_conversation_id).unwrap()),
);
args.insert(
Self::key("text"),
Message::String(CString::new(_text).unwrap()),
);
let reply = self
.call_method(&mut client, "SendMessage", Some(args))
.await?;
if let Some(uuid) = Self::get_string(&reply, "uuid") {
println!("Outgoing message ID: {}", uuid.to_string_lossy());
}
Ok(())
}
async fn wait_for_signals(&mut self) -> Result<()> {
let mach_port_name = Self::build_service_name()?;
let mut client = XPCClient::connect(&mach_port_name);
// Subscribe to begin receiving signals on this connection
eprintln!("[kpcli] Sending SubscribeSignals");
client.send_message(Message::Dictionary(Self::build_request(
"SubscribeSignals",
None,
)));
println!("Waiting for XPC signals...");
while let Some(msg) = client.next().await {
match msg {
Message::Dictionary(map) => {
eprintln!("[kpcli] Received signal dictionary");
let name_key = Self::key("name");
let args_key = Self::key("arguments");
let name = match map.get(&name_key) {
Some(Message::String(s)) => s.to_string_lossy().into_owned(),
_ => continue,
};
match name.as_str() {
"ConversationsUpdated" => {
println!("Signal: Conversations updated");
}
"MessagesUpdated" => {
if let Some(Message::Dictionary(args)) = map.get(&args_key) {
if let Some(Message::String(cid)) =
args.get(&Self::key("conversation_id"))
{
println!(
"Signal: Messages updated for conversation {}",
cid.to_string_lossy()
);
}
}
}
"UpdateStreamReconnected" => {
println!("Signal: Update stream reconnected");
}
"AttachmentDownloadCompleted" => {
if let Some(Message::Dictionary(args)) = map.get(&args_key) {
if let Some(Message::String(aid)) =
args.get(&Self::key("attachment_id"))
{
println!(
"Signal: Attachment downloaded: {}",
aid.to_string_lossy()
);
}
}
}
"AttachmentDownloadFailed" => {
if let Some(Message::Dictionary(args)) = map.get(&args_key) {
if let Some(Message::String(aid)) =
args.get(&Self::key("attachment_id"))
{
eprintln!(
"Signal: Attachment download failed: {}",
aid.to_string_lossy()
);
}
}
}
"AttachmentUploadCompleted" => {
if let Some(Message::Dictionary(args)) = map.get(&args_key) {
let upload = args
.get(&Self::key("upload_guid"))
.and_then(|v| match v {
Message::String(s) => {
Some(s.to_string_lossy().into_owned())
}
_ => None,
})
.unwrap_or_default();
let attachment = args
.get(&Self::key("attachment_guid"))
.and_then(|v| match v {
Message::String(s) => {
Some(s.to_string_lossy().into_owned())
}
_ => None,
})
.unwrap_or_default();
println!(
"Signal: Attachment uploaded: upload={}, attachment={}",
upload, attachment
);
}
}
"ConfigChanged" => {
println!("Signal: Config changed");
}
_ => {}
}
}
Message::Error(xpc_connection::MessageError::ConnectionInvalid) => {
eprintln!("[kpcli] XPC connection invalid");
break;
}
other => {
eprintln!("[kpcli] Unexpected XPC message: {:?}", other);
}
}
}
Ok(())
}
async fn config(&mut self, _cmd: ConfigCommands) -> Result<()> {
let mach_port_name = Self::build_service_name()?;
let mut client = XPCClient::connect(&mach_port_name);
match _cmd {
ConfigCommands::Print => {
let reply = self
.call_method(&mut client, "GetAllSettings", None)
.await?;
let server_url = Self::get_string(&reply, "server_url")
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let username = Self::get_string(&reply, "username")
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let table =
prettytable::table!([b->"Server URL", &server_url], [b->"Username", &username]);
table.printstd();
Ok(())
}
ConfigCommands::SetServerUrl { url } => {
let mut args = HashMap::new();
args.insert(
Self::key("server_url"),
Message::String(CString::new(url).unwrap()),
);
let _ = self
.call_method(&mut client, "UpdateSettings", Some(args))
.await?;
Ok(())
}
ConfigCommands::SetUsername { username } => {
let mut args = HashMap::new();
args.insert(
Self::key("username"),
Message::String(CString::new(username).unwrap()),
);
let _ = self
.call_method(&mut client, "UpdateSettings", Some(args))
.await?;
Ok(())
}
}
}
async fn delete_all_conversations(&mut self) -> Result<()> {
let mach_port_name = Self::build_service_name()?;
let mut client = XPCClient::connect(&mach_port_name);
let _ = self
.call_method(&mut client, "DeleteAllConversations", None)
.await?;
Ok(())
}
async fn download_attachment(&mut self, _attachment_id: String) -> Result<()> {
let mach_port_name = Self::build_service_name()?;
let mut client = XPCClient::connect(&mach_port_name);
let mut args = HashMap::new();
args.insert(
Self::key("attachment_id"),
Message::String(CString::new(_attachment_id).unwrap()),
);
args.insert(
Self::key("preview"),
Message::String(CString::new("false").unwrap()),
);
let _ = self
.call_method(&mut client, "DownloadAttachment", Some(args))
.await?;
Ok(())
}
async fn upload_attachment(&mut self, _path: String) -> Result<()> {
let mach_port_name = Self::build_service_name()?;
let mut client = XPCClient::connect(&mach_port_name);
let mut args = HashMap::new();
args.insert(
Self::key("path"),
Message::String(CString::new(_path).unwrap()),
);
let reply = self
.call_method(&mut client, "UploadAttachment", Some(args))
.await?;
if let Some(guid) = Self::get_string(&reply, "upload_guid") {
println!("Upload GUID: {}", guid.to_string_lossy());
}
Ok(())
}
async fn mark_conversation_as_read(&mut self, _conversation_id: String) -> Result<()> {
let mach_port_name = Self::build_service_name()?;
let mut client = XPCClient::connect(&mach_port_name);
let mut args = HashMap::new();
args.insert(
Self::key("conversation_id"),
Message::String(CString::new(_conversation_id).unwrap()),
);
let _ = self
.call_method(&mut client, "MarkConversationAsRead", Some(args))
.await?;
Ok(())
}
}