diff --git a/core/.claude/settings.local.json b/core/.claude/settings.local.json new file mode 100644 index 0000000..1a8fb56 --- /dev/null +++ b/core/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(find:*)", + "Bash(cargo build:*)", + "Bash(diesel migration generate:*)", + "Bash(cargo clean:*)", + "Bash(git describe:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git tag:*)", + "Bash(git stash:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/core/.gitignore b/core/.gitignore new file mode 100644 index 0000000..b60de5b --- /dev/null +++ b/core/.gitignore @@ -0,0 +1 @@ +**/target diff --git a/core/CLAUDE.md b/core/CLAUDE.md new file mode 100644 index 0000000..c95481c --- /dev/null +++ b/core/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 diff --git a/core/Cargo.lock b/core/Cargo.lock new file mode 100644 index 0000000..261b690 --- /dev/null +++ b/core/Cargo.lock @@ -0,0 +1,2760 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "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]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[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.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "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" +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 = "base64" +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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +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" +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", + "serde", + "wasm-bindgen", + "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" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" +dependencies = [ + "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 0.11.1", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "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" +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" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edb49164822f3ee45b17acd4a208cfc1251410cf0cad9a833234c9890774dd9f" +dependencies = [ + "quote", + "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 0.11.1", + "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]] +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" +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-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" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "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" +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" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" +dependencies = [ + "log", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[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" +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-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[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-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[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" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c" +dependencies = [ + "cfg-if", + "libc", + "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]] +name = "gimli" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[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.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hermit-abi" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "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 = "humantime" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" + +[[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 0.2.12", + "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 = "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 = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +dependencies = [ + "equivalent", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "dbus-secret-service", + "log", + "security-framework 2.10.0", + "security-framework 3.3.0", + "zeroize", +] + +[[package]] +name = "kordophone" +version = "1.0.0" +dependencies = [ + "async-trait", + "base64", + "bytes", + "chrono", + "ctor", + "env_logger 0.11.8", + "futures-util", + "hyper", + "hyper-tls", + "log", + "rustls", + "serde", + "serde_json", + "serde_plain", + "time", + "tokio", + "tokio-tungstenite", + "tokio-util", + "tungstenite", + "urlencoding", + "uuid", +] + +[[package]] +name = "kordophone-db" +version = "1.0.0" +dependencies = [ + "anyhow", + "async-trait", + "bincode", + "chrono", + "diesel", + "diesel_migrations", + "kordophone", + "log", + "serde", + "serde_json", + "time", + "tokio", + "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" +dependencies = [ + "anyhow", + "async-trait", + "block", + "chrono", + "dbus", + "dbus-codegen", + "dbus-crossroads", + "dbus-tokio", + "dbus-tree", + "directories", + "env_logger 0.11.8", + "futures", + "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]] +name = "kpcli" +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", + "futures-util", + "kordophone", + "kordophone-db", + "log", + "pretty", + "prettytable", + "serde_json", + "time", + "tokio", + "xpc-connection", + "xpc-connection-sys", +] + +[[package]] +name = "lazy_static" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" + +[[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 = "libloading" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" +dependencies = [ + "cfg-if", + "windows-targets 0.52.6", +] + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.9.3", + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" + +[[package]] +name = "migrations_internals" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd01039851e82f8799046eabbb354056283fb265c8ec0996af940f4e85a380ff" +dependencies = [ + "serde", + "toml", +] + +[[package]] +name = "migrations_macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb161cc72176cb37aa47f1fc520d3ef02263d67d661f44f05d05a079e1237fd" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + +[[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 = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.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 2.10.0", + "security-framework-sys", + "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" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +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.9.3", + "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 = "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" +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 = "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" +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 = "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" +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 = "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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +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" +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", +] + +[[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]] +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]] +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]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +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 0.2.14", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.14", + "libredox", + "thiserror 2.0.12", +] + +[[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 = "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" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +dependencies = [ + "bitflags 2.9.3", + "errno", + "libc", + "linux-raw-sys", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[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 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", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" +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 = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +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 = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +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" +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 = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[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" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +dependencies = [ + "proc-macro2", + "quote", + "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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +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 = "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 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]] +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +dependencies = [ + "deranged", + "itoa", + "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.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tokio" +version = "1.44.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "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-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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[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" +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 = "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", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.12", + "utf-8", + "webpki-roots 0.26.11", +] + +[[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" +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 = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +dependencies = [ + "getrandom 0.3.2", + "rand 0.9.1", + "uuid-macro-internal", +] + +[[package]] +name = "uuid-macro-internal" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dcd78c4f979627a754f5522cea6e6a25e55139056535fe6e69c506cd64a862" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "vcpkg" +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 = "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" +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" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +dependencies = [ + "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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d011071ae14a2f6671d0b74080ae0cd8ebf3a6f8c9589a2cd45f23126fe29724" +dependencies = [ + "libc", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.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-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[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.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]] +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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "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]] +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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +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" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +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.9.3", +] + +[[package]] +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", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000..f21f005 --- /dev/null +++ b/core/Cargo.toml @@ -0,0 +1,14 @@ +[workspace] +members = [ + "kordophone", + "kordophone-db", + "kordophoned", + "kpcli", + "utilities", +] +resolver = "2" + +[profile.release] +lto = "thin" +debug = 1 +incremental = false diff --git a/core/Dockerfile b/core/Dockerfile new file mode 100644 index 0000000..19840b8 --- /dev/null +++ b/core/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/core/Makefile b/core/Makefile new file mode 100644 index 0000000..a3936f0 --- /dev/null +++ b/core/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 + diff --git a/core/kordophone-db/Cargo.toml b/core/kordophone-db/Cargo.toml new file mode 100644 index 0000000..5ae10ea --- /dev/null +++ b/core/kordophone-db/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "kordophone-db" +version = "1.0.0" +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"] } +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/core/kordophone-db/diesel.toml b/core/kordophone-db/diesel.toml new file mode 100644 index 0000000..c028f4a --- /dev/null +++ b/core/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/core/kordophone-db/migrations/.keep b/core/kordophone-db/migrations/.keep new file mode 100644 index 0000000..e69de29 diff --git a/core/kordophone-db/migrations/2025-06-26-233940_create_schema/down.sql b/core/kordophone-db/migrations/2025-06-26-233940_create_schema/down.sql new file mode 100644 index 0000000..4740fab --- /dev/null +++ b/core/kordophone-db/migrations/2025-06-26-233940_create_schema/down.sql @@ -0,0 +1,7 @@ +-- This file should undo anything in `up.sql` +DROP TABLE IF EXISTS `messages`; +DROP TABLE IF EXISTS `conversation_messages`; +DROP TABLE IF EXISTS `settings`; +DROP TABLE IF EXISTS `conversations`; +DROP TABLE IF EXISTS `participants`; +DROP TABLE IF EXISTS `conversation_participants`; diff --git a/core/kordophone-db/migrations/2025-06-26-233940_create_schema/up.sql b/core/kordophone-db/migrations/2025-06-26-233940_create_schema/up.sql new file mode 100644 index 0000000..d1ba160 --- /dev/null +++ b/core/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/core/kordophone-db/src/database.rs b/core/kordophone-db/src/database.rs new file mode 100644 index 0000000..326156f --- /dev/null +++ b/core/kordophone-db/src/database.rs @@ -0,0 +1,94 @@ +use anyhow::Result; +use async_trait::async_trait; +use diesel::prelude::*; + +pub use std::sync::Arc; +pub use tokio::sync::Mutex; + +use crate::repository::Repository; +use crate::settings::Settings; + +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, +} + +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))?; + + Ok(Self { connection }) + } + + pub fn new_in_memory() -> Result { + Self::new(":memory:") + } +} + +#[async_trait] +impl DatabaseAccess for Database { + async fn with_repository(&mut self, f: F) -> R + where + F: FnOnce(&mut Repository) -> R + Send, + R: Send, + { + let mut repository = Repository::new(&mut self.connection); + f(&mut repository) + } + + async fn with_settings(&mut self, f: F) -> R + where + 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 + } +} diff --git a/core/kordophone-db/src/lib.rs b/core/kordophone-db/src/lib.rs new file mode 100644 index 0000000..3abd200 --- /dev/null +++ b/core/kordophone-db/src/lib.rs @@ -0,0 +1,14 @@ +pub mod database; +pub mod models; +pub mod repository; +pub mod schema; +pub mod settings; + +#[cfg(test)] +mod tests; + +pub mod target { + pub static REPOSITORY: &str = "repository"; +} + +pub use repository::Repository; diff --git a/core/kordophone-db/src/models/conversation.rs b/core/kordophone-db/src/models/conversation.rs new file mode 100644 index 0000000..bde1597 --- /dev/null +++ b/core/kordophone-db/src/models/conversation.rs @@ -0,0 +1,142 @@ +use crate::models::{message::Message, participant::Participant}; +use chrono::{DateTime, NaiveDateTime}; +use uuid::Uuid; + +#[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 { + pub fn builder() -> ConversationBuilder { + ConversationBuilder::new() + } + + pub fn into_builder(&self) -> ConversationBuilder { + ConversationBuilder { + guid: Some(self.guid.clone()), + 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 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 { + 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| Participant::Remote { + handle: p, + contact_id: None, + }) // todo: this is wrong + .collect(), + } + } +} + +#[derive(Default)] +pub struct ConversationBuilder { + guid: Option, + date: NaiveDateTime, + unread_count: Option, + last_message_preview: Option, + participants: 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: NaiveDateTime) -> Self { + self.date = date; + self + } + + pub fn unread_count(mut self, unread_count: u16) -> 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 participants(mut self, participants: Vec) -> Self { + self.participants = Some(participants); + self + } + + pub fn display_name(mut self, display_name: &str) -> Self { + self.display_name = Some(display_name.into()); + self + } + + 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, + participants: self.participants.clone().unwrap_or_default(), + } + } +} diff --git a/core/kordophone-db/src/models/db/conversation.rs b/core/kordophone-db/src/models/db/conversation.rs new file mode 100644 index 0000000..f249265 --- /dev/null +++ b/core/kordophone-db/src/models/db/conversation.rs @@ -0,0 +1,53 @@ +use crate::models::{db::participant::InsertableRecord as InsertableParticipant, Conversation}; +use chrono::NaiveDateTime; +use diesel::prelude::*; + +#[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()), + conversation + .participants + .into_iter() + .map(InsertableParticipant::from) + .collect(), + ) + } +} + +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![], + } + } +} diff --git a/core/kordophone-db/src/models/db/message.rs b/core/kordophone-db/src/models/db/message.rs new file mode 100644 index 0000000..657a926 --- /dev/null +++ b/core/kordophone-db/src/models/db/message.rs @@ -0,0 +1,70 @@ +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)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct Record { + pub id: String, + pub sender_participant_handle: 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_handle: match message.sender { + Participant::Me => None, + Participant::Remote { handle, .. } => Some(handle), + }, + 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()); + + let message_sender = match record.sender_participant_handle { + Some(handle) => Participant::Remote { + handle, + contact_id: None, + }, + None => Participant::Me, + }; + Self { + id: record.id, + sender: message_sender, + text: record.text, + date: record.date, + file_transfer_guids, + attachment_metadata, + } + } +} diff --git a/core/kordophone-db/src/models/db/mod.rs b/core/kordophone-db/src/models/db/mod.rs new file mode 100644 index 0000000..7cbefc7 --- /dev/null +++ b/core/kordophone-db/src/models/db/mod.rs @@ -0,0 +1,3 @@ +pub mod conversation; +pub mod message; +pub mod participant; diff --git a/core/kordophone-db/src/models/db/participant.rs b/core/kordophone-db/src/models/db/participant.rs new file mode 100644 index 0000000..087b71e --- /dev/null +++ b/core/kordophone-db/src/models/db/participant.rs @@ -0,0 +1,81 @@ +use crate::models::Participant; +use crate::schema::conversation_participants; +use diesel::prelude::*; + +#[derive(Queryable, Selectable, AsChangeset, Identifiable)] +#[diesel(table_name = crate::schema::participants)] +#[diesel(primary_key(handle))] +pub struct Record { + pub handle: String, + pub is_me: bool, + pub contact_id: Option, +} + +#[derive(Insertable)] +#[diesel(table_name = crate::schema::participants)] +pub struct InsertableRecord { + pub handle: String, + pub is_me: bool, + pub contact_id: Option, +} + +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, + }, + } + } +} + +#[derive(Identifiable, Selectable, Queryable, Associations, Debug)] +#[diesel(belongs_to(super::conversation::Record, foreign_key = conversation_id))] +#[diesel(belongs_to(Record, foreign_key = participant_handle))] +#[diesel(table_name = conversation_participants)] +#[diesel(primary_key(conversation_id, participant_handle))] +pub struct ConversationParticipant { + pub conversation_id: String, + pub participant_handle: String, +} + +impl From for Participant { + fn from(record: Record) -> Self { + if record.is_me { + Participant::Me + } else { + Participant::Remote { + handle: record.handle.clone(), + contact_id: record.contact_id, + } + } + } +} + +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, + }, + } + } +} diff --git a/core/kordophone-db/src/models/message.rs b/core/kordophone-db/src/models/message.rs new file mode 100644 index 0000000..af8b70e --- /dev/null +++ b/core/kordophone-db/src/models/message.rs @@ -0,0 +1,148 @@ +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; + +#[derive(Clone, Debug)] +pub struct Message { + pub id: String, + pub sender: Participant, + pub text: String, + pub date: NaiveDateTime, + pub file_transfer_guids: Vec, + pub attachment_metadata: Option>, +} + +impl Message { + pub fn builder() -> MessageBuilder { + MessageBuilder::new() + } +} + +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: sender_participant, + text: value.text, + date: DateTime::from_timestamp( + value.date.unix_timestamp(), + 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, + } + } +} + +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, + 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 + } + } +} + +pub struct MessageBuilder { + id: Option, + sender: Option, + text: Option, + date: Option, + file_transfer_guids: Option>, + attachment_metadata: Option>, +} + +impl Default for MessageBuilder { + fn default() -> Self { + Self::new() + } +} + +impl MessageBuilder { + pub fn new() -> Self { + Self { + id: None, + sender: None, + text: None, + date: None, + file_transfer_guids: None, + attachment_metadata: 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 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/core/kordophone-db/src/models/mod.rs b/core/kordophone-db/src/models/mod.rs new file mode 100644 index 0000000..13571fc --- /dev/null +++ b/core/kordophone-db/src/models/mod.rs @@ -0,0 +1,8 @@ +pub mod conversation; +pub mod db; +pub mod message; +pub mod participant; + +pub use conversation::Conversation; +pub use message::Message; +pub use participant::Participant; diff --git a/core/kordophone-db/src/models/participant.rs b/core/kordophone-db/src/models/participant.rs new file mode 100644 index 0000000..1024a5b --- /dev/null +++ b/core/kordophone-db/src/models/participant.rs @@ -0,0 +1,22 @@ +#[derive(Debug, Clone, PartialEq)] +pub enum Participant { + Me, + Remote { + handle: String, + contact_id: Option, + }, +} + +impl Participant { + pub fn handle(&self) -> String { + match self { + Participant::Me => "(Me)".to_string(), + Participant::Remote { handle, .. } => handle.clone(), + } + } + + // Temporary alias for backward compatibility + pub fn display_name(&self) -> String { + self.handle() + } +} diff --git a/core/kordophone-db/src/repository.rs b/core/kordophone-db/src/repository.rs new file mode 100644 index 0000000..0f6783b --- /dev/null +++ b/core/kordophone-db/src/repository.rs @@ -0,0 +1,410 @@ +use anyhow::Result; +use diesel::prelude::*; +use diesel::query_dsl::BelongingToDsl; +use std::collections::HashMap; + +use crate::{ + models::{ + db::conversation::Record as ConversationRecord, + db::message::Record as MessageRecord, + db::participant::{ + ConversationParticipant, InsertableRecord as InsertableParticipantRecord, + Record as ParticipantRecord, + }, + Conversation, Message, Participant, + }, + schema, target, +}; + +pub struct Repository<'a> { + connection: &'a mut SqliteConnection, +} + +impl<'a> Repository<'a> { + pub fn new(connection: &'a mut SqliteConnection) -> Self { + Self { connection } + } + + 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::*; + + let (db_conversation, db_participants) = conversation.into(); + + diesel::replace_into(conversations) + .values(&db_conversation) + .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 { + diesel::replace_into(conversation_participants) + .values(( + conversation_id.eq(&db_conversation.id), + participant_handle.eq(&participant.handle), + )) + .execute(self.connection)?; + } + + Ok(()) + } + + 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::(self.connection) + .optional()?; + + if let Some(conversation) = result { + let db_participants = ConversationParticipant::belonging_to(&conversation) + .inner_join(participants) + .select(ParticipantRecord::as_select()) + .load::(self.connection)?; + + let mut model_conversation: Conversation = conversation.into(); + model_conversation.participants = + db_participants.into_iter().map(|p| p.into()).collect(); + + return Ok(Some(model_conversation)); + } + + Ok(None) + } + + 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(); + for db_conversation in db_conversations { + let db_participants = ConversationParticipant::belonging_to(&db_conversation) + .inner_join(participants) + .select(ParticipantRecord::as_select()) + .load::(self.connection)?; + + 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) + } + + pub fn insert_message(&mut self, conversation_guid: &str, message: Message) -> Result<()> { + use crate::schema::conversation_messages::dsl::*; + use crate::schema::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_handle = self.get_or_create_participant(&sender); + + diesel::replace_into(messages) + .values(&db_message) + .execute(self.connection)?; + + diesel::replace_into(conversation_messages) + .values(( + conversation_id.eq(conversation_guid), + message_id.eq(&db_message.id), + )) + .execute(self.connection)?; + + // Update conversation date + self.update_conversation_metadata(conversation_guid)?; + + Ok(()) + } + + pub fn insert_messages( + &mut self, + conversation_guid: &str, + in_messages: Vec, + ) -> Result<()> { + use crate::schema::conversation_messages::dsl::*; + use crate::schema::messages::dsl::*; + use crate::schema::participants::dsl as participants_dsl; + + #[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(()); + } + + // 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 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(); + + // 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()); + + for message in in_messages { + // Resolve/insert the sender participant only once per display name. + let sender_handle_opt = match &message.sender { + Participant::Me => None, + Participant::Remote { handle, contact_id } => { + if participant_cache.contains_key(handle) { + Some(handle.clone()) + } else { + // Ensure participant exists in DB + let exists: Option = participants_dsl::participants + .filter(participants_dsl::handle.eq(handle)) + .select(participants_dsl::handle) + .first::(conn) + .optional()?; + + if exists.is_none() { + let new_participant = InsertableParticipantRecord { + handle: handle.clone(), + is_me: false, + contact_id: contact_id.clone(), + }; + + diesel::insert_into(participants_dsl::participants) + .values(&new_participant) + .execute(conn)?; + } + + 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_handle = sender_handle_opt.clone(); + + 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(()) + })?; + + // 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(()) + } + + 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 + .filter(conversation_id.eq(conversation_guid)) + .inner_join(messages) + .select(MessageRecord::as_select()) + .order_by(schema::messages::date.asc()) + .load::(self.connection)?; + + let mut result = Vec::new(); + for message_record in message_records { + let mut message: Message = message_record.clone().into(); + + // 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(sender_handle) + .first::(self.connection)?; + message.sender = participant.into(); + } + + result.push(message); + } + + Ok(result) + } + + 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)) + .inner_join(messages) + .select(MessageRecord::as_select()) + .order_by(schema::messages::date.desc()) + .first::(self.connection) + .optional()?; + + 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(()) + } + + 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(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 + ); + + let merged_conversation = conversation.merge(&conversation, Some(&last_message)); + self.insert_conversation(merged_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. + fn last_insert_id(&mut self) -> Result { + Ok( + diesel::select(diesel::dsl::sql::( + "last_insert_rowid()", + )) + .get_result(self.connection)?, + ) + } + + /// Update the contact_id for an existing participant record. + pub fn update_participant_contact( + &mut self, + participant_handle: &str, + new_contact_id: &str, + ) -> Result<()> { + use crate::schema::participants::dsl::*; + + 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 { + match participant { + Participant::Me => None, + Participant::Remote { + handle: p_handle, + contact_id: c_id, + .. + } => { + use crate::schema::participants::dsl::*; + + let existing_participant = participants + .filter(handle.eq(p_handle)) + .first::(self.connection) + .optional() + .unwrap(); + + 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(); + } + + Some(p_handle.clone()) + } + } + } +} diff --git a/core/kordophone-db/src/schema.rs b/core/kordophone-db/src/schema.rs new file mode 100644 index 0000000..c2b41e5 --- /dev/null +++ b/core/kordophone-db/src/schema.rs @@ -0,0 +1,66 @@ +// 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, + unread_count -> BigInt, + display_name -> Nullable, + last_message_preview -> Nullable, + date -> Timestamp, + } +} + +diesel::table! { + participants (handle) { + handle -> Text, + is_me -> Bool, + contact_id -> Nullable, + } +} + +diesel::table! { + conversation_participants (conversation_id, participant_handle) { + conversation_id -> Text, + participant_handle -> Text, + } +} + +diesel::table! { + messages (id) { + id -> Text, // guid + text -> Text, + sender_participant_handle -> Nullable, + date -> Timestamp, + file_transfer_guids -> Nullable, // JSON array of file transfer GUIDs + attachment_metadata -> Nullable, // JSON string of attachment metadata + } +} + +diesel::table! { + conversation_messages (conversation_id, message_id) { + conversation_id -> Text, // guid + message_id -> Text, // guid + } +} + +diesel::table! { + settings (key) { + key -> Text, + value -> Binary, + } +} + +diesel::joinable!(conversation_participants -> conversations (conversation_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, + messages, + conversation_messages, + settings, +); diff --git a/core/kordophone-db/src/settings.rs b/core/kordophone-db/src/settings.rs new file mode 100644 index 0000000..eb4367c --- /dev/null +++ b/core/kordophone-db/src/settings.rs @@ -0,0 +1,63 @@ +use anyhow::Result; +use diesel::*; +use serde::{de::DeserializeOwned, Serialize}; + +#[derive(Insertable, Queryable, AsChangeset)] +#[diesel(table_name = crate::schema::settings)] +struct SettingsRow<'a> { + key: &'a str, + value: &'a [u8], +} + +pub struct Settings<'a> { + connection: &'a mut SqliteConnection, +} + +impl<'a> Settings<'a> { + pub fn new(connection: &'a mut SqliteConnection) -> Self { + Self { connection } + } + + 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(self.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(self.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(self.connection)?) + } + + pub fn list_keys(&mut self) -> Result> { + use crate::schema::settings::dsl::*; + let keys: Vec = settings.select(key).load(self.connection)?; + + Ok(keys) + } +} diff --git a/core/kordophone-db/src/tests/mod.rs b/core/kordophone-db/src/tests/mod.rs new file mode 100644 index 0000000..f631fd3 --- /dev/null +++ b/core/kordophone-db/src/tests/mod.rs @@ -0,0 +1,423 @@ +use crate::{ + database::{Database, DatabaseAccess}, + models::{ + conversation::{Conversation, ConversationBuilder}, + message::Message, + participant::Participant, + }, +}; + +// 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 { handle: name_a, .. }, + Participant::Remote { handle: 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; + } + // 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] +async fn test_database_init() { + let _ = Database::new_in_memory().unwrap(); +} + +#[tokio::test] +async fn test_add_conversation() { + let mut db = Database::new_in_memory().unwrap(); + db.with_repository(|repository| { + 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(); + + // 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(); + + repository + .insert_conversation(modified_conversation.clone()) + .unwrap(); + + // 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 + let conversation = repository.get_conversation_by_guid(guid).unwrap().unwrap(); + assert_eq!(conversation.display_name.unwrap(), "Modified Conversation"); + }) + .await; +} + +#[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()]; + + 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(); + + 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 + )); + + // 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(); + + 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; +} + +#[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 + 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 + repository.insert_conversation(conversation1).unwrap(); + repository.insert_conversation(conversation2).unwrap(); + + // Get all conversations and verify the results + let all_conversations = repository.all_conversations(i32::MAX, 0).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!(participants_vec_equal_ignoring_id( + &conv1.participants, + &participants1 + )); + assert!(participants_vec_equal_ignoring_id( + &conv2.participants, + &participants2 + )); + }) + .await; +} + +#[tokio::test] +async fn test_messages() { + let mut db = Database::new_in_memory().unwrap(); + 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(); + + 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 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(); + + // 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 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 { handle, .. } = &retrieved_message2.sender { + assert_eq!(handle, "Alice"); + } else { + panic!( + "Expected Remote participant. Got: {:?}", + retrieved_message2.sender + ); + } + }) + .await; +} + +#[tokio::test] +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_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(); + + 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 + 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); + + // Messages should be ordered by date + for i in 1..messages.len() { + assert!(messages[i].date > messages[i - 1].date); + } + }) + .await; +} + +#[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 { handle: o_name, .. }, + Participant::Remote { handle: r_name, .. }, + ) => assert_eq!(o_name, r_name), + _ => panic!( + "Sender mismatch: original {:?}, retrieved {:?}", + original.sender, retrieved.sender + ), + } + } + + // 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; +} + +#[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(); + 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); + + // 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/core/kordophone/Cargo.toml b/core/kordophone/Cargo.toml new file mode 100644 index 0000000..9a22dbc --- /dev/null +++ b/core/kordophone/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "kordophone" +version = "1.0.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[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" +futures-util = "0.3.31" +hyper = { version = "0.14", features = ["full"] } +hyper-tls = "0.5.0" +log = { version = "0.4.21", features = [] } +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"] } +tokio-tungstenite = { version = "0.26.2", features = ["rustls-tls-webpki-roots"] } +tokio-util = { version = "0.7.15", features = ["futures-util"] } +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/core/kordophone/src/api/auth.rs b/core/kordophone/src/api/auth.rs new file mode 100644 index 0000000..6e34cfc --- /dev/null +++ b/core/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: String); +} + +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().map(|token| token.to_string()) + } + + async fn set_token(&mut self, token: String) { + self.token = Some(JwtToken::new(&token).unwrap()); + } +} diff --git a/core/kordophone/src/api/event_socket.rs b/core/kordophone/src/api/event_socket.rs new file mode 100644 index 0000000..de900fe --- /dev/null +++ b/core/kordophone/src/api/event_socket.rs @@ -0,0 +1,38 @@ +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>; + + /// Modern event pipeline + 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/core/kordophone/src/api/http_client.rs b/core/kordophone/src/api/http_client.rs new file mode 100644 index 0000000..c28c952 --- /dev/null +++ b/core/kordophone/src/api/http_client.rs @@ -0,0 +1,710 @@ +extern crate hyper; +extern crate serde; + +use std::{path::PathBuf, pin::Pin, str, task::Poll}; + +use crate::api::event_socket::{EventSocket, SinkMessage, SocketEvent, SocketUpdate}; +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}; + +use tokio::net::TcpStream; + +use futures_util::stream::{BoxStream, Stream}; +use futures_util::task::Context; +use futures_util::{Sink, SinkExt, StreamExt, TryStreamExt}; + +use tokio_tungstenite::connect_async; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; + +use crate::{ + model::{ + Conversation, ConversationID, Event, JwtToken, Message, MessageID, OutgoingMessage, + UpdateItem, + }, + APIInterface, +}; + +type HttpClient = Client>; + +pub struct HTTPAPIClient { + pub base_url: Uri, + pub auth_store: K, + client: HttpClient, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct Credentials { + pub username: String, + pub password: String, +} + +#[derive(Debug)] +pub enum Error { + ClientError(String), + HTTPError(hyper::Error), + SerdeError(serde_json::Error), + DecodeError(String), + PongError(tungstenite::Error), + URLError, + Unauthorized, +} + +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) + } +} + +impl From for Error { + fn from(err: serde_json::Error) -> Error { + Error::SerdeError(err) + } +} + +impl From for Error { + fn from(err: tungstenite::Error) -> Error { + Error::ClientError(err.to_string()) + } +} + +trait AuthBuilder { + fn with_auth_string(self, token: &Option) -> Self; +} + +impl AuthBuilder for hyper::http::request::Builder { + fn with_auth_string(self, token: &Option) -> Self { + if let Some(token) = &token { + self.header("Authorization", format!("Bearer: {}", token)) + } else { + self + } + } +} + +#[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 { + self.headers_mut() + .insert("Authorization", token.to_header_value()); + } + } +} + +type WebsocketSink = futures_util::stream::SplitSink< + WebSocketStream>, + tungstenite::Message, +>; +type WebsocketStream = + futures_util::stream::SplitStream>>; + +pub struct WebsocketEventSocket { + sink: Option, + stream: WebsocketStream, +} + +impl WebsocketEventSocket { + pub fn new(socket: WebSocketStream>) -> Self { + let (sink, stream) = socket.split(); + + Self { + sink: Some(sink), + stream, + } + } +} + +impl WebsocketEventSocket { + 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) => { + 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(_) => { + // 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())) + } + _ => Ok(None), + } + }) + } +} + +#[async_trait] +impl EventSocket for WebsocketEventSocket { + type Error = Error; + type EventStream = BoxStream<'static, Result>; + type UpdateStream = BoxStream<'static, Result>; + + 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 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(); + + (stream, sink) + } + + async fn raw_updates(self) -> Self::UpdateStream { + self.raw_update_stream().boxed() + } +} + +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(Error::HTTPError) + } +} + +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.deserialized_response("version", Method::GET).await?; + Ok(version) + } + + async fn get_conversations(&mut self) -> Result, Self::Error> { + let conversations: Vec = self + .deserialized_response("conversations", Method::GET) + .await?; + Ok(conversations) + } + + async fn authenticate(&mut self, credentials: Credentials) -> Result { + #[derive(Deserialize, Debug)] + struct AuthResponse { + jwt: String, + } + + log::debug!("Authenticating with username: {:?}", credentials.username); + + let body = || -> Body { serde_json::to_string(&credentials).unwrap().into() }; + 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); + self.auth_store.set_token(token.to_string()).await; + + 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, + 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.deserialized_response(&endpoint, Method::GET).await?; + Ok(messages) + } + + async fn send_message( + &mut self, + outgoing_message: &OutgoingMessage, + ) -> Result { + let message: Message = self + .deserialized_response_with_body("sendMessage", Method::POST, || { + serde_json::to_string(&outgoing_message).unwrap().into() + }) + .await?; + + Ok(message) + } + + async fn fetch_attachment_data( + &mut self, + guid: &str, + preview: bool, + ) -> Result { + 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) + .map(ResponseStream::from) + } + + async fn upload_attachment( + &mut self, + 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 encoded_filename = urlencoding::encode(filename); + let endpoint = format!("uploadAttachment?filename={}", encoded_filename); + let mut bytes_opt = Some(bytes); + + let response: UploadAttachmentResponse = self + .deserialized_response_with_body_retry( + &endpoint, + Method::POST, + move || { + Body::from( + bytes_opt + .take() + .expect("Body already consumed during retry"), + ) + }, + false, + ) + .await?; + + Ok(response.guid) + } + + 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), + None => "updates".to_string(), + }; + + let uri = self + .uri_for_endpoint(&endpoint, 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"); + + match &auth { + Some(token) => { + request.headers_mut().insert( + "Authorization", + format!("Bearer: {}", token).parse().unwrap(), + ); + } + None => { + log::warn!(target: "websocket", "Proceeding without auth token."); + } + } + + log::debug!("Websocket request: {:?}", request); + + 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.to_string()).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(e), + }, + + _ => Err(e), + }, + } + } +} + +impl HTTPAPIClient { + pub fn new(base_url: Uri, auth_store: K) -> HTTPAPIClient { + 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 { + let mut parts = self.base_url.clone().into_parts(); + let root_path: PathBuf = parts + .path_and_query + .ok_or(Error::URLError)? + .path() + .into(); + + let path = root_path.join(endpoint); + 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().map_err(|_| Error::URLError)?); + } + + Uri::try_from(parts).map_err(|_| Error::URLError) + } + + fn websocket_scheme(&self) -> &str { + if self.base_url.scheme().unwrap() == "https" { + "wss" + } else { + "ws" + } + } + + async fn deserialized_response( + &mut self, + endpoint: &str, + method: Method, + ) -> Result { + self.deserialized_response_with_body(endpoint, method, Body::empty) + .await + } + + async fn deserialized_response_with_body( + &mut self, + endpoint: &str, + method: Method, + body_fn: impl FnMut() -> Body, + ) -> Result + where + T: DeserializeOwned, + { + self.deserialized_response_with_body_retry(endpoint, method, body_fn, true) + .await + } + + async fn deserialized_response_with_body_retry( + &mut self, + endpoint: &str, + method: Method, + body_fn: impl FnMut() -> 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, + mut body_fn: impl FnMut() -> Body, + retry_auth: bool, + ) -> Result, Error> { + use hyper::StatusCode; + + 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.clone()) + .with_auth_string(auth) + .body(body) + .expect("Unable to build request") + }; + + log::trace!("Obtaining token from auth store"); + let token = self.auth_store.get_token().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() { + StatusCode::OK => { /* cool */ } + + // 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 + ); + 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(), + )); + } + } + + // Other errors: bubble up. + _ => { + 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)); + } + } + + Ok(response) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::api::InMemoryAuthenticationStore; + + #[cfg(test)] + 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, + InMemoryAuthenticationStore::new(Some(credentials)), + ) + } + + #[cfg(test)] + async fn mock_client_is_reachable() -> bool { + let mut client = local_mock_client(); + let version = client.get_version().await; + + match version { + Ok(_) => true, + Err(e) => { + log::error!("Mock client error: {:?}", e); + false + } + } + } + + #[tokio::test] + async fn test_version() { + if !mock_client_is_reachable().await { + log::warn!("Skipping http_client tests (mock server not reachable)"); + return; + } + + let mut 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 { + log::warn!("Skipping http_client tests (mock server not reachable)"); + return; + } + + let mut client = local_mock_client(); + 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, 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(None).await.unwrap(); + assert!(true); + } +} diff --git a/core/kordophone/src/api/mod.rs b/core/kordophone/src/api/mod.rs new file mode 100644 index 0000000..c5a9cd4 --- /dev/null +++ b/core/kordophone/src/api/mod.rs @@ -0,0 +1,78 @@ +pub use crate::model::{Conversation, ConversationID, Message, MessageID, OutgoingMessage}; + +use async_trait::async_trait; +use bytes::Bytes; +use futures_util::Stream; + +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; +use std::fmt::Debug; + +#[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, + conversation_id: &ConversationID, + limit: Option, + before: Option, + after: Option, + ) -> Result, Self::Error>; + + // (POST) /sendMessage + async fn send_message( + &mut self, + outgoing_message: &OutgoingMessage, + ) -> Result; + + // (GET) /attachment + async fn fetch_attachment_data( + &mut self, + guid: &str, + preview: bool, + ) -> Result; + + // (POST) /uploadAttachment + async fn upload_attachment( + &mut self, + data: tokio::io::BufReader, + filename: &str, + size: u64, + ) -> Result + where + R: tokio::io::AsyncRead + Unpin + Send + Sync + 'static; + + // (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, + update_seq: Option, + ) -> Result; +} diff --git a/core/kordophone/src/lib.rs b/core/kordophone/src/lib.rs new file mode 100644 index 0000000..d01fe6f --- /dev/null +++ b/core/kordophone/src/lib.rs @@ -0,0 +1,20 @@ +pub mod api; + +pub mod model; +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(); + } +} diff --git a/core/kordophone/src/model/conversation.rs b/core/kordophone/src/model/conversation.rs new file mode 100644 index 0000000..fa397ea --- /dev/null +++ b/core/kordophone/src/model/conversation.rs @@ -0,0 +1,112 @@ +use serde::Deserialize; +use time::OffsetDateTime; +use uuid::Uuid; + +use super::Identifiable; +use crate::model::message::Message; + +pub type ConversationID = ::ID; + +#[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, + + #[serde(rename = "lastMessage")] + pub last_message: Option, +} + +impl Conversation { + pub fn builder() -> ConversationBuilder { + ConversationBuilder::new() + } +} + +impl Identifiable for Conversation { + type ID = String; + + fn id(&self) -> &Self::ID { + &self.guid + } +} + +#[derive(Default)] +pub struct ConversationBuilder { + guid: Option, + date: Option, + unread_count: Option, + last_message_preview: Option, + participant_display_names: Option>, + display_name: Option, + last_message: 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 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()), + 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_default(), + display_name: self.display_name, + last_message: self.last_message, + } + } +} diff --git a/core/kordophone/src/model/event.rs b/core/kordophone/src/model/event.rs new file mode 100644 index 0000000..2471559 --- /dev/null +++ b/core/kordophone/src/model/event.rs @@ -0,0 +1,39 @@ +use crate::model::{Conversation, Message, UpdateItem}; + +#[derive(Debug, Clone)] +pub struct Event { + pub data: EventData, + pub update_seq: u64, +} + +#[derive(Debug, Clone)] +pub enum EventData { + ConversationChanged(Conversation), + MessageReceived(Conversation, Message), +} + +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: Some(message), + .. + } => Event { + data: EventData::MessageReceived(conversation, message), + update_seq: update.seq, + }, + + _ => panic!("Invalid update item: {:?}", update), + } + } +} diff --git a/core/kordophone/src/model/jwt.rs b/core/kordophone/src/model/jwt.rs new file mode 100644 index 0000000..49d27b9 --- /dev/null +++ b/core/kordophone/src/model/jwt.rs @@ -0,0 +1,146 @@ +use std::error::Error; + +use base64::{ + engine::{self, general_purpose}, + Engine, +}; + +use chrono::{DateTime, Utc}; +use hyper::http::HeaderValue; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Debug, Clone)] +#[allow(dead_code)] +struct JwtHeader { + alg: String, + typ: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +#[allow(dead_code)] +enum ExpValue { + Integer(i64), + String(String), +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +#[allow(dead_code)] +struct JwtPayload { + #[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 { + 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 + let timestamp = DateTime::from_timestamp(payload.exp, 0) + .unwrap() + .naive_utc(); + let expiration_date = DateTime::from_naive_utc_and_offset(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. + + 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 { + JwtToken { + header: JwtHeader { + alg: "none".to_string(), + typ: "JWT".to_string(), + }, + payload: JwtPayload { + exp: 0, + 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() + } + + pub fn to_string(&self) -> String { + self.token.clone() + } +} diff --git a/core/kordophone/src/model/message.rs b/core/kordophone/src/model/message.rs new file mode 100644 index 0000000..2b53bdf --- /dev/null +++ b/core/kordophone/src/model/message.rs @@ -0,0 +1,121 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use time::OffsetDateTime; +use uuid::Uuid; + +use super::Identifiable; + +pub type MessageID = ::ID; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AttributionInfo { + /// Picture width + #[serde(rename = "pgensw")] + pub width: Option, + + /// Picture height + #[serde(rename = "pgensh")] + 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, + + #[serde(rename = "text")] + pub text: String, + + #[serde(rename = "sender")] + pub sender: Option, + + #[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 { + pub fn builder() -> MessageBuilder { + MessageBuilder::new() + } +} + +impl Identifiable for Message { + type ID = String; + + fn id(&self) -> &Self::ID { + &self.guid + } +} + +#[derive(Default)] +pub struct MessageBuilder { + guid: Option, + text: Option, + sender: Option, + date: Option, + file_transfer_guids: Option>, + attachment_metadata: 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 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/core/kordophone/src/model/mod.rs b/core/kordophone/src/model/mod.rs new file mode 100644 index 0000000..70538c7 --- /dev/null +++ b/core/kordophone/src/model/mod.rs @@ -0,0 +1,26 @@ +pub mod conversation; +pub mod event; +pub mod message; +pub mod outgoing_message; +pub mod update; + +pub use conversation::Conversation; +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; + +pub mod jwt; +pub use jwt::JwtToken; + +pub trait Identifiable { + type ID; + fn id(&self) -> &Self::ID; +} diff --git a/core/kordophone/src/model/outgoing_message.rs b/core/kordophone/src/model/outgoing_message.rs new file mode 100644 index 0000000..93da21f --- /dev/null +++ b/core/kordophone/src/model/outgoing_message.rs @@ -0,0 +1,72 @@ +use super::conversation::ConversationID; +use chrono::NaiveDateTime; +use serde::Serialize; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize)] +pub struct OutgoingMessage { + #[serde(skip)] + pub guid: Uuid, + + #[serde(skip)] + pub date: NaiveDateTime, + + #[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 { + guid: Option, + text: Option, + conversation_id: Option, + file_transfer_guids: Option>, +} + +impl OutgoingMessageBuilder { + pub fn new() -> Self { + 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 + } + + 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 { + 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(), + date: chrono::Utc::now().naive_utc(), + } + } +} diff --git a/core/kordophone/src/model/update.rs b/core/kordophone/src/model/update.rs new file mode 100644 index 0000000..fc2061b --- /dev/null +++ b/core/kordophone/src/model/update.rs @@ -0,0 +1,18 @@ +use super::conversation::Conversation; +use super::message::Message; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct UpdateItem { + #[serde(rename = "messageSequenceNumber")] + pub seq: u64, + + #[serde(rename = "conversation")] + pub conversation: Option, + + #[serde(rename = "message")] + pub message: Option, + + #[serde(default)] + pub pong: bool, +} diff --git a/core/kordophone/src/tests/mod.rs b/core/kordophone/src/tests/mod.rs new file mode 100644 index 0000000..ee8b46e --- /dev/null +++ b/core/kordophone/src/tests/mod.rs @@ -0,0 +1,31 @@ +mod test_client; +use self::test_client::TestClient; +use crate::APIInterface; + +pub mod api_interface { + use crate::model::Conversation; + + use super::*; + + #[tokio::test] + async fn test_version() { + let mut client = TestClient::new(); + let version = client.get_version().await.unwrap(); + assert_eq!(version, client.version); + } + + #[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, test_convo.display_name); + } +} diff --git a/core/kordophone/src/tests/test_client.rs b/core/kordophone/src/tests/test_client.rs new file mode 100644 index 0000000..3801765 --- /dev/null +++ b/core/kordophone/src/tests/test_client.rs @@ -0,0 +1,158 @@ +use async_trait::async_trait; +use std::collections::HashMap; + +use time::OffsetDateTime; +use uuid::Uuid; + +pub use crate::APIInterface; +use crate::{ + api::event_socket::{EventSocket, SinkMessage, SocketEvent, SocketUpdate}, + api::http_client::Credentials, + model::{ + Conversation, ConversationID, Event, JwtToken, Message, MessageID, OutgoingMessage, + UpdateItem, + }, +}; + +use bytes::Bytes; +use futures_util::stream::BoxStream; +use futures_util::Sink; +use futures_util::StreamExt; + +pub struct TestClient { + pub version: &'static str, + pub conversations: Vec, + pub messages: HashMap>, +} + +#[derive(Debug)] +pub enum TestError { + ConversationNotFound, +} + +impl TestClient { + pub fn new() -> TestClient { + TestClient { + version: "KordophoneTest-1.0", + conversations: vec![], + messages: HashMap::>::new(), + } + } +} + +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>; + + 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() + } +} + +#[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()) + } + + async fn get_version(&mut self) -> Result { + Ok(self.version.to_string()) + } + + async fn get_conversations(&mut self) -> Result, Self::Error> { + Ok(self.conversations.clone()) + } + + 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()); + } + + 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.clone()) + .date(OffsetDateTime::now_utc()) + .build(); + + 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 { + Ok(TestEventSocket::new()) + } + + async fn fetch_attachment_data( + &mut self, + guid: &str, + preview: bool, + ) -> 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, + size: u64, + ) -> Result + where + R: tokio::io::AsyncRead + Unpin + Send + Sync + 'static, + { + Ok(String::from("test")) + } + + async fn mark_conversation_as_read( + &mut self, + conversation_id: &ConversationID, + ) -> Result<(), Self::Error> { + Ok(()) + } +} diff --git a/core/kordophoned/Cargo.toml b/core/kordophoned/Cargo.toml new file mode 100644 index 0000000..227efa9 --- /dev/null +++ b/core/kordophoned/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "kordophoned" +version = "1.0.1" +edition = "2021" +license = "GPL-3.0" +description = "Client daemon for the Kordophone chat protocol" + +[dependencies] +anyhow = "1.0.98" +async-trait = "0.1.88" +chrono = "0.4.38" +directories = "6.0.0" +env_logger = "0.11.6" +futures-util = "0.3.31" +keyring = { version = "3.6.3", features = ["apple-native", "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" +uuid = "1.16.0" +once_cell = "1.19.0" + +# 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" + +# 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] +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"] } + +[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/core/kordophoned/README.md b/core/kordophoned/README.md new file mode 100644 index 0000000..9691a3c --- /dev/null +++ b/core/kordophoned/README.md @@ -0,0 +1,37 @@ +# 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 +``` + +## Running on macOS + +Before any client can talk to the kordophone daemon on macOS, the XPC service needs to be manually registered with launchd. + +- Register using `launchctl load net.buzzert.kordophonecd.plist` + +Plans are to embed this into the app executable, which would then not need to be manually registered (only via the following Swift code): + +```swift +try? SMAppService.agent(plistName: "net.buzzert.kordophonecd.plist").register() +``` + +and the following in Info.plist: + +```xml +Labelnet.buzzert.kordophonecd +BundleProgramContents/MacOS/kordophoned +MachServicesnet.buzzert.kordophonecd +KeepAlive +``` + diff --git a/core/kordophoned/build.rs b/core/kordophoned/build.rs new file mode 100644 index 0000000..4db9d7e --- /dev/null +++ b/core/kordophoned/build.rs @@ -0,0 +1,29 @@ +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(); + 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/core/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/core/kordophoned/include/net.buzzert.kordophonecd.Server.xml new file mode 100644 index 0000000..cdef983 --- /dev/null +++ b/core/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/kordophoned/include/net.buzzert.kordophonecd.plist b/core/kordophoned/include/net.buzzert.kordophonecd.plist new file mode 100644 index 0000000..df9c1a1 --- /dev/null +++ b/core/kordophoned/include/net.buzzert.kordophonecd.plist @@ -0,0 +1,35 @@ + + + + + Label + net.buzzert.kordophonecd + + ProgramArguments + + /Users/buzzert/src/kordophone/kordophone-rs/target/debug/kordophoned + + + EnvironmentVariables + + RUST_LOG + info + + + MachServices + + net.buzzert.kordophonecd + + + + RunAtLoad + + KeepAlive + + + StandardOutPath + /tmp/kordophoned.out.log + StandardErrorPath + /tmp/kordophoned.err.log + + diff --git a/core/kordophoned/include/net.buzzert.kordophonecd.service b/core/kordophoned/include/net.buzzert.kordophonecd.service new file mode 100644 index 0000000..b0e7309 --- /dev/null +++ b/core/kordophoned/include/net.buzzert.kordophonecd.service @@ -0,0 +1,4 @@ +[D-BUS Service] +Name=net.buzzert.kordophonecd +Exec=/usr/libexec/kordophoned + diff --git a/core/kordophoned/src/daemon/attachment_store.rs b/core/kordophoned/src/daemon/attachment_store.rs new file mode 100644 index 0000000..c699503 --- /dev/null +++ b/core/kordophoned/src/daemon/attachment_store.rs @@ -0,0 +1,280 @@ +use std::{ + io::{BufWriter, Write}, + path::PathBuf, +}; + +use anyhow::Result; +use futures_util::StreamExt; +use kordophone::APIInterface; +use thiserror::Error; + +use kordophone_db::database::Database; + +use crate::daemon::events::Event as DaemonEvent; +use crate::daemon::events::Reply; +use crate::daemon::models::Attachment; +use crate::daemon::Daemon; + +use std::sync::Arc; +use tokio::sync::mpsc::{Receiver, Sender}; +use tokio::sync::Mutex; + +use uuid::Uuid; + +mod target { + pub static ATTACHMENTS: &str = "attachments"; +} + +#[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), + + // 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)] +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), +} + +pub struct AttachmentStore { + store_path: PathBuf, + database: Arc>, + daemon_event_sink: Sender, + + event_source: Receiver, + event_sink: Option>, +} + +impl AttachmentStore { + 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 + 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_event_sink(&mut self) -> Sender { + self.event_sink.take().unwrap() + } + + 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, + } + } + + 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::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::warn!(target: target::ATTACHMENTS, "Temporary file already exists: {}, assuming download is in progress", temporary_path.display()); + return Err(AttachmentStoreError::DownloadAlreadyInProgress.into()); + } + + 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); + 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)))?; + + 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::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()); + daemon_event_sink.send(event).await.unwrap(); + + 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 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()); + 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! { + 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); + if !attachment.is_downloaded(preview) { + 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::debug!(target: target::ATTACHMENTS, "Attachment already downloaded: {}", guid); + } + } + + AttachmentStoreEvent::GetAttachmentInfo(guid, reply) => { + 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/core/kordophoned/src/daemon/auth_store.rs b/core/kordophoned/src/daemon/auth_store.rs new file mode 100644 index 0000000..3f7c21b --- /dev/null +++ b/core/kordophoned/src/daemon/auth_store.rs @@ -0,0 +1,87 @@ +use crate::daemon::SettingsKey; + +use keyring::{Entry, Result}; +use std::sync::Arc; +use tokio::sync::Mutex; + +use kordophone::api::{http_client::Credentials, AuthenticationStore}; +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 + }); + + match username { + Some(username) => { + 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 }), + 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| 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: String) { + self.database + .lock() + .await + .with_settings(|settings| settings.put(SettingsKey::TOKEN, &token)) + .await + .unwrap_or_else(|e| { + log::error!("Failed to set token: {}", e); + }); + } +} diff --git a/core/kordophoned/src/daemon/contact_resolver/eds.rs b/core/kordophoned/src/daemon/contact_resolver/eds.rs new file mode 100644 index 0000000..98f5bd5 --- /dev/null +++ b/core/kordophoned/src/daemon/contact_resolver/eds.rs @@ -0,0 +1,294 @@ +use super::ContactResolverBackend; +use dbus::arg::{RefArg, Variant}; +use dbus::blocking::Connection; +use once_cell::sync::OnceCell; +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::Duration; + +#[derive(Clone)] +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(); + +/// 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(); + +/// 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)) + .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 +/// 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, 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()?; + 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()) + } 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)) +} + +/// 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 = match obtain_handle() { + Some(h) => h, + None => return None, + }; + + 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 { + 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() + .skip_while(|c| c.is_numeric() || *c == '(' || *c == ')') + .collect::() + .chars() + .filter(|c| c.is_numeric()) + .collect::(); + + if !local_address.is_empty() { + filters.push(format!("(is \"phone\" \"{}\")", local_address)); + } + + format!("(or {})", filters.join(" ")) + }; + + 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", + (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 handle = match obtain_handle() { + Some(h) => h, + None => return None, + }; + + 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", + (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 + } +} diff --git a/core/kordophoned/src/daemon/contact_resolver/generic.rs b/core/kordophoned/src/daemon/contact_resolver/generic.rs new file mode 100644 index 0000000..7e9cd24 --- /dev/null +++ b/core/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 + } +} diff --git a/core/kordophoned/src/daemon/contact_resolver/mod.rs b/core/kordophoned/src/daemon/contact_resolver/mod.rs new file mode 100644 index 0000000..e391b80 --- /dev/null +++ b/core/kordophoned/src/daemon/contact_resolver/mod.rs @@ -0,0 +1,107 @@ +#[cfg(target_os = "linux")] +pub mod eds; + +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; + +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; + +#[derive(Clone)] +pub struct ContactResolver { + backend: T, + display_name_cache: HashMap, + contact_id_cache: HashMap, +} + +impl ContactResolver +where + T::ContactID: From, + T::ContactID: Into, + T: Default, +{ + pub fn new(backend: T) -> Self { + Self { + backend, + display_name_cache: HashMap::new(), + contact_id_cache: HashMap::new(), + } + } + + 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(&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()); + 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 + } +} + +impl Default for ContactResolver +where + T::ContactID: From, + T::ContactID: Into, + T: Default, +{ + fn default() -> Self { + Self::new(T::default()) + } +} diff --git a/core/kordophoned/src/daemon/events.rs b/core/kordophoned/src/daemon/events.rs new file mode 100644 index 0000000..debdbd6 --- /dev/null +++ b/core/kordophoned/src/daemon/events.rs @@ -0,0 +1,103 @@ +use tokio::sync::oneshot; +use uuid::Uuid; + +use kordophone::model::ConversationID; +use kordophone::model::OutgoingMessage; +use kordophone_db::models::Conversation; + +use crate::daemon::settings::Settings; +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. + 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<()>), + + /// 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, + + /// Returns all known conversations from the database. + /// 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), + + /// 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>), + + /// 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. + /// - attachment_guids: The GUIDs of the attachments to send. + /// - reply: The outgoing message ID (not the server-assigned message ID). + SendMessage(String, String, Vec, 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), + + /// Gets an attachment object from the attachment store. + /// Parameters: + /// - guid: The attachment guid + /// - 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 + /// - preview: Whether to download the preview (true) or full attachment (false) + /// - reply: Reply indicating success or failure + DownloadAttachment(String, bool, Reply<()>), + + /// 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), + + /// 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/core/kordophoned/src/daemon/mod.rs b/core/kordophoned/src/daemon/mod.rs new file mode 100644 index 0000000..ea7be0b --- /dev/null +++ b/core/kordophoned/src/daemon/mod.rs @@ -0,0 +1,745 @@ +pub mod settings; +use settings::keys as SettingsKey; +use settings::Settings; + +pub mod events; +use events::*; + +pub mod signals; +use signals::*; + +use anyhow::Result; +use directories::ProjectDirs; + +use std::collections::HashMap; +use std::error::Error; +use std::path::PathBuf; +use std::sync::Arc; + +use thiserror::Error; +use tokio::sync::mpsc::{Receiver, Sender}; +use tokio::sync::Mutex; +use uuid::Uuid; + +use kordophone_db::{ + database::{Database, DatabaseAccess}, + models::Conversation, +}; + +use kordophone::api::http_client::HTTPAPIClient; +use kordophone::api::APIInterface; +use kordophone::model::outgoing_message::OutgoingMessage; +use kordophone::model::{ConversationID, MessageID}; + +mod update_monitor; +use update_monitor::{UpdateMonitor, UpdateMonitorCommand}; + +mod auth_store; +use auth_store::DatabaseAuthenticationStore; + +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::AttachmentStore; +pub use attachment_store::AttachmentStoreEvent; + +pub mod contact_resolver; +use contact_resolver::ContactResolver; +use contact_resolver::DefaultContactResolverBackend; + +use kordophone_db::models::participant::Participant as DbParticipant; + +#[derive(Debug, Error)] +pub enum DaemonError { + #[error("Client Not Configured")] + ClientNotConfigured, +} + +pub type DaemonResult = Result>; + +pub mod target { + pub static SYNC: &str = "sync"; + pub static EVENT: &str = "event"; + pub static SETTINGS: &str = "settings"; + pub static UPDATES: &str = "updates"; + pub static ATTACHMENTS: &str = "attachments"; + pub static DAEMON: &str = "daemon"; +} + +pub struct Daemon { + pub event_sender: Sender, + event_receiver: Receiver, + + signal_receiver: Option>, + signal_sender: Sender, + + post_office_sink: Sender, + post_office_source: Option>, + + outgoing_messages: HashMap>, + + attachment_store_sink: Option>, + update_monitor_command_tx: Option>, + + version: String, + database: Arc>, + runtime: tokio::runtime::Runtime, +} + +impl Daemon { + 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)?; + + // 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() + .build() + .unwrap(); + + let database_impl = Database::new(&database_path.to_string_lossy())?; + let database = Arc::new(Mutex::new(database_impl)); + + Ok(Self { + version: env!("CARGO_PKG_VERSION").to_string(), + database, + event_receiver, + event_sender, + signal_receiver: Some(signal_receiver), + signal_sender, + post_office_sink, + post_office_source: Some(post_office_source), + outgoing_messages: HashMap::new(), + attachment_store_sink: None, + update_monitor_command_tx: None, + runtime, + }) + } + + pub async fn run(&mut self) { + 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()); + self.update_monitor_command_tx = Some(update_monitor.take_command_channel()); + 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; + }); + } + + // Attachment store + 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; + }); + + while let Some(event) = self.event_receiver.recv().await { + log::debug!(target: target::EVENT, "Received event: {:?}", event); + self.handle_event(event).await; + } + } + + 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) => { + reply.send(self.version.clone()).unwrap(); + } + + Event::SyncConversationList(reply) => { + self.spawn_conversation_list_sync(); + + // 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; + if let Err(e) = result { + log::error!(target: target::SYNC, "Error handling sync event: {}", e); + } + }); + + // 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; + if let Err(e) = result { + log::error!(target: target::SYNC, "Error handling sync event: {}", e); + } + }); + + 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"); + + // 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(); + } + + 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() + }); + + reply.send(settings).unwrap(); + } + + Event::UpdateSettings(settings, reply) => { + 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); + }); + + // 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); + }); + + // Do a sync-list to get the new conversations. + self.spawn_conversation_list_sync(); + + // Also restart the update monitor. + 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); + } + } + + 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); + }); + + reply.send(()).unwrap(); + } + + Event::SendMessage(conversation_id, text, attachment_guids, reply) => { + let conversation_id = conversation_id.clone(); + let uuid = self + .enqueue_outgoing_message(text, conversation_id.clone(), attachment_guids) + .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.into())) + .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(); + } + + Event::GetAttachment(guid, reply) => { + self.attachment_store_sink + .as_ref() + .unwrap() + .send(AttachmentStoreEvent::GetAttachmentInfo(guid, reply)) + .await + .unwrap(); + } + + Event::DownloadAttachment(attachment_id, preview, reply) => { + log::debug!(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(); + } + + Event::AttachmentDownloaded(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 + .send(Signal::AttachmentDownloaded(attachment_id)) + .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(); + } + } + } + + /// 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_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. + 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() + .map(|m| m.into()) // Convert db::Message to daemon::Message + .chain(outgoing_messages.into_iter().map(|m| m.into())) + .collect() + }) + .await + } + + 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. + 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(); + + guid + } + + 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(); + let mut contact_resolver = ContactResolver::new(DefaultContactResolverBackend::default()); + for conversation in db_conversations { + // Insert or update conversation and its participants + database + .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 { + 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) + }) + .await; + } else { + log::trace!(target: target::SYNC, "No contact id found for participant: {}", handle); + } + } + } + } + } + + // Send conversations updated signal + signal_sender.send(Signal::ConversationsUpdated).await?; + + log::info!(target: target::SYNC, "List synchronized: {} conversations", num_conversations); + Ok(()) + } + + 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() + .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?; + + // Sync individual conversation. + Self::sync_conversation_impl(database, signal_sender, conversation_id).await?; + } + + // Send conversations updated signal. + signal_sender.send(Signal::ConversationsUpdated).await?; + + 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<()> { + 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?; + 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?; + } + + // 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?; + + // 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()) + || !m.file_transfer_guids.is_empty() + }) + .collect(); + + let db_messages: Vec = insertable_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::debug!(target: target::SYNC, "Synchronized {} messages for conversation {}", num_messages, &conversation_id); + 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) + } + + async fn update_settings(&mut self, settings: &Settings) -> Result<()> { + self.database.with_settings(|s| settings.save(s)).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( + 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()), + ); + + 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_data_dir() -> Option { + ProjectDirs::from("net", "buzzert", "kordophonecd").map(|p| PathBuf::from(p.data_dir())) + } + + fn get_database_path() -> PathBuf { + 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 + PathBuf::from("database.db") + } + } +} diff --git a/core/kordophoned/src/daemon/models/attachment.rs b/core/kordophoned/src/daemon/models/attachment.rs new file mode 100644 index 0000000..777fff7 --- /dev/null +++ b/core/kordophoned/src/daemon/models/attachment.rs @@ -0,0 +1,83 @@ +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) -> 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_for_preview(preview)).expect( + format!( + "Wasn't able to check for the existence of an attachment file path at {}", + &self.get_path_for_preview(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, + } + } +} diff --git a/core/kordophoned/src/daemon/models/message.rs b/core/kordophoned/src/daemon/models/message.rs new file mode 100644 index 0000000..39bab29 --- /dev/null +++ b/core/kordophoned/src/daemon/models/message.rs @@ -0,0 +1,186 @@ +use chrono::DateTime; +use chrono::NaiveDateTime; + +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 { + handle: String, + contact_id: Option, + }, +} + +impl From for Participant { + fn from(display_name: String) -> Self { + Participant::Remote { + handle: display_name, + contact_id: None, + } + } +} + +impl From<&str> for Participant { + fn from(display_name: &str) -> Self { + Participant::Remote { + handle: display_name.to_string(), + contact_id: None, + } + } +} + +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 { handle, contact_id } => { + Participant::Remote { handle, contact_id } + } + } + } +} + +impl Participant { + pub fn display_name(&self) -> String { + match self { + Participant::Me => "(Me)".to_string(), + Participant::Remote { handle, .. } => handle.clone(), + } + } +} + +#[derive(Clone, Debug)] +pub struct Message { + pub id: String, + pub sender: Participant, + pub text: String, + pub date: NaiveDateTime, + pub attachments: 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, + ); + 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 { handle, contact_id } => { + kordophone_db::models::Participant::Remote { handle, contact_id } + } + }, + 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 { + handle: sender, + contact_id: None, + }, + 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 + } + } +} + +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(), + }, + } + } +} diff --git a/core/kordophoned/src/daemon/models/mod.rs b/core/kordophoned/src/daemon/models/mod.rs new file mode 100644 index 0000000..9c8ba1c --- /dev/null +++ b/core/kordophoned/src/daemon/models/mod.rs @@ -0,0 +1,5 @@ +pub mod attachment; +pub mod message; + +pub use attachment::Attachment; +pub use message::Message; diff --git a/core/kordophoned/src/daemon/post_office.rs b/core/kordophoned/src/daemon/post_office.rs new file mode 100644 index 0000000..60c52ae --- /dev/null +++ b/core/kordophoned/src/daemon/post_office.rs @@ -0,0 +1,130 @@ +use std::collections::VecDeque; +use std::time::Duration; + +use tokio::sync::mpsc::{Receiver, Sender}; +use tokio::sync::Mutex; +use tokio_condvar::Condvar; + +use crate::daemon::events::Event as DaemonEvent; +use kordophone::api::APIInterface; +use kordophone::model::outgoing_message::OutgoingMessage; + +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(); + 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"); + + // 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; + } + } + } + + 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( + 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."); + 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; + retry_messages.push(message); + } + } + + retry_messages + } +} diff --git a/core/kordophoned/src/daemon/settings.rs b/core/kordophoned/src/daemon/settings.rs new file mode 100644 index 0000000..3c145f4 --- /dev/null +++ b/core/kordophoned/src/daemon/settings.rs @@ -0,0 +1,48 @@ +use anyhow::Result; +use kordophone_db::settings::Settings as DbSettings; + +pub mod keys { + pub static SERVER_URL: &str = "ServerURL"; + pub static USERNAME: &str = "Username"; + pub static TOKEN: &str = "Token"; +} + +#[derive(Debug, Default)] +pub struct Settings { + pub server_url: Option, + pub username: Option, + pub token: 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 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<()> { + 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(token) = &self.token { + db_settings.put(keys::TOKEN, &token)?; + } + Ok(()) + } +} diff --git a/core/kordophoned/src/daemon/signals.rs b/core/kordophoned/src/daemon/signals.rs new file mode 100644 index 0000000..d2a4cfa --- /dev/null +++ b/core/kordophoned/src/daemon/signals.rs @@ -0,0 +1,24 @@ +#[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), + + /// Emitted when an attachment has been downloaded. + /// 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), + + /// Emitted when the update stream is reconnected after a timeout or configuration change. + UpdateStreamReconnected, +} diff --git a/core/kordophoned/src/daemon/update_monitor.rs b/core/kordophoned/src/daemon/update_monitor.rs new file mode 100644 index 0000000..860be9c --- /dev/null +++ b/core/kordophoned/src/daemon/update_monitor.rs @@ -0,0 +1,241 @@ +use crate::daemon::{ + events::{Event, Reply}, + target, Daemon, DaemonResult, +}; + +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; + +use kordophone_db::database::Database; +use kordophone_db::database::DatabaseAccess; + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +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, + update_seq: Option, + first_connection: bool, +} + +impl UpdateMonitor { + pub fn new(database: Arc>, event_sender: Sender) -> Self { + let (command_tx, command_rx) = tokio::sync::mpsc::channel(100); + 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 fn take_command_channel(&mut self) -> Sender { + self.command_tx.take().unwrap() + } + + 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.data { + UpdateEventData::ConversationChanged(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 = + 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(1) { + log::warn!(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(); + match (&last_message, &conversation.last_message) { + (Some(message), Some(conversation_message)) => { + if message.id == conversation_message.guid { + log::warn!(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: {}", conversation.guid); + self.send_event(|r| Event::SyncConversation(conversation.guid, r)) + .await + .unwrap_or_else(|e| { + log::error!("Failed to send daemon event: {}", e); + }); + } + + 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 + .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(self.update_seq).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, 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(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(socket_event) => { + match socket_event { + kordophone::api::event_socket::SocketEvent::Update(event) => { + self.handle_update(event).await; + } + + 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) => { + log::error!("Error in event stream: {}", e); + self.first_connection = true; + break; // Break inner loop to reconnect + } + } + } + + _ = timeout.tick() => { + // 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; + } + } + } + + Some(command) = self.command_rx.recv() => { + match command { + UpdateMonitorCommand::Restart => { + log::info!(target: target::UPDATES, "Restarting update monitor"); + self.first_connection = true; + break; + } + } + } + } + } + + // Add a small delay before reconnecting to avoid tight reconnection loops + tokio::time::sleep(Duration::from_secs(1)).await; + } + } +} diff --git a/core/kordophoned/src/dbus/agent.rs b/core/kordophoned/src/dbus/agent.rs new file mode 100644 index 0000000..d284132 --- /dev/null +++ b/core/kordophoned/src/dbus/agent.rs @@ -0,0 +1,512 @@ +use dbus::arg; +use dbus_tree::MethodErr; +use std::sync::Arc; +use std::{future::Future, thread}; +use tokio::sync::{mpsc, oneshot, Mutex}; + +use kordophoned::daemon::{ + contact_resolver::{ContactResolver, DefaultContactResolverBackend}, + events::{Event, Reply}, + settings::Settings, + signals::Signal, + DaemonResult, +}; + +use kordophone_db::models::participant::Participant; + +use crate::dbus::endpoint::DbusRegistry; +use crate::dbus::interface; +use crate::dbus::interface::signals as DbusSignals; +use dbus_tokio::connection; + +#[derive(Clone)] +pub struct DBusAgent { + event_sink: mpsc::Sender, + signal_receiver: Arc>>>, + contact_resolver: ContactResolver, +} + +impl DBusAgent { + pub fn new(event_sink: mpsc::Sender, signal_receiver: mpsc::Receiver) -> Self { + Self { + event_sink, + signal_receiver: Arc::new(Mutex::new(Some(signal_receiver))), + contact_resolver: ContactResolver::new(DefaultContactResolverBackend::default()), + } + } + + 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)) + .await + .map_err(|_| "Failed to send event")?; + + reply_rx.await.map_err(|_| "Failed to receive reply".into()) + } + + 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))) + } + + 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()), + + // Remote participant without a resolved contact_id + Participant::Remote { handle, .. } => handle.clone(), + } + } +} + +// +// 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, 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| self.resolve_participant_display_name(&p)) + .collect::>(), + )), + ); + map.insert( + "date".into(), + arg::Variant(Box::new(conv.date.and_utc().timestamp())), + ); + map + }) + .collect() + }) + } + + fn sync_conversation_list(&mut self) -> Result<(), MethodErr> { + self.send_event_sync(Event::SyncConversationList) + } + + fn sync_all_conversations(&mut self) -> Result<(), MethodErr> { + self.send_event_sync(Event::SyncAllConversations) + } + + fn sync_conversation(&mut self, conversation_id: String) -> Result<(), MethodErr> { + 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 + } else { + Some(last_message_id) + }; + + 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))); + + // Remove the attachment placeholder here. + 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())), + ); + map.insert( + "sender".into(), + arg::Variant(Box::new( + self.resolve_participant_display_name(&msg.sender.into()), + )), + ); + + // 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())), + ); + + // 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); + + 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)), + ); + + // Metadata + if let Some(ref metadata) = attachment.metadata { + let mut metadata_map = arg::PropMap::new(); + + 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)), + ); + } + + attachment_map + }) + .collect(); + + map.insert("attachments".into(), arg::Variant(Box::new(attachments))); + map + }) + .collect() + }) + } + + fn delete_all_conversations(&mut self) -> Result<(), MethodErr> { + self.send_event_sync(Event::DeleteAllConversations) + } + + fn send_message( + &mut self, + conversation_id: String, + text: String, + attachment_guids: Vec, + ) -> 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), 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> { + 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)) + } +} + +// +// 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 { + server_url: Some(url), + username: Some(user), + token: None, + }, + r, + ) + }) + } + + 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<(), MethodErr> { + self.send_event_sync(|r| { + Event::UpdateSettings( + Settings { + server_url: Some(value), + username: None, + token: None, + }, + r, + ) + }) + } + + fn username(&self) -> Result { + self.send_event_sync(Event::GetAllSettings) + .map(|settings| settings.username.unwrap_or_default()) + } + + fn set_username(&self, value: String) -> Result<(), MethodErr> { + self.send_event_sync(|r| { + Event::UpdateSettings( + Settings { + server_url: None, + username: Some(value), + token: None, + }, + r, + ) + }) + } +} + +// +// Helper utilities. +// + +fn run_sync_future(f: F) -> Result +where + T: Send, + F: Future + Send, +{ + 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") +} diff --git a/core/kordophoned/src/dbus/endpoint.rs b/core/kordophoned/src/dbus/endpoint.rs new file mode 100644 index 0000000..f6b8ef3 --- /dev/null +++ b/core/kordophoned/src/dbus/endpoint.rs @@ -0,0 +1,75 @@ +use log::info; +use std::sync::{Arc, Mutex}; + +use dbus::{ + channel::{MatchingReceiver, Sender}, + message::MatchRule, + nonblock::SyncConnection, + Path, +}; +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) + } +} diff --git a/core/kordophoned/src/dbus/mod.rs b/core/kordophoned/src/dbus/mod.rs new file mode 100644 index 0000000..8552c4e --- /dev/null +++ b/core/kordophoned/src/dbus/mod.rs @@ -0,0 +1,19 @@ +pub mod agent; +pub mod endpoint; + +pub mod interface { + #![allow(unused)] + + pub const NAME: &str = "net.buzzert.kordophonecd"; + pub const OBJECT_PATH: &str = "/net/buzzert/kordophonecd/daemon"; + + include!(concat!(env!("OUT_DIR"), "/kordophone-server.rs")); + + pub mod signals { + 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/core/kordophoned/src/lib.rs b/core/kordophoned/src/lib.rs new file mode 100644 index 0000000..d6bbb01 --- /dev/null +++ b/core/kordophoned/src/lib.rs @@ -0,0 +1 @@ +pub mod daemon; diff --git a/core/kordophoned/src/main.rs b/core/kordophoned/src/main.rs new file mode 100644 index 0000000..3f1e8dd --- /dev/null +++ b/core/kordophoned/src/main.rs @@ -0,0 +1,75 @@ +#[cfg(target_os = "linux")] +mod dbus; + +#[cfg(target_os = "macos")] +mod xpc; + +use log::LevelFilter; +use std::future; + +use kordophoned::daemon::Daemon; + +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_millis() + .filter_level(log_level) + .init(); +} + +#[cfg(target_os = "linux")] +async fn start_ipc_agent(daemon: &mut 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: &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()); + 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")))] +async fn start_ipc_agent(daemon: &mut Daemon) { + panic!("Unsupported IPC platform"); +} + +#[tokio::main] +async fn main() { + initialize_logging(); + + // Create the daemon + let mut daemon = Daemon::new() + .map_err(|e| { + log::error!("Failed to initialize daemon: {}", e); + std::process::exit(1); + }) + .unwrap(); + + // Start the IPC agent. + start_ipc_agent(&mut daemon).await; + + // Run the main daemon loop. + daemon.run().await; + + // Keep the process alive as long as any background tasks are running. + future::pending::<()>().await; +} diff --git a/core/kordophoned/src/xpc/agent.rs b/core/kordophoned/src/xpc/agent.rs new file mode 100644 index 0000000..6ebdf79 --- /dev/null +++ b/core/kordophoned/src/xpc/agent.rs @@ -0,0 +1,190 @@ +use crate::xpc::interface::SERVICE_NAME; +use kordophoned::daemon::{events::Event, signals::Signal, DaemonResult}; +use std::ffi::CString; +use std::os::raw::c_char; +use std::ptr; +use std::sync::Arc; +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; + +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(crate) struct XpcConn(pub xpc_sys::xpc_connection_t); +unsafe impl Send for XpcConn {} +unsafe impl Sync for XpcConn {} + +type Subscribers = Arc>>; + +#[derive(Clone)] +pub struct XpcAgent { + event_sink: mpsc::Sender, + signal_receiver: Arc>>>, +} + +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))), + } + } + + pub async fn run(self) { + use block::ConcreteBlock; + use std::ops::Deref; + + // 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 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; + } + }; + + let connections: Subscribers = Arc::new(std::sync::Mutex::new(Vec::new())); + { + let receiver_arc = self.signal_receiver.clone(); + 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 { + log::trace!(target: LOG_TARGET, "Broadcasting signal: {:?}", 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()); + for c in list.iter() { + 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) }; + } + }); + } + + 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, + ) + }; + + 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 { + let client = event as xpc_sys::xpc_connection_t; + log::trace!(target: LOG_TARGET, "New XPC connection accepted"); + + 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 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(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); + }) + .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); + + // 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); + } + } + 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); + } + }) + .copy(); + + unsafe { + xpc_sys::xpc_connection_set_event_handler( + service, + service_handler.deref() as *const _ as *mut _, + ); + xpc_sys::xpc_connection_resume(service); + } + + futures_util::future::pending::<()>().await; + } + + pub async fn send_event( + &self, + make_event: impl FnOnce(kordophoned::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/core/kordophoned/src/xpc/interface.rs b/core/kordophoned/src/xpc/interface.rs new file mode 100644 index 0000000..9418b17 --- /dev/null +++ b/core/kordophoned/src/xpc/interface.rs @@ -0,0 +1,5 @@ +#![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"; diff --git a/core/kordophoned/src/xpc/mod.rs b/core/kordophoned/src/xpc/mod.rs new file mode 100644 index 0000000..d2bf926 --- /dev/null +++ b/core/kordophoned/src/xpc/mod.rs @@ -0,0 +1,24 @@ +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/core/kordophoned/src/xpc/rpc.rs b/core/kordophoned/src/xpc/rpc.rs new file mode 100644 index 0000000..0d8b5c3 --- /dev/null +++ b/core/kordophoned/src/xpc/rpc.rs @@ -0,0 +1,450 @@ +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::*; +use super::DispatchResult; + +pub async fn dispatch( + agent: &XpcAgent, + subscribers: &std::sync::Mutex>, + current_client: xpc_sys::xpc_connection_t, + root: &HashMap, +) -> 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 DispatchResult::new(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); + DispatchResult::new(Message::Dictionary(reply)) + } + Err(e) => DispatchResult::new(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)); + DispatchResult::new(Message::Dictionary(reply)) + } + Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))), + } + } + + // Sync ops + "SyncConversationList" => match agent.send_event(Event::SyncConversationList).await { + 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(()) => 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 DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")), + }; + match agent + .send_event(|r| Event::SyncConversation(conversation_id, r)) + .await + { + Ok(()) => DispatchResult::new(make_ok_reply()), + Err(e) => DispatchResult::new(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 DispatchResult::new(make_error_reply("InvalidRequest", "Missing conversation_id")), + }; + match agent + .send_event(|r| Event::MarkConversationAsRead(conversation_id, r)) + .await + { + Ok(()) => DispatchResult::new(make_ok_reply()), + Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))), + } + } + + // GetMessages + "GetMessages" => { + let args = match get_dictionary_field(root, "arguments") { + Some(a) => a, + 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 DispatchResult::new(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()); + + // 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)); + + // 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(); + dict_put_str(&mut reply, "type", "GetMessagesResponse"); + reply.insert(cstr("messages"), Message::Array(items)); + DispatchResult::new(Message::Dictionary(reply)) + } + Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))), + } + } + + // Delete all + "DeleteAllConversations" => match agent.send_event(Event::DeleteAllConversations).await { + 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 DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")), + }; + let conversation_id = match dict_get_str(args, "conversation_id") { + Some(v) => v, + 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")) { + 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()); + DispatchResult::new(Message::Dictionary(reply)) + } + Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))), + } + } + + // GetAttachmentInfo + "GetAttachmentInfo" => { + let args = match get_dictionary_field(root, "arguments") { + Some(a) => a, + 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 DispatchResult::new(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(), + ); + DispatchResult::new(Message::Dictionary(reply)) + } + Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))), + } + } + + // OpenAttachmentFd (return file descriptor in reply) + "OpenAttachmentFd" => { + let args = match get_dictionary_field(root, "arguments") { + Some(a) => a, + 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 DispatchResult::new(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) => { + use std::os::fd::AsRawFd; + + 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(); + + let mut reply: XpcMap = HashMap::new(); + dict_put_str(&mut reply, "type", "OpenAttachmentFdResponse"); + reply.insert(cstr("fd"), Message::Fd(fd)); + + DispatchResult { message: Message::Dictionary(reply), cleanup: Some(Box::new(file)) } + } + Err(e) => DispatchResult::new(make_error_reply("OpenFailed", &format!("{}", e))), + } + } + Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))), + } + } + + // DownloadAttachment + "DownloadAttachment" => { + let args = match get_dictionary_field(root, "arguments") { + Some(a) => a, + 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 DispatchResult::new(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(()) => DispatchResult::new(make_ok_reply()), + Err(e) => DispatchResult::new(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 DispatchResult::new(make_error_reply("InvalidRequest", "Missing arguments")), + }; + let path = match dict_get_str(args, "path") { + Some(v) => v, + None => return DispatchResult::new(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); + DispatchResult::new(Message::Dictionary(reply)) + } + Err(e) => DispatchResult::new(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(), + ); + DispatchResult::new(Message::Dictionary(reply)) + } + Err(e) => DispatchResult::new(make_error_reply("DaemonError", &format!("{}", e))), + }, + "UpdateSettings" => { + let args = match get_dictionary_field(root, "arguments") { + Some(a) => a, + 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"); + let settings = Settings { + server_url, + username, + token: None, + }; + match agent + .send_event(|r| Event::UpdateSettings(settings, r)) + .await + { + Ok(()) => DispatchResult::new(make_ok_reply()), + Err(e) => DispatchResult::new(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()); + } + DispatchResult::new(make_ok_reply()) + } + + // Unknown method fallback + other => DispatchResult::new(make_error_reply("UnknownMethod", other)), + }; + + response.message = attach_request_id(response.message, request_id); + response +} diff --git a/core/kordophoned/src/xpc/util.rs b/core/kordophoned/src/xpc/util.rs new file mode 100644 index 0000000..8efcb81 --- /dev/null +++ b/core/kordophoned/src/xpc/util.rs @@ -0,0 +1,100 @@ +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) +} diff --git a/core/kpcli/.gitignore b/core/kpcli/.gitignore new file mode 100644 index 0000000..fb016c7 --- /dev/null +++ b/core/kpcli/.gitignore @@ -0,0 +1,4 @@ +.env +.env.* + + diff --git a/core/kpcli/Cargo.toml b/core/kpcli/Cargo.toml new file mode 100644 index 0000000..da2843e --- /dev/null +++ b/core/kpcli/Cargo.toml @@ -0,0 +1,38 @@ +[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] +anyhow = "1.0.93" +clap = { version = "4.5.20", features = ["derive"] } +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" +pretty = { version = "0.12.3", features = ["termcolor"] } +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] +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" + +# XPC (libxpc) interface only on macOS +[target.'cfg(target_os = "macos")'.dependencies] +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/core/kpcli/build.rs b/core/kpcli/build.rs new file mode 100644 index 0000000..4755d5b --- /dev/null +++ b/core/kpcli/build.rs @@ -0,0 +1,27 @@ +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"); + + 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/core/kpcli/src/client/mod.rs b/core/kpcli/src/client/mod.rs new file mode 100644 index 0000000..679e795 --- /dev/null +++ b/core/kpcli/src/client/mod.rs @@ -0,0 +1,178 @@ +use kordophone::api::event_socket::{EventSocket, SocketEvent, SocketUpdate}; +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 kordophone::model::event::EventData; +use kordophone::model::outgoing_message::OutgoingMessage; + +use futures_util::StreamExt; + +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(), + InMemoryAuthenticationStore::new(Some(credentials)), + ) +} + +#[derive(Subcommand)] +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, + + /// Prints all events from the server. + Events, + + /// Prints all raw updates from the server. + RawUpdates, + + /// Sends a message to the server. + SendMessage { + conversation_id: String, + message: String, + }, + + /// Marks a conversation as read. + Mark { conversation_id: String }, +} + +impl Commands { + pub async fn run(cmd: Commands) -> Result<()> { + let mut client = ClientCli::new(); + match cmd { + 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, + Commands::SendMessage { + conversation_id, + message, + } => client.send_message(conversation_id, message).await, + Commands::Mark { conversation_id } => { + client.mark_conversation_as_read(conversation_id).await + } + } + } +} + +struct ClientCli { + api: HTTPAPIClient, +} + +impl ClientCli { + pub fn new() -> Self { + let api = make_api_client_from_env(); + Self { api } + } + + 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<()> { + let conversations = self.api.get_conversations().await?; + for conversation in conversations { + println!("{}", ConversationPrinter::new(&conversation.into())); + } + + Ok(()) + } + + pub async fn print_messages(&mut self, conversation_id: String) -> Result<()> { + let messages = self + .api + .get_messages(&conversation_id, None, None, None) + .await?; + for message in messages { + println!("{}", MessagePrinter::new(&message.into())); + } + Ok(()) + } + + 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(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::Pong => { + println!("Pong"); + } + } + } + Ok(()) + } + + pub async fn print_raw_updates(&mut self) -> Result<()> { + let socket = self.api.open_event_socket(None).await?; + + println!("Listening for raw updates..."); + let mut stream = socket.raw_updates().await; + 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(()) + } + + 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(()) + } + + 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/core/kpcli/src/daemon/dbus.rs b/core/kpcli/src/daemon/dbus.rs new file mode 100644 index 0000000..96d1178 --- /dev/null +++ b/core/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/core/kpcli/src/daemon/mod.rs b/core/kpcli/src/daemon/mod.rs new file mode 100644 index 0000000..872ab1b --- /dev/null +++ b/core/kpcli/src/daemon/mod.rs @@ -0,0 +1,224 @@ +use anyhow::Result; +use async_trait::async_trait; +use clap::Subcommand; + +// Platform-specific modules +#[cfg(target_os = "linux")] +mod dbus; + +#[cfg(target_os = "macos")] +mod xpc; + +#[cfg_attr(target_os = "macos", async_trait(?Send))] +#[cfg_attr(not(target_os = "macos"), async_trait)] +pub trait DaemonInterface { + async fn print_version(&mut self) -> Result<()>; + async fn print_conversations(&mut self) -> Result<()>; + async fn sync_conversations(&mut self, conversation_id: Option) -> 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<()>; +} + +struct StubDaemonInterface; +impl StubDaemonInterface { + fn new() -> Result { + Ok(Self) + } +} + +#[cfg_attr(target_os = "macos", async_trait(?Send))] +#[cfg_attr(not(target_os = "macos"), async_trait)] +impl DaemonInterface for StubDaemonInterface { + async fn print_version(&mut self) -> Result<()> { + Err(anyhow::anyhow!( + "Daemon interface not implemented on this platform" + )) + } + async fn print_conversations(&mut self) -> Result<()> { + Err(anyhow::anyhow!( + "Daemon interface not implemented on this platform" + )) + } + async fn sync_conversations(&mut self, _conversation_id: Option) -> 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(target_os = "macos")] + { + Ok(Box::new(xpc::XpcDaemonInterface::new()?)) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + Ok(Box::new(StubDaemonInterface::new()?)) + } +} + +#[derive(Subcommand)] +pub enum Commands { + /// Gets all known conversations. + Conversations, + + /// Runs a full sync operation for a conversation and its messages. + Sync { conversation_id: Option }, + + /// Runs a sync operation for the conversation list. + SyncList, + + /// Prints the server Kordophone version. + Version, + + /// Configuration options + Config { + #[command(subcommand)] + command: ConfigCommands, + }, + + /// Waits for signals from the daemon. + Signals, + + /// Prints the messages for a conversation. + Messages { + conversation_id: String, + last_message_id: Option, + }, + + /// Deletes all conversations. + DeleteAllConversations, + + /// Enqueues an outgoing message to be sent to a conversation. + SendMessage { + conversation_id: String, + text: String, + }, + + /// Downloads an attachment from the server to the attachment store. Returns the path to the attachment. + DownloadAttachment { attachment_id: String }, + + /// Uploads an attachment to the server, returns upload guid. + UploadAttachment { path: String }, + + /// Marks a conversation as read. + MarkConversationAsRead { conversation_id: String }, +} + +#[derive(Subcommand)] +pub enum ConfigCommands { + /// Prints the current settings. + Print, + + /// Sets the server URL. + SetServerUrl { url: String }, + + /// Sets the username. + SetUsername { username: String }, +} + +impl Commands { + pub async fn run(cmd: Commands) -> Result<()> { + let mut client = new_daemon_interface()?; + match cmd { + Commands::Version => client.print_version().await, + Commands::Conversations => client.print_conversations().await, + Commands::Sync { conversation_id } => client.sync_conversations(conversation_id).await, + Commands::SyncList => client.sync_conversations_list().await, + Commands::Config { command } => client.config(command).await, + Commands::Signals => client.wait_for_signals().await, + Commands::Messages { + conversation_id, + last_message_id, + } => { + client + .print_messages(conversation_id, last_message_id) + .await + } + Commands::DeleteAllConversations => client.delete_all_conversations().await, + Commands::SendMessage { + conversation_id, + text, + } => client.enqueue_outgoing_message(conversation_id, text).await, + Commands::UploadAttachment { path } => client.upload_attachment(path).await, + Commands::DownloadAttachment { attachment_id } => { + client.download_attachment(attachment_id).await + } + Commands::MarkConversationAsRead { conversation_id } => { + client.mark_conversation_as_read(conversation_id).await + } + } + } +} diff --git a/core/kpcli/src/daemon/xpc.rs b/core/kpcli/src/daemon/xpc.rs new file mode 100644 index 0000000..e703781 --- /dev/null +++ b/core/kpcli/src/daemon/xpc.rs @@ -0,0 +1,604 @@ +use super::{ConfigCommands, DaemonInterface}; +use anyhow::Result; +use async_trait::async_trait; +use futures_util::StreamExt; +use std::collections::HashMap; +use std::ffi::{CStr, CString}; +use std::ops::Deref; +use std::{pin::Pin, task::Poll}; + +use xpc_connection::Message; + +use futures::{ + channel::mpsc::{unbounded as unbounded_channel, UnboundedReceiver, UnboundedSender}, + Stream, +}; + +const SERVICE_NAME: &str = "net.buzzert.kordophonecd\0"; + +const GET_VERSION_METHOD: &str = "GetVersion"; +const GET_CONVERSATIONS_METHOD: &str = "GetConversations"; + +// We can't use XPCClient from xpc-connection because of some strange decisions with which flags +// are passed to xpc_connection_create_mach_service. +struct XPCClient { + connection: xpc_connection_sys::xpc_connection_t, + receiver: UnboundedReceiver, + 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_resume; + use xpc_connection_sys::xpc_connection_set_event_handler; + + let name = name.as_ref(); + let connection = unsafe { + xpc_connection_sys::xpc_connection_create_mach_service( + name.as_ptr(), + std::ptr::null_mut(), + 0, + ) + }; + + let (sender, receiver) = unbounded_channel(); + let sender_clone = sender.clone(); + + let block = ConcreteBlock::new(move |event| { + let message = xpc_object_to_message(event); + sender_clone.unbounded_send(message).ok() + }); + + let block = block.copy(); + + unsafe { + xpc_connection_set_event_handler(connection, block.deref() as *const _ as *mut _); + xpc_connection_resume(connection); + } + + Self { + connection, + receiver, + sender, + event_handler_is_running: true, + } + } + + pub fn send_message(&self, message: Message) { + use xpc_connection::message_to_xpc_object; + use xpc_connection_sys::xpc_connection_send_message; + use xpc_connection_sys::xpc_release; + + let xpc_object = message_to_xpc_object(message); + unsafe { + xpc_connection_send_message(self.connection, xpc_object); + xpc_release(xpc_object); + } + } + + pub fn send_message_with_reply(&self, message: Message) -> Message { + use xpc_connection::message_to_xpc_object; + use xpc_connection::xpc_object_to_message; + use xpc_connection_sys::{xpc_connection_send_message_with_reply_sync, xpc_release}; + + unsafe { + let xobj = message_to_xpc_object(message); + let reply = xpc_connection_send_message_with_reply_sync(self.connection, xobj); + xpc_release(xobj); + let msg = xpc_object_to_message(reply); + if !reply.is_null() { + xpc_release(reply); + } + msg + } + } +} + +impl Drop for XPCClient { + fn drop(&mut self) { + use xpc_connection_sys::xpc_object_t; + use xpc_connection_sys::xpc_release; + + unsafe { xpc_release(self.connection as xpc_object_t) }; + } +} + +impl Stream for XPCClient { + type Item = Message; + + fn poll_next( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + match Stream::poll_next(Pin::new(&mut self.receiver), cx) { + Poll::Ready(Some(Message::Error(xpc_connection::MessageError::ConnectionInvalid))) => { + self.event_handler_is_running = false; + Poll::Ready(None) + } + v => v, + } + } +} + +unsafe impl Send for XPCClient {} + +/// XPC-based implementation of DaemonInterface that sends method calls to the daemon over libxpc. +pub struct XpcDaemonInterface; + +impl XpcDaemonInterface { + /// Create a new XpcDaemonInterface. No state is held. + pub fn new() -> Result { + 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); + let reply = client.send_message_with_reply(Message::Dictionary(request)); + match reply { + Message::Dictionary(map) => Ok(map), + other => Err(anyhow::anyhow!("Unexpected XPC reply: {:?}", other)), + } + } + + fn key(k: &str) -> CString { + CString::new(k).unwrap() + } + + fn get_string<'a>(map: &'a HashMap, key: &str) -> Option<&'a CStr> { + map.get(&Self::key(key)).and_then(|v| match v { + Message::String(s) => Some(s.as_c_str()), + _ => None, + }) + } + + fn get_i64_from_str(map: &HashMap, key: &str) -> Option { + Self::get_string(map, key).and_then(|s| s.to_string_lossy().parse().ok()) + } +} + +#[async_trait(?Send)] +impl DaemonInterface for XpcDaemonInterface { + async fn print_version(&mut self) -> Result<()> { + // Build service name and connect + let mach_port_name = Self::build_service_name()?; + let mut client = XPCClient::connect(&mach_port_name); + + // Call generic method and parse reply + let map = self + .call_method(&mut client, GET_VERSION_METHOD, None) + .await?; + if let Some(ver) = Self::get_string(&map, "version") { + println!("Server version: {}", ver.to_string_lossy()); + Ok(()) + } else if let Some(ty) = Self::get_string(&map, "type") { + println!("XPC replied with type: {}", ty.to_string_lossy()); + Ok(()) + } else { + Err(anyhow::anyhow!( + "Unexpected XPC reply payload for GetVersion" + )) + } + } + + // Remaining methods unimplemented on macOS + async fn print_conversations(&mut self) -> Result<()> { + // Connect + let mach_port_name = Self::build_service_name()?; + let mut client = XPCClient::connect(&mach_port_name); + + // Build arguments: limit=100, offset=0 (string-encoded for portability) + let mut args = HashMap::new(); + args.insert( + CString::new("limit").unwrap(), + Message::String(CString::new("100").unwrap()), + ); + args.insert( + CString::new("offset").unwrap(), + Message::String(CString::new("0").unwrap()), + ); + + // Call + let reply = self + .call_method(&mut client, GET_CONVERSATIONS_METHOD, Some(args)) + .await?; + + // Expect an array under "conversations" + match reply.get(&Self::key("conversations")) { + Some(Message::Array(items)) => { + println!("Number of conversations: {}", items.len()); + + for item in items { + if let Message::Dictionary(map) = item { + // Convert to PrintableConversation + let guid = Self::get_string(map, "guid") + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let display_name = Self::get_string(map, "display_name") + .map(|s| s.to_string_lossy().into_owned()); + let last_preview = Self::get_string(map, "last_message_preview") + .map(|s| s.to_string_lossy().into_owned()); + + let unread_count = + Self::get_i64_from_str(map, "unread_count").unwrap_or(0) as i32; + let date_ts: i64 = Self::get_i64_from_str(map, "date").unwrap_or(0); + + let participants: Vec = match map.get(&Self::key("participants")) { + Some(Message::Array(arr)) => arr + .iter() + .filter_map(|m| match m { + Message::String(s) => Some(s.to_string_lossy().into_owned()), + _ => None, + }) + .collect(), + _ => Vec::new(), + }; + + // Build PrintableConversation directly + let conv = crate::printers::PrintableConversation { + guid, + display_name, + last_message_preview: last_preview, + unread_count, + date: time::OffsetDateTime::from_unix_timestamp(date_ts) + .unwrap_or_else(|_| time::OffsetDateTime::UNIX_EPOCH), + participants, + }; + + println!("{}", crate::printers::ConversationPrinter::new(&conv)); + } + } + Ok(()) + } + Some(other) => Err(anyhow::anyhow!( + "Unexpected conversations payload: {:?}", + other + )), + None => Err(anyhow::anyhow!("Missing conversations in reply")), + } + } + async fn sync_conversations(&mut self, _conversation_id: Option) -> Result<()> { + let mach_port_name = Self::build_service_name()?; + let mut client = XPCClient::connect(&mach_port_name); + + if let Some(id) = _conversation_id { + let mut args = HashMap::new(); + args.insert( + Self::key("conversation_id"), + Message::String(CString::new(id).unwrap()), + ); + let _ = self + .call_method(&mut client, "SyncConversation", Some(args)) + .await?; + return Ok(()); + } + + let _ = self + .call_method(&mut client, "SyncAllConversations", None) + .await?; + Ok(()) + } + async fn sync_conversations_list(&mut self) -> Result<()> { + let mach_port_name = Self::build_service_name()?; + let mut client = XPCClient::connect(&mach_port_name); + let _ = self + .call_method(&mut client, "SyncConversationList", None) + .await?; + Ok(()) + } + async fn print_messages( + &mut self, + _conversation_id: String, + _last_message_id: Option, + ) -> Result<()> { + let mach_port_name = Self::build_service_name()?; + let mut client = XPCClient::connect(&mach_port_name); + + let mut args = HashMap::new(); + args.insert( + Self::key("conversation_id"), + Message::String(CString::new(_conversation_id).unwrap()), + ); + if let Some(last) = _last_message_id { + args.insert( + Self::key("last_message_id"), + Message::String(CString::new(last).unwrap()), + ); + } + + let reply = self + .call_method(&mut client, "GetMessages", Some(args)) + .await?; + match reply.get(&Self::key("messages")) { + Some(Message::Array(items)) => { + println!("Number of messages: {}", items.len()); + for item in items { + if let Message::Dictionary(map) = item { + let guid = Self::get_string(map, "id") + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let sender = Self::get_string(map, "sender") + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let text = Self::get_string(map, "text") + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let date_ts = Self::get_i64_from_str(map, "date").unwrap_or(0); + let msg = crate::printers::PrintableMessage { + guid, + date: time::OffsetDateTime::from_unix_timestamp(date_ts) + .unwrap_or_else(|_| time::OffsetDateTime::UNIX_EPOCH), + sender, + text, + file_transfer_guids: vec![], + attachment_metadata: None, + }; + println!("{}", crate::printers::MessagePrinter::new(&msg)); + } + } + Ok(()) + } + _ => Err(anyhow::anyhow!("Unexpected messages payload")), + } + } + async fn enqueue_outgoing_message( + &mut self, + _conversation_id: String, + _text: String, + ) -> Result<()> { + let mach_port_name = Self::build_service_name()?; + let mut client = XPCClient::connect(&mach_port_name); + let mut args = HashMap::new(); + args.insert( + Self::key("conversation_id"), + Message::String(CString::new(_conversation_id).unwrap()), + ); + args.insert( + Self::key("text"), + Message::String(CString::new(_text).unwrap()), + ); + let reply = self + .call_method(&mut client, "SendMessage", Some(args)) + .await?; + if let Some(uuid) = Self::get_string(&reply, "uuid") { + println!("Outgoing message ID: {}", uuid.to_string_lossy()); + } + Ok(()) + } + async fn wait_for_signals(&mut self) -> Result<()> { + let mach_port_name = Self::build_service_name()?; + let mut client = XPCClient::connect(&mach_port_name); + + // Subscribe to begin receiving signals on this connection + eprintln!("[kpcli] Sending SubscribeSignals"); + client.send_message(Message::Dictionary(Self::build_request( + "SubscribeSignals", + None, + ))); + + println!("Waiting for XPC signals..."); + while let Some(msg) = client.next().await { + match msg { + Message::Dictionary(map) => { + eprintln!("[kpcli] Received signal dictionary"); + let name_key = Self::key("name"); + let args_key = Self::key("arguments"); + let name = match map.get(&name_key) { + Some(Message::String(s)) => s.to_string_lossy().into_owned(), + _ => continue, + }; + + match name.as_str() { + "ConversationsUpdated" => { + println!("Signal: Conversations updated"); + } + "MessagesUpdated" => { + if let Some(Message::Dictionary(args)) = map.get(&args_key) { + if let Some(Message::String(cid)) = + args.get(&Self::key("conversation_id")) + { + println!( + "Signal: Messages updated for conversation {}", + cid.to_string_lossy() + ); + } + } + } + "UpdateStreamReconnected" => { + println!("Signal: Update stream reconnected"); + } + "AttachmentDownloadCompleted" => { + if let Some(Message::Dictionary(args)) = map.get(&args_key) { + if let Some(Message::String(aid)) = + args.get(&Self::key("attachment_id")) + { + println!( + "Signal: Attachment downloaded: {}", + aid.to_string_lossy() + ); + } + } + } + "AttachmentDownloadFailed" => { + if let Some(Message::Dictionary(args)) = map.get(&args_key) { + if let Some(Message::String(aid)) = + args.get(&Self::key("attachment_id")) + { + eprintln!( + "Signal: Attachment download failed: {}", + aid.to_string_lossy() + ); + } + } + } + "AttachmentUploadCompleted" => { + if let Some(Message::Dictionary(args)) = map.get(&args_key) { + let upload = args + .get(&Self::key("upload_guid")) + .and_then(|v| match v { + Message::String(s) => { + Some(s.to_string_lossy().into_owned()) + } + _ => None, + }) + .unwrap_or_default(); + let attachment = args + .get(&Self::key("attachment_guid")) + .and_then(|v| match v { + Message::String(s) => { + Some(s.to_string_lossy().into_owned()) + } + _ => None, + }) + .unwrap_or_default(); + println!( + "Signal: Attachment uploaded: upload={}, attachment={}", + upload, attachment + ); + } + } + "ConfigChanged" => { + println!("Signal: Config changed"); + } + _ => {} + } + } + Message::Error(xpc_connection::MessageError::ConnectionInvalid) => { + eprintln!("[kpcli] XPC connection invalid"); + break; + } + other => { + eprintln!("[kpcli] Unexpected XPC message: {:?}", other); + } + } + } + Ok(()) + } + async fn config(&mut self, _cmd: ConfigCommands) -> Result<()> { + let mach_port_name = Self::build_service_name()?; + let mut client = XPCClient::connect(&mach_port_name); + match _cmd { + ConfigCommands::Print => { + let reply = self + .call_method(&mut client, "GetAllSettings", None) + .await?; + let server_url = Self::get_string(&reply, "server_url") + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let username = Self::get_string(&reply, "username") + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let table = + prettytable::table!([b->"Server URL", &server_url], [b->"Username", &username]); + table.printstd(); + Ok(()) + } + ConfigCommands::SetServerUrl { url } => { + let mut args = HashMap::new(); + args.insert( + Self::key("server_url"), + Message::String(CString::new(url).unwrap()), + ); + let _ = self + .call_method(&mut client, "UpdateSettings", Some(args)) + .await?; + Ok(()) + } + ConfigCommands::SetUsername { username } => { + let mut args = HashMap::new(); + args.insert( + Self::key("username"), + Message::String(CString::new(username).unwrap()), + ); + let _ = self + .call_method(&mut client, "UpdateSettings", Some(args)) + .await?; + Ok(()) + } + } + } + async fn delete_all_conversations(&mut self) -> Result<()> { + let mach_port_name = Self::build_service_name()?; + let mut client = XPCClient::connect(&mach_port_name); + let _ = self + .call_method(&mut client, "DeleteAllConversations", None) + .await?; + Ok(()) + } + async fn download_attachment(&mut self, _attachment_id: String) -> Result<()> { + let mach_port_name = Self::build_service_name()?; + let mut client = XPCClient::connect(&mach_port_name); + let mut args = HashMap::new(); + args.insert( + Self::key("attachment_id"), + Message::String(CString::new(_attachment_id).unwrap()), + ); + args.insert( + Self::key("preview"), + Message::String(CString::new("false").unwrap()), + ); + let _ = self + .call_method(&mut client, "DownloadAttachment", Some(args)) + .await?; + Ok(()) + } + async fn upload_attachment(&mut self, _path: String) -> Result<()> { + let mach_port_name = Self::build_service_name()?; + let mut client = XPCClient::connect(&mach_port_name); + let mut args = HashMap::new(); + args.insert( + Self::key("path"), + Message::String(CString::new(_path).unwrap()), + ); + let reply = self + .call_method(&mut client, "UploadAttachment", Some(args)) + .await?; + if let Some(guid) = Self::get_string(&reply, "upload_guid") { + println!("Upload GUID: {}", guid.to_string_lossy()); + } + Ok(()) + } + async fn mark_conversation_as_read(&mut self, _conversation_id: String) -> Result<()> { + let mach_port_name = Self::build_service_name()?; + let mut client = XPCClient::connect(&mach_port_name); + let mut args = HashMap::new(); + args.insert( + Self::key("conversation_id"), + Message::String(CString::new(_conversation_id).unwrap()), + ); + let _ = self + .call_method(&mut client, "MarkConversationAsRead", Some(args)) + .await?; + Ok(()) + } +} diff --git a/core/kpcli/src/db/mod.rs b/core/kpcli/src/db/mod.rs new file mode 100644 index 0000000..ee80369 --- /dev/null +++ b/core/kpcli/src/db/mod.rs @@ -0,0 +1,239 @@ +use anyhow::Result; +use clap::Subcommand; +use kordophone::APIInterface; +use std::{env, path::PathBuf}; + +use crate::{ + client, + printers::{ConversationPrinter, MessagePrinter}, +}; +use kordophone_db::database::{Database, DatabaseAccess}; + +#[derive(Subcommand)] +pub enum Commands { + /// For dealing with the table of cached conversations. + Conversations { + #[clap(subcommand)] + command: ConversationCommands, + }, + + /// For dealing with the table of cached messages. + Messages { + #[clap(subcommand)] + command: MessageCommands, + }, + + /// For managing settings in the database. + Settings { + #[clap(subcommand)] + command: SettingsCommands, + }, +} + +#[derive(Subcommand)] +pub enum ConversationCommands { + /// Lists all conversations currently in the database. + List, + + /// Syncs with an API client. + Sync, +} + +#[derive(Subcommand)] +pub enum MessageCommands { + /// Prints all messages in a conversation. + List { conversation_id: String }, +} + +#[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()?; + match cmd { + Commands::Conversations { command: cmd } => match cmd { + 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).await, + SettingsCommands::Put { key, value } => db.put_setting(key, value).await, + SettingsCommands::Delete { key } => db.delete_setting(key).await, + }, + } + } +} + +struct DbClient { + 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() + } + + pub fn new() -> Result { + let path = Self::database_path(); + let path_str: &str = path.as_path().to_str().unwrap(); + + println!("kpcli: Using db at {}", path_str); + + let db = Database::new(path_str)?; + 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?; + + println!("{} Conversations: ", all_conversations.len()); + for conversation in all_conversations { + println!("{}", ConversationPrinter::new(&conversation.into())); + } + + Ok(()) + } + + 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())); + } + 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(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?; + + // 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(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)?; + } + + 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), + } + } + 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 + } + + pub async fn put_setting(&mut self, key: String, value: String) -> Result<()> { + 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 + } +} diff --git a/core/kpcli/src/main.rs b/core/kpcli/src/main.rs new file mode 100644 index 0000000..a485c1a --- /dev/null +++ b/core/kpcli/src/main.rs @@ -0,0 +1,69 @@ +mod client; +mod daemon; +mod db; +mod printers; + +use anyhow::Result; +use clap::{Parser, Subcommand}; +use log::LevelFilter; + +/// A command line interface for the Kordophone library and daemon +#[derive(Parser)] +#[command(name = "kpcli")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Commands for api client operations + Client { + #[command(subcommand)] + command: client::Commands, + }, + + /// Commands for the cache database + Db { + #[command(subcommand)] + command: db::Commands, + }, + + /// Commands for interacting with the daemon + Daemon { + #[command(subcommand)] + command: daemon::Commands, + }, +} + +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, + } +} + +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 + .map_err(|e| println!("Error: {}", e)) + .err(); +} diff --git a/core/kpcli/src/printers.rs b/core/kpcli/src/printers.rs new file mode 100644 index 0000000..06e07eb --- /dev/null +++ b/core/kpcli/src/printers.rs @@ -0,0 +1,273 @@ +use kordophone::model::message::AttachmentMetadata; +use pretty::RcDoc; +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, + 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, + } + } +} + +#[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()) + .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, + pub sender: String, + pub text: String, + pub file_transfer_guids: Vec, + pub attachment_metadata: Option>, +} + +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, + file_transfer_guids: value.file_transfer_guids, + attachment_metadata: value.attachment_metadata, + } + } +} + +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, + file_transfer_guids: value.file_transfer_guids, + attachment_metadata: value.attachment_metadata, + } + } +} + +#[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") + .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, + } + } +} + +pub struct ConversationPrinter<'a> { + doc: RcDoc<'a, PrintableConversation>, +} + +impl<'a> ConversationPrinter<'a> { + pub fn new(conversation: &'a PrintableConversation) -> 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("Unread Count: ") + .append(conversation.unread_count.to_string()) + .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("]") + .append(RcDoc::line()) + .append("Last Message Preview: ") + .append(preview) + .nest(4), + ) + .append(RcDoc::line()) + .append(">"); + + ConversationPrinter { doc } + } +} + +impl Display for ConversationPrinter<'_> { + 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 Display for MessagePrinter<'_> { + 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 mut doc = RcDoc::text(format!(""); + + MessagePrinter { doc } + } +} diff --git a/core/utilities/Cargo.toml b/core/utilities/Cargo.toml new file mode 100644 index 0000000..1d970dd --- /dev/null +++ b/core/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/core/utilities/src/bin/snoozer.rs b/core/utilities/src/bin/snoozer.rs new file mode 100644 index 0000000..28f6c6c --- /dev/null +++ b/core/utilities/src/bin/snoozer.rs @@ -0,0 +1,94 @@ +use std::env; +use std::process; + +use kordophone::{ + api::{HTTPAPIClient, InMemoryAuthenticationStore, EventSocket}, + model::{ConversationID, event::EventData}, + APIInterface, +}; +use kordophone::api::http_client::Credentials; +use kordophone::api::AuthenticationStore; + +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); + let _ = client.authenticate(credentials).await?; + + // 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(()) +}