From 911454aafbfc1f633ace160afaa766294a3cbc3a Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 1 Aug 2025 12:26:17 -0700 Subject: [PATCH 01/23] first pass at xpc impl --- Cargo.lock | 313 +++++++++++++++++- kordophone-db/src/models/conversation.rs | 17 +- kordophone-db/src/models/db/message.rs | 5 +- kordophone-db/src/models/db/participant.rs | 44 +-- kordophone-db/src/models/message.rs | 15 +- kordophone-db/src/models/mod.rs | 2 +- kordophone-db/src/repository.rs | 6 +- kordophone/src/api/http_client.rs | 8 +- kordophone/src/api/mod.rs | 5 +- kordophone/src/tests/test_client.rs | 5 +- kordophoned/Cargo.toml | 11 +- kordophoned/build.rs | 4 +- .../src/daemon/contact_resolver/eds.rs | 32 +- .../src/daemon/contact_resolver/generic.rs | 2 +- .../src/daemon/contact_resolver/mod.rs | 16 +- kordophoned/src/daemon/mod.rs | 18 +- kordophoned/src/daemon/models/message.rs | 7 +- kordophoned/src/daemon/update_monitor.rs | 5 +- kordophoned/src/dbus/agent.rs | 107 +++--- kordophoned/src/dbus/mod.rs | 2 +- kordophoned/src/main.rs | 14 +- kordophoned/src/xpc/agent.rs | 38 +++ kordophoned/src/xpc/endpoint.rs | 27 ++ kordophoned/src/xpc/interface.rs | 8 + kordophoned/src/xpc/mod.rs | 6 + kpcli/Cargo.toml | 5 + kpcli/build.rs | 7 +- kpcli/src/daemon/mod.rs | 73 +++- kpcli/src/daemon/xpc.rs | 100 ++++++ 29 files changed, 761 insertions(+), 141 deletions(-) create mode 100644 kordophoned/src/xpc/agent.rs create mode 100644 kordophoned/src/xpc/endpoint.rs create mode 100644 kordophoned/src/xpc/interface.rs create mode 100644 kordophoned/src/xpc/mod.rs create mode 100644 kpcli/src/daemon/xpc.rs diff --git a/Cargo.lock b/Cargo.lock index fcf5c89..07b9d26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -169,6 +169,29 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.58.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f8523b410d7187a43085e7e064416ea32ded16bd0a4e6fc025e21616d01258f" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "clap 2.34.0", + "env_logger 0.8.4", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "which", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -181,6 +204,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -208,6 +237,15 @@ version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d32a725bc159af97c3e629873bb9f88fb8cf8a4867175f76dc987815ea07c83b" +[[package]] +name = "cexpr" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4aedb84272dbe89af497cf81375129abda4fc0a9e7c5d317498c15cc30c0d27" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -229,6 +267,17 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "2.34.0" @@ -612,6 +661,19 @@ dependencies = [ "regex", ] +[[package]] +name = "env_logger" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + [[package]] name = "env_logger" version = "0.11.8" @@ -668,6 +730,31 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-async-runtime-preview" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33c03035be1dae627b7e05c6984acb1f2086043fde5249ae51604f1ff20ed037" +dependencies = [ + "futures-core-preview", + "futures-stable-preview", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -675,6 +762,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-channel-preview" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f8aec6b0eb1d281843ec666fba2b71a49610181e3078fbef7a8cbed481821e" +dependencies = [ + "futures-core-preview", ] [[package]] @@ -683,6 +780,55 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-core-preview" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "098785413db44e5dbf3b1fc23c24039a9091bea5acb3eb0d293f386f18aff97d" +dependencies = [ + "either", +] + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-executor-preview" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28ff61425699ca85de5c63c1f135278403518c3398bd15cf4b6fd1d21c9846e4" +dependencies = [ + "futures-channel-preview", + "futures-core-preview", + "futures-util-preview", + "lazy_static", + "num_cpus", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-io-preview" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaa769a6ac904912c1557b4dcf85b93db2bc9ba57c349f9ce43870e49d67f8e1" +dependencies = [ + "futures-core-preview", + "iovec", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -694,12 +840,49 @@ dependencies = [ "syn", ] +[[package]] +name = "futures-preview" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4d575096a4e2cf458f309b5b7bce5c8aaad8e874b8d77f0aa26c08d7ac18f74" +dependencies = [ + "futures-async-runtime-preview", + "futures-channel-preview", + "futures-core-preview", + "futures-executor-preview", + "futures-io-preview", + "futures-sink-preview", + "futures-stable-preview", + "futures-util-preview", +] + [[package]] name = "futures-sink" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +[[package]] +name = "futures-sink-preview" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dc4cdc628b934f18a11ba070d589655f68cfec031a16381b0e7784ff0e9cc18" +dependencies = [ + "either", + "futures-channel-preview", + "futures-core-preview", +] + +[[package]] +name = "futures-stable-preview" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6ba960b8bbbc14a9a741cc8ad9c26aff44538ea14be021db905b43f33854da" +dependencies = [ + "futures-core-preview", + "futures-executor-preview", +] + [[package]] name = "futures-task" version = "0.3.31" @@ -712,15 +895,31 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", ] +[[package]] +name = "futures-util-preview" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b29aa737dba9e2e47a5dcd4d58ec7c7c2d5f78e8460f609f857bcf04163235e" +dependencies = [ + "either", + "futures-channel-preview", + "futures-core-preview", + "futures-io-preview", + "futures-sink-preview", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -760,6 +959,12 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + [[package]] name = "h2" version = "0.3.26" @@ -857,6 +1062,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" + [[package]] name = "hyper" version = "0.14.28" @@ -933,6 +1144,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "iovec" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b3ea6ff95e175473f8ffe6a7eb7c00d054240321b84c57051175fe3c1e075e" +dependencies = [ + "libc", +] + [[package]] name = "is-terminal" version = "0.4.16" @@ -1008,7 +1228,7 @@ dependencies = [ "bytes", "chrono", "ctor", - "env_logger", + "env_logger 0.11.8", "futures-util", "hyper", "hyper-tls", @@ -1057,18 +1277,22 @@ dependencies = [ "dbus-tokio", "dbus-tree", "directories", - "env_logger", + "env_logger 0.11.8", + "futures-preview", "futures-util", "keyring", "kordophone", "kordophone-db", "log", "once_cell", + "serde", "serde_json", "thiserror 2.0.12", "tokio", "tokio-condvar", "uuid", + "xpc-connection", + "xpc-connection-sys", ] [[package]] @@ -1082,7 +1306,8 @@ dependencies = [ "dbus-codegen", "dbus-tree", "dotenv", - "env_logger", + "env_logger 0.11.8", + "futures-preview", "futures-util", "kordophone", "kordophone-db", @@ -1092,6 +1317,7 @@ dependencies = [ "serde_json", "time", "tokio", + "xpc-connection", ] [[package]] @@ -1100,6 +1326,12 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "libc" version = "0.2.172" @@ -1115,6 +1347,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libloading" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" +dependencies = [ + "cfg-if", + "windows-targets 0.48.5", +] + [[package]] name = "libredox" version = "0.1.3" @@ -1223,6 +1465,16 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nom" +version = "5.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08959a387a676302eebf4ddbcbc611da04285579f76f88ee0506c63b1a61dd4b" +dependencies = [ + "memchr", + "version_check", +] + [[package]] name = "num" version = "0.4.3" @@ -1302,6 +1554,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi 0.5.0", + "libc", +] + [[package]] name = "object" version = "0.32.2" @@ -1390,6 +1652,12 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "pin-project-lite" version = "0.2.14" @@ -1610,6 +1878,12 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustix" version = "0.38.34" @@ -1733,6 +2007,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -2219,6 +2499,15 @@ version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" +[[package]] +name = "which" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d011071ae14a2f6671d0b74080ae0cd8ebf3a6f8c9589a2cd45f23126fe29724" +dependencies = [ + "libc", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2421,3 +2710,21 @@ name = "xml-rs" version = "0.8.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5b940ebc25896e71dd073bad2dbaa2abfe97b0a391415e22ad1326d9c54e3c4" + +[[package]] +name = "xpc-connection" +version = "0.2.3" +source = "git+https://github.com/dfrankland/xpc-connection-rs.git?rev=cd4fb3d#cd4fb3d05edb4292ccb9566ae27cdeb874222d2a" +dependencies = [ + "block", + "futures", + "xpc-connection-sys", +] + +[[package]] +name = "xpc-connection-sys" +version = "0.1.1" +source = "git+https://github.com/dfrankland/xpc-connection-rs.git?rev=cd4fb3d#cd4fb3d05edb4292ccb9566ae27cdeb874222d2a" +dependencies = [ + "bindgen", +] diff --git a/kordophone-db/src/models/conversation.rs b/kordophone-db/src/models/conversation.rs index 4b06f75..bde1597 100644 --- a/kordophone-db/src/models/conversation.rs +++ b/kordophone-db/src/models/conversation.rs @@ -50,12 +50,12 @@ impl Conversation { impl PartialEq for Conversation { fn eq(&self, other: &Self) -> bool { - self.guid == other.guid && - self.unread_count == other.unread_count && - self.display_name == other.display_name && - self.last_message_preview == other.last_message_preview && - self.date == other.date && - self.participants == other.participants + self.guid == other.guid + && self.unread_count == other.unread_count + && self.display_name == other.display_name + && self.last_message_preview == other.last_message_preview + && self.date == other.date + && self.participants == other.participants } } @@ -75,7 +75,10 @@ impl From for Conversation { participants: value .participant_display_names .into_iter() - .map(|p| Participant::Remote { handle: p, contact_id: None }) // todo: this is wrong + .map(|p| Participant::Remote { + handle: p, + contact_id: None, + }) // todo: this is wrong .collect(), } } diff --git a/kordophone-db/src/models/db/message.rs b/kordophone-db/src/models/db/message.rs index 7821183..657a926 100644 --- a/kordophone-db/src/models/db/message.rs +++ b/kordophone-db/src/models/db/message.rs @@ -52,7 +52,10 @@ impl From for Message { .and_then(|json| serde_json::from_str(&json).ok()); let message_sender = match record.sender_participant_handle { - Some(handle) => Participant::Remote { handle, contact_id: None }, + Some(handle) => Participant::Remote { + handle, + contact_id: None, + }, None => Participant::Me, }; Self { diff --git a/kordophone-db/src/models/db/participant.rs b/kordophone-db/src/models/db/participant.rs index 6b22917..087b71e 100644 --- a/kordophone-db/src/models/db/participant.rs +++ b/kordophone-db/src/models/db/participant.rs @@ -22,16 +22,18 @@ pub struct InsertableRecord { impl From for InsertableRecord { fn from(participant: Participant) -> Self { match participant { - Participant::Me => InsertableRecord { - handle: "me".to_string(), - is_me: true, - contact_id: None, - }, - Participant::Remote { handle, contact_id, .. } => InsertableRecord { - handle, - is_me: false, - contact_id, - }, + Participant::Me => InsertableRecord { + handle: "me".to_string(), + is_me: true, + contact_id: None, + }, + Participant::Remote { + handle, contact_id, .. + } => InsertableRecord { + handle, + is_me: false, + contact_id, + }, } } } @@ -62,16 +64,18 @@ impl From for Participant { impl From for Record { fn from(participant: Participant) -> Self { match participant { - Participant::Me => Record { - handle: "me".to_string(), - is_me: true, - contact_id: None, - }, - Participant::Remote { handle, contact_id, .. } => Record { - handle, - is_me: false, - contact_id, - }, + Participant::Me => Record { + handle: "me".to_string(), + is_me: true, + contact_id: None, + }, + Participant::Remote { + handle, contact_id, .. + } => Record { + handle, + is_me: false, + contact_id, + }, } } } diff --git a/kordophone-db/src/models/message.rs b/kordophone-db/src/models/message.rs index 4de27e2..af8b70e 100644 --- a/kordophone-db/src/models/message.rs +++ b/kordophone-db/src/models/message.rs @@ -28,9 +28,13 @@ impl From for Message { contact_id: None, // Weird server quirk: some sender handles are encoded with control characters. - handle: sender.chars() - .filter(|c| !c.is_control() && !matches!(c, - '\u{202A}' | // LRE + handle: sender + .chars() + .filter(|c| { + !c.is_control() + && !matches!( + c, + '\u{202A}' | // LRE '\u{202B}' | // RLE '\u{202C}' | // PDF '\u{202D}' | // LRO @@ -38,8 +42,9 @@ impl From for Message { '\u{2066}' | // LRI '\u{2067}' | // RLI '\u{2068}' | // FSI - '\u{2069}' // PDI - )) + '\u{2069}' // PDI + ) + }) .collect::(), }, diff --git a/kordophone-db/src/models/mod.rs b/kordophone-db/src/models/mod.rs index a8c7e94..13571fc 100644 --- a/kordophone-db/src/models/mod.rs +++ b/kordophone-db/src/models/mod.rs @@ -5,4 +5,4 @@ pub mod participant; pub use conversation::Conversation; pub use message::Message; -pub use participant::Participant; \ No newline at end of file +pub use participant::Participant; diff --git a/kordophone-db/src/repository.rs b/kordophone-db/src/repository.rs index 9c52a99..0f6783b 100644 --- a/kordophone-db/src/repository.rs +++ b/kordophone-db/src/repository.rs @@ -377,7 +377,11 @@ impl<'a> Repository<'a> { fn get_or_create_participant(&mut self, participant: &Participant) -> Option { match participant { Participant::Me => None, - Participant::Remote { handle: p_handle, contact_id: c_id, .. } => { + Participant::Remote { + handle: p_handle, + contact_id: c_id, + .. + } => { use crate::schema::participants::dsl::*; let existing_participant = participants diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index f940cc3..b487d53 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -271,10 +271,14 @@ impl APIInterface for HTTPAPIClient { Ok(token) } - async fn mark_conversation_as_read(&mut self, conversation_id: &ConversationID) -> Result<(), Self::Error> { + async fn mark_conversation_as_read( + &mut self, + conversation_id: &ConversationID, + ) -> Result<(), Self::Error> { // SERVER JANK: This should be POST, but it's GET for some reason. let endpoint = format!("markConversation?guid={}", conversation_id); - self.response_with_body_retry(&endpoint, Method::GET, Body::empty, true).await?; + self.response_with_body_retry(&endpoint, Method::GET, Body::empty, true) + .await?; Ok(()) } diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index 5da1ced..c5a9cd4 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -65,7 +65,10 @@ pub trait APIInterface { async fn authenticate(&mut self, credentials: Credentials) -> Result; // (GET) /markConversation - async fn mark_conversation_as_read(&mut self, conversation_id: &ConversationID) -> Result<(), Self::Error>; + async fn mark_conversation_as_read( + &mut self, + conversation_id: &ConversationID, + ) -> Result<(), Self::Error>; // (WS) /updates async fn open_event_socket( diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index c51260e..3801765 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -149,7 +149,10 @@ impl APIInterface for TestClient { Ok(String::from("test")) } - async fn mark_conversation_as_read(&mut self, conversation_id: &ConversationID) -> Result<(), Self::Error> { + async fn mark_conversation_as_read( + &mut self, + conversation_id: &ConversationID, + ) -> Result<(), Self::Error> { Ok(()) } } diff --git a/kordophoned/Cargo.toml b/kordophoned/Cargo.toml index a596c66..22b005f 100644 --- a/kordophoned/Cargo.toml +++ b/kordophoned/Cargo.toml @@ -21,14 +21,21 @@ tokio-condvar = "0.3.0" uuid = "1.16.0" once_cell = "1.19.0" -[target.'cfg(target_os = "linux")'.dependencies] # D-Bus dependencies only on Linux +[target.'cfg(target_os = "linux")'.dependencies] dbus = { version = "0.9.7", features = ["futures"] } dbus-crossroads = "0.5.2" dbus-tokio = "0.7.6" dbus-tree = "0.9.2" -[target.'cfg(target_os = "linux")'.build-dependencies] # D-Bus codegen only on Linux +[target.'cfg(target_os = "linux")'.build-dependencies] dbus-codegen = "0.10.0" dbus-crossroads = "0.5.1" + +# XPC (libxpc) interface for macOS IPC +[target.'cfg(target_os = "macos")'.dependencies] +futures-preview = "=0.2.2" +xpc-connection = { git = "https://github.com/dfrankland/xpc-connection-rs.git", rev = "cd4fb3d", package = "xpc-connection" } +xpc-connection-sys = { git = "https://github.com/dfrankland/xpc-connection-rs.git", rev = "cd4fb3d", package = "xpc-connection-sys" } +serde = { version = "1.0", features = ["derive"] } diff --git a/kordophoned/build.rs b/kordophoned/build.rs index 921915b..4db9d7e 100644 --- a/kordophoned/build.rs +++ b/kordophoned/build.rs @@ -20,8 +20,8 @@ fn main() { let xml = std::fs::read_to_string(KORDOPHONE_XML).expect("Error reading server dbus interface"); - let output = dbus_codegen::generate(&xml, &opts) - .expect("Error generating server dbus interface"); + let output = + dbus_codegen::generate(&xml, &opts).expect("Error generating server dbus interface"); std::fs::write(out_path, output).expect("Error writing server dbus code"); diff --git a/kordophoned/src/daemon/contact_resolver/eds.rs b/kordophoned/src/daemon/contact_resolver/eds.rs index 42e2baf..98f5bd5 100644 --- a/kordophoned/src/daemon/contact_resolver/eds.rs +++ b/kordophoned/src/daemon/contact_resolver/eds.rs @@ -1,10 +1,10 @@ use super::ContactResolverBackend; -use dbus::blocking::Connection; use dbus::arg::{RefArg, Variant}; +use dbus::blocking::Connection; use once_cell::sync::OnceCell; use std::collections::HashMap; -use std::time::Duration; use std::sync::Mutex; +use std::time::Duration; #[derive(Clone)] pub struct EDSContactResolverBackend; @@ -40,8 +40,13 @@ static ADDRESS_BOOK_HANDLE: OnceCell> = OnceCell::new() /// Check whether a given well-known name currently has an owner on the bus. fn name_has_owner(conn: &Connection, name: &str) -> bool { - let proxy = conn.with_proxy("org.freedesktop.DBus", "/org/freedesktop/DBus", Duration::from_secs(2)); - let result: Result<(bool,), _> = proxy.method_call("org.freedesktop.DBus", "NameHasOwner", (name.to_string(),)); + let proxy = conn.with_proxy( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + Duration::from_secs(2), + ); + let result: Result<(bool,), _> = + proxy.method_call("org.freedesktop.DBus", "NameHasOwner", (name.to_string(),)); result.map(|(b,)| b).unwrap_or(false) } @@ -99,10 +104,7 @@ fn ensure_address_book_uid(conn: &Connection) -> anyhow::Result { // The GetManagedObjects reply is the usual ObjectManager map. let (managed_objects,): ( - HashMap< - dbus::Path<'static>, - HashMap>>>, - >, + HashMap, HashMap>>>>, ) = source_manager_proxy.method_call( "org.freedesktop.DBus.ObjectManager", "GetManagedObjects", @@ -153,10 +155,7 @@ fn data_contains_address_book_backend(data: &str) -> bool { /// Open the Evolution address book referenced by `source_uid` and return the /// pair `(object_path, bus_name)` that identifies the newly created D-Bus /// proxy. -fn open_address_book( - conn: &Connection, - source_uid: &str, -) -> anyhow::Result<(String, String)> { +fn open_address_book(conn: &Connection, source_uid: &str) -> anyhow::Result<(String, String)> { let factory_proxy = conn.with_proxy( "org.gnome.evolution.dataserver.AddressBook10", "/org/gnome/evolution/dataserver/AddressBookFactory", @@ -177,11 +176,8 @@ fn open_address_book( /// calls the `Open` method once per process. We ignore any error here /// because the backend might already be open. fn ensure_address_book_open(proxy: &dbus::blocking::Proxy<&Connection>) { - let _: Result<(), _> = proxy.method_call( - "org.gnome.evolution.dataserver.AddressBook", - "Open", - (), - ); + let _: Result<(), _> = + proxy.method_call("org.gnome.evolution.dataserver.AddressBook", "Open", ()); } impl ContactResolverBackend for EDSContactResolverBackend { @@ -295,4 +291,4 @@ impl Default for EDSContactResolverBackend { fn default() -> Self { Self } -} \ No newline at end of file +} diff --git a/kordophoned/src/daemon/contact_resolver/generic.rs b/kordophoned/src/daemon/contact_resolver/generic.rs index d3a3604..7e9cd24 100644 --- a/kordophoned/src/daemon/contact_resolver/generic.rs +++ b/kordophoned/src/daemon/contact_resolver/generic.rs @@ -13,4 +13,4 @@ impl ContactResolverBackend for GenericContactResolverBackend { fn get_contact_display_name(&self, contact_id: &Self::ContactID) -> Option { None } -} \ No newline at end of file +} diff --git a/kordophoned/src/daemon/contact_resolver/mod.rs b/kordophoned/src/daemon/contact_resolver/mod.rs index dce3fe3..e391b80 100644 --- a/kordophoned/src/daemon/contact_resolver/mod.rs +++ b/kordophoned/src/daemon/contact_resolver/mod.rs @@ -15,7 +15,9 @@ pub struct EDSContactResolverBackend; #[cfg(not(target_os = "linux"))] impl Default for EDSContactResolverBackend { - fn default() -> Self { EDSContactResolverBackend } + fn default() -> Self { + EDSContactResolverBackend + } } #[cfg(not(target_os = "linux"))] @@ -56,7 +58,11 @@ where T: Default, { pub fn new(backend: T) -> Self { - Self { backend, display_name_cache: HashMap::new(), contact_id_cache: HashMap::new() } + Self { + backend, + display_name_cache: HashMap::new(), + contact_id_cache: HashMap::new(), + } } pub fn resolve_contact_id(&mut self, address: &str) -> Option { @@ -66,7 +72,8 @@ where let id = self.backend.resolve_contact_id(address).map(|id| id.into()); if let Some(ref id) = id { - self.contact_id_cache.insert(address.to_string(), id.clone()); + self.contact_id_cache + .insert(address.to_string(), id.clone()); } id @@ -80,7 +87,8 @@ where let backend_contact_id: T::ContactID = T::ContactID::from((*contact_id).clone()); let display_name = self.backend.get_contact_display_name(&backend_contact_id); if let Some(ref display_name) = display_name { - self.display_name_cache.insert(contact_id.to_string(), display_name.clone()); + self.display_name_cache + .insert(contact_id.to_string(), display_name.clone()); } display_name diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index e9466af..8481e13 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -522,12 +522,18 @@ impl Daemon { .await? { for p in &saved.participants { - if let DbParticipant::Remote { handle, contact_id: None } = p { + if let DbParticipant::Remote { + handle, + contact_id: None, + } = p + { log::trace!(target: target::SYNC, "Resolving contact id for participant: {}", handle); if let Some(contact) = contact_resolver.resolve_contact_id(handle) { log::trace!(target: target::SYNC, "Resolved contact id for participant: {}", contact); let _ = database - .with_repository(|r| r.update_participant_contact(&handle, &contact)) + .with_repository(|r| { + r.update_participant_contact(&handle, &contact) + }) .await; } else { log::trace!(target: target::SYNC, "No contact id found for participant: {}", handle); @@ -663,11 +669,11 @@ impl Daemon { signal_sender: &Sender, ) -> Result<()> { log::debug!(target: target::DAEMON, "Updating conversation metadata: {}", conversation.guid); - let updated = database.with_repository(|r| r.merge_conversation_metadata(conversation)).await?; + let updated = database + .with_repository(|r| r.merge_conversation_metadata(conversation)) + .await?; if updated { - signal_sender - .send(Signal::ConversationsUpdated) - .await?; + signal_sender.send(Signal::ConversationsUpdated).await?; } Ok(()) diff --git a/kordophoned/src/daemon/models/message.rs b/kordophoned/src/daemon/models/message.rs index ead4f2a..39bab29 100644 --- a/kordophoned/src/daemon/models/message.rs +++ b/kordophoned/src/daemon/models/message.rs @@ -177,7 +177,10 @@ impl From for DbParticipant { fn from(participant: Participant) -> Self { match participant { Participant::Me => DbParticipant::Me, - Participant::Remote { handle, contact_id } => DbParticipant::Remote { handle, contact_id: contact_id.clone() }, + Participant::Remote { handle, contact_id } => DbParticipant::Remote { + handle, + contact_id: contact_id.clone(), + }, } } -} \ No newline at end of file +} diff --git a/kordophoned/src/daemon/update_monitor.rs b/kordophoned/src/daemon/update_monitor.rs index c8be792..936ef5d 100644 --- a/kordophoned/src/daemon/update_monitor.rs +++ b/kordophoned/src/daemon/update_monitor.rs @@ -65,8 +65,9 @@ impl UpdateMonitor { UpdateEventData::ConversationChanged(conversation) => { log::info!(target: target::UPDATES, "Conversation changed: {:?}", conversation); - // Explicitly update the unread count, we assume this is fresh from the notification. - let db_conversation: kordophone_db::models::Conversation = conversation.clone().into(); + // Explicitly update the unread count, we assume this is fresh from the notification. + let db_conversation: kordophone_db::models::Conversation = + conversation.clone().into(); self.send_event(|r| Event::UpdateConversationMetadata(db_conversation, r)) .await .unwrap_or_else(|e| { diff --git a/kordophoned/src/dbus/agent.rs b/kordophoned/src/dbus/agent.rs index bf18163..6cce37a 100644 --- a/kordophoned/src/dbus/agent.rs +++ b/kordophoned/src/dbus/agent.rs @@ -1,15 +1,15 @@ use dbus::arg; use dbus_tree::MethodErr; -use std::{future::Future, thread}; use std::sync::Arc; +use std::{future::Future, thread}; use tokio::sync::{mpsc, oneshot, Mutex}; use crate::daemon::{ + contact_resolver::{ContactResolver, DefaultContactResolverBackend}, events::{Event, Reply}, settings::Settings, signals::Signal, DaemonResult, - contact_resolver::{ContactResolver, DefaultContactResolverBackend}, }; use kordophone_db::models::participant::Participant; @@ -37,7 +37,8 @@ impl DBusAgent { pub async fn run(self) { // Establish a session bus connection. - let (resource, connection) = connection::new_session_sync().expect("Failed to connect to session bus"); + let (resource, connection) = + connection::new_session_sync().expect("Failed to connect to session bus"); // Ensure the D-Bus resource is polled. tokio::spawn(async move { @@ -79,7 +80,10 @@ impl DBusAgent { Signal::ConversationsUpdated => { log::debug!("Sending signal: ConversationsUpdated"); registry - .send_signal(interface::OBJECT_PATH, DbusSignals::ConversationsUpdated {}) + .send_signal( + interface::OBJECT_PATH, + DbusSignals::ConversationsUpdated {}, + ) .unwrap_or_else(|_| { log::error!("Failed to send signal"); 0 @@ -118,7 +122,8 @@ impl DBusAgent { Signal::AttachmentUploaded(upload_guid, attachment_guid) => { log::debug!( "Sending signal: AttachmentUploaded for upload {}, attachment {}", - upload_guid, attachment_guid + upload_guid, + attachment_guid ); registry .send_signal( @@ -154,7 +159,10 @@ impl DBusAgent { std::future::pending::<()>().await; } - pub async fn send_event(&self, make_event: impl FnOnce(Reply) -> Event) -> DaemonResult { + pub async fn send_event( + &self, + make_event: impl FnOnce(Reply) -> Event, + ) -> DaemonResult { let (reply_tx, reply_rx) = oneshot::channel(); self.event_sink .send(make_event(reply_tx)) @@ -172,26 +180,29 @@ impl DBusAgent { .unwrap() .map_err(|e| MethodErr::failed(&format!("Daemon error: {}", e))) } - + fn resolve_participant_display_name(&mut self, participant: &Participant) -> String { match participant { // Me (we should use a special string here...) Participant::Me => "(Me)".to_string(), // Remote participant with a resolved contact_id - Participant::Remote { handle, contact_id: Some(contact_id), .. } => { - self.contact_resolver.get_contact_display_name(contact_id).unwrap_or_else(|| handle.clone()) - } + Participant::Remote { + handle, + contact_id: Some(contact_id), + .. + } => self + .contact_resolver + .get_contact_display_name(contact_id) + .unwrap_or_else(|| handle.clone()), // Remote participant without a resolved contact_id - Participant::Remote { handle, .. } => { - handle.clone() - } + Participant::Remote { handle, .. } => handle.clone(), } } } -// +// // D-Bus repository interface implementation // @@ -203,9 +214,12 @@ impl DbusRepository for DBusAgent { self.send_event_sync(Event::GetVersion) } - fn get_conversations(&mut self, limit: i32, offset: i32) -> Result, MethodErr> { - self - .send_event_sync(|r| Event::GetAllConversations(limit, offset, r)) + fn get_conversations( + &mut self, + limit: i32, + offset: i32, + ) -> Result, MethodErr> { + self.send_event_sync(|r| Event::GetAllConversations(limit, offset, r)) .map(|conversations| { conversations .into_iter() @@ -243,7 +257,6 @@ impl DbusRepository for DBusAgent { }) } - fn sync_conversation_list(&mut self) -> Result<(), MethodErr> { self.send_event_sync(Event::SyncConversationList) } @@ -260,15 +273,18 @@ impl DbusRepository for DBusAgent { self.send_event_sync(|r| Event::MarkConversationAsRead(conversation_id, r)) } - fn get_messages(&mut self, conversation_id: String, last_message_id: String) -> Result, MethodErr> { + fn get_messages( + &mut self, + conversation_id: String, + last_message_id: String, + ) -> Result, MethodErr> { let last_message_id_opt = if last_message_id.is_empty() { None } else { Some(last_message_id) }; - self - .send_event_sync(|r| Event::GetMessages(conversation_id, last_message_id_opt, r)) + self.send_event_sync(|r| Event::GetMessages(conversation_id, last_message_id_opt, r)) .map(|messages| { messages .into_iter() @@ -286,7 +302,9 @@ impl DbusRepository for DBusAgent { ); map.insert( "sender".into(), - arg::Variant(Box::new(self.resolve_participant_display_name(&msg.sender.into()))), + arg::Variant(Box::new( + self.resolve_participant_display_name(&msg.sender.into()), + )), ); // Attachments array @@ -312,7 +330,9 @@ impl DbusRepository for DBusAgent { ); attachment_map.insert( "preview_path".into(), - arg::Variant(Box::new(preview_path.to_string_lossy().to_string())), + arg::Variant(Box::new( + preview_path.to_string_lossy().to_string(), + )), ); attachment_map.insert( "downloaded".into(), @@ -374,27 +394,34 @@ impl DbusRepository for DBusAgent { text: String, attachment_guids: Vec, ) -> Result { - self - .send_event_sync(|r| Event::SendMessage(conversation_id, text, attachment_guids, r)) + self.send_event_sync(|r| Event::SendMessage(conversation_id, text, attachment_guids, r)) .map(|uuid| uuid.to_string()) } - fn get_attachment_info(&mut self, attachment_id: String) -> Result<(String, String, bool, bool), MethodErr> { - self.send_event_sync(|r| Event::GetAttachment(attachment_id, r)).map(|attachment| { - let path = attachment.get_path_for_preview(false); - let downloaded = attachment.is_downloaded(false); - let preview_path = attachment.get_path_for_preview(true); - let preview_downloaded = attachment.is_downloaded(true); - ( - path.to_string_lossy().to_string(), - preview_path.to_string_lossy().to_string(), - downloaded, - preview_downloaded, - ) - }) + fn get_attachment_info( + &mut self, + attachment_id: String, + ) -> Result<(String, String, bool, bool), MethodErr> { + self.send_event_sync(|r| Event::GetAttachment(attachment_id, r)) + .map(|attachment| { + let path = attachment.get_path_for_preview(false); + let downloaded = attachment.is_downloaded(false); + let preview_path = attachment.get_path_for_preview(true); + let preview_downloaded = attachment.is_downloaded(true); + ( + path.to_string_lossy().to_string(), + preview_path.to_string_lossy().to_string(), + downloaded, + preview_downloaded, + ) + }) } - fn download_attachment(&mut self, attachment_id: String, preview: bool) -> Result<(), MethodErr> { + fn download_attachment( + &mut self, + attachment_id: String, + preview: bool, + ) -> Result<(), MethodErr> { self.send_event_sync(|r| Event::DownloadAttachment(attachment_id, preview, r)) } @@ -482,4 +509,4 @@ where .join() }) .expect("Error joining runtime thread") -} \ No newline at end of file +} diff --git a/kordophoned/src/dbus/mod.rs b/kordophoned/src/dbus/mod.rs index 1bc9930..8552c4e 100644 --- a/kordophoned/src/dbus/mod.rs +++ b/kordophoned/src/dbus/mod.rs @@ -1,5 +1,5 @@ -pub mod endpoint; pub mod agent; +pub mod endpoint; pub mod interface { #![allow(unused)] diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index db30aff..d56c824 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -3,6 +3,9 @@ mod daemon; #[cfg(target_os = "linux")] mod dbus; +#[cfg(target_os = "macos")] +mod xpc; + use log::LevelFilter; use std::future; @@ -33,7 +36,16 @@ async fn start_ipc_agent(daemon: &mut Daemon) { #[cfg(target_os = "macos")] async fn start_ipc_agent(daemon: &mut Daemon) { - // TODO: Implement macOS IPC agent. + // Start the macOS XPC agent (events in, signals out) on a dedicated thread. + let agent = xpc::agent::XpcAgent::new(daemon.event_sender.clone(), daemon.obtain_signal_receiver()); + std::thread::spawn(move || { + // Use a single-threaded Tokio runtime for the XPC agent. + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Unable to create tokio runtime for XPC agent"); + rt.block_on(agent.run()); + }); } #[cfg(not(any(target_os = "linux", target_os = "macos")))] diff --git a/kordophoned/src/xpc/agent.rs b/kordophoned/src/xpc/agent.rs new file mode 100644 index 0000000..e49e405 --- /dev/null +++ b/kordophoned/src/xpc/agent.rs @@ -0,0 +1,38 @@ +use crate::daemon::{events::Event, signals::Signal, DaemonResult}; +use std::sync::Arc; +use tokio::sync::{mpsc, oneshot, Mutex}; + +/// XPC IPC agent that forwards daemon events and signals over libxpc. +#[derive(Clone)] +pub struct XpcAgent { + event_sink: mpsc::Sender, + signal_receiver: Arc>>>, +} + +impl XpcAgent { + /// Create a new XPC agent with an event sink and signal receiver. + pub fn new(event_sink: mpsc::Sender, signal_receiver: mpsc::Receiver) -> Self { + Self { + event_sink, + signal_receiver: Arc::new(Mutex::new(Some(signal_receiver))), + } + } + + /// Run the XPC agent: perform a basic GetVersion IPC call to the daemon and print the result. + pub async fn run(self) { + todo!() + } + + /// Send an event to the daemon and await its reply. + pub async fn send_event( + &self, + make_event: impl FnOnce(crate::daemon::events::Reply) -> Event, + ) -> DaemonResult { + let (tx, rx) = oneshot::channel(); + self.event_sink + .send(make_event(tx)) + .await + .map_err(|_| "Failed to send event")?; + rx.await.map_err(|_| "Failed to receive reply".into()) + } +} diff --git a/kordophoned/src/xpc/endpoint.rs b/kordophoned/src/xpc/endpoint.rs new file mode 100644 index 0000000..459e5c4 --- /dev/null +++ b/kordophoned/src/xpc/endpoint.rs @@ -0,0 +1,27 @@ +#![cfg(target_os = "macos")] +//! XPC registry for registering handlers and emitting signals. + +/// Registry for XPC message handlers and signal emission. +pub struct XpcRegistry; + +impl XpcRegistry { + /// Create a new XPC registry for the service. + pub fn new() -> Self { + XpcRegistry + } + + /// Register a handler for incoming messages at a given endpoint. + pub fn register_handler(&self, _name: &str, _handler: F) + where + F: Fn(&[u8]) -> Vec + Send + Sync + 'static, + { + // TODO: Implement handler registration over libxpc using SERVICE_NAME + let _ = (_name, _handler); + } + + /// Send a signal (notification) to connected clients. + pub fn send_signal(&self, _signal: &str, _data: &T) { + // TODO: Serialize and send signal over XPC + let _ = (_signal, _data); + } +} diff --git a/kordophoned/src/xpc/interface.rs b/kordophoned/src/xpc/interface.rs new file mode 100644 index 0000000..23fa648 --- /dev/null +++ b/kordophoned/src/xpc/interface.rs @@ -0,0 +1,8 @@ +#![cfg(target_os = "macos")] +//! XPC interface definitions for macOS IPC + +/// Mach service name for the XPC interface (must include trailing NUL). +pub const SERVICE_NAME: &str = "net.buzzert.kordophonecd\0"; + +/// Method names for the XPC interface (must include trailing NUL). +pub const GET_VERSION_METHOD: &str = "GetVersion\0"; diff --git a/kordophoned/src/xpc/mod.rs b/kordophoned/src/xpc/mod.rs new file mode 100644 index 0000000..6c335d2 --- /dev/null +++ b/kordophoned/src/xpc/mod.rs @@ -0,0 +1,6 @@ +#![cfg(target_os = "macos")] +//! macOS XPC IPC interface modules. + +pub mod agent; +pub mod endpoint; +pub mod interface; diff --git a/kpcli/Cargo.toml b/kpcli/Cargo.toml index 82f1774..6ecef03 100644 --- a/kpcli/Cargo.toml +++ b/kpcli/Cargo.toml @@ -29,3 +29,8 @@ dbus-tree = "0.9.2" # D-Bus codegen only on Linux [target.'cfg(target_os = "linux")'.build-dependencies] dbus-codegen = "0.10.0" + +# XPC (libxpc) interface only on macOS +[target.'cfg(target_os = "macos")'.dependencies] +futures-preview = "=0.2.2" +xpc-connection = { git = "https://github.com/dfrankland/xpc-connection-rs.git", rev = "cd4fb3d", package = "xpc-connection" } diff --git a/kpcli/build.rs b/kpcli/build.rs index 307f48d..4755d5b 100644 --- a/kpcli/build.rs +++ b/kpcli/build.rs @@ -16,11 +16,10 @@ fn main() { ..Default::default() }; - let xml = std::fs::read_to_string(KORDOPHONE_XML) - .expect("Error reading server dbus interface"); + let xml = std::fs::read_to_string(KORDOPHONE_XML).expect("Error reading server dbus interface"); - let output = dbus_codegen::generate(&xml, &opts) - .expect("Error generating client dbus interface"); + let output = + dbus_codegen::generate(&xml, &opts).expect("Error generating client dbus interface"); std::fs::write(out_path, output).expect("Error writing client dbus code"); diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index 19d2491..04d0130 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -6,6 +6,9 @@ use clap::Subcommand; #[cfg(target_os = "linux")] mod dbus; +#[cfg(target_os = "macos")] +mod xpc; + #[async_trait] pub trait DaemonInterface { async fn print_version(&mut self) -> Result<()>; @@ -40,40 +43,72 @@ impl StubDaemonInterface { #[async_trait] impl DaemonInterface for StubDaemonInterface { async fn print_version(&mut self) -> Result<()> { - Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + 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")) + 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")) + 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")) + 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 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 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")) + 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")) + 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")) + 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")) + 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")) + 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")) + Err(anyhow::anyhow!( + "Daemon interface not implemented on this platform" + )) } } @@ -82,7 +117,11 @@ pub fn new_daemon_interface() -> Result> { { Ok(Box::new(dbus::DBusDaemonInterface::new()?)) } - #[cfg(not(target_os = "linux"))] + #[cfg(target_os = "macos")] + { + Ok(Box::new(xpc::XpcDaemonInterface::new()?)) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] { Ok(Box::new(StubDaemonInterface::new()?)) } @@ -162,7 +201,9 @@ 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 { diff --git a/kpcli/src/daemon/xpc.rs b/kpcli/src/daemon/xpc.rs new file mode 100644 index 0000000..951d514 --- /dev/null +++ b/kpcli/src/daemon/xpc.rs @@ -0,0 +1,100 @@ +#![cfg(target_os = "macos")] +//! macOS XPC implementation of the DaemonInterface for kpcli. + +use super::{ConfigCommands, DaemonInterface}; +use anyhow::Result; +use async_trait::async_trait; +use futures::stream::StreamExt; +use futures::executor::block_on; +use std::collections::HashMap; +use xpc_connection::{Message, XpcConnection}; + +const SERVICE_NAME: &str = "net.buzzert.kordophonecd\0"; +const GET_VERSION_METHOD: &str = "GetVersion\0"; + +/// 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 { + Ok(Self) + } +} + +#[async_trait] +impl DaemonInterface for XpcDaemonInterface { + async fn print_version(&mut self) -> Result<()> { + // Open an XPC connection to the daemon service + let mut conn = XpcConnection::new(SERVICE_NAME); + let mut incoming = conn.connect(); + + // Send a GetVersion request as a dictionary message + let mut dict = HashMap::new(); + dict.insert( + GET_VERSION_METHOD.to_string(), + Message::String(String::new()), + ); + conn.send_message(Message::Dictionary(dict)); + + // Wait for a single string reply (futures-preview StreamFuture returns (Option, Stream)) + let (opt_msg, _) = match block_on(incoming.next()) { + Ok(pair) => pair, + Err(e) => { + eprintln!("Error reading XPC reply: {:?}", e); + return Ok(()); + } + }; + if let Some(Message::String(ver_raw)) = opt_msg { + // Trim the trailing NUL if present + let version = ver_raw.trim_end_matches('\0'); + println!("Server version: {}", version); + } else { + eprintln!("Unexpected XPC reply for GetVersion"); + } + Ok(()) + } + + // Remaining methods unimplemented on macOS + async fn print_conversations(&mut self) -> Result<()> { + Err(anyhow::anyhow!("Feature not implemented for XPC")) + } + async fn sync_conversations(&mut self, _conversation_id: Option) -> Result<()> { + Err(anyhow::anyhow!("Feature not implemented for XPC")) + } + async fn sync_conversations_list(&mut self) -> Result<()> { + Err(anyhow::anyhow!("Feature not implemented for XPC")) + } + async fn print_messages( + &mut self, + _conversation_id: String, + _last_message_id: Option, + ) -> Result<()> { + Err(anyhow::anyhow!("Feature not implemented for XPC")) + } + async fn enqueue_outgoing_message( + &mut self, + _conversation_id: String, + _text: String, + ) -> Result<()> { + Err(anyhow::anyhow!("Feature not implemented for XPC")) + } + async fn wait_for_signals(&mut self) -> Result<()> { + Err(anyhow::anyhow!("Feature not implemented for XPC")) + } + async fn config(&mut self, _cmd: ConfigCommands) -> Result<()> { + Err(anyhow::anyhow!("Feature not implemented for XPC")) + } + async fn delete_all_conversations(&mut self) -> Result<()> { + Err(anyhow::anyhow!("Feature not implemented for XPC")) + } + async fn download_attachment(&mut self, _attachment_id: String) -> Result<()> { + Err(anyhow::anyhow!("Feature not implemented for XPC")) + } + async fn upload_attachment(&mut self, _path: String) -> Result<()> { + Err(anyhow::anyhow!("Feature not implemented for XPC")) + } + async fn mark_conversation_as_read(&mut self, _conversation_id: String) -> Result<()> { + Err(anyhow::anyhow!("Feature not implemented for XPC")) + } +} From e9bda39d8a20d62f7a6e2257e659567dd2124f5c Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 10 Aug 2025 21:48:44 -0700 Subject: [PATCH 02/23] xpc: hacky implementation of GetVersion --- Cargo.lock | 4 +- kordophoned/README.md | 13 ++ .../include/net.buzzert.kordophonecd.plist | 29 ++++ kordophoned/src/xpc/agent.rs | 113 +++++++++++- kordophoned/src/xpc/mod.rs | 3 - kpcli/Cargo.toml | 4 +- kpcli/src/daemon/xpc.rs | 163 ++++++++++++++---- 7 files changed, 291 insertions(+), 38 deletions(-) create mode 100644 kordophoned/README.md create mode 100644 kordophoned/include/net.buzzert.kordophonecd.plist diff --git a/Cargo.lock b/Cargo.lock index 07b9d26..f90bab8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1301,13 +1301,14 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "block", "clap 4.5.20", "dbus", "dbus-codegen", "dbus-tree", "dotenv", "env_logger 0.11.8", - "futures-preview", + "futures", "futures-util", "kordophone", "kordophone-db", @@ -1318,6 +1319,7 @@ dependencies = [ "time", "tokio", "xpc-connection", + "xpc-connection-sys", ] [[package]] diff --git a/kordophoned/README.md b/kordophoned/README.md new file mode 100644 index 0000000..2026166 --- /dev/null +++ b/kordophoned/README.md @@ -0,0 +1,13 @@ +# kordophoned + +The daemon executable that exposes an IPC interface (Dbus on Linux, XPC on macoS) to the client. + + +## Running on macOS + +Before any client can talk to the kordophone daemon on macOS, the XPC service needs to be manually registered with launchd. + +- Copy `include/net.buzzert.kordophonecd.plist` to `~/Library/LaunchAgents` (note the `ProgramArguments` key/value). +- Register using `launchctl load ~/Library/LaunchAgents/net.buzzert.kordophonecd.plist` + + diff --git a/kordophoned/include/net.buzzert.kordophonecd.plist b/kordophoned/include/net.buzzert.kordophonecd.plist new file mode 100644 index 0000000..69617ae --- /dev/null +++ b/kordophoned/include/net.buzzert.kordophonecd.plist @@ -0,0 +1,29 @@ + + + + + Label + net.buzzert.kordophonecd + + ProgramArguments + + /Users/buzzert/src/kordophone-rs/target/debug/kordophoned + + + MachServices + + net.buzzert.kordophonecd + + + + RunAtLoad + + KeepAlive + + + StandardOutPath + /tmp/kordophoned.out.log + StandardErrorPath + /tmp/kordophoned.err.log + + \ No newline at end of file diff --git a/kordophoned/src/xpc/agent.rs b/kordophoned/src/xpc/agent.rs index e49e405..6090bfc 100644 --- a/kordophoned/src/xpc/agent.rs +++ b/kordophoned/src/xpc/agent.rs @@ -1,6 +1,14 @@ use crate::daemon::{events::Event, signals::Signal, DaemonResult}; +use crate::xpc::interface::SERVICE_NAME; +use futures_util::StreamExt; +use std::collections::HashMap; +use std::ffi::CString; use std::sync::Arc; use tokio::sync::{mpsc, oneshot, Mutex}; +use xpc_connection::{Message, MessageError, XpcClient, XpcListener}; + + +static LOG_TARGET: &str = "xpc"; /// XPC IPC agent that forwards daemon events and signals over libxpc. #[derive(Clone)] @@ -18,9 +26,33 @@ impl XpcAgent { } } - /// Run the XPC agent: perform a basic GetVersion IPC call to the daemon and print the result. + /// Run the XPC agent and host the XPC service. Implements `GetVersion`. pub async fn run(self) { - todo!() + log::info!(target: LOG_TARGET, "XPCAgent running"); + + // Construct the Mach service name without a trailing NUL for CString. + let service_name = SERVICE_NAME.trim_end_matches('\0'); + let mach_port_name = match CString::new(service_name) { + Ok(c) => c, + Err(e) => { + log::error!(target: LOG_TARGET, "Invalid XPC service name: {e}"); + return; + } + }; + + log::info!( + target: LOG_TARGET, + "Waiting for XPC connections on {}", + service_name + ); + + let mut listener = XpcListener::listen(&mach_port_name); + + while let Some(client) = listener.next().await { + tokio::spawn(handle_client(client)); + } + + log::info!(target: LOG_TARGET, "XPC listener shutting down"); } /// Send an event to the daemon and await its reply. @@ -36,3 +68,80 @@ impl XpcAgent { rx.await.map_err(|_| "Failed to receive reply".into()) } } + +async fn handle_client(mut client: XpcClient) { + log::info!(target: LOG_TARGET, "New XPC connection"); + + while let Some(message) = client.next().await { + match message { + Message::Error(MessageError::ConnectionInterrupted) => { + log::warn!(target: LOG_TARGET, "XPC connection interrupted"); + } + Message::Dictionary(map) => { + // Try keys "method" or "type" to identify the call. + let method_key = CString::new("method").unwrap(); + let type_key = CString::new("type").unwrap(); + + let maybe_method = map + .get(&method_key) + .or_else(|| map.get(&type_key)) + .and_then(|v| match v { + Message::String(s) => Some(s.to_string_lossy().into_owned()), + _ => None, + }); + + match maybe_method.as_deref() { + Some("GetVersion") => { + let mut reply: HashMap = HashMap::new(); + reply.insert( + CString::new("type").unwrap(), + Message::String(CString::new("GetVersionResponse").unwrap()), + ); + reply.insert( + CString::new("version").unwrap(), + Message::String(CString::new(env!("CARGO_PKG_VERSION")).unwrap()), + ); + client.send_message(Message::Dictionary(reply)); + } + Some(other) => { + log::warn!(target: LOG_TARGET, "Unknown XPC method: {}", other); + let mut reply: HashMap = HashMap::new(); + reply.insert( + CString::new("type").unwrap(), + Message::String(CString::new("Error").unwrap()), + ); + reply.insert( + CString::new("error").unwrap(), + Message::String(CString::new("UnknownMethod").unwrap()), + ); + reply.insert( + CString::new("message").unwrap(), + Message::String(CString::new(other).unwrap_or_else(|_| CString::new("").unwrap())), + ); + client.send_message(Message::Dictionary(reply)); + } + None => { + log::warn!(target: LOG_TARGET, "XPC message missing method/type"); + let mut reply: HashMap = HashMap::new(); + reply.insert( + CString::new("type").unwrap(), + Message::String(CString::new("Error").unwrap()), + ); + reply.insert( + CString::new("error").unwrap(), + Message::String(CString::new("InvalidRequest").unwrap()), + ); + client.send_message(Message::Dictionary(reply)); + } + } + } + other => { + // For now just echo any non-dictionary messages (useful for testing). + log::info!(target: LOG_TARGET, "Echoing message: {:?}", other); + client.send_message(other); + } + } + } + + log::info!(target: LOG_TARGET, "XPC connection closed"); +} diff --git a/kordophoned/src/xpc/mod.rs b/kordophoned/src/xpc/mod.rs index 6c335d2..87689fb 100644 --- a/kordophoned/src/xpc/mod.rs +++ b/kordophoned/src/xpc/mod.rs @@ -1,6 +1,3 @@ -#![cfg(target_os = "macos")] -//! macOS XPC IPC interface modules. - pub mod agent; pub mod endpoint; pub mod interface; diff --git a/kpcli/Cargo.toml b/kpcli/Cargo.toml index 6ecef03..da2843e 100644 --- a/kpcli/Cargo.toml +++ b/kpcli/Cargo.toml @@ -32,5 +32,7 @@ dbus-codegen = "0.10.0" # XPC (libxpc) interface only on macOS [target.'cfg(target_os = "macos")'.dependencies] -futures-preview = "=0.2.2" +block = "0.1.6" +futures = "0.3.4" xpc-connection = { git = "https://github.com/dfrankland/xpc-connection-rs.git", rev = "cd4fb3d", package = "xpc-connection" } +xpc-connection-sys = { git = "https://github.com/dfrankland/xpc-connection-rs.git", rev = "cd4fb3d", package = "xpc-connection-sys" } diff --git a/kpcli/src/daemon/xpc.rs b/kpcli/src/daemon/xpc.rs index 951d514..21fd956 100644 --- a/kpcli/src/daemon/xpc.rs +++ b/kpcli/src/daemon/xpc.rs @@ -1,16 +1,107 @@ -#![cfg(target_os = "macos")] -//! macOS XPC implementation of the DaemonInterface for kpcli. - use super::{ConfigCommands, DaemonInterface}; use anyhow::Result; use async_trait::async_trait; -use futures::stream::StreamExt; -use futures::executor::block_on; +use futures_util::StreamExt; use std::collections::HashMap; -use xpc_connection::{Message, XpcConnection}; +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\0"; +const GET_VERSION_METHOD: &str = "GetVersion"; + +// 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, + sender: UnboundedSender, + event_handler_is_running: bool, +} + +impl XPCClient { + pub fn connect(name: impl AsRef) -> Self { + use block::ConcreteBlock; + use xpc_connection::xpc_object_to_message; + use xpc_connection_sys::xpc_connection_set_event_handler; + use xpc_connection_sys::xpc_connection_resume; + + 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); + } + } +} + +impl Drop for XPCClient { + fn drop(&mut self) { + use xpc_connection_sys::xpc_release; + use xpc_connection_sys::xpc_object_t; + + 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> { + 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; @@ -25,33 +116,43 @@ impl XpcDaemonInterface { #[async_trait] impl DaemonInterface for XpcDaemonInterface { async fn print_version(&mut self) -> Result<()> { + // Build service name CString (trim trailing NUL from const) + let service_name = SERVICE_NAME.trim_end_matches('\0'); + let mach_port_name = CString::new(service_name)?; + // Open an XPC connection to the daemon service - let mut conn = XpcConnection::new(SERVICE_NAME); - let mut incoming = conn.connect(); + let mut client = XPCClient::connect(&mach_port_name); - // Send a GetVersion request as a dictionary message - let mut dict = HashMap::new(); - dict.insert( - GET_VERSION_METHOD.to_string(), - Message::String(String::new()), - ); - conn.send_message(Message::Dictionary(dict)); - - // Wait for a single string reply (futures-preview StreamFuture returns (Option, Stream)) - let (opt_msg, _) = match block_on(incoming.next()) { - Ok(pair) => pair, - Err(e) => { - eprintln!("Error reading XPC reply: {:?}", e); - return Ok(()); - } - }; - if let Some(Message::String(ver_raw)) = opt_msg { - // Trim the trailing NUL if present - let version = ver_raw.trim_end_matches('\0'); - println!("Server version: {}", version); - } else { - eprintln!("Unexpected XPC reply for GetVersion"); + // Send a GetVersion request as a dictionary message: { method: "GetVersion" } + { + let mut request = HashMap::new(); + request.insert( + CString::new("method").unwrap(), + Message::String(CString::new(GET_VERSION_METHOD).unwrap()), + ); + client.send_message(Message::Dictionary(request)); } + + // Await a single reply and print the version + match client.next().await { + Some(Message::Dictionary(map)) => { + if let Some(Message::String(ver)) = map.get(&CString::new("version").unwrap()) { + println!("Server version: {}", ver.to_string_lossy()); + } else if let Some(Message::String(ty)) = map.get(&CString::new("type").unwrap()) + { + println!("XPC replied with type: {}", ty.to_string_lossy()); + } else { + eprintln!("Unexpected XPC reply payload for GetVersion"); + } + } + Some(other) => { + eprintln!("Unexpected XPC reply: {:?}", other); + } + None => { + eprintln!("No reply received from XPC daemon"); + } + } + Ok(()) } From e51fa3abeb7414c9df16ba2acb688f38dc335aeb Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 20 Aug 2025 23:12:31 -0700 Subject: [PATCH 03/23] kordophoned becomes a lib --- kordophoned/src/dbus/agent.rs | 2 +- kordophoned/src/lib.rs | 3 +++ kordophoned/src/main.rs | 4 +--- kordophoned/src/xpc/agent.rs | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 kordophoned/src/lib.rs diff --git a/kordophoned/src/dbus/agent.rs b/kordophoned/src/dbus/agent.rs index 6cce37a..d284132 100644 --- a/kordophoned/src/dbus/agent.rs +++ b/kordophoned/src/dbus/agent.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use std::{future::Future, thread}; use tokio::sync::{mpsc, oneshot, Mutex}; -use crate::daemon::{ +use kordophoned::daemon::{ contact_resolver::{ContactResolver, DefaultContactResolverBackend}, events::{Event, Reply}, settings::Settings, diff --git a/kordophoned/src/lib.rs b/kordophoned/src/lib.rs new file mode 100644 index 0000000..3c76722 --- /dev/null +++ b/kordophoned/src/lib.rs @@ -0,0 +1,3 @@ +pub mod daemon; + + diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index d56c824..bf2e037 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -1,5 +1,3 @@ -mod daemon; - #[cfg(target_os = "linux")] mod dbus; @@ -9,7 +7,7 @@ mod xpc; use log::LevelFilter; use std::future; -use daemon::Daemon; +use kordophoned::daemon::Daemon; fn initialize_logging() { // Weird: is this the best way to do this? diff --git a/kordophoned/src/xpc/agent.rs b/kordophoned/src/xpc/agent.rs index 6090bfc..3a1209d 100644 --- a/kordophoned/src/xpc/agent.rs +++ b/kordophoned/src/xpc/agent.rs @@ -1,4 +1,4 @@ -use crate::daemon::{events::Event, signals::Signal, DaemonResult}; +use kordophoned::daemon::{events::Event, signals::Signal, DaemonResult}; use crate::xpc::interface::SERVICE_NAME; use futures_util::StreamExt; use std::collections::HashMap; @@ -58,7 +58,7 @@ impl XpcAgent { /// Send an event to the daemon and await its reply. pub async fn send_event( &self, - make_event: impl FnOnce(crate::daemon::events::Reply) -> Event, + make_event: impl FnOnce(kordophoned::daemon::events::Reply) -> Event, ) -> DaemonResult { let (tx, rx) = oneshot::channel(); self.event_sink From 8ff95f4bf9296e4774499dd91836435d5f4d3a98 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 23 Aug 2025 19:24:42 -0700 Subject: [PATCH 04/23] xpc: generic interface for dispatching methods --- .../include/net.buzzert.kordophonecd.plist | 8 +- kordophoned/src/xpc/agent.rs | 133 ++++++++++-------- kordophoned/src/xpc/endpoint.rs | 27 ---- kordophoned/src/xpc/mod.rs | 1 - 4 files changed, 81 insertions(+), 88 deletions(-) delete mode 100644 kordophoned/src/xpc/endpoint.rs diff --git a/kordophoned/include/net.buzzert.kordophonecd.plist b/kordophoned/include/net.buzzert.kordophonecd.plist index 69617ae..976049b 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.plist +++ b/kordophoned/include/net.buzzert.kordophonecd.plist @@ -10,6 +10,12 @@ /Users/buzzert/src/kordophone-rs/target/debug/kordophoned + EnvironmentVariables + + RUST_LOG + info + + MachServices net.buzzert.kordophonecd @@ -26,4 +32,4 @@ StandardErrorPath /tmp/kordophoned.err.log - \ No newline at end of file + diff --git a/kordophoned/src/xpc/agent.rs b/kordophoned/src/xpc/agent.rs index 3a1209d..a069cfa 100644 --- a/kordophoned/src/xpc/agent.rs +++ b/kordophoned/src/xpc/agent.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; use std::ffi::CString; use std::sync::Arc; use tokio::sync::{mpsc, oneshot, Mutex}; +use std::thread; use xpc_connection::{Message, MessageError, XpcClient, XpcListener}; @@ -26,7 +27,7 @@ impl XpcAgent { } } - /// Run the XPC agent and host the XPC service. Implements `GetVersion`. + /// Run the XPC agent and host the XPC service. Implements generic dispatch. pub async fn run(self) { log::info!(target: LOG_TARGET, "XPCAgent running"); @@ -49,7 +50,20 @@ impl XpcAgent { let mut listener = XpcListener::listen(&mach_port_name); while let Some(client) = listener.next().await { - tokio::spawn(handle_client(client)); + let agent = self.clone(); + thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + log::error!(target: LOG_TARGET, "Failed to build runtime for client: {}", e); + return; + } + }; + rt.block_on(handle_client(agent, client)); + }); } log::info!(target: LOG_TARGET, "XPC listener shutting down"); @@ -69,7 +83,62 @@ impl XpcAgent { } } -async fn handle_client(mut client: XpcClient) { +fn cstr(s: &str) -> CString { CString::new(s).unwrap_or_else(|_| CString::new("").unwrap()) } + +fn get_string_field(map: &HashMap, key: &str) -> Option { + let k = CString::new(key).ok()?; + map.get(&k).and_then(|v| match v { + Message::String(s) => Some(s.to_string_lossy().into_owned()), + _ => None, + }) +} + +fn get_dictionary_field<'a>(map: &'a HashMap, key: &str) -> Option<&'a HashMap> { + let k = CString::new(key).ok()?; + map.get(&k).and_then(|v| match v { + Message::Dictionary(d) => Some(d), + _ => None, + }) +} + +fn make_error_reply(code: &str, message: &str) -> Message { + let mut reply: HashMap = HashMap::new(); + reply.insert(cstr("type"), Message::String(cstr("Error"))); + reply.insert(cstr("error"), Message::String(cstr(code))); + reply.insert(cstr("message"), Message::String(cstr(message))); + + Message::Dictionary(reply) +} + +async fn dispatch(agent: &XpcAgent, root: &HashMap) -> Message { + // Standardized request: { method: String, arguments: Dictionary? } + let method = match get_string_field(root, "method").or_else(|| get_string_field(root, "type")) { + Some(m) => m, + None => return make_error_reply("InvalidRequest", "Missing method/type"), + }; + + let _arguments = get_dictionary_field(root, "arguments"); + + match method.as_str() { + // Example implemented method: GetVersion + "GetVersion" => { + match agent.send_event(Event::GetVersion).await { + Ok(version) => { + let mut reply: HashMap = HashMap::new(); + reply.insert(cstr("type"), Message::String(cstr("GetVersionResponse"))); + reply.insert(cstr("version"), Message::String(cstr(&version))); + Message::Dictionary(reply) + } + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + // Unknown method fallback + other => make_error_reply("UnknownMethod", other), + } +} + +async fn handle_client(agent: XpcAgent, mut client: XpcClient) { log::info!(target: LOG_TARGET, "New XPC connection"); while let Some(message) = client.next().await { @@ -78,62 +147,8 @@ async fn handle_client(mut client: XpcClient) { log::warn!(target: LOG_TARGET, "XPC connection interrupted"); } Message::Dictionary(map) => { - // Try keys "method" or "type" to identify the call. - let method_key = CString::new("method").unwrap(); - let type_key = CString::new("type").unwrap(); - - let maybe_method = map - .get(&method_key) - .or_else(|| map.get(&type_key)) - .and_then(|v| match v { - Message::String(s) => Some(s.to_string_lossy().into_owned()), - _ => None, - }); - - match maybe_method.as_deref() { - Some("GetVersion") => { - let mut reply: HashMap = HashMap::new(); - reply.insert( - CString::new("type").unwrap(), - Message::String(CString::new("GetVersionResponse").unwrap()), - ); - reply.insert( - CString::new("version").unwrap(), - Message::String(CString::new(env!("CARGO_PKG_VERSION")).unwrap()), - ); - client.send_message(Message::Dictionary(reply)); - } - Some(other) => { - log::warn!(target: LOG_TARGET, "Unknown XPC method: {}", other); - let mut reply: HashMap = HashMap::new(); - reply.insert( - CString::new("type").unwrap(), - Message::String(CString::new("Error").unwrap()), - ); - reply.insert( - CString::new("error").unwrap(), - Message::String(CString::new("UnknownMethod").unwrap()), - ); - reply.insert( - CString::new("message").unwrap(), - Message::String(CString::new(other).unwrap_or_else(|_| CString::new("").unwrap())), - ); - client.send_message(Message::Dictionary(reply)); - } - None => { - log::warn!(target: LOG_TARGET, "XPC message missing method/type"); - let mut reply: HashMap = HashMap::new(); - reply.insert( - CString::new("type").unwrap(), - Message::String(CString::new("Error").unwrap()), - ); - reply.insert( - CString::new("error").unwrap(), - Message::String(CString::new("InvalidRequest").unwrap()), - ); - client.send_message(Message::Dictionary(reply)); - } - } + let response = dispatch(&agent, &map).await; + client.send_message(response); } other => { // For now just echo any non-dictionary messages (useful for testing). diff --git a/kordophoned/src/xpc/endpoint.rs b/kordophoned/src/xpc/endpoint.rs deleted file mode 100644 index 459e5c4..0000000 --- a/kordophoned/src/xpc/endpoint.rs +++ /dev/null @@ -1,27 +0,0 @@ -#![cfg(target_os = "macos")] -//! XPC registry for registering handlers and emitting signals. - -/// Registry for XPC message handlers and signal emission. -pub struct XpcRegistry; - -impl XpcRegistry { - /// Create a new XPC registry for the service. - pub fn new() -> Self { - XpcRegistry - } - - /// Register a handler for incoming messages at a given endpoint. - pub fn register_handler(&self, _name: &str, _handler: F) - where - F: Fn(&[u8]) -> Vec + Send + Sync + 'static, - { - // TODO: Implement handler registration over libxpc using SERVICE_NAME - let _ = (_name, _handler); - } - - /// Send a signal (notification) to connected clients. - pub fn send_signal(&self, _signal: &str, _data: &T) { - // TODO: Serialize and send signal over XPC - let _ = (_signal, _data); - } -} diff --git a/kordophoned/src/xpc/mod.rs b/kordophoned/src/xpc/mod.rs index 87689fb..8f189b2 100644 --- a/kordophoned/src/xpc/mod.rs +++ b/kordophoned/src/xpc/mod.rs @@ -1,3 +1,2 @@ pub mod agent; -pub mod endpoint; pub mod interface; From 885c96172d93df193e0df32c3da27c3c4398055e Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 23 Aug 2025 19:41:12 -0700 Subject: [PATCH 05/23] xpc: kpcli: clean up client interface --- kpcli/src/daemon/mod.rs | 6 ++-- kpcli/src/daemon/xpc.rs | 78 ++++++++++++++++++++++------------------- 2 files changed, 46 insertions(+), 38 deletions(-) diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index 04d0130..872ab1b 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -9,7 +9,8 @@ mod dbus; #[cfg(target_os = "macos")] mod xpc; -#[async_trait] +#[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<()>; @@ -40,7 +41,8 @@ impl StubDaemonInterface { } } -#[async_trait] +#[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!( diff --git a/kpcli/src/daemon/xpc.rs b/kpcli/src/daemon/xpc.rs index 21fd956..1308bfe 100644 --- a/kpcli/src/daemon/xpc.rs +++ b/kpcli/src/daemon/xpc.rs @@ -111,49 +111,55 @@ impl XpcDaemonInterface { pub fn new() -> Result { Ok(Self) } + + fn build_service_name() -> Result { + let service_name = SERVICE_NAME.trim_end_matches('\0'); + Ok(CString::new(service_name)?) + } + + fn build_request(method: &str, args: Option>) -> HashMap { + 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>) -> anyhow::Result> { + let request = Self::build_request(method, args); + client.send_message(Message::Dictionary(request)); + + match client.next().await { + Some(Message::Dictionary(map)) => Ok(map), + Some(other) => Err(anyhow::anyhow!("Unexpected XPC reply: {:?}", other)), + None => Err(anyhow::anyhow!("No reply received from XPC daemon")), + } + } + + fn get_string<'a>(map: &'a HashMap, key: &str) -> Option<&'a CStr> { + map.get(&CString::new(key).ok()?).and_then(|v| match v { Message::String(s) => Some(s.as_c_str()), _ => None }) + } } -#[async_trait] +#[async_trait(?Send)] impl DaemonInterface for XpcDaemonInterface { async fn print_version(&mut self) -> Result<()> { - // Build service name CString (trim trailing NUL from const) - let service_name = SERVICE_NAME.trim_end_matches('\0'); - let mach_port_name = CString::new(service_name)?; - - // Open an XPC connection to the daemon service + // Build service name and connect + let mach_port_name = Self::build_service_name()?; let mut client = XPCClient::connect(&mach_port_name); - // Send a GetVersion request as a dictionary message: { method: "GetVersion" } - { - let mut request = HashMap::new(); - request.insert( - CString::new("method").unwrap(), - Message::String(CString::new(GET_VERSION_METHOD).unwrap()), - ); - client.send_message(Message::Dictionary(request)); + // 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")) } - - // Await a single reply and print the version - match client.next().await { - Some(Message::Dictionary(map)) => { - if let Some(Message::String(ver)) = map.get(&CString::new("version").unwrap()) { - println!("Server version: {}", ver.to_string_lossy()); - } else if let Some(Message::String(ty)) = map.get(&CString::new("type").unwrap()) - { - println!("XPC replied with type: {}", ty.to_string_lossy()); - } else { - eprintln!("Unexpected XPC reply payload for GetVersion"); - } - } - Some(other) => { - eprintln!("Unexpected XPC reply: {:?}", other); - } - None => { - eprintln!("No reply received from XPC daemon"); - } - } - - Ok(()) } // Remaining methods unimplemented on macOS From b7fabd6c05522a7d7272f02f2ed8bc919bde9e21 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 23 Aug 2025 19:48:49 -0700 Subject: [PATCH 06/23] xpc: implement GetConversations --- kordophoned/src/xpc/agent.rs | 44 +++++++++++++++++++++++++++++ kpcli/src/daemon/xpc.rs | 54 +++++++++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/kordophoned/src/xpc/agent.rs b/kordophoned/src/xpc/agent.rs index a069cfa..61948f5 100644 --- a/kordophoned/src/xpc/agent.rs +++ b/kordophoned/src/xpc/agent.rs @@ -133,6 +133,50 @@ async fn dispatch(agent: &XpcAgent, root: &HashMap) -> Message } } + "GetConversations" => { + // Defaults + let mut limit: i32 = 100; + let mut offset: i32 = 0; + + if let Some(args) = get_dictionary_field(root, "arguments") { + if let Some(Message::String(v)) = args.get(&cstr("limit")) { limit = v.to_string_lossy().parse().unwrap_or(100); } + if let Some(Message::String(v)) = args.get(&cstr("offset")) { offset = v.to_string_lossy().parse().unwrap_or(0); } + } + + match agent.send_event(|r| Event::GetAllConversations(limit, offset, r)).await { + Ok(conversations) => { + // Build array of conversation dictionaries + let mut items: Vec = Vec::with_capacity(conversations.len()); + for conv in conversations { + let mut m: HashMap = HashMap::new(); + m.insert(cstr("guid"), Message::String(cstr(&conv.guid))); + m.insert(cstr("display_name"), Message::String(cstr(&conv.display_name.unwrap_or_default()))); + m.insert(cstr("unread_count"), Message::String(cstr(&(conv.unread_count as i64).to_string()))); + m.insert(cstr("last_message_preview"), Message::String(cstr(&conv.last_message_preview.unwrap_or_default()))); + + // participants -> array of strings + let participants: Vec = conv + .participants + .into_iter() + .map(|p| Message::String(cstr(&p.display_name()))) + .collect(); + m.insert(cstr("participants"), Message::Array(participants)); + + // date as unix timestamp (i64) + m.insert(cstr("date"), Message::String(cstr(&conv.date.and_utc().timestamp().to_string()))); + + items.push(Message::Dictionary(m)); + } + + let mut reply: HashMap = HashMap::new(); + reply.insert(cstr("type"), Message::String(cstr("GetConversationsResponse"))); + reply.insert(cstr("conversations"), Message::Array(items)); + Message::Dictionary(reply) + } + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + // Unknown method fallback other => make_error_reply("UnknownMethod", other), } diff --git a/kpcli/src/daemon/xpc.rs b/kpcli/src/daemon/xpc.rs index 1308bfe..efd85fb 100644 --- a/kpcli/src/daemon/xpc.rs +++ b/kpcli/src/daemon/xpc.rs @@ -17,6 +17,7 @@ use futures::{ 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. @@ -164,7 +165,58 @@ impl DaemonInterface for XpcDaemonInterface { // Remaining methods unimplemented on macOS async fn print_conversations(&mut self) -> Result<()> { - Err(anyhow::anyhow!("Feature not implemented for XPC")) + // 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(&CString::new("conversations").unwrap()) { + 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 = match map.get(&CString::new("unread_count").unwrap()) { Some(Message::String(v)) => v.to_string_lossy().parse().unwrap_or(0), _ => 0 }; + let date_ts: i64 = match map.get(&CString::new("date").unwrap()) { Some(Message::String(v)) => v.to_string_lossy().parse().unwrap_or(0), _ => 0 }; + + let participants: Vec = match map.get(&CString::new("participants").unwrap()) { + 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) -> Result<()> { Err(anyhow::anyhow!("Feature not implemented for XPC")) From 6f90e1c749c49370eef26748913a0bcc4074fa93 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 23 Aug 2025 20:01:13 -0700 Subject: [PATCH 07/23] xpc: Better type unpacking --- kordophoned/src/xpc/agent.rs | 64 ++++++++++++++++++++++++++---------- kpcli/src/daemon/xpc.rs | 16 ++++++--- 2 files changed, 58 insertions(+), 22 deletions(-) diff --git a/kordophoned/src/xpc/agent.rs b/kordophoned/src/xpc/agent.rs index 61948f5..e337c08 100644 --- a/kordophoned/src/xpc/agent.rs +++ b/kordophoned/src/xpc/agent.rs @@ -110,9 +110,39 @@ fn make_error_reply(code: &str, message: &str) -> Message { Message::Dictionary(reply) } +type XpcMap = HashMap; + +fn dict_get_str(map: &XpcMap, key: &str) -> Option { + let k = CString::new(key).ok()?; + match map.get(&k) { + Some(Message::String(v)) => Some(v.to_string_lossy().into_owned()), + _ => None, + } +} + +fn dict_get_i64_from_str(map: &XpcMap, key: &str) -> Option { + dict_get_str(map, key).and_then(|s| s.parse::().ok()) +} + +fn dict_put_str(map: &mut XpcMap, key: &str, value: impl AsRef) { + map.insert(cstr(key), Message::String(cstr(value.as_ref()))); +} + +fn dict_put_i64_as_str(map: &mut XpcMap, key: &str, value: i64) { + dict_put_str(map, key, value.to_string()); +} + +fn array_from_strs(values: impl IntoIterator) -> Message { + let arr = values + .into_iter() + .map(|s| Message::String(cstr(&s))) + .collect(); + Message::Array(arr) +} + async fn dispatch(agent: &XpcAgent, root: &HashMap) -> Message { // Standardized request: { method: String, arguments: Dictionary? } - let method = match get_string_field(root, "method").or_else(|| get_string_field(root, "type")) { + let method = match dict_get_str(root, "method").or_else(|| dict_get_str(root, "type")) { Some(m) => m, None => return make_error_reply("InvalidRequest", "Missing method/type"), }; @@ -124,9 +154,9 @@ async fn dispatch(agent: &XpcAgent, root: &HashMap) -> Message "GetVersion" => { match agent.send_event(Event::GetVersion).await { Ok(version) => { - let mut reply: HashMap = HashMap::new(); - reply.insert(cstr("type"), Message::String(cstr("GetVersionResponse"))); - reply.insert(cstr("version"), Message::String(cstr(&version))); + let mut reply: XpcMap = HashMap::new(); + dict_put_str(&mut reply, "type", "GetVersionResponse"); + dict_put_str(&mut reply, "version", &version); Message::Dictionary(reply) } Err(e) => make_error_reply("DaemonError", &format!("{}", e)), @@ -139,8 +169,8 @@ async fn dispatch(agent: &XpcAgent, root: &HashMap) -> Message let mut offset: i32 = 0; if let Some(args) = get_dictionary_field(root, "arguments") { - if let Some(Message::String(v)) = args.get(&cstr("limit")) { limit = v.to_string_lossy().parse().unwrap_or(100); } - if let Some(Message::String(v)) = args.get(&cstr("offset")) { offset = v.to_string_lossy().parse().unwrap_or(0); } + if let Some(v) = dict_get_i64_from_str(args, "limit") { limit = v as i32; } + if let Some(v) = dict_get_i64_from_str(args, "offset") { offset = v as i32; } } match agent.send_event(|r| Event::GetAllConversations(limit, offset, r)).await { @@ -148,28 +178,28 @@ async fn dispatch(agent: &XpcAgent, root: &HashMap) -> Message // Build array of conversation dictionaries let mut items: Vec = Vec::with_capacity(conversations.len()); for conv in conversations { - let mut m: HashMap = HashMap::new(); - m.insert(cstr("guid"), Message::String(cstr(&conv.guid))); - m.insert(cstr("display_name"), Message::String(cstr(&conv.display_name.unwrap_or_default()))); - m.insert(cstr("unread_count"), Message::String(cstr(&(conv.unread_count as i64).to_string()))); - m.insert(cstr("last_message_preview"), Message::String(cstr(&conv.last_message_preview.unwrap_or_default()))); + let mut m: XpcMap = HashMap::new(); + dict_put_str(&mut m, "guid", &conv.guid); + dict_put_str(&mut m, "display_name", &conv.display_name.unwrap_or_default()); + dict_put_i64_as_str(&mut m, "unread_count", conv.unread_count as i64); + dict_put_str(&mut m, "last_message_preview", &conv.last_message_preview.unwrap_or_default()); // participants -> array of strings - let participants: Vec = conv + let participant_names: Vec = conv .participants .into_iter() - .map(|p| Message::String(cstr(&p.display_name()))) + .map(|p| p.display_name()) .collect(); - m.insert(cstr("participants"), Message::Array(participants)); + m.insert(cstr("participants"), array_from_strs(participant_names)); // date as unix timestamp (i64) - m.insert(cstr("date"), Message::String(cstr(&conv.date.and_utc().timestamp().to_string()))); + dict_put_i64_as_str(&mut m, "date", conv.date.and_utc().timestamp()); items.push(Message::Dictionary(m)); } - let mut reply: HashMap = HashMap::new(); - reply.insert(cstr("type"), Message::String(cstr("GetConversationsResponse"))); + let mut reply: XpcMap = HashMap::new(); + dict_put_str(&mut reply, "type", "GetConversationsResponse"); reply.insert(cstr("conversations"), Message::Array(items)); Message::Dictionary(reply) } diff --git a/kpcli/src/daemon/xpc.rs b/kpcli/src/daemon/xpc.rs index efd85fb..3595651 100644 --- a/kpcli/src/daemon/xpc.rs +++ b/kpcli/src/daemon/xpc.rs @@ -138,8 +138,14 @@ impl XpcDaemonInterface { } } + fn key(k: &str) -> CString { CString::new(k).unwrap() } + fn get_string<'a>(map: &'a HashMap, key: &str) -> Option<&'a CStr> { - map.get(&CString::new(key).ok()?).and_then(|v| match v { Message::String(s) => Some(s.as_c_str()), _ => None }) + 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, key: &str) -> Option { + Self::get_string(map, key).and_then(|s| s.to_string_lossy().parse().ok()) } } @@ -180,7 +186,7 @@ impl DaemonInterface for XpcDaemonInterface { .await?; // Expect an array under "conversations" - match reply.get(&CString::new("conversations").unwrap()) { + match reply.get(&Self::key("conversations")) { Some(Message::Array(items)) => { println!("Number of conversations: {}", items.len()); @@ -191,10 +197,10 @@ impl DaemonInterface for XpcDaemonInterface { 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 = match map.get(&CString::new("unread_count").unwrap()) { Some(Message::String(v)) => v.to_string_lossy().parse().unwrap_or(0), _ => 0 }; - let date_ts: i64 = match map.get(&CString::new("date").unwrap()) { Some(Message::String(v)) => v.to_string_lossy().parse().unwrap_or(0), _ => 0 }; + 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 = match map.get(&CString::new("participants").unwrap()) { + let participants: Vec = 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(), }; From 0b7b35b30100bb2e0e4de1bb022ea9aa2a1a022c Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 23 Aug 2025 20:02:54 -0700 Subject: [PATCH 08/23] cargo fmt --- kordophoned/src/lib.rs | 2 - kordophoned/src/main.rs | 3 +- kordophoned/src/xpc/agent.rs | 59 +++++++++++++-------- kordophoned/src/xpc/interface.rs | 3 -- kpcli/src/daemon/xpc.rs | 90 ++++++++++++++++++++++++-------- 5 files changed, 109 insertions(+), 48 deletions(-) diff --git a/kordophoned/src/lib.rs b/kordophoned/src/lib.rs index 3c76722..d6bbb01 100644 --- a/kordophoned/src/lib.rs +++ b/kordophoned/src/lib.rs @@ -1,3 +1 @@ pub mod daemon; - - diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index bf2e037..3f1e8dd 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -35,7 +35,8 @@ async fn start_ipc_agent(daemon: &mut Daemon) { #[cfg(target_os = "macos")] async fn start_ipc_agent(daemon: &mut Daemon) { // Start the macOS XPC agent (events in, signals out) on a dedicated thread. - let agent = xpc::agent::XpcAgent::new(daemon.event_sender.clone(), daemon.obtain_signal_receiver()); + let agent = + xpc::agent::XpcAgent::new(daemon.event_sender.clone(), daemon.obtain_signal_receiver()); std::thread::spawn(move || { // Use a single-threaded Tokio runtime for the XPC agent. let rt = tokio::runtime::Builder::new_current_thread() diff --git a/kordophoned/src/xpc/agent.rs b/kordophoned/src/xpc/agent.rs index e337c08..89dd083 100644 --- a/kordophoned/src/xpc/agent.rs +++ b/kordophoned/src/xpc/agent.rs @@ -1,14 +1,13 @@ -use kordophoned::daemon::{events::Event, signals::Signal, DaemonResult}; use crate::xpc::interface::SERVICE_NAME; use futures_util::StreamExt; +use kordophoned::daemon::{events::Event, signals::Signal, DaemonResult}; use std::collections::HashMap; use std::ffi::CString; use std::sync::Arc; -use tokio::sync::{mpsc, oneshot, Mutex}; use std::thread; +use tokio::sync::{mpsc, oneshot, Mutex}; use xpc_connection::{Message, MessageError, XpcClient, XpcListener}; - static LOG_TARGET: &str = "xpc"; /// XPC IPC agent that forwards daemon events and signals over libxpc. @@ -83,7 +82,9 @@ impl XpcAgent { } } -fn cstr(s: &str) -> CString { CString::new(s).unwrap_or_else(|_| CString::new("").unwrap()) } +fn cstr(s: &str) -> CString { + CString::new(s).unwrap_or_else(|_| CString::new("").unwrap()) +} fn get_string_field(map: &HashMap, key: &str) -> Option { let k = CString::new(key).ok()?; @@ -93,7 +94,10 @@ fn get_string_field(map: &HashMap, key: &str) -> Option(map: &'a HashMap, key: &str) -> Option<&'a HashMap> { +fn get_dictionary_field<'a>( + map: &'a HashMap, + key: &str, +) -> Option<&'a HashMap> { let k = CString::new(key).ok()?; map.get(&k).and_then(|v| match v { Message::Dictionary(d) => Some(d), @@ -106,7 +110,7 @@ fn make_error_reply(code: &str, message: &str) -> Message { reply.insert(cstr("type"), Message::String(cstr("Error"))); reply.insert(cstr("error"), Message::String(cstr(code))); reply.insert(cstr("message"), Message::String(cstr(message))); - + Message::Dictionary(reply) } @@ -151,17 +155,15 @@ async fn dispatch(agent: &XpcAgent, root: &HashMap) -> Message match method.as_str() { // Example implemented method: GetVersion - "GetVersion" => { - match agent.send_event(Event::GetVersion).await { - Ok(version) => { - let mut reply: XpcMap = HashMap::new(); - dict_put_str(&mut reply, "type", "GetVersionResponse"); - dict_put_str(&mut reply, "version", &version); - Message::Dictionary(reply) - } - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + "GetVersion" => match agent.send_event(Event::GetVersion).await { + Ok(version) => { + let mut reply: XpcMap = HashMap::new(); + dict_put_str(&mut reply, "type", "GetVersionResponse"); + dict_put_str(&mut reply, "version", &version); + Message::Dictionary(reply) } - } + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + }, "GetConversations" => { // Defaults @@ -169,20 +171,35 @@ async fn dispatch(agent: &XpcAgent, root: &HashMap) -> Message let mut offset: i32 = 0; if let Some(args) = get_dictionary_field(root, "arguments") { - if let Some(v) = dict_get_i64_from_str(args, "limit") { limit = v as i32; } - if let Some(v) = dict_get_i64_from_str(args, "offset") { offset = v as i32; } + if let Some(v) = dict_get_i64_from_str(args, "limit") { + limit = v as i32; + } + if let Some(v) = dict_get_i64_from_str(args, "offset") { + offset = v as i32; + } } - match agent.send_event(|r| Event::GetAllConversations(limit, offset, r)).await { + match agent + .send_event(|r| Event::GetAllConversations(limit, offset, r)) + .await + { Ok(conversations) => { // Build array of conversation dictionaries let mut items: Vec = Vec::with_capacity(conversations.len()); for conv in conversations { let mut m: XpcMap = HashMap::new(); dict_put_str(&mut m, "guid", &conv.guid); - dict_put_str(&mut m, "display_name", &conv.display_name.unwrap_or_default()); + dict_put_str( + &mut m, + "display_name", + &conv.display_name.unwrap_or_default(), + ); dict_put_i64_as_str(&mut m, "unread_count", conv.unread_count as i64); - dict_put_str(&mut m, "last_message_preview", &conv.last_message_preview.unwrap_or_default()); + dict_put_str( + &mut m, + "last_message_preview", + &conv.last_message_preview.unwrap_or_default(), + ); // participants -> array of strings let participant_names: Vec = conv diff --git a/kordophoned/src/xpc/interface.rs b/kordophoned/src/xpc/interface.rs index 23fa648..9418b17 100644 --- a/kordophoned/src/xpc/interface.rs +++ b/kordophoned/src/xpc/interface.rs @@ -3,6 +3,3 @@ /// Mach service name for the XPC interface (must include trailing NUL). pub const SERVICE_NAME: &str = "net.buzzert.kordophonecd\0"; - -/// Method names for the XPC interface (must include trailing NUL). -pub const GET_VERSION_METHOD: &str = "GetVersion\0"; diff --git a/kpcli/src/daemon/xpc.rs b/kpcli/src/daemon/xpc.rs index 3595651..12851a2 100644 --- a/kpcli/src/daemon/xpc.rs +++ b/kpcli/src/daemon/xpc.rs @@ -14,8 +14,8 @@ use futures::{ Stream, }; - const SERVICE_NAME: &str = "net.buzzert.kordophonecd\0"; + const GET_VERSION_METHOD: &str = "GetVersion"; const GET_CONVERSATIONS_METHOD: &str = "GetConversations"; @@ -32,12 +32,16 @@ impl XPCClient { pub fn connect(name: impl AsRef) -> Self { use block::ConcreteBlock; use xpc_connection::xpc_object_to_message; - use xpc_connection_sys::xpc_connection_set_event_handler; 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) + xpc_connection_sys::xpc_connection_create_mach_service( + name.as_ptr(), + std::ptr::null_mut(), + 0, + ) }; let (sender, receiver) = unbounded_channel(); @@ -78,8 +82,8 @@ impl XPCClient { impl Drop for XPCClient { fn drop(&mut self) { - use xpc_connection_sys::xpc_release; use xpc_connection_sys::xpc_object_t; + use xpc_connection_sys::xpc_release; unsafe { xpc_release(self.connection as xpc_object_t) }; } @@ -118,16 +122,30 @@ impl XpcDaemonInterface { Ok(CString::new(service_name)?) } - fn build_request(method: &str, args: Option>) -> HashMap { + fn build_request( + method: &str, + args: Option>, + ) -> HashMap { let mut request = HashMap::new(); - request.insert(CString::new("method").unwrap(), Message::String(CString::new(method).unwrap())); + 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.insert( + CString::new("arguments").unwrap(), + Message::Dictionary(arguments), + ); } request } - async fn call_method(&self, client: &mut XPCClient, method: &str, args: Option>) -> anyhow::Result> { + async fn call_method( + &self, + client: &mut XPCClient, + method: &str, + args: Option>, + ) -> anyhow::Result> { let request = Self::build_request(method, args); client.send_message(Message::Dictionary(request)); @@ -138,10 +156,15 @@ impl XpcDaemonInterface { } } - fn key(k: &str) -> CString { CString::new(k).unwrap() } + fn key(k: &str) -> CString { + CString::new(k).unwrap() + } fn get_string<'a>(map: &'a HashMap, key: &str) -> Option<&'a CStr> { - map.get(&Self::key(key)).and_then(|v| match v { Message::String(s) => Some(s.as_c_str()), _ => None }) + 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, key: &str) -> Option { @@ -157,7 +180,9 @@ impl DaemonInterface for XpcDaemonInterface { 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?; + 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(()) @@ -165,7 +190,9 @@ impl DaemonInterface for XpcDaemonInterface { println!("XPC replied with type: {}", ty.to_string_lossy()); Ok(()) } else { - Err(anyhow::anyhow!("Unexpected XPC reply payload for GetVersion")) + Err(anyhow::anyhow!( + "Unexpected XPC reply payload for GetVersion" + )) } } @@ -177,8 +204,14 @@ impl DaemonInterface for XpcDaemonInterface { // 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())); + 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 @@ -193,15 +226,26 @@ impl DaemonInterface for XpcDaemonInterface { 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 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 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 = 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(), + 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(), }; @@ -211,7 +255,8 @@ impl DaemonInterface for XpcDaemonInterface { display_name, last_message_preview: last_preview, unread_count, - date: time::OffsetDateTime::from_unix_timestamp(date_ts).unwrap_or_else(|_| time::OffsetDateTime::UNIX_EPOCH), + date: time::OffsetDateTime::from_unix_timestamp(date_ts) + .unwrap_or_else(|_| time::OffsetDateTime::UNIX_EPOCH), participants, }; @@ -220,7 +265,10 @@ impl DaemonInterface for XpcDaemonInterface { } Ok(()) } - Some(other) => Err(anyhow::anyhow!("Unexpected conversations payload: {:?}", other)), + Some(other) => Err(anyhow::anyhow!( + "Unexpected conversations payload: {:?}", + other + )), None => Err(anyhow::anyhow!("Missing conversations in reply")), } } From 16db2caacca6a3bc48e587810d7ccd1e9bc4f120 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 23 Aug 2025 20:13:33 -0700 Subject: [PATCH 09/23] xpc: implement rest of methods in kpcli except signals. --- kordophoned/src/xpc/agent.rs | 159 +++++++++++++++++++++++++++++++++++ kpcli/src/daemon/xpc.rs | 115 +++++++++++++++++++++++-- 2 files changed, 265 insertions(+), 9 deletions(-) diff --git a/kordophoned/src/xpc/agent.rs b/kordophoned/src/xpc/agent.rs index 89dd083..50177a4 100644 --- a/kordophoned/src/xpc/agent.rs +++ b/kordophoned/src/xpc/agent.rs @@ -1,6 +1,7 @@ use crate::xpc::interface::SERVICE_NAME; use futures_util::StreamExt; use kordophoned::daemon::{events::Event, signals::Signal, DaemonResult}; +use kordophoned::daemon::settings::Settings; use std::collections::HashMap; use std::ffi::CString; use std::sync::Arc; @@ -144,6 +145,12 @@ fn array_from_strs(values: impl IntoIterator) -> Message { Message::Array(arr) } +fn make_ok_reply() -> Message { + let mut reply: XpcMap = HashMap::new(); + dict_put_str(&mut reply, "type", "Ok"); + Message::Dictionary(reply) +} + async fn dispatch(agent: &XpcAgent, root: &HashMap) -> Message { // Standardized request: { method: String, arguments: Dictionary? } let method = match dict_get_str(root, "method").or_else(|| dict_get_str(root, "type")) { @@ -224,6 +231,158 @@ async fn dispatch(agent: &XpcAgent, root: &HashMap) -> Message } } + "SyncConversationList" => { + match agent.send_event(Event::SyncConversationList).await { + Ok(()) => make_ok_reply(), + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + "SyncAllConversations" => { + match agent.send_event(Event::SyncAllConversations).await { + Ok(()) => make_ok_reply(), + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + "SyncConversation" => { + let conversation_id = match get_dictionary_field(root, "arguments").and_then(|m| dict_get_str(m, "conversation_id")) { + Some(id) => id, + None => return make_error_reply("InvalidRequest", "Missing conversation_id"), + }; + match agent.send_event(|r| Event::SyncConversation(conversation_id, r)).await { + Ok(()) => make_ok_reply(), + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + "MarkConversationAsRead" => { + let conversation_id = match get_dictionary_field(root, "arguments").and_then(|m| dict_get_str(m, "conversation_id")) { + Some(id) => id, + None => return make_error_reply("InvalidRequest", "Missing conversation_id"), + }; + match agent.send_event(|r| Event::MarkConversationAsRead(conversation_id, r)).await { + Ok(()) => make_ok_reply(), + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + "GetMessages" => { + let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; + let conversation_id = match dict_get_str(args, "conversation_id") { Some(id) => id, None => return make_error_reply("InvalidRequest", "Missing conversation_id") }; + let last_message_id = dict_get_str(args, "last_message_id"); + match agent.send_event(|r| Event::GetMessages(conversation_id, last_message_id, r)).await { + Ok(messages) => { + let mut items: Vec = Vec::with_capacity(messages.len()); + for msg in messages { + let mut m: XpcMap = HashMap::new(); + dict_put_str(&mut m, "id", &msg.id); + dict_put_str(&mut m, "text", &msg.text.replace('\u{FFFC}', "")); + dict_put_i64_as_str(&mut m, "date", msg.date.and_utc().timestamp()); + dict_put_str(&mut m, "sender", &msg.sender.display_name()); + items.push(Message::Dictionary(m)); + } + let mut reply: XpcMap = HashMap::new(); + dict_put_str(&mut reply, "type", "GetMessagesResponse"); + reply.insert(cstr("messages"), Message::Array(items)); + Message::Dictionary(reply) + } + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + "DeleteAllConversations" => { + match agent.send_event(Event::DeleteAllConversations).await { + Ok(()) => make_ok_reply(), + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + "SendMessage" => { + let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; + let conversation_id = match dict_get_str(args, "conversation_id") { Some(v) => v, None => return make_error_reply("InvalidRequest", "Missing conversation_id") }; + let text = dict_get_str(args, "text").unwrap_or_default(); + let attachment_guids: Vec = match args.get(&cstr("attachment_guids")) { + 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(), + }; + match agent.send_event(|r| Event::SendMessage(conversation_id, text, attachment_guids, r)).await { + Ok(uuid) => { + let mut reply: XpcMap = HashMap::new(); + dict_put_str(&mut reply, "type", "SendMessageResponse"); + dict_put_str(&mut reply, "uuid", &uuid.to_string()); + Message::Dictionary(reply) + } + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + "GetAttachmentInfo" => { + let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; + let attachment_id = match dict_get_str(args, "attachment_id") { Some(v) => v, None => return make_error_reply("InvalidRequest", "Missing attachment_id") }; + match agent.send_event(|r| Event::GetAttachment(attachment_id, r)).await { + Ok(attachment) => { + let mut reply: XpcMap = HashMap::new(); + dict_put_str(&mut reply, "type", "GetAttachmentInfoResponse"); + dict_put_str(&mut reply, "path", &attachment.get_path_for_preview(false).to_string_lossy()); + dict_put_str(&mut reply, "preview_path", &attachment.get_path_for_preview(true).to_string_lossy()); + dict_put_str(&mut reply, "downloaded", &attachment.is_downloaded(false).to_string()); + dict_put_str(&mut reply, "preview_downloaded", &attachment.is_downloaded(true).to_string()); + Message::Dictionary(reply) + } + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + "DownloadAttachment" => { + let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; + let attachment_id = match dict_get_str(args, "attachment_id") { Some(v) => v, None => return make_error_reply("InvalidRequest", "Missing attachment_id") }; + let preview = dict_get_str(args, "preview").map(|s| s == "true").unwrap_or(false); + match agent.send_event(|r| Event::DownloadAttachment(attachment_id, preview, r)).await { + Ok(()) => make_ok_reply(), + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + "UploadAttachment" => { + use std::path::PathBuf; + let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; + let path = match dict_get_str(args, "path") { Some(v) => v, None => return make_error_reply("InvalidRequest", "Missing path") }; + match agent.send_event(|r| Event::UploadAttachment(PathBuf::from(path), r)).await { + Ok(upload_guid) => { + let mut reply: XpcMap = HashMap::new(); + dict_put_str(&mut reply, "type", "UploadAttachmentResponse"); + dict_put_str(&mut reply, "upload_guid", &upload_guid); + Message::Dictionary(reply) + } + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + "GetAllSettings" => { + match agent.send_event(Event::GetAllSettings).await { + Ok(settings) => { + let mut reply: XpcMap = HashMap::new(); + dict_put_str(&mut reply, "type", "GetAllSettingsResponse"); + dict_put_str(&mut reply, "server_url", &settings.server_url.unwrap_or_default()); + dict_put_str(&mut reply, "username", &settings.username.unwrap_or_default()); + Message::Dictionary(reply) + } + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + "UpdateSettings" => { + let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; + let server_url = dict_get_str(args, "server_url"); + let username = dict_get_str(args, "username"); + let settings = Settings { server_url, username, token: None }; + match agent.send_event(|r| Event::UpdateSettings(settings, r)).await { + Ok(()) => make_ok_reply(), + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + // Unknown method fallback other => make_error_reply("UnknownMethod", other), } diff --git a/kpcli/src/daemon/xpc.rs b/kpcli/src/daemon/xpc.rs index 12851a2..e302f1c 100644 --- a/kpcli/src/daemon/xpc.rs +++ b/kpcli/src/daemon/xpc.rs @@ -273,41 +273,138 @@ impl DaemonInterface for XpcDaemonInterface { } } async fn sync_conversations(&mut self, _conversation_id: Option) -> Result<()> { - Err(anyhow::anyhow!("Feature not implemented for XPC")) + 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<()> { - Err(anyhow::anyhow!("Feature not implemented for XPC")) + 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, ) -> Result<()> { - Err(anyhow::anyhow!("Feature not implemented for XPC")) + 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<()> { - Err(anyhow::anyhow!("Feature not implemented for XPC")) + 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<()> { Err(anyhow::anyhow!("Feature not implemented for XPC")) } async fn config(&mut self, _cmd: ConfigCommands) -> Result<()> { - Err(anyhow::anyhow!("Feature not implemented for XPC")) + 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<()> { - Err(anyhow::anyhow!("Feature not implemented for XPC")) + 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<()> { - Err(anyhow::anyhow!("Feature not implemented for XPC")) + 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<()> { - Err(anyhow::anyhow!("Feature not implemented for XPC")) + 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<()> { - Err(anyhow::anyhow!("Feature not implemented for XPC")) + 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(()) } } From da813806bbd99757a9926629b13e8758853c683d Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 24 Aug 2025 10:36:39 -0700 Subject: [PATCH 10/23] xpc: implement signals --- kordophoned/src/xpc/agent.rs | 100 +++++++++++++++++++++++++++++------ kpcli/src/daemon/xpc.rs | 61 ++++++++++++++++++++- 2 files changed, 145 insertions(+), 16 deletions(-) diff --git a/kordophoned/src/xpc/agent.rs b/kordophoned/src/xpc/agent.rs index 50177a4..f9bb2aa 100644 --- a/kordophoned/src/xpc/agent.rs +++ b/kordophoned/src/xpc/agent.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use std::ffi::CString; use std::sync::Arc; use std::thread; -use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio::sync::{broadcast, mpsc, oneshot, Mutex}; use xpc_connection::{Message, MessageError, XpcClient, XpcListener}; static LOG_TARGET: &str = "xpc"; @@ -47,10 +47,31 @@ impl XpcAgent { service_name ); + // Broadcast channel for signals to all connected clients + let (signal_tx, _signal_rx) = broadcast::channel::(64); + + // Spawn a single distributor task that forwards daemon signals to broadcast + { + let receiver_arc = self.signal_receiver.clone(); + let signal_tx_clone = signal_tx.clone(); + tokio::spawn(async move { + let mut receiver = receiver_arc + .lock() + .await + .take() + .expect("Signal receiver already taken"); + + while let Some(signal) = receiver.recv().await { + let _ = signal_tx_clone.send(signal); + } + }); + } + let mut listener = XpcListener::listen(&mach_port_name); while let Some(client) = listener.next().await { let agent = self.clone(); + let signal_rx = signal_tx.subscribe(); thread::spawn(move || { let rt = match tokio::runtime::Builder::new_current_thread() .enable_all() @@ -62,7 +83,7 @@ impl XpcAgent { return; } }; - rt.block_on(handle_client(agent, client)); + rt.block_on(handle_client(agent, client, signal_rx)); }); } @@ -383,27 +404,76 @@ async fn dispatch(agent: &XpcAgent, root: &HashMap) -> Message } } + // No-op used by clients to ensure the connection is established and subscribed + "SubscribeSignals" => { + make_ok_reply() + } + // Unknown method fallback other => make_error_reply("UnknownMethod", other), } } -async fn handle_client(agent: XpcAgent, mut client: XpcClient) { +fn signal_to_message(signal: Signal) -> Message { + let mut root: XpcMap = HashMap::new(); + let mut args: XpcMap = HashMap::new(); + match signal { + Signal::ConversationsUpdated => { + dict_put_str(&mut root, "name", "ConversationsUpdated"); + } + Signal::MessagesUpdated(conversation_id) => { + dict_put_str(&mut root, "name", "MessagesUpdated"); + dict_put_str(&mut args, "conversation_id", &conversation_id); + } + Signal::AttachmentDownloaded(attachment_id) => { + dict_put_str(&mut root, "name", "AttachmentDownloadCompleted"); + dict_put_str(&mut args, "attachment_id", &attachment_id); + } + Signal::AttachmentUploaded(upload_guid, attachment_guid) => { + dict_put_str(&mut root, "name", "AttachmentUploadCompleted"); + dict_put_str(&mut args, "upload_guid", &upload_guid); + dict_put_str(&mut args, "attachment_guid", &attachment_guid); + } + Signal::UpdateStreamReconnected => { + dict_put_str(&mut root, "name", "UpdateStreamReconnected"); + } + } + if !args.is_empty() { root.insert(cstr("arguments"), Message::Dictionary(args)); } + Message::Dictionary(root) +} + +async fn handle_client(agent: XpcAgent, mut client: XpcClient, mut signal_rx: broadcast::Receiver) { log::info!(target: LOG_TARGET, "New XPC connection"); - while let Some(message) = client.next().await { - match message { - Message::Error(MessageError::ConnectionInterrupted) => { - log::warn!(target: LOG_TARGET, "XPC connection interrupted"); + loop { + tokio::select! { + maybe_msg = client.next() => { + match maybe_msg { + Some(Message::Error(MessageError::ConnectionInterrupted)) => { + log::warn!(target: LOG_TARGET, "XPC connection interrupted"); + } + Some(Message::Dictionary(map)) => { + let response = dispatch(&agent, &map).await; + client.send_message(response); + } + Some(other) => { + log::info!(target: LOG_TARGET, "Echoing message: {:?}", other); + client.send_message(other); + } + None => break, + } } - Message::Dictionary(map) => { - let response = dispatch(&agent, &map).await; - client.send_message(response); - } - other => { - // For now just echo any non-dictionary messages (useful for testing). - log::info!(target: LOG_TARGET, "Echoing message: {:?}", other); - client.send_message(other); + recv = signal_rx.recv() => { + match recv { + Ok(signal) => { + let msg = signal_to_message(signal); + client.send_message(msg); + } + Err(broadcast::error::RecvError::Closed) => break, + Err(broadcast::error::RecvError::Lagged(_)) => { + log::warn!(target: LOG_TARGET, "Lagged behind on signals; dropping some events for this client"); + } + } } } } diff --git a/kpcli/src/daemon/xpc.rs b/kpcli/src/daemon/xpc.rs index e302f1c..fae71d0 100644 --- a/kpcli/src/daemon/xpc.rs +++ b/kpcli/src/daemon/xpc.rs @@ -347,7 +347,66 @@ impl DaemonInterface for XpcDaemonInterface { Ok(()) } async fn wait_for_signals(&mut self) -> Result<()> { - Err(anyhow::anyhow!("Feature not implemented for XPC")) + let mach_port_name = Self::build_service_name()?; + let mut client = XPCClient::connect(&mach_port_name); + + // Send a subscription/warm-up message so the server loop starts selecting for this client + 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) => { + 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) => break, + _ => {} + } + } + Ok(()) } async fn config(&mut self, _cmd: ConfigCommands) -> Result<()> { let mach_port_name = Self::build_service_name()?; From 06b27c041a4a6df7b8a3673ffc061ac7b65caa72 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 24 Aug 2025 11:04:41 -0700 Subject: [PATCH 11/23] cargo fmt --- kordophoned/src/xpc/agent.rs | 219 +++++++++++++++++++++++++---------- kpcli/src/daemon/xpc.rs | 199 ++++++++++++++++++++++++------- 2 files changed, 312 insertions(+), 106 deletions(-) diff --git a/kordophoned/src/xpc/agent.rs b/kordophoned/src/xpc/agent.rs index f9bb2aa..cd7b0b3 100644 --- a/kordophoned/src/xpc/agent.rs +++ b/kordophoned/src/xpc/agent.rs @@ -1,7 +1,7 @@ use crate::xpc::interface::SERVICE_NAME; use futures_util::StreamExt; -use kordophoned::daemon::{events::Event, signals::Signal, DaemonResult}; use kordophoned::daemon::settings::Settings; +use kordophoned::daemon::{events::Event, signals::Signal, DaemonResult}; use std::collections::HashMap; use std::ffi::CString; use std::sync::Arc; @@ -252,47 +252,62 @@ async fn dispatch(agent: &XpcAgent, root: &HashMap) -> Message } } - "SyncConversationList" => { - match agent.send_event(Event::SyncConversationList).await { - Ok(()) => make_ok_reply(), - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), - } - } + "SyncConversationList" => match agent.send_event(Event::SyncConversationList).await { + Ok(()) => make_ok_reply(), + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + }, - "SyncAllConversations" => { - match agent.send_event(Event::SyncAllConversations).await { - Ok(()) => make_ok_reply(), - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), - } - } + "SyncAllConversations" => match agent.send_event(Event::SyncAllConversations).await { + Ok(()) => make_ok_reply(), + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + }, "SyncConversation" => { - let conversation_id = match get_dictionary_field(root, "arguments").and_then(|m| dict_get_str(m, "conversation_id")) { + let conversation_id = match get_dictionary_field(root, "arguments") + .and_then(|m| dict_get_str(m, "conversation_id")) + { Some(id) => id, None => return make_error_reply("InvalidRequest", "Missing conversation_id"), }; - match agent.send_event(|r| Event::SyncConversation(conversation_id, r)).await { + match agent + .send_event(|r| Event::SyncConversation(conversation_id, r)) + .await + { Ok(()) => make_ok_reply(), Err(e) => make_error_reply("DaemonError", &format!("{}", e)), } } "MarkConversationAsRead" => { - let conversation_id = match get_dictionary_field(root, "arguments").and_then(|m| dict_get_str(m, "conversation_id")) { + let conversation_id = match get_dictionary_field(root, "arguments") + .and_then(|m| dict_get_str(m, "conversation_id")) + { Some(id) => id, None => return make_error_reply("InvalidRequest", "Missing conversation_id"), }; - match agent.send_event(|r| Event::MarkConversationAsRead(conversation_id, r)).await { + match agent + .send_event(|r| Event::MarkConversationAsRead(conversation_id, r)) + .await + { Ok(()) => make_ok_reply(), Err(e) => make_error_reply("DaemonError", &format!("{}", e)), } } "GetMessages" => { - let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; - let conversation_id = match dict_get_str(args, "conversation_id") { Some(id) => id, None => return make_error_reply("InvalidRequest", "Missing conversation_id") }; + let args = match get_dictionary_field(root, "arguments") { + Some(a) => a, + None => return make_error_reply("InvalidRequest", "Missing arguments"), + }; + let conversation_id = match dict_get_str(args, "conversation_id") { + Some(id) => id, + None => return make_error_reply("InvalidRequest", "Missing conversation_id"), + }; let last_message_id = dict_get_str(args, "last_message_id"); - match agent.send_event(|r| Event::GetMessages(conversation_id, last_message_id, r)).await { + match agent + .send_event(|r| Event::GetMessages(conversation_id, last_message_id, r)) + .await + { Ok(messages) => { let mut items: Vec = Vec::with_capacity(messages.len()); for msg in messages { @@ -312,22 +327,35 @@ async fn dispatch(agent: &XpcAgent, root: &HashMap) -> Message } } - "DeleteAllConversations" => { - match agent.send_event(Event::DeleteAllConversations).await { - Ok(()) => make_ok_reply(), - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), - } - } + "DeleteAllConversations" => match agent.send_event(Event::DeleteAllConversations).await { + Ok(()) => make_ok_reply(), + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + }, "SendMessage" => { - let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; - let conversation_id = match dict_get_str(args, "conversation_id") { Some(v) => v, None => return make_error_reply("InvalidRequest", "Missing conversation_id") }; + let args = match get_dictionary_field(root, "arguments") { + Some(a) => a, + None => return make_error_reply("InvalidRequest", "Missing arguments"), + }; + let conversation_id = match dict_get_str(args, "conversation_id") { + Some(v) => v, + None => return make_error_reply("InvalidRequest", "Missing conversation_id"), + }; let text = dict_get_str(args, "text").unwrap_or_default(); let attachment_guids: Vec = match args.get(&cstr("attachment_guids")) { - Some(Message::Array(arr)) => arr.iter().filter_map(|m| match m { Message::String(s) => Some(s.to_string_lossy().into_owned()), _ => None }).collect(), + 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(), }; - match agent.send_event(|r| Event::SendMessage(conversation_id, text, attachment_guids, r)).await { + match agent + .send_event(|r| Event::SendMessage(conversation_id, text, attachment_guids, r)) + .await + { Ok(uuid) => { let mut reply: XpcMap = HashMap::new(); dict_put_str(&mut reply, "type", "SendMessageResponse"); @@ -339,16 +367,41 @@ async fn dispatch(agent: &XpcAgent, root: &HashMap) -> Message } "GetAttachmentInfo" => { - let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; - let attachment_id = match dict_get_str(args, "attachment_id") { Some(v) => v, None => return make_error_reply("InvalidRequest", "Missing attachment_id") }; - match agent.send_event(|r| Event::GetAttachment(attachment_id, r)).await { + let args = match get_dictionary_field(root, "arguments") { + Some(a) => a, + None => return make_error_reply("InvalidRequest", "Missing arguments"), + }; + let attachment_id = match dict_get_str(args, "attachment_id") { + Some(v) => v, + None => return make_error_reply("InvalidRequest", "Missing attachment_id"), + }; + match agent + .send_event(|r| Event::GetAttachment(attachment_id, r)) + .await + { Ok(attachment) => { let mut reply: XpcMap = HashMap::new(); dict_put_str(&mut reply, "type", "GetAttachmentInfoResponse"); - dict_put_str(&mut reply, "path", &attachment.get_path_for_preview(false).to_string_lossy()); - dict_put_str(&mut reply, "preview_path", &attachment.get_path_for_preview(true).to_string_lossy()); - dict_put_str(&mut reply, "downloaded", &attachment.is_downloaded(false).to_string()); - dict_put_str(&mut reply, "preview_downloaded", &attachment.is_downloaded(true).to_string()); + dict_put_str( + &mut reply, + "path", + &attachment.get_path_for_preview(false).to_string_lossy(), + ); + dict_put_str( + &mut reply, + "preview_path", + &attachment.get_path_for_preview(true).to_string_lossy(), + ); + dict_put_str( + &mut reply, + "downloaded", + &attachment.is_downloaded(false).to_string(), + ); + dict_put_str( + &mut reply, + "preview_downloaded", + &attachment.is_downloaded(true).to_string(), + ); Message::Dictionary(reply) } Err(e) => make_error_reply("DaemonError", &format!("{}", e)), @@ -356,10 +409,21 @@ async fn dispatch(agent: &XpcAgent, root: &HashMap) -> Message } "DownloadAttachment" => { - let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; - let attachment_id = match dict_get_str(args, "attachment_id") { Some(v) => v, None => return make_error_reply("InvalidRequest", "Missing attachment_id") }; - let preview = dict_get_str(args, "preview").map(|s| s == "true").unwrap_or(false); - match agent.send_event(|r| Event::DownloadAttachment(attachment_id, preview, r)).await { + let args = match get_dictionary_field(root, "arguments") { + Some(a) => a, + None => return make_error_reply("InvalidRequest", "Missing arguments"), + }; + let attachment_id = match dict_get_str(args, "attachment_id") { + Some(v) => v, + None => return make_error_reply("InvalidRequest", "Missing attachment_id"), + }; + let preview = dict_get_str(args, "preview") + .map(|s| s == "true") + .unwrap_or(false); + match agent + .send_event(|r| Event::DownloadAttachment(attachment_id, preview, r)) + .await + { Ok(()) => make_ok_reply(), Err(e) => make_error_reply("DaemonError", &format!("{}", e)), } @@ -367,9 +431,18 @@ async fn dispatch(agent: &XpcAgent, root: &HashMap) -> Message "UploadAttachment" => { use std::path::PathBuf; - let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; - let path = match dict_get_str(args, "path") { Some(v) => v, None => return make_error_reply("InvalidRequest", "Missing path") }; - match agent.send_event(|r| Event::UploadAttachment(PathBuf::from(path), r)).await { + let args = match get_dictionary_field(root, "arguments") { + Some(a) => a, + None => return make_error_reply("InvalidRequest", "Missing arguments"), + }; + let path = match dict_get_str(args, "path") { + Some(v) => v, + None => return make_error_reply("InvalidRequest", "Missing path"), + }; + match agent + .send_event(|r| Event::UploadAttachment(PathBuf::from(path), r)) + .await + { Ok(upload_guid) => { let mut reply: XpcMap = HashMap::new(); dict_put_str(&mut reply, "type", "UploadAttachmentResponse"); @@ -380,34 +453,48 @@ async fn dispatch(agent: &XpcAgent, root: &HashMap) -> Message } } - "GetAllSettings" => { - match agent.send_event(Event::GetAllSettings).await { - Ok(settings) => { - let mut reply: XpcMap = HashMap::new(); - dict_put_str(&mut reply, "type", "GetAllSettingsResponse"); - dict_put_str(&mut reply, "server_url", &settings.server_url.unwrap_or_default()); - dict_put_str(&mut reply, "username", &settings.username.unwrap_or_default()); - Message::Dictionary(reply) - } - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + "GetAllSettings" => match agent.send_event(Event::GetAllSettings).await { + Ok(settings) => { + let mut reply: XpcMap = HashMap::new(); + dict_put_str(&mut reply, "type", "GetAllSettingsResponse"); + dict_put_str( + &mut reply, + "server_url", + &settings.server_url.unwrap_or_default(), + ); + dict_put_str( + &mut reply, + "username", + &settings.username.unwrap_or_default(), + ); + Message::Dictionary(reply) } - } + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + }, "UpdateSettings" => { - let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; + let args = match get_dictionary_field(root, "arguments") { + Some(a) => a, + None => return make_error_reply("InvalidRequest", "Missing arguments"), + }; let server_url = dict_get_str(args, "server_url"); let username = dict_get_str(args, "username"); - let settings = Settings { server_url, username, token: None }; - match agent.send_event(|r| Event::UpdateSettings(settings, r)).await { + let settings = Settings { + server_url, + username, + token: None, + }; + match agent + .send_event(|r| Event::UpdateSettings(settings, r)) + .await + { Ok(()) => make_ok_reply(), Err(e) => make_error_reply("DaemonError", &format!("{}", e)), } } // No-op used by clients to ensure the connection is established and subscribed - "SubscribeSignals" => { - make_ok_reply() - } + "SubscribeSignals" => make_ok_reply(), // Unknown method fallback other => make_error_reply("UnknownMethod", other), @@ -438,11 +525,17 @@ fn signal_to_message(signal: Signal) -> Message { dict_put_str(&mut root, "name", "UpdateStreamReconnected"); } } - if !args.is_empty() { root.insert(cstr("arguments"), Message::Dictionary(args)); } + if !args.is_empty() { + root.insert(cstr("arguments"), Message::Dictionary(args)); + } Message::Dictionary(root) } -async fn handle_client(agent: XpcAgent, mut client: XpcClient, mut signal_rx: broadcast::Receiver) { +async fn handle_client( + agent: XpcAgent, + mut client: XpcClient, + mut signal_rx: broadcast::Receiver, +) { log::info!(target: LOG_TARGET, "New XPC connection"); loop { diff --git a/kpcli/src/daemon/xpc.rs b/kpcli/src/daemon/xpc.rs index fae71d0..d67d9c2 100644 --- a/kpcli/src/daemon/xpc.rs +++ b/kpcli/src/daemon/xpc.rs @@ -278,18 +278,27 @@ impl DaemonInterface for XpcDaemonInterface { 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?; + 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?; + 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?; + let _ = self + .call_method(&mut client, "SyncConversationList", None) + .await?; Ok(()) } async fn print_messages( @@ -301,24 +310,39 @@ impl DaemonInterface for XpcDaemonInterface { 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("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())); + args.insert( + Self::key("last_message_id"), + Message::String(CString::new(last).unwrap()), + ); } - let reply = self.call_method(&mut client, "GetMessages", Some(args)).await?; + 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 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), + date: time::OffsetDateTime::from_unix_timestamp(date_ts) + .unwrap_or_else(|_| time::OffsetDateTime::UNIX_EPOCH), sender, text, file_transfer_guids: vec![], @@ -340,10 +364,20 @@ impl DaemonInterface for XpcDaemonInterface { 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()); } + 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<()> { @@ -351,7 +385,10 @@ impl DaemonInterface for XpcDaemonInterface { let mut client = XPCClient::connect(&mach_port_name); // Send a subscription/warm-up message so the server loop starts selecting for this client - client.send_message(Message::Dictionary(Self::build_request("SubscribeSignals", None))); + client.send_message(Message::Dictionary(Self::build_request( + "SubscribeSignals", + None, + ))); println!("Waiting for XPC signals..."); while let Some(msg) = client.next().await { @@ -359,7 +396,10 @@ impl DaemonInterface for XpcDaemonInterface { Message::Dictionary(map) => { 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 }; + let name = match map.get(&name_key) { + Some(Message::String(s)) => s.to_string_lossy().into_owned(), + _ => continue, + }; match name.as_str() { "ConversationsUpdated" => { @@ -367,8 +407,13 @@ impl DaemonInterface for XpcDaemonInterface { } "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()); + if let Some(Message::String(cid)) = + args.get(&Self::key("conversation_id")) + { + println!( + "Signal: Messages updated for conversation {}", + cid.to_string_lossy() + ); } } } @@ -377,23 +422,52 @@ impl DaemonInterface for XpcDaemonInterface { } "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()); + 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()); + 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); + 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" => { @@ -413,23 +487,40 @@ impl DaemonInterface for XpcDaemonInterface { 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]); + 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?; + 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?; + args.insert( + Self::key("username"), + Message::String(CString::new(username).unwrap()), + ); + let _ = self + .call_method(&mut client, "UpdateSettings", Some(args)) + .await?; Ok(()) } } @@ -437,33 +528,55 @@ impl DaemonInterface for XpcDaemonInterface { 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?; + 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?; + 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()); } + 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?; + args.insert( + Self::key("conversation_id"), + Message::String(CString::new(_conversation_id).unwrap()), + ); + let _ = self + .call_method(&mut client, "MarkConversationAsRead", Some(args)) + .await?; Ok(()) } } From a93a77307149dc12e37a17f2003939b6a1ba3d82 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 24 Aug 2025 15:28:33 -0700 Subject: [PATCH 12/23] xpc: Use reply port when replying to RPC messages --- Cargo.lock | 123 +-------- kordophoned/Cargo.toml | 3 +- .../include/net.buzzert.kordophonecd.plist | 2 +- kordophoned/src/xpc/agent.rs | 249 ++++++++++++------ kpcli/src/daemon/xpc.rs | 40 ++- 5 files changed, 205 insertions(+), 212 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f90bab8..1bd2904 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -745,16 +745,6 @@ dependencies = [ "futures-util", ] -[[package]] -name = "futures-async-runtime-preview" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33c03035be1dae627b7e05c6984acb1f2086043fde5249ae51604f1ff20ed037" -dependencies = [ - "futures-core-preview", - "futures-stable-preview", -] - [[package]] name = "futures-channel" version = "0.3.31" @@ -765,30 +755,12 @@ dependencies = [ "futures-sink", ] -[[package]] -name = "futures-channel-preview" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f8aec6b0eb1d281843ec666fba2b71a49610181e3078fbef7a8cbed481821e" -dependencies = [ - "futures-core-preview", -] - [[package]] name = "futures-core" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" -[[package]] -name = "futures-core-preview" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "098785413db44e5dbf3b1fc23c24039a9091bea5acb3eb0d293f386f18aff97d" -dependencies = [ - "either", -] - [[package]] name = "futures-executor" version = "0.3.31" @@ -800,35 +772,12 @@ dependencies = [ "futures-util", ] -[[package]] -name = "futures-executor-preview" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28ff61425699ca85de5c63c1f135278403518c3398bd15cf4b6fd1d21c9846e4" -dependencies = [ - "futures-channel-preview", - "futures-core-preview", - "futures-util-preview", - "lazy_static", - "num_cpus", -] - [[package]] name = "futures-io" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" -[[package]] -name = "futures-io-preview" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaa769a6ac904912c1557b4dcf85b93db2bc9ba57c349f9ce43870e49d67f8e1" -dependencies = [ - "futures-core-preview", - "iovec", -] - [[package]] name = "futures-macro" version = "0.3.31" @@ -840,49 +789,12 @@ dependencies = [ "syn", ] -[[package]] -name = "futures-preview" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4d575096a4e2cf458f309b5b7bce5c8aaad8e874b8d77f0aa26c08d7ac18f74" -dependencies = [ - "futures-async-runtime-preview", - "futures-channel-preview", - "futures-core-preview", - "futures-executor-preview", - "futures-io-preview", - "futures-sink-preview", - "futures-stable-preview", - "futures-util-preview", -] - [[package]] name = "futures-sink" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" -[[package]] -name = "futures-sink-preview" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dc4cdc628b934f18a11ba070d589655f68cfec031a16381b0e7784ff0e9cc18" -dependencies = [ - "either", - "futures-channel-preview", - "futures-core-preview", -] - -[[package]] -name = "futures-stable-preview" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6ba960b8bbbc14a9a741cc8ad9c26aff44538ea14be021db905b43f33854da" -dependencies = [ - "futures-core-preview", - "futures-executor-preview", -] - [[package]] name = "futures-task" version = "0.3.31" @@ -907,19 +819,6 @@ dependencies = [ "slab", ] -[[package]] -name = "futures-util-preview" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b29aa737dba9e2e47a5dcd4d58ec7c7c2d5f78e8460f609f857bcf04163235e" -dependencies = [ - "either", - "futures-channel-preview", - "futures-core-preview", - "futures-io-preview", - "futures-sink-preview", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -1144,15 +1043,6 @@ dependencies = [ "hashbrown", ] -[[package]] -name = "iovec" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2b3ea6ff95e175473f8ffe6a7eb7c00d054240321b84c57051175fe3c1e075e" -dependencies = [ - "libc", -] - [[package]] name = "is-terminal" version = "0.4.16" @@ -1270,6 +1160,7 @@ version = "1.0.0" dependencies = [ "anyhow", "async-trait", + "block", "chrono", "dbus", "dbus-codegen", @@ -1278,7 +1169,7 @@ dependencies = [ "dbus-tree", "directories", "env_logger 0.11.8", - "futures-preview", + "futures", "futures-util", "keyring", "kordophone", @@ -1556,16 +1447,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi 0.5.0", - "libc", -] - [[package]] name = "object" version = "0.32.2" diff --git a/kordophoned/Cargo.toml b/kordophoned/Cargo.toml index 22b005f..dedfe31 100644 --- a/kordophoned/Cargo.toml +++ b/kordophoned/Cargo.toml @@ -35,7 +35,8 @@ dbus-crossroads = "0.5.1" # XPC (libxpc) interface for macOS IPC [target.'cfg(target_os = "macos")'.dependencies] -futures-preview = "=0.2.2" +block = "0.1.6" +futures = "0.3.31" xpc-connection = { git = "https://github.com/dfrankland/xpc-connection-rs.git", rev = "cd4fb3d", package = "xpc-connection" } xpc-connection-sys = { git = "https://github.com/dfrankland/xpc-connection-rs.git", rev = "cd4fb3d", package = "xpc-connection-sys" } serde = { version = "1.0", features = ["derive"] } diff --git a/kordophoned/include/net.buzzert.kordophonecd.plist b/kordophoned/include/net.buzzert.kordophonecd.plist index 976049b..df9c1a1 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.plist +++ b/kordophoned/include/net.buzzert.kordophonecd.plist @@ -7,7 +7,7 @@ ProgramArguments - /Users/buzzert/src/kordophone-rs/target/debug/kordophoned + /Users/buzzert/src/kordophone/kordophone-rs/target/debug/kordophoned EnvironmentVariables diff --git a/kordophoned/src/xpc/agent.rs b/kordophoned/src/xpc/agent.rs index cd7b0b3..49079bb 100644 --- a/kordophoned/src/xpc/agent.rs +++ b/kordophoned/src/xpc/agent.rs @@ -1,16 +1,25 @@ use crate::xpc::interface::SERVICE_NAME; -use futures_util::StreamExt; use kordophoned::daemon::settings::Settings; use kordophoned::daemon::{events::Event, signals::Signal, DaemonResult}; use std::collections::HashMap; use std::ffi::CString; +use std::os::raw::c_char; +use std::ptr; use std::sync::Arc; use std::thread; -use tokio::sync::{broadcast, mpsc, oneshot, Mutex}; -use xpc_connection::{Message, MessageError, XpcClient, XpcListener}; +use tokio::sync::{mpsc, oneshot, Mutex}; +use xpc_connection::{message_to_xpc_object, xpc_object_to_message, Message, MessageError}; +use xpc_connection_sys as xpc_sys; static LOG_TARGET: &str = "xpc"; +/// Wrapper for raw XPC connection pointer to declare cross-thread usage. +/// Safety: libxpc connections are reference-counted and may be used to send from other threads. +#[derive(Copy, Clone)] +struct XpcConn(pub xpc_sys::xpc_connection_t); +unsafe impl Send for XpcConn {} +unsafe impl Sync for XpcConn {} + /// XPC IPC agent that forwards daemon events and signals over libxpc. #[derive(Clone)] pub struct XpcAgent { @@ -21,14 +30,15 @@ pub struct XpcAgent { impl XpcAgent { /// Create a new XPC agent with an event sink and signal receiver. pub fn new(event_sink: mpsc::Sender, signal_receiver: mpsc::Receiver) -> Self { - Self { - event_sink, - signal_receiver: Arc::new(Mutex::new(Some(signal_receiver))), - } + Self { event_sink, signal_receiver: Arc::new(Mutex::new(Some(signal_receiver))) } } /// Run the XPC agent and host the XPC service. Implements generic dispatch. pub async fn run(self) { + use block::ConcreteBlock; + use std::ops::Deref; + use std::sync::Mutex as StdMutex; + log::info!(target: LOG_TARGET, "XPCAgent running"); // Construct the Mach service name without a trailing NUL for CString. @@ -47,47 +57,137 @@ impl XpcAgent { service_name ); - // Broadcast channel for signals to all connected clients - let (signal_tx, _signal_rx) = broadcast::channel::(64); + // Multi-thread runtime to drive async dispatch from XPC event handlers. + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => Arc::new(rt), + Err(e) => { + log::error!(target: LOG_TARGET, "Failed to create Tokio runtime: {}", e); + return; + } + }; - // Spawn a single distributor task that forwards daemon signals to broadcast + // Shared list of connected clients for signal fanout + let connections: Arc>> = Arc::new(StdMutex::new(Vec::new())); + // Forward daemon signals to all connected clients { let receiver_arc = self.signal_receiver.clone(); - let signal_tx_clone = signal_tx.clone(); - tokio::spawn(async move { + let conns = connections.clone(); + rt.spawn(async move { let mut receiver = receiver_arc .lock() .await .take() .expect("Signal receiver already taken"); - while let Some(signal) = receiver.recv().await { - let _ = signal_tx_clone.send(signal); + log::info!(target: LOG_TARGET, "Broadcasting signal: {:?}", signal); + let msg = signal_to_message(signal); + let xobj = unsafe { message_to_xpc_object(msg) }; + let list = conns.lock().unwrap(); + log::info!(target: LOG_TARGET, "Active XPC clients: {}", list.len()); + for c in list.iter() { + log::info!(target: LOG_TARGET, "Sending signal to client"); + unsafe { xpc_sys::xpc_connection_send_message(c.0, xobj) }; + } + unsafe { xpc_sys::xpc_release(xobj) }; } }); } - let mut listener = XpcListener::listen(&mach_port_name); + // Create the XPC Mach service listener. + let service = unsafe { + xpc_sys::xpc_connection_create_mach_service( + mach_port_name.as_ptr(), + ptr::null_mut(), + xpc_sys::XPC_CONNECTION_MACH_SERVICE_LISTENER as u64, + ) + }; - while let Some(client) = listener.next().await { - let agent = self.clone(); - let signal_rx = signal_tx.subscribe(); - thread::spawn(move || { - let rt = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(rt) => rt, - Err(e) => { - log::error!(target: LOG_TARGET, "Failed to build runtime for client: {}", e); - return; - } - }; - rt.block_on(handle_client(agent, client, signal_rx)); - }); + // Event handler for the service: accepts new client connections. + let agent = self.clone(); + let rt_accept = rt.clone(); + let conns_accept = connections.clone(); + let service_handler = ConcreteBlock::new(move |event: xpc_sys::xpc_object_t| { + unsafe { + // Treat incoming events as connections; ignore others + // We detect connections by trying to set a per-connection handler. + let client = event as xpc_sys::xpc_connection_t; + log::info!(target: LOG_TARGET, "New XPC connection accepted"); + // Do not register for signals until the client explicitly subscribes + + // Per-connection handler + let agent_conn = agent.clone(); + let rt_conn = rt_accept.clone(); + let conns_for_handler = conns_accept.clone(); + let conn_handler = ConcreteBlock::new(move |msg: xpc_sys::xpc_object_t| { + unsafe { + // Convert to higher-level Message for type matching + match xpc_object_to_message(msg) { + Message::Dictionary(map) => { + // Trace inbound method + let method = dict_get_str(&map, "method").or_else(|| dict_get_str(&map, "type")).unwrap_or_else(|| "".to_string()); + log::info!(target: LOG_TARGET, "XPC request received: {}", method); + let response = rt_conn.block_on(dispatch(&agent_conn, &conns_for_handler, client, &map)); + let reply = xpc_sys::xpc_dictionary_create_reply(msg); + if !reply.is_null() { + let payload = message_to_xpc_object(response); + let apply_block = ConcreteBlock::new(move |key: *const c_char, value: xpc_sys::xpc_object_t| { + xpc_sys::xpc_dictionary_set_value(reply, key, value); + }) + .copy(); + xpc_sys::xpc_dictionary_apply(payload, apply_block.deref() as *const _ as *mut _); + xpc_sys::xpc_connection_send_message(client, reply); + xpc_sys::xpc_release(payload); + xpc_sys::xpc_release(reply); + log::info!(target: LOG_TARGET, "XPC reply sent for method: {}", method); + } else { + log::warn!(target: LOG_TARGET, "No reply port for method: {}", method); + } + } + Message::Error(e) => { + match e { + MessageError::ConnectionInvalid => { + // Normal for one-shot RPC connections; keep logs quiet + let mut list = conns_for_handler.lock().unwrap(); + let before = list.len(); + list.retain(|c| c.0 != client); + let after = list.len(); + if after < before { + log::info!(target: LOG_TARGET, "Removed closed XPC client from subscribers ({} -> {})", before, after); + } else { + log::debug!(target: LOG_TARGET, "XPC connection closed (no subscription)"); + } + } + other => { + log::warn!(target: LOG_TARGET, "XPC error event: {:?}", other); + } + } + } + _ => {} + } + } + }) + .copy(); + + xpc_sys::xpc_connection_set_event_handler( + client, + conn_handler.deref() as *const _ as *mut _, + ); + xpc_sys::xpc_connection_resume(client); + } + } + ) + .copy(); + + unsafe { + xpc_sys::xpc_connection_set_event_handler( + service, + service_handler.deref() as *const _ as *mut _, + ); + xpc_sys::xpc_connection_resume(service); } - log::info!(target: LOG_TARGET, "XPC listener shutting down"); + // Keep this future alive forever. + futures_util::future::pending::<()>().await; } /// Send an event to the daemon and await its reply. @@ -128,6 +228,8 @@ fn get_dictionary_field<'a>( } fn make_error_reply(code: &str, message: &str) -> Message { + log::error!(target: LOG_TARGET, "XPC error: {code}: {message}"); + let mut reply: HashMap = HashMap::new(); reply.insert(cstr("type"), Message::String(cstr("Error"))); reply.insert(cstr("error"), Message::String(cstr(code))); @@ -172,16 +274,31 @@ fn make_ok_reply() -> Message { Message::Dictionary(reply) } -async fn dispatch(agent: &XpcAgent, root: &HashMap) -> Message { - // Standardized request: { method: String, arguments: Dictionary? } +/// Attach an optional request_id to a dictionary reply message. +fn attach_request_id(mut message: Message, request_id: Option) -> Message { + if let (Some(id), Message::Dictionary(ref mut m)) = (request_id, &mut message) { + dict_put_str(m, "request_id", &id); + } + message +} + +async fn dispatch( + agent: &XpcAgent, + subscribers: &std::sync::Mutex>, + current_client: xpc_sys::xpc_connection_t, + root: &HashMap, +) -> Message { + // Standardized request: { method: String, arguments: Dictionary?, request_id: String? } + let request_id = dict_get_str(root, "request_id"); + let method = match dict_get_str(root, "method").or_else(|| dict_get_str(root, "type")) { Some(m) => m, - None => return make_error_reply("InvalidRequest", "Missing method/type"), + None => return attach_request_id(make_error_reply("InvalidRequest", "Missing method/type"), request_id), }; let _arguments = get_dictionary_field(root, "arguments"); - match method.as_str() { + let mut response = match method.as_str() { // Example implemented method: GetVersion "GetVersion" => match agent.send_event(Event::GetVersion).await { Ok(version) => { @@ -493,12 +610,24 @@ async fn dispatch(agent: &XpcAgent, root: &HashMap) -> Message } } - // No-op used by clients to ensure the connection is established and subscribed - "SubscribeSignals" => make_ok_reply(), + // Subscribe and return immediately + "SubscribeSignals" => { + let mut list = subscribers.lock().unwrap(); + // Avoid duplicates + if !list.iter().any(|c| c.0 == current_client) { + list.push(XpcConn(current_client)); + log::info!(target: LOG_TARGET, "Client subscribed to signals (total subscribers: {})", list.len()); + } + make_ok_reply() + }, // Unknown method fallback other => make_error_reply("UnknownMethod", other), - } + }; + + // Echo request_id back (if present) so clients can correlate replies + response = attach_request_id(response, request_id); + response } fn signal_to_message(signal: Signal) -> Message { @@ -531,45 +660,5 @@ fn signal_to_message(signal: Signal) -> Message { Message::Dictionary(root) } -async fn handle_client( - agent: XpcAgent, - mut client: XpcClient, - mut signal_rx: broadcast::Receiver, -) { - log::info!(target: LOG_TARGET, "New XPC connection"); +// legacy async client handler removed in reply-port implementation - loop { - tokio::select! { - maybe_msg = client.next() => { - match maybe_msg { - Some(Message::Error(MessageError::ConnectionInterrupted)) => { - log::warn!(target: LOG_TARGET, "XPC connection interrupted"); - } - Some(Message::Dictionary(map)) => { - let response = dispatch(&agent, &map).await; - client.send_message(response); - } - Some(other) => { - log::info!(target: LOG_TARGET, "Echoing message: {:?}", other); - client.send_message(other); - } - None => break, - } - } - recv = signal_rx.recv() => { - match recv { - Ok(signal) => { - let msg = signal_to_message(signal); - client.send_message(msg); - } - Err(broadcast::error::RecvError::Closed) => break, - Err(broadcast::error::RecvError::Lagged(_)) => { - log::warn!(target: LOG_TARGET, "Lagged behind on signals; dropping some events for this client"); - } - } - } - } - } - - log::info!(target: LOG_TARGET, "XPC connection closed"); -} diff --git a/kpcli/src/daemon/xpc.rs b/kpcli/src/daemon/xpc.rs index d67d9c2..bada46e 100644 --- a/kpcli/src/daemon/xpc.rs +++ b/kpcli/src/daemon/xpc.rs @@ -78,6 +78,23 @@ impl XPCClient { 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 { @@ -147,12 +164,10 @@ impl XpcDaemonInterface { args: Option>, ) -> anyhow::Result> { let request = Self::build_request(method, args); - client.send_message(Message::Dictionary(request)); - - match client.next().await { - Some(Message::Dictionary(map)) => Ok(map), - Some(other) => Err(anyhow::anyhow!("Unexpected XPC reply: {:?}", other)), - None => Err(anyhow::anyhow!("No reply received from XPC daemon")), + 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)), } } @@ -384,7 +399,8 @@ impl DaemonInterface for XpcDaemonInterface { let mach_port_name = Self::build_service_name()?; let mut client = XPCClient::connect(&mach_port_name); - // Send a subscription/warm-up message so the server loop starts selecting for this client + // Subscribe to begin receiving signals on this connection + eprintln!("[kpcli] Sending SubscribeSignals"); client.send_message(Message::Dictionary(Self::build_request( "SubscribeSignals", None, @@ -394,6 +410,7 @@ impl DaemonInterface for XpcDaemonInterface { 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) { @@ -476,8 +493,13 @@ impl DaemonInterface for XpcDaemonInterface { _ => {} } } - Message::Error(xpc_connection::MessageError::ConnectionInvalid) => break, - _ => {} + Message::Error(xpc_connection::MessageError::ConnectionInvalid) => { + eprintln!("[kpcli] XPC connection invalid"); + break + } + other => { + eprintln!("[kpcli] Unexpected XPC message: {:?}", other); + } } } Ok(()) From 73508bea9e29931cf5afeaa38bdf61fafa595875 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 24 Aug 2025 15:34:05 -0700 Subject: [PATCH 13/23] xpc: refactor, less chatty logging --- kordophoned/src/xpc/agent.rs | 96 ++++++++++++++++++------------------ 1 file changed, 47 insertions(+), 49 deletions(-) diff --git a/kordophoned/src/xpc/agent.rs b/kordophoned/src/xpc/agent.rs index 49079bb..880334a 100644 --- a/kordophoned/src/xpc/agent.rs +++ b/kordophoned/src/xpc/agent.rs @@ -6,7 +6,6 @@ use std::ffi::CString; use std::os::raw::c_char; use std::ptr; use std::sync::Arc; -use std::thread; use tokio::sync::{mpsc, oneshot, Mutex}; use xpc_connection::{message_to_xpc_object, xpc_object_to_message, Message, MessageError}; use xpc_connection_sys as xpc_sys; @@ -20,6 +19,8 @@ struct XpcConn(pub xpc_sys::xpc_connection_t); unsafe impl Send for XpcConn {} unsafe impl Sync for XpcConn {} +type Subscribers = Arc>>; + /// XPC IPC agent that forwards daemon events and signals over libxpc. #[derive(Clone)] pub struct XpcAgent { @@ -37,9 +38,6 @@ impl XpcAgent { pub async fn run(self) { use block::ConcreteBlock; use std::ops::Deref; - use std::sync::Mutex as StdMutex; - - log::info!(target: LOG_TARGET, "XPCAgent running"); // Construct the Mach service name without a trailing NUL for CString. let service_name = SERVICE_NAME.trim_end_matches('\0'); @@ -67,7 +65,7 @@ impl XpcAgent { }; // Shared list of connected clients for signal fanout - let connections: Arc>> = Arc::new(StdMutex::new(Vec::new())); + let connections: Subscribers = Arc::new(std::sync::Mutex::new(Vec::new())); // Forward daemon signals to all connected clients { let receiver_arc = self.signal_receiver.clone(); @@ -79,13 +77,13 @@ impl XpcAgent { .take() .expect("Signal receiver already taken"); while let Some(signal) = receiver.recv().await { - log::info!(target: LOG_TARGET, "Broadcasting signal: {:?}", signal); + log::trace!(target: LOG_TARGET, "Broadcasting signal: {:?}", signal); let msg = signal_to_message(signal); - let xobj = unsafe { message_to_xpc_object(msg) }; + let xobj = message_to_xpc_object(msg); let list = conns.lock().unwrap(); - log::info!(target: LOG_TARGET, "Active XPC clients: {}", list.len()); + log::trace!(target: LOG_TARGET, "Active XPC clients: {}", list.len()); for c in list.iter() { - log::info!(target: LOG_TARGET, "Sending signal to client"); + log::trace!(target: LOG_TARGET, "Sending signal to client"); unsafe { xpc_sys::xpc_connection_send_message(c.0, xobj) }; } unsafe { xpc_sys::xpc_release(xobj) }; @@ -111,7 +109,7 @@ impl XpcAgent { // Treat incoming events as connections; ignore others // We detect connections by trying to set a per-connection handler. let client = event as xpc_sys::xpc_connection_t; - log::info!(target: LOG_TARGET, "New XPC connection accepted"); + log::trace!(target: LOG_TARGET, "New XPC connection accepted"); // Do not register for signals until the client explicitly subscribes // Per-connection handler @@ -119,51 +117,51 @@ impl XpcAgent { let rt_conn = rt_accept.clone(); let conns_for_handler = conns_accept.clone(); let conn_handler = ConcreteBlock::new(move |msg: xpc_sys::xpc_object_t| { - unsafe { - // Convert to higher-level Message for type matching - match xpc_object_to_message(msg) { - Message::Dictionary(map) => { - // Trace inbound method - let method = dict_get_str(&map, "method").or_else(|| dict_get_str(&map, "type")).unwrap_or_else(|| "".to_string()); - log::info!(target: LOG_TARGET, "XPC request received: {}", method); - let response = rt_conn.block_on(dispatch(&agent_conn, &conns_for_handler, client, &map)); - let reply = xpc_sys::xpc_dictionary_create_reply(msg); - if !reply.is_null() { - let payload = message_to_xpc_object(response); - let apply_block = ConcreteBlock::new(move |key: *const c_char, value: xpc_sys::xpc_object_t| { - xpc_sys::xpc_dictionary_set_value(reply, key, value); - }) - .copy(); + // Convert to higher-level Message for type matching + match xpc_object_to_message(msg) { + Message::Dictionary(map) => { + // Trace inbound method + let method = dict_get_str(&map, "method").or_else(|| dict_get_str(&map, "type")).unwrap_or_else(|| "".to_string()); + log::trace!(target: LOG_TARGET, "XPC request received: {}", method); + let response = rt_conn.block_on(dispatch(&agent_conn, &conns_for_handler, client, &map)); + let reply = unsafe { xpc_sys::xpc_dictionary_create_reply(msg) }; + if !reply.is_null() { + let payload = message_to_xpc_object(response); + let apply_block = ConcreteBlock::new(move |key: *const c_char, value: xpc_sys::xpc_object_t| { + unsafe { xpc_sys::xpc_dictionary_set_value(reply, key, value); } + }) + .copy(); + unsafe { xpc_sys::xpc_dictionary_apply(payload, apply_block.deref() as *const _ as *mut _); xpc_sys::xpc_connection_send_message(client, reply); xpc_sys::xpc_release(payload); xpc_sys::xpc_release(reply); - log::info!(target: LOG_TARGET, "XPC reply sent for method: {}", method); - } else { - log::warn!(target: LOG_TARGET, "No reply port for method: {}", method); } + log::trace!(target: LOG_TARGET, "XPC reply sent for method: {}", method); + } else { + log::warn!(target: LOG_TARGET, "No reply port for method: {}", method); } - Message::Error(e) => { - match e { - MessageError::ConnectionInvalid => { - // Normal for one-shot RPC connections; keep logs quiet - let mut list = conns_for_handler.lock().unwrap(); - let before = list.len(); - list.retain(|c| c.0 != client); - let after = list.len(); - if after < before { - log::info!(target: LOG_TARGET, "Removed closed XPC client from subscribers ({} -> {})", before, after); - } else { - log::debug!(target: LOG_TARGET, "XPC connection closed (no subscription)"); - } - } - other => { - log::warn!(target: LOG_TARGET, "XPC error event: {:?}", other); - } - } - } - _ => {} } + Message::Error(e) => { + match e { + MessageError::ConnectionInvalid => { + // Normal for one-shot RPC connections; keep logs quiet + let mut list = conns_for_handler.lock().unwrap(); + let before = list.len(); + list.retain(|c| c.0 != client); + let after = list.len(); + if after < before { + log::trace!(target: LOG_TARGET, "Removed closed XPC client from subscribers ({} -> {})", before, after); + } else { + log::debug!(target: LOG_TARGET, "XPC connection closed (no subscription)"); + } + } + other => { + log::warn!(target: LOG_TARGET, "XPC error event: {:?}", other); + } + } + } + _ => {} } }) .copy(); @@ -616,7 +614,7 @@ async fn dispatch( // Avoid duplicates if !list.iter().any(|c| c.0 == current_client) { list.push(XpcConn(current_client)); - log::info!(target: LOG_TARGET, "Client subscribed to signals (total subscribers: {})", list.len()); + log::trace!(target: LOG_TARGET, "Client subscribed to signals (total subscribers: {})", list.len()); } make_ok_reply() }, From 00bbc3b330da2e403656a74bc87c0d7ac0b61c44 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 24 Aug 2025 15:49:55 -0700 Subject: [PATCH 14/23] xpc: refactor -- separate rpc impl and xpc glue --- kordophoned/src/xpc/agent.rs | 590 ++++------------------------------- kordophoned/src/xpc/mod.rs | 2 + kordophoned/src/xpc/rpc.rs | 235 ++++++++++++++ kordophoned/src/xpc/util.rs | 87 ++++++ 4 files changed, 381 insertions(+), 533 deletions(-) create mode 100644 kordophoned/src/xpc/rpc.rs create mode 100644 kordophoned/src/xpc/util.rs diff --git a/kordophoned/src/xpc/agent.rs b/kordophoned/src/xpc/agent.rs index 880334a..226f1b5 100644 --- a/kordophoned/src/xpc/agent.rs +++ b/kordophoned/src/xpc/agent.rs @@ -10,18 +10,17 @@ use tokio::sync::{mpsc, oneshot, Mutex}; use xpc_connection::{message_to_xpc_object, xpc_object_to_message, Message, MessageError}; use xpc_connection_sys as xpc_sys; -static LOG_TARGET: &str = "xpc"; +pub(super) static LOG_TARGET: &str = "xpc"; /// Wrapper for raw XPC connection pointer to declare cross-thread usage. /// Safety: libxpc connections are reference-counted and may be used to send from other threads. #[derive(Copy, Clone)] -struct XpcConn(pub xpc_sys::xpc_connection_t); +pub(super) struct XpcConn(pub xpc_sys::xpc_connection_t); unsafe impl Send for XpcConn {} unsafe impl Sync for XpcConn {} type Subscribers = Arc>>; -/// XPC IPC agent that forwards daemon events and signals over libxpc. #[derive(Clone)] pub struct XpcAgent { event_sink: mpsc::Sender, @@ -29,12 +28,10 @@ pub struct XpcAgent { } impl XpcAgent { - /// Create a new XPC agent with an event sink and signal receiver. pub fn new(event_sink: mpsc::Sender, signal_receiver: mpsc::Receiver) -> Self { Self { event_sink, signal_receiver: Arc::new(Mutex::new(Some(signal_receiver))) } } - /// Run the XPC agent and host the XPC service. Implements generic dispatch. pub async fn run(self) { use block::ConcreteBlock; use std::ops::Deref; @@ -55,7 +52,6 @@ impl XpcAgent { service_name ); - // Multi-thread runtime to drive async dispatch from XPC event handlers. let rt = match tokio::runtime::Runtime::new() { Ok(rt) => Arc::new(rt), Err(e) => { @@ -64,9 +60,7 @@ impl XpcAgent { } }; - // Shared list of connected clients for signal fanout let connections: Subscribers = Arc::new(std::sync::Mutex::new(Vec::new())); - // Forward daemon signals to all connected clients { let receiver_arc = self.signal_receiver.clone(); let conns = connections.clone(); @@ -78,7 +72,7 @@ impl XpcAgent { .expect("Signal receiver already taken"); while let Some(signal) = receiver.recv().await { log::trace!(target: LOG_TARGET, "Broadcasting signal: {:?}", signal); - let msg = signal_to_message(signal); + let msg = super::util::signal_to_message(signal); let xobj = message_to_xpc_object(msg); let list = conns.lock().unwrap(); log::trace!(target: LOG_TARGET, "Active XPC clients: {}", list.len()); @@ -91,7 +85,6 @@ impl XpcAgent { }); } - // Create the XPC Mach service listener. let service = unsafe { xpc_sys::xpc_connection_create_mach_service( mach_port_name.as_ptr(), @@ -100,80 +93,71 @@ impl XpcAgent { ) }; - // Event handler for the service: accepts new client connections. let agent = self.clone(); let rt_accept = rt.clone(); let conns_accept = connections.clone(); let service_handler = ConcreteBlock::new(move |event: xpc_sys::xpc_object_t| { unsafe { - // Treat incoming events as connections; ignore others - // We detect connections by trying to set a per-connection handler. - let client = event as xpc_sys::xpc_connection_t; - log::trace!(target: LOG_TARGET, "New XPC connection accepted"); - // Do not register for signals until the client explicitly subscribes + let client = event as xpc_sys::xpc_connection_t; + log::trace!(target: LOG_TARGET, "New XPC connection accepted"); - // Per-connection handler - let agent_conn = agent.clone(); - let rt_conn = rt_accept.clone(); - let conns_for_handler = conns_accept.clone(); - let conn_handler = ConcreteBlock::new(move |msg: xpc_sys::xpc_object_t| { - // Convert to higher-level Message for type matching - match xpc_object_to_message(msg) { - Message::Dictionary(map) => { - // Trace inbound method - let method = dict_get_str(&map, "method").or_else(|| dict_get_str(&map, "type")).unwrap_or_else(|| "".to_string()); - log::trace!(target: LOG_TARGET, "XPC request received: {}", method); - let response = rt_conn.block_on(dispatch(&agent_conn, &conns_for_handler, client, &map)); - let reply = unsafe { xpc_sys::xpc_dictionary_create_reply(msg) }; - if !reply.is_null() { - let payload = message_to_xpc_object(response); - let apply_block = ConcreteBlock::new(move |key: *const c_char, value: xpc_sys::xpc_object_t| { - unsafe { xpc_sys::xpc_dictionary_set_value(reply, key, value); } - }) - .copy(); - unsafe { - xpc_sys::xpc_dictionary_apply(payload, apply_block.deref() as *const _ as *mut _); - xpc_sys::xpc_connection_send_message(client, reply); - xpc_sys::xpc_release(payload); - xpc_sys::xpc_release(reply); - } - log::trace!(target: LOG_TARGET, "XPC reply sent for method: {}", method); - } else { - log::warn!(target: LOG_TARGET, "No reply port for method: {}", method); + let agent_conn = agent.clone(); + let rt_conn = rt_accept.clone(); + let conns_for_handler = conns_accept.clone(); + let conn_handler = ConcreteBlock::new(move |msg: xpc_sys::xpc_object_t| { + match xpc_object_to_message(msg) { + Message::Dictionary(map) => { + let method = super::util::dict_get_str(&map, "method").or_else(|| super::util::dict_get_str(&map, "type")).unwrap_or_else(|| "".to_string()); + log::trace!(target: LOG_TARGET, "XPC request received: {}", method); + let response = rt_conn.block_on(super::rpc::dispatch(&agent_conn, &conns_for_handler, client, &map)); + let reply = unsafe { xpc_sys::xpc_dictionary_create_reply(msg) }; + if !reply.is_null() { + let payload = message_to_xpc_object(response); + let apply_block = ConcreteBlock::new(move |key: *const c_char, value: xpc_sys::xpc_object_t| { + unsafe { xpc_sys::xpc_dictionary_set_value(reply, key, value); } + }) + .copy(); + unsafe { + xpc_sys::xpc_dictionary_apply(payload, apply_block.deref() as *const _ as *mut _); + xpc_sys::xpc_connection_send_message(client, reply); + xpc_sys::xpc_release(payload); + xpc_sys::xpc_release(reply); } + log::trace!(target: LOG_TARGET, "XPC reply sent for method: {}", method); + } else { + log::warn!(target: LOG_TARGET, "No reply port for method: {}", method); } - Message::Error(e) => { - match e { - MessageError::ConnectionInvalid => { - // Normal for one-shot RPC connections; keep logs quiet - let mut list = conns_for_handler.lock().unwrap(); - let before = list.len(); - list.retain(|c| c.0 != client); - let after = list.len(); - if after < before { - log::trace!(target: LOG_TARGET, "Removed closed XPC client from subscribers ({} -> {})", before, after); - } else { - log::debug!(target: LOG_TARGET, "XPC connection closed (no subscription)"); - } - } - other => { - log::warn!(target: LOG_TARGET, "XPC error event: {:?}", other); - } - } - } - _ => {} } - }) - .copy(); + Message::Error(e) => { + match e { + MessageError::ConnectionInvalid => { + let mut list = conns_for_handler.lock().unwrap(); + let before = list.len(); + list.retain(|c| c.0 != client); + let after = list.len(); + if after < before { + log::trace!(target: LOG_TARGET, "Removed closed XPC client from subscribers ({} -> {})", before, after); + } else { + log::debug!(target: LOG_TARGET, "XPC connection closed (no subscription)"); + } + } + other => { + log::warn!(target: LOG_TARGET, "XPC error event: {:?}", other); + } + } + } + _ => {} + } + }) + .copy(); - xpc_sys::xpc_connection_set_event_handler( - client, - conn_handler.deref() as *const _ as *mut _, - ); - xpc_sys::xpc_connection_resume(client); - } + xpc_sys::xpc_connection_set_event_handler( + client, + conn_handler.deref() as *const _ as *mut _, + ); + xpc_sys::xpc_connection_resume(client); } - ) + }) .copy(); unsafe { @@ -184,11 +168,9 @@ impl XpcAgent { xpc_sys::xpc_connection_resume(service); } - // Keep this future alive forever. futures_util::future::pending::<()>().await; } - /// Send an event to the daemon and await its reply. pub async fn send_event( &self, make_event: impl FnOnce(kordophoned::daemon::events::Reply) -> Event, @@ -202,461 +184,3 @@ impl XpcAgent { } } -fn cstr(s: &str) -> CString { - CString::new(s).unwrap_or_else(|_| CString::new("").unwrap()) -} - -fn get_string_field(map: &HashMap, key: &str) -> Option { - let k = CString::new(key).ok()?; - map.get(&k).and_then(|v| match v { - Message::String(s) => Some(s.to_string_lossy().into_owned()), - _ => None, - }) -} - -fn get_dictionary_field<'a>( - map: &'a HashMap, - key: &str, -) -> Option<&'a HashMap> { - let k = CString::new(key).ok()?; - map.get(&k).and_then(|v| match v { - Message::Dictionary(d) => Some(d), - _ => None, - }) -} - -fn make_error_reply(code: &str, message: &str) -> Message { - log::error!(target: LOG_TARGET, "XPC error: {code}: {message}"); - - let mut reply: HashMap = HashMap::new(); - reply.insert(cstr("type"), Message::String(cstr("Error"))); - reply.insert(cstr("error"), Message::String(cstr(code))); - reply.insert(cstr("message"), Message::String(cstr(message))); - - Message::Dictionary(reply) -} - -type XpcMap = HashMap; - -fn dict_get_str(map: &XpcMap, key: &str) -> Option { - let k = CString::new(key).ok()?; - match map.get(&k) { - Some(Message::String(v)) => Some(v.to_string_lossy().into_owned()), - _ => None, - } -} - -fn dict_get_i64_from_str(map: &XpcMap, key: &str) -> Option { - dict_get_str(map, key).and_then(|s| s.parse::().ok()) -} - -fn dict_put_str(map: &mut XpcMap, key: &str, value: impl AsRef) { - map.insert(cstr(key), Message::String(cstr(value.as_ref()))); -} - -fn dict_put_i64_as_str(map: &mut XpcMap, key: &str, value: i64) { - dict_put_str(map, key, value.to_string()); -} - -fn array_from_strs(values: impl IntoIterator) -> Message { - let arr = values - .into_iter() - .map(|s| Message::String(cstr(&s))) - .collect(); - Message::Array(arr) -} - -fn make_ok_reply() -> Message { - let mut reply: XpcMap = HashMap::new(); - dict_put_str(&mut reply, "type", "Ok"); - Message::Dictionary(reply) -} - -/// Attach an optional request_id to a dictionary reply message. -fn attach_request_id(mut message: Message, request_id: Option) -> Message { - if let (Some(id), Message::Dictionary(ref mut m)) = (request_id, &mut message) { - dict_put_str(m, "request_id", &id); - } - message -} - -async fn dispatch( - agent: &XpcAgent, - subscribers: &std::sync::Mutex>, - current_client: xpc_sys::xpc_connection_t, - root: &HashMap, -) -> Message { - // Standardized request: { method: String, arguments: Dictionary?, request_id: String? } - let request_id = dict_get_str(root, "request_id"); - - let method = match dict_get_str(root, "method").or_else(|| dict_get_str(root, "type")) { - Some(m) => m, - None => return attach_request_id(make_error_reply("InvalidRequest", "Missing method/type"), request_id), - }; - - let _arguments = get_dictionary_field(root, "arguments"); - - let mut response = match method.as_str() { - // Example implemented method: GetVersion - "GetVersion" => match agent.send_event(Event::GetVersion).await { - Ok(version) => { - let mut reply: XpcMap = HashMap::new(); - dict_put_str(&mut reply, "type", "GetVersionResponse"); - dict_put_str(&mut reply, "version", &version); - Message::Dictionary(reply) - } - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), - }, - - "GetConversations" => { - // Defaults - let mut limit: i32 = 100; - let mut offset: i32 = 0; - - if let Some(args) = get_dictionary_field(root, "arguments") { - if let Some(v) = dict_get_i64_from_str(args, "limit") { - limit = v as i32; - } - if let Some(v) = dict_get_i64_from_str(args, "offset") { - offset = v as i32; - } - } - - match agent - .send_event(|r| Event::GetAllConversations(limit, offset, r)) - .await - { - Ok(conversations) => { - // Build array of conversation dictionaries - let mut items: Vec = Vec::with_capacity(conversations.len()); - for conv in conversations { - let mut m: XpcMap = HashMap::new(); - dict_put_str(&mut m, "guid", &conv.guid); - dict_put_str( - &mut m, - "display_name", - &conv.display_name.unwrap_or_default(), - ); - dict_put_i64_as_str(&mut m, "unread_count", conv.unread_count as i64); - dict_put_str( - &mut m, - "last_message_preview", - &conv.last_message_preview.unwrap_or_default(), - ); - - // participants -> array of strings - let participant_names: Vec = conv - .participants - .into_iter() - .map(|p| p.display_name()) - .collect(); - m.insert(cstr("participants"), array_from_strs(participant_names)); - - // date as unix timestamp (i64) - dict_put_i64_as_str(&mut m, "date", conv.date.and_utc().timestamp()); - - items.push(Message::Dictionary(m)); - } - - let mut reply: XpcMap = HashMap::new(); - dict_put_str(&mut reply, "type", "GetConversationsResponse"); - reply.insert(cstr("conversations"), Message::Array(items)); - Message::Dictionary(reply) - } - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), - } - } - - "SyncConversationList" => match agent.send_event(Event::SyncConversationList).await { - Ok(()) => make_ok_reply(), - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), - }, - - "SyncAllConversations" => match agent.send_event(Event::SyncAllConversations).await { - Ok(()) => make_ok_reply(), - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), - }, - - "SyncConversation" => { - let conversation_id = match get_dictionary_field(root, "arguments") - .and_then(|m| dict_get_str(m, "conversation_id")) - { - Some(id) => id, - None => return make_error_reply("InvalidRequest", "Missing conversation_id"), - }; - match agent - .send_event(|r| Event::SyncConversation(conversation_id, r)) - .await - { - Ok(()) => make_ok_reply(), - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), - } - } - - "MarkConversationAsRead" => { - let conversation_id = match get_dictionary_field(root, "arguments") - .and_then(|m| dict_get_str(m, "conversation_id")) - { - Some(id) => id, - None => return make_error_reply("InvalidRequest", "Missing conversation_id"), - }; - match agent - .send_event(|r| Event::MarkConversationAsRead(conversation_id, r)) - .await - { - Ok(()) => make_ok_reply(), - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), - } - } - - "GetMessages" => { - let args = match get_dictionary_field(root, "arguments") { - Some(a) => a, - None => return make_error_reply("InvalidRequest", "Missing arguments"), - }; - let conversation_id = match dict_get_str(args, "conversation_id") { - Some(id) => id, - None => return make_error_reply("InvalidRequest", "Missing conversation_id"), - }; - let last_message_id = dict_get_str(args, "last_message_id"); - match agent - .send_event(|r| Event::GetMessages(conversation_id, last_message_id, r)) - .await - { - Ok(messages) => { - let mut items: Vec = Vec::with_capacity(messages.len()); - for msg in messages { - let mut m: XpcMap = HashMap::new(); - dict_put_str(&mut m, "id", &msg.id); - dict_put_str(&mut m, "text", &msg.text.replace('\u{FFFC}', "")); - dict_put_i64_as_str(&mut m, "date", msg.date.and_utc().timestamp()); - dict_put_str(&mut m, "sender", &msg.sender.display_name()); - items.push(Message::Dictionary(m)); - } - let mut reply: XpcMap = HashMap::new(); - dict_put_str(&mut reply, "type", "GetMessagesResponse"); - reply.insert(cstr("messages"), Message::Array(items)); - Message::Dictionary(reply) - } - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), - } - } - - "DeleteAllConversations" => match agent.send_event(Event::DeleteAllConversations).await { - Ok(()) => make_ok_reply(), - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), - }, - - "SendMessage" => { - let args = match get_dictionary_field(root, "arguments") { - Some(a) => a, - None => return make_error_reply("InvalidRequest", "Missing arguments"), - }; - let conversation_id = match dict_get_str(args, "conversation_id") { - Some(v) => v, - None => return make_error_reply("InvalidRequest", "Missing conversation_id"), - }; - let text = dict_get_str(args, "text").unwrap_or_default(); - let attachment_guids: Vec = match args.get(&cstr("attachment_guids")) { - 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(), - }; - match agent - .send_event(|r| Event::SendMessage(conversation_id, text, attachment_guids, r)) - .await - { - Ok(uuid) => { - let mut reply: XpcMap = HashMap::new(); - dict_put_str(&mut reply, "type", "SendMessageResponse"); - dict_put_str(&mut reply, "uuid", &uuid.to_string()); - Message::Dictionary(reply) - } - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), - } - } - - "GetAttachmentInfo" => { - let args = match get_dictionary_field(root, "arguments") { - Some(a) => a, - None => return make_error_reply("InvalidRequest", "Missing arguments"), - }; - let attachment_id = match dict_get_str(args, "attachment_id") { - Some(v) => v, - None => return make_error_reply("InvalidRequest", "Missing attachment_id"), - }; - match agent - .send_event(|r| Event::GetAttachment(attachment_id, r)) - .await - { - Ok(attachment) => { - let mut reply: XpcMap = HashMap::new(); - dict_put_str(&mut reply, "type", "GetAttachmentInfoResponse"); - dict_put_str( - &mut reply, - "path", - &attachment.get_path_for_preview(false).to_string_lossy(), - ); - dict_put_str( - &mut reply, - "preview_path", - &attachment.get_path_for_preview(true).to_string_lossy(), - ); - dict_put_str( - &mut reply, - "downloaded", - &attachment.is_downloaded(false).to_string(), - ); - dict_put_str( - &mut reply, - "preview_downloaded", - &attachment.is_downloaded(true).to_string(), - ); - Message::Dictionary(reply) - } - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), - } - } - - "DownloadAttachment" => { - let args = match get_dictionary_field(root, "arguments") { - Some(a) => a, - None => return make_error_reply("InvalidRequest", "Missing arguments"), - }; - let attachment_id = match dict_get_str(args, "attachment_id") { - Some(v) => v, - None => return make_error_reply("InvalidRequest", "Missing attachment_id"), - }; - let preview = dict_get_str(args, "preview") - .map(|s| s == "true") - .unwrap_or(false); - match agent - .send_event(|r| Event::DownloadAttachment(attachment_id, preview, r)) - .await - { - Ok(()) => make_ok_reply(), - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), - } - } - - "UploadAttachment" => { - use std::path::PathBuf; - let args = match get_dictionary_field(root, "arguments") { - Some(a) => a, - None => return make_error_reply("InvalidRequest", "Missing arguments"), - }; - let path = match dict_get_str(args, "path") { - Some(v) => v, - None => return make_error_reply("InvalidRequest", "Missing path"), - }; - match agent - .send_event(|r| Event::UploadAttachment(PathBuf::from(path), r)) - .await - { - Ok(upload_guid) => { - let mut reply: XpcMap = HashMap::new(); - dict_put_str(&mut reply, "type", "UploadAttachmentResponse"); - dict_put_str(&mut reply, "upload_guid", &upload_guid); - Message::Dictionary(reply) - } - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), - } - } - - "GetAllSettings" => match agent.send_event(Event::GetAllSettings).await { - Ok(settings) => { - let mut reply: XpcMap = HashMap::new(); - dict_put_str(&mut reply, "type", "GetAllSettingsResponse"); - dict_put_str( - &mut reply, - "server_url", - &settings.server_url.unwrap_or_default(), - ); - dict_put_str( - &mut reply, - "username", - &settings.username.unwrap_or_default(), - ); - Message::Dictionary(reply) - } - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), - }, - - "UpdateSettings" => { - let args = match get_dictionary_field(root, "arguments") { - Some(a) => a, - None => return make_error_reply("InvalidRequest", "Missing arguments"), - }; - let server_url = dict_get_str(args, "server_url"); - let username = dict_get_str(args, "username"); - let settings = Settings { - server_url, - username, - token: None, - }; - match agent - .send_event(|r| Event::UpdateSettings(settings, r)) - .await - { - Ok(()) => make_ok_reply(), - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), - } - } - - // Subscribe and return immediately - "SubscribeSignals" => { - let mut list = subscribers.lock().unwrap(); - // Avoid duplicates - if !list.iter().any(|c| c.0 == current_client) { - list.push(XpcConn(current_client)); - log::trace!(target: LOG_TARGET, "Client subscribed to signals (total subscribers: {})", list.len()); - } - make_ok_reply() - }, - - // Unknown method fallback - other => make_error_reply("UnknownMethod", other), - }; - - // Echo request_id back (if present) so clients can correlate replies - response = attach_request_id(response, request_id); - response -} - -fn signal_to_message(signal: Signal) -> Message { - let mut root: XpcMap = HashMap::new(); - let mut args: XpcMap = HashMap::new(); - match signal { - Signal::ConversationsUpdated => { - dict_put_str(&mut root, "name", "ConversationsUpdated"); - } - Signal::MessagesUpdated(conversation_id) => { - dict_put_str(&mut root, "name", "MessagesUpdated"); - dict_put_str(&mut args, "conversation_id", &conversation_id); - } - Signal::AttachmentDownloaded(attachment_id) => { - dict_put_str(&mut root, "name", "AttachmentDownloadCompleted"); - dict_put_str(&mut args, "attachment_id", &attachment_id); - } - Signal::AttachmentUploaded(upload_guid, attachment_guid) => { - dict_put_str(&mut root, "name", "AttachmentUploadCompleted"); - dict_put_str(&mut args, "upload_guid", &upload_guid); - dict_put_str(&mut args, "attachment_guid", &attachment_guid); - } - Signal::UpdateStreamReconnected => { - dict_put_str(&mut root, "name", "UpdateStreamReconnected"); - } - } - if !args.is_empty() { - root.insert(cstr("arguments"), Message::Dictionary(args)); - } - Message::Dictionary(root) -} - -// legacy async client handler removed in reply-port implementation - diff --git a/kordophoned/src/xpc/mod.rs b/kordophoned/src/xpc/mod.rs index 8f189b2..81cbe3f 100644 --- a/kordophoned/src/xpc/mod.rs +++ b/kordophoned/src/xpc/mod.rs @@ -1,2 +1,4 @@ pub mod agent; pub mod interface; +pub mod rpc; +pub mod util; diff --git a/kordophoned/src/xpc/rpc.rs b/kordophoned/src/xpc/rpc.rs new file mode 100644 index 0000000..1647db1 --- /dev/null +++ b/kordophoned/src/xpc/rpc.rs @@ -0,0 +1,235 @@ +use super::agent::{XpcAgent, XpcConn, LOG_TARGET}; +use kordophoned::daemon::events::Event; +use kordophoned::daemon::settings::Settings; +use std::collections::HashMap; +use std::ffi::CString; +use xpc_connection::Message; +use xpc_connection_sys as xpc_sys; + +use super::util::*; + +pub async fn dispatch( + agent: &XpcAgent, + subscribers: &std::sync::Mutex>, + current_client: xpc_sys::xpc_connection_t, + root: &HashMap, +) -> Message { + let request_id = dict_get_str(root, "request_id"); + + let method = match dict_get_str(root, "method").or_else(|| dict_get_str(root, "type")) { + Some(m) => m, + None => return attach_request_id(make_error_reply("InvalidRequest", "Missing method/type"), request_id), + }; + + let _arguments = get_dictionary_field(root, "arguments"); + + let mut response = match method.as_str() { + // GetVersion + "GetVersion" => match agent.send_event(Event::GetVersion).await { + Ok(version) => { + let mut reply: XpcMap = HashMap::new(); + dict_put_str(&mut reply, "type", "GetVersionResponse"); + dict_put_str(&mut reply, "version", &version); + Message::Dictionary(reply) + } + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + }, + + // GetConversations + "GetConversations" => { + let mut limit: i32 = 100; + let mut offset: i32 = 0; + if let Some(args) = get_dictionary_field(root, "arguments") { + if let Some(v) = dict_get_i64_from_str(args, "limit") { limit = v as i32; } + if let Some(v) = dict_get_i64_from_str(args, "offset") { offset = v as i32; } + } + match agent.send_event(|r| Event::GetAllConversations(limit, offset, r)).await { + Ok(conversations) => { + let mut items: Vec = Vec::with_capacity(conversations.len()); + for conv in conversations { + let mut m: XpcMap = HashMap::new(); + dict_put_str(&mut m, "guid", &conv.guid); + dict_put_str(&mut m, "display_name", &conv.display_name.unwrap_or_default()); + dict_put_i64_as_str(&mut m, "unread_count", conv.unread_count as i64); + dict_put_str(&mut m, "last_message_preview", &conv.last_message_preview.unwrap_or_default()); + let participant_names: Vec = conv.participants.into_iter().map(|p| p.display_name()).collect(); + m.insert(cstr("participants"), array_from_strs(participant_names)); + dict_put_i64_as_str(&mut m, "date", conv.date.and_utc().timestamp()); + items.push(Message::Dictionary(m)); + } + let mut reply: XpcMap = HashMap::new(); + dict_put_str(&mut reply, "type", "GetConversationsResponse"); + reply.insert(cstr("conversations"), Message::Array(items)); + Message::Dictionary(reply) + } + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + // Sync ops + "SyncConversationList" => match agent.send_event(Event::SyncConversationList).await { + Ok(()) => make_ok_reply(), + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + }, + "SyncAllConversations" => match agent.send_event(Event::SyncAllConversations).await { + Ok(()) => make_ok_reply(), + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + }, + "SyncConversation" => { + let conversation_id = match get_dictionary_field(root, "arguments").and_then(|m| dict_get_str(m, "conversation_id")) { + Some(id) => id, + None => return make_error_reply("InvalidRequest", "Missing conversation_id"), + }; + match agent.send_event(|r| Event::SyncConversation(conversation_id, r)).await { + Ok(()) => make_ok_reply(), + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + // Mark as read + "MarkConversationAsRead" => { + let conversation_id = match get_dictionary_field(root, "arguments").and_then(|m| dict_get_str(m, "conversation_id")) { + Some(id) => id, + None => return make_error_reply("InvalidRequest", "Missing conversation_id"), + }; + match agent.send_event(|r| Event::MarkConversationAsRead(conversation_id, r)).await { + Ok(()) => make_ok_reply(), + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + // GetMessages + "GetMessages" => { + let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; + let conversation_id = match dict_get_str(args, "conversation_id") { Some(id) => id, None => return make_error_reply("InvalidRequest", "Missing conversation_id") }; + let last_message_id = dict_get_str(args, "last_message_id"); + match agent.send_event(|r| Event::GetMessages(conversation_id, last_message_id, r)).await { + Ok(messages) => { + let mut items: Vec = Vec::with_capacity(messages.len()); + for msg in messages { + let mut m: XpcMap = HashMap::new(); + dict_put_str(&mut m, "id", &msg.id); + dict_put_str(&mut m, "text", &msg.text.replace('\u{FFFC}', "")); + dict_put_i64_as_str(&mut m, "date", msg.date.and_utc().timestamp()); + dict_put_str(&mut m, "sender", &msg.sender.display_name()); + items.push(Message::Dictionary(m)); + } + let mut reply: XpcMap = HashMap::new(); + dict_put_str(&mut reply, "type", "GetMessagesResponse"); + reply.insert(cstr("messages"), Message::Array(items)); + Message::Dictionary(reply) + } + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + // Delete all + "DeleteAllConversations" => match agent.send_event(Event::DeleteAllConversations).await { + Ok(()) => make_ok_reply(), + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + }, + + // SendMessage + "SendMessage" => { + let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; + let conversation_id = match dict_get_str(args, "conversation_id") { Some(v) => v, None => return make_error_reply("InvalidRequest", "Missing conversation_id") }; + let text = dict_get_str(args, "text").unwrap_or_default(); + let attachment_guids: Vec = match args.get(&cstr("attachment_guids")) { + 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(), + }; + match agent.send_event(|r| Event::SendMessage(conversation_id, text, attachment_guids, r)).await { + Ok(uuid) => { + let mut reply: XpcMap = HashMap::new(); + dict_put_str(&mut reply, "type", "SendMessageResponse"); + dict_put_str(&mut reply, "uuid", &uuid.to_string()); + Message::Dictionary(reply) + } + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + // GetAttachmentInfo + "GetAttachmentInfo" => { + let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; + let attachment_id = match dict_get_str(args, "attachment_id") { Some(v) => v, None => return make_error_reply("InvalidRequest", "Missing attachment_id") }; + match agent.send_event(|r| Event::GetAttachment(attachment_id, r)).await { + Ok(attachment) => { + let mut reply: XpcMap = HashMap::new(); + dict_put_str(&mut reply, "type", "GetAttachmentInfoResponse"); + dict_put_str(&mut reply, "path", &attachment.get_path_for_preview(false).to_string_lossy()); + dict_put_str(&mut reply, "preview_path", &attachment.get_path_for_preview(true).to_string_lossy()); + dict_put_str(&mut reply, "downloaded", &attachment.is_downloaded(false).to_string()); + dict_put_str(&mut reply, "preview_downloaded", &attachment.is_downloaded(true).to_string()); + Message::Dictionary(reply) + } + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + // DownloadAttachment + "DownloadAttachment" => { + let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; + let attachment_id = match dict_get_str(args, "attachment_id") { Some(v) => v, None => return make_error_reply("InvalidRequest", "Missing attachment_id") }; + let preview = dict_get_str(args, "preview").map(|s| s == "true").unwrap_or(false); + match agent.send_event(|r| Event::DownloadAttachment(attachment_id, preview, r)).await { + Ok(()) => make_ok_reply(), + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + // UploadAttachment + "UploadAttachment" => { + use std::path::PathBuf; + let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; + let path = match dict_get_str(args, "path") { Some(v) => v, None => return make_error_reply("InvalidRequest", "Missing path") }; + match agent.send_event(|r| Event::UploadAttachment(PathBuf::from(path), r)).await { + Ok(upload_guid) => { + let mut reply: XpcMap = HashMap::new(); + dict_put_str(&mut reply, "type", "UploadAttachmentResponse"); + dict_put_str(&mut reply, "upload_guid", &upload_guid); + Message::Dictionary(reply) + } + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + // Settings + "GetAllSettings" => match agent.send_event(Event::GetAllSettings).await { + Ok(settings) => { + let mut reply: XpcMap = HashMap::new(); + dict_put_str(&mut reply, "type", "GetAllSettingsResponse"); + dict_put_str(&mut reply, "server_url", &settings.server_url.unwrap_or_default()); + dict_put_str(&mut reply, "username", &settings.username.unwrap_or_default()); + Message::Dictionary(reply) + } + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + }, + "UpdateSettings" => { + let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; + let server_url = dict_get_str(args, "server_url"); + let username = dict_get_str(args, "username"); + let settings = Settings { server_url, username, token: None }; + match agent.send_event(|r| Event::UpdateSettings(settings, r)).await { + Ok(()) => make_ok_reply(), + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + + // Subscribe + "SubscribeSignals" => { + let mut list = subscribers.lock().unwrap(); + if !list.iter().any(|c| c.0 == current_client) { + list.push(XpcConn(current_client)); + log::trace!(target: LOG_TARGET, "Client subscribed to signals (total subscribers: {})", list.len()); + } + make_ok_reply() + }, + + // Unknown method fallback + other => make_error_reply("UnknownMethod", other), + }; + + response = attach_request_id(response, request_id); + response +} diff --git a/kordophoned/src/xpc/util.rs b/kordophoned/src/xpc/util.rs new file mode 100644 index 0000000..2889c72 --- /dev/null +++ b/kordophoned/src/xpc/util.rs @@ -0,0 +1,87 @@ +use kordophoned::daemon::signals::Signal; +use std::collections::HashMap; +use std::ffi::CString; +use xpc_connection::Message; + +pub type XpcMap = HashMap; + +pub fn cstr(s: &str) -> CString { CString::new(s).unwrap_or_else(|_| CString::new("").unwrap()) } + +pub fn get_dictionary_field<'a>( + map: &'a HashMap, + key: &str, +) -> Option<&'a HashMap> { + let k = CString::new(key).ok()?; + map.get(&k).and_then(|v| match v { Message::Dictionary(d) => Some(d), _ => None }) +} + +pub fn dict_get_str(map: &HashMap, key: &str) -> Option { + let k = CString::new(key).ok()?; + match map.get(&k) { Some(Message::String(v)) => Some(v.to_string_lossy().into_owned()), _ => None } +} + +pub fn dict_get_i64_from_str(map: &HashMap, key: &str) -> Option { + dict_get_str(map, key).and_then(|s| s.parse::().ok()) +} + +pub fn dict_put_str(map: &mut XpcMap, key: &str, value: impl AsRef) { + map.insert(cstr(key), Message::String(cstr(value.as_ref()))); +} + +pub fn dict_put_i64_as_str(map: &mut XpcMap, key: &str, value: i64) { dict_put_str(map, key, value.to_string()); } + +pub fn array_from_strs(values: impl IntoIterator) -> Message { + let arr = values.into_iter().map(|s| Message::String(cstr(&s))).collect(); + Message::Array(arr) +} + +pub fn make_ok_reply() -> Message { + let mut reply: XpcMap = HashMap::new(); + dict_put_str(&mut reply, "type", "Ok"); + Message::Dictionary(reply) +} + +pub fn make_error_reply(code: &str, message: &str) -> Message { + let mut reply: HashMap = HashMap::new(); + reply.insert(cstr("type"), Message::String(cstr("Error"))); + reply.insert(cstr("error"), Message::String(cstr(code))); + reply.insert(cstr("message"), Message::String(cstr(message))); + Message::Dictionary(reply) +} + +pub fn attach_request_id(mut message: Message, request_id: Option) -> Message { + if let (Some(id), Message::Dictionary(ref mut m)) = (request_id, &mut message) { + dict_put_str(m, "request_id", &id); + } + message +} + +pub fn signal_to_message(signal: Signal) -> Message { + let mut root: XpcMap = HashMap::new(); + let mut args: XpcMap = HashMap::new(); + match signal { + Signal::ConversationsUpdated => { + dict_put_str(&mut root, "name", "ConversationsUpdated"); + } + Signal::MessagesUpdated(conversation_id) => { + dict_put_str(&mut root, "name", "MessagesUpdated"); + dict_put_str(&mut args, "conversation_id", &conversation_id); + } + Signal::AttachmentDownloaded(attachment_id) => { + dict_put_str(&mut root, "name", "AttachmentDownloadCompleted"); + dict_put_str(&mut args, "attachment_id", &attachment_id); + } + Signal::AttachmentUploaded(upload_guid, attachment_guid) => { + dict_put_str(&mut root, "name", "AttachmentUploadCompleted"); + dict_put_str(&mut args, "upload_guid", &upload_guid); + dict_put_str(&mut args, "attachment_guid", &attachment_guid); + } + Signal::UpdateStreamReconnected => { + dict_put_str(&mut root, "name", "UpdateStreamReconnected"); + } + } + if !args.is_empty() { + root.insert(cstr("arguments"), Message::Dictionary(args)); + } + Message::Dictionary(root) +} From 28738a1e92f3203126c1e4f770a4dbeb534b95ba Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 24 Aug 2025 16:19:56 -0700 Subject: [PATCH 15/23] xpc: Some cleanup --- kordophoned/src/xpc/agent.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/kordophoned/src/xpc/agent.rs b/kordophoned/src/xpc/agent.rs index 226f1b5..3260683 100644 --- a/kordophoned/src/xpc/agent.rs +++ b/kordophoned/src/xpc/agent.rs @@ -1,7 +1,5 @@ use crate::xpc::interface::SERVICE_NAME; -use kordophoned::daemon::settings::Settings; use kordophoned::daemon::{events::Event, signals::Signal, DaemonResult}; -use std::collections::HashMap; use std::ffi::CString; use std::os::raw::c_char; use std::ptr; @@ -15,7 +13,7 @@ pub(super) static LOG_TARGET: &str = "xpc"; /// Wrapper for raw XPC connection pointer to declare cross-thread usage. /// Safety: libxpc connections are reference-counted and may be used to send from other threads. #[derive(Copy, Clone)] -pub(super) struct XpcConn(pub xpc_sys::xpc_connection_t); +pub(crate) struct XpcConn(pub xpc_sys::xpc_connection_t); unsafe impl Send for XpcConn {} unsafe impl Sync for XpcConn {} @@ -110,19 +108,17 @@ impl XpcAgent { let method = super::util::dict_get_str(&map, "method").or_else(|| super::util::dict_get_str(&map, "type")).unwrap_or_else(|| "".to_string()); log::trace!(target: LOG_TARGET, "XPC request received: {}", method); let response = rt_conn.block_on(super::rpc::dispatch(&agent_conn, &conns_for_handler, client, &map)); - let reply = unsafe { xpc_sys::xpc_dictionary_create_reply(msg) }; + let reply = xpc_sys::xpc_dictionary_create_reply(msg); if !reply.is_null() { let payload = message_to_xpc_object(response); let apply_block = ConcreteBlock::new(move |key: *const c_char, value: xpc_sys::xpc_object_t| { - unsafe { xpc_sys::xpc_dictionary_set_value(reply, key, value); } + xpc_sys::xpc_dictionary_set_value(reply, key, value); }) .copy(); - unsafe { - xpc_sys::xpc_dictionary_apply(payload, apply_block.deref() as *const _ as *mut _); - xpc_sys::xpc_connection_send_message(client, reply); - xpc_sys::xpc_release(payload); - xpc_sys::xpc_release(reply); - } + xpc_sys::xpc_dictionary_apply(payload, apply_block.deref() as *const _ as *mut _); + xpc_sys::xpc_connection_send_message(client, reply); + xpc_sys::xpc_release(payload); + xpc_sys::xpc_release(reply); log::trace!(target: LOG_TARGET, "XPC reply sent for method: {}", method); } else { log::warn!(target: LOG_TARGET, "No reply port for method: {}", method); From f239d1de19d87e82681ad8fc8b639898d8a85848 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 24 Aug 2025 16:20:14 -0700 Subject: [PATCH 16/23] cargo fmt --- kordophoned/src/xpc/agent.rs | 6 +- kordophoned/src/xpc/rpc.rs | 199 ++++++++++++++++++++++++++++------- kordophoned/src/xpc/util.rs | 23 +++- kpcli/src/daemon/xpc.rs | 2 +- 4 files changed, 183 insertions(+), 47 deletions(-) diff --git a/kordophoned/src/xpc/agent.rs b/kordophoned/src/xpc/agent.rs index 3260683..3e4baaf 100644 --- a/kordophoned/src/xpc/agent.rs +++ b/kordophoned/src/xpc/agent.rs @@ -27,7 +27,10 @@ pub struct XpcAgent { impl XpcAgent { pub fn new(event_sink: mpsc::Sender, signal_receiver: mpsc::Receiver) -> Self { - Self { event_sink, signal_receiver: Arc::new(Mutex::new(Some(signal_receiver))) } + Self { + event_sink, + signal_receiver: Arc::new(Mutex::new(Some(signal_receiver))), + } } pub async fn run(self) { @@ -179,4 +182,3 @@ impl XpcAgent { rx.await.map_err(|_| "Failed to receive reply".into()) } } - diff --git a/kordophoned/src/xpc/rpc.rs b/kordophoned/src/xpc/rpc.rs index 1647db1..9d199a1 100644 --- a/kordophoned/src/xpc/rpc.rs +++ b/kordophoned/src/xpc/rpc.rs @@ -10,7 +10,7 @@ use super::util::*; pub async fn dispatch( agent: &XpcAgent, - subscribers: &std::sync::Mutex>, + subscribers: &std::sync::Mutex>, current_client: xpc_sys::xpc_connection_t, root: &HashMap, ) -> Message { @@ -18,7 +18,12 @@ pub async fn dispatch( let method = match dict_get_str(root, "method").or_else(|| dict_get_str(root, "type")) { Some(m) => m, - None => return attach_request_id(make_error_reply("InvalidRequest", "Missing method/type"), request_id), + None => { + return attach_request_id( + make_error_reply("InvalidRequest", "Missing method/type"), + request_id, + ) + } }; let _arguments = get_dictionary_field(root, "arguments"); @@ -40,19 +45,38 @@ pub async fn dispatch( let mut limit: i32 = 100; let mut offset: i32 = 0; if let Some(args) = get_dictionary_field(root, "arguments") { - if let Some(v) = dict_get_i64_from_str(args, "limit") { limit = v as i32; } - if let Some(v) = dict_get_i64_from_str(args, "offset") { offset = v as i32; } + if let Some(v) = dict_get_i64_from_str(args, "limit") { + limit = v as i32; + } + if let Some(v) = dict_get_i64_from_str(args, "offset") { + offset = v as i32; + } } - match agent.send_event(|r| Event::GetAllConversations(limit, offset, r)).await { + match agent + .send_event(|r| Event::GetAllConversations(limit, offset, r)) + .await + { Ok(conversations) => { let mut items: Vec = Vec::with_capacity(conversations.len()); for conv in conversations { let mut m: XpcMap = HashMap::new(); dict_put_str(&mut m, "guid", &conv.guid); - dict_put_str(&mut m, "display_name", &conv.display_name.unwrap_or_default()); + dict_put_str( + &mut m, + "display_name", + &conv.display_name.unwrap_or_default(), + ); dict_put_i64_as_str(&mut m, "unread_count", conv.unread_count as i64); - dict_put_str(&mut m, "last_message_preview", &conv.last_message_preview.unwrap_or_default()); - let participant_names: Vec = conv.participants.into_iter().map(|p| p.display_name()).collect(); + dict_put_str( + &mut m, + "last_message_preview", + &conv.last_message_preview.unwrap_or_default(), + ); + let participant_names: Vec = conv + .participants + .into_iter() + .map(|p| p.display_name()) + .collect(); m.insert(cstr("participants"), array_from_strs(participant_names)); dict_put_i64_as_str(&mut m, "date", conv.date.and_utc().timestamp()); items.push(Message::Dictionary(m)); @@ -76,11 +100,16 @@ pub async fn dispatch( Err(e) => make_error_reply("DaemonError", &format!("{}", e)), }, "SyncConversation" => { - let conversation_id = match get_dictionary_field(root, "arguments").and_then(|m| dict_get_str(m, "conversation_id")) { + let conversation_id = match get_dictionary_field(root, "arguments") + .and_then(|m| dict_get_str(m, "conversation_id")) + { Some(id) => id, None => return make_error_reply("InvalidRequest", "Missing conversation_id"), }; - match agent.send_event(|r| Event::SyncConversation(conversation_id, r)).await { + match agent + .send_event(|r| Event::SyncConversation(conversation_id, r)) + .await + { Ok(()) => make_ok_reply(), Err(e) => make_error_reply("DaemonError", &format!("{}", e)), } @@ -88,11 +117,16 @@ pub async fn dispatch( // Mark as read "MarkConversationAsRead" => { - let conversation_id = match get_dictionary_field(root, "arguments").and_then(|m| dict_get_str(m, "conversation_id")) { + let conversation_id = match get_dictionary_field(root, "arguments") + .and_then(|m| dict_get_str(m, "conversation_id")) + { Some(id) => id, None => return make_error_reply("InvalidRequest", "Missing conversation_id"), }; - match agent.send_event(|r| Event::MarkConversationAsRead(conversation_id, r)).await { + match agent + .send_event(|r| Event::MarkConversationAsRead(conversation_id, r)) + .await + { Ok(()) => make_ok_reply(), Err(e) => make_error_reply("DaemonError", &format!("{}", e)), } @@ -100,10 +134,19 @@ pub async fn dispatch( // GetMessages "GetMessages" => { - let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; - let conversation_id = match dict_get_str(args, "conversation_id") { Some(id) => id, None => return make_error_reply("InvalidRequest", "Missing conversation_id") }; + let args = match get_dictionary_field(root, "arguments") { + Some(a) => a, + None => return make_error_reply("InvalidRequest", "Missing arguments"), + }; + let conversation_id = match dict_get_str(args, "conversation_id") { + Some(id) => id, + None => return make_error_reply("InvalidRequest", "Missing conversation_id"), + }; let last_message_id = dict_get_str(args, "last_message_id"); - match agent.send_event(|r| Event::GetMessages(conversation_id, last_message_id, r)).await { + match agent + .send_event(|r| Event::GetMessages(conversation_id, last_message_id, r)) + .await + { Ok(messages) => { let mut items: Vec = Vec::with_capacity(messages.len()); for msg in messages { @@ -131,14 +174,29 @@ pub async fn dispatch( // SendMessage "SendMessage" => { - let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; - let conversation_id = match dict_get_str(args, "conversation_id") { Some(v) => v, None => return make_error_reply("InvalidRequest", "Missing conversation_id") }; + let args = match get_dictionary_field(root, "arguments") { + Some(a) => a, + None => return make_error_reply("InvalidRequest", "Missing arguments"), + }; + let conversation_id = match dict_get_str(args, "conversation_id") { + Some(v) => v, + None => return make_error_reply("InvalidRequest", "Missing conversation_id"), + }; let text = dict_get_str(args, "text").unwrap_or_default(); let attachment_guids: Vec = match args.get(&cstr("attachment_guids")) { - Some(Message::Array(arr)) => arr.iter().filter_map(|m| match m { Message::String(s) => Some(s.to_string_lossy().into_owned()), _ => None }).collect(), + 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(), }; - match agent.send_event(|r| Event::SendMessage(conversation_id, text, attachment_guids, r)).await { + match agent + .send_event(|r| Event::SendMessage(conversation_id, text, attachment_guids, r)) + .await + { Ok(uuid) => { let mut reply: XpcMap = HashMap::new(); dict_put_str(&mut reply, "type", "SendMessageResponse"); @@ -151,16 +209,41 @@ pub async fn dispatch( // GetAttachmentInfo "GetAttachmentInfo" => { - let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; - let attachment_id = match dict_get_str(args, "attachment_id") { Some(v) => v, None => return make_error_reply("InvalidRequest", "Missing attachment_id") }; - match agent.send_event(|r| Event::GetAttachment(attachment_id, r)).await { + let args = match get_dictionary_field(root, "arguments") { + Some(a) => a, + None => return make_error_reply("InvalidRequest", "Missing arguments"), + }; + let attachment_id = match dict_get_str(args, "attachment_id") { + Some(v) => v, + None => return make_error_reply("InvalidRequest", "Missing attachment_id"), + }; + match agent + .send_event(|r| Event::GetAttachment(attachment_id, r)) + .await + { Ok(attachment) => { let mut reply: XpcMap = HashMap::new(); dict_put_str(&mut reply, "type", "GetAttachmentInfoResponse"); - dict_put_str(&mut reply, "path", &attachment.get_path_for_preview(false).to_string_lossy()); - dict_put_str(&mut reply, "preview_path", &attachment.get_path_for_preview(true).to_string_lossy()); - dict_put_str(&mut reply, "downloaded", &attachment.is_downloaded(false).to_string()); - dict_put_str(&mut reply, "preview_downloaded", &attachment.is_downloaded(true).to_string()); + dict_put_str( + &mut reply, + "path", + &attachment.get_path_for_preview(false).to_string_lossy(), + ); + dict_put_str( + &mut reply, + "preview_path", + &attachment.get_path_for_preview(true).to_string_lossy(), + ); + dict_put_str( + &mut reply, + "downloaded", + &attachment.is_downloaded(false).to_string(), + ); + dict_put_str( + &mut reply, + "preview_downloaded", + &attachment.is_downloaded(true).to_string(), + ); Message::Dictionary(reply) } Err(e) => make_error_reply("DaemonError", &format!("{}", e)), @@ -169,10 +252,21 @@ pub async fn dispatch( // DownloadAttachment "DownloadAttachment" => { - let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; - let attachment_id = match dict_get_str(args, "attachment_id") { Some(v) => v, None => return make_error_reply("InvalidRequest", "Missing attachment_id") }; - let preview = dict_get_str(args, "preview").map(|s| s == "true").unwrap_or(false); - match agent.send_event(|r| Event::DownloadAttachment(attachment_id, preview, r)).await { + let args = match get_dictionary_field(root, "arguments") { + Some(a) => a, + None => return make_error_reply("InvalidRequest", "Missing arguments"), + }; + let attachment_id = match dict_get_str(args, "attachment_id") { + Some(v) => v, + None => return make_error_reply("InvalidRequest", "Missing attachment_id"), + }; + let preview = dict_get_str(args, "preview") + .map(|s| s == "true") + .unwrap_or(false); + match agent + .send_event(|r| Event::DownloadAttachment(attachment_id, preview, r)) + .await + { Ok(()) => make_ok_reply(), Err(e) => make_error_reply("DaemonError", &format!("{}", e)), } @@ -181,9 +275,18 @@ pub async fn dispatch( // UploadAttachment "UploadAttachment" => { use std::path::PathBuf; - let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; - let path = match dict_get_str(args, "path") { Some(v) => v, None => return make_error_reply("InvalidRequest", "Missing path") }; - match agent.send_event(|r| Event::UploadAttachment(PathBuf::from(path), r)).await { + let args = match get_dictionary_field(root, "arguments") { + Some(a) => a, + None => return make_error_reply("InvalidRequest", "Missing arguments"), + }; + let path = match dict_get_str(args, "path") { + Some(v) => v, + None => return make_error_reply("InvalidRequest", "Missing path"), + }; + match agent + .send_event(|r| Event::UploadAttachment(PathBuf::from(path), r)) + .await + { Ok(upload_guid) => { let mut reply: XpcMap = HashMap::new(); dict_put_str(&mut reply, "type", "UploadAttachmentResponse"); @@ -199,18 +302,36 @@ pub async fn dispatch( Ok(settings) => { let mut reply: XpcMap = HashMap::new(); dict_put_str(&mut reply, "type", "GetAllSettingsResponse"); - dict_put_str(&mut reply, "server_url", &settings.server_url.unwrap_or_default()); - dict_put_str(&mut reply, "username", &settings.username.unwrap_or_default()); + dict_put_str( + &mut reply, + "server_url", + &settings.server_url.unwrap_or_default(), + ); + dict_put_str( + &mut reply, + "username", + &settings.username.unwrap_or_default(), + ); Message::Dictionary(reply) } Err(e) => make_error_reply("DaemonError", &format!("{}", e)), }, "UpdateSettings" => { - let args = match get_dictionary_field(root, "arguments") { Some(a) => a, None => return make_error_reply("InvalidRequest", "Missing arguments") }; + let args = match get_dictionary_field(root, "arguments") { + Some(a) => a, + None => return make_error_reply("InvalidRequest", "Missing arguments"), + }; let server_url = dict_get_str(args, "server_url"); let username = dict_get_str(args, "username"); - let settings = Settings { server_url, username, token: None }; - match agent.send_event(|r| Event::UpdateSettings(settings, r)).await { + let settings = Settings { + server_url, + username, + token: None, + }; + match agent + .send_event(|r| Event::UpdateSettings(settings, r)) + .await + { Ok(()) => make_ok_reply(), Err(e) => make_error_reply("DaemonError", &format!("{}", e)), } @@ -224,7 +345,7 @@ pub async fn dispatch( log::trace!(target: LOG_TARGET, "Client subscribed to signals (total subscribers: {})", list.len()); } make_ok_reply() - }, + } // Unknown method fallback other => make_error_reply("UnknownMethod", other), diff --git a/kordophoned/src/xpc/util.rs b/kordophoned/src/xpc/util.rs index 2889c72..8efcb81 100644 --- a/kordophoned/src/xpc/util.rs +++ b/kordophoned/src/xpc/util.rs @@ -5,19 +5,27 @@ use xpc_connection::Message; pub type XpcMap = HashMap; -pub fn cstr(s: &str) -> CString { CString::new(s).unwrap_or_else(|_| CString::new("").unwrap()) } +pub fn cstr(s: &str) -> CString { + CString::new(s).unwrap_or_else(|_| CString::new("").unwrap()) +} pub fn get_dictionary_field<'a>( map: &'a HashMap, key: &str, ) -> Option<&'a HashMap> { let k = CString::new(key).ok()?; - map.get(&k).and_then(|v| match v { Message::Dictionary(d) => Some(d), _ => None }) + map.get(&k).and_then(|v| match v { + Message::Dictionary(d) => Some(d), + _ => None, + }) } pub fn dict_get_str(map: &HashMap, key: &str) -> Option { let k = CString::new(key).ok()?; - match map.get(&k) { Some(Message::String(v)) => Some(v.to_string_lossy().into_owned()), _ => None } + match map.get(&k) { + Some(Message::String(v)) => Some(v.to_string_lossy().into_owned()), + _ => None, + } } pub fn dict_get_i64_from_str(map: &HashMap, key: &str) -> Option { @@ -28,10 +36,15 @@ pub fn dict_put_str(map: &mut XpcMap, key: &str, value: impl AsRef) { map.insert(cstr(key), Message::String(cstr(value.as_ref()))); } -pub fn dict_put_i64_as_str(map: &mut XpcMap, key: &str, value: i64) { dict_put_str(map, key, value.to_string()); } +pub fn dict_put_i64_as_str(map: &mut XpcMap, key: &str, value: i64) { + dict_put_str(map, key, value.to_string()); +} pub fn array_from_strs(values: impl IntoIterator) -> Message { - let arr = values.into_iter().map(|s| Message::String(cstr(&s))).collect(); + let arr = values + .into_iter() + .map(|s| Message::String(cstr(&s))) + .collect(); Message::Array(arr) } diff --git a/kpcli/src/daemon/xpc.rs b/kpcli/src/daemon/xpc.rs index bada46e..e703781 100644 --- a/kpcli/src/daemon/xpc.rs +++ b/kpcli/src/daemon/xpc.rs @@ -495,7 +495,7 @@ impl DaemonInterface for XpcDaemonInterface { } Message::Error(xpc_connection::MessageError::ConnectionInvalid) => { eprintln!("[kpcli] XPC connection invalid"); - break + break; } other => { eprintln!("[kpcli] Unexpected XPC message: {:?}", other); From ee32a0398f935a13b5a3af8e791783469ec45fb8 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 24 Aug 2025 19:46:16 -0700 Subject: [PATCH 17/23] xpc: include attachment guids --- kordophoned/src/xpc/rpc.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/kordophoned/src/xpc/rpc.rs b/kordophoned/src/xpc/rpc.rs index 9d199a1..6bfa2a5 100644 --- a/kordophoned/src/xpc/rpc.rs +++ b/kordophoned/src/xpc/rpc.rs @@ -155,6 +155,15 @@ pub async fn dispatch( dict_put_str(&mut m, "text", &msg.text.replace('\u{FFFC}', "")); dict_put_i64_as_str(&mut m, "date", msg.date.and_utc().timestamp()); dict_put_str(&mut m, "sender", &msg.sender.display_name()); + + // Include attachment GUIDs for the client to resolve/download + let attachment_guids: Vec = msg + .attachments + .iter() + .map(|a| a.guid.clone()) + .collect(); + m.insert(cstr("attachment_guids"), array_from_strs(attachment_guids)); + items.push(Message::Dictionary(m)); } let mut reply: XpcMap = HashMap::new(); From f277fcd3411266996bc451cb9abc699fea1f4941 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 24 Aug 2025 19:46:28 -0700 Subject: [PATCH 18/23] sync policy: only ignore empty bodies if there are no attachments --- kordophoned/src/daemon/mod.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 8481e13..077ec64 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -626,7 +626,10 @@ impl Daemon { // the typing indicator or stuff like that. In the future, we need to move to ChatItems instead of Messages. let insertable_messages: Vec = messages .into_iter() - .filter(|m| !m.text.is_empty() && !m.text.trim().is_empty()) + .filter(|m| { + (!m.text.is_empty() && !m.text.trim().is_empty()) + || !m.file_transfer_guids.is_empty() + }) .collect(); let db_messages: Vec = insertable_messages From f2353461b36fdf6e103ca58c2d6cd14a37633591 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 24 Aug 2025 23:01:11 -0700 Subject: [PATCH 19/23] xpc: full attachment data --- kordophoned/src/xpc/rpc.rs | 44 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/kordophoned/src/xpc/rpc.rs b/kordophoned/src/xpc/rpc.rs index 6bfa2a5..904e87f 100644 --- a/kordophoned/src/xpc/rpc.rs +++ b/kordophoned/src/xpc/rpc.rs @@ -164,6 +164,50 @@ pub async fn dispatch( .collect(); m.insert(cstr("attachment_guids"), array_from_strs(attachment_guids)); + // Full attachments array with metadata (mirrors DBus fields) + let mut attachments_items: Vec = Vec::new(); + for attachment in msg.attachments.iter() { + let mut a: XpcMap = HashMap::new(); + // Basic identifiers + dict_put_str(&mut a, "guid", &attachment.guid); + + // Paths and download status + let path = attachment.get_path_for_preview(false); + let preview_path = attachment.get_path_for_preview(true); + let downloaded = attachment.is_downloaded(false); + let preview_downloaded = attachment.is_downloaded(true); + + dict_put_str(&mut a, "path", &path.to_string_lossy()); + dict_put_str(&mut a, "preview_path", &preview_path.to_string_lossy()); + dict_put_str(&mut a, "downloaded", &downloaded.to_string()); + dict_put_str( + &mut a, + "preview_downloaded", + &preview_downloaded.to_string(), + ); + + // Metadata (optional) + if let Some(metadata) = &attachment.metadata { + let mut metadata_map: XpcMap = HashMap::new(); + 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); + } + if let Some(height) = attribution_info.height { + dict_put_i64_as_str(&mut attribution_map, "height", height as i64); + } + metadata_map.insert(cstr("attribution_info"), Message::Dictionary(attribution_map)); + } + if !metadata_map.is_empty() { + a.insert(cstr("metadata"), Message::Dictionary(metadata_map)); + } + } + + 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(); From eaa5966e993d5a7b41a711957083be74d6e85dcb Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 24 Aug 2025 23:20:25 -0700 Subject: [PATCH 20/23] xpc: adds OpenAttachmentFd --- kordophoned/src/xpc/agent.rs | 35 +++++++++++++++++++++++++++++++++++ kordophoned/src/xpc/rpc.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/kordophoned/src/xpc/agent.rs b/kordophoned/src/xpc/agent.rs index 3e4baaf..409eb04 100644 --- a/kordophoned/src/xpc/agent.rs +++ b/kordophoned/src/xpc/agent.rs @@ -113,12 +113,47 @@ impl XpcAgent { let response = rt_conn.block_on(super::rpc::dispatch(&agent_conn, &conns_for_handler, client, &map)); let reply = xpc_sys::xpc_dictionary_create_reply(msg); if !reply.is_null() { + use std::ffi::CString as StdCString; + use std::os::fd::AsRawFd; + + // Precompute optional fd_path instruction from the message map + let mut maybe_fd_path: Option = None; + if let Message::Dictionary(ref resp_map) = response { + let attach = super::util::dict_get_str(resp_map, "attach_fd").unwrap_or_default() == "true"; + if attach { + maybe_fd_path = super::util::dict_get_str(resp_map, "fd_path"); + } + } + let payload = message_to_xpc_object(response); let apply_block = ConcreteBlock::new(move |key: *const c_char, value: xpc_sys::xpc_object_t| { xpc_sys::xpc_dictionary_set_value(reply, key, value); }) .copy(); + xpc_sys::xpc_dictionary_apply(payload, apply_block.deref() as *const _ as *mut _); + + // Optional FD attachment if requested by response + if let Some(fd_path) = maybe_fd_path { + match std::fs::OpenOptions::new().read(true).open(&fd_path) { + Ok(file) => { + let raw_fd = file.as_raw_fd(); + unsafe { + let fd_obj = xpc_sys::xpc_fd_create(raw_fd); + let key = StdCString::new("fd").unwrap(); + xpc_sys::xpc_dictionary_set_value(reply, key.as_ptr(), fd_obj); + // fd_obj is retained by reply; release our reference + xpc_sys::xpc_release(fd_obj); + } + // Keep file alive until after send + std::mem::forget(file); + } + Err(e) => { + log::warn!(target: LOG_TARGET, "Failed to open fd_path '{}': {}", fd_path, e); + } + } + } + xpc_sys::xpc_connection_send_message(client, reply); xpc_sys::xpc_release(payload); xpc_sys::xpc_release(reply); diff --git a/kordophoned/src/xpc/rpc.rs b/kordophoned/src/xpc/rpc.rs index 904e87f..bcdc4f1 100644 --- a/kordophoned/src/xpc/rpc.rs +++ b/kordophoned/src/xpc/rpc.rs @@ -303,6 +303,39 @@ pub async fn dispatch( } } + // OpenAttachmentFd (attach file descriptor in reply) + "OpenAttachmentFd" => { + let args = match get_dictionary_field(root, "arguments") { + Some(a) => a, + None => return make_error_reply("InvalidRequest", "Missing arguments"), + }; + let attachment_id = match dict_get_str(args, "attachment_id") { + Some(v) => v, + None => return make_error_reply("InvalidRequest", "Missing attachment_id"), + }; + let preview = dict_get_str(args, "preview") + .map(|s| s == "true") + .unwrap_or(false); + + match agent + .send_event(|r| Event::GetAttachment(attachment_id, r)) + .await + { + Ok(attachment) => { + let path = attachment.get_path_for_preview(preview); + let mut reply: XpcMap = HashMap::new(); + + // The agent resolves fd_path to a file descriptor and returns it in the reply + dict_put_str(&mut reply, "type", "OpenAttachmentFdResponse"); + dict_put_str(&mut reply, "fd_path", &path.to_string_lossy()); + dict_put_str(&mut reply, "attach_fd", "true"); + + Message::Dictionary(reply) + } + Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + } + } + // DownloadAttachment "DownloadAttachment" => { let args = match get_dictionary_field(root, "arguments") { From cc59fe4996cac64a8a54ec21fda76a7c2103a590 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 24 Aug 2025 23:38:14 -0700 Subject: [PATCH 21/23] xpc: better file descriptor handling --- kordophoned/src/xpc/agent.rs | 35 +---------------------------------- kordophoned/src/xpc/rpc.rs | 28 +++++++++++++++++++--------- 2 files changed, 20 insertions(+), 43 deletions(-) diff --git a/kordophoned/src/xpc/agent.rs b/kordophoned/src/xpc/agent.rs index 409eb04..9a7040b 100644 --- a/kordophoned/src/xpc/agent.rs +++ b/kordophoned/src/xpc/agent.rs @@ -113,46 +113,13 @@ impl XpcAgent { let response = rt_conn.block_on(super::rpc::dispatch(&agent_conn, &conns_for_handler, client, &map)); let reply = xpc_sys::xpc_dictionary_create_reply(msg); if !reply.is_null() { - use std::ffi::CString as StdCString; - use std::os::fd::AsRawFd; - - // Precompute optional fd_path instruction from the message map - let mut maybe_fd_path: Option = None; - if let Message::Dictionary(ref resp_map) = response { - let attach = super::util::dict_get_str(resp_map, "attach_fd").unwrap_or_default() == "true"; - if attach { - maybe_fd_path = super::util::dict_get_str(resp_map, "fd_path"); - } - } - let payload = message_to_xpc_object(response); let apply_block = ConcreteBlock::new(move |key: *const c_char, value: xpc_sys::xpc_object_t| { xpc_sys::xpc_dictionary_set_value(reply, key, value); }) .copy(); - - xpc_sys::xpc_dictionary_apply(payload, apply_block.deref() as *const _ as *mut _); - // Optional FD attachment if requested by response - if let Some(fd_path) = maybe_fd_path { - match std::fs::OpenOptions::new().read(true).open(&fd_path) { - Ok(file) => { - let raw_fd = file.as_raw_fd(); - unsafe { - let fd_obj = xpc_sys::xpc_fd_create(raw_fd); - let key = StdCString::new("fd").unwrap(); - xpc_sys::xpc_dictionary_set_value(reply, key.as_ptr(), fd_obj); - // fd_obj is retained by reply; release our reference - xpc_sys::xpc_release(fd_obj); - } - // Keep file alive until after send - std::mem::forget(file); - } - Err(e) => { - log::warn!(target: LOG_TARGET, "Failed to open fd_path '{}': {}", fd_path, e); - } - } - } + xpc_sys::xpc_dictionary_apply(payload, apply_block.deref() as *const _ as *mut _); xpc_sys::xpc_connection_send_message(client, reply); xpc_sys::xpc_release(payload); diff --git a/kordophoned/src/xpc/rpc.rs b/kordophoned/src/xpc/rpc.rs index bcdc4f1..1a599bf 100644 --- a/kordophoned/src/xpc/rpc.rs +++ b/kordophoned/src/xpc/rpc.rs @@ -303,7 +303,7 @@ pub async fn dispatch( } } - // OpenAttachmentFd (attach file descriptor in reply) + // OpenAttachmentFd (return file descriptor in reply) "OpenAttachmentFd" => { let args = match get_dictionary_field(root, "arguments") { Some(a) => a, @@ -322,15 +322,25 @@ pub async fn dispatch( .await { Ok(attachment) => { - let path = attachment.get_path_for_preview(preview); - let mut reply: XpcMap = HashMap::new(); - - // The agent resolves fd_path to a file descriptor and returns it in the reply - dict_put_str(&mut reply, "type", "OpenAttachmentFdResponse"); - dict_put_str(&mut reply, "fd_path", &path.to_string_lossy()); - dict_put_str(&mut reply, "attach_fd", "true"); + use std::os::fd::AsRawFd; - Message::Dictionary(reply) + let path = attachment.get_path_for_preview(preview); + match std::fs::OpenOptions::new().read(true).open(&path) { + Ok(file) => { + let fd = file.as_raw_fd(); + + // Keep file alive until after conversion to XPC + std::mem::forget(file); + + // Return file descriptor in reply + let mut reply: XpcMap = HashMap::new(); + dict_put_str(&mut reply, "type", "OpenAttachmentFdResponse"); + reply.insert(cstr("fd"), Message::Fd(fd)); + + Message::Dictionary(reply) + } + Err(e) => make_error_reply("OpenFailed", &format!("{}", e)), + } } Err(e) => make_error_reply("DaemonError", &format!("{}", e)), } From f82123a4542a8c501ae77ff28e30f4f024922022 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 25 Aug 2025 00:09:57 -0700 Subject: [PATCH 22/23] daemon: fix crash when misconfigured --- kordophoned/src/daemon/mod.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 077ec64..ea7be0b 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -701,7 +701,13 @@ impl Daemon { .ok_or(DaemonError::ClientNotConfigured)?; let client = HTTPAPIClient::new( - server_url.parse().unwrap(), + match server_url.parse() { + Ok(url) => url, + Err(_) => { + log::error!(target: target::DAEMON, "Invalid server URL: {}", server_url); + return Err(DaemonError::ClientNotConfigured.into()); + } + }, DatabaseAuthenticationStore::new(database.clone()), ); From c30330a4447a0c5ff888d073f8b397a1680a3b77 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 25 Aug 2025 00:56:03 -0700 Subject: [PATCH 23/23] auth: try switching to platform agnostic auth store --- Cargo.lock | 58 +++++++++++++++++++++------- kordophoned/Cargo.toml | 2 +- kordophoned/src/daemon/auth_store.rs | 25 ++++-------- 3 files changed, 54 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1bd2904..e748ba9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -200,9 +200,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.5.0" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" [[package]] name = "block" @@ -349,6 +349,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.6" @@ -1101,12 +1111,15 @@ dependencies = [ [[package]] name = "keyring" -version = "3.6.2" +version = "3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1961983669d57bdfe6c0f3ef8e4c229b5ef751afcc7d87e4271d2f71f6ccfa8b" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ "dbus-secret-service", "log", + "security-framework 2.10.0", + "security-framework 3.3.0", + "zeroize", ] [[package]] @@ -1247,7 +1260,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -1256,7 +1269,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.9.3", "libc", ] @@ -1353,7 +1366,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.10.0", "security-framework-sys", "tempfile", ] @@ -1468,7 +1481,7 @@ version = "0.10.64" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.9.3", "cfg-if", "foreign-types", "libc", @@ -1773,7 +1786,7 @@ version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.9.3", "errno", "libc", "linux-raw-sys", @@ -1814,7 +1827,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "770452e37cad93e0a50d5abc3990d2bc351c36d0328f86cefec2f2fb206eaef6" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80fb1d92c5028aa318b4b8bd7302a5bfcf48be96a37fc6fc790f806b0004ee0c" +dependencies = [ + "bitflags 2.9.3", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -1822,9 +1848,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.10.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f3cc463c0ef97e11c3461a9d3787412d30e8e7eb907c79180c4a57bf7c04ef" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" dependencies = [ "core-foundation-sys", "libc", @@ -2585,7 +2611,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.9.3", ] [[package]] @@ -2611,3 +2637,9 @@ source = "git+https://github.com/dfrankland/xpc-connection-rs.git?rev=cd4fb3d#cd dependencies = [ "bindgen", ] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/kordophoned/Cargo.toml b/kordophoned/Cargo.toml index dedfe31..94d0dfb 100644 --- a/kordophoned/Cargo.toml +++ b/kordophoned/Cargo.toml @@ -10,7 +10,7 @@ chrono = "0.4.38" directories = "6.0.0" env_logger = "0.11.6" futures-util = "0.3.31" -keyring = { version = "3.6.2", features = ["sync-secret-service"] } +keyring = { version = "3.6.3", features = ["apple-native", "sync-secret-service"] } kordophone = { path = "../kordophone" } kordophone-db = { path = "../kordophone-db" } log = "0.4.25" diff --git a/kordophoned/src/daemon/auth_store.rs b/kordophoned/src/daemon/auth_store.rs index 077e502..3f7c21b 100644 --- a/kordophoned/src/daemon/auth_store.rs +++ b/kordophoned/src/daemon/auth_store.rs @@ -21,10 +21,7 @@ impl DatabaseAuthenticationStore { #[async_trait] impl AuthenticationStore for DatabaseAuthenticationStore { - #[cfg(target_os = "linux")] async fn get_credentials(&mut self) -> Option { - use keyring::secret_service::SsCredential; - self.database .lock() .await @@ -38,15 +35,14 @@ impl AuthenticationStore for DatabaseAuthenticationStore { match username { Some(username) => { - let credential = SsCredential::new_with_target( - None, - "net.buzzert.kordophonecd", - &username, - ) - .unwrap(); - - let password: Result = - Entry::new_with_credential(Box::new(credential)).get_password(); + let credential_res = Entry::new("net.buzzert.kordophonecd", &username); + let password: Result = match credential_res { + Ok(credential) => credential.get_password(), + Err(e) => { + log::error!("error creating keyring credential: {}", e); + return None; + } + }; match password { Ok(password) => Some(Credentials { username, password }), @@ -62,11 +58,6 @@ impl AuthenticationStore for DatabaseAuthenticationStore { .await } - #[cfg(not(target_os = "linux"))] - async fn get_credentials(&mut self) -> Option { - None - } - async fn get_token(&mut self) -> Option { self.database .lock()