diff --git a/Cargo.lock b/Cargo.lock index 9a36bd2..c6b0f7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,159 +2,112 @@ # It is not intended for manual editing. version = 3 -[[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.3.2" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", - "is-terminal", + "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" -version = "1.0.1" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a30da5c5f2d5e72842e00bcb57657162cdabef0931f40e2deb9b4140440cecd" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" -version = "0.2.1" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.0.0" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "1.0.1" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", - "windows-sys", + "once_cell_polyfill", + "windows-sys 0.61.2", ] [[package]] name = "armesto" -version = "0.0.1" +version = "0.1.0" dependencies = [ - "clap 4.3.10", + "clap", "dbus", - "dbus-codegen", "dbus-crossroads", - "log", + "rusqlite", "serde", "serde_json", - "serde_repr", - "syslog", "thiserror", - "toml", -] - -[[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.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" -version = "2.3.3" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "cc" -version = "1.0.79" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" - -[[package]] -name = "clap" -version = "2.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ - "ansi_term", - "atty", - "bitflags 1.3.2", - "strsim 0.8.0", - "textwrap", - "unicode-width", - "vec_map", + "find-msvc-tools", + "shlex", ] [[package]] name = "clap" -version = "4.3.10" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384e169cc618c613d5e3ca6404dda77a8685a63e08660dcc64abaf7da7cb0c7a" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", "clap_derive", - "once_cell", ] [[package]] name = "clap_builder" -version = "4.3.10" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef137bbe35aab78bdb468ccfba75a5f4d8321ae011d34063770780545176af2d" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim 0.10.0", + "strsim", ] [[package]] name = "clap_derive" -version = "4.3.2" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8cd2b2a819ad6eec39e8f1d6b53001af1e5469f8c177579cdaeb313115b825f" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", @@ -164,253 +117,196 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.5.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "colorchoice" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "dbus" -version = "0.9.7" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b" +checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" dependencies = [ "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", + "windows-sys 0.59.0", ] [[package]] name = "dbus-crossroads" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a4c83437187544ba5142427746835061b330446ca8902eabd70e4afb8f76de0" +checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" dependencies = [ "dbus", ] [[package]] -name = "errno" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" -dependencies = [ - "errno-dragonfly", - "libc", - "windows-sys", -] - -[[package]] -name = "errno-dragonfly" -version = "0.1.2" +name = "fallible-iterator" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" -dependencies = [ - "cc", - "libc", -] +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" [[package]] -name = "error-chain" -version = "0.12.4" +name = "fallible-streaming-iterator" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" -dependencies = [ - "version_check", -] +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] -name = "hashbrown" -version = "0.12.3" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "heck" -version = "0.4.1" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "hermit-abi" -version = "0.1.19" +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "libc", + "foldhash", ] [[package]] -name = "hermit-abi" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286" - -[[package]] -name = "hostname" -version = "0.3.1" +name = "hashlink" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "libc", - "match_cfg", - "winapi", + "hashbrown", ] [[package]] -name = "indexmap" -version = "1.9.3" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown", -] +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "is-terminal" -version = "0.4.8" +name = "is_terminal_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24fddda5af7e54bf7da53067d6e802dbcc381d0a8eef629df528e3ebf68755cb" -dependencies = [ - "hermit-abi 0.3.1", - "rustix", - "windows-sys", -] +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.6" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "libc" -version = "0.2.146" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f92be4933c13fd498862a9e02a3055f8a8d9c039ce33db97306fd5a6caa7f29b" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libdbus-sys" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06085512b750d640299b79be4bad3d2fa90a9c00b1fd9e1b46364f66f0485c72" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" dependencies = [ "pkg-config", ] [[package]] -name = "linux-raw-sys" -version = "0.4.3" +name = "libsqlite3-sys" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0" - -[[package]] -name = "log" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" - -[[package]] -name = "match_cfg" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] [[package]] name = "memchr" -version = "2.5.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] -name = "num_threads" -version = "0.1.6" +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" -dependencies = [ - "libc", -] - -[[package]] -name = "once_cell" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "pkg-config" -version = "0.3.27" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "proc-macro2" -version = "1.0.60" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec2b086b7a862cf4de201096214fa870344cf922b2b30c167badb3af3195406" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.28" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] [[package]] -name = "rustix" -version = "0.38.2" +name = "rusqlite" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aabcb0461ebd01d6b79945797c27f8529082226cb630a9865a71870ff63532a4" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" dependencies = [ - "bitflags 2.3.3", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", ] [[package]] -name = "ryu" -version = "1.0.13" +name = "serde" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] [[package]] -name = "serde" -version = "1.0.164" +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e8c8cf938e98f769bc164923b06dce91cea1751522f46f8466461af04c9027d" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.164" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9735b638ccc51c28bf6914d90a2e9725b377144fc612c49a611fddd1b631d68" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -419,233 +315,118 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.96" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", - "ryu", + "memchr", "serde", + "serde_core", + "zmij", ] [[package]] -name = "serde_repr" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcec881020c684085e55a25f7fd888954d56609ef363479dc5a1305eb0d40cab" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_spanned" -version = "0.6.2" +name = "shlex" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93107647184f6027e3b7dcb2e11034cf95ffa1e3a682c67951963ac69c1c007d" -dependencies = [ - "serde", -] +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] -name = "strsim" -version = "0.8.0" +name = "smallvec" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "strsim" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.18" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] -[[package]] -name = "syslog" -version = "6.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7434e95bcccce1215d30f4bf84fe8c00e8de1b9be4fb736d747ca53d36e7f96f" -dependencies = [ - "error-chain", - "hostname", - "libc", - "log", - "time", -] - -[[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.40" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.40" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", "syn", ] -[[package]] -name = "time" -version = "0.3.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea9e1b3cf1243ae005d9e74085d4d542f3125458f3a81af210d901dcd7411efd" -dependencies = [ - "itoa", - "libc", - "num_threads", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" - -[[package]] -name = "time-macros" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "372950940a5f07bf38dbe211d7283c9e6d7327df53794992d293e534c733d09b" -dependencies = [ - "time-core", -] - -[[package]] -name = "toml" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6135d499e69981f9ff0ef2167955a5333c35e36f6937d382974566b3d5b94ec" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a76a9312f5ba4c2dec6b9161fdf25d87ad8a09256ccea5a556fef03c706a10f" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.19.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380d56e8670370eee6566b0bfd4265f65b3f432e8c6d85623f728d4fa31f739" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "winnow", -] - [[package]] name = "unicode-ident" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" - -[[package]] -name = "unicode-width" -version = "0.1.10" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "utf8parse" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] -name = "vec_map" -version = "0.8.2" +name = "vcpkg" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] -name = "version_check" -version = "0.9.4" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "winapi" -version = "0.3.9" +name = "windows-sys" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "windows-targets", ] -[[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-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-sys" -version = "0.48.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-targets", + "windows-link", ] [[package]] name = "windows-targets" -version = "0.48.1" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", + "windows_i686_gnullvm", "windows_i686_msvc", "windows_x86_64_gnu", "windows_x86_64_gnullvm", @@ -654,57 +435,54 @@ dependencies = [ [[package]] name = "windows_aarch64_gnullvm" -version = "0.48.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" -version = "0.48.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" -version = "0.48.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" +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.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" -version = "0.48.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" -version = "0.48.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" - -[[package]] -name = "winnow" -version = "0.4.6" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61de7bac303dc551fe038e2b3cef0f571087a47571ea6e79a87692ac99b99699" -dependencies = [ - "memchr", -] +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "xml-rs" -version = "0.8.14" +name = "zmij" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52839dc911083a8ef63efa4d039d1f58b5e409f923e44c80828f206f66e5541c" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 6a601a4..2b0f4bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,33 +1,33 @@ [package] name = "armesto" -version = "0.0.1" -description = "Another rofication daemon" +version = "0.1.0" +description = "DB-backed Linux desktop notification backend" authors = ["Ken Gilmer "] license = "MIT OR Apache-2.0" readme = "README.md" -homepage = "https://github.com/kgilmer/armesto" -repository = "https://github.com/kgilmer/armesto" -keywords = ["notification", "daemon", "dbus", "notify"] -categories = [] -include = ["src/**/*", "dbus/*", "build.rs", "Cargo.*", "LICENSE-*", "*.md"] edition = "2021" -rust-version = "1.64" +rust-version = "1.81.0" + +[lib] +name = "armesto" +path = "src/lib.rs" + +[[bin]] +name = "armesto-server" +path = "src/bin/armesto-server.rs" + +[[bin]] +name = "armesto-cli" +path = "src/bin/armesto-cli.rs" [dependencies] -dbus = "0.9.7" -dbus-crossroads = "0.5.2" -thiserror = "1.0.40" +clap = { version = "4.3.10", features = ["derive"] } serde = { version = "1.0.164", features = ["derive"] } -toml = "=0.7.4" serde_json = "1.0.96" -serde_repr = "0.1" -log = "0.4" -syslog = "6.1" -clap = { version = "4.3.10", features = ["derive"] } -time = "=0.3.36" - -[build-dependencies] -dbus-codegen = "0.10.0" +thiserror = "1.0.40" +rusqlite = { version = "0.37.0", features = ["bundled"] } +dbus = "0.9.7" +dbus-crossroads = "0.5.2" [profile.dev] opt-level = 0 @@ -44,7 +44,3 @@ debug = false panic = "unwind" lto = true codegen-units = 1 - -[profile.bench] -opt-level = 3 -debug = false diff --git a/Makefile b/Makefile deleted file mode 100644 index b5cd17d..0000000 --- a/Makefile +++ /dev/null @@ -1,41 +0,0 @@ -#/usr/bin/make -f - -INSTALL_PROGRAM = install -D -m 0755 -INSTALL_DATA = install -D -m 0644 - -PREFIX ?= $(DESTDIR) -BINDIR ?= $(PREFIX)/usr/bin - -BIN := armesto - -MESON = meson - - -all: build - -distclean: clean - -clean: - -cargo clean - -build-arch: build - -build-independent: build - -binary: build - -binary-arch: build - -binary-independent: build - -build: - cargo build --release - -install: - $(INSTALL_PROGRAM) "./target/release/$(BIN)" "$(BINDIR)/$(BIN)" - -uninstall: - rm -f "$(BINDIR)/$(BIN)" - -run-test: - cargo test -- --test-threads=1 diff --git a/build.rs b/build.rs deleted file mode 100644 index 373d559..0000000 --- a/build.rs +++ /dev/null @@ -1,33 +0,0 @@ -use dbus_codegen::{ConnectionType, GenOpts, ServerAccess}; -use std::env; -use std::error::Error; -use std::fs; -use std::path::Path; - -const INTROSPECTION_PATH: &str = "dbus/introspection.xml"; - -fn main() -> Result<(), Box> { - let introspection = fs::read_to_string(INTROSPECTION_PATH)?; - let out_dir = env::var_os("OUT_DIR").ok_or("OUT_DIR is not set")?; - - let gen_path = Path::new(&out_dir).join("introspection.rs"); - let gen_opts = GenOpts { - methodtype: None, - crossroads: true, - skipprefix: None, - serveraccess: ServerAccess::RefClosure, - genericvariant: false, - connectiontype: ConnectionType::Blocking, - propnewtype: false, - interfaces: None, - ..Default::default() - }; - - let code = dbus_codegen::generate(&introspection, &gen_opts)?; - fs::write(&gen_path, code)?; - - println!("D-Bus code generated at {gen_path:?}"); - println!("cargo:rerun-if-changed={INTROSPECTION_PATH}"); - println!("cargo:rerun-if-changed=build.rs"); - Ok(()) -} diff --git a/debian/compat b/debian/compat deleted file mode 100644 index b4de394..0000000 --- a/debian/compat +++ /dev/null @@ -1 +0,0 @@ -11 diff --git a/debian/control b/debian/control index 2d29990..1931891 100644 --- a/debian/control +++ b/debian/control @@ -2,7 +2,7 @@ Source: armesto Section: utils Priority: optional Maintainer: Ken Gilmer -Build-Depends: cargo, debhelper (>= 11), libglib2.0-dev, meson, libdbus-1-dev +Build-Depends: cargo, debhelper-compat (= 13), libdbus-1-dev Standards-Version: 4.5.0 Package: armesto @@ -10,3 +10,4 @@ Architecture: any Multi-Arch: foreign Depends: ${misc:Depends}, ${shlibs:Depends}, libdbus-1-dev Description: Notification middleman for rofication + diff --git a/docs/notification-backend-design.md b/docs/notification-backend-design.md new file mode 100644 index 0000000..d1e02cc --- /dev/null +++ b/docs/notification-backend-design.md @@ -0,0 +1,226 @@ +# SQLite-Backed Linux Notification Backend Design + +## 1. Overview + +This document proposes a new Rust crate, independent from the current `armesto` implementation, to provide a Linux desktop notification backend backed by embedded SQLite. + +The crate includes: +1. A low-memory notification server daemon that accepts D-Bus calls and persists all state in SQLite. +2. A CLI tool to read and manage notifications. +3. A client API intended for use from `grelier` with: + - CRUD operations on notifications. + - A way to subscribe to "new notification available" events. + +## 2. Goals + +1. Keep notification state in SQLite, not in long-lived in-memory collections. +2. React to notification changes through D-Bus events. +3. Support server-side mutation operations (create/update/delete/close/close-all). +4. Provide operational CLI for querying and managing stored notifications. +5. Provide a reusable Rust client API for other applications (including `grelier`). +6. Keep RAM usage low and predictable. +7. Eliminate unmanaged host dependencies like the `mysql` CLI or an external database server. + +## 3. Non-Goals + +1. Full parity with every third-party rofi daemon extension. +2. Reuse of current in-memory `NotificationStore` internals. +3. UI rendering responsibilities. + +## 4. Proposed Crate + +### 4.2 Crate Outputs + +1. `armesto-server` (binary): notification daemon/server. +2. `armesto` (binary): CLI management client. +3. `armesto_notify_backend` (library): data models, DB repo, client API, shared protocol types. + +## 5. Architecture + +### 5.1 Components + +1. D-Bus ingress adapter + - Implements `org.freedesktop.Notifications` methods (`Notify`, `CloseNotification`, etc.). + - Converts incoming messages to storage mutations. + +2. Notification service core + - Stateless service layer; each operation maps to one DB transaction. + - No in-memory long-term notification cache. + +3. SQLite repository layer + - Owns SQL operations and transactional boundaries. + - Uses direct embedded DB access via `rusqlite`. + +4. D-Bus egress notifier + - Emits internal change signals on notification mutations so clients can react quickly. + - Proposed interface: `org.armesto.NotifyStore1` signal `NotificationChanged(change_id, kind, notification_id)`. + +5. CLI (`armesto`) + - Calls repository operations directly via embedded SQLite access. + +6. Rust client API + - Exposes typed CRUD methods. + - Exposes a live subscription API for new change events, with DB recovery semantics. +7. Optional rofi UNIX socket adapter + - Supports legacy commands (`num`, `list`, `del`, `dels`, `dela`, `saw`). + - Supports `watch` for new-notification events. + +### 5.2 Event Flow + +1. App sends `Notify` via D-Bus. +2. `armesto-server` receives D-Bus call and writes notification row in SQLite transactionally. +3. Server appends a change event row in the same store. +4. `grelier` (via client API) fetches latest data from SQLite. + +All durable state remains in SQLite. + +## 6. Data Model + +Database file location policy: +1. Fixed runtime path: `$HOME/.cache/armesto/armesto-.db` +2. `` comes from the backend crate major version. +3. Runtime DB path is not user-configurable from daemon/CLI flags. +4. Path override exists only for automated tests. + +### 6.1 Tables + +1. `notifications` + - `id INTEGER PRIMARY KEY AUTOINCREMENT` + - `app_name TEXT NOT NULL` + - `summary TEXT NOT NULL` + - `body TEXT NOT NULL` + - `icon TEXT NOT NULL DEFAULT ''` + - `urgency INTEGER NOT NULL DEFAULT 1 CHECK (urgency IN (0,1,2))` + - `actions_json TEXT NOT NULL DEFAULT '[]'` + - `hints_json TEXT NOT NULL DEFAULT '{}'` + - `created_at TEXT NOT NULL DEFAULT (STRFTIME(...))` + - `updated_at TEXT NOT NULL DEFAULT (STRFTIME(...))` + - `closed_at TEXT NULL` + - `status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','closed'))` + +2. `notification_changes` + - `change_id INTEGER PRIMARY KEY AUTOINCREMENT` + - `notification_id INTEGER NULL` + - `change_kind TEXT NOT NULL CHECK (...)` + - `created_at TEXT NOT NULL DEFAULT (STRFTIME(...))` + - `metadata_json TEXT NOT NULL DEFAULT '{}'` + +### 6.2 Indexes + +1. `notifications(status, created_at DESC)` +2. `notifications(app_name, status)` +3. `notification_changes(change_id)` +4. `notification_changes(created_at)` + +## 7. D-Bus Interfaces + +### 7.1 Ingress (Desktop Notification Spec) + +Implemented: +1. `Notify(...) -> u32` +2. `CloseNotification(id)` +3. `GetCapabilities()` +4. `GetServerInformation()` + +### 7.2 Internal Change Signal + +Implemented: +1. Bus name: `org.armesto.NotifyStore1` +2. Object path: `/org/armesto/NotifyStore1` +3. Signal: `NotificationChanged(change_id: t, kind: s, notification_id: t)` + +## 8. CLI Design (`armesto`) + +Implemented commands: +1. `list [--status active|closed] [--limit N] [--app APP]` +2. `get ID` +3. `create --app APP --summary TEXT --body TEXT [--urgency 0|1|2]` +4. `update ID [--summary TEXT] [--body TEXT] [--urgency 0|1|2]` +5. `close ID` +6. `delete ID` +7. `close-all [--app APP]` +8. `watch` (streams live change events) + +## 8.1 Optional Rofi Integration + +Implemented via `armesto-server --rofi-socket `: +1. `num` +2. `list` +3. `del:` +4. `dels:` +5. `dela:` +6. `saw:` +7. `watch` (line-delimited JSON for new-notification events) + +## 9. Client API for `grelier` + +### 9.1 Rust API Surface + +```rust +pub struct NotifyClient { /* sqlite repo + optional dbus connection */ } + +impl NotifyClient { + pub fn new(cfg: ClientConfig) -> Result; + pub fn migrate(&self) -> Result<()>; + + pub fn create(&self, req: NewNotification) -> Result; + pub fn get(&self, id: u64) -> Result>; + pub fn list(&self, q: ListQuery) -> Result>; + pub fn update(&self, req: UpdateNotification) -> Result; + pub fn close(&self, id: u64) -> Result<()>; + pub fn delete(&self, id: u64) -> Result<()>; + pub fn close_all(&self, app: Option<&str>) -> Result; + + pub fn subscribe_changes(&self) -> Result; +} +``` + +### 9.2 Subscription Strategy + +1. Uses D-Bus `NotificationChanged` for low-latency wakeups. +2. Uses DB recovery from last seen `change_id` to avoid missed updates. + +## 10. Low-RAM Strategy + +1. No in-memory notification cache. +2. SQLite file as source of truth. +3. Blocking D-Bus processing loop. +4. Per-request short-lived allocations. + +## 11. Error Handling and Reliability + +1. Each mutation operation is transactional. +2. If DB write fails, D-Bus method returns error. +3. Structured error propagation through typed crate errors. + +## 12. Security and Operations + +1. DB file permissions control data access. +2. Systemd hardening should be used for the daemon. +3. SQLite file can be placed under user-controlled runtime directories. +4. Optional explicit D-Bus bus address can be provided for constrained/containerized environments. + +## 13. Implementation Phases + +1. Phase 1: Crate skeleton + models + migrations + repository CRUD. Done. +2. Phase 2: `armesto` CLI on top of repository. Done. +3. Phase 3: D-Bus ingress server to persist `Notify`/`Close` operations. Done. +4. Phase 4: D-Bus change signal + live client API subscription. Done. +5. Phase 5: integration tests with ephemeral D-Bus session. Done. + +## 14. Testing Plan + +1. Unit tests + - URL/path parsing. + - SQL mapping and validation. + +2. Integration tests + - CRUD against SQLite file. + - D-Bus `Notify` creates row. + - Live change signal subscription path. + +## 15. Open Questions + +1. Retention policy for closed notifications. +2. Whether to keep this crate standalone or promote it into a top-level Cargo workspace. +3. Whether to add retention cleanup commands to `armesto`. diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..f37a728 --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,37 @@ +CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + app_name TEXT NOT NULL, + summary TEXT NOT NULL, + body TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '', + urgency INTEGER NOT NULL DEFAULT 1 CHECK (urgency IN (0, 1, 2)), + actions_json TEXT NOT NULL DEFAULT '[]', + hints_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now')), + closed_at TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'closed')) +); + +CREATE TABLE IF NOT EXISTS notification_changes ( + change_id INTEGER PRIMARY KEY AUTOINCREMENT, + notification_id INTEGER, + change_kind TEXT NOT NULL CHECK (change_kind IN ('create', 'update', 'close', 'delete', 'close_all')), + created_at TEXT NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now')), + metadata_json TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY (notification_id) + REFERENCES notifications(id) + ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_notifications_status_created + ON notifications(status, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_notifications_app_status + ON notifications(app_name, status); + +CREATE INDEX IF NOT EXISTS idx_notification_changes_change_id + ON notification_changes(change_id); + +CREATE INDEX IF NOT EXISTS idx_notification_changes_created_at + ON notification_changes(created_at); diff --git a/src/bin/armesto-cli.rs b/src/bin/armesto-cli.rs new file mode 100644 index 0000000..c2e10e9 --- /dev/null +++ b/src/bin/armesto-cli.rs @@ -0,0 +1,192 @@ +//! Command-line client for interacting with the notification backend. +//! + +use armesto::{ + ClientConfig, DatabaseConfig, Error, ListQuery, NewNotification, NotificationStatus, + NotifyClient, UpdateNotification, Urgency, +}; +use clap::{Parser, Subcommand}; +use std::collections::HashMap; +use std::process; + +#[derive(Debug, Parser)] +#[command(author, version, about = "CLI for SQLite-backed notification store")] +struct Cli { + /// Optional explicit D-Bus bus address. + #[arg(long)] + dbus_address: Option, + + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// List notifications. + List { + #[arg(long)] + status: Option, + #[arg(long)] + app: Option, + #[arg(long)] + limit: Option, + }, + /// Get one notification by id. + Get { id: u64 }, + /// Create a notification. + Create { + #[arg(long)] + app: String, + #[arg(long)] + summary: String, + #[arg(long)] + body: String, + #[arg(long, default_value = "1")] + urgency: u8, + }, + /// Update a notification. + Update { + id: u64, + #[arg(long)] + summary: Option, + #[arg(long)] + body: Option, + #[arg(long)] + urgency: Option, + }, + /// Close a notification. + Close { id: u64 }, + /// Delete a notification. + Delete { id: u64 }, + /// Close all active notifications. + CloseAll { + #[arg(long)] + app: Option, + }, + /// Stream change events live. + Watch, +} + +fn main() { + let cli = Cli::parse(); + + let client = match NotifyClient::new(ClientConfig { + database: DatabaseConfig::default(), + dbus_address: cli.dbus_address, + ..ClientConfig::default() + }) { + Ok(client) => client, + Err(err) => { + eprintln!("unable to initialize client: {err}"); + process::exit(1); + } + }; + + if let Err(err) = client.migrate() { + eprintln!("unable to apply migrations: {err}"); + process::exit(1); + } + + if let Err(err) = run_command(&client, cli.command) { + eprintln!("command failed: {err}"); + process::exit(1); + } +} + +fn run_command(client: &NotifyClient, command: Command) -> armesto::Result<()> { + match command { + Command::List { status, app, limit } => { + let query = ListQuery { + status: parse_status(status.as_deref())?, + app_name: app, + limit, + }; + let notifications = client.list(query)?; + print_json(¬ifications); + } + Command::Get { id } => { + let notification = client.get(id)?; + print_json(¬ification); + } + Command::Create { + app, + summary, + body, + urgency, + } => { + let notification = client.create(NewNotification { + app_name: app, + summary, + body, + icon: String::new(), + urgency: parse_urgency(urgency)?, + actions: Vec::new(), + hints: HashMap::new(), + })?; + print_json(¬ification); + } + Command::Update { + id, + summary, + body, + urgency, + } => { + let notification = client.update(UpdateNotification { + id, + summary, + body, + urgency: urgency.map(parse_urgency).transpose()?, + })?; + print_json(¬ification); + } + Command::Close { id } => { + client.close(id)?; + println!("closed {id}"); + } + Command::Delete { id } => { + client.delete(id)?; + println!("deleted {id}"); + } + Command::CloseAll { app } => { + let count = client.close_all(app.as_deref())?; + println!("closed {count} notification(s)"); + } + Command::Watch => { + let events = client.subscribe_changes()?; + for event in events { + print_json(&event); + } + } + } + + Ok(()) +} + +fn parse_status(input: Option<&str>) -> armesto::Result> { + match input { + None => Ok(None), + Some("active") => Ok(Some(NotificationStatus::Active)), + Some("closed") => Ok(Some(NotificationStatus::Closed)), + Some(other) => Err(Error::Validation(format!( + "invalid status '{other}', expected active|closed" + ))), + } +} + +fn parse_urgency(level: u8) -> armesto::Result { + match level { + 0 => Ok(Urgency::Low), + 1 => Ok(Urgency::Normal), + 2 => Ok(Urgency::Critical), + other => Err(Error::Validation(format!( + "invalid urgency '{other}', expected 0|1|2" + ))), + } +} + +fn print_json(value: &T) { + match serde_json::to_string_pretty(value) { + Ok(output) => println!("{output}"), + Err(_) => println!("{}", serde_json::to_string(value).unwrap_or_default()), + } +} diff --git a/src/bin/armesto-server.rs b/src/bin/armesto-server.rs new file mode 100644 index 0000000..04c9c8c --- /dev/null +++ b/src/bin/armesto-server.rs @@ -0,0 +1,39 @@ +//! Command-line entrypoint for running the notification daemon. + +use armesto::{DatabaseConfig, NotificationServer, ServerConfig}; +use clap::Parser; +use std::process; +use std::time::Duration; + +#[derive(Debug, Parser)] +#[command(author, version, about = "DB-backed notification daemon")] +struct Cli { + /// Optional explicit D-Bus bus address. + #[arg(long)] + dbus_address: Option, + + /// Optional rofi-compatible UNIX socket path. + #[arg(long)] + rofi_socket: Option, + + /// D-Bus poll interval in milliseconds. + #[arg(long, default_value = "1000")] + dbus_poll_timeout_ms: u64, +} + +fn main() { + let cli = Cli::parse(); + let config = ServerConfig { + database: DatabaseConfig::default(), + dbus_address: cli.dbus_address, + rofi_socket_path: cli.rofi_socket, + dbus_poll_timeout: Duration::from_millis(cli.dbus_poll_timeout_ms), + ..ServerConfig::default() + }; + + let server = NotificationServer::new(config); + if let Err(err) = server.run() { + eprintln!("armesto-server failed: {err}"); + process::exit(1); + } +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..db6bf97 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,219 @@ +//! Client API and change-stream subscription for notifications. + +use crate::config::ClientConfig; +use crate::dbus_support::{open_connection, CHANGE_INTERFACE, CHANGE_PATH, CHANGE_SIGNAL}; +use crate::error::Result; +use crate::model::{ + ChangeEvent, ChangeKind, ListQuery, NewNotification, Notification, UpdateNotification, +}; +use crate::repository::SqliteRepository; +use dbus::blocking::Connection; +use dbus::message::MatchRule; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +/// Stream of notification change events. +#[derive(Debug)] +pub struct ChangeStream { + receiver: Receiver, + stop: Arc, + worker: Option>, +} + +impl ChangeStream { + /// Waits for the next change event up to the provided timeout. + pub fn next_timeout(&self, timeout: Duration) -> Option { + self.receiver.recv_timeout(timeout).ok() + } +} + +impl Iterator for ChangeStream { + type Item = ChangeEvent; + + fn next(&mut self) -> Option { + self.receiver.recv().ok() + } +} + +impl Drop for ChangeStream { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(handle) = self.worker.take() { + let _ = handle.join(); + } + } +} + +/// API for interacting with the SQLite-backed notification store. +#[derive(Clone, Debug)] +pub struct NotifyClient { + config: ClientConfig, + repository: SqliteRepository, +} + +impl NotifyClient { + /// Create a new API client. + pub fn new(config: ClientConfig) -> Result { + let repository = SqliteRepository::new(&config.database)?; + Ok(Self { config, repository }) + } + + /// Applies migrations before serving API requests. + pub fn migrate(&self) -> Result<()> { + self.repository.migrate() + } + + /// Create a new notification. + pub fn create(&self, req: NewNotification) -> Result { + let _ = &self.config; + self.repository.create(req) + } + + /// Retrieve one notification by id. + pub fn get(&self, id: u64) -> Result> { + let _ = &self.config; + self.repository.get(id) + } + + /// List notifications matching query filters. + pub fn list(&self, query: ListQuery) -> Result> { + let _ = &self.config; + self.repository.list(&query) + } + + /// Update a notification. + pub fn update(&self, req: UpdateNotification) -> Result { + let _ = &self.config; + self.repository.update(req) + } + + /// Close a notification. + pub fn close(&self, id: u64) -> Result<()> { + let _ = &self.config; + self.repository.close(id) + } + + /// Delete a notification. + pub fn delete(&self, id: u64) -> Result<()> { + let _ = &self.config; + self.repository.delete(id) + } + + /// Close all active notifications. + pub fn close_all(&self, app_name: Option<&str>) -> Result { + let _ = &self.config; + self.repository.close_all(app_name) + } + + /// Subscribe to live change events. + /// + /// The stream starts from the current latest change id and yields new events as they arrive. + pub fn subscribe_changes(&self) -> Result { + let repository = self.repository.clone(); + let start_cursor = repository.latest_change_id()?; + let dbus_address = self.config.dbus_address.clone(); + + let (tx, rx) = mpsc::channel(); + let stop = Arc::new(AtomicBool::new(false)); + let stop_worker = Arc::clone(&stop); + + let worker = thread::Builder::new() + .name("notify-change-subscriber".to_string()) + .spawn(move || { + let connection = match open_connection(dbus_address.as_deref()) { + Ok(connection) => connection, + Err(_) => return, + }; + + if install_change_match( + &connection, + repository, + tx, + stop_worker.clone(), + start_cursor, + ) + .is_err() + { + return; + } + + while !stop_worker.load(Ordering::Relaxed) { + if connection.process(Duration::from_millis(200)).is_err() { + break; + } + } + }) + .map_err(crate::Error::from)?; + + Ok(ChangeStream { + receiver: rx, + stop, + worker: Some(worker), + }) + } +} + +fn install_change_match( + connection: &Connection, + repository: SqliteRepository, + tx: mpsc::Sender, + stop: Arc, + start_cursor: u64, +) -> Result<()> { + let mut rule = MatchRule::new_signal(CHANGE_INTERFACE, CHANGE_SIGNAL); + rule.path = Some(CHANGE_PATH.into()); + + let mut last_seen = start_cursor; + connection.add_match(rule, move |signal: (u64, String, u64), _conn, _message| { + if stop.load(Ordering::Relaxed) { + return false; + } + + let (signal_change_id, signal_kind, signal_notification_id) = signal; + + if signal_change_id <= last_seen { + return true; + } + + match repository.list_changes_since(last_seen) { + Ok(changes) => { + for change in changes { + last_seen = change.change_id; + if tx.send(change).is_err() { + stop.store(true, Ordering::Relaxed); + return false; + } + } + } + Err(_) => { + let parsed_kind = signal_kind + .parse::() + .ok() + .map(|kind| ChangeEvent { + change_id: signal_change_id, + kind, + notification_id: if signal_notification_id == 0 { + None + } else { + Some(signal_notification_id) + }, + }); + + if let Some(change) = parsed_kind { + last_seen = change.change_id; + if tx.send(change).is_err() { + stop.store(true, Ordering::Relaxed); + return false; + } + } + } + } + + true + })?; + + Ok(()) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..343fc12 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,93 @@ +//! Runtime configuration types for server and client components. + +use crate::error::{Error, Result}; +use std::env; +use std::path::PathBuf; +use std::time::Duration; + +/// SQLite database settings. +#[derive(Clone, Debug, Default)] +pub struct DatabaseConfig { + /// Internal override path used only for tests. + override_path: Option, +} + +impl DatabaseConfig { + /// Resolves database file path. + /// + /// Runtime path is fixed to `$HOME/.cache/armesto/armesto-.db`. + pub fn resolved_path(&self) -> Result { + if let Some(path) = &self.override_path { + return Ok(path.clone()); + } + + let home = env::var("HOME").map_err(|_| { + Error::Initialization("HOME is not set; cannot resolve DB path".to_string()) + })?; + let major = env!("CARGO_PKG_VERSION").split('.').next().unwrap_or("0"); + let path = PathBuf::from(home) + .join(".cache") + .join("armesto") + .join(format!("armesto-{major}.db")); + Ok(path.to_string_lossy().to_string()) + } + + /// Creates a config with explicit DB path override for tests. + #[doc(hidden)] + pub fn for_test_path(path: impl Into) -> Self { + Self { + override_path: Some(path.into()), + } + } +} + +/// Runtime configuration for the notification daemon. +#[derive(Clone, Debug)] +pub struct ServerConfig { + /// D-Bus service name for freedesktop notification compatibility. + pub notification_bus_name: String, + /// D-Bus service name for internal change notifications. + pub change_bus_name: String, + /// Optional explicit D-Bus bus address. When unset, session bus is used. + pub dbus_address: Option, + /// Optional rofi-compatible UNIX socket path. + pub rofi_socket_path: Option, + /// Poll interval for D-Bus process loop. + pub dbus_poll_timeout: Duration, + /// Database settings. + pub database: DatabaseConfig, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + notification_bus_name: "org.freedesktop.Notifications".to_string(), + change_bus_name: "org.armesto.NotifyStore1".to_string(), + dbus_address: None, + rofi_socket_path: None, + dbus_poll_timeout: Duration::from_millis(1000), + database: DatabaseConfig::default(), + } + } +} + +/// Runtime configuration for API/CLI clients. +#[derive(Clone, Debug)] +pub struct ClientConfig { + /// Database settings. + pub database: DatabaseConfig, + /// Optional D-Bus service name for change subscriptions. + pub change_bus_name: String, + /// Optional explicit D-Bus bus address. When unset, session bus is used. + pub dbus_address: Option, +} + +impl Default for ClientConfig { + fn default() -> Self { + Self { + database: DatabaseConfig::default(), + change_bus_name: "org.armesto.NotifyStore1".to_string(), + dbus_address: None, + } + } +} diff --git a/src/dbus.rs b/src/dbus.rs deleted file mode 100644 index 6abba46..0000000 --- a/src/dbus.rs +++ /dev/null @@ -1,277 +0,0 @@ -use crate::error::{self, Error}; -use crate::notification::{Notification, Action}; -use dbus::arg::{RefArg, Variant}; -use dbus::blocking::stdintf::org_freedesktop_dbus::RequestNameReply; -use dbus::blocking::{Connection, Proxy}; -use dbus::channel::MatchingReceiver; -use dbus::message::MatchRule; -use dbus::MethodErr; -use dbus_crossroads::Crossroads; -use log::{trace, debug}; -use std::collections::HashMap; -use std::sync::atomic::{AtomicU32, Ordering}; -use std::sync::mpsc::Sender; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -/// D-Bus server information. -/// -/// Specifically, the server name, vendor, version, and spec version. -const SERVER_INFO: [&str; 4] = [ - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_AUTHORS"), - env!("CARGO_PKG_VERSION"), - "1.2", -]; - -/// D-Bus server capabilities. -/// -/// - `actions`: The server will provide the specified actions to the user. -/// - `body`: Supports body text. -const SERVER_CAPABILITIES: [&str; 2] = ["actions", "body"]; - -mod dbus_server { - #![allow(clippy::too_many_arguments)] - include!(concat!(env!("OUT_DIR"), "/introspection.rs")); -} - -/// ID counter for the notification. -static ID_COUNT: AtomicU32 = AtomicU32::new(1); - -/// D-Bus interface for desktop notifications. -const NOTIFICATION_INTERFACE: &str = "org.freedesktop.Notifications"; - -/// D-Bus path for desktop notifications. -const NOTIFICATION_PATH: &str = "/org/freedesktop/Notifications"; - -/// D-Bus notification implementation. -/// -/// -pub struct DbusNotification { - sender: Sender, -} - -impl dbus_server::OrgFreedesktopNotifications for DbusNotification { - fn get_capabilities(&mut self) -> Result, dbus::MethodErr> { - Ok(SERVER_CAPABILITIES.into_iter().map(String::from).collect()) - } - - fn notify( - &mut self, - application: String, - replaces_id: u32, - icon: String, - summary: String, - body: String, - actions: Vec, - hints: dbus::arg::PropMap, - _expire_timeout: i32, - ) -> Result { - let id = if replaces_id == 0 { - ID_COUNT.fetch_add(1, Ordering::Relaxed) - } else { - replaces_id - }; - let notification = Notification { - id, - summary, - body, - application, - icon, - urgency: hints - .get("urgency") - .and_then(|v| v.as_u64()) - .map(|v| v.into()) - .unwrap_or_default(), - actions, - hints: hints - .into_iter() - .map(|(k, v)| (k, v.as_str().unwrap_or_default().to_string())) - .collect(), - timestamp: SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|e| dbus::MethodErr::failed(&e))? - .as_secs(), - }; - debug!("Received notification {} from dbus", notification.id); - - match self.sender.send(Action::Show(notification)) { - Ok(_) => Ok(id), - Err(e) => Err(dbus::MethodErr::failed(&e)), - } - } - - fn close_notification(&mut self, id: u32) -> Result<(), dbus::MethodErr> { - debug!("Received close signal for notification {}", id); - match self.sender.send(Action::Close(Some(id))) { - Ok(_) => Ok(()), - Err(e) => Err(dbus::MethodErr::failed(&e)), - } - } - - fn get_server_information( - &mut self, - ) -> Result<(String, String, String, String), dbus::MethodErr> { - Ok(( - SERVER_INFO[0].to_string(), - SERVER_INFO[1].to_string(), - SERVER_INFO[2].to_string(), - SERVER_INFO[3].to_string(), - )) - } -} - -/// Wrapper for a [`D-Bus connection`] and [`server`] handler. -/// -/// [`D-Bus connection`]: Connection -/// [`server`]: Crossroads -pub struct DbusServer { - /// Connection to D-Bus. - connection: Connection, - /// Server handler. - crossroads: Crossroads, -} - -impl DbusServer { - /// Initializes the D-Bus controller. - pub fn init() -> error::Result { - debug!("D-Bus server information: {:#?}", SERVER_INFO); - debug!("D-Bus server capabilities: {:?}", SERVER_CAPABILITIES); - let connection = Connection::new_session()?; - let crossroads = Crossroads::new(); - Ok(Self { - connection, - crossroads, - }) - } - - /// Registers a handler for handling D-Bus notifications. - /// - /// Handles the incoming messages in a blocking manner. - pub fn register_notification_handler( - mut self, - sender: Sender, - timeout: Duration, - ) -> Result<(), Error> { - let reply = self.connection - .request_name(NOTIFICATION_INTERFACE, false, true, false)?; - - if reply != RequestNameReply::PrimaryOwner { - return Err(error::Error::InitializationError); - } - - let token = dbus_server::register_org_freedesktop_notifications(&mut self.crossroads); - self.crossroads.insert( - NOTIFICATION_PATH, - &[token], - DbusNotification { - sender: sender.clone(), - }, - ); - let token = self.crossroads.register(NOTIFICATION_INTERFACE, |builder| { - let sender_cloned = sender.clone(); - builder.method("History", (), ("reply",), move |_, _, ()| { - sender_cloned - .send(Action::ShowLast) - .map_err(|e| MethodErr::failed(&e))?; - Ok((String::from("history signal sent"),)) - }); - let sender_cloned = sender.clone(); - builder.method("Close", (), ("reply",), move |_, _, (): ()| { - sender_cloned - .send(Action::Close(None)) - .map_err(|e| MethodErr::failed(&e))?; - Ok((String::from("close signal sent"),)) - }); - builder.method("CloseAll", (), ("reply",), move |_, _, ()| { - sender - .send(Action::CloseAll) - .map_err(|e| MethodErr::failed(&e))?; - Ok((String::from("close all signal sent"),)) - }); - }); - self.crossroads - .insert(format!("{NOTIFICATION_PATH}/ctl"), &[token], ()); - self.connection.start_receive( - MatchRule::new_method_call(), - Box::new(move |message, connection| { - self.crossroads - .handle_message(message, connection) - .expect("failed to handle message"); - true - }), - ); - loop { - self.connection.process(timeout)?; - } - } -} - -/// Wrapper for a [`D-Bus connection`] without the server part. -/// -/// [`D-Bus connection`]: Connection -pub struct DbusClient { - /// Connection to D-Bus. - connection: Connection, -} - -unsafe impl Send for DbusClient {} -unsafe impl Sync for DbusClient {} - -impl DbusClient { - /// Initializes the D-Bus controller. - pub fn init() -> error::Result { - let connection = Connection::new_session()?; - Ok(Self { connection }) - } - - /// Sends a notification. - /// - /// See `org.freedesktop.Notifications.Notify` - pub fn notify>( - &self, - app_name: S, - summary: S, - body: S, - expire_timeout: i32, - ) -> error::Result<()> { - let proxy = Proxy::new( - NOTIFICATION_INTERFACE, - NOTIFICATION_PATH, - Duration::from_millis(1000), - &self.connection, - ); - proxy.method_call( - NOTIFICATION_INTERFACE, - "Notify", - ( - app_name.into(), - 0_u32, - String::new(), - summary.into(), - body.into(), - Vec::::new(), - { - let mut hints = HashMap::>>::new(); - hints.insert(String::from("urgency"), Variant(Box::new(0_u8))); - hints - }, - expire_timeout, - ), - )?; - Ok(()) - } - - /// Closes the notification. - /// - /// See `org.freedesktop.Notifications.CloseNotification` - pub fn close_notification(&self, id: u32, timeout: Duration) -> error::Result<()> { - let proxy = Proxy::new( - NOTIFICATION_INTERFACE, - NOTIFICATION_PATH, - timeout, - &self.connection, - ); - proxy.method_call(NOTIFICATION_INTERFACE, "CloseNotification", (id,))?; - Ok(()) - } -} diff --git a/src/dbus_support.rs b/src/dbus_support.rs new file mode 100644 index 0000000..51c3874 --- /dev/null +++ b/src/dbus_support.rs @@ -0,0 +1,28 @@ +//! Shared D-Bus constants and connection helpers. + +use crate::{Error, Result}; +use dbus::blocking::Connection; +use dbus::channel::Channel; + +/// Freedesktop notification interface. +pub const NOTIFICATION_INTERFACE: &str = "org.freedesktop.Notifications"; +/// Freedesktop notification object path. +pub const NOTIFICATION_PATH: &str = "/org/freedesktop/Notifications"; +/// Internal change signal interface. +pub const CHANGE_INTERFACE: &str = "org.armesto.NotifyStore1"; +/// Internal change signal object path. +pub const CHANGE_PATH: &str = "/org/armesto/NotifyStore1"; +/// Internal change signal member name. +pub const CHANGE_SIGNAL: &str = "NotificationChanged"; + +/// Opens a blocking D-Bus connection from either explicit address or session bus. +pub fn open_connection(address: Option<&str>) -> Result { + match address { + Some(address) => { + let mut channel = Channel::open_private(address)?; + channel.register()?; + Ok(Connection::from(channel)) + } + None => Connection::new_session().map_err(Error::from), + } +} diff --git a/src/error.rs b/src/error.rs index f7e6d8e..b81be1b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,36 +1,38 @@ -#![allow(missing_docs)] +//! Error and result types used across the notification backend. use thiserror::Error as ThisError; +/// Error type for SQLite-backed notification operations. #[derive(Debug, ThisError)] pub enum Error { + /// IO operation failed. #[error("IO error: `{0}`")] Io(#[from] std::io::Error), + /// JSON operation failed. + #[error("JSON error: `{0}`")] + Json(#[from] serde_json::Error), + /// SQLite operation failed. + #[error("SQLite error: `{0}`")] + Sqlite(#[from] rusqlite::Error), + /// D-Bus operation failed. #[error("D-Bus error: `{0}`")] Dbus(#[from] dbus::Error), - #[error("D-Bus string error: `{0}`")] - DbusString(String), - #[error("D-Bus argument error: `{0}`")] - DbusArgument(String), - #[error("Receiver error: `{0}`")] - Receiver(#[from] std::sync::mpsc::RecvError), - #[error("TOML parsing error: `{0}`")] - Toml(#[from] toml::de::Error), - #[error("Scan error: `{0}`")] - Scanf(String), - #[error("Integer conversion error: `{0}`")] - IntegerConversion(#[from] std::num::TryFromIntError), - #[error("Template parse error:\n{0}")] - TemplateParse(String), - #[error("Template render error:\n{0}")] - TemplateRender(String), - #[error("System time error: `{0}`")] - SystemTime(#[from] std::time::SystemTimeError), - #[error("Config error: `{0}`")] - Config(String), - #[error("Init error")] - InitializationError, + /// Validation failed on user input. + #[error("Validation error: `{0}`")] + Validation(String), + /// Data parse failed. + #[error("Parse error: `{0}`")] + Parse(String), + /// Requested resource was not found. + #[error("Not found: `{0}`")] + NotFound(String), + /// Initialization failed. + #[error("Initialization failed: `{0}`")] + Initialization(String), + /// Feature has not been implemented yet in the scaffold. + #[error("Not implemented: `{0}`")] + NotImplemented(&'static str), } -/// Type alias for the standard [`Result`] type. +/// Result alias for the crate. pub type Result = std::result::Result; diff --git a/src/lib.rs b/src/lib.rs index 3660448..1d81339 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,93 +1,22 @@ -//! A dead simple notification daemon. - #![warn(missing_docs, clippy::unwrap_used)] -/// Error handler. -pub mod error; - -/// D-Bus handler. -pub mod dbus; - -/// Notification manager. -pub mod notification; - -/// Rofi server -pub mod rofi; - -use crate::dbus::DbusServer; -use crate::error::Result; -use clap::Parser; -use log::{debug}; -use notification::Action; -use crate::rofi::RofiServer; -use notification::NotificationStore; -use std::sync::mpsc; -use std::thread; -use std::time::Duration; - -/// Startup configuration -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] -pub struct Config { - /// Local path to file representing domain socket - #[arg(short, long, default_value = "/tmp/armesto")] - pub socket_path: String, - - /// Duration to wait for incoming d-bus messages - #[arg(short, long, default_value_t = 1000)] - pub dbus_poll_timeout: u16, -} - -/// Service entry-point -pub fn run(config: Config) -> Result<()> { - let dbus_server = DbusServer::init()?; - let db = NotificationStore::init(); - let (dbus_sender, receiver) = mpsc::channel(); - let rofi_sender = dbus_sender.clone(); - - thread::Builder::new().name("dbus".to_string()).spawn(move || { - debug!("registering D-Bus server"); - let dbus_sender2 = dbus_sender.clone(); - let duration = Duration::from_millis(config.dbus_poll_timeout.into()); - dbus_server - .register_notification_handler(dbus_sender, duration) - .unwrap_or_else(|err| { - dbus_sender2.send(Action::Shutdown(err.into())).unwrap(); - () - }); - })?; - - let db_clone = db.clone(); - thread::Builder::new().name("rofication".to_string()).spawn(move || { - debug!("starting rofication server"); - let rofi_server = RofiServer::new("/tmp/rofi_notification_daemon".to_string(), db_clone); - rofi_server - .start() - .unwrap_or_else(|err| { - rofi_sender.send(Action::Shutdown(err.into())).unwrap(); - () - }); - })?; - - loop { - match receiver.recv()? { - Action::Show(notification) => { - db.add(notification); - } - Action::ShowLast => { - debug!("showing the last notification"); - } - Action::Close(id) => { - if let Some(id) = id { - debug!("closing notification: {}", id); - db.delete(id); - } - } - Action::CloseAll => { - debug!("closing all notifications"); - db.delete_all(); - } - Action::Shutdown(reason) => break Err(reason), - } - } -} +//! DB-backed Linux desktop notification backend primitives. + +mod client; +mod config; +mod dbus_support; +mod error; +mod model; +mod repository; +mod rofi; +mod server; + +pub use crate::client::{ChangeStream, NotifyClient}; +pub use crate::config::{ClientConfig, DatabaseConfig, ServerConfig}; +pub use crate::error::{Error, Result}; +pub use crate::model::{ + ChangeEvent, ChangeKind, ListQuery, NewNotification, Notification, NotificationStatus, + UpdateNotification, Urgency, +}; +pub use crate::repository::SqliteRepository; +pub use crate::server::NotificationServer; diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index ec089c4..0000000 --- a/src/main.rs +++ /dev/null @@ -1,34 +0,0 @@ -use std::{process}; -use armesto::Config; -use clap::{Parser}; -use syslog::{Facility, Formatter3164, BasicLogger}; -use log::{error, LevelFilter, debug}; - -fn main() { - let formatter = Formatter3164 { - facility: Facility::LOG_USER, - hostname: None, - process: "armesto".into(), - pid: 0, - }; - - let logger = match syslog::unix(formatter) { - Err(e) => { println!("unable to connect to syslog: {:?}", e); return; }, - Ok(logger) => logger, - }; - - log::set_boxed_logger(Box::new(BasicLogger::new(logger))) - .map(|()| log::set_max_level(LevelFilter::Debug)) - .expect("can set logger"); - - let config = Config::parse(); - debug!("Starting armesto with {:?}", config); - - match armesto::run(config) { - Ok(_) => process::exit(0), - Err(e) => { - error!("Unable to start armesto, aborting: {:?}", e); - process::exit(1) - } - } -} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..6cd154f --- /dev/null +++ b/src/model.rs @@ -0,0 +1,154 @@ +//! Data model types for notifications and change events. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::str::FromStr; + +/// Notification urgency. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[repr(u8)] +pub enum Urgency { + /// Low urgency. + Low = 0, + /// Normal urgency. + #[default] + Normal = 1, + /// Critical urgency. + Critical = 2, +} + +/// Notification lifecycle status. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub enum NotificationStatus { + /// Notification is currently active. + #[default] + Active, + /// Notification has been closed. + Closed, +} + +/// Canonical notification record. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct Notification { + /// Persistent notification id. + pub id: u64, + /// Source application name. + pub app_name: String, + /// Summary/title. + pub summary: String, + /// Body text. + pub body: String, + /// App-provided icon token. + pub icon: String, + /// Notification urgency. + pub urgency: Urgency, + /// Optional action definitions. + pub actions: Vec, + /// Optional metadata hints. + pub hints: HashMap, + /// Current status. + pub status: NotificationStatus, + /// UTC timestamp (RFC3339) for creation. + pub created_at: String, + /// UTC timestamp (RFC3339) for last update. + pub updated_at: String, + /// Optional UTC timestamp (RFC3339) for closure. + pub closed_at: Option, +} + +/// Input for creating a notification. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct NewNotification { + /// Source application name. + pub app_name: String, + /// Summary/title. + pub summary: String, + /// Body text. + pub body: String, + /// Icon token. + pub icon: String, + /// Urgency level. + pub urgency: Urgency, + /// Optional action definitions. + pub actions: Vec, + /// Optional metadata hints. + pub hints: HashMap, +} + +/// Input for updating a notification. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct UpdateNotification { + /// Target notification id. + pub id: u64, + /// Optional updated summary. + pub summary: Option, + /// Optional updated body. + pub body: Option, + /// Optional updated urgency. + pub urgency: Option, +} + +/// Query options for list operations. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct ListQuery { + /// Optional status filter. + pub status: Option, + /// Optional application filter. + pub app_name: Option, + /// Optional result limit. + pub limit: Option, +} + +/// Change event type. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum ChangeKind { + /// Notification created. + Create, + /// Notification updated. + Update, + /// Notification closed. + Close, + /// Notification deleted. + Delete, + /// Bulk close performed. + CloseAll, +} + +impl ChangeKind { + /// Returns canonical lower-case representation used in storage and wire signaling. + pub fn as_str(&self) -> &'static str { + match self { + Self::Create => "create", + Self::Update => "update", + Self::Close => "close", + Self::Delete => "delete", + Self::CloseAll => "close_all", + } + } +} + +impl FromStr for ChangeKind { + type Err = (); + + fn from_str(value: &str) -> Result { + match value { + "create" => Ok(Self::Create), + "update" => Ok(Self::Update), + "close" => Ok(Self::Close), + "delete" => Ok(Self::Delete), + "close_all" => Ok(Self::CloseAll), + _ => Err(()), + } + } +} + +/// Storage mutation event for stream consumers. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ChangeEvent { + /// Monotonic change id. + pub change_id: u64, + /// Mutation type. + pub kind: ChangeKind, + /// Optional target notification id. + pub notification_id: Option, +} diff --git a/src/notification.rs b/src/notification.rs deleted file mode 100644 index e06ad01..0000000 --- a/src/notification.rs +++ /dev/null @@ -1,284 +0,0 @@ -use serde::Serialize; -use serde_repr::Serialize_repr; -use std::collections::HashMap; -use std::fmt::Display; -use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}; - -/// Name of the template for rendering the notification message. -pub const NOTIFICATION_MESSAGE_TEMPLATE: &str = "notification_message_template"; - -/// Possible urgency levels for the notification. -#[derive(Clone, Debug, Serialize_repr, Copy, PartialEq)] -#[repr(u8)] -pub enum Urgency { - /// Urgency - low - Low, - /// Urgency - normal - Normal, - /// Urgency - high - Critical, -} - -impl Display for Urgency { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", format!("{self:?}").to_lowercase()) - } -} - -impl From for Urgency { - fn from(value: u64) -> Self { - match value { - 0 => Self::Low, - 1 => Self::Normal, - 2 => Self::Critical, - _ => Self::default(), - } - } -} - -impl Default for Urgency { - fn default() -> Self { - Self::Normal - } -} -/// Representation of a notification. -/// -/// See [D-Bus Notify Parameters](https://specifications.freedesktop.org/notification-spec/latest/ar01s09.html) -#[derive(Clone, Debug, Default, Serialize)] -pub struct Notification { - /// notification id - pub id: u32, - /// summary - pub summary: String, - /// body - pub body: String, - /// name of app that generated the notification - pub application: String, - /// icon name from app that generated the notification - pub icon: String, - /// urgency of notification - pub urgency: Urgency, - /// possible actions against notification - pub actions: Vec, - /// other notification metadata - pub hints: HashMap, - /// time that notification was received by daemon - pub timestamp: u64, -} - -/// Specifies internal events -#[derive(Debug)] -pub enum Action { - /// Show a notification event from dbus - Show(Notification), - /// Show the last notification from dbus - ShowLast, - /// Close a notification event from dbus - Close(Option), - /// Close all the notifications event from dbus - CloseAll, - /// A fatal problem occurred, exit - Shutdown(crate::error::Error), -} - -/// Notification database -#[derive(Debug)] -pub struct NotificationStore { - /// Inner type that holds the notifications in thread-safe way. - inner: Arc>>, -} - -impl Clone for NotificationStore { - fn clone(&self) -> Self { - Self { - inner: Arc::clone(&self.inner), - } - } -} - -impl NotificationStore { - /// Initializes the notification db - pub fn init() -> Self { - Self { - inner: Arc::new(RwLock::new(Vec::new())), - } - } - - /// Returns the number of notifications. - pub fn count(&self) -> usize { - self.inner - .read() - .expect("failed to retrieve notifications") - .len() - } - - /// Adds a new notifications to manage. - pub fn add(&self, notification: Notification) { - self.ds_write().push(notification); - } - - /// Return a copy of all active notifications at time of call - pub fn items(&self) -> Vec { - self.ds_read().iter().cloned().collect() - } - - /// Marks the given notification as read. - pub fn delete(&self, id: u32) { - let mut ds = self.ds_write(); - - ds.retain(|e| e.id != id); - } - /// Marks all the notifications as read. - pub fn delete_all(&self) { - self.ds_write().clear() - } - - /// Marks the given notification as read. - pub fn delete_from_app(&self, app_name: String) { - let mut ds = self.ds_write(); - - ds.retain(|e| e.application != app_name); - } - - /// set the urgency of the notification - pub fn set_urgency(&self, id: u32, target_urgency: Urgency) { - let mut ds = self.ds_write(); - - let notification = ds - .iter_mut() - .find(|n| n.id == id); - - if notification.is_some() { - let notification = notification.unwrap(); - notification.urgency = target_urgency; - } - } - - fn ds_read(&self) -> RwLockReadGuard<'_, Vec> { - self.inner.read().expect("can read from db store") - } - - fn ds_write(&self) -> RwLockWriteGuard<'_, Vec> { - self.inner.write().expect("can write to db store") - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::notification::NotificationStore; - - #[test] - fn notification_store_init() { - let unit = NotificationStore::init(); - - assert_eq!( - unit.count(), - 0, - "initialized store contains no notifications" - ); - } - - #[test] - fn notification_store_add() { - let (unit, added_item) = add_single_item(); - - assert_eq!( - unit.count(), - 1, - "adding one notification has expected count" - ); - - let binding = unit.items(); - let retrieved_item = binding - .iter() - .next() - .expect("Can get added notification from store"); - - assert_eq!(added_item.id, retrieved_item.id); - } - - #[test] - fn notification_store_delete_one() { - let (unit, _) = add_single_item(); - - unit.delete(0); // invalid id - assert_eq!( - unit.count(), - 1, - "no change after attempt to delete invalid id" - ); - - unit.delete(1); - assert_eq!(unit.count(), 0, "count down by own after deleting valid id"); - } - - #[test] - fn notification_store_delete_by_app() { - let (unit, _) = add_single_item(); - - unit.delete_from_app("invalid_app_name".to_string()); // invalid app name - assert_eq!( - unit.count(), - 1, - "no change after attempt to delete invalid id" - ); - - unit.delete_from_app("test-app".to_string()); - assert_eq!(unit.count(), 0, "count down by own after deleting valid id"); - } - - #[test] - fn notification_store_delete_all() { - let (unit, _) = add_single_item(); - - unit.delete_all(); - assert_eq!(unit.count(), 0, "count down by own after deleting valid id"); - } - - #[test] - fn notification_store_change_urgency() { - let (unit, _) = add_single_item(); - - unit.set_urgency(1, Urgency::Low); - - let notifications = unit.items(); - let n = notifications.iter().next().expect("Has added element"); - - assert_eq!(n.id, 1); - assert_eq!(n.urgency, Urgency::Low); - } - - fn add_single_item() -> (NotificationStore, Notification) { - let unit = NotificationStore::init(); - - let test_notification: Notification = Notification { - id: 1, - summary: "test-summary".to_string(), - body: "test-body".to_string(), - application: "test-app".to_string(), - icon: "test-icon".to_string(), - urgency: Urgency::Critical, - actions: vec!["test-action-1".to_string()], - hints: HashMap::from([( - "test-hint-key-1".to_string(), - "test-hint-value-1".to_string(), - )]), - timestamp: 1234, - }; - - let test_notification_copy: Notification = Notification { - summary: test_notification.summary.clone(), - body: test_notification.body.clone(), - application: test_notification.application.clone(), - icon: test_notification.icon.clone(), - actions: test_notification.actions.clone(), - hints: test_notification.hints.clone(), - ..test_notification - }; - - unit.add(test_notification); - - (unit, test_notification_copy) - } -} diff --git a/src/repository.rs b/src/repository.rs new file mode 100644 index 0000000..c63cb72 --- /dev/null +++ b/src/repository.rs @@ -0,0 +1,553 @@ +//! SQLite repository for notification persistence and queries. + +use crate::config::DatabaseConfig; +use crate::error::{Error, Result}; +use crate::model::{ + ChangeEvent, ChangeKind, ListQuery, NewNotification, Notification, NotificationStatus, + UpdateNotification, Urgency, +}; +use rusqlite::types::Value; +use rusqlite::{params, params_from_iter, Connection, OptionalExtension}; +use std::collections::HashMap; +use std::path::Path; + +const INIT_SQL: &str = include_str!("../migrations/0001_init.sql"); +const NOW_SQL: &str = "STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now')"; + +/// SQLite-backed repository. +#[derive(Clone, Debug)] +pub struct SqliteRepository { + db_path: String, +} + +impl SqliteRepository { + /// Creates a new repository instance. + pub fn new(config: &DatabaseConfig) -> Result { + let db_path = config.resolved_path()?; + Ok(Self { db_path }) + } + + /// Applies schema migration scripts. + pub fn migrate(&self) -> Result<()> { + let conn = self.open_connection()?; + conn.execute_batch(INIT_SQL)?; + Ok(()) + } + + /// Inserts a new notification and returns the stored row. + pub fn create(&self, req: NewNotification) -> Result { + validate_new_notification(&req)?; + + let actions_json = serde_json::to_string(&req.actions)?; + let hints_json = serde_json::to_string(&req.hints)?; + + let mut conn = self.open_connection()?; + let tx = conn.transaction()?; + + tx.execute( + "INSERT INTO notifications ( + app_name, summary, body, icon, urgency, actions_json, hints_json, status, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'active', STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now'), STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now'))", + params![ + req.app_name, + req.summary, + req.body, + req.icon, + urgency_to_i64(req.urgency), + actions_json, + hints_json, + ], + )?; + + let id = u64::try_from(tx.last_insert_rowid()) + .map_err(|_| Error::Parse("failed to convert inserted row id".to_string()))?; + + tx.execute( + "INSERT INTO notification_changes (notification_id, change_kind, metadata_json) + VALUES (?1, 'create', ?2)", + params![id, r#"{"source":"api"}"#], + )?; + + tx.commit()?; + + match self.get(id)? { + Some(notification) => Ok(notification), + None => Err(Error::Parse( + "notification inserted but not found".to_string(), + )), + } + } + + /// Fetches one notification by id. + pub fn get(&self, id: u64) -> Result> { + let conn = self.open_connection()?; + let mut stmt = conn.prepare( + "SELECT id, app_name, summary, body, icon, urgency, actions_json, hints_json, + status, created_at, updated_at, closed_at + FROM notifications + WHERE id = ?1 + LIMIT 1", + )?; + + let maybe = stmt + .query_row(params![id], map_notification_row) + .optional()?; + + Ok(maybe) + } + + /// Lists notifications matching query filters. + pub fn list(&self, query: &ListQuery) -> Result> { + if let Some(limit) = query.limit { + if limit == 0 { + return Err(Error::Validation( + "limit must be greater than 0".to_string(), + )); + } + } + + let conn = self.open_connection()?; + let mut sql = String::from( + "SELECT id, app_name, summary, body, icon, urgency, actions_json, hints_json, + status, created_at, updated_at, closed_at + FROM notifications", + ); + let mut filters: Vec = Vec::new(); + let mut values: Vec = Vec::new(); + + if let Some(status) = &query.status { + filters.push("status = ?".to_string()); + values.push(Value::Text(status_to_sql(status).to_string())); + } + + if let Some(app_name) = &query.app_name { + filters.push("app_name = ?".to_string()); + values.push(Value::Text(app_name.clone())); + } + + if !filters.is_empty() { + sql.push_str(" WHERE "); + sql.push_str(&filters.join(" AND ")); + } + + sql.push_str(" ORDER BY created_at DESC"); + + if let Some(limit) = query.limit { + sql.push_str(" LIMIT ?"); + values.push(Value::Integer(i64::from(limit))); + } + + let mut stmt = conn.prepare(&sql)?; + let iter = stmt.query_map(params_from_iter(values.iter()), map_notification_row)?; + let mut notifications = Vec::new(); + for row in iter { + notifications.push(row?); + } + + Ok(notifications) + } + + /// Updates a notification and returns the updated row. + pub fn update(&self, req: UpdateNotification) -> Result { + let mut sets: Vec = Vec::new(); + let mut values: Vec = Vec::new(); + + if let Some(summary) = req.summary { + sets.push("summary = ?".to_string()); + values.push(Value::Text(summary)); + } + if let Some(body) = req.body { + sets.push("body = ?".to_string()); + values.push(Value::Text(body)); + } + if let Some(urgency) = req.urgency { + sets.push("urgency = ?".to_string()); + values.push(Value::Integer(urgency_to_i64(urgency))); + } + + if sets.is_empty() { + return Err(Error::Validation( + "update requires at least one mutable field".to_string(), + )); + } + + sets.push(format!("updated_at = {NOW_SQL}")); + + let mut conn = self.open_connection()?; + let tx = conn.transaction()?; + + let sql = format!("UPDATE notifications SET {} WHERE id = ?", sets.join(", ")); + values.push(Value::Integer(i64::try_from(req.id).map_err(|_| { + Error::Validation("notification id exceeds supported range".to_string()) + })?)); + + let changed = tx.execute(&sql, params_from_iter(values.iter()))?; + if changed == 0 { + return Err(Error::NotFound(format!( + "notification id {} not found", + req.id + ))); + } + + tx.execute( + "INSERT INTO notification_changes (notification_id, change_kind, metadata_json) + VALUES (?1, 'update', ?2)", + params![req.id, r#"{"source":"api"}"#], + )?; + + tx.commit()?; + + match self.get(req.id)? { + Some(notification) => Ok(notification), + None => Err(Error::Parse( + "notification updated but not found".to_string(), + )), + } + } + + /// Marks a notification as closed. + pub fn close(&self, id: u64) -> Result<()> { + let mut conn = self.open_connection()?; + let tx = conn.transaction()?; + + let changed = tx.execute( + "UPDATE notifications + SET status = 'closed', + closed_at = COALESCE(closed_at, STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at = STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = ?1 AND status != 'closed'", + params![id], + )?; + + if changed == 0 { + let exists: Option = tx + .query_row( + "SELECT 1 FROM notifications WHERE id = ?1 LIMIT 1", + params![id], + |row| row.get(0), + ) + .optional()?; + + if exists.is_none() { + return Err(Error::NotFound(format!("notification id {id} not found"))); + } + } else { + tx.execute( + "INSERT INTO notification_changes (notification_id, change_kind, metadata_json) + VALUES (?1, 'close', ?2)", + params![id, r#"{"source":"api"}"#], + )?; + } + + tx.commit()?; + Ok(()) + } + + /// Deletes a notification. + pub fn delete(&self, id: u64) -> Result<()> { + let mut conn = self.open_connection()?; + let tx = conn.transaction()?; + + let changed = tx.execute("DELETE FROM notifications WHERE id = ?1", params![id])?; + + if changed == 0 { + return Err(Error::NotFound(format!("notification id {id} not found"))); + } + + tx.execute( + "INSERT INTO notification_changes (notification_id, change_kind, metadata_json) + VALUES (NULL, 'delete', ?1)", + params![format!(r#"{{"source":"api","notification_id":{id}}}"#)], + )?; + + tx.commit()?; + Ok(()) + } + + /// Closes all active notifications, optionally filtered by application. + pub fn close_all(&self, app_name: Option<&str>) -> Result { + let mut conn = self.open_connection()?; + let tx = conn.transaction()?; + + let changed = match app_name { + Some(app_name) => tx.execute( + "UPDATE notifications + SET status = 'closed', + closed_at = COALESCE(closed_at, STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at = STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE status = 'active' AND app_name = ?1", + params![app_name], + )?, + None => tx.execute( + "UPDATE notifications + SET status = 'closed', + closed_at = COALESCE(closed_at, STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at = STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE status = 'active'", + [], + )?, + }; + + let metadata = match app_name { + Some(app_name) => format!( + r#"{{"source":"api","bulk":true,"app_name":"{}"}}"#, + app_name.replace('"', "\\\"") + ), + None => r#"{"source":"api","bulk":true,"app_name":null}"#.to_string(), + }; + + tx.execute( + "INSERT INTO notification_changes (notification_id, change_kind, metadata_json) + VALUES (NULL, 'close_all', ?1)", + params![metadata], + )?; + + tx.commit()?; + + u64::try_from(changed) + .map_err(|_| Error::Parse("close_all changed count overflow".to_string())) + } + + /// Lists changes after a change id. + pub fn list_changes_since(&self, change_id: u64) -> Result> { + let conn = self.open_connection()?; + let mut stmt = conn.prepare( + "SELECT change_id, change_kind, notification_id + FROM notification_changes + WHERE change_id > ?1 + ORDER BY change_id ASC", + )?; + + let mut changes = Vec::new(); + let iter = stmt.query_map(params![change_id], |row| { + let kind: String = row.get(1)?; + Ok((row.get::<_, i64>(0)?, kind, row.get::<_, Option>(2)?)) + })?; + + for row in iter { + let (change_id, kind, notification_id) = row?; + changes.push(ChangeEvent { + change_id: u64::try_from(change_id) + .map_err(|_| Error::Parse("change_id overflow".to_string()))?, + kind: change_kind_from_sql(&kind)?, + notification_id: notification_id + .map(|id| { + u64::try_from(id) + .map_err(|_| Error::Parse("notification_id overflow".to_string())) + }) + .transpose()?, + }); + } + + Ok(changes) + } + + /// Returns count of active notifications. + pub fn count_active(&self) -> Result { + let conn = self.open_connection()?; + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM notifications WHERE status = 'active'", + [], + |row| row.get(0), + )?; + u64::try_from(count).map_err(|_| Error::Parse("active count overflow".to_string())) + } + + /// Returns active notifications only, newest first. + pub fn list_active(&self) -> Result> { + self.list(&ListQuery { + status: Some(NotificationStatus::Active), + ..ListQuery::default() + }) + } + + /// Deletes notifications from the given application, returning affected rows. + pub fn delete_by_app(&self, app_name: &str) -> Result { + let mut conn = self.open_connection()?; + let tx = conn.transaction()?; + let changed = tx.execute( + "DELETE FROM notifications WHERE app_name = ?1", + params![app_name], + )?; + + if changed > 0 { + tx.execute( + "INSERT INTO notification_changes (notification_id, change_kind, metadata_json) + VALUES (NULL, 'delete', ?1)", + params![format!( + r#"{{"source":"rofi","app_name":"{}"}}"#, + app_name.replace('"', "\\\"") + )], + )?; + } + tx.commit()?; + u64::try_from(changed).map_err(|_| Error::Parse("delete_by_app overflow".to_string())) + } + + /// Sets notification urgency to normal. + pub fn mark_seen(&self, id: u64) -> Result<()> { + self.update(UpdateNotification { + id, + summary: None, + body: None, + urgency: Some(Urgency::Normal), + })?; + Ok(()) + } + + /// Returns the most recent change id in storage. + pub fn latest_change_id(&self) -> Result { + let conn = self.open_connection()?; + let latest: i64 = conn.query_row( + "SELECT COALESCE(MAX(change_id), 0) FROM notification_changes", + [], + |row| row.get(0), + )?; + u64::try_from(latest).map_err(|_| Error::Parse("change_id overflow".to_string())) + } + + fn open_connection(&self) -> Result { + if let Some(parent) = Path::new(&self.db_path).parent() { + std::fs::create_dir_all(parent)?; + } + let conn = Connection::open(&self.db_path)?; + conn.execute_batch( + "PRAGMA foreign_keys = ON; + PRAGMA busy_timeout = 5000; + PRAGMA journal_mode = WAL;", + )?; + Ok(conn) + } +} + +fn map_notification_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let id: i64 = row.get(0)?; + let urgency: i64 = row.get(5)?; + let actions_json: String = row.get(6)?; + let hints_json: String = row.get(7)?; + let status: String = row.get(8)?; + + let parsed_actions = serde_json::from_str::>(&actions_json).map_err(|err| { + rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(err)) + })?; + + let parsed_hints = + serde_json::from_str::>(&hints_json).map_err(|err| { + rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(err)) + })?; + + let urgency = urgency_from_i64(urgency).map_err(|err| { + rusqlite::Error::FromSqlConversionFailure( + 5, + rusqlite::types::Type::Integer, + Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + err.to_string(), + )), + ) + })?; + + let status = status_from_sql(&status).map_err(|err| { + rusqlite::Error::FromSqlConversionFailure( + 8, + rusqlite::types::Type::Text, + Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + err.to_string(), + )), + ) + })?; + + Ok(Notification { + id: u64::try_from(id).map_err(|_| { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Integer, + Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "id overflow", + )), + ) + })?, + app_name: row.get(1)?, + summary: row.get(2)?, + body: row.get(3)?, + icon: row.get(4)?, + urgency, + actions: parsed_actions, + hints: parsed_hints, + status, + created_at: row.get(9)?, + updated_at: row.get(10)?, + closed_at: row.get(11)?, + }) +} + +fn validate_new_notification(req: &NewNotification) -> Result<()> { + if req.app_name.trim().is_empty() { + return Err(Error::Validation("app_name must not be empty".to_string())); + } + if req.summary.trim().is_empty() { + return Err(Error::Validation("summary must not be empty".to_string())); + } + Ok(()) +} + +fn status_to_sql(status: &NotificationStatus) -> &'static str { + match status { + NotificationStatus::Active => "active", + NotificationStatus::Closed => "closed", + } +} + +fn status_from_sql(value: &str) -> Result { + match value { + "active" => Ok(NotificationStatus::Active), + "closed" => Ok(NotificationStatus::Closed), + other => Err(Error::Parse(format!("invalid status value '{other}'"))), + } +} + +fn urgency_to_i64(urgency: Urgency) -> i64 { + match urgency { + Urgency::Low => 0, + Urgency::Normal => 1, + Urgency::Critical => 2, + } +} + +fn urgency_from_i64(value: i64) -> Result { + match value { + 0 => Ok(Urgency::Low), + 1 => Ok(Urgency::Normal), + 2 => Ok(Urgency::Critical), + other => Err(Error::Parse(format!("invalid urgency value '{other}'"))), + } +} + +fn change_kind_from_sql(value: &str) -> Result { + value + .parse::() + .map_err(|_| Error::Parse(format!("invalid change kind '{value}'"))) +} + +#[cfg(test)] +mod tests { + use crate::config::DatabaseConfig; + use crate::repository::SqliteRepository; + + #[test] + fn resolves_default_path() { + let config = DatabaseConfig::default(); + let path = config.resolved_path().expect("default path should resolve"); + assert!(path.contains(".cache/armesto/armesto-")); + assert!(path.ends_with(".db")); + } + + #[test] + fn accepts_override_path_for_test() { + let config = DatabaseConfig::for_test_path("/tmp/test-notify.db"); + let repo = SqliteRepository::new(&config).expect("repo should init"); + assert!(format!("{repo:?}").contains("/tmp/test-notify.db")); + } +} diff --git a/src/rofi.rs b/src/rofi.rs index 19ec0f4..bfc4bdc 100644 --- a/src/rofi.rs +++ b/src/rofi.rs @@ -1,157 +1,377 @@ -use std::{os::unix::net::{UnixListener, UnixStream}, io::BufRead, io::{BufReader, BufWriter, Write}}; -use log::{warn, debug, error}; +//! Optional UNIX-socket compatibility layer for rofi integrations. -use crate::notification::{NotificationStore, Urgency}; +use crate::model::{ChangeKind, Notification, Urgency}; +use crate::repository::SqliteRepository; +use serde::Serialize; +use std::collections::HashMap; +use std::io::{BufRead, BufReader, BufWriter, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::Path; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; -/// Provides service to roficiation clients. See https://github.com/DaveDavenport/Rofication +/// Optional rofi-compatible UNIX socket integration. +#[derive(Clone, Debug)] pub struct RofiServer { socket_path: String, - db: NotificationStore, + repository: SqliteRepository, } -/// See https://github.com/DaveDavenport/Rofication/blob/master/rofication-daemon.py#LL155C1-L170C87 -pub enum RofiCommand { - /// Retrieve count of notifications +#[derive(Debug)] +enum RofiCommand { Count, - /// Retrieve all notifications List, - /// Delete notification by id - DeleteOne(u32), - /// Delete all notifications with same app as id - DeleteSimilar(u32), - /// Delete all notifications with app name + DeleteOne(u64), + DeleteSimilar(u64), DeleteApps(String), - /// Reduce urgency to 'normal' - MarkSeen(u32), + MarkSeen(u64), + Watch, } -impl RofiCommand { - fn parse(client_request: &str) -> Option { - let mut token_iter = client_request.split(":").into_iter(); - - match token_iter.next() { - Some(command) => { - match command { - "num" => Some(Self::Count), - "list" => Some(Self::List), - "del" => { - let id = token_iter - .next()? - .parse::() - .ok()?; - - Some(Self::DeleteOne(id)) - }, - "dels" => { - let id = token_iter - .next()? - .parse::() - .ok()?; - - Some(Self::DeleteSimilar(id)) - }, - "dela" => { - let app_name = token_iter - .next()? - .trim() - .to_string(); - - Some(Self::DeleteApps(app_name)) - }, - "saw" => { - let id = token_iter - .next()? - .parse::() - .ok()?; - - Some(Self::MarkSeen(id)) - }, - unrecognized_cmd => { - warn!("unknown command: '{}'", unrecognized_cmd); - None - } - } +#[derive(Clone, Debug, Serialize)] +struct RofiNotification { + id: u64, + summary: String, + body: String, + application: String, + icon: String, + urgency: u8, + actions: Vec, + hints: HashMap, + timestamp: u64, +} - }, - None => None +impl RofiServer { + /// Creates a new rofi socket server. + pub fn new(socket_path: String, repository: SqliteRepository) -> Self { + Self { + socket_path, + repository, } } -} -impl RofiServer { - /// Create a new server instance - pub fn new(socket_path: String, db: NotificationStore) -> RofiServer { - return RofiServer { socket_path, db } - } + /// Starts the rofi compatibility server in a background thread. + pub fn start_background(self) -> std::io::Result> { + if Path::new(&self.socket_path).exists() { + let _ = std::fs::remove_file(&self.socket_path); + } - /// Server listens for incoming requests, blocks - pub fn start(&self) -> std::io::Result<()> { - debug!("Rofication server binding to path {}", &self.socket_path); let listener = UnixListener::bind(&self.socket_path)?; - - for stream in listener.incoming() { - match stream { - Ok(stream) => { - self.handle_request(stream); - } - Err(err) => { - println!("Failed to initialize socket listener: {}", err); - break; + let shared = Arc::new(self); + Ok(thread::spawn(move || { + for stream in listener.incoming() { + match stream { + Ok(stream) => { + let server = Arc::clone(&shared); + let _ = thread::Builder::new() + .name("rofi-client".to_string()) + .spawn(move || { + server.handle_request(stream); + }); + } + Err(_) => break, } } - } - Ok(()) + })) } - fn handle_request(&self, stream: UnixStream) { + fn handle_request(&self, stream: UnixStream) { let mut client_in = BufReader::new(&stream); let mut client_out = BufWriter::new(&stream); let mut line = String::new(); - let _ = client_in.read_line(&mut line).expect("unable to read"); - - let line = line.trim(); - debug!("Rofication client request: '{}'", line); + if client_in.read_line(&mut line).is_err() { + return; + } - match RofiCommand::parse(&line) { - Some(command) => self.execute_command(command, &mut client_out), - None => error!("Unable to parse message, no action taken: {}", &line), + match RofiCommand::parse(line.trim()) { + Some(command) => { + let _ = self.execute_command(command, &mut client_out); + } + None => { + let _ = client_out.write_all(b"error:unknown command\n"); + let _ = client_out.flush(); + } } } - fn execute_command(&self, cmd: RofiCommand, client_out: &mut BufWriter<&UnixStream>) { - match cmd { - RofiCommand::Count => { - client_out.write(self.db.count().to_string().as_bytes()).unwrap(); - client_out.flush().expect("Sending response back to client") - }, + fn execute_command( + &self, + command: RofiCommand, + out: &mut BufWriter<&UnixStream>, + ) -> std::io::Result<()> { + match command { + RofiCommand::Count => { + let count = self.repository.count_active().unwrap_or_default(); + out.write_all(count.to_string().as_bytes())?; + out.flush()?; + } RofiCommand::List => { - let elems = self.db.items(); - let response = serde_json::to_string(&elems).unwrap(); - client_out.write(&response.as_bytes()).unwrap(); - client_out.flush().expect("Sending response back to client") - }, + let notifications = self + .repository + .list_active() + .unwrap_or_default() + .into_iter() + .map(to_rofi_notification) + .collect::>(); + let payload = + serde_json::to_string(¬ifications).unwrap_or_else(|_| "[]".to_string()); + out.write_all(payload.as_bytes())?; + out.flush()?; + } RofiCommand::DeleteOne(id) => { - self.db.delete(id); - }, + let _ = self.repository.delete(id); + } RofiCommand::DeleteApps(app_name) => { - self.db.delete_from_app(app_name); - }, + let _ = self.repository.delete_by_app(&app_name); + } RofiCommand::DeleteSimilar(id) => { - let notifications = self.db.items(); - let source_notification = notifications.iter().find(|n| n.id == id); + if let Ok(Some(notification)) = self.repository.get(id) { + let _ = self.repository.delete_by_app(¬ification.app_name); + } + } + RofiCommand::MarkSeen(id) => { + let _ = self.repository.mark_seen(id); + } + RofiCommand::Watch => { + self.stream_new_events(out)?; + } + } + + Ok(()) + } + + fn stream_new_events(&self, out: &mut BufWriter<&UnixStream>) -> std::io::Result<()> { + let mut cursor = self.repository.latest_change_id().unwrap_or(0); + + loop { + match self.repository.list_changes_since(cursor) { + Ok(changes) => { + for change in changes { + cursor = change.change_id; + if change.kind != ChangeKind::Create { + continue; + } + + let notification = match change.notification_id { + Some(id) => self.repository.get(id).ok().flatten(), + None => None, + }; - if let Some(source_notification) = source_notification { - let app_name = source_notification.application.clone(); + let payload = notification + .map(to_rofi_notification) + .and_then(|item| serde_json::to_string(&item).ok()) + .unwrap_or_else(|| { + format!( + r#"{{"change_id":{},"event":"new","id":{}}}"#, + change.change_id, + change.notification_id.unwrap_or(0) + ) + }); - if !app_name.is_empty() { - self.db.delete_from_app(app_name); + out.write_all(payload.as_bytes())?; + out.write_all(b"\n")?; + out.flush()?; } } - }, - RofiCommand::MarkSeen(id) => { - self.db.set_urgency(id, Urgency::Normal); + Err(_) => { + thread::sleep(Duration::from_millis(200)); + } } + + thread::sleep(Duration::from_millis(200)); } } } + +impl RofiCommand { + fn parse(input: &str) -> Option { + let mut token_iter = input.split(':'); + + match token_iter.next() { + Some("num") => Some(Self::Count), + Some("list") => Some(Self::List), + Some("del") => token_iter.next()?.parse::().ok().map(Self::DeleteOne), + Some("dels") => token_iter + .next()? + .parse::() + .ok() + .map(Self::DeleteSimilar), + Some("dela") => Some(Self::DeleteApps(token_iter.next()?.trim().to_string())), + Some("saw") => token_iter.next()?.parse::().ok().map(Self::MarkSeen), + Some("watch") => Some(Self::Watch), + _ => None, + } + } +} + +fn to_rofi_notification(notification: Notification) -> RofiNotification { + RofiNotification { + id: notification.id, + summary: notification.summary, + body: notification.body, + application: notification.app_name, + icon: notification.icon, + urgency: match notification.urgency { + Urgency::Low => 0, + Urgency::Normal => 1, + Urgency::Critical => 2, + }, + actions: notification.actions, + hints: notification.hints, + timestamp: unix_now(), + } +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::{RofiCommand, RofiServer}; + use crate::config::DatabaseConfig; + use crate::model::{NewNotification, Urgency}; + use crate::repository::SqliteRepository; + use std::collections::HashMap; + use std::io::{BufRead, BufReader, Read, Write}; + use std::os::unix::net::UnixStream; + use std::path::PathBuf; + use std::thread; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + #[test] + fn parses_rofi_commands() { + assert!(matches!( + RofiCommand::parse("num"), + Some(RofiCommand::Count) + )); + assert!(matches!( + RofiCommand::parse("list"), + Some(RofiCommand::List) + )); + assert!(matches!( + RofiCommand::parse("watch"), + Some(RofiCommand::Watch) + )); + assert!(RofiCommand::parse("del:12").is_some()); + assert!(RofiCommand::parse("unknown").is_none()); + } + + #[test] + fn rofi_num_list_and_watch_work() { + let db_path = temp_db_path("rofi"); + let socket = temp_socket_path("rofi"); + + let repo = SqliteRepository::new(&DatabaseConfig::for_test_path( + db_path.to_string_lossy().to_string(), + )) + .expect("repo should init"); + repo.migrate().expect("migrations should apply"); + + let _handle = match RofiServer::new(socket.to_string_lossy().to_string(), repo.clone()) + .start_background() + { + Ok(handle) => handle, + Err(err) => { + eprintln!("skipping rofi socket test: {err}"); + return; + } + }; + thread::sleep(Duration::from_millis(100)); + + let num_raw = send_command(&socket, "num\n"); + assert_eq!(num_raw.trim(), "0"); + + repo.create(sample_new_notification("first")) + .expect("create should work"); + let num_after = send_command(&socket, "num\n"); + assert_eq!(num_after.trim(), "1"); + + let list_raw = send_command(&socket, "list\n"); + let parsed: serde_json::Value = + serde_json::from_str(&list_raw).expect("list response should be valid json"); + assert_eq!(parsed.as_array().map(|a| a.len()).unwrap_or(0), 1); + + let mut watch_stream = + UnixStream::connect(&socket).expect("watch connection should be established"); + watch_stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("set_read_timeout should work"); + watch_stream + .write_all(b"watch\n") + .expect("watch command should be sent"); + + repo.create(sample_new_notification("second")) + .expect("second create should work"); + + let mut reader = BufReader::new(watch_stream); + let mut line = String::new(); + reader + .read_line(&mut line) + .expect("watch stream should produce event line"); + assert!(!line.trim().is_empty()); + + let watch_json: serde_json::Value = + serde_json::from_str(line.trim()).expect("watch line should be json"); + assert_eq!(watch_json["summary"], "second"); + + let _ = std::fs::remove_file(&db_path); + let _ = std::fs::remove_file(&socket); + } + + fn send_command(socket: &PathBuf, command: &str) -> String { + let mut stream = UnixStream::connect(socket).expect("socket should connect"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("set_read_timeout should work"); + stream + .write_all(command.as_bytes()) + .expect("command should be written"); + stream.flush().expect("command should flush"); + + let mut response = String::new(); + let mut reader = BufReader::new(stream); + let _ = reader.read_line(&mut response); + if response.is_empty() { + let mut bytes = Vec::new(); + let _ = reader.read_to_end(&mut bytes); + response = String::from_utf8_lossy(&bytes).to_string(); + } + response + } + + fn sample_new_notification(summary: &str) -> NewNotification { + NewNotification { + app_name: "test-app".to_string(), + summary: summary.to_string(), + body: "body".to_string(), + icon: String::new(), + urgency: Urgency::Normal, + actions: Vec::new(), + hints: HashMap::new(), + } + } + + fn temp_db_path(prefix: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + let path = + std::env::temp_dir().join(format!("armesto_notify_backend_{prefix}_{unique}.db")); + path + } + + fn temp_socket_path(prefix: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("armesto_notify_backend_{prefix}_{unique}.sock")) + } +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..11a7376 --- /dev/null +++ b/src/server.rs @@ -0,0 +1,314 @@ +//! D-Bus notification server implementation and method handlers. + +use crate::config::ServerConfig; +use crate::dbus_support::{ + open_connection, CHANGE_INTERFACE, CHANGE_PATH, CHANGE_SIGNAL, NOTIFICATION_INTERFACE, + NOTIFICATION_PATH, +}; +use crate::error::{Error, Result}; +use crate::model::{ChangeEvent, NewNotification, UpdateNotification, Urgency}; +use crate::repository::SqliteRepository; +use crate::rofi::RofiServer; +use dbus::arg::{PropMap, RefArg}; +use dbus::blocking::stdintf::org_freedesktop_dbus::RequestNameReply; +use dbus::blocking::Connection; +use dbus::channel::MatchingReceiver; +use dbus::message::MatchRule; +use dbus::Message; +use dbus_crossroads::{Context, Crossroads, MethodErr}; +use std::sync::{Arc, Mutex}; + +const SERVER_INFO_SPEC_VERSION: &str = "1.2"; + +/// Notification daemon entrypoint. +#[derive(Clone, Debug)] +pub struct NotificationServer { + config: ServerConfig, +} + +impl NotificationServer { + /// Build a new server from configuration. + pub fn new(config: ServerConfig) -> Self { + Self { config } + } + + /// Start the daemon and serve D-Bus notification methods. + pub fn run(&self) -> Result<()> { + let repository = self.initialize_repository()?; + self.start_rofi_if_configured(repository.clone())?; + + let connection = self.open_and_claim_dbus()?; + let mut crossroads = Crossroads::new(); + register_notification_interface(&mut crossroads, repository)?; + + connection.start_receive( + MatchRule::new_method_call(), + Box::new(move |message, conn| { + let _ = crossroads.handle_message(message, conn); + true + }), + ); + + loop { + connection.process(self.config.dbus_poll_timeout)?; + } + } + + fn initialize_repository(&self) -> Result { + let repository = SqliteRepository::new(&self.config.database)?; + repository.migrate()?; + Ok(repository) + } + + fn start_rofi_if_configured(&self, repository: SqliteRepository) -> Result<()> { + if let Some(socket_path) = self.config.rofi_socket_path.clone() { + RofiServer::new(socket_path, repository) + .start_background() + .map_err(Error::from)?; + } + Ok(()) + } + + fn open_and_claim_dbus(&self) -> Result { + let connection = open_connection(self.config.dbus_address.as_deref())?; + request_bus_name(&connection, &self.config.notification_bus_name)?; + request_bus_name(&connection, &self.config.change_bus_name)?; + Ok(connection) + } +} + +type NotifyArgs = ( + String, + u32, + String, + String, + String, + Vec, + PropMap, + i32, +); + +fn register_notification_interface( + crossroads: &mut Crossroads, + repository: SqliteRepository, +) -> Result<()> { + let last_emitted = Arc::new(Mutex::new(repository.latest_change_id()?)); + let iface_token = crossroads.register(NOTIFICATION_INTERFACE, move |builder| { + let repo_notify = repository.clone(); + let emitted_notify = Arc::clone(&last_emitted); + builder.method( + "Notify", + ( + "app_name", + "replaces_id", + "icon", + "summary", + "body", + "actions", + "hints", + "expire_timeout", + ), + ("id",), + move |ctx, _, args: NotifyArgs| handle_notify(ctx, &repo_notify, &emitted_notify, args), + ); + + let repo_close = repository.clone(); + let emitted_close = Arc::clone(&last_emitted); + builder.method( + "CloseNotification", + ("id",), + (), + move |ctx, _, (id,): (u32,)| { + handle_close_notification(ctx, &repo_close, &emitted_close, id) + }, + ); + + builder.method("GetCapabilities", (), ("caps",), |_, _, ()| { + Ok((vec!["actions".to_string(), "body".to_string()],)) + }); + + builder.method( + "GetServerInformation", + (), + ("name", "vendor", "version", "spec_version"), + |_, _, ()| { + Ok(( + env!("CARGO_PKG_NAME").to_string(), + env!("CARGO_PKG_AUTHORS").to_string(), + env!("CARGO_PKG_VERSION").to_string(), + SERVER_INFO_SPEC_VERSION.to_string(), + )) + }, + ); + }); + + crossroads.insert(NOTIFICATION_PATH, &[iface_token], ()); + Ok(()) +} + +fn handle_notify( + ctx: &mut Context, + repository: &SqliteRepository, + last_emitted: &Arc>, + (app_name, replaces_id, icon, summary, body, actions, hints, _expire_timeout): NotifyArgs, +) -> std::result::Result<(u32,), MethodErr> { + let urgency = map_urgency_hint(&hints); + let hints = map_hints(&hints); + + let payload = NotifyPayload { + app_name, + icon, + summary, + body, + actions, + hints, + urgency, + }; + let id = upsert_notification(repository, replaces_id, payload).map_err(to_method_err)?; + + emit_pending_change_signals(ctx, repository, last_emitted).map_err(to_method_err)?; + + let reply_id = u32::try_from(id).map_err(|_| { + MethodErr::failed(&format!( + "notification id {id} cannot be represented as u32" + )) + })?; + Ok((reply_id,)) +} + +struct NotifyPayload { + app_name: String, + icon: String, + summary: String, + body: String, + actions: Vec, + hints: std::collections::HashMap, + urgency: Urgency, +} + +fn upsert_notification( + repository: &SqliteRepository, + replaces_id: u32, + payload: NotifyPayload, +) -> Result { + if replaces_id == 0 { + return create_notification(repository, payload); + } + + let replace_id_u64 = u64::from(replaces_id); + if repository.get(replace_id_u64)?.is_some() { + repository.update(UpdateNotification { + id: replace_id_u64, + summary: Some(payload.summary), + body: Some(payload.body), + urgency: Some(payload.urgency), + })?; + return Ok(replace_id_u64); + } + + create_notification(repository, payload) +} + +fn create_notification(repository: &SqliteRepository, payload: NotifyPayload) -> Result { + Ok(repository + .create(NewNotification { + app_name: payload.app_name, + summary: payload.summary, + body: payload.body, + icon: payload.icon, + urgency: payload.urgency, + actions: payload.actions, + hints: payload.hints, + })? + .id) +} + +fn handle_close_notification( + ctx: &mut Context, + repository: &SqliteRepository, + last_emitted: &Arc>, + id: u32, +) -> std::result::Result<(), MethodErr> { + repository.close(u64::from(id)).map_err(to_method_err)?; + emit_pending_change_signals(ctx, repository, last_emitted).map_err(to_method_err)?; + Ok(()) +} + +fn emit_pending_change_signals( + ctx: &mut Context, + repository: &SqliteRepository, + last_emitted: &Arc>, +) -> Result<()> { + let mut cursor = last_emitted + .lock() + .map_err(|_| Error::Initialization("failed to lock change signal cursor".to_string()))?; + let changes = repository.list_changes_since(*cursor)?; + + for change in changes { + ctx.push_msg(change_signal_message(&change)); + *cursor = change.change_id; + } + + Ok(()) +} + +fn change_signal_message(change: &ChangeEvent) -> Message { + Message::signal( + &CHANGE_PATH.into(), + &CHANGE_INTERFACE.into(), + &CHANGE_SIGNAL.into(), + ) + .append3( + change.change_id, + change.kind.as_str().to_string(), + change.notification_id.unwrap_or(0), + ) +} + +fn request_bus_name(connection: &dbus::blocking::Connection, name: &str) -> Result<()> { + let reply = connection.request_name(name, false, true, false)?; + if matches!( + reply, + RequestNameReply::PrimaryOwner | RequestNameReply::AlreadyOwner + ) { + Ok(()) + } else { + Err(Error::Initialization(format!( + "unable to acquire D-Bus name {name}" + ))) + } +} + +fn to_method_err(err: Error) -> MethodErr { + MethodErr::failed(&err.to_string()) +} + +fn map_urgency_hint(hints: &PropMap) -> Urgency { + hints + .get("urgency") + .and_then(|value| value.as_u64()) + .map(|value| match value { + 0 => Urgency::Low, + 2 => Urgency::Critical, + _ => Urgency::Normal, + }) + .unwrap_or(Urgency::Normal) +} + +fn map_hints(hints: &PropMap) -> std::collections::HashMap { + hints + .iter() + .map(|(key, value)| { + let rendered = if let Some(text) = value.as_str() { + text.to_string() + } else if let Some(number) = value.as_u64() { + number.to_string() + } else if let Some(integer) = value.as_i64() { + integer.to_string() + } else { + format!("{value:?}") + }; + (key.clone(), rendered) + }) + .collect() +} diff --git a/tests/dbus_integration.rs b/tests/dbus_integration.rs new file mode 100644 index 0000000..11a37b6 --- /dev/null +++ b/tests/dbus_integration.rs @@ -0,0 +1,237 @@ +//! Integration coverage for D-Bus notification behavior and change signals. + +use armesto_notify_backend::{ + ChangeKind, ClientConfig, DatabaseConfig, NewNotification, NotificationServer, NotifyClient, + ServerConfig, Urgency, +}; +use dbus::arg::PropMap; +use dbus::blocking::Connection; +use dbus::channel::Channel; +use std::collections::HashMap; +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +#[test] +fn dbus_notify_persists_and_emits_change_signal() { + let mut bus = match TestBus::spawn() { + Ok(bus) => bus, + Err(reason) => { + eprintln!("skipping dbus integration test: {reason}"); + return; + } + }; + let db_path = temp_db_path("dbus"); + + let server = NotificationServer::new(ServerConfig { + database: DatabaseConfig::for_test_path(db_path.to_string_lossy().to_string()), + dbus_address: Some(bus.address.clone()), + dbus_poll_timeout: Duration::from_millis(50), + ..ServerConfig::default() + }); + + let server_handle = thread::spawn(move || server.run()); + + if let Err(reason) = wait_for_server(&bus.address) { + eprintln!("skipping dbus integration test: {reason}"); + bus.shutdown(); + let _ = server_handle.join(); + return; + } + + let client = NotifyClient::new(ClientConfig { + database: DatabaseConfig::for_test_path(db_path.to_string_lossy().to_string()), + dbus_address: Some(bus.address.clone()), + ..ClientConfig::default() + }) + .expect("notify client should initialize"); + client.migrate().expect("migrations should apply"); + + let stream = client + .subscribe_changes() + .expect("change stream should subscribe"); + thread::sleep(Duration::from_millis(150)); + + let id = match send_notify(&bus.address, "dbus-test", "from-dbus", "hello") { + Ok(id) => id, + Err(reason) => { + eprintln!("skipping dbus integration test: {reason}"); + bus.shutdown(); + let _ = server_handle.join(); + return; + } + }; + + let event = stream + .next_timeout(Duration::from_secs(3)) + .expect("expected change event from D-Bus signal"); + + assert_eq!(event.kind, ChangeKind::Create); + assert_eq!(event.notification_id, Some(u64::from(id))); + + let stored = client + .get(u64::from(id)) + .expect("get should succeed") + .expect("notification should be persisted"); + assert_eq!(stored.summary, "from-dbus"); + assert_eq!(stored.body, "hello"); + + // Also validate API-side create still works in same DB. + let _api_created = client + .create(NewNotification { + app_name: "api-test".to_string(), + summary: "api".to_string(), + body: "create".to_string(), + icon: String::new(), + urgency: Urgency::Normal, + actions: Vec::new(), + hints: HashMap::new(), + }) + .expect("api create should succeed"); + + drop(stream); + bus.shutdown(); + + let server_result = server_handle + .join() + .expect("server thread should not panic"); + assert!( + server_result.is_err(), + "server should exit when test bus is closed" + ); + + let _ = std::fs::remove_file(&db_path); +} + +struct TestBus { + child: Option, + address: String, +} + +impl TestBus { + fn spawn() -> Result { + let mut child = Command::new("dbus-daemon") + .arg("--session") + .arg("--nofork") + .arg("--nopidfile") + .arg("--print-address") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| format!("failed to spawn dbus-daemon: {err}"))?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| "dbus-daemon stdout unavailable".to_string())?; + let mut reader = BufReader::new(stdout); + let mut address = String::new(); + reader + .read_line(&mut address) + .map_err(|err| format!("failed to read dbus address: {err}"))?; + + let address = address.trim().to_string(); + if address.is_empty() { + let stderr = read_stderr(&mut child); + return Err(format!( + "dbus-daemon did not provide an address; stderr: {}", + stderr.trim() + )); + } + + Ok(Self { + child: Some(child), + address, + }) + } + + fn shutdown(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +fn read_stderr(child: &mut Child) -> String { + let mut output = String::new(); + if let Some(stderr) = child.stderr.take() { + let mut reader = BufReader::new(stderr); + let _ = reader.read_line(&mut output); + } + output +} + +impl Drop for TestBus { + fn drop(&mut self) { + self.shutdown(); + } +} + +fn wait_for_server(address: &str) -> Result<(), String> { + for _ in 0..40 { + if let Ok(conn) = open_connection(address) { + let proxy = conn.with_proxy( + "org.freedesktop.Notifications", + "/org/freedesktop/Notifications", + Duration::from_millis(250), + ); + let call: Result<(Vec,), _> = + proxy.method_call("org.freedesktop.Notifications", "GetCapabilities", ()); + if call.is_ok() { + return Ok(()); + } + } + + thread::sleep(Duration::from_millis(50)); + } + + Err("server did not become ready".to_string()) +} + +fn send_notify(address: &str, app_name: &str, summary: &str, body: &str) -> Result { + let conn = open_connection(address)?; + let proxy = conn.with_proxy( + "org.freedesktop.Notifications", + "/org/freedesktop/Notifications", + Duration::from_secs(2), + ); + + let hints: PropMap = PropMap::new(); + let args = ( + app_name.to_string(), + 0u32, + String::new(), + summary.to_string(), + body.to_string(), + Vec::::new(), + hints, + 5000i32, + ); + + let (id,): (u32,) = proxy + .method_call("org.freedesktop.Notifications", "Notify", args) + .map_err(|err| format!("notify call failed: {err}"))?; + + Ok(id) +} + +fn open_connection(address: &str) -> Result { + let mut channel = + Channel::open_private(address).map_err(|err| format!("open_private failed: {err}"))?; + channel + .register() + .map_err(|err| format!("register failed: {err}"))?; + Ok(Connection::from(channel)) +} + +fn temp_db_path(prefix: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!("armesto_notify_backend_{prefix}_{unique}.db")); + path +} diff --git a/tests/repository_integration.rs b/tests/repository_integration.rs new file mode 100644 index 0000000..c90c6f2 --- /dev/null +++ b/tests/repository_integration.rs @@ -0,0 +1,120 @@ +//! Integration coverage for SQLite repository CRUD and query behavior. + +use armesto_notify_backend::{ + ClientConfig, DatabaseConfig, ListQuery, NewNotification, NotificationStatus, NotifyClient, + UpdateNotification, Urgency, +}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[test] +fn sqlite_crud_lifecycle() { + let db_path = temp_db_path("crud"); + let client = NotifyClient::new(ClientConfig { + database: DatabaseConfig::for_test_path(db_path.to_string_lossy().to_string()), + ..ClientConfig::default() + }) + .expect("client should initialize"); + + client.migrate().expect("migrations should apply"); + + let created = client + .create(NewNotification { + app_name: "integration-test".to_string(), + summary: "hello".to_string(), + body: "body-text".to_string(), + icon: "icon-name".to_string(), + urgency: Urgency::Critical, + actions: vec!["open".to_string()], + hints: HashMap::new(), + }) + .expect("create should succeed"); + + assert!(created.id > 0); + assert_eq!(created.status, NotificationStatus::Active); + + let listed = client + .list(ListQuery { + status: Some(NotificationStatus::Active), + ..ListQuery::default() + }) + .expect("list should succeed"); + assert_eq!(listed.len(), 1); + + let updated = client + .update(UpdateNotification { + id: created.id, + summary: Some("updated".to_string()), + body: None, + urgency: Some(Urgency::Low), + }) + .expect("update should succeed"); + assert_eq!(updated.summary, "updated"); + assert_eq!(updated.urgency, Urgency::Low); + + client + .close(created.id) + .expect("close should succeed for existing notification"); + + let closed = client + .get(created.id) + .expect("get should succeed") + .expect("notification should exist"); + assert_eq!(closed.status, NotificationStatus::Closed); + + client + .delete(created.id) + .expect("delete should succeed for existing notification"); + + let deleted = client + .get(created.id) + .expect("get should succeed after delete"); + assert!(deleted.is_none()); + + let first = client + .create(NewNotification { + app_name: "app-a".to_string(), + summary: "one".to_string(), + body: "x".to_string(), + icon: String::new(), + urgency: Urgency::Normal, + actions: Vec::new(), + hints: HashMap::new(), + }) + .expect("first create should succeed"); + + let _second = client + .create(NewNotification { + app_name: "app-a".to_string(), + summary: "two".to_string(), + body: "y".to_string(), + icon: String::new(), + urgency: Urgency::Normal, + actions: Vec::new(), + hints: HashMap::new(), + }) + .expect("second create should succeed"); + + let closed_count = client + .close_all(Some("app-a")) + .expect("close_all should succeed"); + assert!(closed_count >= 2); + + let first_after = client + .get(first.id) + .expect("get should succeed") + .expect("notification should still exist"); + assert_eq!(first_after.status, NotificationStatus::Closed); + + let _ = std::fs::remove_file(&db_path); +} + +fn temp_db_path(prefix: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!("armesto_notify_backend_{prefix}_{unique}.db")); + path +}