From 0b2811dc9fc616c0539ad2c178242cabfbe51ddb Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 20 Apr 2024 18:17:55 -0700 Subject: [PATCH 001/138] Initial commit --- .gitignore | 1 + Cargo.lock | 7 +++++++ Cargo.toml | 10 ++++++++++ kordophone/Cargo.toml | 8 ++++++++ kordophone/src/api/mod.rs | 12 ++++++++++++ kordophone/src/lib.rs | 16 ++++++++++++++++ 6 files changed, 54 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 kordophone/Cargo.toml create mode 100644 kordophone/src/api/mod.rs create mode 100644 kordophone/src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3f013c7 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "kordophone" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ced7153 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[workspace] +members = [ + "kordophone" +] +resolver = "2" + +[profile.release] +lto = "thin" +debug = 1 +incremental = false diff --git a/kordophone/Cargo.toml b/kordophone/Cargo.toml new file mode 100644 index 0000000..7d1a124 --- /dev/null +++ b/kordophone/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "kordophone" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs new file mode 100644 index 0000000..1e2553d --- /dev/null +++ b/kordophone/src/api/mod.rs @@ -0,0 +1,12 @@ + +pub trait APIInterface { + fn version(&self) -> String; +} + +pub struct TestClient {} + +impl APIInterface for TestClient { + fn version(&self) -> String { + return "KordophoneTest-1.0".to_string() + } +} diff --git a/kordophone/src/lib.rs b/kordophone/src/lib.rs new file mode 100644 index 0000000..203b1a5 --- /dev/null +++ b/kordophone/src/lib.rs @@ -0,0 +1,16 @@ +mod api; + +pub use self::api::TestClient; +pub use self::api::APIInterface; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version() { + let client = TestClient{}; + let version = client.version(); + assert_eq!(version, "KordophoneTest-1.0"); + } +} From 3e878ced4e0870db1b99f8299c2a5acf1e203f3f Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 21 Apr 2024 15:14:16 -0700 Subject: [PATCH 002/138] reorg --- Cargo.lock | 235 +++++++++++++++++++++++++++ kordophone/Cargo.toml | 5 + kordophone/src/api/mod.rs | 18 +- kordophone/src/lib.rs | 13 +- kordophone/src/model/conversation.rs | 86 ++++++++++ kordophone/src/model/mod.rs | 3 + kordophone/src/tests/mod.rs | 22 +++ kordophone/src/tests/test_client.rs | 16 ++ 8 files changed, 379 insertions(+), 19 deletions(-) create mode 100644 kordophone/src/model/conversation.rs create mode 100644 kordophone/src/model/mod.rs create mode 100644 kordophone/src/tests/mod.rs create mode 100644 kordophone/src/tests/test_client.rs diff --git a/Cargo.lock b/Cargo.lock index 3f013c7..f9f0a20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,241 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "async-trait" +version = "0.1.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "getrandom" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + [[package]] name = "kordophone" version = "0.1.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "time", + "uuid", +] + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "proc-macro2" +version = "1.0.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "ryu" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" + +[[package]] +name = "serde" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9846a40c979031340571da2545a4e5b7c4163bdae79b301d5f86d03979451fcc" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e88edab869b01783ba905e7d0153f9fc1a6505a96e4ad3018011eedb838566d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "syn" +version = "2.0.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "uuid" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" +dependencies = [ + "getrandom", + "rand", + "uuid-macro-internal", +] + +[[package]] +name = "uuid-macro-internal" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9881bea7cbe687e36c9ab3b778c36cd0487402e270304e8b1296d5085303c1a2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" diff --git a/kordophone/Cargo.toml b/kordophone/Cargo.toml index 7d1a124..f604194 100644 --- a/kordophone/Cargo.toml +++ b/kordophone/Cargo.toml @@ -6,3 +6,8 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +async-trait = "0.1.80" +serde = { version = "1.0.152", features = ["derive"] } +serde_json = "1.0.91" +time = { version = "0.3.17", features = ["parsing", "serde"] } +uuid = { version = "1.6.1", features = ["v4", "fast-rng", "macro-diagnostics"] } diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index 1e2553d..c5b2ac8 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -1,12 +1,14 @@ +use async_trait::async_trait; +pub use crate::model::Conversation; +#[async_trait] pub trait APIInterface { - fn version(&self) -> String; + type Error; + + // (GET) /version + async fn get_version(&self) -> Result; + + // (GET) /conversations + async fn get_conversations(&self) -> Result, Self::Error>; } -pub struct TestClient {} - -impl APIInterface for TestClient { - fn version(&self) -> String { - return "KordophoneTest-1.0".to_string() - } -} diff --git a/kordophone/src/lib.rs b/kordophone/src/lib.rs index 203b1a5..c0ba5bc 100644 --- a/kordophone/src/lib.rs +++ b/kordophone/src/lib.rs @@ -1,16 +1,7 @@ mod api; -pub use self::api::TestClient; +pub mod model; pub use self::api::APIInterface; #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_version() { - let client = TestClient{}; - let version = client.version(); - assert_eq!(version, "KordophoneTest-1.0"); - } -} +pub mod tests; diff --git a/kordophone/src/model/conversation.rs b/kordophone/src/model/conversation.rs new file mode 100644 index 0000000..fdca656 --- /dev/null +++ b/kordophone/src/model/conversation.rs @@ -0,0 +1,86 @@ +use serde::Deserialize; +use time::OffsetDateTime; +use uuid::Uuid; + +#[derive(Debug, Clone, Deserialize)] +pub struct Conversation { + pub guid: String, + + #[serde(with = "time::serde::iso8601")] + pub date: OffsetDateTime, + + #[serde(rename = "unreadCount")] + pub unread_count: i32, + + #[serde(rename = "lastMessagePreview")] + pub last_message_preview: Option, + + #[serde(rename = "participantDisplayNames")] + pub participant_display_names: Vec, + + #[serde(rename = "displayName")] + pub display_name: Option, +} + +impl Conversation { + pub fn builder() -> ConversationBuilder { + ConversationBuilder::new() + } +} + +#[derive(Default)] +pub struct ConversationBuilder { + guid: Option, + date: Option, + unread_count: Option, + last_message_preview: Option, + participant_display_names: Option>, + display_name: Option, +} + +impl ConversationBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn guid(mut self, guid: String) -> Self { + self.guid = Some(guid); + self + } + + pub fn date(mut self, date: OffsetDateTime) -> Self { + self.date = Some(date); + self + } + + pub fn unread_count(mut self, unread_count: i32) -> Self { + self.unread_count = Some(unread_count); + self + } + + pub fn last_message_preview(mut self, last_message_preview: String) -> Self { + self.last_message_preview = Some(last_message_preview); + self + } + + pub fn participant_display_names(mut self, participant_display_names: Vec) -> Self { + self.participant_display_names = Some(participant_display_names); + self + } + + pub fn display_name(mut self, display_name: T) -> Self where T: Into { + self.display_name = Some(display_name.into()); + self + } + + pub fn build(self) -> Conversation { + Conversation { + guid: self.guid.unwrap_or(Uuid::new_v4().to_string()), + date: self.date.unwrap_or(OffsetDateTime::now_utc()), + unread_count: self.unread_count.unwrap_or(0), + last_message_preview: self.last_message_preview, + participant_display_names: self.participant_display_names.unwrap_or(vec![]), + display_name: self.display_name, + } + } +} diff --git a/kordophone/src/model/mod.rs b/kordophone/src/model/mod.rs new file mode 100644 index 0000000..291ac75 --- /dev/null +++ b/kordophone/src/model/mod.rs @@ -0,0 +1,3 @@ + +pub mod conversation; +pub use conversation::Conversation; diff --git a/kordophone/src/tests/mod.rs b/kordophone/src/tests/mod.rs new file mode 100644 index 0000000..07f905c --- /dev/null +++ b/kordophone/src/tests/mod.rs @@ -0,0 +1,22 @@ +mod test_client; +use self::test_client::TestClient; +use crate::APIInterface; + +pub mod api_interface { + use super::*; + + #[test] + fn test_version() { + let client = TestClient{}; + let version = client.get_version(); + assert_eq!(version, "KordophoneTest-1.0"); + } + + #[test] + fn test_conversations() { + let client = TestClient{}; + let conversations = client.get_conversations(); + assert_eq!(conversations.len(), 1); + assert_eq!(conversations[0].display_name, Some("Test Conversation".to_string())); + } +} \ No newline at end of file diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs new file mode 100644 index 0000000..7a50cef --- /dev/null +++ b/kordophone/src/tests/test_client.rs @@ -0,0 +1,16 @@ +pub use crate::APIInterface; +use crate::model::Conversation; + +pub struct TestClient {} + +impl APIInterface for TestClient { + fn get_version(&self) -> String { + return "KordophoneTest-1.0".to_string() + } + + fn get_conversations(&self) -> Vec { + let mut conversations = Vec::new(); + conversations.push(Conversation::builder().display_name("Test Conversation").build()); + conversations + } +} From 48dcf9daca94ac198e6ab06ccdaa292917f2a491 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 21 Apr 2024 23:09:37 -0700 Subject: [PATCH 003/138] Fix tests --- Cargo.lock | 359 ++++++++++++++++++++++++++++ kordophone/Cargo.toml | 1 + kordophone/src/tests/mod.rs | 29 ++- kordophone/src/tests/test_client.rs | 36 ++- 4 files changed, 406 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f9f0a20..e38ffa4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + [[package]] name = "async-trait" version = "0.1.80" @@ -13,6 +28,45 @@ dependencies = [ "syn", ] +[[package]] +name = "autocfg" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80" + +[[package]] +name = "backtrace" +version = "0.3.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bytes" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" + +[[package]] +name = "cc" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d32a725bc159af97c3e629873bb9f88fb8cf8a4867175f76dc987815ea07c83b" + [[package]] name = "cfg-if" version = "1.0.0" @@ -40,6 +94,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "hermit-abi" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" + [[package]] name = "itoa" version = "1.0.11" @@ -54,6 +120,7 @@ dependencies = [ "serde", "serde_json", "time", + "tokio", "uuid", ] @@ -63,12 +130,96 @@ version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +[[package]] +name = "lock_api" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" + +[[package]] +name = "miniz_oxide" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.48.5", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + [[package]] name = "powerfmt" version = "0.2.0" @@ -129,12 +280,33 @@ dependencies = [ "getrandom", ] +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + [[package]] name = "ryu" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.198" @@ -166,6 +338,31 @@ dependencies = [ "serde", ] +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "smallvec" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970" + +[[package]] +name = "socket2" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "syn" version = "2.0.60" @@ -207,6 +404,36 @@ dependencies = [ "time-core", ] +[[package]] +name = "tokio" +version = "1.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.48.0", +] + +[[package]] +name = "tokio-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "unicode-ident" version = "1.0.12" @@ -240,3 +467,135 @@ name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.4", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" +dependencies = [ + "windows_aarch64_gnullvm 0.52.4", + "windows_aarch64_msvc 0.52.4", + "windows_i686_gnu 0.52.4", + "windows_i686_msvc 0.52.4", + "windows_x86_64_gnu 0.52.4", + "windows_x86_64_gnullvm 0.52.4", + "windows_x86_64_msvc 0.52.4", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" diff --git a/kordophone/Cargo.toml b/kordophone/Cargo.toml index f604194..11b60d6 100644 --- a/kordophone/Cargo.toml +++ b/kordophone/Cargo.toml @@ -10,4 +10,5 @@ async-trait = "0.1.80" serde = { version = "1.0.152", features = ["derive"] } serde_json = "1.0.91" time = { version = "0.3.17", features = ["parsing", "serde"] } +tokio = { version = "1.37.0", features = ["full"] } uuid = { version = "1.6.1", features = ["v4", "fast-rng", "macro-diagnostics"] } diff --git a/kordophone/src/tests/mod.rs b/kordophone/src/tests/mod.rs index 07f905c..b4e065a 100644 --- a/kordophone/src/tests/mod.rs +++ b/kordophone/src/tests/mod.rs @@ -3,20 +3,29 @@ use self::test_client::TestClient; use crate::APIInterface; pub mod api_interface { + use crate::model::Conversation; + use super::*; - #[test] - fn test_version() { - let client = TestClient{}; - let version = client.get_version(); - assert_eq!(version, "KordophoneTest-1.0"); + #[tokio::test] + async fn test_version() { + let client = TestClient::new(); + let version = client.get_version().await.unwrap(); + assert_eq!(version, client.version); } - #[test] - fn test_conversations() { - let client = TestClient{}; - let conversations = client.get_conversations(); + #[tokio::test] + async fn test_conversations() { + let mut client = TestClient::new(); + + let test_convo = Conversation::builder() + .display_name("Test Conversation") + .build(); + + client.conversations.push(test_convo.clone()); + + let conversations = client.get_conversations().await.unwrap(); assert_eq!(conversations.len(), 1); - assert_eq!(conversations[0].display_name, Some("Test Conversation".to_string())); + assert_eq!(conversations[0].display_name, test_convo.display_name); } } \ No newline at end of file diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index 7a50cef..e38882e 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -1,16 +1,34 @@ +use async_trait::async_trait; + pub use crate::APIInterface; use crate::model::Conversation; -pub struct TestClient {} +pub struct TestClient { + pub version: &'static str, + pub conversations: Vec, +} -impl APIInterface for TestClient { - fn get_version(&self) -> String { - return "KordophoneTest-1.0".to_string() - } +#[derive(Debug)] +pub enum TestError {} - fn get_conversations(&self) -> Vec { - let mut conversations = Vec::new(); - conversations.push(Conversation::builder().display_name("Test Conversation").build()); - conversations +impl TestClient { + pub fn new() -> TestClient { + TestClient { + version: "KordophoneTest-1.0", + conversations: vec![], + } + } +} + +#[async_trait] +impl APIInterface for TestClient { + type Error = TestError; + + async fn get_version(&self) -> Result { + Ok(self.version.to_string()) + } + + async fn get_conversations(&self) -> Result, Self::Error> { + Ok(self.conversations.clone()) } } From cf4195858e42e23ccf77aa38567346c7d4f0644b Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 24 Apr 2024 23:41:42 -0700 Subject: [PATCH 004/138] Started work on http server --- Cargo.lock | 448 +++++++++++++++++++++++++++++- kordophone/Cargo.toml | 2 + kordophone/src/api/http_client.rs | 138 +++++++++ kordophone/src/api/mod.rs | 3 + 4 files changed, 590 insertions(+), 1 deletion(-) create mode 100644 kordophone/src/api/http_client.rs diff --git a/Cargo.lock b/Cargo.lock index e38ffa4..6655564 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -55,6 +55,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" + [[package]] name = "bytes" version = "1.6.0" @@ -73,6 +79,22 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + [[package]] name = "deranged" version = "0.3.11" @@ -83,6 +105,88 @@ dependencies = [ "serde", ] +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + [[package]] name = "getrandom" version = "0.2.14" @@ -100,12 +204,118 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "h2" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" + [[package]] name = "hermit-abi" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "indexmap" +version = "2.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "itoa" version = "1.0.11" @@ -117,6 +327,8 @@ name = "kordophone" version = "0.1.0" dependencies = [ "async-trait", + "hyper", + "hyper-tls", "serde", "serde_json", "time", @@ -124,12 +336,24 @@ dependencies = [ "uuid", ] +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + [[package]] name = "libc" version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +[[package]] +name = "linux-raw-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" + [[package]] name = "lock_api" version = "0.4.11" @@ -140,6 +364,12 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "log" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" + [[package]] name = "memchr" version = "2.7.2" @@ -166,6 +396,24 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "native-tls" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -191,6 +439,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "openssl" +version = "0.10.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" +dependencies = [ + "bitflags 2.5.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking_lot" version = "0.12.1" @@ -220,6 +518,18 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" + [[package]] name = "powerfmt" version = "0.2.0" @@ -286,7 +596,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] @@ -295,18 +605,63 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustix" +version = "0.38.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +dependencies = [ + "bitflags 2.5.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "ryu" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" +[[package]] +name = "schannel" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "770452e37cad93e0a50d5abc3990d2bc351c36d0328f86cefec2f2fb206eaef6" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f3cc463c0ef97e11c3461a9d3787412d30e8e7eb907c79180c4a57bf7c04ef" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.198" @@ -347,6 +702,15 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + [[package]] name = "smallvec" version = "1.11.2" @@ -374,6 +738,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "time" version = "0.3.36" @@ -434,6 +810,61 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicode-ident" version = "1.0.12" @@ -462,6 +893,21 @@ dependencies = [ "syn", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/kordophone/Cargo.toml b/kordophone/Cargo.toml index 11b60d6..34af7b8 100644 --- a/kordophone/Cargo.toml +++ b/kordophone/Cargo.toml @@ -7,6 +7,8 @@ edition = "2021" [dependencies] async-trait = "0.1.80" +hyper = { version = "0.14", features = ["full"] } +hyper-tls = "0.5.0" serde = { version = "1.0.152", features = ["derive"] } serde_json = "1.0.91" time = { version = "0.3.17", features = ["parsing", "serde"] } diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs new file mode 100644 index 0000000..ee958df --- /dev/null +++ b/kordophone/src/api/http_client.rs @@ -0,0 +1,138 @@ +extern crate hyper; +extern crate serde; + +use std::path::PathBuf; + +use hyper::{Body, Client, Method, Request, Uri, body}; + +use async_trait::async_trait; +use serde::de::DeserializeOwned; +use crate::{APIInterface, model::Conversation}; + +type HttpClient = Client; + +pub struct HTTPClient { + pub base_url: Uri, + client: HttpClient, +} + +#[derive(Debug)] +pub enum Error { + ClientError(String), + HTTPError(hyper::Error), + SerdeError(serde_json::Error), +} + +impl From for Error { + fn from(err: hyper::Error) -> Error { + Error::HTTPError(err) + } +} + +impl From for Error { + fn from(err: serde_json::Error) -> Error { + Error::SerdeError(err) + } +} + +impl HTTPClient { + pub fn new(base_url: Uri) -> HTTPClient { + HTTPClient { + base_url: base_url, + client: Client::new(), + } + } +} + +#[async_trait] +impl APIInterface for HTTPClient { + type Error = Error; + + async fn get_version(&self) -> Result { + let version: String = self.request("/version", Method::GET).await?; + Ok(version) + } + + async fn get_conversations(&self) -> Result, Self::Error> { + let conversations: Vec = self.request("/conversations", Method::GET).await?; + Ok(conversations) + } +} + +impl HTTPClient { + fn uri_for_endpoint(&self, endpoint: &str) -> Uri { + let mut parts = self.base_url.clone().into_parts(); + let root_path: PathBuf = parts.path_and_query.unwrap().path().into(); + let path = root_path.join(endpoint); + parts.path_and_query = Some(path.to_str().unwrap().parse().unwrap()); + + Uri::try_from(parts).unwrap() + } + + async fn request(&self, endpoint: &str, method: Method) -> Result { + self.request_with_body(endpoint, method, Body::empty()).await + } + + async fn request_with_body(&self, endpoint: &str, method: Method, body: Body) -> Result { + let uri = self.uri_for_endpoint(endpoint); + let request = Request::builder() + .method(method) + .uri(uri) + .body(body) + .unwrap(); + + let future = self.client.request(request); + let res = future.await?; + let status = res.status(); + + if status != hyper::StatusCode::OK { + let message = format!("Request failed ({:})", status); + return Err(Error::ClientError(message)); + } + + // Read and parse response body + let body = hyper::body::to_bytes(res.into_body()).await?; + let parsed: T = serde_json::from_slice(&body)?; + + Ok(parsed) + } +} + +mod test { + use super::*; + + fn local_mock_client() -> HTTPClient { + let base_url = "http://localhost:5738".parse().unwrap(); + HTTPClient::new(base_url) + } + + async fn mock_client_is_reachable() -> bool { + let client = local_mock_client(); + let version = client.get_version().await; + version.is_ok() + } + + #[tokio::test] + async fn test_version() { + if !mock_client_is_reachable().await { + println!("Skipping http_client tests (mock server not reachable)"); + return; + } + + let client = local_mock_client(); + let version = client.get_version().await.unwrap(); + assert!(version.starts_with("KordophoneMock-")); + } + + #[tokio::test] + async fn test_conversations() { + if !mock_client_is_reachable().await { + println!("Skipping http_client tests (mock server not reachable)"); + return; + } + + let client = local_mock_client(); + let conversations = client.get_conversations().await.unwrap(); + assert!(conversations.len() > 0); + } +} diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index c5b2ac8..da28032 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -1,6 +1,9 @@ use async_trait::async_trait; pub use crate::model::Conversation; +pub mod http_client; +pub use http_client::HTTPClient; + #[async_trait] pub trait APIInterface { type Error; From a2caa2ddcafb9331e494650bc0f40a5bc4227b75 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 1 Jun 2024 18:16:25 -0700 Subject: [PATCH 005/138] prepare for tower middleware adoption --- Cargo.lock | 922 ++++++++++++++++++++++++++++-- kordophone/Cargo.toml | 7 + kordophone/src/api/http_client.rs | 62 +- 3 files changed, 936 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6655564..e0c301e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "async-trait" version = "0.1.80" @@ -42,7 +51,7 @@ checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" dependencies = [ "addr2line", "cc", - "cfg-if", + "cfg-if 1.0.0", "libc", "miniz_oxide", "object", @@ -61,6 +70,23 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206fdffcfa2df7cbe15601ef46c813fce0965eb3286db6b56c583b814b51c81c" +dependencies = [ + "byteorder", + "either", + "iovec", +] + [[package]] name = "bytes" version = "1.6.0" @@ -73,12 +99,27 @@ version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d32a725bc159af97c3e629873bb9f88fb8cf8a4867175f76dc987815ea07c83b" +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cloudabi" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -95,6 +136,64 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "crossbeam-deque" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20ff29ded3204c5106278a81a38f4b482636ed4fa1e6cfbeef193291beb29ed" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "maybe-uninit", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "058ed274caafc1f60c4997b5fc07bf7dc7cca454af7c6e81edffe5f33f70dace" +dependencies = [ + "autocfg", + "cfg-if 0.1.10", + "crossbeam-utils", + "lazy_static", + "maybe-uninit", + "memoffset", + "scopeguard", +] + +[[package]] +name = "crossbeam-queue" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "774ba60a54c213d409d5353bda12d49cd68d14e45036a285234c8d6f91f92570" +dependencies = [ + "cfg-if 0.1.10", + "crossbeam-utils", + "maybe-uninit", +] + +[[package]] +name = "crossbeam-utils" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c7c73a2d1e9fc0886a08b93e98eb643461230d5f1925e4036204d5f2e261a8" +dependencies = [ + "autocfg", + "cfg-if 0.1.10", + "lazy_static", +] + +[[package]] +name = "ctor" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edb49164822f3ee45b17acd4a208cfc1251410cf0cad9a833234c9890774dd9f" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "deranged" version = "0.3.11" @@ -105,6 +204,25 @@ dependencies = [ "serde", ] +[[package]] +name = "either" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b" + +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -148,6 +266,28 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "fuchsia-zircon" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e9763c69ebaae630ba35f74888db465e49e259ba1bc0eda7d06f4a067615d82" +dependencies = [ + "bitflags 1.3.2", + "fuchsia-zircon-sys", +] + +[[package]] +name = "fuchsia-zircon-sys" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" + +[[package]] +name = "futures" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678" + [[package]] name = "futures-channel" version = "0.3.30" @@ -163,6 +303,16 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +[[package]] +name = "futures-cpupool" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab90cde24b3319636588d0c35fe03b1333857621051837ed769faefb4c2162e4" +dependencies = [ + "futures", + "num_cpus", +] + [[package]] name = "futures-sink" version = "0.3.30" @@ -193,9 +343,9 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", ] [[package]] @@ -204,25 +354,49 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "h2" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5b34c246847f938a410a03c5458c7fee2274436675e76d8b903c08efc29c462" +dependencies = [ + "byteorder", + "bytes 0.4.12", + "fnv", + "futures", + "http 0.1.21", + "indexmap 1.9.3", + "log", + "slab", + "string", + "tokio-io", +] + [[package]] name = "h2" version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" dependencies = [ - "bytes", + "bytes 1.6.0", "fnv", "futures-core", "futures-sink", "futures-util", - "http", - "indexmap", + "http 0.2.12", + "indexmap 2.2.6", "slab", - "tokio", + "tokio 1.37.0", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.3" @@ -235,15 +409,49 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" +[[package]] +name = "http" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ccf5ede3a895d8856620237b2f02972c1bbc78d2965ad7fe8838d4a0ed41f0" +dependencies = [ + "bytes 0.4.12", + "fnv", + "itoa 0.4.8", +] + [[package]] name = "http" version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" dependencies = [ - "bytes", + "bytes 1.6.0", "fnv", - "itoa", + "itoa 1.0.11", +] + +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes 1.6.0", + "fnv", + "itoa 1.0.11", +] + +[[package]] +name = "http-body" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6741c859c1b2463a423a1dbce98d418e6c3c3fc720fb0d45528657320920292d" +dependencies = [ + "bytes 0.4.12", + "futures", + "http 0.1.21", + "tokio-buf", ] [[package]] @@ -252,11 +460,44 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ - "bytes", - "http", + "bytes 1.6.0", + "http 0.2.12", "pin-project-lite", ] +[[package]] +name = "http-body" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" +dependencies = [ + "bytes 1.6.0", + "http 1.1.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d" +dependencies = [ + "bytes 1.6.0", + "futures-core", + "http 1.1.0", + "http-body 1.0.0", + "pin-project-lite", +] + +[[package]] +name = "http-connection" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6080cea47f7371d4da9a46dd52787c598ce93886393e400bc178f9039bac27" +dependencies = [ + "http 0.1.21", + "tokio-tcp", +] + [[package]] name = "httparse" version = "1.8.0" @@ -269,28 +510,64 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "hyper" +version = "0.12.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c843caf6296fc1f93444735205af9ed4e109a539005abb2564ae1d6fad34c52" +dependencies = [ + "bytes 0.4.12", + "futures", + "futures-cpupool", + "h2 0.1.26", + "http 0.1.21", + "http-body 0.1.0", + "httparse", + "iovec", + "itoa 0.4.8", + "log", + "net2", + "rustc_version", + "time 0.1.45", + "tokio 0.1.22", + "tokio-buf", + "tokio-executor", + "tokio-io", + "tokio-reactor", + "tokio-tcp", + "tokio-threadpool", + "tokio-timer", + "want 0.2.0", +] + [[package]] name = "hyper" version = "0.14.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80" dependencies = [ - "bytes", + "bytes 1.6.0", "futures-channel", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.3.26", + "http 0.2.12", + "http-body 0.4.6", "httparse", "httpdate", - "itoa", + "itoa 1.0.11", "pin-project-lite", "socket2", - "tokio", - "tower-service", + "tokio 1.37.0", + "tower-service 0.3.2", "tracing", - "want", + "want 0.3.1", ] [[package]] @@ -299,13 +576,23 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ - "bytes", - "hyper", + "bytes 1.6.0", + "hyper 0.14.28", "native-tls", - "tokio", + "tokio 1.37.0", "tokio-native-tls", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + [[package]] name = "indexmap" version = "2.2.6" @@ -313,26 +600,69 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.14.3", ] +[[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.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "itoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + [[package]] name = "itoa" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "kernel32-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d" +dependencies = [ + "winapi 0.2.8", + "winapi-build", +] + [[package]] name = "kordophone" version = "0.1.0" dependencies = [ "async-trait", - "hyper", + "ctor", + "hyper 0.14.28", "hyper-tls", + "log", + "pretty_env_logger", "serde", "serde_json", - "time", - "tokio", + "serde_plain", + "time 0.3.36", + "tokio 1.37.0", + "tower", + "tower-http", + "tower-hyper", "uuid", ] @@ -354,6 +684,15 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" +[[package]] +name = "lock_api" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4da24a77a3d8a6d4862d95f72e6fdb9c09a643ecdb402d754004a557f2bec75" +dependencies = [ + "scopeguard", +] + [[package]] name = "lock_api" version = "0.4.11" @@ -370,12 +709,27 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +[[package]] +name = "maybe-uninit" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00" + [[package]] name = "memchr" version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" +[[package]] +name = "memoffset" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "043175f069eda7b85febe4a74abbaeff828d9f8b448515d3151a14a3542811aa" +dependencies = [ + "autocfg", +] + [[package]] name = "miniz_oxide" version = "0.7.2" @@ -385,6 +739,25 @@ dependencies = [ "adler", ] +[[package]] +name = "mio" +version = "0.6.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4afd66f5b91bf2a3bc13fad0e21caedac168ca4c707504e75585648ae80e4cc4" +dependencies = [ + "cfg-if 0.1.10", + "fuchsia-zircon", + "fuchsia-zircon-sys", + "iovec", + "kernel32-sys", + "libc", + "log", + "miow", + "net2", + "slab", + "winapi 0.2.8", +] + [[package]] name = "mio" version = "0.8.11" @@ -392,10 +765,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.48.0", ] +[[package]] +name = "miow" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebd808424166322d4a38da87083bfddd3ac4c131334ed55856112eb06d46944d" +dependencies = [ + "kernel32-sys", + "net2", + "winapi 0.2.8", + "ws2_32-sys", +] + [[package]] name = "native-tls" version = "0.2.11" @@ -414,6 +799,17 @@ dependencies = [ "tempfile", ] +[[package]] +name = "net2" +version = "0.2.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13b648036a2339d06de780866fbdfda0dde886de7b3af2ddeba8b14f4ee34ac" +dependencies = [ + "cfg-if 0.1.10", + "libc", + "winapi 0.3.9", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -452,7 +848,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" dependencies = [ "bitflags 2.5.0", - "cfg-if", + "cfg-if 1.0.0", "foreign-types", "libc", "once_cell", @@ -489,14 +885,40 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parking_lot" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f842b1982eb6c2fe34036a4fbfb06dd185a3f5c8edfaacdf7d1ea10b07de6252" +dependencies = [ + "lock_api 0.3.4", + "parking_lot_core 0.6.3", + "rustc_version", +] + [[package]] name = "parking_lot" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" dependencies = [ - "lock_api", - "parking_lot_core", + "lock_api 0.4.11", + "parking_lot_core 0.9.9", +] + +[[package]] +name = "parking_lot_core" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66b810a62be75176a80873726630147a5ca780cd33921e0b5709033e66b0a" +dependencies = [ + "cfg-if 0.1.10", + "cloudabi", + "libc", + "redox_syscall 0.1.57", + "rustc_version", + "smallvec 0.6.14", + "winapi 0.3.9", ] [[package]] @@ -505,10 +927,10 @@ version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "libc", - "redox_syscall", - "smallvec", + "redox_syscall 0.4.1", + "smallvec 1.11.2", "windows-targets 0.48.5", ] @@ -542,6 +964,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "pretty_env_logger" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865724d4dbe39d9f3dd3b52b88d859d66bcb2d6a0acfd5ea68a65fb66d4bdc1c" +dependencies = [ + "env_logger", + "log", +] + [[package]] name = "proc-macro2" version = "1.0.81" @@ -590,6 +1022,12 @@ dependencies = [ "getrandom", ] +[[package]] +name = "redox_syscall" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" + [[package]] name = "redox_syscall" version = "0.4.1" @@ -599,12 +1037,50 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "regex" +version = "1.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" + [[package]] name = "rustc-demangle" version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.34" @@ -662,6 +1138,21 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + [[package]] name = "serde" version = "1.0.198" @@ -688,11 +1179,20 @@ version = "1.0.116" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813" dependencies = [ - "itoa", + "itoa 1.0.11", "ryu", "serde", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -711,6 +1211,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "smallvec" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97fcaeba89edba30f044a10c6a3cc39df9c3f17d7cd829dd1446cab35f890e0" +dependencies = [ + "maybe-uninit", +] + [[package]] name = "smallvec" version = "1.11.2" @@ -727,6 +1236,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "string" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24114bfcceb867ca7f71a0d3fe45d45619ec47a6fbfa98cb14e14250bfa5d6d" +dependencies = [ + "bytes 0.4.12", +] + [[package]] name = "syn" version = "2.0.60" @@ -744,12 +1262,32 @@ version = "3.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "fastrand", "rustix", "windows-sys 0.52.0", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "time" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi 0.3.9", +] + [[package]] name = "time" version = "0.3.36" @@ -780,6 +1318,24 @@ dependencies = [ "time-core", ] +[[package]] +name = "tokio" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a09c0b5bb588872ab2f09afa13ee6e9dac11e10a0ec9e8e3ba39a5a5d530af6" +dependencies = [ + "bytes 0.4.12", + "futures", + "mio 0.6.23", + "num_cpus", + "tokio-current-thread", + "tokio-executor", + "tokio-io", + "tokio-reactor", + "tokio-threadpool", + "tokio-timer", +] + [[package]] name = "tokio" version = "1.37.0" @@ -787,11 +1343,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" dependencies = [ "backtrace", - "bytes", + "bytes 1.6.0", "libc", - "mio", + "mio 0.8.11", "num_cpus", - "parking_lot", + "parking_lot 0.12.1", "pin-project-lite", "signal-hook-registry", "socket2", @@ -799,6 +1355,48 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "tokio-buf" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb220f46c53859a4b7ec083e41dec9778ff0b1851c0942b211edb89e0ccdc46" +dependencies = [ + "bytes 0.4.12", + "either", + "futures", +] + +[[package]] +name = "tokio-current-thread" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1de0e32a83f131e002238d7ccde18211c0a5397f60cbfffcb112868c2e0e20e" +dependencies = [ + "futures", + "tokio-executor", +] + +[[package]] +name = "tokio-executor" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb2d1b8f4548dbf5e1f7818512e9c406860678f29c300cdf0ebac72d1a3a1671" +dependencies = [ + "crossbeam-utils", + "futures", +] + +[[package]] +name = "tokio-io" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57fc868aae093479e3131e3d165c93b1c7474109d13c90ec0dda2a1bbfff0674" +dependencies = [ + "bytes 0.4.12", + "futures", + "log", +] + [[package]] name = "tokio-macros" version = "2.2.0" @@ -817,7 +1415,79 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" dependencies = [ "native-tls", - "tokio", + "tokio 1.37.0", +] + +[[package]] +name = "tokio-reactor" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09bc590ec4ba8ba87652da2068d150dcada2cfa2e07faae270a5e0409aa51351" +dependencies = [ + "crossbeam-utils", + "futures", + "lazy_static", + "log", + "mio 0.6.23", + "num_cpus", + "parking_lot 0.9.0", + "slab", + "tokio-executor", + "tokio-io", + "tokio-sync", +] + +[[package]] +name = "tokio-sync" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edfe50152bc8164fcc456dab7891fa9bf8beaf01c5ee7e1dd43a397c3cf87dee" +dependencies = [ + "fnv", + "futures", +] + +[[package]] +name = "tokio-tcp" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98df18ed66e3b72e742f185882a9e201892407957e45fbff8da17ae7a7c51f72" +dependencies = [ + "bytes 0.4.12", + "futures", + "iovec", + "mio 0.6.23", + "tokio-io", + "tokio-reactor", +] + +[[package]] +name = "tokio-threadpool" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df720b6581784c118f0eb4310796b12b1d242a7eb95f716a8367855325c25f89" +dependencies = [ + "crossbeam-deque", + "crossbeam-queue", + "crossbeam-utils", + "futures", + "lazy_static", + "log", + "num_cpus", + "slab", + "tokio-executor", +] + +[[package]] +name = "tokio-timer" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93044f2d313c95ff1cb7809ce9a7a05735b012288a888b62d4434fd58c94f296" +dependencies = [ + "crossbeam-utils", + "futures", + "slab", + "tokio-executor", ] [[package]] @@ -826,26 +1496,126 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" dependencies = [ - "bytes", + "bytes 1.6.0", "futures-core", "futures-sink", "pin-project-lite", - "tokio", + "tokio 1.37.0", "tracing", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "tower-layer 0.3.2", + "tower-service 0.3.2", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.5.0", + "bytes 1.6.0", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "pin-project-lite", + "tower-layer 0.3.2", + "tower-service 0.3.2", + "tracing", +] + +[[package]] +name = "tower-http-util" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442ba79e23bda499cdaa5ee52b3776bf08cb84a1d5ca6d3d82bfd4f12c1eefc3" +dependencies = [ + "futures", + "http 0.1.21", + "http-body 0.1.0", + "http-connection", + "tokio-buf", + "tokio-io", + "tower-service 0.2.0", +] + +[[package]] +name = "tower-hyper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff60538bc14baa9cc84fbe4c040d498163daf23bc9846a9aef0e2e40dcc1d51" +dependencies = [ + "futures", + "http 0.1.21", + "http-body 0.1.0", + "http-connection", + "hyper 0.12.36", + "log", + "tokio-buf", + "tokio-executor", + "tokio-io", + "tower-http-util", + "tower-service 0.2.0", + "tower-util", +] + +[[package]] +name = "tower-layer" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ddf07e10c07dcc8f41da6de036dc66def1a85b70eb8a385159e3908bb258328" +dependencies = [ + "futures", + "tower-service 0.2.0", +] + +[[package]] +name = "tower-layer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" + +[[package]] +name = "tower-service" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc0c98637d23732f8de6dfd16494c9f1559c3b9e20b4a46462c8f9b9e827bfa" +dependencies = [ + "futures", +] + [[package]] name = "tower-service" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +[[package]] +name = "tower-util" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4792342fac093db5d2558655055a89a04ca909663467a4310c7739d9f8b64698" +dependencies = [ + "futures", + "tower-layer 0.1.0", + "tower-service 0.2.0", +] + [[package]] name = "tracing" version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ + "log", "pin-project-lite", "tracing-core", ] @@ -899,6 +1669,17 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "want" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6395efa4784b027708f7451087e647ec73cc74f5d9bc2e418404248d679a230" +dependencies = [ + "futures", + "log", + "try-lock", +] + [[package]] name = "want" version = "0.3.1" @@ -908,12 +1689,61 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "winapi" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-build" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-sys" version = "0.48.0" @@ -1045,3 +1875,13 @@ name = "windows_x86_64_msvc" version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" + +[[package]] +name = "ws2_32-sys" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59cefebd0c892fa2dd6de581e937301d8552cb44489cdff035c6187cb63fa5e" +dependencies = [ + "winapi 0.2.8", + "winapi-build", +] diff --git a/kordophone/Cargo.toml b/kordophone/Cargo.toml index 34af7b8..c527300 100644 --- a/kordophone/Cargo.toml +++ b/kordophone/Cargo.toml @@ -7,10 +7,17 @@ edition = "2021" [dependencies] async-trait = "0.1.80" +ctor = "0.2.8" hyper = { version = "0.14", features = ["full"] } hyper-tls = "0.5.0" +log = { version = "0.4.21", features = [] } +pretty_env_logger = "0.5.0" serde = { version = "1.0.152", features = ["derive"] } serde_json = "1.0.91" +serde_plain = "1.0.2" time = { version = "0.3.17", features = ["parsing", "serde"] } tokio = { version = "1.37.0", features = ["full"] } +tower = "0.4.13" +tower-http = { version = "0.5.2", features = ["trace"] } +tower-hyper = "0.1.1" uuid = { version = "1.6.1", features = ["v4", "fast-rng", "macro-diagnostics"] } diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index ee958df..fd554f6 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -1,12 +1,22 @@ extern crate hyper; extern crate serde; -use std::path::PathBuf; +use std::{path::PathBuf, str::FromStr, str}; +use log::{info, warn, error, trace}; use hyper::{Body, Client, Method, Request, Uri, body}; +use tower_hyper::client::Client as TowerClient; +use tower_http::{ + trace::TraceLayer, + classify::StatusInRangeAsFailures, +}; +use tower::{ServiceBuilder, Service}; + use async_trait::async_trait; use serde::de::DeserializeOwned; +use serde_json::Error as SerdeError; +use serde_plain::{Deserializer, derive_deserialize_from_fromstr}; use crate::{APIInterface, model::Conversation}; type HttpClient = Client; @@ -21,6 +31,7 @@ pub enum Error { ClientError(String), HTTPError(hyper::Error), SerdeError(serde_json::Error), + DecodeError, } impl From for Error { @@ -35,15 +46,6 @@ impl From for Error { } } -impl HTTPClient { - pub fn new(base_url: Uri) -> HTTPClient { - HTTPClient { - base_url: base_url, - client: Client::new(), - } - } -} - #[async_trait] impl APIInterface for HTTPClient { type Error = Error; @@ -60,6 +62,16 @@ impl APIInterface for HTTPClient { } impl HTTPClient { + pub fn new(base_url: Uri) -> HTTPClient { + let mut client = ServiceBuilder::new() + .service(Client::new()); + + HTTPClient { + base_url: base_url, + client: client, + } + } + fn uri_for_endpoint(&self, endpoint: &str) -> Uri { let mut parts = self.base_url.clone().into_parts(); let root_path: PathBuf = parts.path_and_query.unwrap().path().into(); @@ -92,7 +104,15 @@ impl HTTPClient { // Read and parse response body let body = hyper::body::to_bytes(res.into_body()).await?; - let parsed: T = serde_json::from_slice(&body)?; + let parsed: T = match serde_json::from_slice(&body) { + Ok(result) => Ok(result), + Err(json_err) => { + // If JSON deserialization fails, try to interpret it as plain text + // Unfortunately the server does return things like this... + let s = str::from_utf8(&body).map_err(|_| Error::DecodeError)?; + serde_plain::from_str(s).map_err(|_| json_err) + } + }?; Ok(parsed) } @@ -100,6 +120,13 @@ impl HTTPClient { mod test { use super::*; + use ctor::ctor; + + #[ctor] + fn init() { + pretty_env_logger::init(); + log::set_max_level(log::LevelFilter::Trace); + } fn local_mock_client() -> HTTPClient { let base_url = "http://localhost:5738".parse().unwrap(); @@ -109,13 +136,20 @@ mod test { async fn mock_client_is_reachable() -> bool { let client = local_mock_client(); let version = client.get_version().await; - version.is_ok() + + match version { + Ok(_) => true, + Err(e) => { + error!("Mock client error: {:?}", e); + false + } + } } #[tokio::test] async fn test_version() { if !mock_client_is_reachable().await { - println!("Skipping http_client tests (mock server not reachable)"); + log::warn!("Skipping http_client tests (mock server not reachable)"); return; } @@ -127,7 +161,7 @@ mod test { #[tokio::test] async fn test_conversations() { if !mock_client_is_reachable().await { - println!("Skipping http_client tests (mock server not reachable)"); + log::warn!("Skipping http_client tests (mock server not reachable)"); return; } From 0dde0b9c530e2c6f1b8c3acfb63b962d61a5eed8 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 1 Jun 2024 18:17:57 -0700 Subject: [PATCH 006/138] clippy --- kordophone/src/api/http_client.rs | 25 +++++++++---------------- kordophone/src/model/conversation.rs | 2 +- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index fd554f6..96b5c91 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -1,22 +1,15 @@ extern crate hyper; extern crate serde; -use std::{path::PathBuf, str::FromStr, str}; -use log::{info, warn, error, trace}; +use std::{path::PathBuf, str}; +use log::{error}; -use hyper::{Body, Client, Method, Request, Uri, body}; - -use tower_hyper::client::Client as TowerClient; -use tower_http::{ - trace::TraceLayer, - classify::StatusInRangeAsFailures, -}; -use tower::{ServiceBuilder, Service}; +use hyper::{Body, Client, Method, Request, Uri}; +use tower::{ServiceBuilder}; use async_trait::async_trait; use serde::de::DeserializeOwned; -use serde_json::Error as SerdeError; -use serde_plain::{Deserializer, derive_deserialize_from_fromstr}; + use crate::{APIInterface, model::Conversation}; type HttpClient = Client; @@ -63,12 +56,12 @@ impl APIInterface for HTTPClient { impl HTTPClient { pub fn new(base_url: Uri) -> HTTPClient { - let mut client = ServiceBuilder::new() + let client = ServiceBuilder::new() .service(Client::new()); HTTPClient { - base_url: base_url, - client: client, + base_url, + client, } } @@ -167,6 +160,6 @@ mod test { let client = local_mock_client(); let conversations = client.get_conversations().await.unwrap(); - assert!(conversations.len() > 0); + assert!(!conversations.is_empty()); } } diff --git a/kordophone/src/model/conversation.rs b/kordophone/src/model/conversation.rs index fdca656..96d2f0d 100644 --- a/kordophone/src/model/conversation.rs +++ b/kordophone/src/model/conversation.rs @@ -79,7 +79,7 @@ impl ConversationBuilder { date: self.date.unwrap_or(OffsetDateTime::now_utc()), unread_count: self.unread_count.unwrap_or(0), last_message_preview: self.last_message_preview, - participant_display_names: self.participant_display_names.unwrap_or(vec![]), + participant_display_names: self.participant_display_names.unwrap_or_default(), display_name: self.display_name, } } From cabd3b502a62a1608f9818ae76c117683ceda30d Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 14 Jun 2024 20:23:44 -0700 Subject: [PATCH 007/138] Retry auth automatically, remove tower dep --- Cargo.lock | 886 +++++----------------------- kordophone/Cargo.toml | 5 +- kordophone/src/api/http_client.rs | 160 +++-- kordophone/src/api/mod.rs | 12 +- kordophone/src/model/jwt.rs | 112 ++++ kordophone/src/model/mod.rs | 4 +- kordophone/src/tests/mod.rs | 2 +- kordophone/src/tests/test_client.rs | 10 +- 8 files changed, 413 insertions(+), 778 deletions(-) create mode 100644 kordophone/src/model/jwt.rs diff --git a/Cargo.lock b/Cargo.lock index e0c301e..34d4ec6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "async-trait" version = "0.1.80" @@ -51,13 +66,19 @@ checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" dependencies = [ "addr2line", "cc", - "cfg-if 1.0.0", + "cfg-if", "libc", "miniz_oxide", "object", "rustc-demangle", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -71,21 +92,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" [[package]] -name = "byteorder" -version = "1.5.0" +name = "bumpalo" +version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "206fdffcfa2df7cbe15601ef46c813fce0965eb3286db6b56c583b814b51c81c" -dependencies = [ - "byteorder", - "either", - "iovec", -] +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "bytes" @@ -99,12 +109,6 @@ version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d32a725bc159af97c3e629873bb9f88fb8cf8a4867175f76dc987815ea07c83b" -[[package]] -name = "cfg-if" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" - [[package]] name = "cfg-if" version = "1.0.0" @@ -112,12 +116,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] -name = "cloudabi" -version = "0.0.3" +name = "chrono" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ - "bitflags 1.3.2", + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets 0.52.4", ] [[package]] @@ -136,54 +145,6 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" -[[package]] -name = "crossbeam-deque" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20ff29ded3204c5106278a81a38f4b482636ed4fa1e6cfbeef193291beb29ed" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", - "maybe-uninit", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "058ed274caafc1f60c4997b5fc07bf7dc7cca454af7c6e81edffe5f33f70dace" -dependencies = [ - "autocfg", - "cfg-if 0.1.10", - "crossbeam-utils", - "lazy_static", - "maybe-uninit", - "memoffset", - "scopeguard", -] - -[[package]] -name = "crossbeam-queue" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "774ba60a54c213d409d5353bda12d49cd68d14e45036a285234c8d6f91f92570" -dependencies = [ - "cfg-if 0.1.10", - "crossbeam-utils", - "maybe-uninit", -] - -[[package]] -name = "crossbeam-utils" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3c7c73a2d1e9fc0886a08b93e98eb643461230d5f1925e4036204d5f2e261a8" -dependencies = [ - "autocfg", - "cfg-if 0.1.10", - "lazy_static", -] - [[package]] name = "ctor" version = "0.2.8" @@ -204,12 +165,6 @@ dependencies = [ "serde", ] -[[package]] -name = "either" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b" - [[package]] name = "env_logger" version = "0.10.2" @@ -266,28 +221,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" -[[package]] -name = "fuchsia-zircon" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e9763c69ebaae630ba35f74888db465e49e259ba1bc0eda7d06f4a067615d82" -dependencies = [ - "bitflags 1.3.2", - "fuchsia-zircon-sys", -] - -[[package]] -name = "fuchsia-zircon-sys" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" - -[[package]] -name = "futures" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678" - [[package]] name = "futures-channel" version = "0.3.30" @@ -303,16 +236,6 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" -[[package]] -name = "futures-cpupool" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab90cde24b3319636588d0c35fe03b1333857621051837ed769faefb4c2162e4" -dependencies = [ - "futures", - "num_cpus", -] - [[package]] name = "futures-sink" version = "0.3.30" @@ -343,9 +266,9 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", ] [[package]] @@ -354,49 +277,25 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" -[[package]] -name = "h2" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5b34c246847f938a410a03c5458c7fee2274436675e76d8b903c08efc29c462" -dependencies = [ - "byteorder", - "bytes 0.4.12", - "fnv", - "futures", - "http 0.1.21", - "indexmap 1.9.3", - "log", - "slab", - "string", - "tokio-io", -] - [[package]] name = "h2" version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" dependencies = [ - "bytes 1.6.0", + "bytes", "fnv", "futures-core", "futures-sink", "futures-util", - "http 0.2.12", - "indexmap 2.2.6", + "http", + "indexmap", "slab", - "tokio 1.37.0", + "tokio", "tokio-util", "tracing", ] -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - [[package]] name = "hashbrown" version = "0.14.3" @@ -409,49 +308,15 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" -[[package]] -name = "http" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6ccf5ede3a895d8856620237b2f02972c1bbc78d2965ad7fe8838d4a0ed41f0" -dependencies = [ - "bytes 0.4.12", - "fnv", - "itoa 0.4.8", -] - [[package]] name = "http" version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" dependencies = [ - "bytes 1.6.0", + "bytes", "fnv", - "itoa 1.0.11", -] - -[[package]] -name = "http" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" -dependencies = [ - "bytes 1.6.0", - "fnv", - "itoa 1.0.11", -] - -[[package]] -name = "http-body" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6741c859c1b2463a423a1dbce98d418e6c3c3fc720fb0d45528657320920292d" -dependencies = [ - "bytes 0.4.12", - "futures", - "http 0.1.21", - "tokio-buf", + "itoa", ] [[package]] @@ -460,44 +325,11 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ - "bytes 1.6.0", - "http 0.2.12", + "bytes", + "http", "pin-project-lite", ] -[[package]] -name = "http-body" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" -dependencies = [ - "bytes 1.6.0", - "http 1.1.0", -] - -[[package]] -name = "http-body-util" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d" -dependencies = [ - "bytes 1.6.0", - "futures-core", - "http 1.1.0", - "http-body 1.0.0", - "pin-project-lite", -] - -[[package]] -name = "http-connection" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6080cea47f7371d4da9a46dd52787c598ce93886393e400bc178f9039bac27" -dependencies = [ - "http 0.1.21", - "tokio-tcp", -] - [[package]] name = "httparse" version = "1.8.0" @@ -516,58 +348,28 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" -[[package]] -name = "hyper" -version = "0.12.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c843caf6296fc1f93444735205af9ed4e109a539005abb2564ae1d6fad34c52" -dependencies = [ - "bytes 0.4.12", - "futures", - "futures-cpupool", - "h2 0.1.26", - "http 0.1.21", - "http-body 0.1.0", - "httparse", - "iovec", - "itoa 0.4.8", - "log", - "net2", - "rustc_version", - "time 0.1.45", - "tokio 0.1.22", - "tokio-buf", - "tokio-executor", - "tokio-io", - "tokio-reactor", - "tokio-tcp", - "tokio-threadpool", - "tokio-timer", - "want 0.2.0", -] - [[package]] name = "hyper" version = "0.14.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80" dependencies = [ - "bytes 1.6.0", + "bytes", "futures-channel", "futures-core", "futures-util", - "h2 0.3.26", - "http 0.2.12", - "http-body 0.4.6", + "h2", + "http", + "http-body", "httparse", "httpdate", - "itoa 1.0.11", + "itoa", "pin-project-lite", "socket2", - "tokio 1.37.0", - "tower-service 0.3.2", + "tokio", + "tower-service", "tracing", - "want 0.3.1", + "want", ] [[package]] @@ -576,21 +378,34 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ - "bytes 1.6.0", - "hyper 0.14.28", + "bytes", + "hyper", "native-tls", - "tokio 1.37.0", + "tokio", "tokio-native-tls", ] [[package]] -name = "indexmap" -version = "1.9.3" +name = "iana-time-zone" +version = "0.1.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" dependencies = [ - "autocfg", - "hashbrown 0.12.3", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", ] [[package]] @@ -600,16 +415,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" dependencies = [ "equivalent", - "hashbrown 0.14.3", -] - -[[package]] -name = "iovec" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2b3ea6ff95e175473f8ffe6a7eb7c00d054240321b84c57051175fe3c1e075e" -dependencies = [ - "libc", + "hashbrown", ] [[package]] @@ -623,12 +429,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "itoa" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" - [[package]] name = "itoa" version = "1.0.11" @@ -636,13 +436,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] -name = "kernel32-sys" -version = "0.2.2" +name = "js-sys" +version = "0.3.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" dependencies = [ - "winapi 0.2.8", - "winapi-build", + "wasm-bindgen", ] [[package]] @@ -650,19 +449,18 @@ name = "kordophone" version = "0.1.0" dependencies = [ "async-trait", + "base64", + "chrono", "ctor", - "hyper 0.14.28", + "hyper", "hyper-tls", "log", "pretty_env_logger", "serde", "serde_json", "serde_plain", - "time 0.3.36", - "tokio 1.37.0", - "tower", - "tower-http", - "tower-hyper", + "time", + "tokio", "uuid", ] @@ -684,15 +482,6 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" -[[package]] -name = "lock_api" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4da24a77a3d8a6d4862d95f72e6fdb9c09a643ecdb402d754004a557f2bec75" -dependencies = [ - "scopeguard", -] - [[package]] name = "lock_api" version = "0.4.11" @@ -709,27 +498,12 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" -[[package]] -name = "maybe-uninit" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00" - [[package]] name = "memchr" version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" -[[package]] -name = "memoffset" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "043175f069eda7b85febe4a74abbaeff828d9f8b448515d3151a14a3542811aa" -dependencies = [ - "autocfg", -] - [[package]] name = "miniz_oxide" version = "0.7.2" @@ -739,25 +513,6 @@ dependencies = [ "adler", ] -[[package]] -name = "mio" -version = "0.6.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4afd66f5b91bf2a3bc13fad0e21caedac168ca4c707504e75585648ae80e4cc4" -dependencies = [ - "cfg-if 0.1.10", - "fuchsia-zircon", - "fuchsia-zircon-sys", - "iovec", - "kernel32-sys", - "libc", - "log", - "miow", - "net2", - "slab", - "winapi 0.2.8", -] - [[package]] name = "mio" version = "0.8.11" @@ -765,22 +520,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", "windows-sys 0.48.0", ] -[[package]] -name = "miow" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebd808424166322d4a38da87083bfddd3ac4c131334ed55856112eb06d46944d" -dependencies = [ - "kernel32-sys", - "net2", - "winapi 0.2.8", - "ws2_32-sys", -] - [[package]] name = "native-tls" version = "0.2.11" @@ -799,23 +542,21 @@ dependencies = [ "tempfile", ] -[[package]] -name = "net2" -version = "0.2.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b13b648036a2339d06de780866fbdfda0dde886de7b3af2ddeba8b14f4ee34ac" -dependencies = [ - "cfg-if 0.1.10", - "libc", - "winapi 0.3.9", -] - [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.16.0" @@ -848,7 +589,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" dependencies = [ "bitflags 2.5.0", - "cfg-if 1.0.0", + "cfg-if", "foreign-types", "libc", "once_cell", @@ -885,40 +626,14 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "parking_lot" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f842b1982eb6c2fe34036a4fbfb06dd185a3f5c8edfaacdf7d1ea10b07de6252" -dependencies = [ - "lock_api 0.3.4", - "parking_lot_core 0.6.3", - "rustc_version", -] - [[package]] name = "parking_lot" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" dependencies = [ - "lock_api 0.4.11", - "parking_lot_core 0.9.9", -] - -[[package]] -name = "parking_lot_core" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda66b810a62be75176a80873726630147a5ca780cd33921e0b5709033e66b0a" -dependencies = [ - "cfg-if 0.1.10", - "cloudabi", - "libc", - "redox_syscall 0.1.57", - "rustc_version", - "smallvec 0.6.14", - "winapi 0.3.9", + "lock_api", + "parking_lot_core", ] [[package]] @@ -927,10 +642,10 @@ version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "libc", - "redox_syscall 0.4.1", - "smallvec 1.11.2", + "redox_syscall", + "smallvec", "windows-targets 0.48.5", ] @@ -1022,12 +737,6 @@ dependencies = [ "getrandom", ] -[[package]] -name = "redox_syscall" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" - [[package]] name = "redox_syscall" version = "0.4.1" @@ -1072,15 +781,6 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" -[[package]] -name = "rustc_version" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" -dependencies = [ - "semver", -] - [[package]] name = "rustix" version = "0.38.34" @@ -1138,21 +838,6 @@ dependencies = [ "libc", ] -[[package]] -name = "semver" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" -dependencies = [ - "semver-parser", -] - -[[package]] -name = "semver-parser" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" - [[package]] name = "serde" version = "1.0.198" @@ -1179,7 +864,7 @@ version = "1.0.116" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813" dependencies = [ - "itoa 1.0.11", + "itoa", "ryu", "serde", ] @@ -1211,15 +896,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "smallvec" -version = "0.6.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97fcaeba89edba30f044a10c6a3cc39df9c3f17d7cd829dd1446cab35f890e0" -dependencies = [ - "maybe-uninit", -] - [[package]] name = "smallvec" version = "1.11.2" @@ -1236,15 +912,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "string" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24114bfcceb867ca7f71a0d3fe45d45619ec47a6fbfa98cb14e14250bfa5d6d" -dependencies = [ - "bytes 0.4.12", -] - [[package]] name = "syn" version = "2.0.60" @@ -1262,7 +929,7 @@ version = "3.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "fastrand", "rustix", "windows-sys 0.52.0", @@ -1277,17 +944,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "time" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" -dependencies = [ - "libc", - "wasi 0.10.0+wasi-snapshot-preview1", - "winapi 0.3.9", -] - [[package]] name = "time" version = "0.3.36" @@ -1318,24 +974,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tokio" -version = "0.1.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a09c0b5bb588872ab2f09afa13ee6e9dac11e10a0ec9e8e3ba39a5a5d530af6" -dependencies = [ - "bytes 0.4.12", - "futures", - "mio 0.6.23", - "num_cpus", - "tokio-current-thread", - "tokio-executor", - "tokio-io", - "tokio-reactor", - "tokio-threadpool", - "tokio-timer", -] - [[package]] name = "tokio" version = "1.37.0" @@ -1343,11 +981,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" dependencies = [ "backtrace", - "bytes 1.6.0", + "bytes", "libc", - "mio 0.8.11", + "mio", "num_cpus", - "parking_lot 0.12.1", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -1355,48 +993,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "tokio-buf" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fb220f46c53859a4b7ec083e41dec9778ff0b1851c0942b211edb89e0ccdc46" -dependencies = [ - "bytes 0.4.12", - "either", - "futures", -] - -[[package]] -name = "tokio-current-thread" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1de0e32a83f131e002238d7ccde18211c0a5397f60cbfffcb112868c2e0e20e" -dependencies = [ - "futures", - "tokio-executor", -] - -[[package]] -name = "tokio-executor" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb2d1b8f4548dbf5e1f7818512e9c406860678f29c300cdf0ebac72d1a3a1671" -dependencies = [ - "crossbeam-utils", - "futures", -] - -[[package]] -name = "tokio-io" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57fc868aae093479e3131e3d165c93b1c7474109d13c90ec0dda2a1bbfff0674" -dependencies = [ - "bytes 0.4.12", - "futures", - "log", -] - [[package]] name = "tokio-macros" version = "2.2.0" @@ -1415,79 +1011,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" dependencies = [ "native-tls", - "tokio 1.37.0", -] - -[[package]] -name = "tokio-reactor" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09bc590ec4ba8ba87652da2068d150dcada2cfa2e07faae270a5e0409aa51351" -dependencies = [ - "crossbeam-utils", - "futures", - "lazy_static", - "log", - "mio 0.6.23", - "num_cpus", - "parking_lot 0.9.0", - "slab", - "tokio-executor", - "tokio-io", - "tokio-sync", -] - -[[package]] -name = "tokio-sync" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edfe50152bc8164fcc456dab7891fa9bf8beaf01c5ee7e1dd43a397c3cf87dee" -dependencies = [ - "fnv", - "futures", -] - -[[package]] -name = "tokio-tcp" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98df18ed66e3b72e742f185882a9e201892407957e45fbff8da17ae7a7c51f72" -dependencies = [ - "bytes 0.4.12", - "futures", - "iovec", - "mio 0.6.23", - "tokio-io", - "tokio-reactor", -] - -[[package]] -name = "tokio-threadpool" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df720b6581784c118f0eb4310796b12b1d242a7eb95f716a8367855325c25f89" -dependencies = [ - "crossbeam-deque", - "crossbeam-queue", - "crossbeam-utils", - "futures", - "lazy_static", - "log", - "num_cpus", - "slab", - "tokio-executor", -] - -[[package]] -name = "tokio-timer" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93044f2d313c95ff1cb7809ce9a7a05735b012288a888b62d4434fd58c94f296" -dependencies = [ - "crossbeam-utils", - "futures", - "slab", - "tokio-executor", + "tokio", ] [[package]] @@ -1496,126 +1020,26 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" dependencies = [ - "bytes 1.6.0", + "bytes", "futures-core", "futures-sink", "pin-project-lite", - "tokio 1.37.0", + "tokio", "tracing", ] -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "tower-layer 0.3.2", - "tower-service 0.3.2", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" -dependencies = [ - "bitflags 2.5.0", - "bytes 1.6.0", - "http 1.1.0", - "http-body 1.0.0", - "http-body-util", - "pin-project-lite", - "tower-layer 0.3.2", - "tower-service 0.3.2", - "tracing", -] - -[[package]] -name = "tower-http-util" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "442ba79e23bda499cdaa5ee52b3776bf08cb84a1d5ca6d3d82bfd4f12c1eefc3" -dependencies = [ - "futures", - "http 0.1.21", - "http-body 0.1.0", - "http-connection", - "tokio-buf", - "tokio-io", - "tower-service 0.2.0", -] - -[[package]] -name = "tower-hyper" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cff60538bc14baa9cc84fbe4c040d498163daf23bc9846a9aef0e2e40dcc1d51" -dependencies = [ - "futures", - "http 0.1.21", - "http-body 0.1.0", - "http-connection", - "hyper 0.12.36", - "log", - "tokio-buf", - "tokio-executor", - "tokio-io", - "tower-http-util", - "tower-service 0.2.0", - "tower-util", -] - -[[package]] -name = "tower-layer" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ddf07e10c07dcc8f41da6de036dc66def1a85b70eb8a385159e3908bb258328" -dependencies = [ - "futures", - "tower-service 0.2.0", -] - -[[package]] -name = "tower-layer" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" - -[[package]] -name = "tower-service" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cc0c98637d23732f8de6dfd16494c9f1559c3b9e20b4a46462c8f9b9e827bfa" -dependencies = [ - "futures", -] - [[package]] name = "tower-service" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" -[[package]] -name = "tower-util" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4792342fac093db5d2558655055a89a04ca909663467a4310c7739d9f8b64698" -dependencies = [ - "futures", - "tower-layer 0.1.0", - "tower-service 0.2.0", -] - [[package]] name = "tracing" version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ - "log", "pin-project-lite", "tracing-core", ] @@ -1669,17 +1093,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "want" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6395efa4784b027708f7451087e647ec73cc74f5d9bc2e418404248d679a230" -dependencies = [ - "futures", - "log", - "try-lock", -] - [[package]] name = "want" version = "0.3.1" @@ -1689,12 +1102,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wasi" -version = "0.10.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" - [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -1702,32 +1109,58 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] -name = "winapi" -version = "0.2.8" +name = "wasm-bindgen" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "cfg-if", + "wasm-bindgen-macro", ] [[package]] -name = "winapi-build" -version = "0.1.1" +name = "wasm-bindgen-backend" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" +name = "wasm-bindgen-macro" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" [[package]] name = "winapi-util" @@ -1739,10 +1172,13 @@ dependencies = [ ] [[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" +name = "windows-core" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.4", +] [[package]] name = "windows-sys" @@ -1875,13 +1311,3 @@ name = "windows_x86_64_msvc" version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" - -[[package]] -name = "ws2_32-sys" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d59cefebd0c892fa2dd6de581e937301d8552cb44489cdff035c6187cb63fa5e" -dependencies = [ - "winapi 0.2.8", - "winapi-build", -] diff --git a/kordophone/Cargo.toml b/kordophone/Cargo.toml index c527300..cc21210 100644 --- a/kordophone/Cargo.toml +++ b/kordophone/Cargo.toml @@ -7,6 +7,8 @@ edition = "2021" [dependencies] async-trait = "0.1.80" +base64 = "0.22.1" +chrono = "0.4.38" ctor = "0.2.8" hyper = { version = "0.14", features = ["full"] } hyper-tls = "0.5.0" @@ -17,7 +19,4 @@ serde_json = "1.0.91" serde_plain = "1.0.2" time = { version = "0.3.17", features = ["parsing", "serde"] } tokio = { version = "1.37.0", features = ["full"] } -tower = "0.4.13" -tower-http = { version = "0.5.2", features = ["trace"] } -tower-hyper = "0.1.1" uuid = { version = "1.6.1", features = ["v4", "fast-rng", "macro-diagnostics"] } diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index 96b5c91..c500715 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -1,24 +1,31 @@ extern crate hyper; extern crate serde; -use std::{path::PathBuf, str}; +use std::{borrow::Cow, default, path::PathBuf, str}; use log::{error}; -use hyper::{Body, Client, Method, Request, Uri}; -use tower::{ServiceBuilder}; +use hyper::{client::ResponseFuture, Body, Client, Method, Request, Uri}; use async_trait::async_trait; -use serde::de::DeserializeOwned; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; -use crate::{APIInterface, model::Conversation}; +use crate::{model::{Conversation, JwtToken}, APIInterface}; type HttpClient = Client; -pub struct HTTPClient { +pub struct HTTPAPIClient { pub base_url: Uri, + credentials: Option, + auth_token: Option, client: HttpClient, } +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct Credentials { + pub username: String, + pub password: String, +} + #[derive(Debug)] pub enum Error { ClientError(String), @@ -39,29 +46,65 @@ impl From for Error { } } +trait AuthBuilder { + fn with_auth(self, token: &Option) -> Self; +} + +impl AuthBuilder for hyper::http::request::Builder { + fn with_auth(self, token: &Option) -> Self { + if let Some(token) = &token { + self.header("Authorization", token.to_header_value()) + } else { self } + } +} + +trait AuthSetting { + fn authenticate(&mut self, token: &Option); +} + +impl AuthSetting for hyper::http::Request { + fn authenticate(&mut self, token: &Option) { + if let Some(token) = &token { + self.headers_mut().insert("Authorization", token.to_header_value()); + } + } +} + #[async_trait] -impl APIInterface for HTTPClient { +impl APIInterface for HTTPAPIClient { type Error = Error; - async fn get_version(&self) -> Result { + async fn get_version(&mut self) -> Result { let version: String = self.request("/version", Method::GET).await?; Ok(version) } - async fn get_conversations(&self) -> Result, Self::Error> { + async fn get_conversations(&mut self) -> Result, Self::Error> { let conversations: Vec = self.request("/conversations", Method::GET).await?; Ok(conversations) } + + async fn authenticate(&mut self, credentials: Credentials) -> Result { + #[derive(Deserialize, Debug)] + struct AuthResponse { + jwt: String, + } + + let body = || -> Body { serde_json::to_string(&credentials).unwrap().into() }; + let token: AuthResponse = self.request_with_body_retry("/authenticate", Method::POST, body, false).await?; + let token = JwtToken::new(&token.jwt).map_err(|_| Error::DecodeError)?; + self.auth_token = Some(token.clone()); + Ok(token) + } } -impl HTTPClient { - pub fn new(base_url: Uri) -> HTTPClient { - let client = ServiceBuilder::new() - .service(Client::new()); - - HTTPClient { - base_url, - client, +impl HTTPAPIClient { + pub fn new(base_url: Uri, credentials: Option) -> HTTPAPIClient { + HTTPAPIClient { + base_url: base_url, + credentials: credentials, + auth_token: Option::None, + client: Client::new(), } } @@ -74,29 +117,67 @@ impl HTTPClient { Uri::try_from(parts).unwrap() } - async fn request(&self, endpoint: &str, method: Method) -> Result { - self.request_with_body(endpoint, method, Body::empty()).await + async fn request(&mut self, endpoint: &str, method: Method) -> Result { + self.request_with_body(endpoint, method, || { Body::empty() }).await } - async fn request_with_body(&self, endpoint: &str, method: Method, body: Body) -> Result { + async fn request_with_body(&mut self, endpoint: &str, method: Method, body_fn: B) -> Result + where T: DeserializeOwned, B: Fn() -> Body + { + self.request_with_body_retry(endpoint, method, body_fn, true).await + } + + async fn request_with_body_retry( + &mut self, + endpoint: &str, + method: Method, + body_fn: B, + retry_auth: bool) -> Result + where + T: DeserializeOwned, + B: Fn() -> Body + { + use hyper::StatusCode; + let uri = self.uri_for_endpoint(endpoint); - let request = Request::builder() - .method(method) - .uri(uri) - .body(body) - .unwrap(); + let build_request = move |auth: &Option| { + let body = body_fn(); + Request::builder() + .method(&method) + .uri(&uri) + .with_auth(auth) + .body(body) + .expect("Unable to build request") + }; - let future = self.client.request(request); - let res = future.await?; - let status = res.status(); + let request = build_request(&self.auth_token); + let mut response = self.client.request(request).await?; + match response.status() { + StatusCode::OK => { /* cool */ }, - if status != hyper::StatusCode::OK { - let message = format!("Request failed ({:})", status); - return Err(Error::ClientError(message)); + // 401: Unauthorized. Token may have expired or is invalid. Attempt to renew. + StatusCode::UNAUTHORIZED => { + if !retry_auth { + return Err(Error::ClientError("Unauthorized".into())); + } + + if let Some(credentials) = &self.credentials { + self.authenticate(credentials.clone()).await?; + + let request = build_request(&self.auth_token); + response = self.client.request(request).await?; + } + }, + + // Other errors: bubble up. + _ => { + let message = format!("Request failed ({:})", response.status()); + return Err(Error::ClientError(message)); + } } // Read and parse response body - let body = hyper::body::to_bytes(res.into_body()).await?; + let body = hyper::body::to_bytes(response.into_body()).await?; let parsed: T = match serde_json::from_slice(&body) { Ok(result) => Ok(result), Err(json_err) => { @@ -121,13 +202,18 @@ mod test { log::set_max_level(log::LevelFilter::Trace); } - fn local_mock_client() -> HTTPClient { + fn local_mock_client() -> HTTPAPIClient { let base_url = "http://localhost:5738".parse().unwrap(); - HTTPClient::new(base_url) + let credentials = Credentials { + username: "test".to_string(), + password: "test".to_string(), + }; + + HTTPAPIClient::new(base_url, credentials.into()) } async fn mock_client_is_reachable() -> bool { - let client = local_mock_client(); + let mut client = local_mock_client(); let version = client.get_version().await; match version { @@ -146,7 +232,7 @@ mod test { return; } - let client = local_mock_client(); + let mut client = local_mock_client(); let version = client.get_version().await.unwrap(); assert!(version.starts_with("KordophoneMock-")); } @@ -158,7 +244,7 @@ mod test { return; } - let client = local_mock_client(); + let mut client = local_mock_client(); let conversations = client.get_conversations().await.unwrap(); assert!(!conversations.is_empty()); } diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index da28032..0cd5c99 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -1,17 +1,23 @@ use async_trait::async_trait; pub use crate::model::Conversation; +use crate::model::JwtToken; pub mod http_client; -pub use http_client::HTTPClient; +pub use http_client::HTTPAPIClient; + +use self::http_client::Credentials; #[async_trait] pub trait APIInterface { type Error; // (GET) /version - async fn get_version(&self) -> Result; + async fn get_version(&mut self) -> Result; // (GET) /conversations - async fn get_conversations(&self) -> Result, Self::Error>; + async fn get_conversations(&mut self) -> Result, Self::Error>; + + // (POST) /authenticate + async fn authenticate(&mut self, credentials: Credentials) -> Result; } diff --git a/kordophone/src/model/jwt.rs b/kordophone/src/model/jwt.rs new file mode 100644 index 0000000..6810c2e --- /dev/null +++ b/kordophone/src/model/jwt.rs @@ -0,0 +1,112 @@ +use std::error::Error; + +use base64::{ + engine::{self, general_purpose}, + Engine, +}; + +use chrono::{DateTime, Utc}; +use hyper::http::HeaderValue; +use serde::Deserialize; + +#[derive(Deserialize, Debug, Clone)] +struct JwtHeader { + alg: String, + typ: String, +} + +#[derive(Deserialize, Debug, Clone)] +enum ExpValue { + Integer(i64), + String(String), +} + +#[derive(Deserialize, Debug, Clone)] +struct JwtPayload { + exp: serde_json::Value, + iss: Option, + user: Option, +} + +#[derive(Debug, Clone)] +pub struct JwtToken { + header: JwtHeader, + payload: JwtPayload, + signature: Vec, + expiration_date: DateTime, + token: String, +} + +impl JwtToken { + fn decode_token_using_engine( + token: &str, + engine: engine::GeneralPurpose, + ) -> Result> { + let mut parts = token.split('.'); + let header = parts.next().unwrap(); + let payload = parts.next().unwrap(); + let signature = parts.next().unwrap(); + + let header = engine.decode(header)?; + let payload = engine.decode(payload)?; + let signature = engine.decode(signature)?; + + // Parse jwt header + let header: JwtHeader = serde_json::from_slice(&header)?; + + // Parse jwt payload + let payload: JwtPayload = serde_json::from_slice(&payload)?; + + // Parse jwt expiration date + // Annoyingly, because of my own fault, this could be either an integer or string. + let exp: i64 = payload.exp.as_i64().unwrap_or_else(|| { + let exp: String = payload.exp.as_str().unwrap().to_string(); + exp.parse().unwrap() + }); + + let timestamp = chrono::NaiveDateTime::from_timestamp_opt(exp, 0).unwrap(); + let expiration_date = DateTime::::from_utc(timestamp, Utc); + + Ok(JwtToken { + header, + payload, + signature, + expiration_date, + token: token.to_string(), + }) + } + + pub fn new(token: &str) -> Result> { + // STUPID: My mock server uses a different encoding than the real server, so we have to + // try both encodings here. + + Self::decode_token_using_engine(token, general_purpose::STANDARD).or( + Self::decode_token_using_engine(token, general_purpose::URL_SAFE_NO_PAD), + ) + } + + pub fn dummy() -> Self { + JwtToken { + header: JwtHeader { + alg: "none".to_string(), + typ: "JWT".to_string(), + }, + payload: JwtPayload { + exp: serde_json::Value::Null, + iss: None, + user: None, + }, + signature: vec![], + expiration_date: Utc::now(), + token: "".to_string(), + } + } + + pub fn is_valid(&self) -> bool { + self.expiration_date > Utc::now() + } + + pub fn to_header_value(&self) -> HeaderValue { + format!("Bearer {}", self.token).parse().unwrap() + } +} diff --git a/kordophone/src/model/mod.rs b/kordophone/src/model/mod.rs index 291ac75..92607cd 100644 --- a/kordophone/src/model/mod.rs +++ b/kordophone/src/model/mod.rs @@ -1,3 +1,5 @@ - pub mod conversation; pub use conversation::Conversation; + +pub mod jwt; +pub use jwt::JwtToken; \ No newline at end of file diff --git a/kordophone/src/tests/mod.rs b/kordophone/src/tests/mod.rs index b4e065a..43f21ef 100644 --- a/kordophone/src/tests/mod.rs +++ b/kordophone/src/tests/mod.rs @@ -9,7 +9,7 @@ pub mod api_interface { #[tokio::test] async fn test_version() { - let client = TestClient::new(); + let mut client = TestClient::new(); let version = client.get_version().await.unwrap(); assert_eq!(version, client.version); } diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index e38882e..455dff8 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; pub use crate::APIInterface; -use crate::model::Conversation; +use crate::{api::http_client::Credentials, model::{Conversation, JwtToken}}; pub struct TestClient { pub version: &'static str, @@ -24,11 +24,15 @@ impl TestClient { impl APIInterface for TestClient { type Error = TestError; - async fn get_version(&self) -> Result { + async fn authenticate(&mut self, credentials: Credentials) -> Result { + Ok(JwtToken::dummy()) + } + + async fn get_version(&mut self) -> Result { Ok(self.version.to_string()) } - async fn get_conversations(&self) -> Result, Self::Error> { + async fn get_conversations(&mut self) -> Result, Self::Error> { Ok(self.conversations.clone()) } } From da36d9da914bd6d2878a886b5d0f5410207333b7 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 14 Jun 2024 20:26:56 -0700 Subject: [PATCH 008/138] Clippy, warnings fix --- kordophone/src/api/http_client.rs | 8 ++++---- kordophone/src/model/jwt.rs | 4 ++-- kordophone/src/tests/test_client.rs | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index c500715..06df77f 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -1,10 +1,10 @@ extern crate hyper; extern crate serde; -use std::{borrow::Cow, default, path::PathBuf, str}; +use std::{path::PathBuf, str}; use log::{error}; -use hyper::{client::ResponseFuture, Body, Client, Method, Request, Uri}; +use hyper::{Body, Client, Method, Request, Uri}; use async_trait::async_trait; use serde::{de::DeserializeOwned, Deserialize, Serialize}; @@ -101,8 +101,8 @@ impl APIInterface for HTTPAPIClient { impl HTTPAPIClient { pub fn new(base_url: Uri, credentials: Option) -> HTTPAPIClient { HTTPAPIClient { - base_url: base_url, - credentials: credentials, + base_url, + credentials, auth_token: Option::None, client: Client::new(), } diff --git a/kordophone/src/model/jwt.rs b/kordophone/src/model/jwt.rs index 6810c2e..b5b62b9 100644 --- a/kordophone/src/model/jwt.rs +++ b/kordophone/src/model/jwt.rs @@ -64,8 +64,8 @@ impl JwtToken { exp.parse().unwrap() }); - let timestamp = chrono::NaiveDateTime::from_timestamp_opt(exp, 0).unwrap(); - let expiration_date = DateTime::::from_utc(timestamp, Utc); + let timestamp = DateTime::from_timestamp(exp, 0).unwrap().naive_utc(); + let expiration_date = DateTime::from_naive_utc_and_offset(timestamp, Utc); Ok(JwtToken { header, diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index 455dff8..375a449 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -24,7 +24,7 @@ impl TestClient { impl APIInterface for TestClient { type Error = TestError; - async fn authenticate(&mut self, credentials: Credentials) -> Result { + async fn authenticate(&mut self, _credentials: Credentials) -> Result { Ok(JwtToken::dummy()) } From 6b9f528cbf573ae33f18da8882558392b3164f06 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 9 Nov 2024 17:35:39 -0800 Subject: [PATCH 009/138] start working on kpcli --- Cargo.lock | 351 ++++++++++++++-------------------------------- Cargo.toml | 3 +- kpcli/Cargo.toml | 10 ++ kpcli/src/main.rs | 43 ++++++ 4 files changed, 161 insertions(+), 246 deletions(-) create mode 100644 kpcli/Cargo.toml create mode 100644 kpcli/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 34d4ec6..063d428 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,27 +18,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" [[package]] -name = "aho-corasick" -version = "1.1.3" +name = "anstream" +version = "0.6.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" dependencies = [ - "memchr", + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", ] [[package]] -name = "android-tzdata" -version = "0.1.1" +name = "anstyle" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" [[package]] -name = "android_system_properties" -version = "0.1.5" +name = "anstyle-parse" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" dependencies = [ - "libc", + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", ] [[package]] @@ -73,12 +98,6 @@ dependencies = [ "rustc-demangle", ] -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "bitflags" version = "1.3.2" @@ -91,12 +110,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" -[[package]] -name = "bumpalo" -version = "3.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" - [[package]] name = "bytes" version = "1.6.0" @@ -116,19 +129,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] -name = "chrono" -version = "0.4.38" +name = "clap" +version = "4.5.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" dependencies = [ - "android-tzdata", - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-targets 0.52.4", + "clap_builder", + "clap_derive", ] +[[package]] +name = "clap_builder" +version = "4.5.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + [[package]] name = "core-foundation" version = "0.9.4" @@ -145,16 +190,6 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" -[[package]] -name = "ctor" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edb49164822f3ee45b17acd4a208cfc1251410cf0cad9a833234c9890774dd9f" -dependencies = [ - "quote", - "syn", -] - [[package]] name = "deranged" version = "0.3.11" @@ -165,19 +200,6 @@ dependencies = [ "serde", ] -[[package]] -name = "env_logger" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" -dependencies = [ - "humantime", - "is-terminal", - "log", - "regex", - "termcolor", -] - [[package]] name = "equivalent" version = "1.0.1" @@ -302,6 +324,12 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.3.3" @@ -342,12 +370,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "humantime" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" - [[package]] name = "hyper" version = "0.14.28" @@ -385,29 +407,6 @@ dependencies = [ "tokio-native-tls", ] -[[package]] -name = "iana-time-zone" -version = "0.1.60" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "indexmap" version = "2.2.6" @@ -419,15 +418,10 @@ dependencies = [ ] [[package]] -name = "is-terminal" -version = "0.4.12" +name = "is_terminal_polyfill" +version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.52.0", -] +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "itoa" @@ -435,35 +429,28 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" -[[package]] -name = "js-sys" -version = "0.3.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" -dependencies = [ - "wasm-bindgen", -] - [[package]] name = "kordophone" version = "0.1.0" dependencies = [ "async-trait", - "base64", - "chrono", - "ctor", "hyper", "hyper-tls", - "log", - "pretty_env_logger", "serde", "serde_json", - "serde_plain", "time", "tokio", "uuid", ] +[[package]] +name = "kpcli" +version = "0.1.0" +dependencies = [ + "clap", + "kordophone", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -548,15 +535,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - [[package]] name = "num_cpus" version = "1.16.0" @@ -679,16 +657,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" -[[package]] -name = "pretty_env_logger" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "865724d4dbe39d9f3dd3b52b88d859d66bcb2d6a0acfd5ea68a65fb66d4bdc1c" -dependencies = [ - "env_logger", - "log", -] - [[package]] name = "proc-macro2" version = "1.0.81" @@ -746,35 +714,6 @@ dependencies = [ "bitflags 1.3.2", ] -[[package]] -name = "regex" -version = "1.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" - [[package]] name = "rustc-demangle" version = "0.1.23" @@ -869,15 +808,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_plain" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" -dependencies = [ - "serde", -] - [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -912,6 +842,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.60" @@ -935,15 +871,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "time" version = "0.3.36" @@ -1065,6 +992,12 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.8.0" @@ -1108,78 +1041,6 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" -[[package]] -name = "wasm-bindgen" -version = "0.2.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" -dependencies = [ - "cfg-if", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" -dependencies = [ - "bumpalo", - "log", - "once_cell", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" - -[[package]] -name = "winapi-util" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" -dependencies = [ - "windows-sys 0.52.0", -] - -[[package]] -name = "windows-core" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" -dependencies = [ - "windows-targets 0.52.4", -] - [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index ced7153..82180ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ - "kordophone" + "kordophone", + "kpcli" ] resolver = "2" diff --git a/kpcli/Cargo.toml b/kpcli/Cargo.toml new file mode 100644 index 0000000..010939f --- /dev/null +++ b/kpcli/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "kpcli" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +clap = { version = "4.5.20", features = ["derive"] } +kordophone = { path = "../kordophone" } diff --git a/kpcli/src/main.rs b/kpcli/src/main.rs new file mode 100644 index 0000000..0eea7ab --- /dev/null +++ b/kpcli/src/main.rs @@ -0,0 +1,43 @@ +use clap::{Parser, Subcommand}; +use kordophone::APIInterface; + +#[derive(Parser)] +#[command(name = "kpcli")] +#[command(about = "CLI tool for the Kordophone daemon")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Commands for api client operations + Client { + #[command(subcommand)] + command: ClientCommands, + }, +} + +#[derive(Subcommand)] +enum ClientCommands { + ListConversations, + Version, +} + +fn main() { + let cli = Cli::parse(); + + match cli.command { + Commands::Client { command } => match command { + ClientCommands::ListConversations => { + println!("Listing conversations..."); + // TODO: Implement conversation listing + }, + + ClientCommands::Version => { + println!("Getting version..."); + // TODO: Implement version getting + }, + }, + } +} From 0e8b8f339a090d4cbea5bea2c04981cd9100390b Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 10 Nov 2024 19:40:39 -0800 Subject: [PATCH 010/138] kpcli: client: adds printing of conversations --- Cargo.lock | 342 +++++++++++++++++++++++++++--- kordophone/Cargo.toml | 2 +- kordophone/src/api/http_client.rs | 35 ++- kordophone/src/lib.rs | 16 +- kpcli/.gitignore | 4 + kpcli/Cargo.toml | 5 + kpcli/src/client_cli.rs | 48 +++++ kpcli/src/main.rs | 41 ++-- kpcli/src/printers.rs | 58 +++++ 9 files changed, 492 insertions(+), 59 deletions(-) create mode 100644 kpcli/.gitignore create mode 100644 kpcli/src/client_cli.rs create mode 100644 kpcli/src/printers.rs diff --git a/Cargo.lock b/Cargo.lock index 063d428..8203337 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,30 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.15" @@ -53,7 +77,7 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" dependencies = [ - "windows-sys 0.52.0", + "windows-sys", ] [[package]] @@ -63,9 +87,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" dependencies = [ "anstyle", - "windows-sys 0.52.0", + "windows-sys", ] +[[package]] +name = "anyhow" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + [[package]] name = "async-trait" version = "0.1.80" @@ -98,6 +134,12 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -110,6 +152,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + [[package]] name = "bytes" version = "1.6.0" @@ -128,6 +176,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets 0.52.4", +] + [[package]] name = "clap" version = "4.5.20" @@ -190,6 +252,16 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "ctor" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edb49164822f3ee45b17acd4a208cfc1251410cf0cad9a833234c9890774dd9f" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "deranged" version = "0.3.11" @@ -200,6 +272,35 @@ dependencies = [ "serde", ] +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "env_filter" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "humantime", + "log", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -213,7 +314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys", ] [[package]] @@ -332,9 +433,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.3.3" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] name = "http" @@ -370,6 +471,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + [[package]] name = "hyper" version = "0.14.28" @@ -407,6 +514,29 @@ dependencies = [ "tokio-native-tls", ] +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "indexmap" version = "2.2.6" @@ -429,15 +559,30 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "js-sys" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +dependencies = [ + "wasm-bindgen", +] + [[package]] name = "kordophone" version = "0.1.0" dependencies = [ "async-trait", + "base64", + "chrono", + "ctor", + "env_logger", "hyper", "hyper-tls", + "log", "serde", "serde_json", + "serde_plain", "time", "tokio", "uuid", @@ -447,8 +592,13 @@ dependencies = [ name = "kpcli" version = "0.1.0" dependencies = [ + "anyhow", "clap", + "dotenv", "kordophone", + "log", + "pretty", + "tokio", ] [[package]] @@ -481,9 +631,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.21" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "memchr" @@ -502,13 +652,14 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.11" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ + "hermit-abi", "libc", "wasi", - "windows-sys 0.48.0", + "windows-sys", ] [[package]] @@ -536,13 +687,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] -name = "num_cpus" -version = "1.16.0" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "hermit-abi", - "libc", + "autocfg", ] [[package]] @@ -657,6 +807,18 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "pretty" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55c4d17d994b637e2f4daf6e5dc5d660d209d5642377d675d7a1c3ab69fa579" +dependencies = [ + "arrayvec", + "termcolor", + "typed-arena", + "unicode-width", +] + [[package]] name = "proc-macro2" version = "1.0.81" @@ -714,6 +876,35 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -730,7 +921,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys", ] [[package]] @@ -745,7 +936,7 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" dependencies = [ - "windows-sys 0.52.0", + "windows-sys", ] [[package]] @@ -808,6 +999,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -839,7 +1039,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys", ] [[package]] @@ -868,7 +1068,16 @@ dependencies = [ "cfg-if", "fastrand", "rustix", - "windows-sys 0.52.0", + "windows-sys", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", ] [[package]] @@ -903,28 +1112,27 @@ dependencies = [ [[package]] name = "tokio" -version = "1.37.0" +version = "1.41.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" +checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33" dependencies = [ "backtrace", "bytes", "libc", "mio", - "num_cpus", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.48.0", + "windows-sys", ] [[package]] name = "tokio-macros" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", @@ -986,12 +1194,24 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "unicode-ident" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1042,12 +1262,76 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] -name = "windows-sys" -version = "0.48.0" +name = "wasm-bindgen" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" dependencies = [ - "windows-targets 0.48.5", + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.4", ] [[package]] diff --git a/kordophone/Cargo.toml b/kordophone/Cargo.toml index cc21210..68fbf2b 100644 --- a/kordophone/Cargo.toml +++ b/kordophone/Cargo.toml @@ -10,10 +10,10 @@ async-trait = "0.1.80" base64 = "0.22.1" chrono = "0.4.38" ctor = "0.2.8" +env_logger = "0.11.5" hyper = { version = "0.14", features = ["full"] } hyper-tls = "0.5.0" log = { version = "0.4.21", features = [] } -pretty_env_logger = "0.5.0" serde = { version = "1.0.152", features = ["derive"] } serde_json = "1.0.91" serde_plain = "1.0.2" diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index 06df77f..eb1a5bc 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -1,7 +1,7 @@ extern crate hyper; extern crate serde; -use std::{path::PathBuf, str}; +use std::{ffi::OsString, path::PathBuf, str}; use log::{error}; use hyper::{Body, Client, Method, Request, Uri}; @@ -34,6 +34,21 @@ pub enum Error { DecodeError, } +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::HTTPError(ref err) => Some(err), + _ => None, + } + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + impl From for Error { fn from(err: hyper::Error) -> Error { Error::HTTPError(err) @@ -75,12 +90,12 @@ impl APIInterface for HTTPAPIClient { type Error = Error; async fn get_version(&mut self) -> Result { - let version: String = self.request("/version", Method::GET).await?; + let version: String = self.request("version", Method::GET).await?; Ok(version) } async fn get_conversations(&mut self) -> Result, Self::Error> { - let conversations: Vec = self.request("/conversations", Method::GET).await?; + let conversations: Vec = self.request("conversations", Method::GET).await?; Ok(conversations) } @@ -91,7 +106,7 @@ impl APIInterface for HTTPAPIClient { } let body = || -> Body { serde_json::to_string(&credentials).unwrap().into() }; - let token: AuthResponse = self.request_with_body_retry("/authenticate", Method::POST, body, false).await?; + let token: AuthResponse = self.request_with_body_retry("authenticate", Method::POST, body, false).await?; let token = JwtToken::new(&token.jwt).map_err(|_| Error::DecodeError)?; self.auth_token = Some(token.clone()); Ok(token) @@ -140,6 +155,8 @@ impl HTTPAPIClient { use hyper::StatusCode; let uri = self.uri_for_endpoint(endpoint); + log::debug!("Requesting {:?} {:?}", method, uri); + let build_request = move |auth: &Option| { let body = body_fn(); Request::builder() @@ -152,6 +169,9 @@ impl HTTPAPIClient { let request = build_request(&self.auth_token); let mut response = self.client.request(request).await?; + + log::debug!("-> Response: {:}", response.status()); + match response.status() { StatusCode::OK => { /* cool */ }, @@ -194,13 +214,6 @@ impl HTTPAPIClient { mod test { use super::*; - use ctor::ctor; - - #[ctor] - fn init() { - pretty_env_logger::init(); - log::set_max_level(log::LevelFilter::Trace); - } fn local_mock_client() -> HTTPAPIClient { let base_url = "http://localhost:5738".parse().unwrap(); diff --git a/kordophone/src/lib.rs b/kordophone/src/lib.rs index c0ba5bc..bb69a38 100644 --- a/kordophone/src/lib.rs +++ b/kordophone/src/lib.rs @@ -1,7 +1,21 @@ -mod api; +pub mod api; pub mod model; pub use self::api::APIInterface; +use ctor::ctor; #[cfg(test)] pub mod tests; + +extern crate env_logger; + +fn initialize_logging() { + env_logger::Builder::from_default_env() + .format_timestamp_secs() + .init(); +} + +#[ctor] +fn init() { + initialize_logging(); +} diff --git a/kpcli/.gitignore b/kpcli/.gitignore new file mode 100644 index 0000000..fb016c7 --- /dev/null +++ b/kpcli/.gitignore @@ -0,0 +1,4 @@ +.env +.env.* + + diff --git a/kpcli/Cargo.toml b/kpcli/Cargo.toml index 010939f..20f4d4d 100644 --- a/kpcli/Cargo.toml +++ b/kpcli/Cargo.toml @@ -6,5 +6,10 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +anyhow = "1.0.93" clap = { version = "4.5.20", features = ["derive"] } +dotenv = "0.15.0" kordophone = { path = "../kordophone" } +log = "0.4.22" +pretty = { version = "0.12.3", features = ["termcolor"] } +tokio = "1.41.1" diff --git a/kpcli/src/client_cli.rs b/kpcli/src/client_cli.rs new file mode 100644 index 0000000..33a56ac --- /dev/null +++ b/kpcli/src/client_cli.rs @@ -0,0 +1,48 @@ +use kordophone::APIInterface; +use kordophone::api::http_client::HTTPAPIClient; +use kordophone::api::http_client::Credentials; + +use dotenv; +use crate::printers::ConversationPrinter; + +pub struct ClientCli { + api: HTTPAPIClient, +} + +impl ClientCli { + pub fn new() -> Self { + dotenv::dotenv().ok(); + + // read from env + let base_url = std::env::var("KORDOPHONE_API_URL") + .expect("KORDOPHONE_API_URL must be set"); + + let credentials = Credentials { + username: std::env::var("KORDOPHONE_USERNAME") + .expect("KORDOPHONE_USERNAME must be set"), + + password: std::env::var("KORDOPHONE_PASSWORD") + .expect("KORDOPHONE_PASSWORD must be set"), + }; + + let api = HTTPAPIClient::new(base_url.parse().unwrap(), credentials.into()); + Self { api: api } + } + + pub async fn print_version(&mut self) -> Result<(), Box> { + let version = self.api.get_version().await?; + println!("Version: {}", version); + Ok(()) + } + + pub async fn print_conversations(&mut self) -> Result<(), Box> { + let conversations = self.api.get_conversations().await?; + for conversation in conversations { + println!("{}", ConversationPrinter::new(&conversation)); + } + + Ok(()) + } +} + + diff --git a/kpcli/src/main.rs b/kpcli/src/main.rs index 0eea7ab..c1ff3bb 100644 --- a/kpcli/src/main.rs +++ b/kpcli/src/main.rs @@ -1,9 +1,12 @@ -use clap::{Parser, Subcommand}; -use kordophone::APIInterface; +mod client_cli; +mod printers; +use clap::{Parser, Subcommand}; +use client_cli::ClientCli; + +/// A command line interface for the Kordophone library and daemon #[derive(Parser)] #[command(name = "kpcli")] -#[command(about = "CLI tool for the Kordophone daemon")] struct Cli { #[command(subcommand)] command: Commands, @@ -20,24 +23,28 @@ enum Commands { #[derive(Subcommand)] enum ClientCommands { - ListConversations, + /// Prints all known conversations on the server. + Conversations, + + /// Prints the server Kordophone version. Version, } -fn main() { - let cli = Cli::parse(); - - match cli.command { +async fn run_command(command: Commands) -> Result<(), Box> { + let mut client = ClientCli::new(); + match command { Commands::Client { command } => match command { - ClientCommands::ListConversations => { - println!("Listing conversations..."); - // TODO: Implement conversation listing - }, - - ClientCommands::Version => { - println!("Getting version..."); - // TODO: Implement version getting - }, + ClientCommands::Version => client.print_version().await, + ClientCommands::Conversations => client.print_conversations().await, }, } } + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + + run_command(cli.command).await + .map_err(|e| log::error!("Error: {}", e)) + .err(); +} diff --git a/kpcli/src/printers.rs b/kpcli/src/printers.rs new file mode 100644 index 0000000..2ed1bb0 --- /dev/null +++ b/kpcli/src/printers.rs @@ -0,0 +1,58 @@ +use std::fmt::Display; + +use pretty::RcDoc; +use kordophone::model::Conversation; + +pub struct ConversationPrinter<'a> { + doc: RcDoc<'a, Conversation> +} + +impl<'a> ConversationPrinter<'a> { + pub fn new(conversation: &'a Conversation) -> Self { + let preview = conversation.last_message_preview + .as_deref() + .unwrap_or("") + .replace('\n', " "); + + let doc = RcDoc::text(format!("")) + .append(RcDoc::line()) + .append("Date: ") + .append(conversation.date.to_string()) + .append(RcDoc::line()) + .append("Participants: ") + .append("[") + .append(RcDoc::line() + .append( + conversation.participant_display_names + .iter() + .map(|name| + RcDoc::text(name) + .append(",") + .append(RcDoc::line()) + ) + .fold(RcDoc::nil(), |acc, x| acc.append(x)) + ) + .nest(4) + ) + .append("]") + .append(RcDoc::line()) + .append("Last Message Preview: ") + .append(preview) + .nest(4) + ) + .append(RcDoc::line()) + .append(">"); + + ConversationPrinter { doc } + } +} + +impl<'a> Display for ConversationPrinter<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.doc.render_fmt(180, f) + } +} \ No newline at end of file From 75d4767009a9824dce3ca5f58fddd0655a208827 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 8 Dec 2024 15:08:15 -0800 Subject: [PATCH 011/138] kpcli: reorg subcommands --- kpcli/src/{client_cli.rs => client/mod.rs} | 22 +++++++++++++++++++++- kpcli/src/main.rs | 21 +++------------------ 2 files changed, 24 insertions(+), 19 deletions(-) rename kpcli/src/{client_cli.rs => client/mod.rs} (71%) diff --git a/kpcli/src/client_cli.rs b/kpcli/src/client/mod.rs similarity index 71% rename from kpcli/src/client_cli.rs rename to kpcli/src/client/mod.rs index 33a56ac..8e35fa5 100644 --- a/kpcli/src/client_cli.rs +++ b/kpcli/src/client/mod.rs @@ -3,9 +3,29 @@ use kordophone::api::http_client::HTTPAPIClient; use kordophone::api::http_client::Credentials; use dotenv; +use clap::Subcommand; use crate::printers::ConversationPrinter; -pub struct ClientCli { +#[derive(Subcommand)] +pub enum Commands { + /// Prints all known conversations on the server. + Conversations, + + /// Prints the server Kordophone version. + Version, +} + +impl Commands { + pub async fn run(cmd: Commands) -> Result<(), Box> { + let mut client = ClientCli::new(); + match cmd { + Commands::Version => client.print_version().await, + Commands::Conversations => client.print_conversations().await, + } + } +} + +struct ClientCli { api: HTTPAPIClient, } diff --git a/kpcli/src/main.rs b/kpcli/src/main.rs index c1ff3bb..c668088 100644 --- a/kpcli/src/main.rs +++ b/kpcli/src/main.rs @@ -1,8 +1,6 @@ -mod client_cli; mod printers; - +mod client; use clap::{Parser, Subcommand}; -use client_cli::ClientCli; /// A command line interface for the Kordophone library and daemon #[derive(Parser)] @@ -17,26 +15,13 @@ enum Commands { /// Commands for api client operations Client { #[command(subcommand)] - command: ClientCommands, + command: client::Commands, }, } -#[derive(Subcommand)] -enum ClientCommands { - /// Prints all known conversations on the server. - Conversations, - - /// Prints the server Kordophone version. - Version, -} - async fn run_command(command: Commands) -> Result<(), Box> { - let mut client = ClientCli::new(); match command { - Commands::Client { command } => match command { - ClientCommands::Version => client.print_version().await, - ClientCommands::Conversations => client.print_conversations().await, - }, + Commands::Client { command } => client::Commands::run(command).await, } } From fac9b1ffe6e31dcea4add5dfc66062e54d2fc14b Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 8 Dec 2024 21:12:17 -0800 Subject: [PATCH 012/138] adds kordophone-db --- Cargo.lock | 130 ++++++++++++++++++----- Cargo.toml | 3 +- kordophone-db/Cargo.toml | 11 ++ kordophone-db/src/chat_database.rs | 78 ++++++++++++++ kordophone-db/src/lib.rs | 69 ++++++++++++ kordophone-db/src/models/conversation.rs | 113 ++++++++++++++++++++ kordophone-db/src/models/date.rs | 18 ++++ kordophone-db/src/models/mod.rs | 2 + 8 files changed, 398 insertions(+), 26 deletions(-) create mode 100644 kordophone-db/Cargo.toml create mode 100644 kordophone-db/src/chat_database.rs create mode 100644 kordophone-db/src/lib.rs create mode 100644 kordophone-db/src/models/conversation.rs create mode 100644 kordophone-db/src/models/date.rs create mode 100644 kordophone-db/src/models/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 8203337..ff6a59e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -110,7 +110,7 @@ checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.90", ] [[package]] @@ -221,7 +221,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.90", ] [[package]] @@ -236,6 +236,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "convert_case" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb4a24b1aaf0fd0ce8b45161144d6f42cd91677fd5940fd431183eb023b3a2b8" + [[package]] name = "core-foundation" version = "0.9.4" @@ -259,7 +265,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edb49164822f3ee45b17acd4a208cfc1251410cf0cad9a833234c9890774dd9f" dependencies = [ "quote", - "syn", + "syn 2.0.90", ] [[package]] @@ -278,6 +284,12 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + [[package]] name = "env_filter" version = "0.1.2" @@ -553,6 +565,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.11" @@ -588,6 +609,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "kordophone-db" +version = "0.1.0" +dependencies = [ + "chrono", + "microrm", + "serde", + "time", + "uuid", +] + [[package]] name = "kpcli" version = "0.1.0" @@ -613,6 +645,16 @@ version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.13" @@ -641,6 +683,33 @@ version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" +[[package]] +name = "microrm" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7119c145e9ee33d8a79a2c7e15c4f429d681079cea976692a55c78aaae34f5ae" +dependencies = [ + "itertools", + "libsqlite3-sys", + "log", + "microrm-macros", + "serde", + "serde_json", + "time", +] + +[[package]] +name = "microrm-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3e29bc1b3c1dc742de1b3a23f11e472a2adc936a967dc5d769c26a809326b8" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "miniz_oxide" version = "0.7.2" @@ -733,7 +802,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.90", ] [[package]] @@ -821,9 +890,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.81" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] @@ -970,22 +1039,22 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.198" +version = "1.0.215" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9846a40c979031340571da2545a4e5b7c4163bdae79b301d5f86d03979451fcc" +checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.198" +version = "1.0.215" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e88edab869b01783ba905e7d0153f9fc1a6505a96e4ad3018011eedb838566d9" +checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.90", ] [[package]] @@ -1050,9 +1119,20 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.60" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" dependencies = [ "proc-macro2", "quote", @@ -1082,9 +1162,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.36" +version = "0.3.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" dependencies = [ "deranged", "num-conv", @@ -1102,9 +1182,9 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.18" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" dependencies = [ "num-conv", "time-core", @@ -1136,7 +1216,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.90", ] [[package]] @@ -1220,9 +1300,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.8.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" +checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" dependencies = [ "getrandom", "rand", @@ -1231,13 +1311,13 @@ dependencies = [ [[package]] name = "uuid-macro-internal" -version = "1.8.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9881bea7cbe687e36c9ab3b778c36cd0487402e270304e8b1296d5085303c1a2" +checksum = "6b91f57fe13a38d0ce9e28a03463d8d3c2468ed03d75375110ec71d93b449a08" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.90", ] [[package]] @@ -1283,7 +1363,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn", + "syn 2.0.90", "wasm-bindgen-shared", ] @@ -1305,7 +1385,7 @@ checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.90", "wasm-bindgen-backend", "wasm-bindgen-shared", ] diff --git a/Cargo.toml b/Cargo.toml index 82180ee..85fc82d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ - "kordophone", + "kordophone", + "kordophone-db", "kpcli" ] resolver = "2" diff --git a/kordophone-db/Cargo.toml b/kordophone-db/Cargo.toml new file mode 100644 index 0000000..ac8e6db --- /dev/null +++ b/kordophone-db/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "kordophone-db" +version = "0.1.0" +edition = "2021" + +[dependencies] +chrono = "0.4.38" +microrm = "0.4.4" +serde = { version = "1.0.215", features = ["derive"] } +time = "0.3.37" +uuid = { version = "1.11.0", features = ["v4"] } diff --git a/kordophone-db/src/chat_database.rs b/kordophone-db/src/chat_database.rs new file mode 100644 index 0000000..1ca973b --- /dev/null +++ b/kordophone-db/src/chat_database.rs @@ -0,0 +1,78 @@ +use microrm::prelude::*; +use microrm::Stored; +use crate::models::conversation::{self, Conversation, ConversationID}; +use std::error::Error; + +pub struct ChatDatabase { + db: DB, +} + +#[derive(Database)] +struct DB { + conversations: microrm::IDMap, +} + +impl ChatDatabase { + pub fn new_in_memory() -> Result> { + let db = DB::open_path(":memory:")?; + return Ok(Self { + db: db, + }) + } + + pub fn insert_conversation(&self, conversation: Conversation) -> Result { + // First see if conversation guid already exists, update it if so + let guid = &conversation.guid; + let mut existing = self.db.conversations + .with(Conversation::Guid, guid) + .get()?; + + if let Some(existing) = existing.first_mut() { + existing.display_name = conversation.display_name; + existing.sync(); + return Ok(existing.id()); + } else { + // Otherwise, insert. + return self.db.conversations.insert(conversation); + } + } + + pub fn get_conversation_by_id(&self, id: ConversationID) -> Result, microrm::Error> { + self.db.conversations + .by_id(id) + .map(|stored_conversation| stored_conversation + .map(|stored| stored.wrapped()) + ) + } + + pub fn get_conversation_by_guid(&self, guid: &str) -> Result, microrm::Error> { + self.db.conversations + .with(Conversation::Guid, guid) + .get() + .and_then(|v| Ok(v + .into_iter() + .map(|c| c.wrapped()) + .last() + )) + } + + pub fn all_conversations(&self) -> Result, microrm::Error> { + self.db.conversations + .get() + .map(|v| v + .into_iter() + .map(|c| c.wrapped()) + .collect() + ) + } + + fn stored_conversation_by_guid(&self, guid: &str) -> Result>, microrm::Error> { + self.db.conversations + .with(Conversation::Guid, guid) + .get() + .map(|v| v + .into_iter() + .last() + ) + } +} \ No newline at end of file diff --git a/kordophone-db/src/lib.rs b/kordophone-db/src/lib.rs new file mode 100644 index 0000000..1eca944 --- /dev/null +++ b/kordophone-db/src/lib.rs @@ -0,0 +1,69 @@ +pub mod models; +pub mod chat_database; + +#[cfg(test)] +mod tests { + use microrm::prelude::Queryable; + + use crate::{chat_database::{self, ChatDatabase}, models::conversation::{Conversation, ConversationBuilder, Participant}}; + + #[test] + fn test_database_init() { + let _ = ChatDatabase::new_in_memory().unwrap(); + } + + #[test] + fn test_add_conversation() { + let db = ChatDatabase::new_in_memory().unwrap(); + + let test_conversation = Conversation::builder() + .guid("test") + .unread_count(2) + .display_name("Test Conversation") + .build(); + + let id = db.insert_conversation(test_conversation.clone()).unwrap(); + + // Try to fetch with id now + let conversation = db.get_conversation_by_id(id).unwrap().unwrap(); + assert_eq!(conversation.guid, "test"); + + // Modify the conversation and update it + let modified_conversation = test_conversation.into_builder() + .display_name("Modified Conversation") + .build(); + + db.insert_conversation(modified_conversation.clone()).unwrap(); + + // Make sure we still only have one conversation. + let all_conversations = db.all_conversations().unwrap(); + assert_eq!(all_conversations.len(), 1); + + // And make sure the display name was updated + let conversation = db.get_conversation_by_id(id).unwrap().unwrap(); + assert_eq!(conversation.display_name.unwrap(), "Modified Conversation"); + } + + #[test] + fn test_conversation_participants() { + let db = ChatDatabase::new_in_memory().unwrap(); + + let participants: Vec = vec!["one".into(), "two".into()]; + let conversation = ConversationBuilder::new() + .display_name("Test") + .participant_display_names(participants.clone()) + .build(); + + let id = db.insert_conversation(conversation).unwrap(); + + let read_conversation = db.get_conversation_by_id(id).unwrap().unwrap(); + let read_participants: Vec = read_conversation.participant_display_names + .get() + .unwrap() + .into_iter() + .map(|p| p.wrapped().display_name) + .collect(); + + assert_eq!(participants, read_participants); + } +} diff --git a/kordophone-db/src/models/conversation.rs b/kordophone-db/src/models/conversation.rs new file mode 100644 index 0000000..3807d54 --- /dev/null +++ b/kordophone-db/src/models/conversation.rs @@ -0,0 +1,113 @@ +use microrm::prelude::*; +use chrono::{DateTime, Local, Utc}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::models::date::Date; + +#[derive(Entity, Clone)] +pub struct Conversation { + #[unique] + pub guid: String, + + pub unread_count: i64, + pub display_name: Option, + pub last_message_preview: Option, + pub date: OffsetDateTime, + pub participant_display_names: microrm::RelationMap, +} + +#[derive(Entity, Clone)] +pub struct Participant { + #[unique] + pub display_name: String +} + +impl Into for String { + fn into(self) -> Participant { + Participant { display_name: self } + } +} + +impl Conversation { + pub fn builder() -> ConversationBuilder { + ConversationBuilder::new() + } + + pub fn into_builder(&self) -> ConversationBuilder { + ConversationBuilder { + guid: Some(self.guid.clone()), + date: Date::new(self.date), + participant_display_names: None, + unread_count: Some(self.unread_count), + last_message_preview: self.last_message_preview.clone(), + display_name: self.display_name.clone(), + } + } +} + +#[derive(Default)] +pub struct ConversationBuilder { + guid: Option, + date: Date, + unread_count: Option, + last_message_preview: Option, + participant_display_names: Option>, + display_name: Option, +} + +impl ConversationBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn guid(mut self, guid: &str) -> Self { + self.guid = Some(guid.into()); + self + } + + pub fn date(mut self, date: Date) -> Self { + self.date = date; + self + } + + pub fn unread_count(mut self, unread_count: i64) -> Self { + self.unread_count = Some(unread_count); + self + } + + pub fn last_message_preview(mut self, last_message_preview: &str) -> Self { + self.last_message_preview = Some(last_message_preview.into()); + self + } + + pub fn participant_display_names(mut self, participant_display_names: Vec) -> Self { + self.participant_display_names = Some(participant_display_names); + self + } + + pub fn display_name(mut self, display_name: &str) -> Self { + self.display_name = Some(display_name.into()); + self + } + + pub fn build(self) -> Conversation { + let result = Conversation { + guid: self.guid.unwrap_or(Uuid::new_v4().to_string()), + unread_count: self.unread_count.unwrap_or(0), + last_message_preview: self.last_message_preview, + display_name: self.display_name, + date: self.date.dt, + participant_display_names: Default::default(), + }; + + // TODO: this isn't right... this is crashing the test. + if let Some(participants) = self.participant_display_names { + for participant in participants { + result.participant_display_names.insert(participant.into()).unwrap(); + } + } + + result + } +} diff --git a/kordophone-db/src/models/date.rs b/kordophone-db/src/models/date.rs new file mode 100644 index 0000000..b4ffea7 --- /dev/null +++ b/kordophone-db/src/models/date.rs @@ -0,0 +1,18 @@ +use chrono::{DateTime, Local, Utc}; +use time::OffsetDateTime; + +pub struct Date { + pub dt: OffsetDateTime, +} + +impl Date { + pub fn new(dt: OffsetDateTime) -> Self { + Self { dt } + } +} + +impl Default for Date { + fn default() -> Self { + Self { dt: OffsetDateTime::now_utc() } + } +} \ No newline at end of file diff --git a/kordophone-db/src/models/mod.rs b/kordophone-db/src/models/mod.rs new file mode 100644 index 0000000..3181e39 --- /dev/null +++ b/kordophone-db/src/models/mod.rs @@ -0,0 +1,2 @@ +pub mod conversation; +pub mod date; \ No newline at end of file From 86601b027a990ee752ab1160642078fdfe03748b Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 14 Dec 2024 12:53:44 -0800 Subject: [PATCH 013/138] kordophone-db: participants, but still need to "upsert" these Might move to SeaORM instead of trying to do this with microrm --- kordophone-db/src/chat_database.rs | 55 +++++++++--- kordophone-db/src/lib.rs | 33 ++++--- kordophone-db/src/models/conversation.rs | 106 ++++++++++++++++------- kordophone-db/src/models/mod.rs | 3 +- kordophone-db/src/models/participant.rs | 19 ++++ 5 files changed, 165 insertions(+), 51 deletions(-) create mode 100644 kordophone-db/src/models/participant.rs diff --git a/kordophone-db/src/chat_database.rs b/kordophone-db/src/chat_database.rs index 1ca973b..65e2286 100644 --- a/kordophone-db/src/chat_database.rs +++ b/kordophone-db/src/chat_database.rs @@ -1,7 +1,14 @@ +use std::error::Error; use microrm::prelude::*; use microrm::Stored; -use crate::models::conversation::{self, Conversation, ConversationID}; -use std::error::Error; + +use crate::models::participant::ParticipantID; +use crate::models::{ + participant::Participant, + conversation::{ + self, Conversation, ConversationID, PendingConversation + } +}; pub struct ChatDatabase { db: DB, @@ -10,6 +17,7 @@ pub struct ChatDatabase { #[derive(Database)] struct DB { conversations: microrm::IDMap, + participants: microrm::IDMap, } impl ChatDatabase { @@ -20,20 +28,27 @@ impl ChatDatabase { }) } - pub fn insert_conversation(&self, conversation: Conversation) -> Result { + pub fn insert_conversation(&self, conversation: PendingConversation) -> Result { // First see if conversation guid already exists, update it if so - let guid = &conversation.guid; - let mut existing = self.db.conversations - .with(Conversation::Guid, guid) - .get()?; + let guid = conversation.guid(); + let mut existing = self.stored_conversation_by_guid(guid)?; - if let Some(existing) = existing.first_mut() { - existing.display_name = conversation.display_name; + if let Some(existing) = existing.as_mut() { + conversation.update(existing); existing.sync(); return Ok(existing.id()); } else { // Otherwise, insert. - return self.db.conversations.insert(conversation); + let inserted = self.db.conversations.insert_and_return(conversation.get_conversation())?; + + // Insert participants + let participants = conversation.get_participants(); + let inserted_participants = participants.iter() + .map(|p| self.db.participants.insert(p.clone()).unwrap()) + .collect::>(); + inserted.connect_participants(inserted_participants); + + return Ok(inserted.id()); } } @@ -66,6 +81,26 @@ impl ChatDatabase { ) } + fn upsert_participants(&self, participants: Vec) -> Vec { + // Filter existing participants and add to result + let existing_participants = participants.iter() + .filter_map(|p| self.db.participants + .with(Participant::DisplayName, &p.display_name) + .get() + .ok() + .and_then(|v| v + .into_iter() + .last() + .map(|p| p.id()) + ) + ) + .collect::>(); + + participants.iter() + .map(|p| self.db.participants.insert(p.clone()).unwrap()) + .collect() + } + fn stored_conversation_by_guid(&self, guid: &str) -> Result>, microrm::Error> { self.db.conversations .with(Conversation::Guid, guid) diff --git a/kordophone-db/src/lib.rs b/kordophone-db/src/lib.rs index 1eca944..6eb6f99 100644 --- a/kordophone-db/src/lib.rs +++ b/kordophone-db/src/lib.rs @@ -3,9 +3,13 @@ pub mod chat_database; #[cfg(test)] mod tests { - use microrm::prelude::Queryable; - - use crate::{chat_database::{self, ChatDatabase}, models::conversation::{Conversation, ConversationBuilder, Participant}}; + use crate::{ + chat_database::ChatDatabase, + models::{ + conversation::{Conversation, ConversationBuilder}, + participant::Participant + } + }; #[test] fn test_database_init() { @@ -48,7 +52,8 @@ mod tests { fn test_conversation_participants() { let db = ChatDatabase::new_in_memory().unwrap(); - let participants: Vec = vec!["one".into(), "two".into()]; + let participants: Vec = vec!["one".into(), "two".into()]; + let conversation = ConversationBuilder::new() .display_name("Test") .participant_display_names(participants.clone()) @@ -57,12 +62,20 @@ mod tests { let id = db.insert_conversation(conversation).unwrap(); let read_conversation = db.get_conversation_by_id(id).unwrap().unwrap(); - let read_participants: Vec = read_conversation.participant_display_names - .get() - .unwrap() - .into_iter() - .map(|p| p.wrapped().display_name) - .collect(); + let read_participants: Vec = read_conversation.get_participant_display_names(); + + assert_eq!(participants, read_participants); + + // Try making another conversation with the same participants + let conversation = ConversationBuilder::new() + .display_name("A Different Test") + .participant_display_names(participants.clone()) + .build(); + + let id = db.insert_conversation(conversation).unwrap(); + + let read_conversation = db.get_conversation_by_id(id).unwrap().unwrap(); + let read_participants: Vec = read_conversation.get_participant_display_names(); assert_eq!(participants, read_participants); } diff --git a/kordophone-db/src/models/conversation.rs b/kordophone-db/src/models/conversation.rs index 3807d54..4fd2ff5 100644 --- a/kordophone-db/src/models/conversation.rs +++ b/kordophone-db/src/models/conversation.rs @@ -1,9 +1,14 @@ use microrm::prelude::*; -use chrono::{DateTime, Local, Utc}; +use microrm::Stored; use time::OffsetDateTime; use uuid::Uuid; -use crate::models::date::Date; +use crate::models::{ + date::Date, + participant::Participant, +}; + +use super::participant::ParticipantID; #[derive(Entity, Clone)] pub struct Conversation { @@ -14,19 +19,8 @@ pub struct Conversation { pub display_name: Option, pub last_message_preview: Option, pub date: OffsetDateTime, - pub participant_display_names: microrm::RelationMap, -} - -#[derive(Entity, Clone)] -pub struct Participant { - #[unique] - pub display_name: String -} - -impl Into for String { - fn into(self) -> Participant { - Participant { display_name: self } - } + + participant_display_names: microrm::RelationMap, } impl Conversation { @@ -44,6 +38,60 @@ impl Conversation { display_name: self.display_name.clone(), } } + + pub fn get_participant_display_names(&self) -> Vec { + self.participant_display_names + .get() + .unwrap() + .into_iter() + .map(|p| p.wrapped()) + .collect() + } + + pub fn update(&self, stored_conversation: &mut Stored) { + *stored_conversation.as_mut() = self.clone(); + } + + pub fn connect_participants(&self, participant_ids: Vec) { + participant_ids.iter().for_each(|id| { + self.participant_display_names.connect_to(*id).unwrap(); + }); + } +} + +#[derive(Clone)] +pub struct PendingConversation { + conversation: Conversation, + participants: Vec, +} + +impl PendingConversation { + pub fn guid(&self) -> &String { + &self.conversation.guid + } + + pub fn into_builder(self) -> ConversationBuilder { + ConversationBuilder { + guid: Some(self.conversation.guid), + date: Date::new(self.conversation.date), + participant_display_names: Some(self.participants), + unread_count: Some(self.conversation.unread_count), + last_message_preview: self.conversation.last_message_preview, + display_name: self.conversation.display_name, + } + } + + pub fn update(&self, stored_conversation: &mut microrm::Stored) { + self.conversation.update(stored_conversation); + } + + pub fn get_participants(&self) -> &Vec { + &self.participants + } + + pub fn get_conversation(&self) -> Conversation { + self.conversation.clone() + } } #[derive(Default)] @@ -52,7 +100,7 @@ pub struct ConversationBuilder { date: Date, unread_count: Option, last_message_preview: Option, - participant_display_names: Option>, + participant_display_names: Option>, display_name: Option, } @@ -81,7 +129,7 @@ impl ConversationBuilder { self } - pub fn participant_display_names(mut self, participant_display_names: Vec) -> Self { + pub fn participant_display_names(mut self, participant_display_names: Vec) -> Self { self.participant_display_names = Some(participant_display_names); self } @@ -91,23 +139,21 @@ impl ConversationBuilder { self } - pub fn build(self) -> Conversation { - let result = Conversation { - guid: self.guid.unwrap_or(Uuid::new_v4().to_string()), + fn build_conversation(&self) -> Conversation { + Conversation { + guid: self.guid.clone().unwrap_or(Uuid::new_v4().to_string()), unread_count: self.unread_count.unwrap_or(0), - last_message_preview: self.last_message_preview, - display_name: self.display_name, + last_message_preview: self.last_message_preview.clone(), + display_name: self.display_name.clone(), date: self.date.dt, participant_display_names: Default::default(), - }; - - // TODO: this isn't right... this is crashing the test. - if let Some(participants) = self.participant_display_names { - for participant in participants { - result.participant_display_names.insert(participant.into()).unwrap(); - } } + } - result + pub fn build(self) -> PendingConversation { + PendingConversation { + conversation: self.build_conversation(), + participants: self.participant_display_names.unwrap_or_default(), + } } } diff --git a/kordophone-db/src/models/mod.rs b/kordophone-db/src/models/mod.rs index 3181e39..8323539 100644 --- a/kordophone-db/src/models/mod.rs +++ b/kordophone-db/src/models/mod.rs @@ -1,2 +1,3 @@ pub mod conversation; -pub mod date; \ No newline at end of file +pub mod date; +pub mod participant; \ No newline at end of file diff --git a/kordophone-db/src/models/participant.rs b/kordophone-db/src/models/participant.rs new file mode 100644 index 0000000..e5cabb4 --- /dev/null +++ b/kordophone-db/src/models/participant.rs @@ -0,0 +1,19 @@ +use microrm::prelude::*; + +#[derive(Entity, Clone, PartialEq)] +pub struct Participant { + #[unique] + pub display_name: String +} + +impl Into for String { + fn into(self) -> Participant { + Participant { display_name: self } + } +} + +impl From<&str> for Participant { + fn from(s: &str) -> Self { + Participant { display_name: s.into() } + } +} From f79cbbbc85d4cc1d5cdce39f1cfc4c27e9e19b77 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 14 Dec 2024 19:03:27 -0800 Subject: [PATCH 014/138] kordophone-db: switch to diesel for more features --- Cargo.lock | 241 +++++++++++++----- kordophone-db/Cargo.toml | 4 +- kordophone-db/diesel.toml | 9 + kordophone-db/migrations/.keep | 0 .../down.sql | 4 + .../up.sql | 20 ++ kordophone-db/src/chat_database.rs | 163 ++++++------ kordophone-db/src/lib.rs | 32 ++- kordophone-db/src/models/conversation.rs | 150 +++++------ kordophone-db/src/models/date.rs | 1 - kordophone-db/src/models/participant.rs | 30 ++- kordophone-db/src/schema.rs | 27 ++ 12 files changed, 432 insertions(+), 249 deletions(-) create mode 100644 kordophone-db/diesel.toml create mode 100644 kordophone-db/migrations/.keep create mode 100644 kordophone-db/migrations/2024-12-15-030301_create_conversations/down.sql create mode 100644 kordophone-db/migrations/2024-12-15-030301_create_conversations/up.sql create mode 100644 kordophone-db/src/schema.rs diff --git a/Cargo.lock b/Cargo.lock index ff6a59e..07d270e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,9 +92,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.93" +version = "1.0.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" +checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" [[package]] name = "arrayvec" @@ -110,7 +110,7 @@ checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.90", + "syn", ] [[package]] @@ -221,7 +221,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.90", + "syn", ] [[package]] @@ -236,12 +236,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" -[[package]] -name = "convert_case" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb4a24b1aaf0fd0ce8b45161144d6f42cd91677fd5940fd431183eb023b3a2b8" - [[package]] name = "core-foundation" version = "0.9.4" @@ -265,7 +259,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edb49164822f3ee45b17acd4a208cfc1251410cf0cad9a833234c9890774dd9f" dependencies = [ "quote", - "syn 2.0.90", + "syn", +] + +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn", ] [[package]] @@ -278,12 +307,71 @@ dependencies = [ "serde", ] +[[package]] +name = "diesel" +version = "2.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf1bedf64cdb9643204a36dd15b19a6ce8e7aa7f7b105868e9f1fad5ffa7d12" +dependencies = [ + "chrono", + "diesel_derives", + "libsqlite3-sys", + "time", +] + +[[package]] +name = "diesel_derives" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7f2c3de51e2ba6bf2a648285696137aaf0f5f487bcbea93972fe8a364e131a4" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "diesel_migrations" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a73ce704bad4231f001bff3314d91dce4aba0770cee8b233991859abc15c1f6" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25" +dependencies = [ + "syn", +] + [[package]] name = "dotenv" version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "dsl_auto_type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9abe6314103864cc2d8901b7ae224e0ab1a103a0a416661b4097b0779b607" +dependencies = [ + "darling", + "either", + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "either" version = "1.13.0" @@ -433,9 +521,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.14.3" +version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" [[package]] name = "heck" @@ -550,10 +638,16 @@ dependencies = [ ] [[package]] -name = "indexmap" -version = "2.2.6" +name = "ident_case" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", "hashbrown", @@ -565,15 +659,6 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.11" @@ -613,8 +698,10 @@ dependencies = [ name = "kordophone-db" version = "0.1.0" dependencies = [ + "anyhow", "chrono", - "microrm", + "diesel", + "diesel_migrations", "serde", "time", "uuid", @@ -647,9 +734,9 @@ checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" [[package]] name = "libsqlite3-sys" -version = "0.28.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ "pkg-config", "vcpkg", @@ -684,30 +771,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" [[package]] -name = "microrm" -version = "0.4.4" +name = "migrations_internals" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7119c145e9ee33d8a79a2c7e15c4f429d681079cea976692a55c78aaae34f5ae" +checksum = "fd01039851e82f8799046eabbb354056283fb265c8ec0996af940f4e85a380ff" dependencies = [ - "itertools", - "libsqlite3-sys", - "log", - "microrm-macros", "serde", - "serde_json", - "time", + "toml", ] [[package]] -name = "microrm-macros" -version = "0.4.1" +name = "migrations_macros" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3e29bc1b3c1dc742de1b3a23f11e472a2adc936a967dc5d769c26a809326b8" +checksum = "ffb161cc72176cb37aa47f1fc520d3ef02263d67d661f44f05d05a079e1237fd" dependencies = [ - "convert_case", + "migrations_internals", "proc-macro2", "quote", - "syn 1.0.109", ] [[package]] @@ -802,7 +883,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.90", + "syn", ] [[package]] @@ -1054,7 +1135,7 @@ checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.90", + "syn", ] [[package]] @@ -1077,6 +1158,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -1117,17 +1207,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.90" @@ -1167,6 +1246,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde", @@ -1216,7 +1296,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn 2.0.90", + "syn", ] [[package]] @@ -1243,6 +1323,40 @@ dependencies = [ "tracing", ] +[[package]] +name = "toml" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "tower-service" version = "0.3.2" @@ -1317,7 +1431,7 @@ checksum = "6b91f57fe13a38d0ce9e28a03463d8d3c2468ed03d75375110ec71d93b449a08" dependencies = [ "proc-macro2", "quote", - "syn 2.0.90", + "syn", ] [[package]] @@ -1363,7 +1477,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.90", + "syn", "wasm-bindgen-shared", ] @@ -1385,7 +1499,7 @@ checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" dependencies = [ "proc-macro2", "quote", - "syn 2.0.90", + "syn", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -1536,3 +1650,12 @@ name = "windows_x86_64_msvc" version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" + +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] diff --git a/kordophone-db/Cargo.toml b/kordophone-db/Cargo.toml index ac8e6db..1fd691e 100644 --- a/kordophone-db/Cargo.toml +++ b/kordophone-db/Cargo.toml @@ -4,8 +4,10 @@ version = "0.1.0" edition = "2021" [dependencies] +anyhow = "1.0.94" chrono = "0.4.38" -microrm = "0.4.4" +diesel = { version = "2.2.6", features = ["chrono", "sqlite", "time"] } +diesel_migrations = { version = "2.2.0", features = ["sqlite"] } serde = { version = "1.0.215", features = ["derive"] } time = "0.3.37" uuid = { version = "1.11.0", features = ["v4"] } diff --git a/kordophone-db/diesel.toml b/kordophone-db/diesel.toml new file mode 100644 index 0000000..c028f4a --- /dev/null +++ b/kordophone-db/diesel.toml @@ -0,0 +1,9 @@ +# For documentation on how to configure this file, +# see https://diesel.rs/guides/configuring-diesel-cli + +[print_schema] +file = "src/schema.rs" +custom_type_derives = ["diesel::query_builder::QueryId"] + +[migrations_directory] +dir = "migrations" diff --git a/kordophone-db/migrations/.keep b/kordophone-db/migrations/.keep new file mode 100644 index 0000000..e69de29 diff --git a/kordophone-db/migrations/2024-12-15-030301_create_conversations/down.sql b/kordophone-db/migrations/2024-12-15-030301_create_conversations/down.sql new file mode 100644 index 0000000..40e3b94 --- /dev/null +++ b/kordophone-db/migrations/2024-12-15-030301_create_conversations/down.sql @@ -0,0 +1,4 @@ +-- This file should undo anything in `up.sql` +DROP TABLE IF EXISTS `participants`; +DROP TABLE IF EXISTS `conversation_participants`; +DROP TABLE IF EXISTS `conversations`; diff --git a/kordophone-db/migrations/2024-12-15-030301_create_conversations/up.sql b/kordophone-db/migrations/2024-12-15-030301_create_conversations/up.sql new file mode 100644 index 0000000..9ac80cd --- /dev/null +++ b/kordophone-db/migrations/2024-12-15-030301_create_conversations/up.sql @@ -0,0 +1,20 @@ +-- Your SQL goes here +CREATE TABLE `participants`( + `id` INTEGER NOT NULL PRIMARY KEY, + `display_name` TEXT NOT NULL +); + +CREATE TABLE `conversation_participants`( + `conversation_id` TEXT NOT NULL, + `participant_id` INTEGER NOT NULL, + PRIMARY KEY(`conversation_id`, `participant_id`) +); + +CREATE TABLE `conversations`( + `id` TEXT NOT NULL PRIMARY KEY, + `unread_count` BIGINT NOT NULL, + `display_name` TEXT, + `last_message_preview` TEXT, + `date` TIMESTAMP NOT NULL +); + diff --git a/kordophone-db/src/chat_database.rs b/kordophone-db/src/chat_database.rs index 65e2286..8d25f83 100644 --- a/kordophone-db/src/chat_database.rs +++ b/kordophone-db/src/chat_database.rs @@ -1,113 +1,100 @@ use std::error::Error; -use microrm::prelude::*; -use microrm::Stored; +use anyhow::Result; +use diesel::prelude::*; +use diesel::query_dsl::BelongingToDsl; -use crate::models::participant::ParticipantID; -use crate::models::{ - participant::Participant, +use crate::{models::{ conversation::{ - self, Conversation, ConversationID, PendingConversation - } -}; + self, Conversation, DbConversation + }, participant::{ConversationParticipant, DbParticipant, Participant} +}, schema}; + +use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; +pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); pub struct ChatDatabase { - db: DB, -} - -#[derive(Database)] -struct DB { - conversations: microrm::IDMap, - participants: microrm::IDMap, + db: SqliteConnection, } impl ChatDatabase { - pub fn new_in_memory() -> Result> { - let db = DB::open_path(":memory:")?; + pub fn new_in_memory() -> Result { + let mut db = SqliteConnection::establish(":memory:")?; + db.run_pending_migrations(MIGRATIONS) + .map_err(|e| anyhow::anyhow!("Error running migrations: {}", e))?; + return Ok(Self { db: db, }) } - pub fn insert_conversation(&self, conversation: PendingConversation) -> Result { - // First see if conversation guid already exists, update it if so - let guid = conversation.guid(); - let mut existing = self.stored_conversation_by_guid(guid)?; + pub fn insert_conversation(&mut self, conversation: Conversation) -> Result<()> { + use crate::schema::conversations::dsl::*; + use crate::schema::participants::dsl::*; + use crate::schema::conversation_participants::dsl::*; - if let Some(existing) = existing.as_mut() { - conversation.update(existing); - existing.sync(); - return Ok(existing.id()); - } else { - // Otherwise, insert. - let inserted = self.db.conversations.insert_and_return(conversation.get_conversation())?; + let (db_conversation, db_participants) = conversation.into(); - // Insert participants - let participants = conversation.get_participants(); - let inserted_participants = participants.iter() - .map(|p| self.db.participants.insert(p.clone()).unwrap()) - .collect::>(); - inserted.connect_participants(inserted_participants); - - return Ok(inserted.id()); + diesel::replace_into(conversations) + .values(&db_conversation) + .execute(&mut self.db)?; + + diesel::replace_into(participants) + .values(&db_participants) + .execute(&mut self.db)?; + + // Sqlite backend doesn't support batch insert, so we have to do this manually + for participant in db_participants { + let pid = participants + .select(schema::participants::id) + .filter(schema::participants::display_name.eq(&participant.display_name)) + .first::(&mut self.db)?; + + diesel::replace_into(conversation_participants) + .values(( + conversation_id.eq(&db_conversation.id), + participant_id.eq(pid), + )) + .execute(&mut self.db)?; } + + Ok(()) } - pub fn get_conversation_by_id(&self, id: ConversationID) -> Result, microrm::Error> { - self.db.conversations - .by_id(id) - .map(|stored_conversation| stored_conversation - .map(|stored| stored.wrapped()) - ) + pub fn get_conversation_by_guid(&mut self, match_guid: &str) -> Result> { + use crate::schema::conversations::dsl::*; + use crate::schema::participants::dsl::*; + + let result = conversations + .find(match_guid) + .first::(&mut self.db) + .optional()?; + + if let Some(conversation) = result { + let dbParticipants = ConversationParticipant::belonging_to(&conversation) + .inner_join(participants) + .select(DbParticipant::as_select()) + .load::(&mut self.db)?; + + let mut modelConversation: Conversation = conversation.into(); + modelConversation.participants = dbParticipants.into_iter().map(|p| p.into()).collect(); + + return Ok(Some(modelConversation)); + } + + Ok(None) } - pub fn get_conversation_by_guid(&self, guid: &str) -> Result, microrm::Error> { - self.db.conversations - .with(Conversation::Guid, guid) - .get() - .and_then(|v| Ok(v - .into_iter() - .map(|c| c.wrapped()) - .last() - )) - } + pub fn all_conversations(&mut self) -> Result> { + use crate::schema::conversations::dsl::*; - pub fn all_conversations(&self) -> Result, microrm::Error> { - self.db.conversations - .get() - .map(|v| v - .into_iter() - .map(|c| c.wrapped()) - .collect() - ) - } + let result = conversations + .load::(&mut self.db)? + .into_iter() + .map(|c| c.into()) + .collect(); - fn upsert_participants(&self, participants: Vec) -> Vec { - // Filter existing participants and add to result - let existing_participants = participants.iter() - .filter_map(|p| self.db.participants - .with(Participant::DisplayName, &p.display_name) - .get() - .ok() - .and_then(|v| v - .into_iter() - .last() - .map(|p| p.id()) - ) - ) - .collect::>(); + // TODO: Need to resolve participants here also somehow... - participants.iter() - .map(|p| self.db.participants.insert(p.clone()).unwrap()) - .collect() - } - - fn stored_conversation_by_guid(&self, guid: &str) -> Result>, microrm::Error> { - self.db.conversations - .with(Conversation::Guid, guid) - .get() - .map(|v| v - .into_iter() - .last() - ) + Ok(result) } } \ No newline at end of file diff --git a/kordophone-db/src/lib.rs b/kordophone-db/src/lib.rs index 6eb6f99..f7623f7 100644 --- a/kordophone-db/src/lib.rs +++ b/kordophone-db/src/lib.rs @@ -1,5 +1,6 @@ pub mod models; pub mod chat_database; +pub mod schema; #[cfg(test)] mod tests { @@ -18,18 +19,19 @@ mod tests { #[test] fn test_add_conversation() { - let db = ChatDatabase::new_in_memory().unwrap(); + let mut db = ChatDatabase::new_in_memory().unwrap(); + let guid = "test"; let test_conversation = Conversation::builder() - .guid("test") + .guid(guid) .unread_count(2) .display_name("Test Conversation") .build(); - let id = db.insert_conversation(test_conversation.clone()).unwrap(); + db.insert_conversation(test_conversation.clone()).unwrap(); // Try to fetch with id now - let conversation = db.get_conversation_by_id(id).unwrap().unwrap(); + let conversation = db.get_conversation_by_guid(guid).unwrap().unwrap(); assert_eq!(conversation.guid, "test"); // Modify the conversation and update it @@ -44,38 +46,40 @@ mod tests { assert_eq!(all_conversations.len(), 1); // And make sure the display name was updated - let conversation = db.get_conversation_by_id(id).unwrap().unwrap(); + let conversation = db.get_conversation_by_guid(guid).unwrap().unwrap(); assert_eq!(conversation.display_name.unwrap(), "Modified Conversation"); } #[test] fn test_conversation_participants() { - let db = ChatDatabase::new_in_memory().unwrap(); + let mut db = ChatDatabase::new_in_memory().unwrap(); let participants: Vec = vec!["one".into(), "two".into()]; + let guid = uuid::Uuid::new_v4().to_string(); let conversation = ConversationBuilder::new() + .guid(&guid) .display_name("Test") - .participant_display_names(participants.clone()) + .participants(participants.clone()) .build(); - let id = db.insert_conversation(conversation).unwrap(); + db.insert_conversation(conversation).unwrap(); - let read_conversation = db.get_conversation_by_id(id).unwrap().unwrap(); - let read_participants: Vec = read_conversation.get_participant_display_names(); + let read_conversation = db.get_conversation_by_guid(&guid).unwrap().unwrap(); + let read_participants = read_conversation.participants; assert_eq!(participants, read_participants); // Try making another conversation with the same participants let conversation = ConversationBuilder::new() .display_name("A Different Test") - .participant_display_names(participants.clone()) + .participants(participants.clone()) .build(); - let id = db.insert_conversation(conversation).unwrap(); + db.insert_conversation(conversation).unwrap(); - let read_conversation = db.get_conversation_by_id(id).unwrap().unwrap(); - let read_participants: Vec = read_conversation.get_participant_display_names(); + let read_conversation = db.get_conversation_by_guid(&guid).unwrap().unwrap(); + let read_participants: Vec = read_conversation.participants; assert_eq!(participants, read_participants); } diff --git a/kordophone-db/src/models/conversation.rs b/kordophone-db/src/models/conversation.rs index 4fd2ff5..e227d00 100644 --- a/kordophone-db/src/models/conversation.rs +++ b/kordophone-db/src/models/conversation.rs @@ -1,26 +1,58 @@ -use microrm::prelude::*; -use microrm::Stored; -use time::OffsetDateTime; +use diesel::prelude::*; +use chrono::NaiveDateTime; use uuid::Uuid; use crate::models::{ - date::Date, participant::Participant, }; -use super::participant::ParticipantID; - -#[derive(Entity, Clone)] -pub struct Conversation { - #[unique] - pub guid: String, - +#[derive(Queryable, Selectable, Insertable, AsChangeset, Clone, Identifiable)] +#[diesel(table_name = crate::schema::conversations)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct DbConversation { + pub id: String, pub unread_count: i64, pub display_name: Option, pub last_message_preview: Option, - pub date: OffsetDateTime, - - participant_display_names: microrm::RelationMap, + pub date: NaiveDateTime, +} + +impl From for DbConversation { + fn from(conversation: Conversation) -> Self { + Self { + id: conversation.guid, + unread_count: conversation.unread_count as i64, + display_name: conversation.display_name, + last_message_preview: conversation.last_message_preview, + date: conversation.date, + } + } +} + +impl From for (DbConversation, Vec) { + fn from(conversation: Conversation) -> Self { + ( + DbConversation { + id: conversation.guid, + unread_count: conversation.unread_count as i64, + display_name: conversation.display_name, + last_message_preview: conversation.last_message_preview, + date: conversation.date, + }, + + conversation.participants + ) + } +} + +#[derive(Clone, Debug)] +pub struct Conversation { + pub guid: String, + pub unread_count: u16, + pub display_name: Option, + pub last_message_preview: Option, + pub date: NaiveDateTime, + pub participants: Vec, } impl Conversation { @@ -31,76 +63,35 @@ impl Conversation { pub fn into_builder(&self) -> ConversationBuilder { ConversationBuilder { guid: Some(self.guid.clone()), - date: Date::new(self.date), - participant_display_names: None, + date: self.date, + participants: None, unread_count: Some(self.unread_count), last_message_preview: self.last_message_preview.clone(), display_name: self.display_name.clone(), } } - - pub fn get_participant_display_names(&self) -> Vec { - self.participant_display_names - .get() - .unwrap() - .into_iter() - .map(|p| p.wrapped()) - .collect() - } - - pub fn update(&self, stored_conversation: &mut Stored) { - *stored_conversation.as_mut() = self.clone(); - } - - pub fn connect_participants(&self, participant_ids: Vec) { - participant_ids.iter().for_each(|id| { - self.participant_display_names.connect_to(*id).unwrap(); - }); - } } -#[derive(Clone)] -pub struct PendingConversation { - conversation: Conversation, - participants: Vec, -} - -impl PendingConversation { - pub fn guid(&self) -> &String { - &self.conversation.guid - } - - pub fn into_builder(self) -> ConversationBuilder { - ConversationBuilder { - guid: Some(self.conversation.guid), - date: Date::new(self.conversation.date), - participant_display_names: Some(self.participants), - unread_count: Some(self.conversation.unread_count), - last_message_preview: self.conversation.last_message_preview, - display_name: self.conversation.display_name, +impl From for Conversation { + fn from(db_conversation: DbConversation) -> Self { + Self { + guid: db_conversation.id, + unread_count: db_conversation.unread_count as u16, + display_name: db_conversation.display_name, + last_message_preview: db_conversation.last_message_preview, + date: db_conversation.date, + participants: vec![], } } - - pub fn update(&self, stored_conversation: &mut microrm::Stored) { - self.conversation.update(stored_conversation); - } - - pub fn get_participants(&self) -> &Vec { - &self.participants - } - - pub fn get_conversation(&self) -> Conversation { - self.conversation.clone() - } } #[derive(Default)] pub struct ConversationBuilder { guid: Option, - date: Date, - unread_count: Option, + date: NaiveDateTime, + unread_count: Option, last_message_preview: Option, - participant_display_names: Option>, + participants: Option>, display_name: Option, } @@ -114,12 +105,12 @@ impl ConversationBuilder { self } - pub fn date(mut self, date: Date) -> Self { + pub fn date(mut self, date: NaiveDateTime) -> Self { self.date = date; self } - pub fn unread_count(mut self, unread_count: i64) -> Self { + pub fn unread_count(mut self, unread_count: u16) -> Self { self.unread_count = Some(unread_count); self } @@ -129,8 +120,8 @@ impl ConversationBuilder { self } - pub fn participant_display_names(mut self, participant_display_names: Vec) -> Self { - self.participant_display_names = Some(participant_display_names); + pub fn participants(mut self, participants: Vec) -> Self { + self.participants = Some(participants); self } @@ -139,21 +130,14 @@ impl ConversationBuilder { self } - fn build_conversation(&self) -> Conversation { + pub fn build(&self) -> Conversation { Conversation { guid: self.guid.clone().unwrap_or(Uuid::new_v4().to_string()), unread_count: self.unread_count.unwrap_or(0), last_message_preview: self.last_message_preview.clone(), display_name: self.display_name.clone(), - date: self.date.dt, - participant_display_names: Default::default(), - } - } - - pub fn build(self) -> PendingConversation { - PendingConversation { - conversation: self.build_conversation(), - participants: self.participant_display_names.unwrap_or_default(), + date: self.date, + participants: self.participants.clone().unwrap_or_default(), } } } diff --git a/kordophone-db/src/models/date.rs b/kordophone-db/src/models/date.rs index b4ffea7..084769e 100644 --- a/kordophone-db/src/models/date.rs +++ b/kordophone-db/src/models/date.rs @@ -1,4 +1,3 @@ -use chrono::{DateTime, Local, Utc}; use time::OffsetDateTime; pub struct Date { diff --git a/kordophone-db/src/models/participant.rs b/kordophone-db/src/models/participant.rs index e5cabb4..89733d8 100644 --- a/kordophone-db/src/models/participant.rs +++ b/kordophone-db/src/models/participant.rs @@ -1,11 +1,35 @@ -use microrm::prelude::*; +use diesel::prelude::*; +use crate::{models::conversation::DbConversation, schema::conversation_participants}; -#[derive(Entity, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Insertable)] +#[diesel(table_name = crate::schema::participants)] pub struct Participant { - #[unique] + pub display_name: String, +} + +impl From for Participant { + fn from(participant: DbParticipant) -> Self { + Participant { display_name: participant.display_name } + } +} + +#[derive(Queryable, Selectable, Insertable, AsChangeset, Clone, PartialEq, Debug, Identifiable)] +#[diesel(table_name = crate::schema::participants)] +pub struct DbParticipant { + pub id: i32, pub display_name: String } +#[derive(Identifiable, Selectable, Queryable, Associations, Debug)] +#[diesel(belongs_to(DbConversation, foreign_key = conversation_id))] +#[diesel(belongs_to(DbParticipant, foreign_key = participant_id))] +#[diesel(table_name = conversation_participants)] +#[diesel(primary_key(conversation_id, participant_id))] +pub struct ConversationParticipant { + pub conversation_id: String, + pub participant_id: i32, +} + impl Into for String { fn into(self) -> Participant { Participant { display_name: self } diff --git a/kordophone-db/src/schema.rs b/kordophone-db/src/schema.rs new file mode 100644 index 0000000..19556cb --- /dev/null +++ b/kordophone-db/src/schema.rs @@ -0,0 +1,27 @@ +diesel::table! { + conversations (id) { + id -> Text, + unread_count -> BigInt, + display_name -> Nullable, + last_message_preview -> Nullable, + date -> Timestamp, + } +} + +diesel::table! { + participants (id) { + id -> Integer, + display_name -> Text, + } +} + +diesel::table! { + conversation_participants (conversation_id, participant_id) { + conversation_id -> Text, + participant_id -> Integer, + } +} + +diesel::joinable!(conversation_participants -> conversations (conversation_id)); +diesel::joinable!(conversation_participants -> participants (participant_id)); +diesel::allow_tables_to_appear_in_same_query!(conversations, participants, conversation_participants); From c4c6e4245dc4b7980f7f8c46c79296b17835b738 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 21 Dec 2024 16:34:47 -0800 Subject: [PATCH 015/138] chatdatabase: implements all_conversations with participant zippering --- kordophone-db/src/chat_database.rs | 21 ++++++++++++----- kordophone-db/src/lib.rs | 38 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/kordophone-db/src/chat_database.rs b/kordophone-db/src/chat_database.rs index 8d25f83..8c9f2bd 100644 --- a/kordophone-db/src/chat_database.rs +++ b/kordophone-db/src/chat_database.rs @@ -86,14 +86,23 @@ impl ChatDatabase { pub fn all_conversations(&mut self) -> Result> { use crate::schema::conversations::dsl::*; + use crate::schema::participants::dsl::*; - let result = conversations - .load::(&mut self.db)? - .into_iter() - .map(|c| c.into()) - .collect(); + let db_conversations = conversations + .load::(&mut self.db)?; - // TODO: Need to resolve participants here also somehow... + let mut result = Vec::new(); + for db_conversation in db_conversations { + let db_participants = ConversationParticipant::belonging_to(&db_conversation) + .inner_join(participants) + .select(DbParticipant::as_select()) + .load::(&mut self.db)?; + + let mut model_conversation: Conversation = db_conversation.into(); + model_conversation.participants = db_participants.into_iter().map(|p| p.into()).collect(); + + result.push(model_conversation); + } Ok(result) } diff --git a/kordophone-db/src/lib.rs b/kordophone-db/src/lib.rs index f7623f7..baa25fa 100644 --- a/kordophone-db/src/lib.rs +++ b/kordophone-db/src/lib.rs @@ -83,4 +83,42 @@ mod tests { assert_eq!(participants, read_participants); } + + #[test] + fn test_all_conversations_with_participants() { + let mut db = ChatDatabase::new_in_memory().unwrap(); + + // Create two conversations with different participants + let participants1: Vec = vec!["one".into(), "two".into()]; + let participants2: Vec = vec!["three".into(), "four".into()]; + + let guid1 = uuid::Uuid::new_v4().to_string(); + let conversation1 = ConversationBuilder::new() + .guid(&guid1) + .display_name("Test 1") + .participants(participants1.clone()) + .build(); + + let guid2 = uuid::Uuid::new_v4().to_string(); + let conversation2 = ConversationBuilder::new() + .guid(&guid2) + .display_name("Test 2") + .participants(participants2.clone()) + .build(); + + // Insert both conversations + db.insert_conversation(conversation1).unwrap(); + db.insert_conversation(conversation2).unwrap(); + + // Get all conversations and verify the results + let all_conversations = db.all_conversations().unwrap(); + assert_eq!(all_conversations.len(), 2); + + // Find and verify each conversation's participants + let conv1 = all_conversations.iter().find(|c| c.guid == guid1).unwrap(); + let conv2 = all_conversations.iter().find(|c| c.guid == guid2).unwrap(); + + assert_eq!(conv1.participants, participants1); + assert_eq!(conv2.participants, participants2); + } } From 8f523fd7fc9f44bb21901e0581e40ac670ac17f6 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 21 Dec 2024 17:09:37 -0800 Subject: [PATCH 016/138] reorg: separate db::records from insertable models --- kordophone-db/src/chat_database.rs | 38 +++++++------- kordophone-db/src/models/conversation.rs | 58 +-------------------- kordophone-db/src/models/db/conversation.rs | 52 ++++++++++++++++++ kordophone-db/src/models/db/mod.rs | 2 + kordophone-db/src/models/db/participant.rs | 37 +++++++++++++ kordophone-db/src/models/mod.rs | 7 ++- kordophone-db/src/models/participant.rs | 36 +++---------- 7 files changed, 123 insertions(+), 107 deletions(-) create mode 100644 kordophone-db/src/models/db/conversation.rs create mode 100644 kordophone-db/src/models/db/mod.rs create mode 100644 kordophone-db/src/models/db/participant.rs diff --git a/kordophone-db/src/chat_database.rs b/kordophone-db/src/chat_database.rs index 8c9f2bd..8f4f1eb 100644 --- a/kordophone-db/src/chat_database.rs +++ b/kordophone-db/src/chat_database.rs @@ -1,13 +1,15 @@ -use std::error::Error; use anyhow::Result; use diesel::prelude::*; use diesel::query_dsl::BelongingToDsl; -use crate::{models::{ - conversation::{ - self, Conversation, DbConversation - }, participant::{ConversationParticipant, DbParticipant, Participant} -}, schema}; +use crate::{ + models::{ + Conversation, + db::conversation::Record as ConversationRecord, + db::participant::{Record as ParticipantRecord, ConversationParticipant}, + }, + schema, +}; use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); @@ -22,9 +24,7 @@ impl ChatDatabase { db.run_pending_migrations(MIGRATIONS) .map_err(|e| anyhow::anyhow!("Error running migrations: {}", e))?; - return Ok(Self { - db: db, - }) + return Ok(Self { db }); } pub fn insert_conversation(&mut self, conversation: Conversation) -> Result<()> { @@ -66,19 +66,19 @@ impl ChatDatabase { let result = conversations .find(match_guid) - .first::(&mut self.db) + .first::(&mut self.db) .optional()?; if let Some(conversation) = result { - let dbParticipants = ConversationParticipant::belonging_to(&conversation) + let db_participants = ConversationParticipant::belonging_to(&conversation) .inner_join(participants) - .select(DbParticipant::as_select()) - .load::(&mut self.db)?; + .select(ParticipantRecord::as_select()) + .load::(&mut self.db)?; - let mut modelConversation: Conversation = conversation.into(); - modelConversation.participants = dbParticipants.into_iter().map(|p| p.into()).collect(); + let mut model_conversation: Conversation = conversation.into(); + model_conversation.participants = db_participants.into_iter().map(|p| p.into()).collect(); - return Ok(Some(modelConversation)); + return Ok(Some(model_conversation)); } Ok(None) @@ -89,14 +89,14 @@ impl ChatDatabase { use crate::schema::participants::dsl::*; let db_conversations = conversations - .load::(&mut self.db)?; + .load::(&mut self.db)?; let mut result = Vec::new(); for db_conversation in db_conversations { let db_participants = ConversationParticipant::belonging_to(&db_conversation) .inner_join(participants) - .select(DbParticipant::as_select()) - .load::(&mut self.db)?; + .select(ParticipantRecord::as_select()) + .load::(&mut self.db)?; let mut model_conversation: Conversation = db_conversation.into(); model_conversation.participants = db_participants.into_iter().map(|p| p.into()).collect(); diff --git a/kordophone-db/src/models/conversation.rs b/kordophone-db/src/models/conversation.rs index e227d00..415e151 100644 --- a/kordophone-db/src/models/conversation.rs +++ b/kordophone-db/src/models/conversation.rs @@ -1,49 +1,6 @@ -use diesel::prelude::*; use chrono::NaiveDateTime; use uuid::Uuid; - -use crate::models::{ - participant::Participant, -}; - -#[derive(Queryable, Selectable, Insertable, AsChangeset, Clone, Identifiable)] -#[diesel(table_name = crate::schema::conversations)] -#[diesel(check_for_backend(diesel::sqlite::Sqlite))] -pub struct DbConversation { - pub id: String, - pub unread_count: i64, - pub display_name: Option, - pub last_message_preview: Option, - pub date: NaiveDateTime, -} - -impl From for DbConversation { - fn from(conversation: Conversation) -> Self { - Self { - id: conversation.guid, - unread_count: conversation.unread_count as i64, - display_name: conversation.display_name, - last_message_preview: conversation.last_message_preview, - date: conversation.date, - } - } -} - -impl From for (DbConversation, Vec) { - fn from(conversation: Conversation) -> Self { - ( - DbConversation { - id: conversation.guid, - unread_count: conversation.unread_count as i64, - display_name: conversation.display_name, - last_message_preview: conversation.last_message_preview, - date: conversation.date, - }, - - conversation.participants - ) - } -} +use crate::models::participant::Participant; #[derive(Clone, Debug)] pub struct Conversation { @@ -72,19 +29,6 @@ impl Conversation { } } -impl From for Conversation { - fn from(db_conversation: DbConversation) -> Self { - Self { - guid: db_conversation.id, - unread_count: db_conversation.unread_count as u16, - display_name: db_conversation.display_name, - last_message_preview: db_conversation.last_message_preview, - date: db_conversation.date, - participants: vec![], - } - } -} - #[derive(Default)] pub struct ConversationBuilder { guid: Option, diff --git a/kordophone-db/src/models/db/conversation.rs b/kordophone-db/src/models/db/conversation.rs new file mode 100644 index 0000000..0ca384e --- /dev/null +++ b/kordophone-db/src/models/db/conversation.rs @@ -0,0 +1,52 @@ +use diesel::prelude::*; +use chrono::NaiveDateTime; +use crate::models::{Conversation, Participant}; + +#[derive(Queryable, Selectable, Insertable, AsChangeset, Clone, Identifiable)] +#[diesel(table_name = crate::schema::conversations)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct Record { + pub id: String, + pub unread_count: i64, + pub display_name: Option, + pub last_message_preview: Option, + pub date: NaiveDateTime, +} + +impl From for Record { + fn from(conversation: Conversation) -> Self { + Self { + id: conversation.guid, + unread_count: conversation.unread_count as i64, + display_name: conversation.display_name, + last_message_preview: conversation.last_message_preview, + date: conversation.date, + } + } +} + +// This implementation returns the insertable data types for the conversation and participants +impl From for (Record, Vec) { + fn from(conversation: Conversation) -> Self { + ( + Record::from(conversation.clone()), + + // Keep in mind, db::participant::Record is the selectable data type for the + // participants table, whereas Participant is the insertable model type. + conversation.participants + ) + } +} + +impl From for Conversation { + fn from(record: Record) -> Self { + Self { + guid: record.id, + unread_count: record.unread_count as u16, + display_name: record.display_name, + last_message_preview: record.last_message_preview, + date: record.date, + participants: vec![], + } + } +} \ No newline at end of file diff --git a/kordophone-db/src/models/db/mod.rs b/kordophone-db/src/models/db/mod.rs new file mode 100644 index 0000000..6c3c3f6 --- /dev/null +++ b/kordophone-db/src/models/db/mod.rs @@ -0,0 +1,2 @@ +pub mod conversation; +pub mod participant; diff --git a/kordophone-db/src/models/db/participant.rs b/kordophone-db/src/models/db/participant.rs new file mode 100644 index 0000000..2e4ead1 --- /dev/null +++ b/kordophone-db/src/models/db/participant.rs @@ -0,0 +1,37 @@ +use diesel::prelude::*; +use crate::models::Participant; +use crate::schema::conversation_participants; + +#[derive(Queryable, Selectable, AsChangeset, Clone, PartialEq, Debug, Identifiable)] +#[diesel(table_name = crate::schema::participants)] +pub struct Record { + pub id: i32, + pub display_name: String +} + +#[derive(Identifiable, Selectable, Queryable, Associations, Debug)] +#[diesel(belongs_to(super::conversation::Record, foreign_key = conversation_id))] +#[diesel(belongs_to(Record, foreign_key = participant_id))] +#[diesel(table_name = conversation_participants)] +#[diesel(primary_key(conversation_id, participant_id))] +pub struct ConversationParticipant { + pub conversation_id: String, + pub participant_id: i32, +} + +impl From for Participant { + fn from(record: Record) -> Self { + Participant { + display_name: record.display_name + } + } +} + +impl From for Record { + fn from(participant: Participant) -> Self { + Record { + id: 0, // This will be set by the database + display_name: participant.display_name, + } + } +} \ No newline at end of file diff --git a/kordophone-db/src/models/mod.rs b/kordophone-db/src/models/mod.rs index 8323539..4d76d37 100644 --- a/kordophone-db/src/models/mod.rs +++ b/kordophone-db/src/models/mod.rs @@ -1,3 +1,8 @@ pub mod conversation; +pub mod participant; pub mod date; -pub mod participant; \ No newline at end of file +pub mod db; + +// Re-export the public types +pub use conversation::Conversation; +pub use participant::Participant; \ No newline at end of file diff --git a/kordophone-db/src/models/participant.rs b/kordophone-db/src/models/participant.rs index 89733d8..349187a 100644 --- a/kordophone-db/src/models/participant.rs +++ b/kordophone-db/src/models/participant.rs @@ -1,5 +1,4 @@ -use diesel::prelude::*; -use crate::{models::conversation::DbConversation, schema::conversation_participants}; +use diesel::prelude::*; #[derive(Debug, Clone, PartialEq, Insertable)] #[diesel(table_name = crate::schema::participants)] @@ -7,37 +6,14 @@ pub struct Participant { pub display_name: String, } -impl From for Participant { - fn from(participant: DbParticipant) -> Self { - Participant { display_name: participant.display_name } - } -} - -#[derive(Queryable, Selectable, Insertable, AsChangeset, Clone, PartialEq, Debug, Identifiable)] -#[diesel(table_name = crate::schema::participants)] -pub struct DbParticipant { - pub id: i32, - pub display_name: String -} - -#[derive(Identifiable, Selectable, Queryable, Associations, Debug)] -#[diesel(belongs_to(DbConversation, foreign_key = conversation_id))] -#[diesel(belongs_to(DbParticipant, foreign_key = participant_id))] -#[diesel(table_name = conversation_participants)] -#[diesel(primary_key(conversation_id, participant_id))] -pub struct ConversationParticipant { - pub conversation_id: String, - pub participant_id: i32, -} - -impl Into for String { - fn into(self) -> Participant { - Participant { display_name: self } +impl From for Participant { + fn from(display_name: String) -> Self { + Participant { display_name } } } impl From<&str> for Participant { - fn from(s: &str) -> Self { - Participant { display_name: s.into() } + fn from(display_name: &str) -> Self { + Participant { display_name: display_name.to_string() } } } From ab44a169e6989ece7eb607a2e0a7236173377054 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 21 Dec 2024 17:50:47 -0800 Subject: [PATCH 017/138] reorg: move tests to separate module --- kordophone-db/src/lib.rs | 120 +-------------------------------- kordophone-db/src/tests/mod.rs | 117 ++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 119 deletions(-) create mode 100644 kordophone-db/src/tests/mod.rs diff --git a/kordophone-db/src/lib.rs b/kordophone-db/src/lib.rs index baa25fa..5ef901f 100644 --- a/kordophone-db/src/lib.rs +++ b/kordophone-db/src/lib.rs @@ -3,122 +3,4 @@ pub mod chat_database; pub mod schema; #[cfg(test)] -mod tests { - use crate::{ - chat_database::ChatDatabase, - models::{ - conversation::{Conversation, ConversationBuilder}, - participant::Participant - } - }; - - #[test] - fn test_database_init() { - let _ = ChatDatabase::new_in_memory().unwrap(); - } - - #[test] - fn test_add_conversation() { - let mut db = ChatDatabase::new_in_memory().unwrap(); - - let guid = "test"; - let test_conversation = Conversation::builder() - .guid(guid) - .unread_count(2) - .display_name("Test Conversation") - .build(); - - db.insert_conversation(test_conversation.clone()).unwrap(); - - // Try to fetch with id now - let conversation = db.get_conversation_by_guid(guid).unwrap().unwrap(); - assert_eq!(conversation.guid, "test"); - - // Modify the conversation and update it - let modified_conversation = test_conversation.into_builder() - .display_name("Modified Conversation") - .build(); - - db.insert_conversation(modified_conversation.clone()).unwrap(); - - // Make sure we still only have one conversation. - let all_conversations = db.all_conversations().unwrap(); - assert_eq!(all_conversations.len(), 1); - - // And make sure the display name was updated - let conversation = db.get_conversation_by_guid(guid).unwrap().unwrap(); - assert_eq!(conversation.display_name.unwrap(), "Modified Conversation"); - } - - #[test] - fn test_conversation_participants() { - let mut db = ChatDatabase::new_in_memory().unwrap(); - - let participants: Vec = vec!["one".into(), "two".into()]; - - let guid = uuid::Uuid::new_v4().to_string(); - let conversation = ConversationBuilder::new() - .guid(&guid) - .display_name("Test") - .participants(participants.clone()) - .build(); - - db.insert_conversation(conversation).unwrap(); - - let read_conversation = db.get_conversation_by_guid(&guid).unwrap().unwrap(); - let read_participants = read_conversation.participants; - - assert_eq!(participants, read_participants); - - // Try making another conversation with the same participants - let conversation = ConversationBuilder::new() - .display_name("A Different Test") - .participants(participants.clone()) - .build(); - - db.insert_conversation(conversation).unwrap(); - - let read_conversation = db.get_conversation_by_guid(&guid).unwrap().unwrap(); - let read_participants: Vec = read_conversation.participants; - - assert_eq!(participants, read_participants); - } - - #[test] - fn test_all_conversations_with_participants() { - let mut db = ChatDatabase::new_in_memory().unwrap(); - - // Create two conversations with different participants - let participants1: Vec = vec!["one".into(), "two".into()]; - let participants2: Vec = vec!["three".into(), "four".into()]; - - let guid1 = uuid::Uuid::new_v4().to_string(); - let conversation1 = ConversationBuilder::new() - .guid(&guid1) - .display_name("Test 1") - .participants(participants1.clone()) - .build(); - - let guid2 = uuid::Uuid::new_v4().to_string(); - let conversation2 = ConversationBuilder::new() - .guid(&guid2) - .display_name("Test 2") - .participants(participants2.clone()) - .build(); - - // Insert both conversations - db.insert_conversation(conversation1).unwrap(); - db.insert_conversation(conversation2).unwrap(); - - // Get all conversations and verify the results - let all_conversations = db.all_conversations().unwrap(); - assert_eq!(all_conversations.len(), 2); - - // Find and verify each conversation's participants - let conv1 = all_conversations.iter().find(|c| c.guid == guid1).unwrap(); - let conv2 = all_conversations.iter().find(|c| c.guid == guid2).unwrap(); - - assert_eq!(conv1.participants, participants1); - assert_eq!(conv2.participants, participants2); - } -} +mod tests; diff --git a/kordophone-db/src/tests/mod.rs b/kordophone-db/src/tests/mod.rs new file mode 100644 index 0000000..d29563d --- /dev/null +++ b/kordophone-db/src/tests/mod.rs @@ -0,0 +1,117 @@ +use crate::{ + chat_database::ChatDatabase, + models::{ + conversation::{Conversation, ConversationBuilder}, + participant::Participant + } +}; + +#[test] +fn test_database_init() { + let _ = ChatDatabase::new_in_memory().unwrap(); +} + +#[test] +fn test_add_conversation() { + let mut db = ChatDatabase::new_in_memory().unwrap(); + + let guid = "test"; + let test_conversation = Conversation::builder() + .guid(guid) + .unread_count(2) + .display_name("Test Conversation") + .build(); + + db.insert_conversation(test_conversation.clone()).unwrap(); + + // Try to fetch with id now + let conversation = db.get_conversation_by_guid(guid).unwrap().unwrap(); + assert_eq!(conversation.guid, "test"); + + // Modify the conversation and update it + let modified_conversation = test_conversation.into_builder() + .display_name("Modified Conversation") + .build(); + + db.insert_conversation(modified_conversation.clone()).unwrap(); + + // Make sure we still only have one conversation. + let all_conversations = db.all_conversations().unwrap(); + assert_eq!(all_conversations.len(), 1); + + // And make sure the display name was updated + let conversation = db.get_conversation_by_guid(guid).unwrap().unwrap(); + assert_eq!(conversation.display_name.unwrap(), "Modified Conversation"); +} + +#[test] +fn test_conversation_participants() { + let mut db = ChatDatabase::new_in_memory().unwrap(); + + let participants: Vec = vec!["one".into(), "two".into()]; + + let guid = uuid::Uuid::new_v4().to_string(); + let conversation = ConversationBuilder::new() + .guid(&guid) + .display_name("Test") + .participants(participants.clone()) + .build(); + + db.insert_conversation(conversation).unwrap(); + + let read_conversation = db.get_conversation_by_guid(&guid).unwrap().unwrap(); + let read_participants = read_conversation.participants; + + assert_eq!(participants, read_participants); + + // Try making another conversation with the same participants + let conversation = ConversationBuilder::new() + .display_name("A Different Test") + .participants(participants.clone()) + .build(); + + db.insert_conversation(conversation).unwrap(); + + let read_conversation = db.get_conversation_by_guid(&guid).unwrap().unwrap(); + let read_participants: Vec = read_conversation.participants; + + assert_eq!(participants, read_participants); +} + +#[test] +fn test_all_conversations_with_participants() { + let mut db = ChatDatabase::new_in_memory().unwrap(); + + // Create two conversations with different participants + let participants1: Vec = vec!["one".into(), "two".into()]; + let participants2: Vec = vec!["three".into(), "four".into()]; + + let guid1 = uuid::Uuid::new_v4().to_string(); + let conversation1 = ConversationBuilder::new() + .guid(&guid1) + .display_name("Test 1") + .participants(participants1.clone()) + .build(); + + let guid2 = uuid::Uuid::new_v4().to_string(); + let conversation2 = ConversationBuilder::new() + .guid(&guid2) + .display_name("Test 2") + .participants(participants2.clone()) + .build(); + + // Insert both conversations + db.insert_conversation(conversation1).unwrap(); + db.insert_conversation(conversation2).unwrap(); + + // Get all conversations and verify the results + let all_conversations = db.all_conversations().unwrap(); + assert_eq!(all_conversations.len(), 2); + + // Find and verify each conversation's participants + let conv1 = all_conversations.iter().find(|c| c.guid == guid1).unwrap(); + let conv2 = all_conversations.iter().find(|c| c.guid == guid2).unwrap(); + + assert_eq!(conv1.participants, participants1); + assert_eq!(conv2.participants, participants2); +} \ No newline at end of file From 53d4604b63d8bc1464d1ea02ff03633c839e4c44 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 21 Dec 2024 17:52:11 -0800 Subject: [PATCH 018/138] remove unused Date model --- kordophone-db/src/models/date.rs | 17 ----------------- kordophone-db/src/models/mod.rs | 1 - 2 files changed, 18 deletions(-) delete mode 100644 kordophone-db/src/models/date.rs diff --git a/kordophone-db/src/models/date.rs b/kordophone-db/src/models/date.rs deleted file mode 100644 index 084769e..0000000 --- a/kordophone-db/src/models/date.rs +++ /dev/null @@ -1,17 +0,0 @@ -use time::OffsetDateTime; - -pub struct Date { - pub dt: OffsetDateTime, -} - -impl Date { - pub fn new(dt: OffsetDateTime) -> Self { - Self { dt } - } -} - -impl Default for Date { - fn default() -> Self { - Self { dt: OffsetDateTime::now_utc() } - } -} \ No newline at end of file diff --git a/kordophone-db/src/models/mod.rs b/kordophone-db/src/models/mod.rs index 4d76d37..910f406 100644 --- a/kordophone-db/src/models/mod.rs +++ b/kordophone-db/src/models/mod.rs @@ -1,6 +1,5 @@ pub mod conversation; pub mod participant; -pub mod date; pub mod db; // Re-export the public types From 89f8d21ebb175b8b2a9ae93ea0438bce28b302aa Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 21 Dec 2024 18:01:00 -0800 Subject: [PATCH 019/138] clippy/cleanup --- kordophone-db/src/chat_database.rs | 2 +- kordophone-db/src/models/db/conversation.rs | 12 ++++++++---- kordophone-db/src/models/db/participant.rs | 16 +++++++++++++++- kordophone-db/src/models/mod.rs | 1 - kordophone-db/src/models/participant.rs | 5 +---- 5 files changed, 25 insertions(+), 11 deletions(-) diff --git a/kordophone-db/src/chat_database.rs b/kordophone-db/src/chat_database.rs index 8f4f1eb..71dc246 100644 --- a/kordophone-db/src/chat_database.rs +++ b/kordophone-db/src/chat_database.rs @@ -24,7 +24,7 @@ impl ChatDatabase { db.run_pending_migrations(MIGRATIONS) .map_err(|e| anyhow::anyhow!("Error running migrations: {}", e))?; - return Ok(Self { db }); + Ok(Self { db }) } pub fn insert_conversation(&mut self, conversation: Conversation) -> Result<()> { diff --git a/kordophone-db/src/models/db/conversation.rs b/kordophone-db/src/models/db/conversation.rs index 0ca384e..f1b37c5 100644 --- a/kordophone-db/src/models/db/conversation.rs +++ b/kordophone-db/src/models/db/conversation.rs @@ -1,6 +1,9 @@ use diesel::prelude::*; use chrono::NaiveDateTime; -use crate::models::{Conversation, Participant}; +use crate::models::{ + Conversation, + db::participant::InsertableRecord as InsertableParticipant, +}; #[derive(Queryable, Selectable, Insertable, AsChangeset, Clone, Identifiable)] #[diesel(table_name = crate::schema::conversations)] @@ -26,14 +29,15 @@ impl From for Record { } // This implementation returns the insertable data types for the conversation and participants -impl From for (Record, Vec) { +impl From for (Record, Vec) { fn from(conversation: Conversation) -> Self { ( Record::from(conversation.clone()), - // Keep in mind, db::participant::Record is the selectable data type for the - // participants table, whereas Participant is the insertable model type. conversation.participants + .into_iter() + .map(InsertableParticipant::from) + .collect() ) } } diff --git a/kordophone-db/src/models/db/participant.rs b/kordophone-db/src/models/db/participant.rs index 2e4ead1..ba6d50b 100644 --- a/kordophone-db/src/models/db/participant.rs +++ b/kordophone-db/src/models/db/participant.rs @@ -2,13 +2,27 @@ use diesel::prelude::*; use crate::models::Participant; use crate::schema::conversation_participants; -#[derive(Queryable, Selectable, AsChangeset, Clone, PartialEq, Debug, Identifiable)] +#[derive(Queryable, Selectable, AsChangeset, Identifiable)] #[diesel(table_name = crate::schema::participants)] pub struct Record { pub id: i32, pub display_name: String } +#[derive(Insertable)] +#[diesel(table_name = crate::schema::participants)] +pub struct InsertableRecord { + pub display_name: String +} + +impl From for InsertableRecord { + fn from(participant: Participant) -> Self { + InsertableRecord { + display_name: participant.display_name + } + } +} + #[derive(Identifiable, Selectable, Queryable, Associations, Debug)] #[diesel(belongs_to(super::conversation::Record, foreign_key = conversation_id))] #[diesel(belongs_to(Record, foreign_key = participant_id))] diff --git a/kordophone-db/src/models/mod.rs b/kordophone-db/src/models/mod.rs index 910f406..f0b8b0c 100644 --- a/kordophone-db/src/models/mod.rs +++ b/kordophone-db/src/models/mod.rs @@ -2,6 +2,5 @@ pub mod conversation; pub mod participant; pub mod db; -// Re-export the public types pub use conversation::Conversation; pub use participant::Participant; \ No newline at end of file diff --git a/kordophone-db/src/models/participant.rs b/kordophone-db/src/models/participant.rs index 349187a..9528e2e 100644 --- a/kordophone-db/src/models/participant.rs +++ b/kordophone-db/src/models/participant.rs @@ -1,7 +1,4 @@ -use diesel::prelude::*; - -#[derive(Debug, Clone, PartialEq, Insertable)] -#[diesel(table_name = crate::schema::participants)] +#[derive(Debug, Clone, PartialEq)] pub struct Participant { pub display_name: String, } From 793faab7219da2a1cd7ea26867e84d31a1c3ca45 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 8 Jan 2025 13:32:55 -0800 Subject: [PATCH 020/138] kpcli: adds 'db' subcommand for interacting with the database --- Cargo.lock | 3 + kordophone-db/Cargo.toml | 1 + kordophone-db/src/chat_database.rs | 9 ++- kordophone-db/src/lib.rs | 2 + kordophone-db/src/models/conversation.rs | 25 ++++++- kpcli/Cargo.toml | 2 + kpcli/src/client/mod.rs | 43 ++++++------ kpcli/src/db/mod.rs | 83 ++++++++++++++++++++++++ kpcli/src/main.rs | 14 +++- kpcli/src/printers.rs | 44 +++++++++++-- 10 files changed, 197 insertions(+), 29 deletions(-) create mode 100644 kpcli/src/db/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 07d270e..b5107df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -702,6 +702,7 @@ dependencies = [ "chrono", "diesel", "diesel_migrations", + "kordophone", "serde", "time", "uuid", @@ -715,8 +716,10 @@ dependencies = [ "clap", "dotenv", "kordophone", + "kordophone-db", "log", "pretty", + "time", "tokio", ] diff --git a/kordophone-db/Cargo.toml b/kordophone-db/Cargo.toml index 1fd691e..626cc7e 100644 --- a/kordophone-db/Cargo.toml +++ b/kordophone-db/Cargo.toml @@ -8,6 +8,7 @@ anyhow = "1.0.94" chrono = "0.4.38" diesel = { version = "2.2.6", features = ["chrono", "sqlite", "time"] } diesel_migrations = { version = "2.2.0", features = ["sqlite"] } +kordophone = { path = "../kordophone" } serde = { version = "1.0.215", features = ["derive"] } time = "0.3.37" uuid = { version = "1.11.0", features = ["v4"] } diff --git a/kordophone-db/src/chat_database.rs b/kordophone-db/src/chat_database.rs index 71dc246..380659d 100644 --- a/kordophone-db/src/chat_database.rs +++ b/kordophone-db/src/chat_database.rs @@ -1,6 +1,7 @@ use anyhow::Result; -use diesel::prelude::*; +use diesel::{prelude::*, sqlite::Sqlite}; use diesel::query_dsl::BelongingToDsl; +use std::path::{Path, PathBuf}; use crate::{ models::{ @@ -20,7 +21,11 @@ pub struct ChatDatabase { impl ChatDatabase { pub fn new_in_memory() -> Result { - let mut db = SqliteConnection::establish(":memory:")?; + Self::new(":memory:") + } + + pub fn new(db_path: &str) -> Result { + let mut db = SqliteConnection::establish(db_path)?; db.run_pending_migrations(MIGRATIONS) .map_err(|e| anyhow::anyhow!("Error running migrations: {}", e))?; diff --git a/kordophone-db/src/lib.rs b/kordophone-db/src/lib.rs index 5ef901f..3ca1345 100644 --- a/kordophone-db/src/lib.rs +++ b/kordophone-db/src/lib.rs @@ -4,3 +4,5 @@ pub mod schema; #[cfg(test)] mod tests; + +pub use chat_database::ChatDatabase; \ No newline at end of file diff --git a/kordophone-db/src/models/conversation.rs b/kordophone-db/src/models/conversation.rs index 415e151..85a9a96 100644 --- a/kordophone-db/src/models/conversation.rs +++ b/kordophone-db/src/models/conversation.rs @@ -1,4 +1,4 @@ -use chrono::NaiveDateTime; +use chrono::{DateTime, NaiveDateTime}; use uuid::Uuid; use crate::models::participant::Participant; @@ -29,6 +29,29 @@ impl Conversation { } } +impl From for Conversation { + fn from(value: kordophone::model::Conversation) -> Self { + Self { + guid: value.guid, + unread_count: u16::try_from(value.unread_count).unwrap(), + display_name: value.display_name, + last_message_preview: value.last_message_preview, + date: DateTime::from_timestamp( + value.date.unix_timestamp(), + value.date.unix_timestamp_nanos() + .try_into() + .unwrap_or(0), + ) + .unwrap() + .naive_local(), + participants: value.participant_display_names + .into_iter() + .map(|p| p.into()) + .collect(), + } + } +} + #[derive(Default)] pub struct ConversationBuilder { guid: Option, diff --git a/kpcli/Cargo.toml b/kpcli/Cargo.toml index 20f4d4d..760462e 100644 --- a/kpcli/Cargo.toml +++ b/kpcli/Cargo.toml @@ -10,6 +10,8 @@ anyhow = "1.0.93" clap = { version = "4.5.20", features = ["derive"] } dotenv = "0.15.0" kordophone = { path = "../kordophone" } +kordophone-db = { path = "../kordophone-db" } log = "0.4.22" pretty = { version = "0.12.3", features = ["termcolor"] } +time = "0.3.37" tokio = "1.41.1" diff --git a/kpcli/src/client/mod.rs b/kpcli/src/client/mod.rs index 8e35fa5..55e6333 100644 --- a/kpcli/src/client/mod.rs +++ b/kpcli/src/client/mod.rs @@ -3,9 +3,28 @@ use kordophone::api::http_client::HTTPAPIClient; use kordophone::api::http_client::Credentials; use dotenv; +use anyhow::Result; use clap::Subcommand; use crate::printers::ConversationPrinter; +pub fn make_api_client_from_env() -> HTTPAPIClient { + dotenv::dotenv().ok(); + + // read from env + let base_url = std::env::var("KORDOPHONE_API_URL") + .expect("KORDOPHONE_API_URL must be set"); + + let credentials = Credentials { + username: std::env::var("KORDOPHONE_USERNAME") + .expect("KORDOPHONE_USERNAME must be set"), + + password: std::env::var("KORDOPHONE_PASSWORD") + .expect("KORDOPHONE_PASSWORD must be set"), + }; + + HTTPAPIClient::new(base_url.parse().unwrap(), credentials.into()) +} + #[derive(Subcommand)] pub enum Commands { /// Prints all known conversations on the server. @@ -16,7 +35,7 @@ pub enum Commands { } impl Commands { - pub async fn run(cmd: Commands) -> Result<(), Box> { + pub async fn run(cmd: Commands) -> Result<()> { let mut client = ClientCli::new(); match cmd { Commands::Version => client.print_version().await, @@ -31,34 +50,20 @@ struct ClientCli { impl ClientCli { pub fn new() -> Self { - dotenv::dotenv().ok(); - - // read from env - let base_url = std::env::var("KORDOPHONE_API_URL") - .expect("KORDOPHONE_API_URL must be set"); - - let credentials = Credentials { - username: std::env::var("KORDOPHONE_USERNAME") - .expect("KORDOPHONE_USERNAME must be set"), - - password: std::env::var("KORDOPHONE_PASSWORD") - .expect("KORDOPHONE_PASSWORD must be set"), - }; - - let api = HTTPAPIClient::new(base_url.parse().unwrap(), credentials.into()); + let api = make_api_client_from_env(); Self { api: api } } - pub async fn print_version(&mut self) -> Result<(), Box> { + pub async fn print_version(&mut self) -> Result<()> { let version = self.api.get_version().await?; println!("Version: {}", version); Ok(()) } - pub async fn print_conversations(&mut self) -> Result<(), Box> { + pub async fn print_conversations(&mut self) -> Result<()> { let conversations = self.api.get_conversations().await?; for conversation in conversations { - println!("{}", ConversationPrinter::new(&conversation)); + println!("{}", ConversationPrinter::new(&conversation.into())); } Ok(()) diff --git a/kpcli/src/db/mod.rs b/kpcli/src/db/mod.rs new file mode 100644 index 0000000..36b68ea --- /dev/null +++ b/kpcli/src/db/mod.rs @@ -0,0 +1,83 @@ +use anyhow::Result; +use clap::Subcommand; +use kordophone::APIInterface; +use std::{env, path::{Path, PathBuf}}; + +use kordophone_db::ChatDatabase; +use crate::{client, printers::ConversationPrinter}; + +#[derive(Subcommand)] +pub enum Commands { + /// For dealing with the table of cached conversations. + Conversations { + #[clap(subcommand)] + command: ConversationCommands + }, +} + +#[derive(Subcommand)] +pub enum ConversationCommands { + /// Lists all conversations currently in the database. + List, + + /// Syncs with an API client. + Sync, +} + +impl Commands { + pub async fn run(cmd: Commands) -> Result<()> { + let mut db = DbClient::new()?; + match cmd { + Commands::Conversations { command: cmd } => match cmd { + ConversationCommands::List => db.print_conversations(), + ConversationCommands::Sync => db.sync_with_client().await, + }, + } + } +} + +struct DbClient { + db: ChatDatabase +} + +impl DbClient { + fn database_path() -> PathBuf { + let temp_dir = env::temp_dir(); + temp_dir.join("kpcli_chat.db") + } + + pub fn new() -> Result { + let path = Self::database_path(); + let path_str: &str = path.as_path().to_str().unwrap(); + + println!("kpcli: Using temporary db at {}", path_str); + + let db = ChatDatabase::new(path_str)?; + Ok( Self { db }) + } + + pub fn print_conversations(&mut self) -> Result<()> { + let all_conversations = self.db.all_conversations()?; + + println!("{} Conversations: ", all_conversations.len()); + for conversation in all_conversations { + println!("{}", ConversationPrinter::new(&conversation.into())); + } + + Ok(()) + } + + pub async fn sync_with_client(&mut self) -> Result<()> { + let mut client = client::make_api_client_from_env(); + let fetched_conversations = client.get_conversations().await?; + let db_conversations: Vec = fetched_conversations.into_iter() + .map(|c| kordophone_db::models::Conversation::from(c)) + .collect(); + + for conversation in db_conversations { + self.db.insert_conversation(conversation)?; + } + + Ok(()) + } +} diff --git a/kpcli/src/main.rs b/kpcli/src/main.rs index c668088..628a112 100644 --- a/kpcli/src/main.rs +++ b/kpcli/src/main.rs @@ -1,5 +1,8 @@ +mod client; +mod db; mod printers; -mod client; + +use anyhow::Result; use clap::{Parser, Subcommand}; /// A command line interface for the Kordophone library and daemon @@ -17,11 +20,18 @@ enum Commands { #[command(subcommand)] command: client::Commands, }, + + /// Commands for the cache database + Db { + #[command(subcommand)] + command: db::Commands, + } } -async fn run_command(command: Commands) -> Result<(), Box> { +async fn run_command(command: Commands) -> Result<()> { match command { Commands::Client { command } => client::Commands::run(command).await, + Commands::Db { command } => db::Commands::run(command).await, } } diff --git a/kpcli/src/printers.rs b/kpcli/src/printers.rs index 2ed1bb0..37b73e8 100644 --- a/kpcli/src/printers.rs +++ b/kpcli/src/printers.rs @@ -1,14 +1,48 @@ use std::fmt::Display; - +use time::OffsetDateTime; use pretty::RcDoc; -use kordophone::model::Conversation; + +pub struct PrintableConversation { + pub guid: String, + pub date: OffsetDateTime, + pub unread_count: i32, + pub last_message_preview: Option, + pub participants: Vec, + pub display_name: Option, +} + +impl From for PrintableConversation { + fn from(value: kordophone::model::Conversation) -> Self { + Self { + guid: value.guid, + date: value.date, + unread_count: value.unread_count, + last_message_preview: value.last_message_preview, + participants: value.participant_display_names, + display_name: value.display_name, + } + } +} + +impl From for PrintableConversation { + fn from(value: kordophone_db::models::Conversation) -> Self { + Self { + guid: value.guid, + date: OffsetDateTime::from_unix_timestamp(value.date.and_utc().timestamp()).unwrap(), + unread_count: value.unread_count.into(), + last_message_preview: value.last_message_preview, + participants: value.participants.into_iter().map(|p| p.display_name).collect(), + display_name: value.display_name, + } + } +} pub struct ConversationPrinter<'a> { - doc: RcDoc<'a, Conversation> + doc: RcDoc<'a, PrintableConversation> } impl<'a> ConversationPrinter<'a> { - pub fn new(conversation: &'a Conversation) -> Self { + pub fn new(conversation: &'a PrintableConversation) -> Self { let preview = conversation.last_message_preview .as_deref() .unwrap_or("") @@ -27,7 +61,7 @@ impl<'a> ConversationPrinter<'a> { .append("[") .append(RcDoc::line() .append( - conversation.participant_display_names + conversation.participants .iter() .map(|name| RcDoc::text(name) From a8104c379c620cde922128daac4d3bd5e0b3fff8 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 20 Jan 2025 19:43:21 -0800 Subject: [PATCH 021/138] kordophone: add support for /messages --- kordophone/src/api/http_client.rs | 25 ++++++++++- kordophone/src/api/mod.rs | 7 ++- kordophone/src/model/conversation.rs | 12 +++++ kordophone/src/model/message.rs | 67 ++++++++++++++++++++++++++++ kordophone/src/model/mod.rs | 12 ++++- kordophone/src/tests/test_client.rs | 20 ++++++++- kpcli/src/client/mod.rs | 16 ++++++- kpcli/src/printers.rs | 53 ++++++++++++++++++++++ 8 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 kordophone/src/model/message.rs diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index eb1a5bc..c1e8def 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -9,7 +9,10 @@ use hyper::{Body, Client, Method, Request, Uri}; use async_trait::async_trait; use serde::{de::DeserializeOwned, Deserialize, Serialize}; -use crate::{model::{Conversation, JwtToken}, APIInterface}; +use crate::{ + model::{Conversation, ConversationID, JwtToken, Message}, + APIInterface +}; type HttpClient = Client; @@ -111,6 +114,12 @@ impl APIInterface for HTTPAPIClient { self.auth_token = Some(token.clone()); Ok(token) } + + async fn get_messages(&mut self, conversation_id: &ConversationID) -> Result, Self::Error> { + let endpoint = format!("messages?guid={}", conversation_id); + let messages: Vec = self.request(&endpoint, Method::GET).await?; + Ok(messages) + } } impl HTTPAPIClient { @@ -261,4 +270,18 @@ mod test { let conversations = client.get_conversations().await.unwrap(); assert!(!conversations.is_empty()); } + + #[tokio::test] + async fn test_messages() { + if !mock_client_is_reachable().await { + log::warn!("Skipping http_client tests (mock server not reachable)"); + return; + } + + let mut client = local_mock_client(); + let conversations = client.get_conversations().await.unwrap(); + let conversation = conversations.first().unwrap(); + let messages = client.get_messages(&conversation.guid).await.unwrap(); + assert!(!messages.is_empty()); + } } diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index 0cd5c99..581f35a 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -1,5 +1,7 @@ use async_trait::async_trait; -pub use crate::model::Conversation; +pub use crate::model::{ + Conversation, Message, ConversationID +}; use crate::model::JwtToken; pub mod http_client; @@ -17,6 +19,9 @@ pub trait APIInterface { // (GET) /conversations async fn get_conversations(&mut self) -> Result, Self::Error>; + // (GET) /messages + async fn get_messages(&mut self, conversation_id: &ConversationID) -> Result, Self::Error>; + // (POST) /authenticate async fn authenticate(&mut self, credentials: Credentials) -> Result; } diff --git a/kordophone/src/model/conversation.rs b/kordophone/src/model/conversation.rs index 96d2f0d..f7b74c3 100644 --- a/kordophone/src/model/conversation.rs +++ b/kordophone/src/model/conversation.rs @@ -2,6 +2,10 @@ use serde::Deserialize; use time::OffsetDateTime; use uuid::Uuid; +use super::Identifiable; + +pub type ConversationID = ::ID; + #[derive(Debug, Clone, Deserialize)] pub struct Conversation { pub guid: String, @@ -28,6 +32,14 @@ impl Conversation { } } +impl Identifiable for Conversation { + type ID = String; + + fn id(&self) -> &Self::ID { + &self.guid + } +} + #[derive(Default)] pub struct ConversationBuilder { guid: Option, diff --git a/kordophone/src/model/message.rs b/kordophone/src/model/message.rs new file mode 100644 index 0000000..1420536 --- /dev/null +++ b/kordophone/src/model/message.rs @@ -0,0 +1,67 @@ +use serde::Deserialize; +use time::OffsetDateTime; +use uuid::Uuid; + +#[derive(Debug, Clone, Deserialize)] +pub struct Message { + pub guid: String, + + #[serde(rename = "text")] + pub text: String, + + #[serde(rename = "sender")] + pub sender: Option, + + #[serde(with = "time::serde::iso8601")] + pub date: OffsetDateTime, +} + +impl Message { + pub fn builder() -> MessageBuilder { + MessageBuilder::new() + } +} + +#[derive(Default)] +pub struct MessageBuilder { + guid: Option, + text: Option, + sender: Option, + date: Option, +} + +impl MessageBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn guid(mut self, guid: String) -> Self { + self.guid = Some(guid); + self + } + + pub fn text(mut self, text: String) -> Self { + self.text = Some(text); + self + } + + pub fn sender(mut self, sender: String) -> Self { + self.sender = Some(sender); + self + } + + pub fn date(mut self, date: OffsetDateTime) -> Self { + self.date = Some(date); + self + } + + pub fn build(self) -> Message { + Message { + guid: self.guid.unwrap_or(Uuid::new_v4().to_string()), + text: self.text.unwrap_or("".to_string()), + sender: self.sender, + date: self.date.unwrap_or(OffsetDateTime::now_utc()), + } + } +} + diff --git a/kordophone/src/model/mod.rs b/kordophone/src/model/mod.rs index 92607cd..d1c848e 100644 --- a/kordophone/src/model/mod.rs +++ b/kordophone/src/model/mod.rs @@ -1,5 +1,15 @@ pub mod conversation; +pub mod message; + pub use conversation::Conversation; +pub use conversation::ConversationID; + +pub use message::Message; pub mod jwt; -pub use jwt::JwtToken; \ No newline at end of file +pub use jwt::JwtToken; + +pub trait Identifiable { + type ID; + fn id(&self) -> &Self::ID; +} \ No newline at end of file diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index 375a449..6f44d6b 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -1,21 +1,29 @@ use async_trait::async_trait; +use std::collections::HashMap; pub use crate::APIInterface; -use crate::{api::http_client::Credentials, model::{Conversation, JwtToken}}; +use crate::{ + api::http_client::Credentials, + model::{conversation, Conversation, ConversationID, JwtToken, Message} +}; pub struct TestClient { pub version: &'static str, pub conversations: Vec, + pub messages: HashMap>, } #[derive(Debug)] -pub enum TestError {} +pub enum TestError { + ConversationNotFound, +} impl TestClient { pub fn new() -> TestClient { TestClient { version: "KordophoneTest-1.0", conversations: vec![], + messages: HashMap::>::new(), } } } @@ -35,4 +43,12 @@ impl APIInterface for TestClient { async fn get_conversations(&mut self) -> Result, Self::Error> { Ok(self.conversations.clone()) } + + async fn get_messages(&mut self, conversation: Conversation) -> Result, Self::Error> { + if let Some(messages) = self.messages.get(&conversation.guid) { + return Ok(messages.clone()) + } + + Err(TestError::ConversationNotFound) + } } diff --git a/kpcli/src/client/mod.rs b/kpcli/src/client/mod.rs index 55e6333..671db2f 100644 --- a/kpcli/src/client/mod.rs +++ b/kpcli/src/client/mod.rs @@ -5,7 +5,7 @@ use kordophone::api::http_client::Credentials; use dotenv; use anyhow::Result; use clap::Subcommand; -use crate::printers::ConversationPrinter; +use crate::printers::{ConversationPrinter, MessagePrinter}; pub fn make_api_client_from_env() -> HTTPAPIClient { dotenv::dotenv().ok(); @@ -30,6 +30,11 @@ pub enum Commands { /// Prints all known conversations on the server. Conversations, + /// Prints all messages in a conversation. + Messages { + conversation_id: String, + }, + /// Prints the server Kordophone version. Version, } @@ -40,6 +45,7 @@ impl Commands { match cmd { Commands::Version => client.print_version().await, Commands::Conversations => client.print_conversations().await, + Commands::Messages { conversation_id } => client.print_messages(conversation_id).await, } } } @@ -68,6 +74,14 @@ impl ClientCli { Ok(()) } + + pub async fn print_messages(&mut self, conversation_id: String) -> Result<()> { + let messages = self.api.get_messages(&conversation_id).await?; + for message in messages { + println!("{}", MessagePrinter::new(&message.into())); + } + Ok(()) + } } diff --git a/kpcli/src/printers.rs b/kpcli/src/printers.rs index 37b73e8..7ae3b48 100644 --- a/kpcli/src/printers.rs +++ b/kpcli/src/printers.rs @@ -37,6 +37,24 @@ impl From for PrintableConversation { } } +pub struct PrintableMessage { + pub guid: String, + pub date: OffsetDateTime, + pub sender: String, + pub text: String, +} + +impl From for PrintableMessage { + fn from(value: kordophone::model::Message) -> Self { + Self { + guid: value.guid, + date: value.date, + sender: value.sender.unwrap_or("".to_string()), + text: value.text, + } + } +} + pub struct ConversationPrinter<'a> { doc: RcDoc<'a, PrintableConversation> } @@ -56,6 +74,9 @@ impl<'a> ConversationPrinter<'a> { .append(RcDoc::line()) .append("Date: ") .append(conversation.date.to_string()) + .append(RcDoc::line()) + .append("Unread Count: ") + .append(conversation.unread_count.to_string()) .append(RcDoc::line()) .append("Participants: ") .append("[") @@ -89,4 +110,36 @@ impl<'a> Display for ConversationPrinter<'a> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.doc.render_fmt(180, f) } +} + +pub struct MessagePrinter<'a> { + doc: RcDoc<'a, PrintableMessage> +} + +impl<'a> Display for MessagePrinter<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.doc.render_fmt(180, f) + } +} + +impl<'a> MessagePrinter<'a> { + pub fn new(message: &'a PrintableMessage) -> Self { + let doc = RcDoc::text(format!(""); + + MessagePrinter { doc } + } } \ No newline at end of file From 146fac2759319c473edaddbd2f39aa5e64ee7075 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 20 Jan 2025 22:05:34 -0800 Subject: [PATCH 022/138] kordophone-db: adds support for the Messages table --- .../down.sql | 4 +- .../up.sql | 24 +++- kordophone-db/src/chat_database.rs | 103 ++++++++++++++- kordophone-db/src/models/db/message.rs | 40 ++++++ kordophone-db/src/models/db/mod.rs | 1 + kordophone-db/src/models/db/participant.rs | 40 ++++-- kordophone-db/src/models/message.rs | 84 +++++++++++++ kordophone-db/src/models/mod.rs | 4 +- kordophone-db/src/models/participant.rs | 27 +++- kordophone-db/src/schema.rs | 26 +++- kordophone-db/src/tests/mod.rs | 119 +++++++++++++++++- 11 files changed, 444 insertions(+), 28 deletions(-) rename kordophone-db/migrations/{2024-12-15-030301_create_conversations => 2025-01-21-051154_create_conversations}/down.sql (68%) rename kordophone-db/migrations/{2024-12-15-030301_create_conversations => 2025-01-21-051154_create_conversations}/up.sql (56%) create mode 100644 kordophone-db/src/models/db/message.rs create mode 100644 kordophone-db/src/models/message.rs diff --git a/kordophone-db/migrations/2024-12-15-030301_create_conversations/down.sql b/kordophone-db/migrations/2025-01-21-051154_create_conversations/down.sql similarity index 68% rename from kordophone-db/migrations/2024-12-15-030301_create_conversations/down.sql rename to kordophone-db/migrations/2025-01-21-051154_create_conversations/down.sql index 40e3b94..34cefce 100644 --- a/kordophone-db/migrations/2024-12-15-030301_create_conversations/down.sql +++ b/kordophone-db/migrations/2025-01-21-051154_create_conversations/down.sql @@ -1,4 +1,6 @@ -- This file should undo anything in `up.sql` -DROP TABLE IF EXISTS `participants`; DROP TABLE IF EXISTS `conversation_participants`; +DROP TABLE IF EXISTS `messages`; +DROP TABLE IF EXISTS `conversation_messages`; +DROP TABLE IF EXISTS `participants`; DROP TABLE IF EXISTS `conversations`; diff --git a/kordophone-db/migrations/2024-12-15-030301_create_conversations/up.sql b/kordophone-db/migrations/2025-01-21-051154_create_conversations/up.sql similarity index 56% rename from kordophone-db/migrations/2024-12-15-030301_create_conversations/up.sql rename to kordophone-db/migrations/2025-01-21-051154_create_conversations/up.sql index 9ac80cd..69c3b8b 100644 --- a/kordophone-db/migrations/2024-12-15-030301_create_conversations/up.sql +++ b/kordophone-db/migrations/2025-01-21-051154_create_conversations/up.sql @@ -1,15 +1,29 @@ -- Your SQL goes here -CREATE TABLE `participants`( - `id` INTEGER NOT NULL PRIMARY KEY, - `display_name` TEXT NOT NULL -); - CREATE TABLE `conversation_participants`( `conversation_id` TEXT NOT NULL, `participant_id` INTEGER NOT NULL, PRIMARY KEY(`conversation_id`, `participant_id`) ); +CREATE TABLE `messages`( + `id` TEXT NOT NULL PRIMARY KEY, + `text` TEXT NOT NULL, + `sender_participant_id` INTEGER, + `date` TIMESTAMP NOT NULL +); + +CREATE TABLE `conversation_messages`( + `conversation_id` TEXT NOT NULL, + `message_id` TEXT NOT NULL, + PRIMARY KEY(`conversation_id`, `message_id`) +); + +CREATE TABLE `participants`( + `id` INTEGER NOT NULL PRIMARY KEY, + `display_name` TEXT, + `is_me` BOOL NOT NULL +); + CREATE TABLE `conversations`( `id` TEXT NOT NULL PRIMARY KEY, `unread_count` BIGINT NOT NULL, diff --git a/kordophone-db/src/chat_database.rs b/kordophone-db/src/chat_database.rs index 380659d..1e310fb 100644 --- a/kordophone-db/src/chat_database.rs +++ b/kordophone-db/src/chat_database.rs @@ -3,11 +3,18 @@ use diesel::{prelude::*, sqlite::Sqlite}; use diesel::query_dsl::BelongingToDsl; use std::path::{Path, PathBuf}; +use crate::models::Participant; use crate::{ models::{ Conversation, + Message, db::conversation::Record as ConversationRecord, - db::participant::{Record as ParticipantRecord, ConversationParticipant}, + db::participant::{ + ConversationParticipant, + Record as ParticipantRecord, + InsertableRecord as InsertableParticipantRecord + }, + db::message::Record as MessageRecord, }, schema, }; @@ -24,6 +31,14 @@ impl ChatDatabase { Self::new(":memory:") } + // Helper function to get the last inserted row ID + // This is a workaround since the Sqlite backend doesn't support `RETURNING` + // Huge caveat with this is that it depends on whatever the last insert was, prevents concurrent inserts. + fn last_insert_id(&mut self) -> Result { + Ok(diesel::select(diesel::dsl::sql::("last_insert_rowid()")) + .get_result(&mut self.db)?) + } + pub fn new(db_path: &str) -> Result { let mut db = SqliteConnection::establish(db_path)?; db.run_pending_migrations(MIGRATIONS) @@ -111,4 +126,88 @@ impl ChatDatabase { Ok(result) } -} \ No newline at end of file + + pub fn insert_message(&mut self, conversation_guid: &str, message: Message) -> Result<()> { + use crate::schema::messages::dsl::*; + use crate::schema::conversation_messages::dsl::*; + + // Handle participant if message has a remote sender + let sender = message.sender.clone(); + let mut db_message: MessageRecord = message.into(); + db_message.sender_participant_id = self.get_or_create_participant(&sender); + + diesel::replace_into(messages) + .values(&db_message) + .execute(&mut self.db)?; + + diesel::replace_into(conversation_messages) + .values(( + conversation_id.eq(conversation_guid), + message_id.eq(&db_message.id), + )) + .execute(&mut self.db)?; + + Ok(()) + } + + pub fn get_messages_for_conversation(&mut self, conversation_guid: &str) -> Result> { + use crate::schema::messages::dsl::*; + use crate::schema::conversation_messages::dsl::*; + use crate::schema::participants::dsl::*; + + let message_records = conversation_messages + .filter(conversation_id.eq(conversation_guid)) + .inner_join(messages) + .select(MessageRecord::as_select()) + .order_by(schema::messages::date.asc()) + .load::(&mut self.db)?; + + let mut result = Vec::new(); + for message_record in message_records { + let mut message: Message = message_record.clone().into(); + + // If there's a sender_participant_id, load the participant info + if let Some(pid) = message_record.sender_participant_id { + let participant = participants + .find(pid) + .first::(&mut self.db)?; + message.sender = participant.into(); + } + + result.push(message); + } + + Ok(result) + } + + fn get_or_create_participant(&mut self, participant: &Participant) -> Option { + match participant { + Participant::Me => None, + Participant::Remote { display_name: p_name, .. } => { + use crate::schema::participants::dsl::*; + + let existing_participant = participants + .filter(display_name.eq(p_name)) + .first::(&mut self.db) + .optional() + .unwrap(); + + if let Some(participant) = existing_participant { + return Some(participant.id); + } + + let participant_record = InsertableParticipantRecord { + display_name: Some(participant.display_name()), + is_me: false, + }; + + diesel::insert_into(participants) + .values(&participant_record) + .execute(&mut self.db) + .unwrap(); + + self.last_insert_id().ok() + } + } + } +} diff --git a/kordophone-db/src/models/db/message.rs b/kordophone-db/src/models/db/message.rs new file mode 100644 index 0000000..67c7392 --- /dev/null +++ b/kordophone-db/src/models/db/message.rs @@ -0,0 +1,40 @@ +use diesel::prelude::*; +use chrono::NaiveDateTime; +use crate::models::{Message, Participant}; + +#[derive(Queryable, Selectable, Insertable, AsChangeset, Clone, Identifiable)] +#[diesel(table_name = crate::schema::messages)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct Record { + pub id: String, + pub sender_participant_id: Option, + pub text: String, + pub date: NaiveDateTime, +} + +impl From for Record { + fn from(message: Message) -> Self { + Self { + id: message.id, + sender_participant_id: match message.sender { + Participant::Me => None, + Participant::Remote { id, .. } => id, + }, + text: message.text, + date: message.date, + } + } +} + +impl From for Message { + fn from(record: Record) -> Self { + Self { + id: record.id, + // We'll set the proper sender later when loading participant info + sender: Participant::Me, + text: record.text, + date: record.date, + } + } +} + diff --git a/kordophone-db/src/models/db/mod.rs b/kordophone-db/src/models/db/mod.rs index 6c3c3f6..eeedf6c 100644 --- a/kordophone-db/src/models/db/mod.rs +++ b/kordophone-db/src/models/db/mod.rs @@ -1,2 +1,3 @@ pub mod conversation; pub mod participant; +pub mod message; \ No newline at end of file diff --git a/kordophone-db/src/models/db/participant.rs b/kordophone-db/src/models/db/participant.rs index ba6d50b..e40dae9 100644 --- a/kordophone-db/src/models/db/participant.rs +++ b/kordophone-db/src/models/db/participant.rs @@ -6,19 +6,28 @@ use crate::schema::conversation_participants; #[diesel(table_name = crate::schema::participants)] pub struct Record { pub id: i32, - pub display_name: String + pub display_name: Option, + pub is_me: bool, } #[derive(Insertable)] #[diesel(table_name = crate::schema::participants)] pub struct InsertableRecord { - pub display_name: String + pub display_name: Option, + pub is_me: bool, } impl From for InsertableRecord { fn from(participant: Participant) -> Self { - InsertableRecord { - display_name: participant.display_name + match participant { + Participant::Me => InsertableRecord { + display_name: None, + is_me: true, + }, + Participant::Remote { display_name, .. } => InsertableRecord { + display_name: Some(display_name), + is_me: false, + } } } } @@ -35,17 +44,30 @@ pub struct ConversationParticipant { impl From for Participant { fn from(record: Record) -> Self { - Participant { - display_name: record.display_name + if record.is_me { + Participant::Me + } else { + Participant::Remote { + id: Some(record.id), + display_name: record.display_name.unwrap_or_default(), + } } } } impl From for Record { fn from(participant: Participant) -> Self { - Record { - id: 0, // This will be set by the database - display_name: participant.display_name, + match participant { + Participant::Me => Record { + id: 0, // This will be set by the database + display_name: None, + is_me: true, + }, + Participant::Remote { display_name, .. } => Record { + id: 0, // This will be set by the database + display_name: Some(display_name), + is_me: false, + } } } } \ No newline at end of file diff --git a/kordophone-db/src/models/message.rs b/kordophone-db/src/models/message.rs new file mode 100644 index 0000000..9ef76d9 --- /dev/null +++ b/kordophone-db/src/models/message.rs @@ -0,0 +1,84 @@ +use chrono::{DateTime, NaiveDateTime}; +use uuid::Uuid; +use crate::models::participant::Participant; + +#[derive(Clone, Debug)] +pub struct Message { + pub id: String, + pub sender: Participant, + pub text: String, + pub date: NaiveDateTime, +} + +impl Message { + pub fn builder() -> MessageBuilder { + MessageBuilder::new() + } +} + +impl From for Message { + fn from(value: kordophone::model::Message) -> Self { + Self { + id: value.guid, + sender: match value.sender { + Some(sender) => Participant::Remote { + id: None, + display_name: sender, + }, + None => Participant::Me, + }, + text: value.text, + date: DateTime::from_timestamp( + value.date.unix_timestamp(), + value.date.unix_timestamp_nanos() + .try_into() + .unwrap_or(0), + ) + .unwrap() + .naive_local() + } + } +} + +pub struct MessageBuilder { + id: Option, + sender: Option, + text: Option, + date: Option, +} + +impl MessageBuilder { + pub fn new() -> Self { + Self { + id: None, + sender: None, + text: None, + date: None, + } + } + + pub fn sender(mut self, sender: Participant) -> Self { + self.sender = Some(sender); + self + } + + pub fn text(mut self, text: String) -> Self { + self.text = Some(text); + self + } + + pub fn date(mut self, date: NaiveDateTime) -> Self { + self.date = Some(date); + self + } + + pub fn build(self) -> Message { + Message { + id: self.id.unwrap_or_else(|| Uuid::new_v4().to_string()), + sender: self.sender.unwrap_or(Participant::Me), + text: self.text.unwrap_or_default(), + date: self.date.unwrap_or_else(|| chrono::Utc::now().naive_utc()), + } + } +} + diff --git a/kordophone-db/src/models/mod.rs b/kordophone-db/src/models/mod.rs index f0b8b0c..206eb44 100644 --- a/kordophone-db/src/models/mod.rs +++ b/kordophone-db/src/models/mod.rs @@ -1,6 +1,8 @@ pub mod conversation; pub mod participant; +pub mod message; pub mod db; pub use conversation::Conversation; -pub use participant::Participant; \ No newline at end of file +pub use participant::Participant; +pub use message::Message; \ No newline at end of file diff --git a/kordophone-db/src/models/participant.rs b/kordophone-db/src/models/participant.rs index 9528e2e..f643202 100644 --- a/kordophone-db/src/models/participant.rs +++ b/kordophone-db/src/models/participant.rs @@ -1,16 +1,35 @@ #[derive(Debug, Clone, PartialEq)] -pub struct Participant { - pub display_name: String, +pub enum Participant { + Me, + Remote { + id: Option, + display_name: String, + }, } impl From for Participant { fn from(display_name: String) -> Self { - Participant { display_name } + Participant::Remote { + id: None, + display_name, + } } } impl From<&str> for Participant { fn from(display_name: &str) -> Self { - Participant { display_name: display_name.to_string() } + Participant::Remote { + id: None, + display_name: display_name.to_string(), + } + } +} + +impl Participant { + pub fn display_name(&self) -> String { + match self { + Participant::Me => "(Me)".to_string(), + Participant::Remote { display_name, .. } => display_name.clone(), + } } } diff --git a/kordophone-db/src/schema.rs b/kordophone-db/src/schema.rs index 19556cb..f1708b9 100644 --- a/kordophone-db/src/schema.rs +++ b/kordophone-db/src/schema.rs @@ -1,3 +1,6 @@ +// When this file changes, run the following command to generate a new migration: +// DATABASE_URL=/tmp/db.sql diesel migration generate --diff-schema create_conversations + diesel::table! { conversations (id) { id -> Text, @@ -11,7 +14,8 @@ diesel::table! { diesel::table! { participants (id) { id -> Integer, - display_name -> Text, + display_name -> Nullable, + is_me -> Bool, } } @@ -22,6 +26,26 @@ diesel::table! { } } +diesel::table! { + messages (id) { + id -> Text, // guid + text -> Text, + sender_participant_id -> Nullable, + date -> Timestamp, + } +} + +diesel::table! { + conversation_messages (conversation_id, message_id) { + conversation_id -> Text, // guid + message_id -> Text, // guid + } +} + diesel::joinable!(conversation_participants -> conversations (conversation_id)); diesel::joinable!(conversation_participants -> participants (participant_id)); diesel::allow_tables_to_appear_in_same_query!(conversations, participants, conversation_participants); + +diesel::joinable!(conversation_messages -> conversations (conversation_id)); +diesel::joinable!(conversation_messages -> messages (message_id)); +diesel::allow_tables_to_appear_in_same_query!(conversations, messages, conversation_messages); \ No newline at end of file diff --git a/kordophone-db/src/tests/mod.rs b/kordophone-db/src/tests/mod.rs index d29563d..8434447 100644 --- a/kordophone-db/src/tests/mod.rs +++ b/kordophone-db/src/tests/mod.rs @@ -2,10 +2,28 @@ use crate::{ chat_database::ChatDatabase, models::{ conversation::{Conversation, ConversationBuilder}, - participant::Participant + participant::Participant, + message::Message, } }; +// Helper function to compare participants ignoring database IDs +fn participants_equal_ignoring_id(a: &Participant, b: &Participant) -> bool { + match (a, b) { + (Participant::Me, Participant::Me) => true, + (Participant::Remote { display_name: name_a, .. }, + Participant::Remote { display_name: name_b, .. }) => name_a == name_b, + _ => false + } +} + +fn participants_vec_equal_ignoring_id(a: &[Participant], b: &[Participant]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter().zip(b.iter()).all(|(a, b)| participants_equal_ignoring_id(a, b)) +} + #[test] fn test_database_init() { let _ = ChatDatabase::new_in_memory().unwrap(); @@ -62,7 +80,7 @@ fn test_conversation_participants() { let read_conversation = db.get_conversation_by_guid(&guid).unwrap().unwrap(); let read_participants = read_conversation.participants; - assert_eq!(participants, read_participants); + assert!(participants_vec_equal_ignoring_id(&participants, &read_participants)); // Try making another conversation with the same participants let conversation = ConversationBuilder::new() @@ -75,7 +93,7 @@ fn test_conversation_participants() { let read_conversation = db.get_conversation_by_guid(&guid).unwrap().unwrap(); let read_participants: Vec = read_conversation.participants; - assert_eq!(participants, read_participants); + assert!(participants_vec_equal_ignoring_id(&participants, &read_participants)); } #[test] @@ -112,6 +130,97 @@ fn test_all_conversations_with_participants() { let conv1 = all_conversations.iter().find(|c| c.guid == guid1).unwrap(); let conv2 = all_conversations.iter().find(|c| c.guid == guid2).unwrap(); - assert_eq!(conv1.participants, participants1); - assert_eq!(conv2.participants, participants2); + assert!(participants_vec_equal_ignoring_id(&conv1.participants, &participants1)); + assert!(participants_vec_equal_ignoring_id(&conv2.participants, &participants2)); +} + +#[test] +fn test_messages() { + let mut db = ChatDatabase::new_in_memory().unwrap(); + + // First create a conversation with participants + let participants = vec!["Alice".into(), "Bob".into()]; + let conversation = ConversationBuilder::new() + .display_name("Test Chat") + .participants(participants) + .build(); + let conversation_id = conversation.guid.clone(); + + db.insert_conversation(conversation).unwrap(); + + // Create and insert a message from Me + let message1 = Message::builder() + .text("Hello everyone!".to_string()) + .build(); + + // Create and insert a message from a remote participant + let message2 = Message::builder() + .text("Hi there!".to_string()) + .sender("Alice".into()) + .build(); + + // Insert both messages + db.insert_message(&conversation_id, message1.clone()).unwrap(); + db.insert_message(&conversation_id, message2.clone()).unwrap(); + + // Retrieve messages + let messages = db.get_messages_for_conversation(&conversation_id).unwrap(); + assert_eq!(messages.len(), 2); + + // Verify first message (from Me) + let retrieved_message1 = messages.iter().find(|m| m.id == message1.id).unwrap(); + assert_eq!(retrieved_message1.text, "Hello everyone!"); + assert!(matches!(retrieved_message1.sender, Participant::Me)); + + // Verify second message (from Alice) + let retrieved_message2 = messages.iter().find(|m| m.id == message2.id).unwrap(); + assert_eq!(retrieved_message2.text, "Hi there!"); + if let Participant::Remote { display_name, .. } = &retrieved_message2.sender { + assert_eq!(display_name, "Alice"); + } else { + panic!("Expected Remote participant. Got: {:?}", retrieved_message2.sender); + } +} + +#[test] +fn test_message_ordering() { + let mut db = ChatDatabase::new_in_memory().unwrap(); + + // Create a conversation + let conversation = ConversationBuilder::new() + .display_name("Test Chat") + .build(); + let conversation_id = conversation.guid.clone(); + db.insert_conversation(conversation).unwrap(); + + // Create messages with specific timestamps + let now = chrono::Utc::now().naive_utc(); + let message1 = Message::builder() + .text("First message".to_string()) + .date(now) + .build(); + + let message2 = Message::builder() + .text("Second message".to_string()) + .date(now + chrono::Duration::minutes(1)) + .build(); + + let message3 = Message::builder() + .text("Third message".to_string()) + .date(now + chrono::Duration::minutes(2)) + .build(); + + // Insert messages + db.insert_message(&conversation_id, message1).unwrap(); + db.insert_message(&conversation_id, message2).unwrap(); + db.insert_message(&conversation_id, message3).unwrap(); + + // Retrieve messages and verify order + let messages = db.get_messages_for_conversation(&conversation_id).unwrap(); + assert_eq!(messages.len(), 3); + + // Messages should be ordered by date + for i in 1..messages.len() { + assert!(messages[i].date > messages[i-1].date); + } } \ No newline at end of file From 5d3d2f194a69dc02a4ca70cb3b30d09b5ff2f100 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 20 Jan 2025 22:05:53 -0800 Subject: [PATCH 023/138] kpcli: adds support for querying messages --- kordophone/src/tests/test_client.rs | 4 ++-- kpcli/src/printers.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index 6f44d6b..9af75b5 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -44,8 +44,8 @@ impl APIInterface for TestClient { Ok(self.conversations.clone()) } - async fn get_messages(&mut self, conversation: Conversation) -> Result, Self::Error> { - if let Some(messages) = self.messages.get(&conversation.guid) { + async fn get_messages(&mut self, conversation_id: &ConversationID) -> Result, Self::Error> { + if let Some(messages) = self.messages.get(conversation_id) { return Ok(messages.clone()) } diff --git a/kpcli/src/printers.rs b/kpcli/src/printers.rs index 7ae3b48..b00da37 100644 --- a/kpcli/src/printers.rs +++ b/kpcli/src/printers.rs @@ -31,7 +31,7 @@ impl From for PrintableConversation { date: OffsetDateTime::from_unix_timestamp(value.date.and_utc().timestamp()).unwrap(), unread_count: value.unread_count.into(), last_message_preview: value.last_message_preview, - participants: value.participants.into_iter().map(|p| p.display_name).collect(), + participants: value.participants.into_iter().map(|p| p.display_name()).collect(), display_name: value.display_name, } } From bfc6fdddc138c2c19aa7b5e690d307a86085d0a6 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 20 Jan 2025 22:13:44 -0800 Subject: [PATCH 024/138] proj: Fix warnings --- kordophone-db/src/chat_database.rs | 3 +-- kordophone/src/api/http_client.rs | 11 ++++++++--- kordophone/src/model/jwt.rs | 4 ++++ kordophone/src/tests/test_client.rs | 2 +- kpcli/src/db/mod.rs | 2 +- 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/kordophone-db/src/chat_database.rs b/kordophone-db/src/chat_database.rs index 1e310fb..e4d55c8 100644 --- a/kordophone-db/src/chat_database.rs +++ b/kordophone-db/src/chat_database.rs @@ -1,7 +1,6 @@ use anyhow::Result; -use diesel::{prelude::*, sqlite::Sqlite}; +use diesel::prelude::*; use diesel::query_dsl::BelongingToDsl; -use std::path::{Path, PathBuf}; use crate::models::Participant; use crate::{ diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index c1e8def..024b44d 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -1,8 +1,7 @@ extern crate hyper; extern crate serde; -use std::{ffi::OsString, path::PathBuf, str}; -use log::{error}; +use std::{path::PathBuf, str}; use hyper::{Body, Client, Method, Request, Uri}; @@ -76,10 +75,13 @@ impl AuthBuilder for hyper::http::request::Builder { } } +#[cfg(test)] +#[allow(dead_code)] trait AuthSetting { fn authenticate(&mut self, token: &Option); } +#[cfg(test)] impl AuthSetting for hyper::http::Request { fn authenticate(&mut self, token: &Option) { if let Some(token) = &token { @@ -221,9 +223,11 @@ impl HTTPAPIClient { } } +#[cfg(test)] mod test { use super::*; + #[cfg(test)] fn local_mock_client() -> HTTPAPIClient { let base_url = "http://localhost:5738".parse().unwrap(); let credentials = Credentials { @@ -234,6 +238,7 @@ mod test { HTTPAPIClient::new(base_url, credentials.into()) } + #[cfg(test)] async fn mock_client_is_reachable() -> bool { let mut client = local_mock_client(); let version = client.get_version().await; @@ -241,7 +246,7 @@ mod test { match version { Ok(_) => true, Err(e) => { - error!("Mock client error: {:?}", e); + log::error!("Mock client error: {:?}", e); false } } diff --git a/kordophone/src/model/jwt.rs b/kordophone/src/model/jwt.rs index b5b62b9..70c86e6 100644 --- a/kordophone/src/model/jwt.rs +++ b/kordophone/src/model/jwt.rs @@ -10,18 +10,21 @@ use hyper::http::HeaderValue; use serde::Deserialize; #[derive(Deserialize, Debug, Clone)] +#[allow(dead_code)] struct JwtHeader { alg: String, typ: String, } #[derive(Deserialize, Debug, Clone)] +#[allow(dead_code)] enum ExpValue { Integer(i64), String(String), } #[derive(Deserialize, Debug, Clone)] +#[allow(dead_code)] struct JwtPayload { exp: serde_json::Value, iss: Option, @@ -29,6 +32,7 @@ struct JwtPayload { } #[derive(Debug, Clone)] +#[allow(dead_code)] pub struct JwtToken { header: JwtHeader, payload: JwtPayload, diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index 9af75b5..a67612b 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; pub use crate::APIInterface; use crate::{ api::http_client::Credentials, - model::{conversation, Conversation, ConversationID, JwtToken, Message} + model::{Conversation, ConversationID, JwtToken, Message} }; pub struct TestClient { diff --git a/kpcli/src/db/mod.rs b/kpcli/src/db/mod.rs index 36b68ea..471f6a2 100644 --- a/kpcli/src/db/mod.rs +++ b/kpcli/src/db/mod.rs @@ -1,7 +1,7 @@ use anyhow::Result; use clap::Subcommand; use kordophone::APIInterface; -use std::{env, path::{Path, PathBuf}}; +use std::{env, path::PathBuf}; use kordophone_db::ChatDatabase; use crate::{client, printers::ConversationPrinter}; From 16c202734ca04931d3e2846585e4bd1de3521d24 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 20 Jan 2025 22:23:18 -0800 Subject: [PATCH 025/138] kpcli: db: add support for printing messages table --- kpcli/src/db/mod.rs | 35 ++++++++++++++++++++++++++++++++++- kpcli/src/printers.rs | 11 +++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/kpcli/src/db/mod.rs b/kpcli/src/db/mod.rs index 471f6a2..eed8002 100644 --- a/kpcli/src/db/mod.rs +++ b/kpcli/src/db/mod.rs @@ -4,7 +4,7 @@ use kordophone::APIInterface; use std::{env, path::PathBuf}; use kordophone_db::ChatDatabase; -use crate::{client, printers::ConversationPrinter}; +use crate::{client, printers::{ConversationPrinter, MessagePrinter}}; #[derive(Subcommand)] pub enum Commands { @@ -13,6 +13,12 @@ pub enum Commands { #[clap(subcommand)] command: ConversationCommands }, + + /// For dealing with the table of cached messages. + Messages { + #[clap(subcommand)] + command: MessageCommands + }, } #[derive(Subcommand)] @@ -24,6 +30,14 @@ pub enum ConversationCommands { Sync, } +#[derive(Subcommand)] +pub enum MessageCommands { + /// Prints all messages in a conversation. + List { + conversation_id: String + }, +} + impl Commands { pub async fn run(cmd: Commands) -> Result<()> { let mut db = DbClient::new()?; @@ -32,6 +46,9 @@ impl Commands { ConversationCommands::List => db.print_conversations(), ConversationCommands::Sync => db.sync_with_client().await, }, + Commands::Messages { command: cmd } => match cmd { + MessageCommands::List { conversation_id } => db.print_messages(&conversation_id).await, + }, } } } @@ -67,6 +84,14 @@ impl DbClient { Ok(()) } + pub async fn print_messages(&mut self, conversation_id: &str) -> Result<()> { + let messages = self.db.get_messages_for_conversation(conversation_id)?; + for message in messages { + println!("{}", MessagePrinter::new(&message.into())); + } + Ok(()) + } + pub async fn sync_with_client(&mut self) -> Result<()> { let mut client = client::make_api_client_from_env(); let fetched_conversations = client.get_conversations().await?; @@ -75,7 +100,15 @@ impl DbClient { .collect(); for conversation in db_conversations { + let conversation_id = conversation.guid.clone(); self.db.insert_conversation(conversation)?; + + // Fetch and sync messages for this conversation + let messages = client.get_messages(&conversation_id).await?; + for message in messages { + let db_message = kordophone_db::models::Message::from(message); + self.db.insert_message(&conversation_id, db_message)?; + } } Ok(()) diff --git a/kpcli/src/printers.rs b/kpcli/src/printers.rs index b00da37..81930d1 100644 --- a/kpcli/src/printers.rs +++ b/kpcli/src/printers.rs @@ -55,6 +55,17 @@ impl From for PrintableMessage { } } +impl From for PrintableMessage { + fn from(value: kordophone_db::models::Message) -> Self { + Self { + guid: value.id, + date: OffsetDateTime::from_unix_timestamp(value.date.and_utc().timestamp()).unwrap(), + sender: value.sender.display_name(), + text: value.text, + } + } +} + pub struct ConversationPrinter<'a> { doc: RcDoc<'a, PrintableConversation> } From fddc45c62af9eccde97f576bbdfeb239f39d9d50 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Tue, 11 Feb 2025 23:15:24 -0800 Subject: [PATCH 026/138] Adds kordophoned, basic dbus interface --- Cargo.lock | 188 +++++++++++++++++- Cargo.toml | 3 +- kordophoned/Cargo.toml | 18 ++ kordophoned/build.rs | 24 +++ .../net.buzzert.kordophonecd.Server.xml | 8 + kordophoned/src/daemon/mod.rs | 11 + kordophoned/src/dbus/endpoint.rs | 93 +++++++++ kordophoned/src/dbus/mod.rs | 6 + kordophoned/src/main.rs | 24 +++ 9 files changed, 365 insertions(+), 10 deletions(-) create mode 100644 kordophoned/Cargo.toml create mode 100644 kordophoned/build.rs create mode 100644 kordophoned/include/net.buzzert.kordophonecd.Server.xml create mode 100644 kordophoned/src/daemon/mod.rs create mode 100644 kordophoned/src/dbus/endpoint.rs create mode 100644 kordophoned/src/dbus/mod.rs create mode 100644 kordophoned/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index b5107df..a36d850 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -41,6 +41,15 @@ dependencies = [ "libc", ] +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + [[package]] name = "anstream" version = "0.6.15" @@ -113,6 +122,17 @@ dependencies = [ "syn", ] +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.2.0" @@ -190,6 +210,21 @@ dependencies = [ "windows-targets 0.52.4", ] +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "ansi_term", + "atty", + "bitflags 1.3.2", + "strsim 0.8.0", + "textwrap", + "unicode-width", + "vec_map", +] + [[package]] name = "clap" version = "4.5.20" @@ -209,7 +244,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] @@ -282,7 +317,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn", ] @@ -297,6 +332,59 @@ dependencies = [ "syn", ] +[[package]] +name = "dbus" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b" +dependencies = [ + "futures-channel", + "futures-util", + "libc", + "libdbus-sys", + "winapi", +] + +[[package]] +name = "dbus-codegen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a76dc35ce83e4e9fa089b4fabe66c757b27bd504dc2179c97a01b36d3e874fb0" +dependencies = [ + "clap 2.34.0", + "dbus", + "xml-rs", +] + +[[package]] +name = "dbus-crossroads" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a4c83437187544ba5142427746835061b330446ca8902eabd70e4afb8f76de0" +dependencies = [ + "dbus", +] + +[[package]] +name = "dbus-tokio" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007688d459bc677131c063a3a77fb899526e17b7980f390b69644bdbc41fad13" +dependencies = [ + "dbus", + "libc", + "tokio", +] + +[[package]] +name = "dbus-tree" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f456e698ae8e54575e19ddb1f9b7bce2298568524f215496b248eb9498b4f508" +dependencies = [ + "dbus", +] + [[package]] name = "deranged" version = "0.3.11" @@ -390,9 +478,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" +checksum = "dcaee3d8e3cfc3fd92428d477bc97fc29ec8716d180c0d74c643bb26166660e0" dependencies = [ "anstream", "anstyle", @@ -531,6 +619,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hermit-abi" version = "0.3.9" @@ -708,12 +805,27 @@ dependencies = [ "uuid", ] +[[package]] +name = "kordophoned" +version = "0.1.0" +dependencies = [ + "dbus", + "dbus-codegen", + "dbus-crossroads", + "dbus-tokio", + "dbus-tree", + "env_logger", + "kordophone", + "log", + "tokio", +] + [[package]] name = "kpcli" version = "0.1.0" dependencies = [ "anyhow", - "clap", + "clap 4.5.20", "dotenv", "kordophone", "kordophone-db", @@ -735,6 +847,15 @@ version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +[[package]] +name = "libdbus-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06085512b750d640299b79be4bad3d2fa90a9c00b1fd9e1b46364f66f0485c72" +dependencies = [ + "pkg-config", +] + [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -763,9 +884,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.22" +version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" [[package]] name = "memchr" @@ -809,7 +930,7 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ - "hermit-abi", + "hermit-abi 0.3.9", "libc", "wasi", "windows-sys", @@ -1204,6 +1325,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + [[package]] name = "strsim" version = "0.11.1" @@ -1242,6 +1369,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + [[package]] name = "time" version = "0.3.37" @@ -1443,6 +1579,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "want" version = "0.3.1" @@ -1513,6 +1655,22 @@ version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.9" @@ -1522,6 +1680,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.52.0" @@ -1662,3 +1826,9 @@ checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" dependencies = [ "memchr", ] + +[[package]] +name = "xml-rs" +version = "0.8.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b940ebc25896e71dd073bad2dbaa2abfe97b0a391415e22ad1326d9c54e3c4" diff --git a/Cargo.toml b/Cargo.toml index 85fc82d..e9fa9ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,8 @@ [workspace] members = [ "kordophone", - "kordophone-db", + "kordophone-db", + "kordophoned", "kpcli" ] resolver = "2" diff --git a/kordophoned/Cargo.toml b/kordophoned/Cargo.toml new file mode 100644 index 0000000..208b953 --- /dev/null +++ b/kordophoned/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "kordophoned" +version = "0.1.0" +edition = "2021" + +[dependencies] +dbus = "0.9.7" +dbus-crossroads = "0.5.2" +dbus-tokio = "0.7.6" +dbus-tree = "0.9.2" +env_logger = "0.11.6" +kordophone = { path = "../kordophone" } +log = "0.4.25" +tokio = { version = "1", features = ["full"] } + +[build-dependencies] +dbus-codegen = "0.10.0" +dbus-crossroads = "0.5.1" diff --git a/kordophoned/build.rs b/kordophoned/build.rs new file mode 100644 index 0000000..e9d606b --- /dev/null +++ b/kordophoned/build.rs @@ -0,0 +1,24 @@ +const KORDOPHONE_XML: &str = "include/net.buzzert.kordophonecd.Server.xml"; + +fn main() { + let out_dir = std::env::var("OUT_DIR").unwrap(); + let out_path = std::path::Path::new(&out_dir).join("kordophone-server.rs"); + + let opts = dbus_codegen::GenOpts { + connectiontype: dbus_codegen::ConnectionType::Nonblock, + methodtype: None, // Set to None for crossroads + crossroads: true, + ..Default::default() + }; + + 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"); + + std::fs::write(out_path, output) + .expect("Error writing server dbus code"); + + println!("cargo:rerun-if-changed={}", KORDOPHONE_XML); +} diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml new file mode 100644 index 0000000..4e2b4b9 --- /dev/null +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs new file mode 100644 index 0000000..5f75ab0 --- /dev/null +++ b/kordophoned/src/daemon/mod.rs @@ -0,0 +1,11 @@ +use std::sync::Arc; + +pub struct Daemon { + pub version: String, +} + +impl Daemon { + pub fn new() -> Self { + Self { version: "0.1.0".to_string() } + } +} \ No newline at end of file diff --git a/kordophoned/src/dbus/endpoint.rs b/kordophoned/src/dbus/endpoint.rs new file mode 100644 index 0000000..66f6350 --- /dev/null +++ b/kordophoned/src/dbus/endpoint.rs @@ -0,0 +1,93 @@ +use crate::{dbus::interface::OBJECT_PATH, daemon::Daemon}; + +use crossroads::Crossroads; +use dbus::{ + channel::{MatchingReceiver, Sender}, + message::MatchRule, + nonblock::SyncConnection, + Path, +}; + +use dbus_crossroads as crossroads; +use dbus_tokio::connection; +use dbus_tree::{DataType, MethodErr}; +use log::info; +use std::{future::Future, sync::Arc, thread}; + +mod dbus_interface { + #![allow(unused)] + include!(concat!(env!("OUT_DIR"), "/kordophone-server.rs")); +} + +use dbus_interface::NetBuzzertKordophoneServer; + +pub struct Endpoint { + connection: Arc, + daemon: Arc, +} + +impl Endpoint { + pub fn new(daemon: Arc) -> Self { + let (resource, connection) = connection::new_session_sync().unwrap(); + + // The resource is a task that should be spawned onto a tokio compatible + // reactor ASAP. If the resource ever finishes, you lost connection to D-Bus. + // + // To shut down the connection, both call _handle.abort() and drop the connection. + let _handle = tokio::spawn(async { + let err = resource.await; + panic!("Lost connection to D-Bus: {}", err); + }); + + Self { connection, daemon } + } + + pub async fn start(&self) { + use crate::dbus::interface; + + self.connection + .request_name(interface::NAME, false, true, false) + .await + .expect("Unable to acquire dbus name"); + + let mut cr = Crossroads::new(); + + // Enable async support for the crossroads instance. + // (Currently irrelevant since dbus generates sync code) + cr.set_async_support(Some(( + self.connection.clone(), + Box::new(|x| { + tokio::spawn(x); + }), + ))); + + // Register the daemon as a D-Bus object. + let token = dbus_interface::register_net_buzzert_kordophone_server(&mut cr); + cr.insert(OBJECT_PATH, &[token], self.daemon.clone()); + + // Start receiving messages. + self.connection.start_receive( + MatchRule::new_method_call(), + Box::new(move |msg, conn| + cr.handle_message(msg, conn).is_ok() + ), + ); + + info!(target: "dbus", "DBus server started"); + } + + pub fn send_signal(&self, signal: S) -> Result + where + S: dbus::message::SignalArgs + dbus::arg::AppendAll, + { + let message = signal.to_emit_message(&Path::new(OBJECT_PATH).unwrap()); + self.connection.send(message) + } + +} + +impl NetBuzzertKordophoneServer for Arc { + fn get_version(&mut self) -> Result { + Ok(self.version.clone()) + } +} diff --git a/kordophoned/src/dbus/mod.rs b/kordophoned/src/dbus/mod.rs new file mode 100644 index 0000000..e178de5 --- /dev/null +++ b/kordophoned/src/dbus/mod.rs @@ -0,0 +1,6 @@ +pub mod endpoint; + +pub mod interface { + pub static NAME: &str = "net.buzzert.kordophonecd"; + pub static OBJECT_PATH: &str = "/net/buzzert/kordophone/Server"; +} diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs new file mode 100644 index 0000000..6761a1f --- /dev/null +++ b/kordophoned/src/main.rs @@ -0,0 +1,24 @@ +mod dbus; +mod daemon; + +use std::future; +use std::sync::Arc; +use log::LevelFilter; +fn initialize_logging() { + env_logger::Builder::from_default_env() + .filter_level(LevelFilter::Info) + .format_timestamp_secs() + .init(); +} + +#[tokio::main] +async fn main() { + initialize_logging(); + + let daemon = Arc::new(daemon::Daemon::new()); + let endpoint = Arc::new(dbus::endpoint::Endpoint::new(daemon)); + endpoint.start().await; + + future::pending::<()>().await; + unreachable!() +} From 6a7d376aa98c4e7853efc499e50ec81275f3692e Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 12 Feb 2025 00:10:33 -0800 Subject: [PATCH 027/138] kpcli: add daemon messaging support --- Cargo.lock | 3 +++ kpcli/Cargo.toml | 5 +++++ kpcli/build.rs | 23 +++++++++++++++++++ kpcli/src/daemon/mod.rs | 50 +++++++++++++++++++++++++++++++++++++++++ kpcli/src/main.rs | 8 +++++++ 5 files changed, 89 insertions(+) create mode 100644 kpcli/build.rs create mode 100644 kpcli/src/daemon/mod.rs diff --git a/Cargo.lock b/Cargo.lock index a36d850..7a70174 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -826,6 +826,9 @@ version = "0.1.0" dependencies = [ "anyhow", "clap 4.5.20", + "dbus", + "dbus-codegen", + "dbus-tree", "dotenv", "kordophone", "kordophone-db", diff --git a/kpcli/Cargo.toml b/kpcli/Cargo.toml index 760462e..d55b6d7 100644 --- a/kpcli/Cargo.toml +++ b/kpcli/Cargo.toml @@ -8,6 +8,8 @@ edition = "2021" [dependencies] anyhow = "1.0.93" clap = { version = "4.5.20", features = ["derive"] } +dbus = "0.9.7" +dbus-tree = "0.9.2" dotenv = "0.15.0" kordophone = { path = "../kordophone" } kordophone-db = { path = "../kordophone-db" } @@ -15,3 +17,6 @@ log = "0.4.22" pretty = { version = "0.12.3", features = ["termcolor"] } time = "0.3.37" tokio = "1.41.1" + +[build-dependencies] +dbus-codegen = "0.10.0" diff --git a/kpcli/build.rs b/kpcli/build.rs new file mode 100644 index 0000000..7d9bf4e --- /dev/null +++ b/kpcli/build.rs @@ -0,0 +1,23 @@ +const KORDOPHONE_XML: &str = "../kordophoned/include/net.buzzert.kordophonecd.Server.xml"; + +fn main() { + let out_dir = std::env::var("OUT_DIR").unwrap(); + let out_path = std::path::Path::new(&out_dir).join("kordophone-client.rs"); + + let opts = dbus_codegen::GenOpts { + connectiontype: dbus_codegen::ConnectionType::Blocking, + methodtype: None, + ..Default::default() + }; + + 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"); + + std::fs::write(out_path, output) + .expect("Error writing client dbus code"); + + println!("cargo:rerun-if-changed={}", KORDOPHONE_XML); +} diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs new file mode 100644 index 0000000..9e52757 --- /dev/null +++ b/kpcli/src/daemon/mod.rs @@ -0,0 +1,50 @@ +use anyhow::Result; +use clap::Subcommand; +use dbus::blocking::{Connection, Proxy}; + +const DBUS_NAME: &str = "net.buzzert.kordophonecd"; +const DBUS_PATH: &str = "/net/buzzert/kordophone/Server"; + +mod dbus_interface { + #![allow(unused)] + include!(concat!(env!("OUT_DIR"), "/kordophone-client.rs")); +} + +use dbus_interface::NetBuzzertKordophoneServer as KordophoneServer; + +#[derive(Subcommand)] +pub enum Commands { + /// Prints the server Kordophone version. + Version, +} + +impl Commands { + pub async fn run(cmd: Commands) -> Result<()> { + let mut client = DaemonCli::new()?; + match cmd { + Commands::Version => client.print_version().await, + } + } +} + +struct DaemonCli { + conn: Connection, +} + +impl DaemonCli { + pub fn new() -> Result { + Ok(Self { + conn: Connection::new_session()? + }) + } + + fn proxy(&self) -> Proxy<&Connection> { + self.conn.with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000)) + } + + pub async fn print_version(&mut self) -> Result<()> { + let version = KordophoneServer::get_version(&self.proxy())?; + println!("Server version: {}", version); + Ok(()) + } +} \ No newline at end of file diff --git a/kpcli/src/main.rs b/kpcli/src/main.rs index 628a112..7445699 100644 --- a/kpcli/src/main.rs +++ b/kpcli/src/main.rs @@ -1,6 +1,7 @@ mod client; mod db; mod printers; +mod daemon; use anyhow::Result; use clap::{Parser, Subcommand}; @@ -25,6 +26,12 @@ enum Commands { Db { #[command(subcommand)] command: db::Commands, + }, + + /// Commands for interacting with the daemon + Daemon { + #[command(subcommand)] + command: daemon::Commands, } } @@ -32,6 +39,7 @@ async fn run_command(command: Commands) -> Result<()> { match command { Commands::Client { command } => client::Commands::run(command).await, Commands::Db { command } => db::Commands::run(command).await, + Commands::Daemon { command } => daemon::Commands::run(command).await, } } From 68ff158d6cea4da9c1e9d45d0490f0315573f435 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 12 Feb 2025 00:26:32 -0800 Subject: [PATCH 028/138] kordophoned: reorg: server impl in separate file, skeleton for conversations --- .../net.buzzert.kordophonecd.Server.xml | 11 ++++++ kordophoned/src/daemon/mod.rs | 2 -- kordophoned/src/dbus/endpoint.rs | 35 +++++-------------- kordophoned/src/dbus/mod.rs | 13 ++++--- kordophoned/src/dbus/server_impl.rs | 16 +++++++++ 5 files changed, 45 insertions(+), 32 deletions(-) create mode 100644 kordophoned/src/dbus/server_impl.rs diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index 4e2b4b9..954009f 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -4,5 +4,16 @@ + + + + + + diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 5f75ab0..0cdb6aa 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -1,5 +1,3 @@ -use std::sync::Arc; - pub struct Daemon { pub version: String, } diff --git a/kordophoned/src/dbus/endpoint.rs b/kordophoned/src/dbus/endpoint.rs index 66f6350..33cb906 100644 --- a/kordophoned/src/dbus/endpoint.rs +++ b/kordophoned/src/dbus/endpoint.rs @@ -1,26 +1,16 @@ -use crate::{dbus::interface::OBJECT_PATH, daemon::Daemon}; +use log::info; +use std::sync::Arc; +use crate::{daemon::Daemon, dbus::interface}; -use crossroads::Crossroads; +use dbus_crossroads::Crossroads; +use dbus_tokio::connection; use dbus::{ - channel::{MatchingReceiver, Sender}, message::MatchRule, nonblock::SyncConnection, + channel::{Sender, MatchingReceiver}, Path, }; -use dbus_crossroads as crossroads; -use dbus_tokio::connection; -use dbus_tree::{DataType, MethodErr}; -use log::info; -use std::{future::Future, sync::Arc, thread}; - -mod dbus_interface { - #![allow(unused)] - include!(concat!(env!("OUT_DIR"), "/kordophone-server.rs")); -} - -use dbus_interface::NetBuzzertKordophoneServer; - pub struct Endpoint { connection: Arc, daemon: Arc, @@ -62,8 +52,8 @@ impl Endpoint { ))); // Register the daemon as a D-Bus object. - let token = dbus_interface::register_net_buzzert_kordophone_server(&mut cr); - cr.insert(OBJECT_PATH, &[token], self.daemon.clone()); + let token = interface::register_net_buzzert_kordophone_server(&mut cr); + cr.insert(interface::OBJECT_PATH, &[token], self.daemon.clone()); // Start receiving messages. self.connection.start_receive( @@ -80,14 +70,7 @@ impl Endpoint { where S: dbus::message::SignalArgs + dbus::arg::AppendAll, { - let message = signal.to_emit_message(&Path::new(OBJECT_PATH).unwrap()); + let message = signal.to_emit_message(&Path::new(interface::OBJECT_PATH).unwrap()); self.connection.send(message) } - -} - -impl NetBuzzertKordophoneServer for Arc { - fn get_version(&mut self) -> Result { - Ok(self.version.clone()) - } } diff --git a/kordophoned/src/dbus/mod.rs b/kordophoned/src/dbus/mod.rs index e178de5..ff97742 100644 --- a/kordophoned/src/dbus/mod.rs +++ b/kordophoned/src/dbus/mod.rs @@ -1,6 +1,11 @@ pub mod endpoint; +mod server_impl; -pub mod interface { - pub static NAME: &str = "net.buzzert.kordophonecd"; - pub static OBJECT_PATH: &str = "/net/buzzert/kordophone/Server"; -} +mod interface { + #![allow(unused)] + + pub const NAME: &str = "net.buzzert.kordophonecd"; + pub const OBJECT_PATH: &str = "/net/buzzert/kordophonecd"; + + include!(concat!(env!("OUT_DIR"), "/kordophone-server.rs")); +} \ No newline at end of file diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs new file mode 100644 index 0000000..b356a81 --- /dev/null +++ b/kordophoned/src/dbus/server_impl.rs @@ -0,0 +1,16 @@ +use dbus::arg; +use dbus_tree::MethodErr; +use std::sync::Arc; + +use crate::daemon::Daemon; +use crate::dbus::interface::NetBuzzertKordophoneServer as DbusServer; + +impl DbusServer for Arc { + fn get_version(&mut self) -> Result { + Ok(self.version.clone()) + } + + fn get_conversations(&mut self) -> Result, dbus::MethodErr> { + todo!() + } +} \ No newline at end of file From dd9025cc104041067933a80548ac20f8aa5368e4 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 12 Feb 2025 00:32:44 -0800 Subject: [PATCH 029/138] daemon: main reorg --- kordophoned/src/main.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index 6761a1f..71b75d5 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -4,6 +4,10 @@ mod daemon; use std::future; use std::sync::Arc; use log::LevelFilter; + +use daemon::Daemon; +use dbus::endpoint::Endpoint as DbusEndpoint; + fn initialize_logging() { env_logger::Builder::from_default_env() .filter_level(LevelFilter::Info) @@ -15,8 +19,10 @@ fn initialize_logging() { async fn main() { initialize_logging(); - let daemon = Arc::new(daemon::Daemon::new()); - let endpoint = Arc::new(dbus::endpoint::Endpoint::new(daemon)); + // Daemon is stored in an Arc so it can be shared with other endpoints eventually. + let daemon = Arc::new(Daemon::new()); + + let endpoint = DbusEndpoint::new(daemon); endpoint.start().await; future::pending::<()>().await; From f7d094fcd67c85d6fb02c2a4bf3a5dee783c569a Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 25 Apr 2025 15:42:46 -0700 Subject: [PATCH 030/138] reorg: split repo / database so settings can use db connection as well --- Cargo.lock | 10 ++ kordophone-db/Cargo.toml | 1 + .../2025-04-25-223015_add_settings/down.sql | 7 ++ .../2025-04-25-223015_add_settings/up.sql | 11 ++ kordophone-db/src/database.rs | 34 ++++++ kordophone-db/src/lib.rs | 6 +- .../src/{chat_database.rs => repository.rs} | 70 +++++------- kordophone-db/src/schema.rs | 7 ++ kordophone-db/src/settings.rs | 71 ++++++++++++ kordophone-db/src/tests/mod.rs | 83 +++++++++----- kpcli/src/daemon/mod.rs | 2 +- kpcli/src/db/mod.rs | 106 ++++++++++++++++-- 12 files changed, 326 insertions(+), 82 deletions(-) create mode 100644 kordophone-db/migrations/2025-04-25-223015_add_settings/down.sql create mode 100644 kordophone-db/migrations/2025-04-25-223015_add_settings/up.sql create mode 100644 kordophone-db/src/database.rs rename kordophone-db/src/{chat_database.rs => repository.rs} (82%) create mode 100644 kordophone-db/src/settings.rs diff --git a/Cargo.lock b/Cargo.lock index 7a70174..ca1bd19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,6 +160,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -796,6 +805,7 @@ name = "kordophone-db" version = "0.1.0" dependencies = [ "anyhow", + "bincode", "chrono", "diesel", "diesel_migrations", diff --git a/kordophone-db/Cargo.toml b/kordophone-db/Cargo.toml index 626cc7e..0696e36 100644 --- a/kordophone-db/Cargo.toml +++ b/kordophone-db/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] anyhow = "1.0.94" +bincode = "1.3.3" chrono = "0.4.38" diesel = { version = "2.2.6", features = ["chrono", "sqlite", "time"] } diesel_migrations = { version = "2.2.0", features = ["sqlite"] } diff --git a/kordophone-db/migrations/2025-04-25-223015_add_settings/down.sql b/kordophone-db/migrations/2025-04-25-223015_add_settings/down.sql new file mode 100644 index 0000000..0445954 --- /dev/null +++ b/kordophone-db/migrations/2025-04-25-223015_add_settings/down.sql @@ -0,0 +1,7 @@ +-- This file should undo anything in `up.sql` + + + + + +DROP TABLE IF EXISTS `settings`; diff --git a/kordophone-db/migrations/2025-04-25-223015_add_settings/up.sql b/kordophone-db/migrations/2025-04-25-223015_add_settings/up.sql new file mode 100644 index 0000000..56e9cc6 --- /dev/null +++ b/kordophone-db/migrations/2025-04-25-223015_add_settings/up.sql @@ -0,0 +1,11 @@ +-- Your SQL goes here + + + + + +CREATE TABLE `settings`( + `key` TEXT NOT NULL PRIMARY KEY, + `value` BINARY NOT NULL +); + diff --git a/kordophone-db/src/database.rs b/kordophone-db/src/database.rs new file mode 100644 index 0000000..29bb6fd --- /dev/null +++ b/kordophone-db/src/database.rs @@ -0,0 +1,34 @@ +use anyhow::Result; +use diesel::prelude::*; + +use crate::repository::Repository; +use crate::settings::Settings; + +use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; +pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); + +pub struct Database { + pub connection: SqliteConnection, +} + +impl Database { + pub fn new(path: &str) -> Result { + let mut connection = SqliteConnection::establish(path)?; + connection.run_pending_migrations(MIGRATIONS) + .map_err(|e| anyhow::anyhow!("Error running migrations: {}", e))?; + + Ok(Self { connection }) + } + + pub fn new_in_memory() -> Result { + Self::new(":memory:") + } + + pub fn get_repository(&mut self) -> Repository { + Repository::new(self) + } + + pub fn get_settings(&mut self) -> Settings { + Settings::new(self) + } +} \ No newline at end of file diff --git a/kordophone-db/src/lib.rs b/kordophone-db/src/lib.rs index 3ca1345..abdc8f7 100644 --- a/kordophone-db/src/lib.rs +++ b/kordophone-db/src/lib.rs @@ -1,8 +1,10 @@ +pub mod database; pub mod models; -pub mod chat_database; +pub mod repository; pub mod schema; +pub mod settings; #[cfg(test)] mod tests; -pub use chat_database::ChatDatabase; \ No newline at end of file +pub use repository::Repository; \ No newline at end of file diff --git a/kordophone-db/src/chat_database.rs b/kordophone-db/src/repository.rs similarity index 82% rename from kordophone-db/src/chat_database.rs rename to kordophone-db/src/repository.rs index e4d55c8..8eb535f 100644 --- a/kordophone-db/src/chat_database.rs +++ b/kordophone-db/src/repository.rs @@ -2,11 +2,14 @@ use anyhow::Result; use diesel::prelude::*; use diesel::query_dsl::BelongingToDsl; -use crate::models::Participant; +use std::sync::Arc; + use crate::{ + database::Database, models::{ Conversation, Message, + Participant, db::conversation::Record as ConversationRecord, db::participant::{ ConversationParticipant, @@ -18,32 +21,13 @@ use crate::{ schema, }; -use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; -pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); - -pub struct ChatDatabase { - db: SqliteConnection, +pub struct Repository<'a> { + db: &'a mut Database, } -impl ChatDatabase { - pub fn new_in_memory() -> Result { - Self::new(":memory:") - } - - // Helper function to get the last inserted row ID - // This is a workaround since the Sqlite backend doesn't support `RETURNING` - // Huge caveat with this is that it depends on whatever the last insert was, prevents concurrent inserts. - fn last_insert_id(&mut self) -> Result { - Ok(diesel::select(diesel::dsl::sql::("last_insert_rowid()")) - .get_result(&mut self.db)?) - } - - pub fn new(db_path: &str) -> Result { - let mut db = SqliteConnection::establish(db_path)?; - db.run_pending_migrations(MIGRATIONS) - .map_err(|e| anyhow::anyhow!("Error running migrations: {}", e))?; - - Ok(Self { db }) +impl<'a> Repository<'a> { + pub fn new(db: &'a mut Database) -> Self { + Self { db } } pub fn insert_conversation(&mut self, conversation: Conversation) -> Result<()> { @@ -55,25 +39,25 @@ impl ChatDatabase { diesel::replace_into(conversations) .values(&db_conversation) - .execute(&mut self.db)?; + .execute(&mut self.db.connection)?; diesel::replace_into(participants) .values(&db_participants) - .execute(&mut self.db)?; + .execute(&mut self.db.connection)?; // Sqlite backend doesn't support batch insert, so we have to do this manually for participant in db_participants { let pid = participants .select(schema::participants::id) .filter(schema::participants::display_name.eq(&participant.display_name)) - .first::(&mut self.db)?; + .first::(&mut self.db.connection)?; diesel::replace_into(conversation_participants) .values(( conversation_id.eq(&db_conversation.id), participant_id.eq(pid), )) - .execute(&mut self.db)?; + .execute(&mut self.db.connection)?; } Ok(()) @@ -85,14 +69,14 @@ impl ChatDatabase { let result = conversations .find(match_guid) - .first::(&mut self.db) + .first::(&mut self.db.connection) .optional()?; if let Some(conversation) = result { let db_participants = ConversationParticipant::belonging_to(&conversation) .inner_join(participants) .select(ParticipantRecord::as_select()) - .load::(&mut self.db)?; + .load::(&mut self.db.connection)?; let mut model_conversation: Conversation = conversation.into(); model_conversation.participants = db_participants.into_iter().map(|p| p.into()).collect(); @@ -108,14 +92,14 @@ impl ChatDatabase { use crate::schema::participants::dsl::*; let db_conversations = conversations - .load::(&mut self.db)?; + .load::(&mut self.db.connection)?; let mut result = Vec::new(); for db_conversation in db_conversations { let db_participants = ConversationParticipant::belonging_to(&db_conversation) .inner_join(participants) .select(ParticipantRecord::as_select()) - .load::(&mut self.db)?; + .load::(&mut self.db.connection)?; let mut model_conversation: Conversation = db_conversation.into(); model_conversation.participants = db_participants.into_iter().map(|p| p.into()).collect(); @@ -137,14 +121,14 @@ impl ChatDatabase { diesel::replace_into(messages) .values(&db_message) - .execute(&mut self.db)?; + .execute(&mut self.db.connection)?; diesel::replace_into(conversation_messages) .values(( conversation_id.eq(conversation_guid), message_id.eq(&db_message.id), )) - .execute(&mut self.db)?; + .execute(&mut self.db.connection)?; Ok(()) } @@ -159,7 +143,7 @@ impl ChatDatabase { .inner_join(messages) .select(MessageRecord::as_select()) .order_by(schema::messages::date.asc()) - .load::(&mut self.db)?; + .load::(&mut self.db.connection)?; let mut result = Vec::new(); for message_record in message_records { @@ -169,7 +153,7 @@ impl ChatDatabase { if let Some(pid) = message_record.sender_participant_id { let participant = participants .find(pid) - .first::(&mut self.db)?; + .first::(&mut self.db.connection)?; message.sender = participant.into(); } @@ -179,6 +163,14 @@ impl ChatDatabase { Ok(result) } + // Helper function to get the last inserted row ID + // This is a workaround since the Sqlite backend doesn't support `RETURNING` + // Huge caveat with this is that it depends on whatever the last insert was, prevents concurrent inserts. + fn last_insert_id(&mut self) -> Result { + Ok(diesel::select(diesel::dsl::sql::("last_insert_rowid()")) + .get_result(&mut self.db.connection)?) + } + fn get_or_create_participant(&mut self, participant: &Participant) -> Option { match participant { Participant::Me => None, @@ -187,7 +179,7 @@ impl ChatDatabase { let existing_participant = participants .filter(display_name.eq(p_name)) - .first::(&mut self.db) + .first::(&mut self.db.connection) .optional() .unwrap(); @@ -202,7 +194,7 @@ impl ChatDatabase { diesel::insert_into(participants) .values(&participant_record) - .execute(&mut self.db) + .execute(&mut self.db.connection) .unwrap(); self.last_insert_id().ok() diff --git a/kordophone-db/src/schema.rs b/kordophone-db/src/schema.rs index f1708b9..d77e401 100644 --- a/kordophone-db/src/schema.rs +++ b/kordophone-db/src/schema.rs @@ -42,6 +42,13 @@ diesel::table! { } } +diesel::table! { + settings (key) { + key -> Text, + value -> Binary, + } +} + diesel::joinable!(conversation_participants -> conversations (conversation_id)); diesel::joinable!(conversation_participants -> participants (participant_id)); diesel::allow_tables_to_appear_in_same_query!(conversations, participants, conversation_participants); diff --git a/kordophone-db/src/settings.rs b/kordophone-db/src/settings.rs new file mode 100644 index 0000000..a234560 --- /dev/null +++ b/kordophone-db/src/settings.rs @@ -0,0 +1,71 @@ +use diesel::*; +use serde::{Serialize, de::DeserializeOwned}; +use anyhow::Result; +use crate::database::Database; +#[derive(Insertable, Queryable, AsChangeset)] +#[diesel(table_name = crate::schema::settings)] +struct SettingsRow<'a> { + key: &'a str, + value: &'a [u8], +} + +pub struct Settings<'a> { + db: &'a mut Database, +} + +impl<'a> Settings<'a> { + pub fn new(db: &'a mut Database) -> Self { + Self { db } + } + + pub fn put( + &mut self, + k: &str, + v: &T, + ) -> Result<()> { + use crate::schema::settings::dsl::*; + let bytes = bincode::serialize(v)?; + + diesel::insert_into(settings) + .values(SettingsRow { key: k, value: &bytes }) + .on_conflict(key) + .do_update() + .set((value.eq(&bytes))) + .execute(&mut self.db.connection)?; + + Ok(()) + } + + pub fn get( + &mut self, + k: &str, + ) -> Result> { + use crate::schema::settings::dsl::*; + let blob: Option> = settings + .select(value) + .filter(key.eq(k)) + .first(&mut self.db.connection) + .optional()?; + + Ok(match blob { + Some(b) => Some(bincode::deserialize(&b)?), + None => None, + }) + } + + pub fn del(&mut self, k: &str) -> Result { + use crate::schema::settings::dsl::*; + Ok(diesel::delete(settings.filter(key.eq(k))).execute(&mut self.db.connection)?) + } + + pub fn list_keys(&mut self) -> Result> { + use crate::schema::settings::dsl::*; + let keys: Vec = settings + .select(key) + .load(&mut self.db.connection)?; + + Ok(keys) + } +} + + diff --git a/kordophone-db/src/tests/mod.rs b/kordophone-db/src/tests/mod.rs index 8434447..961f486 100644 --- a/kordophone-db/src/tests/mod.rs +++ b/kordophone-db/src/tests/mod.rs @@ -1,10 +1,12 @@ use crate::{ - chat_database::ChatDatabase, + database::Database, + repository::Repository, models::{ conversation::{Conversation, ConversationBuilder}, participant::Participant, message::Message, - } + }, + settings::Settings, }; // Helper function to compare participants ignoring database IDs @@ -26,12 +28,14 @@ fn participants_vec_equal_ignoring_id(a: &[Participant], b: &[Participant]) -> b #[test] fn test_database_init() { - let _ = ChatDatabase::new_in_memory().unwrap(); + let mut db = Database::new_in_memory().unwrap(); + let _ = Repository::new(&mut db); } #[test] fn test_add_conversation() { - let mut db = ChatDatabase::new_in_memory().unwrap(); + let mut db = Database::new_in_memory().unwrap(); + let mut repository = db.get_repository(); let guid = "test"; let test_conversation = Conversation::builder() @@ -40,10 +44,10 @@ fn test_add_conversation() { .display_name("Test Conversation") .build(); - db.insert_conversation(test_conversation.clone()).unwrap(); + repository.insert_conversation(test_conversation.clone()).unwrap(); // Try to fetch with id now - let conversation = db.get_conversation_by_guid(guid).unwrap().unwrap(); + let conversation = repository.get_conversation_by_guid(guid).unwrap().unwrap(); assert_eq!(conversation.guid, "test"); // Modify the conversation and update it @@ -51,20 +55,21 @@ fn test_add_conversation() { .display_name("Modified Conversation") .build(); - db.insert_conversation(modified_conversation.clone()).unwrap(); + repository.insert_conversation(modified_conversation.clone()).unwrap(); // Make sure we still only have one conversation. - let all_conversations = db.all_conversations().unwrap(); + let all_conversations = repository.all_conversations().unwrap(); assert_eq!(all_conversations.len(), 1); // And make sure the display name was updated - let conversation = db.get_conversation_by_guid(guid).unwrap().unwrap(); + let conversation = repository.get_conversation_by_guid(guid).unwrap().unwrap(); assert_eq!(conversation.display_name.unwrap(), "Modified Conversation"); } #[test] fn test_conversation_participants() { - let mut db = ChatDatabase::new_in_memory().unwrap(); + let mut db = Database::new_in_memory().unwrap(); + let mut repository = db.get_repository(); let participants: Vec = vec!["one".into(), "two".into()]; @@ -75,9 +80,9 @@ fn test_conversation_participants() { .participants(participants.clone()) .build(); - db.insert_conversation(conversation).unwrap(); + repository.insert_conversation(conversation).unwrap(); - let read_conversation = db.get_conversation_by_guid(&guid).unwrap().unwrap(); + let read_conversation = repository.get_conversation_by_guid(&guid).unwrap().unwrap(); let read_participants = read_conversation.participants; assert!(participants_vec_equal_ignoring_id(&participants, &read_participants)); @@ -88,9 +93,9 @@ fn test_conversation_participants() { .participants(participants.clone()) .build(); - db.insert_conversation(conversation).unwrap(); + repository.insert_conversation(conversation).unwrap(); - let read_conversation = db.get_conversation_by_guid(&guid).unwrap().unwrap(); + let read_conversation = repository.get_conversation_by_guid(&guid).unwrap().unwrap(); let read_participants: Vec = read_conversation.participants; assert!(participants_vec_equal_ignoring_id(&participants, &read_participants)); @@ -98,7 +103,8 @@ fn test_conversation_participants() { #[test] fn test_all_conversations_with_participants() { - let mut db = ChatDatabase::new_in_memory().unwrap(); + let mut db = Database::new_in_memory().unwrap(); + let mut repository = db.get_repository(); // Create two conversations with different participants let participants1: Vec = vec!["one".into(), "two".into()]; @@ -119,11 +125,11 @@ fn test_all_conversations_with_participants() { .build(); // Insert both conversations - db.insert_conversation(conversation1).unwrap(); - db.insert_conversation(conversation2).unwrap(); + repository.insert_conversation(conversation1).unwrap(); + repository.insert_conversation(conversation2).unwrap(); // Get all conversations and verify the results - let all_conversations = db.all_conversations().unwrap(); + let all_conversations = repository.all_conversations().unwrap(); assert_eq!(all_conversations.len(), 2); // Find and verify each conversation's participants @@ -136,7 +142,8 @@ fn test_all_conversations_with_participants() { #[test] fn test_messages() { - let mut db = ChatDatabase::new_in_memory().unwrap(); + let mut db = Database::new_in_memory().unwrap(); + let mut repository = db.get_repository(); // First create a conversation with participants let participants = vec!["Alice".into(), "Bob".into()]; @@ -146,7 +153,7 @@ fn test_messages() { .build(); let conversation_id = conversation.guid.clone(); - db.insert_conversation(conversation).unwrap(); + repository.insert_conversation(conversation).unwrap(); // Create and insert a message from Me let message1 = Message::builder() @@ -160,11 +167,11 @@ fn test_messages() { .build(); // Insert both messages - db.insert_message(&conversation_id, message1.clone()).unwrap(); - db.insert_message(&conversation_id, message2.clone()).unwrap(); + repository.insert_message(&conversation_id, message1.clone()).unwrap(); + repository.insert_message(&conversation_id, message2.clone()).unwrap(); // Retrieve messages - let messages = db.get_messages_for_conversation(&conversation_id).unwrap(); + let messages = repository.get_messages_for_conversation(&conversation_id).unwrap(); assert_eq!(messages.len(), 2); // Verify first message (from Me) @@ -184,14 +191,15 @@ fn test_messages() { #[test] fn test_message_ordering() { - let mut db = ChatDatabase::new_in_memory().unwrap(); + let mut db = Database::new_in_memory().unwrap(); + let mut repository = db.get_repository(); // Create a conversation let conversation = ConversationBuilder::new() .display_name("Test Chat") .build(); let conversation_id = conversation.guid.clone(); - db.insert_conversation(conversation).unwrap(); + repository.insert_conversation(conversation).unwrap(); // Create messages with specific timestamps let now = chrono::Utc::now().naive_utc(); @@ -211,16 +219,31 @@ fn test_message_ordering() { .build(); // Insert messages - db.insert_message(&conversation_id, message1).unwrap(); - db.insert_message(&conversation_id, message2).unwrap(); - db.insert_message(&conversation_id, message3).unwrap(); + repository.insert_message(&conversation_id, message1).unwrap(); + repository.insert_message(&conversation_id, message2).unwrap(); + repository.insert_message(&conversation_id, message3).unwrap(); // Retrieve messages and verify order - let messages = db.get_messages_for_conversation(&conversation_id).unwrap(); + let messages = repository.get_messages_for_conversation(&conversation_id).unwrap(); assert_eq!(messages.len(), 3); // Messages should be ordered by date for i in 1..messages.len() { assert!(messages[i].date > messages[i-1].date); } -} \ No newline at end of file +} + +#[test] +fn test_settings() { + let mut db = Database::new_in_memory().unwrap(); + let mut settings = db.get_settings(); + + settings.put("test", &"test".to_string()).unwrap(); + assert_eq!(settings.get::("test").unwrap().unwrap(), "test"); + + settings.del("test").unwrap(); + assert!(settings.get::("test").unwrap().is_none()); + + let keys = settings.list_keys().unwrap(); + assert_eq!(keys.len(), 0); +} diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index 9e52757..8e5ae49 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -3,7 +3,7 @@ use clap::Subcommand; use dbus::blocking::{Connection, Proxy}; const DBUS_NAME: &str = "net.buzzert.kordophonecd"; -const DBUS_PATH: &str = "/net/buzzert/kordophone/Server"; +const DBUS_PATH: &str = "/net/buzzert/kordophonecd"; mod dbus_interface { #![allow(unused)] diff --git a/kpcli/src/db/mod.rs b/kpcli/src/db/mod.rs index eed8002..c95ae76 100644 --- a/kpcli/src/db/mod.rs +++ b/kpcli/src/db/mod.rs @@ -3,7 +3,7 @@ use clap::Subcommand; use kordophone::APIInterface; use std::{env, path::PathBuf}; -use kordophone_db::ChatDatabase; +use kordophone_db::{database::Database, repository::Repository, settings}; use crate::{client, printers::{ConversationPrinter, MessagePrinter}}; #[derive(Subcommand)] @@ -19,6 +19,12 @@ pub enum Commands { #[clap(subcommand)] command: MessageCommands }, + + /// For managing settings in the database. + Settings { + #[clap(subcommand)] + command: SettingsCommands + }, } #[derive(Subcommand)] @@ -38,6 +44,29 @@ pub enum MessageCommands { }, } +#[derive(Subcommand)] +pub enum SettingsCommands { + /// Lists all settings or gets a specific setting. + Get { + /// The key to get. If not provided, all settings will be listed. + key: Option + }, + + /// Sets a setting value. + Put { + /// The key to set. + key: String, + /// The value to set. + value: String, + }, + + /// Deletes a setting. + Delete { + /// The key to delete. + key: String, + }, +} + impl Commands { pub async fn run(cmd: Commands) -> Result<()> { let mut db = DbClient::new()?; @@ -49,12 +78,17 @@ impl Commands { Commands::Messages { command: cmd } => match cmd { MessageCommands::List { conversation_id } => db.print_messages(&conversation_id).await, }, + Commands::Settings { command: cmd } => match cmd { + SettingsCommands::Get { key } => db.get_setting(key), + SettingsCommands::Put { key, value } => db.put_setting(key, value), + SettingsCommands::Delete { key } => db.delete_setting(key), + }, } } } struct DbClient { - db: ChatDatabase + database: Database } impl DbClient { @@ -69,12 +103,12 @@ impl DbClient { println!("kpcli: Using temporary db at {}", path_str); - let db = ChatDatabase::new(path_str)?; - Ok( Self { db }) + let db = Database::new(path_str)?; + Ok( Self { database: db }) } pub fn print_conversations(&mut self) -> Result<()> { - let all_conversations = self.db.all_conversations()?; + let all_conversations = self.database.get_repository().all_conversations()?; println!("{} Conversations: ", all_conversations.len()); for conversation in all_conversations { @@ -85,7 +119,7 @@ impl DbClient { } pub async fn print_messages(&mut self, conversation_id: &str) -> Result<()> { - let messages = self.db.get_messages_for_conversation(conversation_id)?; + let messages = self.database.get_repository().get_messages_for_conversation(conversation_id)?; for message in messages { println!("{}", MessagePrinter::new(&message.into())); } @@ -99,18 +133,70 @@ impl DbClient { .map(|c| kordophone_db::models::Conversation::from(c)) .collect(); + let mut repository = self.database.get_repository(); for conversation in db_conversations { let conversation_id = conversation.guid.clone(); - self.db.insert_conversation(conversation)?; + repository.insert_conversation(conversation)?; // Fetch and sync messages for this conversation let messages = client.get_messages(&conversation_id).await?; - for message in messages { - let db_message = kordophone_db::models::Message::from(message); - self.db.insert_message(&conversation_id, db_message)?; + let db_messages: Vec = messages.into_iter() + .map(|m| kordophone_db::models::Message::from(m)) + .collect(); + + for message in db_messages { + repository.insert_message(&conversation_id, message)?; } } Ok(()) } + + pub fn get_setting(&mut self, key: Option) -> Result<()> { + let mut settings = self.database.get_settings(); + + match key { + Some(key) => { + // Get a specific setting + let value: Option = settings.get(&key)?; + match value { + Some(v) => println!("{} = {}", key, v), + None => println!("Setting '{}' not found", key), + } + }, + None => { + // List all settings + let keys = settings.list_keys()?; + if keys.is_empty() { + println!("No settings found"); + } else { + println!("Settings:"); + for key in keys { + let value: Option = settings.get(&key)?; + match value { + Some(v) => println!(" {} = {}", key, v), + None => println!(" {} = ", key), + } + } + } + } + } + + Ok(()) + } + + pub fn put_setting(&mut self, key: String, value: String) -> Result<()> { + let mut settings = self.database.get_settings(); + settings.put(&key, &value)?; + Ok(()) + } + + pub fn delete_setting(&mut self, key: String) -> Result<()> { + let mut settings = self.database.get_settings(); + let count = settings.del(&key)?; + if count == 0 { + println!("Setting '{}' not found", key); + } + Ok(()) + } } From 89c9ffc187c4febdc826ffba347e40798b23672d Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 25 Apr 2025 15:48:50 -0700 Subject: [PATCH 031/138] cleanup --- kordophone-db/src/repository.rs | 2 -- kordophone-db/src/settings.rs | 2 +- kpcli/src/db/mod.rs | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/kordophone-db/src/repository.rs b/kordophone-db/src/repository.rs index 8eb535f..d6f48ee 100644 --- a/kordophone-db/src/repository.rs +++ b/kordophone-db/src/repository.rs @@ -2,8 +2,6 @@ use anyhow::Result; use diesel::prelude::*; use diesel::query_dsl::BelongingToDsl; -use std::sync::Arc; - use crate::{ database::Database, models::{ diff --git a/kordophone-db/src/settings.rs b/kordophone-db/src/settings.rs index a234560..ae6175a 100644 --- a/kordophone-db/src/settings.rs +++ b/kordophone-db/src/settings.rs @@ -30,7 +30,7 @@ impl<'a> Settings<'a> { .values(SettingsRow { key: k, value: &bytes }) .on_conflict(key) .do_update() - .set((value.eq(&bytes))) + .set(value.eq(&bytes)) .execute(&mut self.db.connection)?; Ok(()) diff --git a/kpcli/src/db/mod.rs b/kpcli/src/db/mod.rs index c95ae76..73f5dc8 100644 --- a/kpcli/src/db/mod.rs +++ b/kpcli/src/db/mod.rs @@ -3,7 +3,7 @@ use clap::Subcommand; use kordophone::APIInterface; use std::{env, path::PathBuf}; -use kordophone_db::{database::Database, repository::Repository, settings}; +use kordophone_db::database::Database; use crate::{client, printers::{ConversationPrinter, MessagePrinter}}; #[derive(Subcommand)] From b1f171136abbf0275eb03f3cfc06a0f6d18afb31 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 25 Apr 2025 16:34:00 -0700 Subject: [PATCH 032/138] refactor: with_repository/with_settings --- kordophone-db/src/database.rs | 18 +- kordophone-db/src/repository.rs | 37 ++-- kordophone-db/src/settings.rs | 16 +- kordophone-db/src/tests/mod.rs | 305 ++++++++++++++++---------------- kpcli/src/db/mod.rs | 100 ++++++----- 5 files changed, 249 insertions(+), 227 deletions(-) diff --git a/kordophone-db/src/database.rs b/kordophone-db/src/database.rs index 29bb6fd..ca53285 100644 --- a/kordophone-db/src/database.rs +++ b/kordophone-db/src/database.rs @@ -8,7 +8,7 @@ use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); pub struct Database { - pub connection: SqliteConnection, + connection: SqliteConnection, } impl Database { @@ -24,11 +24,19 @@ impl Database { Self::new(":memory:") } - pub fn get_repository(&mut self) -> Repository { - Repository::new(self) + pub fn with_repository(&mut self, f: F) -> R + where + F: FnOnce(&mut Repository) -> R, + { + let mut repository = Repository::new(&mut self.connection); + f(&mut repository) } - pub fn get_settings(&mut self) -> Settings { - Settings::new(self) + pub fn with_settings(&mut self, f: F) -> R + where + F: FnOnce(&mut Settings) -> R, + { + let mut settings = Settings::new(&mut self.connection); + f(&mut settings) } } \ No newline at end of file diff --git a/kordophone-db/src/repository.rs b/kordophone-db/src/repository.rs index d6f48ee..a82457a 100644 --- a/kordophone-db/src/repository.rs +++ b/kordophone-db/src/repository.rs @@ -3,7 +3,6 @@ use diesel::prelude::*; use diesel::query_dsl::BelongingToDsl; use crate::{ - database::Database, models::{ Conversation, Message, @@ -20,12 +19,12 @@ use crate::{ }; pub struct Repository<'a> { - db: &'a mut Database, + connection: &'a mut SqliteConnection, } impl<'a> Repository<'a> { - pub fn new(db: &'a mut Database) -> Self { - Self { db } + pub fn new(connection: &'a mut SqliteConnection) -> Self { + Self { connection } } pub fn insert_conversation(&mut self, conversation: Conversation) -> Result<()> { @@ -37,25 +36,25 @@ impl<'a> Repository<'a> { diesel::replace_into(conversations) .values(&db_conversation) - .execute(&mut self.db.connection)?; + .execute(self.connection)?; diesel::replace_into(participants) .values(&db_participants) - .execute(&mut self.db.connection)?; + .execute(self.connection)?; // Sqlite backend doesn't support batch insert, so we have to do this manually for participant in db_participants { let pid = participants .select(schema::participants::id) .filter(schema::participants::display_name.eq(&participant.display_name)) - .first::(&mut self.db.connection)?; + .first::(self.connection)?; diesel::replace_into(conversation_participants) .values(( conversation_id.eq(&db_conversation.id), participant_id.eq(pid), )) - .execute(&mut self.db.connection)?; + .execute(self.connection)?; } Ok(()) @@ -67,14 +66,14 @@ impl<'a> Repository<'a> { let result = conversations .find(match_guid) - .first::(&mut self.db.connection) + .first::(self.connection) .optional()?; if let Some(conversation) = result { let db_participants = ConversationParticipant::belonging_to(&conversation) .inner_join(participants) .select(ParticipantRecord::as_select()) - .load::(&mut self.db.connection)?; + .load::(self.connection)?; let mut model_conversation: Conversation = conversation.into(); model_conversation.participants = db_participants.into_iter().map(|p| p.into()).collect(); @@ -90,14 +89,14 @@ impl<'a> Repository<'a> { use crate::schema::participants::dsl::*; let db_conversations = conversations - .load::(&mut self.db.connection)?; + .load::(self.connection)?; let mut result = Vec::new(); for db_conversation in db_conversations { let db_participants = ConversationParticipant::belonging_to(&db_conversation) .inner_join(participants) .select(ParticipantRecord::as_select()) - .load::(&mut self.db.connection)?; + .load::(self.connection)?; let mut model_conversation: Conversation = db_conversation.into(); model_conversation.participants = db_participants.into_iter().map(|p| p.into()).collect(); @@ -119,14 +118,14 @@ impl<'a> Repository<'a> { diesel::replace_into(messages) .values(&db_message) - .execute(&mut self.db.connection)?; + .execute(self.connection)?; diesel::replace_into(conversation_messages) .values(( conversation_id.eq(conversation_guid), message_id.eq(&db_message.id), )) - .execute(&mut self.db.connection)?; + .execute(self.connection)?; Ok(()) } @@ -141,7 +140,7 @@ impl<'a> Repository<'a> { .inner_join(messages) .select(MessageRecord::as_select()) .order_by(schema::messages::date.asc()) - .load::(&mut self.db.connection)?; + .load::(self.connection)?; let mut result = Vec::new(); for message_record in message_records { @@ -151,7 +150,7 @@ impl<'a> Repository<'a> { if let Some(pid) = message_record.sender_participant_id { let participant = participants .find(pid) - .first::(&mut self.db.connection)?; + .first::(self.connection)?; message.sender = participant.into(); } @@ -166,7 +165,7 @@ impl<'a> Repository<'a> { // Huge caveat with this is that it depends on whatever the last insert was, prevents concurrent inserts. fn last_insert_id(&mut self) -> Result { Ok(diesel::select(diesel::dsl::sql::("last_insert_rowid()")) - .get_result(&mut self.db.connection)?) + .get_result(self.connection)?) } fn get_or_create_participant(&mut self, participant: &Participant) -> Option { @@ -177,7 +176,7 @@ impl<'a> Repository<'a> { let existing_participant = participants .filter(display_name.eq(p_name)) - .first::(&mut self.db.connection) + .first::(self.connection) .optional() .unwrap(); @@ -192,7 +191,7 @@ impl<'a> Repository<'a> { diesel::insert_into(participants) .values(&participant_record) - .execute(&mut self.db.connection) + .execute(self.connection) .unwrap(); self.last_insert_id().ok() diff --git a/kordophone-db/src/settings.rs b/kordophone-db/src/settings.rs index ae6175a..4c14dcb 100644 --- a/kordophone-db/src/settings.rs +++ b/kordophone-db/src/settings.rs @@ -1,7 +1,7 @@ use diesel::*; use serde::{Serialize, de::DeserializeOwned}; use anyhow::Result; -use crate::database::Database; + #[derive(Insertable, Queryable, AsChangeset)] #[diesel(table_name = crate::schema::settings)] struct SettingsRow<'a> { @@ -10,12 +10,12 @@ struct SettingsRow<'a> { } pub struct Settings<'a> { - db: &'a mut Database, + connection: &'a mut SqliteConnection, } impl<'a> Settings<'a> { - pub fn new(db: &'a mut Database) -> Self { - Self { db } + pub fn new(connection: &'a mut SqliteConnection) -> Self { + Self { connection } } pub fn put( @@ -31,7 +31,7 @@ impl<'a> Settings<'a> { .on_conflict(key) .do_update() .set(value.eq(&bytes)) - .execute(&mut self.db.connection)?; + .execute(self.connection)?; Ok(()) } @@ -44,7 +44,7 @@ impl<'a> Settings<'a> { let blob: Option> = settings .select(value) .filter(key.eq(k)) - .first(&mut self.db.connection) + .first(self.connection) .optional()?; Ok(match blob { @@ -55,14 +55,14 @@ impl<'a> Settings<'a> { pub fn del(&mut self, k: &str) -> Result { use crate::schema::settings::dsl::*; - Ok(diesel::delete(settings.filter(key.eq(k))).execute(&mut self.db.connection)?) + Ok(diesel::delete(settings.filter(key.eq(k))).execute(self.connection)?) } pub fn list_keys(&mut self) -> Result> { use crate::schema::settings::dsl::*; let keys: Vec = settings .select(key) - .load(&mut self.db.connection)?; + .load(self.connection)?; Ok(keys) } diff --git a/kordophone-db/src/tests/mod.rs b/kordophone-db/src/tests/mod.rs index 961f486..535becc 100644 --- a/kordophone-db/src/tests/mod.rs +++ b/kordophone-db/src/tests/mod.rs @@ -28,222 +28,221 @@ fn participants_vec_equal_ignoring_id(a: &[Participant], b: &[Participant]) -> b #[test] fn test_database_init() { - let mut db = Database::new_in_memory().unwrap(); - let _ = Repository::new(&mut db); + let _ = Database::new_in_memory().unwrap(); } #[test] fn test_add_conversation() { let mut db = Database::new_in_memory().unwrap(); - let mut repository = db.get_repository(); + db.with_repository(|repository| { + let guid = "test"; + let test_conversation = Conversation::builder() + .guid(guid) + .unread_count(2) + .display_name("Test Conversation") + .build(); - let guid = "test"; - let test_conversation = Conversation::builder() - .guid(guid) - .unread_count(2) - .display_name("Test Conversation") - .build(); + repository.insert_conversation(test_conversation.clone()).unwrap(); - repository.insert_conversation(test_conversation.clone()).unwrap(); + // Try to fetch with id now + let conversation = repository.get_conversation_by_guid(guid).unwrap().unwrap(); + assert_eq!(conversation.guid, "test"); - // Try to fetch with id now - let conversation = repository.get_conversation_by_guid(guid).unwrap().unwrap(); - assert_eq!(conversation.guid, "test"); + // Modify the conversation and update it + let modified_conversation = test_conversation.into_builder() + .display_name("Modified Conversation") + .build(); - // Modify the conversation and update it - let modified_conversation = test_conversation.into_builder() - .display_name("Modified Conversation") - .build(); + repository.insert_conversation(modified_conversation.clone()).unwrap(); - repository.insert_conversation(modified_conversation.clone()).unwrap(); + // Make sure we still only have one conversation. + let all_conversations = repository.all_conversations().unwrap(); + assert_eq!(all_conversations.len(), 1); - // Make sure we still only have one conversation. - let all_conversations = repository.all_conversations().unwrap(); - assert_eq!(all_conversations.len(), 1); - - // And make sure the display name was updated - let conversation = repository.get_conversation_by_guid(guid).unwrap().unwrap(); - assert_eq!(conversation.display_name.unwrap(), "Modified Conversation"); + // And make sure the display name was updated + let conversation = repository.get_conversation_by_guid(guid).unwrap().unwrap(); + assert_eq!(conversation.display_name.unwrap(), "Modified Conversation"); + }); } #[test] fn test_conversation_participants() { let mut db = Database::new_in_memory().unwrap(); - let mut repository = db.get_repository(); + db.with_repository(|repository| { + let participants: Vec = vec!["one".into(), "two".into()]; - let participants: Vec = vec!["one".into(), "two".into()]; + let guid = uuid::Uuid::new_v4().to_string(); + let conversation = ConversationBuilder::new() + .guid(&guid) + .display_name("Test") + .participants(participants.clone()) + .build(); - let guid = uuid::Uuid::new_v4().to_string(); - let conversation = ConversationBuilder::new() - .guid(&guid) - .display_name("Test") - .participants(participants.clone()) - .build(); + repository.insert_conversation(conversation).unwrap(); - repository.insert_conversation(conversation).unwrap(); + let read_conversation = repository.get_conversation_by_guid(&guid).unwrap().unwrap(); + let read_participants = read_conversation.participants; - let read_conversation = repository.get_conversation_by_guid(&guid).unwrap().unwrap(); - let read_participants = read_conversation.participants; + assert!(participants_vec_equal_ignoring_id(&participants, &read_participants)); - assert!(participants_vec_equal_ignoring_id(&participants, &read_participants)); + // Try making another conversation with the same participants + let conversation = ConversationBuilder::new() + .display_name("A Different Test") + .participants(participants.clone()) + .build(); - // Try making another conversation with the same participants - let conversation = ConversationBuilder::new() - .display_name("A Different Test") - .participants(participants.clone()) - .build(); + repository.insert_conversation(conversation).unwrap(); - repository.insert_conversation(conversation).unwrap(); + let read_conversation = repository.get_conversation_by_guid(&guid).unwrap().unwrap(); + let read_participants: Vec = read_conversation.participants; - let read_conversation = repository.get_conversation_by_guid(&guid).unwrap().unwrap(); - let read_participants: Vec = read_conversation.participants; - - assert!(participants_vec_equal_ignoring_id(&participants, &read_participants)); + assert!(participants_vec_equal_ignoring_id(&participants, &read_participants)); + }); } #[test] fn test_all_conversations_with_participants() { let mut db = Database::new_in_memory().unwrap(); - let mut repository = db.get_repository(); + db.with_repository(|repository| { + // Create two conversations with different participants + let participants1: Vec = vec!["one".into(), "two".into()]; + let participants2: Vec = vec!["three".into(), "four".into()]; - // Create two conversations with different participants - let participants1: Vec = vec!["one".into(), "two".into()]; - let participants2: Vec = vec!["three".into(), "four".into()]; + let guid1 = uuid::Uuid::new_v4().to_string(); + let conversation1 = ConversationBuilder::new() + .guid(&guid1) + .display_name("Test 1") + .participants(participants1.clone()) + .build(); - let guid1 = uuid::Uuid::new_v4().to_string(); - let conversation1 = ConversationBuilder::new() - .guid(&guid1) - .display_name("Test 1") - .participants(participants1.clone()) - .build(); + let guid2 = uuid::Uuid::new_v4().to_string(); + let conversation2 = ConversationBuilder::new() + .guid(&guid2) + .display_name("Test 2") + .participants(participants2.clone()) + .build(); - let guid2 = uuid::Uuid::new_v4().to_string(); - let conversation2 = ConversationBuilder::new() - .guid(&guid2) - .display_name("Test 2") - .participants(participants2.clone()) - .build(); + // Insert both conversations + repository.insert_conversation(conversation1).unwrap(); + repository.insert_conversation(conversation2).unwrap(); - // Insert both conversations - repository.insert_conversation(conversation1).unwrap(); - repository.insert_conversation(conversation2).unwrap(); + // Get all conversations and verify the results + let all_conversations = repository.all_conversations().unwrap(); + assert_eq!(all_conversations.len(), 2); - // Get all conversations and verify the results - let all_conversations = repository.all_conversations().unwrap(); - assert_eq!(all_conversations.len(), 2); + // Find and verify each conversation's participants + let conv1 = all_conversations.iter().find(|c| c.guid == guid1).unwrap(); + let conv2 = all_conversations.iter().find(|c| c.guid == guid2).unwrap(); - // Find and verify each conversation's participants - let conv1 = all_conversations.iter().find(|c| c.guid == guid1).unwrap(); - let conv2 = all_conversations.iter().find(|c| c.guid == guid2).unwrap(); - - assert!(participants_vec_equal_ignoring_id(&conv1.participants, &participants1)); - assert!(participants_vec_equal_ignoring_id(&conv2.participants, &participants2)); + assert!(participants_vec_equal_ignoring_id(&conv1.participants, &participants1)); + assert!(participants_vec_equal_ignoring_id(&conv2.participants, &participants2)); + }); } #[test] fn test_messages() { let mut db = Database::new_in_memory().unwrap(); - let mut repository = db.get_repository(); + db.with_repository(|repository| { + // First create a conversation with participants + let participants = vec!["Alice".into(), "Bob".into()]; + let conversation = ConversationBuilder::new() + .display_name("Test Chat") + .participants(participants) + .build(); + let conversation_id = conversation.guid.clone(); - // First create a conversation with participants - let participants = vec!["Alice".into(), "Bob".into()]; - let conversation = ConversationBuilder::new() - .display_name("Test Chat") - .participants(participants) - .build(); - let conversation_id = conversation.guid.clone(); + repository.insert_conversation(conversation).unwrap(); - repository.insert_conversation(conversation).unwrap(); + // Create and insert a message from Me + let message1 = Message::builder() + .text("Hello everyone!".to_string()) + .build(); - // Create and insert a message from Me - let message1 = Message::builder() - .text("Hello everyone!".to_string()) - .build(); + // Create and insert a message from a remote participant + let message2 = Message::builder() + .text("Hi there!".to_string()) + .sender("Alice".into()) + .build(); - // Create and insert a message from a remote participant - let message2 = Message::builder() - .text("Hi there!".to_string()) - .sender("Alice".into()) - .build(); + // Insert both messages + repository.insert_message(&conversation_id, message1.clone()).unwrap(); + repository.insert_message(&conversation_id, message2.clone()).unwrap(); - // Insert both messages - repository.insert_message(&conversation_id, message1.clone()).unwrap(); - repository.insert_message(&conversation_id, message2.clone()).unwrap(); + // Retrieve messages + let messages = repository.get_messages_for_conversation(&conversation_id).unwrap(); + assert_eq!(messages.len(), 2); - // Retrieve messages - let messages = repository.get_messages_for_conversation(&conversation_id).unwrap(); - assert_eq!(messages.len(), 2); + // Verify first message (from Me) + let retrieved_message1 = messages.iter().find(|m| m.id == message1.id).unwrap(); + assert_eq!(retrieved_message1.text, "Hello everyone!"); + assert!(matches!(retrieved_message1.sender, Participant::Me)); - // Verify first message (from Me) - let retrieved_message1 = messages.iter().find(|m| m.id == message1.id).unwrap(); - assert_eq!(retrieved_message1.text, "Hello everyone!"); - assert!(matches!(retrieved_message1.sender, Participant::Me)); - - // Verify second message (from Alice) - let retrieved_message2 = messages.iter().find(|m| m.id == message2.id).unwrap(); - assert_eq!(retrieved_message2.text, "Hi there!"); - if let Participant::Remote { display_name, .. } = &retrieved_message2.sender { - assert_eq!(display_name, "Alice"); - } else { - panic!("Expected Remote participant. Got: {:?}", retrieved_message2.sender); - } + // Verify second message (from Alice) + let retrieved_message2 = messages.iter().find(|m| m.id == message2.id).unwrap(); + assert_eq!(retrieved_message2.text, "Hi there!"); + if let Participant::Remote { display_name, .. } = &retrieved_message2.sender { + assert_eq!(display_name, "Alice"); + } else { + panic!("Expected Remote participant. Got: {:?}", retrieved_message2.sender); + } + }); } #[test] fn test_message_ordering() { let mut db = Database::new_in_memory().unwrap(); - let mut repository = db.get_repository(); + db.with_repository(|repository| { + // Create a conversation + let conversation = ConversationBuilder::new() + .display_name("Test Chat") + .build(); + let conversation_id = conversation.guid.clone(); + repository.insert_conversation(conversation).unwrap(); - // Create a conversation - let conversation = ConversationBuilder::new() - .display_name("Test Chat") - .build(); - let conversation_id = conversation.guid.clone(); - repository.insert_conversation(conversation).unwrap(); + // Create messages with specific timestamps + let now = chrono::Utc::now().naive_utc(); + let message1 = Message::builder() + .text("First message".to_string()) + .date(now) + .build(); - // Create messages with specific timestamps - let now = chrono::Utc::now().naive_utc(); - let message1 = Message::builder() - .text("First message".to_string()) - .date(now) - .build(); + let message2 = Message::builder() + .text("Second message".to_string()) + .date(now + chrono::Duration::minutes(1)) + .build(); - let message2 = Message::builder() - .text("Second message".to_string()) - .date(now + chrono::Duration::minutes(1)) - .build(); + let message3 = Message::builder() + .text("Third message".to_string()) + .date(now + chrono::Duration::minutes(2)) + .build(); - let message3 = Message::builder() - .text("Third message".to_string()) - .date(now + chrono::Duration::minutes(2)) - .build(); + // Insert messages + repository.insert_message(&conversation_id, message1).unwrap(); + repository.insert_message(&conversation_id, message2).unwrap(); + repository.insert_message(&conversation_id, message3).unwrap(); - // Insert messages - repository.insert_message(&conversation_id, message1).unwrap(); - repository.insert_message(&conversation_id, message2).unwrap(); - repository.insert_message(&conversation_id, message3).unwrap(); + // Retrieve messages and verify order + let messages = repository.get_messages_for_conversation(&conversation_id).unwrap(); + assert_eq!(messages.len(), 3); - // Retrieve messages and verify order - let messages = repository.get_messages_for_conversation(&conversation_id).unwrap(); - assert_eq!(messages.len(), 3); - - // Messages should be ordered by date - for i in 1..messages.len() { - assert!(messages[i].date > messages[i-1].date); - } + // Messages should be ordered by date + for i in 1..messages.len() { + assert!(messages[i].date > messages[i-1].date); + } + }); } #[test] fn test_settings() { let mut db = Database::new_in_memory().unwrap(); - let mut settings = db.get_settings(); + db.with_settings(|settings| { + settings.put("test", &"test".to_string()).unwrap(); + assert_eq!(settings.get::("test").unwrap().unwrap(), "test"); - settings.put("test", &"test".to_string()).unwrap(); - assert_eq!(settings.get::("test").unwrap().unwrap(), "test"); + settings.del("test").unwrap(); + assert!(settings.get::("test").unwrap().is_none()); - settings.del("test").unwrap(); - assert!(settings.get::("test").unwrap().is_none()); - - let keys = settings.list_keys().unwrap(); - assert_eq!(keys.len(), 0); + let keys = settings.list_keys().unwrap(); + assert_eq!(keys.len(), 0); + }); } diff --git a/kpcli/src/db/mod.rs b/kpcli/src/db/mod.rs index 73f5dc8..a183938 100644 --- a/kpcli/src/db/mod.rs +++ b/kpcli/src/db/mod.rs @@ -108,7 +108,9 @@ impl DbClient { } pub fn print_conversations(&mut self) -> Result<()> { - let all_conversations = self.database.get_repository().all_conversations()?; + let all_conversations = self.database.with_repository(|repository| { + repository.all_conversations() + })?; println!("{} Conversations: ", all_conversations.len()); for conversation in all_conversations { @@ -119,7 +121,10 @@ impl DbClient { } pub async fn print_messages(&mut self, conversation_id: &str) -> Result<()> { - let messages = self.database.get_repository().get_messages_for_conversation(conversation_id)?; + let messages = self.database.with_repository(|repository| { + repository.get_messages_for_conversation(conversation_id) + })?; + for message in messages { println!("{}", MessagePrinter::new(&message.into())); } @@ -133,10 +138,14 @@ impl DbClient { .map(|c| kordophone_db::models::Conversation::from(c)) .collect(); - let mut repository = self.database.get_repository(); + // Process each conversation for conversation in db_conversations { let conversation_id = conversation.guid.clone(); - repository.insert_conversation(conversation)?; + + // Insert the conversation + self.database.with_repository(|repository| { + repository.insert_conversation(conversation) + })?; // Fetch and sync messages for this conversation let messages = client.get_messages(&conversation_id).await?; @@ -144,59 +153,66 @@ impl DbClient { .map(|m| kordophone_db::models::Message::from(m)) .collect(); - for message in db_messages { - repository.insert_message(&conversation_id, message)?; - } + // Insert each message + self.database.with_repository(|repository| -> Result<()> { + for message in db_messages { + repository.insert_message(&conversation_id, message)?; + } + + Ok(()) + })?; } Ok(()) } pub fn get_setting(&mut self, key: Option) -> Result<()> { - let mut settings = self.database.get_settings(); - - match key { - Some(key) => { - // Get a specific setting - let value: Option = settings.get(&key)?; - match value { - Some(v) => println!("{} = {}", key, v), - None => println!("Setting '{}' not found", key), - } - }, - None => { - // List all settings - let keys = settings.list_keys()?; - if keys.is_empty() { - println!("No settings found"); - } else { - println!("Settings:"); - for key in keys { - let value: Option = settings.get(&key)?; - match value { - Some(v) => println!(" {} = {}", key, v), - None => println!(" {} = ", key), + self.database.with_settings(|settings| { + match key { + Some(key) => { + // Get a specific setting + let value: Option = settings.get(&key)?; + match value { + Some(v) => println!("{} = {}", key, v), + None => println!("Setting '{}' not found", key), + } + }, + None => { + // List all settings + let keys = settings.list_keys()?; + if keys.is_empty() { + println!("No settings found"); + } else { + println!("Settings:"); + for key in keys { + let value: Option = settings.get(&key)?; + match value { + Some(v) => println!(" {} = {}", key, v), + None => println!(" {} = ", key), + } } } } } - } - - Ok(()) + + Ok(()) + }) } pub fn put_setting(&mut self, key: String, value: String) -> Result<()> { - let mut settings = self.database.get_settings(); - settings.put(&key, &value)?; - Ok(()) + self.database.with_settings(|settings| { + settings.put(&key, &value)?; + Ok(()) + }) } pub fn delete_setting(&mut self, key: String) -> Result<()> { - let mut settings = self.database.get_settings(); - let count = settings.del(&key)?; - if count == 0 { - println!("Setting '{}' not found", key); - } - Ok(()) + self.database.with_settings(|settings| { + let count = settings.del(&key)?; + if count == 0 { + println!("Setting '{}' not found", key); + } + Ok(()) + }) } } From 0c6b55fa38a243ad79f5ec8cbd0de51e42c858c3 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 25 Apr 2025 16:54:37 -0700 Subject: [PATCH 033/138] kordophoned: better daemon bootstrapping --- Cargo.lock | 163 +++++++++++++++++++++------- kordophone/src/lib.rs | 16 +-- kordophoned/Cargo.toml | 3 + kordophoned/src/daemon/mod.rs | 41 ++++++- kordophoned/src/dbus/endpoint.rs | 11 +- kordophoned/src/dbus/server_impl.rs | 24 +++- kordophoned/src/main.rs | 11 +- 7 files changed, 202 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ca1bd19..6679ace 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,7 +86,7 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" dependencies = [ - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -96,14 +96,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" dependencies = [ "anstyle", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] name = "anyhow" -version = "1.0.94" +version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" [[package]] name = "arrayvec" @@ -216,7 +216,7 @@ dependencies = [ "js-sys", "num-traits", "wasm-bindgen", - "windows-targets 0.52.4", + "windows-targets 0.52.6", ] [[package]] @@ -449,6 +449,27 @@ dependencies = [ "syn", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.59.0", +] + [[package]] name = "dotenv" version = "0.15.0" @@ -511,7 +532,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -819,13 +840,16 @@ dependencies = [ name = "kordophoned" version = "0.1.0" dependencies = [ + "anyhow", "dbus", "dbus-codegen", "dbus-crossroads", "dbus-tokio", "dbus-tree", + "directories", "env_logger", "kordophone", + "kordophone-db", "log", "tokio", ] @@ -869,6 +893,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.5.0", + "libc", +] + [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -946,7 +980,7 @@ dependencies = [ "hermit-abi 0.3.9", "libc", "wasi", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -1041,6 +1075,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking_lot" version = "0.12.1" @@ -1163,6 +1203,17 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.11.1" @@ -1208,7 +1259,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -1223,7 +1274,7 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" dependencies = [ - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -1335,7 +1386,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -1370,7 +1421,7 @@ dependencies = [ "cfg-if", "fastrand", "rustix", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -1391,6 +1442,26 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "time" version = "0.3.37" @@ -1437,7 +1508,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -1690,7 +1761,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -1705,7 +1776,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets 0.52.4", + "windows-targets 0.52.6", ] [[package]] @@ -1714,7 +1785,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.4", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -1734,17 +1814,18 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.4", - "windows_aarch64_msvc 0.52.4", - "windows_i686_gnu 0.52.4", - "windows_i686_msvc 0.52.4", - "windows_x86_64_gnu 0.52.4", - "windows_x86_64_gnullvm 0.52.4", - "windows_x86_64_msvc 0.52.4", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -1755,9 +1836,9 @@ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" @@ -1767,9 +1848,9 @@ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" @@ -1779,9 +1860,15 @@ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" @@ -1791,9 +1878,9 @@ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" @@ -1803,9 +1890,9 @@ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" @@ -1815,9 +1902,9 @@ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" @@ -1827,9 +1914,9 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" diff --git a/kordophone/src/lib.rs b/kordophone/src/lib.rs index bb69a38..f93f108 100644 --- a/kordophone/src/lib.rs +++ b/kordophone/src/lib.rs @@ -2,20 +2,6 @@ pub mod api; pub mod model; pub use self::api::APIInterface; -use ctor::ctor; #[cfg(test)] -pub mod tests; - -extern crate env_logger; - -fn initialize_logging() { - env_logger::Builder::from_default_env() - .format_timestamp_secs() - .init(); -} - -#[ctor] -fn init() { - initialize_logging(); -} +pub mod tests; \ No newline at end of file diff --git a/kordophoned/Cargo.toml b/kordophoned/Cargo.toml index 208b953..7af3d47 100644 --- a/kordophoned/Cargo.toml +++ b/kordophoned/Cargo.toml @@ -4,12 +4,15 @@ version = "0.1.0" edition = "2021" [dependencies] +anyhow = "1.0.98" dbus = "0.9.7" dbus-crossroads = "0.5.2" dbus-tokio = "0.7.6" dbus-tree = "0.9.2" +directories = "6.0.0" env_logger = "0.11.6" kordophone = { path = "../kordophone" } +kordophone-db = { path = "../kordophone-db" } log = "0.4.25" tokio = { version = "1", features = ["full"] } diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 0cdb6aa..178abac 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -1,9 +1,46 @@ +use directories::ProjectDirs; +use std::path::PathBuf; +use anyhow::Result; + +use kordophone_db::{ + database::Database, + settings::Settings, + models::Conversation, +}; + pub struct Daemon { pub version: String, + database: Database, } impl Daemon { - pub fn new() -> Self { - Self { version: "0.1.0".to_string() } + pub fn new() -> Result { + let database_path = Self::get_database_path(); + log::info!("Database path: {}", database_path.display()); + + // Create the database directory if it doesn't exist + let database_dir = database_path.parent().unwrap(); + std::fs::create_dir_all(database_dir)?; + + let database = Database::new(&database_path.to_string_lossy())?; + Ok(Self { version: "0.1.0".to_string(), database }) + } + + pub fn get_version(&self) -> String { + self.version.clone() + } + + pub fn get_conversations(&mut self) -> Vec { + self.database.with_repository(|r| r.all_conversations().unwrap()) + } + + fn get_database_path() -> PathBuf { + if let Some(proj_dirs) = ProjectDirs::from("com", "kordophone", "kordophone") { + let data_dir = proj_dirs.data_dir(); + data_dir.join("database.db") + } else { + // Fallback to a local path if we can't get the system directories + PathBuf::from("database.db") + } } } \ No newline at end of file diff --git a/kordophoned/src/dbus/endpoint.rs b/kordophoned/src/dbus/endpoint.rs index 33cb906..e1f3cce 100644 --- a/kordophoned/src/dbus/endpoint.rs +++ b/kordophoned/src/dbus/endpoint.rs @@ -1,5 +1,5 @@ use log::info; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use crate::{daemon::Daemon, dbus::interface}; use dbus_crossroads::Crossroads; @@ -13,11 +13,11 @@ use dbus::{ pub struct Endpoint { connection: Arc, - daemon: Arc, + daemon: Arc>, } impl Endpoint { - pub fn new(daemon: Arc) -> Self { + pub fn new(daemon: Daemon) -> Self { let (resource, connection) = connection::new_session_sync().unwrap(); // The resource is a task that should be spawned onto a tokio compatible @@ -29,7 +29,10 @@ impl Endpoint { panic!("Lost connection to D-Bus: {}", err); }); - Self { connection, daemon } + Self { + connection, + daemon: Arc::new(Mutex::new(daemon)) + } } pub async fn start(&self) { diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index b356a81..a1dae48 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -1,16 +1,30 @@ use dbus::arg; use dbus_tree::MethodErr; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use crate::daemon::Daemon; use crate::dbus::interface::NetBuzzertKordophoneServer as DbusServer; -impl DbusServer for Arc { +impl DbusServer for Arc> { fn get_version(&mut self) -> Result { - Ok(self.version.clone()) + let daemon = self.lock().map_err(|_| MethodErr::failed("Failed to lock daemon"))?; + Ok(daemon.version.clone()) } - + fn get_conversations(&mut self) -> Result, dbus::MethodErr> { - todo!() + // Get a repository instance and use it to fetch conversations + let mut daemon = self.lock().map_err(|_| MethodErr::failed("Failed to lock daemon"))?; + let conversations = daemon.get_conversations(); + + // Convert conversations to DBus property maps + let result = conversations.into_iter().map(|conv| { + let mut map = arg::PropMap::new(); + map.insert("guid".into(), arg::Variant(Box::new(conv.guid))); + map.insert("display_name".into(), arg::Variant(Box::new(conv.display_name.unwrap_or_default()))); + map.insert("unread_count".into(), arg::Variant(Box::new(conv.unread_count as i32))); + map + }).collect(); + + Ok(result) } } \ No newline at end of file diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index 71b75d5..a63db94 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -2,7 +2,6 @@ mod dbus; mod daemon; use std::future; -use std::sync::Arc; use log::LevelFilter; use daemon::Daemon; @@ -19,9 +18,15 @@ fn initialize_logging() { async fn main() { initialize_logging(); - // Daemon is stored in an Arc so it can be shared with other endpoints eventually. - let daemon = Arc::new(Daemon::new()); + // Create the daemon + let daemon = Daemon::new() + .map_err(|e| { + log::error!("Failed to start daemon: {}", e); + std::process::exit(1); + }) + .unwrap(); + // Create the D-Bus endpoint let endpoint = DbusEndpoint::new(daemon); endpoint.start().await; From fe32efef2c2b11d5be0cfcaf07c4fd7ff9f4e361 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 25 Apr 2025 18:02:54 -0700 Subject: [PATCH 034/138] daemon: scaffolding for settings / sync --- Cargo.lock | 1 + kordophoned/Cargo.toml | 1 + .../net.buzzert.kordophonecd.Server.xml | 38 +++++++++- kordophoned/src/daemon/mod.rs | 28 +++++-- kordophoned/src/dbus/endpoint.rs | 38 ++++++---- kordophoned/src/dbus/mod.rs | 6 +- kordophoned/src/dbus/server_impl.rs | 74 +++++++++++++++++-- kordophoned/src/main.rs | 37 +++++++--- kpcli/src/daemon/mod.rs | 24 +++++- kpcli/src/main.rs | 2 +- 10 files changed, 204 insertions(+), 45 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6679ace..12cbe08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -851,6 +851,7 @@ dependencies = [ "kordophone", "kordophone-db", "log", + "thiserror", "tokio", ] diff --git a/kordophoned/Cargo.toml b/kordophoned/Cargo.toml index 7af3d47..d5c6ac7 100644 --- a/kordophoned/Cargo.toml +++ b/kordophoned/Cargo.toml @@ -14,6 +14,7 @@ env_logger = "0.11.6" kordophone = { path = "../kordophone" } kordophone-db = { path = "../kordophone-db" } log = "0.4.25" +thiserror = "2.0.12" tokio = { version = "1", features = ["full"] } [build-dependencies] diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index 954009f..760eda9 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -1,6 +1,7 @@ - - - + + + @@ -15,5 +16,36 @@ 'is_unread' (boolean): Unread status"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 178abac..751d639 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -1,16 +1,24 @@ use directories::ProjectDirs; use std::path::PathBuf; use anyhow::Result; - +use thiserror::Error; use kordophone_db::{ database::Database, - settings::Settings, models::Conversation, }; +use kordophone::api::http_client::HTTPAPIClient; + +#[derive(Debug, Error)] +pub enum DaemonError { + #[error("Client Not Configured")] + ClientNotConfigured, +} + pub struct Daemon { pub version: String, database: Database, + client: Option, } impl Daemon { @@ -23,17 +31,25 @@ impl Daemon { std::fs::create_dir_all(database_dir)?; let database = Database::new(&database_path.to_string_lossy())?; - Ok(Self { version: "0.1.0".to_string(), database }) - } - pub fn get_version(&self) -> String { - self.version.clone() + // TODO: Check to see if we have client settings in the database + + + Ok(Self { version: "0.1.0".to_string(), database, client: None }) } pub fn get_conversations(&mut self) -> Vec { self.database.with_repository(|r| r.all_conversations().unwrap()) } + pub fn sync_all_conversations(&mut self) -> Result<()> { + let client = self.client + .as_mut() + .ok_or(DaemonError::ClientNotConfigured)?; + + Ok(()) + } + fn get_database_path() -> PathBuf { if let Some(proj_dirs) = ProjectDirs::from("com", "kordophone", "kordophone") { let data_dir = proj_dirs.data_dir(); diff --git a/kordophoned/src/dbus/endpoint.rs b/kordophoned/src/dbus/endpoint.rs index e1f3cce..8e9d4f6 100644 --- a/kordophoned/src/dbus/endpoint.rs +++ b/kordophoned/src/dbus/endpoint.rs @@ -1,6 +1,5 @@ use log::info; use std::sync::{Arc, Mutex}; -use crate::{daemon::Daemon, dbus::interface}; use dbus_crossroads::Crossroads; use dbus_tokio::connection; @@ -11,13 +10,13 @@ use dbus::{ Path, }; -pub struct Endpoint { +pub struct Endpoint { connection: Arc, - daemon: Arc>, + implementation: T, } -impl Endpoint { - pub fn new(daemon: Daemon) -> Self { +impl Endpoint { + pub fn new(implementation: T) -> Self { let (resource, connection) = connection::new_session_sync().unwrap(); // The resource is a task that should be spawned onto a tokio compatible @@ -31,15 +30,24 @@ impl Endpoint { Self { connection, - daemon: Arc::new(Mutex::new(daemon)) + implementation } } - pub async fn start(&self) { - use crate::dbus::interface; + pub async fn register( + &self, + name: &str, + path: &str, + register_fn: F + ) + where + F: Fn(&mut Crossroads) -> R, + R: IntoIterator>, + { + let dbus_path = String::from(path); self.connection - .request_name(interface::NAME, false, true, false) + .request_name(name, false, true, false) .await .expect("Unable to acquire dbus name"); @@ -54,9 +62,9 @@ impl Endpoint { }), ))); - // Register the daemon as a D-Bus object. - let token = interface::register_net_buzzert_kordophone_server(&mut cr); - cr.insert(interface::OBJECT_PATH, &[token], self.daemon.clone()); + // Register the daemon as a D-Bus object with multiple interfaces + let tokens: Vec<_> = register_fn(&mut cr).into_iter().collect(); + cr.insert(dbus_path, &tokens, self.implementation.clone()); // Start receiving messages. self.connection.start_receive( @@ -66,14 +74,14 @@ impl Endpoint { ), ); - info!(target: "dbus", "DBus server started"); + info!(target: "dbus", "Registered endpoint at {} with {} interfaces", path, tokens.len()); } - pub fn send_signal(&self, signal: S) -> Result + pub fn send_signal(&self, path: &str, signal: S) -> Result where S: dbus::message::SignalArgs + dbus::arg::AppendAll, { - let message = signal.to_emit_message(&Path::new(interface::OBJECT_PATH).unwrap()); + let message = signal.to_emit_message(&Path::new(path).unwrap()); self.connection.send(message) } } diff --git a/kordophoned/src/dbus/mod.rs b/kordophoned/src/dbus/mod.rs index ff97742..66c8455 100644 --- a/kordophoned/src/dbus/mod.rs +++ b/kordophoned/src/dbus/mod.rs @@ -1,11 +1,11 @@ pub mod endpoint; -mod server_impl; +pub mod server_impl; -mod interface { +pub mod interface { #![allow(unused)] pub const NAME: &str = "net.buzzert.kordophonecd"; - pub const OBJECT_PATH: &str = "/net/buzzert/kordophonecd"; + pub const OBJECT_PATH: &str = "/net/buzzert/kordophonecd/daemon"; include!(concat!(env!("OUT_DIR"), "/kordophone-server.rs")); } \ No newline at end of file diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index a1dae48..f107bd3 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -1,19 +1,36 @@ use dbus::arg; use dbus_tree::MethodErr; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, MutexGuard}; +use log::info; use crate::daemon::Daemon; -use crate::dbus::interface::NetBuzzertKordophoneServer as DbusServer; +use crate::dbus::interface::NetBuzzertKordophoneRepository as DbusRepository; +use crate::dbus::interface::NetBuzzertKordophoneSettings as DbusSettings; -impl DbusServer for Arc> { +#[derive(Clone)] +pub struct ServerImpl { + daemon: Arc>, +} + +impl ServerImpl { + pub fn new(daemon: Arc>) -> Self { + Self { daemon } + } + + pub fn get_daemon(&self) -> Result, MethodErr> { + self.daemon.lock().map_err(|_| MethodErr::failed("Failed to lock daemon")) + } +} + +impl DbusRepository for ServerImpl { fn get_version(&mut self) -> Result { - let daemon = self.lock().map_err(|_| MethodErr::failed("Failed to lock daemon"))?; + let daemon = self.get_daemon()?; Ok(daemon.version.clone()) } fn get_conversations(&mut self) -> Result, dbus::MethodErr> { // Get a repository instance and use it to fetch conversations - let mut daemon = self.lock().map_err(|_| MethodErr::failed("Failed to lock daemon"))?; + let mut daemon = self.get_daemon()?; let conversations = daemon.get_conversations(); // Convert conversations to DBus property maps @@ -27,4 +44,49 @@ impl DbusServer for Arc> { Ok(result) } -} \ No newline at end of file + + fn sync_all_conversations(&mut self) -> Result { + let mut daemon = self.get_daemon()?; + daemon.sync_all_conversations().map_err(|e| { + log::error!("Failed to sync conversations: {}", e); + MethodErr::failed(&format!("Failed to sync conversations: {}", e)) + })?; + + Ok(true) + } +} + +impl DbusSettings for ServerImpl { + fn set_server(&mut self, url: String, user: String) -> Result<(), dbus::MethodErr> { + todo!() + } + + fn set_credential_item_(&mut self, item_path: dbus::Path<'static>) -> Result<(), dbus::MethodErr> { + todo!() + } + + fn server_url(&self) -> Result { + todo!() + } + + fn set_server_url(&self, value: String) -> Result<(), dbus::MethodErr> { + todo!() + } + + fn username(&self) -> Result { + todo!() + } + + fn set_username(&self, value: String) -> Result<(), dbus::MethodErr> { + todo!() + } + + fn credential_item(&self) -> Result, dbus::MethodErr> { + todo!() + } + + fn set_credential_item(&self, value: dbus::Path<'static>) -> Result<(), dbus::MethodErr> { + todo!() + } + +} diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index a63db94..e6772e5 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -2,10 +2,13 @@ mod dbus; mod daemon; use std::future; +use std::sync::{Arc, Mutex}; use log::LevelFilter; use daemon::Daemon; use dbus::endpoint::Endpoint as DbusEndpoint; +use dbus::interface; +use dbus::server_impl::ServerImpl; fn initialize_logging() { env_logger::Builder::from_default_env() @@ -19,16 +22,32 @@ async fn main() { initialize_logging(); // Create the daemon - let daemon = Daemon::new() - .map_err(|e| { - log::error!("Failed to start daemon: {}", e); - std::process::exit(1); - }) - .unwrap(); + let daemon = Arc::new( + Mutex::new( + Daemon::new() + .map_err(|e| { + log::error!("Failed to start daemon: {}", e); + std::process::exit(1); + }) + .unwrap() + ) + ); - // Create the D-Bus endpoint - let endpoint = DbusEndpoint::new(daemon); - endpoint.start().await; + // Create the server implementation + let server = ServerImpl::new(daemon); + + // Register DBus interfaces with endpoint + let endpoint = DbusEndpoint::new(server.clone()); + endpoint.register( + interface::NAME, + interface::OBJECT_PATH, + |cr| { + vec![ + interface::register_net_buzzert_kordophone_repository(cr), + interface::register_net_buzzert_kordophone_settings(cr) + ] + } + ).await; future::pending::<()>().await; unreachable!() diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index 8e5ae49..e7de320 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -10,10 +10,16 @@ mod dbus_interface { include!(concat!(env!("OUT_DIR"), "/kordophone-client.rs")); } -use dbus_interface::NetBuzzertKordophoneServer as KordophoneServer; +use dbus_interface::NetBuzzertKordophoneRepository as KordophoneRepository; #[derive(Subcommand)] pub enum Commands { + /// Gets all known conversations. + Conversations, + + /// Runs a sync operation. + Sync, + /// Prints the server Kordophone version. Version, } @@ -23,6 +29,8 @@ impl Commands { let mut client = DaemonCli::new()?; match cmd { Commands::Version => client.print_version().await, + Commands::Conversations => client.print_conversations().await, + Commands::Sync => client.sync_conversations().await, } } } @@ -43,8 +51,20 @@ impl DaemonCli { } pub async fn print_version(&mut self) -> Result<()> { - let version = KordophoneServer::get_version(&self.proxy())?; + let version = KordophoneRepository::get_version(&self.proxy())?; println!("Server version: {}", version); Ok(()) } + + pub async fn print_conversations(&mut self) -> Result<()> { + let conversations = KordophoneRepository::get_conversations(&self.proxy())?; + println!("Conversations: {:?}", conversations); + Ok(()) + } + + pub async fn sync_conversations(&mut self) -> Result<()> { + let success = KordophoneRepository::sync_all_conversations(&self.proxy())?; + println!("Synced conversations: {}", success); + Ok(()) + } } \ No newline at end of file diff --git a/kpcli/src/main.rs b/kpcli/src/main.rs index 7445699..e0f7743 100644 --- a/kpcli/src/main.rs +++ b/kpcli/src/main.rs @@ -48,6 +48,6 @@ async fn main() { let cli = Cli::parse(); run_command(cli.command).await - .map_err(|e| log::error!("Error: {}", e)) + .map_err(|e| println!("Error: {}", e)) .err(); } From 82192ffbe5beacbb398c698b345c31d711131ef2 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 25 Apr 2025 20:02:18 -0700 Subject: [PATCH 035/138] daemon: setting foundation for client creation --- Cargo.lock | 1 + kordophone-db/src/database.rs | 25 +++++++- kordophone/Cargo.toml | 2 +- kordophone/src/api/mod.rs | 24 +++++++ kordophone/src/model/jwt.rs | 10 +-- kordophoned/src/daemon/mod.rs | 99 +++++++++++++++++++++++++---- kordophoned/src/daemon/settings.rs | 35 ++++++++++ kordophoned/src/dbus/server_impl.rs | 38 +++++++++-- kpcli/src/client/mod.rs | 1 + kpcli/src/daemon/mod.rs | 2 +- 10 files changed, 210 insertions(+), 27 deletions(-) create mode 100644 kordophoned/src/daemon/settings.rs diff --git a/Cargo.lock b/Cargo.lock index 12cbe08..a32f8de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,6 +215,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-targets 0.52.6", ] diff --git a/kordophone-db/src/database.rs b/kordophone-db/src/database.rs index ca53285..55f99c1 100644 --- a/kordophone-db/src/database.rs +++ b/kordophone-db/src/database.rs @@ -4,11 +4,14 @@ use diesel::prelude::*; use crate::repository::Repository; use crate::settings::Settings; +pub use kordophone::api::TokenManagement; +use kordophone::model::JwtToken; + use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); pub struct Database { - connection: SqliteConnection, + pub connection: SqliteConnection, } impl Database { @@ -39,4 +42,22 @@ impl Database { let mut settings = Settings::new(&mut self.connection); f(&mut settings) } -} \ No newline at end of file +} + +static TOKEN_KEY: &str = "token"; + +impl TokenManagement for Database { + fn get_token(&mut self) -> Option { + self.with_settings(|settings| { + let token: Result> = settings.get(TOKEN_KEY); + match token { + Ok(data) => data, + Err(_) => None, + } + }) + } + + fn set_token(&mut self, token: JwtToken) { + self.with_settings(|settings| settings.put(TOKEN_KEY, &token).unwrap()); + } +} diff --git a/kordophone/Cargo.toml b/kordophone/Cargo.toml index 68fbf2b..4d0d2aa 100644 --- a/kordophone/Cargo.toml +++ b/kordophone/Cargo.toml @@ -8,7 +8,7 @@ edition = "2021" [dependencies] async-trait = "0.1.80" base64 = "0.22.1" -chrono = "0.4.38" +chrono = { version = "0.4.38", features = ["serde"] } ctor = "0.2.8" env_logger = "0.11.5" hyper = { version = "0.14", features = ["full"] } diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index 581f35a..6a337b2 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -26,3 +26,27 @@ pub trait APIInterface { async fn authenticate(&mut self, credentials: Credentials) -> Result; } +pub trait TokenManagement { + fn get_token(&mut self) -> Option; + fn set_token(&mut self, token: JwtToken); +} + +pub struct InMemoryTokenManagement { + token: Option, +} + +impl InMemoryTokenManagement { + pub fn new() -> Self { + Self { token: None } + } +} + +impl TokenManagement for InMemoryTokenManagement { + fn get_token(&mut self) -> Option { + self.token.clone() + } + + fn set_token(&mut self, token: JwtToken) { + self.token = Some(token); + } +} diff --git a/kordophone/src/model/jwt.rs b/kordophone/src/model/jwt.rs index 70c86e6..8ef5683 100644 --- a/kordophone/src/model/jwt.rs +++ b/kordophone/src/model/jwt.rs @@ -7,23 +7,23 @@ use base64::{ use chrono::{DateTime, Utc}; use hyper::http::HeaderValue; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] #[allow(dead_code)] struct JwtHeader { alg: String, typ: String, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] #[allow(dead_code)] enum ExpValue { Integer(i64), String(String), } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] #[allow(dead_code)] struct JwtPayload { exp: serde_json::Value, @@ -31,7 +31,7 @@ struct JwtPayload { user: Option, } -#[derive(Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] #[allow(dead_code)] pub struct JwtToken { header: JwtHeader, diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 751d639..17282f1 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -1,13 +1,23 @@ +mod settings; +use settings::Settings; + use directories::ProjectDirs; use std::path::PathBuf; use anyhow::Result; use thiserror::Error; + use kordophone_db::{ database::Database, models::Conversation, + repository::Repository, }; -use kordophone::api::http_client::HTTPAPIClient; +use kordophone::model::JwtToken; +use kordophone::api::{ + http_client::{Credentials, HTTPAPIClient}, + APIInterface, + TokenManagement, +}; #[derive(Debug, Error)] pub enum DaemonError { @@ -18,7 +28,6 @@ pub enum DaemonError { pub struct Daemon { pub version: String, database: Database, - client: Option, } impl Daemon { @@ -31,27 +40,80 @@ impl Daemon { std::fs::create_dir_all(database_dir)?; let database = Database::new(&database_path.to_string_lossy())?; - - // TODO: Check to see if we have client settings in the database - - - Ok(Self { version: "0.1.0".to_string(), database, client: None }) + Ok(Self { version: "0.1.0".to_string(), database }) } pub fn get_conversations(&mut self) -> Vec { self.database.with_repository(|r| r.all_conversations().unwrap()) } - pub fn sync_all_conversations(&mut self) -> Result<()> { - let client = self.client - .as_mut() - .ok_or(DaemonError::ClientNotConfigured)?; + pub async fn sync_all_conversations(&mut self) -> Result<()> { + let mut client = self.get_client() + .map_err(|_| DaemonError::ClientNotConfigured)?; + + let fetched_conversations = client.get_conversations().await?; + let db_conversations: Vec = fetched_conversations.into_iter() + .map(|c| kordophone_db::models::Conversation::from(c)) + .collect(); + + // Process each conversation + let mut repository = Repository::new(&mut self.database.connection); + for conversation in db_conversations { + let conversation_id = conversation.guid.clone(); + + // Insert the conversation + repository.insert_conversation(conversation)?; + + // Fetch and sync messages for this conversation + let messages = client.get_messages(&conversation_id).await?; + let db_messages: Vec = messages.into_iter() + .map(|m| kordophone_db::models::Message::from(m)) + .collect(); + + // Insert each message + for message in db_messages { + repository.insert_message(&conversation_id, message)?; + } + } Ok(()) } + pub fn get_settings(&mut self) -> Result { + let settings = self.database.with_settings(|s| + Settings::from_db(s) + )?; + + Ok(settings) + } + + fn get_client(&mut self) -> Result { + let settings = self.database.with_settings(|s| + Settings::from_db(s) + )?; + + let server_url = settings.server_url + .ok_or(DaemonError::ClientNotConfigured)?; + + let client = HTTPAPIClient::new( + server_url.parse().unwrap(), + + match (settings.username, settings.credential_item) { + (Some(username), Some(password)) => Some( + Credentials { + username, + password, + } + ), + _ => None, + } + ); + + Ok(client) + } + fn get_database_path() -> PathBuf { - if let Some(proj_dirs) = ProjectDirs::from("com", "kordophone", "kordophone") { + if let Some(proj_dirs) = ProjectDirs::from("net", "buzzert", "kordophonecd") { let data_dir = proj_dirs.data_dir(); data_dir.join("database.db") } else { @@ -59,4 +121,15 @@ impl Daemon { PathBuf::from("database.db") } } -} \ No newline at end of file +} + +impl TokenManagement for &mut Daemon { + fn get_token(&mut self) -> Option { + self.database.get_token() + } + + fn set_token(&mut self, token: JwtToken) { + self.database.set_token(token); + } +} + diff --git a/kordophoned/src/daemon/settings.rs b/kordophoned/src/daemon/settings.rs new file mode 100644 index 0000000..4205d3b --- /dev/null +++ b/kordophoned/src/daemon/settings.rs @@ -0,0 +1,35 @@ +use kordophone_db::settings::Settings as DbSettings; +use anyhow::Result; + +mod keys { + pub static SERVER_URL: &str = "ServerURL"; + pub static USERNAME: &str = "Username"; + pub static CREDENTIAL_ITEM: &str = "CredentialItem"; +} + +pub struct Settings { + pub server_url: Option, + pub username: Option, + pub credential_item: Option, +} + +impl Settings { + pub fn from_db(db_settings: &mut DbSettings) -> Result { + let server_url = db_settings.get(keys::SERVER_URL)?; + let username = db_settings.get(keys::USERNAME)?; + let credential_item = db_settings.get(keys::CREDENTIAL_ITEM)?; + + Ok(Self { + server_url, + username, + credential_item, + }) + } + + pub fn save(&self, db_settings: &mut DbSettings) -> Result<()> { + db_settings.put(keys::SERVER_URL, &self.server_url)?; + db_settings.put(keys::USERNAME, &self.username)?; + db_settings.put(keys::CREDENTIAL_ITEM, &self.credential_item)?; + Ok(()) + } +} \ No newline at end of file diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index f107bd3..c957c3a 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -1,7 +1,8 @@ use dbus::arg; use dbus_tree::MethodErr; use std::sync::{Arc, Mutex, MutexGuard}; -use log::info; +use std::future::Future; +use std::thread; use crate::daemon::Daemon; use crate::dbus::interface::NetBuzzertKordophoneRepository as DbusRepository; @@ -47,10 +48,14 @@ impl DbusRepository for ServerImpl { fn sync_all_conversations(&mut self) -> Result { let mut daemon = self.get_daemon()?; - daemon.sync_all_conversations().map_err(|e| { - log::error!("Failed to sync conversations: {}", e); - MethodErr::failed(&format!("Failed to sync conversations: {}", e)) - })?; + + // TODO: We don't actually probably want to block here. + run_sync_future(daemon.sync_all_conversations()) + .unwrap() + .map_err(|e| { + log::error!("Failed to sync conversations: {}", e); + MethodErr::failed(&format!("Failed to sync conversations: {}", e)) + })?; Ok(true) } @@ -90,3 +95,26 @@ impl DbusSettings for ServerImpl { } } + +fn run_sync_future(f: F) -> Result +where + T: Send, + F: Future + Send, +{ + // We use `scope` here to ensure that the thread is joined before the + // function returns. This allows us to capture references of values that + // have lifetimes shorter than 'static, which is what thread::spawn requires. + thread::scope(move |s| { + s.spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| MethodErr::failed("Unable to create tokio runtime"))?; + + let result = rt.block_on(f); + Ok(result) + }) + .join() + }) + .expect("Error joining runtime thread") +} \ No newline at end of file diff --git a/kpcli/src/client/mod.rs b/kpcli/src/client/mod.rs index 671db2f..d5c441e 100644 --- a/kpcli/src/client/mod.rs +++ b/kpcli/src/client/mod.rs @@ -1,6 +1,7 @@ use kordophone::APIInterface; use kordophone::api::http_client::HTTPAPIClient; use kordophone::api::http_client::Credentials; +use kordophone::api::InMemoryTokenManagement; use dotenv; use anyhow::Result; diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index e7de320..8c89165 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -3,7 +3,7 @@ use clap::Subcommand; use dbus::blocking::{Connection, Proxy}; const DBUS_NAME: &str = "net.buzzert.kordophonecd"; -const DBUS_PATH: &str = "/net/buzzert/kordophonecd"; +const DBUS_PATH: &str = "/net/buzzert/kordophonecd/daemon"; mod dbus_interface { #![allow(unused)] From ef74df9f28031b9f1fc40428dc645fac227650e7 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 25 Apr 2025 21:42:29 -0700 Subject: [PATCH 036/138] daemon: start working on events. notes: Probably need to make the locking mechanism more granular. Only lock the database during db writes, see if we can do multiple readers and a single writer. Otherwise, the daemon will not be able to service requests while an event is being handled, which is not good. --- Cargo.lock | 26 +++++-- kordophoned/Cargo.toml | 1 + .../net.buzzert.kordophonecd.Server.xml | 3 - kordophoned/src/daemon/mod.rs | 16 ++++- kordophoned/src/dbus/server_impl.rs | 72 ++++++++++--------- kordophoned/src/main.rs | 16 ++++- kpcli/src/daemon/mod.rs | 2 +- 7 files changed, 90 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a32f8de..8a78e5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -574,9 +574,20 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "futures-sink" @@ -586,20 +597,22 @@ checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -849,6 +862,7 @@ dependencies = [ "dbus-tree", "directories", "env_logger", + "futures-util", "kordophone", "kordophone-db", "log", diff --git a/kordophoned/Cargo.toml b/kordophoned/Cargo.toml index d5c6ac7..e14a322 100644 --- a/kordophoned/Cargo.toml +++ b/kordophoned/Cargo.toml @@ -11,6 +11,7 @@ dbus-tokio = "0.7.6" dbus-tree = "0.9.2" directories = "6.0.0" env_logger = "0.11.6" +futures-util = "0.3.31" kordophone = { path = "../kordophone" } kordophone-db = { path = "../kordophone-db" } log = "0.4.25" diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index 760eda9..9f8bc32 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -18,10 +18,7 @@ - - - diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 17282f1..d98882c 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -1,6 +1,7 @@ mod settings; use settings::Settings; +use std::sync::mpsc; use directories::ProjectDirs; use std::path::PathBuf; use anyhow::Result; @@ -19,6 +20,10 @@ use kordophone::api::{ TokenManagement, }; +pub enum Event { + SyncAllConversations, +} + #[derive(Debug, Error)] pub enum DaemonError { #[error("Client Not Configured")] @@ -87,6 +92,16 @@ impl Daemon { Ok(settings) } + pub async fn handle_event(&mut self, event: Event) { + match event { + Event::SyncAllConversations => { + self.sync_all_conversations().await.unwrap_or_else(|e| { + log::error!("Error handling sync event: {}", e); + }); + } + } + } + fn get_client(&mut self) -> Result { let settings = self.database.with_settings(|s| Settings::from_db(s) @@ -132,4 +147,3 @@ impl TokenManagement for &mut Daemon { self.database.set_token(token); } } - diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index c957c3a..0595d51 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -1,63 +1,69 @@ use dbus::arg; use dbus_tree::MethodErr; -use std::sync::{Arc, Mutex, MutexGuard}; +use std::sync::Arc; +use tokio::sync::{Mutex, MutexGuard}; use std::future::Future; use std::thread; +use std::sync::mpsc; +use futures_util::future::FutureExt; -use crate::daemon::Daemon; +use crate::daemon::{Daemon, Event}; use crate::dbus::interface::NetBuzzertKordophoneRepository as DbusRepository; use crate::dbus::interface::NetBuzzertKordophoneSettings as DbusSettings; #[derive(Clone)] pub struct ServerImpl { daemon: Arc>, + event_sender: mpsc::Sender, } impl ServerImpl { - pub fn new(daemon: Arc>) -> Self { - Self { daemon } + pub fn new(daemon: Arc>, event_sender: mpsc::Sender) -> Self { + Self { daemon, event_sender } } - pub fn get_daemon(&self) -> Result, MethodErr> { - self.daemon.lock().map_err(|_| MethodErr::failed("Failed to lock daemon")) + pub async fn get_daemon(&self) -> MutexGuard<'_, Daemon> { + self.daemon.lock().await // .map_err(|_| MethodErr::failed("Failed to lock daemon")) + } + + pub fn daemon_then(&self, f: F) -> Result + where F: FnOnce(MutexGuard<'_, Daemon>) -> T + Send, + T: Send, + { + run_sync_future(self.get_daemon().then(|daemon| async move { + f(daemon) + })) } } impl DbusRepository for ServerImpl { fn get_version(&mut self) -> Result { - let daemon = self.get_daemon()?; - Ok(daemon.version.clone()) + self.daemon_then(|daemon| daemon.version.clone()) } fn get_conversations(&mut self) -> Result, dbus::MethodErr> { - // Get a repository instance and use it to fetch conversations - let mut daemon = self.get_daemon()?; - let conversations = daemon.get_conversations(); - - // Convert conversations to DBus property maps - let result = conversations.into_iter().map(|conv| { - let mut map = arg::PropMap::new(); - map.insert("guid".into(), arg::Variant(Box::new(conv.guid))); - map.insert("display_name".into(), arg::Variant(Box::new(conv.display_name.unwrap_or_default()))); - map.insert("unread_count".into(), arg::Variant(Box::new(conv.unread_count as i32))); - map - }).collect(); - - Ok(result) + self.daemon_then(|mut daemon| { + let conversations = daemon.get_conversations(); + + // Convert conversations to DBus property maps + let result = conversations.into_iter().map(|conv| { + let mut map = arg::PropMap::new(); + map.insert("guid".into(), arg::Variant(Box::new(conv.guid))); + map.insert("display_name".into(), arg::Variant(Box::new(conv.display_name.unwrap_or_default()))); + map.insert("unread_count".into(), arg::Variant(Box::new(conv.unread_count as i32))); + map + }).collect(); + + Ok(result) + })? } - fn sync_all_conversations(&mut self) -> Result { - let mut daemon = self.get_daemon()?; + fn sync_all_conversations(&mut self) -> Result<(), dbus::MethodErr> { + self.event_sender.send(Event::SyncAllConversations).unwrap_or_else(|e| { + log::error!("Error sending sync event: {}", e); + }); - // TODO: We don't actually probably want to block here. - run_sync_future(daemon.sync_all_conversations()) - .unwrap() - .map_err(|e| { - log::error!("Failed to sync conversations: {}", e); - MethodErr::failed(&format!("Failed to sync conversations: {}", e)) - })?; - - Ok(true) + Ok(()) } } diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index e6772e5..0fd1987 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -2,9 +2,12 @@ mod dbus; mod daemon; use std::future; -use std::sync::{Arc, Mutex}; +use std::sync::mpsc; use log::LevelFilter; +use std::sync::Arc; +use tokio::sync::Mutex; + use daemon::Daemon; use dbus::endpoint::Endpoint as DbusEndpoint; use dbus::interface; @@ -21,6 +24,8 @@ fn initialize_logging() { async fn main() { initialize_logging(); + let (sender, receiver) = mpsc::channel::(); + // Create the daemon let daemon = Arc::new( Mutex::new( @@ -34,7 +39,7 @@ async fn main() { ); // Create the server implementation - let server = ServerImpl::new(daemon); + let server = ServerImpl::new(daemon.clone(), sender); // Register DBus interfaces with endpoint let endpoint = DbusEndpoint::new(server.clone()); @@ -49,6 +54,13 @@ async fn main() { } ).await; + tokio::spawn(async move { + for event in receiver { + // Important! Only lock the daemon when handling events. + daemon.lock().await.handle_event(event).await; + } + }); + future::pending::<()>().await; unreachable!() } diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index 8c89165..7a96180 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -64,7 +64,7 @@ impl DaemonCli { pub async fn sync_conversations(&mut self) -> Result<()> { let success = KordophoneRepository::sync_all_conversations(&self.proxy())?; - println!("Synced conversations: {}", success); + println!("Initiated sync"); Ok(()) } } \ No newline at end of file From 22554a764455d43cc3e6e3ce01e36321938f2328 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 27 Apr 2025 12:53:45 -0700 Subject: [PATCH 037/138] daemon: reorg: use channels for comms instead of copying daemon arc/mutex --- kordophoned/src/daemon/events.rs | 17 +++++++ kordophoned/src/daemon/mod.rs | 79 ++++++++++++++++++++--------- kordophoned/src/dbus/server_impl.rs | 78 +++++++++++++++------------- kordophoned/src/main.rs | 28 +++------- 4 files changed, 124 insertions(+), 78 deletions(-) create mode 100644 kordophoned/src/daemon/events.rs diff --git a/kordophoned/src/daemon/events.rs b/kordophoned/src/daemon/events.rs new file mode 100644 index 0000000..d7342df --- /dev/null +++ b/kordophoned/src/daemon/events.rs @@ -0,0 +1,17 @@ +use tokio::sync::oneshot; +use kordophone_db::models::Conversation; + +pub type Reply = oneshot::Sender; + +pub enum Event { + /// Get the version of the daemon. + GetVersion(Reply), + + /// Asynchronous event for syncing all conversations with the server. + SyncAllConversations(Reply<()>), + + /// Returns all known conversations from the database. + GetAllConversations(Reply>), +} + + diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index d98882c..0f5e9c3 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -1,11 +1,15 @@ -mod settings; +pub mod settings; use settings::Settings; -use std::sync::mpsc; -use directories::ProjectDirs; -use std::path::PathBuf; +pub mod events; +use events::*; + use anyhow::Result; +use directories::ProjectDirs; +use std::error::Error; +use std::path::PathBuf; use thiserror::Error; +use tokio::sync::mpsc::{Sender, Receiver}; use kordophone_db::{ database::Database, @@ -20,19 +24,20 @@ use kordophone::api::{ TokenManagement, }; -pub enum Event { - SyncAllConversations, -} - #[derive(Debug, Error)] pub enum DaemonError { #[error("Client Not Configured")] ClientNotConfigured, } +pub type DaemonResult = Result>; + pub struct Daemon { - pub version: String, + pub event_sender: Sender, + event_receiver: Receiver, + version: String, database: Database, + runtime: tokio::runtime::Runtime, } impl Daemon { @@ -44,15 +49,53 @@ impl Daemon { let database_dir = database_path.parent().unwrap(); std::fs::create_dir_all(database_dir)?; + // Create event channels + let (event_sender, event_receiver) = tokio::sync::mpsc::channel(100); + + // Create background task runtime + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + let database = Database::new(&database_path.to_string_lossy())?; - Ok(Self { version: "0.1.0".to_string(), database }) + Ok(Self { version: "0.1.0".to_string(), database, event_receiver, event_sender, runtime }) } - pub fn get_conversations(&mut self) -> Vec { + pub async fn run(&mut self) { + while let Some(event) = self.event_receiver.recv().await { + self.handle_event(event).await; + } + } + + async fn handle_event(&mut self, event: Event) { + match event { + Event::GetVersion(reply) => { + reply.send(self.version.clone()).unwrap(); + }, + + Event::SyncAllConversations(reply) => { + self.sync_all_conversations().await.unwrap_or_else(|e| { + log::error!("Error handling sync event: {}", e); + }); + + reply.send(()).unwrap(); + }, + + Event::GetAllConversations(reply) => { + let conversations = self.get_conversations(); + reply.send(conversations).unwrap(); + }, + } + } + + fn get_conversations(&mut self) -> Vec { self.database.with_repository(|r| r.all_conversations().unwrap()) } - pub async fn sync_all_conversations(&mut self) -> Result<()> { + async fn sync_all_conversations(&mut self) -> Result<()> { + tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; + let mut client = self.get_client() .map_err(|_| DaemonError::ClientNotConfigured)?; @@ -84,7 +127,7 @@ impl Daemon { Ok(()) } - pub fn get_settings(&mut self) -> Result { + fn get_settings(&mut self) -> Result { let settings = self.database.with_settings(|s| Settings::from_db(s) )?; @@ -92,16 +135,6 @@ impl Daemon { Ok(settings) } - pub async fn handle_event(&mut self, event: Event) { - match event { - Event::SyncAllConversations => { - self.sync_all_conversations().await.unwrap_or_else(|e| { - log::error!("Error handling sync event: {}", e); - }); - } - } - } - fn get_client(&mut self) -> Result { let settings = self.database.with_settings(|s| Settings::from_db(s) diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 0595d51..469507c 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -4,66 +4,74 @@ use std::sync::Arc; use tokio::sync::{Mutex, MutexGuard}; use std::future::Future; use std::thread; -use std::sync::mpsc; +use tokio::sync::oneshot; +use tokio::sync::mpsc; use futures_util::future::FutureExt; -use crate::daemon::{Daemon, Event}; +use crate::daemon::{ + Daemon, + DaemonResult, + events::{Event, Reply}, +}; + use crate::dbus::interface::NetBuzzertKordophoneRepository as DbusRepository; use crate::dbus::interface::NetBuzzertKordophoneSettings as DbusSettings; #[derive(Clone)] pub struct ServerImpl { - daemon: Arc>, - event_sender: mpsc::Sender, + event_sink: mpsc::Sender, } impl ServerImpl { - pub fn new(daemon: Arc>, event_sender: mpsc::Sender) -> Self { - Self { daemon, event_sender } + pub fn new(event_sink: mpsc::Sender) -> Self { + Self { event_sink } } - pub async fn get_daemon(&self) -> MutexGuard<'_, Daemon> { - self.daemon.lock().await // .map_err(|_| MethodErr::failed("Failed to lock daemon")) + 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)) + .await + .map_err(|_| "Failed to send event")?; + + reply_rx.await.map_err(|_| "Failed to receive reply".into()) } - pub fn daemon_then(&self, f: F) -> Result - where F: FnOnce(MutexGuard<'_, Daemon>) -> T + Send, - T: Send, - { - run_sync_future(self.get_daemon().then(|daemon| async move { - f(daemon) - })) + pub fn send_event_sync( + &self, + make_event: impl FnOnce(Reply) -> Event + Send, + ) -> Result { + run_sync_future(self.send_event(make_event)) + .unwrap() + .map_err(|e| MethodErr::failed(&format!("Daemon error: {}", e))) } } impl DbusRepository for ServerImpl { fn get_version(&mut self) -> Result { - self.daemon_then(|daemon| daemon.version.clone()) + self.send_event_sync(Event::GetVersion) } fn get_conversations(&mut self) -> Result, dbus::MethodErr> { - self.daemon_then(|mut daemon| { - let conversations = daemon.get_conversations(); - - // Convert conversations to DBus property maps - let result = conversations.into_iter().map(|conv| { - let mut map = arg::PropMap::new(); - map.insert("guid".into(), arg::Variant(Box::new(conv.guid))); - map.insert("display_name".into(), arg::Variant(Box::new(conv.display_name.unwrap_or_default()))); - map.insert("unread_count".into(), arg::Variant(Box::new(conv.unread_count as i32))); - map - }).collect(); - - Ok(result) - })? + self.send_event_sync(Event::GetAllConversations) + .and_then(|conversations| { + // Convert conversations to DBus property maps + let result = conversations.into_iter().map(|conv| { + let mut map = arg::PropMap::new(); + map.insert("guid".into(), arg::Variant(Box::new(conv.guid))); + map.insert("display_name".into(), arg::Variant(Box::new(conv.display_name.unwrap_or_default()))); + map.insert("unread_count".into(), arg::Variant(Box::new(conv.unread_count as i32))); + map + }).collect(); + + Ok(result) + }) } fn sync_all_conversations(&mut self) -> Result<(), dbus::MethodErr> { - self.event_sender.send(Event::SyncAllConversations).unwrap_or_else(|e| { - log::error!("Error sending sync event: {}", e); - }); - - Ok(()) + self.send_event_sync(Event::SyncAllConversations) } } diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index 0fd1987..a36f719 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -2,7 +2,6 @@ mod dbus; mod daemon; use std::future; -use std::sync::mpsc; use log::LevelFilter; use std::sync::Arc; @@ -24,22 +23,16 @@ fn initialize_logging() { async fn main() { initialize_logging(); - let (sender, receiver) = mpsc::channel::(); - // Create the daemon - let daemon = Arc::new( - Mutex::new( - Daemon::new() - .map_err(|e| { - log::error!("Failed to start daemon: {}", e); - std::process::exit(1); - }) - .unwrap() - ) - ); + let mut daemon = Daemon::new() + .map_err(|e| { + log::error!("Failed to start daemon: {}", e); + std::process::exit(1); + }) + .unwrap(); // Create the server implementation - let server = ServerImpl::new(daemon.clone(), sender); + let server = ServerImpl::new(daemon.event_sender.clone()); // Register DBus interfaces with endpoint let endpoint = DbusEndpoint::new(server.clone()); @@ -54,12 +47,7 @@ async fn main() { } ).await; - tokio::spawn(async move { - for event in receiver { - // Important! Only lock the daemon when handling events. - daemon.lock().await.handle_event(event).await; - } - }); + daemon.run().await; future::pending::<()>().await; unreachable!() From 84f782cc03851eb3f46558dcdbe982df37f126ba Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 27 Apr 2025 13:40:59 -0700 Subject: [PATCH 038/138] daemon: implement solution for background sync --- Cargo.lock | 26 +++++---- kordophone-db/Cargo.toml | 2 + kordophone-db/src/database.rs | 59 ++++++++++++++++--- kordophone-db/src/tests/mod.rs | 2 +- kordophone/src/api/mod.rs | 8 +-- kordophoned/src/daemon/events.rs | 3 +- kordophoned/src/daemon/mod.rs | 98 ++++++++++++++++++++------------ kpcli/src/db/mod.rs | 35 ++++++------ 8 files changed, 154 insertions(+), 79 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8a78e5e..7b619fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,9 +113,9 @@ checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" [[package]] name = "async-trait" -version = "0.1.80" +version = "0.1.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" dependencies = [ "proc-macro2", "quote", @@ -565,9 +565,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "futures-channel" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", ] @@ -591,9 +591,9 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" @@ -840,6 +840,7 @@ name = "kordophone-db" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "bincode", "chrono", "diesel", @@ -847,6 +848,7 @@ dependencies = [ "kordophone", "serde", "time", + "tokio", "uuid", ] @@ -896,9 +898,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.153" +version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" [[package]] name = "libdbus-sys" @@ -1511,9 +1513,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.41.1" +version = "1.44.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33" +checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" dependencies = [ "backtrace", "bytes", @@ -1529,9 +1531,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", diff --git a/kordophone-db/Cargo.toml b/kordophone-db/Cargo.toml index 0696e36..65e7bba 100644 --- a/kordophone-db/Cargo.toml +++ b/kordophone-db/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] anyhow = "1.0.94" +async-trait = "0.1.88" bincode = "1.3.3" chrono = "0.4.38" diesel = { version = "2.2.6", features = ["chrono", "sqlite", "time"] } @@ -12,4 +13,5 @@ diesel_migrations = { version = "2.2.0", features = ["sqlite"] } kordophone = { path = "../kordophone" } serde = { version = "1.0.215", features = ["derive"] } time = "0.3.37" +tokio = "1.44.2" uuid = { version = "1.11.0", features = ["v4"] } diff --git a/kordophone-db/src/database.rs b/kordophone-db/src/database.rs index 55f99c1..3841140 100644 --- a/kordophone-db/src/database.rs +++ b/kordophone-db/src/database.rs @@ -1,5 +1,9 @@ use anyhow::Result; use diesel::prelude::*; +use async_trait::async_trait; + +pub use std::sync::Arc; +pub use tokio::sync::Mutex; use crate::repository::Repository; use crate::settings::Settings; @@ -10,6 +14,19 @@ use kordophone::model::JwtToken; use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); +#[async_trait] +pub trait DatabaseAccess { + async fn with_repository(&mut self, f: F) -> R + where + F: FnOnce(&mut Repository) -> R + Send, + R: Send; + + async fn with_settings(&mut self, f: F) -> R + where + F: FnOnce(&mut Settings) -> R + Send, + R: Send; +} + pub struct Database { pub connection: SqliteConnection, } @@ -26,38 +43,64 @@ impl Database { pub fn new_in_memory() -> Result { Self::new(":memory:") } +} - pub fn with_repository(&mut self, f: F) -> R +#[async_trait] +impl DatabaseAccess for Database { + async fn with_repository(&mut self, f: F) -> R where - F: FnOnce(&mut Repository) -> R, + F: FnOnce(&mut Repository) -> R + Send, + R: Send, { let mut repository = Repository::new(&mut self.connection); f(&mut repository) } - pub fn with_settings(&mut self, f: F) -> R + async fn with_settings(&mut self, f: F) -> R where - F: FnOnce(&mut Settings) -> R, + F: FnOnce(&mut Settings) -> R + Send, + R: Send, { let mut settings = Settings::new(&mut self.connection); f(&mut settings) } } +#[async_trait] +impl DatabaseAccess for Arc> { + async fn with_repository(&mut self, f: F) -> R + where + F: FnOnce(&mut Repository) -> R + Send, + R: Send, + { + let mut database = self.lock().await; + database.with_repository(f).await + } + + async fn with_settings(&mut self, f: F) -> R + where + F: FnOnce(&mut Settings) -> R + Send, + R: Send, + { + let mut database = self.lock().await; + database.with_settings(f).await + } +} + static TOKEN_KEY: &str = "token"; impl TokenManagement for Database { - fn get_token(&mut self) -> Option { + async fn get_token(&mut self) -> Option { self.with_settings(|settings| { let token: Result> = settings.get(TOKEN_KEY); match token { Ok(data) => data, Err(_) => None, } - }) + }).await } - fn set_token(&mut self, token: JwtToken) { - self.with_settings(|settings| settings.put(TOKEN_KEY, &token).unwrap()); + async fn set_token(&mut self, token: JwtToken) { + self.with_settings(|settings| settings.put(TOKEN_KEY, &token).unwrap()).await; } } diff --git a/kordophone-db/src/tests/mod.rs b/kordophone-db/src/tests/mod.rs index 535becc..3918875 100644 --- a/kordophone-db/src/tests/mod.rs +++ b/kordophone-db/src/tests/mod.rs @@ -1,5 +1,5 @@ use crate::{ - database::Database, + database::{Database, DatabaseAccess}, repository::Repository, models::{ conversation::{Conversation, ConversationBuilder}, diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index 6a337b2..55ea9db 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -27,8 +27,8 @@ pub trait APIInterface { } pub trait TokenManagement { - fn get_token(&mut self) -> Option; - fn set_token(&mut self, token: JwtToken); + async fn get_token(&mut self) -> Option; + async fn set_token(&mut self, token: JwtToken); } pub struct InMemoryTokenManagement { @@ -42,11 +42,11 @@ impl InMemoryTokenManagement { } impl TokenManagement for InMemoryTokenManagement { - fn get_token(&mut self) -> Option { + async fn get_token(&mut self) -> Option { self.token.clone() } - fn set_token(&mut self, token: JwtToken) { + async fn set_token(&mut self, token: JwtToken) { self.token = Some(token); } } diff --git a/kordophoned/src/daemon/events.rs b/kordophoned/src/daemon/events.rs index d7342df..3d0e2ba 100644 --- a/kordophoned/src/daemon/events.rs +++ b/kordophoned/src/daemon/events.rs @@ -1,8 +1,9 @@ use tokio::sync::oneshot; use kordophone_db::models::Conversation; -pub type Reply = oneshot::Sender; +pub type Reply = oneshot::Sender; +#[derive(Debug)] pub enum Event { /// Get the version of the daemon. GetVersion(Reply), diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 0f5e9c3..6b10f8a 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -10,9 +10,12 @@ use std::error::Error; use std::path::PathBuf; use thiserror::Error; use tokio::sync::mpsc::{Sender, Receiver}; +use std::sync::Arc; +use tokio::sync::Mutex; +use futures_util::FutureExt; use kordophone_db::{ - database::Database, + database::{Database, DatabaseAccess}, models::Conversation, repository::Repository, }; @@ -36,7 +39,7 @@ pub struct Daemon { pub event_sender: Sender, event_receiver: Receiver, version: String, - database: Database, + database: Arc>, runtime: tokio::runtime::Runtime, } @@ -58,7 +61,8 @@ impl Daemon { .build() .unwrap(); - let database = Database::new(&database_path.to_string_lossy())?; + let database_impl = Database::new(&database_path.to_string_lossy())?; + let database = Arc::new(Mutex::new(database_impl)); Ok(Self { version: "0.1.0".to_string(), database, event_receiver, event_sender, runtime }) } @@ -75,70 +79,100 @@ impl Daemon { }, Event::SyncAllConversations(reply) => { - self.sync_all_conversations().await.unwrap_or_else(|e| { - log::error!("Error handling sync event: {}", e); + let db_clone = self.database.clone(); + self.runtime.spawn(async move { + let result = Self::sync_all_conversations_impl(db_clone).await; + if let Err(e) = result { + log::error!("Error handling sync event: {}", e); + } }); + // This is a background operation, so return right away. reply.send(()).unwrap(); - }, + }, Event::GetAllConversations(reply) => { - let conversations = self.get_conversations(); + let conversations = self.get_conversations().await; reply.send(conversations).unwrap(); }, } } - fn get_conversations(&mut self) -> Vec { - self.database.with_repository(|r| r.all_conversations().unwrap()) + async fn get_conversations(&mut self) -> Vec { + self.database.lock().await.with_repository(|r| r.all_conversations().unwrap()).await } - async fn sync_all_conversations(&mut self) -> Result<()> { - tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; + async fn sync_all_conversations_impl(mut database: Arc>) -> Result<()> { + log::info!("Starting conversation sync"); + + // Get client from the database + let settings = database.with_settings(|s| Settings::from_db(s)) + .await?; - let mut client = self.get_client() - .map_err(|_| DaemonError::ClientNotConfigured)?; + let server_url = settings.server_url + .ok_or(DaemonError::ClientNotConfigured)?; + let mut client = HTTPAPIClient::new( + server_url.parse().unwrap(), + match (settings.username, settings.credential_item) { + (Some(username), Some(password)) => Some( + Credentials { + username, + password, + } + ), + _ => None, + } + ); + + // This function needed to implement TokenManagement + // let token = database.lock().await.get_token(); + // TODO: Clent.token = token + + // Fetch conversations from server let fetched_conversations = client.get_conversations().await?; let db_conversations: Vec = fetched_conversations.into_iter() .map(|c| kordophone_db::models::Conversation::from(c)) .collect(); - + // Process each conversation - let mut repository = Repository::new(&mut self.database.connection); for conversation in db_conversations { let conversation_id = conversation.guid.clone(); // Insert the conversation - repository.insert_conversation(conversation)?; - + database.with_repository(|r| r.insert_conversation(conversation)).await?; + // Fetch and sync messages for this conversation let messages = client.get_messages(&conversation_id).await?; let db_messages: Vec = messages.into_iter() .map(|m| kordophone_db::models::Message::from(m)) .collect(); - + // Insert each message - for message in db_messages { - repository.insert_message(&conversation_id, message)?; - } + database.with_repository(|r| -> Result<()> { + for message in db_messages { + r.insert_message(&conversation_id, message)?; + } + + Ok(()) + }).await?; } - + Ok(()) - } + } - fn get_settings(&mut self) -> Result { + async fn get_settings(&mut self) -> Result { let settings = self.database.with_settings(|s| Settings::from_db(s) - )?; + ).await?; Ok(settings) } - fn get_client(&mut self) -> Result { + async fn get_client(&mut self) -> Result { let settings = self.database.with_settings(|s| Settings::from_db(s) - )?; + ).await?; let server_url = settings.server_url .ok_or(DaemonError::ClientNotConfigured)?; @@ -170,13 +204,3 @@ impl Daemon { } } } - -impl TokenManagement for &mut Daemon { - fn get_token(&mut self) -> Option { - self.database.get_token() - } - - fn set_token(&mut self, token: JwtToken) { - self.database.set_token(token); - } -} diff --git a/kpcli/src/db/mod.rs b/kpcli/src/db/mod.rs index a183938..829ca74 100644 --- a/kpcli/src/db/mod.rs +++ b/kpcli/src/db/mod.rs @@ -3,7 +3,10 @@ use clap::Subcommand; use kordophone::APIInterface; use std::{env, path::PathBuf}; -use kordophone_db::database::Database; +use kordophone_db::{ + database::{Database, DatabaseAccess}, + models::{Conversation, Message}, +}; use crate::{client, printers::{ConversationPrinter, MessagePrinter}}; #[derive(Subcommand)] @@ -72,16 +75,16 @@ impl Commands { let mut db = DbClient::new()?; match cmd { Commands::Conversations { command: cmd } => match cmd { - ConversationCommands::List => db.print_conversations(), + ConversationCommands::List => db.print_conversations().await, ConversationCommands::Sync => db.sync_with_client().await, }, Commands::Messages { command: cmd } => match cmd { MessageCommands::List { conversation_id } => db.print_messages(&conversation_id).await, }, Commands::Settings { command: cmd } => match cmd { - SettingsCommands::Get { key } => db.get_setting(key), - SettingsCommands::Put { key, value } => db.put_setting(key, value), - SettingsCommands::Delete { key } => db.delete_setting(key), + SettingsCommands::Get { key } => db.get_setting(key).await, + SettingsCommands::Put { key, value } => db.put_setting(key, value).await, + SettingsCommands::Delete { key } => db.delete_setting(key).await, }, } } @@ -107,10 +110,10 @@ impl DbClient { Ok( Self { database: db }) } - pub fn print_conversations(&mut self) -> Result<()> { + pub async fn print_conversations(&mut self) -> Result<()> { let all_conversations = self.database.with_repository(|repository| { repository.all_conversations() - })?; + }).await?; println!("{} Conversations: ", all_conversations.len()); for conversation in all_conversations { @@ -123,7 +126,7 @@ impl DbClient { pub async fn print_messages(&mut self, conversation_id: &str) -> Result<()> { let messages = self.database.with_repository(|repository| { repository.get_messages_for_conversation(conversation_id) - })?; + }).await?; for message in messages { println!("{}", MessagePrinter::new(&message.into())); @@ -145,7 +148,7 @@ impl DbClient { // Insert the conversation self.database.with_repository(|repository| { repository.insert_conversation(conversation) - })?; + }).await?; // Fetch and sync messages for this conversation let messages = client.get_messages(&conversation_id).await?; @@ -160,13 +163,13 @@ impl DbClient { } Ok(()) - })?; + }).await?; } Ok(()) } - pub fn get_setting(&mut self, key: Option) -> Result<()> { + pub async fn get_setting(&mut self, key: Option) -> Result<()> { self.database.with_settings(|settings| { match key { Some(key) => { @@ -196,23 +199,23 @@ impl DbClient { } Ok(()) - }) + }).await } - pub fn put_setting(&mut self, key: String, value: String) -> Result<()> { + pub async fn put_setting(&mut self, key: String, value: String) -> Result<()> { self.database.with_settings(|settings| { settings.put(&key, &value)?; Ok(()) - }) + }).await } - pub fn delete_setting(&mut self, key: String) -> Result<()> { + pub async fn delete_setting(&mut self, key: String) -> Result<()> { self.database.with_settings(|settings| { let count = settings.del(&key)?; if count == 0 { println!("Setting '{}' not found", key); } Ok(()) - }) + }).await } } From 49f8b81b9cac2b0ffcbf34a95fca336d524f46f0 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 27 Apr 2025 14:01:19 -0700 Subject: [PATCH 039/138] daemon: Token store --- Cargo.lock | 1 + kordophone-db/src/database.rs | 5 +++-- kordophone/src/api/http_client.rs | 24 +++++++++++++----------- kordophone/src/api/mod.rs | 10 ++++++---- kordophoned/Cargo.toml | 1 + kordophoned/src/daemon/mod.rs | 27 ++++++++++++++++++++++----- kordophoned/src/daemon/settings.rs | 1 + kpcli/src/client/mod.rs | 8 ++++---- 8 files changed, 51 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7b619fd..24f8823 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -857,6 +857,7 @@ name = "kordophoned" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "dbus", "dbus-codegen", "dbus-crossroads", diff --git a/kordophone-db/src/database.rs b/kordophone-db/src/database.rs index 3841140..29a8723 100644 --- a/kordophone-db/src/database.rs +++ b/kordophone-db/src/database.rs @@ -8,7 +8,7 @@ pub use tokio::sync::Mutex; use crate::repository::Repository; use crate::settings::Settings; -pub use kordophone::api::TokenManagement; +pub use kordophone::api::TokenStore; use kordophone::model::JwtToken; use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; @@ -89,7 +89,8 @@ impl DatabaseAccess for Arc> { static TOKEN_KEY: &str = "token"; -impl TokenManagement for Database { +#[async_trait] +impl TokenStore for Database { async fn get_token(&mut self) -> Option { self.with_settings(|settings| { let token: Result> = settings.get(TOKEN_KEY); diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index 024b44d..46bf197 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -3,6 +3,7 @@ extern crate serde; use std::{path::PathBuf, str}; +use crate::api::{TokenStore, InMemoryTokenStore}; use hyper::{Body, Client, Method, Request, Uri}; use async_trait::async_trait; @@ -15,10 +16,10 @@ use crate::{ type HttpClient = Client; -pub struct HTTPAPIClient { +pub struct HTTPAPIClient { pub base_url: Uri, + pub token_store: K, credentials: Option, - auth_token: Option, client: HttpClient, } @@ -91,7 +92,7 @@ impl AuthSetting for hyper::http::Request { } #[async_trait] -impl APIInterface for HTTPAPIClient { +impl APIInterface for HTTPAPIClient { type Error = Error; async fn get_version(&mut self) -> Result { @@ -113,7 +114,7 @@ impl APIInterface for HTTPAPIClient { let body = || -> Body { serde_json::to_string(&credentials).unwrap().into() }; let token: AuthResponse = self.request_with_body_retry("authenticate", Method::POST, body, false).await?; let token = JwtToken::new(&token.jwt).map_err(|_| Error::DecodeError)?; - self.auth_token = Some(token.clone()); + self.token_store.set_token(token.clone()).await; Ok(token) } @@ -124,12 +125,12 @@ impl APIInterface for HTTPAPIClient { } } -impl HTTPAPIClient { - pub fn new(base_url: Uri, credentials: Option) -> HTTPAPIClient { +impl HTTPAPIClient { + pub fn new(base_url: Uri, credentials: Option, token_store: K) -> HTTPAPIClient { HTTPAPIClient { base_url, credentials, - auth_token: Option::None, + token_store, client: Client::new(), } } @@ -178,7 +179,8 @@ impl HTTPAPIClient { .expect("Unable to build request") }; - let request = build_request(&self.auth_token); + let token = self.token_store.get_token().await; + let request = build_request(&token); let mut response = self.client.request(request).await?; log::debug!("-> Response: {:}", response.status()); @@ -195,7 +197,7 @@ impl HTTPAPIClient { if let Some(credentials) = &self.credentials { self.authenticate(credentials.clone()).await?; - let request = build_request(&self.auth_token); + let request = build_request(&token); response = self.client.request(request).await?; } }, @@ -228,14 +230,14 @@ mod test { use super::*; #[cfg(test)] - fn local_mock_client() -> HTTPAPIClient { + fn local_mock_client() -> HTTPAPIClient { let base_url = "http://localhost:5738".parse().unwrap(); let credentials = Credentials { username: "test".to_string(), password: "test".to_string(), }; - HTTPAPIClient::new(base_url, credentials.into()) + HTTPAPIClient::new(base_url, credentials.into(), InMemoryTokenStore::new()) } #[cfg(test)] diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index 55ea9db..de7a8cd 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -26,22 +26,24 @@ pub trait APIInterface { async fn authenticate(&mut self, credentials: Credentials) -> Result; } -pub trait TokenManagement { +#[async_trait] +pub trait TokenStore { async fn get_token(&mut self) -> Option; async fn set_token(&mut self, token: JwtToken); } -pub struct InMemoryTokenManagement { +pub struct InMemoryTokenStore { token: Option, } -impl InMemoryTokenManagement { +impl InMemoryTokenStore { pub fn new() -> Self { Self { token: None } } } -impl TokenManagement for InMemoryTokenManagement { +#[async_trait] +impl TokenStore for InMemoryTokenStore { async fn get_token(&mut self) -> Option { self.token.clone() } diff --git a/kordophoned/Cargo.toml b/kordophoned/Cargo.toml index e14a322..74c2556 100644 --- a/kordophoned/Cargo.toml +++ b/kordophoned/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] anyhow = "1.0.98" +async-trait = "0.1.88" dbus = "0.9.7" dbus-crossroads = "0.5.2" dbus-tokio = "0.7.6" diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 6b10f8a..d2d140a 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -12,7 +12,7 @@ use thiserror::Error; use tokio::sync::mpsc::{Sender, Receiver}; use std::sync::Arc; use tokio::sync::Mutex; -use futures_util::FutureExt; +use async_trait::async_trait; use kordophone_db::{ database::{Database, DatabaseAccess}, @@ -24,7 +24,7 @@ use kordophone::model::JwtToken; use kordophone::api::{ http_client::{Credentials, HTTPAPIClient}, APIInterface, - TokenManagement, + TokenStore, }; #[derive(Debug, Error)] @@ -35,6 +35,21 @@ pub enum DaemonError { pub type DaemonResult = Result>; +struct DatabaseTokenStore { + database: Arc>, +} + +#[async_trait] +impl TokenStore for DatabaseTokenStore { + async fn get_token(&mut self) -> Option { + self.database.lock().await.get_token().await + } + + async fn set_token(&mut self, token: JwtToken) { + self.database.lock().await.set_token(token).await; + } +} + pub struct Daemon { pub event_sender: Sender, event_receiver: Receiver, @@ -122,7 +137,8 @@ impl Daemon { } ), _ => None, - } + }, + DatabaseTokenStore { database: database.clone() } ); // This function needed to implement TokenManagement @@ -169,7 +185,7 @@ impl Daemon { Ok(settings) } - async fn get_client(&mut self) -> Result { + async fn get_client(&mut self) -> Result> { let settings = self.database.with_settings(|s| Settings::from_db(s) ).await?; @@ -188,7 +204,8 @@ impl Daemon { } ), _ => None, - } + }, + DatabaseTokenStore { database: self.database.clone() } ); Ok(client) diff --git a/kordophoned/src/daemon/settings.rs b/kordophoned/src/daemon/settings.rs index 4205d3b..29f9899 100644 --- a/kordophoned/src/daemon/settings.rs +++ b/kordophoned/src/daemon/settings.rs @@ -7,6 +7,7 @@ mod keys { pub static CREDENTIAL_ITEM: &str = "CredentialItem"; } +#[derive(Debug)] pub struct Settings { pub server_url: Option, pub username: Option, diff --git a/kpcli/src/client/mod.rs b/kpcli/src/client/mod.rs index d5c441e..a0c146e 100644 --- a/kpcli/src/client/mod.rs +++ b/kpcli/src/client/mod.rs @@ -1,14 +1,14 @@ use kordophone::APIInterface; use kordophone::api::http_client::HTTPAPIClient; use kordophone::api::http_client::Credentials; -use kordophone::api::InMemoryTokenManagement; +use kordophone::api::InMemoryTokenStore; use dotenv; use anyhow::Result; use clap::Subcommand; use crate::printers::{ConversationPrinter, MessagePrinter}; -pub fn make_api_client_from_env() -> HTTPAPIClient { +pub fn make_api_client_from_env() -> HTTPAPIClient { dotenv::dotenv().ok(); // read from env @@ -23,7 +23,7 @@ pub fn make_api_client_from_env() -> HTTPAPIClient { .expect("KORDOPHONE_PASSWORD must be set"), }; - HTTPAPIClient::new(base_url.parse().unwrap(), credentials.into()) + HTTPAPIClient::new(base_url.parse().unwrap(), credentials.into(), InMemoryTokenStore::new()) } #[derive(Subcommand)] @@ -52,7 +52,7 @@ impl Commands { } struct ClientCli { - api: HTTPAPIClient, + api: HTTPAPIClient, } impl ClientCli { From cecfd7cd762fc573a994d7ec0df308114622165a Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 27 Apr 2025 18:07:58 -0700 Subject: [PATCH 040/138] implements settings, conversation dbus encoding --- Cargo.lock | 68 ++++++--- kordophone-db/Cargo.toml | 1 + kordophone-db/src/repository.rs | 47 ++++++ kordophone-db/src/tests/mod.rs | 144 +++++++++++++++--- .../net.buzzert.kordophonecd.Server.xml | 5 +- kordophoned/src/daemon/events.rs | 7 + kordophoned/src/daemon/mod.rs | 76 ++++----- kordophoned/src/daemon/settings.rs | 28 +++- kordophoned/src/dbus/server_impl.rs | 70 +++++++-- kpcli/src/daemon/mod.rs | 76 ++++++++- kpcli/src/printers.rs | 20 +++ 11 files changed, 446 insertions(+), 96 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 24f8823..7e5ea67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -509,14 +509,14 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcaee3d8e3cfc3fd92428d477bc97fc29ec8716d180c0d74c643bb26166660e0" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" dependencies = [ "anstream", "anstyle", "env_filter", - "humantime", + "jiff", "log", ] @@ -712,12 +712,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "humantime" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" - [[package]] name = "hyper" version = "0.14.28" @@ -806,6 +800,30 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "jiff" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a064218214dc6a10fbae5ec5fa888d80c45d611aba169222fc272072bf7aef6" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199b7932d97e325aff3a7030e141eafe7f2c6268e1d1b24859b753a627f45254" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "js-sys" version = "0.3.72" @@ -846,6 +864,7 @@ dependencies = [ "diesel", "diesel_migrations", "kordophone", + "log", "serde", "time", "tokio", @@ -950,9 +969,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.25" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "memchr" @@ -1141,6 +1160,21 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +[[package]] +name = "portable-atomic" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1167,18 +1201,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.92" +version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.36" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] @@ -1422,9 +1456,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.90" +version = "2.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" dependencies = [ "proc-macro2", "quote", diff --git a/kordophone-db/Cargo.toml b/kordophone-db/Cargo.toml index 65e7bba..fe8fd87 100644 --- a/kordophone-db/Cargo.toml +++ b/kordophone-db/Cargo.toml @@ -11,6 +11,7 @@ chrono = "0.4.38" diesel = { version = "2.2.6", features = ["chrono", "sqlite", "time"] } diesel_migrations = { version = "2.2.0", features = ["sqlite"] } kordophone = { path = "../kordophone" } +log = "0.4.27" serde = { version = "1.0.215", features = ["derive"] } time = "0.3.37" tokio = "1.44.2" diff --git a/kordophone-db/src/repository.rs b/kordophone-db/src/repository.rs index a82457a..1371c3c 100644 --- a/kordophone-db/src/repository.rs +++ b/kordophone-db/src/repository.rs @@ -130,6 +130,53 @@ impl<'a> Repository<'a> { Ok(()) } + pub fn insert_messages(&mut self, conversation_guid: &str, in_messages: Vec) -> Result<()> { + use crate::schema::messages::dsl::*; + use crate::schema::conversation_messages::dsl::*; + + // Local insertable struct for the join table + #[derive(Insertable)] + #[diesel(table_name = crate::schema::conversation_messages)] + struct InsertableConversationMessage { + pub conversation_id: String, + pub message_id: String, + } + + if in_messages.is_empty() { + return Ok(()); + } + + // Build the collections of insertable records + let mut db_messages: Vec = Vec::with_capacity(in_messages.len()); + let mut conv_msg_records: Vec = Vec::with_capacity(in_messages.len()); + + for message in in_messages { + // Handle participant if message has a remote sender + let sender = message.sender.clone(); + let mut db_message: MessageRecord = message.into(); + db_message.sender_participant_id = self.get_or_create_participant(&sender); + + conv_msg_records.push(InsertableConversationMessage { + conversation_id: conversation_guid.to_string(), + message_id: db_message.id.clone(), + }); + + db_messages.push(db_message); + } + + // Batch insert or replace messages + diesel::replace_into(messages) + .values(&db_messages) + .execute(self.connection)?; + + // Batch insert the conversation-message links + diesel::replace_into(conversation_messages) + .values(&conv_msg_records) + .execute(self.connection)?; + + Ok(()) + } + pub fn get_messages_for_conversation(&mut self, conversation_guid: &str) -> Result> { use crate::schema::messages::dsl::*; use crate::schema::conversation_messages::dsl::*; diff --git a/kordophone-db/src/tests/mod.rs b/kordophone-db/src/tests/mod.rs index 3918875..2b21712 100644 --- a/kordophone-db/src/tests/mod.rs +++ b/kordophone-db/src/tests/mod.rs @@ -26,13 +26,13 @@ fn participants_vec_equal_ignoring_id(a: &[Participant], b: &[Participant]) -> b a.iter().zip(b.iter()).all(|(a, b)| participants_equal_ignoring_id(a, b)) } -#[test] -fn test_database_init() { +#[tokio::test] +async fn test_database_init() { let _ = Database::new_in_memory().unwrap(); } -#[test] -fn test_add_conversation() { +#[tokio::test] +async fn test_add_conversation() { let mut db = Database::new_in_memory().unwrap(); db.with_repository(|repository| { let guid = "test"; @@ -62,11 +62,11 @@ fn test_add_conversation() { // And make sure the display name was updated let conversation = repository.get_conversation_by_guid(guid).unwrap().unwrap(); assert_eq!(conversation.display_name.unwrap(), "Modified Conversation"); - }); + }).await; } -#[test] -fn test_conversation_participants() { +#[tokio::test] +async fn test_conversation_participants() { let mut db = Database::new_in_memory().unwrap(); db.with_repository(|repository| { let participants: Vec = vec!["one".into(), "two".into()]; @@ -97,11 +97,11 @@ fn test_conversation_participants() { let read_participants: Vec = read_conversation.participants; assert!(participants_vec_equal_ignoring_id(&participants, &read_participants)); - }); + }).await; } -#[test] -fn test_all_conversations_with_participants() { +#[tokio::test] +async fn test_all_conversations_with_participants() { let mut db = Database::new_in_memory().unwrap(); db.with_repository(|repository| { // Create two conversations with different participants @@ -136,11 +136,11 @@ fn test_all_conversations_with_participants() { assert!(participants_vec_equal_ignoring_id(&conv1.participants, &participants1)); assert!(participants_vec_equal_ignoring_id(&conv2.participants, &participants2)); - }); + }).await; } -#[test] -fn test_messages() { +#[tokio::test] +async fn test_messages() { let mut db = Database::new_in_memory().unwrap(); db.with_repository(|repository| { // First create a conversation with participants @@ -185,11 +185,11 @@ fn test_messages() { } else { panic!("Expected Remote participant. Got: {:?}", retrieved_message2.sender); } - }); + }).await; } -#[test] -fn test_message_ordering() { +#[tokio::test] +async fn test_message_ordering() { let mut db = Database::new_in_memory().unwrap(); db.with_repository(|repository| { // Create a conversation @@ -229,11 +229,93 @@ fn test_message_ordering() { for i in 1..messages.len() { assert!(messages[i].date > messages[i-1].date); } - }); + }).await; } -#[test] -fn test_settings() { +#[tokio::test] +async fn test_insert_messages_batch() { + let mut db = Database::new_in_memory().unwrap(); + db.with_repository(|repository| { + // Create a conversation with two remote participants + let participants: Vec = vec!["Alice".into(), "Bob".into()]; + let conversation = ConversationBuilder::new() + .display_name("Batch Chat") + .participants(participants.clone()) + .build(); + let conversation_id = conversation.guid.clone(); + repository.insert_conversation(conversation).unwrap(); + + // Prepare a batch of messages with increasing timestamps + let now = chrono::Utc::now().naive_utc(); + let message1 = Message::builder() + .text("Hi".to_string()) + .date(now) + .build(); + + let message2 = Message::builder() + .text("Hello".to_string()) + .sender("Alice".into()) + .date(now + chrono::Duration::seconds(1)) + .build(); + + let message3 = Message::builder() + .text("How are you?".to_string()) + .sender("Bob".into()) + .date(now + chrono::Duration::seconds(2)) + .build(); + + let message4 = Message::builder() + .text("Great!".to_string()) + .date(now + chrono::Duration::seconds(3)) + .build(); + + let original_messages = vec![ + message1.clone(), + message2.clone(), + message3.clone(), + message4.clone(), + ]; + + // Batch insert the messages + repository + .insert_messages(&conversation_id, original_messages.clone()) + .unwrap(); + + // Retrieve messages and verify + let retrieved_messages = repository.get_messages_for_conversation(&conversation_id).unwrap(); + assert_eq!(retrieved_messages.len(), original_messages.len()); + + // Ensure ordering by date + for i in 1..retrieved_messages.len() { + assert!(retrieved_messages[i].date > retrieved_messages[i - 1].date); + } + + // Verify that all messages are present with correct content and sender + for original in &original_messages { + let retrieved = retrieved_messages + .iter() + .find(|m| m.id == original.id) + .expect("Message not found"); + assert_eq!(retrieved.text, original.text); + + match (&original.sender, &retrieved.sender) { + (Participant::Me, Participant::Me) => {} + ( + Participant::Remote { display_name: o_name, .. }, + Participant::Remote { display_name: r_name, .. }, + ) => assert_eq!(o_name, r_name), + _ => panic!( + "Sender mismatch: original {:?}, retrieved {:?}", + original.sender, retrieved.sender + ), + } + } + }) + .await; +} + +#[tokio::test] +async fn test_settings() { let mut db = Database::new_in_memory().unwrap(); db.with_settings(|settings| { settings.put("test", &"test".to_string()).unwrap(); @@ -244,5 +326,27 @@ fn test_settings() { let keys = settings.list_keys().unwrap(); assert_eq!(keys.len(), 0); - }); + + // Try encoding a struct + #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq)] + struct TestStruct { + name: String, + age: u32, + } + + let test_struct = TestStruct { + name: "James".to_string(), + age: 35, + }; + + settings.put("test_struct", &test_struct).unwrap(); + assert_eq!(settings.get::("test_struct").unwrap().unwrap(), test_struct); + + // Test with an option + settings.put("test_struct_option", &Option::::None).unwrap(); + assert!(settings.get::>("test_struct_option").unwrap().unwrap().is_none()); + + settings.put("test_struct_option", &Option::::Some("test".to_string())).unwrap(); + assert_eq!(settings.get::>("test_struct_option").unwrap().unwrap(), Some("test".to_string())); + }).await; } diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index 9f8bc32..e5f4474 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -13,7 +13,10 @@ 'id' (string): Unique identifier 'title' (string): Display name 'last_message' (string): Preview text - 'is_unread' (boolean): Unread status"/> + 'is_unread' (boolean): Unread status + 'date' (int64): Date of last message + 'participants' (array of strings): List of participants + 'unread_count' (int32): Number of unread messages"/> diff --git a/kordophoned/src/daemon/events.rs b/kordophoned/src/daemon/events.rs index 3d0e2ba..9b52874 100644 --- a/kordophoned/src/daemon/events.rs +++ b/kordophoned/src/daemon/events.rs @@ -1,5 +1,6 @@ use tokio::sync::oneshot; use kordophone_db::models::Conversation; +use crate::daemon::settings::Settings; pub type Reply = oneshot::Sender; @@ -13,6 +14,12 @@ pub enum Event { /// Returns all known conversations from the database. GetAllConversations(Reply>), + + /// Returns all known settings from the database. + GetAllSettings(Reply), + + /// Update settings in the database. + UpdateSettings(Settings, Reply<()>), } diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index d2d140a..e50fd7d 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -50,6 +50,10 @@ impl TokenStore for DatabaseTokenStore { } } +mod target { + pub static SYNC: &str = "sync"; +} + pub struct Daemon { pub event_sender: Sender, event_receiver: Receiver, @@ -110,6 +114,25 @@ impl Daemon { let conversations = self.get_conversations().await; reply.send(conversations).unwrap(); }, + + Event::GetAllSettings(reply) => { + let settings = self.get_settings().await + .unwrap_or_else(|e| { + log::error!("Failed to get settings: {:#?}", e); + Settings::default() + }); + + reply.send(settings).unwrap(); + }, + + Event::UpdateSettings(settings, reply) => { + self.update_settings(settings).await + .unwrap_or_else(|e| { + log::error!("Failed to update settings: {}", e); + }); + + reply.send(()).unwrap(); + }, } } @@ -118,32 +141,9 @@ impl Daemon { } async fn sync_all_conversations_impl(mut database: Arc>) -> Result<()> { - log::info!("Starting conversation sync"); - - // Get client from the database - let settings = database.with_settings(|s| Settings::from_db(s)) - .await?; + log::info!(target: target::SYNC, "Starting conversation sync"); - let server_url = settings.server_url - .ok_or(DaemonError::ClientNotConfigured)?; - - let mut client = HTTPAPIClient::new( - server_url.parse().unwrap(), - match (settings.username, settings.credential_item) { - (Some(username), Some(password)) => Some( - Credentials { - username, - password, - } - ), - _ => None, - }, - DatabaseTokenStore { database: database.clone() } - ); - - // This function needed to implement TokenManagement - // let token = database.lock().await.get_token(); - // TODO: Clent.token = token + let mut client = Self::get_client_impl(database.clone()).await?; // Fetch conversations from server let fetched_conversations = client.get_conversations().await?; @@ -152,6 +152,7 @@ impl Daemon { .collect(); // Process each conversation + let num_conversations = db_conversations.len(); for conversation in db_conversations { let conversation_id = conversation.guid.clone(); @@ -159,21 +160,18 @@ impl Daemon { database.with_repository(|r| r.insert_conversation(conversation)).await?; // Fetch and sync messages for this conversation + log::info!(target: target::SYNC, "Fetching messages for conversation {}", conversation_id); let messages = client.get_messages(&conversation_id).await?; let db_messages: Vec = messages.into_iter() .map(|m| kordophone_db::models::Message::from(m)) .collect(); // Insert each message - database.with_repository(|r| -> Result<()> { - for message in db_messages { - r.insert_message(&conversation_id, message)?; - } - - Ok(()) - }).await?; + log::info!(target: target::SYNC, "Inserting {} messages for conversation {}", db_messages.len(), conversation_id); + database.with_repository(|r| r.insert_messages(&conversation_id, db_messages)).await?; } - + + log::info!(target: target::SYNC, "Synchronized {} conversations", num_conversations); Ok(()) } @@ -185,8 +183,16 @@ impl Daemon { Ok(settings) } + async fn update_settings(&mut self, settings: Settings) -> Result<()> { + self.database.with_settings(|s| settings.save(s)).await + } + async fn get_client(&mut self) -> Result> { - let settings = self.database.with_settings(|s| + Self::get_client_impl(self.database.clone()).await + } + + async fn get_client_impl(mut database: Arc>) -> Result> { + let settings = database.with_settings(|s| Settings::from_db(s) ).await?; @@ -205,7 +211,7 @@ impl Daemon { ), _ => None, }, - DatabaseTokenStore { database: self.database.clone() } + DatabaseTokenStore { database: database.clone() } ); Ok(client) diff --git a/kordophoned/src/daemon/settings.rs b/kordophoned/src/daemon/settings.rs index 29f9899..8d7a8ed 100644 --- a/kordophoned/src/daemon/settings.rs +++ b/kordophoned/src/daemon/settings.rs @@ -16,9 +16,9 @@ pub struct Settings { impl Settings { pub fn from_db(db_settings: &mut DbSettings) -> Result { - let server_url = db_settings.get(keys::SERVER_URL)?; - let username = db_settings.get(keys::USERNAME)?; - let credential_item = db_settings.get(keys::CREDENTIAL_ITEM)?; + let server_url: Option = db_settings.get(keys::SERVER_URL)?; + let username: Option = db_settings.get(keys::USERNAME)?; + let credential_item: Option = db_settings.get(keys::CREDENTIAL_ITEM)?; Ok(Self { server_url, @@ -28,9 +28,25 @@ impl Settings { } pub fn save(&self, db_settings: &mut DbSettings) -> Result<()> { - db_settings.put(keys::SERVER_URL, &self.server_url)?; - db_settings.put(keys::USERNAME, &self.username)?; - db_settings.put(keys::CREDENTIAL_ITEM, &self.credential_item)?; + if let Some(server_url) = &self.server_url { + db_settings.put(keys::SERVER_URL, &server_url)?; + } + if let Some(username) = &self.username { + db_settings.put(keys::USERNAME, &username)?; + } + if let Some(credential_item) = &self.credential_item { + db_settings.put(keys::CREDENTIAL_ITEM, &credential_item)?; + } Ok(()) } +} + +impl Default for Settings { + fn default() -> Self { + Self { + server_url: None, + username: None, + credential_item: None, + } + } } \ No newline at end of file diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 469507c..8ab4cbf 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -1,17 +1,14 @@ use dbus::arg; use dbus_tree::MethodErr; -use std::sync::Arc; -use tokio::sync::{Mutex, MutexGuard}; +use tokio::sync::mpsc; use std::future::Future; use std::thread; use tokio::sync::oneshot; -use tokio::sync::mpsc; -use futures_util::future::FutureExt; use crate::daemon::{ - Daemon, DaemonResult, events::{Event, Reply}, + settings::Settings, }; use crate::dbus::interface::NetBuzzertKordophoneRepository as DbusRepository; @@ -63,7 +60,10 @@ impl DbusRepository for ServerImpl { map.insert("guid".into(), arg::Variant(Box::new(conv.guid))); map.insert("display_name".into(), arg::Variant(Box::new(conv.display_name.unwrap_or_default()))); map.insert("unread_count".into(), arg::Variant(Box::new(conv.unread_count as i32))); - map + map.insert("last_message_preview".into(), arg::Variant(Box::new(conv.last_message_preview.unwrap_or_default()))); + map.insert("participants".into(), arg::Variant(Box::new(conv.participants.into_iter().map(|p| p.display_name()).collect::>()))); + map.insert("date".into(), arg::Variant(Box::new(conv.date.and_utc().timestamp()))); + map }).collect(); Ok(result) @@ -77,35 +77,77 @@ impl DbusRepository for ServerImpl { impl DbusSettings for ServerImpl { fn set_server(&mut self, url: String, user: String) -> Result<(), dbus::MethodErr> { - todo!() + self.send_event_sync(|r| + Event::UpdateSettings(Settings { + server_url: Some(url), + username: Some(user), + credential_item: None, + }, r) + ) } fn set_credential_item_(&mut self, item_path: dbus::Path<'static>) -> Result<(), dbus::MethodErr> { - todo!() + self.send_event_sync(|r| + Event::UpdateSettings(Settings { + server_url: None, + username: None, + credential_item: Some(item_path.to_string()), + }, r) + ) } fn server_url(&self) -> Result { - todo!() + self.send_event_sync(Event::GetAllSettings) + .and_then(|settings| { + Ok(settings.server_url.unwrap_or_default()) + }) } fn set_server_url(&self, value: String) -> Result<(), dbus::MethodErr> { - todo!() + self.send_event_sync(|r| + Event::UpdateSettings(Settings { + server_url: Some(value), + username: None, + credential_item: None, + }, r) + ) } fn username(&self) -> Result { - todo!() + self.send_event_sync(Event::GetAllSettings) + .and_then(|settings| { + Ok(settings.username.unwrap_or_default()) + }) } fn set_username(&self, value: String) -> Result<(), dbus::MethodErr> { - todo!() + self.send_event_sync(|r| + Event::UpdateSettings(Settings { + server_url: None, + username: Some(value), + credential_item: None, + }, r) + ) } fn credential_item(&self) -> Result, dbus::MethodErr> { - todo!() + self.send_event_sync(Event::GetAllSettings) + .and_then(|settings| { + Ok(settings.credential_item.unwrap_or_default()) + }) + .and_then(|item| { + Ok(dbus::Path::new(item).unwrap_or_default()) + }) } fn set_credential_item(&self, value: dbus::Path<'static>) -> Result<(), dbus::MethodErr> { - todo!() + self.send_event_sync(|r| + Event::UpdateSettings(Settings { + server_url: None, + username: None, + credential_item: Some(value.to_string()), + }, r) + ) } } diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index 7a96180..17889aa 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -1,6 +1,7 @@ use anyhow::Result; use clap::Subcommand; use dbus::blocking::{Connection, Proxy}; +use crate::printers::{ConversationPrinter, MessagePrinter}; const DBUS_NAME: &str = "net.buzzert.kordophonecd"; const DBUS_PATH: &str = "/net/buzzert/kordophonecd/daemon"; @@ -11,6 +12,7 @@ mod dbus_interface { } use dbus_interface::NetBuzzertKordophoneRepository as KordophoneRepository; +use dbus_interface::NetBuzzertKordophoneSettings as KordophoneSettings; #[derive(Subcommand)] pub enum Commands { @@ -22,6 +24,33 @@ pub enum Commands { /// Prints the server Kordophone version. Version, + + /// Configuration options + Config { + #[command(subcommand)] + command: ConfigCommands, + }, +} + +#[derive(Subcommand)] +pub enum ConfigCommands { + /// Prints the current settings. + Print, + + /// Sets the server URL. + SetServerUrl { + url: String, + }, + + /// Sets the username. + SetUsername { + username: String, + }, + + /// Sets the credential item. + SetCredentialItem { + item: String, + }, } impl Commands { @@ -31,6 +60,7 @@ impl Commands { Commands::Version => client.print_version().await, Commands::Conversations => client.print_conversations().await, Commands::Sync => client.sync_conversations().await, + Commands::Config { command } => client.config(command).await, } } } @@ -58,13 +88,53 @@ impl DaemonCli { pub async fn print_conversations(&mut self) -> Result<()> { let conversations = KordophoneRepository::get_conversations(&self.proxy())?; - println!("Conversations: {:?}", conversations); + println!("Number of conversations: {}", conversations.len()); + + for conversation in conversations { + println!("{}", ConversationPrinter::new(&conversation.into())); + } + Ok(()) } pub async fn sync_conversations(&mut self) -> Result<()> { - let success = KordophoneRepository::sync_all_conversations(&self.proxy())?; - println!("Initiated sync"); + KordophoneRepository::sync_all_conversations(&self.proxy()) + .map_err(|e| anyhow::anyhow!("Failed to sync conversations: {}", e)) + } + + pub async fn config(&mut self, cmd: ConfigCommands) -> Result<()> { + match cmd { + ConfigCommands::Print => self.print_settings().await, + ConfigCommands::SetServerUrl { url } => self.set_server_url(url).await, + ConfigCommands::SetUsername { username } => self.set_username(username).await, + ConfigCommands::SetCredentialItem { item } => self.set_credential_item(item).await, + } + } + + pub async fn print_settings(&mut self) -> Result<()> { + let server_url = KordophoneSettings::server_url(&self.proxy())?; + let username = KordophoneSettings::username(&self.proxy())?; + let credential_item = KordophoneSettings::credential_item(&self.proxy())?; + + println!("Server URL: {}", server_url); + println!("Username: {}", username); + println!("Credential Item: {}", credential_item); Ok(()) } + + pub async fn set_server_url(&mut self, url: String) -> Result<()> { + KordophoneSettings::set_server_url(&self.proxy(), url) + .map_err(|e| anyhow::anyhow!("Failed to set server URL: {}", e)) + } + + pub async fn set_username(&mut self, username: String) -> Result<()> { + KordophoneSettings::set_username(&self.proxy(), username) + .map_err(|e| anyhow::anyhow!("Failed to set username: {}", e)) + } + + pub async fn set_credential_item(&mut self, item: String) -> Result<()> { + KordophoneSettings::set_credential_item(&self.proxy(), item.into()) + .map_err(|e| anyhow::anyhow!("Failed to set credential item: {}", e)) + } + } \ No newline at end of file diff --git a/kpcli/src/printers.rs b/kpcli/src/printers.rs index 81930d1..96a56e3 100644 --- a/kpcli/src/printers.rs +++ b/kpcli/src/printers.rs @@ -1,6 +1,7 @@ use std::fmt::Display; use time::OffsetDateTime; use pretty::RcDoc; +use dbus::arg::{self, RefArg}; pub struct PrintableConversation { pub guid: String, @@ -37,6 +38,25 @@ impl From for PrintableConversation { } } +impl From for PrintableConversation { + fn from(value: arg::PropMap) -> Self { + Self { + guid: value.get("guid").unwrap().as_str().unwrap().to_string(), + date: OffsetDateTime::from_unix_timestamp(value.get("date").unwrap().as_i64().unwrap()).unwrap(), + unread_count: value.get("unread_count").unwrap().as_i64().unwrap().try_into().unwrap(), + last_message_preview: value.get("last_message_preview").unwrap().as_str().map(|s| s.to_string()), + participants: value.get("participants") + .unwrap() + .0 + .as_iter() + .unwrap() + .map(|s| s.as_str().unwrap().to_string()) + .collect(), + display_name: value.get("display_name").unwrap().as_str().map(|s| s.to_string()), + } + } +} + pub struct PrintableMessage { pub guid: String, pub date: OffsetDateTime, From 1e9b5709933401990a9685647e81fe77c62588fe Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 27 Apr 2025 22:44:05 -0700 Subject: [PATCH 041/138] devises a strategy for signals --- .../net.buzzert.kordophonecd.Server.xml | 9 ++++++ kordophoned/src/daemon/mod.rs | 32 ++++++++++++++++--- kordophoned/src/daemon/signals.rs | 4 +++ kordophoned/src/dbus/mod.rs | 4 +++ kordophoned/src/dbus/server_impl.rs | 1 + kordophoned/src/main.rs | 25 ++++++++++++--- kpcli/src/daemon/mod.rs | 24 ++++++++++++++ 7 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 kordophoned/src/daemon/signals.rs diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index e5f4474..00e98f8 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -4,6 +4,8 @@ + @@ -21,7 +23,14 @@ + + + + + diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index e50fd7d..58e2740 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -4,6 +4,9 @@ use settings::Settings; pub mod events; use events::*; +pub mod signals; +use signals::*; + use anyhow::Result; use directories::ProjectDirs; use std::error::Error; @@ -57,6 +60,10 @@ mod target { pub struct Daemon { pub event_sender: Sender, event_receiver: Receiver, + + signal_receiver: Option>, + signal_sender: Sender, + version: String, database: Arc>, runtime: tokio::runtime::Runtime, @@ -73,7 +80,7 @@ impl Daemon { // Create event channels let (event_sender, event_receiver) = tokio::sync::mpsc::channel(100); - + let (signal_sender, signal_receiver) = tokio::sync::mpsc::channel(100); // Create background task runtime let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -82,7 +89,15 @@ impl Daemon { let database_impl = Database::new(&database_path.to_string_lossy())?; let database = Arc::new(Mutex::new(database_impl)); - Ok(Self { version: "0.1.0".to_string(), database, event_receiver, event_sender, runtime }) + Ok(Self { + version: "0.1.0".to_string(), + database, + event_receiver, + event_sender, + signal_receiver: Some(signal_receiver), + signal_sender, + runtime + }) } pub async fn run(&mut self) { @@ -99,8 +114,9 @@ impl Daemon { Event::SyncAllConversations(reply) => { let db_clone = self.database.clone(); + let signal_sender = self.signal_sender.clone(); self.runtime.spawn(async move { - let result = Self::sync_all_conversations_impl(db_clone).await; + let result = Self::sync_all_conversations_impl(db_clone, signal_sender).await; if let Err(e) = result { log::error!("Error handling sync event: {}", e); } @@ -136,11 +152,16 @@ impl Daemon { } } + /// Panics if the signal receiver has already been taken. + pub fn obtain_signal_receiver(&mut self) -> Receiver { + self.signal_receiver.take().unwrap() + } + async fn get_conversations(&mut self) -> Vec { self.database.lock().await.with_repository(|r| r.all_conversations().unwrap()).await } - async fn sync_all_conversations_impl(mut database: Arc>) -> Result<()> { + async fn sync_all_conversations_impl(mut database: Arc>, signal_sender: Sender) -> Result<()> { log::info!(target: target::SYNC, "Starting conversation sync"); let mut client = Self::get_client_impl(database.clone()).await?; @@ -171,6 +192,9 @@ impl Daemon { database.with_repository(|r| r.insert_messages(&conversation_id, db_messages)).await?; } + // Send conversations updated signal. + signal_sender.send(Signal::ConversationsUpdated).await?; + log::info!(target: target::SYNC, "Synchronized {} conversations", num_conversations); Ok(()) } diff --git a/kordophoned/src/daemon/signals.rs b/kordophoned/src/daemon/signals.rs new file mode 100644 index 0000000..05bb75f --- /dev/null +++ b/kordophoned/src/daemon/signals.rs @@ -0,0 +1,4 @@ +#[derive(Debug, Clone)] +pub enum Signal { + ConversationsUpdated, +} diff --git a/kordophoned/src/dbus/mod.rs b/kordophoned/src/dbus/mod.rs index 66c8455..a901658 100644 --- a/kordophoned/src/dbus/mod.rs +++ b/kordophoned/src/dbus/mod.rs @@ -8,4 +8,8 @@ pub mod interface { pub const OBJECT_PATH: &str = "/net/buzzert/kordophonecd/daemon"; include!(concat!(env!("OUT_DIR"), "/kordophone-server.rs")); + + pub mod signals { + pub use crate::interface::NetBuzzertKordophoneRepositoryConversationsUpdated as ConversationsUpdated; + } } \ No newline at end of file diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 8ab4cbf..63458a3 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -9,6 +9,7 @@ use crate::daemon::{ DaemonResult, events::{Event, Reply}, settings::Settings, + signals::Signal, }; use crate::dbus::interface::NetBuzzertKordophoneRepository as DbusRepository; diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index a36f719..9b507aa 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -4,10 +4,9 @@ mod daemon; use std::future; use log::LevelFilter; -use std::sync::Arc; -use tokio::sync::Mutex; - use daemon::Daemon; +use daemon::signals::Signal; + use dbus::endpoint::Endpoint as DbusEndpoint; use dbus::interface; use dbus::server_impl::ServerImpl; @@ -35,7 +34,7 @@ async fn main() { let server = ServerImpl::new(daemon.event_sender.clone()); // Register DBus interfaces with endpoint - let endpoint = DbusEndpoint::new(server.clone()); + let endpoint = DbusEndpoint::new(server); endpoint.register( interface::NAME, interface::OBJECT_PATH, @@ -47,6 +46,24 @@ async fn main() { } ).await; + let mut signal_receiver = daemon.obtain_signal_receiver(); + tokio::spawn(async move { + use dbus::interface::signals as DbusSignals; + + while let Some(signal) = signal_receiver.recv().await { + match signal { + Signal::ConversationsUpdated => { + log::info!("Sending signal: ConversationsUpdated"); + endpoint.send_signal(interface::OBJECT_PATH, DbusSignals::ConversationsUpdated{}) + .unwrap_or_else(|_| { + log::error!("Failed to send signal"); + 0 + }); + } + } + } + }); + daemon.run().await; future::pending::<()>().await; diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index 17889aa..e2a6644 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -2,6 +2,7 @@ use anyhow::Result; use clap::Subcommand; use dbus::blocking::{Connection, Proxy}; use crate::printers::{ConversationPrinter, MessagePrinter}; +use std::future; const DBUS_NAME: &str = "net.buzzert.kordophonecd"; const DBUS_PATH: &str = "/net/buzzert/kordophonecd/daemon"; @@ -30,6 +31,9 @@ pub enum Commands { #[command(subcommand)] command: ConfigCommands, }, + + /// Waits for signals from the daemon. + Signals, } #[derive(Subcommand)] @@ -61,6 +65,7 @@ impl Commands { Commands::Conversations => client.print_conversations().await, Commands::Sync => client.sync_conversations().await, Commands::Config { command } => client.config(command).await, + Commands::Signals => client.wait_for_signals().await, } } } @@ -102,6 +107,25 @@ impl DaemonCli { .map_err(|e| anyhow::anyhow!("Failed to sync conversations: {}", e)) } + pub async fn wait_for_signals(&mut self) -> Result<()> { + use dbus::Message; + mod dbus_signals { + pub use super::dbus_interface::NetBuzzertKordophoneRepositoryConversationsUpdated as ConversationsUpdated; + } + + let _id = self.proxy().match_signal(|h: dbus_signals::ConversationsUpdated, _: &Connection, _: &Message| { + println!("Signal: Conversations updated"); + true + }); + + println!("Waiting for signals..."); + loop { + self.conn.process(std::time::Duration::from_millis(1000))?; + } + + Ok(()) + } + pub async fn config(&mut self, cmd: ConfigCommands) -> Result<()> { match cmd { ConfigCommands::Print => self.print_settings().await, From 6375284d9eda0a6118e79a8ff9a5dc33a3f5ae8b Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 27 Apr 2025 23:27:21 -0700 Subject: [PATCH 042/138] daemon: copy audit, cleanup --- kordophoned/src/daemon/mod.rs | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 58e2740..ee36af9 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -20,7 +20,6 @@ use async_trait::async_trait; use kordophone_db::{ database::{Database, DatabaseAccess}, models::Conversation, - repository::Repository, }; use kordophone::model::JwtToken; @@ -113,10 +112,10 @@ impl Daemon { }, Event::SyncAllConversations(reply) => { - let db_clone = self.database.clone(); + let mut db_clone = self.database.clone(); let signal_sender = self.signal_sender.clone(); self.runtime.spawn(async move { - let result = Self::sync_all_conversations_impl(db_clone, signal_sender).await; + let result = Self::sync_all_conversations_impl(&mut db_clone, &signal_sender).await; if let Err(e) = result { log::error!("Error handling sync event: {}", e); } @@ -142,7 +141,7 @@ impl Daemon { }, Event::UpdateSettings(settings, reply) => { - self.update_settings(settings).await + self.update_settings(&settings).await .unwrap_or_else(|e| { log::error!("Failed to update settings: {}", e); }); @@ -161,10 +160,10 @@ impl Daemon { self.database.lock().await.with_repository(|r| r.all_conversations().unwrap()).await } - async fn sync_all_conversations_impl(mut database: Arc>, signal_sender: Sender) -> Result<()> { + async fn sync_all_conversations_impl(database: &mut Arc>, signal_sender: &Sender) -> Result<()> { log::info!(target: target::SYNC, "Starting conversation sync"); - let mut client = Self::get_client_impl(database.clone()).await?; + let mut client = Self::get_client_impl(database).await?; // Fetch conversations from server let fetched_conversations = client.get_conversations().await?; @@ -181,14 +180,14 @@ impl Daemon { database.with_repository(|r| r.insert_conversation(conversation)).await?; // Fetch and sync messages for this conversation - log::info!(target: target::SYNC, "Fetching messages for conversation {}", conversation_id); + log::debug!(target: target::SYNC, "Fetching messages for conversation {}", conversation_id); let messages = client.get_messages(&conversation_id).await?; let db_messages: Vec = messages.into_iter() .map(|m| kordophone_db::models::Message::from(m)) .collect(); // Insert each message - log::info!(target: target::SYNC, "Inserting {} messages for conversation {}", db_messages.len(), conversation_id); + log::debug!(target: target::SYNC, "Inserting {} messages for conversation {}", db_messages.len(), conversation_id); database.with_repository(|r| r.insert_messages(&conversation_id, db_messages)).await?; } @@ -207,15 +206,15 @@ impl Daemon { Ok(settings) } - async fn update_settings(&mut self, settings: Settings) -> Result<()> { + async fn update_settings(&mut self, settings: &Settings) -> Result<()> { self.database.with_settings(|s| settings.save(s)).await } async fn get_client(&mut self) -> Result> { - Self::get_client_impl(self.database.clone()).await + Self::get_client_impl(&mut self.database).await } - async fn get_client_impl(mut database: Arc>) -> Result> { + async fn get_client_impl(database: &mut Arc>) -> Result> { let settings = database.with_settings(|s| Settings::from_db(s) ).await?; From 9c245a5b52b649326b4bae502ade6e5a62a7f1a3 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 28 Apr 2025 15:17:58 -0700 Subject: [PATCH 043/138] client: Started working on ability to sync messages after last known message --- kordophone/src/api/http_client.rs | 27 +++++++++++++++++++++++---- kordophone/src/api/mod.rs | 10 ++++++++-- kordophone/src/model/message.rs | 12 ++++++++++++ kordophone/src/model/mod.rs | 1 + kordophone/src/tests/test_client.rs | 10 ++++++++-- kordophoned/src/daemon/mod.rs | 2 +- kpcli/src/client/mod.rs | 2 +- kpcli/src/db/mod.rs | 2 +- 8 files changed, 55 insertions(+), 11 deletions(-) diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index 46bf197..ec1c186 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -10,7 +10,7 @@ use async_trait::async_trait; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use crate::{ - model::{Conversation, ConversationID, JwtToken, Message}, + model::{Conversation, ConversationID, JwtToken, Message, MessageID}, APIInterface }; @@ -118,8 +118,27 @@ impl APIInterface for HTTPAPIClient { Ok(token) } - async fn get_messages(&mut self, conversation_id: &ConversationID) -> Result, Self::Error> { - let endpoint = format!("messages?guid={}", conversation_id); + async fn get_messages( + &mut self, + conversation_id: &ConversationID, + limit: Option, + before: Option, + after: Option, + ) -> Result, Self::Error> { + let mut endpoint = format!("messages?guid={}", conversation_id); + + if let Some(limit_val) = limit { + endpoint.push_str(&format!("&limit={}", limit_val)); + } + + if let Some(before_id) = before { + endpoint.push_str(&format!("&beforeMessageGUID={}", before_id)); + } + + if let Some(after_id) = after { + endpoint.push_str(&format!("&afterMessageGUID={}", after_id)); + } + let messages: Vec = self.request(&endpoint, Method::GET).await?; Ok(messages) } @@ -288,7 +307,7 @@ mod test { let mut client = local_mock_client(); let conversations = client.get_conversations().await.unwrap(); let conversation = conversations.first().unwrap(); - let messages = client.get_messages(&conversation.guid).await.unwrap(); + let messages = client.get_messages(&conversation.guid, None, None, None).await.unwrap(); assert!(!messages.is_empty()); } } diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index de7a8cd..6ae0243 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -1,6 +1,6 @@ use async_trait::async_trait; pub use crate::model::{ - Conversation, Message, ConversationID + Conversation, Message, ConversationID, MessageID, }; use crate::model::JwtToken; @@ -20,7 +20,13 @@ pub trait APIInterface { async fn get_conversations(&mut self) -> Result, Self::Error>; // (GET) /messages - async fn get_messages(&mut self, conversation_id: &ConversationID) -> Result, Self::Error>; + async fn get_messages( + &mut self, + conversation_id: &ConversationID, + limit: Option, + before: Option, + after: Option, + ) -> Result, Self::Error>; // (POST) /authenticate async fn authenticate(&mut self, credentials: Credentials) -> Result; diff --git a/kordophone/src/model/message.rs b/kordophone/src/model/message.rs index 1420536..d226604 100644 --- a/kordophone/src/model/message.rs +++ b/kordophone/src/model/message.rs @@ -2,6 +2,10 @@ use serde::Deserialize; use time::OffsetDateTime; use uuid::Uuid; +use super::Identifiable; + +pub type MessageID = ::ID; + #[derive(Debug, Clone, Deserialize)] pub struct Message { pub guid: String, @@ -22,6 +26,14 @@ impl Message { } } +impl Identifiable for Message { + type ID = String; + + fn id(&self) -> &Self::ID { + &self.guid + } +} + #[derive(Default)] pub struct MessageBuilder { guid: Option, diff --git a/kordophone/src/model/mod.rs b/kordophone/src/model/mod.rs index d1c848e..f07d6f6 100644 --- a/kordophone/src/model/mod.rs +++ b/kordophone/src/model/mod.rs @@ -5,6 +5,7 @@ pub use conversation::Conversation; pub use conversation::ConversationID; pub use message::Message; +pub use message::MessageID; pub mod jwt; pub use jwt::JwtToken; diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index a67612b..1ddfd35 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; pub use crate::APIInterface; use crate::{ api::http_client::Credentials, - model::{Conversation, ConversationID, JwtToken, Message} + model::{Conversation, ConversationID, JwtToken, Message, MessageID} }; pub struct TestClient { @@ -44,7 +44,13 @@ impl APIInterface for TestClient { Ok(self.conversations.clone()) } - async fn get_messages(&mut self, conversation_id: &ConversationID) -> Result, Self::Error> { + async fn get_messages( + &mut self, + conversation_id: &ConversationID, + limit: Option, + before: Option, + after: Option + ) -> Result, Self::Error> { if let Some(messages) = self.messages.get(conversation_id) { return Ok(messages.clone()) } diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index ee36af9..71932b2 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -181,7 +181,7 @@ impl Daemon { // Fetch and sync messages for this conversation log::debug!(target: target::SYNC, "Fetching messages for conversation {}", conversation_id); - let messages = client.get_messages(&conversation_id).await?; + let messages = client.get_messages(&conversation_id, None, None, None).await?; let db_messages: Vec = messages.into_iter() .map(|m| kordophone_db::models::Message::from(m)) .collect(); diff --git a/kpcli/src/client/mod.rs b/kpcli/src/client/mod.rs index a0c146e..8283c1d 100644 --- a/kpcli/src/client/mod.rs +++ b/kpcli/src/client/mod.rs @@ -77,7 +77,7 @@ impl ClientCli { } pub async fn print_messages(&mut self, conversation_id: String) -> Result<()> { - let messages = self.api.get_messages(&conversation_id).await?; + let messages = self.api.get_messages(&conversation_id, None, None, None).await?; for message in messages { println!("{}", MessagePrinter::new(&message.into())); } diff --git a/kpcli/src/db/mod.rs b/kpcli/src/db/mod.rs index 829ca74..f5198b1 100644 --- a/kpcli/src/db/mod.rs +++ b/kpcli/src/db/mod.rs @@ -151,7 +151,7 @@ impl DbClient { }).await?; // Fetch and sync messages for this conversation - let messages = client.get_messages(&conversation_id).await?; + let messages = client.get_messages(&conversation_id, None, None, None).await?; let db_messages: Vec = messages.into_iter() .map(|m| kordophone_db::models::Message::from(m)) .collect(); From c189e5f9e32990f3ec855660288f78cc5b188a09 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 28 Apr 2025 16:00:04 -0700 Subject: [PATCH 044/138] daemon: add support for getting messages from db --- Cargo.lock | 136 +++++++++++++++++- kordophone-db/src/repository.rs | 15 ++ kordophone-db/src/tests/mod.rs | 4 + .../net.buzzert.kordophonecd.Server.xml | 16 +++ kordophoned/src/daemon/events.rs | 8 +- kordophoned/src/daemon/mod.rs | 26 +++- kordophoned/src/dbus/server_impl.rs | 22 +++ kordophoned/src/main.rs | 7 +- kpcli/Cargo.toml | 1 + kpcli/src/daemon/mod.rs | 36 ++++- kpcli/src/printers.rs | 11 ++ 11 files changed, 267 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7e5ea67..f29747f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -297,6 +297,27 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "csv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdc4883a9c96732e4733212c01447ebd805833b7275a73ca3ee080fd77afdaf" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d02f3b0da4c6504f86e9cd789d8dbafab48c2321be74e9987593de5a894d93d" +dependencies = [ + "memchr", +] + [[package]] name = "ctor" version = "0.2.8" @@ -459,6 +480,16 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + [[package]] name = "dirs-sys" version = "0.5.0" @@ -467,10 +498,21 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.0", "windows-sys 0.59.0", ] +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + [[package]] name = "dotenv" version = "0.15.0" @@ -497,6 +539,12 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "env_filter" version = "0.1.2" @@ -678,6 +726,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hermit-abi" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e" + [[package]] name = "http" version = "0.2.12" @@ -788,6 +842,17 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "is-terminal" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +dependencies = [ + "hermit-abi 0.5.0", + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -888,7 +953,7 @@ dependencies = [ "kordophone", "kordophone-db", "log", - "thiserror", + "thiserror 2.0.12", "tokio", ] @@ -906,6 +971,7 @@ dependencies = [ "kordophone-db", "log", "pretty", + "prettytable", "time", "tokio", ] @@ -1199,6 +1265,20 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "prettytable" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46480520d1b77c9a3482d39939fcf96831537a250ec62d4fd8fbdf8e0302e781" +dependencies = [ + "csv", + "encode_unicode", + "is-terminal", + "lazy_static", + "term", + "unicode-width", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -1256,6 +1336,17 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.0" @@ -1264,7 +1355,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ "getrandom", "libredox", - "thiserror", + "thiserror 2.0.12", ] [[package]] @@ -1315,6 +1406,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + [[package]] name = "ryu" version = "1.0.17" @@ -1477,6 +1574,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -1495,13 +1603,33 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] diff --git a/kordophone-db/src/repository.rs b/kordophone-db/src/repository.rs index 1371c3c..468ed68 100644 --- a/kordophone-db/src/repository.rs +++ b/kordophone-db/src/repository.rs @@ -207,6 +207,21 @@ impl<'a> Repository<'a> { Ok(result) } + pub fn get_last_message_for_conversation(&mut self, conversation_guid: &str) -> Result> { + use crate::schema::messages::dsl::*; + use crate::schema::conversation_messages::dsl::*; + + let message_record = conversation_messages + .filter(conversation_id.eq(conversation_guid)) + .inner_join(messages) + .select(MessageRecord::as_select()) + .order_by(schema::messages::date.desc()) + .first::(self.connection) + .optional()?; + + Ok(message_record.map(|r| r.into())) + } + // Helper function to get the last inserted row ID // This is a workaround since the Sqlite backend doesn't support `RETURNING` // Huge caveat with this is that it depends on whatever the last insert was, prevents concurrent inserts. diff --git a/kordophone-db/src/tests/mod.rs b/kordophone-db/src/tests/mod.rs index 2b21712..facc721 100644 --- a/kordophone-db/src/tests/mod.rs +++ b/kordophone-db/src/tests/mod.rs @@ -310,6 +310,10 @@ async fn test_insert_messages_batch() { ), } } + + // Make sure the last message is the last one we inserted + let last_message = repository.get_last_message_for_conversation(&conversation_id).unwrap().unwrap(); + assert_eq!(last_message.id, message4.id); }) .await; } diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index 00e98f8..cbe2060 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -8,6 +8,8 @@ value="Returns the version of the client daemon."/> + + + + + + + + + + + + + + + diff --git a/kordophoned/src/daemon/events.rs b/kordophoned/src/daemon/events.rs index 9b52874..598a7a0 100644 --- a/kordophoned/src/daemon/events.rs +++ b/kordophoned/src/daemon/events.rs @@ -1,5 +1,5 @@ use tokio::sync::oneshot; -use kordophone_db::models::Conversation; +use kordophone_db::models::{Conversation, Message}; use crate::daemon::settings::Settings; pub type Reply = oneshot::Sender; @@ -20,6 +20,12 @@ pub enum Event { /// Update settings in the database. UpdateSettings(Settings, Reply<()>), + + /// Returns all messages for a conversation from the database. + /// Parameters: + /// - conversation_id: The ID of the conversation to get messages for. + /// - last_message_id: (optional) The ID of the last message to get. If None, all messages are returned. + GetMessages(String, Option, Reply>), } diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 71932b2..58dcb44 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -19,7 +19,7 @@ use async_trait::async_trait; use kordophone_db::{ database::{Database, DatabaseAccess}, - models::Conversation, + models::{Conversation, Message}, }; use kordophone::model::JwtToken; @@ -54,6 +54,7 @@ impl TokenStore for DatabaseTokenStore { mod target { pub static SYNC: &str = "sync"; + pub static EVENT: &str = "event"; } pub struct Daemon { @@ -100,7 +101,11 @@ impl Daemon { } pub async fn run(&mut self) { + log::info!("Starting daemon version {}", self.version); + log::debug!("Debug logging enabled."); + while let Some(event) = self.event_receiver.recv().await { + log::debug!(target: target::EVENT, "Received event: {:?}", event); self.handle_event(event).await; } } @@ -148,6 +153,11 @@ impl Daemon { reply.send(()).unwrap(); }, + + Event::GetMessages(conversation_id, last_message_id, reply) => { + let messages = self.get_messages(conversation_id, last_message_id).await; + reply.send(messages).unwrap(); + }, } } @@ -160,6 +170,10 @@ impl Daemon { self.database.lock().await.with_repository(|r| r.all_conversations().unwrap()).await } + async fn get_messages(&mut self, conversation_id: String, last_message_id: Option) -> Vec { + self.database.lock().await.with_repository(|r| r.get_messages_for_conversation(&conversation_id).unwrap()).await + } + async fn sync_all_conversations_impl(database: &mut Arc>, signal_sender: &Sender) -> Result<()> { log::info!(target: target::SYNC, "Starting conversation sync"); @@ -180,8 +194,16 @@ impl Daemon { database.with_repository(|r| r.insert_conversation(conversation)).await?; // Fetch and sync messages for this conversation + let last_message_id = database.with_repository(|r| -> Option { + r.get_last_message_for_conversation(&conversation_id) + .unwrap_or(None) + .map(|m| m.id) + }).await; + log::debug!(target: target::SYNC, "Fetching messages for conversation {}", conversation_id); - let messages = client.get_messages(&conversation_id, None, None, None).await?; + log::debug!(target: target::SYNC, "Last message id: {:?}", last_message_id); + + let messages = client.get_messages(&conversation_id, None, None, last_message_id).await?; let db_messages: Vec = messages.into_iter() .map(|m| kordophone_db::models::Message::from(m)) .collect(); diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 63458a3..6a1523a 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -74,6 +74,28 @@ impl DbusRepository for ServerImpl { fn sync_all_conversations(&mut self) -> Result<(), dbus::MethodErr> { self.send_event_sync(Event::SyncAllConversations) } + + fn get_messages(&mut self, conversation_id: String, last_message_id: String) -> Result, dbus::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)) + .and_then(|messages| { + let result = messages.into_iter().map(|msg| { + let mut map = arg::PropMap::new(); + map.insert("id".into(), arg::Variant(Box::new(msg.id))); + map.insert("text".into(), arg::Variant(Box::new(msg.text))); + map.insert("date".into(), arg::Variant(Box::new(msg.date.and_utc().timestamp()))); + map.insert("sender".into(), arg::Variant(Box::new(msg.sender.display_name()))); + map + }).collect(); + + Ok(result) + }) + } } impl DbusSettings for ServerImpl { diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index 9b507aa..fa48fef 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -12,9 +12,14 @@ use dbus::interface; use dbus::server_impl::ServerImpl; fn initialize_logging() { + // Weird: is this the best way to do this? + let log_level = std::env::var("RUST_LOG") + .map(|s| s.parse::().unwrap_or(LevelFilter::Info)) + .unwrap_or(LevelFilter::Info); + env_logger::Builder::from_default_env() - .filter_level(LevelFilter::Info) .format_timestamp_secs() + .filter_level(log_level) .init(); } diff --git a/kpcli/Cargo.toml b/kpcli/Cargo.toml index d55b6d7..420baf6 100644 --- a/kpcli/Cargo.toml +++ b/kpcli/Cargo.toml @@ -15,6 +15,7 @@ kordophone = { path = "../kordophone" } kordophone-db = { path = "../kordophone-db" } log = "0.4.22" pretty = { version = "0.12.3", features = ["termcolor"] } +prettytable = "0.10.0" time = "0.3.37" tokio = "1.41.1" diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index e2a6644..9cf11b2 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -1,8 +1,8 @@ use anyhow::Result; use clap::Subcommand; use dbus::blocking::{Connection, Proxy}; +use prettytable::table; use crate::printers::{ConversationPrinter, MessagePrinter}; -use std::future; const DBUS_NAME: &str = "net.buzzert.kordophonecd"; const DBUS_PATH: &str = "/net/buzzert/kordophonecd/daemon"; @@ -34,6 +34,12 @@ pub enum Commands { /// Waits for signals from the daemon. Signals, + + /// Prints the messages for a conversation. + Messages { + conversation_id: String, + last_message_id: Option, + }, } #[derive(Subcommand)] @@ -66,6 +72,7 @@ impl Commands { Commands::Sync => client.sync_conversations().await, Commands::Config { command } => client.config(command).await, Commands::Signals => client.wait_for_signals().await, + Commands::Messages { conversation_id, last_message_id } => client.print_messages(conversation_id, last_message_id).await, } } } @@ -107,6 +114,17 @@ impl DaemonCli { .map_err(|e| anyhow::anyhow!("Failed to sync conversations: {}", e)) } + pub async fn print_messages(&mut self, conversation_id: String, last_message_id: Option) -> Result<()> { + let messages = KordophoneRepository::get_messages(&self.proxy(), &conversation_id, &last_message_id.unwrap_or_default())?; + println!("Number of messages: {}", messages.len()); + + for message in messages { + println!("{}", MessagePrinter::new(&message.into())); + } + + Ok(()) + } + pub async fn wait_for_signals(&mut self) -> Result<()> { use dbus::Message; mod dbus_signals { @@ -136,13 +154,17 @@ impl DaemonCli { } pub async fn print_settings(&mut self) -> Result<()> { - let server_url = KordophoneSettings::server_url(&self.proxy())?; - let username = KordophoneSettings::username(&self.proxy())?; - let credential_item = KordophoneSettings::credential_item(&self.proxy())?; + let server_url = KordophoneSettings::server_url(&self.proxy()).unwrap_or_default(); + let username = KordophoneSettings::username(&self.proxy()).unwrap_or_default(); + let credential_item = KordophoneSettings::credential_item(&self.proxy()).unwrap_or_default(); + + let table = table!( + [ b->"Server URL", &server_url ], + [ b->"Username", &username ], + [ b->"Credential Item", &credential_item ] + ); + table.printstd(); - println!("Server URL: {}", server_url); - println!("Username: {}", username); - println!("Credential Item: {}", credential_item); Ok(()) } diff --git a/kpcli/src/printers.rs b/kpcli/src/printers.rs index 96a56e3..5a7146f 100644 --- a/kpcli/src/printers.rs +++ b/kpcli/src/printers.rs @@ -86,6 +86,17 @@ impl From for PrintableMessage { } } +impl From for PrintableMessage { + fn from(value: arg::PropMap) -> Self { + Self { + guid: value.get("id").unwrap().as_str().unwrap().to_string(), + date: OffsetDateTime::from_unix_timestamp(value.get("date").unwrap().as_i64().unwrap()).unwrap(), + sender: value.get("sender").unwrap().as_str().unwrap().to_string(), + text: value.get("text").unwrap().as_str().unwrap().to_string(), + } + } +} + pub struct ConversationPrinter<'a> { doc: RcDoc<'a, PrintableConversation> } From e7d837d68c665c8f51e1a21cadc210b4b40cc637 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 28 Apr 2025 16:06:51 -0700 Subject: [PATCH 045/138] cargo clippy/fix --- kordophone-db/src/database.rs | 5 +--- kordophone-db/src/models/message.rs | 6 +++++ kordophone-db/src/tests/mod.rs | 6 ++--- kordophone/src/api/http_client.rs | 5 ++-- kordophone/src/api/mod.rs | 6 +++++ kordophoned/src/daemon/mod.rs | 10 ++++---- kordophoned/src/daemon/settings.rs | 10 +------- kordophoned/src/dbus/endpoint.rs | 2 +- kordophoned/src/dbus/server_impl.rs | 36 +++++++++++------------------ kpcli/src/client/mod.rs | 3 +-- kpcli/src/daemon/mod.rs | 2 -- kpcli/src/db/mod.rs | 9 +++----- kpcli/src/printers.rs | 4 ++-- 13 files changed, 43 insertions(+), 61 deletions(-) diff --git a/kordophone-db/src/database.rs b/kordophone-db/src/database.rs index 29a8723..c8d86ac 100644 --- a/kordophone-db/src/database.rs +++ b/kordophone-db/src/database.rs @@ -94,10 +94,7 @@ impl TokenStore for Database { async fn get_token(&mut self) -> Option { self.with_settings(|settings| { let token: Result> = settings.get(TOKEN_KEY); - match token { - Ok(data) => data, - Err(_) => None, - } + token.unwrap_or_default() }).await } diff --git a/kordophone-db/src/models/message.rs b/kordophone-db/src/models/message.rs index 9ef76d9..d16f620 100644 --- a/kordophone-db/src/models/message.rs +++ b/kordophone-db/src/models/message.rs @@ -47,6 +47,12 @@ pub struct MessageBuilder { date: Option, } +impl Default for MessageBuilder { + fn default() -> Self { + Self::new() + } +} + impl MessageBuilder { pub fn new() -> Self { Self { diff --git a/kordophone-db/src/tests/mod.rs b/kordophone-db/src/tests/mod.rs index facc721..1023b9e 100644 --- a/kordophone-db/src/tests/mod.rs +++ b/kordophone-db/src/tests/mod.rs @@ -1,12 +1,10 @@ use crate::{ - database::{Database, DatabaseAccess}, - repository::Repository, + database::{Database, DatabaseAccess}, models::{ conversation::{Conversation, ConversationBuilder}, participant::Participant, message::Message, - }, - settings::Settings, + }, }; // Helper function to compare participants ignoring database IDs diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index ec1c186..2cada8b 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -3,7 +3,7 @@ extern crate serde; use std::{path::PathBuf, str}; -use crate::api::{TokenStore, InMemoryTokenStore}; +use crate::api::TokenStore; use hyper::{Body, Client, Method, Request, Uri}; use async_trait::async_trait; @@ -247,7 +247,8 @@ impl HTTPAPIClient { #[cfg(test)] mod test { use super::*; - + use crate::api::InMemoryTokenStore; + #[cfg(test)] fn local_mock_client() -> HTTPAPIClient { let base_url = "http://localhost:5738".parse().unwrap(); diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index 6ae0243..da9090a 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -42,6 +42,12 @@ pub struct InMemoryTokenStore { token: Option, } +impl Default for InMemoryTokenStore { + fn default() -> Self { + Self::new() + } +} + impl InMemoryTokenStore { pub fn new() -> Self { Self { token: None } diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 58dcb44..1e0375c 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -182,7 +182,7 @@ impl Daemon { // Fetch conversations from server let fetched_conversations = client.get_conversations().await?; let db_conversations: Vec = fetched_conversations.into_iter() - .map(|c| kordophone_db::models::Conversation::from(c)) + .map(kordophone_db::models::Conversation::from) .collect(); // Process each conversation @@ -205,7 +205,7 @@ impl Daemon { let messages = client.get_messages(&conversation_id, None, None, last_message_id).await?; let db_messages: Vec = messages.into_iter() - .map(|m| kordophone_db::models::Message::from(m)) + .map(kordophone_db::models::Message::from) .collect(); // Insert each message @@ -221,8 +221,7 @@ impl Daemon { } async fn get_settings(&mut self) -> Result { - let settings = self.database.with_settings(|s| - Settings::from_db(s) + let settings = self.database.with_settings(Settings::from_db ).await?; Ok(settings) @@ -237,8 +236,7 @@ impl Daemon { } async fn get_client_impl(database: &mut Arc>) -> Result> { - let settings = database.with_settings(|s| - Settings::from_db(s) + let settings = database.with_settings(Settings::from_db ).await?; let server_url = settings.server_url diff --git a/kordophoned/src/daemon/settings.rs b/kordophoned/src/daemon/settings.rs index 8d7a8ed..5a3d202 100644 --- a/kordophoned/src/daemon/settings.rs +++ b/kordophoned/src/daemon/settings.rs @@ -8,6 +8,7 @@ mod keys { } #[derive(Debug)] +#[derive(Default)] pub struct Settings { pub server_url: Option, pub username: Option, @@ -41,12 +42,3 @@ impl Settings { } } -impl Default for Settings { - fn default() -> Self { - Self { - server_url: None, - username: None, - credential_item: None, - } - } -} \ No newline at end of file diff --git a/kordophoned/src/dbus/endpoint.rs b/kordophoned/src/dbus/endpoint.rs index 8e9d4f6..a35f371 100644 --- a/kordophoned/src/dbus/endpoint.rs +++ b/kordophoned/src/dbus/endpoint.rs @@ -1,5 +1,5 @@ use log::info; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use dbus_crossroads::Crossroads; use dbus_tokio::connection; diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 6a1523a..4c7a5b6 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -9,7 +9,6 @@ use crate::daemon::{ DaemonResult, events::{Event, Reply}, settings::Settings, - signals::Signal, }; use crate::dbus::interface::NetBuzzertKordophoneRepository as DbusRepository; @@ -54,9 +53,11 @@ impl DbusRepository for ServerImpl { fn get_conversations(&mut self) -> Result, dbus::MethodErr> { self.send_event_sync(Event::GetAllConversations) - .and_then(|conversations| { + .map(|conversations| { // Convert conversations to DBus property maps - let result = conversations.into_iter().map(|conv| { + + + conversations.into_iter().map(|conv| { let mut map = arg::PropMap::new(); map.insert("guid".into(), arg::Variant(Box::new(conv.guid))); map.insert("display_name".into(), arg::Variant(Box::new(conv.display_name.unwrap_or_default()))); @@ -65,9 +66,7 @@ impl DbusRepository for ServerImpl { map.insert("participants".into(), arg::Variant(Box::new(conv.participants.into_iter().map(|p| p.display_name()).collect::>()))); map.insert("date".into(), arg::Variant(Box::new(conv.date.and_utc().timestamp()))); map - }).collect(); - - Ok(result) + }).collect() }) } @@ -83,17 +82,17 @@ impl DbusRepository for ServerImpl { }; self.send_event_sync(|r| Event::GetMessages(conversation_id, last_message_id_opt, r)) - .and_then(|messages| { - let result = messages.into_iter().map(|msg| { + .map(|messages| { + + + messages.into_iter().map(|msg| { let mut map = arg::PropMap::new(); map.insert("id".into(), arg::Variant(Box::new(msg.id))); map.insert("text".into(), arg::Variant(Box::new(msg.text))); map.insert("date".into(), arg::Variant(Box::new(msg.date.and_utc().timestamp()))); map.insert("sender".into(), arg::Variant(Box::new(msg.sender.display_name()))); map - }).collect(); - - Ok(result) + }).collect() }) } } @@ -121,9 +120,7 @@ impl DbusSettings for ServerImpl { fn server_url(&self) -> Result { self.send_event_sync(Event::GetAllSettings) - .and_then(|settings| { - Ok(settings.server_url.unwrap_or_default()) - }) + .map(|settings| settings.server_url.unwrap_or_default()) } fn set_server_url(&self, value: String) -> Result<(), dbus::MethodErr> { @@ -138,9 +135,7 @@ impl DbusSettings for ServerImpl { fn username(&self) -> Result { self.send_event_sync(Event::GetAllSettings) - .and_then(|settings| { - Ok(settings.username.unwrap_or_default()) - }) + .map(|settings| settings.username.unwrap_or_default()) } fn set_username(&self, value: String) -> Result<(), dbus::MethodErr> { @@ -155,12 +150,7 @@ impl DbusSettings for ServerImpl { fn credential_item(&self) -> Result, dbus::MethodErr> { self.send_event_sync(Event::GetAllSettings) - .and_then(|settings| { - Ok(settings.credential_item.unwrap_or_default()) - }) - .and_then(|item| { - Ok(dbus::Path::new(item).unwrap_or_default()) - }) + .map(|settings| settings.credential_item.unwrap_or_default()).map(|item| dbus::Path::new(item).unwrap_or_default()) } fn set_credential_item(&self, value: dbus::Path<'static>) -> Result<(), dbus::MethodErr> { diff --git a/kpcli/src/client/mod.rs b/kpcli/src/client/mod.rs index 8283c1d..d683593 100644 --- a/kpcli/src/client/mod.rs +++ b/kpcli/src/client/mod.rs @@ -3,7 +3,6 @@ use kordophone::api::http_client::HTTPAPIClient; use kordophone::api::http_client::Credentials; use kordophone::api::InMemoryTokenStore; -use dotenv; use anyhow::Result; use clap::Subcommand; use crate::printers::{ConversationPrinter, MessagePrinter}; @@ -58,7 +57,7 @@ struct ClientCli { impl ClientCli { pub fn new() -> Self { let api = make_api_client_from_env(); - Self { api: api } + Self { api } } pub async fn print_version(&mut self) -> Result<()> { diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index 9cf11b2..e159c3f 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -140,8 +140,6 @@ impl DaemonCli { loop { self.conn.process(std::time::Duration::from_millis(1000))?; } - - Ok(()) } pub async fn config(&mut self, cmd: ConfigCommands) -> Result<()> { diff --git a/kpcli/src/db/mod.rs b/kpcli/src/db/mod.rs index f5198b1..ecd5ce8 100644 --- a/kpcli/src/db/mod.rs +++ b/kpcli/src/db/mod.rs @@ -3,10 +3,7 @@ use clap::Subcommand; use kordophone::APIInterface; use std::{env, path::PathBuf}; -use kordophone_db::{ - database::{Database, DatabaseAccess}, - models::{Conversation, Message}, -}; +use kordophone_db::database::{Database, DatabaseAccess}; use crate::{client, printers::{ConversationPrinter, MessagePrinter}}; #[derive(Subcommand)] @@ -138,7 +135,7 @@ impl DbClient { let mut client = client::make_api_client_from_env(); let fetched_conversations = client.get_conversations().await?; let db_conversations: Vec = fetched_conversations.into_iter() - .map(|c| kordophone_db::models::Conversation::from(c)) + .map(kordophone_db::models::Conversation::from) .collect(); // Process each conversation @@ -153,7 +150,7 @@ impl DbClient { // Fetch and sync messages for this conversation let messages = client.get_messages(&conversation_id, None, None, None).await?; let db_messages: Vec = messages.into_iter() - .map(|m| kordophone_db::models::Message::from(m)) + .map(kordophone_db::models::Message::from) .collect(); // Insert each message diff --git a/kpcli/src/printers.rs b/kpcli/src/printers.rs index 5a7146f..b3af6c2 100644 --- a/kpcli/src/printers.rs +++ b/kpcli/src/printers.rs @@ -148,7 +148,7 @@ impl<'a> ConversationPrinter<'a> { } } -impl<'a> Display for ConversationPrinter<'a> { +impl Display for ConversationPrinter<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.doc.render_fmt(180, f) } @@ -158,7 +158,7 @@ pub struct MessagePrinter<'a> { doc: RcDoc<'a, PrintableMessage> } -impl<'a> Display for MessagePrinter<'a> { +impl Display for MessagePrinter<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.doc.render_fmt(180, f) } From 7200ae54e4acadfbc3c5daa951fdf5d63539299c Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 28 Apr 2025 18:39:52 -0700 Subject: [PATCH 046/138] Adds the ability to sync just one conversation --- .../net.buzzert.kordophonecd.Server.xml | 11 ++- kordophoned/src/daemon/events.rs | 3 + kordophoned/src/daemon/mod.rs | 67 ++++++++++++++----- kordophoned/src/daemon/signals.rs | 6 ++ kordophoned/src/dbus/mod.rs | 1 + kordophoned/src/dbus/server_impl.rs | 4 ++ kordophoned/src/main.rs | 9 +++ kpcli/src/daemon/mod.rs | 17 +++-- 8 files changed, 93 insertions(+), 25 deletions(-) diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index cbe2060..ca2cb2c 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -15,8 +15,8 @@ + + + + + @@ -43,6 +49,7 @@ + diff --git a/kordophoned/src/daemon/events.rs b/kordophoned/src/daemon/events.rs index 598a7a0..b7839cb 100644 --- a/kordophoned/src/daemon/events.rs +++ b/kordophoned/src/daemon/events.rs @@ -12,6 +12,9 @@ pub enum Event { /// Asynchronous event for syncing all conversations with the server. SyncAllConversations(Reply<()>), + /// Asynchronous event for syncing a single conversation with the server. + SyncConversation(String, Reply<()>), + /// Returns all known conversations from the database. GetAllConversations(Reply>), diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 1e0375c..9cf182a 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -130,6 +130,19 @@ impl Daemon { reply.send(()).unwrap(); }, + Event::SyncConversation(conversation_id, reply) => { + let mut db_clone = self.database.clone(); + let signal_sender = self.signal_sender.clone(); + self.runtime.spawn(async move { + let result = Self::sync_conversation_impl(&mut db_clone, &signal_sender, conversation_id).await; + if let Err(e) = result { + log::error!("Error handling sync event: {}", e); + } + }); + + reply.send(()).unwrap(); + }, + Event::GetAllConversations(reply) => { let conversations = self.get_conversations().await; reply.send(conversations).unwrap(); @@ -193,24 +206,8 @@ impl Daemon { // Insert the conversation database.with_repository(|r| r.insert_conversation(conversation)).await?; - // Fetch and sync messages for this conversation - let last_message_id = database.with_repository(|r| -> Option { - r.get_last_message_for_conversation(&conversation_id) - .unwrap_or(None) - .map(|m| m.id) - }).await; - - log::debug!(target: target::SYNC, "Fetching messages for conversation {}", conversation_id); - log::debug!(target: target::SYNC, "Last message id: {:?}", last_message_id); - - let messages = client.get_messages(&conversation_id, None, None, last_message_id).await?; - let db_messages: Vec = messages.into_iter() - .map(kordophone_db::models::Message::from) - .collect(); - - // Insert each message - log::debug!(target: target::SYNC, "Inserting {} messages for conversation {}", db_messages.len(), conversation_id); - database.with_repository(|r| r.insert_messages(&conversation_id, db_messages)).await?; + // Sync individual conversation. + Self::sync_conversation_impl(database, signal_sender, conversation_id).await?; } // Send conversations updated signal. @@ -220,6 +217,40 @@ impl Daemon { Ok(()) } + async fn sync_conversation_impl(database: &mut Arc>, signal_sender: &Sender, conversation_id: String) -> Result<()> { + log::info!(target: target::SYNC, "Starting conversation sync for {}", conversation_id); + + let mut client = Self::get_client_impl(database).await?; + + // Fetch and sync messages for this conversation + let last_message_id = database.with_repository(|r| -> Option { + r.get_last_message_for_conversation(&conversation_id) + .unwrap_or(None) + .map(|m| m.id) + }).await; + + log::debug!(target: target::SYNC, "Fetching messages for conversation {}", &conversation_id); + log::debug!(target: target::SYNC, "Last message id: {:?}", last_message_id); + + let messages = client.get_messages(&conversation_id, None, None, last_message_id).await?; + let db_messages: Vec = messages.into_iter() + .map(kordophone_db::models::Message::from) + .collect(); + + // Insert each message + let num_messages = db_messages.len(); + log::debug!(target: target::SYNC, "Inserting {} messages for conversation {}", num_messages, &conversation_id); + database.with_repository(|r| r.insert_messages(&conversation_id, db_messages)).await?; + + // Send messages updated signal, if we actually inserted any messages. + if num_messages > 0 { + signal_sender.send(Signal::MessagesUpdated(conversation_id.clone())).await?; + } + + log::info!(target: target::SYNC, "Synchronized {} messages for conversation {}", num_messages, &conversation_id); + Ok(()) + } + async fn get_settings(&mut self) -> Result { let settings = self.database.with_settings(Settings::from_db ).await?; diff --git a/kordophoned/src/daemon/signals.rs b/kordophoned/src/daemon/signals.rs index 05bb75f..c4fb715 100644 --- a/kordophoned/src/daemon/signals.rs +++ b/kordophoned/src/daemon/signals.rs @@ -1,4 +1,10 @@ #[derive(Debug, Clone)] pub enum Signal { + /// Emitted when the list of conversations is updated. ConversationsUpdated, + + /// Emitted when the list of messages for a conversation is updated. + /// Parameters: + /// - conversation_id: The ID of the conversation that was updated. + MessagesUpdated(String), } diff --git a/kordophoned/src/dbus/mod.rs b/kordophoned/src/dbus/mod.rs index a901658..143fb83 100644 --- a/kordophoned/src/dbus/mod.rs +++ b/kordophoned/src/dbus/mod.rs @@ -11,5 +11,6 @@ pub mod interface { pub mod signals { pub use crate::interface::NetBuzzertKordophoneRepositoryConversationsUpdated as ConversationsUpdated; + pub use crate::interface::NetBuzzertKordophoneRepositoryMessagesUpdated as MessagesUpdated; } } \ No newline at end of file diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 4c7a5b6..a8d01b1 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -74,6 +74,10 @@ impl DbusRepository for ServerImpl { self.send_event_sync(Event::SyncAllConversations) } + fn sync_conversation(&mut self, conversation_id: String) -> Result<(), dbus::MethodErr> { + self.send_event_sync(|r| Event::SyncConversation(conversation_id, r)) + } + fn get_messages(&mut self, conversation_id: String, last_message_id: String) -> Result, dbus::MethodErr> { let last_message_id_opt = if last_message_id.is_empty() { None diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index fa48fef..56d3399 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -65,6 +65,15 @@ async fn main() { 0 }); } + + Signal::MessagesUpdated(conversation_id) => { + log::info!("Sending signal: MessagesUpdated for conversation {}", conversation_id); + endpoint.send_signal(interface::OBJECT_PATH, DbusSignals::MessagesUpdated{ conversation_id }) + .unwrap_or_else(|_| { + log::error!("Failed to send signal"); + 0 + }); + } } } }); diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index e159c3f..561c60a 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -21,7 +21,9 @@ pub enum Commands { Conversations, /// Runs a sync operation. - Sync, + Sync { + conversation_id: Option, + }, /// Prints the server Kordophone version. Version, @@ -69,7 +71,7 @@ impl Commands { match cmd { Commands::Version => client.print_version().await, Commands::Conversations => client.print_conversations().await, - Commands::Sync => client.sync_conversations().await, + Commands::Sync { conversation_id } => client.sync_conversations(conversation_id).await, Commands::Config { command } => client.config(command).await, Commands::Signals => client.wait_for_signals().await, Commands::Messages { conversation_id, last_message_id } => client.print_messages(conversation_id, last_message_id).await, @@ -109,9 +111,14 @@ impl DaemonCli { Ok(()) } - pub async fn sync_conversations(&mut self) -> Result<()> { - KordophoneRepository::sync_all_conversations(&self.proxy()) - .map_err(|e| anyhow::anyhow!("Failed to sync conversations: {}", e)) + pub async fn sync_conversations(&mut self, conversation_id: Option) -> Result<()> { + if let Some(conversation_id) = conversation_id { + KordophoneRepository::sync_conversation(&self.proxy(), &conversation_id) + .map_err(|e| anyhow::anyhow!("Failed to sync conversation: {}", e)) + } else { + KordophoneRepository::sync_all_conversations(&self.proxy()) + .map_err(|e| anyhow::anyhow!("Failed to sync conversations: {}", e)) + } } pub async fn print_messages(&mut self, conversation_id: String, last_message_id: Option) -> Result<()> { From 59cfc8008b77664f587dfa4ec9ca4049607d2437 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 30 Apr 2025 14:51:49 -0700 Subject: [PATCH 047/138] dbus: remove duplicate property for credential item --- .../include/net.buzzert.kordophonecd.Server.xml | 4 ---- kordophoned/src/dbus/server_impl.rs | 10 ---------- 2 files changed, 14 deletions(-) diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index ca2cb2c..bbe5263 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -73,10 +73,6 @@ - - - - diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index a8d01b1..6d82bed 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -112,16 +112,6 @@ impl DbusSettings for ServerImpl { ) } - fn set_credential_item_(&mut self, item_path: dbus::Path<'static>) -> Result<(), dbus::MethodErr> { - self.send_event_sync(|r| - Event::UpdateSettings(Settings { - server_url: None, - username: None, - credential_item: Some(item_path.to_string()), - }, r) - ) - } - fn server_url(&self) -> Result { self.send_event_sync(Event::GetAllSettings) .map(|settings| settings.server_url.unwrap_or_default()) From fd4c43d585acf43043bbb833a4f4863433aea644 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 1 May 2025 01:02:36 -0700 Subject: [PATCH 048/138] client: actually do authentication properly --- kordophone-db/src/database.rs | 19 -------- kordophone/src/api/http_client.rs | 45 +++++++++++-------- kordophone/src/api/mod.rs | 25 +++++++---- kordophoned/src/daemon/mod.rs | 68 +++++++++++++++++++---------- kordophoned/src/daemon/settings.rs | 10 ++++- kordophoned/src/dbus/server_impl.rs | 4 ++ kpcli/src/client/mod.rs | 8 ++-- 7 files changed, 105 insertions(+), 74 deletions(-) diff --git a/kordophone-db/src/database.rs b/kordophone-db/src/database.rs index c8d86ac..d7eb7d3 100644 --- a/kordophone-db/src/database.rs +++ b/kordophone-db/src/database.rs @@ -8,9 +8,6 @@ pub use tokio::sync::Mutex; use crate::repository::Repository; use crate::settings::Settings; -pub use kordophone::api::TokenStore; -use kordophone::model::JwtToken; - use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); @@ -86,19 +83,3 @@ impl DatabaseAccess for Arc> { database.with_settings(f).await } } - -static TOKEN_KEY: &str = "token"; - -#[async_trait] -impl TokenStore for Database { - async fn get_token(&mut self) -> Option { - self.with_settings(|settings| { - let token: Result> = settings.get(TOKEN_KEY); - token.unwrap_or_default() - }).await - } - - async fn set_token(&mut self, token: JwtToken) { - self.with_settings(|settings| settings.put(TOKEN_KEY, &token).unwrap()).await; - } -} diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index 2cada8b..86c61de 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -3,7 +3,7 @@ extern crate serde; use std::{path::PathBuf, str}; -use crate::api::TokenStore; +use crate::api::AuthenticationStore; use hyper::{Body, Client, Method, Request, Uri}; use async_trait::async_trait; @@ -16,10 +16,9 @@ use crate::{ type HttpClient = Client; -pub struct HTTPAPIClient { +pub struct HTTPAPIClient { pub base_url: Uri, - pub token_store: K, - credentials: Option, + pub auth_store: K, client: HttpClient, } @@ -92,7 +91,7 @@ impl AuthSetting for hyper::http::Request { } #[async_trait] -impl APIInterface for HTTPAPIClient { +impl APIInterface for HTTPAPIClient { type Error = Error; async fn get_version(&mut self) -> Result { @@ -111,10 +110,15 @@ impl APIInterface for HTTPAPIClient { jwt: String, } + log::debug!("Authenticating with username: {:?}", credentials.username); + let body = || -> Body { serde_json::to_string(&credentials).unwrap().into() }; let token: AuthResponse = self.request_with_body_retry("authenticate", Method::POST, body, false).await?; let token = JwtToken::new(&token.jwt).map_err(|_| Error::DecodeError)?; - self.token_store.set_token(token.clone()).await; + + log::debug!("Saving token: {:?}", token); + self.auth_store.set_token(token.clone()).await; + Ok(token) } @@ -144,12 +148,11 @@ impl APIInterface for HTTPAPIClient { } } -impl HTTPAPIClient { - pub fn new(base_url: Uri, credentials: Option, token_store: K) -> HTTPAPIClient { +impl HTTPAPIClient { + pub fn new(base_url: Uri, auth_store: K) -> HTTPAPIClient { HTTPAPIClient { base_url, - credentials, - token_store, + auth_store, client: Client::new(), } } @@ -198,7 +201,7 @@ impl HTTPAPIClient { .expect("Unable to build request") }; - let token = self.token_store.get_token().await; + let token = self.auth_store.get_token().await; let request = build_request(&token); let mut response = self.client.request(request).await?; @@ -213,11 +216,14 @@ impl HTTPAPIClient { return Err(Error::ClientError("Unauthorized".into())); } - if let Some(credentials) = &self.credentials { - self.authenticate(credentials.clone()).await?; + if let Some(credentials) = &self.auth_store.get_credentials().await { + log::debug!("Renewing token using credentials: u: {:?}", credentials.username); + let new_token = self.authenticate(credentials.clone()).await?; - let request = build_request(&token); + let request = build_request(&Some(new_token)); response = self.client.request(request).await?; + } else { + return Err(Error::ClientError("Unauthorized, no credentials provided".into())); } }, @@ -233,6 +239,9 @@ impl HTTPAPIClient { let parsed: T = match serde_json::from_slice(&body) { Ok(result) => Ok(result), Err(json_err) => { + log::error!("Error deserializing JSON: {:?}", json_err); + log::error!("Body: {:?}", String::from_utf8_lossy(&body)); + // If JSON deserialization fails, try to interpret it as plain text // Unfortunately the server does return things like this... let s = str::from_utf8(&body).map_err(|_| Error::DecodeError)?; @@ -247,17 +256,17 @@ impl HTTPAPIClient { #[cfg(test)] mod test { use super::*; - use crate::api::InMemoryTokenStore; + use crate::api::InMemoryAuthenticationStore; #[cfg(test)] - fn local_mock_client() -> HTTPAPIClient { + fn local_mock_client() -> HTTPAPIClient { let base_url = "http://localhost:5738".parse().unwrap(); - let credentials = Credentials { + let credentials = Credentials { username: "test".to_string(), password: "test".to_string(), }; - HTTPAPIClient::new(base_url, credentials.into(), InMemoryTokenStore::new()) + HTTPAPIClient::new(base_url, InMemoryAuthenticationStore::new(Some(credentials))) } #[cfg(test)] diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index da9090a..159a657 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -33,29 +33,38 @@ pub trait APIInterface { } #[async_trait] -pub trait TokenStore { +pub trait AuthenticationStore { + async fn get_credentials(&mut self) -> Option; async fn get_token(&mut self) -> Option; async fn set_token(&mut self, token: JwtToken); } -pub struct InMemoryTokenStore { +pub struct InMemoryAuthenticationStore { + credentials: Option, token: Option, } -impl Default for InMemoryTokenStore { +impl Default for InMemoryAuthenticationStore { fn default() -> Self { - Self::new() + Self::new(None) } } -impl InMemoryTokenStore { - pub fn new() -> Self { - Self { token: None } +impl InMemoryAuthenticationStore { + pub fn new(credentials: Option) -> Self { + Self { + credentials, + token: None, + } } } #[async_trait] -impl TokenStore for InMemoryTokenStore { +impl AuthenticationStore for InMemoryAuthenticationStore { + async fn get_credentials(&mut self) -> Option { + self.credentials.clone() + } + async fn get_token(&mut self) -> Option { self.token.clone() } diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 9cf182a..4528f40 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -1,5 +1,6 @@ pub mod settings; use settings::Settings; +use settings::keys as SettingsKey; pub mod events; use events::*; @@ -26,7 +27,7 @@ use kordophone::model::JwtToken; use kordophone::api::{ http_client::{Credentials, HTTPAPIClient}, APIInterface, - TokenStore, + AuthenticationStore, }; #[derive(Debug, Error)] @@ -37,18 +38,52 @@ pub enum DaemonError { pub type DaemonResult = Result>; -struct DatabaseTokenStore { +struct DatabaseAuthenticationStore { database: Arc>, } #[async_trait] -impl TokenStore for DatabaseTokenStore { +impl AuthenticationStore for DatabaseAuthenticationStore { + async fn get_credentials(&mut self) -> Option { + self.database.lock().await.with_settings(|settings| { + let username: Option = settings.get::(SettingsKey::USERNAME) + .unwrap_or_else(|e| { + log::warn!("error getting username from database: {}", e); + None + }); + + // TODO: This would be the point where we map from credential item to password. + let password: String = settings.get::(SettingsKey::CREDENTIAL_ITEM) + .unwrap_or_else(|e| { + log::warn!("error getting password from database: {}", e); + None + }) + .unwrap_or_else(|| { + log::warn!("warning: no password in database, [DEBUG] using default password"); + "test".to_string() + }); + + if username.is_none() { + log::warn!("Username not present in database"); + } + + match (username, password) { + (Some(username), password) => Some(Credentials { username, password }), + _ => None, + } + }).await + } + async fn get_token(&mut self) -> Option { - self.database.lock().await.get_token().await + self.database.lock().await + .with_settings(|settings| settings.get::(SettingsKey::TOKEN).unwrap_or_default()).await } async fn set_token(&mut self, token: JwtToken) { - self.database.lock().await.set_token(token).await; + self.database.lock().await + .with_settings(|settings| settings.put(SettingsKey::TOKEN, &token)).await.unwrap_or_else(|e| { + log::error!("Failed to set token: {}", e); + }); } } @@ -252,9 +287,7 @@ impl Daemon { } async fn get_settings(&mut self) -> Result { - let settings = self.database.with_settings(Settings::from_db - ).await?; - + let settings = self.database.with_settings(Settings::from_db).await?; Ok(settings) } @@ -262,30 +295,19 @@ impl Daemon { self.database.with_settings(|s| settings.save(s)).await } - async fn get_client(&mut self) -> Result> { + async fn get_client(&mut self) -> Result> { Self::get_client_impl(&mut self.database).await } - async fn get_client_impl(database: &mut Arc>) -> Result> { - let settings = database.with_settings(Settings::from_db - ).await?; + async fn get_client_impl(database: &mut Arc>) -> Result> { + let settings = database.with_settings(Settings::from_db).await?; let server_url = settings.server_url .ok_or(DaemonError::ClientNotConfigured)?; let client = HTTPAPIClient::new( server_url.parse().unwrap(), - - match (settings.username, settings.credential_item) { - (Some(username), Some(password)) => Some( - Credentials { - username, - password, - } - ), - _ => None, - }, - DatabaseTokenStore { database: database.clone() } + DatabaseAuthenticationStore { database: database.clone() } ); Ok(client) diff --git a/kordophoned/src/daemon/settings.rs b/kordophoned/src/daemon/settings.rs index 5a3d202..18e9482 100644 --- a/kordophoned/src/daemon/settings.rs +++ b/kordophoned/src/daemon/settings.rs @@ -1,10 +1,11 @@ use kordophone_db::settings::Settings as DbSettings; use anyhow::Result; -mod keys { +pub mod keys { pub static SERVER_URL: &str = "ServerURL"; pub static USERNAME: &str = "Username"; pub static CREDENTIAL_ITEM: &str = "CredentialItem"; + pub static TOKEN: &str = "Token"; } #[derive(Debug)] @@ -13,6 +14,7 @@ pub struct Settings { pub server_url: Option, pub username: Option, pub credential_item: Option, + pub token: Option, } impl Settings { @@ -20,11 +22,12 @@ impl Settings { let server_url: Option = db_settings.get(keys::SERVER_URL)?; let username: Option = db_settings.get(keys::USERNAME)?; let credential_item: Option = db_settings.get(keys::CREDENTIAL_ITEM)?; - + let token: Option = db_settings.get(keys::TOKEN)?; Ok(Self { server_url, username, credential_item, + token, }) } @@ -38,6 +41,9 @@ impl Settings { if let Some(credential_item) = &self.credential_item { db_settings.put(keys::CREDENTIAL_ITEM, &credential_item)?; } + if let Some(token) = &self.token { + db_settings.put(keys::TOKEN, &token)?; + } Ok(()) } } diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 6d82bed..25a1f3c 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -108,6 +108,7 @@ impl DbusSettings for ServerImpl { server_url: Some(url), username: Some(user), credential_item: None, + token: None, }, r) ) } @@ -123,6 +124,7 @@ impl DbusSettings for ServerImpl { server_url: Some(value), username: None, credential_item: None, + token: None, }, r) ) } @@ -138,6 +140,7 @@ impl DbusSettings for ServerImpl { server_url: None, username: Some(value), credential_item: None, + token: None, }, r) ) } @@ -153,6 +156,7 @@ impl DbusSettings for ServerImpl { server_url: None, username: None, credential_item: Some(value.to_string()), + token: None, }, r) ) } diff --git a/kpcli/src/client/mod.rs b/kpcli/src/client/mod.rs index d683593..1057601 100644 --- a/kpcli/src/client/mod.rs +++ b/kpcli/src/client/mod.rs @@ -1,13 +1,13 @@ use kordophone::APIInterface; use kordophone::api::http_client::HTTPAPIClient; use kordophone::api::http_client::Credentials; -use kordophone::api::InMemoryTokenStore; +use kordophone::api::InMemoryAuthenticationStore; use anyhow::Result; use clap::Subcommand; use crate::printers::{ConversationPrinter, MessagePrinter}; -pub fn make_api_client_from_env() -> HTTPAPIClient { +pub fn make_api_client_from_env() -> HTTPAPIClient { dotenv::dotenv().ok(); // read from env @@ -22,7 +22,7 @@ pub fn make_api_client_from_env() -> HTTPAPIClient { .expect("KORDOPHONE_PASSWORD must be set"), }; - HTTPAPIClient::new(base_url.parse().unwrap(), credentials.into(), InMemoryTokenStore::new()) + HTTPAPIClient::new(base_url.parse().unwrap(), InMemoryAuthenticationStore::new(Some(credentials))) } #[derive(Subcommand)] @@ -51,7 +51,7 @@ impl Commands { } struct ClientCli { - api: HTTPAPIClient, + api: HTTPAPIClient, } impl ClientCli { From 13a78ccd474546df13a5468f7859d6bb663dd1ff Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 1 May 2025 01:08:13 -0700 Subject: [PATCH 049/138] adds the ability to clear db --- kordophone-db/src/repository.rs | 12 +++++++++++ .../net.buzzert.kordophonecd.Server.xml | 5 +++++ kordophoned/src/daemon/events.rs | 3 +++ kordophoned/src/daemon/mod.rs | 21 +++++++++++++++++++ kordophoned/src/dbus/server_impl.rs | 4 ++++ kpcli/src/daemon/mod.rs | 8 +++++++ 6 files changed, 53 insertions(+) diff --git a/kordophone-db/src/repository.rs b/kordophone-db/src/repository.rs index 468ed68..8189d62 100644 --- a/kordophone-db/src/repository.rs +++ b/kordophone-db/src/repository.rs @@ -222,6 +222,18 @@ impl<'a> Repository<'a> { Ok(message_record.map(|r| r.into())) } + pub fn delete_all_conversations(&mut self) -> Result<()> { + use crate::schema::conversations::dsl::*; + diesel::delete(conversations).execute(self.connection)?; + Ok(()) + } + + pub fn delete_all_messages(&mut self) -> Result<()> { + use crate::schema::messages::dsl::*; + diesel::delete(messages).execute(self.connection)?; + Ok(()) + } + // Helper function to get the last inserted row ID // This is a workaround since the Sqlite backend doesn't support `RETURNING` // Huge caveat with this is that it depends on whatever the last insert was, prevents concurrent inserts. diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index bbe5263..37ad35f 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -40,6 +40,11 @@ value="Emitted when the list of conversations is updated."/> + + + + diff --git a/kordophoned/src/daemon/events.rs b/kordophoned/src/daemon/events.rs index b7839cb..97427e9 100644 --- a/kordophoned/src/daemon/events.rs +++ b/kordophoned/src/daemon/events.rs @@ -29,6 +29,9 @@ pub enum Event { /// - conversation_id: The ID of the conversation to get messages for. /// - last_message_id: (optional) The ID of the last message to get. If None, all messages are returned. GetMessages(String, Option, Reply>), + + /// Delete all conversations from the database. + DeleteAllConversations(Reply<()>), } diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 4528f40..a7f9997 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -206,6 +206,15 @@ impl Daemon { let messages = self.get_messages(conversation_id, last_message_id).await; reply.send(messages).unwrap(); }, + + Event::DeleteAllConversations(reply) => { + self.delete_all_conversations().await + .unwrap_or_else(|e| { + log::error!("Failed to delete all conversations: {}", e); + }); + + reply.send(()).unwrap(); + }, } } @@ -313,6 +322,18 @@ impl Daemon { Ok(client) } + async fn delete_all_conversations(&mut self) -> Result<()> { + self.database.with_repository(|r| -> Result<()> { + r.delete_all_conversations()?; + r.delete_all_messages()?; + Ok(()) + }).await?; + + self.signal_sender.send(Signal::ConversationsUpdated).await?; + + Ok(()) + } + fn get_database_path() -> PathBuf { if let Some(proj_dirs) = ProjectDirs::from("net", "buzzert", "kordophonecd") { let data_dir = proj_dirs.data_dir(); diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 25a1f3c..04632e2 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -99,6 +99,10 @@ impl DbusRepository for ServerImpl { }).collect() }) } + + fn delete_all_conversations(&mut self) -> Result<(), dbus::MethodErr> { + self.send_event_sync(Event::DeleteAllConversations) + } } impl DbusSettings for ServerImpl { diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index 561c60a..2212f8b 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -42,6 +42,9 @@ pub enum Commands { conversation_id: String, last_message_id: Option, }, + + /// Deletes all conversations. + DeleteAllConversations, } #[derive(Subcommand)] @@ -75,6 +78,7 @@ impl Commands { Commands::Config { command } => client.config(command).await, Commands::Signals => client.wait_for_signals().await, Commands::Messages { conversation_id, last_message_id } => client.print_messages(conversation_id, last_message_id).await, + Commands::DeleteAllConversations => client.delete_all_conversations().await, } } } @@ -188,4 +192,8 @@ impl DaemonCli { .map_err(|e| anyhow::anyhow!("Failed to set credential item: {}", e)) } + pub async fn delete_all_conversations(&mut self) -> Result<()> { + KordophoneRepository::delete_all_conversations(&self.proxy()) + .map_err(|e| anyhow::anyhow!("Failed to delete all conversations: {}", e)) + } } \ No newline at end of file From f6ac3b5a5894b84485daf7380189394dad9c2d6b Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 1 May 2025 18:07:18 -0700 Subject: [PATCH 050/138] client: implements event/updates websocket --- Cargo.lock | 224 ++++++++++++++++++++++++++-- kordophone/Cargo.toml | 3 + kordophone/src/api/auth.rs | 45 ++++++ kordophone/src/api/event_socket.rs | 17 +++ kordophone/src/api/http_client.rs | 140 ++++++++++++++++- kordophone/src/api/mod.rs | 52 ++----- kordophone/src/model/event.rs | 17 +++ kordophone/src/model/mod.rs | 6 + kordophone/src/model/update.rs | 21 +++ kordophone/src/tests/test_client.rs | 40 ++++- kordophoned/src/dbus/server_impl.rs | 5 - kpcli/Cargo.toml | 2 + kpcli/src/client/mod.rs | 41 +++++ kpcli/src/main.rs | 15 ++ 14 files changed, 561 insertions(+), 67 deletions(-) create mode 100644 kordophone/src/api/auth.rs create mode 100644 kordophone/src/api/event_socket.rs create mode 100644 kordophone/src/model/event.rs create mode 100644 kordophone/src/model/update.rs diff --git a/Cargo.lock b/Cargo.lock index f29747f..bdbc538 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,6 +181,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -189,9 +198,9 @@ checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "bytes" -version = "1.6.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" @@ -297,6 +306,25 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "csv" version = "1.3.1" @@ -363,6 +391,12 @@ dependencies = [ "syn", ] +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + [[package]] name = "dbus" version = "0.9.7" @@ -471,6 +505,16 @@ dependencies = [ "syn", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "directories" version = "6.0.0" @@ -657,12 +701,23 @@ checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", "futures-macro", + "futures-sink", "futures-task", "pin-project-lite", "pin-utils", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.14" @@ -671,7 +726,19 @@ checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", ] [[package]] @@ -691,7 +758,7 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http", + "http 0.2.12", "indexmap", "slab", "tokio", @@ -743,6 +810,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http-body" version = "0.4.6" @@ -750,7 +828,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", - "http", + "http 0.2.12", "pin-project-lite", ] @@ -777,7 +855,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", + "http 0.2.12", "http-body", "httparse", "httpdate", @@ -907,6 +985,7 @@ dependencies = [ "chrono", "ctor", "env_logger", + "futures-util", "hyper", "hyper-tls", "log", @@ -915,6 +994,8 @@ dependencies = [ "serde_plain", "time", "tokio", + "tokio-tungstenite", + "tungstenite", "uuid", ] @@ -967,6 +1048,8 @@ dependencies = [ "dbus-codegen", "dbus-tree", "dotenv", + "env_logger", + "futures-util", "kordophone", "kordophone-db", "log", @@ -1083,7 +1166,7 @@ checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ "hermit-abi 0.3.9", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.52.0", ] @@ -1297,6 +1380,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + [[package]] name = "rand" version = "0.8.5" @@ -1304,8 +1393,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -1315,7 +1414,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -1324,7 +1433,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.14", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.2", ] [[package]] @@ -1342,7 +1460,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom", + "getrandom 0.2.14", "libredox", "thiserror 1.0.69", ] @@ -1353,7 +1471,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ - "getrandom", + "getrandom 0.2.14", "libredox", "thiserror 2.0.12", ] @@ -1505,6 +1623,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -1713,6 +1842,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.10" @@ -1792,12 +1933,35 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "rand 0.9.1", + "sha1", + "thiserror 2.0.12", + "utf-8", +] + [[package]] name = "typed-arena" version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + [[package]] name = "unicode-ident" version = "1.0.12" @@ -1810,6 +1974,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1822,8 +1992,8 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" dependencies = [ - "getrandom", - "rand", + "getrandom 0.2.14", + "rand 0.8.5", "uuid-macro-internal", ] @@ -1850,6 +2020,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -1865,6 +2041,15 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + [[package]] name = "wasm-bindgen" version = "0.2.95" @@ -2108,6 +2293,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.5.0", +] + [[package]] name = "xml-rs" version = "0.8.25" diff --git a/kordophone/Cargo.toml b/kordophone/Cargo.toml index 4d0d2aa..9a64c68 100644 --- a/kordophone/Cargo.toml +++ b/kordophone/Cargo.toml @@ -11,6 +11,7 @@ base64 = "0.22.1" chrono = { version = "0.4.38", features = ["serde"] } ctor = "0.2.8" env_logger = "0.11.5" +futures-util = "0.3.31" hyper = { version = "0.14", features = ["full"] } hyper-tls = "0.5.0" log = { version = "0.4.21", features = [] } @@ -19,4 +20,6 @@ serde_json = "1.0.91" serde_plain = "1.0.2" time = { version = "0.3.17", features = ["parsing", "serde"] } tokio = { version = "1.37.0", features = ["full"] } +tokio-tungstenite = "0.26.2" +tungstenite = "0.26.2" uuid = { version = "1.6.1", features = ["v4", "fast-rng", "macro-diagnostics"] } diff --git a/kordophone/src/api/auth.rs b/kordophone/src/api/auth.rs new file mode 100644 index 0000000..d192c25 --- /dev/null +++ b/kordophone/src/api/auth.rs @@ -0,0 +1,45 @@ +use crate::api::Credentials; +use crate::api::JwtToken; +use async_trait::async_trait; + +#[async_trait] +pub trait AuthenticationStore { + async fn get_credentials(&mut self) -> Option; + async fn get_token(&mut self) -> Option; + async fn set_token(&mut self, token: JwtToken); +} + +pub struct InMemoryAuthenticationStore { + credentials: Option, + token: Option, +} + +impl Default for InMemoryAuthenticationStore { + fn default() -> Self { + Self::new(None) + } +} + +impl InMemoryAuthenticationStore { + pub fn new(credentials: Option) -> Self { + Self { + credentials, + token: None, + } + } +} + +#[async_trait] +impl AuthenticationStore for InMemoryAuthenticationStore { + async fn get_credentials(&mut self) -> Option { + self.credentials.clone() + } + + async fn get_token(&mut self) -> Option { + self.token.clone() + } + + async fn set_token(&mut self, token: JwtToken) { + self.token = Some(token); + } +} diff --git a/kordophone/src/api/event_socket.rs b/kordophone/src/api/event_socket.rs new file mode 100644 index 0000000..8896c3b --- /dev/null +++ b/kordophone/src/api/event_socket.rs @@ -0,0 +1,17 @@ +use async_trait::async_trait; +use crate::model::update::UpdateItem; +use crate::model::event::Event; +use futures_util::stream::Stream; + +#[async_trait] +pub trait EventSocket { + type Error; + type EventStream: Stream>; + type UpdateStream: Stream, Self::Error>>; + + /// Modern event pipeline + async fn events(self) -> Self::EventStream; + + /// Raw update items from the v1 API. + async fn raw_updates(self) -> Self::UpdateStream; +} \ No newline at end of file diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index 86c61de..e72af03 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -4,13 +4,25 @@ extern crate serde; use std::{path::PathBuf, str}; use crate::api::AuthenticationStore; +use crate::api::event_socket::EventSocket; use hyper::{Body, Client, Method, Request, Uri}; use async_trait::async_trait; use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use tokio::net::TcpStream; + +use futures_util::{StreamExt, TryStreamExt}; +use futures_util::stream::{SplitStream, SplitSink, TryFilterMap, MapErr, Stream}; +use futures_util::stream::Map; +use futures_util::stream::BoxStream; +use std::future::Future; + +use tokio_tungstenite::connect_async; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; + use crate::{ - model::{Conversation, ConversationID, JwtToken, Message, MessageID}, + model::{Conversation, ConversationID, JwtToken, Message, MessageID, UpdateItem, Event}, APIInterface }; @@ -63,6 +75,12 @@ impl From for Error { } } +impl From for Error { + fn from(err: tungstenite::Error) -> Error { + Error::ClientError(err.to_string()) + } +} + trait AuthBuilder { fn with_auth(self, token: &Option) -> Self; } @@ -90,6 +108,58 @@ impl AuthSetting for hyper::http::Request { } } +type WebsocketSink = SplitSink>, tungstenite::Message>; +type WebsocketStream = SplitStream>>; + +pub struct WebsocketEventSocket { + _sink: WebsocketSink, + stream: WebsocketStream, +} + +impl WebsocketEventSocket { + pub fn new(socket: WebSocketStream>) -> Self { + let (sink, stream) = socket.split(); + Self { _sink: sink, stream } + } +} + +impl WebsocketEventSocket { + fn raw_update_stream(self) -> impl Stream, Error>> { + self.stream + .map_err(Error::from) + .try_filter_map(|msg| async move { + match msg { + tungstenite::Message::Text(text) => { + serde_json::from_str::>(&text) + .map(Some) + .map_err(Error::from) + } + _ => Ok(None) + } + }) + } +} + +#[async_trait] +impl EventSocket for WebsocketEventSocket { + type Error = Error; + type EventStream = BoxStream<'static, Result>; + type UpdateStream = BoxStream<'static, Result, Error>>; + + async fn events(self) -> Self::EventStream { + use futures_util::stream::iter; + + self.raw_update_stream() + .map_ok(|updates| iter(updates.into_iter().map(|update| Ok(Event::from(update))))) + .try_flatten() + .boxed() + } + + async fn raw_updates(self) -> Self::UpdateStream { + self.raw_update_stream().boxed() + } +} + #[async_trait] impl APIInterface for HTTPAPIClient { type Error = Error; @@ -146,6 +216,44 @@ impl APIInterface for HTTPAPIClient { let messages: Vec = self.request(&endpoint, Method::GET).await?; Ok(messages) } + + async fn open_event_socket(&mut self) -> Result { + use tungstenite::http::StatusCode; + use tungstenite::handshake::client::Request as TungsteniteRequest; + use tungstenite::handshake::client::generate_key; + + let uri = self.uri_for_endpoint("updates", Some(self.websocket_scheme())); + + log::debug!("Connecting to websocket: {:?}", uri); + + let auth = self.auth_store.get_token().await; + let host = uri.authority().unwrap().host(); + let mut request = TungsteniteRequest::builder() + .header("Host", host) + .header("Connection", "Upgrade") + .header("Upgrade", "websocket") + .header("Sec-WebSocket-Version", "13") + .header("Sec-WebSocket-Key", generate_key()) + .uri(uri.to_string()) + .body(()) + .expect("Unable to build websocket request"); + + log::debug!("Websocket request: {:?}", request); + + if let Some(token) = &auth { + let header_value = token.to_header_value().to_str().unwrap().parse().unwrap(); // ugh + request.headers_mut().insert("Authorization", header_value); + } + + let (socket, response) = connect_async(request).await.unwrap(); + log::debug!("Websocket connected: {:?}", response.status()); + + if response.status() != StatusCode::SWITCHING_PROTOCOLS { + return Err(Error::ClientError("Websocket connection failed".into())); + } + + Ok(WebsocketEventSocket::new(socket)) + } } impl HTTPAPIClient { @@ -157,15 +265,27 @@ impl HTTPAPIClient { } } - fn uri_for_endpoint(&self, endpoint: &str) -> Uri { + fn uri_for_endpoint(&self, endpoint: &str, scheme: Option<&str>) -> Uri { let mut parts = self.base_url.clone().into_parts(); let root_path: PathBuf = parts.path_and_query.unwrap().path().into(); let path = root_path.join(endpoint); parts.path_and_query = Some(path.to_str().unwrap().parse().unwrap()); + if let Some(scheme) = scheme { + parts.scheme = Some(scheme.parse().unwrap()); + } + Uri::try_from(parts).unwrap() } + fn websocket_scheme(&self) -> &str { + if self.base_url.scheme().unwrap() == "https" { + "wss" + } else { + "ws" + } + } + async fn request(&mut self, endpoint: &str, method: Method) -> Result { self.request_with_body(endpoint, method, || { Body::empty() }).await } @@ -188,7 +308,7 @@ impl HTTPAPIClient { { use hyper::StatusCode; - let uri = self.uri_for_endpoint(endpoint); + let uri = self.uri_for_endpoint(endpoint, None); log::debug!("Requesting {:?} {:?}", method, uri); let build_request = move |auth: &Option| { @@ -320,4 +440,18 @@ mod test { let messages = client.get_messages(&conversation.guid, None, None, None).await.unwrap(); assert!(!messages.is_empty()); } + + #[tokio::test] + async fn test_updates() { + if !mock_client_is_reachable().await { + log::warn!("Skipping http_client tests (mock server not reachable)"); + return; + } + + let mut client = local_mock_client(); + + // We just want to see if the connection is established, we won't wait for any events + let _ = client.open_event_socket().await.unwrap(); + assert!(true); + } } diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index 159a657..5f35bfd 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -2,11 +2,18 @@ use async_trait::async_trait; pub use crate::model::{ Conversation, Message, ConversationID, MessageID, }; + +pub mod auth; +pub use crate::api::auth::{AuthenticationStore, InMemoryAuthenticationStore}; + use crate::model::JwtToken; pub mod http_client; pub use http_client::HTTPAPIClient; +pub mod event_socket; +pub use event_socket::EventSocket; + use self::http_client::Credentials; #[async_trait] @@ -30,46 +37,7 @@ pub trait APIInterface { // (POST) /authenticate async fn authenticate(&mut self, credentials: Credentials) -> Result; -} - -#[async_trait] -pub trait AuthenticationStore { - async fn get_credentials(&mut self) -> Option; - async fn get_token(&mut self) -> Option; - async fn set_token(&mut self, token: JwtToken); -} - -pub struct InMemoryAuthenticationStore { - credentials: Option, - token: Option, -} - -impl Default for InMemoryAuthenticationStore { - fn default() -> Self { - Self::new(None) - } -} - -impl InMemoryAuthenticationStore { - pub fn new(credentials: Option) -> Self { - Self { - credentials, - token: None, - } - } -} - -#[async_trait] -impl AuthenticationStore for InMemoryAuthenticationStore { - async fn get_credentials(&mut self) -> Option { - self.credentials.clone() - } - - async fn get_token(&mut self) -> Option { - self.token.clone() - } - - async fn set_token(&mut self, token: JwtToken) { - self.token = Some(token); - } + + // (WS) /updates + async fn open_event_socket(&mut self) -> Result; } diff --git a/kordophone/src/model/event.rs b/kordophone/src/model/event.rs new file mode 100644 index 0000000..aca7cb1 --- /dev/null +++ b/kordophone/src/model/event.rs @@ -0,0 +1,17 @@ +use crate::model::{Conversation, Message, UpdateItem}; + +#[derive(Debug, Clone)] +pub enum Event { + ConversationChanged(Conversation), + MessageReceived(Conversation, Message), +} + +impl From for Event { + fn from(update: UpdateItem) -> Self { + match update { + UpdateItem { conversation: Some(conversation), message: None, .. } => Event::ConversationChanged(conversation), + UpdateItem { conversation: Some(conversation), message: Some(message), .. } => Event::MessageReceived(conversation, message), + _ => panic!("Invalid update item: {:?}", update), + } + } +} \ No newline at end of file diff --git a/kordophone/src/model/mod.rs b/kordophone/src/model/mod.rs index f07d6f6..b54ce2e 100644 --- a/kordophone/src/model/mod.rs +++ b/kordophone/src/model/mod.rs @@ -1,5 +1,7 @@ pub mod conversation; +pub mod event; pub mod message; +pub mod update; pub use conversation::Conversation; pub use conversation::ConversationID; @@ -7,6 +9,10 @@ pub use conversation::ConversationID; pub use message::Message; pub use message::MessageID; +pub use update::UpdateItem; + +pub use event::Event; + pub mod jwt; pub use jwt::JwtToken; diff --git a/kordophone/src/model/update.rs b/kordophone/src/model/update.rs new file mode 100644 index 0000000..4d89896 --- /dev/null +++ b/kordophone/src/model/update.rs @@ -0,0 +1,21 @@ +use serde::Deserialize; +use super::conversation::Conversation; +use super::message::Message; + +#[derive(Debug, Clone, Deserialize)] +pub struct UpdateItem { + #[serde(rename = "messageSequenceNumber")] + pub seq: u64, + + #[serde(rename = "conversation")] + pub conversation: Option, + + #[serde(rename = "message")] + pub message: Option, +} + +impl Default for UpdateItem { + fn default() -> Self { + Self { seq: 0, conversation: None, message: None } + } +} \ No newline at end of file diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index 1ddfd35..3e06f3a 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -4,8 +4,12 @@ use std::collections::HashMap; pub use crate::APIInterface; use crate::{ api::http_client::Credentials, - model::{Conversation, ConversationID, JwtToken, Message, MessageID} -}; + model::{Conversation, ConversationID, JwtToken, Message, MessageID, UpdateItem, Event}, + api::event_socket::EventSocket, +}; + +use futures_util::StreamExt; +use futures_util::stream::BoxStream; pub struct TestClient { pub version: &'static str, @@ -28,6 +32,32 @@ impl TestClient { } } +pub struct TestEventSocket { + pub events: Vec, +} + +impl TestEventSocket { + pub fn new() -> Self { + Self { events: vec![] } + } +} + +#[async_trait] +impl EventSocket for TestEventSocket { + type Error = TestError; + type EventStream = BoxStream<'static, Result>; + type UpdateStream = BoxStream<'static, Result, TestError>>; + + async fn events(self) -> Self::EventStream { + futures_util::stream::iter(self.events.into_iter().map(Ok)).boxed() + } + + async fn raw_updates(self) -> Self::UpdateStream { + let results: Vec, TestError>> = vec![]; + futures_util::stream::iter(results.into_iter()).boxed() + } +} + #[async_trait] impl APIInterface for TestClient { type Error = TestError; @@ -57,4 +87,10 @@ impl APIInterface for TestClient { Err(TestError::ConversationNotFound) } + + async fn open_event_socket(&mut self) -> Result { + Ok(TestEventSocket::new()) + } } + + diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 04632e2..9bc0c75 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -54,9 +54,6 @@ impl DbusRepository for ServerImpl { fn get_conversations(&mut self) -> Result, dbus::MethodErr> { self.send_event_sync(Event::GetAllConversations) .map(|conversations| { - // Convert conversations to DBus property maps - - conversations.into_iter().map(|conv| { let mut map = arg::PropMap::new(); map.insert("guid".into(), arg::Variant(Box::new(conv.guid))); @@ -87,8 +84,6 @@ impl DbusRepository for ServerImpl { self.send_event_sync(|r| Event::GetMessages(conversation_id, last_message_id_opt, r)) .map(|messages| { - - messages.into_iter().map(|msg| { let mut map = arg::PropMap::new(); map.insert("id".into(), arg::Variant(Box::new(msg.id))); diff --git a/kpcli/Cargo.toml b/kpcli/Cargo.toml index 420baf6..cda9f57 100644 --- a/kpcli/Cargo.toml +++ b/kpcli/Cargo.toml @@ -11,6 +11,8 @@ clap = { version = "4.5.20", features = ["derive"] } dbus = "0.9.7" dbus-tree = "0.9.2" dotenv = "0.15.0" +env_logger = "0.11.8" +futures-util = "0.3.31" kordophone = { path = "../kordophone" } kordophone-db = { path = "../kordophone-db" } log = "0.4.22" diff --git a/kpcli/src/client/mod.rs b/kpcli/src/client/mod.rs index 1057601..87a0e08 100644 --- a/kpcli/src/client/mod.rs +++ b/kpcli/src/client/mod.rs @@ -2,10 +2,14 @@ use kordophone::APIInterface; use kordophone::api::http_client::HTTPAPIClient; use kordophone::api::http_client::Credentials; use kordophone::api::InMemoryAuthenticationStore; +use kordophone::api::event_socket::EventSocket; use anyhow::Result; use clap::Subcommand; use crate::printers::{ConversationPrinter, MessagePrinter}; +use kordophone::model::event::Event; + +use futures_util::StreamExt; pub fn make_api_client_from_env() -> HTTPAPIClient { dotenv::dotenv().ok(); @@ -37,6 +41,12 @@ pub enum Commands { /// Prints the server Kordophone version. Version, + + /// Prints all events from the server. + Events, + + /// Prints all raw updates from the server. + RawUpdates, } impl Commands { @@ -46,6 +56,8 @@ impl Commands { Commands::Version => client.print_version().await, Commands::Conversations => client.print_conversations().await, Commands::Messages { conversation_id } => client.print_messages(conversation_id).await, + Commands::RawUpdates => client.print_raw_updates().await, + Commands::Events => client.print_events().await, } } } @@ -82,6 +94,35 @@ impl ClientCli { } Ok(()) } + + pub async fn print_events(&mut self) -> Result<()> { + let socket = self.api.open_event_socket().await?; + + let mut stream = socket.events().await; + while let Some(Ok(event)) = stream.next().await { + match event { + Event::ConversationChanged(conversation) => { + println!("Conversation changed: {}", conversation.guid); + } + Event::MessageReceived(conversation, message) => { + println!("Message received: msg: {} conversation: {}", message.guid, conversation.guid); + } + } + } + Ok(()) + } + + pub async fn print_raw_updates(&mut self) -> Result<()> { + let socket = self.api.open_event_socket().await?; + + println!("Listening for raw updates..."); + let mut stream = socket.raw_updates().await; + while let Some(update) = stream.next().await { + println!("Got update: {:?}", update); + } + + Ok(()) + } } diff --git a/kpcli/src/main.rs b/kpcli/src/main.rs index e0f7743..2cf4ba8 100644 --- a/kpcli/src/main.rs +++ b/kpcli/src/main.rs @@ -5,6 +5,7 @@ mod daemon; use anyhow::Result; use clap::{Parser, Subcommand}; +use log::LevelFilter; /// A command line interface for the Kordophone library and daemon #[derive(Parser)] @@ -43,8 +44,22 @@ async fn run_command(command: Commands) -> Result<()> { } } +fn initialize_logging() { + // Weird: is this the best way to do this? + let log_level = std::env::var("RUST_LOG") + .map(|s| s.parse::().unwrap_or(LevelFilter::Info)) + .unwrap_or(LevelFilter::Info); + + env_logger::Builder::from_default_env() + .format_timestamp_secs() + .filter_level(log_level) + .init(); +} + #[tokio::main] async fn main() { + initialize_logging(); + let cli = Cli::parse(); run_command(cli.command).await From 1c2f09e81b3cccb4b09bbde7b7ef335f8fed8e96 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 1 May 2025 18:08:04 -0700 Subject: [PATCH 051/138] clippy --- kordophone/src/api/http_client.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index e72af03..c7321b1 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -13,10 +13,8 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize}; use tokio::net::TcpStream; use futures_util::{StreamExt, TryStreamExt}; -use futures_util::stream::{SplitStream, SplitSink, TryFilterMap, MapErr, Stream}; -use futures_util::stream::Map; +use futures_util::stream::{SplitStream, SplitSink, Stream}; use futures_util::stream::BoxStream; -use std::future::Future; use tokio_tungstenite::connect_async; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; From 2314713bb43939f2b752a6cf4754b2784428aedc Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 1 May 2025 20:36:43 -0700 Subject: [PATCH 052/138] daemon: incorporate update monitor in daemon activities --- kordophone-db/src/repository.rs | 17 ++++ kordophone/src/api/http_client.rs | 2 +- .../net.buzzert.kordophonecd.Server.xml | 5 + kordophoned/src/daemon/events.rs | 3 + kordophoned/src/daemon/mod.rs | 61 +++++++++++- kordophoned/src/daemon/update_monitor.rs | 98 +++++++++++++++++++ kordophoned/src/dbus/server_impl.rs | 4 + kpcli/src/daemon/mod.rs | 11 ++- 8 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 kordophoned/src/daemon/update_monitor.rs diff --git a/kordophone-db/src/repository.rs b/kordophone-db/src/repository.rs index 8189d62..dee4497 100644 --- a/kordophone-db/src/repository.rs +++ b/kordophone-db/src/repository.rs @@ -127,6 +127,9 @@ impl<'a> Repository<'a> { )) .execute(self.connection)?; + // Update conversation date + self.update_conversation_metadata(conversation_guid, &db_message)?; + Ok(()) } @@ -174,6 +177,9 @@ impl<'a> Repository<'a> { .values(&conv_msg_records) .execute(self.connection)?; + // Update conversation date + self.update_conversation_metadata(conversation_guid, &db_messages.last().unwrap())?; + Ok(()) } @@ -234,6 +240,17 @@ impl<'a> Repository<'a> { Ok(()) } + fn update_conversation_metadata(&mut self, conversation_guid: &str, last_message: &MessageRecord) -> Result<()> { + let conversation = self.get_conversation_by_guid(conversation_guid)?; + if let Some(mut conversation) = conversation { + conversation.date = last_message.date; + conversation.last_message_preview = Some(last_message.text.clone()); + self.insert_conversation(conversation)?; + } + + Ok(()) + } + // Helper function to get the last inserted row ID // This is a workaround since the Sqlite backend doesn't support `RETURNING` // Huge caveat with this is that it depends on whatever the last insert was, prevents concurrent inserts. diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index c7321b1..bfc2488 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -243,7 +243,7 @@ impl APIInterface for HTTPAPIClient { request.headers_mut().insert("Authorization", header_value); } - let (socket, response) = connect_async(request).await.unwrap(); + let (socket, response) = connect_async(request).await.map_err(Error::from)?; log::debug!("Websocket connected: {:?}", response.status()); if response.status() != StatusCode::SWITCHING_PROTOCOLS { diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index 37ad35f..b346742 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -24,6 +24,11 @@ + + + + diff --git a/kordophoned/src/daemon/events.rs b/kordophoned/src/daemon/events.rs index 97427e9..b356992 100644 --- a/kordophoned/src/daemon/events.rs +++ b/kordophoned/src/daemon/events.rs @@ -9,6 +9,9 @@ pub enum Event { /// Get the version of the daemon. GetVersion(Reply), + /// Asynchronous event for syncing the conversation list with the server. + SyncConversationList(Reply<()>), + /// Asynchronous event for syncing all conversations with the server. SyncAllConversations(Reply<()>), diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index a7f9997..30e4266 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -30,6 +30,9 @@ use kordophone::api::{ AuthenticationStore, }; +mod update_monitor; +use update_monitor::UpdateMonitor; + #[derive(Debug, Error)] pub enum DaemonError { #[error("Client Not Configured")] @@ -87,11 +90,11 @@ impl AuthenticationStore for DatabaseAuthenticationStore { } } -mod target { +pub mod target { pub static SYNC: &str = "sync"; pub static EVENT: &str = "event"; + pub static UPDATES: &str = "updates"; } - pub struct Daemon { pub event_sender: Sender, event_receiver: Receiver, @@ -139,6 +142,13 @@ impl Daemon { log::info!("Starting daemon version {}", self.version); log::debug!("Debug logging enabled."); + let mut update_monitor = UpdateMonitor::new(self.database.clone(), self.event_sender.clone()); + + tokio::spawn(async move { + log::info!(target: target::UPDATES, "Starting update monitor"); + update_monitor.run().await; // should run indefinitely + }); + while let Some(event) = self.event_receiver.recv().await { log::debug!(target: target::EVENT, "Received event: {:?}", event); self.handle_event(event).await; @@ -151,6 +161,20 @@ impl Daemon { reply.send(self.version.clone()).unwrap(); }, + Event::SyncConversationList(reply) => { + let mut db_clone = self.database.clone(); + let signal_sender = self.signal_sender.clone(); + self.runtime.spawn(async move { + let result = Self::sync_conversation_list(&mut db_clone, &signal_sender).await; + if let Err(e) = result { + log::error!("Error handling sync event: {}", e); + } + }); + + // This is a background operation, so return right away. + reply.send(()).unwrap(); + }, + Event::SyncAllConversations(reply) => { let mut db_clone = self.database.clone(); let signal_sender = self.signal_sender.clone(); @@ -231,8 +255,32 @@ impl Daemon { self.database.lock().await.with_repository(|r| r.get_messages_for_conversation(&conversation_id).unwrap()).await } + async fn sync_conversation_list(database: &mut Arc>, signal_sender: &Sender) -> Result<()> { + log::info!(target: target::SYNC, "Starting list conversation sync"); + + let mut client = Self::get_client_impl(database).await?; + + // Fetch conversations from server + let fetched_conversations = client.get_conversations().await?; + let db_conversations: Vec = fetched_conversations.into_iter() + .map(kordophone_db::models::Conversation::from) + .collect(); + + // Insert each conversation + let num_conversations = db_conversations.len(); + for conversation in db_conversations { + database.with_repository(|r| r.insert_conversation(conversation)).await?; + } + + // Send conversations updated signal + signal_sender.send(Signal::ConversationsUpdated).await?; + + log::info!(target: target::SYNC, "Synchronized {} conversations", num_conversations); + Ok(()) + } + async fn sync_all_conversations_impl(database: &mut Arc>, signal_sender: &Sender) -> Result<()> { - log::info!(target: target::SYNC, "Starting conversation sync"); + log::info!(target: target::SYNC, "Starting full conversation sync"); let mut client = Self::get_client_impl(database).await?; @@ -266,6 +314,13 @@ impl Daemon { let mut client = Self::get_client_impl(database).await?; + // Check if conversation exists in database. + let conversation = database.with_repository(|r| r.get_conversation_by_guid(&conversation_id)).await?; + if conversation.is_none() { + // If the conversation doesn't exist, first do a conversation list sync. + Self::sync_conversation_list(database, signal_sender).await?; + } + // Fetch and sync messages for this conversation let last_message_id = database.with_repository(|r| -> Option { r.get_last_message_for_conversation(&conversation_id) diff --git a/kordophoned/src/daemon/update_monitor.rs b/kordophoned/src/daemon/update_monitor.rs new file mode 100644 index 0000000..57ed1ca --- /dev/null +++ b/kordophoned/src/daemon/update_monitor.rs @@ -0,0 +1,98 @@ +use crate::daemon::{ + Daemon, + DaemonResult, + + events::{Event, Reply}, + target, +}; + +use kordophone::APIInterface; +use kordophone::api::event_socket::EventSocket; +use kordophone::model::event::Event as UpdateEvent; + +use kordophone_db::database::Database; + +use tokio::sync::mpsc::Sender; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub struct UpdateMonitor { + database: Arc>, + event_sender: Sender, +} + +impl UpdateMonitor { + pub fn new(database: Arc>, event_sender: Sender) -> Self { + Self { database, event_sender } + } + + pub async fn send_event( + &self, + make_event: impl FnOnce(Reply) -> Event, + ) -> DaemonResult { + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + self.event_sender.send(make_event(reply_tx)) + .await + .map_err(|_| "Failed to send event")?; + + reply_rx.await.map_err(|_| "Failed to receive reply".into()) + } + + async fn handle_update(&mut self, update: UpdateEvent) { + match update { + UpdateEvent::ConversationChanged(conversation) => { + log::info!(target: target::UPDATES, "Conversation changed: {:?}", conversation); + log::info!(target: target::UPDATES, "Triggering conversation list sync"); + self.send_event(Event::SyncConversationList).await + .unwrap_or_else(|e| { + log::error!("Failed to send daemon event: {}", e); + }); + } + + UpdateEvent::MessageReceived(conversation, message) => { + log::info!(target: target::UPDATES, "Message received: msgid:{:?}, convid:{:?}", message.guid, conversation.guid); + log::info!(target: target::UPDATES, "Triggering message sync for conversation id: {}", conversation.guid); + self.send_event(|r| Event::SyncConversation(conversation.guid, r)).await + .unwrap_or_else(|e| { + log::error!("Failed to send daemon event: {}", e); + }); + } + } + } + + pub async fn run(&mut self) { + use futures_util::stream::StreamExt; + + log::info!(target: target::UPDATES, "Starting update monitor"); + + loop { + log::debug!(target: target::UPDATES, "Creating client"); + let mut client = match Daemon::get_client_impl(&mut self.database).await { + Ok(client) => client, + Err(e) => { + log::error!("Failed to get client: {}", e); + log::warn!("Retrying in 5 seconds..."); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + continue; + } + }; + + log::debug!(target: target::UPDATES, "Opening event socket"); + let socket = match client.open_event_socket().await { + Ok(events) => events, + Err(e) => { + log::warn!("Failed to open event socket: {}", e); + log::warn!("Retrying in 5 seconds..."); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + continue; + } + }; + + log::debug!(target: target::UPDATES, "Starting event stream"); + let mut event_stream = socket.events().await; + while let Some(Ok(event)) = event_stream.next().await { + self.handle_update(event).await; + } + } + } +} \ No newline at end of file diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 9bc0c75..5c00a58 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -67,6 +67,10 @@ impl DbusRepository for ServerImpl { }) } + fn sync_conversation_list(&mut self) -> Result<(), dbus::MethodErr> { + self.send_event_sync(Event::SyncConversationList) + } + fn sync_all_conversations(&mut self) -> Result<(), dbus::MethodErr> { self.send_event_sync(Event::SyncAllConversations) } diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index 2212f8b..ad5b52f 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -20,11 +20,14 @@ pub enum Commands { /// Gets all known conversations. Conversations, - /// Runs a sync operation. + /// Runs a full sync operation for a conversation and its messages. Sync { conversation_id: Option, }, + /// Runs a sync operation for the conversation list. + SyncList, + /// Prints the server Kordophone version. Version, @@ -75,6 +78,7 @@ impl Commands { Commands::Version => client.print_version().await, Commands::Conversations => client.print_conversations().await, Commands::Sync { conversation_id } => client.sync_conversations(conversation_id).await, + Commands::SyncList => client.sync_conversations_list().await, Commands::Config { command } => client.config(command).await, Commands::Signals => client.wait_for_signals().await, Commands::Messages { conversation_id, last_message_id } => client.print_messages(conversation_id, last_message_id).await, @@ -125,6 +129,11 @@ impl DaemonCli { } } + pub async fn sync_conversations_list(&mut self) -> Result<()> { + KordophoneRepository::sync_conversation_list(&self.proxy()) + .map_err(|e| anyhow::anyhow!("Failed to sync conversations: {}", e)) + } + pub async fn print_messages(&mut self, conversation_id: String, last_message_id: Option) -> Result<()> { let messages = KordophoneRepository::get_messages(&self.proxy(), &conversation_id, &last_message_id.unwrap_or_default())?; println!("Number of messages: {}", messages.len()); From 2106bce7554d054ca7101bde22d92154063151d7 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 1 May 2025 20:45:20 -0700 Subject: [PATCH 053/138] daemon: reorg --- kordophoned/src/daemon/auth_store.rs | 65 +++++++++++++++++++++++ kordophoned/src/daemon/mod.rs | 77 +++++----------------------- 2 files changed, 78 insertions(+), 64 deletions(-) create mode 100644 kordophoned/src/daemon/auth_store.rs diff --git a/kordophoned/src/daemon/auth_store.rs b/kordophoned/src/daemon/auth_store.rs new file mode 100644 index 0000000..5956b66 --- /dev/null +++ b/kordophoned/src/daemon/auth_store.rs @@ -0,0 +1,65 @@ +use crate::daemon::SettingsKey; + +use std::sync::Arc; +use tokio::sync::Mutex; + +use kordophone::api::{AuthenticationStore, http_client::Credentials}; +use kordophone::model::JwtToken; +use kordophone_db::database::{Database, DatabaseAccess}; + +use async_trait::async_trait; + +pub struct DatabaseAuthenticationStore { + database: Arc>, +} + +impl DatabaseAuthenticationStore { + pub fn new(database: Arc>) -> Self { + Self { database } + } +} + +#[async_trait] +impl AuthenticationStore for DatabaseAuthenticationStore { + async fn get_credentials(&mut self) -> Option { + self.database.lock().await.with_settings(|settings| { + let username: Option = settings.get::(SettingsKey::USERNAME) + .unwrap_or_else(|e| { + log::warn!("error getting username from database: {}", e); + None + }); + + // TODO: This would be the point where we map from credential item to password. + let password: String = settings.get::(SettingsKey::CREDENTIAL_ITEM) + .unwrap_or_else(|e| { + log::warn!("error getting password from database: {}", e); + None + }) + .unwrap_or_else(|| { + log::warn!("warning: no password in database, [DEBUG] using default password"); + "test".to_string() + }); + + if username.is_none() { + log::warn!("Username not present in database"); + } + + match (username, password) { + (Some(username), password) => Some(Credentials { username, password }), + _ => None, + } + }).await + } + + async fn get_token(&mut self) -> Option { + self.database.lock().await + .with_settings(|settings| settings.get::(SettingsKey::TOKEN).unwrap_or_default()).await + } + + async fn set_token(&mut self, token: JwtToken) { + self.database.lock().await + .with_settings(|settings| settings.put(SettingsKey::TOKEN, &token)).await.unwrap_or_else(|e| { + log::error!("Failed to set token: {}", e); + }); + } +} \ No newline at end of file diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 30e4266..4af1875 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -16,23 +16,21 @@ use thiserror::Error; use tokio::sync::mpsc::{Sender, Receiver}; use std::sync::Arc; use tokio::sync::Mutex; -use async_trait::async_trait; use kordophone_db::{ database::{Database, DatabaseAccess}, models::{Conversation, Message}, }; -use kordophone::model::JwtToken; -use kordophone::api::{ - http_client::{Credentials, HTTPAPIClient}, - APIInterface, - AuthenticationStore, -}; +use kordophone::api::APIInterface; +use kordophone::api::http_client::HTTPAPIClient; mod update_monitor; use update_monitor::UpdateMonitor; +mod auth_store; +use auth_store::DatabaseAuthenticationStore; + #[derive(Debug, Error)] pub enum DaemonError { #[error("Client Not Configured")] @@ -41,58 +39,10 @@ pub enum DaemonError { pub type DaemonResult = Result>; -struct DatabaseAuthenticationStore { - database: Arc>, -} - -#[async_trait] -impl AuthenticationStore for DatabaseAuthenticationStore { - async fn get_credentials(&mut self) -> Option { - self.database.lock().await.with_settings(|settings| { - let username: Option = settings.get::(SettingsKey::USERNAME) - .unwrap_or_else(|e| { - log::warn!("error getting username from database: {}", e); - None - }); - - // TODO: This would be the point where we map from credential item to password. - let password: String = settings.get::(SettingsKey::CREDENTIAL_ITEM) - .unwrap_or_else(|e| { - log::warn!("error getting password from database: {}", e); - None - }) - .unwrap_or_else(|| { - log::warn!("warning: no password in database, [DEBUG] using default password"); - "test".to_string() - }); - - if username.is_none() { - log::warn!("Username not present in database"); - } - - match (username, password) { - (Some(username), password) => Some(Credentials { username, password }), - _ => None, - } - }).await - } - - async fn get_token(&mut self) -> Option { - self.database.lock().await - .with_settings(|settings| settings.get::(SettingsKey::TOKEN).unwrap_or_default()).await - } - - async fn set_token(&mut self, token: JwtToken) { - self.database.lock().await - .with_settings(|settings| settings.put(SettingsKey::TOKEN, &token)).await.unwrap_or_else(|e| { - log::error!("Failed to set token: {}", e); - }); - } -} - pub mod target { pub static SYNC: &str = "sync"; pub static EVENT: &str = "event"; + pub static SETTINGS: &str = "settings"; pub static UPDATES: &str = "updates"; } pub struct Daemon { @@ -145,7 +95,6 @@ impl Daemon { let mut update_monitor = UpdateMonitor::new(self.database.clone(), self.event_sender.clone()); tokio::spawn(async move { - log::info!(target: target::UPDATES, "Starting update monitor"); update_monitor.run().await; // should run indefinitely }); @@ -167,7 +116,7 @@ impl Daemon { self.runtime.spawn(async move { let result = Self::sync_conversation_list(&mut db_clone, &signal_sender).await; if let Err(e) = result { - log::error!("Error handling sync event: {}", e); + log::error!(target: target::SYNC, "Error handling sync event: {}", e); } }); @@ -181,7 +130,7 @@ impl Daemon { self.runtime.spawn(async move { let result = Self::sync_all_conversations_impl(&mut db_clone, &signal_sender).await; if let Err(e) = result { - log::error!("Error handling sync event: {}", e); + log::error!(target: target::SYNC, "Error handling sync event: {}", e); } }); @@ -195,7 +144,7 @@ impl Daemon { self.runtime.spawn(async move { let result = Self::sync_conversation_impl(&mut db_clone, &signal_sender, conversation_id).await; if let Err(e) = result { - log::error!("Error handling sync event: {}", e); + log::error!(target: target::SYNC, "Error handling sync event: {}", e); } }); @@ -210,7 +159,7 @@ impl Daemon { Event::GetAllSettings(reply) => { let settings = self.get_settings().await .unwrap_or_else(|e| { - log::error!("Failed to get settings: {:#?}", e); + log::error!(target: target::SETTINGS, "Failed to get settings: {:#?}", e); Settings::default() }); @@ -220,7 +169,7 @@ impl Daemon { Event::UpdateSettings(settings, reply) => { self.update_settings(&settings).await .unwrap_or_else(|e| { - log::error!("Failed to update settings: {}", e); + log::error!(target: target::SETTINGS, "Failed to update settings: {}", e); }); reply.send(()).unwrap(); @@ -234,7 +183,7 @@ impl Daemon { Event::DeleteAllConversations(reply) => { self.delete_all_conversations().await .unwrap_or_else(|e| { - log::error!("Failed to delete all conversations: {}", e); + log::error!(target: target::SYNC, "Failed to delete all conversations: {}", e); }); reply.send(()).unwrap(); @@ -371,7 +320,7 @@ impl Daemon { let client = HTTPAPIClient::new( server_url.parse().unwrap(), - DatabaseAuthenticationStore { database: database.clone() } + DatabaseAuthenticationStore::new(database.clone()) ); Ok(client) From 07b55f861528c0cd555842cb9a89c1ad69ccc7c2 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 2 May 2025 12:03:56 -0700 Subject: [PATCH 054/138] client: implements send_message --- kordophone/src/api/http_client.rs | 36 ++++++++++++--- kordophone/src/api/mod.rs | 8 +++- kordophone/src/model/mod.rs | 4 ++ kordophone/src/model/outgoing_message.rs | 56 ++++++++++++++++++++++++ kordophone/src/tests/test_client.rs | 19 +++++++- kpcli/src/client/mod.rs | 19 ++++++++ 6 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 kordophone/src/model/outgoing_message.rs diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index bfc2488..1a9c3c4 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -20,7 +20,17 @@ use tokio_tungstenite::connect_async; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use crate::{ - model::{Conversation, ConversationID, JwtToken, Message, MessageID, UpdateItem, Event}, + model::{ + Conversation, + ConversationID, + JwtToken, + Message, + MessageID, + UpdateItem, + Event, + OutgoingMessage, + }, + APIInterface }; @@ -215,6 +225,19 @@ impl APIInterface for HTTPAPIClient { Ok(messages) } + async fn send_message( + &mut self, + outgoing_message: OutgoingMessage, + ) -> Result { + let message: Message = self.request_with_body( + "sendMessage", + Method::POST, + || serde_json::to_string(&outgoing_message).unwrap().into() + ).await?; + + Ok(message) + } + async fn open_event_socket(&mut self) -> Result { use tungstenite::http::StatusCode; use tungstenite::handshake::client::Request as TungsteniteRequest; @@ -285,24 +308,23 @@ impl HTTPAPIClient { } async fn request(&mut self, endpoint: &str, method: Method) -> Result { - self.request_with_body(endpoint, method, || { Body::empty() }).await + self.request_with_body(endpoint, method, Body::empty).await } - async fn request_with_body(&mut self, endpoint: &str, method: Method, body_fn: B) -> Result - where T: DeserializeOwned, B: Fn() -> Body + async fn request_with_body(&mut self, endpoint: &str, method: Method, body_fn: impl Fn() -> Body) -> Result + where T: DeserializeOwned { self.request_with_body_retry(endpoint, method, body_fn, true).await } - async fn request_with_body_retry( + async fn request_with_body_retry( &mut self, endpoint: &str, method: Method, - body_fn: B, + body_fn: impl Fn() -> Body, retry_auth: bool) -> Result where T: DeserializeOwned, - B: Fn() -> Body { use hyper::StatusCode; diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index 5f35bfd..81f8fae 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -1,6 +1,6 @@ use async_trait::async_trait; pub use crate::model::{ - Conversation, Message, ConversationID, MessageID, + Conversation, Message, ConversationID, MessageID, OutgoingMessage, }; pub mod auth; @@ -35,6 +35,12 @@ pub trait APIInterface { after: Option, ) -> Result, Self::Error>; + // (POST) /sendMessage + async fn send_message( + &mut self, + outgoing_message: OutgoingMessage, + ) -> Result; + // (POST) /authenticate async fn authenticate(&mut self, credentials: Credentials) -> Result; diff --git a/kordophone/src/model/mod.rs b/kordophone/src/model/mod.rs index b54ce2e..cc659aa 100644 --- a/kordophone/src/model/mod.rs +++ b/kordophone/src/model/mod.rs @@ -1,6 +1,7 @@ pub mod conversation; pub mod event; pub mod message; +pub mod outgoing_message; pub mod update; pub use conversation::Conversation; @@ -9,6 +10,9 @@ pub use conversation::ConversationID; pub use message::Message; pub use message::MessageID; +pub use outgoing_message::OutgoingMessage; +pub use outgoing_message::OutgoingMessageBuilder; + pub use update::UpdateItem; pub use event::Event; diff --git a/kordophone/src/model/outgoing_message.rs b/kordophone/src/model/outgoing_message.rs new file mode 100644 index 0000000..1f13d2f --- /dev/null +++ b/kordophone/src/model/outgoing_message.rs @@ -0,0 +1,56 @@ +use serde::Serialize; +use super::conversation::ConversationID; + +#[derive(Debug, Clone, Serialize)] +pub struct OutgoingMessage { + #[serde(rename = "body")] + pub text: String, + + #[serde(rename = "guid")] + pub conversation_id: ConversationID, + + #[serde(rename = "fileTransferGUIDs")] + pub file_transfer_guids: Vec, +} + +impl OutgoingMessage { + pub fn builder() -> OutgoingMessageBuilder { + OutgoingMessageBuilder::new() + } +} + +#[derive(Default)] +pub struct OutgoingMessageBuilder { + text: Option, + conversation_id: Option, + file_transfer_guids: Option>, +} + +impl OutgoingMessageBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn text(mut self, text: String) -> Self { + self.text = Some(text); + self + } + + pub fn conversation_id(mut self, conversation_id: ConversationID) -> Self { + self.conversation_id = Some(conversation_id); + self + } + + pub fn file_transfer_guids(mut self, file_transfer_guids: Vec) -> Self { + self.file_transfer_guids = Some(file_transfer_guids); + self + } + + pub fn build(self) -> OutgoingMessage { + OutgoingMessage { + text: self.text.unwrap(), + conversation_id: self.conversation_id.unwrap(), + file_transfer_guids: self.file_transfer_guids.unwrap_or_default(), + } + } +} \ No newline at end of file diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index 3e06f3a..fdc3eff 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -1,10 +1,13 @@ use async_trait::async_trait; use std::collections::HashMap; +use time::OffsetDateTime; +use uuid::Uuid; + pub use crate::APIInterface; use crate::{ api::http_client::Credentials, - model::{Conversation, ConversationID, JwtToken, Message, MessageID, UpdateItem, Event}, + model::{Conversation, ConversationID, JwtToken, Message, MessageID, UpdateItem, Event, OutgoingMessage}, api::event_socket::EventSocket, }; @@ -88,6 +91,20 @@ impl APIInterface for TestClient { Err(TestError::ConversationNotFound) } + async fn send_message( + &mut self, + outgoing_message: OutgoingMessage, + ) -> Result { + let message = Message::builder() + .guid(Uuid::new_v4().to_string()) + .text(outgoing_message.text) + .date(OffsetDateTime::now_utc()) + .build(); + + self.messages.entry(outgoing_message.conversation_id).or_insert(vec![]).push(message.clone()); + Ok(message) + } + async fn open_event_socket(&mut self) -> Result { Ok(TestEventSocket::new()) } diff --git a/kpcli/src/client/mod.rs b/kpcli/src/client/mod.rs index 87a0e08..d805f9e 100644 --- a/kpcli/src/client/mod.rs +++ b/kpcli/src/client/mod.rs @@ -8,6 +8,7 @@ use anyhow::Result; use clap::Subcommand; use crate::printers::{ConversationPrinter, MessagePrinter}; use kordophone::model::event::Event; +use kordophone::model::outgoing_message::OutgoingMessage; use futures_util::StreamExt; @@ -47,6 +48,12 @@ pub enum Commands { /// Prints all raw updates from the server. RawUpdates, + + /// Sends a message to the server. + SendMessage { + conversation_id: String, + message: String, + }, } impl Commands { @@ -58,6 +65,7 @@ impl Commands { Commands::Messages { conversation_id } => client.print_messages(conversation_id).await, Commands::RawUpdates => client.print_raw_updates().await, Commands::Events => client.print_events().await, + Commands::SendMessage { conversation_id, message } => client.send_message(conversation_id, message).await, } } } @@ -123,6 +131,17 @@ impl ClientCli { Ok(()) } + + pub async fn send_message(&mut self, conversation_id: String, message: String) -> Result<()> { + let outgoing_message = OutgoingMessage::builder() + .conversation_id(conversation_id) + .text(message) + .build(); + + let message = self.api.send_message(outgoing_message).await?; + println!("Message sent: {}", message.guid); + Ok(()) + } } From 2519bc05ad19d7e792928e51ae66d63ffd7c8452 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 2 May 2025 14:22:43 -0700 Subject: [PATCH 055/138] daemon: implements post office --- Cargo.lock | 61 ++++------ kordophone/src/api/http_client.rs | 2 +- kordophone/src/api/mod.rs | 5 +- kordophone/src/model/outgoing_message.rs | 11 ++ kordophone/src/tests/test_client.rs | 6 +- kordophoned/Cargo.toml | 2 + .../net.buzzert.kordophonecd.Server.xml | 9 ++ kordophoned/src/daemon/events.rs | 9 ++ kordophoned/src/daemon/mod.rs | 42 ++++++- kordophoned/src/daemon/post_office.rs | 115 ++++++++++++++++++ kordophoned/src/dbus/server_impl.rs | 5 + kpcli/src/client/mod.rs | 2 +- kpcli/src/daemon/mod.rs | 13 ++ 13 files changed, 234 insertions(+), 48 deletions(-) create mode 100644 kordophoned/src/daemon/post_office.rs diff --git a/Cargo.lock b/Cargo.lock index bdbc538..026e141 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1036,6 +1036,8 @@ dependencies = [ "log", "thiserror 2.0.12", "tokio", + "tokio-condvar", + "uuid", ] [[package]] @@ -1386,35 +1388,14 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.3", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "rand_chacha", + "rand_core", ] [[package]] @@ -1424,16 +1405,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.14", + "rand_core", ] [[package]] @@ -1821,6 +1793,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "tokio-condvar" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8530e402d24f6a65019baa57593f1769557c670302f493cdf8fa3dfbe4d85ac" +dependencies = [ + "tokio", +] + [[package]] name = "tokio-macros" version = "2.5.0" @@ -1944,7 +1925,7 @@ dependencies = [ "http 1.3.1", "httparse", "log", - "rand 0.9.1", + "rand", "sha1", "thiserror 2.0.12", "utf-8", @@ -1988,20 +1969,20 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.11.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" +checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ - "getrandom 0.2.14", - "rand 0.8.5", + "getrandom 0.3.2", + "rand", "uuid-macro-internal", ] [[package]] name = "uuid-macro-internal" -version = "1.11.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b91f57fe13a38d0ce9e28a03463d8d3c2468ed03d75375110ec71d93b449a08" +checksum = "72dcd78c4f979627a754f5522cea6e6a25e55139056535fe6e69c506cd64a862" dependencies = [ "proc-macro2", "quote", diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index 1a9c3c4..63d2522 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -227,7 +227,7 @@ impl APIInterface for HTTPAPIClient { async fn send_message( &mut self, - outgoing_message: OutgoingMessage, + outgoing_message: &OutgoingMessage, ) -> Result { let message: Message = self.request_with_body( "sendMessage", diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index 81f8fae..d711948 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -15,10 +15,11 @@ pub mod event_socket; pub use event_socket::EventSocket; use self::http_client::Credentials; +use std::fmt::Debug; #[async_trait] pub trait APIInterface { - type Error; + type Error: Debug; // (GET) /version async fn get_version(&mut self) -> Result; @@ -38,7 +39,7 @@ pub trait APIInterface { // (POST) /sendMessage async fn send_message( &mut self, - outgoing_message: OutgoingMessage, + outgoing_message: &OutgoingMessage, ) -> Result; // (POST) /authenticate diff --git a/kordophone/src/model/outgoing_message.rs b/kordophone/src/model/outgoing_message.rs index 1f13d2f..530b741 100644 --- a/kordophone/src/model/outgoing_message.rs +++ b/kordophone/src/model/outgoing_message.rs @@ -1,8 +1,12 @@ use serde::Serialize; use super::conversation::ConversationID; +use uuid::Uuid; #[derive(Debug, Clone, Serialize)] pub struct OutgoingMessage { + #[serde(skip)] + pub guid: Uuid, + #[serde(rename = "body")] pub text: String, @@ -21,6 +25,7 @@ impl OutgoingMessage { #[derive(Default)] pub struct OutgoingMessageBuilder { + guid: Option, text: Option, conversation_id: Option, file_transfer_guids: Option>, @@ -31,6 +36,11 @@ impl OutgoingMessageBuilder { Self::default() } + pub fn guid(mut self, guid: Uuid) -> Self { + self.guid = Some(guid); + self + } + pub fn text(mut self, text: String) -> Self { self.text = Some(text); self @@ -48,6 +58,7 @@ impl OutgoingMessageBuilder { pub fn build(self) -> OutgoingMessage { OutgoingMessage { + guid: self.guid.unwrap_or_else(|| Uuid::new_v4()), text: self.text.unwrap(), conversation_id: self.conversation_id.unwrap(), file_transfer_guids: self.file_transfer_guids.unwrap_or_default(), diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index fdc3eff..4edd627 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -93,15 +93,15 @@ impl APIInterface for TestClient { async fn send_message( &mut self, - outgoing_message: OutgoingMessage, + outgoing_message: &OutgoingMessage, ) -> Result { let message = Message::builder() .guid(Uuid::new_v4().to_string()) - .text(outgoing_message.text) + .text(outgoing_message.text.clone()) .date(OffsetDateTime::now_utc()) .build(); - self.messages.entry(outgoing_message.conversation_id).or_insert(vec![]).push(message.clone()); + self.messages.entry(outgoing_message.conversation_id.clone()).or_insert(vec![]).push(message.clone()); Ok(message) } diff --git a/kordophoned/Cargo.toml b/kordophoned/Cargo.toml index 74c2556..7891e35 100644 --- a/kordophoned/Cargo.toml +++ b/kordophoned/Cargo.toml @@ -18,6 +18,8 @@ kordophone-db = { path = "../kordophone-db" } log = "0.4.25" thiserror = "2.0.12" tokio = { version = "1", features = ["full"] } +tokio-condvar = "0.3.0" +uuid = "1.16.0" [build-dependencies] dbus-codegen = "0.10.0" diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index b346742..01fb1c3 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -58,6 +58,15 @@ + + + + + + + + , Reply>), + /// Enqueues a message to be sent to the server. + /// Parameters: + /// - conversation_id: The ID of the conversation to send the message to. + /// - text: The text of the message to send. + /// - reply: The outgoing message ID (not the server-assigned message ID). + SendMessage(String, String, Reply), + /// Delete all conversations from the database. DeleteAllConversations(Reply<()>), } diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 4af1875..f4eeda0 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -16,6 +16,7 @@ use thiserror::Error; use tokio::sync::mpsc::{Sender, Receiver}; use std::sync::Arc; use tokio::sync::Mutex; +use uuid::Uuid; use kordophone_db::{ database::{Database, DatabaseAccess}, @@ -24,6 +25,7 @@ use kordophone_db::{ use kordophone::api::APIInterface; use kordophone::api::http_client::HTTPAPIClient; +use kordophone::model::outgoing_message::OutgoingMessage; mod update_monitor; use update_monitor::UpdateMonitor; @@ -31,6 +33,10 @@ use update_monitor::UpdateMonitor; mod auth_store; use auth_store::DatabaseAuthenticationStore; +mod post_office; +use post_office::PostOffice; +use post_office::Event as PostOfficeEvent; + #[derive(Debug, Error)] pub enum DaemonError { #[error("Client Not Configured")] @@ -52,6 +58,9 @@ pub struct Daemon { signal_receiver: Option>, signal_sender: Sender, + post_office_sink: Sender, + post_office_source: Option>, + version: String, database: Arc>, runtime: tokio::runtime::Runtime, @@ -69,6 +78,7 @@ impl Daemon { // Create event channels let (event_sender, event_receiver) = tokio::sync::mpsc::channel(100); let (signal_sender, signal_receiver) = tokio::sync::mpsc::channel(100); + let (post_office_sink, post_office_source) = tokio::sync::mpsc::channel(100); // Create background task runtime let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -84,6 +94,8 @@ impl Daemon { event_sender, signal_receiver: Some(signal_receiver), signal_sender, + post_office_sink, + post_office_source: Some(post_office_source), runtime }) } @@ -92,12 +104,23 @@ impl Daemon { log::info!("Starting daemon version {}", self.version); log::debug!("Debug logging enabled."); + // Update monitor let mut update_monitor = UpdateMonitor::new(self.database.clone(), self.event_sender.clone()); - tokio::spawn(async move { update_monitor.run().await; // should run indefinitely }); + // Post office + { + let mut database = self.database.clone(); + let event_sender = self.event_sender.clone(); + let post_office_source = self.post_office_source.take().unwrap(); + tokio::spawn(async move { + let mut post_office = PostOffice::new(post_office_source, event_sender, async move || Self::get_client_impl(&mut database).await ); + post_office.run().await; + }); + } + while let Some(event) = self.event_receiver.recv().await { log::debug!(target: target::EVENT, "Received event: {:?}", event); self.handle_event(event).await; @@ -188,6 +211,11 @@ impl Daemon { reply.send(()).unwrap(); }, + + Event::SendMessage(conversation_id, text, reply) => { + let uuid = self.enqueue_outgoing_message(text, conversation_id).await; + reply.send(uuid).unwrap(); + }, } } @@ -204,6 +232,18 @@ impl Daemon { self.database.lock().await.with_repository(|r| r.get_messages_for_conversation(&conversation_id).unwrap()).await } + async fn enqueue_outgoing_message(&mut self, text: String, conversation_id: String) -> Uuid { + let outgoing_message = OutgoingMessage::builder() + .text(text) + .conversation_id(conversation_id) + .build(); + + let guid = outgoing_message.guid.clone(); + self.post_office_sink.send(PostOfficeEvent::EnqueueOutgoingMessage(outgoing_message)).await.unwrap(); + + guid + } + async fn sync_conversation_list(database: &mut Arc>, signal_sender: &Sender) -> Result<()> { log::info!(target: target::SYNC, "Starting list conversation sync"); diff --git a/kordophoned/src/daemon/post_office.rs b/kordophoned/src/daemon/post_office.rs new file mode 100644 index 0000000..88ebe30 --- /dev/null +++ b/kordophoned/src/daemon/post_office.rs @@ -0,0 +1,115 @@ +use std::collections::VecDeque; +use std::time::Duration; + +use tokio::sync::mpsc::{Sender, Receiver}; +use tokio::sync::{Mutex, MutexGuard}; +use tokio_condvar::Condvar; + +use crate::daemon::events::Event as DaemonEvent; +use kordophone::model::outgoing_message::OutgoingMessage; +use kordophone::api::APIInterface; + +use anyhow::Result; + +mod target { + pub static POST_OFFICE: &str = "post_office"; +} + +#[derive(Debug)] +pub enum Event { + EnqueueOutgoingMessage(OutgoingMessage), +} + +pub struct PostOffice Result> { + event_source: Receiver, + event_sink: Sender, + make_client: F, + message_queue: Mutex>, + message_available: Condvar, +} + +impl Result> PostOffice { + pub fn new(event_source: Receiver, event_sink: Sender, make_client: F) -> Self { + Self { + event_source, + event_sink, + make_client, + message_queue: Mutex::new(VecDeque::new()), + message_available: Condvar::new(), + } + } + + pub async fn queue_message(&mut self, message: &OutgoingMessage) { + self.message_queue.lock().await.push_back(message.clone()); + self.message_available.notify_one(); + } + + pub async fn run(&mut self) { + log::info!(target: target::POST_OFFICE, "Starting post office"); + + loop { + let mut retry_messages = Vec::new(); + + log::debug!(target: target::POST_OFFICE, "Waiting for event"); + + tokio::select! { + // Incoming events + Some(event) = self.event_source.recv() => { + match event { + Event::EnqueueOutgoingMessage(message) => { + log::debug!(target: target::POST_OFFICE, "Received enqueue outgoing message event"); + self.message_queue.lock().await.push_back(message); + self.message_available.notify_one(); + } + } + } + + // Message queue + mut lock = self.message_available.wait(self.message_queue.lock().await) => { + log::debug!(target: target::POST_OFFICE, "Message available in queue"); + retry_messages = Self::try_send_message_impl(&mut lock, &mut self.make_client).await; + } + } + + if !retry_messages.is_empty() { + log::debug!(target: target::POST_OFFICE, "Queueing {} messages for retry", retry_messages.len()); + for message in retry_messages { + self.queue_message(&message).await; + } + } + } + } + + async fn try_send_message_impl(message_queue: &mut MutexGuard<'_, VecDeque>, make_client: &mut F) -> Vec { + log::debug!(target: target::POST_OFFICE, "Trying to send enqueued messages"); + + let mut retry_messages = Vec::new(); + while let Some(message) = message_queue.pop_front() { + match (make_client)().await { + Ok(mut client) => { + log::debug!(target: target::POST_OFFICE, "Obtained client, sending message."); + match client.send_message(&message).await { + Ok(message) => { + log::info!(target: target::POST_OFFICE, "Message sent successfully: {}", message.guid); + // TODO: Notify the daemon via the event sink. + } + Err(e) => { + log::error!(target: target::POST_OFFICE, "Error sending message: {:?}", e); + log::warn!(target: target::POST_OFFICE, "Retrying in 5 seconds"); + tokio::time::sleep(Duration::from_secs(5)).await; + retry_messages.push(message); + } + } + } + + Err(e) => { + log::error!(target: target::POST_OFFICE, "Error creating client: {:?}", e); + log::warn!(target: target::POST_OFFICE, "Retrying in 5 seconds"); + tokio::time::sleep(Duration::from_secs(5)).await; + } + } + } + + retry_messages + } +} \ No newline at end of file diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 5c00a58..2dc8a38 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -102,6 +102,11 @@ impl DbusRepository for ServerImpl { fn delete_all_conversations(&mut self) -> Result<(), dbus::MethodErr> { self.send_event_sync(Event::DeleteAllConversations) } + + fn send_message(&mut self, conversation_id: String, text: String) -> Result { + self.send_event_sync(|r| Event::SendMessage(conversation_id, text, r)) + .map(|uuid| uuid.to_string()) + } } impl DbusSettings for ServerImpl { diff --git a/kpcli/src/client/mod.rs b/kpcli/src/client/mod.rs index d805f9e..9356d33 100644 --- a/kpcli/src/client/mod.rs +++ b/kpcli/src/client/mod.rs @@ -138,7 +138,7 @@ impl ClientCli { .text(message) .build(); - let message = self.api.send_message(outgoing_message).await?; + let message = self.api.send_message(&outgoing_message).await?; println!("Message sent: {}", message.guid); Ok(()) } diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index ad5b52f..26bafd9 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -48,6 +48,12 @@ pub enum Commands { /// Deletes all conversations. DeleteAllConversations, + + /// Enqueues an outgoing message to be sent to a conversation. + SendMessage { + conversation_id: String, + text: String, + }, } #[derive(Subcommand)] @@ -83,6 +89,7 @@ impl Commands { Commands::Signals => client.wait_for_signals().await, Commands::Messages { conversation_id, last_message_id } => client.print_messages(conversation_id, last_message_id).await, Commands::DeleteAllConversations => client.delete_all_conversations().await, + Commands::SendMessage { conversation_id, text } => client.enqueue_outgoing_message(conversation_id, text).await, } } } @@ -145,6 +152,12 @@ impl DaemonCli { Ok(()) } + pub async fn enqueue_outgoing_message(&mut self, conversation_id: String, text: String) -> Result<()> { + let outgoing_message_id = KordophoneRepository::send_message(&self.proxy(), &conversation_id, &text)?; + println!("Outgoing message ID: {}", outgoing_message_id); + Ok(()) + } + pub async fn wait_for_signals(&mut self) -> Result<()> { use dbus::Message; mod dbus_signals { From 461c37bd208e372eddcace51a5ffc122baa1f04e Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 2 May 2025 15:46:33 -0700 Subject: [PATCH 056/138] daemon: updatemonitor: dont sync convo list on conversation update, only message sync --- kordophoned/src/daemon/mod.rs | 7 ++++--- kordophoned/src/daemon/update_monitor.rs | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index f4eeda0..399df4d 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -240,7 +240,7 @@ impl Daemon { let guid = outgoing_message.guid.clone(); self.post_office_sink.send(PostOfficeEvent::EnqueueOutgoingMessage(outgoing_message)).await.unwrap(); - + guid } @@ -264,7 +264,7 @@ impl Daemon { // Send conversations updated signal signal_sender.send(Signal::ConversationsUpdated).await?; - log::info!(target: target::SYNC, "Synchronized {} conversations", num_conversations); + log::info!(target: target::SYNC, "List synchronized: {} conversations", num_conversations); Ok(()) } @@ -294,7 +294,7 @@ impl Daemon { // Send conversations updated signal. signal_sender.send(Signal::ConversationsUpdated).await?; - log::info!(target: target::SYNC, "Synchronized {} conversations", num_conversations); + log::info!(target: target::SYNC, "Full sync complete, {} conversations processed", num_conversations); Ok(()) } @@ -307,6 +307,7 @@ impl Daemon { let conversation = database.with_repository(|r| r.get_conversation_by_guid(&conversation_id)).await?; if conversation.is_none() { // If the conversation doesn't exist, first do a conversation list sync. + log::warn!(target: target::SYNC, "Conversation {} not found, performing list sync", conversation_id); Self::sync_conversation_list(database, signal_sender).await?; } diff --git a/kordophoned/src/daemon/update_monitor.rs b/kordophoned/src/daemon/update_monitor.rs index 57ed1ca..06edec7 100644 --- a/kordophoned/src/daemon/update_monitor.rs +++ b/kordophoned/src/daemon/update_monitor.rs @@ -42,8 +42,8 @@ impl UpdateMonitor { match update { UpdateEvent::ConversationChanged(conversation) => { log::info!(target: target::UPDATES, "Conversation changed: {:?}", conversation); - log::info!(target: target::UPDATES, "Triggering conversation list sync"); - self.send_event(Event::SyncConversationList).await + log::info!(target: target::UPDATES, "Syncing new messages for conversation id: {}", conversation.guid); + self.send_event(|r| Event::SyncConversation(conversation.guid, r)).await .unwrap_or_else(|e| { log::error!("Failed to send daemon event: {}", e); }); From 26d54f91d50cddcce8f8eed36fcc7ae9953e65da Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 3 May 2025 01:06:50 -0700 Subject: [PATCH 057/138] implements authentication/token retrieval/keyring --- Cargo.lock | 128 +++++++++++++++++- kordophone/src/api/http_client.rs | 56 +++++--- kordophone/src/model/jwt.rs | 46 +++++-- kordophoned/Cargo.toml | 1 + .../net.buzzert.kordophonecd.Server.xml | 6 - kordophoned/src/daemon/auth_store.rs | 45 +++--- kordophoned/src/daemon/mod.rs | 1 + kordophoned/src/daemon/settings.rs | 7 - kordophoned/src/dbus/server_impl.rs | 20 --- kpcli/src/daemon/mod.rs | 15 +- kpcli/src/db/mod.rs | 8 +- 11 files changed, 234 insertions(+), 99 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 026e141..0172a63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -430,6 +430,19 @@ dependencies = [ "dbus", ] +[[package]] +name = "dbus-secret-service" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42a16374481d92aed73ae45b1f120207d8e71d24fb89f357fadbd8f946fd84b" +dependencies = [ + "dbus", + "futures-util", + "num", + "once_cell", + "rand 0.8.5", +] + [[package]] name = "dbus-tokio" version = "0.7.6" @@ -976,6 +989,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyring" +version = "3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1961983669d57bdfe6c0f3ef8e4c229b5ef751afcc7d87e4271d2f71f6ccfa8b" +dependencies = [ + "dbus-secret-service", + "log", +] + [[package]] name = "kordophone" version = "0.1.0" @@ -1031,6 +1054,7 @@ dependencies = [ "directories", "env_logger", "futures-util", + "keyring", "kordophone", "kordophone-db", "log", @@ -1190,12 +1214,76 @@ dependencies = [ "tempfile", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1388,14 +1476,35 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -1405,7 +1514,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.14", ] [[package]] @@ -1925,7 +2043,7 @@ dependencies = [ "http 1.3.1", "httparse", "log", - "rand", + "rand 0.9.1", "sha1", "thiserror 2.0.12", "utf-8", @@ -1974,7 +2092,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ "getrandom 0.3.2", - "rand", + "rand 0.9.1", "uuid-macro-internal", ] diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index 63d2522..bfdf0ec 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -53,7 +53,8 @@ pub enum Error { ClientError(String), HTTPError(hyper::Error), SerdeError(serde_json::Error), - DecodeError, + DecodeError(String), + Unauthorized, } impl std::error::Error for Error { @@ -192,7 +193,7 @@ impl APIInterface for HTTPAPIClient { let body = || -> Body { serde_json::to_string(&credentials).unwrap().into() }; let token: AuthResponse = self.request_with_body_retry("authenticate", Method::POST, body, false).await?; - let token = JwtToken::new(&token.jwt).map_err(|_| Error::DecodeError)?; + let token = JwtToken::new(&token.jwt).map_err(|e| Error::DecodeError(e.to_string()))?; log::debug!("Saving token: {:?}", token); self.auth_store.set_token(token.clone()).await; @@ -239,7 +240,6 @@ impl APIInterface for HTTPAPIClient { } async fn open_event_socket(&mut self) -> Result { - use tungstenite::http::StatusCode; use tungstenite::handshake::client::Request as TungsteniteRequest; use tungstenite::handshake::client::generate_key; @@ -259,21 +259,45 @@ impl APIInterface for HTTPAPIClient { .body(()) .expect("Unable to build websocket request"); + match &auth { + Some(token) => { + let header_value = token.to_header_value().to_str().unwrap().parse().unwrap(); // ugh + request.headers_mut().insert("Authorization", header_value); + } + None => { + log::warn!(target: "websocket", "Proceeding without auth token."); + } + } + log::debug!("Websocket request: {:?}", request); - if let Some(token) = &auth { - let header_value = token.to_header_value().to_str().unwrap().parse().unwrap(); // ugh - request.headers_mut().insert("Authorization", header_value); + match connect_async(request).await.map_err(Error::from) { + Ok((socket, response)) => { + log::debug!("Websocket connected: {:?}", response.status()); + Ok(WebsocketEventSocket::new(socket)) + } + Err(e) => match e { + Error::ClientError(ce) => match ce.as_str() { + "HTTP error: 401 Unauthorized" | "Unauthorized" => { + // Try to authenticate + if let Some(credentials) = &self.auth_store.get_credentials().await { + log::warn!("Websocket connection failed, attempting to authenticate"); + let new_token = self.authenticate(credentials.clone()).await?; + self.auth_store.set_token(new_token).await; + + // try again on the next attempt. + return Err(Error::Unauthorized); + } else { + log::error!("Websocket unauthorized, no credentials provided"); + return Err(Error::ClientError("Unauthorized, no credentials provided".into())); + } + } + _ => Err(Error::Unauthorized) + } + + _ => Err(e) + } } - - let (socket, response) = connect_async(request).await.map_err(Error::from)?; - log::debug!("Websocket connected: {:?}", response.status()); - - if response.status() != StatusCode::SWITCHING_PROTOCOLS { - return Err(Error::ClientError("Websocket connection failed".into())); - } - - Ok(WebsocketEventSocket::new(socket)) } } @@ -384,7 +408,7 @@ impl HTTPAPIClient { // If JSON deserialization fails, try to interpret it as plain text // Unfortunately the server does return things like this... - let s = str::from_utf8(&body).map_err(|_| Error::DecodeError)?; + let s = str::from_utf8(&body).map_err(|e| Error::DecodeError(e.to_string()))?; serde_plain::from_str(s).map_err(|_| json_err) } }?; diff --git a/kordophone/src/model/jwt.rs b/kordophone/src/model/jwt.rs index 8ef5683..9521dac 100644 --- a/kordophone/src/model/jwt.rs +++ b/kordophone/src/model/jwt.rs @@ -26,11 +26,31 @@ enum ExpValue { #[derive(Deserialize, Serialize, Debug, Clone)] #[allow(dead_code)] struct JwtPayload { - exp: serde_json::Value, + #[serde(deserialize_with = "deserialize_exp")] + exp: i64, iss: Option, user: Option, } +fn deserialize_exp<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error; + + #[derive(Deserialize)] + #[serde(untagged)] + enum ExpValue { + String(String), + Number(i64), + } + + match ExpValue::deserialize(deserializer)? { + ExpValue::String(s) => s.parse().map_err(D::Error::custom), + ExpValue::Number(n) => Ok(n), + } +} + #[derive(Deserialize, Serialize, Debug, Clone)] #[allow(dead_code)] pub struct JwtToken { @@ -62,13 +82,7 @@ impl JwtToken { let payload: JwtPayload = serde_json::from_slice(&payload)?; // Parse jwt expiration date - // Annoyingly, because of my own fault, this could be either an integer or string. - let exp: i64 = payload.exp.as_i64().unwrap_or_else(|| { - let exp: String = payload.exp.as_str().unwrap().to_string(); - exp.parse().unwrap() - }); - - let timestamp = DateTime::from_timestamp(exp, 0).unwrap().naive_utc(); + let timestamp = DateTime::from_timestamp(payload.exp, 0).unwrap().naive_utc(); let expiration_date = DateTime::from_naive_utc_and_offset(timestamp, Utc); Ok(JwtToken { @@ -84,9 +98,19 @@ impl JwtToken { // STUPID: My mock server uses a different encoding than the real server, so we have to // try both encodings here. - Self::decode_token_using_engine(token, general_purpose::STANDARD).or( + log::debug!("Attempting to decode JWT token: {}", token); + + let result = Self::decode_token_using_engine(token, general_purpose::STANDARD).or( Self::decode_token_using_engine(token, general_purpose::URL_SAFE_NO_PAD), - ) + ); + + if let Err(ref e) = result { + log::error!("Failed to decode JWT token: {}", e); + log::error!("Token length: {}", token.len()); + log::error!("Token parts: {:?}", token.split('.').collect::>()); + } + + result } pub fn dummy() -> Self { @@ -96,7 +120,7 @@ impl JwtToken { typ: "JWT".to_string(), }, payload: JwtPayload { - exp: serde_json::Value::Null, + exp: 0, iss: None, user: None, }, diff --git a/kordophoned/Cargo.toml b/kordophoned/Cargo.toml index 7891e35..775a381 100644 --- a/kordophoned/Cargo.toml +++ b/kordophoned/Cargo.toml @@ -13,6 +13,7 @@ dbus-tree = "0.9.2" directories = "6.0.0" env_logger = "0.11.6" futures-util = "0.3.31" +keyring = { version = "3.6.2", features = ["sync-secret-service"] } kordophone = { path = "../kordophone" } kordophone-db = { path = "../kordophone-db" } log = "0.4.25" diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index 01fb1c3..7623714 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -80,12 +80,6 @@ - - - - - diff --git a/kordophoned/src/daemon/auth_store.rs b/kordophoned/src/daemon/auth_store.rs index 5956b66..ff58749 100644 --- a/kordophoned/src/daemon/auth_store.rs +++ b/kordophoned/src/daemon/auth_store.rs @@ -2,6 +2,7 @@ use crate::daemon::SettingsKey; use std::sync::Arc; use tokio::sync::Mutex; +use keyring::{Entry, Result}; use kordophone::api::{AuthenticationStore, http_client::Credentials}; use kordophone::model::JwtToken; @@ -22,6 +23,8 @@ impl DatabaseAuthenticationStore { #[async_trait] impl AuthenticationStore for DatabaseAuthenticationStore { async fn get_credentials(&mut self) -> Option { + use keyring::secret_service::SsCredential; + self.database.lock().await.with_settings(|settings| { let username: Option = settings.get::(SettingsKey::USERNAME) .unwrap_or_else(|e| { @@ -29,31 +32,39 @@ impl AuthenticationStore for DatabaseAuthenticationStore { None }); - // TODO: This would be the point where we map from credential item to password. - let password: String = settings.get::(SettingsKey::CREDENTIAL_ITEM) - .unwrap_or_else(|e| { - log::warn!("error getting password from database: {}", e); - None - }) - .unwrap_or_else(|| { - log::warn!("warning: no password in database, [DEBUG] using default password"); - "test".to_string() - }); + match username { + Some(username) => { + let credential = SsCredential::new_with_target(None, "net.buzzert.kordophonecd", &username).unwrap(); - if username.is_none() { - log::warn!("Username not present in database"); - } + let password: Result = Entry::new_with_credential(Box::new(credential)) + .get_password(); - match (username, password) { - (Some(username), password) => Some(Credentials { username, password }), - _ => None, + log::debug!("password: {:?}", password); + + match password { + Ok(password) => Some(Credentials { username, password }), + Err(e) => { + log::error!("error getting password from keyring: {}", e); + None + } + } + } + None => None, } }).await } async fn get_token(&mut self) -> Option { self.database.lock().await - .with_settings(|settings| settings.get::(SettingsKey::TOKEN).unwrap_or_default()).await + .with_settings(|settings| { + match settings.get::(SettingsKey::TOKEN) { + Ok(token) => token, + Err(e) => { + log::warn!("Failed to get token from settings: {}", e); + None + } + } + }).await } async fn set_token(&mut self, token: JwtToken) { diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 399df4d..4a6e656 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -51,6 +51,7 @@ pub mod target { pub static SETTINGS: &str = "settings"; pub static UPDATES: &str = "updates"; } + pub struct Daemon { pub event_sender: Sender, event_receiver: Receiver, diff --git a/kordophoned/src/daemon/settings.rs b/kordophoned/src/daemon/settings.rs index 18e9482..4d78d93 100644 --- a/kordophoned/src/daemon/settings.rs +++ b/kordophoned/src/daemon/settings.rs @@ -4,7 +4,6 @@ use anyhow::Result; pub mod keys { pub static SERVER_URL: &str = "ServerURL"; pub static USERNAME: &str = "Username"; - pub static CREDENTIAL_ITEM: &str = "CredentialItem"; pub static TOKEN: &str = "Token"; } @@ -13,7 +12,6 @@ pub mod keys { pub struct Settings { pub server_url: Option, pub username: Option, - pub credential_item: Option, pub token: Option, } @@ -21,12 +19,10 @@ impl Settings { pub fn from_db(db_settings: &mut DbSettings) -> Result { let server_url: Option = db_settings.get(keys::SERVER_URL)?; let username: Option = db_settings.get(keys::USERNAME)?; - let credential_item: Option = db_settings.get(keys::CREDENTIAL_ITEM)?; let token: Option = db_settings.get(keys::TOKEN)?; Ok(Self { server_url, username, - credential_item, token, }) } @@ -38,9 +34,6 @@ impl Settings { if let Some(username) = &self.username { db_settings.put(keys::USERNAME, &username)?; } - if let Some(credential_item) = &self.credential_item { - db_settings.put(keys::CREDENTIAL_ITEM, &credential_item)?; - } if let Some(token) = &self.token { db_settings.put(keys::TOKEN, &token)?; } diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 2dc8a38..2abc10d 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -115,7 +115,6 @@ impl DbusSettings for ServerImpl { Event::UpdateSettings(Settings { server_url: Some(url), username: Some(user), - credential_item: None, token: None, }, r) ) @@ -131,7 +130,6 @@ impl DbusSettings for ServerImpl { Event::UpdateSettings(Settings { server_url: Some(value), username: None, - credential_item: None, token: None, }, r) ) @@ -147,28 +145,10 @@ impl DbusSettings for ServerImpl { Event::UpdateSettings(Settings { server_url: None, username: Some(value), - credential_item: None, token: None, }, r) ) } - - fn credential_item(&self) -> Result, dbus::MethodErr> { - self.send_event_sync(Event::GetAllSettings) - .map(|settings| settings.credential_item.unwrap_or_default()).map(|item| dbus::Path::new(item).unwrap_or_default()) - } - - fn set_credential_item(&self, value: dbus::Path<'static>) -> Result<(), dbus::MethodErr> { - self.send_event_sync(|r| - Event::UpdateSettings(Settings { - server_url: None, - username: None, - credential_item: Some(value.to_string()), - token: None, - }, r) - ) - } - } fn run_sync_future(f: F) -> Result diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index 26bafd9..9dc9af6 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -70,11 +70,6 @@ pub enum ConfigCommands { SetUsername { username: String, }, - - /// Sets the credential item. - SetCredentialItem { - item: String, - }, } impl Commands { @@ -180,19 +175,16 @@ impl DaemonCli { ConfigCommands::Print => self.print_settings().await, ConfigCommands::SetServerUrl { url } => self.set_server_url(url).await, ConfigCommands::SetUsername { username } => self.set_username(username).await, - ConfigCommands::SetCredentialItem { item } => self.set_credential_item(item).await, } } pub async fn print_settings(&mut self) -> Result<()> { let server_url = KordophoneSettings::server_url(&self.proxy()).unwrap_or_default(); let username = KordophoneSettings::username(&self.proxy()).unwrap_or_default(); - let credential_item = KordophoneSettings::credential_item(&self.proxy()).unwrap_or_default(); let table = table!( [ b->"Server URL", &server_url ], - [ b->"Username", &username ], - [ b->"Credential Item", &credential_item ] + [ b->"Username", &username ] ); table.printstd(); @@ -209,11 +201,6 @@ impl DaemonCli { .map_err(|e| anyhow::anyhow!("Failed to set username: {}", e)) } - pub async fn set_credential_item(&mut self, item: String) -> Result<()> { - KordophoneSettings::set_credential_item(&self.proxy(), item.into()) - .map_err(|e| anyhow::anyhow!("Failed to set credential item: {}", e)) - } - pub async fn delete_all_conversations(&mut self) -> Result<()> { KordophoneRepository::delete_all_conversations(&self.proxy()) .map_err(|e| anyhow::anyhow!("Failed to delete all conversations: {}", e)) diff --git a/kpcli/src/db/mod.rs b/kpcli/src/db/mod.rs index ecd5ce8..15b58ea 100644 --- a/kpcli/src/db/mod.rs +++ b/kpcli/src/db/mod.rs @@ -93,15 +93,17 @@ struct DbClient { impl DbClient { fn database_path() -> PathBuf { - let temp_dir = env::temp_dir(); - temp_dir.join("kpcli_chat.db") + env::var("KORDOPHONE_DB_PATH").unwrap_or_else(|_| { + let temp_dir = env::temp_dir(); + temp_dir.join("kpcli_chat.db").to_str().unwrap().to_string() + }).into() } pub fn new() -> Result { let path = Self::database_path(); let path_str: &str = path.as_path().to_str().unwrap(); - println!("kpcli: Using temporary db at {}", path_str); + println!("kpcli: Using db at {}", path_str); let db = Database::new(path_str)?; Ok( Self { database: db }) From 0d61b6f2d7209b431e1ce35b5128e850a92c9ae6 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 3 May 2025 18:19:48 -0700 Subject: [PATCH 058/138] daemon: adds conversation list limit, fixes auth saving in db auth store --- kordophone-db/src/repository.rs | 5 +++- kordophone/src/api/auth.rs | 12 +++++----- kordophone/src/api/http_client.rs | 24 ++++++++++++------- kordophone/src/model/jwt.rs | 4 ++++ .../net.buzzert.kordophonecd.Server.xml | 3 +++ kordophoned/src/daemon/auth_store.rs | 6 ++--- kordophoned/src/daemon/events.rs | 5 +++- kordophoned/src/daemon/mod.rs | 10 +++++--- kordophoned/src/daemon/settings.rs | 17 +++++++++---- kordophoned/src/daemon/update_monitor.rs | 12 ++++++---- kordophoned/src/dbus/server_impl.rs | 4 ++-- kpcli/src/daemon/mod.rs | 2 +- kpcli/src/db/mod.rs | 2 +- 13 files changed, 69 insertions(+), 37 deletions(-) diff --git a/kordophone-db/src/repository.rs b/kordophone-db/src/repository.rs index dee4497..d630810 100644 --- a/kordophone-db/src/repository.rs +++ b/kordophone-db/src/repository.rs @@ -84,11 +84,14 @@ impl<'a> Repository<'a> { Ok(None) } - pub fn all_conversations(&mut self) -> Result> { + pub fn all_conversations(&mut self, limit: i32, offset: i32) -> Result> { use crate::schema::conversations::dsl::*; use crate::schema::participants::dsl::*; let db_conversations = conversations + .order(schema::conversations::date.desc()) + .offset(offset as i64) + .limit(limit as i64) .load::(self.connection)?; let mut result = Vec::new(); diff --git a/kordophone/src/api/auth.rs b/kordophone/src/api/auth.rs index d192c25..4d8f65c 100644 --- a/kordophone/src/api/auth.rs +++ b/kordophone/src/api/auth.rs @@ -5,8 +5,8 @@ use async_trait::async_trait; #[async_trait] pub trait AuthenticationStore { async fn get_credentials(&mut self) -> Option; - async fn get_token(&mut self) -> Option; - async fn set_token(&mut self, token: JwtToken); + async fn get_token(&mut self) -> Option; + async fn set_token(&mut self, token: String); } pub struct InMemoryAuthenticationStore { @@ -35,11 +35,11 @@ impl AuthenticationStore for InMemoryAuthenticationStore { self.credentials.clone() } - async fn get_token(&mut self) -> Option { - self.token.clone() + async fn get_token(&mut self) -> Option { + self.token.clone().map(|token| token.to_string()) } - async fn set_token(&mut self, token: JwtToken) { - self.token = Some(token); + async fn set_token(&mut self, token: String) { + self.token = Some(JwtToken::new(&token).unwrap()); } } diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index bfdf0ec..c89b74f 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -92,6 +92,7 @@ impl From for Error { trait AuthBuilder { fn with_auth(self, token: &Option) -> Self; + fn with_auth_string(self, token: &Option) -> Self; } impl AuthBuilder for hyper::http::request::Builder { @@ -100,6 +101,12 @@ impl AuthBuilder for hyper::http::request::Builder { self.header("Authorization", token.to_header_value()) } else { self } } + + fn with_auth_string(self, token: &Option) -> Self { + if let Some(token) = &token { + self.header("Authorization", format!("Bearer: {}", token)) + } else { self } + } } #[cfg(test)] @@ -196,7 +203,7 @@ impl APIInterface for HTTPAPIClient { let token = JwtToken::new(&token.jwt).map_err(|e| Error::DecodeError(e.to_string()))?; log::debug!("Saving token: {:?}", token); - self.auth_store.set_token(token.clone()).await; + self.auth_store.set_token(token.to_string()).await; Ok(token) } @@ -261,8 +268,7 @@ impl APIInterface for HTTPAPIClient { match &auth { Some(token) => { - let header_value = token.to_header_value().to_str().unwrap().parse().unwrap(); // ugh - request.headers_mut().insert("Authorization", header_value); + request.headers_mut().insert("Authorization", format!("Bearer: {}", token).parse().unwrap()); } None => { log::warn!(target: "websocket", "Proceeding without auth token."); @@ -276,14 +282,14 @@ impl APIInterface for HTTPAPIClient { log::debug!("Websocket connected: {:?}", response.status()); Ok(WebsocketEventSocket::new(socket)) } - Err(e) => match e { + Err(e) => match &e { Error::ClientError(ce) => match ce.as_str() { "HTTP error: 401 Unauthorized" | "Unauthorized" => { // Try to authenticate if let Some(credentials) = &self.auth_store.get_credentials().await { log::warn!("Websocket connection failed, attempting to authenticate"); let new_token = self.authenticate(credentials.clone()).await?; - self.auth_store.set_token(new_token).await; + self.auth_store.set_token(new_token.to_string()).await; // try again on the next attempt. return Err(Error::Unauthorized); @@ -292,7 +298,7 @@ impl APIInterface for HTTPAPIClient { return Err(Error::ClientError("Unauthorized, no credentials provided".into())); } } - _ => Err(Error::Unauthorized) + _ => Err(e) } _ => Err(e) @@ -355,12 +361,12 @@ impl HTTPAPIClient { let uri = self.uri_for_endpoint(endpoint, None); log::debug!("Requesting {:?} {:?}", method, uri); - let build_request = move |auth: &Option| { + let build_request = move |auth: &Option| { let body = body_fn(); Request::builder() .method(&method) .uri(&uri) - .with_auth(auth) + .with_auth_string(auth) .body(body) .expect("Unable to build request") }; @@ -384,7 +390,7 @@ impl HTTPAPIClient { log::debug!("Renewing token using credentials: u: {:?}", credentials.username); let new_token = self.authenticate(credentials.clone()).await?; - let request = build_request(&Some(new_token)); + let request = build_request(&Some(new_token.to_string())); response = self.client.request(request).await?; } else { return Err(Error::ClientError("Unauthorized, no credentials provided".into())); diff --git a/kordophone/src/model/jwt.rs b/kordophone/src/model/jwt.rs index 9521dac..f458b5f 100644 --- a/kordophone/src/model/jwt.rs +++ b/kordophone/src/model/jwt.rs @@ -137,4 +137,8 @@ impl JwtToken { pub fn to_header_value(&self) -> HeaderValue { format!("Bearer {}", self.token).parse().unwrap() } + + pub fn to_string(&self) -> String { + self.token.clone() + } } diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index 7623714..8198155 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -11,6 +11,9 @@ + + + ), /// Returns all known conversations from the database. - GetAllConversations(Reply>), + /// Parameters: + /// - limit: The maximum number of conversations to return. (-1 for no limit) + /// - offset: The offset into the conversation list to start returning conversations from. + GetAllConversations(i32, i32, Reply>), /// Returns all known settings from the database. GetAllSettings(Reply), diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 4a6e656..5bd4e75 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -175,8 +175,8 @@ impl Daemon { reply.send(()).unwrap(); }, - Event::GetAllConversations(reply) => { - let conversations = self.get_conversations().await; + Event::GetAllConversations(limit, offset, reply) => { + let conversations = self.get_conversations_limit_offset(limit, offset).await; reply.send(conversations).unwrap(); }, @@ -226,7 +226,11 @@ impl Daemon { } async fn get_conversations(&mut self) -> Vec { - self.database.lock().await.with_repository(|r| r.all_conversations().unwrap()).await + self.database.lock().await.with_repository(|r| r.all_conversations(i32::MAX, 0).unwrap()).await + } + + async fn get_conversations_limit_offset(&mut self, limit: i32, offset: i32) -> Vec { + self.database.lock().await.with_repository(|r| r.all_conversations(limit, offset).unwrap()).await } async fn get_messages(&mut self, conversation_id: String, last_message_id: Option) -> Vec { diff --git a/kordophoned/src/daemon/settings.rs b/kordophoned/src/daemon/settings.rs index 4d78d93..265c988 100644 --- a/kordophoned/src/daemon/settings.rs +++ b/kordophoned/src/daemon/settings.rs @@ -17,14 +17,21 @@ pub struct Settings { impl Settings { pub fn from_db(db_settings: &mut DbSettings) -> Result { - let server_url: Option = db_settings.get(keys::SERVER_URL)?; - let username: Option = db_settings.get(keys::USERNAME)?; - let token: Option = db_settings.get(keys::TOKEN)?; - Ok(Self { + let server_url = db_settings.get(keys::SERVER_URL)?; + let username = db_settings.get(keys::USERNAME)?; + let token = db_settings.get(keys::TOKEN)?; + + // Create the settings struct with the results + let settings = Self { server_url, username, token, - }) + }; + + // Load bearing + log::debug!("Loaded settings: {:?}", settings); + + Ok(settings) } pub fn save(&self, db_settings: &mut DbSettings) -> Result<()> { diff --git a/kordophoned/src/daemon/update_monitor.rs b/kordophoned/src/daemon/update_monitor.rs index 06edec7..ed65766 100644 --- a/kordophoned/src/daemon/update_monitor.rs +++ b/kordophoned/src/daemon/update_monitor.rs @@ -42,11 +42,13 @@ impl UpdateMonitor { match update { UpdateEvent::ConversationChanged(conversation) => { log::info!(target: target::UPDATES, "Conversation changed: {:?}", conversation); - log::info!(target: target::UPDATES, "Syncing new messages for conversation id: {}", conversation.guid); - self.send_event(|r| Event::SyncConversation(conversation.guid, r)).await - .unwrap_or_else(|e| { - log::error!("Failed to send daemon event: {}", e); - }); + if conversation.unread_count > 0 { + log::info!(target: target::UPDATES, "Syncing new messages for conversation id: {}", conversation.guid); + self.send_event(|r| Event::SyncConversation(conversation.guid, r)).await + .unwrap_or_else(|e| { + log::error!("Failed to send daemon event: {}", e); + }); + } } UpdateEvent::MessageReceived(conversation, message) => { diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 2abc10d..93b3cb8 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -51,8 +51,8 @@ impl DbusRepository for ServerImpl { self.send_event_sync(Event::GetVersion) } - fn get_conversations(&mut self) -> Result, dbus::MethodErr> { - self.send_event_sync(Event::GetAllConversations) + fn get_conversations(&mut self, limit: i32, offset: i32) -> Result, dbus::MethodErr> { + self.send_event_sync(|r| Event::GetAllConversations(limit, offset, r)) .map(|conversations| { conversations.into_iter().map(|conv| { let mut map = arg::PropMap::new(); diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index 9dc9af6..00c7d88 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -111,7 +111,7 @@ impl DaemonCli { } pub async fn print_conversations(&mut self) -> Result<()> { - let conversations = KordophoneRepository::get_conversations(&self.proxy())?; + let conversations = KordophoneRepository::get_conversations(&self.proxy(), 100, 0)?; println!("Number of conversations: {}", conversations.len()); for conversation in conversations { diff --git a/kpcli/src/db/mod.rs b/kpcli/src/db/mod.rs index 15b58ea..e5dc513 100644 --- a/kpcli/src/db/mod.rs +++ b/kpcli/src/db/mod.rs @@ -111,7 +111,7 @@ impl DbClient { pub async fn print_conversations(&mut self) -> Result<()> { let all_conversations = self.database.with_repository(|repository| { - repository.all_conversations() + repository.all_conversations(i32::MAX, 0) }).await?; println!("{} Conversations: ", all_conversations.len()); From d843127c6d6b0f5f3869cec2bb77e26cf6f38bd8 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 3 May 2025 21:45:53 -0700 Subject: [PATCH 059/138] daemon: maintain outgoing message reference so model is consistent --- kordophone-db/src/models/db/message.rs | 2 +- kordophone-db/src/models/message.rs | 12 +++++ kordophone-db/src/repository.rs | 1 + kordophone-db/src/tests/mod.rs | 4 +- kordophone/src/model/outgoing_message.rs | 5 ++ kordophoned/src/daemon/events.rs | 10 ++++ kordophoned/src/daemon/mod.rs | 58 ++++++++++++++++++-- kordophoned/src/daemon/post_office.rs | 67 ++++++++++++++---------- 8 files changed, 124 insertions(+), 35 deletions(-) diff --git a/kordophone-db/src/models/db/message.rs b/kordophone-db/src/models/db/message.rs index 67c7392..7f94262 100644 --- a/kordophone-db/src/models/db/message.rs +++ b/kordophone-db/src/models/db/message.rs @@ -2,7 +2,7 @@ use diesel::prelude::*; use chrono::NaiveDateTime; use crate::models::{Message, Participant}; -#[derive(Queryable, Selectable, Insertable, AsChangeset, Clone, Identifiable)] +#[derive(Queryable, Selectable, Insertable, AsChangeset, Clone, Identifiable, Debug)] #[diesel(table_name = crate::schema::messages)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct Record { diff --git a/kordophone-db/src/models/message.rs b/kordophone-db/src/models/message.rs index d16f620..076ea52 100644 --- a/kordophone-db/src/models/message.rs +++ b/kordophone-db/src/models/message.rs @@ -1,6 +1,7 @@ use chrono::{DateTime, NaiveDateTime}; use uuid::Uuid; use crate::models::participant::Participant; +use kordophone::model::outgoing_message::OutgoingMessage; #[derive(Clone, Debug)] pub struct Message { @@ -40,6 +41,17 @@ impl From for Message { } } +impl From<&OutgoingMessage> for Message { + fn from(value: &OutgoingMessage) -> Self { + Self { + id: value.guid.to_string(), + sender: Participant::Me, + text: value.text.clone(), + date: value.date, + } + } +} + pub struct MessageBuilder { id: Option, sender: Option, diff --git a/kordophone-db/src/repository.rs b/kordophone-db/src/repository.rs index d630810..e200feb 100644 --- a/kordophone-db/src/repository.rs +++ b/kordophone-db/src/repository.rs @@ -246,6 +246,7 @@ impl<'a> Repository<'a> { fn update_conversation_metadata(&mut self, conversation_guid: &str, last_message: &MessageRecord) -> Result<()> { let conversation = self.get_conversation_by_guid(conversation_guid)?; if let Some(mut conversation) = conversation { + log::debug!("Updating conversation metadata: {} message: {:?}", conversation_guid, last_message); conversation.date = last_message.date; conversation.last_message_preview = Some(last_message.text.clone()); self.insert_conversation(conversation)?; diff --git a/kordophone-db/src/tests/mod.rs b/kordophone-db/src/tests/mod.rs index 1023b9e..c8c64d3 100644 --- a/kordophone-db/src/tests/mod.rs +++ b/kordophone-db/src/tests/mod.rs @@ -54,7 +54,7 @@ async fn test_add_conversation() { repository.insert_conversation(modified_conversation.clone()).unwrap(); // Make sure we still only have one conversation. - let all_conversations = repository.all_conversations().unwrap(); + let all_conversations = repository.all_conversations(i32::MAX, 0).unwrap(); assert_eq!(all_conversations.len(), 1); // And make sure the display name was updated @@ -125,7 +125,7 @@ async fn test_all_conversations_with_participants() { repository.insert_conversation(conversation2).unwrap(); // Get all conversations and verify the results - let all_conversations = repository.all_conversations().unwrap(); + let all_conversations = repository.all_conversations(i32::MAX, 0).unwrap(); assert_eq!(all_conversations.len(), 2); // Find and verify each conversation's participants diff --git a/kordophone/src/model/outgoing_message.rs b/kordophone/src/model/outgoing_message.rs index 530b741..d3a4123 100644 --- a/kordophone/src/model/outgoing_message.rs +++ b/kordophone/src/model/outgoing_message.rs @@ -1,5 +1,6 @@ use serde::Serialize; use super::conversation::ConversationID; +use chrono::NaiveDateTime; use uuid::Uuid; #[derive(Debug, Clone, Serialize)] @@ -7,6 +8,9 @@ pub struct OutgoingMessage { #[serde(skip)] pub guid: Uuid, + #[serde(skip)] + pub date: NaiveDateTime, + #[serde(rename = "body")] pub text: String, @@ -62,6 +66,7 @@ impl OutgoingMessageBuilder { text: self.text.unwrap(), conversation_id: self.conversation_id.unwrap(), file_transfer_guids: self.file_transfer_guids.unwrap_or_default(), + date: chrono::Utc::now().naive_utc(), } } } \ No newline at end of file diff --git a/kordophoned/src/daemon/events.rs b/kordophoned/src/daemon/events.rs index b21107f..2b20d6b 100644 --- a/kordophoned/src/daemon/events.rs +++ b/kordophoned/src/daemon/events.rs @@ -2,6 +2,9 @@ use tokio::sync::oneshot; use uuid::Uuid; use kordophone_db::models::{Conversation, Message}; +use kordophone::model::ConversationID; +use kordophone::model::OutgoingMessage; + use crate::daemon::settings::Settings; pub type Reply = oneshot::Sender; @@ -45,6 +48,13 @@ pub enum Event { /// - reply: The outgoing message ID (not the server-assigned message ID). SendMessage(String, String, Reply), + /// Notifies the daemon that a message has been sent. + /// Parameters: + /// - message: The message that was sent. + /// - outgoing_message: The outgoing message that was sent. + /// - conversation_id: The ID of the conversation that the message was sent to. + MessageSent(Message, OutgoingMessage, ConversationID), + /// Delete all conversations from the database. DeleteAllConversations(Reply<()>), } diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 5bd4e75..42059ba 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -10,11 +10,14 @@ use signals::*; use anyhow::Result; use directories::ProjectDirs; + use std::error::Error; use std::path::PathBuf; +use std::collections::HashMap; +use std::sync::Arc; + use thiserror::Error; use tokio::sync::mpsc::{Sender, Receiver}; -use std::sync::Arc; use tokio::sync::Mutex; use uuid::Uuid; @@ -26,6 +29,7 @@ use kordophone_db::{ use kordophone::api::APIInterface; use kordophone::api::http_client::HTTPAPIClient; use kordophone::model::outgoing_message::OutgoingMessage; +use kordophone::model::ConversationID; mod update_monitor; use update_monitor::UpdateMonitor; @@ -62,6 +66,8 @@ pub struct Daemon { post_office_sink: Sender, post_office_source: Option>, + outgoing_messages: HashMap>, + version: String, database: Arc>, runtime: tokio::runtime::Runtime, @@ -80,6 +86,7 @@ impl Daemon { let (event_sender, event_receiver) = tokio::sync::mpsc::channel(100); let (signal_sender, signal_receiver) = tokio::sync::mpsc::channel(100); let (post_office_sink, post_office_source) = tokio::sync::mpsc::channel(100); + // Create background task runtime let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -97,6 +104,7 @@ impl Daemon { signal_sender, post_office_sink, post_office_source: Some(post_office_source), + outgoing_messages: HashMap::new(), runtime }) } @@ -214,8 +222,31 @@ impl Daemon { }, Event::SendMessage(conversation_id, text, reply) => { - let uuid = self.enqueue_outgoing_message(text, conversation_id).await; + let conversation_id = conversation_id.clone(); + let uuid = self.enqueue_outgoing_message(text, conversation_id.clone()).await; reply.send(uuid).unwrap(); + + // Send message updated signal, we have a placeholder message we will return. + self.signal_sender.send(Signal::MessagesUpdated(conversation_id.clone())).await.unwrap(); + }, + + Event::MessageSent(message, outgoing_message, conversation_id) => { + log::info!(target: target::EVENT, "Daemon: message sent: {}", message.id); + + // Insert the message into the database. + log::debug!(target: target::EVENT, "inserting sent message into database: {}", message.id); + self.database.lock().await + .with_repository(|r| + r.insert_message( &conversation_id, message) + ).await.unwrap(); + + // Remove from outgoing messages. + log::debug!(target: target::EVENT, "Removing message from outgoing messages: {}", outgoing_message.guid); + self.outgoing_messages.get_mut(&conversation_id) + .map(|messages| messages.retain(|m| m.guid != outgoing_message.guid)); + + // Send message updated signal. + self.signal_sender.send(Signal::MessagesUpdated(conversation_id)).await.unwrap(); }, } } @@ -234,15 +265,34 @@ impl Daemon { } async fn get_messages(&mut self, conversation_id: String, last_message_id: Option) -> Vec { - self.database.lock().await.with_repository(|r| r.get_messages_for_conversation(&conversation_id).unwrap()).await + // Get outgoing messages for this conversation. + let empty_vec: Vec = vec![]; + let outgoing_messages: &Vec = self.outgoing_messages.get(&conversation_id) + .unwrap_or(&empty_vec); + + self.database.lock().await + .with_repository(|r| + r.get_messages_for_conversation(&conversation_id) + .unwrap() + .into_iter() + .chain(outgoing_messages.into_iter().map(|m| m.into())) + .collect() + ) + .await } async fn enqueue_outgoing_message(&mut self, text: String, conversation_id: String) -> Uuid { + let conversation_id = conversation_id.clone(); let outgoing_message = OutgoingMessage::builder() .text(text) - .conversation_id(conversation_id) + .conversation_id(conversation_id.clone()) .build(); + // Keep a record of this so we can provide a consistent model to the client. + self.outgoing_messages.entry(conversation_id) + .or_insert(vec![]) + .push(outgoing_message.clone()); + let guid = outgoing_message.guid.clone(); self.post_office_sink.send(PostOfficeEvent::EnqueueOutgoingMessage(outgoing_message)).await.unwrap(); diff --git a/kordophoned/src/daemon/post_office.rs b/kordophoned/src/daemon/post_office.rs index 88ebe30..55d626e 100644 --- a/kordophoned/src/daemon/post_office.rs +++ b/kordophoned/src/daemon/post_office.rs @@ -2,7 +2,7 @@ use std::collections::VecDeque; use std::time::Duration; use tokio::sync::mpsc::{Sender, Receiver}; -use tokio::sync::{Mutex, MutexGuard}; +use tokio::sync::Mutex; use tokio_condvar::Condvar; use crate::daemon::events::Event as DaemonEvent; @@ -49,9 +49,6 @@ impl Result> PostOffice { loop { let mut retry_messages = Vec::new(); - - log::debug!(target: target::POST_OFFICE, "Waiting for event"); - tokio::select! { // Incoming events Some(event) = self.event_source.recv() => { @@ -67,7 +64,14 @@ impl Result> PostOffice { // Message queue mut lock = self.message_available.wait(self.message_queue.lock().await) => { log::debug!(target: target::POST_OFFICE, "Message available in queue"); - retry_messages = Self::try_send_message_impl(&mut lock, &mut self.make_client).await; + + // Get the next message to send, if any + let message = lock.pop_front(); + drop(lock); // Release the lock before sending, we dont want to remain locked while sending. + + if let Some(message) = message { + retry_messages = Self::try_send_message(&mut self.make_client, &self.event_sink, message).await; + } } } @@ -80,33 +84,40 @@ impl Result> PostOffice { } } - async fn try_send_message_impl(message_queue: &mut MutexGuard<'_, VecDeque>, make_client: &mut F) -> Vec { - log::debug!(target: target::POST_OFFICE, "Trying to send enqueued messages"); - + async fn try_send_message( + make_client: &mut F, + event_sink: &Sender, + message: OutgoingMessage + ) -> Vec + { let mut retry_messages = Vec::new(); - while let Some(message) = message_queue.pop_front() { - match (make_client)().await { - Ok(mut client) => { - log::debug!(target: target::POST_OFFICE, "Obtained client, sending message."); - match client.send_message(&message).await { - Ok(message) => { - log::info!(target: target::POST_OFFICE, "Message sent successfully: {}", message.guid); - // TODO: Notify the daemon via the event sink. - } - Err(e) => { - log::error!(target: target::POST_OFFICE, "Error sending message: {:?}", e); - log::warn!(target: target::POST_OFFICE, "Retrying in 5 seconds"); - tokio::time::sleep(Duration::from_secs(5)).await; - retry_messages.push(message); - } + + match (make_client)().await { + Ok(mut client) => { + log::debug!(target: target::POST_OFFICE, "Obtained client, sending message."); + match client.send_message(&message).await { + Ok(sent_message) => { + log::info!(target: target::POST_OFFICE, "Message sent successfully: {}", message.guid); + + let conversation_id = message.conversation_id.clone(); + let event = DaemonEvent::MessageSent(sent_message.into(), message, conversation_id); + event_sink.send(event).await.unwrap(); + } + + Err(e) => { + log::error!(target: target::POST_OFFICE, "Error sending message: {:?}", e); + log::warn!(target: target::POST_OFFICE, "Retrying in 5 seconds"); + tokio::time::sleep(Duration::from_secs(5)).await; + retry_messages.push(message); } } + } - Err(e) => { - log::error!(target: target::POST_OFFICE, "Error creating client: {:?}", e); - log::warn!(target: target::POST_OFFICE, "Retrying in 5 seconds"); - tokio::time::sleep(Duration::from_secs(5)).await; - } + Err(e) => { + log::error!(target: target::POST_OFFICE, "Error creating client: {:?}", e); + log::warn!(target: target::POST_OFFICE, "Retrying in 5 seconds"); + tokio::time::sleep(Duration::from_secs(5)).await; + retry_messages.push(message); } } From 8e87c2bce27fddea8db8382d7022b6e8e95b7084 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 3 May 2025 22:13:03 -0700 Subject: [PATCH 060/138] Less chattier log when syncing --- kordophoned/src/daemon/mod.rs | 4 ++-- kordophoned/src/main.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 42059ba..4cb3bcb 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -354,7 +354,7 @@ impl Daemon { } async fn sync_conversation_impl(database: &mut Arc>, signal_sender: &Sender, conversation_id: String) -> Result<()> { - log::info!(target: target::SYNC, "Starting conversation sync for {}", conversation_id); + log::debug!(target: target::SYNC, "Starting conversation sync for {}", conversation_id); let mut client = Self::get_client_impl(database).await?; @@ -391,7 +391,7 @@ impl Daemon { signal_sender.send(Signal::MessagesUpdated(conversation_id.clone())).await?; } - log::info!(target: target::SYNC, "Synchronized {} messages for conversation {}", num_messages, &conversation_id); + log::debug!(target: target::SYNC, "Synchronized {} messages for conversation {}", num_messages, &conversation_id); Ok(()) } diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index 56d3399..d3806da 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -58,7 +58,7 @@ async fn main() { while let Some(signal) = signal_receiver.recv().await { match signal { Signal::ConversationsUpdated => { - log::info!("Sending signal: ConversationsUpdated"); + log::debug!("Sending signal: ConversationsUpdated"); endpoint.send_signal(interface::OBJECT_PATH, DbusSignals::ConversationsUpdated{}) .unwrap_or_else(|_| { log::error!("Failed to send signal"); @@ -67,7 +67,7 @@ async fn main() { } Signal::MessagesUpdated(conversation_id) => { - log::info!("Sending signal: MessagesUpdated for conversation {}", conversation_id); + log::debug!("Sending signal: MessagesUpdated for conversation {}", conversation_id); endpoint.send_signal(interface::OBJECT_PATH, DbusSignals::MessagesUpdated{ conversation_id }) .unwrap_or_else(|_| { log::error!("Failed to send signal"); From 819b852c1f95461e1ea2e47881ef13e8c9a16ed1 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 4 May 2025 00:15:13 -0700 Subject: [PATCH 061/138] Fixes bug where updates can cause a sync loop --- kordophone-db/src/repository.rs | 16 ++++++------ kordophone/src/model/conversation.rs | 11 ++++++++ kordophoned/src/daemon/update_monitor.rs | 32 +++++++++++++++++++----- 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/kordophone-db/src/repository.rs b/kordophone-db/src/repository.rs index e200feb..eb18fad 100644 --- a/kordophone-db/src/repository.rs +++ b/kordophone-db/src/repository.rs @@ -131,7 +131,7 @@ impl<'a> Repository<'a> { .execute(self.connection)?; // Update conversation date - self.update_conversation_metadata(conversation_guid, &db_message)?; + self.update_conversation_metadata(conversation_guid)?; Ok(()) } @@ -181,7 +181,7 @@ impl<'a> Repository<'a> { .execute(self.connection)?; // Update conversation date - self.update_conversation_metadata(conversation_guid, &db_messages.last().unwrap())?; + self.update_conversation_metadata(conversation_guid)?; Ok(()) } @@ -243,13 +243,15 @@ impl<'a> Repository<'a> { Ok(()) } - fn update_conversation_metadata(&mut self, conversation_guid: &str, last_message: &MessageRecord) -> Result<()> { + fn update_conversation_metadata(&mut self, conversation_guid: &str) -> Result<()> { let conversation = self.get_conversation_by_guid(conversation_guid)?; if let Some(mut conversation) = conversation { - log::debug!("Updating conversation metadata: {} message: {:?}", conversation_guid, last_message); - conversation.date = last_message.date; - conversation.last_message_preview = Some(last_message.text.clone()); - self.insert_conversation(conversation)?; + if let Some(last_message) = self.get_last_message_for_conversation(conversation_guid)? { + log::debug!("Updating conversation metadata: {} message: {:?}", conversation_guid, last_message); + conversation.date = last_message.date; + conversation.last_message_preview = Some(last_message.text.clone()); + self.insert_conversation(conversation)?; + } } Ok(()) diff --git a/kordophone/src/model/conversation.rs b/kordophone/src/model/conversation.rs index f7b74c3..004b362 100644 --- a/kordophone/src/model/conversation.rs +++ b/kordophone/src/model/conversation.rs @@ -3,6 +3,7 @@ use time::OffsetDateTime; use uuid::Uuid; use super::Identifiable; +use crate::model::message::Message; pub type ConversationID = ::ID; @@ -24,6 +25,9 @@ pub struct Conversation { #[serde(rename = "displayName")] pub display_name: Option, + + #[serde(rename = "lastMessage")] + pub last_message: Option, } impl Conversation { @@ -48,6 +52,7 @@ pub struct ConversationBuilder { last_message_preview: Option, participant_display_names: Option>, display_name: Option, + last_message: Option, } impl ConversationBuilder { @@ -85,6 +90,11 @@ impl ConversationBuilder { self } + pub fn last_message(mut self, last_message: Message) -> Self { + self.last_message = Some(last_message); + self + } + pub fn build(self) -> Conversation { Conversation { guid: self.guid.unwrap_or(Uuid::new_v4().to_string()), @@ -93,6 +103,7 @@ impl ConversationBuilder { last_message_preview: self.last_message_preview, participant_display_names: self.participant_display_names.unwrap_or_default(), display_name: self.display_name, + last_message: self.last_message, } } } diff --git a/kordophoned/src/daemon/update_monitor.rs b/kordophoned/src/daemon/update_monitor.rs index ed65766..7632540 100644 --- a/kordophoned/src/daemon/update_monitor.rs +++ b/kordophoned/src/daemon/update_monitor.rs @@ -11,6 +11,7 @@ use kordophone::api::event_socket::EventSocket; use kordophone::model::event::Event as UpdateEvent; use kordophone_db::database::Database; +use kordophone_db::database::DatabaseAccess; use tokio::sync::mpsc::Sender; use std::sync::Arc; @@ -42,13 +43,32 @@ impl UpdateMonitor { match update { UpdateEvent::ConversationChanged(conversation) => { log::info!(target: target::UPDATES, "Conversation changed: {:?}", conversation); - if conversation.unread_count > 0 { - log::info!(target: target::UPDATES, "Syncing new messages for conversation id: {}", conversation.guid); - self.send_event(|r| Event::SyncConversation(conversation.guid, r)).await - .unwrap_or_else(|e| { - log::error!("Failed to send daemon event: {}", e); - }); + + // Weird. We can get in a loop because calling getMessages triggers a conversation changed + // event for some reason. Check to see if the change event says the last message id is the same + // as the last message id in the database. If so, skip the sync. + let last_message = self.database.with_repository(|r| r.get_last_message_for_conversation(&conversation.guid)).await.unwrap_or_default(); + let should_sync_conversation = match (&last_message, &conversation.last_message) { + (Some(message), Some(conversation_message)) => { + if message.id == conversation_message.guid { + false + } else { + true + } + } + _ => true + }; + + if !should_sync_conversation { + log::info!(target: target::UPDATES, "Skipping sync for conversation id: {}. We already have this message.", conversation.guid); + return; } + + log::info!(target: target::UPDATES, "Syncing new messages for conversation id: {}, last message: {:?}", conversation.guid, last_message); + self.send_event(|r| Event::SyncConversation(conversation.guid, r)).await + .unwrap_or_else(|e| { + log::error!("Failed to send daemon event: {}", e); + }); } UpdateEvent::MessageReceived(conversation, message) => { From 4ad9613827f974d38144556a9ff1bc3aa96ff15b Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 12 May 2025 20:46:26 -0700 Subject: [PATCH 062/138] temporary solution for infinite sync: just remember the times --- kordophoned/src/daemon/update_monitor.rs | 41 ++++++++++++++++-------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/kordophoned/src/daemon/update_monitor.rs b/kordophoned/src/daemon/update_monitor.rs index 7632540..1888ad9 100644 --- a/kordophoned/src/daemon/update_monitor.rs +++ b/kordophoned/src/daemon/update_monitor.rs @@ -16,15 +16,22 @@ use kordophone_db::database::DatabaseAccess; use tokio::sync::mpsc::Sender; use std::sync::Arc; use tokio::sync::Mutex; +use std::collections::HashMap; +use std::time::{Duration, Instant}; pub struct UpdateMonitor { database: Arc>, event_sender: Sender, + last_sync_times: HashMap, } impl UpdateMonitor { pub fn new(database: Arc>, event_sender: Sender) -> Self { - Self { database, event_sender } + Self { + database, + event_sender, + last_sync_times: HashMap::new(), + } } pub async fn send_event( @@ -44,27 +51,33 @@ impl UpdateMonitor { UpdateEvent::ConversationChanged(conversation) => { log::info!(target: target::UPDATES, "Conversation changed: {:?}", conversation); - // Weird. We can get in a loop because calling getMessages triggers a conversation changed - // event for some reason. Check to see if the change event says the last message id is the same - // as the last message id in the database. If so, skip the sync. + // Check if we've synced this conversation recently (within 5 seconds) + // This is currently a hack/workaround to prevent an infinite loop of sync events, because for some reason + // imagent will post a conversation changed notification when we call getMessages. + if let Some(last_sync) = self.last_sync_times.get(&conversation.guid) { + if last_sync.elapsed() < Duration::from_secs(5) { + log::info!(target: target::UPDATES, "Skipping sync for conversation id: {}. Last sync was {} seconds ago.", + conversation.guid, last_sync.elapsed().as_secs_f64()); + return; + } + } + + // This is the non-hacky path once we can reason about chat items with associatedMessageGUIDs (e.g., reactions). let last_message = self.database.with_repository(|r| r.get_last_message_for_conversation(&conversation.guid)).await.unwrap_or_default(); - let should_sync_conversation = match (&last_message, &conversation.last_message) { + match (&last_message, &conversation.last_message) { (Some(message), Some(conversation_message)) => { if message.id == conversation_message.guid { - false - } else { - true + log::info!(target: target::UPDATES, "Skipping sync for conversation id: {}. We already have this message.", conversation.guid); + return; } } - _ => true + _ => {} }; - if !should_sync_conversation { - log::info!(target: target::UPDATES, "Skipping sync for conversation id: {}. We already have this message.", conversation.guid); - return; - } + // Update the last sync time and proceed with sync + self.last_sync_times.insert(conversation.guid.clone(), Instant::now()); - log::info!(target: target::UPDATES, "Syncing new messages for conversation id: {}, last message: {:?}", conversation.guid, last_message); + log::info!(target: target::UPDATES, "Syncing new messages for conversation id: {}", conversation.guid); self.send_event(|r| Event::SyncConversation(conversation.guid, r)).await .unwrap_or_else(|e| { log::error!("Failed to send daemon event: {}", e); From 83eb97fd9c7bd37b5d5094554bb21528eed2a8f6 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 14 May 2025 17:39:23 -0700 Subject: [PATCH 063/138] websocket: automatically reconnect if not heard from for a while --- kordophone/src/api/http_client.rs | 35 +++++++++++------ kordophone/src/api/mod.rs | 2 +- kordophone/src/model/event.rs | 16 ++++++-- kordophone/src/tests/test_client.rs | 2 +- kordophoned/src/daemon/update_monitor.rs | 48 +++++++++++++++++++++--- 5 files changed, 80 insertions(+), 23 deletions(-) diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index c89b74f..7b7eaa9 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -12,7 +12,7 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize}; use tokio::net::TcpStream; -use futures_util::{StreamExt, TryStreamExt}; +use futures_util::{SinkExt, StreamExt, TryStreamExt}; use futures_util::stream::{SplitStream, SplitSink, Stream}; use futures_util::stream::BoxStream; @@ -124,24 +124,21 @@ impl AuthSetting for hyper::http::Request { } } -type WebsocketSink = SplitSink>, tungstenite::Message>; -type WebsocketStream = SplitStream>>; - pub struct WebsocketEventSocket { - _sink: WebsocketSink, - stream: WebsocketStream, + socket: WebSocketStream>, } impl WebsocketEventSocket { pub fn new(socket: WebSocketStream>) -> Self { - let (sink, stream) = socket.split(); - Self { _sink: sink, stream } + Self { socket } } } impl WebsocketEventSocket { fn raw_update_stream(self) -> impl Stream, Error>> { - self.stream + let (_, stream) = self.socket.split(); + + stream .map_err(Error::from) .try_filter_map(|msg| async move { match msg { @@ -150,6 +147,15 @@ impl WebsocketEventSocket { .map(Some) .map_err(Error::from) } + tungstenite::Message::Ping(_) => { + // Borrowing issue here with the sink, need to handle pings at the client level (whomever + // is consuming these updateitems, should be a union type of updateitem | ping). + Ok(None) + } + tungstenite::Message::Close(_) => { + // Connection was closed cleanly + Err(Error::ClientError("WebSocket connection closed".into())) + } _ => Ok(None) } }) @@ -246,11 +252,16 @@ impl APIInterface for HTTPAPIClient { Ok(message) } - async fn open_event_socket(&mut self) -> Result { + async fn open_event_socket(&mut self, update_seq: Option) -> Result { use tungstenite::handshake::client::Request as TungsteniteRequest; use tungstenite::handshake::client::generate_key; - let uri = self.uri_for_endpoint("updates", Some(self.websocket_scheme())); + let endpoint = match update_seq { + Some(seq) => format!("updates?seq={}", seq), + None => "updates".to_string(), + }; + + let uri = self.uri_for_endpoint(&endpoint, Some(self.websocket_scheme())); log::debug!("Connecting to websocket: {:?}", uri); @@ -501,7 +512,7 @@ mod test { let mut client = local_mock_client(); // We just want to see if the connection is established, we won't wait for any events - let _ = client.open_event_socket().await.unwrap(); + let _ = client.open_event_socket(None).await.unwrap(); assert!(true); } } diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index d711948..e7fa0ae 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -46,5 +46,5 @@ pub trait APIInterface { async fn authenticate(&mut self, credentials: Credentials) -> Result; // (WS) /updates - async fn open_event_socket(&mut self) -> Result; + async fn open_event_socket(&mut self, update_seq: Option) -> Result; } diff --git a/kordophone/src/model/event.rs b/kordophone/src/model/event.rs index aca7cb1..f44e4c7 100644 --- a/kordophone/src/model/event.rs +++ b/kordophone/src/model/event.rs @@ -1,7 +1,13 @@ use crate::model::{Conversation, Message, UpdateItem}; #[derive(Debug, Clone)] -pub enum Event { +pub struct Event { + pub data: EventData, + pub update_seq: u64, +} + +#[derive(Debug, Clone)] +pub enum EventData { ConversationChanged(Conversation), MessageReceived(Conversation, Message), } @@ -9,8 +15,12 @@ pub enum Event { impl From for Event { fn from(update: UpdateItem) -> Self { match update { - UpdateItem { conversation: Some(conversation), message: None, .. } => Event::ConversationChanged(conversation), - UpdateItem { conversation: Some(conversation), message: Some(message), .. } => Event::MessageReceived(conversation, message), + UpdateItem { conversation: Some(conversation), message: None, .. } + => Event { data: EventData::ConversationChanged(conversation), update_seq: update.seq }, + + UpdateItem { conversation: Some(conversation), message: Some(message), .. } + => Event { data: EventData::MessageReceived(conversation, message), update_seq: update.seq }, + _ => panic!("Invalid update item: {:?}", update), } } diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index 4edd627..4d945ca 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -105,7 +105,7 @@ impl APIInterface for TestClient { Ok(message) } - async fn open_event_socket(&mut self) -> Result { + async fn open_event_socket(&mut self, _update_seq: Option) -> Result { Ok(TestEventSocket::new()) } } diff --git a/kordophoned/src/daemon/update_monitor.rs b/kordophoned/src/daemon/update_monitor.rs index 1888ad9..8447619 100644 --- a/kordophoned/src/daemon/update_monitor.rs +++ b/kordophoned/src/daemon/update_monitor.rs @@ -9,6 +9,7 @@ use crate::daemon::{ use kordophone::APIInterface; use kordophone::api::event_socket::EventSocket; use kordophone::model::event::Event as UpdateEvent; +use kordophone::model::event::EventData as UpdateEventData; use kordophone_db::database::Database; use kordophone_db::database::DatabaseAccess; @@ -23,6 +24,7 @@ pub struct UpdateMonitor { database: Arc>, event_sender: Sender, last_sync_times: HashMap, + update_seq: Option, } impl UpdateMonitor { @@ -31,6 +33,7 @@ impl UpdateMonitor { database, event_sender, last_sync_times: HashMap::new(), + update_seq: None, } } @@ -47,8 +50,10 @@ impl UpdateMonitor { } async fn handle_update(&mut self, update: UpdateEvent) { - match update { - UpdateEvent::ConversationChanged(conversation) => { + self.update_seq = Some(update.update_seq); + + match update.data { + UpdateEventData::ConversationChanged(conversation) => { log::info!(target: target::UPDATES, "Conversation changed: {:?}", conversation); // Check if we've synced this conversation recently (within 5 seconds) @@ -84,7 +89,7 @@ impl UpdateMonitor { }); } - UpdateEvent::MessageReceived(conversation, message) => { + UpdateEventData::MessageReceived(conversation, message) => { log::info!(target: target::UPDATES, "Message received: msgid:{:?}, convid:{:?}", message.guid, conversation.guid); log::info!(target: target::UPDATES, "Triggering message sync for conversation id: {}", conversation.guid); self.send_event(|r| Event::SyncConversation(conversation.guid, r)).await @@ -113,7 +118,7 @@ impl UpdateMonitor { }; log::debug!(target: target::UPDATES, "Opening event socket"); - let socket = match client.open_event_socket().await { + let socket = match client.open_event_socket(self.update_seq).await { Ok(events) => events, Err(e) => { log::warn!("Failed to open event socket: {}", e); @@ -125,9 +130,40 @@ impl UpdateMonitor { log::debug!(target: target::UPDATES, "Starting event stream"); let mut event_stream = socket.events().await; - while let Some(Ok(event)) = event_stream.next().await { - self.handle_update(event).await; + + // We won't know if the websocket is dead until we try to send a message, so time out waiting for + // a message every 30 seconds. + let mut timeout = tokio::time::interval(Duration::from_secs(30)); + timeout.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + // First tick will happen immediately + timeout.tick().await; + + loop { + tokio::select! { + Some(result) = event_stream.next() => { + match result { + Ok(event) => { + self.handle_update(event).await; + + // Reset the timeout since we got a message + timeout.reset(); + } + Err(e) => { + log::error!("Error in event stream: {}", e); + break; // Break inner loop to reconnect + } + } + } + _ = timeout.tick() => { + log::warn!("No messages received for 30 seconds, reconnecting..."); + break; // Break inner loop to reconnect + } + } } + + // Add a small delay before reconnecting to avoid tight reconnection loops + tokio::time::sleep(Duration::from_secs(1)).await; } } } \ No newline at end of file From 77177e07aa1b2a39fdd4525c5b487f89f0ae5526 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 14 May 2025 17:43:28 -0700 Subject: [PATCH 064/138] kpcli: fix for update data structure changes --- kpcli/src/client/mod.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/kpcli/src/client/mod.rs b/kpcli/src/client/mod.rs index 9356d33..7438868 100644 --- a/kpcli/src/client/mod.rs +++ b/kpcli/src/client/mod.rs @@ -7,7 +7,7 @@ use kordophone::api::event_socket::EventSocket; use anyhow::Result; use clap::Subcommand; use crate::printers::{ConversationPrinter, MessagePrinter}; -use kordophone::model::event::Event; +use kordophone::model::event::EventData; use kordophone::model::outgoing_message::OutgoingMessage; use futures_util::StreamExt; @@ -104,15 +104,15 @@ impl ClientCli { } pub async fn print_events(&mut self) -> Result<()> { - let socket = self.api.open_event_socket().await?; + let socket = self.api.open_event_socket(None).await?; let mut stream = socket.events().await; while let Some(Ok(event)) = stream.next().await { - match event { - Event::ConversationChanged(conversation) => { + match event.data { + EventData::ConversationChanged(conversation) => { println!("Conversation changed: {}", conversation.guid); } - Event::MessageReceived(conversation, message) => { + EventData::MessageReceived(conversation, message) => { println!("Message received: msg: {} conversation: {}", message.guid, conversation.guid); } } @@ -121,7 +121,7 @@ impl ClientCli { } pub async fn print_raw_updates(&mut self) -> Result<()> { - let socket = self.api.open_event_socket().await?; + let socket = self.api.open_event_socket(None).await?; println!("Listening for raw updates..."); let mut stream = socket.raw_updates().await; From 0d4c2e51046702eb9b2fbc99f0d11abf5bf1228c Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 15 May 2025 20:11:10 -0700 Subject: [PATCH 065/138] Started working on attachment store --- Cargo.lock | 1 + kordophone/Cargo.toml | 1 + kordophone/src/api/http_client.rs | 252 ++++++++++----- kordophone/src/api/mod.rs | 28 +- kordophone/src/tests/test_client.rs | 43 ++- .../net.buzzert.kordophonecd.Server.xml | 42 ++- kordophoned/src/daemon/attachment_store.rs | 104 ++++++ kordophoned/src/daemon/auth_store.rs | 77 +++-- kordophoned/src/daemon/mod.rs | 302 ++++++++++++------ kordophoned/src/dbus/server_impl.rs | 178 ++++++++--- 10 files changed, 721 insertions(+), 307 deletions(-) create mode 100644 kordophoned/src/daemon/attachment_store.rs diff --git a/Cargo.lock b/Cargo.lock index 0172a63..2e4b394 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1005,6 +1005,7 @@ version = "0.1.0" dependencies = [ "async-trait", "base64", + "bytes", "chrono", "ctor", "env_logger", diff --git a/kordophone/Cargo.toml b/kordophone/Cargo.toml index 9a64c68..131ca83 100644 --- a/kordophone/Cargo.toml +++ b/kordophone/Cargo.toml @@ -8,6 +8,7 @@ edition = "2021" [dependencies] async-trait = "0.1.80" base64 = "0.22.1" +bytes = "1.10.1" chrono = { version = "0.4.38", features = ["serde"] } ctor = "0.2.8" env_logger = "0.11.5" diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index 7b7eaa9..d69318a 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -1,10 +1,12 @@ extern crate hyper; extern crate serde; -use std::{path::PathBuf, str}; +use std::{path::PathBuf, pin::Pin, str, task::Poll}; -use crate::api::AuthenticationStore; use crate::api::event_socket::EventSocket; +use crate::api::AuthenticationStore; +use bytes::Bytes; +use hyper::body::HttpBody; use hyper::{Body, Client, Method, Request, Uri}; use async_trait::async_trait; @@ -12,26 +14,19 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize}; use tokio::net::TcpStream; +use futures_util::stream::{BoxStream, Stream}; +use futures_util::task::Context; use futures_util::{SinkExt, StreamExt, TryStreamExt}; -use futures_util::stream::{SplitStream, SplitSink, Stream}; -use futures_util::stream::BoxStream; use tokio_tungstenite::connect_async; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use crate::{ model::{ - Conversation, - ConversationID, - JwtToken, - Message, - MessageID, - UpdateItem, - Event, - OutgoingMessage, - }, - - APIInterface + Conversation, ConversationID, Event, JwtToken, Message, MessageID, OutgoingMessage, + UpdateItem, + }, + APIInterface, }; type HttpClient = Client; @@ -72,19 +67,19 @@ impl std::fmt::Display for Error { } } -impl From for Error { +impl From for Error { fn from(err: hyper::Error) -> Error { Error::HTTPError(err) } } -impl From for Error { +impl From for Error { fn from(err: serde_json::Error) -> Error { Error::SerdeError(err) } } -impl From for Error { +impl From for Error { fn from(err: tungstenite::Error) -> Error { Error::ClientError(err.to_string()) } @@ -99,13 +94,17 @@ impl AuthBuilder for hyper::http::request::Builder { fn with_auth(self, token: &Option) -> Self { if let Some(token) = &token { self.header("Authorization", token.to_header_value()) - } else { self } + } else { + self + } } fn with_auth_string(self, token: &Option) -> Self { if let Some(token) = &token { self.header("Authorization", format!("Bearer: {}", token)) - } else { self } + } else { + self + } } } @@ -119,7 +118,8 @@ trait AuthSetting { impl AuthSetting for hyper::http::Request { fn authenticate(&mut self, token: &Option) { if let Some(token) = &token { - self.headers_mut().insert("Authorization", token.to_header_value()); + self.headers_mut() + .insert("Authorization", token.to_header_value()); } } } @@ -156,7 +156,7 @@ impl WebsocketEventSocket { // Connection was closed cleanly Err(Error::ClientError("WebSocket connection closed".into())) } - _ => Ok(None) + _ => Ok(None), } }) } @@ -182,17 +182,40 @@ impl EventSocket for WebsocketEventSocket { } } +pub struct ResponseStream { + body: hyper::Body, +} + +impl Stream for ResponseStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.body + .poll_next_unpin(cx) + .map_err(|e| Error::HTTPError(e)) + } +} + +impl From for ResponseStream { + fn from(value: hyper::Body) -> Self { + ResponseStream { body: value } + } +} + #[async_trait] impl APIInterface for HTTPAPIClient { type Error = Error; + type ResponseStream = ResponseStream; async fn get_version(&mut self) -> Result { - let version: String = self.request("version", Method::GET).await?; + let version: String = self.deserialized_response("version", Method::GET).await?; Ok(version) } async fn get_conversations(&mut self) -> Result, Self::Error> { - let conversations: Vec = self.request("conversations", Method::GET).await?; + let conversations: Vec = self + .deserialized_response("conversations", Method::GET) + .await?; Ok(conversations) } @@ -205,7 +228,9 @@ impl APIInterface for HTTPAPIClient { log::debug!("Authenticating with username: {:?}", credentials.username); let body = || -> Body { serde_json::to_string(&credentials).unwrap().into() }; - let token: AuthResponse = self.request_with_body_retry("authenticate", Method::POST, body, false).await?; + let token: AuthResponse = self + .deserialized_response_with_body_retry("authenticate", Method::POST, body, false) + .await?; let token = JwtToken::new(&token.jwt).map_err(|e| Error::DecodeError(e.to_string()))?; log::debug!("Saving token: {:?}", token); @@ -215,46 +240,60 @@ impl APIInterface for HTTPAPIClient { } async fn get_messages( - &mut self, + &mut self, conversation_id: &ConversationID, limit: Option, before: Option, after: Option, ) -> Result, Self::Error> { let mut endpoint = format!("messages?guid={}", conversation_id); - + if let Some(limit_val) = limit { endpoint.push_str(&format!("&limit={}", limit_val)); } - + if let Some(before_id) = before { endpoint.push_str(&format!("&beforeMessageGUID={}", before_id)); } - + if let Some(after_id) = after { endpoint.push_str(&format!("&afterMessageGUID={}", after_id)); } - let messages: Vec = self.request(&endpoint, Method::GET).await?; + let messages: Vec = self.deserialized_response(&endpoint, Method::GET).await?; Ok(messages) } async fn send_message( - &mut self, + &mut self, outgoing_message: &OutgoingMessage, ) -> Result { - let message: Message = self.request_with_body( - "sendMessage", - Method::POST, - || serde_json::to_string(&outgoing_message).unwrap().into() - ).await?; + let message: Message = self + .deserialized_response_with_body("sendMessage", Method::POST, || { + serde_json::to_string(&outgoing_message).unwrap().into() + }) + .await?; Ok(message) } - async fn open_event_socket(&mut self, update_seq: Option) -> Result { - use tungstenite::handshake::client::Request as TungsteniteRequest; + async fn fetch_attachment_data( + &mut self, + guid: &String, + ) -> Result { + let endpoint = format!("attachment?guid={}", guid); + self.response_with_body_retry(&endpoint, Method::GET, Body::empty, true) + .await + .map(hyper::Response::into_body) + .map(ResponseStream::from) + } + + async fn open_event_socket( + &mut self, + update_seq: Option, + ) -> Result { use tungstenite::handshake::client::generate_key; + use tungstenite::handshake::client::Request as TungsteniteRequest; let endpoint = match update_seq { Some(seq) => format!("updates?seq={}", seq), @@ -279,7 +318,10 @@ impl APIInterface for HTTPAPIClient { match &auth { Some(token) => { - request.headers_mut().insert("Authorization", format!("Bearer: {}", token).parse().unwrap()); + request.headers_mut().insert( + "Authorization", + format!("Bearer: {}", token).parse().unwrap(), + ); } None => { log::warn!(target: "websocket", "Proceeding without auth token."); @@ -306,21 +348,23 @@ impl APIInterface for HTTPAPIClient { return Err(Error::Unauthorized); } else { log::error!("Websocket unauthorized, no credentials provided"); - return Err(Error::ClientError("Unauthorized, no credentials provided".into())); + return Err(Error::ClientError( + "Unauthorized, no credentials provided".into(), + )); } } - _ => Err(e) - } + _ => Err(e), + }, - _ => Err(e) - } + _ => Err(e), + }, } } } impl HTTPAPIClient { pub fn new(base_url: Uri, auth_store: K) -> HTTPAPIClient { - HTTPAPIClient { + HTTPAPIClient { base_url, auth_store, client: Client::new(), @@ -348,25 +392,67 @@ impl HTTPAPIClient { } } - async fn request(&mut self, endpoint: &str, method: Method) -> Result { - self.request_with_body(endpoint, method, Body::empty).await + async fn deserialized_response( + &mut self, + endpoint: &str, + method: Method, + ) -> Result { + self.deserialized_response_with_body(endpoint, method, Body::empty) + .await } - async fn request_with_body(&mut self, endpoint: &str, method: Method, body_fn: impl Fn() -> Body) -> Result - where T: DeserializeOwned + async fn deserialized_response_with_body( + &mut self, + endpoint: &str, + method: Method, + body_fn: impl Fn() -> Body, + ) -> Result + where + T: DeserializeOwned, { - self.request_with_body_retry(endpoint, method, body_fn, true).await + self.deserialized_response_with_body_retry(endpoint, method, body_fn, true) + .await } - async fn request_with_body_retry( - &mut self, - endpoint: &str, - method: Method, - body_fn: impl Fn() -> Body, - retry_auth: bool) -> Result - where - T: DeserializeOwned, + async fn deserialized_response_with_body_retry( + &mut self, + endpoint: &str, + method: Method, + body_fn: impl Fn() -> Body, + retry_auth: bool, + ) -> Result + where + T: DeserializeOwned, { + let response = self + .response_with_body_retry(endpoint, method, body_fn, retry_auth) + .await?; + + // Read and parse response body + let body = hyper::body::to_bytes(response.into_body()).await?; + let parsed: T = match serde_json::from_slice(&body) { + Ok(result) => Ok(result), + Err(json_err) => { + log::error!("Error deserializing JSON: {:?}", json_err); + log::error!("Body: {:?}", String::from_utf8_lossy(&body)); + + // If JSON deserialization fails, try to interpret it as plain text + // Unfortunately the server does return things like this... + let s = str::from_utf8(&body).map_err(|e| Error::DecodeError(e.to_string()))?; + serde_plain::from_str(s).map_err(|_| json_err) + } + }?; + + Ok(parsed) + } + + async fn response_with_body_retry( + &mut self, + endpoint: &str, + method: Method, + body_fn: impl Fn() -> Body, + retry_auth: bool, + ) -> Result, Error> { use hyper::StatusCode; let uri = self.uri_for_endpoint(endpoint, None); @@ -389,48 +475,38 @@ impl HTTPAPIClient { log::debug!("-> Response: {:}", response.status()); match response.status() { - StatusCode::OK => { /* cool */ }, + StatusCode::OK => { /* cool */ } - // 401: Unauthorized. Token may have expired or is invalid. Attempt to renew. + // 401: Unauthorized. Token may have expired or is invalid. Attempt to renew. StatusCode::UNAUTHORIZED => { if !retry_auth { return Err(Error::ClientError("Unauthorized".into())); } if let Some(credentials) = &self.auth_store.get_credentials().await { - log::debug!("Renewing token using credentials: u: {:?}", credentials.username); + log::debug!( + "Renewing token using credentials: u: {:?}", + credentials.username + ); let new_token = self.authenticate(credentials.clone()).await?; let request = build_request(&Some(new_token.to_string())); response = self.client.request(request).await?; } else { - return Err(Error::ClientError("Unauthorized, no credentials provided".into())); + return Err(Error::ClientError( + "Unauthorized, no credentials provided".into(), + )); } - }, + } - // Other errors: bubble up. + // Other errors: bubble up. _ => { let message = format!("Request failed ({:})", response.status()); - return Err(Error::ClientError(message)); + return Err(Error::ClientError(message)); } } - // Read and parse response body - let body = hyper::body::to_bytes(response.into_body()).await?; - let parsed: T = match serde_json::from_slice(&body) { - Ok(result) => Ok(result), - Err(json_err) => { - log::error!("Error deserializing JSON: {:?}", json_err); - log::error!("Body: {:?}", String::from_utf8_lossy(&body)); - - // If JSON deserialization fails, try to interpret it as plain text - // Unfortunately the server does return things like this... - let s = str::from_utf8(&body).map_err(|e| Error::DecodeError(e.to_string()))?; - serde_plain::from_str(s).map_err(|_| json_err) - } - }?; - - Ok(parsed) + Ok(response) } } @@ -438,7 +514,7 @@ impl HTTPAPIClient { mod test { use super::*; use crate::api::InMemoryAuthenticationStore; - + #[cfg(test)] fn local_mock_client() -> HTTPAPIClient { let base_url = "http://localhost:5738".parse().unwrap(); @@ -447,7 +523,10 @@ mod test { password: "test".to_string(), }; - HTTPAPIClient::new(base_url, InMemoryAuthenticationStore::new(Some(credentials))) + HTTPAPIClient::new( + base_url, + InMemoryAuthenticationStore::new(Some(credentials)), + ) } #[cfg(test)] @@ -459,7 +538,7 @@ mod test { Ok(_) => true, Err(e) => { log::error!("Mock client error: {:?}", e); - false + false } } } @@ -498,7 +577,10 @@ mod test { let mut client = local_mock_client(); let conversations = client.get_conversations().await.unwrap(); let conversation = conversations.first().unwrap(); - let messages = client.get_messages(&conversation.guid, None, None, None).await.unwrap(); + let messages = client + .get_messages(&conversation.guid, None, None, None) + .await + .unwrap(); assert!(!messages.is_empty()); } diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index e7fa0ae..c25f124 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -1,7 +1,8 @@ +pub use crate::model::{Conversation, ConversationID, Message, MessageID, OutgoingMessage}; + use async_trait::async_trait; -pub use crate::model::{ - Conversation, Message, ConversationID, MessageID, OutgoingMessage, -}; +use bytes::Bytes; +use futures_util::Stream; pub mod auth; pub use crate::api::auth::{AuthenticationStore, InMemoryAuthenticationStore}; @@ -15,21 +16,23 @@ pub mod event_socket; pub use event_socket::EventSocket; use self::http_client::Credentials; -use std::fmt::Debug; +use core::error::Error as StdError; +use std::{fmt::Debug, io::BufRead}; #[async_trait] pub trait APIInterface { type Error: Debug; + type ResponseStream: Stream>; // (GET) /version async fn get_version(&mut self) -> Result; - + // (GET) /conversations async fn get_conversations(&mut self) -> Result, Self::Error>; // (GET) /messages async fn get_messages( - &mut self, + &mut self, conversation_id: &ConversationID, limit: Option, before: Option, @@ -38,13 +41,22 @@ pub trait APIInterface { // (POST) /sendMessage async fn send_message( - &mut self, + &mut self, outgoing_message: &OutgoingMessage, ) -> Result; + // (GET) /attachment + async fn fetch_attachment_data( + &mut self, + guid: &String, + ) -> Result; + // (POST) /authenticate async fn authenticate(&mut self, credentials: Credentials) -> Result; // (WS) /updates - async fn open_event_socket(&mut self, update_seq: Option) -> Result; + async fn open_event_socket( + &mut self, + update_seq: Option, + ) -> Result; } diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index 4d945ca..a9f548e 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -6,13 +6,16 @@ use uuid::Uuid; pub use crate::APIInterface; use crate::{ - api::http_client::Credentials, - model::{Conversation, ConversationID, JwtToken, Message, MessageID, UpdateItem, Event, OutgoingMessage}, api::event_socket::EventSocket, -}; + api::http_client::Credentials, + model::{ + Conversation, ConversationID, Event, JwtToken, Message, MessageID, OutgoingMessage, + UpdateItem, + }, +}; -use futures_util::StreamExt; use futures_util::stream::BoxStream; +use futures_util::StreamExt; pub struct TestClient { pub version: &'static str, @@ -59,7 +62,7 @@ impl EventSocket for TestEventSocket { let results: Vec, TestError>> = vec![]; futures_util::stream::iter(results.into_iter()).boxed() } -} +} #[async_trait] impl APIInterface for TestClient { @@ -78,21 +81,21 @@ impl APIInterface for TestClient { } async fn get_messages( - &mut self, - conversation_id: &ConversationID, - limit: Option, - before: Option, - after: Option + &mut self, + conversation_id: &ConversationID, + limit: Option, + before: Option, + after: Option, ) -> Result, Self::Error> { if let Some(messages) = self.messages.get(conversation_id) { - return Ok(messages.clone()) + return Ok(messages.clone()); } Err(TestError::ConversationNotFound) } async fn send_message( - &mut self, + &mut self, outgoing_message: &OutgoingMessage, ) -> Result { let message = Message::builder() @@ -101,13 +104,21 @@ impl APIInterface for TestClient { .date(OffsetDateTime::now_utc()) .build(); - self.messages.entry(outgoing_message.conversation_id.clone()).or_insert(vec![]).push(message.clone()); + self.messages + .entry(outgoing_message.conversation_id.clone()) + .or_insert(vec![]) + .push(message.clone()); Ok(message) } - async fn open_event_socket(&mut self, _update_seq: Option) -> Result { + async fn open_event_socket( + &mut self, + _update_seq: Option, + ) -> Result { Ok(TestEventSocket::new()) } + + async fn fetch_attachment_data(&mut self, guid: &String) -> Result, Self::Error> { + Ok(vec![]) + } } - - diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index 8198155..b29ed0c 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -4,7 +4,7 @@ - @@ -13,9 +13,9 @@ - + - - - - - - @@ -66,16 +66,34 @@ - - - + + + + + + + + + + + + + + + + + + @@ -90,6 +108,6 @@ - + diff --git a/kordophoned/src/daemon/attachment_store.rs b/kordophoned/src/daemon/attachment_store.rs new file mode 100644 index 0000000..38f8324 --- /dev/null +++ b/kordophoned/src/daemon/attachment_store.rs @@ -0,0 +1,104 @@ +use std::{ + io::{BufReader, BufWriter, Read, Write}, + path::{Path, PathBuf}, +}; + +use anyhow::{Error, Result}; +use futures_util::{poll, StreamExt}; +use kordophone::APIInterface; +use thiserror::Error; +use tokio::pin; + +mod target { + pub static ATTACHMENTS: &str = "attachments"; +} + +#[derive(Debug, Clone)] +pub struct Attachment { + pub guid: String, + pub path: PathBuf, + pub downloaded: bool, +} + +#[derive(Debug, Error)] +enum AttachmentStoreError { + #[error("attachment has already been downloaded")] + AttachmentAlreadyDownloaded, + + #[error("Client error: {0}")] + APIClientError(String), +} + +pub struct AttachmentStore { + store_path: PathBuf, +} + +impl AttachmentStore { + pub fn new(data_dir: &PathBuf) -> AttachmentStore { + let store_path = data_dir.join("attachments"); + log::info!(target: target::ATTACHMENTS, "Attachment store path: {}", store_path.display()); + + // Create the attachment store if it doesn't exist + std::fs::create_dir_all(&store_path) + .expect("Wasn't able to create the attachment store path"); + + AttachmentStore { + store_path: store_path, + } + } + + pub fn get_attachment(&self, guid: &String) -> Attachment { + let path = self.store_path.join(guid); + let path_exists = std::fs::exists(&path).expect( + format!( + "Wasn't able to check for the existence of an attachment file path at {}", + &path.display() + ) + .as_str(), + ); + + Attachment { + guid: guid.to_owned(), + path: path, + downloaded: path_exists, + } + } + + pub async fn download_attachent( + &mut self, + attachment: &Attachment, + mut client_factory: F, + ) -> Result<()> + where + C: APIInterface, + F: AsyncFnMut() -> Result, + { + if attachment.downloaded { + log::error!(target: target::ATTACHMENTS, "Attempted to download existing attachment."); + return Err(AttachmentStoreError::AttachmentAlreadyDownloaded.into()); + } + + // Create temporary file first, we'll atomically swap later. + assert!(!std::fs::exists(&attachment.path).unwrap()); + let file = std::fs::File::create(&attachment.path)?; + let mut writer = BufWriter::new(&file); + + log::trace!(target: target::ATTACHMENTS, "Created attachment file at {}", &attachment.path.display()); + + let mut client = (client_factory)().await?; + let stream = client + .fetch_attachment_data(&attachment.guid) + .await + .map_err(|e| AttachmentStoreError::APIClientError(format!("{:?}", e)))?; + + // Since we're async, we need to pin this. + pin!(stream); + + log::trace!(target: target::ATTACHMENTS, "Writing attachment data to disk"); + while let Some(Ok(data)) = stream.next().await { + writer.write(data.as_ref())?; + } + + Ok(()) + } +} diff --git a/kordophoned/src/daemon/auth_store.rs b/kordophoned/src/daemon/auth_store.rs index c4b3945..283b169 100644 --- a/kordophoned/src/daemon/auth_store.rs +++ b/kordophoned/src/daemon/auth_store.rs @@ -1,10 +1,10 @@ use crate::daemon::SettingsKey; +use keyring::{Entry, Result}; use std::sync::Arc; use tokio::sync::Mutex; -use keyring::{Entry, Result}; -use kordophone::api::{AuthenticationStore, http_client::Credentials}; +use kordophone::api::{http_client::Credentials, AuthenticationStore}; use kordophone::model::JwtToken; use kordophone_db::database::{Database, DatabaseAccess}; @@ -25,52 +25,67 @@ impl AuthenticationStore for DatabaseAuthenticationStore { async fn get_credentials(&mut self) -> Option { use keyring::secret_service::SsCredential; - self.database.lock().await.with_settings(|settings| { - let username: Option = settings.get::(SettingsKey::USERNAME) - .unwrap_or_else(|e| { - log::warn!("error getting username from database: {}", e); - None - }); + self.database + .lock() + .await + .with_settings(|settings| { + let username: Option = settings + .get::(SettingsKey::USERNAME) + .unwrap_or_else(|e| { + log::warn!("error getting username from database: {}", e); + None + }); - match username { - Some(username) => { - let credential = SsCredential::new_with_target(None, "net.buzzert.kordophonecd", &username).unwrap(); + 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 password: Result = + Entry::new_with_credential(Box::new(credential)).get_password(); - log::debug!("password: {:?}", password); - - match password { - Ok(password) => Some(Credentials { username, password }), - Err(e) => { - log::error!("error getting password from keyring: {}", e); - None + match password { + Ok(password) => Some(Credentials { username, password }), + Err(e) => { + log::error!("error getting password from keyring: {}", e); + None + } } } + None => None, } - None => None, - } - }).await + }) + .await } async fn get_token(&mut self) -> Option { - self.database.lock().await - .with_settings(|settings| { - match settings.get::(SettingsKey::TOKEN) { + self.database + .lock() + .await + .with_settings( + |settings| match settings.get::(SettingsKey::TOKEN) { Ok(token) => token, Err(e) => { log::warn!("Failed to get token from settings: {}", e); None } - } - }).await + }, + ) + .await } async fn set_token(&mut self, token: String) { - self.database.lock().await - .with_settings(|settings| settings.put(SettingsKey::TOKEN, &token)).await.unwrap_or_else(|e| { + self.database + .lock() + .await + .with_settings(|settings| settings.put(SettingsKey::TOKEN, &token)) + .await + .unwrap_or_else(|e| { log::error!("Failed to set token: {}", e); }); } -} \ No newline at end of file +} diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 4cb3bcb..ce950b8 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -1,6 +1,6 @@ pub mod settings; -use settings::Settings; use settings::keys as SettingsKey; +use settings::Settings; pub mod events; use events::*; @@ -11,13 +11,13 @@ use signals::*; use anyhow::Result; use directories::ProjectDirs; -use std::error::Error; -use std::path::PathBuf; use std::collections::HashMap; +use std::error::Error; +use std::path::{Path, PathBuf}; use std::sync::Arc; use thiserror::Error; -use tokio::sync::mpsc::{Sender, Receiver}; +use tokio::sync::mpsc::{Receiver, Sender}; use tokio::sync::Mutex; use uuid::Uuid; @@ -26,8 +26,8 @@ use kordophone_db::{ models::{Conversation, Message}, }; -use kordophone::api::APIInterface; use kordophone::api::http_client::HTTPAPIClient; +use kordophone::api::APIInterface; use kordophone::model::outgoing_message::OutgoingMessage; use kordophone::model::ConversationID; @@ -38,8 +38,12 @@ mod auth_store; use auth_store::DatabaseAuthenticationStore; mod post_office; -use post_office::PostOffice; use post_office::Event as PostOfficeEvent; +use post_office::PostOffice; + +mod attachment_store; +pub use attachment_store::Attachment; +use attachment_store::AttachmentStore; #[derive(Debug, Error)] pub enum DaemonError { @@ -49,6 +53,8 @@ pub enum DaemonError { pub type DaemonResult = Result>; +type DaemonClient = HTTPAPIClient; + pub mod target { pub static SYNC: &str = "sync"; pub static EVENT: &str = "event"; @@ -68,6 +74,8 @@ pub struct Daemon { outgoing_messages: HashMap>, + attachment_store: AttachmentStore, + version: String, database: Arc>, runtime: tokio::runtime::Runtime, @@ -87,7 +95,7 @@ impl Daemon { let (signal_sender, signal_receiver) = tokio::sync::mpsc::channel(100); let (post_office_sink, post_office_source) = tokio::sync::mpsc::channel(100); - // Create background task runtime + // Create background task runtime let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() @@ -95,17 +103,22 @@ impl Daemon { let database_impl = Database::new(&database_path.to_string_lossy())?; let database = Arc::new(Mutex::new(database_impl)); - Ok(Self { - version: "0.1.0".to_string(), - database, - event_receiver, - event_sender, + + let data_path = Self::get_data_dir().expect("Unable to get data path"); + let attachment_store = AttachmentStore::new(&data_path); + + Ok(Self { + version: "0.1.0".to_string(), + database, + event_receiver, + event_sender, signal_receiver: Some(signal_receiver), - signal_sender, + signal_sender, post_office_sink, post_office_source: Some(post_office_source), outgoing_messages: HashMap::new(), - runtime + attachment_store: attachment_store, + runtime, }) } @@ -114,7 +127,8 @@ impl Daemon { log::debug!("Debug logging enabled."); // Update monitor - let mut update_monitor = UpdateMonitor::new(self.database.clone(), self.event_sender.clone()); + let mut update_monitor = + UpdateMonitor::new(self.database.clone(), self.event_sender.clone()); tokio::spawn(async move { update_monitor.run().await; // should run indefinitely }); @@ -125,7 +139,10 @@ impl Daemon { let event_sender = self.event_sender.clone(); let post_office_source = self.post_office_source.take().unwrap(); tokio::spawn(async move { - let mut post_office = PostOffice::new(post_office_source, event_sender, async move || Self::get_client_impl(&mut database).await ); + let mut post_office = + PostOffice::new(post_office_source, event_sender, async move || { + Self::get_client_impl(&mut database).await + }); post_office.run().await; }); } @@ -140,7 +157,7 @@ impl Daemon { match event { Event::GetVersion(reply) => { reply.send(self.version.clone()).unwrap(); - }, + } Event::SyncConversationList(reply) => { let mut db_clone = self.database.clone(); @@ -152,132 +169,166 @@ impl Daemon { } }); - // This is a background operation, so return right away. + // This is a background operation, so return right away. reply.send(()).unwrap(); - }, + } Event::SyncAllConversations(reply) => { let mut db_clone = self.database.clone(); let signal_sender = self.signal_sender.clone(); self.runtime.spawn(async move { - let result = Self::sync_all_conversations_impl(&mut db_clone, &signal_sender).await; + let result = + Self::sync_all_conversations_impl(&mut db_clone, &signal_sender).await; if let Err(e) = result { log::error!(target: target::SYNC, "Error handling sync event: {}", e); } }); - // This is a background operation, so return right away. + // This is a background operation, so return right away. reply.send(()).unwrap(); - }, + } Event::SyncConversation(conversation_id, reply) => { let mut db_clone = self.database.clone(); let signal_sender = self.signal_sender.clone(); self.runtime.spawn(async move { - let result = Self::sync_conversation_impl(&mut db_clone, &signal_sender, conversation_id).await; + let result = Self::sync_conversation_impl( + &mut db_clone, + &signal_sender, + conversation_id, + ) + .await; if let Err(e) = result { log::error!(target: target::SYNC, "Error handling sync event: {}", e); } }); reply.send(()).unwrap(); - }, + } Event::GetAllConversations(limit, offset, reply) => { let conversations = self.get_conversations_limit_offset(limit, offset).await; reply.send(conversations).unwrap(); - }, + } Event::GetAllSettings(reply) => { - let settings = self.get_settings().await - .unwrap_or_else(|e| { - log::error!(target: target::SETTINGS, "Failed to get settings: {:#?}", e); - Settings::default() - }); + let settings = self.get_settings().await.unwrap_or_else(|e| { + log::error!(target: target::SETTINGS, "Failed to get settings: {:#?}", e); + Settings::default() + }); reply.send(settings).unwrap(); - }, + } Event::UpdateSettings(settings, reply) => { - self.update_settings(&settings).await - .unwrap_or_else(|e| { - log::error!(target: target::SETTINGS, "Failed to update settings: {}", e); - }); + self.update_settings(&settings).await.unwrap_or_else(|e| { + log::error!(target: target::SETTINGS, "Failed to update settings: {}", e); + }); reply.send(()).unwrap(); - }, + } Event::GetMessages(conversation_id, last_message_id, reply) => { let messages = self.get_messages(conversation_id, last_message_id).await; reply.send(messages).unwrap(); - }, + } Event::DeleteAllConversations(reply) => { - self.delete_all_conversations().await - .unwrap_or_else(|e| { - log::error!(target: target::SYNC, "Failed to delete all conversations: {}", e); - }); + self.delete_all_conversations().await.unwrap_or_else(|e| { + log::error!(target: target::SYNC, "Failed to delete all conversations: {}", e); + }); reply.send(()).unwrap(); - }, + } Event::SendMessage(conversation_id, text, reply) => { let conversation_id = conversation_id.clone(); - let uuid = self.enqueue_outgoing_message(text, conversation_id.clone()).await; + let uuid = self + .enqueue_outgoing_message(text, conversation_id.clone()) + .await; reply.send(uuid).unwrap(); - // Send message updated signal, we have a placeholder message we will return. - self.signal_sender.send(Signal::MessagesUpdated(conversation_id.clone())).await.unwrap(); - }, + // Send message updated signal, we have a placeholder message we will return. + self.signal_sender + .send(Signal::MessagesUpdated(conversation_id.clone())) + .await + .unwrap(); + } Event::MessageSent(message, outgoing_message, conversation_id) => { log::info!(target: target::EVENT, "Daemon: message sent: {}", message.id); - // Insert the message into the database. + // Insert the message into the database. log::debug!(target: target::EVENT, "inserting sent message into database: {}", message.id); - self.database.lock().await - .with_repository(|r| - r.insert_message( &conversation_id, message) - ).await.unwrap(); + self.database + .lock() + .await + .with_repository(|r| r.insert_message(&conversation_id, message)) + .await + .unwrap(); // Remove from outgoing messages. log::debug!(target: target::EVENT, "Removing message from outgoing messages: {}", outgoing_message.guid); - self.outgoing_messages.get_mut(&conversation_id) + self.outgoing_messages + .get_mut(&conversation_id) .map(|messages| messages.retain(|m| m.guid != outgoing_message.guid)); // Send message updated signal. - self.signal_sender.send(Signal::MessagesUpdated(conversation_id)).await.unwrap(); - }, + self.signal_sender + .send(Signal::MessagesUpdated(conversation_id)) + .await + .unwrap(); + } } } - /// Panics if the signal receiver has already been taken. - pub fn obtain_signal_receiver(&mut self) -> Receiver { + /// Panics if the signal receiver has already been taken. + pub fn obtain_signal_receiver(&mut self) -> Receiver { self.signal_receiver.take().unwrap() } async fn get_conversations(&mut self) -> Vec { - self.database.lock().await.with_repository(|r| r.all_conversations(i32::MAX, 0).unwrap()).await + self.database + .lock() + .await + .with_repository(|r| r.all_conversations(i32::MAX, 0).unwrap()) + .await } - async fn get_conversations_limit_offset(&mut self, limit: i32, offset: i32) -> Vec { - self.database.lock().await.with_repository(|r| r.all_conversations(limit, offset).unwrap()).await + async fn get_conversations_limit_offset( + &mut self, + limit: i32, + offset: i32, + ) -> Vec { + self.database + .lock() + .await + .with_repository(|r| r.all_conversations(limit, offset).unwrap()) + .await } - async fn get_messages(&mut self, conversation_id: String, last_message_id: Option) -> Vec { - // Get outgoing messages for this conversation. + async fn get_messages( + &mut self, + conversation_id: String, + last_message_id: Option, + ) -> Vec { + // Get outgoing messages for this conversation. let empty_vec: Vec = vec![]; - let outgoing_messages: &Vec = self.outgoing_messages.get(&conversation_id) + let outgoing_messages: &Vec = self + .outgoing_messages + .get(&conversation_id) .unwrap_or(&empty_vec); - self.database.lock().await - .with_repository(|r| + self.database + .lock() + .await + .with_repository(|r| { r.get_messages_for_conversation(&conversation_id) - .unwrap() - .into_iter() - .chain(outgoing_messages.into_iter().map(|m| m.into())) - .collect() - ) + .unwrap() + .into_iter() + .chain(outgoing_messages.into_iter().map(|m| m.into())) + .collect() + }) .await } @@ -289,31 +340,41 @@ impl Daemon { .build(); // Keep a record of this so we can provide a consistent model to the client. - self.outgoing_messages.entry(conversation_id) + self.outgoing_messages + .entry(conversation_id) .or_insert(vec![]) .push(outgoing_message.clone()); let guid = outgoing_message.guid.clone(); - self.post_office_sink.send(PostOfficeEvent::EnqueueOutgoingMessage(outgoing_message)).await.unwrap(); + self.post_office_sink + .send(PostOfficeEvent::EnqueueOutgoingMessage(outgoing_message)) + .await + .unwrap(); guid } - async fn sync_conversation_list(database: &mut Arc>, signal_sender: &Sender) -> Result<()> { + async fn sync_conversation_list( + database: &mut Arc>, + signal_sender: &Sender, + ) -> Result<()> { log::info!(target: target::SYNC, "Starting list conversation sync"); let mut client = Self::get_client_impl(database).await?; // Fetch conversations from server let fetched_conversations = client.get_conversations().await?; - let db_conversations: Vec = fetched_conversations.into_iter() + let db_conversations: Vec = fetched_conversations + .into_iter() .map(kordophone_db::models::Conversation::from) .collect(); // Insert each conversation let num_conversations = db_conversations.len(); for conversation in db_conversations { - database.with_repository(|r| r.insert_conversation(conversation)).await?; + database + .with_repository(|r| r.insert_conversation(conversation)) + .await?; } // Send conversations updated signal @@ -323,25 +384,31 @@ impl Daemon { Ok(()) } - async fn sync_all_conversations_impl(database: &mut Arc>, signal_sender: &Sender) -> Result<()> { + async fn sync_all_conversations_impl( + database: &mut Arc>, + signal_sender: &Sender, + ) -> Result<()> { log::info!(target: target::SYNC, "Starting full conversation sync"); let mut client = Self::get_client_impl(database).await?; - + // Fetch conversations from server let fetched_conversations = client.get_conversations().await?; - let db_conversations: Vec = fetched_conversations.into_iter() + let db_conversations: Vec = fetched_conversations + .into_iter() .map(kordophone_db::models::Conversation::from) .collect(); - + // Process each conversation let num_conversations = db_conversations.len(); for conversation in db_conversations { let conversation_id = conversation.guid.clone(); - + // Insert the conversation - database.with_repository(|r| r.insert_conversation(conversation)).await?; - + database + .with_repository(|r| r.insert_conversation(conversation)) + .await?; + // Sync individual conversation. Self::sync_conversation_impl(database, signal_sender, conversation_id).await?; } @@ -351,44 +418,59 @@ impl Daemon { log::info!(target: target::SYNC, "Full sync complete, {} conversations processed", num_conversations); Ok(()) - } + } - async fn sync_conversation_impl(database: &mut Arc>, signal_sender: &Sender, conversation_id: String) -> Result<()> { + async fn sync_conversation_impl( + database: &mut Arc>, + signal_sender: &Sender, + conversation_id: String, + ) -> Result<()> { log::debug!(target: target::SYNC, "Starting conversation sync for {}", conversation_id); let mut client = Self::get_client_impl(database).await?; // Check if conversation exists in database. - let conversation = database.with_repository(|r| r.get_conversation_by_guid(&conversation_id)).await?; + let conversation = database + .with_repository(|r| r.get_conversation_by_guid(&conversation_id)) + .await?; if conversation.is_none() { - // If the conversation doesn't exist, first do a conversation list sync. + // If the conversation doesn't exist, first do a conversation list sync. log::warn!(target: target::SYNC, "Conversation {} not found, performing list sync", conversation_id); Self::sync_conversation_list(database, signal_sender).await?; } // Fetch and sync messages for this conversation - let last_message_id = database.with_repository(|r| -> Option { - r.get_last_message_for_conversation(&conversation_id) - .unwrap_or(None) - .map(|m| m.id) - }).await; + let last_message_id = database + .with_repository(|r| -> Option { + r.get_last_message_for_conversation(&conversation_id) + .unwrap_or(None) + .map(|m| m.id) + }) + .await; log::debug!(target: target::SYNC, "Fetching messages for conversation {}", &conversation_id); log::debug!(target: target::SYNC, "Last message id: {:?}", last_message_id); - let messages = client.get_messages(&conversation_id, None, None, last_message_id).await?; - let db_messages: Vec = messages.into_iter() + let messages = client + .get_messages(&conversation_id, None, None, last_message_id) + .await?; + let db_messages: Vec = messages + .into_iter() .map(kordophone_db::models::Message::from) .collect(); // Insert each message let num_messages = db_messages.len(); log::debug!(target: target::SYNC, "Inserting {} messages for conversation {}", num_messages, &conversation_id); - database.with_repository(|r| r.insert_messages(&conversation_id, db_messages)).await?; + database + .with_repository(|r| r.insert_messages(&conversation_id, db_messages)) + .await?; // Send messages updated signal, if we actually inserted any messages. if num_messages > 0 { - signal_sender.send(Signal::MessagesUpdated(conversation_id.clone())).await?; + signal_sender + .send(Signal::MessagesUpdated(conversation_id.clone())) + .await?; } log::debug!(target: target::SYNC, "Synchronized {} messages for conversation {}", num_messages, &conversation_id); @@ -408,35 +490,45 @@ impl Daemon { Self::get_client_impl(&mut self.database).await } - async fn get_client_impl(database: &mut Arc>) -> Result> { + async fn get_client_impl( + database: &mut Arc>, + ) -> Result> { let settings = database.with_settings(Settings::from_db).await?; - let server_url = settings.server_url + let server_url = settings + .server_url .ok_or(DaemonError::ClientNotConfigured)?; let client = HTTPAPIClient::new( server_url.parse().unwrap(), - DatabaseAuthenticationStore::new(database.clone()) + DatabaseAuthenticationStore::new(database.clone()), ); Ok(client) } async fn delete_all_conversations(&mut self) -> Result<()> { - self.database.with_repository(|r| -> Result<()> { - r.delete_all_conversations()?; - r.delete_all_messages()?; - Ok(()) - }).await?; + self.database + .with_repository(|r| -> Result<()> { + r.delete_all_conversations()?; + r.delete_all_messages()?; + Ok(()) + }) + .await?; - self.signal_sender.send(Signal::ConversationsUpdated).await?; + self.signal_sender + .send(Signal::ConversationsUpdated) + .await?; Ok(()) } + fn get_data_dir() -> Option { + ProjectDirs::from("net", "buzzert", "kordophonecd").map(|p| PathBuf::from(p.data_dir())) + } + fn get_database_path() -> PathBuf { - if let Some(proj_dirs) = ProjectDirs::from("net", "buzzert", "kordophonecd") { - let data_dir = proj_dirs.data_dir(); + if let Some(data_dir) = Self::get_data_dir() { data_dir.join("database.db") } else { // Fallback to a local path if we can't get the system directories diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 93b3cb8..955f325 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -1,16 +1,17 @@ use dbus::arg; use dbus_tree::MethodErr; -use tokio::sync::mpsc; use std::future::Future; use std::thread; -use tokio::sync::oneshot; +use tokio::sync::mpsc; +use tokio::sync::oneshot; use crate::daemon::{ - DaemonResult, events::{Event, Reply}, settings::Settings, + Attachment, DaemonResult, }; +use crate::dbus::interface::NetBuzzertKordophoneAttachment as DbusAttachment; use crate::dbus::interface::NetBuzzertKordophoneRepository as DbusRepository; use crate::dbus::interface::NetBuzzertKordophoneSettings as DbusSettings; @@ -29,10 +30,11 @@ impl ServerImpl { make_event: impl FnOnce(Reply) -> Event, ) -> DaemonResult { let (reply_tx, reply_rx) = oneshot::channel(); - self.event_sink.send(make_event(reply_tx)) + self.event_sink + .send(make_event(reply_tx)) .await .map_err(|_| "Failed to send event")?; - + reply_rx.await.map_err(|_| "Failed to receive reply".into()) } @@ -49,21 +51,48 @@ impl ServerImpl { impl DbusRepository for ServerImpl { fn get_version(&mut self) -> Result { self.send_event_sync(Event::GetVersion) - } + } - fn get_conversations(&mut self, limit: i32, offset: i32) -> Result, dbus::MethodErr> { + fn get_conversations( + &mut self, + limit: i32, + offset: i32, + ) -> Result, dbus::MethodErr> { self.send_event_sync(|r| Event::GetAllConversations(limit, offset, r)) .map(|conversations| { - conversations.into_iter().map(|conv| { - let mut map = arg::PropMap::new(); - map.insert("guid".into(), arg::Variant(Box::new(conv.guid))); - map.insert("display_name".into(), arg::Variant(Box::new(conv.display_name.unwrap_or_default()))); - map.insert("unread_count".into(), arg::Variant(Box::new(conv.unread_count as i32))); - map.insert("last_message_preview".into(), arg::Variant(Box::new(conv.last_message_preview.unwrap_or_default()))); - map.insert("participants".into(), arg::Variant(Box::new(conv.participants.into_iter().map(|p| p.display_name()).collect::>()))); - map.insert("date".into(), arg::Variant(Box::new(conv.date.and_utc().timestamp()))); - map - }).collect() + conversations + .into_iter() + .map(|conv| { + let mut map = arg::PropMap::new(); + map.insert("guid".into(), arg::Variant(Box::new(conv.guid))); + map.insert( + "display_name".into(), + arg::Variant(Box::new(conv.display_name.unwrap_or_default())), + ); + map.insert( + "unread_count".into(), + arg::Variant(Box::new(conv.unread_count as i32)), + ); + map.insert( + "last_message_preview".into(), + arg::Variant(Box::new(conv.last_message_preview.unwrap_or_default())), + ); + map.insert( + "participants".into(), + arg::Variant(Box::new( + conv.participants + .into_iter() + .map(|p| p.display_name()) + .collect::>(), + )), + ); + map.insert( + "date".into(), + arg::Variant(Box::new(conv.date.and_utc().timestamp())), + ); + map + }) + .collect() }) } @@ -79,7 +108,11 @@ impl DbusRepository for ServerImpl { self.send_event_sync(|r| Event::SyncConversation(conversation_id, r)) } - fn get_messages(&mut self, conversation_id: String, last_message_id: String) -> Result, dbus::MethodErr> { + fn get_messages( + &mut self, + conversation_id: String, + last_message_id: String, + ) -> Result, dbus::MethodErr> { let last_message_id_opt = if last_message_id.is_empty() { None } else { @@ -88,14 +121,23 @@ impl DbusRepository for ServerImpl { self.send_event_sync(|r| Event::GetMessages(conversation_id, last_message_id_opt, r)) .map(|messages| { - messages.into_iter().map(|msg| { - let mut map = arg::PropMap::new(); - map.insert("id".into(), arg::Variant(Box::new(msg.id))); - map.insert("text".into(), arg::Variant(Box::new(msg.text))); - map.insert("date".into(), arg::Variant(Box::new(msg.date.and_utc().timestamp()))); - map.insert("sender".into(), arg::Variant(Box::new(msg.sender.display_name()))); - map - }).collect() + messages + .into_iter() + .map(|msg| { + let mut map = arg::PropMap::new(); + map.insert("id".into(), arg::Variant(Box::new(msg.id))); + map.insert("text".into(), arg::Variant(Box::new(msg.text))); + map.insert( + "date".into(), + arg::Variant(Box::new(msg.date.and_utc().timestamp())), + ); + map.insert( + "sender".into(), + arg::Variant(Box::new(msg.sender.display_name())), + ); + map + }) + .collect() }) } @@ -103,21 +145,35 @@ impl DbusRepository for ServerImpl { self.send_event_sync(Event::DeleteAllConversations) } - fn send_message(&mut self, conversation_id: String, text: String) -> Result { + fn send_message( + &mut self, + conversation_id: String, + text: String, + ) -> Result { self.send_event_sync(|r| Event::SendMessage(conversation_id, text, r)) .map(|uuid| uuid.to_string()) } -} + + fn get_attachment( + &mut self, + attachment_id: String, + ) -> Result, dbus::MethodErr> { + todo!() + } +} impl DbusSettings for ServerImpl { fn set_server(&mut self, url: String, user: String) -> Result<(), dbus::MethodErr> { - self.send_event_sync(|r| - Event::UpdateSettings(Settings { - server_url: Some(url), - username: Some(user), - token: None, - }, r) - ) + self.send_event_sync(|r| { + Event::UpdateSettings( + Settings { + server_url: Some(url), + username: Some(user), + token: None, + }, + r, + ) + }) } fn server_url(&self) -> Result { @@ -126,13 +182,16 @@ impl DbusSettings for ServerImpl { } fn set_server_url(&self, value: String) -> Result<(), dbus::MethodErr> { - self.send_event_sync(|r| - Event::UpdateSettings(Settings { - server_url: Some(value), - username: None, - token: None, - }, r) - ) + self.send_event_sync(|r| { + Event::UpdateSettings( + Settings { + server_url: Some(value), + username: None, + token: None, + }, + r, + ) + }) } fn username(&self) -> Result { @@ -141,13 +200,32 @@ impl DbusSettings for ServerImpl { } fn set_username(&self, value: String) -> Result<(), dbus::MethodErr> { - self.send_event_sync(|r| - Event::UpdateSettings(Settings { - server_url: None, - username: Some(value), - token: None, - }, r) - ) + self.send_event_sync(|r| { + Event::UpdateSettings( + Settings { + server_url: None, + username: Some(value), + token: None, + }, + r, + ) + }) + } +} + +impl DbusAttachment for Attachment { + fn file_path(&self) -> Result { + Ok(self.path.as_os_str().to_os_string().into_string().unwrap()) + } + + fn downloaded(&self) -> Result { + Ok(self.downloaded) + } + + fn delete(&mut self) -> Result<(), dbus::MethodErr> { + // Mostly a placeholder method because dbuscodegen for some reason barfs on this + // if there are no methods defined. + todo!() } } @@ -172,4 +250,4 @@ where .join() }) .expect("Error joining runtime thread") -} \ No newline at end of file +} From c02d4ecdf357fff70d23f85c7b11890781e2d053 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 25 May 2025 18:52:18 -0700 Subject: [PATCH 066/138] broken: started working on attachment dbus object, but order of endpoint creation seems to matter, need to reuse more parts --- kordophoned/src/daemon/events.rs | 21 +++++---- kordophoned/src/daemon/mod.rs | 5 +++ kordophoned/src/dbus/endpoint.rs | 46 +++++-------------- kordophoned/src/dbus/server_impl.rs | 32 +++++++++++-- kordophoned/src/main.rs | 70 ++++++++++++++++++++++------- 5 files changed, 112 insertions(+), 62 deletions(-) diff --git a/kordophoned/src/daemon/events.rs b/kordophoned/src/daemon/events.rs index 2b20d6b..287f188 100644 --- a/kordophoned/src/daemon/events.rs +++ b/kordophoned/src/daemon/events.rs @@ -1,11 +1,12 @@ use tokio::sync::oneshot; use uuid::Uuid; -use kordophone_db::models::{Conversation, Message}; use kordophone::model::ConversationID; use kordophone::model::OutgoingMessage; +use kordophone_db::models::{Conversation, Message}; use crate::daemon::settings::Settings; +use crate::daemon::Attachment; pub type Reply = oneshot::Sender; @@ -17,14 +18,14 @@ pub enum Event { /// Asynchronous event for syncing the conversation list with the server. SyncConversationList(Reply<()>), - /// Asynchronous event for syncing all conversations with the server. + /// Asynchronous event for syncing all conversations with the server. SyncAllConversations(Reply<()>), /// Asynchronous event for syncing a single conversation with the server. SyncConversation(String, Reply<()>), /// Returns all known conversations from the database. - /// Parameters: + /// Parameters: /// - limit: The maximum number of conversations to return. (-1 for no limit) /// - offset: The offset into the conversation list to start returning conversations from. GetAllConversations(i32, i32, Reply>), @@ -36,27 +37,31 @@ pub enum Event { UpdateSettings(Settings, Reply<()>), /// Returns all messages for a conversation from the database. - /// Parameters: + /// Parameters: /// - conversation_id: The ID of the conversation to get messages for. /// - last_message_id: (optional) The ID of the last message to get. If None, all messages are returned. GetMessages(String, Option, Reply>), /// Enqueues a message to be sent to the server. - /// Parameters: + /// Parameters: /// - conversation_id: The ID of the conversation to send the message to. /// - text: The text of the message to send. /// - reply: The outgoing message ID (not the server-assigned message ID). SendMessage(String, String, Reply), /// Notifies the daemon that a message has been sent. - /// Parameters: + /// Parameters: /// - message: The message that was sent. /// - outgoing_message: The outgoing message that was sent. /// - conversation_id: The ID of the conversation that the message was sent to. MessageSent(Message, OutgoingMessage, ConversationID), + /// Gets an attachment object from the attachment store. + /// Parameters: + /// - guid: The attachment guid + /// - reply: Reply of the attachment object, if known. + GetAttachment(String, Reply), + /// Delete all conversations from the database. DeleteAllConversations(Reply<()>), } - - diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index ce950b8..5cee910 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -279,6 +279,11 @@ impl Daemon { .await .unwrap(); } + + Event::GetAttachment(guid, reply) => { + let attachment = self.attachment_store.get_attachment(&guid); + reply.send(attachment).unwrap(); + } } } diff --git a/kordophoned/src/dbus/endpoint.rs b/kordophoned/src/dbus/endpoint.rs index a35f371..ddef080 100644 --- a/kordophoned/src/dbus/endpoint.rs +++ b/kordophoned/src/dbus/endpoint.rs @@ -1,60 +1,38 @@ use log::info; use std::sync::Arc; -use dbus_crossroads::Crossroads; -use dbus_tokio::connection; use dbus::{ + channel::{MatchingReceiver, Sender}, message::MatchRule, nonblock::SyncConnection, - channel::{Sender, MatchingReceiver}, Path, }; +use dbus_crossroads::Crossroads; +#[derive(Clone)] pub struct Endpoint { connection: Arc, implementation: T, } impl Endpoint { - pub fn new(implementation: T) -> Self { - let (resource, connection) = connection::new_session_sync().unwrap(); - - // The resource is a task that should be spawned onto a tokio compatible - // reactor ASAP. If the resource ever finishes, you lost connection to D-Bus. - // - // To shut down the connection, both call _handle.abort() and drop the connection. - let _handle = tokio::spawn(async { - let err = resource.await; - panic!("Lost connection to D-Bus: {}", err); - }); - - Self { - connection, - implementation + pub fn new(connection: Arc, implementation: T) -> Self { + Self { + connection, + implementation, } } - pub async fn register( - &self, - name: &str, - path: &str, - register_fn: F - ) + pub async fn register_object(&self, path: &str, register_fn: F) where F: Fn(&mut Crossroads) -> R, R: IntoIterator>, { let dbus_path = String::from(path); - self.connection - .request_name(name, false, true, false) - .await - .expect("Unable to acquire dbus name"); - - let mut cr = Crossroads::new(); - - // Enable async support for the crossroads instance. + // Enable async support for the crossroads instance. // (Currently irrelevant since dbus generates sync code) + let mut cr = Crossroads::new(); cr.set_async_support(Some(( self.connection.clone(), Box::new(|x| { @@ -69,9 +47,7 @@ impl Endpoint { // Start receiving messages. self.connection.start_receive( MatchRule::new_method_call(), - Box::new(move |msg, conn| - cr.handle_message(msg, conn).is_ok() - ), + Box::new(move |msg, conn| cr.handle_message(msg, conn).is_ok()), ); info!(target: "dbus", "Registered endpoint at {} with {} interfaces", path, tokens.len()); diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 955f325..8f853db 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -1,6 +1,8 @@ use dbus::arg; +use dbus::nonblock::SyncConnection; use dbus_tree::MethodErr; use std::future::Future; +use std::sync::Arc; use std::thread; use tokio::sync::mpsc; use tokio::sync::oneshot; @@ -11,18 +13,25 @@ use crate::daemon::{ Attachment, DaemonResult, }; +use crate::dbus::endpoint::Endpoint; use crate::dbus::interface::NetBuzzertKordophoneAttachment as DbusAttachment; use crate::dbus::interface::NetBuzzertKordophoneRepository as DbusRepository; use crate::dbus::interface::NetBuzzertKordophoneSettings as DbusSettings; #[derive(Clone)] pub struct ServerImpl { + connection: Arc, event_sink: mpsc::Sender, + attachment_objects: Vec>, } impl ServerImpl { - pub fn new(event_sink: mpsc::Sender) -> Self { - Self { event_sink } + pub fn new(connection: Arc, event_sink: mpsc::Sender) -> Self { + Self { + connection: connection, + event_sink: event_sink, + attachment_objects: vec![], + } } pub async fn send_event( @@ -158,7 +167,24 @@ impl DbusRepository for ServerImpl { &mut self, attachment_id: String, ) -> Result, dbus::MethodErr> { - todo!() + use crate::dbus::interface; + + self.send_event_sync(|r| Event::GetAttachment(attachment_id.clone(), r)) + .and_then(|attachment| { + let id: &str = attachment_id.split("-").take(1).last().unwrap(); + let obj_path = format!("/net/buzzert/kordophonecd/attachments/{}", &id); + log::trace!("Registering attachment at path: {}", &obj_path); + + let endpoint = Endpoint::new(self.connection.clone(), attachment); + run_sync_future(endpoint.register_object(obj_path.as_str(), |cr| { + vec![interface::register_net_buzzert_kordophone_attachment(cr)] + }))?; + + self.attachment_objects.push(endpoint); + log::trace!("Attachment objects: {:?}", self.attachment_objects.len()); + + Ok(obj_path.into()) + }) } } diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index d3806da..4a0f65d 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -1,15 +1,16 @@ -mod dbus; mod daemon; +mod dbus; -use std::future; use log::LevelFilter; +use std::future; -use daemon::Daemon; use daemon::signals::Signal; +use daemon::{Attachment, Daemon}; use dbus::endpoint::Endpoint as DbusEndpoint; use dbus::interface; use dbus::server_impl::ServerImpl; +use dbus_tokio::connection; fn initialize_logging() { // Weird: is this the best way to do this? @@ -17,7 +18,7 @@ fn initialize_logging() { .map(|s| s.parse::().unwrap_or(LevelFilter::Info)) .unwrap_or(LevelFilter::Info); - env_logger::Builder::from_default_env() + env_logger::Builder::from_default_env() .format_timestamp_secs() .filter_level(log_level) .init(); @@ -35,21 +36,50 @@ async fn main() { }) .unwrap(); + // Initialize dbus session connection + let (resource, connection) = connection::new_session_sync().unwrap(); + + // The resource is a task that should be spawned onto a tokio compatible + // reactor ASAP. If the resource ever finishes, you lost connection to D-Bus. + // + // To shut down the connection, both call _handle.abort() and drop the connection. + let _handle = tokio::spawn(async { + let err = resource.await; + panic!("Lost connection to D-Bus: {}", err); + }); + + // Acquire the name + connection + .request_name(interface::NAME, false, true, false) + .await + .expect("Unable to acquire dbus name"); + + let attachment = Attachment { + guid: "asdf".into(), + path: "/dev/null".into(), + downloaded: false, + }; + + let att_endpoint = DbusEndpoint::new(connection.clone(), attachment); + att_endpoint + .register_object("/net/buzzert/kordophonecd/attachments/test", |cr| { + vec![interface::register_net_buzzert_kordophone_attachment(cr)] + }) + .await; + // Create the server implementation - let server = ServerImpl::new(daemon.event_sender.clone()); + let server = ServerImpl::new(connection.clone(), daemon.event_sender.clone()); // Register DBus interfaces with endpoint - let endpoint = DbusEndpoint::new(server); - endpoint.register( - interface::NAME, - interface::OBJECT_PATH, - |cr| { + let endpoint = DbusEndpoint::new(connection.clone(), server); + endpoint + .register_object(interface::OBJECT_PATH, |cr| { vec![ interface::register_net_buzzert_kordophone_repository(cr), - interface::register_net_buzzert_kordophone_settings(cr) + interface::register_net_buzzert_kordophone_settings(cr), ] - } - ).await; + }) + .await; let mut signal_receiver = daemon.obtain_signal_receiver(); tokio::spawn(async move { @@ -59,7 +89,8 @@ async fn main() { match signal { Signal::ConversationsUpdated => { log::debug!("Sending signal: ConversationsUpdated"); - endpoint.send_signal(interface::OBJECT_PATH, DbusSignals::ConversationsUpdated{}) + endpoint + .send_signal(interface::OBJECT_PATH, DbusSignals::ConversationsUpdated {}) .unwrap_or_else(|_| { log::error!("Failed to send signal"); 0 @@ -67,8 +98,15 @@ async fn main() { } Signal::MessagesUpdated(conversation_id) => { - log::debug!("Sending signal: MessagesUpdated for conversation {}", conversation_id); - endpoint.send_signal(interface::OBJECT_PATH, DbusSignals::MessagesUpdated{ conversation_id }) + log::debug!( + "Sending signal: MessagesUpdated for conversation {}", + conversation_id + ); + endpoint + .send_signal( + interface::OBJECT_PATH, + DbusSignals::MessagesUpdated { conversation_id }, + ) .unwrap_or_else(|_| { log::error!("Failed to send signal"); 0 From 831e490eb473dd40773895d74f59e16247b48a93 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 26 May 2025 15:49:29 -0700 Subject: [PATCH 067/138] Started to factor out DbusRegistry from Endpoint --- kordophoned/src/dbus/endpoint.rs | 68 ++++++++++++++++++++++++++++- kordophoned/src/dbus/server_impl.rs | 24 ++++------ kordophoned/src/main.rs | 44 +++++++------------ 3 files changed, 92 insertions(+), 44 deletions(-) diff --git a/kordophoned/src/dbus/endpoint.rs b/kordophoned/src/dbus/endpoint.rs index ddef080..ec76030 100644 --- a/kordophoned/src/dbus/endpoint.rs +++ b/kordophoned/src/dbus/endpoint.rs @@ -1,5 +1,5 @@ use log::info; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use dbus::{ channel::{MatchingReceiver, Sender}, @@ -9,6 +9,72 @@ use dbus::{ }; use dbus_crossroads::Crossroads; +#[derive(Clone)] +pub struct DbusRegistry { + connection: Arc, + crossroads: Arc>, + message_handler_started: Arc>, +} + +impl DbusRegistry { + pub fn new(connection: Arc) -> Self { + let mut cr = Crossroads::new(); + // Enable async support for the crossroads instance. + // (Currently irrelevant since dbus generates sync code) + cr.set_async_support(Some(( + connection.clone(), + Box::new(|x| { + tokio::spawn(x); + }), + ))); + + Self { + connection, + crossroads: Arc::new(Mutex::new(cr)), + message_handler_started: Arc::new(Mutex::new(false)), + } + } + + pub fn register_object(&self, path: &str, implementation: T, register_fn: F) + where + T: Send + Clone + 'static, + F: Fn(&mut Crossroads) -> R, + R: IntoIterator>, + { + let dbus_path = String::from(path); + + let mut cr = self.crossroads.lock().unwrap(); + let tokens: Vec<_> = register_fn(&mut cr).into_iter().collect(); + cr.insert(dbus_path, &tokens, implementation); + + // Start message handler if not already started + let mut handler_started = self.message_handler_started.lock().unwrap(); + if !*handler_started { + let crossroads_clone = self.crossroads.clone(); + self.connection.start_receive( + MatchRule::new_method_call(), + Box::new(move |msg, conn| { + let mut cr = crossroads_clone.lock().unwrap(); + cr.handle_message(msg, conn).is_ok() + }), + ); + *handler_started = true; + info!(target: "dbus", "Started D-Bus message handler"); + } + + info!(target: "dbus", "Registered object at {} with {} interfaces", path, tokens.len()); + } + + pub fn send_signal(&self, path: &str, signal: S) -> Result + where + S: dbus::message::SignalArgs + dbus::arg::AppendAll, + { + let message = signal.to_emit_message(&Path::new(path).unwrap()); + self.connection.send(message) + } +} + +// Keep the old Endpoint struct for backward compatibility during transition #[derive(Clone)] pub struct Endpoint { connection: Arc, diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 8f853db..cb94674 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -1,8 +1,6 @@ use dbus::arg; -use dbus::nonblock::SyncConnection; use dbus_tree::MethodErr; use std::future::Future; -use std::sync::Arc; use std::thread; use tokio::sync::mpsc; use tokio::sync::oneshot; @@ -13,24 +11,22 @@ use crate::daemon::{ Attachment, DaemonResult, }; -use crate::dbus::endpoint::Endpoint; +use crate::dbus::endpoint::DbusRegistry; use crate::dbus::interface::NetBuzzertKordophoneAttachment as DbusAttachment; use crate::dbus::interface::NetBuzzertKordophoneRepository as DbusRepository; use crate::dbus::interface::NetBuzzertKordophoneSettings as DbusSettings; #[derive(Clone)] pub struct ServerImpl { - connection: Arc, event_sink: mpsc::Sender, - attachment_objects: Vec>, + dbus_registry: DbusRegistry, } impl ServerImpl { - pub fn new(connection: Arc, event_sink: mpsc::Sender) -> Self { + pub fn new(event_sink: mpsc::Sender, dbus_registry: DbusRegistry) -> Self { Self { - connection: connection, event_sink: event_sink, - attachment_objects: vec![], + dbus_registry: dbus_registry, } } @@ -175,13 +171,11 @@ impl DbusRepository for ServerImpl { let obj_path = format!("/net/buzzert/kordophonecd/attachments/{}", &id); log::trace!("Registering attachment at path: {}", &obj_path); - let endpoint = Endpoint::new(self.connection.clone(), attachment); - run_sync_future(endpoint.register_object(obj_path.as_str(), |cr| { - vec![interface::register_net_buzzert_kordophone_attachment(cr)] - }))?; - - self.attachment_objects.push(endpoint); - log::trace!("Attachment objects: {:?}", self.attachment_objects.len()); + self.dbus_registry.register_object( + &obj_path, + attachment, + |cr| vec![interface::register_net_buzzert_kordophone_attachment(cr)] + ); Ok(obj_path.into()) }) diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index 4a0f65d..4cd6727 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -5,9 +5,9 @@ use log::LevelFilter; use std::future; use daemon::signals::Signal; -use daemon::{Attachment, Daemon}; +use daemon::Daemon; -use dbus::endpoint::Endpoint as DbusEndpoint; +use dbus::endpoint::DbusRegistry; use dbus::interface; use dbus::server_impl::ServerImpl; use dbus_tokio::connection; @@ -54,32 +54,20 @@ async fn main() { .await .expect("Unable to acquire dbus name"); - let attachment = Attachment { - guid: "asdf".into(), - path: "/dev/null".into(), - downloaded: false, - }; + // Create shared D-Bus registry + let dbus_registry = DbusRegistry::new(connection.clone()); - let att_endpoint = DbusEndpoint::new(connection.clone(), attachment); - att_endpoint - .register_object("/net/buzzert/kordophonecd/attachments/test", |cr| { - vec![interface::register_net_buzzert_kordophone_attachment(cr)] - }) - .await; + // Create and register server implementation + let server = ServerImpl::new(daemon.event_sender.clone(), dbus_registry.clone()); - // Create the server implementation - let server = ServerImpl::new(connection.clone(), daemon.event_sender.clone()); - - // Register DBus interfaces with endpoint - let endpoint = DbusEndpoint::new(connection.clone(), server); - endpoint - .register_object(interface::OBJECT_PATH, |cr| { - vec![ - interface::register_net_buzzert_kordophone_repository(cr), - interface::register_net_buzzert_kordophone_settings(cr), - ] - }) - .await; + dbus_registry.register_object( + interface::OBJECT_PATH, + server, + |cr| vec![ + interface::register_net_buzzert_kordophone_repository(cr), + interface::register_net_buzzert_kordophone_settings(cr), + ] + ); let mut signal_receiver = daemon.obtain_signal_receiver(); tokio::spawn(async move { @@ -89,7 +77,7 @@ async fn main() { match signal { Signal::ConversationsUpdated => { log::debug!("Sending signal: ConversationsUpdated"); - endpoint + dbus_registry .send_signal(interface::OBJECT_PATH, DbusSignals::ConversationsUpdated {}) .unwrap_or_else(|_| { log::error!("Failed to send signal"); @@ -102,7 +90,7 @@ async fn main() { "Sending signal: MessagesUpdated for conversation {}", conversation_id ); - endpoint + dbus_registry .send_signal( interface::OBJECT_PATH, DbusSignals::MessagesUpdated { conversation_id }, From 2b5df53cc3ac4f0e7b8ca5ad8f76a52444368090 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 26 May 2025 16:19:26 -0700 Subject: [PATCH 068/138] better d-bus interface for attachments --- .../net.buzzert.kordophonecd.Server.xml | 47 ++++++++++++---- kordophoned/src/daemon/attachment_store.rs | 26 ++++++--- kordophoned/src/daemon/events.rs | 6 ++ kordophoned/src/daemon/mod.rs | 7 +++ kordophoned/src/dbus/server_impl.rs | 55 ++++++++----------- 5 files changed, 88 insertions(+), 53 deletions(-) diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index b29ed0c..59ddae6 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -78,22 +78,45 @@ - + - + - - - - - - - - + value="Returns attachment info: (file_path: string, downloaded: bool, file_size: uint64)"/> - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kordophoned/src/daemon/attachment_store.rs b/kordophoned/src/daemon/attachment_store.rs index 38f8324..33e8510 100644 --- a/kordophoned/src/daemon/attachment_store.rs +++ b/kordophoned/src/daemon/attachment_store.rs @@ -1,10 +1,10 @@ use std::{ - io::{BufReader, BufWriter, Read, Write}, - path::{Path, PathBuf}, + io::{BufWriter, Write}, + path::PathBuf, }; -use anyhow::{Error, Result}; -use futures_util::{poll, StreamExt}; +use anyhow::Result; +use futures_util::StreamExt; use kordophone::APIInterface; use thiserror::Error; use tokio::pin; @@ -64,20 +64,23 @@ impl AttachmentStore { } } - pub async fn download_attachent( + pub async fn download_attachment( &mut self, attachment: &Attachment, mut client_factory: F, ) -> Result<()> where C: APIInterface, - F: AsyncFnMut() -> Result, + F: FnMut() -> Fut, + Fut: std::future::Future>, { if attachment.downloaded { - log::error!(target: target::ATTACHMENTS, "Attempted to download existing attachment."); + log::info!(target: target::ATTACHMENTS, "Attachment already downloaded: {}", attachment.guid); return Err(AttachmentStoreError::AttachmentAlreadyDownloaded.into()); } + log::info!(target: target::ATTACHMENTS, "Starting download for attachment: {}", attachment.guid); + // Create temporary file first, we'll atomically swap later. assert!(!std::fs::exists(&attachment.path).unwrap()); let file = std::fs::File::create(&attachment.path)?; @@ -85,7 +88,7 @@ impl AttachmentStore { log::trace!(target: target::ATTACHMENTS, "Created attachment file at {}", &attachment.path.display()); - let mut client = (client_factory)().await?; + let mut client = client_factory().await?; let stream = client .fetch_attachment_data(&attachment.guid) .await @@ -99,6 +102,13 @@ impl AttachmentStore { writer.write(data.as_ref())?; } + log::info!(target: target::ATTACHMENTS, "Completed download for attachment: {}", attachment.guid); Ok(()) } + + /// Check if an attachment should be downloaded + pub fn should_download(&self, attachment_id: &str) -> bool { + let attachment = self.get_attachment(&attachment_id.to_string()); + !attachment.downloaded + } } diff --git a/kordophoned/src/daemon/events.rs b/kordophoned/src/daemon/events.rs index 287f188..633d119 100644 --- a/kordophoned/src/daemon/events.rs +++ b/kordophoned/src/daemon/events.rs @@ -62,6 +62,12 @@ pub enum Event { /// - reply: Reply of the attachment object, if known. GetAttachment(String, Reply), + /// Downloads an attachment from the server. + /// Parameters: + /// - attachment_id: The attachment ID to download + /// - reply: Reply indicating success or failure + DownloadAttachment(String, Reply<()>), + /// Delete all conversations from the database. DeleteAllConversations(Reply<()>), } diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 5cee910..7012bf0 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -60,6 +60,7 @@ pub mod target { pub static EVENT: &str = "event"; pub static SETTINGS: &str = "settings"; pub static UPDATES: &str = "updates"; + pub static ATTACHMENTS: &str = "attachments"; } pub struct Daemon { @@ -284,6 +285,12 @@ impl Daemon { let attachment = self.attachment_store.get_attachment(&guid); reply.send(attachment).unwrap(); } + + Event::DownloadAttachment(attachment_id, reply) => { + // For now, just return success - we'll implement the actual download logic later + log::info!(target: target::ATTACHMENTS, "Download requested for attachment: {}", attachment_id); + reply.send(()).unwrap(); + } } } diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index cb94674..3ac69e6 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -12,7 +12,6 @@ use crate::daemon::{ }; use crate::dbus::endpoint::DbusRegistry; -use crate::dbus::interface::NetBuzzertKordophoneAttachment as DbusAttachment; use crate::dbus::interface::NetBuzzertKordophoneRepository as DbusRepository; use crate::dbus::interface::NetBuzzertKordophoneSettings as DbusSettings; @@ -159,27 +158,32 @@ impl DbusRepository for ServerImpl { .map(|uuid| uuid.to_string()) } - fn get_attachment( + fn get_attachment_info( &mut self, attachment_id: String, - ) -> Result, dbus::MethodErr> { - use crate::dbus::interface; - - self.send_event_sync(|r| Event::GetAttachment(attachment_id.clone(), r)) - .and_then(|attachment| { - let id: &str = attachment_id.split("-").take(1).last().unwrap(); - let obj_path = format!("/net/buzzert/kordophonecd/attachments/{}", &id); - log::trace!("Registering attachment at path: {}", &obj_path); - - self.dbus_registry.register_object( - &obj_path, - attachment, - |cr| vec![interface::register_net_buzzert_kordophone_attachment(cr)] - ); - - Ok(obj_path.into()) + ) -> Result<(String, bool, u32), dbus::MethodErr> { + self.send_event_sync(|r| Event::GetAttachment(attachment_id, r)) + .map(|attachment| { + let file_size = if attachment.downloaded { + std::fs::metadata(&attachment.path) + .map(|m| m.len() as u32) + .unwrap_or(0) + } else { + 0 + }; + + ( + attachment.path.to_string_lossy().to_string(), + attachment.downloaded, + file_size, + ) }) } + + fn download_attachment(&mut self, attachment_id: String) -> Result<(), dbus::MethodErr> { + // For now, just trigger the download event - we'll implement the actual download logic later + self.send_event_sync(|r| Event::DownloadAttachment(attachment_id, r)) + } } impl DbusSettings for ServerImpl { @@ -233,21 +237,6 @@ impl DbusSettings for ServerImpl { } } -impl DbusAttachment for Attachment { - fn file_path(&self) -> Result { - Ok(self.path.as_os_str().to_os_string().into_string().unwrap()) - } - - fn downloaded(&self) -> Result { - Ok(self.downloaded) - } - - fn delete(&mut self) -> Result<(), dbus::MethodErr> { - // Mostly a placeholder method because dbuscodegen for some reason barfs on this - // if there are no methods defined. - todo!() - } -} fn run_sync_future(f: F) -> Result where From e55b29eb4d8c9b463310186ac3b5681576de8193 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 26 May 2025 16:52:38 -0700 Subject: [PATCH 069/138] plub through attachment guids via messages --- Cargo.lock | 3 + kordophone-db/Cargo.toml | 1 + .../down.sql | 2 + .../up.sql | 2 + .../down.sql | 2 + .../up.sql | 2 + kordophone-db/src/models/db/message.rs | 22 +++++ kordophone-db/src/models/message.rs | 26 +++++- kordophone-db/src/schema.rs | 4 +- kordophone/src/model/message.rs | 44 +++++++++- kordophoned/Cargo.toml | 1 + .../net.buzzert.kordophonecd.Server.xml | 11 ++- kordophoned/src/dbus/server_impl.rs | 26 ++++++ kpcli/Cargo.toml | 1 + kpcli/src/printers.rs | 87 +++++++++++++++---- 15 files changed, 214 insertions(+), 20 deletions(-) create mode 100644 kordophone-db/migrations/2025-05-26-232206_add_attachment_metadata_to_messages/down.sql create mode 100644 kordophone-db/migrations/2025-05-26-232206_add_attachment_metadata_to_messages/up.sql create mode 100644 kordophone-db/migrations/2025-05-26-234230_add_file_transfer_guids_to_messages/down.sql create mode 100644 kordophone-db/migrations/2025-05-26-234230_add_file_transfer_guids_to_messages/up.sql diff --git a/Cargo.lock b/Cargo.lock index 2e4b394..61cc498 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1036,6 +1036,7 @@ dependencies = [ "kordophone", "log", "serde", + "serde_json", "time", "tokio", "uuid", @@ -1059,6 +1060,7 @@ dependencies = [ "kordophone", "kordophone-db", "log", + "serde_json", "thiserror 2.0.12", "tokio", "tokio-condvar", @@ -1082,6 +1084,7 @@ dependencies = [ "log", "pretty", "prettytable", + "serde_json", "time", "tokio", ] diff --git a/kordophone-db/Cargo.toml b/kordophone-db/Cargo.toml index fe8fd87..446eafe 100644 --- a/kordophone-db/Cargo.toml +++ b/kordophone-db/Cargo.toml @@ -13,6 +13,7 @@ diesel_migrations = { version = "2.2.0", features = ["sqlite"] } kordophone = { path = "../kordophone" } log = "0.4.27" serde = { version = "1.0.215", features = ["derive"] } +serde_json = "1.0" time = "0.3.37" tokio = "1.44.2" uuid = { version = "1.11.0", features = ["v4"] } diff --git a/kordophone-db/migrations/2025-05-26-232206_add_attachment_metadata_to_messages/down.sql b/kordophone-db/migrations/2025-05-26-232206_add_attachment_metadata_to_messages/down.sql new file mode 100644 index 0000000..722f7e8 --- /dev/null +++ b/kordophone-db/migrations/2025-05-26-232206_add_attachment_metadata_to_messages/down.sql @@ -0,0 +1,2 @@ +-- Remove attachment_metadata column from messages table +ALTER TABLE messages DROP COLUMN attachment_metadata; \ No newline at end of file diff --git a/kordophone-db/migrations/2025-05-26-232206_add_attachment_metadata_to_messages/up.sql b/kordophone-db/migrations/2025-05-26-232206_add_attachment_metadata_to_messages/up.sql new file mode 100644 index 0000000..50d4826 --- /dev/null +++ b/kordophone-db/migrations/2025-05-26-232206_add_attachment_metadata_to_messages/up.sql @@ -0,0 +1,2 @@ +-- Add attachment_metadata column to messages table +ALTER TABLE messages ADD COLUMN attachment_metadata TEXT; \ No newline at end of file diff --git a/kordophone-db/migrations/2025-05-26-234230_add_file_transfer_guids_to_messages/down.sql b/kordophone-db/migrations/2025-05-26-234230_add_file_transfer_guids_to_messages/down.sql new file mode 100644 index 0000000..d58fd05 --- /dev/null +++ b/kordophone-db/migrations/2025-05-26-234230_add_file_transfer_guids_to_messages/down.sql @@ -0,0 +1,2 @@ +-- Remove file_transfer_guids column from messages table +ALTER TABLE messages DROP COLUMN file_transfer_guids; \ No newline at end of file diff --git a/kordophone-db/migrations/2025-05-26-234230_add_file_transfer_guids_to_messages/up.sql b/kordophone-db/migrations/2025-05-26-234230_add_file_transfer_guids_to_messages/up.sql new file mode 100644 index 0000000..6a244b7 --- /dev/null +++ b/kordophone-db/migrations/2025-05-26-234230_add_file_transfer_guids_to_messages/up.sql @@ -0,0 +1,2 @@ +-- Add file_transfer_guids column to messages table +ALTER TABLE messages ADD COLUMN file_transfer_guids TEXT; \ No newline at end of file diff --git a/kordophone-db/src/models/db/message.rs b/kordophone-db/src/models/db/message.rs index 7f94262..736c28d 100644 --- a/kordophone-db/src/models/db/message.rs +++ b/kordophone-db/src/models/db/message.rs @@ -10,10 +10,21 @@ pub struct Record { pub sender_participant_id: Option, pub text: String, pub date: NaiveDateTime, + pub file_transfer_guids: Option, // JSON array + pub attachment_metadata: Option, // JSON string } impl From for Record { fn from(message: Message) -> Self { + let file_transfer_guids = if message.file_transfer_guids.is_empty() { + None + } else { + Some(serde_json::to_string(&message.file_transfer_guids).unwrap_or_default()) + }; + + let attachment_metadata = message.attachment_metadata + .map(|metadata| serde_json::to_string(&metadata).unwrap_or_default()); + Self { id: message.id, sender_participant_id: match message.sender { @@ -22,18 +33,29 @@ impl From for Record { }, text: message.text, date: message.date, + file_transfer_guids, + attachment_metadata, } } } impl From for Message { fn from(record: Record) -> Self { + let file_transfer_guids = record.file_transfer_guids + .and_then(|json| serde_json::from_str(&json).ok()) + .unwrap_or_default(); + + let attachment_metadata = record.attachment_metadata + .and_then(|json| serde_json::from_str(&json).ok()); + Self { id: record.id, // We'll set the proper sender later when loading participant info sender: Participant::Me, text: record.text, date: record.date, + file_transfer_guids, + attachment_metadata, } } } diff --git a/kordophone-db/src/models/message.rs b/kordophone-db/src/models/message.rs index 076ea52..100ffcc 100644 --- a/kordophone-db/src/models/message.rs +++ b/kordophone-db/src/models/message.rs @@ -1,7 +1,9 @@ use chrono::{DateTime, NaiveDateTime}; +use std::collections::HashMap; use uuid::Uuid; use crate::models::participant::Participant; use kordophone::model::outgoing_message::OutgoingMessage; +use kordophone::model::message::AttachmentMetadata; #[derive(Clone, Debug)] pub struct Message { @@ -9,6 +11,8 @@ pub struct Message { pub sender: Participant, pub text: String, pub date: NaiveDateTime, + pub file_transfer_guids: Vec, + pub attachment_metadata: Option>, } impl Message { @@ -36,7 +40,9 @@ impl From for Message { .unwrap_or(0), ) .unwrap() - .naive_local() + .naive_local(), + file_transfer_guids: value.file_transfer_guids, + attachment_metadata: value.attachment_metadata, } } } @@ -48,6 +54,8 @@ impl From<&OutgoingMessage> for Message { sender: Participant::Me, text: value.text.clone(), date: value.date, + file_transfer_guids: Vec::new(), // Outgoing messages don't have file transfer GUIDs initially + attachment_metadata: None, // Outgoing messages don't have attachment metadata initially } } } @@ -57,6 +65,8 @@ pub struct MessageBuilder { sender: Option, text: Option, date: Option, + file_transfer_guids: Option>, + attachment_metadata: Option>, } impl Default for MessageBuilder { @@ -72,6 +82,8 @@ impl MessageBuilder { sender: None, text: None, date: None, + file_transfer_guids: None, + attachment_metadata: None, } } @@ -90,12 +102,24 @@ impl MessageBuilder { self } + pub fn file_transfer_guids(mut self, file_transfer_guids: Vec) -> Self { + self.file_transfer_guids = Some(file_transfer_guids); + self + } + + pub fn attachment_metadata(mut self, attachment_metadata: HashMap) -> Self { + self.attachment_metadata = Some(attachment_metadata); + self + } + pub fn build(self) -> Message { Message { id: self.id.unwrap_or_else(|| Uuid::new_v4().to_string()), sender: self.sender.unwrap_or(Participant::Me), text: self.text.unwrap_or_default(), date: self.date.unwrap_or_else(|| chrono::Utc::now().naive_utc()), + file_transfer_guids: self.file_transfer_guids.unwrap_or_default(), + attachment_metadata: self.attachment_metadata, } } } diff --git a/kordophone-db/src/schema.rs b/kordophone-db/src/schema.rs index d77e401..1cf6f3a 100644 --- a/kordophone-db/src/schema.rs +++ b/kordophone-db/src/schema.rs @@ -31,7 +31,9 @@ diesel::table! { id -> Text, // guid text -> Text, sender_participant_id -> Nullable, - date -> Timestamp, + date -> Timestamp, + file_transfer_guids -> Nullable, // JSON array of file transfer GUIDs + attachment_metadata -> Nullable, // JSON string of attachment metadata } } diff --git a/kordophone/src/model/message.rs b/kordophone/src/model/message.rs index d226604..ef90efc 100644 --- a/kordophone/src/model/message.rs +++ b/kordophone/src/model/message.rs @@ -1,4 +1,5 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use time::OffsetDateTime; use uuid::Uuid; @@ -6,6 +7,23 @@ use super::Identifiable; pub type MessageID = ::ID; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AttributionInfo { + /// Picture width + #[serde(rename = "pgensh")] + pub width: Option, + + /// Picture height + #[serde(rename = "pgensw")] + pub height: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AttachmentMetadata { + #[serde(rename = "attributionInfo")] + pub attribution_info: Option, +} + #[derive(Debug, Clone, Deserialize)] pub struct Message { pub guid: String, @@ -18,6 +36,14 @@ pub struct Message { #[serde(with = "time::serde::iso8601")] pub date: OffsetDateTime, + + /// Array of file transfer GUIDs for attachments + #[serde(rename = "fileTransferGUIDs", default)] + pub file_transfer_guids: Vec, + + /// Optional attachment metadata, keyed by attachment GUID + #[serde(rename = "attachmentMetadata")] + pub attachment_metadata: Option>, } impl Message { @@ -39,7 +65,9 @@ pub struct MessageBuilder { guid: Option, text: Option, sender: Option, - date: Option, + date: Option, + file_transfer_guids: Option>, + attachment_metadata: Option>, } impl MessageBuilder { @@ -67,12 +95,24 @@ impl MessageBuilder { self } + pub fn file_transfer_guids(mut self, file_transfer_guids: Vec) -> Self { + self.file_transfer_guids = Some(file_transfer_guids); + self + } + + pub fn attachment_metadata(mut self, attachment_metadata: HashMap) -> Self { + self.attachment_metadata = Some(attachment_metadata); + self + } + pub fn build(self) -> Message { Message { guid: self.guid.unwrap_or(Uuid::new_v4().to_string()), text: self.text.unwrap_or("".to_string()), sender: self.sender, date: self.date.unwrap_or(OffsetDateTime::now_utc()), + file_transfer_guids: self.file_transfer_guids.unwrap_or_default(), + attachment_metadata: self.attachment_metadata, } } } diff --git a/kordophoned/Cargo.toml b/kordophoned/Cargo.toml index 775a381..5a25583 100644 --- a/kordophoned/Cargo.toml +++ b/kordophoned/Cargo.toml @@ -17,6 +17,7 @@ keyring = { version = "3.6.2", features = ["sync-secret-service"] } kordophone = { path = "../kordophone" } kordophone-db = { path = "../kordophone-db" } log = "0.4.25" +serde_json = "1.0" thiserror = "2.0.12" tokio = { version = "1", features = ["full"] } tokio-condvar = "0.3.0" diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index 59ddae6..da4e231 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -58,7 +58,16 @@ - + + + diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 3ac69e6..a953573 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -128,6 +128,7 @@ impl DbusRepository for ServerImpl { messages .into_iter() .map(|msg| { + let msg_id = msg.id.clone(); // Store ID for potential error logging let mut map = arg::PropMap::new(); map.insert("id".into(), arg::Variant(Box::new(msg.id))); map.insert("text".into(), arg::Variant(Box::new(msg.text))); @@ -139,6 +140,31 @@ impl DbusRepository for ServerImpl { "sender".into(), arg::Variant(Box::new(msg.sender.display_name())), ); + + // Add file transfer GUIDs if present + if !msg.file_transfer_guids.is_empty() { + match serde_json::to_string(&msg.file_transfer_guids) { + Ok(json_str) => { + map.insert("file_transfer_guids".into(), arg::Variant(Box::new(json_str))); + } + Err(e) => { + log::warn!("Failed to serialize file transfer GUIDs for message {}: {}", msg_id, e); + } + } + } + + // Add attachment metadata if present + if let Some(ref attachment_metadata) = msg.attachment_metadata { + match serde_json::to_string(attachment_metadata) { + Ok(json_str) => { + map.insert("attachment_metadata".into(), arg::Variant(Box::new(json_str))); + } + Err(e) => { + log::warn!("Failed to serialize attachment metadata for message {}: {}", msg_id, e); + } + } + } + map }) .collect() diff --git a/kpcli/Cargo.toml b/kpcli/Cargo.toml index cda9f57..c83e9c3 100644 --- a/kpcli/Cargo.toml +++ b/kpcli/Cargo.toml @@ -18,6 +18,7 @@ kordophone-db = { path = "../kordophone-db" } log = "0.4.22" pretty = { version = "0.12.3", features = ["termcolor"] } prettytable = "0.10.0" +serde_json = "1.0" time = "0.3.37" tokio = "1.41.1" diff --git a/kpcli/src/printers.rs b/kpcli/src/printers.rs index b3af6c2..f9e0c0a 100644 --- a/kpcli/src/printers.rs +++ b/kpcli/src/printers.rs @@ -1,7 +1,9 @@ use std::fmt::Display; +use std::collections::HashMap; use time::OffsetDateTime; use pretty::RcDoc; use dbus::arg::{self, RefArg}; +use kordophone::model::message::AttachmentMetadata; pub struct PrintableConversation { pub guid: String, @@ -62,6 +64,8 @@ pub struct PrintableMessage { pub date: OffsetDateTime, pub sender: String, pub text: String, + pub file_transfer_guids: Vec, + pub attachment_metadata: Option>, } impl From for PrintableMessage { @@ -71,6 +75,8 @@ impl From for PrintableMessage { date: value.date, sender: value.sender.unwrap_or("".to_string()), text: value.text, + file_transfer_guids: value.file_transfer_guids, + attachment_metadata: value.attachment_metadata, } } } @@ -82,17 +88,32 @@ impl From for PrintableMessage { date: OffsetDateTime::from_unix_timestamp(value.date.and_utc().timestamp()).unwrap(), sender: value.sender.display_name(), text: value.text, + file_transfer_guids: value.file_transfer_guids, + attachment_metadata: value.attachment_metadata, } } } impl From for PrintableMessage { fn from(value: arg::PropMap) -> Self { + // Parse file transfer GUIDs from JSON if present + let file_transfer_guids = value.get("file_transfer_guids") + .and_then(|v| v.as_str()) + .and_then(|json_str| serde_json::from_str(json_str).ok()) + .unwrap_or_default(); + + // Parse attachment metadata from JSON if present + let attachment_metadata = value.get("attachment_metadata") + .and_then(|v| v.as_str()) + .and_then(|json_str| serde_json::from_str(json_str).ok()); + Self { guid: value.get("id").unwrap().as_str().unwrap().to_string(), date: OffsetDateTime::from_unix_timestamp(value.get("date").unwrap().as_i64().unwrap()).unwrap(), sender: value.get("sender").unwrap().as_str().unwrap().to_string(), text: value.get("text").unwrap().as_str().unwrap().to_string(), + file_transfer_guids, + attachment_metadata, } } } @@ -166,21 +187,57 @@ impl Display for MessagePrinter<'_> { impl<'a> MessagePrinter<'a> { pub fn new(message: &'a PrintableMessage) -> Self { - let doc = RcDoc::text(format!(""); + let mut doc = RcDoc::text(format!(""); MessagePrinter { doc } } From 595c7a764b708f111aadc74630b36a82f7fac83d Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 28 May 2025 14:57:12 -0700 Subject: [PATCH 070/138] adds CLAUDE hints --- .claude/settings.local.json | 10 +++++ CLAUDE.md | 77 +++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 CLAUDE.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..6535198 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(find:*)", + "Bash(cargo build:*)", + "Bash(diesel migration generate:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c95481c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,77 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +### Build & Run +```bash +# Build all workspace members +cargo build + +# Build specific package +cargo build -p kordophone +cargo build -p kordophone-db +cargo build -p kordophoned +cargo build -p kpcli + +# Run daemon +cargo run --bin kordophoned + +# Run CLI tool +cargo run --bin kpcli -- --help +``` + +### Testing +```bash +# Run all tests +cargo test + +# Run tests for specific package +cargo test -p kordophone +cargo test -p kordophone-db +``` + +### Database Operations +```bash +# Database migrations (from kordophone-db directory) +cd kordophone-db +diesel migration run +diesel migration revert +``` + +## Architecture + +This is a Rust workspace with 4 main packages forming a messaging client/daemon system: + +### Core Components + +- **kordophone**: Core library providing API client and models for messaging operations +- **kordophone-db**: Database layer using Diesel ORM with SQLite, handles conversations/messages storage +- **kordophoned**: Background daemon that syncs with messaging server and exposes D-Bus interface +- **kpcli**: Command-line interface for interacting with daemon and performing database operations + +### Key Architecture Patterns + +- **D-Bus IPC**: Daemon exposes functionality via D-Bus at `net.buzzert.kordophonecd` +- **Event-driven**: Daemon uses async channels for internal communication and D-Bus signals for external notifications +- **Repository Pattern**: Database access abstracted through repository layer in kordophone-db +- **Workspace Dependencies**: Packages depend on each other (kordophoned uses both kordophone and kordophone-db) + +### Data Flow + +1. kpcli/external clients interact with kordophoned via D-Bus +2. kordophoned manages HTTP API client connections to messaging server +3. Background sync processes fetch data and store via kordophone-db repository +4. D-Bus signals notify clients of data updates (ConversationsUpdated, MessagesUpdated) + +### Important Files + +- `kordophone-db/diesel.toml`: Database configuration +- `kordophone-db/migrations/`: Database schema definitions +- `kordophoned/include/net.buzzert.kordophonecd.Server.xml`: D-Bus interface definition +- `*/build.rs`: D-Bus code generation for dbus-crossroads interfaces + +### Settings Storage + +Settings are persisted in SQLite database using a key-value store approach. Access via `Settings` struct in kordophone-db. \ No newline at end of file From cbc7679f58ef994e82c3f41d911c4bb55890badc Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 5 Jun 2025 20:19:34 -0700 Subject: [PATCH 071/138] AttachmentStore now has its own runloop, can download attachments --- kordophone/src/api/http_client.rs | 3 +- kordophone/src/api/mod.rs | 1 + .../net.buzzert.kordophonecd.Server.xml | 18 ++- kordophoned/src/daemon/attachment_store.rs | 125 +++++++++++++----- kordophoned/src/daemon/events.rs | 3 +- kordophoned/src/daemon/mod.rs | 39 ++++-- kordophoned/src/dbus/server_impl.rs | 39 +++--- kordophoned/src/main.rs | 2 +- 8 files changed, 160 insertions(+), 70 deletions(-) diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index d69318a..8d58724 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -280,8 +280,9 @@ impl APIInterface for HTTPAPIClient { async fn fetch_attachment_data( &mut self, guid: &String, + preview: bool, ) -> Result { - let endpoint = format!("attachment?guid={}", guid); + let endpoint = format!("attachment?guid={}&preview={}", guid, preview); self.response_with_body_retry(&endpoint, Method::GET, Body::empty, true) .await .map(hyper::Response::into_body) diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index c25f124..b0326db 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -49,6 +49,7 @@ pub trait APIInterface { async fn fetch_attachment_data( &mut self, guid: &String, + preview: bool, ) -> Result; // (POST) /authenticate diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index da4e231..34b99a8 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -89,15 +89,27 @@ - + + + value="Returns attachment info: + - path: string + - preview_path: string + - downloaded: boolean + - preview_downloaded: boolean + "/> + + + value="Initiates download of the specified attachment if not already downloaded. + Arguments: + attachment_id: the attachment GUID + preview: whether to download the preview (true) or full attachment (false) + "/> diff --git a/kordophoned/src/daemon/attachment_store.rs b/kordophoned/src/daemon/attachment_store.rs index 33e8510..429c19c 100644 --- a/kordophoned/src/daemon/attachment_store.rs +++ b/kordophoned/src/daemon/attachment_store.rs @@ -7,7 +7,20 @@ use anyhow::Result; use futures_util::StreamExt; use kordophone::APIInterface; use thiserror::Error; + +use kordophone_db::database::Database; +use kordophone_db::database::DatabaseAccess; + +use crate::daemon::events::Event; +use crate::daemon::events::Reply; +use crate::daemon::Daemon; + +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio::sync::mpsc::{Receiver, Sender}; + use tokio::pin; +use tokio::time::Duration; mod target { pub static ATTACHMENTS: &str = "attachments"; @@ -16,8 +29,31 @@ mod target { #[derive(Debug, Clone)] pub struct Attachment { pub guid: String, - pub path: PathBuf, - pub downloaded: bool, + pub base_path: PathBuf, +} + +impl Attachment { + pub fn get_path(&self, preview: bool) -> PathBuf { + self.base_path.with_extension(if preview { "preview" } else { "full" }) + } + + pub fn is_downloaded(&self, preview: bool) -> bool { + std::fs::exists(&self.get_path(preview)) + .expect(format!("Wasn't able to check for the existence of an attachment file path at {}", &self.get_path(preview).display()).as_str()) + } +} + +#[derive(Debug)] +pub enum AttachmentStoreEvent { + // Get the attachment info for a given attachment guid. + // Args: attachment guid, reply channel. + GetAttachmentInfo(String, Reply), + + // Queue a download for a given attachment guid. + // Args: + // - attachment guid + // - preview: whether to download the preview (true) or full attachment (false) + QueueDownloadAttachment(String, bool), } #[derive(Debug, Error)] @@ -31,10 +67,15 @@ enum AttachmentStoreError { pub struct AttachmentStore { store_path: PathBuf, + database: Arc>, + daemon_event_sink: Sender, + + event_source: Receiver, + event_sink: Option>, } impl AttachmentStore { - pub fn new(data_dir: &PathBuf) -> AttachmentStore { + pub fn new(data_dir: &PathBuf, database: Arc>, daemon_event_sink: Sender) -> AttachmentStore { let store_path = data_dir.join("attachments"); log::info!(target: target::ATTACHMENTS, "Attachment store path: {}", store_path.display()); @@ -42,39 +83,31 @@ impl AttachmentStore { std::fs::create_dir_all(&store_path) .expect("Wasn't able to create the attachment store path"); + let (event_sink, event_source) = tokio::sync::mpsc::channel(100); + AttachmentStore { store_path: store_path, + database: database, + daemon_event_sink: daemon_event_sink, + event_source: event_source, + event_sink: Some(event_sink), } } - pub fn get_attachment(&self, guid: &String) -> Attachment { - let path = self.store_path.join(guid); - let path_exists = std::fs::exists(&path).expect( - format!( - "Wasn't able to check for the existence of an attachment file path at {}", - &path.display() - ) - .as_str(), - ); + pub fn get_event_sink(&mut self) -> Sender { + self.event_sink.take().unwrap() + } + fn get_attachment(&self, guid: &String, preview: bool) -> Attachment { + let base_path = self.store_path.join(guid); Attachment { guid: guid.to_owned(), - path: path, - downloaded: path_exists, + base_path: base_path, } } - - pub async fn download_attachment( - &mut self, - attachment: &Attachment, - mut client_factory: F, - ) -> Result<()> - where - C: APIInterface, - F: FnMut() -> Fut, - Fut: std::future::Future>, - { - if attachment.downloaded { + + async fn download_attachment(&mut self, attachment: &Attachment, preview: bool) -> Result<()> { + if attachment.is_downloaded(preview) { log::info!(target: target::ATTACHMENTS, "Attachment already downloaded: {}", attachment.guid); return Err(AttachmentStoreError::AttachmentAlreadyDownloaded.into()); } @@ -82,15 +115,15 @@ impl AttachmentStore { log::info!(target: target::ATTACHMENTS, "Starting download for attachment: {}", attachment.guid); // Create temporary file first, we'll atomically swap later. - assert!(!std::fs::exists(&attachment.path).unwrap()); - let file = std::fs::File::create(&attachment.path)?; + assert!(!std::fs::exists(&attachment.get_path(preview)).unwrap()); + let file = std::fs::File::create(&attachment.get_path(preview))?; let mut writer = BufWriter::new(&file); - log::trace!(target: target::ATTACHMENTS, "Created attachment file at {}", &attachment.path.display()); + log::trace!(target: target::ATTACHMENTS, "Created attachment file at {}", &attachment.get_path(preview).display()); - let mut client = client_factory().await?; + let mut client = Daemon::get_client_impl(&mut self.database).await?; let stream = client - .fetch_attachment_data(&attachment.guid) + .fetch_attachment_data(&attachment.guid, preview) .await .map_err(|e| AttachmentStoreError::APIClientError(format!("{:?}", e)))?; @@ -106,9 +139,31 @@ impl AttachmentStore { Ok(()) } - /// Check if an attachment should be downloaded - pub fn should_download(&self, attachment_id: &str) -> bool { - let attachment = self.get_attachment(&attachment_id.to_string()); - !attachment.downloaded + pub async fn run(&mut self) { + loop { + tokio::select! { + Some(event) = self.event_source.recv() => { + log::debug!(target: target::ATTACHMENTS, "Received attachment store event: {:?}", event); + + match event { + AttachmentStoreEvent::QueueDownloadAttachment(guid, preview) => { + let attachment = self.get_attachment(&guid, preview); + if !attachment.is_downloaded(preview) { + self.download_attachment(&attachment, preview).await.unwrap_or_else(|e| { + log::error!(target: target::ATTACHMENTS, "Error downloading attachment: {}", e); + }); + } else { + log::info!(target: target::ATTACHMENTS, "Attachment already downloaded: {}", guid); + } + } + + AttachmentStoreEvent::GetAttachmentInfo(guid, reply) => { + let attachment = self.get_attachment(&guid, false); + reply.send(attachment).unwrap(); + } + } + } + } + } } } diff --git a/kordophoned/src/daemon/events.rs b/kordophoned/src/daemon/events.rs index 633d119..2e11ff2 100644 --- a/kordophoned/src/daemon/events.rs +++ b/kordophoned/src/daemon/events.rs @@ -65,8 +65,9 @@ pub enum Event { /// Downloads an attachment from the server. /// Parameters: /// - attachment_id: The attachment ID to download + /// - preview: Whether to download the preview (true) or full attachment (false) /// - reply: Reply indicating success or failure - DownloadAttachment(String, Reply<()>), + DownloadAttachment(String, bool, Reply<()>), /// Delete all conversations from the database. DeleteAllConversations(Reply<()>), diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 7012bf0..4c444e4 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -43,7 +43,8 @@ use post_office::PostOffice; mod attachment_store; pub use attachment_store::Attachment; -use attachment_store::AttachmentStore; +pub use attachment_store::AttachmentStore; +pub use attachment_store::AttachmentStoreEvent; #[derive(Debug, Error)] pub enum DaemonError { @@ -75,7 +76,7 @@ pub struct Daemon { outgoing_messages: HashMap>, - attachment_store: AttachmentStore, + attachment_store_sink: Option>, version: String, database: Arc>, @@ -105,9 +106,6 @@ impl Daemon { let database_impl = Database::new(&database_path.to_string_lossy())?; let database = Arc::new(Mutex::new(database_impl)); - let data_path = Self::get_data_dir().expect("Unable to get data path"); - let attachment_store = AttachmentStore::new(&data_path); - Ok(Self { version: "0.1.0".to_string(), database, @@ -118,7 +116,7 @@ impl Daemon { post_office_sink, post_office_source: Some(post_office_source), outgoing_messages: HashMap::new(), - attachment_store: attachment_store, + attachment_store_sink: None, runtime, }) } @@ -148,6 +146,14 @@ impl Daemon { }); } + // Attachment store + let data_path = Self::get_data_dir().expect("Unable to get data path"); + let mut attachment_store = AttachmentStore::new(&data_path, self.database.clone(), self.event_sender.clone()); + self.attachment_store_sink = Some(attachment_store.get_event_sink()); + tokio::spawn(async move { + attachment_store.run().await; + }); + while let Some(event) = self.event_receiver.recv().await { log::debug!(target: target::EVENT, "Received event: {:?}", event); self.handle_event(event).await; @@ -282,13 +288,24 @@ impl Daemon { } Event::GetAttachment(guid, reply) => { - let attachment = self.attachment_store.get_attachment(&guid); - reply.send(attachment).unwrap(); + self.attachment_store_sink + .as_ref() + .unwrap() + .send(AttachmentStoreEvent::GetAttachmentInfo(guid, reply)) + .await + .unwrap(); } - Event::DownloadAttachment(attachment_id, reply) => { - // For now, just return success - we'll implement the actual download logic later - log::info!(target: target::ATTACHMENTS, "Download requested for attachment: {}", attachment_id); + Event::DownloadAttachment(attachment_id, preview, reply) => { + log::info!(target: target::ATTACHMENTS, "Download requested for attachment: {}, preview: {}", &attachment_id, preview); + + self.attachment_store_sink + .as_ref() + .unwrap() + .send(AttachmentStoreEvent::QueueDownloadAttachment(attachment_id, preview)) + .await + .unwrap(); + reply.send(()).unwrap(); } } diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index a953573..2e7155f 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -11,21 +11,18 @@ use crate::daemon::{ Attachment, DaemonResult, }; -use crate::dbus::endpoint::DbusRegistry; use crate::dbus::interface::NetBuzzertKordophoneRepository as DbusRepository; use crate::dbus::interface::NetBuzzertKordophoneSettings as DbusSettings; #[derive(Clone)] pub struct ServerImpl { event_sink: mpsc::Sender, - dbus_registry: DbusRegistry, } impl ServerImpl { - pub fn new(event_sink: mpsc::Sender, dbus_registry: DbusRegistry) -> Self { + pub fn new(event_sink: mpsc::Sender) -> Self { Self { event_sink: event_sink, - dbus_registry: dbus_registry, } } @@ -187,28 +184,34 @@ impl DbusRepository for ServerImpl { fn get_attachment_info( &mut self, attachment_id: String, - ) -> Result<(String, bool, u32), dbus::MethodErr> { + ) -> Result<(String, String, bool, bool), dbus::MethodErr> { self.send_event_sync(|r| Event::GetAttachment(attachment_id, r)) .map(|attachment| { - let file_size = if attachment.downloaded { - std::fs::metadata(&attachment.path) - .map(|m| m.len() as u32) - .unwrap_or(0) - } else { - 0 - }; - + let path = attachment.get_path(false); + let downloaded = attachment.is_downloaded(false); + + let preview_path = attachment.get_path(true); + let preview_downloaded = attachment.is_downloaded(true); + ( - attachment.path.to_string_lossy().to_string(), - attachment.downloaded, - file_size, + // - path: string + path.to_string_lossy().to_string(), + + // - preview_path: string + preview_path.to_string_lossy().to_string(), + + // - downloaded: boolean + downloaded, + + // - preview_downloaded: boolean + preview_downloaded, ) }) } - fn download_attachment(&mut self, attachment_id: String) -> Result<(), dbus::MethodErr> { + fn download_attachment(&mut self, attachment_id: String, preview: bool) -> Result<(), dbus::MethodErr> { // For now, just trigger the download event - we'll implement the actual download logic later - self.send_event_sync(|r| Event::DownloadAttachment(attachment_id, r)) + self.send_event_sync(|r| Event::DownloadAttachment(attachment_id, preview, r)) } } diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index 4cd6727..c3454f4 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -58,7 +58,7 @@ async fn main() { let dbus_registry = DbusRegistry::new(connection.clone()); // Create and register server implementation - let server = ServerImpl::new(daemon.event_sender.clone(), dbus_registry.clone()); + let server = ServerImpl::new(daemon.event_sender.clone()); dbus_registry.register_object( interface::OBJECT_PATH, From 2e55f3ac9e7768eb501894e1b3e6c0ac0d63f973 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 5 Jun 2025 20:21:30 -0700 Subject: [PATCH 072/138] dbus: remove some signals I wont implement --- .../include/net.buzzert.kordophonecd.Server.xml | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index 34b99a8..1eeee32 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -90,7 +90,7 @@ - + - - - - - - - - - - - + + From 77e1078d6a5f25996b5ba8e6babe32e01df76f1b Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 6 Jun 2025 16:28:29 -0700 Subject: [PATCH 073/138] plumb all known attachments via dbus if known --- Cargo.lock | 1 + kordophoned/Cargo.toml | 1 + .../net.buzzert.kordophonecd.Server.xml | 14 +- kordophoned/src/daemon/attachment_store.rs | 40 ++-- kordophoned/src/daemon/events.rs | 4 +- kordophoned/src/daemon/mod.rs | 13 +- kordophoned/src/daemon/models/attachment.rs | 64 ++++++ kordophoned/src/daemon/models/message.rs | 217 ++++++++++++++++++ kordophoned/src/daemon/models/mod.rs | 5 + kordophoned/src/dbus/server_impl.rs | 63 +++-- 10 files changed, 368 insertions(+), 54 deletions(-) create mode 100644 kordophoned/src/daemon/models/attachment.rs create mode 100644 kordophoned/src/daemon/models/message.rs create mode 100644 kordophoned/src/daemon/models/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 61cc498..01619c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1048,6 +1048,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "chrono", "dbus", "dbus-codegen", "dbus-crossroads", diff --git a/kordophoned/Cargo.toml b/kordophoned/Cargo.toml index 5a25583..1222916 100644 --- a/kordophoned/Cargo.toml +++ b/kordophoned/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] anyhow = "1.0.98" async-trait = "0.1.88" +chrono = "0.4.38" dbus = "0.9.7" dbus-crossroads = "0.5.2" dbus-tokio = "0.7.6" diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index 1eeee32..d771c05 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -65,8 +65,16 @@ 'text' (string): Message body text 'date' (int64): Message timestamp 'sender' (string): Sender display name - 'file_transfer_guids' (string, optional): JSON array of file transfer GUIDs - 'attachment_metadata' (string, optional): JSON string of attachment metadata"/> + 'attachments' (array of dictionaries): List of attachments + 'guid' (string): Attachment GUID + 'path' (string): Attachment path + 'preview_path' (string): Preview attachment path + 'downloaded' (boolean): Whether the attachment is downloaded + 'preview_downloaded' (boolean): Whether the preview is downloaded + 'metadata' (dictionary, optional): Attachment metadata + 'attribution_info' (dictionary, optional): Attribution info + 'width' (int32, optional): Width + 'height' (int32, optional): Height"/> @@ -116,7 +124,7 @@ - + diff --git a/kordophoned/src/daemon/attachment_store.rs b/kordophoned/src/daemon/attachment_store.rs index 429c19c..e0cd3a7 100644 --- a/kordophoned/src/daemon/attachment_store.rs +++ b/kordophoned/src/daemon/attachment_store.rs @@ -13,6 +13,7 @@ use kordophone_db::database::DatabaseAccess; use crate::daemon::events::Event; use crate::daemon::events::Reply; +use crate::daemon::models::Attachment; use crate::daemon::Daemon; use std::sync::Arc; @@ -26,23 +27,6 @@ mod target { pub static ATTACHMENTS: &str = "attachments"; } -#[derive(Debug, Clone)] -pub struct Attachment { - pub guid: String, - pub base_path: PathBuf, -} - -impl Attachment { - pub fn get_path(&self, preview: bool) -> PathBuf { - self.base_path.with_extension(if preview { "preview" } else { "full" }) - } - - pub fn is_downloaded(&self, preview: bool) -> bool { - std::fs::exists(&self.get_path(preview)) - .expect(format!("Wasn't able to check for the existence of an attachment file path at {}", &self.get_path(preview).display()).as_str()) - } -} - #[derive(Debug)] pub enum AttachmentStoreEvent { // Get the attachment info for a given attachment guid. @@ -75,8 +59,13 @@ pub struct AttachmentStore { } impl AttachmentStore { - pub fn new(data_dir: &PathBuf, database: Arc>, daemon_event_sink: Sender) -> AttachmentStore { - let store_path = data_dir.join("attachments"); + pub fn get_default_store_path() -> PathBuf { + let data_dir = Daemon::get_data_dir().expect("Unable to get data path"); + data_dir.join("attachments") + } + + pub fn new(database: Arc>, daemon_event_sink: Sender) -> AttachmentStore { + let store_path = Self::get_default_store_path(); log::info!(target: target::ATTACHMENTS, "Attachment store path: {}", store_path.display()); // Create the attachment store if it doesn't exist @@ -98,11 +87,16 @@ impl AttachmentStore { self.event_sink.take().unwrap() } - fn get_attachment(&self, guid: &String, preview: bool) -> Attachment { - let base_path = self.store_path.join(guid); + fn get_attachment(&self, guid: &String) -> Attachment { + Self::get_attachment_impl(&self.store_path, guid) + } + + pub fn get_attachment_impl(store_path: &PathBuf, guid: &String) -> Attachment { + let base_path = store_path.join(guid); Attachment { guid: guid.to_owned(), base_path: base_path, + metadata: None, } } @@ -147,7 +141,7 @@ impl AttachmentStore { match event { AttachmentStoreEvent::QueueDownloadAttachment(guid, preview) => { - let attachment = self.get_attachment(&guid, preview); + let attachment = self.get_attachment(&guid); if !attachment.is_downloaded(preview) { self.download_attachment(&attachment, preview).await.unwrap_or_else(|e| { log::error!(target: target::ATTACHMENTS, "Error downloading attachment: {}", e); @@ -158,7 +152,7 @@ impl AttachmentStore { } AttachmentStoreEvent::GetAttachmentInfo(guid, reply) => { - let attachment = self.get_attachment(&guid, false); + let attachment = self.get_attachment(&guid); reply.send(attachment).unwrap(); } } diff --git a/kordophoned/src/daemon/events.rs b/kordophoned/src/daemon/events.rs index 2e11ff2..627461e 100644 --- a/kordophoned/src/daemon/events.rs +++ b/kordophoned/src/daemon/events.rs @@ -3,10 +3,10 @@ use uuid::Uuid; use kordophone::model::ConversationID; use kordophone::model::OutgoingMessage; -use kordophone_db::models::{Conversation, Message}; +use kordophone_db::models::Conversation; use crate::daemon::settings::Settings; -use crate::daemon::Attachment; +use crate::daemon::{Attachment, Message}; pub type Reply = oneshot::Sender; diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 4c444e4..c735034 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -23,7 +23,7 @@ use uuid::Uuid; use kordophone_db::{ database::{Database, DatabaseAccess}, - models::{Conversation, Message}, + models::Conversation, }; use kordophone::api::http_client::HTTPAPIClient; @@ -41,8 +41,11 @@ mod post_office; use post_office::Event as PostOfficeEvent; use post_office::PostOffice; +mod models; +pub use models::Attachment; +pub use models::Message; + mod attachment_store; -pub use attachment_store::Attachment; pub use attachment_store::AttachmentStore; pub use attachment_store::AttachmentStoreEvent; @@ -147,8 +150,7 @@ impl Daemon { } // Attachment store - let data_path = Self::get_data_dir().expect("Unable to get data path"); - let mut attachment_store = AttachmentStore::new(&data_path, self.database.clone(), self.event_sender.clone()); + let mut attachment_store = AttachmentStore::new(self.database.clone(), self.event_sender.clone()); self.attachment_store_sink = Some(attachment_store.get_event_sink()); tokio::spawn(async move { attachment_store.run().await; @@ -270,7 +272,7 @@ impl Daemon { self.database .lock() .await - .with_repository(|r| r.insert_message(&conversation_id, message)) + .with_repository(|r| r.insert_message(&conversation_id, message.into())) .await .unwrap(); @@ -355,6 +357,7 @@ impl Daemon { r.get_messages_for_conversation(&conversation_id) .unwrap() .into_iter() + .map(|m| m.into()) // Convert db::Message to daemon::Message .chain(outgoing_messages.into_iter().map(|m| m.into())) .collect() }) diff --git a/kordophoned/src/daemon/models/attachment.rs b/kordophoned/src/daemon/models/attachment.rs new file mode 100644 index 0000000..51ab0c9 --- /dev/null +++ b/kordophoned/src/daemon/models/attachment.rs @@ -0,0 +1,64 @@ +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct AttachmentMetadata { + pub attribution_info: Option, +} + +#[derive(Debug, Clone)] +pub struct AttributionInfo { + pub width: Option, + pub height: Option, +} + +#[derive(Debug, Clone)] +pub struct Attachment { + pub guid: String, + pub base_path: PathBuf, + pub metadata: Option, +} + +impl Attachment { + pub fn get_path(&self, preview: bool) -> PathBuf { + self.base_path.with_extension(if preview { "preview" } else { "full" }) + } + + pub fn is_downloaded(&self, preview: bool) -> bool { + std::fs::exists(&self.get_path(preview)) + .expect(format!("Wasn't able to check for the existence of an attachment file path at {}", &self.get_path(preview).display()).as_str()) + } +} + +impl From for AttachmentMetadata { + fn from(metadata: kordophone::model::message::AttachmentMetadata) -> Self { + Self { + attribution_info: metadata.attribution_info.map(|info| info.into()), + } + } +} + +impl From for AttributionInfo { + fn from(info: kordophone::model::message::AttributionInfo) -> Self { + Self { + width: info.width, + height: info.height, + } + } +} + +impl From for kordophone::model::message::AttachmentMetadata { + fn from(metadata: AttachmentMetadata) -> Self { + Self { + attribution_info: metadata.attribution_info.map(|info| info.into()), + } + } +} + +impl From for kordophone::model::message::AttributionInfo { + fn from(info: AttributionInfo) -> Self { + Self { + width: info.width, + height: info.height, + } + } +} \ No newline at end of file diff --git a/kordophoned/src/daemon/models/message.rs b/kordophoned/src/daemon/models/message.rs new file mode 100644 index 0000000..da40f80 --- /dev/null +++ b/kordophoned/src/daemon/models/message.rs @@ -0,0 +1,217 @@ +use chrono::NaiveDateTime; +use chrono::DateTime; + +use std::collections::HashMap; +use uuid::Uuid; +use kordophone::model::message::AttachmentMetadata; +use kordophone::model::outgoing_message::OutgoingMessage; +use crate::daemon::models::Attachment; +use crate::daemon::attachment_store::AttachmentStore; + +#[derive(Clone, Debug)] +pub enum Participant { + Me, + Remote { + id: Option, + display_name: String, + }, +} + +impl From for Participant { + fn from(display_name: String) -> Self { + Participant::Remote { + id: None, + display_name, + } + } +} + +impl From<&str> for Participant { + fn from(display_name: &str) -> Self { + Participant::Remote { + id: None, + display_name: display_name.to_string(), + } + } +} + +impl From for Participant { + fn from(participant: kordophone_db::models::Participant) -> Self { + match participant { + kordophone_db::models::Participant::Me => Participant::Me, + kordophone_db::models::Participant::Remote { id, display_name } => { + Participant::Remote { id, display_name } + } + } + } +} + +impl Participant { + pub fn display_name(&self) -> String { + match self { + Participant::Me => "(Me)".to_string(), + Participant::Remote { display_name, .. } => display_name.clone(), + } + } +} + +#[derive(Clone, Debug)] +pub struct Message { + pub id: String, + pub sender: Participant, + pub text: String, + pub date: NaiveDateTime, + pub attachments: Vec, +} + +impl Message { + pub fn builder() -> MessageBuilder { + MessageBuilder::new() + } +} + +fn attachments_from(file_transfer_guids: &Vec, attachment_metadata: &Option>) -> Vec { + file_transfer_guids + .iter() + .map(|guid| { + let mut attachment = AttachmentStore::get_attachment_impl(&AttachmentStore::get_default_store_path(), guid); + attachment.metadata = match attachment_metadata { + Some(attachment_metadata) => attachment_metadata.get(guid).cloned().map(|metadata| metadata.into()), + None => None, + }; + + attachment + }) + .collect() +} + +impl From for Message { + fn from(message: kordophone_db::models::Message) -> Self { + let attachments = attachments_from(&message.file_transfer_guids, &message.attachment_metadata); + Self { + id: message.id, + sender: message.sender.into(), + text: message.text, + date: message.date, + attachments, + } + } +} + +impl From for kordophone_db::models::Message { + fn from(message: Message) -> Self { + Self { + id: message.id, + sender: match message.sender { + Participant::Me => kordophone_db::models::Participant::Me, + Participant::Remote { id, display_name } => { + kordophone_db::models::Participant::Remote { id, display_name } + } + }, + text: message.text, + date: message.date, + file_transfer_guids: message.attachments.iter().map(|a| a.guid.clone()).collect(), + attachment_metadata: { + let metadata_map: HashMap = message.attachments + .iter() + .filter_map(|a| a.metadata.as_ref().map(|m| (a.guid.clone(), m.clone().into()))) + .collect(); + if metadata_map.is_empty() { None } else { Some(metadata_map) } + }, + } + } +} + +impl From for Message { + fn from(message: kordophone::model::Message) -> Self { + let attachments = attachments_from(&message.file_transfer_guids, &message.attachment_metadata); + Self { + id: message.guid, + sender: match message.sender { + Some(sender) => Participant::Remote { + id: None, + display_name: sender, + }, + None => Participant::Me, + }, + text: message.text, + date: DateTime::from_timestamp( + message.date.unix_timestamp(), + message.date.unix_timestamp_nanos() + .try_into() + .unwrap_or(0), + ) + .unwrap() + .naive_local(), + attachments, + } + } +} + +impl From<&OutgoingMessage> for Message { + fn from(value: &OutgoingMessage) -> Self { + Self { + id: value.guid.to_string(), + sender: Participant::Me, + text: value.text.clone(), + date: value.date, + attachments: Vec::new(), // Outgoing messages don't have attachments initially + } + } +} + +pub struct MessageBuilder { + id: Option, + sender: Option, + text: Option, + date: Option, + attachments: Vec, +} + +impl Default for MessageBuilder { + fn default() -> Self { + Self::new() + } +} + +impl MessageBuilder { + pub fn new() -> Self { + Self { + id: None, + sender: None, + text: None, + date: None, + attachments: Vec::new(), + } + } + + pub fn sender(mut self, sender: Participant) -> Self { + self.sender = Some(sender); + self + } + + pub fn text(mut self, text: String) -> Self { + self.text = Some(text); + self + } + + pub fn date(mut self, date: NaiveDateTime) -> Self { + self.date = Some(date); + self + } + + pub fn attachments(mut self, attachments: Vec) -> Self { + self.attachments = attachments; + self + } + + pub fn build(self) -> Message { + Message { + id: self.id.unwrap_or_else(|| Uuid::new_v4().to_string()), + sender: self.sender.unwrap_or(Participant::Me), + text: self.text.unwrap_or_default(), + date: self.date.unwrap_or_else(|| chrono::Utc::now().naive_utc()), + attachments: self.attachments, + } + } +} \ No newline at end of file diff --git a/kordophoned/src/daemon/models/mod.rs b/kordophoned/src/daemon/models/mod.rs new file mode 100644 index 0000000..8a63c4a --- /dev/null +++ b/kordophoned/src/daemon/models/mod.rs @@ -0,0 +1,5 @@ +pub mod attachment; +pub mod message; + +pub use attachment::Attachment; +pub use message::Message; \ No newline at end of file diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 2e7155f..f66f676 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -138,29 +138,50 @@ impl DbusRepository for ServerImpl { arg::Variant(Box::new(msg.sender.display_name())), ); - // Add file transfer GUIDs if present - if !msg.file_transfer_guids.is_empty() { - match serde_json::to_string(&msg.file_transfer_guids) { - Ok(json_str) => { - map.insert("file_transfer_guids".into(), arg::Variant(Box::new(json_str))); + // Add attachments array + let attachments: Vec = msg.attachments + .into_iter() + .map(|attachment| { + let mut attachment_map = arg::PropMap::new(); + attachment_map.insert("guid".into(), arg::Variant(Box::new(attachment.guid.clone()))); + + // Get attachment paths and download status + let path = attachment.get_path(false); + let preview_path = attachment.get_path(true); + let downloaded = attachment.is_downloaded(false); + let preview_downloaded = attachment.is_downloaded(true); + + attachment_map.insert("path".into(), arg::Variant(Box::new(path.to_string_lossy().to_string()))); + attachment_map.insert("preview_path".into(), arg::Variant(Box::new(preview_path.to_string_lossy().to_string()))); + attachment_map.insert("downloaded".into(), arg::Variant(Box::new(downloaded))); + attachment_map.insert("preview_downloaded".into(), arg::Variant(Box::new(preview_downloaded))); + + // Add metadata if present + if let Some(ref metadata) = attachment.metadata { + let mut metadata_map = arg::PropMap::new(); + + // Add attribution_info if present + if let Some(ref attribution_info) = metadata.attribution_info { + let mut attribution_map = arg::PropMap::new(); + + if let Some(width) = attribution_info.width { + attribution_map.insert("width".into(), arg::Variant(Box::new(width as i32))); + } + if let Some(height) = attribution_info.height { + attribution_map.insert("height".into(), arg::Variant(Box::new(height as i32))); + } + + metadata_map.insert("attribution_info".into(), arg::Variant(Box::new(attribution_map))); + } + + attachment_map.insert("metadata".into(), arg::Variant(Box::new(metadata_map))); } - Err(e) => { - log::warn!("Failed to serialize file transfer GUIDs for message {}: {}", msg_id, e); - } - } - } + + attachment_map + }) + .collect(); - // Add attachment metadata if present - if let Some(ref attachment_metadata) = msg.attachment_metadata { - match serde_json::to_string(attachment_metadata) { - Ok(json_str) => { - map.insert("attachment_metadata".into(), arg::Variant(Box::new(json_str))); - } - Err(e) => { - log::warn!("Failed to serialize attachment metadata for message {}: {}", msg_id, e); - } - } - } + map.insert("attachments".into(), arg::Variant(Box::new(attachments))); map }) From 9e8c976a0efccbc5cbd7bbed39b1fb1f12f7a030 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 6 Jun 2025 16:30:22 -0700 Subject: [PATCH 074/138] remove some unused builder code in daemon::models::message --- kordophoned/src/daemon/models/message.rs | 62 ------------------------ 1 file changed, 62 deletions(-) diff --git a/kordophoned/src/daemon/models/message.rs b/kordophoned/src/daemon/models/message.rs index da40f80..297dcc7 100644 --- a/kordophoned/src/daemon/models/message.rs +++ b/kordophoned/src/daemon/models/message.rs @@ -64,12 +64,6 @@ pub struct Message { pub attachments: Vec, } -impl Message { - pub fn builder() -> MessageBuilder { - MessageBuilder::new() - } -} - fn attachments_from(file_transfer_guids: &Vec, attachment_metadata: &Option>) -> Vec { file_transfer_guids .iter() @@ -159,59 +153,3 @@ impl From<&OutgoingMessage> for Message { } } } - -pub struct MessageBuilder { - id: Option, - sender: Option, - text: Option, - date: Option, - attachments: Vec, -} - -impl Default for MessageBuilder { - fn default() -> Self { - Self::new() - } -} - -impl MessageBuilder { - pub fn new() -> Self { - Self { - id: None, - sender: None, - text: None, - date: None, - attachments: Vec::new(), - } - } - - pub fn sender(mut self, sender: Participant) -> Self { - self.sender = Some(sender); - self - } - - pub fn text(mut self, text: String) -> Self { - self.text = Some(text); - self - } - - pub fn date(mut self, date: NaiveDateTime) -> Self { - self.date = Some(date); - self - } - - pub fn attachments(mut self, attachments: Vec) -> Self { - self.attachments = attachments; - self - } - - pub fn build(self) -> Message { - Message { - id: self.id.unwrap_or_else(|| Uuid::new_v4().to_string()), - sender: self.sender.unwrap_or(Participant::Me), - text: self.text.unwrap_or_default(), - date: self.date.unwrap_or_else(|| chrono::Utc::now().naive_utc()), - attachments: self.attachments, - } - } -} \ No newline at end of file From 8cd72d94177501ca0db9e9fd51a6b75289f3034c Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 6 Jun 2025 16:35:51 -0700 Subject: [PATCH 075/138] cargo fix --- kordophone/src/api/http_client.rs | 2 +- kordophone/src/api/mod.rs | 3 +-- kordophone/src/tests/test_client.rs | 6 ++++-- kordophoned/src/daemon/attachment_store.rs | 2 -- kordophoned/src/daemon/auth_store.rs | 1 - kordophoned/src/daemon/mod.rs | 2 +- kordophoned/src/daemon/models/message.rs | 1 - kordophoned/src/dbus/server_impl.rs | 3 +-- 8 files changed, 8 insertions(+), 12 deletions(-) diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index 8d58724..e0f4249 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -16,7 +16,7 @@ use tokio::net::TcpStream; use futures_util::stream::{BoxStream, Stream}; use futures_util::task::Context; -use futures_util::{SinkExt, StreamExt, TryStreamExt}; +use futures_util::{StreamExt, TryStreamExt}; use tokio_tungstenite::connect_async; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index b0326db..0f63308 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -16,8 +16,7 @@ pub mod event_socket; pub use event_socket::EventSocket; use self::http_client::Credentials; -use core::error::Error as StdError; -use std::{fmt::Debug, io::BufRead}; +use std::fmt::Debug; #[async_trait] pub trait APIInterface { diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index a9f548e..a8ca377 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -16,6 +16,7 @@ use crate::{ use futures_util::stream::BoxStream; use futures_util::StreamExt; +use bytes::Bytes; pub struct TestClient { pub version: &'static str, @@ -67,6 +68,7 @@ impl EventSocket for TestEventSocket { #[async_trait] impl APIInterface for TestClient { type Error = TestError; + type ResponseStream = BoxStream<'static, Result>; async fn authenticate(&mut self, _credentials: Credentials) -> Result { Ok(JwtToken::dummy()) @@ -118,7 +120,7 @@ impl APIInterface for TestClient { Ok(TestEventSocket::new()) } - async fn fetch_attachment_data(&mut self, guid: &String) -> Result, Self::Error> { - Ok(vec![]) + async fn fetch_attachment_data(&mut self, guid: &String, preview: bool) -> Result { + Ok(futures_util::stream::iter(vec![Ok(Bytes::from_static(b"test"))]).boxed()) } } diff --git a/kordophoned/src/daemon/attachment_store.rs b/kordophoned/src/daemon/attachment_store.rs index e0cd3a7..6395ea0 100644 --- a/kordophoned/src/daemon/attachment_store.rs +++ b/kordophoned/src/daemon/attachment_store.rs @@ -9,7 +9,6 @@ use kordophone::APIInterface; use thiserror::Error; use kordophone_db::database::Database; -use kordophone_db::database::DatabaseAccess; use crate::daemon::events::Event; use crate::daemon::events::Reply; @@ -21,7 +20,6 @@ use tokio::sync::Mutex; use tokio::sync::mpsc::{Receiver, Sender}; use tokio::pin; -use tokio::time::Duration; mod target { pub static ATTACHMENTS: &str = "attachments"; diff --git a/kordophoned/src/daemon/auth_store.rs b/kordophoned/src/daemon/auth_store.rs index 283b169..35a789a 100644 --- a/kordophoned/src/daemon/auth_store.rs +++ b/kordophoned/src/daemon/auth_store.rs @@ -5,7 +5,6 @@ use std::sync::Arc; use tokio::sync::Mutex; use kordophone::api::{http_client::Credentials, AuthenticationStore}; -use kordophone::model::JwtToken; use kordophone_db::database::{Database, DatabaseAccess}; use async_trait::async_trait; diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index c735034..3f64f3c 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -13,7 +13,7 @@ use directories::ProjectDirs; use std::collections::HashMap; use std::error::Error; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::Arc; use thiserror::Error; diff --git a/kordophoned/src/daemon/models/message.rs b/kordophoned/src/daemon/models/message.rs index 297dcc7..4735275 100644 --- a/kordophoned/src/daemon/models/message.rs +++ b/kordophoned/src/daemon/models/message.rs @@ -2,7 +2,6 @@ use chrono::NaiveDateTime; use chrono::DateTime; use std::collections::HashMap; -use uuid::Uuid; use kordophone::model::message::AttachmentMetadata; use kordophone::model::outgoing_message::OutgoingMessage; use crate::daemon::models::Attachment; diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index f66f676..4053a61 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -7,8 +7,7 @@ use tokio::sync::oneshot; use crate::daemon::{ events::{Event, Reply}, - settings::Settings, - Attachment, DaemonResult, + settings::Settings, DaemonResult, }; use crate::dbus::interface::NetBuzzertKordophoneRepository as DbusRepository; From 1d3b2f25bac69bf63c0de44a404edebaa1dfe471 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 6 Jun 2025 16:39:31 -0700 Subject: [PATCH 076/138] cargo fmt --- kordophone-db/src/database.rs | 5 +- kordophone-db/src/lib.rs | 2 +- kordophone-db/src/models/conversation.rs | 17 +- kordophone-db/src/models/db/conversation.rs | 15 +- kordophone-db/src/models/db/message.rs | 22 +-- kordophone-db/src/models/db/mod.rs | 2 +- kordophone-db/src/models/db/participant.rs | 8 +- kordophone-db/src/models/message.rs | 24 +-- kordophone-db/src/models/mod.rs | 6 +- kordophone-db/src/repository.rs | 69 +++++--- kordophone-db/src/schema.rs | 14 +- kordophone-db/src/settings.rs | 30 ++-- kordophone-db/src/tests/mod.rs | 172 ++++++++++++++------ kordophone/src/api/auth.rs | 2 +- kordophone/src/api/event_socket.rs | 12 +- kordophone/src/lib.rs | 2 +- kordophone/src/model/conversation.rs | 7 +- kordophone/src/model/event.rs | 24 ++- kordophone/src/model/jwt.rs | 10 +- kordophone/src/model/message.rs | 24 +-- kordophone/src/model/mod.rs | 2 +- kordophone/src/model/outgoing_message.rs | 10 +- kordophone/src/model/update.rs | 10 +- kordophone/src/tests/mod.rs | 4 +- kordophone/src/tests/test_client.rs | 8 +- kordophoned/build.rs | 10 +- kordophoned/src/daemon/attachment_store.rs | 13 +- kordophoned/src/daemon/mod.rs | 8 +- kordophoned/src/daemon/models/attachment.rs | 16 +- kordophoned/src/daemon/models/message.rs | 63 ++++--- kordophoned/src/daemon/models/mod.rs | 2 +- kordophoned/src/daemon/post_office.rs | 34 ++-- kordophoned/src/daemon/settings.rs | 10 +- kordophoned/src/daemon/update_monitor.rs | 61 +++---- kordophoned/src/dbus/endpoint.rs | 4 +- kordophoned/src/dbus/mod.rs | 2 +- kordophoned/src/dbus/server_impl.rs | 85 +++++++--- kordophoned/src/main.rs | 8 +- kpcli/build.rs | 12 +- kpcli/src/client/mod.rs | 47 +++--- kpcli/src/daemon/mod.rs | 70 +++++--- kpcli/src/db/mod.rs | 161 ++++++++++-------- kpcli/src/main.rs | 9 +- kpcli/src/printers.rs | 147 ++++++++++------- 44 files changed, 758 insertions(+), 505 deletions(-) diff --git a/kordophone-db/src/database.rs b/kordophone-db/src/database.rs index d7eb7d3..b0fe629 100644 --- a/kordophone-db/src/database.rs +++ b/kordophone-db/src/database.rs @@ -1,6 +1,6 @@ use anyhow::Result; -use diesel::prelude::*; use async_trait::async_trait; +use diesel::prelude::*; pub use std::sync::Arc; pub use tokio::sync::Mutex; @@ -31,7 +31,8 @@ pub struct Database { impl Database { pub fn new(path: &str) -> Result { let mut connection = SqliteConnection::establish(path)?; - connection.run_pending_migrations(MIGRATIONS) + connection + .run_pending_migrations(MIGRATIONS) .map_err(|e| anyhow::anyhow!("Error running migrations: {}", e))?; Ok(Self { connection }) diff --git a/kordophone-db/src/lib.rs b/kordophone-db/src/lib.rs index abdc8f7..e026a7d 100644 --- a/kordophone-db/src/lib.rs +++ b/kordophone-db/src/lib.rs @@ -7,4 +7,4 @@ pub mod settings; #[cfg(test)] mod tests; -pub use repository::Repository; \ No newline at end of file +pub use repository::Repository; diff --git a/kordophone-db/src/models/conversation.rs b/kordophone-db/src/models/conversation.rs index 85a9a96..c7de2de 100644 --- a/kordophone-db/src/models/conversation.rs +++ b/kordophone-db/src/models/conversation.rs @@ -1,6 +1,6 @@ +use crate::models::participant::Participant; use chrono::{DateTime, NaiveDateTime}; use uuid::Uuid; -use crate::models::participant::Participant; #[derive(Clone, Debug)] pub struct Conversation { @@ -33,18 +33,17 @@ impl From for Conversation { fn from(value: kordophone::model::Conversation) -> Self { Self { guid: value.guid, - unread_count: u16::try_from(value.unread_count).unwrap(), + unread_count: u16::try_from(value.unread_count).unwrap(), display_name: value.display_name, last_message_preview: value.last_message_preview, date: DateTime::from_timestamp( value.date.unix_timestamp(), - value.date.unix_timestamp_nanos() - .try_into() - .unwrap_or(0), - ) - .unwrap() - .naive_local(), - participants: value.participant_display_names + value.date.unix_timestamp_nanos().try_into().unwrap_or(0), + ) + .unwrap() + .naive_local(), + participants: value + .participant_display_names .into_iter() .map(|p| p.into()) .collect(), diff --git a/kordophone-db/src/models/db/conversation.rs b/kordophone-db/src/models/db/conversation.rs index f1b37c5..f249265 100644 --- a/kordophone-db/src/models/db/conversation.rs +++ b/kordophone-db/src/models/db/conversation.rs @@ -1,9 +1,6 @@ -use diesel::prelude::*; +use crate::models::{db::participant::InsertableRecord as InsertableParticipant, Conversation}; use chrono::NaiveDateTime; -use crate::models::{ - Conversation, - db::participant::InsertableRecord as InsertableParticipant, -}; +use diesel::prelude::*; #[derive(Queryable, Selectable, Insertable, AsChangeset, Clone, Identifiable)] #[diesel(table_name = crate::schema::conversations)] @@ -33,11 +30,11 @@ impl From for (Record, Vec) { fn from(conversation: Conversation) -> Self { ( Record::from(conversation.clone()), - - conversation.participants + conversation + .participants .into_iter() .map(InsertableParticipant::from) - .collect() + .collect(), ) } } @@ -53,4 +50,4 @@ impl From for Conversation { participants: vec![], } } -} \ No newline at end of file +} diff --git a/kordophone-db/src/models/db/message.rs b/kordophone-db/src/models/db/message.rs index 736c28d..9ca6e72 100644 --- a/kordophone-db/src/models/db/message.rs +++ b/kordophone-db/src/models/db/message.rs @@ -1,6 +1,6 @@ -use diesel::prelude::*; -use chrono::NaiveDateTime; use crate::models::{Message, Participant}; +use chrono::NaiveDateTime; +use diesel::prelude::*; #[derive(Queryable, Selectable, Insertable, AsChangeset, Clone, Identifiable, Debug)] #[diesel(table_name = crate::schema::messages)] @@ -21,10 +21,11 @@ impl From for Record { } else { Some(serde_json::to_string(&message.file_transfer_guids).unwrap_or_default()) }; - - let attachment_metadata = message.attachment_metadata + + let attachment_metadata = message + .attachment_metadata .map(|metadata| serde_json::to_string(&metadata).unwrap_or_default()); - + Self { id: message.id, sender_participant_id: match message.sender { @@ -41,13 +42,15 @@ impl From for Record { impl From for Message { fn from(record: Record) -> Self { - let file_transfer_guids = record.file_transfer_guids + let file_transfer_guids = record + .file_transfer_guids .and_then(|json| serde_json::from_str(&json).ok()) .unwrap_or_default(); - - let attachment_metadata = record.attachment_metadata + + let attachment_metadata = record + .attachment_metadata .and_then(|json| serde_json::from_str(&json).ok()); - + Self { id: record.id, // We'll set the proper sender later when loading participant info @@ -59,4 +62,3 @@ impl From for Message { } } } - diff --git a/kordophone-db/src/models/db/mod.rs b/kordophone-db/src/models/db/mod.rs index eeedf6c..7cbefc7 100644 --- a/kordophone-db/src/models/db/mod.rs +++ b/kordophone-db/src/models/db/mod.rs @@ -1,3 +1,3 @@ pub mod conversation; +pub mod message; pub mod participant; -pub mod message; \ No newline at end of file diff --git a/kordophone-db/src/models/db/participant.rs b/kordophone-db/src/models/db/participant.rs index e40dae9..0ce7150 100644 --- a/kordophone-db/src/models/db/participant.rs +++ b/kordophone-db/src/models/db/participant.rs @@ -1,6 +1,6 @@ -use diesel::prelude::*; use crate::models::Participant; use crate::schema::conversation_participants; +use diesel::prelude::*; #[derive(Queryable, Selectable, AsChangeset, Identifiable)] #[diesel(table_name = crate::schema::participants)] @@ -27,7 +27,7 @@ impl From for InsertableRecord { Participant::Remote { display_name, .. } => InsertableRecord { display_name: Some(display_name), is_me: false, - } + }, } } } @@ -67,7 +67,7 @@ impl From for Record { id: 0, // This will be set by the database display_name: Some(display_name), is_me: false, - } + }, } } -} \ No newline at end of file +} diff --git a/kordophone-db/src/models/message.rs b/kordophone-db/src/models/message.rs index 100ffcc..d57e086 100644 --- a/kordophone-db/src/models/message.rs +++ b/kordophone-db/src/models/message.rs @@ -1,11 +1,11 @@ +use crate::models::participant::Participant; use chrono::{DateTime, NaiveDateTime}; +use kordophone::model::message::AttachmentMetadata; +use kordophone::model::outgoing_message::OutgoingMessage; use std::collections::HashMap; use uuid::Uuid; -use crate::models::participant::Participant; -use kordophone::model::outgoing_message::OutgoingMessage; -use kordophone::model::message::AttachmentMetadata; -#[derive(Clone, Debug)] +#[derive(Clone, Debug)] pub struct Message { pub id: String, pub sender: Participant, @@ -35,12 +35,10 @@ impl From for Message { text: value.text, date: DateTime::from_timestamp( value.date.unix_timestamp(), - value.date.unix_timestamp_nanos() - .try_into() - .unwrap_or(0), - ) - .unwrap() - .naive_local(), + value.date.unix_timestamp_nanos().try_into().unwrap_or(0), + ) + .unwrap() + .naive_local(), file_transfer_guids: value.file_transfer_guids, attachment_metadata: value.attachment_metadata, } @@ -107,7 +105,10 @@ impl MessageBuilder { self } - pub fn attachment_metadata(mut self, attachment_metadata: HashMap) -> Self { + pub fn attachment_metadata( + mut self, + attachment_metadata: HashMap, + ) -> Self { self.attachment_metadata = Some(attachment_metadata); self } @@ -123,4 +124,3 @@ impl MessageBuilder { } } } - diff --git a/kordophone-db/src/models/mod.rs b/kordophone-db/src/models/mod.rs index 206eb44..13571fc 100644 --- a/kordophone-db/src/models/mod.rs +++ b/kordophone-db/src/models/mod.rs @@ -1,8 +1,8 @@ pub mod conversation; -pub mod participant; -pub mod message; pub mod db; +pub mod message; +pub mod participant; pub use conversation::Conversation; +pub use message::Message; pub use participant::Participant; -pub use message::Message; \ No newline at end of file diff --git a/kordophone-db/src/repository.rs b/kordophone-db/src/repository.rs index eb18fad..c537e07 100644 --- a/kordophone-db/src/repository.rs +++ b/kordophone-db/src/repository.rs @@ -4,16 +4,13 @@ use diesel::query_dsl::BelongingToDsl; use crate::{ models::{ - Conversation, - Message, - Participant, db::conversation::Record as ConversationRecord, - db::participant::{ - ConversationParticipant, - Record as ParticipantRecord, - InsertableRecord as InsertableParticipantRecord - }, db::message::Record as MessageRecord, + db::participant::{ + ConversationParticipant, InsertableRecord as InsertableParticipantRecord, + Record as ParticipantRecord, + }, + Conversation, Message, Participant, }, schema, }; @@ -28,9 +25,9 @@ impl<'a> Repository<'a> { } pub fn insert_conversation(&mut self, conversation: Conversation) -> Result<()> { + use crate::schema::conversation_participants::dsl::*; use crate::schema::conversations::dsl::*; use crate::schema::participants::dsl::*; - use crate::schema::conversation_participants::dsl::*; let (db_conversation, db_participants) = conversation.into(); @@ -76,7 +73,8 @@ impl<'a> Repository<'a> { .load::(self.connection)?; let mut model_conversation: Conversation = conversation.into(); - model_conversation.participants = db_participants.into_iter().map(|p| p.into()).collect(); + model_conversation.participants = + db_participants.into_iter().map(|p| p.into()).collect(); return Ok(Some(model_conversation)); } @@ -102,7 +100,8 @@ impl<'a> Repository<'a> { .load::(self.connection)?; let mut model_conversation: Conversation = db_conversation.into(); - model_conversation.participants = db_participants.into_iter().map(|p| p.into()).collect(); + model_conversation.participants = + db_participants.into_iter().map(|p| p.into()).collect(); result.push(model_conversation); } @@ -111,8 +110,8 @@ impl<'a> Repository<'a> { } pub fn insert_message(&mut self, conversation_guid: &str, message: Message) -> Result<()> { - use crate::schema::messages::dsl::*; use crate::schema::conversation_messages::dsl::*; + use crate::schema::messages::dsl::*; // Handle participant if message has a remote sender let sender = message.sender.clone(); @@ -136,9 +135,13 @@ impl<'a> Repository<'a> { Ok(()) } - pub fn insert_messages(&mut self, conversation_guid: &str, in_messages: Vec) -> Result<()> { - use crate::schema::messages::dsl::*; + pub fn insert_messages( + &mut self, + conversation_guid: &str, + in_messages: Vec, + ) -> Result<()> { use crate::schema::conversation_messages::dsl::*; + use crate::schema::messages::dsl::*; // Local insertable struct for the join table #[derive(Insertable)] @@ -154,7 +157,8 @@ impl<'a> Repository<'a> { // Build the collections of insertable records let mut db_messages: Vec = Vec::with_capacity(in_messages.len()); - let mut conv_msg_records: Vec = Vec::with_capacity(in_messages.len()); + let mut conv_msg_records: Vec = + Vec::with_capacity(in_messages.len()); for message in in_messages { // Handle participant if message has a remote sender @@ -186,9 +190,12 @@ impl<'a> Repository<'a> { Ok(()) } - pub fn get_messages_for_conversation(&mut self, conversation_guid: &str) -> Result> { - use crate::schema::messages::dsl::*; + pub fn get_messages_for_conversation( + &mut self, + conversation_guid: &str, + ) -> Result> { use crate::schema::conversation_messages::dsl::*; + use crate::schema::messages::dsl::*; use crate::schema::participants::dsl::*; let message_records = conversation_messages @@ -201,7 +208,7 @@ impl<'a> Repository<'a> { let mut result = Vec::new(); for message_record in message_records { let mut message: Message = message_record.clone().into(); - + // If there's a sender_participant_id, load the participant info if let Some(pid) = message_record.sender_participant_id { let participant = participants @@ -216,9 +223,12 @@ impl<'a> Repository<'a> { Ok(result) } - pub fn get_last_message_for_conversation(&mut self, conversation_guid: &str) -> Result> { - use crate::schema::messages::dsl::*; + pub fn get_last_message_for_conversation( + &mut self, + conversation_guid: &str, + ) -> Result> { use crate::schema::conversation_messages::dsl::*; + use crate::schema::messages::dsl::*; let message_record = conversation_messages .filter(conversation_id.eq(conversation_guid)) @@ -247,7 +257,11 @@ impl<'a> Repository<'a> { let conversation = self.get_conversation_by_guid(conversation_guid)?; if let Some(mut conversation) = conversation { if let Some(last_message) = self.get_last_message_for_conversation(conversation_guid)? { - log::debug!("Updating conversation metadata: {} message: {:?}", conversation_guid, last_message); + log::debug!( + "Updating conversation metadata: {} message: {:?}", + conversation_guid, + last_message + ); conversation.date = last_message.date; conversation.last_message_preview = Some(last_message.text.clone()); self.insert_conversation(conversation)?; @@ -261,14 +275,21 @@ impl<'a> Repository<'a> { // This is a workaround since the Sqlite backend doesn't support `RETURNING` // Huge caveat with this is that it depends on whatever the last insert was, prevents concurrent inserts. fn last_insert_id(&mut self) -> Result { - Ok(diesel::select(diesel::dsl::sql::("last_insert_rowid()")) - .get_result(self.connection)?) + Ok( + diesel::select(diesel::dsl::sql::( + "last_insert_rowid()", + )) + .get_result(self.connection)?, + ) } fn get_or_create_participant(&mut self, participant: &Participant) -> Option { match participant { Participant::Me => None, - Participant::Remote { display_name: p_name, .. } => { + Participant::Remote { + display_name: p_name, + .. + } => { use crate::schema::participants::dsl::*; let existing_participant = participants diff --git a/kordophone-db/src/schema.rs b/kordophone-db/src/schema.rs index 1cf6f3a..d0c5355 100644 --- a/kordophone-db/src/schema.rs +++ b/kordophone-db/src/schema.rs @@ -28,9 +28,9 @@ diesel::table! { diesel::table! { messages (id) { - id -> Text, // guid - text -> Text, - sender_participant_id -> Nullable, + id -> Text, // guid + text -> Text, + sender_participant_id -> Nullable, date -> Timestamp, file_transfer_guids -> Nullable, // JSON array of file transfer GUIDs attachment_metadata -> Nullable, // JSON string of attachment metadata @@ -53,8 +53,12 @@ diesel::table! { diesel::joinable!(conversation_participants -> conversations (conversation_id)); diesel::joinable!(conversation_participants -> participants (participant_id)); -diesel::allow_tables_to_appear_in_same_query!(conversations, participants, conversation_participants); +diesel::allow_tables_to_appear_in_same_query!( + conversations, + participants, + conversation_participants +); diesel::joinable!(conversation_messages -> conversations (conversation_id)); diesel::joinable!(conversation_messages -> messages (message_id)); -diesel::allow_tables_to_appear_in_same_query!(conversations, messages, conversation_messages); \ No newline at end of file +diesel::allow_tables_to_appear_in_same_query!(conversations, messages, conversation_messages); diff --git a/kordophone-db/src/settings.rs b/kordophone-db/src/settings.rs index 4c14dcb..eb4367c 100644 --- a/kordophone-db/src/settings.rs +++ b/kordophone-db/src/settings.rs @@ -1,6 +1,6 @@ -use diesel::*; -use serde::{Serialize, de::DeserializeOwned}; use anyhow::Result; +use diesel::*; +use serde::{de::DeserializeOwned, Serialize}; #[derive(Insertable, Queryable, AsChangeset)] #[diesel(table_name = crate::schema::settings)] @@ -18,16 +18,15 @@ impl<'a> Settings<'a> { Self { connection } } - pub fn put( - &mut self, - k: &str, - v: &T, - ) -> Result<()> { + pub fn put(&mut self, k: &str, v: &T) -> Result<()> { use crate::schema::settings::dsl::*; let bytes = bincode::serialize(v)?; diesel::insert_into(settings) - .values(SettingsRow { key: k, value: &bytes }) + .values(SettingsRow { + key: k, + value: &bytes, + }) .on_conflict(key) .do_update() .set(value.eq(&bytes)) @@ -36,10 +35,7 @@ impl<'a> Settings<'a> { Ok(()) } - pub fn get( - &mut self, - k: &str, - ) -> Result> { + pub fn get(&mut self, k: &str) -> Result> { use crate::schema::settings::dsl::*; let blob: Option> = settings .select(value) @@ -49,7 +45,7 @@ impl<'a> Settings<'a> { Ok(match blob { Some(b) => Some(bincode::deserialize(&b)?), - None => None, + None => None, }) } @@ -60,12 +56,8 @@ impl<'a> Settings<'a> { pub fn list_keys(&mut self) -> Result> { use crate::schema::settings::dsl::*; - let keys: Vec = settings - .select(key) - .load(self.connection)?; - + let keys: Vec = settings.select(key).load(self.connection)?; + Ok(keys) } } - - diff --git a/kordophone-db/src/tests/mod.rs b/kordophone-db/src/tests/mod.rs index c8c64d3..1ab6e86 100644 --- a/kordophone-db/src/tests/mod.rs +++ b/kordophone-db/src/tests/mod.rs @@ -1,9 +1,9 @@ use crate::{ - database::{Database, DatabaseAccess}, + database::{Database, DatabaseAccess}, models::{ conversation::{Conversation, ConversationBuilder}, - participant::Participant, message::Message, + participant::Participant, }, }; @@ -11,9 +11,17 @@ use crate::{ fn participants_equal_ignoring_id(a: &Participant, b: &Participant) -> bool { match (a, b) { (Participant::Me, Participant::Me) => true, - (Participant::Remote { display_name: name_a, .. }, - Participant::Remote { display_name: name_b, .. }) => name_a == name_b, - _ => false + ( + Participant::Remote { + display_name: name_a, + .. + }, + Participant::Remote { + display_name: name_b, + .. + }, + ) => name_a == name_b, + _ => false, } } @@ -21,7 +29,9 @@ fn participants_vec_equal_ignoring_id(a: &[Participant], b: &[Participant]) -> b if a.len() != b.len() { return false; } - a.iter().zip(b.iter()).all(|(a, b)| participants_equal_ignoring_id(a, b)) + a.iter() + .zip(b.iter()) + .all(|(a, b)| participants_equal_ignoring_id(a, b)) } #[tokio::test] @@ -40,27 +50,33 @@ async fn test_add_conversation() { .display_name("Test Conversation") .build(); - repository.insert_conversation(test_conversation.clone()).unwrap(); + repository + .insert_conversation(test_conversation.clone()) + .unwrap(); - // Try to fetch with id now + // Try to fetch with id now let conversation = repository.get_conversation_by_guid(guid).unwrap().unwrap(); assert_eq!(conversation.guid, "test"); // Modify the conversation and update it - let modified_conversation = test_conversation.into_builder() + let modified_conversation = test_conversation + .into_builder() .display_name("Modified Conversation") .build(); - repository.insert_conversation(modified_conversation.clone()).unwrap(); + repository + .insert_conversation(modified_conversation.clone()) + .unwrap(); - // Make sure we still only have one conversation. + // Make sure we still only have one conversation. let all_conversations = repository.all_conversations(i32::MAX, 0).unwrap(); assert_eq!(all_conversations.len(), 1); - // And make sure the display name was updated + // And make sure the display name was updated let conversation = repository.get_conversation_by_guid(guid).unwrap().unwrap(); assert_eq!(conversation.display_name.unwrap(), "Modified Conversation"); - }).await; + }) + .await; } #[tokio::test] @@ -81,7 +97,10 @@ async fn test_conversation_participants() { let read_conversation = repository.get_conversation_by_guid(&guid).unwrap().unwrap(); let read_participants = read_conversation.participants; - assert!(participants_vec_equal_ignoring_id(&participants, &read_participants)); + assert!(participants_vec_equal_ignoring_id( + &participants, + &read_participants + )); // Try making another conversation with the same participants let conversation = ConversationBuilder::new() @@ -94,8 +113,12 @@ async fn test_conversation_participants() { let read_conversation = repository.get_conversation_by_guid(&guid).unwrap().unwrap(); let read_participants: Vec = read_conversation.participants; - assert!(participants_vec_equal_ignoring_id(&participants, &read_participants)); - }).await; + assert!(participants_vec_equal_ignoring_id( + &participants, + &read_participants + )); + }) + .await; } #[tokio::test] @@ -132,9 +155,16 @@ async fn test_all_conversations_with_participants() { let conv1 = all_conversations.iter().find(|c| c.guid == guid1).unwrap(); let conv2 = all_conversations.iter().find(|c| c.guid == guid2).unwrap(); - assert!(participants_vec_equal_ignoring_id(&conv1.participants, &participants1)); - assert!(participants_vec_equal_ignoring_id(&conv2.participants, &participants2)); - }).await; + assert!(participants_vec_equal_ignoring_id( + &conv1.participants, + &participants1 + )); + assert!(participants_vec_equal_ignoring_id( + &conv2.participants, + &participants2 + )); + }) + .await; } #[tokio::test] @@ -163,11 +193,17 @@ async fn test_messages() { .build(); // Insert both messages - repository.insert_message(&conversation_id, message1.clone()).unwrap(); - repository.insert_message(&conversation_id, message2.clone()).unwrap(); + repository + .insert_message(&conversation_id, message1.clone()) + .unwrap(); + repository + .insert_message(&conversation_id, message2.clone()) + .unwrap(); // Retrieve messages - let messages = repository.get_messages_for_conversation(&conversation_id).unwrap(); + let messages = repository + .get_messages_for_conversation(&conversation_id) + .unwrap(); assert_eq!(messages.len(), 2); // Verify first message (from Me) @@ -181,9 +217,13 @@ async fn test_messages() { if let Participant::Remote { display_name, .. } = &retrieved_message2.sender { assert_eq!(display_name, "Alice"); } else { - panic!("Expected Remote participant. Got: {:?}", retrieved_message2.sender); + panic!( + "Expected Remote participant. Got: {:?}", + retrieved_message2.sender + ); } - }).await; + }) + .await; } #[tokio::test] @@ -191,9 +231,7 @@ async fn test_message_ordering() { let mut db = Database::new_in_memory().unwrap(); db.with_repository(|repository| { // Create a conversation - let conversation = ConversationBuilder::new() - .display_name("Test Chat") - .build(); + let conversation = ConversationBuilder::new().display_name("Test Chat").build(); let conversation_id = conversation.guid.clone(); repository.insert_conversation(conversation).unwrap(); @@ -215,19 +253,28 @@ async fn test_message_ordering() { .build(); // Insert messages - repository.insert_message(&conversation_id, message1).unwrap(); - repository.insert_message(&conversation_id, message2).unwrap(); - repository.insert_message(&conversation_id, message3).unwrap(); + repository + .insert_message(&conversation_id, message1) + .unwrap(); + repository + .insert_message(&conversation_id, message2) + .unwrap(); + repository + .insert_message(&conversation_id, message3) + .unwrap(); // Retrieve messages and verify order - let messages = repository.get_messages_for_conversation(&conversation_id).unwrap(); + let messages = repository + .get_messages_for_conversation(&conversation_id) + .unwrap(); assert_eq!(messages.len(), 3); // Messages should be ordered by date for i in 1..messages.len() { - assert!(messages[i].date > messages[i-1].date); + assert!(messages[i].date > messages[i - 1].date); } - }).await; + }) + .await; } #[tokio::test] @@ -245,10 +292,7 @@ async fn test_insert_messages_batch() { // Prepare a batch of messages with increasing timestamps let now = chrono::Utc::now().naive_utc(); - let message1 = Message::builder() - .text("Hi".to_string()) - .date(now) - .build(); + let message1 = Message::builder().text("Hi".to_string()).date(now).build(); let message2 = Message::builder() .text("Hello".to_string()) @@ -280,7 +324,9 @@ async fn test_insert_messages_batch() { .unwrap(); // Retrieve messages and verify - let retrieved_messages = repository.get_messages_for_conversation(&conversation_id).unwrap(); + let retrieved_messages = repository + .get_messages_for_conversation(&conversation_id) + .unwrap(); assert_eq!(retrieved_messages.len(), original_messages.len()); // Ensure ordering by date @@ -299,8 +345,14 @@ async fn test_insert_messages_batch() { match (&original.sender, &retrieved.sender) { (Participant::Me, Participant::Me) => {} ( - Participant::Remote { display_name: o_name, .. }, - Participant::Remote { display_name: r_name, .. }, + Participant::Remote { + display_name: o_name, + .. + }, + Participant::Remote { + display_name: r_name, + .. + }, ) => assert_eq!(o_name, r_name), _ => panic!( "Sender mismatch: original {:?}, retrieved {:?}", @@ -310,7 +362,10 @@ async fn test_insert_messages_batch() { } // Make sure the last message is the last one we inserted - let last_message = repository.get_last_message_for_conversation(&conversation_id).unwrap().unwrap(); + let last_message = repository + .get_last_message_for_conversation(&conversation_id) + .unwrap() + .unwrap(); assert_eq!(last_message.id, message4.id); }) .await; @@ -329,7 +384,7 @@ async fn test_settings() { let keys = settings.list_keys().unwrap(); assert_eq!(keys.len(), 0); - // Try encoding a struct + // Try encoding a struct #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq)] struct TestStruct { name: String, @@ -342,13 +397,34 @@ async fn test_settings() { }; settings.put("test_struct", &test_struct).unwrap(); - assert_eq!(settings.get::("test_struct").unwrap().unwrap(), test_struct); + assert_eq!( + settings.get::("test_struct").unwrap().unwrap(), + test_struct + ); // Test with an option - settings.put("test_struct_option", &Option::::None).unwrap(); - assert!(settings.get::>("test_struct_option").unwrap().unwrap().is_none()); + settings + .put("test_struct_option", &Option::::None) + .unwrap(); + assert!(settings + .get::>("test_struct_option") + .unwrap() + .unwrap() + .is_none()); - settings.put("test_struct_option", &Option::::Some("test".to_string())).unwrap(); - assert_eq!(settings.get::>("test_struct_option").unwrap().unwrap(), Some("test".to_string())); - }).await; + settings + .put( + "test_struct_option", + &Option::::Some("test".to_string()), + ) + .unwrap(); + assert_eq!( + settings + .get::>("test_struct_option") + .unwrap() + .unwrap(), + Some("test".to_string()) + ); + }) + .await; } diff --git a/kordophone/src/api/auth.rs b/kordophone/src/api/auth.rs index 4d8f65c..6e34cfc 100644 --- a/kordophone/src/api/auth.rs +++ b/kordophone/src/api/auth.rs @@ -22,7 +22,7 @@ impl Default for InMemoryAuthenticationStore { impl InMemoryAuthenticationStore { pub fn new(credentials: Option) -> Self { - Self { + Self { credentials, token: None, } diff --git a/kordophone/src/api/event_socket.rs b/kordophone/src/api/event_socket.rs index 8896c3b..636677d 100644 --- a/kordophone/src/api/event_socket.rs +++ b/kordophone/src/api/event_socket.rs @@ -1,6 +1,6 @@ -use async_trait::async_trait; -use crate::model::update::UpdateItem; use crate::model::event::Event; +use crate::model::update::UpdateItem; +use async_trait::async_trait; use futures_util::stream::Stream; #[async_trait] @@ -8,10 +8,10 @@ pub trait EventSocket { type Error; type EventStream: Stream>; type UpdateStream: Stream, Self::Error>>; - - /// Modern event pipeline + + /// Modern event pipeline async fn events(self) -> Self::EventStream; - /// Raw update items from the v1 API. + /// Raw update items from the v1 API. async fn raw_updates(self) -> Self::UpdateStream; -} \ No newline at end of file +} diff --git a/kordophone/src/lib.rs b/kordophone/src/lib.rs index f93f108..8688da3 100644 --- a/kordophone/src/lib.rs +++ b/kordophone/src/lib.rs @@ -4,4 +4,4 @@ pub mod model; pub use self::api::APIInterface; #[cfg(test)] -pub mod tests; \ No newline at end of file +pub mod tests; diff --git a/kordophone/src/model/conversation.rs b/kordophone/src/model/conversation.rs index 004b362..fa397ea 100644 --- a/kordophone/src/model/conversation.rs +++ b/kordophone/src/model/conversation.rs @@ -41,7 +41,7 @@ impl Identifiable for Conversation { fn id(&self) -> &Self::ID { &self.guid - } + } } #[derive(Default)] @@ -85,7 +85,10 @@ impl ConversationBuilder { self } - pub fn display_name(mut self, display_name: T) -> Self where T: Into { + pub fn display_name(mut self, display_name: T) -> Self + where + T: Into, + { self.display_name = Some(display_name.into()); self } diff --git a/kordophone/src/model/event.rs b/kordophone/src/model/event.rs index f44e4c7..2471559 100644 --- a/kordophone/src/model/event.rs +++ b/kordophone/src/model/event.rs @@ -15,13 +15,25 @@ pub enum EventData { impl From for Event { fn from(update: UpdateItem) -> Self { match update { - UpdateItem { conversation: Some(conversation), message: None, .. } - => Event { data: EventData::ConversationChanged(conversation), update_seq: update.seq }, + UpdateItem { + conversation: Some(conversation), + message: None, + .. + } => Event { + data: EventData::ConversationChanged(conversation), + update_seq: update.seq, + }, + + UpdateItem { + conversation: Some(conversation), + message: Some(message), + .. + } => Event { + data: EventData::MessageReceived(conversation, message), + update_seq: update.seq, + }, - UpdateItem { conversation: Some(conversation), message: Some(message), .. } - => Event { data: EventData::MessageReceived(conversation, message), update_seq: update.seq }, - _ => panic!("Invalid update item: {:?}", update), } } -} \ No newline at end of file +} diff --git a/kordophone/src/model/jwt.rs b/kordophone/src/model/jwt.rs index f458b5f..49d27b9 100644 --- a/kordophone/src/model/jwt.rs +++ b/kordophone/src/model/jwt.rs @@ -37,14 +37,14 @@ where D: serde::Deserializer<'de>, { use serde::de::Error; - + #[derive(Deserialize)] #[serde(untagged)] enum ExpValue { String(String), Number(i64), } - + match ExpValue::deserialize(deserializer)? { ExpValue::String(s) => s.parse().map_err(D::Error::custom), ExpValue::Number(n) => Ok(n), @@ -82,7 +82,9 @@ impl JwtToken { let payload: JwtPayload = serde_json::from_slice(&payload)?; // Parse jwt expiration date - let timestamp = DateTime::from_timestamp(payload.exp, 0).unwrap().naive_utc(); + let timestamp = DateTime::from_timestamp(payload.exp, 0) + .unwrap() + .naive_utc(); let expiration_date = DateTime::from_naive_utc_and_offset(timestamp, Utc); Ok(JwtToken { @@ -99,7 +101,7 @@ impl JwtToken { // try both encodings here. log::debug!("Attempting to decode JWT token: {}", token); - + let result = Self::decode_token_using_engine(token, general_purpose::STANDARD).or( Self::decode_token_using_engine(token, general_purpose::URL_SAFE_NO_PAD), ); diff --git a/kordophone/src/model/message.rs b/kordophone/src/model/message.rs index ef90efc..cca26f5 100644 --- a/kordophone/src/model/message.rs +++ b/kordophone/src/model/message.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use time::OffsetDateTime; +use time::OffsetDateTime; use uuid::Uuid; use super::Identifiable; @@ -12,7 +12,7 @@ pub struct AttributionInfo { /// Picture width #[serde(rename = "pgensh")] pub width: Option, - + /// Picture height #[serde(rename = "pgensw")] pub height: Option, @@ -26,7 +26,7 @@ pub struct AttachmentMetadata { #[derive(Debug, Clone, Deserialize)] pub struct Message { - pub guid: String, + pub guid: String, #[serde(rename = "text")] pub text: String, @@ -62,9 +62,9 @@ impl Identifiable for Message { #[derive(Default)] pub struct MessageBuilder { - guid: Option, - text: Option, - sender: Option, + guid: Option, + text: Option, + sender: Option, date: Option, file_transfer_guids: Option>, attachment_metadata: Option>, @@ -77,17 +77,17 @@ impl MessageBuilder { pub fn guid(mut self, guid: String) -> Self { self.guid = Some(guid); - self + self } pub fn text(mut self, text: String) -> Self { self.text = Some(text); - self + self } pub fn sender(mut self, sender: String) -> Self { self.sender = Some(sender); - self + self } pub fn date(mut self, date: OffsetDateTime) -> Self { @@ -100,7 +100,10 @@ impl MessageBuilder { self } - pub fn attachment_metadata(mut self, attachment_metadata: HashMap) -> Self { + pub fn attachment_metadata( + mut self, + attachment_metadata: HashMap, + ) -> Self { self.attachment_metadata = Some(attachment_metadata); self } @@ -116,4 +119,3 @@ impl MessageBuilder { } } } - diff --git a/kordophone/src/model/mod.rs b/kordophone/src/model/mod.rs index cc659aa..70538c7 100644 --- a/kordophone/src/model/mod.rs +++ b/kordophone/src/model/mod.rs @@ -23,4 +23,4 @@ pub use jwt::JwtToken; pub trait Identifiable { type ID; fn id(&self) -> &Self::ID; -} \ No newline at end of file +} diff --git a/kordophone/src/model/outgoing_message.rs b/kordophone/src/model/outgoing_message.rs index d3a4123..15e03a7 100644 --- a/kordophone/src/model/outgoing_message.rs +++ b/kordophone/src/model/outgoing_message.rs @@ -1,6 +1,6 @@ -use serde::Serialize; use super::conversation::ConversationID; use chrono::NaiveDateTime; +use serde::Serialize; use uuid::Uuid; #[derive(Debug, Clone, Serialize)] @@ -61,12 +61,12 @@ impl OutgoingMessageBuilder { } pub fn build(self) -> OutgoingMessage { - OutgoingMessage { + OutgoingMessage { guid: self.guid.unwrap_or_else(|| Uuid::new_v4()), - text: self.text.unwrap(), - conversation_id: self.conversation_id.unwrap(), + text: self.text.unwrap(), + conversation_id: self.conversation_id.unwrap(), file_transfer_guids: self.file_transfer_guids.unwrap_or_default(), date: chrono::Utc::now().naive_utc(), } } -} \ No newline at end of file +} diff --git a/kordophone/src/model/update.rs b/kordophone/src/model/update.rs index 4d89896..69889fa 100644 --- a/kordophone/src/model/update.rs +++ b/kordophone/src/model/update.rs @@ -1,6 +1,6 @@ -use serde::Deserialize; use super::conversation::Conversation; use super::message::Message; +use serde::Deserialize; #[derive(Debug, Clone, Deserialize)] pub struct UpdateItem { @@ -16,6 +16,10 @@ pub struct UpdateItem { impl Default for UpdateItem { fn default() -> Self { - Self { seq: 0, conversation: None, message: None } + Self { + seq: 0, + conversation: None, + message: None, + } } -} \ No newline at end of file +} diff --git a/kordophone/src/tests/mod.rs b/kordophone/src/tests/mod.rs index 43f21ef..ee8b46e 100644 --- a/kordophone/src/tests/mod.rs +++ b/kordophone/src/tests/mod.rs @@ -6,7 +6,7 @@ pub mod api_interface { use crate::model::Conversation; use super::*; - + #[tokio::test] async fn test_version() { let mut client = TestClient::new(); @@ -28,4 +28,4 @@ pub mod api_interface { assert_eq!(conversations.len(), 1); assert_eq!(conversations[0].display_name, test_convo.display_name); } -} \ No newline at end of file +} diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index a8ca377..c051e1f 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -14,9 +14,9 @@ use crate::{ }, }; +use bytes::Bytes; use futures_util::stream::BoxStream; use futures_util::StreamExt; -use bytes::Bytes; pub struct TestClient { pub version: &'static str, @@ -120,7 +120,11 @@ impl APIInterface for TestClient { Ok(TestEventSocket::new()) } - async fn fetch_attachment_data(&mut self, guid: &String, preview: bool) -> Result { + async fn fetch_attachment_data( + &mut self, + guid: &String, + preview: bool, + ) -> Result { Ok(futures_util::stream::iter(vec![Ok(Bytes::from_static(b"test"))]).boxed()) } } diff --git a/kordophoned/build.rs b/kordophoned/build.rs index e9d606b..ec36f2c 100644 --- a/kordophoned/build.rs +++ b/kordophoned/build.rs @@ -11,14 +11,12 @@ 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 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"); + std::fs::write(out_path, output).expect("Error writing server dbus code"); println!("cargo:rerun-if-changed={}", KORDOPHONE_XML); } diff --git a/kordophoned/src/daemon/attachment_store.rs b/kordophoned/src/daemon/attachment_store.rs index 6395ea0..7b35cb9 100644 --- a/kordophoned/src/daemon/attachment_store.rs +++ b/kordophoned/src/daemon/attachment_store.rs @@ -16,8 +16,8 @@ use crate::daemon::models::Attachment; use crate::daemon::Daemon; use std::sync::Arc; -use tokio::sync::Mutex; use tokio::sync::mpsc::{Receiver, Sender}; +use tokio::sync::Mutex; use tokio::pin; @@ -32,7 +32,7 @@ pub enum AttachmentStoreEvent { GetAttachmentInfo(String, Reply), // Queue a download for a given attachment guid. - // Args: + // Args: // - attachment guid // - preview: whether to download the preview (true) or full attachment (false) QueueDownloadAttachment(String, bool), @@ -62,7 +62,10 @@ impl AttachmentStore { data_dir.join("attachments") } - pub fn new(database: Arc>, daemon_event_sink: Sender) -> AttachmentStore { + pub fn new( + database: Arc>, + daemon_event_sink: Sender, + ) -> AttachmentStore { let store_path = Self::get_default_store_path(); log::info!(target: target::ATTACHMENTS, "Attachment store path: {}", store_path.display()); @@ -97,7 +100,7 @@ impl AttachmentStore { metadata: None, } } - + async fn download_attachment(&mut self, attachment: &Attachment, preview: bool) -> Result<()> { if attachment.is_downloaded(preview) { log::info!(target: target::ATTACHMENTS, "Attachment already downloaded: {}", attachment.guid); @@ -130,7 +133,7 @@ impl AttachmentStore { log::info!(target: target::ATTACHMENTS, "Completed download for attachment: {}", attachment.guid); Ok(()) } - + pub async fn run(&mut self) { loop { tokio::select! { diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 3f64f3c..d62b67c 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -150,7 +150,8 @@ impl Daemon { } // Attachment store - let mut attachment_store = AttachmentStore::new(self.database.clone(), self.event_sender.clone()); + let mut attachment_store = + AttachmentStore::new(self.database.clone(), self.event_sender.clone()); self.attachment_store_sink = Some(attachment_store.get_event_sink()); tokio::spawn(async move { attachment_store.run().await; @@ -304,7 +305,10 @@ impl Daemon { self.attachment_store_sink .as_ref() .unwrap() - .send(AttachmentStoreEvent::QueueDownloadAttachment(attachment_id, preview)) + .send(AttachmentStoreEvent::QueueDownloadAttachment( + attachment_id, + preview, + )) .await .unwrap(); diff --git a/kordophoned/src/daemon/models/attachment.rs b/kordophoned/src/daemon/models/attachment.rs index 51ab0c9..0251451 100644 --- a/kordophoned/src/daemon/models/attachment.rs +++ b/kordophoned/src/daemon/models/attachment.rs @@ -20,14 +20,20 @@ pub struct Attachment { impl Attachment { pub fn get_path(&self, preview: bool) -> PathBuf { - self.base_path.with_extension(if preview { "preview" } else { "full" }) + self.base_path + .with_extension(if preview { "preview" } else { "full" }) } pub fn is_downloaded(&self, preview: bool) -> bool { - std::fs::exists(&self.get_path(preview)) - .expect(format!("Wasn't able to check for the existence of an attachment file path at {}", &self.get_path(preview).display()).as_str()) + std::fs::exists(&self.get_path(preview)).expect( + format!( + "Wasn't able to check for the existence of an attachment file path at {}", + &self.get_path(preview).display() + ) + .as_str(), + ) } -} +} impl From for AttachmentMetadata { fn from(metadata: kordophone::model::message::AttachmentMetadata) -> Self { @@ -61,4 +67,4 @@ impl From for kordophone::model::message::AttributionInfo { height: info.height, } } -} \ No newline at end of file +} diff --git a/kordophoned/src/daemon/models/message.rs b/kordophoned/src/daemon/models/message.rs index 4735275..f1bc32f 100644 --- a/kordophoned/src/daemon/models/message.rs +++ b/kordophoned/src/daemon/models/message.rs @@ -1,11 +1,11 @@ -use chrono::NaiveDateTime; use chrono::DateTime; +use chrono::NaiveDateTime; -use std::collections::HashMap; +use crate::daemon::attachment_store::AttachmentStore; +use crate::daemon::models::Attachment; use kordophone::model::message::AttachmentMetadata; use kordophone::model::outgoing_message::OutgoingMessage; -use crate::daemon::models::Attachment; -use crate::daemon::attachment_store::AttachmentStore; +use std::collections::HashMap; #[derive(Clone, Debug)] pub enum Participant { @@ -54,7 +54,7 @@ impl Participant { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug)] pub struct Message { pub id: String, pub sender: Participant, @@ -63,24 +63,34 @@ pub struct Message { pub attachments: Vec, } -fn attachments_from(file_transfer_guids: &Vec, attachment_metadata: &Option>) -> Vec { +fn attachments_from( + file_transfer_guids: &Vec, + attachment_metadata: &Option>, +) -> Vec { file_transfer_guids .iter() .map(|guid| { - let mut attachment = AttachmentStore::get_attachment_impl(&AttachmentStore::get_default_store_path(), guid); + let mut attachment = AttachmentStore::get_attachment_impl( + &AttachmentStore::get_default_store_path(), + guid, + ); attachment.metadata = match attachment_metadata { - Some(attachment_metadata) => attachment_metadata.get(guid).cloned().map(|metadata| metadata.into()), + Some(attachment_metadata) => attachment_metadata + .get(guid) + .cloned() + .map(|metadata| metadata.into()), None => None, }; attachment }) .collect() -} +} impl From for Message { fn from(message: kordophone_db::models::Message) -> Self { - let attachments = attachments_from(&message.file_transfer_guids, &message.attachment_metadata); + let attachments = + attachments_from(&message.file_transfer_guids, &message.attachment_metadata); Self { id: message.id, sender: message.sender.into(), @@ -105,11 +115,21 @@ impl From for kordophone_db::models::Message { date: message.date, file_transfer_guids: message.attachments.iter().map(|a| a.guid.clone()).collect(), attachment_metadata: { - let metadata_map: HashMap = message.attachments - .iter() - .filter_map(|a| a.metadata.as_ref().map(|m| (a.guid.clone(), m.clone().into()))) - .collect(); - if metadata_map.is_empty() { None } else { Some(metadata_map) } + let metadata_map: HashMap = + message + .attachments + .iter() + .filter_map(|a| { + a.metadata + .as_ref() + .map(|m| (a.guid.clone(), m.clone().into())) + }) + .collect(); + if metadata_map.is_empty() { + None + } else { + Some(metadata_map) + } }, } } @@ -117,7 +137,8 @@ impl From for kordophone_db::models::Message { impl From for Message { fn from(message: kordophone::model::Message) -> Self { - let attachments = attachments_from(&message.file_transfer_guids, &message.attachment_metadata); + let attachments = + attachments_from(&message.file_transfer_guids, &message.attachment_metadata); Self { id: message.guid, sender: match message.sender { @@ -130,12 +151,10 @@ impl From for Message { text: message.text, date: DateTime::from_timestamp( message.date.unix_timestamp(), - message.date.unix_timestamp_nanos() - .try_into() - .unwrap_or(0), - ) - .unwrap() - .naive_local(), + message.date.unix_timestamp_nanos().try_into().unwrap_or(0), + ) + .unwrap() + .naive_local(), attachments, } } diff --git a/kordophoned/src/daemon/models/mod.rs b/kordophoned/src/daemon/models/mod.rs index 8a63c4a..9c8ba1c 100644 --- a/kordophoned/src/daemon/models/mod.rs +++ b/kordophoned/src/daemon/models/mod.rs @@ -2,4 +2,4 @@ pub mod attachment; pub mod message; pub use attachment::Attachment; -pub use message::Message; \ No newline at end of file +pub use message::Message; diff --git a/kordophoned/src/daemon/post_office.rs b/kordophoned/src/daemon/post_office.rs index 55d626e..60c52ae 100644 --- a/kordophoned/src/daemon/post_office.rs +++ b/kordophoned/src/daemon/post_office.rs @@ -1,13 +1,13 @@ use std::collections::VecDeque; use std::time::Duration; -use tokio::sync::mpsc::{Sender, Receiver}; +use tokio::sync::mpsc::{Receiver, Sender}; use tokio::sync::Mutex; use tokio_condvar::Condvar; use crate::daemon::events::Event as DaemonEvent; -use kordophone::model::outgoing_message::OutgoingMessage; use kordophone::api::APIInterface; +use kordophone::model::outgoing_message::OutgoingMessage; use anyhow::Result; @@ -25,15 +25,19 @@ pub struct PostOffice Result> { event_sink: Sender, make_client: F, message_queue: Mutex>, - message_available: Condvar, + message_available: Condvar, } impl Result> PostOffice { - pub fn new(event_source: Receiver, event_sink: Sender, make_client: F) -> Self { - Self { + pub fn new( + event_source: Receiver, + event_sink: Sender, + make_client: F, + ) -> Self { + Self { event_source, - event_sink, - make_client, + event_sink, + make_client, message_queue: Mutex::new(VecDeque::new()), message_available: Condvar::new(), } @@ -85,13 +89,12 @@ impl Result> PostOffice { } async fn try_send_message( - make_client: &mut F, - event_sink: &Sender, - message: OutgoingMessage - ) -> Vec - { + make_client: &mut F, + event_sink: &Sender, + message: OutgoingMessage, + ) -> Vec { let mut retry_messages = Vec::new(); - + match (make_client)().await { Ok(mut client) => { log::debug!(target: target::POST_OFFICE, "Obtained client, sending message."); @@ -100,7 +103,8 @@ impl Result> PostOffice { log::info!(target: target::POST_OFFICE, "Message sent successfully: {}", message.guid); let conversation_id = message.conversation_id.clone(); - let event = DaemonEvent::MessageSent(sent_message.into(), message, conversation_id); + let event = + DaemonEvent::MessageSent(sent_message.into(), message, conversation_id); event_sink.send(event).await.unwrap(); } @@ -123,4 +127,4 @@ impl Result> PostOffice { retry_messages } -} \ No newline at end of file +} diff --git a/kordophoned/src/daemon/settings.rs b/kordophoned/src/daemon/settings.rs index 265c988..3c145f4 100644 --- a/kordophoned/src/daemon/settings.rs +++ b/kordophoned/src/daemon/settings.rs @@ -1,5 +1,5 @@ -use kordophone_db::settings::Settings as DbSettings; use anyhow::Result; +use kordophone_db::settings::Settings as DbSettings; pub mod keys { pub static SERVER_URL: &str = "ServerURL"; @@ -7,8 +7,7 @@ pub mod keys { pub static TOKEN: &str = "Token"; } -#[derive(Debug)] -#[derive(Default)] +#[derive(Debug, Default)] pub struct Settings { pub server_url: Option, pub username: Option, @@ -20,7 +19,7 @@ impl Settings { let server_url = db_settings.get(keys::SERVER_URL)?; let username = db_settings.get(keys::USERNAME)?; let token = db_settings.get(keys::TOKEN)?; - + // Create the settings struct with the results let settings = Self { server_url, @@ -30,7 +29,7 @@ impl Settings { // Load bearing log::debug!("Loaded settings: {:?}", settings); - + Ok(settings) } @@ -47,4 +46,3 @@ impl Settings { Ok(()) } } - diff --git a/kordophoned/src/daemon/update_monitor.rs b/kordophoned/src/daemon/update_monitor.rs index 8447619..184832a 100644 --- a/kordophoned/src/daemon/update_monitor.rs +++ b/kordophoned/src/daemon/update_monitor.rs @@ -1,24 +1,21 @@ use crate::daemon::{ - Daemon, - DaemonResult, - events::{Event, Reply}, - target, + target, Daemon, DaemonResult, }; -use kordophone::APIInterface; use kordophone::api::event_socket::EventSocket; use kordophone::model::event::Event as UpdateEvent; use kordophone::model::event::EventData as UpdateEventData; +use kordophone::APIInterface; use kordophone_db::database::Database; use kordophone_db::database::DatabaseAccess; -use tokio::sync::mpsc::Sender; -use std::sync::Arc; -use tokio::sync::Mutex; use std::collections::HashMap; +use std::sync::Arc; use std::time::{Duration, Instant}; +use tokio::sync::mpsc::Sender; +use tokio::sync::Mutex; pub struct UpdateMonitor { database: Arc>, @@ -29,8 +26,8 @@ pub struct UpdateMonitor { impl UpdateMonitor { pub fn new(database: Arc>, event_sender: Sender) -> Self { - Self { - database, + Self { + database, event_sender, last_sync_times: HashMap::new(), update_seq: None, @@ -42,23 +39,24 @@ impl UpdateMonitor { make_event: impl FnOnce(Reply) -> Event, ) -> DaemonResult { let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); - self.event_sender.send(make_event(reply_tx)) + self.event_sender + .send(make_event(reply_tx)) .await .map_err(|_| "Failed to send event")?; - + reply_rx.await.map_err(|_| "Failed to receive reply".into()) } - + async fn handle_update(&mut self, update: UpdateEvent) { self.update_seq = Some(update.update_seq); - + match update.data { UpdateEventData::ConversationChanged(conversation) => { log::info!(target: target::UPDATES, "Conversation changed: {:?}", conversation); // Check if we've synced this conversation recently (within 5 seconds) // This is currently a hack/workaround to prevent an infinite loop of sync events, because for some reason - // imagent will post a conversation changed notification when we call getMessages. + // imagent will post a conversation changed notification when we call getMessages. if let Some(last_sync) = self.last_sync_times.get(&conversation.guid) { if last_sync.elapsed() < Duration::from_secs(5) { log::info!(target: target::UPDATES, "Skipping sync for conversation id: {}. Last sync was {} seconds ago.", @@ -67,8 +65,12 @@ impl UpdateMonitor { } } - // This is the non-hacky path once we can reason about chat items with associatedMessageGUIDs (e.g., reactions). - let last_message = self.database.with_repository(|r| r.get_last_message_for_conversation(&conversation.guid)).await.unwrap_or_default(); + // This is the non-hacky path once we can reason about chat items with associatedMessageGUIDs (e.g., reactions). + let last_message = self + .database + .with_repository(|r| r.get_last_message_for_conversation(&conversation.guid)) + .await + .unwrap_or_default(); match (&last_message, &conversation.last_message) { (Some(message), Some(conversation_message)) => { if message.id == conversation_message.guid { @@ -80,10 +82,12 @@ impl UpdateMonitor { }; // Update the last sync time and proceed with sync - self.last_sync_times.insert(conversation.guid.clone(), Instant::now()); - + self.last_sync_times + .insert(conversation.guid.clone(), Instant::now()); + log::info!(target: target::UPDATES, "Syncing new messages for conversation id: {}", conversation.guid); - self.send_event(|r| Event::SyncConversation(conversation.guid, r)).await + self.send_event(|r| Event::SyncConversation(conversation.guid, r)) + .await .unwrap_or_else(|e| { log::error!("Failed to send daemon event: {}", e); }); @@ -92,14 +96,15 @@ impl UpdateMonitor { UpdateEventData::MessageReceived(conversation, message) => { log::info!(target: target::UPDATES, "Message received: msgid:{:?}, convid:{:?}", message.guid, conversation.guid); log::info!(target: target::UPDATES, "Triggering message sync for conversation id: {}", conversation.guid); - self.send_event(|r| Event::SyncConversation(conversation.guid, r)).await + self.send_event(|r| Event::SyncConversation(conversation.guid, r)) + .await .unwrap_or_else(|e| { log::error!("Failed to send daemon event: {}", e); }); } } } - + pub async fn run(&mut self) { use futures_util::stream::StreamExt; @@ -130,15 +135,15 @@ impl UpdateMonitor { log::debug!(target: target::UPDATES, "Starting event stream"); let mut event_stream = socket.events().await; - - // We won't know if the websocket is dead until we try to send a message, so time out waiting for - // a message every 30 seconds. + + // We won't know if the websocket is dead until we try to send a message, so time out waiting for + // a message every 30 seconds. let mut timeout = tokio::time::interval(Duration::from_secs(30)); timeout.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); // First tick will happen immediately timeout.tick().await; - + loop { tokio::select! { Some(result) = event_stream.next() => { @@ -161,9 +166,9 @@ impl UpdateMonitor { } } } - + // Add a small delay before reconnecting to avoid tight reconnection loops tokio::time::sleep(Duration::from_secs(1)).await; } } -} \ No newline at end of file +} diff --git a/kordophoned/src/dbus/endpoint.rs b/kordophoned/src/dbus/endpoint.rs index ec76030..aa327b5 100644 --- a/kordophoned/src/dbus/endpoint.rs +++ b/kordophoned/src/dbus/endpoint.rs @@ -42,11 +42,11 @@ impl DbusRegistry { R: IntoIterator>, { let dbus_path = String::from(path); - + let mut cr = self.crossroads.lock().unwrap(); let tokens: Vec<_> = register_fn(&mut cr).into_iter().collect(); cr.insert(dbus_path, &tokens, implementation); - + // Start message handler if not already started let mut handler_started = self.message_handler_started.lock().unwrap(); if !*handler_started { diff --git a/kordophoned/src/dbus/mod.rs b/kordophoned/src/dbus/mod.rs index 143fb83..17fbf1e 100644 --- a/kordophoned/src/dbus/mod.rs +++ b/kordophoned/src/dbus/mod.rs @@ -13,4 +13,4 @@ pub mod interface { pub use crate::interface::NetBuzzertKordophoneRepositoryConversationsUpdated as ConversationsUpdated; pub use crate::interface::NetBuzzertKordophoneRepositoryMessagesUpdated as MessagesUpdated; } -} \ No newline at end of file +} diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 4053a61..ba5773e 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -7,7 +7,8 @@ use tokio::sync::oneshot; use crate::daemon::{ events::{Event, Reply}, - settings::Settings, DaemonResult, + settings::Settings, + DaemonResult, }; use crate::dbus::interface::NetBuzzertKordophoneRepository as DbusRepository; @@ -136,52 +137,82 @@ impl DbusRepository for ServerImpl { "sender".into(), arg::Variant(Box::new(msg.sender.display_name())), ); - + // Add attachments array - let attachments: Vec = msg.attachments + let attachments: Vec = msg + .attachments .into_iter() .map(|attachment| { let mut attachment_map = arg::PropMap::new(); - attachment_map.insert("guid".into(), arg::Variant(Box::new(attachment.guid.clone()))); - + attachment_map.insert( + "guid".into(), + arg::Variant(Box::new(attachment.guid.clone())), + ); + // Get attachment paths and download status let path = attachment.get_path(false); let preview_path = attachment.get_path(true); let downloaded = attachment.is_downloaded(false); let preview_downloaded = attachment.is_downloaded(true); - - attachment_map.insert("path".into(), arg::Variant(Box::new(path.to_string_lossy().to_string()))); - attachment_map.insert("preview_path".into(), arg::Variant(Box::new(preview_path.to_string_lossy().to_string()))); - attachment_map.insert("downloaded".into(), arg::Variant(Box::new(downloaded))); - attachment_map.insert("preview_downloaded".into(), arg::Variant(Box::new(preview_downloaded))); - + + attachment_map.insert( + "path".into(), + arg::Variant(Box::new(path.to_string_lossy().to_string())), + ); + attachment_map.insert( + "preview_path".into(), + arg::Variant(Box::new( + preview_path.to_string_lossy().to_string(), + )), + ); + attachment_map.insert( + "downloaded".into(), + arg::Variant(Box::new(downloaded)), + ); + attachment_map.insert( + "preview_downloaded".into(), + arg::Variant(Box::new(preview_downloaded)), + ); + // Add metadata if present if let Some(ref metadata) = attachment.metadata { let mut metadata_map = arg::PropMap::new(); - + // Add attribution_info if present if let Some(ref attribution_info) = metadata.attribution_info { let mut attribution_map = arg::PropMap::new(); - + if let Some(width) = attribution_info.width { - attribution_map.insert("width".into(), arg::Variant(Box::new(width as i32))); + attribution_map.insert( + "width".into(), + arg::Variant(Box::new(width as i32)), + ); } if let Some(height) = attribution_info.height { - attribution_map.insert("height".into(), arg::Variant(Box::new(height as i32))); + attribution_map.insert( + "height".into(), + arg::Variant(Box::new(height as i32)), + ); } - - metadata_map.insert("attribution_info".into(), arg::Variant(Box::new(attribution_map))); + + metadata_map.insert( + "attribution_info".into(), + arg::Variant(Box::new(attribution_map)), + ); } - - attachment_map.insert("metadata".into(), arg::Variant(Box::new(metadata_map))); + + attachment_map.insert( + "metadata".into(), + arg::Variant(Box::new(metadata_map)), + ); } - + attachment_map }) .collect(); - + map.insert("attachments".into(), arg::Variant(Box::new(attachments))); - + map }) .collect() @@ -216,20 +247,21 @@ impl DbusRepository for ServerImpl { ( // - path: string path.to_string_lossy().to_string(), - // - preview_path: string preview_path.to_string_lossy().to_string(), - // - downloaded: boolean downloaded, - // - preview_downloaded: boolean preview_downloaded, ) }) } - fn download_attachment(&mut self, attachment_id: String, preview: bool) -> Result<(), dbus::MethodErr> { + fn download_attachment( + &mut self, + attachment_id: String, + preview: bool, + ) -> Result<(), dbus::MethodErr> { // For now, just trigger the download event - we'll implement the actual download logic later self.send_event_sync(|r| Event::DownloadAttachment(attachment_id, preview, r)) } @@ -286,7 +318,6 @@ impl DbusSettings for ServerImpl { } } - fn run_sync_future(f: F) -> Result where T: Send, diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index c3454f4..629b26a 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -60,14 +60,12 @@ async fn main() { // Create and register server implementation let server = ServerImpl::new(daemon.event_sender.clone()); - dbus_registry.register_object( - interface::OBJECT_PATH, - server, - |cr| vec![ + dbus_registry.register_object(interface::OBJECT_PATH, server, |cr| { + vec![ interface::register_net_buzzert_kordophone_repository(cr), interface::register_net_buzzert_kordophone_settings(cr), ] - ); + }); let mut signal_receiver = daemon.obtain_signal_receiver(); tokio::spawn(async move { diff --git a/kpcli/build.rs b/kpcli/build.rs index 7d9bf4e..9254308 100644 --- a/kpcli/build.rs +++ b/kpcli/build.rs @@ -10,14 +10,12 @@ 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"); + std::fs::write(out_path, output).expect("Error writing client dbus code"); println!("cargo:rerun-if-changed={}", KORDOPHONE_XML); -} +} diff --git a/kpcli/src/client/mod.rs b/kpcli/src/client/mod.rs index 7438868..edfece7 100644 --- a/kpcli/src/client/mod.rs +++ b/kpcli/src/client/mod.rs @@ -1,12 +1,12 @@ -use kordophone::APIInterface; -use kordophone::api::http_client::HTTPAPIClient; -use kordophone::api::http_client::Credentials; -use kordophone::api::InMemoryAuthenticationStore; use kordophone::api::event_socket::EventSocket; +use kordophone::api::http_client::Credentials; +use kordophone::api::http_client::HTTPAPIClient; +use kordophone::api::InMemoryAuthenticationStore; +use kordophone::APIInterface; +use crate::printers::{ConversationPrinter, MessagePrinter}; use anyhow::Result; use clap::Subcommand; -use crate::printers::{ConversationPrinter, MessagePrinter}; use kordophone::model::event::EventData; use kordophone::model::outgoing_message::OutgoingMessage; @@ -16,18 +16,18 @@ pub fn make_api_client_from_env() -> HTTPAPIClient dotenv::dotenv().ok(); // read from env - let base_url = std::env::var("KORDOPHONE_API_URL") - .expect("KORDOPHONE_API_URL must be set"); + let base_url = std::env::var("KORDOPHONE_API_URL").expect("KORDOPHONE_API_URL must be set"); let credentials = Credentials { - username: std::env::var("KORDOPHONE_USERNAME") - .expect("KORDOPHONE_USERNAME must be set"), + username: std::env::var("KORDOPHONE_USERNAME").expect("KORDOPHONE_USERNAME must be set"), - password: std::env::var("KORDOPHONE_PASSWORD") - .expect("KORDOPHONE_PASSWORD must be set"), + password: std::env::var("KORDOPHONE_PASSWORD").expect("KORDOPHONE_PASSWORD must be set"), }; - HTTPAPIClient::new(base_url.parse().unwrap(), InMemoryAuthenticationStore::new(Some(credentials))) + HTTPAPIClient::new( + base_url.parse().unwrap(), + InMemoryAuthenticationStore::new(Some(credentials)), + ) } #[derive(Subcommand)] @@ -36,9 +36,7 @@ pub enum Commands { Conversations, /// Prints all messages in a conversation. - Messages { - conversation_id: String, - }, + Messages { conversation_id: String }, /// Prints the server Kordophone version. Version, @@ -65,7 +63,10 @@ impl Commands { Commands::Messages { conversation_id } => client.print_messages(conversation_id).await, Commands::RawUpdates => client.print_raw_updates().await, Commands::Events => client.print_events().await, - Commands::SendMessage { conversation_id, message } => client.send_message(conversation_id, message).await, + Commands::SendMessage { + conversation_id, + message, + } => client.send_message(conversation_id, message).await, } } } @@ -96,7 +97,10 @@ impl ClientCli { } pub async fn print_messages(&mut self, conversation_id: String) -> Result<()> { - let messages = self.api.get_messages(&conversation_id, None, None, None).await?; + let messages = self + .api + .get_messages(&conversation_id, None, None, None) + .await?; for message in messages { println!("{}", MessagePrinter::new(&message.into())); } @@ -113,8 +117,11 @@ impl ClientCli { println!("Conversation changed: {}", conversation.guid); } EventData::MessageReceived(conversation, message) => { - println!("Message received: msg: {} conversation: {}", message.guid, conversation.guid); - } + println!( + "Message received: msg: {} conversation: {}", + message.guid, conversation.guid + ); + } } } Ok(()) @@ -143,5 +150,3 @@ impl ClientCli { Ok(()) } } - - diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index 00c7d88..5bd0f8f 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -1,8 +1,8 @@ +use crate::printers::{ConversationPrinter, MessagePrinter}; use anyhow::Result; use clap::Subcommand; use dbus::blocking::{Connection, Proxy}; use prettytable::table; -use crate::printers::{ConversationPrinter, MessagePrinter}; const DBUS_NAME: &str = "net.buzzert.kordophonecd"; const DBUS_PATH: &str = "/net/buzzert/kordophonecd/daemon"; @@ -21,9 +21,7 @@ pub enum Commands { Conversations, /// Runs a full sync operation for a conversation and its messages. - Sync { - conversation_id: Option, - }, + Sync { conversation_id: Option }, /// Runs a sync operation for the conversation list. SyncList, @@ -31,7 +29,7 @@ pub enum Commands { /// Prints the server Kordophone version. Version, - /// Configuration options + /// Configuration options Config { #[command(subcommand)] command: ConfigCommands, @@ -62,14 +60,10 @@ pub enum ConfigCommands { Print, /// Sets the server URL. - SetServerUrl { - url: String, - }, + SetServerUrl { url: String }, /// Sets the username. - SetUsername { - username: String, - }, + SetUsername { username: String }, } impl Commands { @@ -82,9 +76,19 @@ impl Commands { Commands::SyncList => client.sync_conversations_list().await, Commands::Config { command } => client.config(command).await, Commands::Signals => client.wait_for_signals().await, - Commands::Messages { conversation_id, last_message_id } => client.print_messages(conversation_id, last_message_id).await, + Commands::Messages { + conversation_id, + last_message_id, + } => { + client + .print_messages(conversation_id, last_message_id) + .await + } Commands::DeleteAllConversations => client.delete_all_conversations().await, - Commands::SendMessage { conversation_id, text } => client.enqueue_outgoing_message(conversation_id, text).await, + Commands::SendMessage { + conversation_id, + text, + } => client.enqueue_outgoing_message(conversation_id, text).await, } } } @@ -96,12 +100,13 @@ struct DaemonCli { impl DaemonCli { pub fn new() -> Result { Ok(Self { - conn: Connection::new_session()? + conn: Connection::new_session()?, }) } fn proxy(&self) -> Proxy<&Connection> { - self.conn.with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000)) + self.conn + .with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000)) } pub async fn print_version(&mut self) -> Result<()> { @@ -117,7 +122,7 @@ impl DaemonCli { for conversation in conversations { println!("{}", ConversationPrinter::new(&conversation.into())); } - + Ok(()) } @@ -136,8 +141,16 @@ impl DaemonCli { .map_err(|e| anyhow::anyhow!("Failed to sync conversations: {}", e)) } - pub async fn print_messages(&mut self, conversation_id: String, last_message_id: Option) -> Result<()> { - let messages = KordophoneRepository::get_messages(&self.proxy(), &conversation_id, &last_message_id.unwrap_or_default())?; + pub async fn print_messages( + &mut self, + conversation_id: String, + last_message_id: Option, + ) -> Result<()> { + let messages = KordophoneRepository::get_messages( + &self.proxy(), + &conversation_id, + &last_message_id.unwrap_or_default(), + )?; println!("Number of messages: {}", messages.len()); for message in messages { @@ -147,8 +160,13 @@ impl DaemonCli { Ok(()) } - pub async fn enqueue_outgoing_message(&mut self, conversation_id: String, text: String) -> Result<()> { - let outgoing_message_id = KordophoneRepository::send_message(&self.proxy(), &conversation_id, &text)?; + pub async fn enqueue_outgoing_message( + &mut self, + conversation_id: String, + text: String, + ) -> Result<()> { + let outgoing_message_id = + KordophoneRepository::send_message(&self.proxy(), &conversation_id, &text)?; println!("Outgoing message ID: {}", outgoing_message_id); Ok(()) } @@ -159,10 +177,12 @@ impl DaemonCli { pub use super::dbus_interface::NetBuzzertKordophoneRepositoryConversationsUpdated as ConversationsUpdated; } - let _id = self.proxy().match_signal(|h: dbus_signals::ConversationsUpdated, _: &Connection, _: &Message| { - println!("Signal: Conversations updated"); - true - }); + let _id = self.proxy().match_signal( + |h: dbus_signals::ConversationsUpdated, _: &Connection, _: &Message| { + println!("Signal: Conversations updated"); + true + }, + ); println!("Waiting for signals..."); loop { @@ -205,4 +225,4 @@ impl DaemonCli { KordophoneRepository::delete_all_conversations(&self.proxy()) .map_err(|e| anyhow::anyhow!("Failed to delete all conversations: {}", e)) } -} \ No newline at end of file +} diff --git a/kpcli/src/db/mod.rs b/kpcli/src/db/mod.rs index e5dc513..ee80369 100644 --- a/kpcli/src/db/mod.rs +++ b/kpcli/src/db/mod.rs @@ -3,34 +3,37 @@ use clap::Subcommand; use kordophone::APIInterface; use std::{env, path::PathBuf}; +use crate::{ + client, + printers::{ConversationPrinter, MessagePrinter}, +}; use kordophone_db::database::{Database, DatabaseAccess}; -use crate::{client, printers::{ConversationPrinter, MessagePrinter}}; #[derive(Subcommand)] pub enum Commands { /// For dealing with the table of cached conversations. Conversations { #[clap(subcommand)] - command: ConversationCommands + command: ConversationCommands, }, /// For dealing with the table of cached messages. Messages { #[clap(subcommand)] - command: MessageCommands + command: MessageCommands, }, /// For managing settings in the database. Settings { #[clap(subcommand)] - command: SettingsCommands + command: SettingsCommands, }, } #[derive(Subcommand)] pub enum ConversationCommands { /// Lists all conversations currently in the database. - List, + List, /// Syncs with an API client. Sync, @@ -39,9 +42,7 @@ pub enum ConversationCommands { #[derive(Subcommand)] pub enum MessageCommands { /// Prints all messages in a conversation. - List { - conversation_id: String - }, + List { conversation_id: String }, } #[derive(Subcommand)] @@ -49,7 +50,7 @@ pub enum SettingsCommands { /// Lists all settings or gets a specific setting. Get { /// The key to get. If not provided, all settings will be listed. - key: Option + key: Option, }, /// Sets a setting value. @@ -76,7 +77,9 @@ impl Commands { ConversationCommands::Sync => db.sync_with_client().await, }, Commands::Messages { command: cmd } => match cmd { - MessageCommands::List { conversation_id } => db.print_messages(&conversation_id).await, + MessageCommands::List { conversation_id } => { + db.print_messages(&conversation_id).await + } }, Commands::Settings { command: cmd } => match cmd { SettingsCommands::Get { key } => db.get_setting(key).await, @@ -88,15 +91,17 @@ impl Commands { } struct DbClient { - database: Database + database: Database, } impl DbClient { fn database_path() -> PathBuf { - env::var("KORDOPHONE_DB_PATH").unwrap_or_else(|_| { - let temp_dir = env::temp_dir(); - temp_dir.join("kpcli_chat.db").to_str().unwrap().to_string() - }).into() + env::var("KORDOPHONE_DB_PATH") + .unwrap_or_else(|_| { + let temp_dir = env::temp_dir(); + temp_dir.join("kpcli_chat.db").to_str().unwrap().to_string() + }) + .into() } pub fn new() -> Result { @@ -106,13 +111,14 @@ impl DbClient { println!("kpcli: Using db at {}", path_str); let db = Database::new(path_str)?; - Ok( Self { database: db }) + Ok(Self { database: db }) } pub async fn print_conversations(&mut self) -> Result<()> { - let all_conversations = self.database.with_repository(|repository| { - repository.all_conversations(i32::MAX, 0) - }).await?; + let all_conversations = self + .database + .with_repository(|repository| repository.all_conversations(i32::MAX, 0)) + .await?; println!("{} Conversations: ", all_conversations.len()); for conversation in all_conversations { @@ -123,9 +129,10 @@ impl DbClient { } pub async fn print_messages(&mut self, conversation_id: &str) -> Result<()> { - let messages = self.database.with_repository(|repository| { - repository.get_messages_for_conversation(conversation_id) - }).await?; + let messages = self + .database + .with_repository(|repository| repository.get_messages_for_conversation(conversation_id)) + .await?; for message in messages { println!("{}", MessagePrinter::new(&message.into())); @@ -136,85 +143,97 @@ impl DbClient { pub async fn sync_with_client(&mut self) -> Result<()> { let mut client = client::make_api_client_from_env(); let fetched_conversations = client.get_conversations().await?; - let db_conversations: Vec = fetched_conversations.into_iter() + let db_conversations: Vec = fetched_conversations + .into_iter() .map(kordophone_db::models::Conversation::from) .collect(); // Process each conversation for conversation in db_conversations { let conversation_id = conversation.guid.clone(); - + // Insert the conversation - self.database.with_repository(|repository| { - repository.insert_conversation(conversation) - }).await?; + self.database + .with_repository(|repository| repository.insert_conversation(conversation)) + .await?; // Fetch and sync messages for this conversation - let messages = client.get_messages(&conversation_id, None, None, None).await?; - let db_messages: Vec = messages.into_iter() + let messages = client + .get_messages(&conversation_id, None, None, None) + .await?; + let db_messages: Vec = messages + .into_iter() .map(kordophone_db::models::Message::from) .collect(); // Insert each message - self.database.with_repository(|repository| -> Result<()> { - for message in db_messages { - repository.insert_message(&conversation_id, message)?; - } + self.database + .with_repository(|repository| -> Result<()> { + for message in db_messages { + repository.insert_message(&conversation_id, message)?; + } - Ok(()) - }).await?; + Ok(()) + }) + .await?; } Ok(()) } pub async fn get_setting(&mut self, key: Option) -> Result<()> { - self.database.with_settings(|settings| { - match key { - Some(key) => { - // Get a specific setting - let value: Option = settings.get(&key)?; - match value { - Some(v) => println!("{} = {}", key, v), - None => println!("Setting '{}' not found", key), + self.database + .with_settings(|settings| { + match key { + Some(key) => { + // Get a specific setting + let value: Option = settings.get(&key)?; + match value { + Some(v) => println!("{} = {}", key, v), + None => println!("Setting '{}' not found", key), + } } - }, - None => { - // List all settings - let keys = settings.list_keys()?; - if keys.is_empty() { - println!("No settings found"); - } else { - println!("Settings:"); - for key in keys { - let value: Option = settings.get(&key)?; - match value { - Some(v) => println!(" {} = {}", key, v), - None => println!(" {} = ", key), + None => { + // List all settings + let keys = settings.list_keys()?; + if keys.is_empty() { + println!("No settings found"); + } else { + println!("Settings:"); + for key in keys { + let value: Option = settings.get(&key)?; + match value { + Some(v) => println!(" {} = {}", key, v), + None => println!(" {} = ", key), + } } } } } - } - - Ok(()) - }).await + + Ok(()) + }) + .await } pub async fn put_setting(&mut self, key: String, value: String) -> Result<()> { - self.database.with_settings(|settings| { - settings.put(&key, &value)?; - Ok(()) - }).await + self.database + .with_settings(|settings| { + settings.put(&key, &value)?; + Ok(()) + }) + .await } pub async fn delete_setting(&mut self, key: String) -> Result<()> { - self.database.with_settings(|settings| { - let count = settings.del(&key)?; - if count == 0 { - println!("Setting '{}' not found", key); - } - Ok(()) - }).await + self.database + .with_settings(|settings| { + let count = settings.del(&key)?; + if count == 0 { + println!("Setting '{}' not found", key); + } + Ok(()) + }) + .await } } diff --git a/kpcli/src/main.rs b/kpcli/src/main.rs index 2cf4ba8..a485c1a 100644 --- a/kpcli/src/main.rs +++ b/kpcli/src/main.rs @@ -1,7 +1,7 @@ mod client; +mod daemon; mod db; mod printers; -mod daemon; use anyhow::Result; use clap::{Parser, Subcommand}; @@ -33,7 +33,7 @@ enum Commands { Daemon { #[command(subcommand)] command: daemon::Commands, - } + }, } async fn run_command(command: Commands) -> Result<()> { @@ -50,7 +50,7 @@ fn initialize_logging() { .map(|s| s.parse::().unwrap_or(LevelFilter::Info)) .unwrap_or(LevelFilter::Info); - env_logger::Builder::from_default_env() + env_logger::Builder::from_default_env() .format_timestamp_secs() .filter_level(log_level) .init(); @@ -62,7 +62,8 @@ async fn main() { let cli = Cli::parse(); - run_command(cli.command).await + run_command(cli.command) + .await .map_err(|e| println!("Error: {}", e)) .err(); } diff --git a/kpcli/src/printers.rs b/kpcli/src/printers.rs index f9e0c0a..6289419 100644 --- a/kpcli/src/printers.rs +++ b/kpcli/src/printers.rs @@ -1,9 +1,9 @@ -use std::fmt::Display; -use std::collections::HashMap; -use time::OffsetDateTime; -use pretty::RcDoc; use dbus::arg::{self, RefArg}; use kordophone::model::message::AttachmentMetadata; +use pretty::RcDoc; +use std::collections::HashMap; +use std::fmt::Display; +use time::OffsetDateTime; pub struct PrintableConversation { pub guid: String, @@ -17,7 +17,7 @@ pub struct PrintableConversation { impl From for PrintableConversation { fn from(value: kordophone::model::Conversation) -> Self { Self { - guid: value.guid, + guid: value.guid, date: value.date, unread_count: value.unread_count, last_message_preview: value.last_message_preview, @@ -34,7 +34,11 @@ impl From for PrintableConversation { date: OffsetDateTime::from_unix_timestamp(value.date.and_utc().timestamp()).unwrap(), unread_count: value.unread_count.into(), last_message_preview: value.last_message_preview, - participants: value.participants.into_iter().map(|p| p.display_name()).collect(), + participants: value + .participants + .into_iter() + .map(|p| p.display_name()) + .collect(), display_name: value.display_name, } } @@ -44,17 +48,33 @@ impl From for PrintableConversation { fn from(value: arg::PropMap) -> Self { Self { guid: value.get("guid").unwrap().as_str().unwrap().to_string(), - date: OffsetDateTime::from_unix_timestamp(value.get("date").unwrap().as_i64().unwrap()).unwrap(), - unread_count: value.get("unread_count").unwrap().as_i64().unwrap().try_into().unwrap(), - last_message_preview: value.get("last_message_preview").unwrap().as_str().map(|s| s.to_string()), - participants: value.get("participants") + date: OffsetDateTime::from_unix_timestamp(value.get("date").unwrap().as_i64().unwrap()) + .unwrap(), + unread_count: value + .get("unread_count") + .unwrap() + .as_i64() + .unwrap() + .try_into() + .unwrap(), + last_message_preview: value + .get("last_message_preview") + .unwrap() + .as_str() + .map(|s| s.to_string()), + participants: value + .get("participants") .unwrap() .0 .as_iter() .unwrap() .map(|s| s.as_str().unwrap().to_string()) .collect(), - display_name: value.get("display_name").unwrap().as_str().map(|s| s.to_string()), + display_name: value + .get("display_name") + .unwrap() + .as_str() + .map(|s| s.to_string()), } } } @@ -97,19 +117,22 @@ impl From for PrintableMessage { impl From for PrintableMessage { fn from(value: arg::PropMap) -> Self { // Parse file transfer GUIDs from JSON if present - let file_transfer_guids = value.get("file_transfer_guids") + let file_transfer_guids = value + .get("file_transfer_guids") .and_then(|v| v.as_str()) .and_then(|json_str| serde_json::from_str(json_str).ok()) .unwrap_or_default(); // Parse attachment metadata from JSON if present - let attachment_metadata = value.get("attachment_metadata") + let attachment_metadata = value + .get("attachment_metadata") .and_then(|v| v.as_str()) .and_then(|json_str| serde_json::from_str(json_str).ok()); Self { guid: value.get("id").unwrap().as_str().unwrap().to_string(), - date: OffsetDateTime::from_unix_timestamp(value.get("date").unwrap().as_i64().unwrap()).unwrap(), + date: OffsetDateTime::from_unix_timestamp(value.get("date").unwrap().as_i64().unwrap()) + .unwrap(), sender: value.get("sender").unwrap().as_str().unwrap().to_string(), text: value.get("text").unwrap().as_str().unwrap().to_string(), file_transfer_guids, @@ -119,12 +142,13 @@ impl From for PrintableMessage { } pub struct ConversationPrinter<'a> { - doc: RcDoc<'a, PrintableConversation> + doc: RcDoc<'a, PrintableConversation>, } impl<'a> ConversationPrinter<'a> { pub fn new(conversation: &'a PrintableConversation) -> Self { - let preview = conversation.last_message_preview + let preview = conversation + .last_message_preview .as_deref() .unwrap_or("") .replace('\n', " "); @@ -134,33 +158,31 @@ impl<'a> ConversationPrinter<'a> { RcDoc::line() .append("Display Name: ") .append(conversation.display_name.as_deref().unwrap_or("")) - .append(RcDoc::line()) + .append(RcDoc::line()) .append("Date: ") .append(conversation.date.to_string()) - .append(RcDoc::line()) + .append(RcDoc::line()) .append("Unread Count: ") .append(conversation.unread_count.to_string()) - .append(RcDoc::line()) + .append(RcDoc::line()) .append("Participants: ") .append("[") - .append(RcDoc::line() - .append( - conversation.participants - .iter() - .map(|name| - RcDoc::text(name) - .append(",") - .append(RcDoc::line()) - ) - .fold(RcDoc::nil(), |acc, x| acc.append(x)) - ) - .nest(4) + .append( + RcDoc::line() + .append( + conversation + .participants + .iter() + .map(|name| RcDoc::text(name).append(",").append(RcDoc::line())) + .fold(RcDoc::nil(), |acc, x| acc.append(x)), + ) + .nest(4), ) .append("]") - .append(RcDoc::line()) + .append(RcDoc::line()) .append("Last Message Preview: ") .append(preview) - .nest(4) + .nest(4), ) .append(RcDoc::line()) .append(">"); @@ -176,7 +198,7 @@ impl Display for ConversationPrinter<'_> { } pub struct MessagePrinter<'a> { - doc: RcDoc<'a, PrintableMessage> + doc: RcDoc<'a, PrintableMessage>, } impl Display for MessagePrinter<'_> { @@ -187,37 +209,40 @@ impl Display for MessagePrinter<'_> { impl<'a> MessagePrinter<'a> { pub fn new(message: &'a PrintableMessage) -> Self { - let mut doc = RcDoc::text(format!(" MessagePrinter<'a> { attachment_doc }) - .fold(RcDoc::nil(), |acc, x| acc.append(x)) - ) - .nest(4) - ); + .fold(RcDoc::nil(), |acc, x| acc.append(x)), + ) + .nest(4), + ); } doc = doc.append(RcDoc::line()).append(">"); MessagePrinter { doc } } -} \ No newline at end of file +} From 4ddc0dca391b8a20b6baf1b7f93ab9d93c92a96c Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 6 Jun 2025 20:02:09 -0700 Subject: [PATCH 077/138] Notify when attachment download succeeds, fix deadlock in attachment store --- kordophone/src/api/http_client.rs | 12 ++- kordophone/src/model/message.rs | 4 +- .../net.buzzert.kordophonecd.Server.xml | 2 - kordophoned/src/daemon/attachment_store.rs | 81 ++++++++++++++----- kordophoned/src/daemon/events.rs | 5 ++ kordophoned/src/daemon/mod.rs | 10 +++ kordophoned/src/daemon/models/attachment.rs | 22 +++-- kordophoned/src/daemon/signals.rs | 5 ++ kordophoned/src/dbus/mod.rs | 1 + kordophoned/src/dbus/server_impl.rs | 9 +-- kordophoned/src/main.rs | 10 +++ 11 files changed, 126 insertions(+), 35 deletions(-) diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index e0f4249..360d92b 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -469,10 +469,14 @@ impl HTTPAPIClient { .expect("Unable to build request") }; + log::trace!("Obtaining token from auth store"); let token = self.auth_store.get_token().await; - let request = build_request(&token); - let mut response = self.client.request(request).await?; + log::trace!("Token: {:?}", token); + let request = build_request(&token); + log::trace!("Request: {:?}. Sending request...", request); + + let mut response = self.client.request(request).await?; log::debug!("-> Response: {:}", response.status()); match response.status() { @@ -502,7 +506,9 @@ impl HTTPAPIClient { // Other errors: bubble up. _ => { - let message = format!("Request failed ({:})", response.status()); + let status = response.status(); + let body_str = hyper::body::to_bytes(response.into_body()).await?; + let message = format!("Request failed ({:}). Response body: {:?}", status, String::from_utf8_lossy(&body_str)); return Err(Error::ClientError(message)); } } diff --git a/kordophone/src/model/message.rs b/kordophone/src/model/message.rs index cca26f5..2b53bdf 100644 --- a/kordophone/src/model/message.rs +++ b/kordophone/src/model/message.rs @@ -10,11 +10,11 @@ pub type MessageID = ::ID; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AttributionInfo { /// Picture width - #[serde(rename = "pgensh")] + #[serde(rename = "pgensw")] pub width: Option, /// Picture height - #[serde(rename = "pgensw")] + #[serde(rename = "pgensh")] pub height: Option, } diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index d771c05..2cf0e9b 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -122,8 +122,6 @@ - - diff --git a/kordophoned/src/daemon/attachment_store.rs b/kordophoned/src/daemon/attachment_store.rs index 7b35cb9..711fb95 100644 --- a/kordophoned/src/daemon/attachment_store.rs +++ b/kordophoned/src/daemon/attachment_store.rs @@ -10,7 +10,7 @@ use thiserror::Error; use kordophone_db::database::Database; -use crate::daemon::events::Event; +use crate::daemon::events::Event as DaemonEvent; use crate::daemon::events::Reply; use crate::daemon::models::Attachment; use crate::daemon::Daemon; @@ -43,14 +43,23 @@ enum AttachmentStoreError { #[error("attachment has already been downloaded")] AttachmentAlreadyDownloaded, + #[error("temporary file already exists, assuming download is in progress")] + DownloadAlreadyInProgress, + #[error("Client error: {0}")] APIClientError(String), } +#[derive(Debug, Clone)] +struct DownloadRequest { + guid: String, + preview: bool, +} + pub struct AttachmentStore { store_path: PathBuf, database: Arc>, - daemon_event_sink: Sender, + daemon_event_sink: Sender, event_source: Receiver, event_sink: Option>, @@ -64,7 +73,7 @@ impl AttachmentStore { pub fn new( database: Arc>, - daemon_event_sink: Sender, + daemon_event_sink: Sender, ) -> AttachmentStore { let store_path = Self::get_default_store_path(); log::info!(target: target::ATTACHMENTS, "Attachment store path: {}", store_path.display()); @@ -101,36 +110,54 @@ impl AttachmentStore { } } - async fn download_attachment(&mut self, attachment: &Attachment, preview: bool) -> Result<()> { + async fn download_attachment_impl( + store_path: &PathBuf, + database: &mut Arc>, + daemon_event_sink: &Sender, + guid: &String, + preview: bool + ) -> Result<()> { + let attachment = Self::get_attachment_impl(store_path, guid); + if attachment.is_downloaded(preview) { log::info!(target: target::ATTACHMENTS, "Attachment already downloaded: {}", attachment.guid); return Err(AttachmentStoreError::AttachmentAlreadyDownloaded.into()); } + let temporary_path = attachment.get_path_for_preview_scratch(preview, true); + if std::fs::exists(&temporary_path).unwrap_or(false) { + log::info!(target: target::ATTACHMENTS, "Temporary file already exists: {}, assuming download is in progress", temporary_path.display()); + return Err(AttachmentStoreError::DownloadAlreadyInProgress.into()); + } + log::info!(target: target::ATTACHMENTS, "Starting download for attachment: {}", attachment.guid); - // Create temporary file first, we'll atomically swap later. - assert!(!std::fs::exists(&attachment.get_path(preview)).unwrap()); - let file = std::fs::File::create(&attachment.get_path(preview))?; + let file = std::fs::File::create(&temporary_path)?; let mut writer = BufWriter::new(&file); - - log::trace!(target: target::ATTACHMENTS, "Created attachment file at {}", &attachment.get_path(preview).display()); - - let mut client = Daemon::get_client_impl(&mut self.database).await?; - let stream = client + let mut client = Daemon::get_client_impl(database).await?; + let mut stream = client .fetch_attachment_data(&attachment.guid, preview) .await .map_err(|e| AttachmentStoreError::APIClientError(format!("{:?}", e)))?; - // Since we're async, we need to pin this. - pin!(stream); - - log::trace!(target: target::ATTACHMENTS, "Writing attachment data to disk"); + log::trace!(target: target::ATTACHMENTS, "Writing attachment {:?} data to temporary file {:?}", &attachment.guid, &temporary_path); while let Some(Ok(data)) = stream.next().await { writer.write(data.as_ref())?; } + + // Flush and sync the temporary file before moving + writer.flush()?; + file.sync_all()?; + + // Atomically move the temporary file to the final location + std::fs::rename(&temporary_path, &attachment.get_path_for_preview_scratch(preview, false))?; log::info!(target: target::ATTACHMENTS, "Completed download for attachment: {}", attachment.guid); + + // Send a signal to the daemon that the attachment has been downloaded. + let event = DaemonEvent::AttachmentDownloaded(attachment.guid.clone()); + daemon_event_sink.send(event).await.unwrap(); + Ok(()) } @@ -144,9 +171,27 @@ impl AttachmentStore { AttachmentStoreEvent::QueueDownloadAttachment(guid, preview) => { let attachment = self.get_attachment(&guid); if !attachment.is_downloaded(preview) { - self.download_attachment(&attachment, preview).await.unwrap_or_else(|e| { - log::error!(target: target::ATTACHMENTS, "Error downloading attachment: {}", e); + let store_path = self.store_path.clone(); + let mut database = self.database.clone(); + let daemon_event_sink = self.daemon_event_sink.clone(); + let _guid = guid.clone(); + + // Spawn a new task here so we don't block incoming queue events. + tokio::spawn(async move { + let result = Self::download_attachment_impl( + &store_path, + &mut database, + &daemon_event_sink, + &_guid, + preview, + ).await; + + if let Err(e) = result { + log::error!(target: target::ATTACHMENTS, "Error downloading attachment {}: {}", &_guid, e); + } }); + + log::debug!(target: target::ATTACHMENTS, "Queued download for attachment: {}", &guid); } else { log::info!(target: target::ATTACHMENTS, "Attachment already downloaded: {}", guid); } diff --git a/kordophoned/src/daemon/events.rs b/kordophoned/src/daemon/events.rs index 627461e..19c89a6 100644 --- a/kordophoned/src/daemon/events.rs +++ b/kordophoned/src/daemon/events.rs @@ -71,4 +71,9 @@ pub enum Event { /// Delete all conversations from the database. DeleteAllConversations(Reply<()>), + + /// Notifies the daemon that an attachment has been downloaded. + /// Parameters: + /// - attachment_id: The attachment ID that was downloaded. + AttachmentDownloaded(String), } diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index d62b67c..3266eda 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -314,6 +314,16 @@ impl Daemon { reply.send(()).unwrap(); } + + Event::AttachmentDownloaded(attachment_id) => { + log::info!(target: target::ATTACHMENTS, "Daemon: attachment downloaded: {}, sending signal", attachment_id); + + // Send signal to the client that the attachment has been downloaded. + self.signal_sender + .send(Signal::AttachmentDownloaded(attachment_id)) + .await + .unwrap(); + } } } diff --git a/kordophoned/src/daemon/models/attachment.rs b/kordophoned/src/daemon/models/attachment.rs index 0251451..6e0b95a 100644 --- a/kordophoned/src/daemon/models/attachment.rs +++ b/kordophoned/src/daemon/models/attachment.rs @@ -19,16 +19,28 @@ pub struct Attachment { } impl Attachment { - pub fn get_path(&self, preview: bool) -> PathBuf { - self.base_path - .with_extension(if preview { "preview" } else { "full" }) + pub fn get_path(&self) -> PathBuf { + self.get_path_for_preview_scratch(false, false) + } + + pub fn get_path_for_preview(&self, preview: bool) -> PathBuf { + self.get_path_for_preview_scratch(preview, false) + } + + pub fn get_path_for_preview_scratch(&self, preview: bool, scratch: bool) -> PathBuf { + let extension = if preview { "preview" } else { "full" }; + if scratch { + self.base_path.with_extension(format!("{}.download", extension)) + } else { + self.base_path.with_extension(extension) + } } pub fn is_downloaded(&self, preview: bool) -> bool { - std::fs::exists(&self.get_path(preview)).expect( + std::fs::exists(&self.get_path_for_preview(preview)).expect( format!( "Wasn't able to check for the existence of an attachment file path at {}", - &self.get_path(preview).display() + &self.get_path_for_preview(preview).display() ) .as_str(), ) diff --git a/kordophoned/src/daemon/signals.rs b/kordophoned/src/daemon/signals.rs index c4fb715..7509740 100644 --- a/kordophoned/src/daemon/signals.rs +++ b/kordophoned/src/daemon/signals.rs @@ -7,4 +7,9 @@ pub enum Signal { /// Parameters: /// - conversation_id: The ID of the conversation that was updated. MessagesUpdated(String), + + /// Emitted when an attachment has been downloaded. + /// Parameters: + /// - attachment_id: The ID of the attachment that was downloaded. + AttachmentDownloaded(String), } diff --git a/kordophoned/src/dbus/mod.rs b/kordophoned/src/dbus/mod.rs index 17fbf1e..1ad4a0c 100644 --- a/kordophoned/src/dbus/mod.rs +++ b/kordophoned/src/dbus/mod.rs @@ -12,5 +12,6 @@ pub mod interface { pub mod signals { pub use crate::interface::NetBuzzertKordophoneRepositoryConversationsUpdated as ConversationsUpdated; pub use crate::interface::NetBuzzertKordophoneRepositoryMessagesUpdated as MessagesUpdated; + pub use crate::interface::NetBuzzertKordophoneRepositoryAttachmentDownloadCompleted as AttachmentDownloadCompleted; } } diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index ba5773e..0964f4a 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -125,7 +125,6 @@ impl DbusRepository for ServerImpl { messages .into_iter() .map(|msg| { - let msg_id = msg.id.clone(); // Store ID for potential error logging let mut map = arg::PropMap::new(); map.insert("id".into(), arg::Variant(Box::new(msg.id))); map.insert("text".into(), arg::Variant(Box::new(msg.text))); @@ -150,8 +149,8 @@ impl DbusRepository for ServerImpl { ); // Get attachment paths and download status - let path = attachment.get_path(false); - let preview_path = attachment.get_path(true); + 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); @@ -238,10 +237,10 @@ impl DbusRepository for ServerImpl { ) -> Result<(String, String, bool, bool), dbus::MethodErr> { self.send_event_sync(|r| Event::GetAttachment(attachment_id, r)) .map(|attachment| { - let path = attachment.get_path(false); + let path = attachment.get_path_for_preview(false); let downloaded = attachment.is_downloaded(false); - let preview_path = attachment.get_path(true); + let preview_path = attachment.get_path_for_preview(true); let preview_downloaded = attachment.is_downloaded(true); ( diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index 629b26a..03bca17 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -98,6 +98,16 @@ async fn main() { 0 }); } + + Signal::AttachmentDownloaded(attachment_id) => { + log::debug!("Sending signal: AttachmentDownloaded for attachment {}", attachment_id); + dbus_registry + .send_signal(interface::OBJECT_PATH, DbusSignals::AttachmentDownloadCompleted { attachment_id }) + .unwrap_or_else(|_| { + log::error!("Failed to send signal"); + 0 + }); + } } } }); From 2f4e9b7c07df6afdf167fa5570a33b784adbf9d5 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 12 Jun 2025 17:58:03 -0700 Subject: [PATCH 078/138] Implements attachment uploading --- Cargo.lock | 7 +- kordophone/Cargo.toml | 1 + kordophone/src/api/http_client.rs | 43 ++++++++++- kordophone/src/api/mod.rs | 9 +++ kordophone/src/tests/test_client.rs | 11 +++ .../net.buzzert.kordophonecd.Server.xml | 17 +++++ kordophoned/src/daemon/attachment_store.rs | 73 ++++++++++++++++++- kordophoned/src/daemon/events.rs | 14 ++++ kordophoned/src/daemon/mod.rs | 18 +++++ kordophoned/src/daemon/signals.rs | 6 ++ kordophoned/src/dbus/mod.rs | 1 + kordophoned/src/dbus/server_impl.rs | 10 +++ kordophoned/src/main.rs | 10 +++ kpcli/src/daemon/mod.rs | 56 ++++++++++++++ 14 files changed, 268 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 01619c7..12d628e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1019,6 +1019,7 @@ dependencies = [ "time", "tokio", "tokio-tungstenite", + "tokio-util", "tungstenite", "uuid", ] @@ -1960,16 +1961,16 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.10" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", - "tracing", ] [[package]] diff --git a/kordophone/Cargo.toml b/kordophone/Cargo.toml index 131ca83..24e074c 100644 --- a/kordophone/Cargo.toml +++ b/kordophone/Cargo.toml @@ -22,5 +22,6 @@ serde_plain = "1.0.2" time = { version = "0.3.17", features = ["parsing", "serde"] } tokio = { version = "1.37.0", features = ["full"] } tokio-tungstenite = "0.26.2" +tokio-util = { version = "0.7.15", features = ["futures-util"] } tungstenite = "0.26.2" uuid = { version = "1.6.1", features = ["v4", "fast-rng", "macro-diagnostics"] } diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index 360d92b..ddd6d65 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -13,6 +13,7 @@ use async_trait::async_trait; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use tokio::net::TcpStream; +use tokio_util::io::ReaderStream; use futures_util::stream::{BoxStream, Stream}; use futures_util::task::Context; @@ -289,6 +290,40 @@ impl APIInterface for HTTPAPIClient { .map(ResponseStream::from) } + async fn upload_attachment( + &mut self, + data: tokio::io::BufReader, + filename: &str, + ) -> Result + where + R: tokio::io::AsyncRead + Unpin + Send + Sync + 'static, + { + #[derive(Deserialize, Debug)] + struct UploadAttachmentResponse { + #[serde(rename = "fileTransferGUID")] + guid: String, + } + + let endpoint = format!("uploadAttachment?filename={}", filename); + let mut data_opt = Some(data); + + let response: UploadAttachmentResponse = self + .deserialized_response_with_body_retry( + &endpoint, + Method::POST, + move || { + let stream = ReaderStream::new( + data_opt.take().expect("Stream already consumed during retry"), + ); + Body::wrap_stream(stream) + }, + false, // don't retry auth for streaming body + ) + .await?; + + Ok(response.guid) + } + async fn open_event_socket( &mut self, update_seq: Option, @@ -406,7 +441,7 @@ impl HTTPAPIClient { &mut self, endpoint: &str, method: Method, - body_fn: impl Fn() -> Body, + body_fn: impl FnMut() -> Body, ) -> Result where T: DeserializeOwned, @@ -419,7 +454,7 @@ impl HTTPAPIClient { &mut self, endpoint: &str, method: Method, - body_fn: impl Fn() -> Body, + body_fn: impl FnMut() -> Body, retry_auth: bool, ) -> Result where @@ -451,7 +486,7 @@ impl HTTPAPIClient { &mut self, endpoint: &str, method: Method, - body_fn: impl Fn() -> Body, + mut body_fn: impl FnMut() -> Body, retry_auth: bool, ) -> Result, Error> { use hyper::StatusCode; @@ -459,7 +494,7 @@ impl HTTPAPIClient { let uri = self.uri_for_endpoint(endpoint, None); log::debug!("Requesting {:?} {:?}", method, uri); - let build_request = move |auth: &Option| { + let mut build_request = |auth: &Option| { let body = body_fn(); Request::builder() .method(&method) diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index 0f63308..c0e67c0 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -51,6 +51,15 @@ pub trait APIInterface { preview: bool, ) -> Result; + // (POST) /uploadAttachment + async fn upload_attachment( + &mut self, + data: tokio::io::BufReader, + filename: &str, + ) -> Result + where + R: tokio::io::AsyncRead + Unpin + Send + Sync + 'static; + // (POST) /authenticate async fn authenticate(&mut self, credentials: Credentials) -> Result; diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index c051e1f..ffb9874 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -127,4 +127,15 @@ impl APIInterface for TestClient { ) -> Result { Ok(futures_util::stream::iter(vec![Ok(Bytes::from_static(b"test"))]).boxed()) } + + async fn upload_attachment( + &mut self, + data: tokio::io::BufReader, + filename: &str, + ) -> Result + where + R: tokio::io::AsyncRead + Unpin + Send + Sync + 'static, + { + Ok(String::from("test")) + } } diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index 2cf0e9b..bdc0fb2 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -120,6 +120,11 @@ "/> + + + + + @@ -133,6 +138,18 @@ + + + + + + + diff --git a/kordophoned/src/daemon/attachment_store.rs b/kordophoned/src/daemon/attachment_store.rs index 711fb95..b77294f 100644 --- a/kordophoned/src/daemon/attachment_store.rs +++ b/kordophoned/src/daemon/attachment_store.rs @@ -19,7 +19,7 @@ use std::sync::Arc; use tokio::sync::mpsc::{Receiver, Sender}; use tokio::sync::Mutex; -use tokio::pin; +use uuid::Uuid; mod target { pub static ATTACHMENTS: &str = "attachments"; @@ -36,6 +36,12 @@ pub enum AttachmentStoreEvent { // - attachment guid // - preview: whether to download the preview (true) or full attachment (false) QueueDownloadAttachment(String, bool), + + // Queue an upload for a given attachment file. + // Args: + // - path: the path to the attachment file + // - reply: a reply channel to send the pending upload guid to + QueueUploadAttachment(PathBuf, Reply), } #[derive(Debug, Error)] @@ -161,6 +167,47 @@ impl AttachmentStore { Ok(()) } + async fn upload_attachment_impl( + store_path: &PathBuf, + incoming_path: &PathBuf, + upload_guid: &String, + database: &mut Arc>, + daemon_event_sink: &Sender, + ) -> Result { + use tokio::fs::File; + use tokio::io::BufReader; + + // Create uploads directory if it doesn't exist. + let uploads_path = store_path.join("uploads"); + std::fs::create_dir_all(&uploads_path).unwrap(); + + // First, copy the file to the store path, under /uploads/. + log::trace!(target: target::ATTACHMENTS, "Copying attachment to uploads directory: {}", uploads_path.display()); + let temporary_path = uploads_path.join(incoming_path.file_name().unwrap()); + std::fs::copy(incoming_path, &temporary_path).unwrap(); + + // Open file handle to the temporary file, + log::trace!(target: target::ATTACHMENTS, "Opening stream to temporary file: {}", temporary_path.display()); + let file = File::open(&temporary_path).await?; + let reader: BufReader = BufReader::new(file); + + // Upload the file to the server. + let filename = incoming_path.file_name().unwrap().to_str().unwrap(); + log::trace!(target: target::ATTACHMENTS, "Uploading attachment to server: {}", &filename); + let mut client = Daemon::get_client_impl(database).await?; + let guid = client.upload_attachment(reader, filename).await?; + + // Delete the temporary file. + log::debug!(target: target::ATTACHMENTS, "Upload completed with guid {}, deleting temporary file: {}", guid, temporary_path.display()); + std::fs::remove_file(&temporary_path).unwrap(); + + // Send a signal to the daemon that the attachment has been uploaded. + let event = DaemonEvent::AttachmentUploaded(upload_guid.clone(), guid.clone()); + daemon_event_sink.send(event).await.unwrap(); + + Ok(guid) + } + pub async fn run(&mut self) { loop { tokio::select! { @@ -201,6 +248,30 @@ impl AttachmentStore { let attachment = self.get_attachment(&guid); reply.send(attachment).unwrap(); } + + AttachmentStoreEvent::QueueUploadAttachment(path, reply) => { + let upload_guid = Uuid::new_v4().to_string(); + let store_path = self.store_path.clone(); + let mut database = self.database.clone(); + let daemon_event_sink = self.daemon_event_sink.clone(); + + let _upload_guid = upload_guid.clone(); + tokio::spawn(async move { + let result = Self::upload_attachment_impl( + &store_path, + &path, + &_upload_guid, + &mut database, + &daemon_event_sink, + ).await; + + if let Err(e) = result { + log::error!(target: target::ATTACHMENTS, "Error uploading attachment {}: {}", &_upload_guid, e); + } + }); + + reply.send(upload_guid).unwrap(); + } } } } diff --git a/kordophoned/src/daemon/events.rs b/kordophoned/src/daemon/events.rs index 19c89a6..43062fb 100644 --- a/kordophoned/src/daemon/events.rs +++ b/kordophoned/src/daemon/events.rs @@ -10,6 +10,8 @@ use crate::daemon::{Attachment, Message}; pub type Reply = oneshot::Sender; +use std::path::PathBuf; + #[derive(Debug)] pub enum Event { /// Get the version of the daemon. @@ -76,4 +78,16 @@ pub enum Event { /// Parameters: /// - attachment_id: The attachment ID that was downloaded. AttachmentDownloaded(String), + + /// Upload an attachment to the server. + /// Parameters: + /// - path: The path to the attachment file + /// - reply: Reply indicating the upload GUID + UploadAttachment(PathBuf, Reply), + + /// Notifies the daemon that an attachment has been uploaded. + /// Parameters: + /// - upload_id: The upload ID that was uploaded. + /// - attachment_id: The attachment ID that was uploaded. + AttachmentUploaded(String, String), } diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 3266eda..e32e6ea 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -324,6 +324,24 @@ impl Daemon { .await .unwrap(); } + + Event::UploadAttachment(path, reply) => { + self.attachment_store_sink + .as_ref() + .unwrap() + .send(AttachmentStoreEvent::QueueUploadAttachment(path, reply)) + .await + .unwrap(); + } + + Event::AttachmentUploaded(upload_guid, attachment_guid) => { + log::info!(target: target::ATTACHMENTS, "Daemon: attachment uploaded: {}, {}", upload_guid, attachment_guid); + + self.signal_sender + .send(Signal::AttachmentUploaded(upload_guid, attachment_guid)) + .await + .unwrap(); + } } } diff --git a/kordophoned/src/daemon/signals.rs b/kordophoned/src/daemon/signals.rs index 7509740..6fc5cd2 100644 --- a/kordophoned/src/daemon/signals.rs +++ b/kordophoned/src/daemon/signals.rs @@ -12,4 +12,10 @@ pub enum Signal { /// Parameters: /// - attachment_id: The ID of the attachment that was downloaded. AttachmentDownloaded(String), + + /// Emitted when an attachment has been uploaded. + /// Parameters: + /// - upload_guid: The GUID of the upload. + /// - attachment_guid: The GUID of the attachment on the server. + AttachmentUploaded(String, String), } diff --git a/kordophoned/src/dbus/mod.rs b/kordophoned/src/dbus/mod.rs index 1ad4a0c..2cf6189 100644 --- a/kordophoned/src/dbus/mod.rs +++ b/kordophoned/src/dbus/mod.rs @@ -13,5 +13,6 @@ pub mod interface { pub use crate::interface::NetBuzzertKordophoneRepositoryConversationsUpdated as ConversationsUpdated; pub use crate::interface::NetBuzzertKordophoneRepositoryMessagesUpdated as MessagesUpdated; pub use crate::interface::NetBuzzertKordophoneRepositoryAttachmentDownloadCompleted as AttachmentDownloadCompleted; + pub use crate::interface::NetBuzzertKordophoneRepositoryAttachmentUploadCompleted as AttachmentUploadCompleted; } } diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 0964f4a..7926f99 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -264,6 +264,16 @@ impl DbusRepository for ServerImpl { // For now, just trigger the download event - we'll implement the actual download logic later self.send_event_sync(|r| Event::DownloadAttachment(attachment_id, preview, r)) } + + fn upload_attachment( + &mut self, + path: String, + ) -> Result { + use std::path::PathBuf; + + let path = PathBuf::from(path); + self.send_event_sync(|r| Event::UploadAttachment(path, r)) + } } impl DbusSettings for ServerImpl { diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index 03bca17..128333a 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -108,6 +108,16 @@ async fn main() { 0 }); } + + Signal::AttachmentUploaded(upload_guid, attachment_guid) => { + log::debug!("Sending signal: AttachmentUploaded for upload {}, attachment {}", upload_guid, attachment_guid); + dbus_registry + .send_signal(interface::OBJECT_PATH, DbusSignals::AttachmentUploadCompleted { upload_guid, attachment_guid }) + .unwrap_or_else(|_| { + log::error!("Failed to send signal"); + 0 + }); + } } } }); diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index 5bd0f8f..cb4d5ff 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -52,6 +52,16 @@ pub enum Commands { conversation_id: String, text: String, }, + + /// Downloads an attachment from the server to the attachment store. Returns the path to the attachment. + DownloadAttachment { + attachment_id: String, + }, + + /// Uploads an attachment to the server, returns upload guid. + UploadAttachment { + path: String, + }, } #[derive(Subcommand)] @@ -89,6 +99,8 @@ impl Commands { conversation_id, text, } => client.enqueue_outgoing_message(conversation_id, text).await, + Commands::UploadAttachment { path } => client.upload_attachment(path).await, + Commands::DownloadAttachment { attachment_id } => client.download_attachment(attachment_id).await, } } } @@ -225,4 +237,48 @@ impl DaemonCli { KordophoneRepository::delete_all_conversations(&self.proxy()) .map_err(|e| anyhow::anyhow!("Failed to delete all conversations: {}", e)) } + + pub async fn download_attachment(&mut self, attachment_id: String) -> Result<()> { + // Trigger download. + KordophoneRepository::download_attachment(&self.proxy(), &attachment_id, false)?; + + // Get attachment info. + let attachment_info = KordophoneRepository::get_attachment_info(&self.proxy(), &attachment_id)?; + let (path, preview_path, downloaded, preview_downloaded) = attachment_info; + + if downloaded { + println!("Attachment already downloaded: {}", path); + return Ok(()); + } + + println!("Downloading attachment: {}", attachment_id); + + // Attach to the signal that the attachment has been downloaded. + let _id = self.proxy().match_signal( + move |h: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadCompleted, _: &Connection, _: &dbus::message::Message| { + println!("Signal: Attachment downloaded: {}", path); + std::process::exit(0); + }, + ); + + let _id = self.proxy().match_signal( + |h: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadFailed, _: &Connection, _: &dbus::message::Message| { + println!("Signal: Attachment download failed: {}", h.attachment_id); + std::process::exit(1); + }, + ); + + // Wait for the signal. + loop { + self.conn.process(std::time::Duration::from_millis(1000))?; + } + + Ok(()) + } + + pub async fn upload_attachment(&mut self, path: String) -> Result<()> { + let upload_guid = KordophoneRepository::upload_attachment(&self.proxy(), &path)?; + println!("Upload GUID: {}", upload_guid); + Ok(()) + } } From 930f905efc009d40d1aa4512809e76cdb798ae47 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 12 Jun 2025 18:09:58 -0700 Subject: [PATCH 079/138] Perf optimizations, recommended by o3 --- kordophone-db/src/database.rs | 8 +++ kordophone-db/src/repository.rs | 115 ++++++++++++++++++++++++-------- kordophoned/src/main.rs | 2 +- 3 files changed, 98 insertions(+), 27 deletions(-) diff --git a/kordophone-db/src/database.rs b/kordophone-db/src/database.rs index b0fe629..326156f 100644 --- a/kordophone-db/src/database.rs +++ b/kordophone-db/src/database.rs @@ -31,6 +31,14 @@ pub struct Database { impl Database { pub fn new(path: &str) -> Result { let mut connection = SqliteConnection::establish(path)?; + + // Performance optimisations for SQLite. These are safe defaults that speed + // up concurrent writes and cut the fsync cost dramatically while still + // keeping durability guarantees that are good enough for an end-user + // application. + diesel::sql_query("PRAGMA journal_mode = WAL;").execute(&mut connection)?; + diesel::sql_query("PRAGMA synchronous = NORMAL;").execute(&mut connection)?; + connection .run_pending_migrations(MIGRATIONS) .map_err(|e| anyhow::anyhow!("Error running migrations: {}", e))?; diff --git a/kordophone-db/src/repository.rs b/kordophone-db/src/repository.rs index c537e07..9fd70b1 100644 --- a/kordophone-db/src/repository.rs +++ b/kordophone-db/src/repository.rs @@ -1,6 +1,7 @@ use anyhow::Result; use diesel::prelude::*; use diesel::query_dsl::BelongingToDsl; +use std::collections::HashMap; use crate::{ models::{ @@ -142,8 +143,8 @@ impl<'a> Repository<'a> { ) -> Result<()> { use crate::schema::conversation_messages::dsl::*; use crate::schema::messages::dsl::*; + use crate::schema::participants::dsl as participants_dsl; - // Local insertable struct for the join table #[derive(Insertable)] #[diesel(table_name = crate::schema::conversation_messages)] struct InsertableConversationMessage { @@ -155,37 +156,99 @@ impl<'a> Repository<'a> { return Ok(()); } - // Build the collections of insertable records - let mut db_messages: Vec = Vec::with_capacity(in_messages.len()); - let mut conv_msg_records: Vec = - Vec::with_capacity(in_messages.len()); + // Use a single transaction for everything – this removes the implicit + // autocommit after every statement which costs a lot when we have many + // individual queries. + self.connection + .transaction::<_, diesel::result::Error, _>(|conn| { + // Cache participant ids we have already looked up / created – in a + // typical conversation we only have a handful of participants, but we + // might be processing hundreds of messages. Avoiding an extra SELECT per + // message saves a lot of round-trips to SQLite. + let mut participant_cache: HashMap = HashMap::new(); - for message in in_messages { - // Handle participant if message has a remote sender - let sender = message.sender.clone(); - let mut db_message: MessageRecord = message.into(); - db_message.sender_participant_id = self.get_or_create_participant(&sender); + // Prepare collections for the batch inserts. + let mut db_messages: Vec = + Vec::with_capacity(in_messages.len()); + let mut conv_msg_records: Vec = + Vec::with_capacity(in_messages.len()); - conv_msg_records.push(InsertableConversationMessage { - conversation_id: conversation_guid.to_string(), - message_id: db_message.id.clone(), - }); + for message in in_messages { + // Resolve/insert the sender participant only once per display name. + let sender_id = match &message.sender { + Participant::Me => None, + Participant::Remote { display_name, .. } => { + if let Some(cached_participant_id) = participant_cache.get(display_name) { + Some(*cached_participant_id) + } else { + // Try to load from DB first + let existing: Option = participants_dsl::participants + .filter(participants_dsl::display_name.eq(display_name)) + .select(participants_dsl::id) + .first::(conn) + .optional()?; - db_messages.push(db_message); - } + let participant_id = if let Some(pid) = existing { + pid + } else { + let new_participant = InsertableParticipantRecord { + display_name: Some(display_name.clone()), + is_me: false, + }; - // Batch insert or replace messages - diesel::replace_into(messages) - .values(&db_messages) - .execute(self.connection)?; + diesel::insert_into(participants_dsl::participants) + .values(&new_participant) + .execute(conn)?; - // Batch insert the conversation-message links - diesel::replace_into(conversation_messages) - .values(&conv_msg_records) - .execute(self.connection)?; + // last_insert_rowid() is connection-wide, but we are the only + // writer inside this transaction. + diesel::select(diesel::dsl::sql::( + "last_insert_rowid()", + )) + .get_result::(conn)? + }; - // Update conversation date - self.update_conversation_metadata(conversation_guid)?; + participant_cache.insert(display_name.clone(), participant_id); + Some(participant_id) + } + } + }; + + // Convert the message into its DB form. + let mut db_message: MessageRecord = message.into(); + db_message.sender_participant_id = sender_id; + + conv_msg_records.push(InsertableConversationMessage { + conversation_id: conversation_guid.to_string(), + message_id: db_message.id.clone(), + }); + + db_messages.push(db_message); + } + + // Execute the actual batch inserts. + diesel::replace_into(messages) + .values(&db_messages) + .execute(conn)?; + + diesel::replace_into(conversation_messages) + .values(&conv_msg_records) + .execute(conn)?; + + // Update conversation metadata quickly using the last message we just + // processed instead of re-querying the DB. + if let Some(last_msg) = db_messages.last() { + use crate::schema::conversations::dsl as conv_dsl; + diesel::update(conv_dsl::conversations.filter(conv_dsl::id.eq(conversation_guid))) + .set(( + conv_dsl::date.eq(last_msg.date), + conv_dsl::last_message_preview.eq::>(Some(last_msg.text.clone())), + )) + .execute(conn)?; + } + + Ok(()) + })?; Ok(()) } diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index 128333a..18228d0 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -19,7 +19,7 @@ fn initialize_logging() { .unwrap_or(LevelFilter::Info); env_logger::Builder::from_default_env() - .format_timestamp_secs() + .format_timestamp_millis() .filter_level(log_level) .init(); } From ff03e73758f30c081a9319a8c04025cba69b8393 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 12 Jun 2025 20:36:40 -0700 Subject: [PATCH 080/138] plumb attachment guids for sendmessage --- .../include/net.buzzert.kordophonecd.Server.xml | 12 +++++++++++- kordophoned/src/daemon/attachment_store.rs | 2 +- kordophoned/src/daemon/events.rs | 3 ++- kordophoned/src/daemon/mod.rs | 7 ++++--- kordophoned/src/dbus/server_impl.rs | 3 ++- kpcli/src/daemon/mod.rs | 3 ++- 6 files changed, 22 insertions(+), 8 deletions(-) diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index bdc0fb2..8e93ca5 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -81,10 +81,20 @@ + + + value="Sends a message to the server. Returns the outgoing message ID. + Arguments: + - conversation_id: The ID of the conversation to send the message to. + - text: The text of the message to send. + - attachment_guids: The GUIDs of the attachments to send. + + Returns: + - outgoing_message_id: The ID of the outgoing message. + "/> diff --git a/kordophoned/src/daemon/attachment_store.rs b/kordophoned/src/daemon/attachment_store.rs index b77294f..6b1c00b 100644 --- a/kordophoned/src/daemon/attachment_store.rs +++ b/kordophoned/src/daemon/attachment_store.rs @@ -175,7 +175,7 @@ impl AttachmentStore { daemon_event_sink: &Sender, ) -> Result { use tokio::fs::File; - use tokio::io::BufReader; + use tokio::io::BufReader; // Create uploads directory if it doesn't exist. let uploads_path = store_path.join("uploads"); diff --git a/kordophoned/src/daemon/events.rs b/kordophoned/src/daemon/events.rs index 43062fb..d14f371 100644 --- a/kordophoned/src/daemon/events.rs +++ b/kordophoned/src/daemon/events.rs @@ -48,8 +48,9 @@ pub enum Event { /// Parameters: /// - conversation_id: The ID of the conversation to send the message to. /// - text: The text of the message to send. + /// - attachment_guids: The GUIDs of the attachments to send. /// - reply: The outgoing message ID (not the server-assigned message ID). - SendMessage(String, String, Reply), + SendMessage(String, String, Vec, Reply), /// Notifies the daemon that a message has been sent. /// Parameters: diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index e32e6ea..7606b90 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -251,10 +251,10 @@ impl Daemon { reply.send(()).unwrap(); } - Event::SendMessage(conversation_id, text, reply) => { + Event::SendMessage(conversation_id, text, attachment_guids, reply) => { let conversation_id = conversation_id.clone(); let uuid = self - .enqueue_outgoing_message(text, conversation_id.clone()) + .enqueue_outgoing_message(text, conversation_id.clone(), attachment_guids) .await; reply.send(uuid).unwrap(); @@ -396,11 +396,12 @@ impl Daemon { .await } - async fn enqueue_outgoing_message(&mut self, text: String, conversation_id: String) -> Uuid { + async fn enqueue_outgoing_message(&mut self, text: String, conversation_id: String, attachment_guids: Vec) -> Uuid { let conversation_id = conversation_id.clone(); let outgoing_message = OutgoingMessage::builder() .text(text) .conversation_id(conversation_id.clone()) + .file_transfer_guids(attachment_guids) .build(); // Keep a record of this so we can provide a consistent model to the client. diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 7926f99..8d9fea7 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -226,8 +226,9 @@ impl DbusRepository for ServerImpl { &mut self, conversation_id: String, text: String, + attachment_guids: Vec, ) -> Result { - self.send_event_sync(|r| Event::SendMessage(conversation_id, text, r)) + self.send_event_sync(|r| Event::SendMessage(conversation_id, text, attachment_guids, r)) .map(|uuid| uuid.to_string()) } diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index cb4d5ff..974a7df 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -177,8 +177,9 @@ impl DaemonCli { conversation_id: String, text: String, ) -> Result<()> { + let attachment_guids: Vec<&str> = vec![]; let outgoing_message_id = - KordophoneRepository::send_message(&self.proxy(), &conversation_id, &text)?; + KordophoneRepository::send_message(&self.proxy(), &conversation_id, &text, attachment_guids)?; println!("Outgoing message ID: {}", outgoing_message_id); Ok(()) } From 4f40be205d05fedd18b4511a920771e1ba49c634 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 12 Jun 2025 21:19:47 -0700 Subject: [PATCH 081/138] Adds CONTENT_LENGTH workaround for CocoaHTTPServer bug --- kordophone/src/api/http_client.rs | 28 +++++++++++++++------- kordophone/src/api/mod.rs | 1 + kordophone/src/tests/test_client.rs | 1 + kordophoned/src/daemon/attachment_store.rs | 5 +++- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index ddd6d65..c604c6e 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -13,7 +13,6 @@ use async_trait::async_trait; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use tokio::net::TcpStream; -use tokio_util::io::ReaderStream; use futures_util::stream::{BoxStream, Stream}; use futures_util::task::Context; @@ -292,32 +291,45 @@ impl APIInterface for HTTPAPIClient { async fn upload_attachment( &mut self, - data: tokio::io::BufReader, + mut data: tokio::io::BufReader, filename: &str, + size: u64, ) -> Result where R: tokio::io::AsyncRead + Unpin + Send + Sync + 'static, { + use tokio::io::AsyncReadExt; + #[derive(Deserialize, Debug)] struct UploadAttachmentResponse { #[serde(rename = "fileTransferGUID")] guid: String, } + // TODO: We can still use Body::wrap_stream here, but we need to make sure to plumb the CONTENT_LENGTH header, + // otherwise CocoaHTTPServer will crash because of a bug. + // + // See ff03e73758f30c081a9319a8c04025cba69b8393 for what this was like before. + let mut bytes = Vec::new(); + data.read_to_end(&mut bytes) + .await + .map_err(|e| Error::ClientError(e.to_string()))?; + let endpoint = format!("uploadAttachment?filename={}", filename); - let mut data_opt = Some(data); + let mut bytes_opt = Some(bytes); let response: UploadAttachmentResponse = self .deserialized_response_with_body_retry( &endpoint, Method::POST, move || { - let stream = ReaderStream::new( - data_opt.take().expect("Stream already consumed during retry"), - ); - Body::wrap_stream(stream) + Body::from( + bytes_opt + .take() + .expect("Body already consumed during retry"), + ) }, - false, // don't retry auth for streaming body + false, ) .await?; diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index c0e67c0..7220511 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -56,6 +56,7 @@ pub trait APIInterface { &mut self, data: tokio::io::BufReader, filename: &str, + size: u64, ) -> Result where R: tokio::io::AsyncRead + Unpin + Send + Sync + 'static; diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index ffb9874..122e74c 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -132,6 +132,7 @@ impl APIInterface for TestClient { &mut self, data: tokio::io::BufReader, filename: &str, + size: u64, ) -> Result where R: tokio::io::AsyncRead + Unpin + Send + Sync + 'static, diff --git a/kordophoned/src/daemon/attachment_store.rs b/kordophoned/src/daemon/attachment_store.rs index 6b1c00b..b8c1718 100644 --- a/kordophoned/src/daemon/attachment_store.rs +++ b/kordophoned/src/daemon/attachment_store.rs @@ -195,7 +195,10 @@ impl AttachmentStore { let filename = incoming_path.file_name().unwrap().to_str().unwrap(); log::trace!(target: target::ATTACHMENTS, "Uploading attachment to server: {}", &filename); let mut client = Daemon::get_client_impl(database).await?; - let guid = client.upload_attachment(reader, filename).await?; + + let metadata = std::fs::metadata(&temporary_path)?; + let size = metadata.len(); + let guid = client.upload_attachment(reader, filename, size).await?; // Delete the temporary file. log::debug!(target: target::ATTACHMENTS, "Upload completed with guid {}, deleting temporary file: {}", guid, temporary_path.display()); From dece6f1abc0c1a45dc4bf3f2c593f0080936f4f3 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 13 Jun 2025 16:45:28 -0700 Subject: [PATCH 082/138] daemon: update monitor: implements ping/pong (required server changes) --- kordophone/src/api/event_socket.rs | 22 +++++- kordophone/src/api/http_client.rs | 71 +++++++++++++------ kordophone/src/model/update.rs | 4 ++ kordophone/src/tests/test_client.rs | 6 +- .../net.buzzert.kordophonecd.Server.xml | 5 ++ kordophoned/src/daemon/events.rs | 3 + kordophoned/src/daemon/mod.rs | 33 ++++++--- kordophoned/src/daemon/signals.rs | 3 + kordophoned/src/daemon/update_monitor.rs | 57 ++++++++++++--- kordophoned/src/dbus/mod.rs | 1 + kordophoned/src/main.rs | 10 +++ kpcli/src/client/mod.rs | 42 +++++++---- 12 files changed, 202 insertions(+), 55 deletions(-) diff --git a/kordophone/src/api/event_socket.rs b/kordophone/src/api/event_socket.rs index 636677d..31ae740 100644 --- a/kordophone/src/api/event_socket.rs +++ b/kordophone/src/api/event_socket.rs @@ -2,15 +2,31 @@ use crate::model::event::Event; use crate::model::update::UpdateItem; use async_trait::async_trait; use futures_util::stream::Stream; +use futures_util::Sink; + +#[derive(Debug, Eq, PartialEq, Clone)] +pub enum SinkMessage { + Ping, +} + +pub enum SocketUpdate { + Update(Vec), + Pong, +} + +pub enum SocketEvent { + Update(Event), + Pong, +} #[async_trait] pub trait EventSocket { type Error; - type EventStream: Stream>; - type UpdateStream: Stream, Self::Error>>; + type EventStream: Stream>; + type UpdateStream: Stream>; /// Modern event pipeline - async fn events(self) -> Self::EventStream; + async fn events(self) -> (Self::EventStream, impl Sink); /// Raw update items from the v1 API. async fn raw_updates(self) -> Self::UpdateStream; diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index c604c6e..0f379f2 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -3,10 +3,9 @@ extern crate serde; use std::{path::PathBuf, pin::Pin, str, task::Poll}; -use crate::api::event_socket::EventSocket; +use crate::api::event_socket::{EventSocket, SinkMessage, SocketEvent, SocketUpdate}; use crate::api::AuthenticationStore; use bytes::Bytes; -use hyper::body::HttpBody; use hyper::{Body, Client, Method, Request, Uri}; use async_trait::async_trait; @@ -16,7 +15,7 @@ use tokio::net::TcpStream; use futures_util::stream::{BoxStream, Stream}; use futures_util::task::Context; -use futures_util::{StreamExt, TryStreamExt}; +use futures_util::{Sink, SinkExt, StreamExt, TryStreamExt}; use tokio_tungstenite::connect_async; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; @@ -49,6 +48,7 @@ pub enum Error { HTTPError(hyper::Error), SerdeError(serde_json::Error), DecodeError(String), + PongError(tungstenite::Error), Unauthorized, } @@ -124,34 +124,44 @@ impl AuthSetting for hyper::http::Request { } } +type WebsocketSink = futures_util::stream::SplitSink>, tungstenite::Message>; +type WebsocketStream = futures_util::stream::SplitStream>>; + pub struct WebsocketEventSocket { - socket: WebSocketStream>, + sink: Option, + stream: WebsocketStream } impl WebsocketEventSocket { pub fn new(socket: WebSocketStream>) -> Self { - Self { socket } + let (sink, stream) = socket.split(); + + Self { sink: Some(sink), stream } } } impl WebsocketEventSocket { - fn raw_update_stream(self) -> impl Stream, Error>> { - let (_, stream) = self.socket.split(); - - stream + fn raw_update_stream(self) -> impl Stream> { + self.stream .map_err(Error::from) .try_filter_map(|msg| async move { match msg { tungstenite::Message::Text(text) => { - serde_json::from_str::>(&text) - .map(Some) - .map_err(Error::from) + match serde_json::from_str::>(&text) { + Ok(updates) => Ok(Some(SocketUpdate::Update(updates))), + Err(e) => { + log::error!("Error parsing update: {:?}", e); + Err(Error::from(e)) + } + } } tungstenite::Message::Ping(_) => { - // Borrowing issue here with the sink, need to handle pings at the client level (whomever - // is consuming these updateitems, should be a union type of updateitem | ping). + // We don't expect the server to send us pings. Ok(None) } + tungstenite::Message::Pong(_) => { + Ok(Some(SocketUpdate::Pong)) + } tungstenite::Message::Close(_) => { // Connection was closed cleanly Err(Error::ClientError("WebSocket connection closed".into())) @@ -165,16 +175,37 @@ impl WebsocketEventSocket { #[async_trait] impl EventSocket for WebsocketEventSocket { type Error = Error; - type EventStream = BoxStream<'static, Result>; - type UpdateStream = BoxStream<'static, Result, Error>>; + type EventStream = BoxStream<'static, Result>; + type UpdateStream = BoxStream<'static, Result>; - async fn events(self) -> Self::EventStream { + async fn events(mut self) -> (Self::EventStream, impl Sink) { use futures_util::stream::iter; - self.raw_update_stream() - .map_ok(|updates| iter(updates.into_iter().map(|update| Ok(Event::from(update))))) + let sink = self.sink.take().unwrap().with(|f| { + match f { + SinkMessage::Ping => futures_util::future::ready(Ok::(tungstenite::Message::Ping(Bytes::new()))) + } + }); + + let stream = self.raw_update_stream() + .map_ok(|updates| -> BoxStream<'static, Result> { + match updates { + SocketUpdate::Update(updates) => { + let iter_stream = iter( + updates.into_iter().map(|u| Ok(SocketEvent::Update(Event::from(u)))) + ); + iter_stream.boxed() + } + SocketUpdate::Pong => { + iter(std::iter::once(Ok(SocketEvent::Pong))).boxed() + } + } + }) .try_flatten() - .boxed() + .boxed(); + + + (stream, sink) } async fn raw_updates(self) -> Self::UpdateStream { diff --git a/kordophone/src/model/update.rs b/kordophone/src/model/update.rs index 69889fa..e92f857 100644 --- a/kordophone/src/model/update.rs +++ b/kordophone/src/model/update.rs @@ -12,6 +12,9 @@ pub struct UpdateItem { #[serde(rename = "message")] pub message: Option, + + #[serde(default)] + pub pong: bool, } impl Default for UpdateItem { @@ -20,6 +23,7 @@ impl Default for UpdateItem { seq: 0, conversation: None, message: None, + pong: false, } } } diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index 122e74c..c514bbd 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -6,7 +6,7 @@ use uuid::Uuid; pub use crate::APIInterface; use crate::{ - api::event_socket::EventSocket, + api::event_socket::{EventSocket, SinkMessage}, api::http_client::Credentials, model::{ Conversation, ConversationID, Event, JwtToken, Message, MessageID, OutgoingMessage, @@ -63,6 +63,10 @@ impl EventSocket for TestEventSocket { let results: Vec, TestError>> = vec![]; futures_util::stream::iter(results.into_iter()).boxed() } + + fn get_sink(&mut self) -> impl futures_util::Sink { + todo!("") + } } #[async_trait] diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index 8e93ca5..a0898e3 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -103,6 +103,11 @@ value="Emitted when the list of messages is updated."/> + + + + diff --git a/kordophoned/src/daemon/events.rs b/kordophoned/src/daemon/events.rs index d14f371..56c082a 100644 --- a/kordophoned/src/daemon/events.rs +++ b/kordophoned/src/daemon/events.rs @@ -26,6 +26,9 @@ pub enum Event { /// Asynchronous event for syncing a single conversation with the server. SyncConversation(String, Reply<()>), + /// Sent when the update stream is reconnected after a timeout or configuration change. + UpdateStreamReconnected, + /// Returns all known conversations from the database. /// Parameters: /// - limit: The maximum number of conversations to return. (-1 for no limit) diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 7606b90..b673f9b 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -163,6 +163,17 @@ impl Daemon { } } + fn spawn_conversation_list_sync(&mut self) { + let mut db_clone = self.database.clone(); + let signal_sender = self.signal_sender.clone(); + self.runtime.spawn(async move { + let result = Self::sync_conversation_list(&mut db_clone, &signal_sender).await; + if let Err(e) = result { + log::error!(target: target::SYNC, "Error handling sync event: {}", e); + } + }); + } + async fn handle_event(&mut self, event: Event) { match event { Event::GetVersion(reply) => { @@ -170,14 +181,7 @@ impl Daemon { } Event::SyncConversationList(reply) => { - let mut db_clone = self.database.clone(); - let signal_sender = self.signal_sender.clone(); - self.runtime.spawn(async move { - let result = Self::sync_conversation_list(&mut db_clone, &signal_sender).await; - if let Err(e) = result { - log::error!(target: target::SYNC, "Error handling sync event: {}", e); - } - }); + self.spawn_conversation_list_sync(); // This is a background operation, so return right away. reply.send(()).unwrap(); @@ -216,6 +220,19 @@ impl Daemon { reply.send(()).unwrap(); } + Event::UpdateStreamReconnected => { + log::info!(target: target::UPDATES, "Update stream reconnected"); + + // The ui client will respond differently, but we'll almost certainly want to do a sync-list in response to this. + self.spawn_conversation_list_sync(); + + // Send signal to the client that the update stream has been reconnected. + self.signal_sender + .send(Signal::UpdateStreamReconnected) + .await + .unwrap(); + } + Event::GetAllConversations(limit, offset, reply) => { let conversations = self.get_conversations_limit_offset(limit, offset).await; reply.send(conversations).unwrap(); diff --git a/kordophoned/src/daemon/signals.rs b/kordophoned/src/daemon/signals.rs index 6fc5cd2..d2a4cfa 100644 --- a/kordophoned/src/daemon/signals.rs +++ b/kordophoned/src/daemon/signals.rs @@ -18,4 +18,7 @@ pub enum Signal { /// - upload_guid: The GUID of the upload. /// - attachment_guid: The GUID of the attachment on the server. AttachmentUploaded(String, String), + + /// Emitted when the update stream is reconnected after a timeout or configuration change. + UpdateStreamReconnected, } diff --git a/kordophoned/src/daemon/update_monitor.rs b/kordophoned/src/daemon/update_monitor.rs index 184832a..39ee875 100644 --- a/kordophoned/src/daemon/update_monitor.rs +++ b/kordophoned/src/daemon/update_monitor.rs @@ -3,7 +3,8 @@ use crate::daemon::{ target, Daemon, DaemonResult, }; -use kordophone::api::event_socket::EventSocket; +use futures_util::SinkExt; +use kordophone::api::event_socket::{EventSocket, SinkMessage}; use kordophone::model::event::Event as UpdateEvent; use kordophone::model::event::EventData as UpdateEventData; use kordophone::APIInterface; @@ -22,6 +23,7 @@ pub struct UpdateMonitor { event_sender: Sender, last_sync_times: HashMap, update_seq: Option, + first_connection: bool, } impl UpdateMonitor { @@ -31,6 +33,7 @@ impl UpdateMonitor { event_sender, last_sync_times: HashMap::new(), update_seq: None, + first_connection: false, // optimistic assumption that we're not reconnecting the first time. } } @@ -48,8 +51,6 @@ impl UpdateMonitor { } async fn handle_update(&mut self, update: UpdateEvent) { - self.update_seq = Some(update.update_seq); - match update.data { UpdateEventData::ConversationChanged(conversation) => { log::info!(target: target::UPDATES, "Conversation changed: {:?}", conversation); @@ -134,24 +135,42 @@ impl UpdateMonitor { }; log::debug!(target: target::UPDATES, "Starting event stream"); - let mut event_stream = socket.events().await; + let (mut event_stream, mut sink) = socket.events().await; // We won't know if the websocket is dead until we try to send a message, so time out waiting for // a message every 30 seconds. - let mut timeout = tokio::time::interval(Duration::from_secs(30)); + let mut timeout = tokio::time::interval(Duration::from_secs(10)); timeout.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); // First tick will happen immediately timeout.tick().await; + // Track when the last ping was sent so we know when to give up + // waiting for the corresponding pong. + let mut ping_sent_at: Option = None; + loop { tokio::select! { Some(result) = event_stream.next() => { match result { - Ok(event) => { - self.handle_update(event).await; + Ok(socket_event) => { + match socket_event { + kordophone::api::event_socket::SocketEvent::Update(event) => { + self.handle_update(event).await; + } - // Reset the timeout since we got a message + kordophone::api::event_socket::SocketEvent::Pong => { + log::debug!(target: target::UPDATES, "Received websocket pong"); + } + } + + if self.first_connection { + self.event_sender.send(Event::UpdateStreamReconnected).await.unwrap(); + self.first_connection = false; + } + + // Any successfully handled message (update or pong) keeps the connection alive. + ping_sent_at = None; timeout.reset(); } Err(e) => { @@ -160,9 +179,27 @@ impl UpdateMonitor { } } } + _ = timeout.tick() => { - log::warn!("No messages received for 30 seconds, reconnecting..."); - break; // Break inner loop to reconnect + // If we previously sent a ping and haven't heard back since the timeout, we'll assume the connection is dead. + if let Some(_) = ping_sent_at { + log::error!(target: target::UPDATES, "Ping timed out. Restarting stream."); + self.first_connection = true; + break; + } + + log::debug!("Sending websocket ping on timer"); + match sink.send(SinkMessage::Ping).await { + Ok(_) => { + ping_sent_at = Some(Instant::now()); + } + + Err(e) => { + log::error!(target: target::UPDATES, "Error writing ping to event socket: {}, restarting stream.", e); + self.first_connection = true; + break; + } + } } } } diff --git a/kordophoned/src/dbus/mod.rs b/kordophoned/src/dbus/mod.rs index 2cf6189..4edc275 100644 --- a/kordophoned/src/dbus/mod.rs +++ b/kordophoned/src/dbus/mod.rs @@ -14,5 +14,6 @@ pub mod interface { pub use crate::interface::NetBuzzertKordophoneRepositoryMessagesUpdated as MessagesUpdated; pub use crate::interface::NetBuzzertKordophoneRepositoryAttachmentDownloadCompleted as AttachmentDownloadCompleted; pub use crate::interface::NetBuzzertKordophoneRepositoryAttachmentUploadCompleted as AttachmentUploadCompleted; + pub use crate::interface::NetBuzzertKordophoneRepositoryUpdateStreamReconnected as UpdateStreamReconnected; } } diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index 18228d0..59db0fb 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -118,6 +118,16 @@ async fn main() { 0 }); } + + Signal::UpdateStreamReconnected => { + log::debug!("Sending signal: UpdateStreamReconnected"); + dbus_registry + .send_signal(interface::OBJECT_PATH, DbusSignals::UpdateStreamReconnected {}) + .unwrap_or_else(|_| { + log::error!("Failed to send signal"); + 0 + }); + } } } }); diff --git a/kpcli/src/client/mod.rs b/kpcli/src/client/mod.rs index edfece7..8709d89 100644 --- a/kpcli/src/client/mod.rs +++ b/kpcli/src/client/mod.rs @@ -1,4 +1,4 @@ -use kordophone::api::event_socket::EventSocket; +use kordophone::api::event_socket::{EventSocket, SocketEvent, SocketUpdate}; use kordophone::api::http_client::Credentials; use kordophone::api::http_client::HTTPAPIClient; use kordophone::api::InMemoryAuthenticationStore; @@ -110,17 +110,24 @@ impl ClientCli { pub async fn print_events(&mut self) -> Result<()> { let socket = self.api.open_event_socket(None).await?; - let mut stream = socket.events().await; - while let Some(Ok(event)) = stream.next().await { - match event.data { - EventData::ConversationChanged(conversation) => { - println!("Conversation changed: {}", conversation.guid); + let (mut stream, _) = socket.events().await; + while let Some(Ok(socket_event)) = stream.next().await { + match socket_event { + SocketEvent::Update(event) => { + match event.data { + EventData::ConversationChanged(conversation) => { + println!("Conversation changed: {}", conversation.guid); + } + EventData::MessageReceived(conversation, message) => { + println!( + "Message received: msg: {} conversation: {}", + message.guid, conversation.guid + ); + } + } } - EventData::MessageReceived(conversation, message) => { - println!( - "Message received: msg: {} conversation: {}", - message.guid, conversation.guid - ); + SocketEvent::Pong => { + println!("Pong"); } } } @@ -132,8 +139,17 @@ impl ClientCli { println!("Listening for raw updates..."); let mut stream = socket.raw_updates().await; - while let Some(update) = stream.next().await { - println!("Got update: {:?}", update); + while let Some(Ok(update)) = stream.next().await { + match update { + SocketUpdate::Update(updates) => { + for update in updates { + println!("Got update: {:?}", update); + } + } + SocketUpdate::Pong => { + println!("Pong"); + } + } } Ok(()) From 45d873907f2004ed2da03ea61c5905e30e739bd8 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 13 Jun 2025 17:11:29 -0700 Subject: [PATCH 083/138] bugfixes, better handling of server url changes --- Cargo.lock | 7 +++++++ kordophone/Cargo.toml | 1 + kordophone/src/api/http_client.rs | 3 ++- kordophoned/src/daemon/mod.rs | 25 ++++++++++++++++++++++- kordophoned/src/daemon/update_monitor.rs | 26 ++++++++++++++++++++++-- 5 files changed, 58 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 12d628e..ddbe939 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1021,6 +1021,7 @@ dependencies = [ "tokio-tungstenite", "tokio-util", "tungstenite", + "urlencoding", "uuid", ] @@ -2079,6 +2080,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" diff --git a/kordophone/Cargo.toml b/kordophone/Cargo.toml index 24e074c..81d060a 100644 --- a/kordophone/Cargo.toml +++ b/kordophone/Cargo.toml @@ -24,4 +24,5 @@ tokio = { version = "1.37.0", features = ["full"] } tokio-tungstenite = "0.26.2" tokio-util = { version = "0.7.15", features = ["futures-util"] } tungstenite = "0.26.2" +urlencoding = "2.1.3" uuid = { version = "1.6.1", features = ["v4", "fast-rng", "macro-diagnostics"] } diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index 0f379f2..a7be3f7 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -346,7 +346,8 @@ impl APIInterface for HTTPAPIClient { .await .map_err(|e| Error::ClientError(e.to_string()))?; - let endpoint = format!("uploadAttachment?filename={}", filename); + let encoded_filename = urlencoding::encode(filename); + let endpoint = format!("uploadAttachment?filename={}", encoded_filename); let mut bytes_opt = Some(bytes); let response: UploadAttachmentResponse = self diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index b673f9b..0a78113 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -32,7 +32,7 @@ use kordophone::model::outgoing_message::OutgoingMessage; use kordophone::model::ConversationID; mod update_monitor; -use update_monitor::UpdateMonitor; +use update_monitor::{UpdateMonitor, UpdateMonitorCommand}; mod auth_store; use auth_store::DatabaseAuthenticationStore; @@ -80,6 +80,7 @@ pub struct Daemon { outgoing_messages: HashMap>, attachment_store_sink: Option>, + update_monitor_command_tx: Option>, version: String, database: Arc>, @@ -120,6 +121,7 @@ impl Daemon { post_office_source: Some(post_office_source), outgoing_messages: HashMap::new(), attachment_store_sink: None, + update_monitor_command_tx: None, runtime, }) } @@ -131,6 +133,7 @@ impl Daemon { // Update monitor let mut update_monitor = UpdateMonitor::new(self.database.clone(), self.event_sender.clone()); + self.update_monitor_command_tx = Some(update_monitor.take_command_channel()); tokio::spawn(async move { update_monitor.run().await; // should run indefinitely }); @@ -248,10 +251,30 @@ impl Daemon { } Event::UpdateSettings(settings, reply) => { + let previous_server_url = self.get_settings().await.unwrap_or_default().server_url; + self.update_settings(&settings).await.unwrap_or_else(|e| { log::error!(target: target::SETTINGS, "Failed to update settings: {}", e); }); + if previous_server_url != settings.server_url { + // If the server url has changed, we'll need to do a full re-sync. + self.delete_all_conversations().await.unwrap_or_else(|e| { + log::error!(target: target::SYNC, "Failed to delete all conversations: {}", e); + }); + + // Do a sync-list to get the new conversations. + self.spawn_conversation_list_sync(); + + // Also restart the update monitor. + self.update_monitor_command_tx + .as_ref() + .unwrap() + .send(UpdateMonitorCommand::Restart) + .await + .unwrap(); + } + reply.send(()).unwrap(); } diff --git a/kordophoned/src/daemon/update_monitor.rs b/kordophoned/src/daemon/update_monitor.rs index 39ee875..7085dcc 100644 --- a/kordophoned/src/daemon/update_monitor.rs +++ b/kordophoned/src/daemon/update_monitor.rs @@ -15,10 +15,16 @@ use kordophone_db::database::DatabaseAccess; use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; -use tokio::sync::mpsc::Sender; +use tokio::sync::mpsc::{Receiver, Sender}; use tokio::sync::Mutex; +pub enum UpdateMonitorCommand { + Restart, +} + pub struct UpdateMonitor { + command_tx: Option>, + command_rx: Receiver, database: Arc>, event_sender: Sender, last_sync_times: HashMap, @@ -28,16 +34,23 @@ pub struct UpdateMonitor { impl UpdateMonitor { pub fn new(database: Arc>, event_sender: Sender) -> Self { + let (command_tx, command_rx) = tokio::sync::mpsc::channel(1); Self { database, event_sender, last_sync_times: HashMap::new(), update_seq: None, first_connection: false, // optimistic assumption that we're not reconnecting the first time. + command_tx: Some(command_tx), + command_rx, } } - pub async fn send_event( + pub fn take_command_channel(&mut self) -> Sender { + self.command_tx.take().unwrap() + } + + async fn send_event( &self, make_event: impl FnOnce(Reply) -> Event, ) -> DaemonResult { @@ -201,6 +214,15 @@ impl UpdateMonitor { } } } + + Some(command) = self.command_rx.recv() => { + match command { + UpdateMonitorCommand::Restart => { + log::info!(target: target::UPDATES, "Restarting update monitor"); + break; + } + } + } } } From b2049fb4327bb8c027b16842f037d9482cddbb53 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 13 Jun 2025 17:47:29 -0700 Subject: [PATCH 084/138] Workaround for empty server messages (typing indicator) --- kordophone-db/src/repository.rs | 15 +++++++++++++-- kordophoned/src/daemon/mod.rs | 10 +++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/kordophone-db/src/repository.rs b/kordophone-db/src/repository.rs index 9fd70b1..8c4102f 100644 --- a/kordophone-db/src/repository.rs +++ b/kordophone-db/src/repository.rs @@ -250,6 +250,10 @@ impl<'a> Repository<'a> { Ok(()) })?; + // TODO: May need to update conversation metadata here, but this has a perf impact. + // Ideally we would consolidate this in the code above, assuming we're only inserting *new* messages, but + // this may not necessarily be the case. + Ok(()) } @@ -325,8 +329,15 @@ impl<'a> Repository<'a> { conversation_guid, last_message ); - conversation.date = last_message.date; - conversation.last_message_preview = Some(last_message.text.clone()); + + if last_message.date > conversation.date { + conversation.date = last_message.date; + } + + if !last_message.text.is_empty() && !last_message.text.trim().is_empty() { + conversation.last_message_preview = Some(last_message.text.clone()); + } + self.insert_conversation(conversation)?; } } diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 0a78113..9876c0b 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -559,7 +559,15 @@ impl Daemon { let messages = client .get_messages(&conversation_id, None, None, last_message_id) .await?; - let db_messages: Vec = messages + + // Filter messages that have an empty body, or a body that is just whitespace. + // This is a workaround for a bug in the server where it returns messages with an empty body, which is usually + // 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()).collect(); + + let db_messages: Vec = insertable_messages .into_iter() .map(kordophone_db::models::Message::from) .collect(); From 31eeb8659acab9865a3f623601ab7a7d7c34f064 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 13 Jun 2025 19:01:00 -0700 Subject: [PATCH 085/138] fix update reconnect notification when waking from sleep --- kordophoned/src/daemon/update_monitor.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/kordophoned/src/daemon/update_monitor.rs b/kordophoned/src/daemon/update_monitor.rs index 7085dcc..d5bdf71 100644 --- a/kordophoned/src/daemon/update_monitor.rs +++ b/kordophoned/src/daemon/update_monitor.rs @@ -188,6 +188,7 @@ impl UpdateMonitor { } Err(e) => { log::error!("Error in event stream: {}", e); + self.first_connection = true; break; // Break inner loop to reconnect } } @@ -219,6 +220,7 @@ impl UpdateMonitor { match command { UpdateMonitorCommand::Restart => { log::info!(target: target::UPDATES, "Restarting update monitor"); + self.first_connection = true; break; } } From 45aaf558045404e28f68fbf6a64afeab480c15b4 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 16 Jun 2025 18:52:58 -0700 Subject: [PATCH 086/138] dbus: filter attachment characters here. not ideal... --- kordophoned/src/dbus/server_impl.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 8d9fea7..98ff227 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -127,7 +127,13 @@ impl DbusRepository for ServerImpl { .map(|msg| { let mut map = arg::PropMap::new(); map.insert("id".into(), arg::Variant(Box::new(msg.id))); - map.insert("text".into(), arg::Variant(Box::new(msg.text))); + + // xxx: Remove the attachment placeholder here. + // This is not the ideal place to do this, but once we start using ChatItems instead of IMMessages + // from the server, we shouldn't be seeing these placeholders. + let text = msg.text.replace("\u{FFFC}", ""); + + map.insert("text".into(), arg::Variant(Box::new(text))); map.insert( "date".into(), arg::Variant(Box::new(msg.date.and_utc().timestamp())), From 9d591dffc5e71f9d988a5d3642d9ef544a96644c Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 16 Jun 2025 19:06:35 -0700 Subject: [PATCH 087/138] Try to resolve daemon hang when changing settings --- kordophoned/src/daemon/mod.rs | 14 ++++++++------ kordophoned/src/daemon/update_monitor.rs | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 9876c0b..b79fbbb 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -251,13 +251,15 @@ impl Daemon { } Event::UpdateSettings(settings, reply) => { - let previous_server_url = self.get_settings().await.unwrap_or_default().server_url; + let previous_settings = self.get_settings().await.unwrap_or_default(); + let previous_server_url = previous_settings.server_url; self.update_settings(&settings).await.unwrap_or_else(|e| { log::error!(target: target::SETTINGS, "Failed to update settings: {}", e); }); - if previous_server_url != settings.server_url { + // Only trigger re-sync if both URLs are Some and different, or if one is Some and other is None + if previous_server_url.as_deref() != settings.server_url.as_deref() { // If the server url has changed, we'll need to do a full re-sync. self.delete_all_conversations().await.unwrap_or_else(|e| { log::error!(target: target::SYNC, "Failed to delete all conversations: {}", e); @@ -267,12 +269,12 @@ impl Daemon { self.spawn_conversation_list_sync(); // Also restart the update monitor. - self.update_monitor_command_tx + if let Err(e) = self.update_monitor_command_tx .as_ref() .unwrap() - .send(UpdateMonitorCommand::Restart) - .await - .unwrap(); + .try_send(UpdateMonitorCommand::Restart) { + log::warn!(target: target::UPDATES, "Failed to send restart command to update monitor: {}", e); + } } reply.send(()).unwrap(); diff --git a/kordophoned/src/daemon/update_monitor.rs b/kordophoned/src/daemon/update_monitor.rs index d5bdf71..12ae643 100644 --- a/kordophoned/src/daemon/update_monitor.rs +++ b/kordophoned/src/daemon/update_monitor.rs @@ -34,7 +34,7 @@ pub struct UpdateMonitor { impl UpdateMonitor { pub fn new(database: Arc>, event_sender: Sender) -> Self { - let (command_tx, command_rx) = tokio::sync::mpsc::channel(1); + let (command_tx, command_rx) = tokio::sync::mpsc::channel(100); Self { database, event_sender, From 75fe4d46086692734c08e89dc9d309715f1fc73d Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 16 Jun 2025 19:25:24 -0700 Subject: [PATCH 088/138] fix all warnings --- kordophone/src/api/http_client.rs | 15 ++---- kordophone/src/api/mod.rs | 2 +- kordophone/src/model/outgoing_message.rs | 2 +- kordophone/src/model/update.rs | 11 +---- kordophone/src/tests/test_client.rs | 20 ++++---- kordophoned/src/daemon/attachment_store.rs | 6 --- kordophoned/src/daemon/mod.rs | 18 +------- kordophoned/src/dbus/endpoint.rs | 54 ---------------------- 8 files changed, 18 insertions(+), 110 deletions(-) diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index a7be3f7..6bd29f8 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -86,19 +86,10 @@ impl From for Error { } trait AuthBuilder { - fn with_auth(self, token: &Option) -> Self; fn with_auth_string(self, token: &Option) -> Self; } impl AuthBuilder for hyper::http::request::Builder { - fn with_auth(self, token: &Option) -> Self { - if let Some(token) = &token { - self.header("Authorization", token.to_header_value()) - } else { - self - } - } - fn with_auth_string(self, token: &Option) -> Self { if let Some(token) = &token { self.header("Authorization", format!("Bearer: {}", token)) @@ -223,7 +214,7 @@ impl Stream for ResponseStream { fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { self.body .poll_next_unpin(cx) - .map_err(|e| Error::HTTPError(e)) + .map_err(Error::HTTPError) } } @@ -310,7 +301,7 @@ impl APIInterface for HTTPAPIClient { async fn fetch_attachment_data( &mut self, - guid: &String, + guid: &str, preview: bool, ) -> Result { let endpoint = format!("attachment?guid={}&preview={}", guid, preview); @@ -324,7 +315,7 @@ impl APIInterface for HTTPAPIClient { &mut self, mut data: tokio::io::BufReader, filename: &str, - size: u64, + _size: u64, ) -> Result where R: tokio::io::AsyncRead + Unpin + Send + Sync + 'static, diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index 7220511..f092307 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -47,7 +47,7 @@ pub trait APIInterface { // (GET) /attachment async fn fetch_attachment_data( &mut self, - guid: &String, + guid: &str, preview: bool, ) -> Result; diff --git a/kordophone/src/model/outgoing_message.rs b/kordophone/src/model/outgoing_message.rs index 15e03a7..93da21f 100644 --- a/kordophone/src/model/outgoing_message.rs +++ b/kordophone/src/model/outgoing_message.rs @@ -62,7 +62,7 @@ impl OutgoingMessageBuilder { pub fn build(self) -> OutgoingMessage { OutgoingMessage { - guid: self.guid.unwrap_or_else(|| Uuid::new_v4()), + guid: self.guid.unwrap_or_else(Uuid::new_v4), text: self.text.unwrap(), conversation_id: self.conversation_id.unwrap(), file_transfer_guids: self.file_transfer_guids.unwrap_or_default(), diff --git a/kordophone/src/model/update.rs b/kordophone/src/model/update.rs index e92f857..b61ef5d 100644 --- a/kordophone/src/model/update.rs +++ b/kordophone/src/model/update.rs @@ -3,6 +3,7 @@ use super::message::Message; use serde::Deserialize; #[derive(Debug, Clone, Deserialize)] +#[derive(Default)] pub struct UpdateItem { #[serde(rename = "messageSequenceNumber")] pub seq: u64, @@ -17,13 +18,3 @@ pub struct UpdateItem { pub pong: bool, } -impl Default for UpdateItem { - fn default() -> Self { - Self { - seq: 0, - conversation: None, - message: None, - pong: false, - } - } -} diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index c514bbd..0ab53d3 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -6,7 +6,7 @@ use uuid::Uuid; pub use crate::APIInterface; use crate::{ - api::event_socket::{EventSocket, SinkMessage}, + api::event_socket::{EventSocket, SinkMessage, SocketEvent, SocketUpdate}, api::http_client::Credentials, model::{ Conversation, ConversationID, Event, JwtToken, Message, MessageID, OutgoingMessage, @@ -16,6 +16,7 @@ use crate::{ use bytes::Bytes; use futures_util::stream::BoxStream; +use futures_util::Sink; use futures_util::StreamExt; pub struct TestClient { @@ -52,21 +53,20 @@ impl TestEventSocket { #[async_trait] impl EventSocket for TestEventSocket { type Error = TestError; - type EventStream = BoxStream<'static, Result>; - type UpdateStream = BoxStream<'static, Result, TestError>>; + type EventStream = BoxStream<'static, Result>; + type UpdateStream = BoxStream<'static, Result>; - async fn events(self) -> Self::EventStream { - futures_util::stream::iter(self.events.into_iter().map(Ok)).boxed() + async fn events(self) -> (Self::EventStream, impl Sink) { + ( + futures_util::stream::iter(self.events.into_iter().map(Ok)).boxed(), + futures_util::sink::sink(), + ) } async fn raw_updates(self) -> Self::UpdateStream { let results: Vec, TestError>> = vec![]; futures_util::stream::iter(results.into_iter()).boxed() } - - fn get_sink(&mut self) -> impl futures_util::Sink { - todo!("") - } } #[async_trait] @@ -126,7 +126,7 @@ impl APIInterface for TestClient { async fn fetch_attachment_data( &mut self, - guid: &String, + guid: &str, preview: bool, ) -> Result { Ok(futures_util::stream::iter(vec![Ok(Bytes::from_static(b"test"))]).boxed()) diff --git a/kordophoned/src/daemon/attachment_store.rs b/kordophoned/src/daemon/attachment_store.rs index b8c1718..0ba3df3 100644 --- a/kordophoned/src/daemon/attachment_store.rs +++ b/kordophoned/src/daemon/attachment_store.rs @@ -56,12 +56,6 @@ enum AttachmentStoreError { APIClientError(String), } -#[derive(Debug, Clone)] -struct DownloadRequest { - guid: String, - preview: bool, -} - pub struct AttachmentStore { store_path: PathBuf, database: Arc>, diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index b79fbbb..4428f4a 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -29,7 +29,7 @@ use kordophone_db::{ use kordophone::api::http_client::HTTPAPIClient; use kordophone::api::APIInterface; use kordophone::model::outgoing_message::OutgoingMessage; -use kordophone::model::ConversationID; +use kordophone::model::{ConversationID, MessageID}; mod update_monitor; use update_monitor::{UpdateMonitor, UpdateMonitorCommand}; @@ -57,8 +57,6 @@ pub enum DaemonError { pub type DaemonResult = Result>; -type DaemonClient = HTTPAPIClient; - pub mod target { pub static SYNC: &str = "sync"; pub static EVENT: &str = "event"; @@ -392,14 +390,6 @@ impl Daemon { self.signal_receiver.take().unwrap() } - async fn get_conversations(&mut self) -> Vec { - self.database - .lock() - .await - .with_repository(|r| r.all_conversations(i32::MAX, 0).unwrap()) - .await - } - async fn get_conversations_limit_offset( &mut self, limit: i32, @@ -415,7 +405,7 @@ impl Daemon { async fn get_messages( &mut self, conversation_id: String, - last_message_id: Option, + _last_message_id: Option, ) -> Vec { // Get outgoing messages for this conversation. let empty_vec: Vec = vec![]; @@ -601,10 +591,6 @@ impl Daemon { self.database.with_settings(|s| settings.save(s)).await } - async fn get_client(&mut self) -> Result> { - Self::get_client_impl(&mut self.database).await - } - async fn get_client_impl( database: &mut Arc>, ) -> Result> { diff --git a/kordophoned/src/dbus/endpoint.rs b/kordophoned/src/dbus/endpoint.rs index aa327b5..f6b8ef3 100644 --- a/kordophoned/src/dbus/endpoint.rs +++ b/kordophoned/src/dbus/endpoint.rs @@ -73,57 +73,3 @@ impl DbusRegistry { self.connection.send(message) } } - -// Keep the old Endpoint struct for backward compatibility during transition -#[derive(Clone)] -pub struct Endpoint { - connection: Arc, - implementation: T, -} - -impl Endpoint { - pub fn new(connection: Arc, implementation: T) -> Self { - Self { - connection, - implementation, - } - } - - pub async fn register_object(&self, path: &str, register_fn: F) - where - F: Fn(&mut Crossroads) -> R, - R: IntoIterator>, - { - let dbus_path = String::from(path); - - // Enable async support for the crossroads instance. - // (Currently irrelevant since dbus generates sync code) - let mut cr = Crossroads::new(); - cr.set_async_support(Some(( - self.connection.clone(), - Box::new(|x| { - tokio::spawn(x); - }), - ))); - - // Register the daemon as a D-Bus object with multiple interfaces - let tokens: Vec<_> = register_fn(&mut cr).into_iter().collect(); - cr.insert(dbus_path, &tokens, self.implementation.clone()); - - // Start receiving messages. - self.connection.start_receive( - MatchRule::new_method_call(), - Box::new(move |msg, conn| cr.handle_message(msg, conn).is_ok()), - ); - - info!(target: "dbus", "Registered endpoint at {} with {} interfaces", path, tokens.len()); - } - - pub fn send_signal(&self, path: &str, signal: S) -> Result - where - S: dbus::message::SignalArgs + dbus::arg::AppendAll, - { - let message = signal.to_emit_message(&Path::new(path).unwrap()); - self.connection.send(message) - } -} From 032573d23bdbddd4f52e920c744ca4599fcec255 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 16 Jun 2025 19:26:13 -0700 Subject: [PATCH 089/138] cargo fmt --- kordophone-db/src/repository.rs | 23 +++--- kordophone/src/api/event_socket.rs | 7 +- kordophone/src/api/http_client.rs | 84 ++++++++++++--------- kordophone/src/model/update.rs | 4 +- kordophone/src/tests/test_client.rs | 7 +- kordophoned/src/daemon/attachment_store.rs | 21 +++--- kordophoned/src/daemon/mod.rs | 26 ++++--- kordophoned/src/daemon/models/attachment.rs | 3 +- kordophoned/src/daemon/update_monitor.rs | 7 +- kordophoned/src/dbus/mod.rs | 4 +- kordophoned/src/dbus/server_impl.rs | 13 ++-- kordophoned/src/main.rs | 29 +++++-- kpcli/src/client/mod.rs | 22 +++--- kpcli/src/daemon/mod.rs | 31 +++++--- 14 files changed, 168 insertions(+), 113 deletions(-) diff --git a/kordophone-db/src/repository.rs b/kordophone-db/src/repository.rs index 8c4102f..c37e956 100644 --- a/kordophone-db/src/repository.rs +++ b/kordophone-db/src/repository.rs @@ -168,8 +168,7 @@ impl<'a> Repository<'a> { let mut participant_cache: HashMap = HashMap::new(); // Prepare collections for the batch inserts. - let mut db_messages: Vec = - Vec::with_capacity(in_messages.len()); + let mut db_messages: Vec = Vec::with_capacity(in_messages.len()); let mut conv_msg_records: Vec = Vec::with_capacity(in_messages.len()); @@ -178,7 +177,8 @@ impl<'a> Repository<'a> { let sender_id = match &message.sender { Participant::Me => None, Participant::Remote { display_name, .. } => { - if let Some(cached_participant_id) = participant_cache.get(display_name) { + if let Some(cached_participant_id) = participant_cache.get(display_name) + { Some(*cached_participant_id) } else { // Try to load from DB first @@ -239,19 +239,22 @@ impl<'a> Repository<'a> { // processed instead of re-querying the DB. if let Some(last_msg) = db_messages.last() { use crate::schema::conversations::dsl as conv_dsl; - diesel::update(conv_dsl::conversations.filter(conv_dsl::id.eq(conversation_guid))) - .set(( - conv_dsl::date.eq(last_msg.date), - conv_dsl::last_message_preview.eq::>(Some(last_msg.text.clone())), - )) - .execute(conn)?; + diesel::update( + conv_dsl::conversations.filter(conv_dsl::id.eq(conversation_guid)), + ) + .set(( + conv_dsl::date.eq(last_msg.date), + conv_dsl::last_message_preview + .eq::>(Some(last_msg.text.clone())), + )) + .execute(conn)?; } Ok(()) })?; // TODO: May need to update conversation metadata here, but this has a perf impact. - // Ideally we would consolidate this in the code above, assuming we're only inserting *new* messages, but + // Ideally we would consolidate this in the code above, assuming we're only inserting *new* messages, but // this may not necessarily be the case. Ok(()) diff --git a/kordophone/src/api/event_socket.rs b/kordophone/src/api/event_socket.rs index 31ae740..de900fe 100644 --- a/kordophone/src/api/event_socket.rs +++ b/kordophone/src/api/event_socket.rs @@ -26,7 +26,12 @@ pub trait EventSocket { type UpdateStream: Stream>; /// Modern event pipeline - async fn events(self) -> (Self::EventStream, impl Sink); + async fn events( + self, + ) -> ( + Self::EventStream, + impl Sink, + ); /// Raw update items from the v1 API. async fn raw_updates(self) -> Self::UpdateStream; diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index 6bd29f8..265d72e 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -115,19 +115,26 @@ impl AuthSetting for hyper::http::Request { } } -type WebsocketSink = futures_util::stream::SplitSink>, tungstenite::Message>; -type WebsocketStream = futures_util::stream::SplitStream>>; +type WebsocketSink = futures_util::stream::SplitSink< + WebSocketStream>, + tungstenite::Message, +>; +type WebsocketStream = + futures_util::stream::SplitStream>>; pub struct WebsocketEventSocket { sink: Option, - stream: WebsocketStream + stream: WebsocketStream, } impl WebsocketEventSocket { pub fn new(socket: WebSocketStream>) -> Self { let (sink, stream) = socket.split(); - Self { sink: Some(sink), stream } + Self { + sink: Some(sink), + stream, + } } } @@ -147,12 +154,10 @@ impl WebsocketEventSocket { } } tungstenite::Message::Ping(_) => { - // We don't expect the server to send us pings. + // We don't expect the server to send us pings. Ok(None) } - tungstenite::Message::Pong(_) => { - Ok(Some(SocketUpdate::Pong)) - } + tungstenite::Message::Pong(_) => Ok(Some(SocketUpdate::Pong)), tungstenite::Message::Close(_) => { // Connection was closed cleanly Err(Error::ClientError("WebSocket connection closed".into())) @@ -169,33 +174,40 @@ impl EventSocket for WebsocketEventSocket { type EventStream = BoxStream<'static, Result>; type UpdateStream = BoxStream<'static, Result>; - async fn events(mut self) -> (Self::EventStream, impl Sink) { + async fn events( + mut self, + ) -> ( + Self::EventStream, + impl Sink, + ) { use futures_util::stream::iter; - let sink = self.sink.take().unwrap().with(|f| { - match f { - SinkMessage::Ping => futures_util::future::ready(Ok::(tungstenite::Message::Ping(Bytes::new()))) - } + let sink = self.sink.take().unwrap().with(|f| match f { + SinkMessage::Ping => futures_util::future::ready(Ok::( + tungstenite::Message::Ping(Bytes::new()), + )), }); - let stream = self.raw_update_stream() - .map_ok(|updates| -> BoxStream<'static, Result> { - match updates { - SocketUpdate::Update(updates) => { - let iter_stream = iter( - updates.into_iter().map(|u| Ok(SocketEvent::Update(Event::from(u)))) - ); - iter_stream.boxed() + let stream = self + .raw_update_stream() + .map_ok( + |updates| -> BoxStream<'static, Result> { + match updates { + SocketUpdate::Update(updates) => { + let iter_stream = iter( + updates + .into_iter() + .map(|u| Ok(SocketEvent::Update(Event::from(u)))), + ); + iter_stream.boxed() + } + SocketUpdate::Pong => iter(std::iter::once(Ok(SocketEvent::Pong))).boxed(), } - SocketUpdate::Pong => { - iter(std::iter::once(Ok(SocketEvent::Pong))).boxed() - } - } - }) + }, + ) .try_flatten() .boxed(); - (stream, sink) } @@ -212,9 +224,7 @@ impl Stream for ResponseStream { type Item = Result; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.body - .poll_next_unpin(cx) - .map_err(Error::HTTPError) + self.body.poll_next_unpin(cx).map_err(Error::HTTPError) } } @@ -328,10 +338,10 @@ impl APIInterface for HTTPAPIClient { guid: String, } - // TODO: We can still use Body::wrap_stream here, but we need to make sure to plumb the CONTENT_LENGTH header, - // otherwise CocoaHTTPServer will crash because of a bug. - // - // See ff03e73758f30c081a9319a8c04025cba69b8393 for what this was like before. + // TODO: We can still use Body::wrap_stream here, but we need to make sure to plumb the CONTENT_LENGTH header, + // otherwise CocoaHTTPServer will crash because of a bug. + // + // See ff03e73758f30c081a9319a8c04025cba69b8393 for what this was like before. let mut bytes = Vec::new(); data.read_to_end(&mut bytes) .await @@ -578,7 +588,11 @@ impl HTTPAPIClient { _ => { let status = response.status(); let body_str = hyper::body::to_bytes(response.into_body()).await?; - let message = format!("Request failed ({:}). Response body: {:?}", status, String::from_utf8_lossy(&body_str)); + let message = format!( + "Request failed ({:}). Response body: {:?}", + status, + String::from_utf8_lossy(&body_str) + ); return Err(Error::ClientError(message)); } } diff --git a/kordophone/src/model/update.rs b/kordophone/src/model/update.rs index b61ef5d..fc2061b 100644 --- a/kordophone/src/model/update.rs +++ b/kordophone/src/model/update.rs @@ -2,8 +2,7 @@ use super::conversation::Conversation; use super::message::Message; use serde::Deserialize; -#[derive(Debug, Clone, Deserialize)] -#[derive(Default)] +#[derive(Debug, Clone, Deserialize, Default)] pub struct UpdateItem { #[serde(rename = "messageSequenceNumber")] pub seq: u64, @@ -17,4 +16,3 @@ pub struct UpdateItem { #[serde(default)] pub pong: bool, } - diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index 0ab53d3..3709855 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -56,7 +56,12 @@ impl EventSocket for TestEventSocket { type EventStream = BoxStream<'static, Result>; type UpdateStream = BoxStream<'static, Result>; - async fn events(self) -> (Self::EventStream, impl Sink) { + async fn events( + self, + ) -> ( + Self::EventStream, + impl Sink, + ) { ( futures_util::stream::iter(self.events.into_iter().map(Ok)).boxed(), futures_util::sink::sink(), diff --git a/kordophoned/src/daemon/attachment_store.rs b/kordophoned/src/daemon/attachment_store.rs index 0ba3df3..8158c6a 100644 --- a/kordophoned/src/daemon/attachment_store.rs +++ b/kordophoned/src/daemon/attachment_store.rs @@ -114,11 +114,11 @@ impl AttachmentStore { store_path: &PathBuf, database: &mut Arc>, daemon_event_sink: &Sender, - guid: &String, - preview: bool + guid: &String, + preview: bool, ) -> Result<()> { let attachment = Self::get_attachment_impl(store_path, guid); - + if attachment.is_downloaded(preview) { log::info!(target: target::ATTACHMENTS, "Attachment already downloaded: {}", attachment.guid); return Err(AttachmentStoreError::AttachmentAlreadyDownloaded.into()); @@ -144,13 +144,16 @@ impl AttachmentStore { while let Some(Ok(data)) = stream.next().await { writer.write(data.as_ref())?; } - + // Flush and sync the temporary file before moving writer.flush()?; file.sync_all()?; // Atomically move the temporary file to the final location - std::fs::rename(&temporary_path, &attachment.get_path_for_preview_scratch(preview, false))?; + std::fs::rename( + &temporary_path, + &attachment.get_path_for_preview_scratch(preview, false), + )?; log::info!(target: target::ATTACHMENTS, "Completed download for attachment: {}", attachment.guid); @@ -175,12 +178,12 @@ impl AttachmentStore { let uploads_path = store_path.join("uploads"); std::fs::create_dir_all(&uploads_path).unwrap(); - // First, copy the file to the store path, under /uploads/. + // First, copy the file to the store path, under /uploads/. log::trace!(target: target::ATTACHMENTS, "Copying attachment to uploads directory: {}", uploads_path.display()); let temporary_path = uploads_path.join(incoming_path.file_name().unwrap()); std::fs::copy(incoming_path, &temporary_path).unwrap(); - // Open file handle to the temporary file, + // Open file handle to the temporary file, log::trace!(target: target::ATTACHMENTS, "Opening stream to temporary file: {}", temporary_path.display()); let file = File::open(&temporary_path).await?; let reader: BufReader = BufReader::new(file); @@ -189,7 +192,7 @@ impl AttachmentStore { let filename = incoming_path.file_name().unwrap().to_str().unwrap(); log::trace!(target: target::ATTACHMENTS, "Uploading attachment to server: {}", &filename); let mut client = Daemon::get_client_impl(database).await?; - + let metadata = std::fs::metadata(&temporary_path)?; let size = metadata.len(); let guid = client.upload_attachment(reader, filename, size).await?; @@ -220,7 +223,7 @@ impl AttachmentStore { let daemon_event_sink = self.daemon_event_sink.clone(); let _guid = guid.clone(); - // Spawn a new task here so we don't block incoming queue events. + // Spawn a new task here so we don't block incoming queue events. tokio::spawn(async move { let result = Self::download_attachment_impl( &store_path, diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 4428f4a..ed2edb9 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -224,7 +224,7 @@ impl Daemon { Event::UpdateStreamReconnected => { log::info!(target: target::UPDATES, "Update stream reconnected"); - // The ui client will respond differently, but we'll almost certainly want to do a sync-list in response to this. + // The ui client will respond differently, but we'll almost certainly want to do a sync-list in response to this. self.spawn_conversation_list_sync(); // Send signal to the client that the update stream has been reconnected. @@ -267,12 +267,14 @@ impl Daemon { self.spawn_conversation_list_sync(); // Also restart the update monitor. - if let Err(e) = self.update_monitor_command_tx + if let Err(e) = self + .update_monitor_command_tx .as_ref() .unwrap() - .try_send(UpdateMonitorCommand::Restart) { - log::warn!(target: target::UPDATES, "Failed to send restart command to update monitor: {}", e); - } + .try_send(UpdateMonitorCommand::Restart) + { + log::warn!(target: target::UPDATES, "Failed to send restart command to update monitor: {}", e); + } } reply.send(()).unwrap(); @@ -428,7 +430,12 @@ impl Daemon { .await } - async fn enqueue_outgoing_message(&mut self, text: String, conversation_id: String, attachment_guids: Vec) -> Uuid { + async fn enqueue_outgoing_message( + &mut self, + text: String, + conversation_id: String, + attachment_guids: Vec, + ) -> Uuid { let conversation_id = conversation_id.clone(); let outgoing_message = OutgoingMessage::builder() .text(text) @@ -553,11 +560,12 @@ impl Daemon { .await?; // Filter messages that have an empty body, or a body that is just whitespace. - // This is a workaround for a bug in the server where it returns messages with an empty body, which is usually - // the typing indicator or stuff like that. In the future, we need to move to ChatItems instead of Messages. + // This is a workaround for a bug in the server where it returns messages with an empty body, which is usually + // 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()).collect(); + .filter(|m| !m.text.is_empty() && !m.text.trim().is_empty()) + .collect(); let db_messages: Vec = insertable_messages .into_iter() diff --git a/kordophoned/src/daemon/models/attachment.rs b/kordophoned/src/daemon/models/attachment.rs index 6e0b95a..777fff7 100644 --- a/kordophoned/src/daemon/models/attachment.rs +++ b/kordophoned/src/daemon/models/attachment.rs @@ -30,7 +30,8 @@ impl Attachment { pub fn get_path_for_preview_scratch(&self, preview: bool, scratch: bool) -> PathBuf { let extension = if preview { "preview" } else { "full" }; if scratch { - self.base_path.with_extension(format!("{}.download", extension)) + self.base_path + .with_extension(format!("{}.download", extension)) } else { self.base_path.with_extension(extension) } diff --git a/kordophoned/src/daemon/update_monitor.rs b/kordophoned/src/daemon/update_monitor.rs index 12ae643..3b0da7c 100644 --- a/kordophoned/src/daemon/update_monitor.rs +++ b/kordophoned/src/daemon/update_monitor.rs @@ -40,7 +40,7 @@ impl UpdateMonitor { event_sender, last_sync_times: HashMap::new(), update_seq: None, - first_connection: false, // optimistic assumption that we're not reconnecting the first time. + first_connection: false, // optimistic assumption that we're not reconnecting the first time. command_tx: Some(command_tx), command_rx, } @@ -50,10 +50,7 @@ impl UpdateMonitor { self.command_tx.take().unwrap() } - async fn send_event( - &self, - make_event: impl FnOnce(Reply) -> Event, - ) -> DaemonResult { + async fn send_event(&self, make_event: impl FnOnce(Reply) -> Event) -> DaemonResult { let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); self.event_sender .send(make_event(reply_tx)) diff --git a/kordophoned/src/dbus/mod.rs b/kordophoned/src/dbus/mod.rs index 4edc275..5efd0d2 100644 --- a/kordophoned/src/dbus/mod.rs +++ b/kordophoned/src/dbus/mod.rs @@ -10,10 +10,10 @@ pub mod interface { include!(concat!(env!("OUT_DIR"), "/kordophone-server.rs")); pub mod signals { - pub use crate::interface::NetBuzzertKordophoneRepositoryConversationsUpdated as ConversationsUpdated; - pub use crate::interface::NetBuzzertKordophoneRepositoryMessagesUpdated as MessagesUpdated; pub use crate::interface::NetBuzzertKordophoneRepositoryAttachmentDownloadCompleted as AttachmentDownloadCompleted; pub use crate::interface::NetBuzzertKordophoneRepositoryAttachmentUploadCompleted as AttachmentUploadCompleted; + pub use crate::interface::NetBuzzertKordophoneRepositoryConversationsUpdated as ConversationsUpdated; + pub use crate::interface::NetBuzzertKordophoneRepositoryMessagesUpdated as MessagesUpdated; pub use crate::interface::NetBuzzertKordophoneRepositoryUpdateStreamReconnected as UpdateStreamReconnected; } } diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 98ff227..6bcc241 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -128,11 +128,11 @@ impl DbusRepository for ServerImpl { let mut map = arg::PropMap::new(); map.insert("id".into(), arg::Variant(Box::new(msg.id))); - // xxx: Remove the attachment placeholder here. + // xxx: Remove the attachment placeholder here. // This is not the ideal place to do this, but once we start using ChatItems instead of IMMessages - // from the server, we shouldn't be seeing these placeholders. + // from the server, we shouldn't be seeing these placeholders. let text = msg.text.replace("\u{FFFC}", ""); - + map.insert("text".into(), arg::Variant(Box::new(text))); map.insert( "date".into(), @@ -272,12 +272,9 @@ impl DbusRepository for ServerImpl { self.send_event_sync(|r| Event::DownloadAttachment(attachment_id, preview, r)) } - fn upload_attachment( - &mut self, - path: String, - ) -> Result { + fn upload_attachment(&mut self, path: String) -> Result { use std::path::PathBuf; - + let path = PathBuf::from(path); self.send_event_sync(|r| Event::UploadAttachment(path, r)) } diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index 59db0fb..3ee1608 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -100,9 +100,15 @@ async fn main() { } Signal::AttachmentDownloaded(attachment_id) => { - log::debug!("Sending signal: AttachmentDownloaded for attachment {}", attachment_id); + log::debug!( + "Sending signal: AttachmentDownloaded for attachment {}", + attachment_id + ); dbus_registry - .send_signal(interface::OBJECT_PATH, DbusSignals::AttachmentDownloadCompleted { attachment_id }) + .send_signal( + interface::OBJECT_PATH, + DbusSignals::AttachmentDownloadCompleted { attachment_id }, + ) .unwrap_or_else(|_| { log::error!("Failed to send signal"); 0 @@ -110,9 +116,19 @@ async fn main() { } Signal::AttachmentUploaded(upload_guid, attachment_guid) => { - log::debug!("Sending signal: AttachmentUploaded for upload {}, attachment {}", upload_guid, attachment_guid); + log::debug!( + "Sending signal: AttachmentUploaded for upload {}, attachment {}", + upload_guid, + attachment_guid + ); dbus_registry - .send_signal(interface::OBJECT_PATH, DbusSignals::AttachmentUploadCompleted { upload_guid, attachment_guid }) + .send_signal( + interface::OBJECT_PATH, + DbusSignals::AttachmentUploadCompleted { + upload_guid, + attachment_guid, + }, + ) .unwrap_or_else(|_| { log::error!("Failed to send signal"); 0 @@ -122,7 +138,10 @@ async fn main() { Signal::UpdateStreamReconnected => { log::debug!("Sending signal: UpdateStreamReconnected"); dbus_registry - .send_signal(interface::OBJECT_PATH, DbusSignals::UpdateStreamReconnected {}) + .send_signal( + interface::OBJECT_PATH, + DbusSignals::UpdateStreamReconnected {}, + ) .unwrap_or_else(|_| { log::error!("Failed to send signal"); 0 diff --git a/kpcli/src/client/mod.rs b/kpcli/src/client/mod.rs index 8709d89..4ee5eaa 100644 --- a/kpcli/src/client/mod.rs +++ b/kpcli/src/client/mod.rs @@ -113,19 +113,17 @@ impl ClientCli { let (mut stream, _) = socket.events().await; while let Some(Ok(socket_event)) = stream.next().await { match socket_event { - SocketEvent::Update(event) => { - match event.data { - EventData::ConversationChanged(conversation) => { - println!("Conversation changed: {}", conversation.guid); - } - EventData::MessageReceived(conversation, message) => { - println!( - "Message received: msg: {} conversation: {}", - message.guid, conversation.guid - ); - } + SocketEvent::Update(event) => match event.data { + EventData::ConversationChanged(conversation) => { + println!("Conversation changed: {}", conversation.guid); } - } + EventData::MessageReceived(conversation, message) => { + println!( + "Message received: msg: {} conversation: {}", + message.guid, conversation.guid + ); + } + }, SocketEvent::Pong => { println!("Pong"); } diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index 974a7df..b67fb3d 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -54,14 +54,10 @@ pub enum Commands { }, /// Downloads an attachment from the server to the attachment store. Returns the path to the attachment. - DownloadAttachment { - attachment_id: String, - }, + DownloadAttachment { attachment_id: String }, /// Uploads an attachment to the server, returns upload guid. - UploadAttachment { - path: String, - }, + UploadAttachment { path: String }, } #[derive(Subcommand)] @@ -100,7 +96,9 @@ impl Commands { text, } => client.enqueue_outgoing_message(conversation_id, text).await, Commands::UploadAttachment { path } => client.upload_attachment(path).await, - Commands::DownloadAttachment { attachment_id } => client.download_attachment(attachment_id).await, + Commands::DownloadAttachment { attachment_id } => { + client.download_attachment(attachment_id).await + } } } } @@ -178,8 +176,12 @@ impl DaemonCli { text: String, ) -> Result<()> { let attachment_guids: Vec<&str> = vec![]; - let outgoing_message_id = - KordophoneRepository::send_message(&self.proxy(), &conversation_id, &text, attachment_guids)?; + let outgoing_message_id = KordophoneRepository::send_message( + &self.proxy(), + &conversation_id, + &text, + attachment_guids, + )?; println!("Outgoing message ID: {}", outgoing_message_id); Ok(()) } @@ -244,7 +246,8 @@ impl DaemonCli { KordophoneRepository::download_attachment(&self.proxy(), &attachment_id, false)?; // Get attachment info. - let attachment_info = KordophoneRepository::get_attachment_info(&self.proxy(), &attachment_id)?; + let attachment_info = + KordophoneRepository::get_attachment_info(&self.proxy(), &attachment_id)?; let (path, preview_path, downloaded, preview_downloaded) = attachment_info; if downloaded { @@ -256,14 +259,18 @@ impl DaemonCli { // Attach to the signal that the attachment has been downloaded. let _id = self.proxy().match_signal( - move |h: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadCompleted, _: &Connection, _: &dbus::message::Message| { + move |h: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadCompleted, + _: &Connection, + _: &dbus::message::Message| { println!("Signal: Attachment downloaded: {}", path); std::process::exit(0); }, ); let _id = self.proxy().match_signal( - |h: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadFailed, _: &Connection, _: &dbus::message::Message| { + |h: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadFailed, + _: &Connection, + _: &dbus::message::Message| { println!("Signal: Attachment download failed: {}", h.attachment_id); std::process::exit(1); }, From fa6c7c50b7eb1222bb0bc2cb7bd54f4e6a26cc91 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 18 Jun 2025 01:03:14 -0700 Subject: [PATCH 090/138] Refactor: serverimpl -> dbus::agent, clean up main.rs --- .../src/dbus/{server_impl.rs => agent.rs} | 277 ++++++++++++------ kordophoned/src/dbus/mod.rs | 12 +- kordophoned/src/main.rs | 126 +------- 3 files changed, 203 insertions(+), 212 deletions(-) rename kordophoned/src/dbus/{server_impl.rs => agent.rs} (54%) diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/agent.rs similarity index 54% rename from kordophoned/src/dbus/server_impl.rs rename to kordophoned/src/dbus/agent.rs index 6bcc241..21708bd 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/agent.rs @@ -1,35 +1,155 @@ use dbus::arg; use dbus_tree::MethodErr; -use std::future::Future; -use std::thread; -use tokio::sync::mpsc; -use tokio::sync::oneshot; +use std::{future::Future, thread}; +use std::sync::Arc; +use tokio::sync::{mpsc, oneshot, Mutex}; use crate::daemon::{ events::{Event, Reply}, settings::Settings, + signals::Signal, DaemonResult, }; -use crate::dbus::interface::NetBuzzertKordophoneRepository as DbusRepository; -use crate::dbus::interface::NetBuzzertKordophoneSettings as DbusSettings; +use crate::dbus::endpoint::DbusRegistry; +use crate::dbus::interface; +use crate::dbus::interface::signals as DbusSignals; +use dbus_tokio::connection; #[derive(Clone)] -pub struct ServerImpl { +pub struct DBusAgent { event_sink: mpsc::Sender, + signal_receiver: Arc>>>, } -impl ServerImpl { - pub fn new(event_sink: mpsc::Sender) -> Self { +impl DBusAgent { + pub fn new(event_sink: mpsc::Sender, signal_receiver: mpsc::Receiver) -> Self { Self { - event_sink: event_sink, + event_sink, + signal_receiver: Arc::new(Mutex::new(Some(signal_receiver))), } } - pub async fn send_event( - &self, - make_event: impl FnOnce(Reply) -> Event, - ) -> DaemonResult { + pub async fn run(self) { + // Establish a session bus connection. + 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 { + let err = resource.await; + panic!("Lost connection to D-Bus: {:?}", err); + }); + + // Claim well-known bus name. + connection + .request_name(interface::NAME, false, true, false) + .await + .expect("Unable to acquire D-Bus name"); + + // Registry for objects & signals. + let dbus_registry = DbusRegistry::new(connection.clone()); + + // Register our object implementation. + let implementation = self.clone(); + dbus_registry.register_object(interface::OBJECT_PATH, implementation, |cr| { + vec![ + interface::register_net_buzzert_kordophone_repository(cr), + interface::register_net_buzzert_kordophone_settings(cr), + ] + }); + + // Spawn task that forwards daemon signals to D-Bus. + { + let registry = dbus_registry.clone(); + let receiver_arc = self.signal_receiver.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 { + match signal { + Signal::ConversationsUpdated => { + log::debug!("Sending signal: ConversationsUpdated"); + registry + .send_signal(interface::OBJECT_PATH, DbusSignals::ConversationsUpdated {}) + .unwrap_or_else(|_| { + log::error!("Failed to send signal"); + 0 + }); + } + Signal::MessagesUpdated(conversation_id) => { + log::debug!( + "Sending signal: MessagesUpdated for conversation {}", + conversation_id + ); + registry + .send_signal( + interface::OBJECT_PATH, + DbusSignals::MessagesUpdated { conversation_id }, + ) + .unwrap_or_else(|_| { + log::error!("Failed to send signal"); + 0 + }); + } + Signal::AttachmentDownloaded(attachment_id) => { + log::debug!( + "Sending signal: AttachmentDownloaded for attachment {}", + attachment_id + ); + registry + .send_signal( + interface::OBJECT_PATH, + DbusSignals::AttachmentDownloadCompleted { attachment_id }, + ) + .unwrap_or_else(|_| { + log::error!("Failed to send signal"); + 0 + }); + } + Signal::AttachmentUploaded(upload_guid, attachment_guid) => { + log::debug!( + "Sending signal: AttachmentUploaded for upload {}, attachment {}", + upload_guid, attachment_guid + ); + registry + .send_signal( + interface::OBJECT_PATH, + DbusSignals::AttachmentUploadCompleted { + upload_guid, + attachment_guid, + }, + ) + .unwrap_or_else(|_| { + log::error!("Failed to send signal"); + 0 + }); + } + Signal::UpdateStreamReconnected => { + log::debug!("Sending signal: UpdateStreamReconnected"); + registry + .send_signal( + interface::OBJECT_PATH, + DbusSignals::UpdateStreamReconnected {}, + ) + .unwrap_or_else(|_| { + log::error!("Failed to send signal"); + 0 + }); + } + } + } + }); + } + + // Keep running forever. + std::future::pending::<()>().await; + } + + 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)) @@ -49,17 +169,21 @@ impl ServerImpl { } } -impl DbusRepository for ServerImpl { +// +// D-Bus repository interface implementation +// + +use crate::dbus::interface::NetBuzzertKordophoneRepository as DbusRepository; +use crate::dbus::interface::NetBuzzertKordophoneSettings as DbusSettings; + +impl DbusRepository for DBusAgent { fn get_version(&mut self) -> Result { self.send_event_sync(Event::GetVersion) } - fn get_conversations( - &mut self, - limit: i32, - offset: i32, - ) -> Result, dbus::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() @@ -97,30 +221,27 @@ impl DbusRepository for ServerImpl { }) } - fn sync_conversation_list(&mut self) -> Result<(), dbus::MethodErr> { + fn sync_conversation_list(&mut self) -> Result<(), MethodErr> { self.send_event_sync(Event::SyncConversationList) } - fn sync_all_conversations(&mut self) -> Result<(), dbus::MethodErr> { + fn sync_all_conversations(&mut self) -> Result<(), MethodErr> { self.send_event_sync(Event::SyncAllConversations) } - fn sync_conversation(&mut self, conversation_id: String) -> Result<(), dbus::MethodErr> { + fn sync_conversation(&mut self, conversation_id: String) -> Result<(), MethodErr> { self.send_event_sync(|r| Event::SyncConversation(conversation_id, r)) } - fn get_messages( - &mut self, - conversation_id: String, - last_message_id: String, - ) -> Result, dbus::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() @@ -128,9 +249,7 @@ impl DbusRepository for ServerImpl { let mut map = arg::PropMap::new(); map.insert("id".into(), arg::Variant(Box::new(msg.id))); - // xxx: Remove the attachment placeholder here. - // This is not the ideal place to do this, but once we start using ChatItems instead of IMMessages - // from the server, we shouldn't be seeing these placeholders. + // Remove the attachment placeholder here. let text = msg.text.replace("\u{FFFC}", ""); map.insert("text".into(), arg::Variant(Box::new(text))); @@ -143,7 +262,7 @@ impl DbusRepository for ServerImpl { arg::Variant(Box::new(msg.sender.display_name())), ); - // Add attachments array + // Attachments array let attachments: Vec = msg .attachments .into_iter() @@ -154,7 +273,7 @@ impl DbusRepository for ServerImpl { arg::Variant(Box::new(attachment.guid.clone())), ); - // Get attachment paths and download status + // 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); @@ -166,9 +285,7 @@ impl DbusRepository for ServerImpl { ); 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(), @@ -179,14 +296,12 @@ impl DbusRepository for ServerImpl { arg::Variant(Box::new(preview_downloaded)), ); - // Add metadata if present + // Metadata if let Some(ref metadata) = attachment.metadata { let mut metadata_map = arg::PropMap::new(); - // Add attribution_info if present if let Some(ref attribution_info) = metadata.attribution_info { let mut attribution_map = arg::PropMap::new(); - if let Some(width) = attribution_info.width { attribution_map.insert( "width".into(), @@ -199,7 +314,6 @@ impl DbusRepository for ServerImpl { arg::Variant(Box::new(height as i32)), ); } - metadata_map.insert( "attribution_info".into(), arg::Variant(Box::new(attribution_map)), @@ -217,14 +331,13 @@ impl DbusRepository for ServerImpl { .collect(); map.insert("attachments".into(), arg::Variant(Box::new(attachments))); - map }) .collect() }) } - fn delete_all_conversations(&mut self) -> Result<(), dbus::MethodErr> { + fn delete_all_conversations(&mut self) -> Result<(), MethodErr> { self.send_event_sync(Event::DeleteAllConversations) } @@ -233,55 +346,44 @@ impl DbusRepository for ServerImpl { conversation_id: String, text: String, attachment_guids: Vec, - ) -> Result { - self.send_event_sync(|r| Event::SendMessage(conversation_id, text, attachment_guids, r)) + ) -> Result { + 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), dbus::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: string - path.to_string_lossy().to_string(), - // - preview_path: string - preview_path.to_string_lossy().to_string(), - // - downloaded: boolean - downloaded, - // - preview_downloaded: boolean - 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<(), dbus::MethodErr> { - // For now, just trigger the download event - we'll implement the actual download logic later + fn download_attachment(&mut self, attachment_id: String, preview: bool) -> Result<(), MethodErr> { self.send_event_sync(|r| Event::DownloadAttachment(attachment_id, preview, r)) } - fn upload_attachment(&mut self, path: String) -> Result { + fn upload_attachment(&mut self, path: String) -> Result { use std::path::PathBuf; - let path = PathBuf::from(path); self.send_event_sync(|r| Event::UploadAttachment(path, r)) } } -impl DbusSettings for ServerImpl { - fn set_server(&mut self, url: String, user: String) -> Result<(), dbus::MethodErr> { +// +// D-Bus settings interface implementation. +// + +impl DbusSettings for DBusAgent { + fn set_server(&mut self, url: String, user: String) -> Result<(), MethodErr> { self.send_event_sync(|r| { Event::UpdateSettings( Settings { @@ -294,12 +396,12 @@ impl DbusSettings for ServerImpl { }) } - fn server_url(&self) -> Result { + fn server_url(&self) -> Result { self.send_event_sync(Event::GetAllSettings) .map(|settings| settings.server_url.unwrap_or_default()) } - fn set_server_url(&self, value: String) -> Result<(), dbus::MethodErr> { + fn set_server_url(&self, value: String) -> Result<(), MethodErr> { self.send_event_sync(|r| { Event::UpdateSettings( Settings { @@ -312,12 +414,12 @@ impl DbusSettings for ServerImpl { }) } - fn username(&self) -> Result { + fn username(&self) -> Result { self.send_event_sync(Event::GetAllSettings) .map(|settings| settings.username.unwrap_or_default()) } - fn set_username(&self, value: String) -> Result<(), dbus::MethodErr> { + fn set_username(&self, value: String) -> Result<(), MethodErr> { self.send_event_sync(|r| { Event::UpdateSettings( Settings { @@ -331,14 +433,15 @@ impl DbusSettings for ServerImpl { } } +// +// Helper utilities. +// + fn run_sync_future(f: F) -> Result where T: Send, F: Future + Send, { - // We use `scope` here to ensure that the thread is joined before the - // function returns. This allows us to capture references of values that - // have lifetimes shorter than 'static, which is what thread::spawn requires. thread::scope(move |s| { s.spawn(move || { let rt = tokio::runtime::Builder::new_current_thread() @@ -352,4 +455,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 5efd0d2..1bc9930 100644 --- a/kordophoned/src/dbus/mod.rs +++ b/kordophoned/src/dbus/mod.rs @@ -1,5 +1,5 @@ pub mod endpoint; -pub mod server_impl; +pub mod agent; pub mod interface { #![allow(unused)] @@ -10,10 +10,10 @@ pub mod interface { include!(concat!(env!("OUT_DIR"), "/kordophone-server.rs")); pub mod signals { - pub use crate::interface::NetBuzzertKordophoneRepositoryAttachmentDownloadCompleted as AttachmentDownloadCompleted; - pub use crate::interface::NetBuzzertKordophoneRepositoryAttachmentUploadCompleted as AttachmentUploadCompleted; - pub use crate::interface::NetBuzzertKordophoneRepositoryConversationsUpdated as ConversationsUpdated; - pub use crate::interface::NetBuzzertKordophoneRepositoryMessagesUpdated as MessagesUpdated; - pub use crate::interface::NetBuzzertKordophoneRepositoryUpdateStreamReconnected as UpdateStreamReconnected; + pub use super::NetBuzzertKordophoneRepositoryAttachmentDownloadCompleted as AttachmentDownloadCompleted; + pub use super::NetBuzzertKordophoneRepositoryAttachmentUploadCompleted as AttachmentUploadCompleted; + pub use super::NetBuzzertKordophoneRepositoryConversationsUpdated as ConversationsUpdated; + pub use super::NetBuzzertKordophoneRepositoryMessagesUpdated as MessagesUpdated; + pub use super::NetBuzzertKordophoneRepositoryUpdateStreamReconnected as UpdateStreamReconnected; } } diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index 3ee1608..43a1b29 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -4,13 +4,9 @@ mod dbus; use log::LevelFilter; use std::future; -use daemon::signals::Signal; use daemon::Daemon; -use dbus::endpoint::DbusRegistry; -use dbus::interface; -use dbus::server_impl::ServerImpl; -use dbus_tokio::connection; +use dbus::agent::DBusAgent; fn initialize_logging() { // Weird: is this the best way to do this? @@ -31,128 +27,20 @@ async fn main() { // Create the daemon let mut daemon = Daemon::new() .map_err(|e| { - log::error!("Failed to start daemon: {}", e); + log::error!("Failed to initialize daemon: {}", e); std::process::exit(1); }) .unwrap(); - // Initialize dbus session connection - let (resource, connection) = connection::new_session_sync().unwrap(); - - // The resource is a task that should be spawned onto a tokio compatible - // reactor ASAP. If the resource ever finishes, you lost connection to D-Bus. - // - // To shut down the connection, both call _handle.abort() and drop the connection. - let _handle = tokio::spawn(async { - let err = resource.await; - panic!("Lost connection to D-Bus: {}", err); - }); - - // Acquire the name - connection - .request_name(interface::NAME, false, true, false) - .await - .expect("Unable to acquire dbus name"); - - // Create shared D-Bus registry - let dbus_registry = DbusRegistry::new(connection.clone()); - - // Create and register server implementation - let server = ServerImpl::new(daemon.event_sender.clone()); - - dbus_registry.register_object(interface::OBJECT_PATH, server, |cr| { - vec![ - interface::register_net_buzzert_kordophone_repository(cr), - interface::register_net_buzzert_kordophone_settings(cr), - ] - }); - - let mut signal_receiver = daemon.obtain_signal_receiver(); + // Start the D-Bus agent (events in, signals out). + let agent = DBusAgent::new(daemon.event_sender.clone(), daemon.obtain_signal_receiver()); tokio::spawn(async move { - use dbus::interface::signals as DbusSignals; - - while let Some(signal) = signal_receiver.recv().await { - match signal { - Signal::ConversationsUpdated => { - log::debug!("Sending signal: ConversationsUpdated"); - dbus_registry - .send_signal(interface::OBJECT_PATH, DbusSignals::ConversationsUpdated {}) - .unwrap_or_else(|_| { - log::error!("Failed to send signal"); - 0 - }); - } - - Signal::MessagesUpdated(conversation_id) => { - log::debug!( - "Sending signal: MessagesUpdated for conversation {}", - conversation_id - ); - dbus_registry - .send_signal( - interface::OBJECT_PATH, - DbusSignals::MessagesUpdated { conversation_id }, - ) - .unwrap_or_else(|_| { - log::error!("Failed to send signal"); - 0 - }); - } - - Signal::AttachmentDownloaded(attachment_id) => { - log::debug!( - "Sending signal: AttachmentDownloaded for attachment {}", - attachment_id - ); - dbus_registry - .send_signal( - interface::OBJECT_PATH, - DbusSignals::AttachmentDownloadCompleted { attachment_id }, - ) - .unwrap_or_else(|_| { - log::error!("Failed to send signal"); - 0 - }); - } - - Signal::AttachmentUploaded(upload_guid, attachment_guid) => { - log::debug!( - "Sending signal: AttachmentUploaded for upload {}, attachment {}", - upload_guid, - attachment_guid - ); - dbus_registry - .send_signal( - interface::OBJECT_PATH, - DbusSignals::AttachmentUploadCompleted { - upload_guid, - attachment_guid, - }, - ) - .unwrap_or_else(|_| { - log::error!("Failed to send signal"); - 0 - }); - } - - Signal::UpdateStreamReconnected => { - log::debug!("Sending signal: UpdateStreamReconnected"); - dbus_registry - .send_signal( - interface::OBJECT_PATH, - DbusSignals::UpdateStreamReconnected {}, - ) - .unwrap_or_else(|_| { - log::error!("Failed to send signal"); - 0 - }); - } - } - } + agent.run().await; }); + // Run the main daemon loop. daemon.run().await; + // Keep the process alive as long as any background tasks are running. future::pending::<()>().await; - unreachable!() } From 3b30cb77c896d93c0c195739d568363c1ce142ca Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 18 Jun 2025 15:02:04 -0700 Subject: [PATCH 091/138] Implements mark as read --- kordophone-db/src/lib.rs | 4 ++ kordophone-db/src/models/conversation.rs | 32 ++++++++++- kordophone-db/src/repository.rs | 31 +++++++---- kordophone/src/api/http_client.rs | 7 +++ kordophone/src/api/mod.rs | 3 ++ kordophone/src/tests/test_client.rs | 4 ++ .../net.buzzert.kordophonecd.Server.xml | 6 +++ kordophoned/src/daemon/events.rs | 6 +++ kordophoned/src/daemon/mod.rs | 53 +++++++++++++++++++ kordophoned/src/daemon/update_monitor.rs | 12 ++++- kordophoned/src/dbus/agent.rs | 4 ++ kpcli/src/client/mod.rs | 12 +++++ kpcli/src/daemon/mod.rs | 11 ++++ 13 files changed, 172 insertions(+), 13 deletions(-) diff --git a/kordophone-db/src/lib.rs b/kordophone-db/src/lib.rs index e026a7d..3abd200 100644 --- a/kordophone-db/src/lib.rs +++ b/kordophone-db/src/lib.rs @@ -7,4 +7,8 @@ pub mod settings; #[cfg(test)] mod tests; +pub mod target { + pub static REPOSITORY: &str = "repository"; +} + pub use repository::Repository; diff --git a/kordophone-db/src/models/conversation.rs b/kordophone-db/src/models/conversation.rs index c7de2de..2df10a7 100644 --- a/kordophone-db/src/models/conversation.rs +++ b/kordophone-db/src/models/conversation.rs @@ -1,4 +1,4 @@ -use crate::models::participant::Participant; +use crate::models::{message::Message, participant::Participant}; use chrono::{DateTime, NaiveDateTime}; use uuid::Uuid; @@ -27,6 +27,36 @@ impl Conversation { display_name: self.display_name.clone(), } } + + pub fn merge(&self, other: &Conversation, last_message: Option<&Message>) -> Conversation { + let mut new_conversation = self.clone(); + new_conversation.unread_count = other.unread_count; + new_conversation.participants = other.participants.clone(); + new_conversation.display_name = other.display_name.clone(); + + if let Some(last_message) = last_message { + if last_message.date > self.date { + new_conversation.date = last_message.date; + } + + if !last_message.text.is_empty() && !last_message.text.trim().is_empty() { + new_conversation.last_message_preview = Some(last_message.text.clone()); + } + } + + new_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 + } } impl From for Conversation { diff --git a/kordophone-db/src/repository.rs b/kordophone-db/src/repository.rs index c37e956..0f104ad 100644 --- a/kordophone-db/src/repository.rs +++ b/kordophone-db/src/repository.rs @@ -14,6 +14,7 @@ use crate::{ Conversation, Message, Participant, }, schema, + target, }; pub struct Repository<'a> { @@ -323,25 +324,35 @@ impl<'a> Repository<'a> { Ok(()) } + pub fn merge_conversation_metadata(&mut self, in_conversation: Conversation) -> Result { + let mut updated = false; + let conversation = self.get_conversation_by_guid(&in_conversation.guid)?; + if let Some(conversation) = conversation { + let merged_conversation = conversation.merge(&in_conversation, None); + + if merged_conversation != conversation { + self.insert_conversation(merged_conversation)?; + updated = true; + } + } + + log::debug!(target: target::REPOSITORY, "Merged conversation metadata: {} updated: {}", in_conversation.guid, updated); + Ok(updated) + } + fn update_conversation_metadata(&mut self, conversation_guid: &str) -> Result<()> { let conversation = self.get_conversation_by_guid(conversation_guid)?; - if let Some(mut conversation) = conversation { + if let Some(conversation) = conversation { if let Some(last_message) = self.get_last_message_for_conversation(conversation_guid)? { log::debug!( + target: target::REPOSITORY, "Updating conversation metadata: {} message: {:?}", conversation_guid, last_message ); - if last_message.date > conversation.date { - conversation.date = last_message.date; - } - - if !last_message.text.is_empty() && !last_message.text.trim().is_empty() { - conversation.last_message_preview = Some(last_message.text.clone()); - } - - self.insert_conversation(conversation)?; + let merged_conversation = conversation.merge(&conversation, Some(&last_message)); + self.insert_conversation(merged_conversation)?; } } diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index 265d72e..f940cc3 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -271,6 +271,13 @@ impl APIInterface for HTTPAPIClient { Ok(token) } + 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?; + Ok(()) + } + async fn get_messages( &mut self, conversation_id: &ConversationID, diff --git a/kordophone/src/api/mod.rs b/kordophone/src/api/mod.rs index f092307..5da1ced 100644 --- a/kordophone/src/api/mod.rs +++ b/kordophone/src/api/mod.rs @@ -64,6 +64,9 @@ pub trait APIInterface { // (POST) /authenticate async fn authenticate(&mut self, credentials: Credentials) -> Result; + // (GET) /markConversation + async fn mark_conversation_as_read(&mut self, conversation_id: &ConversationID) -> Result<(), Self::Error>; + // (WS) /updates async fn open_event_socket( &mut self, diff --git a/kordophone/src/tests/test_client.rs b/kordophone/src/tests/test_client.rs index 3709855..c51260e 100644 --- a/kordophone/src/tests/test_client.rs +++ b/kordophone/src/tests/test_client.rs @@ -148,4 +148,8 @@ impl APIInterface for TestClient { { Ok(String::from("test")) } + + async fn mark_conversation_as_read(&mut self, conversation_id: &ConversationID) -> Result<(), Self::Error> { + Ok(()) + } } diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index a0898e3..cdef983 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -43,6 +43,12 @@ value="Initiates a background sync of a single conversation with the server."/> + + + + + diff --git a/kordophoned/src/daemon/events.rs b/kordophoned/src/daemon/events.rs index 56c082a..debdbd6 100644 --- a/kordophoned/src/daemon/events.rs +++ b/kordophoned/src/daemon/events.rs @@ -26,6 +26,12 @@ pub enum Event { /// Asynchronous event for syncing a single conversation with the server. SyncConversation(String, Reply<()>), + /// Asynchronous event for marking a conversation as read. + MarkConversationAsRead(String, Reply<()>), + + /// Asynchronous event for updating the metadata for a conversation. + UpdateConversationMetadata(Conversation, Reply<()>), + /// Sent when the update stream is reconnected after a timeout or configuration change. UpdateStreamReconnected, diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index ed2edb9..3d10d3c 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -63,6 +63,7 @@ pub mod target { pub static SETTINGS: &str = "settings"; pub static UPDATES: &str = "updates"; pub static ATTACHMENTS: &str = "attachments"; + pub static DAEMON: &str = "daemon"; } pub struct Daemon { @@ -221,6 +222,31 @@ impl Daemon { reply.send(()).unwrap(); } + Event::MarkConversationAsRead(conversation_id, reply) => { + let mut db_clone = self.database.clone(); + self.runtime.spawn(async move { + let result = Self::mark_conversation_as_read_impl(&mut db_clone, conversation_id).await; + if let Err(e) = result { + log::error!(target: target::DAEMON, "Error handling mark conversation as read event: {}", e); + } + }); + + reply.send(()).unwrap(); + } + + Event::UpdateConversationMetadata(conversation, reply) => { + let mut db_clone = self.database.clone(); + let signal_sender = self.signal_sender.clone(); + self.runtime.spawn(async move { + let result = Self::update_conversation_metadata_impl(&mut db_clone, conversation, &signal_sender).await; + if let Err(e) = result { + log::error!(target: target::DAEMON, "Error handling update conversation metadata event: {}", e); + } + }); + + reply.send(()).unwrap(); + } + Event::UpdateStreamReconnected => { log::info!(target: target::UPDATES, "Update stream reconnected"); @@ -590,6 +616,33 @@ impl Daemon { Ok(()) } + async fn mark_conversation_as_read_impl( + database: &mut Arc>, + conversation_id: String, + ) -> Result<()> { + log::debug!(target: target::DAEMON, "Marking conversation as read: {}", conversation_id); + + let mut client = Self::get_client_impl(database).await?; + client.mark_conversation_as_read(&conversation_id).await?; + Ok(()) + } + + async fn update_conversation_metadata_impl( + database: &mut Arc>, + conversation: Conversation, + 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?; + if updated { + signal_sender + .send(Signal::ConversationsUpdated) + .await?; + } + + Ok(()) + } + async fn get_settings(&mut self) -> Result { let settings = self.database.with_settings(Settings::from_db).await?; Ok(settings) diff --git a/kordophoned/src/daemon/update_monitor.rs b/kordophoned/src/daemon/update_monitor.rs index 3b0da7c..329954b 100644 --- a/kordophoned/src/daemon/update_monitor.rs +++ b/kordophoned/src/daemon/update_monitor.rs @@ -65,11 +65,19 @@ 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(); + self.send_event(|r| Event::UpdateConversationMetadata(db_conversation, r)) + .await + .unwrap_or_else(|e| { + log::error!("Failed to send daemon event: {}", e); + }); + // Check if we've synced this conversation recently (within 5 seconds) // This is currently a hack/workaround to prevent an infinite loop of sync events, because for some reason // imagent will post a conversation changed notification when we call getMessages. if let Some(last_sync) = self.last_sync_times.get(&conversation.guid) { - if last_sync.elapsed() < Duration::from_secs(5) { + if last_sync.elapsed() < Duration::from_secs(1) { log::info!(target: target::UPDATES, "Skipping sync for conversation id: {}. Last sync was {} seconds ago.", conversation.guid, last_sync.elapsed().as_secs_f64()); return; @@ -85,7 +93,7 @@ impl UpdateMonitor { match (&last_message, &conversation.last_message) { (Some(message), Some(conversation_message)) => { if message.id == conversation_message.guid { - log::info!(target: target::UPDATES, "Skipping sync for conversation id: {}. We already have this message.", conversation.guid); + log::info!(target: target::UPDATES, "Skipping sync for conversation id: {}. We already have this message.", &conversation.guid); return; } } diff --git a/kordophoned/src/dbus/agent.rs b/kordophoned/src/dbus/agent.rs index 21708bd..cb42995 100644 --- a/kordophoned/src/dbus/agent.rs +++ b/kordophoned/src/dbus/agent.rs @@ -233,6 +233,10 @@ impl DbusRepository for DBusAgent { self.send_event_sync(|r| Event::SyncConversation(conversation_id, r)) } + fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<(), MethodErr> { + self.send_event_sync(|r| Event::MarkConversationAsRead(conversation_id, r)) + } + 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 diff --git a/kpcli/src/client/mod.rs b/kpcli/src/client/mod.rs index 4ee5eaa..679e795 100644 --- a/kpcli/src/client/mod.rs +++ b/kpcli/src/client/mod.rs @@ -52,6 +52,9 @@ pub enum Commands { conversation_id: String, message: String, }, + + /// Marks a conversation as read. + Mark { conversation_id: String }, } impl Commands { @@ -67,6 +70,9 @@ impl Commands { conversation_id, message, } => client.send_message(conversation_id, message).await, + Commands::Mark { conversation_id } => { + client.mark_conversation_as_read(conversation_id).await + } } } } @@ -163,4 +169,10 @@ impl ClientCli { println!("Message sent: {}", message.guid); Ok(()) } + + pub async fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()> { + self.api.mark_conversation_as_read(&conversation_id).await?; + println!("Conversation marked as read: {}", conversation_id); + Ok(()) + } } diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index b67fb3d..e154bde 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -58,6 +58,9 @@ pub enum Commands { /// Uploads an attachment to the server, returns upload guid. UploadAttachment { path: String }, + + /// Marks a conversation as read. + MarkConversationAsRead { conversation_id: String }, } #[derive(Subcommand)] @@ -99,6 +102,9 @@ impl Commands { Commands::DownloadAttachment { attachment_id } => { client.download_attachment(attachment_id).await } + Commands::MarkConversationAsRead { conversation_id } => { + client.mark_conversation_as_read(conversation_id).await + } } } } @@ -289,4 +295,9 @@ impl DaemonCli { println!("Upload GUID: {}", upload_guid); Ok(()) } + + pub async fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()> { + KordophoneRepository::mark_conversation_as_read(&self.proxy(), &conversation_id) + .map_err(|e| anyhow::anyhow!("Failed to mark conversation as read: {}", e)) + } } From bb19db17cd83d6c130ed024388a4af6bf17141b7 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 26 Jun 2025 16:23:53 -0700 Subject: [PATCH 092/138] Started working on contact resolution --- Cargo.lock | 1 + .../down.sql | 14 ++ .../up.sql | 2 + kordophone-db/src/models/db/participant.rs | 43 ++-- kordophone-db/src/models/message.rs | 1 + kordophone-db/src/models/participant.rs | 3 + kordophone-db/src/repository.rs | 21 +- kordophone-db/src/schema.rs | 1 + kordophoned/Cargo.toml | 3 +- .../src/daemon/contact_resolver/eds.rs | 229 ++++++++++++++++++ .../src/daemon/contact_resolver/mod.rs | 46 ++++ kordophoned/src/daemon/mod.rs | 32 ++- kordophoned/src/daemon/models/message.rs | 12 +- kordophoned/src/dbus/agent.rs | 24 +- 14 files changed, 405 insertions(+), 27 deletions(-) create mode 100644 kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/down.sql create mode 100644 kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/up.sql create mode 100644 kordophoned/src/daemon/contact_resolver/eds.rs create mode 100644 kordophoned/src/daemon/contact_resolver/mod.rs diff --git a/Cargo.lock b/Cargo.lock index ddbe939..e7241ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1063,6 +1063,7 @@ dependencies = [ "kordophone", "kordophone-db", "log", + "once_cell", "serde_json", "thiserror 2.0.12", "tokio", diff --git a/kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/down.sql b/kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/down.sql new file mode 100644 index 0000000..a549445 --- /dev/null +++ b/kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/down.sql @@ -0,0 +1,14 @@ +-- Revert participants table to remove contact_id column +-- SQLite does not support DROP COLUMN directly, so we recreate the table without contact_id +PRAGMA foreign_keys=off; +CREATE TABLE participants_backup ( + id INTEGER NOT NULL PRIMARY KEY, + display_name TEXT, + is_me BOOL NOT NULL +); +INSERT INTO participants_backup (id, display_name, is_me) + SELECT id, display_name, is_me + FROM participants; +DROP TABLE participants; +ALTER TABLE participants_backup RENAME TO participants; +PRAGMA foreign_keys=on; \ No newline at end of file diff --git a/kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/up.sql b/kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/up.sql new file mode 100644 index 0000000..c72b96b --- /dev/null +++ b/kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/up.sql @@ -0,0 +1,2 @@ +-- Add contact_id column to participants to store an external contact identifier (e.g., Folks ID) +ALTER TABLE participants ADD COLUMN contact_id TEXT; \ No newline at end of file diff --git a/kordophone-db/src/models/db/participant.rs b/kordophone-db/src/models/db/participant.rs index 0ce7150..959f8a9 100644 --- a/kordophone-db/src/models/db/participant.rs +++ b/kordophone-db/src/models/db/participant.rs @@ -8,6 +8,7 @@ pub struct Record { pub id: i32, pub display_name: Option, pub is_me: bool, + pub contact_id: Option, } #[derive(Insertable)] @@ -15,19 +16,22 @@ pub struct Record { pub struct InsertableRecord { pub display_name: Option, pub is_me: bool, + pub contact_id: Option, } impl From for InsertableRecord { fn from(participant: Participant) -> Self { match participant { - Participant::Me => InsertableRecord { - display_name: None, - is_me: true, - }, - Participant::Remote { display_name, .. } => InsertableRecord { - display_name: Some(display_name), - is_me: false, - }, + Participant::Me => InsertableRecord { + display_name: None, + is_me: true, + contact_id: None, + }, + Participant::Remote { display_name, contact_id, .. } => InsertableRecord { + display_name: Some(display_name), + is_me: false, + contact_id, + }, } } } @@ -50,6 +54,7 @@ impl From for Participant { Participant::Remote { id: Some(record.id), display_name: record.display_name.unwrap_or_default(), + contact_id: record.contact_id, } } } @@ -58,16 +63,18 @@ impl From for Participant { impl From for Record { fn from(participant: Participant) -> Self { match participant { - Participant::Me => Record { - id: 0, // This will be set by the database - display_name: None, - is_me: true, - }, - Participant::Remote { display_name, .. } => Record { - id: 0, // This will be set by the database - display_name: Some(display_name), - is_me: false, - }, + Participant::Me => Record { + id: 0, // This will be set by the database + display_name: None, + is_me: true, + contact_id: None, + }, + Participant::Remote { display_name, contact_id, .. } => Record { + id: 0, // This will be set by the database + display_name: Some(display_name), + is_me: false, + contact_id, + }, } } } diff --git a/kordophone-db/src/models/message.rs b/kordophone-db/src/models/message.rs index d57e086..75d0cab 100644 --- a/kordophone-db/src/models/message.rs +++ b/kordophone-db/src/models/message.rs @@ -29,6 +29,7 @@ impl From for Message { Some(sender) => Participant::Remote { id: None, display_name: sender, + contact_id: None, }, None => Participant::Me, }, diff --git a/kordophone-db/src/models/participant.rs b/kordophone-db/src/models/participant.rs index f643202..ce9d37d 100644 --- a/kordophone-db/src/models/participant.rs +++ b/kordophone-db/src/models/participant.rs @@ -4,6 +4,7 @@ pub enum Participant { Remote { id: Option, display_name: String, + contact_id: Option, }, } @@ -12,6 +13,7 @@ impl From for Participant { Participant::Remote { id: None, display_name, + contact_id: None, } } } @@ -21,6 +23,7 @@ impl From<&str> for Participant { Participant::Remote { id: None, display_name: display_name.to_string(), + contact_id: None, } } } diff --git a/kordophone-db/src/repository.rs b/kordophone-db/src/repository.rs index 0f104ad..b6787cb 100644 --- a/kordophone-db/src/repository.rs +++ b/kordophone-db/src/repository.rs @@ -13,8 +13,7 @@ use crate::{ }, Conversation, Message, Participant, }, - schema, - target, + schema, target, }; pub struct Repository<'a> { @@ -195,6 +194,7 @@ impl<'a> Repository<'a> { let new_participant = InsertableParticipantRecord { display_name: Some(display_name.clone()), is_me: false, + contact_id: None, }; diesel::insert_into(participants_dsl::participants) @@ -371,11 +371,27 @@ impl<'a> Repository<'a> { ) } + /// Update the contact_id for an existing participant record. + pub fn update_participant_contact( + &mut self, + participant_db_id: i32, + new_contact_id: &str, + ) -> Result<()> { + use crate::schema::participants::dsl::*; + + log::debug!(target: target::REPOSITORY, "Updating participant contact id {} => {}", participant_db_id, new_contact_id); + diesel::update(participants.filter(id.eq(participant_db_id))) + .set(contact_id.eq(Some(new_contact_id.to_string()))) + .execute(self.connection)?; + Ok(()) + } + fn get_or_create_participant(&mut self, participant: &Participant) -> Option { match participant { Participant::Me => None, Participant::Remote { display_name: p_name, + contact_id: c_id, .. } => { use crate::schema::participants::dsl::*; @@ -393,6 +409,7 @@ impl<'a> Repository<'a> { let participant_record = InsertableParticipantRecord { display_name: Some(participant.display_name()), is_me: false, + contact_id: c_id.clone(), }; diesel::insert_into(participants) diff --git a/kordophone-db/src/schema.rs b/kordophone-db/src/schema.rs index d0c5355..8f1f554 100644 --- a/kordophone-db/src/schema.rs +++ b/kordophone-db/src/schema.rs @@ -16,6 +16,7 @@ diesel::table! { id -> Integer, display_name -> Nullable, is_me -> Bool, + contact_id -> Nullable, } } diff --git a/kordophoned/Cargo.toml b/kordophoned/Cargo.toml index 1222916..51e88b2 100644 --- a/kordophoned/Cargo.toml +++ b/kordophoned/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" anyhow = "1.0.98" async-trait = "0.1.88" chrono = "0.4.38" -dbus = "0.9.7" +dbus = { version = "0.9.7", features = ["futures"] } dbus-crossroads = "0.5.2" dbus-tokio = "0.7.6" dbus-tree = "0.9.2" @@ -23,6 +23,7 @@ thiserror = "2.0.12" tokio = { version = "1", features = ["full"] } tokio-condvar = "0.3.0" uuid = "1.16.0" +once_cell = "1.19.0" [build-dependencies] dbus-codegen = "0.10.0" diff --git a/kordophoned/src/daemon/contact_resolver/eds.rs b/kordophoned/src/daemon/contact_resolver/eds.rs new file mode 100644 index 0000000..a913cf4 --- /dev/null +++ b/kordophoned/src/daemon/contact_resolver/eds.rs @@ -0,0 +1,229 @@ +use super::ContactResolverBackend; +use dbus::blocking::Connection; +use dbus::arg::{RefArg, Variant}; +use once_cell::sync::OnceCell; +use std::collections::HashMap; +use std::time::Duration; + +pub struct EDSContactResolverBackend; + +// Cache the UID of the default local address book so we do not have to scan +// all sources over and over again. Discovering the address book requires a +// D-Bus round-trip that we would rather avoid on every lookup. +static ADDRESS_BOOK_SOURCE_UID: OnceCell = OnceCell::new(); + +/// Helper that returns a blocking D-Bus session connection. Creating the +/// connection is cheap (<1 ms) but we still keep it around because the +/// underlying socket is re-used by the dbus crate. +fn new_session_connection() -> Result { + Connection::new_session() +} + +/// Scan Evolution-Data-Server sources to find a suitable address-book source +/// UID. The implementation mirrors what `gdbus introspect` reveals for the +/// EDS interfaces. We search all `org.gnome.evolution.dataserver.Source` +/// objects and pick the first one that advertises the `[Address Book]` section +/// with a `BackendName=` entry in its INI-style `Data` property. +fn ensure_address_book_uid(conn: &Connection) -> anyhow::Result { + if let Some(uid) = ADDRESS_BOOK_SOURCE_UID.get() { + return Ok(uid.clone()); + } + + let source_manager_proxy = conn.with_proxy( + "org.gnome.evolution.dataserver.Sources5", + "/org/gnome/evolution/dataserver/SourceManager", + Duration::from_secs(5), + ); + + // The GetManagedObjects reply is the usual ObjectManager map. + let (managed_objects,): ( + HashMap< + dbus::Path<'static>, + HashMap>>>, + >, + ) = source_manager_proxy.method_call( + "org.freedesktop.DBus.ObjectManager", + "GetManagedObjects", + (), + )?; + + let uid = managed_objects + .values() + .filter_map(|ifaces| ifaces.get("org.gnome.evolution.dataserver.Source")) + .filter_map(|props| { + let uid = props.get("UID")?.as_str()?; + let data = props.get("Data")?.as_str()?; + if data_contains_address_book_backend(data) { + Some(uid.to_owned()) + } else { + None + } + }) + .next() + .ok_or_else(|| anyhow::anyhow!("No address book source found"))?; + + // Remember for future look-ups. + log::debug!("EDS resolver: found address book source UID: {}", uid); + let _ = ADDRESS_BOOK_SOURCE_UID.set(uid.clone()); + Ok(uid) +} + +fn data_contains_address_book_backend(data: &str) -> bool { + let mut in_address_book_section = false; + for line in data.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_address_book_section = trimmed == "[Address Book]"; + continue; + } + if in_address_book_section && trimmed.starts_with("BackendName=") { + return true; + } + } + false +} + +/// 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)> { + let factory_proxy = conn.with_proxy( + "org.gnome.evolution.dataserver.AddressBook10", + "/org/gnome/evolution/dataserver/AddressBookFactory", + Duration::from_secs(60), + ); + + let (object_path, bus_name): (String, String) = factory_proxy.method_call( + "org.gnome.evolution.dataserver.AddressBookFactory", + "OpenAddressBook", + (source_uid.to_owned(),), + )?; + + Ok((object_path, bus_name)) +} + +impl ContactResolverBackend for EDSContactResolverBackend { + type ContactID = String; + + fn resolve_contact_id(&self, address: &str) -> Option { + // Only email addresses are supported for now. We fall back to NONE on + // any error to keep the resolver infallible for callers. + let conn = match new_session_connection() { + Ok(c) => c, + Err(e) => { + log::debug!("EDS resolver: failed to open session D-Bus: {}", e); + return None; + } + }; + + let source_uid = match ensure_address_book_uid(&conn) { + Ok(u) => u, + Err(e) => { + log::debug!("EDS resolver: could not determine address-book UID: {}", e); + return None; + } + }; + + let (object_path, bus_name) = match open_address_book(&conn, &source_uid) { + Ok(v) => v, + Err(e) => { + log::debug!("EDS resolver: failed to open address book: {}", e); + return None; + } + }; + + let address_book_proxy = conn.with_proxy(bus_name, object_path, Duration::from_secs(60)); + + let filter = if address.contains('@') { + format!("(is \"email\" \"{}\")", address) + } else { + // Remove country code, if present + let address = address.replace("+", "") + .chars() + .skip_while(|c| c.is_numeric() || *c == '(' || *c == ')') + .collect::(); + + // Remove any remaining non-numeric characters + let address = address.chars() + .filter(|c| c.is_numeric()) + .collect::(); + + format!("(is \"phone\" \"{}\")", address) + }; + + let uids_result: Result<(Vec,), _> = address_book_proxy.method_call( + "org.gnome.evolution.dataserver.AddressBook", + "GetContactListUids", + (filter,), + ); + + let (uids,) = match uids_result { + Ok(v) => v, + Err(e) => { + log::debug!("EDS resolver: GetContactListUids failed: {}", e); + return None; + } + }; + + uids.into_iter().next() + } + + fn get_contact_display_name(&self, contact_id: &Self::ContactID) -> Option { + let conn = match new_session_connection() { + Ok(c) => c, + Err(e) => { + log::debug!("EDS resolver: failed to open session D-Bus: {}", e); + return None; + } + }; + + let source_uid = match ensure_address_book_uid(&conn) { + Ok(u) => u, + Err(e) => { + log::debug!("EDS resolver: could not determine address-book UID: {}", e); + return None; + } + }; + + let (object_path, bus_name) = match open_address_book(&conn, &source_uid) { + Ok(v) => v, + Err(e) => { + log::debug!("EDS resolver: failed to open address book: {}", e); + return None; + } + }; + + let address_book_proxy = conn.with_proxy(bus_name, object_path, Duration::from_secs(60)); + + let vcard_result: Result<(String,), _> = address_book_proxy.method_call( + "org.gnome.evolution.dataserver.AddressBook", + "GetContact", + (contact_id.clone(),), + ); + + let (vcard,) = match vcard_result { + Ok(v) => v, + Err(e) => { + log::debug!("EDS resolver: GetContact failed: {}", e); + return None; + } + }; + + for line in vcard.lines() { + if let Some(rest) = line.strip_prefix("FN:") { + return Some(rest.to_string()); + } + } + + None + } +} + +impl Default for EDSContactResolverBackend { + fn default() -> Self { + Self + } +} \ No newline at end of file diff --git a/kordophoned/src/daemon/contact_resolver/mod.rs b/kordophoned/src/daemon/contact_resolver/mod.rs new file mode 100644 index 0000000..2271fae --- /dev/null +++ b/kordophoned/src/daemon/contact_resolver/mod.rs @@ -0,0 +1,46 @@ +pub mod eds; +pub use eds::EDSContactResolverBackend; + +pub trait ContactResolverBackend { + type ContactID; + + fn resolve_contact_id(&self, address: &str) -> Option; + fn get_contact_display_name(&self, contact_id: &Self::ContactID) -> Option; +} + +pub type AnyContactID = String; + +pub struct ContactResolver { + backend: T, +} + +impl ContactResolver +where + T::ContactID: From, + T::ContactID: Into, + T: Default, +{ + pub fn new(backend: T) -> Self { + Self { backend } + } + + pub fn resolve_contact_id(&self, address: &str) -> Option { + self.backend.resolve_contact_id(address).map(|id| id.into()) + } + + pub fn get_contact_display_name(&self, contact_id: &AnyContactID) -> Option { + let backend_contact_id: T::ContactID = T::ContactID::from((*contact_id).clone()); + self.backend.get_contact_display_name(&backend_contact_id) + } +} + +impl Default for ContactResolver +where + T::ContactID: From, + T::ContactID: Into, + T: Default, +{ + fn default() -> Self { + Self::new(T::default()) + } +} diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 3d10d3c..546424f 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -49,6 +49,12 @@ mod attachment_store; pub use attachment_store::AttachmentStore; pub use attachment_store::AttachmentStoreEvent; +pub mod contact_resolver; +use contact_resolver::ContactResolver; +use contact_resolver::EDSContactResolverBackend; + +use kordophone_db::models::participant::Participant as DbParticipant; + #[derive(Debug, Error)] pub enum DaemonError { #[error("Client Not Configured")] @@ -501,10 +507,34 @@ impl Daemon { // Insert each conversation let num_conversations = db_conversations.len(); + let contact_resolver = ContactResolver::new(EDSContactResolverBackend::default()); for conversation in db_conversations { + // Insert or update conversation and its participants database - .with_repository(|r| r.insert_conversation(conversation)) + .with_repository(|r| r.insert_conversation(conversation.clone())) .await?; + + // Resolve any new participants via the contact resolver and store their contact_id + log::trace!(target: target::SYNC, "Resolving participants for conversation: {}", conversation.guid); + let guid = conversation.guid.clone(); + if let Some(saved) = database + .with_repository(|r| r.get_conversation_by_guid(&guid)) + .await? + { + for p in &saved.participants { + if let DbParticipant::Remote { id: Some(pid), display_name, contact_id: None } = p { + log::trace!(target: target::SYNC, "Resolving contact id for participant: {}", display_name); + if let Some(contact) = contact_resolver.resolve_contact_id(display_name) { + log::trace!(target: target::SYNC, "Resolved contact id for participant: {}", contact); + let _ = database + .with_repository(|r| r.update_participant_contact(*pid, &contact)) + .await; + } else { + log::trace!(target: target::SYNC, "No contact id found for participant: {}", display_name); + } + } + } + } } // Send conversations updated signal diff --git a/kordophoned/src/daemon/models/message.rs b/kordophoned/src/daemon/models/message.rs index f1bc32f..7b68ae8 100644 --- a/kordophoned/src/daemon/models/message.rs +++ b/kordophoned/src/daemon/models/message.rs @@ -13,6 +13,7 @@ pub enum Participant { Remote { id: Option, display_name: String, + contact_id: Option, }, } @@ -21,6 +22,7 @@ impl From for Participant { Participant::Remote { id: None, display_name, + contact_id: None, } } } @@ -30,6 +32,7 @@ impl From<&str> for Participant { Participant::Remote { id: None, display_name: display_name.to_string(), + contact_id: None, } } } @@ -38,8 +41,8 @@ impl From for Participant { fn from(participant: kordophone_db::models::Participant) -> Self { match participant { kordophone_db::models::Participant::Me => Participant::Me, - kordophone_db::models::Participant::Remote { id, display_name } => { - Participant::Remote { id, display_name } + kordophone_db::models::Participant::Remote { id, display_name, contact_id } => { + Participant::Remote { id, display_name, contact_id } } } } @@ -107,8 +110,8 @@ impl From for kordophone_db::models::Message { id: message.id, sender: match message.sender { Participant::Me => kordophone_db::models::Participant::Me, - Participant::Remote { id, display_name } => { - kordophone_db::models::Participant::Remote { id, display_name } + Participant::Remote { id, display_name, contact_id } => { + kordophone_db::models::Participant::Remote { id, display_name, contact_id } } }, text: message.text, @@ -145,6 +148,7 @@ impl From for Message { Some(sender) => Participant::Remote { id: None, display_name: sender, + contact_id: None, }, None => Participant::Me, }, diff --git a/kordophoned/src/dbus/agent.rs b/kordophoned/src/dbus/agent.rs index cb42995..e8ecc60 100644 --- a/kordophoned/src/dbus/agent.rs +++ b/kordophoned/src/dbus/agent.rs @@ -9,8 +9,11 @@ use crate::daemon::{ settings::Settings, signals::Signal, DaemonResult, + contact_resolver::{ContactResolver, EDSContactResolverBackend}, }; +use kordophone_db::models::participant::Participant; + use crate::dbus::endpoint::DbusRegistry; use crate::dbus::interface; use crate::dbus::interface::signals as DbusSignals; @@ -167,6 +170,24 @@ impl DBusAgent { .unwrap() .map_err(|e| MethodErr::failed(&format!("Daemon error: {}", e))) } + + fn resolve_participant_display_name(&self, participant: &Participant) -> String { + let resolver = ContactResolver::new(EDSContactResolverBackend::default()); + match participant { + // Me (we should use a special string here...) + Participant::Me => "(Me)".to_string(), + + // Remote participant with a resolved contact_id + Participant::Remote { display_name, contact_id: Some(contact_id), .. } => { + resolver.get_contact_display_name(contact_id).unwrap_or_else(|| display_name.clone()) + } + + // Remote participant without a resolved contact_id + Participant::Remote { display_name, .. } => { + display_name.clone() + } + } + } } // @@ -207,7 +228,7 @@ impl DbusRepository for DBusAgent { arg::Variant(Box::new( conv.participants .into_iter() - .map(|p| p.display_name()) + .map(|p| self.resolve_participant_display_name(&p)) .collect::>(), )), ); @@ -221,6 +242,7 @@ impl DbusRepository for DBusAgent { }) } + fn sync_conversation_list(&mut self) -> Result<(), MethodErr> { self.send_event_sync(Event::SyncConversationList) } From f6bb1a9b57f7e41273a0b29b312230c25590a3fc Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 26 Jun 2025 18:23:15 -0700 Subject: [PATCH 093/138] Don't overwrite already resolved participants, better naming of keys --- .../up.sql | 34 ----- .../2025-04-25-223015_add_settings/down.sql | 7 - .../2025-04-25-223015_add_settings/up.sql | 11 -- .../down.sql | 2 - .../up.sql | 2 - .../down.sql | 2 - .../up.sql | 2 - .../down.sql | 14 -- .../up.sql | 2 - .../down.sql | 5 +- .../2025-06-26-233940_create_schema/up.sql | 46 +++++++ kordophone-db/src/models/conversation.rs | 2 +- kordophone-db/src/models/db/message.rs | 13 +- kordophone-db/src/models/db/participant.rs | 29 ++-- kordophone-db/src/models/message.rs | 3 +- kordophone-db/src/models/mod.rs | 2 +- kordophone-db/src/models/participant.rs | 32 ++--- kordophone-db/src/repository.rs | 114 +++++++--------- kordophone-db/src/schema.rs | 25 ++-- kordophone-db/src/tests/mod.rs | 35 ++--- .../src/daemon/contact_resolver/eds.rs | 127 ++++++++++-------- .../src/daemon/contact_resolver/mod.rs | 1 + kordophoned/src/daemon/mod.rs | 10 +- kordophoned/src/daemon/models/message.rs | 32 +++-- kordophoned/src/dbus/agent.rs | 17 ++- 25 files changed, 263 insertions(+), 306 deletions(-) delete mode 100644 kordophone-db/migrations/2025-01-21-051154_create_conversations/up.sql delete mode 100644 kordophone-db/migrations/2025-04-25-223015_add_settings/down.sql delete mode 100644 kordophone-db/migrations/2025-04-25-223015_add_settings/up.sql delete mode 100644 kordophone-db/migrations/2025-05-26-232206_add_attachment_metadata_to_messages/down.sql delete mode 100644 kordophone-db/migrations/2025-05-26-232206_add_attachment_metadata_to_messages/up.sql delete mode 100644 kordophone-db/migrations/2025-05-26-234230_add_file_transfer_guids_to_messages/down.sql delete mode 100644 kordophone-db/migrations/2025-05-26-234230_add_file_transfer_guids_to_messages/up.sql delete mode 100644 kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/down.sql delete mode 100644 kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/up.sql rename kordophone-db/migrations/{2025-01-21-051154_create_conversations => 2025-06-26-233940_create_schema}/down.sql (88%) create mode 100644 kordophone-db/migrations/2025-06-26-233940_create_schema/up.sql diff --git a/kordophone-db/migrations/2025-01-21-051154_create_conversations/up.sql b/kordophone-db/migrations/2025-01-21-051154_create_conversations/up.sql deleted file mode 100644 index 69c3b8b..0000000 --- a/kordophone-db/migrations/2025-01-21-051154_create_conversations/up.sql +++ /dev/null @@ -1,34 +0,0 @@ --- Your SQL goes here -CREATE TABLE `conversation_participants`( - `conversation_id` TEXT NOT NULL, - `participant_id` INTEGER NOT NULL, - PRIMARY KEY(`conversation_id`, `participant_id`) -); - -CREATE TABLE `messages`( - `id` TEXT NOT NULL PRIMARY KEY, - `text` TEXT NOT NULL, - `sender_participant_id` INTEGER, - `date` TIMESTAMP NOT NULL -); - -CREATE TABLE `conversation_messages`( - `conversation_id` TEXT NOT NULL, - `message_id` TEXT NOT NULL, - PRIMARY KEY(`conversation_id`, `message_id`) -); - -CREATE TABLE `participants`( - `id` INTEGER NOT NULL PRIMARY KEY, - `display_name` TEXT, - `is_me` BOOL NOT NULL -); - -CREATE TABLE `conversations`( - `id` TEXT NOT NULL PRIMARY KEY, - `unread_count` BIGINT NOT NULL, - `display_name` TEXT, - `last_message_preview` TEXT, - `date` TIMESTAMP NOT NULL -); - diff --git a/kordophone-db/migrations/2025-04-25-223015_add_settings/down.sql b/kordophone-db/migrations/2025-04-25-223015_add_settings/down.sql deleted file mode 100644 index 0445954..0000000 --- a/kordophone-db/migrations/2025-04-25-223015_add_settings/down.sql +++ /dev/null @@ -1,7 +0,0 @@ --- This file should undo anything in `up.sql` - - - - - -DROP TABLE IF EXISTS `settings`; diff --git a/kordophone-db/migrations/2025-04-25-223015_add_settings/up.sql b/kordophone-db/migrations/2025-04-25-223015_add_settings/up.sql deleted file mode 100644 index 56e9cc6..0000000 --- a/kordophone-db/migrations/2025-04-25-223015_add_settings/up.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Your SQL goes here - - - - - -CREATE TABLE `settings`( - `key` TEXT NOT NULL PRIMARY KEY, - `value` BINARY NOT NULL -); - diff --git a/kordophone-db/migrations/2025-05-26-232206_add_attachment_metadata_to_messages/down.sql b/kordophone-db/migrations/2025-05-26-232206_add_attachment_metadata_to_messages/down.sql deleted file mode 100644 index 722f7e8..0000000 --- a/kordophone-db/migrations/2025-05-26-232206_add_attachment_metadata_to_messages/down.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Remove attachment_metadata column from messages table -ALTER TABLE messages DROP COLUMN attachment_metadata; \ No newline at end of file diff --git a/kordophone-db/migrations/2025-05-26-232206_add_attachment_metadata_to_messages/up.sql b/kordophone-db/migrations/2025-05-26-232206_add_attachment_metadata_to_messages/up.sql deleted file mode 100644 index 50d4826..0000000 --- a/kordophone-db/migrations/2025-05-26-232206_add_attachment_metadata_to_messages/up.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add attachment_metadata column to messages table -ALTER TABLE messages ADD COLUMN attachment_metadata TEXT; \ No newline at end of file diff --git a/kordophone-db/migrations/2025-05-26-234230_add_file_transfer_guids_to_messages/down.sql b/kordophone-db/migrations/2025-05-26-234230_add_file_transfer_guids_to_messages/down.sql deleted file mode 100644 index d58fd05..0000000 --- a/kordophone-db/migrations/2025-05-26-234230_add_file_transfer_guids_to_messages/down.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Remove file_transfer_guids column from messages table -ALTER TABLE messages DROP COLUMN file_transfer_guids; \ No newline at end of file diff --git a/kordophone-db/migrations/2025-05-26-234230_add_file_transfer_guids_to_messages/up.sql b/kordophone-db/migrations/2025-05-26-234230_add_file_transfer_guids_to_messages/up.sql deleted file mode 100644 index 6a244b7..0000000 --- a/kordophone-db/migrations/2025-05-26-234230_add_file_transfer_guids_to_messages/up.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add file_transfer_guids column to messages table -ALTER TABLE messages ADD COLUMN file_transfer_guids TEXT; \ No newline at end of file diff --git a/kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/down.sql b/kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/down.sql deleted file mode 100644 index a549445..0000000 --- a/kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/down.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Revert participants table to remove contact_id column --- SQLite does not support DROP COLUMN directly, so we recreate the table without contact_id -PRAGMA foreign_keys=off; -CREATE TABLE participants_backup ( - id INTEGER NOT NULL PRIMARY KEY, - display_name TEXT, - is_me BOOL NOT NULL -); -INSERT INTO participants_backup (id, display_name, is_me) - SELECT id, display_name, is_me - FROM participants; -DROP TABLE participants; -ALTER TABLE participants_backup RENAME TO participants; -PRAGMA foreign_keys=on; \ No newline at end of file diff --git a/kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/up.sql b/kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/up.sql deleted file mode 100644 index c72b96b..0000000 --- a/kordophone-db/migrations/2025-06-01-000000_add_contact_id_to_participants/up.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add contact_id column to participants to store an external contact identifier (e.g., Folks ID) -ALTER TABLE participants ADD COLUMN contact_id TEXT; \ No newline at end of file diff --git a/kordophone-db/migrations/2025-01-21-051154_create_conversations/down.sql b/kordophone-db/migrations/2025-06-26-233940_create_schema/down.sql similarity index 88% rename from kordophone-db/migrations/2025-01-21-051154_create_conversations/down.sql rename to kordophone-db/migrations/2025-06-26-233940_create_schema/down.sql index 34cefce..4740fab 100644 --- a/kordophone-db/migrations/2025-01-21-051154_create_conversations/down.sql +++ b/kordophone-db/migrations/2025-06-26-233940_create_schema/down.sql @@ -1,6 +1,7 @@ -- This file should undo anything in `up.sql` -DROP TABLE IF EXISTS `conversation_participants`; DROP TABLE IF EXISTS `messages`; DROP TABLE IF EXISTS `conversation_messages`; -DROP TABLE IF EXISTS `participants`; +DROP TABLE IF EXISTS `settings`; DROP TABLE IF EXISTS `conversations`; +DROP TABLE IF EXISTS `participants`; +DROP TABLE IF EXISTS `conversation_participants`; diff --git a/kordophone-db/migrations/2025-06-26-233940_create_schema/up.sql b/kordophone-db/migrations/2025-06-26-233940_create_schema/up.sql new file mode 100644 index 0000000..d1ba160 --- /dev/null +++ b/kordophone-db/migrations/2025-06-26-233940_create_schema/up.sql @@ -0,0 +1,46 @@ +-- Your SQL goes here +CREATE TABLE `messages`( + `id` TEXT NOT NULL PRIMARY KEY, + `text` TEXT NOT NULL, + `sender_participant_handle` TEXT, + `date` TIMESTAMP NOT NULL, + `file_transfer_guids` TEXT, + `attachment_metadata` TEXT, + FOREIGN KEY (`sender_participant_handle`) REFERENCES `participants`(`handle`) +); + +CREATE TABLE `conversation_messages`( + `conversation_id` TEXT NOT NULL, + `message_id` TEXT NOT NULL, + PRIMARY KEY(`conversation_id`, `message_id`), + FOREIGN KEY (`conversation_id`) REFERENCES `conversations`(`id`), + FOREIGN KEY (`message_id`) REFERENCES `messages`(`id`) +); + +CREATE TABLE `settings`( + `key` TEXT NOT NULL PRIMARY KEY, + `value` BINARY NOT NULL +); + +CREATE TABLE `conversations`( + `id` TEXT NOT NULL PRIMARY KEY, + `unread_count` BIGINT NOT NULL, + `display_name` TEXT, + `last_message_preview` TEXT, + `date` TIMESTAMP NOT NULL +); + +CREATE TABLE `participants`( + `handle` TEXT NOT NULL PRIMARY KEY, + `is_me` BOOL NOT NULL, + `contact_id` TEXT +); + +CREATE TABLE `conversation_participants`( + `conversation_id` TEXT NOT NULL, + `participant_handle` TEXT NOT NULL, + PRIMARY KEY(`conversation_id`, `participant_handle`), + FOREIGN KEY (`conversation_id`) REFERENCES `conversations`(`id`), + FOREIGN KEY (`participant_handle`) REFERENCES `participants`(`handle`) +); + diff --git a/kordophone-db/src/models/conversation.rs b/kordophone-db/src/models/conversation.rs index 2df10a7..4b06f75 100644 --- a/kordophone-db/src/models/conversation.rs +++ b/kordophone-db/src/models/conversation.rs @@ -75,7 +75,7 @@ impl From for Conversation { participants: value .participant_display_names .into_iter() - .map(|p| p.into()) + .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 9ca6e72..7821183 100644 --- a/kordophone-db/src/models/db/message.rs +++ b/kordophone-db/src/models/db/message.rs @@ -7,7 +7,7 @@ use diesel::prelude::*; #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct Record { pub id: String, - pub sender_participant_id: Option, + pub sender_participant_handle: Option, pub text: String, pub date: NaiveDateTime, pub file_transfer_guids: Option, // JSON array @@ -28,9 +28,9 @@ impl From for Record { Self { id: message.id, - sender_participant_id: match message.sender { + sender_participant_handle: match message.sender { Participant::Me => None, - Participant::Remote { id, .. } => id, + Participant::Remote { handle, .. } => Some(handle), }, text: message.text, date: message.date, @@ -51,10 +51,13 @@ impl From for Message { .attachment_metadata .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 }, + None => Participant::Me, + }; Self { id: record.id, - // We'll set the proper sender later when loading participant info - sender: Participant::Me, + sender: message_sender, text: record.text, date: record.date, file_transfer_guids, diff --git a/kordophone-db/src/models/db/participant.rs b/kordophone-db/src/models/db/participant.rs index 959f8a9..6b22917 100644 --- a/kordophone-db/src/models/db/participant.rs +++ b/kordophone-db/src/models/db/participant.rs @@ -4,9 +4,9 @@ use diesel::prelude::*; #[derive(Queryable, Selectable, AsChangeset, Identifiable)] #[diesel(table_name = crate::schema::participants)] +#[diesel(primary_key(handle))] pub struct Record { - pub id: i32, - pub display_name: Option, + pub handle: String, pub is_me: bool, pub contact_id: Option, } @@ -14,7 +14,7 @@ pub struct Record { #[derive(Insertable)] #[diesel(table_name = crate::schema::participants)] pub struct InsertableRecord { - pub display_name: Option, + pub handle: String, pub is_me: bool, pub contact_id: Option, } @@ -23,12 +23,12 @@ impl From for InsertableRecord { fn from(participant: Participant) -> Self { match participant { Participant::Me => InsertableRecord { - display_name: None, + handle: "me".to_string(), is_me: true, contact_id: None, }, - Participant::Remote { display_name, contact_id, .. } => InsertableRecord { - display_name: Some(display_name), + Participant::Remote { handle, contact_id, .. } => InsertableRecord { + handle, is_me: false, contact_id, }, @@ -38,12 +38,12 @@ impl From for InsertableRecord { #[derive(Identifiable, Selectable, Queryable, Associations, Debug)] #[diesel(belongs_to(super::conversation::Record, foreign_key = conversation_id))] -#[diesel(belongs_to(Record, foreign_key = participant_id))] +#[diesel(belongs_to(Record, foreign_key = participant_handle))] #[diesel(table_name = conversation_participants)] -#[diesel(primary_key(conversation_id, participant_id))] +#[diesel(primary_key(conversation_id, participant_handle))] pub struct ConversationParticipant { pub conversation_id: String, - pub participant_id: i32, + pub participant_handle: String, } impl From for Participant { @@ -52,8 +52,7 @@ impl From for Participant { Participant::Me } else { Participant::Remote { - id: Some(record.id), - display_name: record.display_name.unwrap_or_default(), + handle: record.handle.clone(), contact_id: record.contact_id, } } @@ -64,14 +63,12 @@ impl From for Record { fn from(participant: Participant) -> Self { match participant { Participant::Me => Record { - id: 0, // This will be set by the database - display_name: None, + handle: "me".to_string(), is_me: true, contact_id: None, }, - Participant::Remote { display_name, contact_id, .. } => Record { - id: 0, // This will be set by the database - display_name: Some(display_name), + 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 75d0cab..92ffba7 100644 --- a/kordophone-db/src/models/message.rs +++ b/kordophone-db/src/models/message.rs @@ -27,8 +27,7 @@ impl From for Message { id: value.guid, sender: match value.sender { Some(sender) => Participant::Remote { - id: None, - display_name: sender, + handle: sender, contact_id: None, }, None => Participant::Me, diff --git a/kordophone-db/src/models/mod.rs b/kordophone-db/src/models/mod.rs index 13571fc..a8c7e94 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; +pub use participant::Participant; \ No newline at end of file diff --git a/kordophone-db/src/models/participant.rs b/kordophone-db/src/models/participant.rs index ce9d37d..1024a5b 100644 --- a/kordophone-db/src/models/participant.rs +++ b/kordophone-db/src/models/participant.rs @@ -2,37 +2,21 @@ pub enum Participant { Me, Remote { - id: Option, - display_name: String, + handle: String, contact_id: Option, }, } -impl From for Participant { - fn from(display_name: String) -> Self { - Participant::Remote { - id: None, - display_name, - contact_id: None, - } - } -} - -impl From<&str> for Participant { - fn from(display_name: &str) -> Self { - Participant::Remote { - id: None, - display_name: display_name.to_string(), - contact_id: None, - } - } -} - impl Participant { - pub fn display_name(&self) -> String { + pub fn handle(&self) -> String { match self { Participant::Me => "(Me)".to_string(), - Participant::Remote { display_name, .. } => display_name.clone(), + Participant::Remote { handle, .. } => handle.clone(), } } + + // Temporary alias for backward compatibility + pub fn display_name(&self) -> String { + self.handle() + } } diff --git a/kordophone-db/src/repository.rs b/kordophone-db/src/repository.rs index b6787cb..9c52a99 100644 --- a/kordophone-db/src/repository.rs +++ b/kordophone-db/src/repository.rs @@ -36,21 +36,19 @@ impl<'a> Repository<'a> { .values(&db_conversation) .execute(self.connection)?; - diesel::replace_into(participants) - .values(&db_participants) - .execute(self.connection)?; + for participant in &db_participants { + diesel::insert_into(participants) + .values(participant) + .on_conflict_do_nothing() + .execute(self.connection)?; + } // Sqlite backend doesn't support batch insert, so we have to do this manually - for participant in db_participants { - let pid = participants - .select(schema::participants::id) - .filter(schema::participants::display_name.eq(&participant.display_name)) - .first::(self.connection)?; - + for participant in &db_participants { diesel::replace_into(conversation_participants) .values(( conversation_id.eq(&db_conversation.id), - participant_id.eq(pid), + participant_handle.eq(&participant.handle), )) .execute(self.connection)?; } @@ -117,7 +115,7 @@ impl<'a> Repository<'a> { // Handle participant if message has a remote sender let sender = message.sender.clone(); let mut db_message: MessageRecord = message.into(); - db_message.sender_participant_id = self.get_or_create_participant(&sender); + db_message.sender_participant_handle = self.get_or_create_participant(&sender); diesel::replace_into(messages) .values(&db_message) @@ -161,11 +159,11 @@ impl<'a> Repository<'a> { // individual queries. self.connection .transaction::<_, diesel::result::Error, _>(|conn| { - // Cache participant ids we have already looked up / created – in a + // Cache participant handles we have already looked up / created – in a // typical conversation we only have a handful of participants, but we // might be processing hundreds of messages. Avoiding an extra SELECT per // message saves a lot of round-trips to SQLite. - let mut participant_cache: HashMap = HashMap::new(); + let mut participant_cache: HashMap = HashMap::new(); // Prepare collections for the batch inserts. let mut db_messages: Vec = Vec::with_capacity(in_messages.len()); @@ -174,50 +172,40 @@ impl<'a> Repository<'a> { for message in in_messages { // Resolve/insert the sender participant only once per display name. - let sender_id = match &message.sender { + let sender_handle_opt = match &message.sender { Participant::Me => None, - Participant::Remote { display_name, .. } => { - if let Some(cached_participant_id) = participant_cache.get(display_name) - { - Some(*cached_participant_id) + Participant::Remote { handle, contact_id } => { + if participant_cache.contains_key(handle) { + Some(handle.clone()) } else { - // Try to load from DB first - let existing: Option = participants_dsl::participants - .filter(participants_dsl::display_name.eq(display_name)) - .select(participants_dsl::id) - .first::(conn) + // Ensure participant exists in DB + let exists: Option = participants_dsl::participants + .filter(participants_dsl::handle.eq(handle)) + .select(participants_dsl::handle) + .first::(conn) .optional()?; - let participant_id = if let Some(pid) = existing { - pid - } else { + if exists.is_none() { let new_participant = InsertableParticipantRecord { - display_name: Some(display_name.clone()), + handle: handle.clone(), is_me: false, - contact_id: None, + contact_id: contact_id.clone(), }; diesel::insert_into(participants_dsl::participants) .values(&new_participant) .execute(conn)?; + } - // last_insert_rowid() is connection-wide, but we are the only - // writer inside this transaction. - diesel::select(diesel::dsl::sql::( - "last_insert_rowid()", - )) - .get_result::(conn)? - }; - - participant_cache.insert(display_name.clone(), participant_id); - Some(participant_id) + participant_cache.insert(handle.clone(), handle.clone()); + Some(handle.clone()) } } }; // Convert the message into its DB form. let mut db_message: MessageRecord = message.into(); - db_message.sender_participant_id = sender_id; + db_message.sender_participant_handle = sender_handle_opt.clone(); conv_msg_records.push(InsertableConversationMessage { conversation_id: conversation_guid.to_string(), @@ -280,10 +268,10 @@ impl<'a> Repository<'a> { for message_record in message_records { let mut message: Message = message_record.clone().into(); - // If there's a sender_participant_id, load the participant info - if let Some(pid) = message_record.sender_participant_id { + // If the message references a sender participant, load the participant info + if let Some(sender_handle) = message_record.sender_participant_handle { let participant = participants - .find(pid) + .find(sender_handle) .first::(self.connection)?; message.sender = participant.into(); } @@ -374,50 +362,44 @@ impl<'a> Repository<'a> { /// Update the contact_id for an existing participant record. pub fn update_participant_contact( &mut self, - participant_db_id: i32, + participant_handle: &str, new_contact_id: &str, ) -> Result<()> { use crate::schema::participants::dsl::*; - log::debug!(target: target::REPOSITORY, "Updating participant contact id {} => {}", participant_db_id, new_contact_id); - diesel::update(participants.filter(id.eq(participant_db_id))) + log::debug!(target: target::REPOSITORY, "Updating participant contact {} => {}", participant_handle, new_contact_id); + diesel::update(participants.filter(handle.eq(participant_handle))) .set(contact_id.eq(Some(new_contact_id.to_string()))) .execute(self.connection)?; Ok(()) } - fn get_or_create_participant(&mut self, participant: &Participant) -> Option { + fn get_or_create_participant(&mut self, participant: &Participant) -> Option { match participant { Participant::Me => None, - Participant::Remote { - display_name: p_name, - contact_id: c_id, - .. - } => { + Participant::Remote { handle: p_handle, contact_id: c_id, .. } => { use crate::schema::participants::dsl::*; let existing_participant = participants - .filter(display_name.eq(p_name)) + .filter(handle.eq(p_handle)) .first::(self.connection) .optional() .unwrap(); - if let Some(participant) = existing_participant { - return Some(participant.id); + if existing_participant.is_none() { + let participant_record = InsertableParticipantRecord { + handle: p_handle.clone(), + is_me: false, + contact_id: c_id.clone(), + }; + + diesel::insert_into(participants) + .values(&participant_record) + .execute(self.connection) + .unwrap(); } - let participant_record = InsertableParticipantRecord { - display_name: Some(participant.display_name()), - is_me: false, - contact_id: c_id.clone(), - }; - - diesel::insert_into(participants) - .values(&participant_record) - .execute(self.connection) - .unwrap(); - - self.last_insert_id().ok() + Some(p_handle.clone()) } } } diff --git a/kordophone-db/src/schema.rs b/kordophone-db/src/schema.rs index 8f1f554..c2b41e5 100644 --- a/kordophone-db/src/schema.rs +++ b/kordophone-db/src/schema.rs @@ -12,18 +12,17 @@ diesel::table! { } diesel::table! { - participants (id) { - id -> Integer, - display_name -> Nullable, + participants (handle) { + handle -> Text, is_me -> Bool, contact_id -> Nullable, } } diesel::table! { - conversation_participants (conversation_id, participant_id) { + conversation_participants (conversation_id, participant_handle) { conversation_id -> Text, - participant_id -> Integer, + participant_handle -> Text, } } @@ -31,7 +30,7 @@ diesel::table! { messages (id) { id -> Text, // guid text -> Text, - sender_participant_id -> Nullable, + sender_participant_handle -> Nullable, date -> Timestamp, file_transfer_guids -> Nullable, // JSON array of file transfer GUIDs attachment_metadata -> Nullable, // JSON string of attachment metadata @@ -53,13 +52,15 @@ diesel::table! { } diesel::joinable!(conversation_participants -> conversations (conversation_id)); -diesel::joinable!(conversation_participants -> participants (participant_id)); +diesel::joinable!(conversation_participants -> participants (participant_handle)); +diesel::joinable!(messages -> participants (sender_participant_handle)); +diesel::joinable!(conversation_messages -> conversations (conversation_id)); +diesel::joinable!(conversation_messages -> messages (message_id)); diesel::allow_tables_to_appear_in_same_query!( conversations, participants, - conversation_participants + conversation_participants, + messages, + conversation_messages, + settings, ); - -diesel::joinable!(conversation_messages -> conversations (conversation_id)); -diesel::joinable!(conversation_messages -> messages (message_id)); -diesel::allow_tables_to_appear_in_same_query!(conversations, messages, conversation_messages); diff --git a/kordophone-db/src/tests/mod.rs b/kordophone-db/src/tests/mod.rs index 1ab6e86..f631fd3 100644 --- a/kordophone-db/src/tests/mod.rs +++ b/kordophone-db/src/tests/mod.rs @@ -12,14 +12,8 @@ fn participants_equal_ignoring_id(a: &Participant, b: &Participant) -> bool { match (a, b) { (Participant::Me, Participant::Me) => true, ( - Participant::Remote { - display_name: name_a, - .. - }, - Participant::Remote { - display_name: name_b, - .. - }, + Participant::Remote { handle: name_a, .. }, + Participant::Remote { handle: name_b, .. }, ) => name_a == name_b, _ => false, } @@ -29,9 +23,14 @@ fn participants_vec_equal_ignoring_id(a: &[Participant], b: &[Participant]) -> b if a.len() != b.len() { return false; } - a.iter() - .zip(b.iter()) - .all(|(a, b)| participants_equal_ignoring_id(a, b)) + // For each participant in a, check if there is a matching participant in b + a.iter().all(|a_participant| { + b.iter().any(|b_participant| participants_equal_ignoring_id(a_participant, b_participant)) + }) && + // Also check the reverse to ensure no duplicates + b.iter().all(|b_participant| { + a.iter().any(|a_participant| participants_equal_ignoring_id(b_participant, a_participant)) + }) } #[tokio::test] @@ -214,8 +213,8 @@ async fn test_messages() { // Verify second message (from Alice) let retrieved_message2 = messages.iter().find(|m| m.id == message2.id).unwrap(); assert_eq!(retrieved_message2.text, "Hi there!"); - if let Participant::Remote { display_name, .. } = &retrieved_message2.sender { - assert_eq!(display_name, "Alice"); + if let Participant::Remote { handle, .. } = &retrieved_message2.sender { + assert_eq!(handle, "Alice"); } else { panic!( "Expected Remote participant. Got: {:?}", @@ -345,14 +344,8 @@ async fn test_insert_messages_batch() { match (&original.sender, &retrieved.sender) { (Participant::Me, Participant::Me) => {} ( - Participant::Remote { - display_name: o_name, - .. - }, - Participant::Remote { - display_name: r_name, - .. - }, + Participant::Remote { handle: o_name, .. }, + Participant::Remote { handle: r_name, .. }, ) => assert_eq!(o_name, r_name), _ => panic!( "Sender mismatch: original {:?}, retrieved {:?}", diff --git a/kordophoned/src/daemon/contact_resolver/eds.rs b/kordophoned/src/daemon/contact_resolver/eds.rs index a913cf4..424bacb 100644 --- a/kordophoned/src/daemon/contact_resolver/eds.rs +++ b/kordophoned/src/daemon/contact_resolver/eds.rs @@ -4,7 +4,10 @@ use dbus::arg::{RefArg, Variant}; use once_cell::sync::OnceCell; use std::collections::HashMap; use std::time::Duration; +use std::sync::Mutex; +use std::thread; +#[derive(Clone)] pub struct EDSContactResolverBackend; // Cache the UID of the default local address book so we do not have to scan @@ -12,6 +15,42 @@ pub struct EDSContactResolverBackend; // D-Bus round-trip that we would rather avoid on every lookup. static ADDRESS_BOOK_SOURCE_UID: OnceCell = OnceCell::new(); +/// Holds a D-Bus connection and the identifiers needed to create an address-book proxy. +struct AddressBookHandle { + connection: Connection, + object_path: String, + bus_name: String, +} + +impl AddressBookHandle { + fn new() -> anyhow::Result { + let connection = new_session_connection()?; + let source_uid = ensure_address_book_uid(&connection)?; + let (object_path, bus_name) = open_address_book(&connection, &source_uid)?; + + Ok(Self { + connection, + object_path, + bus_name, + }) + } +} + +/// Obtain the global address-book handle, initialising it on the first call. +static ADDRESS_BOOK_HANDLE: OnceCell> = OnceCell::new(); + +fn get_address_book_handle() -> Option<&'static Mutex> { + ADDRESS_BOOK_HANDLE + .get_or_try_init(|| AddressBookHandle::new().map(Mutex::new)) + .map_err(|e| { + log::debug!( + "EDS resolver: failed to initialise address book handle: {}", + e + ); + }) + .ok() +} + /// Helper that returns a blocking D-Bus session connection. Creating the /// connection is cheap (<1 ms) but we still keep it around because the /// underlying socket is re-used by the dbus crate. @@ -109,51 +148,41 @@ impl ContactResolverBackend for EDSContactResolverBackend { type ContactID = String; fn resolve_contact_id(&self, address: &str) -> Option { - // Only email addresses are supported for now. We fall back to NONE on - // any error to keep the resolver infallible for callers. - let conn = match new_session_connection() { - Ok(c) => c, - Err(e) => { - log::debug!("EDS resolver: failed to open session D-Bus: {}", e); - return None; - } + let handle_mutex = match get_address_book_handle() { + Some(h) => h, + None => return None, }; - let source_uid = match ensure_address_book_uid(&conn) { - Ok(u) => u, - Err(e) => { - log::debug!("EDS resolver: could not determine address-book UID: {}", e); - return None; - } - }; - - let (object_path, bus_name) = match open_address_book(&conn, &source_uid) { - Ok(v) => v, - Err(e) => { - log::debug!("EDS resolver: failed to open address book: {}", e); - return None; - } - }; - - let address_book_proxy = conn.with_proxy(bus_name, object_path, Duration::from_secs(60)); + let handle = handle_mutex.lock().unwrap(); + let address_book_proxy = handle.connection.with_proxy( + &handle.bus_name, + &handle.object_path, + Duration::from_secs(60), + ); let filter = if address.contains('@') { format!("(is \"email\" \"{}\")", address) } else { - // Remove country code, if present - let address = address.replace("+", "") + let normalized_address = address + .replace('+', "") .chars() .skip_while(|c| c.is_numeric() || *c == '(' || *c == ')') - .collect::(); - - // Remove any remaining non-numeric characters - let address = address.chars() + .collect::() + .chars() .filter(|c| c.is_numeric()) .collect::(); - - format!("(is \"phone\" \"{}\")", address) + format!( + "(or (is \"phone\" \"{}\") (is \"phone\" \"{}\") )", + address, normalized_address + ) }; + log::trace!( + "EDS resolver: GetContactListUids filter: {}, address: {}", + filter, + address + ); + let uids_result: Result<(Vec,), _> = address_book_proxy.method_call( "org.gnome.evolution.dataserver.AddressBook", "GetContactListUids", @@ -172,31 +201,17 @@ impl ContactResolverBackend for EDSContactResolverBackend { } fn get_contact_display_name(&self, contact_id: &Self::ContactID) -> Option { - let conn = match new_session_connection() { - Ok(c) => c, - Err(e) => { - log::debug!("EDS resolver: failed to open session D-Bus: {}", e); - return None; - } + let handle_mutex = match get_address_book_handle() { + Some(h) => h, + None => return None, }; - let source_uid = match ensure_address_book_uid(&conn) { - Ok(u) => u, - Err(e) => { - log::debug!("EDS resolver: could not determine address-book UID: {}", e); - return None; - } - }; - - let (object_path, bus_name) = match open_address_book(&conn, &source_uid) { - Ok(v) => v, - Err(e) => { - log::debug!("EDS resolver: failed to open address book: {}", e); - return None; - } - }; - - let address_book_proxy = conn.with_proxy(bus_name, object_path, Duration::from_secs(60)); + let handle = handle_mutex.lock().unwrap(); + let address_book_proxy = handle.connection.with_proxy( + &handle.bus_name, + &handle.object_path, + Duration::from_secs(60), + ); let vcard_result: Result<(String,), _> = address_book_proxy.method_call( "org.gnome.evolution.dataserver.AddressBook", diff --git a/kordophoned/src/daemon/contact_resolver/mod.rs b/kordophoned/src/daemon/contact_resolver/mod.rs index 2271fae..f8dd1b8 100644 --- a/kordophoned/src/daemon/contact_resolver/mod.rs +++ b/kordophoned/src/daemon/contact_resolver/mod.rs @@ -10,6 +10,7 @@ pub trait ContactResolverBackend { pub type AnyContactID = String; +#[derive(Clone)] pub struct ContactResolver { backend: T, } diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 546424f..d9567b8 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -522,15 +522,15 @@ impl Daemon { .await? { for p in &saved.participants { - if let DbParticipant::Remote { id: Some(pid), display_name, contact_id: None } = p { - log::trace!(target: target::SYNC, "Resolving contact id for participant: {}", display_name); - if let Some(contact) = contact_resolver.resolve_contact_id(display_name) { + 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(*pid, &contact)) + .with_repository(|r| r.update_participant_contact(&handle, &contact)) .await; } else { - log::trace!(target: target::SYNC, "No contact id found for participant: {}", display_name); + log::trace!(target: target::SYNC, "No contact id found for participant: {}", handle); } } } diff --git a/kordophoned/src/daemon/models/message.rs b/kordophoned/src/daemon/models/message.rs index 7b68ae8..ead4f2a 100644 --- a/kordophoned/src/daemon/models/message.rs +++ b/kordophoned/src/daemon/models/message.rs @@ -5,14 +5,14 @@ use crate::daemon::attachment_store::AttachmentStore; use crate::daemon::models::Attachment; use kordophone::model::message::AttachmentMetadata; use kordophone::model::outgoing_message::OutgoingMessage; +use kordophone_db::models::participant::Participant as DbParticipant; use std::collections::HashMap; #[derive(Clone, Debug)] pub enum Participant { Me, Remote { - id: Option, - display_name: String, + handle: String, contact_id: Option, }, } @@ -20,8 +20,7 @@ pub enum Participant { impl From for Participant { fn from(display_name: String) -> Self { Participant::Remote { - id: None, - display_name, + handle: display_name, contact_id: None, } } @@ -30,8 +29,7 @@ impl From for Participant { impl From<&str> for Participant { fn from(display_name: &str) -> Self { Participant::Remote { - id: None, - display_name: display_name.to_string(), + handle: display_name.to_string(), contact_id: None, } } @@ -41,8 +39,8 @@ impl From for Participant { fn from(participant: kordophone_db::models::Participant) -> Self { match participant { kordophone_db::models::Participant::Me => Participant::Me, - kordophone_db::models::Participant::Remote { id, display_name, contact_id } => { - Participant::Remote { id, display_name, contact_id } + kordophone_db::models::Participant::Remote { handle, contact_id } => { + Participant::Remote { handle, contact_id } } } } @@ -52,7 +50,7 @@ impl Participant { pub fn display_name(&self) -> String { match self { Participant::Me => "(Me)".to_string(), - Participant::Remote { display_name, .. } => display_name.clone(), + Participant::Remote { handle, .. } => handle.clone(), } } } @@ -110,8 +108,8 @@ impl From for kordophone_db::models::Message { id: message.id, sender: match message.sender { Participant::Me => kordophone_db::models::Participant::Me, - Participant::Remote { id, display_name, contact_id } => { - kordophone_db::models::Participant::Remote { id, display_name, contact_id } + Participant::Remote { handle, contact_id } => { + kordophone_db::models::Participant::Remote { handle, contact_id } } }, text: message.text, @@ -146,8 +144,7 @@ impl From for Message { id: message.guid, sender: match message.sender { Some(sender) => Participant::Remote { - id: None, - display_name: sender, + handle: sender, contact_id: None, }, None => Participant::Me, @@ -175,3 +172,12 @@ impl From<&OutgoingMessage> for Message { } } } + +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() }, + } + } +} \ No newline at end of file diff --git a/kordophoned/src/dbus/agent.rs b/kordophoned/src/dbus/agent.rs index e8ecc60..755dbfd 100644 --- a/kordophoned/src/dbus/agent.rs +++ b/kordophoned/src/dbus/agent.rs @@ -23,6 +23,7 @@ use dbus_tokio::connection; pub struct DBusAgent { event_sink: mpsc::Sender, signal_receiver: Arc>>>, + contact_resolver: ContactResolver, } impl DBusAgent { @@ -30,6 +31,7 @@ impl DBusAgent { Self { event_sink, signal_receiver: Arc::new(Mutex::new(Some(signal_receiver))), + contact_resolver: ContactResolver::new(EDSContactResolverBackend::default()), } } @@ -172,19 +174,18 @@ impl DBusAgent { } fn resolve_participant_display_name(&self, participant: &Participant) -> String { - let resolver = ContactResolver::new(EDSContactResolverBackend::default()); match participant { // Me (we should use a special string here...) Participant::Me => "(Me)".to_string(), // Remote participant with a resolved contact_id - Participant::Remote { display_name, contact_id: Some(contact_id), .. } => { - resolver.get_contact_display_name(contact_id).unwrap_or_else(|| display_name.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 { display_name, .. } => { - display_name.clone() + Participant::Remote { handle, .. } => { + handle.clone() } } } @@ -278,6 +279,8 @@ impl DbusRepository for DBusAgent { // Remove the attachment placeholder here. let text = msg.text.replace("\u{FFFC}", ""); + log::debug!("sender: {:?}", msg.sender.clone()); + map.insert("text".into(), arg::Variant(Box::new(text))); map.insert( "date".into(), @@ -285,9 +288,11 @@ impl DbusRepository for DBusAgent { ); map.insert( "sender".into(), - arg::Variant(Box::new(msg.sender.display_name())), + arg::Variant(Box::new(self.resolve_participant_display_name(&msg.sender.into()))), ); + + // Attachments array let attachments: Vec = msg .attachments From 5a399cc6cafd5a9f193cd6afaf355a492225eb4f Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 26 Jun 2025 18:32:53 -0700 Subject: [PATCH 094/138] weird: need to filter out bidi control characters from sender handles from server --- kordophone-db/src/models/message.rs | 31 ++++++++++++++++++++++------- kordophoned/src/dbus/agent.rs | 4 ---- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/kordophone-db/src/models/message.rs b/kordophone-db/src/models/message.rs index 92ffba7..4de27e2 100644 --- a/kordophone-db/src/models/message.rs +++ b/kordophone-db/src/models/message.rs @@ -23,15 +23,32 @@ impl Message { impl From for Message { fn from(value: kordophone::model::Message) -> Self { + let sender_participant = match value.sender { + Some(sender) => Participant::Remote { + 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 + '\u{202B}' | // RLE + '\u{202C}' | // PDF + '\u{202D}' | // LRO + '\u{202E}' | // RLO + '\u{2066}' | // LRI + '\u{2067}' | // RLI + '\u{2068}' | // FSI + '\u{2069}' // PDI + )) + .collect::(), + }, + + None => Participant::Me, + }; + Self { id: value.guid, - sender: match value.sender { - Some(sender) => Participant::Remote { - handle: sender, - contact_id: None, - }, - None => Participant::Me, - }, + sender: sender_participant, text: value.text, date: DateTime::from_timestamp( value.date.unix_timestamp(), diff --git a/kordophoned/src/dbus/agent.rs b/kordophoned/src/dbus/agent.rs index 755dbfd..81e17d6 100644 --- a/kordophoned/src/dbus/agent.rs +++ b/kordophoned/src/dbus/agent.rs @@ -279,8 +279,6 @@ impl DbusRepository for DBusAgent { // Remove the attachment placeholder here. let text = msg.text.replace("\u{FFFC}", ""); - log::debug!("sender: {:?}", msg.sender.clone()); - map.insert("text".into(), arg::Variant(Box::new(text))); map.insert( "date".into(), @@ -291,8 +289,6 @@ impl DbusRepository for DBusAgent { arg::Variant(Box::new(self.resolve_participant_display_name(&msg.sender.into()))), ); - - // Attachments array let attachments: Vec = msg .attachments From e73cf321c0879063459c3f82c03272bbb9dcadf3 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 26 Jun 2025 18:37:23 -0700 Subject: [PATCH 095/138] Add normalization for eds resolver --- kordophoned/src/daemon/contact_resolver/eds.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/kordophoned/src/daemon/contact_resolver/eds.rs b/kordophoned/src/daemon/contact_resolver/eds.rs index 424bacb..ceb9fc5 100644 --- a/kordophoned/src/daemon/contact_resolver/eds.rs +++ b/kordophoned/src/daemon/contact_resolver/eds.rs @@ -164,6 +164,11 @@ impl ContactResolverBackend for EDSContactResolverBackend { format!("(is \"email\" \"{}\")", address) } else { let normalized_address = address + .chars() + .filter(|c| c.is_numeric()) + .collect::(); + + let local_address = address .replace('+', "") .chars() .skip_while(|c| c.is_numeric() || *c == '(' || *c == ')') @@ -171,9 +176,10 @@ impl ContactResolverBackend for EDSContactResolverBackend { .chars() .filter(|c| c.is_numeric()) .collect::(); + format!( - "(or (is \"phone\" \"{}\") (is \"phone\" \"{}\") )", - address, normalized_address + "(or (is \"phone\" \"{}\") (is \"phone\" \"{}\") (is \"phone\" \"{}\"))", + address, normalized_address, local_address ) }; From 9e3e6dc66f6af6eb97ce1cbcdddd2d2b913825d9 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 26 Jun 2025 18:50:58 -0700 Subject: [PATCH 096/138] ContactResolver: implement in-memory cache for positive results --- .../src/daemon/contact_resolver/mod.rs | 32 ++++++++++++++++--- kordophoned/src/daemon/mod.rs | 2 +- kordophoned/src/dbus/agent.rs | 2 +- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/kordophoned/src/daemon/contact_resolver/mod.rs b/kordophoned/src/daemon/contact_resolver/mod.rs index f8dd1b8..555aed2 100644 --- a/kordophoned/src/daemon/contact_resolver/mod.rs +++ b/kordophoned/src/daemon/contact_resolver/mod.rs @@ -1,6 +1,8 @@ pub mod eds; pub use eds::EDSContactResolverBackend; +use std::collections::HashMap; + pub trait ContactResolverBackend { type ContactID; @@ -13,6 +15,8 @@ pub type AnyContactID = String; #[derive(Clone)] pub struct ContactResolver { backend: T, + display_name_cache: HashMap, + contact_id_cache: HashMap, } impl ContactResolver @@ -22,16 +26,34 @@ where T: Default, { pub fn new(backend: T) -> Self { - Self { backend } + Self { backend, display_name_cache: HashMap::new(), contact_id_cache: HashMap::new() } } - pub fn resolve_contact_id(&self, address: &str) -> Option { - self.backend.resolve_contact_id(address).map(|id| id.into()) + pub fn resolve_contact_id(&mut self, address: &str) -> Option { + if let Some(id) = self.contact_id_cache.get(address) { + return Some(id.clone()); + } + + 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()); + } + + id } - pub fn get_contact_display_name(&self, contact_id: &AnyContactID) -> Option { + pub fn get_contact_display_name(&mut self, contact_id: &AnyContactID) -> Option { + if let Some(display_name) = self.display_name_cache.get(contact_id) { + return Some(display_name.clone()); + } + let backend_contact_id: T::ContactID = T::ContactID::from((*contact_id).clone()); - self.backend.get_contact_display_name(&backend_contact_id) + 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()); + } + + display_name } } diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index d9567b8..3498e81 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -507,7 +507,7 @@ impl Daemon { // Insert each conversation let num_conversations = db_conversations.len(); - let contact_resolver = ContactResolver::new(EDSContactResolverBackend::default()); + let mut contact_resolver = ContactResolver::new(EDSContactResolverBackend::default()); for conversation in db_conversations { // Insert or update conversation and its participants database diff --git a/kordophoned/src/dbus/agent.rs b/kordophoned/src/dbus/agent.rs index 81e17d6..fcb71af 100644 --- a/kordophoned/src/dbus/agent.rs +++ b/kordophoned/src/dbus/agent.rs @@ -173,7 +173,7 @@ impl DBusAgent { .map_err(|e| MethodErr::failed(&format!("Daemon error: {}", e))) } - fn resolve_participant_display_name(&self, participant: &Participant) -> String { + 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(), From b043ff6f0894bbc00f0a0b82b1a93d4f2e0baf7d Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 26 Jun 2025 20:44:24 -0700 Subject: [PATCH 097/138] eds: still not able to resolve sometimes, some AI generated attempts at solving --- .../src/daemon/contact_resolver/eds.rs | 65 +++++++++++++++---- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/kordophoned/src/daemon/contact_resolver/eds.rs b/kordophoned/src/daemon/contact_resolver/eds.rs index ceb9fc5..06abc40 100644 --- a/kordophoned/src/daemon/contact_resolver/eds.rs +++ b/kordophoned/src/daemon/contact_resolver/eds.rs @@ -5,7 +5,6 @@ use once_cell::sync::OnceCell; use std::collections::HashMap; use std::time::Duration; use std::sync::Mutex; -use std::thread; #[derive(Clone)] pub struct EDSContactResolverBackend; @@ -39,16 +38,40 @@ impl AddressBookHandle { /// Obtain the global address-book handle, initialising it on the first call. static ADDRESS_BOOK_HANDLE: OnceCell> = OnceCell::new(); -fn get_address_book_handle() -> Option<&'static Mutex> { - ADDRESS_BOOK_HANDLE +/// 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(),)); + result.map(|(b,)| b).unwrap_or(false) +} + +/// Returns a fresh handle, ensuring the cached one is still valid. If the backend owning the +/// address-book disappeared, the cache is cleared and we try to create a new handle. +fn obtain_handle() -> Option> { + // Initialize cell if necessary. + let cell = ADDRESS_BOOK_HANDLE .get_or_try_init(|| AddressBookHandle::new().map(Mutex::new)) - .map_err(|e| { - log::debug!( - "EDS resolver: failed to initialise address book handle: {}", - e - ); - }) - .ok() + .ok()?; + + // Validate existing handle. + { + let mut guard = cell.lock().ok()?; + if !name_has_owner(&guard.connection, &guard.bus_name) { + // Try to refresh the handle in-place. + match AddressBookHandle::new() { + Ok(new_h) => { + *guard = new_h; + } + Err(e) => { + log::debug!("EDS resolver: failed to refresh address book handle: {}", e); + // keep the stale handle but report failure + return None; + } + } + } + // Return guard after ensuring validity. + return Some(guard); + } } /// Helper that returns a blocking D-Bus session connection. Creating the @@ -144,22 +167,35 @@ fn open_address_book( Ok((object_path, bus_name)) } +/// Ensure that the backend for the given address-book proxy is opened. +/// Evolution-Data-Server returns "Backend is not opened yet" until someone +/// 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", + (), + ); +} + impl ContactResolverBackend for EDSContactResolverBackend { type ContactID = String; fn resolve_contact_id(&self, address: &str) -> Option { - let handle_mutex = match get_address_book_handle() { + let handle = match obtain_handle() { Some(h) => h, None => return None, }; - let handle = handle_mutex.lock().unwrap(); let address_book_proxy = handle.connection.with_proxy( &handle.bus_name, &handle.object_path, Duration::from_secs(60), ); + ensure_address_book_open(&address_book_proxy); + let filter = if address.contains('@') { format!("(is \"email\" \"{}\")", address) } else { @@ -207,18 +243,19 @@ impl ContactResolverBackend for EDSContactResolverBackend { } fn get_contact_display_name(&self, contact_id: &Self::ContactID) -> Option { - let handle_mutex = match get_address_book_handle() { + let handle = match obtain_handle() { Some(h) => h, None => return None, }; - let handle = handle_mutex.lock().unwrap(); let address_book_proxy = handle.connection.with_proxy( &handle.bus_name, &handle.object_path, Duration::from_secs(60), ); + ensure_address_book_open(&address_book_proxy); + let vcard_result: Result<(String,), _> = address_book_proxy.method_call( "org.gnome.evolution.dataserver.AddressBook", "GetContact", From 6e14585a12cb2d6bbf1beb8ce32c72e467ff4ce0 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 27 Jun 2025 00:48:20 -0700 Subject: [PATCH 098/138] EDS: Found the issue where address book sometimes doesn't load -v The wrong source was getting selected. Not sure if this one is always a decoy, there might be others that we aren't supposed to use. Happy that it's working now though. --- .../src/daemon/contact_resolver/eds.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/kordophoned/src/daemon/contact_resolver/eds.rs b/kordophoned/src/daemon/contact_resolver/eds.rs index 06abc40..42e2baf 100644 --- a/kordophoned/src/daemon/contact_resolver/eds.rs +++ b/kordophoned/src/daemon/contact_resolver/eds.rs @@ -114,6 +114,11 @@ fn ensure_address_book_uid(conn: &Connection) -> anyhow::Result { .filter_map(|ifaces| ifaces.get("org.gnome.evolution.dataserver.Source")) .filter_map(|props| { let uid = props.get("UID")?.as_str()?; + if uid == "system-address-book" { + // Decoy. + return None; + } + let data = props.get("Data")?.as_str()?; if data_contains_address_book_backend(data) { Some(uid.to_owned()) @@ -199,11 +204,16 @@ impl ContactResolverBackend for EDSContactResolverBackend { let filter = if address.contains('@') { format!("(is \"email\" \"{}\")", address) } else { + let mut filters: Vec = Vec::new(); + filters.push(format!("(is \"phone\" \"{}\")", address)); + let normalized_address = address .chars() .filter(|c| c.is_numeric()) .collect::(); + filters.push(format!("(is \"phone\" \"{}\")", normalized_address)); + let local_address = address .replace('+', "") .chars() @@ -213,10 +223,11 @@ impl ContactResolverBackend for EDSContactResolverBackend { .filter(|c| c.is_numeric()) .collect::(); - format!( - "(or (is \"phone\" \"{}\") (is \"phone\" \"{}\") (is \"phone\" \"{}\"))", - address, normalized_address, local_address - ) + if !local_address.is_empty() { + filters.push(format!("(is \"phone\" \"{}\")", local_address)); + } + + format!("(or {})", filters.join(" ")) }; log::trace!( From 21703b9f8ecf816ba7a52efcfb7dd4024e40599b Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 27 Jun 2025 00:52:09 -0700 Subject: [PATCH 099/138] AttachmentStore: less chatty logging --- kordophoned/src/daemon/attachment_store.rs | 10 +++++----- kordophoned/src/daemon/mod.rs | 4 ++-- kordophoned/src/daemon/update_monitor.rs | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/kordophoned/src/daemon/attachment_store.rs b/kordophoned/src/daemon/attachment_store.rs index 8158c6a..c699503 100644 --- a/kordophoned/src/daemon/attachment_store.rs +++ b/kordophoned/src/daemon/attachment_store.rs @@ -120,17 +120,17 @@ impl AttachmentStore { let attachment = Self::get_attachment_impl(store_path, guid); if attachment.is_downloaded(preview) { - log::info!(target: target::ATTACHMENTS, "Attachment already downloaded: {}", attachment.guid); + log::debug!(target: target::ATTACHMENTS, "Attachment already downloaded: {}", attachment.guid); return Err(AttachmentStoreError::AttachmentAlreadyDownloaded.into()); } let temporary_path = attachment.get_path_for_preview_scratch(preview, true); if std::fs::exists(&temporary_path).unwrap_or(false) { - log::info!(target: target::ATTACHMENTS, "Temporary file already exists: {}, assuming download is in progress", temporary_path.display()); + log::warn!(target: target::ATTACHMENTS, "Temporary file already exists: {}, assuming download is in progress", temporary_path.display()); return Err(AttachmentStoreError::DownloadAlreadyInProgress.into()); } - log::info!(target: target::ATTACHMENTS, "Starting download for attachment: {}", attachment.guid); + log::debug!(target: target::ATTACHMENTS, "Starting download for attachment: {}", attachment.guid); let file = std::fs::File::create(&temporary_path)?; let mut writer = BufWriter::new(&file); @@ -155,7 +155,7 @@ impl AttachmentStore { &attachment.get_path_for_preview_scratch(preview, false), )?; - log::info!(target: target::ATTACHMENTS, "Completed download for attachment: {}", attachment.guid); + log::debug!(target: target::ATTACHMENTS, "Completed download for attachment: {}", attachment.guid); // Send a signal to the daemon that the attachment has been downloaded. let event = DaemonEvent::AttachmentDownloaded(attachment.guid.clone()); @@ -240,7 +240,7 @@ impl AttachmentStore { log::debug!(target: target::ATTACHMENTS, "Queued download for attachment: {}", &guid); } else { - log::info!(target: target::ATTACHMENTS, "Attachment already downloaded: {}", guid); + log::debug!(target: target::ATTACHMENTS, "Attachment already downloaded: {}", guid); } } diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 3498e81..5f0daf3 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -374,7 +374,7 @@ impl Daemon { } Event::DownloadAttachment(attachment_id, preview, reply) => { - log::info!(target: target::ATTACHMENTS, "Download requested for attachment: {}, preview: {}", &attachment_id, preview); + log::debug!(target: target::ATTACHMENTS, "Download requested for attachment: {}, preview: {}", &attachment_id, preview); self.attachment_store_sink .as_ref() @@ -390,7 +390,7 @@ impl Daemon { } Event::AttachmentDownloaded(attachment_id) => { - log::info!(target: target::ATTACHMENTS, "Daemon: attachment downloaded: {}, sending signal", attachment_id); + log::debug!(target: target::ATTACHMENTS, "Daemon: attachment downloaded: {}, sending signal", attachment_id); // Send signal to the client that the attachment has been downloaded. self.signal_sender diff --git a/kordophoned/src/daemon/update_monitor.rs b/kordophoned/src/daemon/update_monitor.rs index 329954b..c8be792 100644 --- a/kordophoned/src/daemon/update_monitor.rs +++ b/kordophoned/src/daemon/update_monitor.rs @@ -78,7 +78,7 @@ impl UpdateMonitor { // imagent will post a conversation changed notification when we call getMessages. if let Some(last_sync) = self.last_sync_times.get(&conversation.guid) { if last_sync.elapsed() < Duration::from_secs(1) { - log::info!(target: target::UPDATES, "Skipping sync for conversation id: {}. Last sync was {} seconds ago.", + log::warn!(target: target::UPDATES, "Skipping sync for conversation id: {}. Last sync was {} seconds ago.", conversation.guid, last_sync.elapsed().as_secs_f64()); return; } @@ -93,7 +93,7 @@ impl UpdateMonitor { match (&last_message, &conversation.last_message) { (Some(message), Some(conversation_message)) => { if message.id == conversation_message.guid { - log::info!(target: target::UPDATES, "Skipping sync for conversation id: {}. We already have this message.", &conversation.guid); + log::warn!(target: target::UPDATES, "Skipping sync for conversation id: {}. We already have this message.", &conversation.guid); return; } } From 3197814098c937dc1bbb7fd3ca81937a07be87b5 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Tue, 15 Jul 2025 16:39:57 -0700 Subject: [PATCH 100/138] Implement hybrid versioning approach --- .claude/settings.local.json | 6 +++++- kordophoned/build.rs | 33 +++++++++++++++++++++++++++++++++ kordophoned/src/daemon/mod.rs | 2 +- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6535198..861e025 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,11 @@ "allow": [ "Bash(find:*)", "Bash(cargo build:*)", - "Bash(diesel migration generate:*)" + "Bash(diesel migration generate:*)", + "Bash(cargo clean:*)", + "Bash(git describe:*)", + "Bash(git add:*)", + "Bash(git commit:*)" ], "deny": [] } diff --git a/kordophoned/build.rs b/kordophoned/build.rs index ec36f2c..09dc74c 100644 --- a/kordophoned/build.rs +++ b/kordophoned/build.rs @@ -1,6 +1,7 @@ const KORDOPHONE_XML: &str = "include/net.buzzert.kordophonecd.Server.xml"; fn main() { + // Generate D-Bus code let out_dir = std::env::var("OUT_DIR").unwrap(); let out_path = std::path::Path::new(&out_dir).join("kordophone-server.rs"); @@ -19,4 +20,36 @@ fn main() { std::fs::write(out_path, output).expect("Error writing server dbus code"); println!("cargo:rerun-if-changed={}", KORDOPHONE_XML); + + // Create hybrid version: use Cargo.toml version as base, augment with git info + let cargo_version = env!("CARGO_PKG_VERSION"); + + let final_version = if let Ok(output) = std::process::Command::new("git") + .args(&["describe", "--tags", "--always", "--dirty"]) + .output() + { + let git_desc = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + // Check if we're on a clean tag that matches the cargo version + if git_desc == format!("v{}", cargo_version) || git_desc == cargo_version { + // Clean release build - just use cargo version + cargo_version.to_string() + } else { + // Development build - append git info + if git_desc.contains("-dirty") { + format!("{}-dev-{}", cargo_version, git_desc) + } else if git_desc.starts_with("v") && git_desc.contains(&format!("v{}", cargo_version)) { + // We're N commits ahead of the tag + format!("{}-dev-{}", cargo_version, git_desc.strip_prefix("v").unwrap_or(&git_desc)) + } else { + // Fallback: just append the git description + format!("{}-dev-{}", cargo_version, git_desc) + } + } + } else { + // Git not available - just use cargo version + cargo_version.to_string() + }; + + println!("cargo:rustc-env=GIT_VERSION={}", final_version); } diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 5f0daf3..b84a4af 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -116,7 +116,7 @@ impl Daemon { let database = Arc::new(Mutex::new(database_impl)); Ok(Self { - version: "0.1.0".to_string(), + version: env!("GIT_VERSION").to_string(), database, event_receiver, event_sender, From 742703cb8e00555696fab4103bfe2a8adedbe95a Mon Sep 17 00:00:00 2001 From: James Magahern Date: Tue, 15 Jul 2025 18:04:11 -0700 Subject: [PATCH 101/138] Version: 1.0.0 --- .claude/settings.local.json | 4 +++- Cargo.lock | 6 +++--- kordophone-db/Cargo.toml | 2 +- kordophone/Cargo.toml | 2 +- kordophoned/Cargo.toml | 2 +- kordophoned/build.rs | 32 -------------------------------- kordophoned/src/daemon/mod.rs | 2 +- 7 files changed, 10 insertions(+), 40 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 861e025..1a8fb56 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,9 @@ "Bash(cargo clean:*)", "Bash(git describe:*)", "Bash(git add:*)", - "Bash(git commit:*)" + "Bash(git commit:*)", + "Bash(git tag:*)", + "Bash(git stash:*)" ], "deny": [] } diff --git a/Cargo.lock b/Cargo.lock index e7241ca..6c8786d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1001,7 +1001,7 @@ dependencies = [ [[package]] name = "kordophone" -version = "0.1.0" +version = "1.0.0" dependencies = [ "async-trait", "base64", @@ -1027,7 +1027,7 @@ dependencies = [ [[package]] name = "kordophone-db" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "async-trait", @@ -1046,7 +1046,7 @@ dependencies = [ [[package]] name = "kordophoned" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "async-trait", diff --git a/kordophone-db/Cargo.toml b/kordophone-db/Cargo.toml index 446eafe..5ae10ea 100644 --- a/kordophone-db/Cargo.toml +++ b/kordophone-db/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kordophone-db" -version = "0.1.0" +version = "1.0.0" edition = "2021" [dependencies] diff --git a/kordophone/Cargo.toml b/kordophone/Cargo.toml index 81d060a..209b469 100644 --- a/kordophone/Cargo.toml +++ b/kordophone/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kordophone" -version = "0.1.0" +version = "1.0.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/kordophoned/Cargo.toml b/kordophoned/Cargo.toml index 51e88b2..e32e7ee 100644 --- a/kordophoned/Cargo.toml +++ b/kordophoned/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kordophoned" -version = "0.1.0" +version = "1.0.0" edition = "2021" [dependencies] diff --git a/kordophoned/build.rs b/kordophoned/build.rs index 09dc74c..a674f93 100644 --- a/kordophoned/build.rs +++ b/kordophoned/build.rs @@ -20,36 +20,4 @@ fn main() { std::fs::write(out_path, output).expect("Error writing server dbus code"); println!("cargo:rerun-if-changed={}", KORDOPHONE_XML); - - // Create hybrid version: use Cargo.toml version as base, augment with git info - let cargo_version = env!("CARGO_PKG_VERSION"); - - let final_version = if let Ok(output) = std::process::Command::new("git") - .args(&["describe", "--tags", "--always", "--dirty"]) - .output() - { - let git_desc = String::from_utf8_lossy(&output.stdout).trim().to_string(); - - // Check if we're on a clean tag that matches the cargo version - if git_desc == format!("v{}", cargo_version) || git_desc == cargo_version { - // Clean release build - just use cargo version - cargo_version.to_string() - } else { - // Development build - append git info - if git_desc.contains("-dirty") { - format!("{}-dev-{}", cargo_version, git_desc) - } else if git_desc.starts_with("v") && git_desc.contains(&format!("v{}", cargo_version)) { - // We're N commits ahead of the tag - format!("{}-dev-{}", cargo_version, git_desc.strip_prefix("v").unwrap_or(&git_desc)) - } else { - // Fallback: just append the git description - format!("{}-dev-{}", cargo_version, git_desc) - } - } - } else { - // Git not available - just use cargo version - cargo_version.to_string() - }; - - println!("cargo:rustc-env=GIT_VERSION={}", final_version); } diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index b84a4af..6ac25eb 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -116,7 +116,7 @@ impl Daemon { let database = Arc::new(Mutex::new(database_impl)); Ok(Self { - version: env!("GIT_VERSION").to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), database, event_receiver, event_sender, From 8115f941212371e89ab6a33692a5ecd357901988 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 31 Jul 2025 19:16:44 -0700 Subject: [PATCH 102/138] kordophoned sans kpcli building on macos --- kordophoned/Cargo.toml | 14 +++++--- kordophoned/build.rs | 10 ++++-- kordophoned/src/daemon/auth_store.rs | 6 ++++ .../src/daemon/contact_resolver/generic.rs | 16 ++++++++++ .../src/daemon/contact_resolver/mod.rs | 32 ++++++++++++++++++- kordophoned/src/daemon/mod.rs | 4 +-- kordophoned/src/dbus/agent.rs | 6 ++-- kordophoned/src/main.rs | 32 +++++++++++++++---- kpcli/Cargo.toml | 10 ++++-- kpcli/build.rs | 13 ++++++-- kpcli/src/main.rs | 4 ++- 11 files changed, 120 insertions(+), 27 deletions(-) create mode 100644 kordophoned/src/daemon/contact_resolver/generic.rs diff --git a/kordophoned/Cargo.toml b/kordophoned/Cargo.toml index e32e7ee..a596c66 100644 --- a/kordophoned/Cargo.toml +++ b/kordophoned/Cargo.toml @@ -7,10 +7,6 @@ edition = "2021" anyhow = "1.0.98" async-trait = "0.1.88" chrono = "0.4.38" -dbus = { version = "0.9.7", features = ["futures"] } -dbus-crossroads = "0.5.2" -dbus-tokio = "0.7.6" -dbus-tree = "0.9.2" directories = "6.0.0" env_logger = "0.11.6" futures-util = "0.3.31" @@ -25,6 +21,14 @@ tokio-condvar = "0.3.0" uuid = "1.16.0" once_cell = "1.19.0" -[build-dependencies] +[target.'cfg(target_os = "linux")'.dependencies] +# D-Bus dependencies only on Linux +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 dbus-codegen = "0.10.0" dbus-crossroads = "0.5.1" diff --git a/kordophoned/build.rs b/kordophoned/build.rs index a674f93..921915b 100644 --- a/kordophoned/build.rs +++ b/kordophoned/build.rs @@ -1,5 +1,11 @@ const KORDOPHONE_XML: &str = "include/net.buzzert.kordophonecd.Server.xml"; +#[cfg(not(target_os = "linux"))] +fn main() { + // No D-Bus code generation on non-Linux platforms +} + +#[cfg(target_os = "linux")] fn main() { // Generate D-Bus code let out_dir = std::env::var("OUT_DIR").unwrap(); @@ -14,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/auth_store.rs b/kordophoned/src/daemon/auth_store.rs index 35a789a..077e502 100644 --- a/kordophoned/src/daemon/auth_store.rs +++ b/kordophoned/src/daemon/auth_store.rs @@ -21,6 +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; @@ -61,6 +62,11 @@ 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() diff --git a/kordophoned/src/daemon/contact_resolver/generic.rs b/kordophoned/src/daemon/contact_resolver/generic.rs new file mode 100644 index 0000000..d3a3604 --- /dev/null +++ b/kordophoned/src/daemon/contact_resolver/generic.rs @@ -0,0 +1,16 @@ +use super::ContactResolverBackend; + +#[derive(Clone, Default)] +pub struct GenericContactResolverBackend; + +impl ContactResolverBackend for GenericContactResolverBackend { + type ContactID = String; + + fn resolve_contact_id(&self, address: &str) -> Option { + None + } + + 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 555aed2..dce3fe3 100644 --- a/kordophoned/src/daemon/contact_resolver/mod.rs +++ b/kordophoned/src/daemon/contact_resolver/mod.rs @@ -1,5 +1,35 @@ +#[cfg(target_os = "linux")] pub mod eds; -pub use eds::EDSContactResolverBackend; + +pub mod generic; + +// Convenient alias for the platform's default backend +#[cfg(target_os = "linux")] +pub type DefaultContactResolverBackend = eds::EDSContactResolverBackend; +#[cfg(not(target_os = "linux"))] +pub type DefaultContactResolverBackend = generic::GenericContactResolverBackend; + +#[cfg(not(target_os = "linux"))] +#[derive(Clone)] +pub struct EDSContactResolverBackend; + +#[cfg(not(target_os = "linux"))] +impl Default for EDSContactResolverBackend { + fn default() -> Self { EDSContactResolverBackend } +} + +#[cfg(not(target_os = "linux"))] +impl ContactResolverBackend for EDSContactResolverBackend { + type ContactID = String; + + fn resolve_contact_id(&self, _address: &str) -> Option { + None + } + + fn get_contact_display_name(&self, _contact_id: &Self::ContactID) -> Option { + None + } +} use std::collections::HashMap; diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 6ac25eb..e9466af 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -51,7 +51,7 @@ pub use attachment_store::AttachmentStoreEvent; pub mod contact_resolver; use contact_resolver::ContactResolver; -use contact_resolver::EDSContactResolverBackend; +use contact_resolver::DefaultContactResolverBackend; use kordophone_db::models::participant::Participant as DbParticipant; @@ -507,7 +507,7 @@ impl Daemon { // Insert each conversation let num_conversations = db_conversations.len(); - let mut contact_resolver = ContactResolver::new(EDSContactResolverBackend::default()); + let mut contact_resolver = ContactResolver::new(DefaultContactResolverBackend::default()); for conversation in db_conversations { // Insert or update conversation and its participants database diff --git a/kordophoned/src/dbus/agent.rs b/kordophoned/src/dbus/agent.rs index fcb71af..bf18163 100644 --- a/kordophoned/src/dbus/agent.rs +++ b/kordophoned/src/dbus/agent.rs @@ -9,7 +9,7 @@ use crate::daemon::{ settings::Settings, signals::Signal, DaemonResult, - contact_resolver::{ContactResolver, EDSContactResolverBackend}, + contact_resolver::{ContactResolver, DefaultContactResolverBackend}, }; use kordophone_db::models::participant::Participant; @@ -23,7 +23,7 @@ use dbus_tokio::connection; pub struct DBusAgent { event_sink: mpsc::Sender, signal_receiver: Arc>>>, - contact_resolver: ContactResolver, + contact_resolver: ContactResolver, } impl DBusAgent { @@ -31,7 +31,7 @@ impl DBusAgent { Self { event_sink, signal_receiver: Arc::new(Mutex::new(Some(signal_receiver))), - contact_resolver: ContactResolver::new(EDSContactResolverBackend::default()), + contact_resolver: ContactResolver::new(DefaultContactResolverBackend::default()), } } diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index 43a1b29..eeb968c 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -1,4 +1,6 @@ mod daemon; + +#[cfg(target_os = "linux")] mod dbus; use log::LevelFilter; @@ -6,8 +8,6 @@ use std::future; use daemon::Daemon; -use dbus::agent::DBusAgent; - fn initialize_logging() { // Weird: is this the best way to do this? let log_level = std::env::var("RUST_LOG") @@ -20,6 +20,27 @@ fn initialize_logging() { .init(); } +#[cfg(target_os = "linux")] +async fn start_ipc_agent(daemon: &Daemon) { + use dbus::agent::DBusAgent; + + // Start the D-Bus agent (events in, signals out). + let agent = DBusAgent::new(daemon.event_sender.clone(), daemon.obtain_signal_receiver()); + tokio::spawn(async move { + agent.run().await; + }); +} + +#[cfg(target_os = "macos")] +async fn start_ipc_agent(daemon: &Daemon) { + // TODO: Implement macOS IPC agent. +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +async fn start_ipc_agent(daemon: &Daemon) { + panic!("Unsupported IPC platform"); +} + #[tokio::main] async fn main() { initialize_logging(); @@ -32,11 +53,8 @@ async fn main() { }) .unwrap(); - // Start the D-Bus agent (events in, signals out). - let agent = DBusAgent::new(daemon.event_sender.clone(), daemon.obtain_signal_receiver()); - tokio::spawn(async move { - agent.run().await; - }); + // Start the IPC agent. + start_ipc_agent(&daemon).await; // Run the main daemon loop. daemon.run().await; diff --git a/kpcli/Cargo.toml b/kpcli/Cargo.toml index c83e9c3..4719106 100644 --- a/kpcli/Cargo.toml +++ b/kpcli/Cargo.toml @@ -8,8 +8,6 @@ edition = "2021" [dependencies] anyhow = "1.0.93" clap = { version = "4.5.20", features = ["derive"] } -dbus = "0.9.7" -dbus-tree = "0.9.2" dotenv = "0.15.0" env_logger = "0.11.8" futures-util = "0.3.31" @@ -22,5 +20,11 @@ serde_json = "1.0" time = "0.3.37" tokio = "1.41.1" -[build-dependencies] +# D-Bus dependencies only on Linux +[target.'cfg(target_os = "linux")'.dependencies] +dbus = "0.9.7" +dbus-tree = "0.9.2" + +# D-Bus codegen only on Linux +[target.'cfg(target_os = "linux")'.build-dependencies] dbus-codegen = "0.10.0" diff --git a/kpcli/build.rs b/kpcli/build.rs index 9254308..307f48d 100644 --- a/kpcli/build.rs +++ b/kpcli/build.rs @@ -1,5 +1,11 @@ const KORDOPHONE_XML: &str = "../kordophoned/include/net.buzzert.kordophonecd.Server.xml"; +#[cfg(not(target_os = "linux"))] +fn main() { + // No D-Bus codegen on non-Linux platforms +} + +#[cfg(target_os = "linux")] fn main() { let out_dir = std::env::var("OUT_DIR").unwrap(); let out_path = std::path::Path::new(&out_dir).join("kordophone-client.rs"); @@ -10,10 +16,11 @@ 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/main.rs b/kpcli/src/main.rs index a485c1a..cc30164 100644 --- a/kpcli/src/main.rs +++ b/kpcli/src/main.rs @@ -1,8 +1,10 @@ mod client; -mod daemon; mod db; mod printers; +#[cfg(target_os = "linux")] +mod daemon; + use anyhow::Result; use clap::{Parser, Subcommand}; use log::LevelFilter; From 0e034898b23f0c44f3dd85ff588cfeca3d5e16e5 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 31 Jul 2025 19:19:29 -0700 Subject: [PATCH 103/138] kpcli fix stage 1 --- kpcli/src/main.rs | 2 -- kpcli/src/printers.rs | 11 ++++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/kpcli/src/main.rs b/kpcli/src/main.rs index cc30164..19157f1 100644 --- a/kpcli/src/main.rs +++ b/kpcli/src/main.rs @@ -1,8 +1,6 @@ mod client; mod db; mod printers; - -#[cfg(target_os = "linux")] mod daemon; use anyhow::Result; diff --git a/kpcli/src/printers.rs b/kpcli/src/printers.rs index 6289419..5a5440c 100644 --- a/kpcli/src/printers.rs +++ b/kpcli/src/printers.rs @@ -1,4 +1,3 @@ -use dbus::arg::{self, RefArg}; use kordophone::model::message::AttachmentMetadata; use pretty::RcDoc; use std::collections::HashMap; @@ -44,8 +43,9 @@ impl From for PrintableConversation { } } -impl From for PrintableConversation { - fn from(value: arg::PropMap) -> Self { +#[cfg(target_os = "linux")] +impl From for PrintableConversation { + fn from(value: dbus::arg::PropMap) -> Self { Self { guid: value.get("guid").unwrap().as_str().unwrap().to_string(), date: OffsetDateTime::from_unix_timestamp(value.get("date").unwrap().as_i64().unwrap()) @@ -114,8 +114,9 @@ impl From for PrintableMessage { } } -impl From for PrintableMessage { - fn from(value: arg::PropMap) -> Self { +#[cfg(target_os = "linux")] +impl From for PrintableMessage { + fn from(value: dbus::arg::PropMap) -> Self { // Parse file transfer GUIDs from JSON if present let file_transfer_guids = value .get("file_transfer_guids") From c7d620c1b5542cadf91d4bbcba14ff5a63fcef92 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 31 Jul 2025 19:30:54 -0700 Subject: [PATCH 104/138] kpcli: finish separation of daemon interface --- Cargo.lock | 1 + kpcli/Cargo.toml | 1 + kpcli/src/daemon/dbus.rs | 212 ++++++++++++++++++++++++++++ kpcli/src/daemon/mod.rs | 292 ++++++++++++--------------------------- 4 files changed, 299 insertions(+), 207 deletions(-) create mode 100644 kpcli/src/daemon/dbus.rs diff --git a/Cargo.lock b/Cargo.lock index 6c8786d..fcf5c89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1076,6 +1076,7 @@ name = "kpcli" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "clap 4.5.20", "dbus", "dbus-codegen", diff --git a/kpcli/Cargo.toml b/kpcli/Cargo.toml index 4719106..82f1774 100644 --- a/kpcli/Cargo.toml +++ b/kpcli/Cargo.toml @@ -19,6 +19,7 @@ prettytable = "0.10.0" serde_json = "1.0" time = "0.3.37" tokio = "1.41.1" +async-trait = "0.1.80" # D-Bus dependencies only on Linux [target.'cfg(target_os = "linux")'.dependencies] diff --git a/kpcli/src/daemon/dbus.rs b/kpcli/src/daemon/dbus.rs new file mode 100644 index 0000000..96d1178 --- /dev/null +++ b/kpcli/src/daemon/dbus.rs @@ -0,0 +1,212 @@ +//! Linux-only D-Bus implementation of the `DaemonInterface`. +#![cfg(target_os = "linux")] + +use super::{ConfigCommands, DaemonInterface}; +use crate::printers::{ConversationPrinter, MessagePrinter}; +use anyhow::Result; +use async_trait::async_trait; +use dbus::blocking::{Connection, Proxy}; +use prettytable::table; + +const DBUS_NAME: &str = "net.buzzert.kordophonecd"; +const DBUS_PATH: &str = "/net/buzzert/kordophonecd/daemon"; + +#[allow(unused)] +mod dbus_interface { + #![allow(unused)] + include!(concat!(env!("OUT_DIR"), "/kordophone-client.rs")); +} + +use dbus_interface::NetBuzzertKordophoneRepository as KordophoneRepository; +use dbus_interface::NetBuzzertKordophoneSettings as KordophoneSettings; + +pub struct DBusDaemonInterface { + conn: Connection, +} + +impl DBusDaemonInterface { + pub fn new() -> Result { + Ok(Self { + conn: Connection::new_session()?, + }) + } + + fn proxy(&self) -> Proxy<&Connection> { + self.conn + .with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000)) + } + + async fn print_settings(&mut self) -> Result<()> { + let server_url = KordophoneSettings::server_url(&self.proxy()).unwrap_or_default(); + let username = KordophoneSettings::username(&self.proxy()).unwrap_or_default(); + + let table = table!([ + b->"Server URL", &server_url + ], [ + b->"Username", &username + ]); + table.printstd(); + Ok(()) + } + + async fn set_server_url(&mut self, url: String) -> Result<()> { + KordophoneSettings::set_server_url(&self.proxy(), url) + .map_err(|e| anyhow::anyhow!("Failed to set server URL: {}", e)) + } + + async fn set_username(&mut self, username: String) -> Result<()> { + KordophoneSettings::set_username(&self.proxy(), username) + .map_err(|e| anyhow::anyhow!("Failed to set username: {}", e)) + } +} + +#[async_trait] +impl DaemonInterface for DBusDaemonInterface { + async fn print_version(&mut self) -> Result<()> { + let version = KordophoneRepository::get_version(&self.proxy())?; + println!("Server version: {}", version); + Ok(()) + } + + async fn print_conversations(&mut self) -> Result<()> { + let conversations = KordophoneRepository::get_conversations(&self.proxy(), 100, 0)?; + println!("Number of conversations: {}", conversations.len()); + for conversation in conversations { + println!("{}", ConversationPrinter::new(&conversation.into())); + } + Ok(()) + } + + async fn sync_conversations(&mut self, conversation_id: Option) -> Result<()> { + if let Some(conversation_id) = conversation_id { + KordophoneRepository::sync_conversation(&self.proxy(), &conversation_id) + .map_err(|e| anyhow::anyhow!("Failed to sync conversation: {}", e)) + } else { + KordophoneRepository::sync_all_conversations(&self.proxy()) + .map_err(|e| anyhow::anyhow!("Failed to sync conversations: {}", e)) + } + } + + async fn sync_conversations_list(&mut self) -> Result<()> { + KordophoneRepository::sync_conversation_list(&self.proxy()) + .map_err(|e| anyhow::anyhow!("Failed to sync conversations: {}", e)) + } + + async fn print_messages( + &mut self, + conversation_id: String, + last_message_id: Option, + ) -> Result<()> { + let messages = KordophoneRepository::get_messages( + &self.proxy(), + &conversation_id, + &last_message_id.unwrap_or_default(), + )?; + println!("Number of messages: {}", messages.len()); + for message in messages { + println!("{}", MessagePrinter::new(&message.into())); + } + Ok(()) + } + + async fn enqueue_outgoing_message( + &mut self, + conversation_id: String, + text: String, + ) -> Result<()> { + let attachment_guids: Vec<&str> = vec![]; + let outgoing_message_id = KordophoneRepository::send_message( + &self.proxy(), + &conversation_id, + &text, + attachment_guids, + )?; + println!("Outgoing message ID: {}", outgoing_message_id); + Ok(()) + } + + async fn wait_for_signals(&mut self) -> Result<()> { + use dbus::Message; + mod dbus_signals { + pub use super::dbus_interface::NetBuzzertKordophoneRepositoryConversationsUpdated as ConversationsUpdated; + } + + let _id = self.proxy().match_signal( + |_: dbus_signals::ConversationsUpdated, _: &Connection, _: &Message| { + println!("Signal: Conversations updated"); + true + }, + ); + + println!("Waiting for signals..."); + loop { + self.conn.process(std::time::Duration::from_millis(1000))?; + } + } + + async fn config(&mut self, cmd: ConfigCommands) -> Result<()> { + match cmd { + ConfigCommands::Print => self.print_settings().await, + ConfigCommands::SetServerUrl { url } => self.set_server_url(url).await, + ConfigCommands::SetUsername { username } => self.set_username(username).await, + } + } + + async fn delete_all_conversations(&mut self) -> Result<()> { + KordophoneRepository::delete_all_conversations(&self.proxy()) + .map_err(|e| anyhow::anyhow!("Failed to delete all conversations: {}", e)) + } + + async fn download_attachment(&mut self, attachment_id: String) -> Result<()> { + // Trigger download. + KordophoneRepository::download_attachment(&self.proxy(), &attachment_id, false)?; + + // Get attachment info. + let attachment_info = + KordophoneRepository::get_attachment_info(&self.proxy(), &attachment_id)?; + let (path, _preview_path, downloaded, _preview_downloaded) = attachment_info; + + if downloaded { + println!("Attachment already downloaded: {}", path); + return Ok(()); + } + + println!("Downloading attachment: {}", attachment_id); + + // Attach to the signal that the attachment has been downloaded. + let download_path = path.clone(); + let _id = self.proxy().match_signal( + move |_: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadCompleted, + _: &Connection, + _: &dbus::message::Message| { + println!("Signal: Attachment downloaded: {}", download_path); + std::process::exit(0); + }, + ); + + let _id = self.proxy().match_signal( + |h: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadFailed, + _: &Connection, + _: &dbus::message::Message| { + println!("Signal: Attachment download failed: {}", h.attachment_id); + std::process::exit(1); + }, + ); + + // Wait for the signal. + loop { + self.conn.process(std::time::Duration::from_millis(1000))?; + } + } + + async fn upload_attachment(&mut self, path: String) -> Result<()> { + let upload_guid = KordophoneRepository::upload_attachment(&self.proxy(), &path)?; + println!("Upload GUID: {}", upload_guid); + Ok(()) + } + + async fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()> { + KordophoneRepository::mark_conversation_as_read(&self.proxy(), &conversation_id) + .map_err(|e| anyhow::anyhow!("Failed to mark conversation as read: {}", e)) + } +} diff --git a/kpcli/src/daemon/mod.rs b/kpcli/src/daemon/mod.rs index e154bde..19d2491 100644 --- a/kpcli/src/daemon/mod.rs +++ b/kpcli/src/daemon/mod.rs @@ -1,19 +1,92 @@ -use crate::printers::{ConversationPrinter, MessagePrinter}; use anyhow::Result; +use async_trait::async_trait; use clap::Subcommand; -use dbus::blocking::{Connection, Proxy}; -use prettytable::table; -const DBUS_NAME: &str = "net.buzzert.kordophonecd"; -const DBUS_PATH: &str = "/net/buzzert/kordophonecd/daemon"; +// Platform-specific modules +#[cfg(target_os = "linux")] +mod dbus; -mod dbus_interface { - #![allow(unused)] - include!(concat!(env!("OUT_DIR"), "/kordophone-client.rs")); +#[async_trait] +pub trait DaemonInterface { + async fn print_version(&mut self) -> Result<()>; + async fn print_conversations(&mut self) -> Result<()>; + async fn sync_conversations(&mut self, conversation_id: Option) -> Result<()>; + async fn sync_conversations_list(&mut self) -> Result<()>; + async fn print_messages( + &mut self, + conversation_id: String, + last_message_id: Option, + ) -> Result<()>; + async fn enqueue_outgoing_message( + &mut self, + conversation_id: String, + text: String, + ) -> Result<()>; + async fn wait_for_signals(&mut self) -> Result<()>; + async fn config(&mut self, cmd: ConfigCommands) -> Result<()>; + async fn delete_all_conversations(&mut self) -> Result<()>; + async fn download_attachment(&mut self, attachment_id: String) -> Result<()>; + async fn upload_attachment(&mut self, path: String) -> Result<()>; + async fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()>; } -use dbus_interface::NetBuzzertKordophoneRepository as KordophoneRepository; -use dbus_interface::NetBuzzertKordophoneSettings as KordophoneSettings; +struct StubDaemonInterface; +impl StubDaemonInterface { + fn new() -> Result { + Ok(Self) + } +} + +#[async_trait] +impl DaemonInterface for StubDaemonInterface { + async fn print_version(&mut self) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn print_conversations(&mut self) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn sync_conversations(&mut self, _conversation_id: Option) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn sync_conversations_list(&mut self) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn print_messages(&mut self, _conversation_id: String, _last_message_id: Option) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn enqueue_outgoing_message(&mut self, _conversation_id: String, _text: String) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn wait_for_signals(&mut self) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn config(&mut self, _cmd: ConfigCommands) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn delete_all_conversations(&mut self) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn download_attachment(&mut self, _attachment_id: String) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn upload_attachment(&mut self, _path: String) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } + async fn mark_conversation_as_read(&mut self, _conversation_id: String) -> Result<()> { + Err(anyhow::anyhow!("Daemon interface not implemented on this platform")) + } +} + +pub fn new_daemon_interface() -> Result> { + #[cfg(target_os = "linux")] + { + Ok(Box::new(dbus::DBusDaemonInterface::new()?)) + } + #[cfg(not(target_os = "linux"))] + { + Ok(Box::new(StubDaemonInterface::new()?)) + } +} #[derive(Subcommand)] pub enum Commands { @@ -77,7 +150,7 @@ pub enum ConfigCommands { impl Commands { pub async fn run(cmd: Commands) -> Result<()> { - let mut client = DaemonCli::new()?; + let mut client = new_daemon_interface()?; match cmd { Commands::Version => client.print_version().await, Commands::Conversations => client.print_conversations().await, @@ -89,9 +162,7 @@ impl Commands { conversation_id, last_message_id, } => { - client - .print_messages(conversation_id, last_message_id) - .await + client.print_messages(conversation_id, last_message_id).await } Commands::DeleteAllConversations => client.delete_all_conversations().await, Commands::SendMessage { @@ -108,196 +179,3 @@ impl Commands { } } } - -struct DaemonCli { - conn: Connection, -} - -impl DaemonCli { - pub fn new() -> Result { - Ok(Self { - conn: Connection::new_session()?, - }) - } - - fn proxy(&self) -> Proxy<&Connection> { - self.conn - .with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000)) - } - - pub async fn print_version(&mut self) -> Result<()> { - let version = KordophoneRepository::get_version(&self.proxy())?; - println!("Server version: {}", version); - Ok(()) - } - - pub async fn print_conversations(&mut self) -> Result<()> { - let conversations = KordophoneRepository::get_conversations(&self.proxy(), 100, 0)?; - println!("Number of conversations: {}", conversations.len()); - - for conversation in conversations { - println!("{}", ConversationPrinter::new(&conversation.into())); - } - - Ok(()) - } - - pub async fn sync_conversations(&mut self, conversation_id: Option) -> Result<()> { - if let Some(conversation_id) = conversation_id { - KordophoneRepository::sync_conversation(&self.proxy(), &conversation_id) - .map_err(|e| anyhow::anyhow!("Failed to sync conversation: {}", e)) - } else { - KordophoneRepository::sync_all_conversations(&self.proxy()) - .map_err(|e| anyhow::anyhow!("Failed to sync conversations: {}", e)) - } - } - - pub async fn sync_conversations_list(&mut self) -> Result<()> { - KordophoneRepository::sync_conversation_list(&self.proxy()) - .map_err(|e| anyhow::anyhow!("Failed to sync conversations: {}", e)) - } - - pub async fn print_messages( - &mut self, - conversation_id: String, - last_message_id: Option, - ) -> Result<()> { - let messages = KordophoneRepository::get_messages( - &self.proxy(), - &conversation_id, - &last_message_id.unwrap_or_default(), - )?; - println!("Number of messages: {}", messages.len()); - - for message in messages { - println!("{}", MessagePrinter::new(&message.into())); - } - - Ok(()) - } - - pub async fn enqueue_outgoing_message( - &mut self, - conversation_id: String, - text: String, - ) -> Result<()> { - let attachment_guids: Vec<&str> = vec![]; - let outgoing_message_id = KordophoneRepository::send_message( - &self.proxy(), - &conversation_id, - &text, - attachment_guids, - )?; - println!("Outgoing message ID: {}", outgoing_message_id); - Ok(()) - } - - pub async fn wait_for_signals(&mut self) -> Result<()> { - use dbus::Message; - mod dbus_signals { - pub use super::dbus_interface::NetBuzzertKordophoneRepositoryConversationsUpdated as ConversationsUpdated; - } - - let _id = self.proxy().match_signal( - |h: dbus_signals::ConversationsUpdated, _: &Connection, _: &Message| { - println!("Signal: Conversations updated"); - true - }, - ); - - println!("Waiting for signals..."); - loop { - self.conn.process(std::time::Duration::from_millis(1000))?; - } - } - - pub async fn config(&mut self, cmd: ConfigCommands) -> Result<()> { - match cmd { - ConfigCommands::Print => self.print_settings().await, - ConfigCommands::SetServerUrl { url } => self.set_server_url(url).await, - ConfigCommands::SetUsername { username } => self.set_username(username).await, - } - } - - pub async fn print_settings(&mut self) -> Result<()> { - let server_url = KordophoneSettings::server_url(&self.proxy()).unwrap_or_default(); - let username = KordophoneSettings::username(&self.proxy()).unwrap_or_default(); - - let table = table!( - [ b->"Server URL", &server_url ], - [ b->"Username", &username ] - ); - table.printstd(); - - Ok(()) - } - - pub async fn set_server_url(&mut self, url: String) -> Result<()> { - KordophoneSettings::set_server_url(&self.proxy(), url) - .map_err(|e| anyhow::anyhow!("Failed to set server URL: {}", e)) - } - - pub async fn set_username(&mut self, username: String) -> Result<()> { - KordophoneSettings::set_username(&self.proxy(), username) - .map_err(|e| anyhow::anyhow!("Failed to set username: {}", e)) - } - - pub async fn delete_all_conversations(&mut self) -> Result<()> { - KordophoneRepository::delete_all_conversations(&self.proxy()) - .map_err(|e| anyhow::anyhow!("Failed to delete all conversations: {}", e)) - } - - pub async fn download_attachment(&mut self, attachment_id: String) -> Result<()> { - // Trigger download. - KordophoneRepository::download_attachment(&self.proxy(), &attachment_id, false)?; - - // Get attachment info. - let attachment_info = - KordophoneRepository::get_attachment_info(&self.proxy(), &attachment_id)?; - let (path, preview_path, downloaded, preview_downloaded) = attachment_info; - - if downloaded { - println!("Attachment already downloaded: {}", path); - return Ok(()); - } - - println!("Downloading attachment: {}", attachment_id); - - // Attach to the signal that the attachment has been downloaded. - let _id = self.proxy().match_signal( - move |h: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadCompleted, - _: &Connection, - _: &dbus::message::Message| { - println!("Signal: Attachment downloaded: {}", path); - std::process::exit(0); - }, - ); - - let _id = self.proxy().match_signal( - |h: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadFailed, - _: &Connection, - _: &dbus::message::Message| { - println!("Signal: Attachment download failed: {}", h.attachment_id); - std::process::exit(1); - }, - ); - - // Wait for the signal. - loop { - self.conn.process(std::time::Duration::from_millis(1000))?; - } - - Ok(()) - } - - pub async fn upload_attachment(&mut self, path: String) -> Result<()> { - let upload_guid = KordophoneRepository::upload_attachment(&self.proxy(), &path)?; - println!("Upload GUID: {}", upload_guid); - Ok(()) - } - - pub async fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()> { - KordophoneRepository::mark_conversation_as_read(&self.proxy(), &conversation_id) - .map_err(|e| anyhow::anyhow!("Failed to mark conversation as read: {}", e)) - } -} From 43b668e9a254b9f9fde664b7dae04795ffdbfd52 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 31 Jul 2025 19:40:03 -0700 Subject: [PATCH 105/138] Fix linux build --- kordophoned/src/main.rs | 8 ++++---- kpcli/src/main.rs | 2 +- kpcli/src/printers.rs | 3 +++ 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/kordophoned/src/main.rs b/kordophoned/src/main.rs index eeb968c..db30aff 100644 --- a/kordophoned/src/main.rs +++ b/kordophoned/src/main.rs @@ -21,7 +21,7 @@ fn initialize_logging() { } #[cfg(target_os = "linux")] -async fn start_ipc_agent(daemon: &Daemon) { +async fn start_ipc_agent(daemon: &mut Daemon) { use dbus::agent::DBusAgent; // Start the D-Bus agent (events in, signals out). @@ -32,12 +32,12 @@ async fn start_ipc_agent(daemon: &Daemon) { } #[cfg(target_os = "macos")] -async fn start_ipc_agent(daemon: &Daemon) { +async fn start_ipc_agent(daemon: &mut Daemon) { // TODO: Implement macOS IPC agent. } #[cfg(not(any(target_os = "linux", target_os = "macos")))] -async fn start_ipc_agent(daemon: &Daemon) { +async fn start_ipc_agent(daemon: &mut Daemon) { panic!("Unsupported IPC platform"); } @@ -54,7 +54,7 @@ async fn main() { .unwrap(); // Start the IPC agent. - start_ipc_agent(&daemon).await; + start_ipc_agent(&mut daemon).await; // Run the main daemon loop. daemon.run().await; diff --git a/kpcli/src/main.rs b/kpcli/src/main.rs index 19157f1..a485c1a 100644 --- a/kpcli/src/main.rs +++ b/kpcli/src/main.rs @@ -1,7 +1,7 @@ mod client; +mod daemon; mod db; mod printers; -mod daemon; use anyhow::Result; use clap::{Parser, Subcommand}; diff --git a/kpcli/src/printers.rs b/kpcli/src/printers.rs index 5a5440c..06e07eb 100644 --- a/kpcli/src/printers.rs +++ b/kpcli/src/printers.rs @@ -4,6 +4,9 @@ use std::collections::HashMap; use std::fmt::Display; use time::OffsetDateTime; +#[cfg(target_os = "linux")] +use dbus::arg::{self, RefArg}; + pub struct PrintableConversation { pub guid: String, pub date: OffsetDateTime, From 911454aafbfc1f633ace160afaa766294a3cbc3a Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 1 Aug 2025 12:26:17 -0700 Subject: [PATCH 106/138] 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 8cdcb049cffebe51a1f9ca54108307548aaac1bc Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 8 Aug 2025 11:55:47 -0700 Subject: [PATCH 107/138] rpm packaging, includes systemd service --- .gitignore | 2 +- kordophoned/Cargo.toml | 12 +++++++++++- kordophoned/README.md | 15 +++++++++++++++ .../include/net.buzzert.kordophonecd.service | 4 ++++ 4 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 kordophoned/README.md create mode 100644 kordophoned/include/net.buzzert.kordophonecd.service diff --git a/.gitignore b/.gitignore index ea8c4bf..b60de5b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -/target +**/target diff --git a/kordophoned/Cargo.toml b/kordophoned/Cargo.toml index a596c66..248ead3 100644 --- a/kordophoned/Cargo.toml +++ b/kordophoned/Cargo.toml @@ -1,7 +1,9 @@ [package] name = "kordophoned" -version = "1.0.0" +version = "1.0.1" edition = "2021" +license = "GPL-3.0" +description = "Client daemon for the Kordophone chat protocol" [dependencies] anyhow = "1.0.98" @@ -32,3 +34,11 @@ dbus-tree = "0.9.2" # D-Bus codegen only on Linux dbus-codegen = "0.10.0" dbus-crossroads = "0.5.1" + + +[package.metadata.generate-rpm] +assets = [ + { source = "../target/release/kordophoned", dest = "/usr/libexec/kordophoned", mode = "755" }, + { source = "../target/release/kpcli", dest = "/usr/bin/kpcli", mode = "755" }, + { source = "include/net.buzzert.kordophonecd.service", dest = "/usr/share/dbus-1/services/net.buzzert.kordophonecd.service", mode = "644" }, +] diff --git a/kordophoned/README.md b/kordophoned/README.md new file mode 100644 index 0000000..5de870f --- /dev/null +++ b/kordophoned/README.md @@ -0,0 +1,15 @@ +# kordophoned + +This is the client Kordophone daemon. It exposes a dbus interface for accessing the caching layer, handles the update cycle, etc. + +# Building RPM + +Make sure cargo-generate-rpm is installed, `cargo install cargo-generate-rpm`. + +Then: + +``` +cargo build --release +strip -s target/release/kordophoned +cargo generate-rpm +``` diff --git a/kordophoned/include/net.buzzert.kordophonecd.service b/kordophoned/include/net.buzzert.kordophonecd.service new file mode 100644 index 0000000..b0e7309 --- /dev/null +++ b/kordophoned/include/net.buzzert.kordophonecd.service @@ -0,0 +1,4 @@ +[D-BUS Service] +Name=net.buzzert.kordophonecd +Exec=/usr/libexec/kordophoned + From 201982170f22d88c512e6a9e4eb42c2e646c52e5 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 8 Aug 2025 15:42:21 -0700 Subject: [PATCH 108/138] Adds makefile and dockerfile for building rpms --- Cargo.lock | 2 +- Dockerfile | 26 ++++++++++++++++++++++++++ Makefile | 16 ++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 Dockerfile create mode 100644 Makefile diff --git a/Cargo.lock b/Cargo.lock index fcf5c89..5b96d86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1046,7 +1046,7 @@ dependencies = [ [[package]] name = "kordophoned" -version = "1.0.0" +version = "1.0.1" dependencies = [ "anyhow", "async-trait", diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..19840b8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM fedora:40 + +RUN dnf update -y && \ + dnf install -y \ + curl \ + gcc \ + gcc-c++ \ + make \ + openssl-devel \ + sqlite-devel \ + dbus-devel \ + systemd-devel \ + rpm-build \ + && dnf clean all + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +ENV PATH="/root/.cargo/bin:${PATH}" + +RUN cargo install cargo-generate-rpm + +WORKDIR /workspace + +COPY . . + +CMD ["make", "rpm"] + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a3936f0 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ + +.PHONY: all +all: + cargo build + +.PHONY: release +release: + cargo build --release + +.PHONY: rpm +rpm: + cargo build --release --workspace + strip -s target/release/kordophoned + strip -s target/release/kpcli + cargo generate-rpm -p kordophoned + From e9bda39d8a20d62f7a6e2257e659567dd2124f5c Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 10 Aug 2025 21:48:44 -0700 Subject: [PATCH 109/138] 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 110/138] 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 111/138] 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 112/138] 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 113/138] 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 114/138] 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 115/138] 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 116/138] 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 117/138] 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 118/138] 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 119/138] 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 120/138] 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 121/138] 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 122/138] 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 123/138] 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 124/138] 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 125/138] 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 126/138] 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 127/138] 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 128/138] 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 129/138] 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 130/138] 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() From eb4426e473ee3ca7e8c36de5cc4a0a480871412b Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 29 Aug 2025 15:12:23 -0600 Subject: [PATCH 131/138] UpdateMonitor: dont leak convo in log --- kordophoned/src/daemon/update_monitor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kordophoned/src/daemon/update_monitor.rs b/kordophoned/src/daemon/update_monitor.rs index 936ef5d..860be9c 100644 --- a/kordophoned/src/daemon/update_monitor.rs +++ b/kordophoned/src/daemon/update_monitor.rs @@ -63,7 +63,7 @@ impl UpdateMonitor { async fn handle_update(&mut self, update: UpdateEvent) { match update.data { UpdateEventData::ConversationChanged(conversation) => { - log::info!(target: target::UPDATES, "Conversation changed: {:?}", conversation); + log::info!(target: target::UPDATES, "Conversation changed: {}", conversation.guid); // Explicitly update the unread count, we assume this is fresh from the notification. let db_conversation: kordophone_db::models::Conversation = From 012872376572c8d44cc9044668caa62c9637221f Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 29 Aug 2025 18:48:16 -0600 Subject: [PATCH 132/138] xpc: Fixes file handle explosion - drop fd after its copied via xpc --- kordophoned/src/xpc/agent.rs | 8 ++- kordophoned/src/xpc/mod.rs | 20 +++++++ kordophoned/src/xpc/rpc.rs | 110 +++++++++++++++++------------------ 3 files changed, 80 insertions(+), 58 deletions(-) diff --git a/kordophoned/src/xpc/agent.rs b/kordophoned/src/xpc/agent.rs index 9a7040b..6ebdf79 100644 --- a/kordophoned/src/xpc/agent.rs +++ b/kordophoned/src/xpc/agent.rs @@ -110,10 +110,10 @@ impl XpcAgent { 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 result = 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() { - let payload = message_to_xpc_object(response); + let payload = message_to_xpc_object(result.message); 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); }) @@ -124,6 +124,10 @@ impl XpcAgent { xpc_sys::xpc_connection_send_message(client, reply); xpc_sys::xpc_release(payload); xpc_sys::xpc_release(reply); + + // Drop any cleanup resource now that payload is constructed and sent. + drop(result.cleanup); + log::trace!(target: LOG_TARGET, "XPC reply sent for method: {}", method); } else { log::warn!(target: LOG_TARGET, "No reply port for method: {}", method); diff --git a/kordophoned/src/xpc/mod.rs b/kordophoned/src/xpc/mod.rs index 81cbe3f..d2bf926 100644 --- a/kordophoned/src/xpc/mod.rs +++ b/kordophoned/src/xpc/mod.rs @@ -2,3 +2,23 @@ pub mod agent; pub mod interface; pub mod rpc; pub mod util; + +use std::any::Any; +use xpc_connection::Message; + +/// Result of dispatching an XPC request: the message to send plus an optional +/// resource to keep alive until after the XPC payload is constructed. +pub struct DispatchResult { + pub message: Message, + pub cleanup: Option>, +} + +impl DispatchResult { + pub fn new(message: Message) -> Self { + Self { message, cleanup: None } + } + + pub fn with_cleanup(message: Message, cleanup: T) -> Self { + Self { message, cleanup: Some(Box::new(cleanup)) } + } +} diff --git a/kordophoned/src/xpc/rpc.rs b/kordophoned/src/xpc/rpc.rs index 1a599bf..0d8b5c3 100644 --- a/kordophoned/src/xpc/rpc.rs +++ b/kordophoned/src/xpc/rpc.rs @@ -7,22 +7,23 @@ use xpc_connection::Message; use xpc_connection_sys as xpc_sys; use super::util::*; +use super::DispatchResult; pub async fn dispatch( agent: &XpcAgent, subscribers: &std::sync::Mutex>, current_client: xpc_sys::xpc_connection_t, root: &HashMap, -) -> Message { +) -> DispatchResult { 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( + return DispatchResult::new(attach_request_id( make_error_reply("InvalidRequest", "Missing method/type"), request_id, - ) + )) } }; @@ -35,9 +36,9 @@ pub async fn dispatch( let mut reply: XpcMap = HashMap::new(); dict_put_str(&mut reply, "type", "GetVersionResponse"); dict_put_str(&mut reply, "version", &version); - Message::Dictionary(reply) + DispatchResult::new(Message::Dictionary(reply)) } - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))), }, // GetConversations @@ -84,34 +85,34 @@ pub async fn dispatch( let mut reply: XpcMap = HashMap::new(); dict_put_str(&mut reply, "type", "GetConversationsResponse"); reply.insert(cstr("conversations"), Message::Array(items)); - Message::Dictionary(reply) + DispatchResult::new(Message::Dictionary(reply)) } - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + Err(e) => DispatchResult::new(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)), + Ok(()) => DispatchResult::new(make_ok_reply()), + Err(e) => DispatchResult::new(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)), + Ok(()) => DispatchResult::new(make_ok_reply()), + Err(e) => DispatchResult::new(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"), + None => return DispatchResult::new(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)), + Ok(()) => DispatchResult::new(make_ok_reply()), + Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))), } } @@ -121,14 +122,14 @@ pub async fn dispatch( .and_then(|m| dict_get_str(m, "conversation_id")) { Some(id) => id, - None => return make_error_reply("InvalidRequest", "Missing conversation_id"), + None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")), }; match agent .send_event(|r| Event::MarkConversationAsRead(conversation_id, r)) .await { - Ok(()) => make_ok_reply(), - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + Ok(()) => DispatchResult::new(make_ok_reply()), + Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))), } } @@ -136,11 +137,11 @@ pub async fn dispatch( "GetMessages" => { let args = match get_dictionary_field(root, "arguments") { Some(a) => a, - None => return make_error_reply("InvalidRequest", "Missing arguments"), + None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")), }; let conversation_id = match dict_get_str(args, "conversation_id") { Some(id) => id, - None => return make_error_reply("InvalidRequest", "Missing conversation_id"), + None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")), }; let last_message_id = dict_get_str(args, "last_message_id"); match agent @@ -213,27 +214,27 @@ pub async fn dispatch( let mut reply: XpcMap = HashMap::new(); dict_put_str(&mut reply, "type", "GetMessagesResponse"); reply.insert(cstr("messages"), Message::Array(items)); - Message::Dictionary(reply) + DispatchResult::new(Message::Dictionary(reply)) } - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + Err(e) => DispatchResult::new(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)), + Ok(()) => DispatchResult::new(make_ok_reply()), + Err(e) => DispatchResult::new(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"), + None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")), }; let conversation_id = match dict_get_str(args, "conversation_id") { Some(v) => v, - None => return make_error_reply("InvalidRequest", "Missing conversation_id"), + None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")), }; let text = dict_get_str(args, "text").unwrap_or_default(); let attachment_guids: Vec = match args.get(&cstr("attachment_guids")) { @@ -254,9 +255,9 @@ pub async fn dispatch( 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) + DispatchResult::new(Message::Dictionary(reply)) } - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))), } } @@ -264,11 +265,11 @@ pub async fn dispatch( "GetAttachmentInfo" => { let args = match get_dictionary_field(root, "arguments") { Some(a) => a, - None => return make_error_reply("InvalidRequest", "Missing arguments"), + None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")), }; let attachment_id = match dict_get_str(args, "attachment_id") { Some(v) => v, - None => return make_error_reply("InvalidRequest", "Missing attachment_id"), + None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing attachment_id")), }; match agent .send_event(|r| Event::GetAttachment(attachment_id, r)) @@ -297,9 +298,9 @@ pub async fn dispatch( "preview_downloaded", &attachment.is_downloaded(true).to_string(), ); - Message::Dictionary(reply) + DispatchResult::new(Message::Dictionary(reply)) } - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))), } } @@ -307,11 +308,11 @@ pub async fn dispatch( "OpenAttachmentFd" => { let args = match get_dictionary_field(root, "arguments") { Some(a) => a, - None => return make_error_reply("InvalidRequest", "Missing arguments"), + None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")), }; let attachment_id = match dict_get_str(args, "attachment_id") { Some(v) => v, - None => return make_error_reply("InvalidRequest", "Missing attachment_id"), + None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing attachment_id")), }; let preview = dict_get_str(args, "preview") .map(|s| s == "true") @@ -327,22 +328,19 @@ pub async fn dispatch( let path = attachment.get_path_for_preview(preview); match std::fs::OpenOptions::new().read(true).open(&path) { Ok(file) => { + use std::os::fd::AsRawFd; 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) + DispatchResult { message: Message::Dictionary(reply), cleanup: Some(Box::new(file)) } } - Err(e) => make_error_reply("OpenFailed", &format!("{}", e)), + Err(e) => DispatchResult::new(make_error_reply("OpenFailed", &format!("{}", e))), } } - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))), } } @@ -350,11 +348,11 @@ pub async fn dispatch( "DownloadAttachment" => { let args = match get_dictionary_field(root, "arguments") { Some(a) => a, - None => return make_error_reply("InvalidRequest", "Missing arguments"), + None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")), }; let attachment_id = match dict_get_str(args, "attachment_id") { Some(v) => v, - None => return make_error_reply("InvalidRequest", "Missing attachment_id"), + None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing attachment_id")), }; let preview = dict_get_str(args, "preview") .map(|s| s == "true") @@ -363,8 +361,8 @@ pub async fn dispatch( .send_event(|r| Event::DownloadAttachment(attachment_id, preview, r)) .await { - Ok(()) => make_ok_reply(), - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + Ok(()) => DispatchResult::new(make_ok_reply()), + Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))), } } @@ -373,11 +371,11 @@ pub async fn dispatch( use std::path::PathBuf; let args = match get_dictionary_field(root, "arguments") { Some(a) => a, - None => return make_error_reply("InvalidRequest", "Missing arguments"), + None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")), }; let path = match dict_get_str(args, "path") { Some(v) => v, - None => return make_error_reply("InvalidRequest", "Missing path"), + None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing path")), }; match agent .send_event(|r| Event::UploadAttachment(PathBuf::from(path), r)) @@ -387,9 +385,9 @@ pub async fn dispatch( 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) + DispatchResult::new(Message::Dictionary(reply)) } - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))), } } @@ -408,14 +406,14 @@ pub async fn dispatch( "username", &settings.username.unwrap_or_default(), ); - Message::Dictionary(reply) + DispatchResult::new(Message::Dictionary(reply)) } - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + Err(e) => DispatchResult::new(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"), + None => return DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")), }; let server_url = dict_get_str(args, "server_url"); let username = dict_get_str(args, "username"); @@ -428,8 +426,8 @@ pub async fn dispatch( .send_event(|r| Event::UpdateSettings(settings, r)) .await { - Ok(()) => make_ok_reply(), - Err(e) => make_error_reply("DaemonError", &format!("{}", e)), + Ok(()) => DispatchResult::new(make_ok_reply()), + Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))), } } @@ -440,13 +438,13 @@ pub async fn dispatch( list.push(XpcConn(current_client)); log::trace!(target: LOG_TARGET, "Client subscribed to signals (total subscribers: {})", list.len()); } - make_ok_reply() + DispatchResult::new(make_ok_reply()) } // Unknown method fallback - other => make_error_reply("UnknownMethod", other), + other => DispatchResult::new(make_error_reply("UnknownMethod", other)), }; - response = attach_request_id(response, request_id); + response.message = attach_request_id(response.message, request_id); response } From 92d5b9985395328fec40a5fa98f28c88ecb1c287 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 29 Aug 2025 22:08:56 -0600 Subject: [PATCH 133/138] kordophone: better handling of url decoding errors --- kordophone/src/api/http_client.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index b487d53..66c414a 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -49,6 +49,7 @@ pub enum Error { SerdeError(serde_json::Error), DecodeError(String), PongError(tungstenite::Error), + URLError, Unauthorized, } @@ -392,7 +393,8 @@ impl APIInterface for HTTPAPIClient { None => "updates".to_string(), }; - let uri = self.uri_for_endpoint(&endpoint, Some(self.websocket_scheme())); + let uri = self + .uri_for_endpoint(&endpoint, Some(self.websocket_scheme()))?; log::debug!("Connecting to websocket: {:?}", uri); @@ -463,17 +465,23 @@ impl HTTPAPIClient { } } - fn uri_for_endpoint(&self, endpoint: &str, scheme: Option<&str>) -> Uri { + fn uri_for_endpoint(&self, endpoint: &str, scheme: Option<&str>) -> Result { let mut parts = self.base_url.clone().into_parts(); - let root_path: PathBuf = parts.path_and_query.unwrap().path().into(); + let root_path: PathBuf = parts + .path_and_query + .ok_or(Error::URLError)? + .path() + .into(); + let path = root_path.join(endpoint); - parts.path_and_query = Some(path.to_str().unwrap().parse().unwrap()); + let path_str = path.to_str().ok_or(Error::URLError)?; + parts.path_and_query = Some(path_str.parse().map_err(|_| Error::URLError)?); if let Some(scheme) = scheme { - parts.scheme = Some(scheme.parse().unwrap()); + parts.scheme = Some(scheme.parse().map_err(|_| Error::URLError)?); } - Uri::try_from(parts).unwrap() + Uri::try_from(parts).map_err(|_| Error::URLError) } fn websocket_scheme(&self) -> &str { @@ -547,14 +555,14 @@ impl HTTPAPIClient { ) -> Result, Error> { use hyper::StatusCode; - let uri = self.uri_for_endpoint(endpoint, None); + let uri = self.uri_for_endpoint(endpoint, None)?; log::debug!("Requesting {:?} {:?}", method, uri); let mut build_request = |auth: &Option| { let body = body_fn(); Request::builder() .method(&method) - .uri(&uri) + .uri(uri.clone()) .with_auth_string(auth) .body(body) .expect("Unable to build request") From 54f7f3a4db4e24681906c72c025705470af1af0f Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 29 Aug 2025 22:44:09 -0600 Subject: [PATCH 134/138] adds utilities > snoozer --- Cargo.lock | 12 +++++ Cargo.toml | 3 +- utilities/Cargo.toml | 12 +++++ utilities/src/bin/snoozer.rs | 96 ++++++++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 utilities/Cargo.toml create mode 100644 utilities/src/bin/snoozer.rs diff --git a/Cargo.lock b/Cargo.lock index 00f9bfc..18e02e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1167,6 +1167,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "kordophone-utilities" +version = "0.1.0" +dependencies = [ + "env_logger 0.11.8", + "futures-util", + "hyper", + "kordophone", + "log", + "tokio", +] + [[package]] name = "kordophoned" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index e9fa9ed..f21f005 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,8 @@ members = [ "kordophone", "kordophone-db", "kordophoned", - "kpcli" + "kpcli", + "utilities", ] resolver = "2" diff --git a/utilities/Cargo.toml b/utilities/Cargo.toml new file mode 100644 index 0000000..1d970dd --- /dev/null +++ b/utilities/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "kordophone-utilities" +version = "0.1.0" +edition = "2024" + +[dependencies] +env_logger = "0.11.5" +futures-util = "0.3.31" +hyper = { version = "0.14" } +kordophone = { path = "../kordophone" } +log = { version = "0.4.21", features = [] } +tokio = { version = "1.37.0", features = ["full"] } \ No newline at end of file diff --git a/utilities/src/bin/snoozer.rs b/utilities/src/bin/snoozer.rs new file mode 100644 index 0000000..135baae --- /dev/null +++ b/utilities/src/bin/snoozer.rs @@ -0,0 +1,96 @@ +use std::env; +use std::process; + +use kordophone::{ + api::{HTTPAPIClient, InMemoryAuthenticationStore, EventSocket}, + model::{ConversationID, event::EventData}, + APIInterface, +}; +use kordophone::api::http_client::Credentials; + +use futures_util::StreamExt; +use hyper::Uri; + +#[tokio::main] +async fn main() -> Result<(), Box> { + env_logger::init(); + + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} [conversation_id2] [conversation_id3] ...", args[0]); + eprintln!("Environment variables required:"); + eprintln!(" KORDOPHONE_API_URL - Server URL"); + eprintln!(" KORDOPHONE_USERNAME - Username for authentication"); + eprintln!(" KORDOPHONE_PASSWORD - Password for authentication"); + process::exit(1); + } + + // Read environment variables + let server_url: Uri = env::var("KORDOPHONE_API_URL") + .map_err(|_| "KORDOPHONE_API_URL environment variable not set")? + .parse()?; + + let username = env::var("KORDOPHONE_USERNAME") + .map_err(|_| "KORDOPHONE_USERNAME environment variable not set")?; + + let password = env::var("KORDOPHONE_PASSWORD") + .map_err(|_| "KORDOPHONE_PASSWORD environment variable not set")?; + + let credentials = Credentials { username, password }; + + // Collect all conversation IDs from command line arguments + let target_conversation_ids: Vec = args[1..].iter() + .map(|id| id.clone()) + .collect(); + + println!("Monitoring {} conversation(s) for updates: {:?}", + target_conversation_ids.len(), target_conversation_ids); + + let auth_store = InMemoryAuthenticationStore::new(Some(credentials.clone())); + let mut client = HTTPAPIClient::new(server_url, auth_store); + + // Authenticate first + let _token = client.authenticate(credentials).await?; + println!("Authenticated successfully"); + + // Open event socket + let event_socket = client.open_event_socket(None).await?; + let (mut stream, _sink) = event_socket.events().await; + + println!("Connected to event stream, waiting for updates..."); + + // Process events + while let Some(event_result) = stream.next().await { + match event_result { + Ok(socket_event) => { + match socket_event { + kordophone::api::event_socket::SocketEvent::Update(event) => { + match event.data { + EventData::MessageReceived(conversation, _message) => { + if target_conversation_ids.contains(&conversation.guid) { + println!("Message update detected for conversation {}, marking as read...", conversation.guid); + match client.mark_conversation_as_read(&conversation.guid).await { + Ok(_) => println!("Successfully marked conversation {} as read", conversation.guid), + Err(e) => eprintln!("Failed to mark conversation {} as read: {:?}", conversation.guid, e), + } + } + }, + + _ => {} + } + }, + kordophone::api::event_socket::SocketEvent::Pong => { + // Ignore pong messages + } + } + }, + Err(e) => { + eprintln!("Error receiving event: {:?}", e); + break; + } + } + } + + println!("Event stream ended"); + Ok(()) +} From 8fcc7609b957ddc0f1b685aa8b54970bf1b6f40a Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 29 Aug 2025 23:08:37 -0600 Subject: [PATCH 135/138] snoozer: fix auth --- utilities/src/bin/snoozer.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/utilities/src/bin/snoozer.rs b/utilities/src/bin/snoozer.rs index 135baae..623c766 100644 --- a/utilities/src/bin/snoozer.rs +++ b/utilities/src/bin/snoozer.rs @@ -7,6 +7,7 @@ use kordophone::{ APIInterface, }; use kordophone::api::http_client::Credentials; +use kordophone::api::AuthenticationStore; use futures_util::StreamExt; use hyper::Uri; @@ -49,9 +50,10 @@ async fn main() -> Result<(), Box> { let auth_store = InMemoryAuthenticationStore::new(Some(credentials.clone())); let mut client = HTTPAPIClient::new(server_url, auth_store); - // Authenticate first - let _token = client.authenticate(credentials).await?; - println!("Authenticated successfully"); + // Authenticate first (and set token manually in case the client doesn't) + let token = client.authenticate(credentials).await?; + client.auth_store.set_token(token.to_string()).await; + println!("Authenticated successfully: {:?}", token); // Open event socket let event_socket = client.open_event_socket(None).await?; From 44fa638b1c4fe4f114295c38a0a505d361229a5d Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 29 Aug 2025 23:09:57 -0600 Subject: [PATCH 136/138] snoozer: try another auth method --- utilities/src/bin/snoozer.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/utilities/src/bin/snoozer.rs b/utilities/src/bin/snoozer.rs index 623c766..ce86f56 100644 --- a/utilities/src/bin/snoozer.rs +++ b/utilities/src/bin/snoozer.rs @@ -49,11 +49,9 @@ async fn main() -> Result<(), Box> { let auth_store = InMemoryAuthenticationStore::new(Some(credentials.clone())); let mut client = HTTPAPIClient::new(server_url, auth_store); - - // Authenticate first (and set token manually in case the client doesn't) - let token = client.authenticate(credentials).await?; - client.auth_store.set_token(token.to_string()).await; - println!("Authenticated successfully: {:?}", token); + + // Kick auth... this is bad. Update monitor should do this. + _ = client.get_conversations().await?; // Open event socket let event_socket = client.open_event_socket(None).await?; From 0595fbc651a19c6c46f37ceffaafab75f40257c9 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 29 Aug 2025 23:19:14 -0600 Subject: [PATCH 137/138] This ended up being pebkac --- utilities/src/bin/snoozer.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/utilities/src/bin/snoozer.rs b/utilities/src/bin/snoozer.rs index ce86f56..28f6c6c 100644 --- a/utilities/src/bin/snoozer.rs +++ b/utilities/src/bin/snoozer.rs @@ -49,9 +49,7 @@ async fn main() -> Result<(), Box> { let auth_store = InMemoryAuthenticationStore::new(Some(credentials.clone())); let mut client = HTTPAPIClient::new(server_url, auth_store); - - // Kick auth... this is bad. Update monitor should do this. - _ = client.get_conversations().await?; + let _ = client.authenticate(credentials).await?; // Open event socket let event_socket = client.open_event_socket(None).await?; From b0dfc4146ca0da535a87f8509aec68817fb2ab14 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 3 Sep 2025 22:23:45 -0700 Subject: [PATCH 138/138] Add TLS support --- Cargo.lock | 103 ++++++++++++++++++++++++++++++ kordophone/Cargo.toml | 5 +- kordophone/src/api/http_client.rs | 12 ++-- kordophone/src/lib.rs | 13 ++++ 4 files changed, 125 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 18e02e3..261b690 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1136,6 +1136,7 @@ dependencies = [ "hyper", "hyper-tls", "log", + "rustls", "serde", "serde_json", "serde_plain", @@ -1780,6 +1781,21 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.14", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -1805,6 +1821,40 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.23.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.20" @@ -1968,6 +2018,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "strsim" version = "0.8.0" @@ -1980,6 +2036,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.101" @@ -2151,6 +2213,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.26.2" @@ -2159,8 +2231,12 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", + "rustls", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite", + "webpki-roots 0.26.11", ] [[package]] @@ -2254,9 +2330,12 @@ dependencies = [ "httparse", "log", "rand 0.9.1", + "rustls", + "rustls-pki-types", "sha1", "thiserror 2.0.12", "utf-8", + "webpki-roots 0.26.11", ] [[package]] @@ -2283,6 +2362,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "urlencoding" version = "2.1.3" @@ -2420,6 +2505,24 @@ version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.2", +] + +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "which" version = "3.1.1" diff --git a/kordophone/Cargo.toml b/kordophone/Cargo.toml index 209b469..9a22dbc 100644 --- a/kordophone/Cargo.toml +++ b/kordophone/Cargo.toml @@ -21,8 +21,9 @@ serde_json = "1.0.91" serde_plain = "1.0.2" time = { version = "0.3.17", features = ["parsing", "serde"] } tokio = { version = "1.37.0", features = ["full"] } -tokio-tungstenite = "0.26.2" +tokio-tungstenite = { version = "0.26.2", features = ["rustls-tls-webpki-roots"] } tokio-util = { version = "0.7.15", features = ["futures-util"] } -tungstenite = "0.26.2" +tungstenite = { version = "0.26.2", features = ["rustls-tls-webpki-roots"] } urlencoding = "2.1.3" uuid = { version = "1.6.1", features = ["v4", "fast-rng", "macro-diagnostics"] } +rustls = { version = "0.23", default-features = false, features = ["ring"] } diff --git a/kordophone/src/api/http_client.rs b/kordophone/src/api/http_client.rs index 66c414a..c28c952 100644 --- a/kordophone/src/api/http_client.rs +++ b/kordophone/src/api/http_client.rs @@ -7,6 +7,7 @@ use crate::api::event_socket::{EventSocket, SinkMessage, SocketEvent, SocketUpda use crate::api::AuthenticationStore; use bytes::Bytes; use hyper::{Body, Client, Method, Request, Uri}; +use hyper_tls::HttpsConnector; use async_trait::async_trait; use serde::{de::DeserializeOwned, Deserialize, Serialize}; @@ -28,7 +29,7 @@ use crate::{ APIInterface, }; -type HttpClient = Client; +type HttpClient = Client>; pub struct HTTPAPIClient { pub base_url: Uri, @@ -458,11 +459,10 @@ impl APIInterface for HTTPAPIClient { impl HTTPAPIClient { pub fn new(base_url: Uri, auth_store: K) -> HTTPAPIClient { - HTTPAPIClient { - base_url, - auth_store, - client: Client::new(), - } + let https = HttpsConnector::new(); + let client = Client::builder().build::<_, Body>(https); + + HTTPAPIClient { base_url, auth_store, client } } fn uri_for_endpoint(&self, endpoint: &str, scheme: Option<&str>) -> Result { diff --git a/kordophone/src/lib.rs b/kordophone/src/lib.rs index 8688da3..d01fe6f 100644 --- a/kordophone/src/lib.rs +++ b/kordophone/src/lib.rs @@ -5,3 +5,16 @@ pub use self::api::APIInterface; #[cfg(test)] pub mod tests; + +// Ensure a process-level rustls CryptoProvider is installed for TLS (wss). +// Rustls 0.23 requires an explicit provider installation (e.g., ring or aws-lc). +// We depend on rustls with feature "ring" and install it once at startup. +#[ctor::ctor] +fn install_rustls_crypto_provider() { + // If already installed, this is a no-op. Ignore the result. + #[allow(unused_must_use)] + { + use rustls::crypto::ring; + ring::default_provider().install_default(); + } +}