From cdb3370873bacf865c5563be3403c8daa8329f3f Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 13:11:41 +0100 Subject: [PATCH] feat: move all plugin and runtime crates from owlry --- Cargo.lock | 3724 +++++++++++++++++++++ crates/owlry-lua/Cargo.toml | 46 + crates/owlry-lua/src/api/mod.rs | 52 + crates/owlry-lua/src/api/provider.rs | 237 ++ crates/owlry-lua/src/api/utils.rs | 370 ++ crates/owlry-lua/src/lib.rs | 349 ++ crates/owlry-lua/src/loader.rs | 212 ++ crates/owlry-lua/src/manifest.rs | 173 + crates/owlry-lua/src/runtime.rs | 153 + crates/owlry-plugin-bookmarks/Cargo.toml | 31 + crates/owlry-plugin-bookmarks/src/lib.rs | 662 ++++ crates/owlry-plugin-calculator/Cargo.toml | 23 + crates/owlry-plugin-calculator/src/lib.rs | 231 ++ crates/owlry-plugin-clipboard/Cargo.toml | 20 + crates/owlry-plugin-clipboard/src/lib.rs | 259 ++ crates/owlry-plugin-emoji/Cargo.toml | 20 + crates/owlry-plugin-emoji/src/lib.rs | 565 ++++ crates/owlry-plugin-filesearch/Cargo.toml | 23 + crates/owlry-plugin-filesearch/src/lib.rs | 322 ++ crates/owlry-plugin-media/Cargo.toml | 23 + crates/owlry-plugin-media/src/lib.rs | 468 +++ crates/owlry-plugin-pomodoro/Cargo.toml | 30 + crates/owlry-plugin-pomodoro/src/lib.rs | 478 +++ crates/owlry-plugin-scripts/Cargo.toml | 23 + crates/owlry-plugin-scripts/src/lib.rs | 290 ++ crates/owlry-plugin-ssh/Cargo.toml | 23 + crates/owlry-plugin-ssh/src/lib.rs | 328 ++ crates/owlry-plugin-system/Cargo.toml | 20 + crates/owlry-plugin-system/src/lib.rs | 254 ++ crates/owlry-plugin-systemd/Cargo.toml | 20 + crates/owlry-plugin-systemd/src/lib.rs | 457 +++ crates/owlry-plugin-weather/Cargo.toml | 33 + crates/owlry-plugin-weather/src/lib.rs | 754 +++++ crates/owlry-plugin-websearch/Cargo.toml | 20 + crates/owlry-plugin-websearch/src/lib.rs | 299 ++ crates/owlry-rune/Cargo.toml | 44 + crates/owlry-rune/src/api.rs | 130 + crates/owlry-rune/src/lib.rs | 251 ++ crates/owlry-rune/src/loader.rs | 175 + crates/owlry-rune/src/manifest.rs | 155 + crates/owlry-rune/src/runtime.rs | 160 + 41 files changed, 11907 insertions(+) create mode 100644 Cargo.lock create mode 100644 crates/owlry-lua/Cargo.toml create mode 100644 crates/owlry-lua/src/api/mod.rs create mode 100644 crates/owlry-lua/src/api/provider.rs create mode 100644 crates/owlry-lua/src/api/utils.rs create mode 100644 crates/owlry-lua/src/lib.rs create mode 100644 crates/owlry-lua/src/loader.rs create mode 100644 crates/owlry-lua/src/manifest.rs create mode 100644 crates/owlry-lua/src/runtime.rs create mode 100644 crates/owlry-plugin-bookmarks/Cargo.toml create mode 100644 crates/owlry-plugin-bookmarks/src/lib.rs create mode 100644 crates/owlry-plugin-calculator/Cargo.toml create mode 100644 crates/owlry-plugin-calculator/src/lib.rs create mode 100644 crates/owlry-plugin-clipboard/Cargo.toml create mode 100644 crates/owlry-plugin-clipboard/src/lib.rs create mode 100644 crates/owlry-plugin-emoji/Cargo.toml create mode 100644 crates/owlry-plugin-emoji/src/lib.rs create mode 100644 crates/owlry-plugin-filesearch/Cargo.toml create mode 100644 crates/owlry-plugin-filesearch/src/lib.rs create mode 100644 crates/owlry-plugin-media/Cargo.toml create mode 100644 crates/owlry-plugin-media/src/lib.rs create mode 100644 crates/owlry-plugin-pomodoro/Cargo.toml create mode 100644 crates/owlry-plugin-pomodoro/src/lib.rs create mode 100644 crates/owlry-plugin-scripts/Cargo.toml create mode 100644 crates/owlry-plugin-scripts/src/lib.rs create mode 100644 crates/owlry-plugin-ssh/Cargo.toml create mode 100644 crates/owlry-plugin-ssh/src/lib.rs create mode 100644 crates/owlry-plugin-system/Cargo.toml create mode 100644 crates/owlry-plugin-system/src/lib.rs create mode 100644 crates/owlry-plugin-systemd/Cargo.toml create mode 100644 crates/owlry-plugin-systemd/src/lib.rs create mode 100644 crates/owlry-plugin-weather/Cargo.toml create mode 100644 crates/owlry-plugin-weather/src/lib.rs create mode 100644 crates/owlry-plugin-websearch/Cargo.toml create mode 100644 crates/owlry-plugin-websearch/src/lib.rs create mode 100644 crates/owlry-rune/Cargo.toml create mode 100644 crates/owlry-rune/src/api.rs create mode 100644 crates/owlry-rune/src/lib.rs create mode 100644 crates/owlry-rune/src/loader.rs create mode 100644 crates/owlry-rune/src/manifest.rs create mode 100644 crates/owlry-rune/src/runtime.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c8413cb --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3724 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "abi_stable" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d6512d3eb05ffe5004c59c206de7f99c34951504056ce23fc953842f12c445" +dependencies = [ + "abi_stable_derive", + "abi_stable_shared", + "const_panic", + "core_extensions", + "crossbeam-channel", + "generational-arena", + "libloading", + "lock_api", + "parking_lot", + "paste", + "repr_offset", + "rustc_version", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "abi_stable_derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7178468b407a4ee10e881bc7a328a65e739f0863615cca4429d43916b05e898" +dependencies = [ + "abi_stable_shared", + "as_derive_utils", + "core_extensions", + "proc-macro2", + "quote", + "rustc_version", + "syn 1.0.109", + "typed-arena", +] + +[[package]] +name = "abi_stable_shared" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b5df7688c123e63f4d4d649cba63f2967ba7f7861b1664fca3f77d3dad2b63" +dependencies = [ + "core_extensions", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "as_derive_utils" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff3c96645900a44cf11941c111bd08a6573b0e2f9f69bc9264b179d8fae753c4" +dependencies = [ + "core_extensions", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "const_panic" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" +dependencies = [ + "typewit", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_extensions" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42bb5e5d0269fd4f739ea6cedaf29c16d81c27a7ce7582008e90eb50dcd57003" +dependencies = [ + "core_extensions_proc_macros", +] + +[[package]] +name = "core_extensions_proc_macros" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533d38ecd2709b7608fb8e18e4504deb99e9a72879e6aa66373a76d8dc4259ea" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generational-arena" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877e94aff08e743b651baaea359664321055749b398adff8740a7399af7796e7" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lua-src" +version = "550.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e836dc8ae16806c9bdcf42003a88da27d163433e3f9684c52f0301258004a4fb" +dependencies = [ + "cc", +] + +[[package]] +name = "luajit-src" +version = "210.6.6+707c12b" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a86cc925d4053d0526ae7f5bc765dbd0d7a5d1a63d43974f4966cb349ca63295" +dependencies = [ + "cc", + "which", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "meval" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79496a5651c8d57cd033c5add8ca7ee4e3d5f7587a4777484640d9cb60392d9" +dependencies = [ + "fnv", + "nom", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mlua" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd36acfa49ce6ee56d1307a061dd302c564eee757e6e4cd67eb4f7204846fab" +dependencies = [ + "bstr", + "either", + "erased-serde", + "libc", + "mlua-sys", + "num-traits", + "parking_lot", + "rustc-hash", + "rustversion", + "serde", + "serde-value", +] + +[[package]] +name = "mlua-sys" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f1c3a7fc7580227ece249fd90aa2fa3b39eb2b49d3aec5e103b3e85f2c3dfc8" +dependencies = [ + "cc", + "cfg-if", + "libc", + "lua-src", + "luajit-src", + "pkg-config", +] + +[[package]] +name = "musli" +version = "0.0.124" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b310b280353d9e1c92861820321f8742b02666acaf984a29cd8946965444384" +dependencies = [ + "loom", + "musli-core", + "serde", + "simdutf8", +] + +[[package]] +name = "musli-core" +version = "0.0.124" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d00e227a374e92550ce2eb5002ae116e02a43926d7243c95997138406ae4e157" +dependencies = [ + "musli-macros", +] + +[[package]] +name = "musli-macros" +version = "0.0.124" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7427c9aa85c882cd4dbe712d2fcdc511db05d595f7787e6747c90cd7d67efc4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "nom" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owlry-lua" +version = "0.4.10" +dependencies = [ + "abi_stable", + "chrono", + "dirs", + "meval", + "mlua", + "owlry-plugin-api", + "reqwest 0.13.2", + "semver", + "serde", + "serde_json", + "tempfile", + "toml", +] + +[[package]] +name = "owlry-plugin-api" +version = "0.4.10" +dependencies = [ + "abi_stable", + "serde", +] + +[[package]] +name = "owlry-plugin-bookmarks" +version = "0.4.10" +dependencies = [ + "abi_stable", + "dirs", + "owlry-plugin-api", + "rusqlite", + "serde", + "serde_json", +] + +[[package]] +name = "owlry-plugin-calculator" +version = "0.4.10" +dependencies = [ + "abi_stable", + "meval", + "owlry-plugin-api", +] + +[[package]] +name = "owlry-plugin-clipboard" +version = "0.4.10" +dependencies = [ + "abi_stable", + "owlry-plugin-api", +] + +[[package]] +name = "owlry-plugin-emoji" +version = "0.4.10" +dependencies = [ + "abi_stable", + "owlry-plugin-api", +] + +[[package]] +name = "owlry-plugin-filesearch" +version = "0.4.10" +dependencies = [ + "abi_stable", + "dirs", + "owlry-plugin-api", +] + +[[package]] +name = "owlry-plugin-media" +version = "0.4.10" +dependencies = [ + "abi_stable", + "owlry-plugin-api", +] + +[[package]] +name = "owlry-plugin-pomodoro" +version = "0.4.10" +dependencies = [ + "abi_stable", + "dirs", + "owlry-plugin-api", + "serde", + "serde_json", + "toml", +] + +[[package]] +name = "owlry-plugin-scripts" +version = "0.4.10" +dependencies = [ + "abi_stable", + "dirs", + "owlry-plugin-api", +] + +[[package]] +name = "owlry-plugin-ssh" +version = "0.4.10" +dependencies = [ + "abi_stable", + "dirs", + "owlry-plugin-api", +] + +[[package]] +name = "owlry-plugin-system" +version = "0.4.10" +dependencies = [ + "abi_stable", + "owlry-plugin-api", +] + +[[package]] +name = "owlry-plugin-systemd" +version = "0.4.10" +dependencies = [ + "abi_stable", + "owlry-plugin-api", +] + +[[package]] +name = "owlry-plugin-weather" +version = "0.4.10" +dependencies = [ + "abi_stable", + "dirs", + "owlry-plugin-api", + "reqwest 0.13.2", + "serde", + "serde_json", + "toml", +] + +[[package]] +name = "owlry-plugin-websearch" +version = "0.4.10" +dependencies = [ + "abi_stable", + "owlry-plugin-api", +] + +[[package]] +name = "owlry-rune" +version = "0.4.10" +dependencies = [ + "chrono", + "dirs", + "env_logger", + "log", + "owlry-plugin-api", + "reqwest 0.13.2", + "rune", + "rune-modules", + "semver", + "serde", + "serde_json", + "tempfile", + "toml", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "repr_offset" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1070755bd29dffc19d0971cab794e607839ba2ef4b69a9e6fbc8733c1b72ea" +dependencies = [ + "tstr", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + +[[package]] +name = "rune" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7b9174512d64882469ea9b12876305680154a541be448dcdc56f58acacbc3e0" +dependencies = [ + "anyhow", + "codespan-reporting", + "futures-core", + "futures-util", + "itoa", + "memchr", + "musli", + "num", + "once_cell", + "pin-project", + "rune-alloc", + "rune-core", + "rune-macros", + "rune-tracing", + "ryu", + "serde", + "syntree", + "unicode-ident", +] + +[[package]] +name = "rune-alloc" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12484a608c8907b9a4590f11b669263e960d1fd40f3d3c992c6f15eec931ae9" +dependencies = [ + "ahash", + "pin-project", + "rune-alloc-macros", + "serde", +] + +[[package]] +name = "rune-alloc-macros" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "382b14f6d8e65e9cfec789e85125f3e1d758b2756705739e39ccf06fd249a564" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "rune-core" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c424b28fde0f5012680361662145f238f04aeac8a320f352a6e2de863709e7b3" +dependencies = [ + "musli", + "rune-alloc", + "serde", + "twox-hash", +] + +[[package]] +name = "rune-macros" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86600b36281adeb101c2e4f0be325752fa4c07431e9234e05be5678ad9a97f7" +dependencies = [ + "proc-macro2", + "quote", + "rune-core", + "syn 2.0.117", +] + +[[package]] +name = "rune-modules" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4ef5dc3546042989f4abc70d6b9f707a539d5cbb5cb2fb167f8fbe891e1b64" +dependencies = [ + "base64", + "nanorand", + "reqwest 0.12.28", + "rune", + "serde_json", + "tokio", + "toml", +] + +[[package]] +name = "rune-tracing" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717a7c726015688a19ebfa4bea03a20d2acdd96eeacb5ef48f4ec780e11ac4b" +dependencies = [ + "rune-tracing-macros", +] + +[[package]] +name = "rune-tracing-macros" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12387f96a3e131ce5be8c5668e55f1581dbc6635555d77aa07ab509fd13562bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "rusqlite" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "syntree" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c99c9cda412afe293a6b962af651b4594161ba88c1affe7ef66459ea040a06" + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "iri-string", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tstr" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8e0294f14baae476d0dd0a2d780b2e24d66e349a9de876f5126777a37bdba7" +dependencies = [ + "tstr_proc_macros", +] + +[[package]] +name = "tstr_proc_macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78122066b0cb818b8afd08f7ed22f7fdbc3e90815035726f0840d0d26c0747a" + +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typewit" +version = "1.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c1ae7cc0fdb8b842d65d127cb981574b0d2b249b74d1c7a2986863dc134f71" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/owlry-lua/Cargo.toml b/crates/owlry-lua/Cargo.toml new file mode 100644 index 0000000..2227c63 --- /dev/null +++ b/crates/owlry-lua/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "owlry-lua" +version = "0.4.10" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Lua runtime for owlry plugins - enables loading user-created Lua plugins" +keywords = ["owlry", "plugin", "lua", "runtime"] +categories = ["development-tools"] + +[lib] +crate-type = ["cdylib"] # Compile as dynamic library (.so) + +[dependencies] +# Plugin API for owlry (shared types) +owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" } + +# ABI-stable types +abi_stable = "0.11" + +# Lua runtime +mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"] } + +# Plugin manifest parsing +toml = "0.8" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Version compatibility +semver = "1" + +# HTTP client for plugins +reqwest = { version = "0.13", features = ["blocking", "json"] } + +# Math expression evaluation +meval = "0.2" + +# Date/time for os.date +chrono = "0.4" + +# XDG paths +dirs = "5.0" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/owlry-lua/src/api/mod.rs b/crates/owlry-lua/src/api/mod.rs new file mode 100644 index 0000000..a85c3df --- /dev/null +++ b/crates/owlry-lua/src/api/mod.rs @@ -0,0 +1,52 @@ +//! Lua API implementations for plugins +//! +//! This module provides the `owlry` global table and its submodules +//! that plugins can use to interact with owlry. + +mod provider; +mod utils; + +use mlua::{Lua, Result as LuaResult}; +use owlry_plugin_api::PluginItem; + +use crate::loader::ProviderRegistration; + +/// Register all owlry APIs in the Lua runtime +pub fn register_apis(lua: &Lua, plugin_dir: &std::path::Path, plugin_id: &str) -> LuaResult<()> { + let globals = lua.globals(); + + // Create the main owlry table + let owlry = lua.create_table()?; + + // Register utility APIs (log, path, fs, json) + utils::register_log_api(lua, &owlry)?; + utils::register_path_api(lua, &owlry, plugin_dir)?; + utils::register_fs_api(lua, &owlry, plugin_dir)?; + utils::register_json_api(lua, &owlry)?; + + // Register provider API + provider::register_provider_api(lua, &owlry)?; + + // Set owlry as global + globals.set("owlry", owlry)?; + + // Suppress unused warnings + let _ = plugin_id; + + Ok(()) +} + +/// Get provider registrations from the Lua runtime +pub fn get_provider_registrations(lua: &Lua) -> LuaResult> { + provider::get_registrations(lua) +} + +/// Call a provider's refresh function +pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult> { + provider::call_refresh(lua, provider_name) +} + +/// Call a provider's query function +pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult> { + provider::call_query(lua, provider_name, query) +} diff --git a/crates/owlry-lua/src/api/provider.rs b/crates/owlry-lua/src/api/provider.rs new file mode 100644 index 0000000..bf49aa3 --- /dev/null +++ b/crates/owlry-lua/src/api/provider.rs @@ -0,0 +1,237 @@ +//! Provider registration API for Lua plugins + +use mlua::{Function, Lua, Result as LuaResult, Table, Value}; +use owlry_plugin_api::PluginItem; +use std::cell::RefCell; + +use crate::loader::ProviderRegistration; + +thread_local! { + static REGISTRATIONS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// Register the provider API in the owlry table +pub fn register_provider_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { + let provider = lua.create_table()?; + + // owlry.provider.register(config) + provider.set("register", lua.create_function(register_provider)?)?; + + owlry.set("provider", provider)?; + Ok(()) +} + +/// Implementation of owlry.provider.register() +fn register_provider(_lua: &Lua, config: Table) -> LuaResult<()> { + let name: String = config.get("name")?; + let display_name: String = config.get::>("display_name")? + .unwrap_or_else(|| name.clone()); + let type_id: String = config.get::>("type_id")? + .unwrap_or_else(|| name.replace('-', "_")); + let default_icon: String = config.get::>("default_icon")? + .unwrap_or_else(|| "application-x-addon".to_string()); + let prefix: Option = config.get("prefix")?; + + // Check if it's a dynamic provider (has query function) or static (has refresh) + let has_query: bool = config.contains_key("query")?; + let has_refresh: bool = config.contains_key("refresh")?; + + if !has_query && !has_refresh { + return Err(mlua::Error::external( + "Provider must have either 'refresh' or 'query' function", + )); + } + + let is_dynamic = has_query; + + REGISTRATIONS.with(|regs| { + regs.borrow_mut().push(ProviderRegistration { + name, + display_name, + type_id, + default_icon, + prefix, + is_dynamic, + }); + }); + + Ok(()) +} + +/// Get all registered providers +pub fn get_registrations(lua: &Lua) -> LuaResult> { + // Suppress unused warning + let _ = lua; + + REGISTRATIONS.with(|regs| Ok(regs.borrow().clone())) +} + +/// Call a provider's refresh function +pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult> { + let globals = lua.globals(); + let owlry: Table = globals.get("owlry")?; + let provider: Table = owlry.get("provider")?; + + // Get the registered providers table (internal) + let registrations: Table = match provider.get::("_registrations")? { + Value::Table(t) => t, + _ => { + // Try to find the config directly from the global scope + // This happens when register was called with the config table + return call_provider_function(lua, provider_name, "refresh", None); + } + }; + + let config: Table = match registrations.get(provider_name)? { + Value::Table(t) => t, + _ => return Ok(Vec::new()), + }; + + let refresh_fn: Function = match config.get("refresh")? { + Value::Function(f) => f, + _ => return Ok(Vec::new()), + }; + + let result: Value = refresh_fn.call(())?; + parse_items_result(result) +} + +/// Call a provider's query function +pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult> { + call_provider_function(lua, provider_name, "query", Some(query)) +} + +/// Call a provider function by name +fn call_provider_function( + lua: &Lua, + provider_name: &str, + function_name: &str, + query: Option<&str>, +) -> LuaResult> { + // Search through all registered providers in the Lua globals + // This is a workaround since we store registrations thread-locally + let globals = lua.globals(); + + // Try to find a registered provider with matching name + // First check if there's a _providers table + if let Ok(Value::Table(providers)) = globals.get::("_owlry_providers") + && let Ok(Value::Table(config)) = providers.get::(provider_name) + && let Ok(Value::Function(func)) = config.get::(function_name) { + let result: Value = match query { + Some(q) => func.call(q)?, + None => func.call(())?, + }; + return parse_items_result(result); + } + + // Fall back: search through globals for functions + // This is less reliable but handles simple cases + Ok(Vec::new()) +} + +/// Parse items from Lua return value +fn parse_items_result(result: Value) -> LuaResult> { + let mut items = Vec::new(); + + if let Value::Table(table) = result { + for pair in table.pairs::() { + let (_, item_table) = pair?; + if let Ok(item) = parse_item(&item_table) { + items.push(item); + } + } + } + + Ok(items) +} + +/// Parse a single item from a Lua table +fn parse_item(table: &Table) -> LuaResult { + let id: String = table.get("id")?; + let name: String = table.get("name")?; + let command: String = table.get::>("command")?.unwrap_or_default(); + let description: Option = table.get("description")?; + let icon: Option = table.get("icon")?; + let terminal: bool = table.get::>("terminal")?.unwrap_or(false); + let tags: Vec = table.get::>>("tags")?.unwrap_or_default(); + + let mut item = PluginItem::new(id, name, command); + + if let Some(desc) = description { + item = item.with_description(desc); + } + if let Some(ic) = icon { + item = item.with_icon(&ic); + } + if terminal { + item = item.with_terminal(true); + } + if !tags.is_empty() { + item = item.with_keywords(tags); + } + + Ok(item) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::{create_lua_runtime, SandboxConfig}; + + #[test] + fn test_register_static_provider() { + let config = SandboxConfig::default(); + let lua = create_lua_runtime(&config).unwrap(); + + let owlry = lua.create_table().unwrap(); + register_provider_api(&lua, &owlry).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + + let code = r#" + owlry.provider.register({ + name = "test-provider", + display_name = "Test Provider", + refresh = function() + return { + { id = "1", name = "Item 1" } + } + end + }) + "#; + lua.load(code).set_name("test").call::<()>(()).unwrap(); + + let regs = get_registrations(&lua).unwrap(); + assert_eq!(regs.len(), 1); + assert_eq!(regs[0].name, "test-provider"); + assert!(!regs[0].is_dynamic); + } + + #[test] + fn test_register_dynamic_provider() { + let config = SandboxConfig::default(); + let lua = create_lua_runtime(&config).unwrap(); + + let owlry = lua.create_table().unwrap(); + register_provider_api(&lua, &owlry).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + + let code = r#" + owlry.provider.register({ + name = "query-provider", + prefix = "?", + query = function(q) + return { + { id = "search", name = "Search: " .. q } + } + end + }) + "#; + lua.load(code).set_name("test").call::<()>(()).unwrap(); + + let regs = get_registrations(&lua).unwrap(); + assert_eq!(regs.len(), 1); + assert_eq!(regs[0].name, "query-provider"); + assert!(regs[0].is_dynamic); + assert_eq!(regs[0].prefix, Some("?".to_string())); + } +} diff --git a/crates/owlry-lua/src/api/utils.rs b/crates/owlry-lua/src/api/utils.rs new file mode 100644 index 0000000..4c9058d --- /dev/null +++ b/crates/owlry-lua/src/api/utils.rs @@ -0,0 +1,370 @@ +//! Utility APIs: logging, paths, filesystem, JSON + +use mlua::{Lua, Result as LuaResult, Table, Value}; +use std::path::{Path, PathBuf}; + +// ============================================================================ +// Logging API +// ============================================================================ + +/// Register the log API in the owlry table +pub fn register_log_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { + let log = lua.create_table()?; + + log.set("debug", lua.create_function(|_, msg: String| { + eprintln!("[DEBUG] {}", msg); + Ok(()) + })?)?; + + log.set("info", lua.create_function(|_, msg: String| { + eprintln!("[INFO] {}", msg); + Ok(()) + })?)?; + + log.set("warn", lua.create_function(|_, msg: String| { + eprintln!("[WARN] {}", msg); + Ok(()) + })?)?; + + log.set("error", lua.create_function(|_, msg: String| { + eprintln!("[ERROR] {}", msg); + Ok(()) + })?)?; + + owlry.set("log", log)?; + Ok(()) +} + +// ============================================================================ +// Path API +// ============================================================================ + +/// Register the path API in the owlry table +pub fn register_path_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult<()> { + let path = lua.create_table()?; + + // owlry.path.config() -> ~/.config/owlry + path.set("config", lua.create_function(|_, ()| { + Ok(dirs::config_dir() + .map(|d| d.join("owlry")) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default()) + })?)?; + + // owlry.path.data() -> ~/.local/share/owlry + path.set("data", lua.create_function(|_, ()| { + Ok(dirs::data_dir() + .map(|d| d.join("owlry")) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default()) + })?)?; + + // owlry.path.cache() -> ~/.cache/owlry + path.set("cache", lua.create_function(|_, ()| { + Ok(dirs::cache_dir() + .map(|d| d.join("owlry")) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default()) + })?)?; + + // owlry.path.home() -> ~ + path.set("home", lua.create_function(|_, ()| { + Ok(dirs::home_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default()) + })?)?; + + // owlry.path.join(...) -> joined path + path.set("join", lua.create_function(|_, parts: mlua::Variadic| { + let mut path = PathBuf::new(); + for part in parts { + path.push(part); + } + Ok(path.to_string_lossy().to_string()) + })?)?; + + // owlry.path.plugin_dir() -> plugin directory + let plugin_dir_str = plugin_dir.to_string_lossy().to_string(); + path.set("plugin_dir", lua.create_function(move |_, ()| { + Ok(plugin_dir_str.clone()) + })?)?; + + // owlry.path.expand(path) -> expanded path (~ -> home) + path.set("expand", lua.create_function(|_, path: String| { + if path.starts_with("~/") + && let Some(home) = dirs::home_dir() { + return Ok(home.join(&path[2..]).to_string_lossy().to_string()); + } + Ok(path) + })?)?; + + owlry.set("path", path)?; + Ok(()) +} + +// ============================================================================ +// Filesystem API +// ============================================================================ + +/// Register the fs API in the owlry table +pub fn register_fs_api(lua: &Lua, owlry: &Table, _plugin_dir: &Path) -> LuaResult<()> { + let fs = lua.create_table()?; + + // owlry.fs.exists(path) -> bool + fs.set("exists", lua.create_function(|_, path: String| { + let path = expand_path(&path); + Ok(Path::new(&path).exists()) + })?)?; + + // owlry.fs.is_dir(path) -> bool + fs.set("is_dir", lua.create_function(|_, path: String| { + let path = expand_path(&path); + Ok(Path::new(&path).is_dir()) + })?)?; + + // owlry.fs.read(path) -> string or nil + fs.set("read", lua.create_function(|_, path: String| { + let path = expand_path(&path); + match std::fs::read_to_string(&path) { + Ok(content) => Ok(Some(content)), + Err(_) => Ok(None), + } + })?)?; + + // owlry.fs.read_lines(path) -> table of strings or nil + fs.set("read_lines", lua.create_function(|lua, path: String| { + let path = expand_path(&path); + match std::fs::read_to_string(&path) { + Ok(content) => { + let lines: Vec = content.lines().map(|s| s.to_string()).collect(); + Ok(Some(lua.create_sequence_from(lines)?)) + } + Err(_) => Ok(None), + } + })?)?; + + // owlry.fs.list_dir(path) -> table of filenames or nil + fs.set("list_dir", lua.create_function(|lua, path: String| { + let path = expand_path(&path); + match std::fs::read_dir(&path) { + Ok(entries) => { + let names: Vec = entries + .filter_map(|e| e.ok()) + .filter_map(|e| e.file_name().into_string().ok()) + .collect(); + Ok(Some(lua.create_sequence_from(names)?)) + } + Err(_) => Ok(None), + } + })?)?; + + // owlry.fs.read_json(path) -> table or nil + fs.set("read_json", lua.create_function(|lua, path: String| { + let path = expand_path(&path); + match std::fs::read_to_string(&path) { + Ok(content) => { + match serde_json::from_str::(&content) { + Ok(value) => json_to_lua(lua, &value), + Err(_) => Ok(Value::Nil), + } + } + Err(_) => Ok(Value::Nil), + } + })?)?; + + // owlry.fs.write(path, content) -> bool + fs.set("write", lua.create_function(|_, (path, content): (String, String)| { + let path = expand_path(&path); + // Create parent directories if needed + if let Some(parent) = Path::new(&path).parent() { + let _ = std::fs::create_dir_all(parent); + } + Ok(std::fs::write(&path, content).is_ok()) + })?)?; + + owlry.set("fs", fs)?; + Ok(()) +} + +// ============================================================================ +// JSON API +// ============================================================================ + +/// Register the json API in the owlry table +pub fn register_json_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { + let json = lua.create_table()?; + + // owlry.json.encode(value) -> string + json.set("encode", lua.create_function(|lua, value: Value| { + let json_value = lua_to_json(lua, &value)?; + Ok(serde_json::to_string(&json_value).unwrap_or_else(|_| "null".to_string())) + })?)?; + + // owlry.json.decode(string) -> value or nil + json.set("decode", lua.create_function(|lua, s: String| { + match serde_json::from_str::(&s) { + Ok(value) => json_to_lua(lua, &value), + Err(_) => Ok(Value::Nil), + } + })?)?; + + owlry.set("json", json)?; + Ok(()) +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/// Expand ~ in paths +fn expand_path(path: &str) -> String { + if path.starts_with("~/") + && let Some(home) = dirs::home_dir() { + return home.join(&path[2..]).to_string_lossy().to_string(); + } + path.to_string() +} + +/// Convert JSON value to Lua value +fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult { + match value { + serde_json::Value::Null => Ok(Value::Nil), + serde_json::Value::Bool(b) => Ok(Value::Boolean(*b)), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Ok(Value::Integer(i)) + } else if let Some(f) = n.as_f64() { + Ok(Value::Number(f)) + } else { + Ok(Value::Nil) + } + } + serde_json::Value::String(s) => Ok(Value::String(lua.create_string(s)?)), + serde_json::Value::Array(arr) => { + let table = lua.create_table()?; + for (i, v) in arr.iter().enumerate() { + table.set(i + 1, json_to_lua(lua, v)?)?; + } + Ok(Value::Table(table)) + } + serde_json::Value::Object(obj) => { + let table = lua.create_table()?; + for (k, v) in obj { + table.set(k.as_str(), json_to_lua(lua, v)?)?; + } + Ok(Value::Table(table)) + } + } +} + +/// Convert Lua value to JSON value +fn lua_to_json(_lua: &Lua, value: &Value) -> LuaResult { + match value { + Value::Nil => Ok(serde_json::Value::Null), + Value::Boolean(b) => Ok(serde_json::Value::Bool(*b)), + Value::Integer(i) => Ok(serde_json::Value::Number((*i).into())), + Value::Number(n) => Ok(serde_json::json!(*n)), + Value::String(s) => Ok(serde_json::Value::String(s.to_str()?.to_string())), + Value::Table(t) => { + // Check if it's an array (sequential integer keys starting from 1) + let mut is_array = true; + let mut max_key = 0i64; + for pair in t.clone().pairs::() { + let (k, _) = pair?; + match k { + Value::Integer(i) if i > 0 => { + max_key = max_key.max(i); + } + _ => { + is_array = false; + break; + } + } + } + + if is_array && max_key > 0 { + let mut arr = Vec::new(); + for i in 1..=max_key { + let v: Value = t.get(i)?; + arr.push(lua_to_json(_lua, &v)?); + } + Ok(serde_json::Value::Array(arr)) + } else { + let mut obj = serde_json::Map::new(); + for pair in t.clone().pairs::() { + let (k, v) = pair?; + obj.insert(k, lua_to_json(_lua, &v)?); + } + Ok(serde_json::Value::Object(obj)) + } + } + _ => Ok(serde_json::Value::Null), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::{create_lua_runtime, SandboxConfig}; + + #[test] + fn test_log_api() { + let config = SandboxConfig::default(); + let lua = create_lua_runtime(&config).unwrap(); + let owlry = lua.create_table().unwrap(); + register_log_api(&lua, &owlry).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + + // Just verify it doesn't panic + lua.load("owlry.log.info('test message')").set_name("test").call::<()>(()).unwrap(); + } + + #[test] + fn test_path_api() { + let config = SandboxConfig::default(); + let lua = create_lua_runtime(&config).unwrap(); + let owlry = lua.create_table().unwrap(); + register_path_api(&lua, &owlry, Path::new("/tmp/test-plugin")).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + + let home: String = lua.load("return owlry.path.home()").set_name("test").call(()).unwrap(); + assert!(!home.is_empty()); + + let plugin_dir: String = lua.load("return owlry.path.plugin_dir()").set_name("test").call(()).unwrap(); + assert_eq!(plugin_dir, "/tmp/test-plugin"); + } + + #[test] + fn test_fs_api() { + let config = SandboxConfig::default(); + let lua = create_lua_runtime(&config).unwrap(); + let owlry = lua.create_table().unwrap(); + register_fs_api(&lua, &owlry, Path::new("/tmp")).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + + let exists: bool = lua.load("return owlry.fs.exists('/tmp')").set_name("test").call(()).unwrap(); + assert!(exists); + + let is_dir: bool = lua.load("return owlry.fs.is_dir('/tmp')").set_name("test").call(()).unwrap(); + assert!(is_dir); + } + + #[test] + fn test_json_api() { + let config = SandboxConfig::default(); + let lua = create_lua_runtime(&config).unwrap(); + let owlry = lua.create_table().unwrap(); + register_json_api(&lua, &owlry).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + + let code = r#" + local t = { name = "test", value = 42 } + local json = owlry.json.encode(t) + local decoded = owlry.json.decode(json) + return decoded.name, decoded.value + "#; + let (name, value): (String, i32) = lua.load(code).set_name("test").call(()).unwrap(); + assert_eq!(name, "test"); + assert_eq!(value, 42); + } +} diff --git a/crates/owlry-lua/src/lib.rs b/crates/owlry-lua/src/lib.rs new file mode 100644 index 0000000..2ff1204 --- /dev/null +++ b/crates/owlry-lua/src/lib.rs @@ -0,0 +1,349 @@ +//! Owlry Lua Runtime +//! +//! This crate provides Lua plugin support for owlry. It is loaded dynamically +//! by the core when Lua plugins need to be executed. +//! +//! # Architecture +//! +//! The runtime acts as a "meta-plugin" that: +//! 1. Discovers Lua plugins in `~/.config/owlry/plugins/` +//! 2. Creates sandboxed Lua VMs for each plugin +//! 3. Registers the `owlry` API table +//! 4. Bridges Lua providers to native `PluginItem` format +//! +//! # Plugin Structure +//! +//! Each plugin lives in its own directory: +//! ```text +//! ~/.config/owlry/plugins/ +//! my-plugin/ +//! plugin.toml # Plugin manifest +//! init.lua # Entry point +//! ``` + +mod api; +mod loader; +mod manifest; +mod runtime; + +use abi_stable::std_types::{ROption, RStr, RString, RVec}; +use owlry_plugin_api::{PluginItem, ProviderKind}; +use std::collections::HashMap; +use std::path::PathBuf; + +use loader::LoadedPlugin; + +// Runtime metadata +const RUNTIME_ID: &str = "lua"; +const RUNTIME_NAME: &str = "Lua Runtime"; +const RUNTIME_VERSION: &str = env!("CARGO_PKG_VERSION"); +const RUNTIME_DESCRIPTION: &str = "Lua 5.4 runtime for user plugins"; + +/// API version for compatibility checking +pub const LUA_RUNTIME_API_VERSION: u32 = 1; + +/// Runtime vtable - exported interface for the core to use +#[repr(C)] +pub struct LuaRuntimeVTable { + /// Get runtime info + pub info: extern "C" fn() -> RuntimeInfo, + /// Initialize the runtime with plugins directory + pub init: extern "C" fn(plugins_dir: RStr<'_>) -> RuntimeHandle, + /// Get provider infos from all loaded plugins + pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec, + /// Refresh a provider's items + pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec, + /// Query a dynamic provider + pub query: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>, query: RStr<'_>) -> RVec, + /// Cleanup and drop the runtime + pub drop: extern "C" fn(handle: RuntimeHandle), +} + +/// Runtime info returned by the runtime +#[repr(C)] +pub struct RuntimeInfo { + pub id: RString, + pub name: RString, + pub version: RString, + pub description: RString, + pub api_version: u32, +} + +/// Opaque handle to the runtime state +#[repr(C)] +#[derive(Clone, Copy)] +pub struct RuntimeHandle { + pub ptr: *mut (), +} + +unsafe impl Send for RuntimeHandle {} +unsafe impl Sync for RuntimeHandle {} + +impl RuntimeHandle { + /// Create a null handle (reserved for error cases) + #[allow(dead_code)] + fn null() -> Self { + Self { ptr: std::ptr::null_mut() } + } + + fn from_box(state: Box) -> Self { + Self { ptr: Box::into_raw(state) as *mut () } + } + + unsafe fn drop_as(&self) { + if !self.ptr.is_null() { + unsafe { drop(Box::from_raw(self.ptr as *mut T)) }; + } + } +} + +/// Provider info from a Lua plugin +#[repr(C)] +pub struct LuaProviderInfo { + /// Full provider ID: "plugin_id:provider_name" + pub id: RString, + /// Plugin ID this provider belongs to + pub plugin_id: RString, + /// Provider name within the plugin + pub provider_name: RString, + /// Display name + pub display_name: RString, + /// Optional prefix trigger + pub prefix: ROption, + /// Icon name + pub icon: RString, + /// Provider type (static/dynamic) + pub provider_type: ProviderKind, + /// Type ID for filtering + pub type_id: RString, +} + +/// Internal runtime state +struct LuaRuntimeState { + plugins_dir: PathBuf, + plugins: HashMap, + /// Maps "plugin_id:provider_name" to plugin_id for lookup + provider_map: HashMap, +} + +impl LuaRuntimeState { + fn new(plugins_dir: PathBuf) -> Self { + Self { + plugins_dir, + plugins: HashMap::new(), + provider_map: HashMap::new(), + } + } + + fn discover_and_load(&mut self, owlry_version: &str) { + let discovered = match loader::discover_plugins(&self.plugins_dir) { + Ok(d) => d, + Err(e) => { + eprintln!("owlry-lua: Failed to discover plugins: {}", e); + return; + } + }; + + for (id, (manifest, path)) in discovered { + // Check version compatibility + if !manifest.is_compatible_with(owlry_version) { + eprintln!("owlry-lua: Plugin '{}' not compatible with owlry {}", id, owlry_version); + continue; + } + + let mut plugin = LoadedPlugin::new(manifest, path); + if let Err(e) = plugin.initialize() { + eprintln!("owlry-lua: Failed to initialize plugin '{}': {}", id, e); + continue; + } + + // Build provider map + if let Ok(registrations) = plugin.get_provider_registrations() { + for reg in ®istrations { + let full_id = format!("{}:{}", id, reg.name); + self.provider_map.insert(full_id, id.clone()); + } + } + + self.plugins.insert(id, plugin); + } + } + + fn get_providers(&self) -> Vec { + let mut providers = Vec::new(); + + for (plugin_id, plugin) in &self.plugins { + if let Ok(registrations) = plugin.get_provider_registrations() { + for reg in registrations { + let full_id = format!("{}:{}", plugin_id, reg.name); + let provider_type = if reg.is_dynamic { + ProviderKind::Dynamic + } else { + ProviderKind::Static + }; + + providers.push(LuaProviderInfo { + id: RString::from(full_id), + plugin_id: RString::from(plugin_id.as_str()), + provider_name: RString::from(reg.name.as_str()), + display_name: RString::from(reg.display_name.as_str()), + prefix: reg.prefix.map(RString::from).into(), + icon: RString::from(reg.default_icon.as_str()), + provider_type, + type_id: RString::from(reg.type_id.as_str()), + }); + } + } + } + + providers + } + + fn refresh_provider(&self, provider_id: &str) -> Vec { + // Parse "plugin_id:provider_name" + let parts: Vec<&str> = provider_id.splitn(2, ':').collect(); + if parts.len() != 2 { + return Vec::new(); + } + let (plugin_id, provider_name) = (parts[0], parts[1]); + + if let Some(plugin) = self.plugins.get(plugin_id) { + match plugin.call_provider_refresh(provider_name) { + Ok(items) => items, + Err(e) => { + eprintln!("owlry-lua: Refresh failed for {}: {}", provider_id, e); + Vec::new() + } + } + } else { + Vec::new() + } + } + + fn query_provider(&self, provider_id: &str, query: &str) -> Vec { + // Parse "plugin_id:provider_name" + let parts: Vec<&str> = provider_id.splitn(2, ':').collect(); + if parts.len() != 2 { + return Vec::new(); + } + let (plugin_id, provider_name) = (parts[0], parts[1]); + + if let Some(plugin) = self.plugins.get(plugin_id) { + match plugin.call_provider_query(provider_name, query) { + Ok(items) => items, + Err(e) => { + eprintln!("owlry-lua: Query failed for {}: {}", provider_id, e); + Vec::new() + } + } + } else { + Vec::new() + } + } +} + +// ============================================================================ +// Exported Functions +// ============================================================================ + +extern "C" fn runtime_info() -> RuntimeInfo { + RuntimeInfo { + id: RString::from(RUNTIME_ID), + name: RString::from(RUNTIME_NAME), + version: RString::from(RUNTIME_VERSION), + description: RString::from(RUNTIME_DESCRIPTION), + api_version: LUA_RUNTIME_API_VERSION, + } +} + +extern "C" fn runtime_init(plugins_dir: RStr<'_>) -> RuntimeHandle { + let plugins_dir = PathBuf::from(plugins_dir.as_str()); + let mut state = Box::new(LuaRuntimeState::new(plugins_dir)); + + // TODO: Get owlry version from core somehow + // For now, use a reasonable default + state.discover_and_load("0.3.0"); + + RuntimeHandle::from_box(state) +} + +extern "C" fn runtime_providers(handle: RuntimeHandle) -> RVec { + if handle.ptr.is_null() { + return RVec::new(); + } + + let state = unsafe { &*(handle.ptr as *const LuaRuntimeState) }; + state.get_providers().into() +} + +extern "C" fn runtime_refresh(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec { + if handle.ptr.is_null() { + return RVec::new(); + } + + let state = unsafe { &*(handle.ptr as *const LuaRuntimeState) }; + state.refresh_provider(provider_id.as_str()).into() +} + +extern "C" fn runtime_query(handle: RuntimeHandle, provider_id: RStr<'_>, query: RStr<'_>) -> RVec { + if handle.ptr.is_null() { + return RVec::new(); + } + + let state = unsafe { &*(handle.ptr as *const LuaRuntimeState) }; + state.query_provider(provider_id.as_str(), query.as_str()).into() +} + +extern "C" fn runtime_drop(handle: RuntimeHandle) { + if !handle.ptr.is_null() { + unsafe { + handle.drop_as::(); + } + } +} + +/// Static vtable instance +static LUA_RUNTIME_VTABLE: LuaRuntimeVTable = LuaRuntimeVTable { + info: runtime_info, + init: runtime_init, + providers: runtime_providers, + refresh: runtime_refresh, + query: runtime_query, + drop: runtime_drop, +}; + +/// Entry point - returns the runtime vtable +#[unsafe(no_mangle)] +pub extern "C" fn owlry_lua_runtime_vtable() -> &'static LuaRuntimeVTable { + &LUA_RUNTIME_VTABLE +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_runtime_info() { + let info = runtime_info(); + assert_eq!(info.id.as_str(), "lua"); + assert_eq!(info.api_version, LUA_RUNTIME_API_VERSION); + } + + #[test] + fn test_runtime_handle_null() { + let handle = RuntimeHandle::null(); + assert!(handle.ptr.is_null()); + } + + #[test] + fn test_runtime_handle_from_box() { + let state = Box::new(42u32); + let handle = RuntimeHandle::from_box(state); + assert!(!handle.ptr.is_null()); + unsafe { handle.drop_as::() }; + } +} diff --git a/crates/owlry-lua/src/loader.rs b/crates/owlry-lua/src/loader.rs new file mode 100644 index 0000000..c5f5fd4 --- /dev/null +++ b/crates/owlry-lua/src/loader.rs @@ -0,0 +1,212 @@ +//! Plugin discovery and loading + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use mlua::Lua; +use owlry_plugin_api::PluginItem; + +use crate::api; +use crate::manifest::PluginManifest; +use crate::runtime::{create_lua_runtime, load_file, SandboxConfig}; + +/// Provider registration info from Lua +#[derive(Debug, Clone)] +pub struct ProviderRegistration { + pub name: String, + pub display_name: String, + pub type_id: String, + pub default_icon: String, + pub prefix: Option, + pub is_dynamic: bool, +} + +/// A loaded plugin instance +pub struct LoadedPlugin { + /// Plugin manifest + pub manifest: PluginManifest, + /// Path to plugin directory + pub path: PathBuf, + /// Whether plugin is enabled + pub enabled: bool, + /// Lua runtime (None if not yet initialized) + lua: Option, +} + +impl std::fmt::Debug for LoadedPlugin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LoadedPlugin") + .field("manifest", &self.manifest) + .field("path", &self.path) + .field("enabled", &self.enabled) + .field("lua", &self.lua.is_some()) + .finish() + } +} + +impl LoadedPlugin { + /// Create a new loaded plugin (not yet initialized) + pub fn new(manifest: PluginManifest, path: PathBuf) -> Self { + Self { + manifest, + path, + enabled: true, + lua: None, + } + } + + /// Get the plugin ID + pub fn id(&self) -> &str { + &self.manifest.plugin.id + } + + /// Initialize the Lua runtime and load the entry point + pub fn initialize(&mut self) -> Result<(), String> { + if self.lua.is_some() { + return Ok(()); // Already initialized + } + + let sandbox = SandboxConfig::from_permissions(&self.manifest.permissions); + let lua = create_lua_runtime(&sandbox) + .map_err(|e| format!("Failed to create Lua runtime: {}", e))?; + + // Register owlry APIs before loading entry point + api::register_apis(&lua, &self.path, self.id()) + .map_err(|e| format!("Failed to register APIs: {}", e))?; + + // Load the entry point file + let entry_path = self.path.join(&self.manifest.plugin.entry); + if !entry_path.exists() { + return Err(format!("Entry point '{}' not found", self.manifest.plugin.entry)); + } + + load_file(&lua, &entry_path) + .map_err(|e| format!("Failed to load entry point: {}", e))?; + + self.lua = Some(lua); + Ok(()) + } + + /// Get provider registrations from this plugin + pub fn get_provider_registrations(&self) -> Result, String> { + let lua = self.lua.as_ref() + .ok_or_else(|| "Plugin not initialized".to_string())?; + + api::get_provider_registrations(lua) + .map_err(|e| format!("Failed to get registrations: {}", e)) + } + + /// Call a provider's refresh function + pub fn call_provider_refresh(&self, provider_name: &str) -> Result, String> { + let lua = self.lua.as_ref() + .ok_or_else(|| "Plugin not initialized".to_string())?; + + api::call_refresh(lua, provider_name) + .map_err(|e| format!("Refresh failed: {}", e)) + } + + /// Call a provider's query function + pub fn call_provider_query(&self, provider_name: &str, query: &str) -> Result, String> { + let lua = self.lua.as_ref() + .ok_or_else(|| "Plugin not initialized".to_string())?; + + api::call_query(lua, provider_name, query) + .map_err(|e| format!("Query failed: {}", e)) + } +} + +/// Discover plugins in a directory +pub fn discover_plugins(plugins_dir: &Path) -> Result, String> { + let mut plugins = HashMap::new(); + + if !plugins_dir.exists() { + return Ok(plugins); + } + + let entries = std::fs::read_dir(plugins_dir) + .map_err(|e| format!("Failed to read plugins directory: {}", e))?; + + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + let manifest_path = path.join("plugin.toml"); + if !manifest_path.exists() { + continue; + } + + match PluginManifest::load(&manifest_path) { + Ok(manifest) => { + let id = manifest.plugin.id.clone(); + if plugins.contains_key(&id) { + eprintln!("owlry-lua: Duplicate plugin ID '{}', skipping {}", id, path.display()); + continue; + } + plugins.insert(id, (manifest, path)); + } + Err(e) => { + eprintln!("owlry-lua: Failed to load plugin at {}: {}", path.display(), e); + } + } + } + + Ok(plugins) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn create_test_plugin(dir: &Path, id: &str) { + let plugin_dir = dir.join(id); + fs::create_dir_all(&plugin_dir).unwrap(); + + let manifest = format!( + r#" +[plugin] +id = "{}" +name = "Test {}" +version = "1.0.0" +"#, + id, id + ); + fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); + fs::write(plugin_dir.join("init.lua"), "-- empty plugin").unwrap(); + } + + #[test] + fn test_discover_plugins() { + let temp = TempDir::new().unwrap(); + let plugins_dir = temp.path(); + + create_test_plugin(plugins_dir, "test-plugin"); + create_test_plugin(plugins_dir, "another-plugin"); + + let plugins = discover_plugins(plugins_dir).unwrap(); + assert_eq!(plugins.len(), 2); + assert!(plugins.contains_key("test-plugin")); + assert!(plugins.contains_key("another-plugin")); + } + + #[test] + fn test_discover_plugins_empty_dir() { + let temp = TempDir::new().unwrap(); + let plugins = discover_plugins(temp.path()).unwrap(); + assert!(plugins.is_empty()); + } + + #[test] + fn test_discover_plugins_nonexistent_dir() { + let plugins = discover_plugins(Path::new("/nonexistent/path")).unwrap(); + assert!(plugins.is_empty()); + } +} diff --git a/crates/owlry-lua/src/manifest.rs b/crates/owlry-lua/src/manifest.rs new file mode 100644 index 0000000..fcdd69a --- /dev/null +++ b/crates/owlry-lua/src/manifest.rs @@ -0,0 +1,173 @@ +//! Plugin manifest (plugin.toml) parsing + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::Path; + +/// Plugin manifest loaded from plugin.toml +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginManifest { + pub plugin: PluginInfo, + #[serde(default)] + pub provides: PluginProvides, + #[serde(default)] + pub permissions: PluginPermissions, + #[serde(default)] + pub settings: HashMap, +} + +/// Core plugin information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginInfo { + /// Unique plugin identifier (lowercase, alphanumeric, hyphens) + pub id: String, + /// Human-readable name + pub name: String, + /// Semantic version + pub version: String, + /// Short description + #[serde(default)] + pub description: String, + /// Plugin author + #[serde(default)] + pub author: String, + /// License identifier + #[serde(default)] + pub license: String, + /// Repository URL + #[serde(default)] + pub repository: Option, + /// Required owlry version (semver constraint) + #[serde(default = "default_owlry_version")] + pub owlry_version: String, + /// Entry point file (relative to plugin directory) + #[serde(default = "default_entry")] + pub entry: String, +} + +fn default_owlry_version() -> String { + ">=0.1.0".to_string() +} + +fn default_entry() -> String { + "init.lua".to_string() +} + +/// What the plugin provides +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PluginProvides { + /// Provider names this plugin registers + #[serde(default)] + pub providers: Vec, + /// Whether this plugin registers actions + #[serde(default)] + pub actions: bool, + /// Theme names this plugin contributes + #[serde(default)] + pub themes: Vec, + /// Whether this plugin registers hooks + #[serde(default)] + pub hooks: bool, +} + +/// Plugin permissions/capabilities +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PluginPermissions { + /// Allow network/HTTP requests + #[serde(default)] + pub network: bool, + /// Filesystem paths the plugin can access (beyond its own directory) + #[serde(default)] + pub filesystem: Vec, + /// Commands the plugin is allowed to run + #[serde(default)] + pub run_commands: Vec, + /// Environment variables the plugin reads + #[serde(default)] + pub environment: Vec, +} + +impl PluginManifest { + /// Load a plugin manifest from a plugin.toml file + pub fn load(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("Failed to read manifest: {}", e))?; + let manifest: PluginManifest = toml::from_str(&content) + .map_err(|e| format!("Failed to parse manifest: {}", e))?; + manifest.validate()?; + Ok(manifest) + } + + /// Validate the manifest + fn validate(&self) -> Result<(), String> { + // Validate plugin ID format + if self.plugin.id.is_empty() { + return Err("Plugin ID cannot be empty".to_string()); + } + + if !self.plugin.id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { + return Err("Plugin ID must be lowercase alphanumeric with hyphens".to_string()); + } + + // Validate version format + if semver::Version::parse(&self.plugin.version).is_err() { + return Err(format!("Invalid version format: {}", self.plugin.version)); + } + + // Validate owlry_version constraint + if semver::VersionReq::parse(&self.plugin.owlry_version).is_err() { + return Err(format!("Invalid owlry_version constraint: {}", self.plugin.owlry_version)); + } + + Ok(()) + } + + /// Check if this plugin is compatible with the given owlry version + pub fn is_compatible_with(&self, owlry_version: &str) -> bool { + let req = match semver::VersionReq::parse(&self.plugin.owlry_version) { + Ok(r) => r, + Err(_) => return false, + }; + let version = match semver::Version::parse(owlry_version) { + Ok(v) => v, + Err(_) => return false, + }; + req.matches(&version) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_minimal_manifest() { + let toml_str = r#" +[plugin] +id = "test-plugin" +name = "Test Plugin" +version = "1.0.0" +"#; + let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); + assert_eq!(manifest.plugin.id, "test-plugin"); + assert_eq!(manifest.plugin.name, "Test Plugin"); + assert_eq!(manifest.plugin.version, "1.0.0"); + assert_eq!(manifest.plugin.entry, "init.lua"); + } + + #[test] + fn test_version_compatibility() { + let toml_str = r#" +[plugin] +id = "test" +name = "Test" +version = "1.0.0" +owlry_version = ">=0.3.0, <1.0.0" +"#; + let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); + assert!(manifest.is_compatible_with("0.3.5")); + assert!(manifest.is_compatible_with("0.4.0")); + assert!(!manifest.is_compatible_with("0.2.0")); + assert!(!manifest.is_compatible_with("1.0.0")); + } +} diff --git a/crates/owlry-lua/src/runtime.rs b/crates/owlry-lua/src/runtime.rs new file mode 100644 index 0000000..4a2664c --- /dev/null +++ b/crates/owlry-lua/src/runtime.rs @@ -0,0 +1,153 @@ +//! Lua runtime setup and sandboxing + +use mlua::{Lua, Result as LuaResult, StdLib}; + +use crate::manifest::PluginPermissions; + +/// Configuration for the Lua sandbox +/// +/// Note: Some fields are reserved for future sandbox enforcement. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct SandboxConfig { + /// Allow shell command running (reserved for future enforcement) + pub allow_commands: bool, + /// Allow HTTP requests (reserved for future enforcement) + pub allow_network: bool, + /// Allow filesystem access outside plugin directory (reserved for future enforcement) + pub allow_external_fs: bool, + /// Maximum run time per call (ms) (reserved for future enforcement) + pub max_run_time_ms: u64, + /// Memory limit (bytes, 0 = unlimited) (reserved for future enforcement) + pub max_memory: usize, +} + +impl Default for SandboxConfig { + fn default() -> Self { + Self { + allow_commands: false, + allow_network: false, + allow_external_fs: false, + max_run_time_ms: 5000, // 5 seconds + max_memory: 64 * 1024 * 1024, // 64 MB + } + } +} + +impl SandboxConfig { + /// Create a sandbox config from plugin permissions + pub fn from_permissions(permissions: &PluginPermissions) -> Self { + Self { + allow_commands: !permissions.run_commands.is_empty(), + allow_network: permissions.network, + allow_external_fs: !permissions.filesystem.is_empty(), + ..Default::default() + } + } +} + +/// Create a new sandboxed Lua runtime +pub fn create_lua_runtime(_sandbox: &SandboxConfig) -> LuaResult { + // Create Lua with safe standard libraries only + // We exclude: debug, io, os (dangerous parts), package (loadlib), ffi + let libs = StdLib::COROUTINE + | StdLib::TABLE + | StdLib::STRING + | StdLib::UTF8 + | StdLib::MATH; + + let lua = Lua::new_with(libs, mlua::LuaOptions::default())?; + + // Set up safe environment + setup_safe_globals(&lua)?; + + Ok(lua) +} + +/// Set up safe global environment by removing/replacing dangerous functions +fn setup_safe_globals(lua: &Lua) -> LuaResult<()> { + let globals = lua.globals(); + + // Remove dangerous globals + globals.set("dofile", mlua::Value::Nil)?; + globals.set("loadfile", mlua::Value::Nil)?; + + // Create a restricted os table with only safe functions + let os_table = lua.create_table()?; + os_table.set("clock", lua.create_function(|_, ()| { + Ok(std::time::Instant::now().elapsed().as_secs_f64()) + })?)?; + os_table.set("date", lua.create_function(os_date)?)?; + os_table.set("difftime", lua.create_function(|_, (t2, t1): (f64, f64)| Ok(t2 - t1))?)?; + os_table.set("time", lua.create_function(os_time)?)?; + globals.set("os", os_table)?; + + // Remove print (plugins should use owlry.log instead) + globals.set("print", mlua::Value::Nil)?; + + Ok(()) +} + +/// Safe os.date implementation +fn os_date(_lua: &Lua, format: Option) -> LuaResult { + use chrono::Local; + let now = Local::now(); + let fmt = format.unwrap_or_else(|| "%c".to_string()); + Ok(now.format(&fmt).to_string()) +} + +/// Safe os.time implementation +fn os_time(_lua: &Lua, _args: ()) -> LuaResult { + use std::time::{SystemTime, UNIX_EPOCH}; + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + Ok(duration.as_secs() as i64) +} + +/// Load and run a Lua file in the given runtime +pub fn load_file(lua: &Lua, path: &std::path::Path) -> LuaResult<()> { + let content = std::fs::read_to_string(path) + .map_err(mlua::Error::external)?; + lua.load(&content) + .set_name(path.file_name().and_then(|n| n.to_str()).unwrap_or("chunk")) + .into_function()? + .call(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_sandboxed_runtime() { + let config = SandboxConfig::default(); + let lua = create_lua_runtime(&config).unwrap(); + + // Verify dangerous functions are removed + let result: LuaResult = lua.globals().get("dofile"); + assert!(matches!(result, Ok(mlua::Value::Nil))); + + // Verify safe functions work + let result: String = lua.load("return os.date('%Y')").call(()).unwrap(); + assert!(!result.is_empty()); + } + + #[test] + fn test_basic_lua_operations() { + let config = SandboxConfig::default(); + let lua = create_lua_runtime(&config).unwrap(); + + // Test basic math + let result: i32 = lua.load("return 2 + 2").call(()).unwrap(); + assert_eq!(result, 4); + + // Test table operations + let result: i32 = lua.load("local t = {1,2,3}; return #t").call(()).unwrap(); + assert_eq!(result, 3); + + // Test string operations + let result: String = lua.load("return string.upper('hello')").call(()).unwrap(); + assert_eq!(result, "HELLO"); + } +} diff --git a/crates/owlry-plugin-bookmarks/Cargo.toml b/crates/owlry-plugin-bookmarks/Cargo.toml new file mode 100644 index 0000000..a7c8005 --- /dev/null +++ b/crates/owlry-plugin-bookmarks/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "owlry-plugin-bookmarks" +version = "0.4.10" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Bookmarks plugin for owlry - browser bookmark search" +keywords = ["owlry", "plugin", "bookmarks", "browser"] +categories = ["web-programming"] + +[lib] +crate-type = ["cdylib"] # Compile as dynamic library (.so) + +[dependencies] +# Plugin API for owlry +owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" } + +# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) +abi_stable = "0.11" + +# For finding browser config directories +dirs = "5.0" + +# For parsing Chrome bookmarks JSON +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# For reading Firefox bookmarks (places.sqlite) +# Use bundled SQLite to avoid system library version conflicts +rusqlite = { version = "0.39", features = ["bundled"] } diff --git a/crates/owlry-plugin-bookmarks/src/lib.rs b/crates/owlry-plugin-bookmarks/src/lib.rs new file mode 100644 index 0000000..3eb5f3c --- /dev/null +++ b/crates/owlry-plugin-bookmarks/src/lib.rs @@ -0,0 +1,662 @@ +//! Bookmarks Plugin for Owlry +//! +//! A static provider that reads browser bookmarks from various browsers. +//! +//! Supported browsers: +//! - Firefox (via places.sqlite using rusqlite with bundled SQLite) +//! - Chrome +//! - Chromium +//! - Brave +//! - Edge + +use abi_stable::std_types::{ROption, RStr, RString, RVec}; +use owlry_plugin_api::{ + owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, + ProviderPosition, API_VERSION, +}; +use rusqlite::{Connection, OpenFlags}; +use serde::Deserialize; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; + +// Plugin metadata +const PLUGIN_ID: &str = "bookmarks"; +const PLUGIN_NAME: &str = "Bookmarks"; +const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); +const PLUGIN_DESCRIPTION: &str = "Browser bookmark search"; + +// Provider metadata +const PROVIDER_ID: &str = "bookmarks"; +const PROVIDER_NAME: &str = "Bookmarks"; +const PROVIDER_PREFIX: &str = ":bm"; +const PROVIDER_ICON: &str = "user-bookmarks-symbolic"; +const PROVIDER_TYPE_ID: &str = "bookmarks"; + +/// Bookmarks provider state - holds cached items +struct BookmarksState { + /// Cached bookmark items (returned immediately on refresh) + items: Vec, + /// Flag to prevent concurrent background loads + loading: Arc, +} + +impl BookmarksState { + fn new() -> Self { + Self { + items: Vec::new(), + loading: Arc::new(AtomicBool::new(false)), + } + } + + /// Get or create the favicon cache directory + fn favicon_cache_dir() -> Option { + dirs::cache_dir().map(|d| d.join("owlry/favicons")) + } + + /// Ensure the favicon cache directory exists + fn ensure_favicon_cache_dir() -> Option { + Self::favicon_cache_dir().and_then(|dir| { + fs::create_dir_all(&dir).ok()?; + Some(dir) + }) + } + + /// Hash a URL to create a cache filename + fn url_to_cache_filename(url: &str) -> String { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + url.hash(&mut hasher); + format!("{:016x}.png", hasher.finish()) + } + + /// Get the bookmark cache file path + fn bookmark_cache_file() -> Option { + dirs::cache_dir().map(|d| d.join("owlry/bookmarks.json")) + } + + /// Load cached bookmarks from disk (fast) + fn load_cached_bookmarks() -> Vec { + let cache_file = match Self::bookmark_cache_file() { + Some(f) => f, + None => return Vec::new(), + }; + + if !cache_file.exists() { + return Vec::new(); + } + + let content = match fs::read_to_string(&cache_file) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + // Parse cached bookmarks (simple JSON format) + #[derive(serde::Deserialize)] + struct CachedBookmark { + id: String, + name: String, + command: String, + description: Option, + icon: String, + } + + let cached: Vec = match serde_json::from_str(&content) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + cached + .into_iter() + .map(|b| { + let mut item = PluginItem::new(b.id, b.name, b.command) + .with_icon(&b.icon) + .with_keywords(vec!["bookmark".to_string()]); + if let Some(desc) = b.description { + item = item.with_description(desc); + } + item + }) + .collect() + } + + /// Save bookmarks to cache file + fn save_cached_bookmarks(items: &[PluginItem]) { + let cache_file = match Self::bookmark_cache_file() { + Some(f) => f, + None => return, + }; + + // Ensure cache directory exists + if let Some(parent) = cache_file.parent() { + let _ = fs::create_dir_all(parent); + } + + #[derive(serde::Serialize)] + struct CachedBookmark { + id: String, + name: String, + command: String, + description: Option, + icon: String, + } + + let cached: Vec = items + .iter() + .map(|item| { + let desc: Option = match &item.description { + abi_stable::std_types::ROption::RSome(s) => Some(s.to_string()), + abi_stable::std_types::ROption::RNone => None, + }; + let icon: String = match &item.icon { + abi_stable::std_types::ROption::RSome(s) => s.to_string(), + abi_stable::std_types::ROption::RNone => PROVIDER_ICON.to_string(), + }; + CachedBookmark { + id: item.id.to_string(), + name: item.name.to_string(), + command: item.command.to_string(), + description: desc, + icon, + } + }) + .collect(); + + if let Ok(json) = serde_json::to_string(&cached) { + let _ = fs::write(&cache_file, json); + } + } + + fn chromium_bookmark_paths() -> Vec { + let mut paths = Vec::new(); + + if let Some(config_dir) = dirs::config_dir() { + // Chrome + paths.push(config_dir.join("google-chrome/Default/Bookmarks")); + paths.push(config_dir.join("google-chrome-stable/Default/Bookmarks")); + + // Chromium + paths.push(config_dir.join("chromium/Default/Bookmarks")); + + // Brave + paths.push(config_dir.join("BraveSoftware/Brave-Browser/Default/Bookmarks")); + + // Edge + paths.push(config_dir.join("microsoft-edge/Default/Bookmarks")); + } + + paths + } + + fn firefox_places_paths() -> Vec { + let mut paths = Vec::new(); + + if let Some(home) = dirs::home_dir() { + let firefox_dir = home.join(".mozilla/firefox"); + if firefox_dir.exists() { + // Find all profile directories + if let Ok(entries) = fs::read_dir(&firefox_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let places = path.join("places.sqlite"); + if places.exists() { + paths.push(places); + } + } + } + } + } + } + + paths + } + + /// Find Firefox favicons.sqlite paths (paired with places.sqlite) + fn firefox_favicons_path(places_path: &Path) -> Option { + let favicons = places_path.parent()?.join("favicons.sqlite"); + if favicons.exists() { + Some(favicons) + } else { + None + } + } + + fn load_bookmarks(&mut self) { + // Fast path: load from cache immediately + if self.items.is_empty() { + self.items = Self::load_cached_bookmarks(); + } + + // Don't start another background load if one is already running + if self.loading.swap(true, Ordering::SeqCst) { + return; + } + + // Spawn background thread to refresh bookmarks + let loading = self.loading.clone(); + thread::spawn(move || { + let mut items = Vec::new(); + + // Load Chrome/Chromium bookmarks (fast - just JSON parsing) + for path in Self::chromium_bookmark_paths() { + if path.exists() { + Self::read_chrome_bookmarks_static(&path, &mut items); + } + } + + // Load Firefox bookmarks with favicons (synchronous with rusqlite) + for path in Self::firefox_places_paths() { + Self::read_firefox_bookmarks(&path, &mut items); + } + + // Save to cache for next startup + Self::save_cached_bookmarks(&items); + + loading.store(false, Ordering::SeqCst); + }); + } + + /// Read Chrome bookmarks (static helper for background thread) + fn read_chrome_bookmarks_static(path: &PathBuf, items: &mut Vec) { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return, + }; + + let bookmarks: ChromeBookmarks = match serde_json::from_str(&content) { + Ok(b) => b, + Err(_) => return, + }; + + if let Some(roots) = bookmarks.roots { + if let Some(bar) = roots.bookmark_bar { + Self::process_chrome_folder_static(&bar, items); + } + if let Some(other) = roots.other { + Self::process_chrome_folder_static(&other, items); + } + if let Some(synced) = roots.synced { + Self::process_chrome_folder_static(&synced, items); + } + } + } + + fn process_chrome_folder_static(folder: &ChromeBookmarkNode, items: &mut Vec) { + if let Some(ref children) = folder.children { + for child in children { + match child.node_type.as_deref() { + Some("url") => { + if let Some(ref url) = child.url { + let name = child.name.clone().unwrap_or_else(|| url.clone()); + items.push( + PluginItem::new( + format!("bookmark:{}", url), + name, + format!("xdg-open '{}'", url.replace('\'', "'\\''")), + ) + .with_description(url.clone()) + .with_icon(PROVIDER_ICON) + .with_keywords(vec!["bookmark".to_string(), "chrome".to_string()]), + ); + } + } + Some("folder") => { + Self::process_chrome_folder_static(child, items); + } + _ => {} + } + } + } + } + + /// Read Firefox bookmarks using rusqlite (synchronous, bundled SQLite) + fn read_firefox_bookmarks(places_path: &PathBuf, items: &mut Vec) { + let temp_dir = std::env::temp_dir(); + let temp_db = temp_dir.join("owlry_places_temp.sqlite"); + + // Copy database to temp location to avoid locking issues + if fs::copy(places_path, &temp_db).is_err() { + return; + } + + // Also copy WAL file if it exists + let wal_path = places_path.with_extension("sqlite-wal"); + if wal_path.exists() { + let temp_wal = temp_db.with_extension("sqlite-wal"); + let _ = fs::copy(&wal_path, &temp_wal); + } + + // Copy favicons database if available + let favicons_path = Self::firefox_favicons_path(places_path); + let temp_favicons = temp_dir.join("owlry_favicons_temp.sqlite"); + if let Some(ref fp) = favicons_path { + let _ = fs::copy(fp, &temp_favicons); + let fav_wal = fp.with_extension("sqlite-wal"); + if fav_wal.exists() { + let _ = fs::copy(&fav_wal, temp_favicons.with_extension("sqlite-wal")); + } + } + + let cache_dir = Self::ensure_favicon_cache_dir(); + + // Read bookmarks from places.sqlite + let bookmarks = Self::fetch_firefox_bookmarks(&temp_db, &temp_favicons, cache_dir.as_ref()); + + // Clean up temp files + let _ = fs::remove_file(&temp_db); + let _ = fs::remove_file(temp_db.with_extension("sqlite-wal")); + let _ = fs::remove_file(&temp_favicons); + let _ = fs::remove_file(temp_favicons.with_extension("sqlite-wal")); + + for (title, url, favicon_path) in bookmarks { + let icon = favicon_path.unwrap_or_else(|| PROVIDER_ICON.to_string()); + items.push( + PluginItem::new( + format!("bookmark:firefox:{}", url), + title, + format!("xdg-open '{}'", url.replace('\'', "'\\''")), + ) + .with_description(url) + .with_icon(&icon) + .with_keywords(vec!["bookmark".to_string(), "firefox".to_string()]), + ); + } + } + + /// Fetch Firefox bookmarks with optional favicons + fn fetch_firefox_bookmarks( + places_path: &Path, + favicons_path: &Path, + cache_dir: Option<&PathBuf>, + ) -> Vec<(String, String, Option)> { + // Open places.sqlite in read-only mode + let conn = match Connection::open_with_flags( + places_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + // Query bookmarks joining moz_bookmarks with moz_places + // type=1 means URL bookmarks (not folders, separators, etc.) + let query = r#" + SELECT b.title, p.url + FROM moz_bookmarks b + JOIN moz_places p ON b.fk = p.id + WHERE b.type = 1 + AND p.url NOT LIKE 'place:%' + AND p.url NOT LIKE 'about:%' + AND b.title IS NOT NULL + AND b.title != '' + ORDER BY b.dateAdded DESC + LIMIT 500 + "#; + + let mut stmt = match conn.prepare(query) { + Ok(s) => s, + Err(_) => return Vec::new(), + }; + + let bookmarks: Vec<(String, String)> = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .ok() + .map(|rows| rows.filter_map(|r| r.ok()).collect()) + .unwrap_or_default(); + + // If no favicons or cache dir, return without favicons + let cache_dir = match cache_dir { + Some(c) => c, + None => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(), + }; + + // Try to open favicons database + let fav_conn = match Connection::open_with_flags( + favicons_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) { + Ok(c) => c, + Err(_) => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(), + }; + + // Fetch favicons for each URL + let mut results = Vec::new(); + for (title, url) in bookmarks { + let favicon_path = Self::get_favicon_for_url(&fav_conn, &url, cache_dir); + results.push((title, url, favicon_path)); + } + + results + } + + /// Get favicon for a URL, caching to file if needed + fn get_favicon_for_url( + conn: &Connection, + page_url: &str, + cache_dir: &Path, + ) -> Option { + // Check if already cached + let cache_filename = Self::url_to_cache_filename(page_url); + let cache_path = cache_dir.join(&cache_filename); + if cache_path.exists() { + return Some(cache_path.to_string_lossy().to_string()); + } + + // Query favicon data from database + // Join moz_pages_w_icons -> moz_icons_to_pages -> moz_icons + // Prefer smaller icons (32px) for efficiency + let query = r#" + SELECT i.data + FROM moz_pages_w_icons p + JOIN moz_icons_to_pages ip ON p.id = ip.page_id + JOIN moz_icons i ON ip.icon_id = i.id + WHERE p.page_url = ? + AND i.data IS NOT NULL + ORDER BY ABS(i.width - 32) ASC + LIMIT 1 + "#; + + let data: Option> = conn + .query_row(query, [page_url], |row| row.get(0)) + .ok(); + + let data = data?; + if data.is_empty() { + return None; + } + + // Write favicon data to cache file + let mut file = fs::File::create(&cache_path).ok()?; + file.write_all(&data).ok()?; + + Some(cache_path.to_string_lossy().to_string()) + } +} + +// Chrome bookmark JSON structures +#[derive(Debug, Deserialize)] +struct ChromeBookmarks { + roots: Option, +} + +#[derive(Debug, Deserialize)] +struct ChromeBookmarkRoots { + bookmark_bar: Option, + other: Option, + synced: Option, +} + +#[derive(Debug, Deserialize)] +struct ChromeBookmarkNode { + name: Option, + url: Option, + #[serde(rename = "type")] + node_type: Option, + children: Option>, +} + +// ============================================================================ +// Plugin Interface Implementation +// ============================================================================ + +extern "C" fn plugin_info() -> PluginInfo { + PluginInfo { + id: RString::from(PLUGIN_ID), + name: RString::from(PLUGIN_NAME), + version: RString::from(PLUGIN_VERSION), + description: RString::from(PLUGIN_DESCRIPTION), + api_version: API_VERSION, + } +} + +extern "C" fn plugin_providers() -> RVec { + vec![ProviderInfo { + id: RString::from(PROVIDER_ID), + name: RString::from(PROVIDER_NAME), + prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)), + icon: RString::from(PROVIDER_ICON), + provider_type: ProviderKind::Static, + type_id: RString::from(PROVIDER_TYPE_ID), + position: ProviderPosition::Normal, + priority: 0, // Static: use frecency ordering + }] + .into() +} + +extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { + let state = Box::new(BookmarksState::new()); + ProviderHandle::from_box(state) +} + +extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { + if handle.ptr.is_null() { + return RVec::new(); + } + + // SAFETY: We created this handle from Box + let state = unsafe { &mut *(handle.ptr as *mut BookmarksState) }; + + // Load bookmarks + state.load_bookmarks(); + + // Return items + state.items.to_vec().into() +} + +extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec { + // Static provider - query is handled by the core using cached items + RVec::new() +} + +extern "C" fn provider_drop(handle: ProviderHandle) { + if !handle.ptr.is_null() { + // SAFETY: We created this handle from Box + unsafe { + handle.drop_as::(); + } + } +} + +// Register the plugin vtable +owlry_plugin! { + info: plugin_info, + providers: plugin_providers, + init: provider_init, + refresh: provider_refresh, + query: provider_query, + drop: provider_drop, +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bookmarks_state_new() { + let state = BookmarksState::new(); + assert!(state.items.is_empty()); + } + + #[test] + fn test_chromium_paths() { + let paths = BookmarksState::chromium_bookmark_paths(); + // Should have at least some paths configured + assert!(!paths.is_empty()); + } + + #[test] + fn test_firefox_paths() { + // This will find paths if Firefox is installed + let paths = BookmarksState::firefox_places_paths(); + // Path detection should work (may be empty if Firefox not installed) + let _ = paths.len(); // Just ensure it doesn't panic + } + + #[test] + fn test_parse_chrome_bookmarks() { + let json = r#"{ + "roots": { + "bookmark_bar": { + "type": "folder", + "children": [ + { + "type": "url", + "name": "Example", + "url": "https://example.com" + } + ] + } + } + }"#; + + let bookmarks: ChromeBookmarks = serde_json::from_str(json).unwrap(); + assert!(bookmarks.roots.is_some()); + + let roots = bookmarks.roots.unwrap(); + assert!(roots.bookmark_bar.is_some()); + + let bar = roots.bookmark_bar.unwrap(); + assert!(bar.children.is_some()); + assert_eq!(bar.children.unwrap().len(), 1); + } + + #[test] + fn test_process_folder() { + let mut items = Vec::new(); + + let folder = ChromeBookmarkNode { + name: Some("Test Folder".to_string()), + url: None, + node_type: Some("folder".to_string()), + children: Some(vec![ + ChromeBookmarkNode { + name: Some("Test Bookmark".to_string()), + url: Some("https://test.com".to_string()), + node_type: Some("url".to_string()), + children: None, + }, + ]), + }; + + BookmarksState::process_chrome_folder_static(&folder, &mut items); + assert_eq!(items.len(), 1); + assert_eq!(items[0].name.as_str(), "Test Bookmark"); + } + + #[test] + fn test_url_escaping() { + let url = "https://example.com/path?query='test'"; + let command = format!("xdg-open '{}'", url.replace('\'', "'\\''")); + assert!(command.contains("'\\''")); + } +} diff --git a/crates/owlry-plugin-calculator/Cargo.toml b/crates/owlry-plugin-calculator/Cargo.toml new file mode 100644 index 0000000..fb64561 --- /dev/null +++ b/crates/owlry-plugin-calculator/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "owlry-plugin-calculator" +version = "0.4.10" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Calculator plugin for owlry - evaluates mathematical expressions" +keywords = ["owlry", "plugin", "calculator"] +categories = ["mathematics"] + +[lib] +crate-type = ["cdylib"] # Compile as dynamic library (.so) + +[dependencies] +# Plugin API for owlry +owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" } + +# Math expression evaluation +meval = "0.2" + +# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) +abi_stable = "0.11" diff --git a/crates/owlry-plugin-calculator/src/lib.rs b/crates/owlry-plugin-calculator/src/lib.rs new file mode 100644 index 0000000..dc9ab19 --- /dev/null +++ b/crates/owlry-plugin-calculator/src/lib.rs @@ -0,0 +1,231 @@ +//! Calculator Plugin for Owlry +//! +//! A dynamic provider that evaluates mathematical expressions. +//! Supports queries prefixed with `=` or `calc `. +//! +//! Examples: +//! - `= 5 + 3` → 8 +//! - `calc sqrt(16)` → 4 +//! - `= pi * 2` → 6.283185... + +use abi_stable::std_types::{ROption, RStr, RString, RVec}; +use owlry_plugin_api::{ + owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, + ProviderPosition, API_VERSION, +}; + +// Plugin metadata +const PLUGIN_ID: &str = "calculator"; +const PLUGIN_NAME: &str = "Calculator"; +const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); +const PLUGIN_DESCRIPTION: &str = "Evaluate mathematical expressions"; + +// Provider metadata +const PROVIDER_ID: &str = "calculator"; +const PROVIDER_NAME: &str = "Calculator"; +const PROVIDER_PREFIX: &str = "="; +const PROVIDER_ICON: &str = "accessories-calculator"; +const PROVIDER_TYPE_ID: &str = "calc"; + +/// Calculator provider state (empty for now, but could cache results) +struct CalculatorState; + +// ============================================================================ +// Plugin Interface Implementation +// ============================================================================ + +extern "C" fn plugin_info() -> PluginInfo { + PluginInfo { + id: RString::from(PLUGIN_ID), + name: RString::from(PLUGIN_NAME), + version: RString::from(PLUGIN_VERSION), + description: RString::from(PLUGIN_DESCRIPTION), + api_version: API_VERSION, + } +} + +extern "C" fn plugin_providers() -> RVec { + vec![ProviderInfo { + id: RString::from(PROVIDER_ID), + name: RString::from(PROVIDER_NAME), + prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)), + icon: RString::from(PROVIDER_ICON), + provider_type: ProviderKind::Dynamic, + type_id: RString::from(PROVIDER_TYPE_ID), + position: ProviderPosition::Normal, + priority: 10000, // Dynamic: calculator results first + }] + .into() +} + +extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { + // Create state and return handle + let state = Box::new(CalculatorState); + ProviderHandle::from_box(state) +} + +extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec { + // Dynamic provider - refresh does nothing + RVec::new() +} + +extern "C" fn provider_query(_handle: ProviderHandle, query: RStr<'_>) -> RVec { + let query_str = query.as_str(); + + // Extract expression from query + let expr = match extract_expression(query_str) { + Some(e) if !e.is_empty() => e, + _ => return RVec::new(), + }; + + // Evaluate the expression + match evaluate_expression(expr) { + Some(item) => vec![item].into(), + None => RVec::new(), + } +} + +extern "C" fn provider_drop(handle: ProviderHandle) { + if !handle.ptr.is_null() { + // SAFETY: We created this handle from Box + unsafe { + handle.drop_as::(); + } + } +} + +// Register the plugin vtable +owlry_plugin! { + info: plugin_info, + providers: plugin_providers, + init: provider_init, + refresh: provider_refresh, + query: provider_query, + drop: provider_drop, +} + +// ============================================================================ +// Calculator Logic +// ============================================================================ + +/// Extract expression from query (handles `= expr` and `calc expr` formats) +fn extract_expression(query: &str) -> Option<&str> { + let trimmed = query.trim(); + + // Support both "= expr" and "=expr" (with or without space) + if let Some(expr) = trimmed.strip_prefix("= ") { + Some(expr.trim()) + } else if let Some(expr) = trimmed.strip_prefix('=') { + Some(expr.trim()) + } else if let Some(expr) = trimmed.strip_prefix("calc ") { + Some(expr.trim()) + } else { + // For filter mode - accept raw expressions + Some(trimmed) + } +} + +/// Evaluate a mathematical expression and return a PluginItem +fn evaluate_expression(expr: &str) -> Option { + match meval::eval_str(expr) { + Ok(result) => { + // Format result nicely + let result_str = format_result(result); + + Some( + PluginItem::new( + format!("calc:{}", expr), + result_str.clone(), + format!("sh -c 'echo -n \"{}\" | wl-copy'", result_str), + ) + .with_description(format!("= {}", expr)) + .with_icon(PROVIDER_ICON) + .with_keywords(vec!["math".to_string(), "calculator".to_string()]), + ) + } + Err(_) => None, + } +} + +/// Format a numeric result nicely +fn format_result(result: f64) -> String { + if result.fract() == 0.0 && result.abs() < 1e15 { + // Integer result + format!("{}", result as i64) + } else { + // Float result with reasonable precision, trimming trailing zeros + let formatted = format!("{:.10}", result); + formatted + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_expression() { + assert_eq!(extract_expression("= 5+3"), Some("5+3")); + assert_eq!(extract_expression("=5+3"), Some("5+3")); + assert_eq!(extract_expression("calc 5+3"), Some("5+3")); + assert_eq!(extract_expression(" = 5 + 3 "), Some("5 + 3")); + assert_eq!(extract_expression("5+3"), Some("5+3")); // Raw expression + } + + #[test] + fn test_format_result() { + assert_eq!(format_result(8.0), "8"); + assert_eq!(format_result(2.5), "2.5"); + assert_eq!(format_result(3.14159265358979), "3.1415926536"); + } + + #[test] + fn test_evaluate_basic() { + let item = evaluate_expression("5+3").unwrap(); + assert_eq!(item.name.as_str(), "8"); + + let item = evaluate_expression("10 * 2").unwrap(); + assert_eq!(item.name.as_str(), "20"); + + let item = evaluate_expression("15 / 3").unwrap(); + assert_eq!(item.name.as_str(), "5"); + } + + #[test] + fn test_evaluate_float() { + let item = evaluate_expression("5/2").unwrap(); + assert_eq!(item.name.as_str(), "2.5"); + } + + #[test] + fn test_evaluate_functions() { + let item = evaluate_expression("sqrt(16)").unwrap(); + assert_eq!(item.name.as_str(), "4"); + + let item = evaluate_expression("abs(-5)").unwrap(); + assert_eq!(item.name.as_str(), "5"); + } + + #[test] + fn test_evaluate_constants() { + let item = evaluate_expression("pi").unwrap(); + assert!(item.name.as_str().starts_with("3.14159")); + + let item = evaluate_expression("e").unwrap(); + assert!(item.name.as_str().starts_with("2.718")); + } + + #[test] + fn test_evaluate_invalid() { + assert!(evaluate_expression("").is_none()); + assert!(evaluate_expression("invalid").is_none()); + assert!(evaluate_expression("5 +").is_none()); + } +} diff --git a/crates/owlry-plugin-clipboard/Cargo.toml b/crates/owlry-plugin-clipboard/Cargo.toml new file mode 100644 index 0000000..4256be7 --- /dev/null +++ b/crates/owlry-plugin-clipboard/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "owlry-plugin-clipboard" +version = "0.4.10" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Clipboard plugin for owlry - clipboard history via cliphist" +keywords = ["owlry", "plugin", "clipboard"] +categories = ["os"] + +[lib] +crate-type = ["cdylib"] # Compile as dynamic library (.so) + +[dependencies] +# Plugin API for owlry +owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" } + +# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) +abi_stable = "0.11" diff --git a/crates/owlry-plugin-clipboard/src/lib.rs b/crates/owlry-plugin-clipboard/src/lib.rs new file mode 100644 index 0000000..600e67b --- /dev/null +++ b/crates/owlry-plugin-clipboard/src/lib.rs @@ -0,0 +1,259 @@ +//! Clipboard Plugin for Owlry +//! +//! A static provider that integrates with cliphist to show clipboard history. +//! Requires cliphist and wl-clipboard to be installed. +//! +//! Dependencies: +//! - cliphist: clipboard history manager +//! - wl-clipboard: Wayland clipboard utilities (wl-copy) + +use abi_stable::std_types::{ROption, RStr, RString, RVec}; +use owlry_plugin_api::{ + owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, + ProviderPosition, API_VERSION, +}; +use std::process::Command; + +// Plugin metadata +const PLUGIN_ID: &str = "clipboard"; +const PLUGIN_NAME: &str = "Clipboard"; +const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); +const PLUGIN_DESCRIPTION: &str = "Clipboard history via cliphist"; + +// Provider metadata +const PROVIDER_ID: &str = "clipboard"; +const PROVIDER_NAME: &str = "Clipboard"; +const PROVIDER_PREFIX: &str = ":clip"; +const PROVIDER_ICON: &str = "edit-paste"; +const PROVIDER_TYPE_ID: &str = "clipboard"; + +// Default max entries to show +const DEFAULT_MAX_ENTRIES: usize = 50; + +/// Clipboard provider state - holds cached items +struct ClipboardState { + items: Vec, + max_entries: usize, +} + +impl ClipboardState { + fn new() -> Self { + Self { + items: Vec::new(), + max_entries: DEFAULT_MAX_ENTRIES, + } + } + + /// Check if cliphist is available + fn has_cliphist() -> bool { + Command::new("which") + .arg("cliphist") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn load_clipboard_history(&mut self) { + self.items.clear(); + + if !Self::has_cliphist() { + return; + } + + // Get clipboard history from cliphist + let output = match Command::new("cliphist").arg("list").output() { + Ok(o) => o, + Err(_) => return, + }; + + if !output.status.success() { + return; + } + + let content = String::from_utf8_lossy(&output.stdout); + + for (idx, line) in content.lines().take(self.max_entries).enumerate() { + // cliphist format: "id\tpreview" + let parts: Vec<&str> = line.splitn(2, '\t').collect(); + + if parts.is_empty() { + continue; + } + + let clip_id = parts[0]; + let preview = if parts.len() > 1 { + // Truncate long previews (char-safe for UTF-8) + let p = parts[1]; + if p.chars().count() > 80 { + let truncated: String = p.chars().take(77).collect(); + format!("{}...", truncated) + } else { + p.to_string() + } + } else { + "[binary data]".to_string() + }; + + // Clean up preview - replace newlines with spaces + let preview_clean = preview + .replace('\n', " ") + .replace('\r', "") + .replace('\t', " "); + + // Command to paste this entry + // echo "id" | cliphist decode | wl-copy + let command = format!( + "echo '{}' | cliphist decode | wl-copy", + clip_id.replace('\'', "'\\''") + ); + + self.items.push( + PluginItem::new(format!("clipboard:{}", idx), preview_clean, command) + .with_description("Copy to clipboard") + .with_icon(PROVIDER_ICON), + ); + } + } +} + +// ============================================================================ +// Plugin Interface Implementation +// ============================================================================ + +extern "C" fn plugin_info() -> PluginInfo { + PluginInfo { + id: RString::from(PLUGIN_ID), + name: RString::from(PLUGIN_NAME), + version: RString::from(PLUGIN_VERSION), + description: RString::from(PLUGIN_DESCRIPTION), + api_version: API_VERSION, + } +} + +extern "C" fn plugin_providers() -> RVec { + vec![ProviderInfo { + id: RString::from(PROVIDER_ID), + name: RString::from(PROVIDER_NAME), + prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)), + icon: RString::from(PROVIDER_ICON), + provider_type: ProviderKind::Static, + type_id: RString::from(PROVIDER_TYPE_ID), + position: ProviderPosition::Normal, + priority: 0, // Static: use frecency ordering + }] + .into() +} + +extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { + let state = Box::new(ClipboardState::new()); + ProviderHandle::from_box(state) +} + +extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { + if handle.ptr.is_null() { + return RVec::new(); + } + + // SAFETY: We created this handle from Box + let state = unsafe { &mut *(handle.ptr as *mut ClipboardState) }; + + // Load clipboard history + state.load_clipboard_history(); + + // Return items + state.items.to_vec().into() +} + +extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec { + // Static provider - query is handled by the core using cached items + RVec::new() +} + +extern "C" fn provider_drop(handle: ProviderHandle) { + if !handle.ptr.is_null() { + // SAFETY: We created this handle from Box + unsafe { + handle.drop_as::(); + } + } +} + +// Register the plugin vtable +owlry_plugin! { + info: plugin_info, + providers: plugin_providers, + init: provider_init, + refresh: provider_refresh, + query: provider_query, + drop: provider_drop, +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clipboard_state_new() { + let state = ClipboardState::new(); + assert!(state.items.is_empty()); + assert_eq!(state.max_entries, DEFAULT_MAX_ENTRIES); + } + + #[test] + fn test_preview_truncation() { + // Test that long strings would be truncated (char-safe) + let long_text = "a".repeat(100); + let truncated = if long_text.chars().count() > 80 { + let t: String = long_text.chars().take(77).collect(); + format!("{}...", t) + } else { + long_text.clone() + }; + assert_eq!(truncated.chars().count(), 80); + assert!(truncated.ends_with("...")); + } + + #[test] + fn test_preview_truncation_utf8() { + // Test with multi-byte UTF-8 characters (box-drawing chars are 3 bytes each) + let utf8_text = "├── ".repeat(30); // Each "├── " is 7 bytes but 4 chars + let truncated = if utf8_text.chars().count() > 80 { + let t: String = utf8_text.chars().take(77).collect(); + format!("{}...", t) + } else { + utf8_text.clone() + }; + assert_eq!(truncated.chars().count(), 80); + assert!(truncated.ends_with("...")); + } + + #[test] + fn test_preview_cleaning() { + let dirty = "line1\nline2\tcolumn\rend"; + let clean = dirty + .replace('\n', " ") + .replace('\r', "") + .replace('\t', " "); + assert_eq!(clean, "line1 line2 columnend"); + } + + #[test] + fn test_command_escaping() { + let clip_id = "test'id"; + let command = format!( + "echo '{}' | cliphist decode | wl-copy", + clip_id.replace('\'', "'\\''") + ); + assert!(command.contains("test'\\''id")); + } + + #[test] + fn test_has_cliphist_runs() { + // Just ensure it doesn't panic - cliphist may or may not be installed + let _ = ClipboardState::has_cliphist(); + } +} diff --git a/crates/owlry-plugin-emoji/Cargo.toml b/crates/owlry-plugin-emoji/Cargo.toml new file mode 100644 index 0000000..155fbea --- /dev/null +++ b/crates/owlry-plugin-emoji/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "owlry-plugin-emoji" +version = "0.4.10" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Emoji plugin for owlry - search and copy emojis" +keywords = ["owlry", "plugin", "emoji"] +categories = ["text-processing"] + +[lib] +crate-type = ["cdylib"] # Compile as dynamic library (.so) + +[dependencies] +# Plugin API for owlry +owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" } + +# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) +abi_stable = "0.11" diff --git a/crates/owlry-plugin-emoji/src/lib.rs b/crates/owlry-plugin-emoji/src/lib.rs new file mode 100644 index 0000000..9e08045 --- /dev/null +++ b/crates/owlry-plugin-emoji/src/lib.rs @@ -0,0 +1,565 @@ +//! Emoji Plugin for Owlry +//! +//! A static provider that provides emoji search and copy functionality. +//! Requires wl-clipboard (wl-copy) for copying to clipboard. +//! +//! Examples: +//! - Search "smile" → 😀 😃 😄 etc. +//! - Search "heart" → ❤️ 💙 💚 etc. + +use abi_stable::std_types::{ROption, RStr, RString, RVec}; +use owlry_plugin_api::{ + owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, + ProviderPosition, API_VERSION, +}; + +// Plugin metadata +const PLUGIN_ID: &str = "emoji"; +const PLUGIN_NAME: &str = "Emoji"; +const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); +const PLUGIN_DESCRIPTION: &str = "Search and copy emojis"; + +// Provider metadata +const PROVIDER_ID: &str = "emoji"; +const PROVIDER_NAME: &str = "Emoji"; +const PROVIDER_PREFIX: &str = ":emoji"; +const PROVIDER_ICON: &str = "face-smile"; +const PROVIDER_TYPE_ID: &str = "emoji"; + +/// Emoji provider state - holds cached items +struct EmojiState { + items: Vec, +} + +impl EmojiState { + fn new() -> Self { + Self { items: Vec::new() } + } + + fn load_emojis(&mut self) { + self.items.clear(); + + // Common emojis with searchable names + // Format: (emoji, name, keywords) + let emojis: &[(&str, &str, &str)] = &[ + // Smileys & Emotion + ("😀", "grinning face", "smile happy"), + ("😃", "grinning face with big eyes", "smile happy"), + ("😄", "grinning face with smiling eyes", "smile happy laugh"), + ("😁", "beaming face with smiling eyes", "smile happy grin"), + ("😅", "grinning face with sweat", "smile nervous"), + ("🤣", "rolling on the floor laughing", "lol rofl funny"), + ("😂", "face with tears of joy", "laugh cry funny lol"), + ("🙂", "slightly smiling face", "smile"), + ("😊", "smiling face with smiling eyes", "blush happy"), + ("😇", "smiling face with halo", "angel innocent"), + ("🥰", "smiling face with hearts", "love adore"), + ("😍", "smiling face with heart-eyes", "love crush"), + ("🤩", "star-struck", "excited wow amazing"), + ("😘", "face blowing a kiss", "kiss love"), + ("😜", "winking face with tongue", "playful silly"), + ("🤪", "zany face", "crazy silly wild"), + ("😎", "smiling face with sunglasses", "cool"), + ("🤓", "nerd face", "geek glasses"), + ("🧐", "face with monocle", "thinking inspect"), + ("😏", "smirking face", "smug"), + ("😒", "unamused face", "meh annoyed"), + ("🙄", "face with rolling eyes", "whatever annoyed"), + ("😬", "grimacing face", "awkward nervous"), + ("😮‍💨", "face exhaling", "sigh relief"), + ("🤥", "lying face", "pinocchio lie"), + ("😌", "relieved face", "relaxed peaceful"), + ("😔", "pensive face", "sad thoughtful"), + ("😪", "sleepy face", "tired"), + ("🤤", "drooling face", "hungry yummy"), + ("😴", "sleeping face", "zzz tired"), + ("😷", "face with medical mask", "sick covid"), + ("🤒", "face with thermometer", "sick fever"), + ("🤕", "face with head-bandage", "hurt injured"), + ("🤢", "nauseated face", "sick gross"), + ("🤮", "face vomiting", "sick puke"), + ("🤧", "sneezing face", "achoo sick"), + ("🥵", "hot face", "sweating heat"), + ("🥶", "cold face", "freezing"), + ("😵", "face with crossed-out eyes", "dizzy dead"), + ("🤯", "exploding head", "mind blown wow"), + ("🤠", "cowboy hat face", "yeehaw western"), + ("🥳", "partying face", "celebration party"), + ("🥸", "disguised face", "incognito"), + ("🤡", "clown face", "circus"), + ("👻", "ghost", "halloween spooky"), + ("💀", "skull", "dead death"), + ("☠️", "skull and crossbones", "danger death"), + ("👽", "alien", "ufo extraterrestrial"), + ("🤖", "robot", "bot android"), + ("💩", "pile of poo", "poop"), + ("😈", "smiling face with horns", "devil evil"), + ("👿", "angry face with horns", "devil evil"), + // Gestures & People + ("👋", "waving hand", "hello hi bye wave"), + ("🤚", "raised back of hand", "stop"), + ("🖐️", "hand with fingers splayed", "five high"), + ("✋", "raised hand", "stop high five"), + ("🖖", "vulcan salute", "spock trek"), + ("👌", "ok hand", "okay perfect"), + ("🤌", "pinched fingers", "italian"), + ("🤏", "pinching hand", "small tiny"), + ("✌️", "victory hand", "peace two"), + ("🤞", "crossed fingers", "luck hope"), + ("🤟", "love-you gesture", "ily rock"), + ("🤘", "sign of the horns", "rock metal"), + ("🤙", "call me hand", "shaka hang loose"), + ("👈", "backhand index pointing left", "left point"), + ("👉", "backhand index pointing right", "right point"), + ("👆", "backhand index pointing up", "up point"), + ("👇", "backhand index pointing down", "down point"), + ("☝️", "index pointing up", "one point"), + ("👍", "thumbs up", "like yes good approve"), + ("👎", "thumbs down", "dislike no bad"), + ("✊", "raised fist", "power solidarity"), + ("👊", "oncoming fist", "punch bump"), + ("🤛", "left-facing fist", "fist bump"), + ("🤜", "right-facing fist", "fist bump"), + ("👏", "clapping hands", "applause bravo"), + ("🙌", "raising hands", "hooray celebrate"), + ("👐", "open hands", "hug"), + ("🤲", "palms up together", "prayer"), + ("🤝", "handshake", "agreement deal"), + ("🙏", "folded hands", "prayer please thanks"), + ("✍️", "writing hand", "write"), + ("💪", "flexed biceps", "strong muscle"), + ("🦾", "mechanical arm", "robot prosthetic"), + ("🦵", "leg", "kick"), + ("🦶", "foot", "kick"), + ("👂", "ear", "listen hear"), + ("👃", "nose", "smell"), + ("🧠", "brain", "smart think"), + ("👀", "eyes", "look see watch"), + ("👁️", "eye", "see look"), + ("👅", "tongue", "taste lick"), + ("👄", "mouth", "lips kiss"), + // Hearts & Love + ("❤️", "red heart", "love"), + ("🧡", "orange heart", "love"), + ("💛", "yellow heart", "love friendship"), + ("💚", "green heart", "love"), + ("💙", "blue heart", "love"), + ("💜", "purple heart", "love"), + ("🖤", "black heart", "love dark"), + ("🤍", "white heart", "love pure"), + ("🤎", "brown heart", "love"), + ("💔", "broken heart", "heartbreak sad"), + ("❤️‍🔥", "heart on fire", "passion love"), + ("❤️‍🩹", "mending heart", "healing recovery"), + ("💕", "two hearts", "love"), + ("💞", "revolving hearts", "love"), + ("💓", "beating heart", "love"), + ("💗", "growing heart", "love"), + ("💖", "sparkling heart", "love"), + ("💘", "heart with arrow", "love cupid"), + ("💝", "heart with ribbon", "love gift"), + ("💟", "heart decoration", "love"), + // Animals + ("🐶", "dog face", "puppy"), + ("🐱", "cat face", "kitty"), + ("🐭", "mouse face", ""), + ("🐹", "hamster", ""), + ("🐰", "rabbit face", "bunny"), + ("🦊", "fox", ""), + ("🐻", "bear", ""), + ("🐼", "panda", ""), + ("🐨", "koala", ""), + ("🐯", "tiger face", ""), + ("🦁", "lion", ""), + ("🐮", "cow face", ""), + ("🐷", "pig face", ""), + ("🐸", "frog", ""), + ("🐵", "monkey face", ""), + ("🦄", "unicorn", "magic"), + ("🐝", "bee", "honeybee"), + ("🦋", "butterfly", ""), + ("🐌", "snail", "slow"), + ("🐛", "bug", "caterpillar"), + ("🦀", "crab", ""), + ("🐙", "octopus", ""), + ("🐠", "tropical fish", ""), + ("🐟", "fish", ""), + ("🐬", "dolphin", ""), + ("🐳", "whale", ""), + ("🦈", "shark", ""), + ("🐊", "crocodile", "alligator"), + ("🐢", "turtle", ""), + ("🦎", "lizard", ""), + ("🐍", "snake", ""), + ("🦖", "t-rex", "dinosaur"), + ("🦕", "sauropod", "dinosaur"), + ("🐔", "chicken", ""), + ("🐧", "penguin", ""), + ("🦅", "eagle", "bird"), + ("🦆", "duck", ""), + ("🦉", "owl", ""), + // Food & Drink + ("🍎", "red apple", "fruit"), + ("🍐", "pear", "fruit"), + ("🍊", "orange", "tangerine fruit"), + ("🍋", "lemon", "fruit"), + ("🍌", "banana", "fruit"), + ("🍉", "watermelon", "fruit"), + ("🍇", "grapes", "fruit"), + ("🍓", "strawberry", "fruit"), + ("🍒", "cherries", "fruit"), + ("🍑", "peach", "fruit"), + ("🥭", "mango", "fruit"), + ("🍍", "pineapple", "fruit"), + ("🥥", "coconut", "fruit"), + ("🥝", "kiwi", "fruit"), + ("🍅", "tomato", "vegetable"), + ("🥑", "avocado", ""), + ("🥦", "broccoli", "vegetable"), + ("🥬", "leafy green", "vegetable salad"), + ("🥒", "cucumber", "vegetable"), + ("🌶️", "hot pepper", "spicy chili"), + ("🌽", "corn", ""), + ("🥕", "carrot", "vegetable"), + ("🧄", "garlic", ""), + ("🧅", "onion", ""), + ("🥔", "potato", ""), + ("🍞", "bread", ""), + ("🥐", "croissant", ""), + ("🥖", "baguette", "bread french"), + ("🥨", "pretzel", ""), + ("🧀", "cheese", ""), + ("🥚", "egg", ""), + ("🍳", "cooking", "frying pan egg"), + ("🥞", "pancakes", "breakfast"), + ("🧇", "waffle", "breakfast"), + ("🥓", "bacon", "breakfast"), + ("🍔", "hamburger", "burger"), + ("🍟", "french fries", ""), + ("🍕", "pizza", ""), + ("🌭", "hot dog", ""), + ("🥪", "sandwich", ""), + ("🌮", "taco", "mexican"), + ("🌯", "burrito", "mexican"), + ("🍜", "steaming bowl", "ramen noodles"), + ("🍝", "spaghetti", "pasta"), + ("🍣", "sushi", "japanese"), + ("🍱", "bento box", "japanese"), + ("🍩", "doughnut", "donut dessert"), + ("🍪", "cookie", "dessert"), + ("🎂", "birthday cake", "dessert"), + ("🍰", "shortcake", "dessert"), + ("🧁", "cupcake", "dessert"), + ("🍫", "chocolate bar", "dessert"), + ("🍬", "candy", "sweet"), + ("🍭", "lollipop", "candy sweet"), + ("🍦", "soft ice cream", "dessert"), + ("🍨", "ice cream", "dessert"), + ("☕", "hot beverage", "coffee tea"), + ("🍵", "teacup", "tea"), + ("🧃", "juice box", ""), + ("🥤", "cup with straw", "soda drink"), + ("🍺", "beer mug", "drink alcohol"), + ("🍻", "clinking beer mugs", "cheers drink"), + ("🥂", "clinking glasses", "champagne cheers"), + ("🍷", "wine glass", "drink alcohol"), + ("🥃", "tumbler glass", "whiskey drink"), + ("🍸", "cocktail glass", "martini drink"), + // Objects & Symbols + ("💻", "laptop", "computer"), + ("🖥️", "desktop computer", "pc"), + ("⌨️", "keyboard", ""), + ("🖱️", "computer mouse", ""), + ("💾", "floppy disk", "save"), + ("💿", "optical disk", "cd"), + ("📱", "mobile phone", "smartphone"), + ("☎️", "telephone", "phone"), + ("📧", "email", "mail"), + ("📨", "incoming envelope", "email"), + ("📩", "envelope with arrow", "email send"), + ("📝", "memo", "note write"), + ("📄", "page facing up", "document"), + ("📃", "page with curl", "document"), + ("📑", "bookmark tabs", ""), + ("📚", "books", "library read"), + ("📖", "open book", "read"), + ("🔗", "link", "chain url"), + ("📎", "paperclip", "attachment"), + ("🔒", "locked", "security"), + ("🔓", "unlocked", "security open"), + ("🔑", "key", "password"), + ("🔧", "wrench", "tool fix"), + ("🔨", "hammer", "tool"), + ("⚙️", "gear", "settings"), + ("🧲", "magnet", ""), + ("💡", "light bulb", "idea"), + ("🔦", "flashlight", ""), + ("🔋", "battery", "power"), + ("🔌", "electric plug", "power"), + ("💰", "money bag", ""), + ("💵", "dollar", "money cash"), + ("💳", "credit card", "payment"), + ("⏰", "alarm clock", "time"), + ("⏱️", "stopwatch", "timer"), + ("📅", "calendar", "date"), + ("📆", "tear-off calendar", "date"), + ("✅", "check mark", "done yes"), + ("❌", "cross mark", "no wrong delete"), + ("❓", "question mark", "help"), + ("❗", "exclamation mark", "important warning"), + ("⚠️", "warning", "caution alert"), + ("🚫", "prohibited", "no ban forbidden"), + ("⭕", "hollow circle", ""), + ("🔴", "red circle", ""), + ("🟠", "orange circle", ""), + ("🟡", "yellow circle", ""), + ("🟢", "green circle", ""), + ("🔵", "blue circle", ""), + ("🟣", "purple circle", ""), + ("⚫", "black circle", ""), + ("⚪", "white circle", ""), + ("🟤", "brown circle", ""), + ("⬛", "black square", ""), + ("⬜", "white square", ""), + ("🔶", "large orange diamond", ""), + ("🔷", "large blue diamond", ""), + ("⭐", "star", "favorite"), + ("🌟", "glowing star", "sparkle"), + ("✨", "sparkles", "magic shine"), + ("💫", "dizzy", "star"), + ("🔥", "fire", "hot lit"), + ("💧", "droplet", "water"), + ("🌊", "wave", "water ocean"), + ("🎵", "musical note", "music"), + ("🎶", "musical notes", "music"), + ("🎤", "microphone", "sing karaoke"), + ("🎧", "headphones", "music"), + ("🎮", "video game", "gaming controller"), + ("🕹️", "joystick", "gaming"), + ("🎯", "direct hit", "target bullseye"), + ("🏆", "trophy", "winner award"), + ("🥇", "1st place medal", "gold winner"), + ("🥈", "2nd place medal", "silver"), + ("🥉", "3rd place medal", "bronze"), + ("🎁", "wrapped gift", "present"), + ("🎈", "balloon", "party"), + ("🎉", "party popper", "celebration tada"), + ("🎊", "confetti ball", "celebration"), + // Arrows & Misc + ("➡️", "right arrow", ""), + ("⬅️", "left arrow", ""), + ("⬆️", "up arrow", ""), + ("⬇️", "down arrow", ""), + ("↗️", "up-right arrow", ""), + ("↘️", "down-right arrow", ""), + ("↙️", "down-left arrow", ""), + ("↖️", "up-left arrow", ""), + ("↕️", "up-down arrow", ""), + ("↔️", "left-right arrow", ""), + ("🔄", "counterclockwise arrows", "refresh reload"), + ("🔃", "clockwise arrows", "refresh reload"), + ("➕", "plus", "add"), + ("➖", "minus", "subtract"), + ("➗", "division", "divide"), + ("✖️", "multiply", "times"), + ("♾️", "infinity", "forever"), + ("💯", "hundred points", "100 perfect"), + ("🆗", "ok button", "okay"), + ("🆕", "new button", ""), + ("🆓", "free button", ""), + ("ℹ️", "information", "info"), + ("🅿️", "parking", ""), + ("🚀", "rocket", "launch startup"), + ("✈️", "airplane", "travel flight"), + ("🚗", "car", "automobile"), + ("🚕", "taxi", "cab"), + ("🚌", "bus", ""), + ("🚂", "locomotive", "train"), + ("🏠", "house", "home"), + ("🏢", "office building", "work"), + ("🏥", "hospital", ""), + ("🏫", "school", ""), + ("🏛️", "classical building", ""), + ("⛪", "church", ""), + ("🕌", "mosque", ""), + ("🕍", "synagogue", ""), + ("🗽", "statue of liberty", "usa america"), + ("🗼", "tokyo tower", "japan"), + ("🗾", "map of japan", ""), + ("🌍", "globe europe-africa", "earth world"), + ("🌎", "globe americas", "earth world"), + ("🌏", "globe asia-australia", "earth world"), + ("🌑", "new moon", ""), + ("🌕", "full moon", ""), + ("☀️", "sun", "sunny"), + ("🌙", "crescent moon", "night"), + ("☁️", "cloud", ""), + ("🌧️", "cloud with rain", "rainy"), + ("⛈️", "cloud with lightning", "storm thunder"), + ("🌈", "rainbow", ""), + ("❄️", "snowflake", "cold winter"), + ("☃️", "snowman", "winter"), + ("🎄", "christmas tree", "xmas holiday"), + ("🎃", "jack-o-lantern", "halloween pumpkin"), + ("🐚", "shell", "beach"), + ("🌸", "cherry blossom", "flower spring"), + ("🌺", "hibiscus", "flower"), + ("🌻", "sunflower", "flower"), + ("🌹", "rose", "flower love"), + ("🌷", "tulip", "flower"), + ("🌱", "seedling", "plant grow"), + ("🌲", "evergreen tree", ""), + ("🌳", "deciduous tree", ""), + ("🌴", "palm tree", "tropical"), + ("🌵", "cactus", "desert"), + ("🍀", "four leaf clover", "luck irish"), + ("🍁", "maple leaf", "fall autumn canada"), + ("🍂", "fallen leaf", "fall autumn"), + ]; + + for (emoji, name, keywords) in emojis { + self.items.push( + PluginItem::new( + format!("emoji:{}", emoji), + name.to_string(), + format!("printf '%s' '{}' | wl-copy", emoji), + ) + .with_icon(*emoji) // Use emoji character as icon + .with_description(format!("{} {}", emoji, keywords)) + .with_keywords(vec![name.to_string(), keywords.to_string()]), + ); + } + } +} + +// ============================================================================ +// Plugin Interface Implementation +// ============================================================================ + +extern "C" fn plugin_info() -> PluginInfo { + PluginInfo { + id: RString::from(PLUGIN_ID), + name: RString::from(PLUGIN_NAME), + version: RString::from(PLUGIN_VERSION), + description: RString::from(PLUGIN_DESCRIPTION), + api_version: API_VERSION, + } +} + +extern "C" fn plugin_providers() -> RVec { + vec![ProviderInfo { + id: RString::from(PROVIDER_ID), + name: RString::from(PROVIDER_NAME), + prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)), + icon: RString::from(PROVIDER_ICON), + provider_type: ProviderKind::Static, + type_id: RString::from(PROVIDER_TYPE_ID), + position: ProviderPosition::Normal, + priority: 0, // Static: use frecency ordering + }] + .into() +} + +extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { + let state = Box::new(EmojiState::new()); + ProviderHandle::from_box(state) +} + +extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { + if handle.ptr.is_null() { + return RVec::new(); + } + + // SAFETY: We created this handle from Box + let state = unsafe { &mut *(handle.ptr as *mut EmojiState) }; + + // Load emojis + state.load_emojis(); + + // Return items + state.items.to_vec().into() +} + +extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec { + // Static provider - query is handled by the core using cached items + RVec::new() +} + +extern "C" fn provider_drop(handle: ProviderHandle) { + if !handle.ptr.is_null() { + // SAFETY: We created this handle from Box + unsafe { + handle.drop_as::(); + } + } +} + +// Register the plugin vtable +owlry_plugin! { + info: plugin_info, + providers: plugin_providers, + init: provider_init, + refresh: provider_refresh, + query: provider_query, + drop: provider_drop, +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_emoji_state_new() { + let state = EmojiState::new(); + assert!(state.items.is_empty()); + } + + #[test] + fn test_emoji_count() { + let mut state = EmojiState::new(); + state.load_emojis(); + assert!(state.items.len() > 100, "Should have more than 100 emojis"); + } + + #[test] + fn test_emoji_has_grinning_face() { + let mut state = EmojiState::new(); + state.load_emojis(); + + let grinning = state + .items + .iter() + .find(|i| i.name.as_str() == "grinning face"); + assert!(grinning.is_some()); + + let item = grinning.unwrap(); + assert!(item.description.as_ref().unwrap().as_str().contains("😀")); + } + + #[test] + fn test_emoji_command_format() { + let mut state = EmojiState::new(); + state.load_emojis(); + + let item = &state.items[0]; + assert!(item.command.as_str().contains("wl-copy")); + assert!(item.command.as_str().contains("printf")); + } + + #[test] + fn test_emojis_have_keywords() { + let mut state = EmojiState::new(); + state.load_emojis(); + + // Check that items have keywords for searching + let heart = state + .items + .iter() + .find(|i| i.name.as_str() == "red heart"); + assert!(heart.is_some()); + } +} diff --git a/crates/owlry-plugin-filesearch/Cargo.toml b/crates/owlry-plugin-filesearch/Cargo.toml new file mode 100644 index 0000000..22561de --- /dev/null +++ b/crates/owlry-plugin-filesearch/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "owlry-plugin-filesearch" +version = "0.4.10" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "File search plugin for owlry - find files with fd or locate" +keywords = ["owlry", "plugin", "files", "search"] +categories = ["filesystem"] + +[lib] +crate-type = ["cdylib"] # Compile as dynamic library (.so) + +[dependencies] +# Plugin API for owlry +owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" } + +# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) +abi_stable = "0.11" + +# For finding home directory +dirs = "5.0" diff --git a/crates/owlry-plugin-filesearch/src/lib.rs b/crates/owlry-plugin-filesearch/src/lib.rs new file mode 100644 index 0000000..9eca26a --- /dev/null +++ b/crates/owlry-plugin-filesearch/src/lib.rs @@ -0,0 +1,322 @@ +//! File Search Plugin for Owlry +//! +//! A dynamic provider that searches for files using `fd` or `locate`. +//! +//! Examples: +//! - `/ config.toml` → Search for files matching "config.toml" +//! - `file bashrc` → Search for files matching "bashrc" +//! - `find readme` → Search for files matching "readme" +//! +//! Dependencies: +//! - fd (preferred) or locate + +use abi_stable::std_types::{ROption, RStr, RString, RVec}; +use owlry_plugin_api::{ + owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, + ProviderPosition, API_VERSION, +}; +use std::path::Path; +use std::process::Command; + +// Plugin metadata +const PLUGIN_ID: &str = "filesearch"; +const PLUGIN_NAME: &str = "File Search"; +const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); +const PLUGIN_DESCRIPTION: &str = "Find files with fd or locate"; + +// Provider metadata +const PROVIDER_ID: &str = "filesearch"; +const PROVIDER_NAME: &str = "Files"; +const PROVIDER_PREFIX: &str = "/"; +const PROVIDER_ICON: &str = "folder"; +const PROVIDER_TYPE_ID: &str = "filesearch"; + +// Maximum results to return +const MAX_RESULTS: usize = 20; + +#[derive(Debug, Clone, Copy)] +enum SearchTool { + Fd, + Locate, + None, +} + +/// File search provider state +struct FileSearchState { + search_tool: SearchTool, + home: String, +} + +impl FileSearchState { + fn new() -> Self { + let search_tool = Self::detect_search_tool(); + let home = dirs::home_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "/".to_string()); + + Self { search_tool, home } + } + + fn detect_search_tool() -> SearchTool { + // Prefer fd (faster, respects .gitignore) + if Self::command_exists("fd") { + return SearchTool::Fd; + } + // Fall back to locate (requires updatedb) + if Self::command_exists("locate") { + return SearchTool::Locate; + } + SearchTool::None + } + + fn command_exists(cmd: &str) -> bool { + Command::new("which") + .arg(cmd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + /// Extract the search term from the query + fn extract_search_term(query: &str) -> Option<&str> { + let trimmed = query.trim(); + + if let Some(rest) = trimmed.strip_prefix("/ ") { + Some(rest.trim()) + } else if let Some(rest) = trimmed.strip_prefix("/") { + Some(rest.trim()) + } else { + // Handle "file " and "find " prefixes (case-insensitive), or raw query in filter mode + let lower = trimmed.to_lowercase(); + if lower.starts_with("file ") || lower.starts_with("find ") { + Some(trimmed[5..].trim()) + } else { + Some(trimmed) + } + } + } + + /// Evaluate a query and return file results + fn evaluate(&self, query: &str) -> Vec { + let search_term = match Self::extract_search_term(query) { + Some(t) if !t.is_empty() => t, + _ => return Vec::new(), + }; + + self.search_files(search_term) + } + + fn search_files(&self, pattern: &str) -> Vec { + match self.search_tool { + SearchTool::Fd => self.search_with_fd(pattern), + SearchTool::Locate => self.search_with_locate(pattern), + SearchTool::None => Vec::new(), + } + } + + fn search_with_fd(&self, pattern: &str) -> Vec { + let output = match Command::new("fd") + .args([ + "--max-results", + &MAX_RESULTS.to_string(), + "--type", + "f", // Files only + "--type", + "d", // And directories + pattern, + ]) + .current_dir(&self.home) + .output() + { + Ok(o) => o, + Err(_) => return Vec::new(), + }; + + self.parse_file_results(&String::from_utf8_lossy(&output.stdout)) + } + + fn search_with_locate(&self, pattern: &str) -> Vec { + let output = match Command::new("locate") + .args([ + "--limit", + &MAX_RESULTS.to_string(), + "--ignore-case", + pattern, + ]) + .output() + { + Ok(o) => o, + Err(_) => return Vec::new(), + }; + + self.parse_file_results(&String::from_utf8_lossy(&output.stdout)) + } + + fn parse_file_results(&self, output: &str) -> Vec { + output + .lines() + .filter(|line| !line.is_empty()) + .map(|path| { + let path = path.trim(); + let full_path = if path.starts_with('/') { + path.to_string() + } else { + format!("{}/{}", self.home, path) + }; + + // Get filename for display + let filename = Path::new(&full_path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| full_path.clone()); + + // Determine icon based on whether it's a directory + let is_dir = Path::new(&full_path).is_dir(); + let icon = if is_dir { "folder" } else { "text-x-generic" }; + + // Command to open with xdg-open + let command = format!("xdg-open '{}'", full_path.replace('\'', "'\\''")); + + PluginItem::new(format!("file:{}", full_path), filename, command) + .with_description(full_path.clone()) + .with_icon(icon) + .with_keywords(vec!["file".to_string()]) + }) + .collect() + } +} + +// ============================================================================ +// Plugin Interface Implementation +// ============================================================================ + +extern "C" fn plugin_info() -> PluginInfo { + PluginInfo { + id: RString::from(PLUGIN_ID), + name: RString::from(PLUGIN_NAME), + version: RString::from(PLUGIN_VERSION), + description: RString::from(PLUGIN_DESCRIPTION), + api_version: API_VERSION, + } +} + +extern "C" fn plugin_providers() -> RVec { + vec![ProviderInfo { + id: RString::from(PROVIDER_ID), + name: RString::from(PROVIDER_NAME), + prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)), + icon: RString::from(PROVIDER_ICON), + provider_type: ProviderKind::Dynamic, + type_id: RString::from(PROVIDER_TYPE_ID), + position: ProviderPosition::Normal, + priority: 8000, // Dynamic: file search + }] + .into() +} + +extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { + let state = Box::new(FileSearchState::new()); + ProviderHandle::from_box(state) +} + +extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec { + // Dynamic provider - refresh does nothing + RVec::new() +} + +extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec { + if handle.ptr.is_null() { + return RVec::new(); + } + + // SAFETY: We created this handle from Box + let state = unsafe { &*(handle.ptr as *const FileSearchState) }; + + let query_str = query.as_str(); + + state.evaluate(query_str).into() +} + +extern "C" fn provider_drop(handle: ProviderHandle) { + if !handle.ptr.is_null() { + // SAFETY: We created this handle from Box + unsafe { + handle.drop_as::(); + } + } +} + +// Register the plugin vtable +owlry_plugin! { + info: plugin_info, + providers: plugin_providers, + init: provider_init, + refresh: provider_refresh, + query: provider_query, + drop: provider_drop, +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_search_term() { + assert_eq!( + FileSearchState::extract_search_term("/ config.toml"), + Some("config.toml") + ); + assert_eq!( + FileSearchState::extract_search_term("/config"), + Some("config") + ); + assert_eq!( + FileSearchState::extract_search_term("file bashrc"), + Some("bashrc") + ); + assert_eq!( + FileSearchState::extract_search_term("find readme"), + Some("readme") + ); + } + + #[test] + fn test_extract_search_term_empty() { + assert_eq!(FileSearchState::extract_search_term("/"), Some("")); + assert_eq!(FileSearchState::extract_search_term("/ "), Some("")); + } + + #[test] + fn test_command_exists() { + // 'which' should exist on any Unix system + assert!(FileSearchState::command_exists("which")); + // This should not exist + assert!(!FileSearchState::command_exists("nonexistent-command-12345")); + } + + #[test] + fn test_detect_search_tool() { + // Just ensure it doesn't panic + let _ = FileSearchState::detect_search_tool(); + } + + #[test] + fn test_state_new() { + let state = FileSearchState::new(); + assert!(!state.home.is_empty()); + } + + #[test] + fn test_evaluate_empty() { + let state = FileSearchState::new(); + let results = state.evaluate("/"); + assert!(results.is_empty()); + + let results = state.evaluate("/ "); + assert!(results.is_empty()); + } +} diff --git a/crates/owlry-plugin-media/Cargo.toml b/crates/owlry-plugin-media/Cargo.toml new file mode 100644 index 0000000..bfcc8b7 --- /dev/null +++ b/crates/owlry-plugin-media/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "owlry-plugin-media" +version = "0.4.10" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "MPRIS media player widget plugin for owlry - shows and controls currently playing media. Requires playerctl." +keywords = ["owlry", "plugin", "media", "mpris", "widget", "playerctl"] +categories = ["gui"] + +# System dependencies (for packagers): +# - playerctl: for media control commands + +[lib] +crate-type = ["cdylib"] # Compile as dynamic library (.so) + +[dependencies] +# Plugin API for owlry +owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" } + +# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) +abi_stable = "0.11" diff --git a/crates/owlry-plugin-media/src/lib.rs b/crates/owlry-plugin-media/src/lib.rs new file mode 100644 index 0000000..0b064c2 --- /dev/null +++ b/crates/owlry-plugin-media/src/lib.rs @@ -0,0 +1,468 @@ +//! MPRIS Media Player Widget Plugin for Owlry +//! +//! Shows currently playing track as a single row with play/pause action. +//! Uses D-Bus via dbus-send to communicate with MPRIS-compatible players. + +use abi_stable::std_types::{ROption, RStr, RString, RVec}; +use owlry_plugin_api::{ + owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, + ProviderPosition, API_VERSION, +}; +use std::process::Command; + +// Plugin metadata +const PLUGIN_ID: &str = "media"; +const PLUGIN_NAME: &str = "Media Player"; +const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); +const PLUGIN_DESCRIPTION: &str = "MPRIS media player widget - shows and controls currently playing media"; + +// Provider metadata +const PROVIDER_ID: &str = "media"; +const PROVIDER_NAME: &str = "Media"; +const PROVIDER_ICON: &str = "applications-multimedia"; +const PROVIDER_TYPE_ID: &str = "media"; + +#[derive(Debug, Default, Clone)] +struct MediaState { + player_name: String, + title: String, + artist: String, + is_playing: bool, +} + +/// Media provider state +struct MediaProviderState { + items: Vec, + /// Current player name for submenu actions + current_player: Option, + /// Current playback state + is_playing: bool, +} + +impl MediaProviderState { + fn new() -> Self { + // Don't query D-Bus during init - defer to first refresh() call + // This prevents blocking the main thread during startup + Self { + items: Vec::new(), + current_player: None, + is_playing: false, + } + } + + fn refresh(&mut self) { + self.items.clear(); + + let players = Self::find_players(); + if players.is_empty() { + return; + } + + // Find first active player + for player in &players { + if let Some(state) = Self::get_player_state(player) { + self.generate_items(&state); + return; + } + } + } + + /// Find active MPRIS players via dbus-send + fn find_players() -> Vec { + let output = Command::new("dbus-send") + .args([ + "--session", + "--dest=org.freedesktop.DBus", + "--type=method_call", + "--print-reply", + "/org/freedesktop/DBus", + "org.freedesktop.DBus.ListNames", + ]) + .output(); + + match output { + Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout); + stdout + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.starts_with("string \"org.mpris.MediaPlayer2.") { + let start = "string \"org.mpris.MediaPlayer2.".len(); + let end = trimmed.len() - 1; + Some(trimmed[start..end].to_string()) + } else { + None + } + }) + .collect() + } + Err(_) => Vec::new(), + } + } + + /// Get metadata from an MPRIS player + fn get_player_state(player: &str) -> Option { + let dest = format!("org.mpris.MediaPlayer2.{}", player); + + // Get playback status + let status_output = Command::new("dbus-send") + .args([ + "--session", + &format!("--dest={}", dest), + "--type=method_call", + "--print-reply", + "/org/mpris/MediaPlayer2", + "org.freedesktop.DBus.Properties.Get", + "string:org.mpris.MediaPlayer2.Player", + "string:PlaybackStatus", + ]) + .output() + .ok()?; + + let status_str = String::from_utf8_lossy(&status_output.stdout); + let is_playing = status_str.contains("\"Playing\""); + let is_paused = status_str.contains("\"Paused\""); + + // Only show if playing or paused (not stopped) + if !is_playing && !is_paused { + return None; + } + + // Get metadata + let metadata_output = Command::new("dbus-send") + .args([ + "--session", + &format!("--dest={}", dest), + "--type=method_call", + "--print-reply", + "/org/mpris/MediaPlayer2", + "org.freedesktop.DBus.Properties.Get", + "string:org.mpris.MediaPlayer2.Player", + "string:Metadata", + ]) + .output() + .ok()?; + + let metadata_str = String::from_utf8_lossy(&metadata_output.stdout); + + let title = Self::extract_string(&metadata_str, "xesam:title") + .unwrap_or_else(|| "Unknown".to_string()); + let artist = Self::extract_array(&metadata_str, "xesam:artist") + .unwrap_or_else(|| "Unknown".to_string()); + + Some(MediaState { + player_name: player.to_string(), + title, + artist, + is_playing, + }) + } + + /// Extract string value from D-Bus output + fn extract_string(output: &str, key: &str) -> Option { + let key_pattern = format!("\"{}\"", key); + let mut found = false; + + for line in output.lines() { + let trimmed = line.trim(); + if trimmed.contains(&key_pattern) { + found = true; + continue; + } + if found { + if let Some(pos) = trimmed.find("string \"") { + let start = pos + "string \"".len(); + if let Some(end) = trimmed[start..].find('"') { + let value = &trimmed[start..start + end]; + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + if !trimmed.starts_with("variant") { + found = false; + } + } + } + None + } + + /// Extract array value from D-Bus output + fn extract_array(output: &str, key: &str) -> Option { + let key_pattern = format!("\"{}\"", key); + let mut found = false; + let mut in_array = false; + let mut values = Vec::new(); + + for line in output.lines() { + let trimmed = line.trim(); + if trimmed.contains(&key_pattern) { + found = true; + continue; + } + if found && trimmed.contains("array [") { + in_array = true; + continue; + } + if in_array { + if let Some(pos) = trimmed.find("string \"") { + let start = pos + "string \"".len(); + if let Some(end) = trimmed[start..].find('"') { + values.push(trimmed[start..start + end].to_string()); + } + } + if trimmed.contains(']') { + break; + } + } + } + + if values.is_empty() { + None + } else { + Some(values.join(", ")) + } + } + + /// Generate single LaunchItem for media state (opens submenu) + fn generate_items(&mut self, state: &MediaState) { + self.items.clear(); + + // Store state for submenu + self.current_player = Some(state.player_name.clone()); + self.is_playing = state.is_playing; + + // Single row: "Title — Artist" + let name = format!("{} — {}", state.title, state.artist); + + // Extract player display name (e.g., "firefox.instance_1_94" -> "Firefox") + let player_display = Self::format_player_name(&state.player_name); + + // Opens submenu with media controls + self.items.push( + PluginItem::new("media-now-playing", name, "SUBMENU:media:controls") + .with_description(format!("{} · Select for controls", player_display)) + .with_icon("/org/owlry/launcher/icons/media/music-note.svg") + .with_keywords(vec!["media".to_string(), "widget".to_string()]), + ); + } + + /// Format player name for display + fn format_player_name(player_name: &str) -> String { + let player_display = player_name.split('.').next().unwrap_or(player_name); + if player_display.is_empty() { + "Player".to_string() + } else { + let mut chars = player_display.chars(); + match chars.next() { + None => "Player".to_string(), + Some(first) => first.to_uppercase().chain(chars).collect(), + } + } + } + + /// Generate submenu items for media controls + fn generate_submenu_items(&self) -> Vec { + let player = match &self.current_player { + Some(p) => p, + None => return Vec::new(), + }; + + let mut items = Vec::new(); + + // Use playerctl for simpler, more reliable media control + // playerctl -p + + // Play/Pause + if self.is_playing { + items.push( + PluginItem::new( + "media-pause", + "Pause", + format!("playerctl -p {} pause", player), + ) + .with_description("Pause playback") + .with_icon("media-playback-pause"), + ); + } else { + items.push( + PluginItem::new( + "media-play", + "Play", + format!("playerctl -p {} play", player), + ) + .with_description("Resume playback") + .with_icon("media-playback-start"), + ); + } + + // Next track + items.push( + PluginItem::new( + "media-next", + "Next", + format!("playerctl -p {} next", player), + ) + .with_description("Skip to next track") + .with_icon("media-skip-forward"), + ); + + // Previous track + items.push( + PluginItem::new( + "media-previous", + "Previous", + format!("playerctl -p {} previous", player), + ) + .with_description("Go to previous track") + .with_icon("media-skip-backward"), + ); + + // Stop + items.push( + PluginItem::new( + "media-stop", + "Stop", + format!("playerctl -p {} stop", player), + ) + .with_description("Stop playback") + .with_icon("media-playback-stop"), + ); + + items + } +} + +// ============================================================================ +// Plugin Interface Implementation +// ============================================================================ + +extern "C" fn plugin_info() -> PluginInfo { + PluginInfo { + id: RString::from(PLUGIN_ID), + name: RString::from(PLUGIN_NAME), + version: RString::from(PLUGIN_VERSION), + description: RString::from(PLUGIN_DESCRIPTION), + api_version: API_VERSION, + } +} + +extern "C" fn plugin_providers() -> RVec { + vec![ProviderInfo { + id: RString::from(PROVIDER_ID), + name: RString::from(PROVIDER_NAME), + prefix: ROption::RNone, + icon: RString::from(PROVIDER_ICON), + provider_type: ProviderKind::Static, + type_id: RString::from(PROVIDER_TYPE_ID), + position: ProviderPosition::Widget, + priority: 11000, // Widget: media player + }] + .into() +} + +extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { + let state = Box::new(MediaProviderState::new()); + ProviderHandle::from_box(state) +} + +extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { + if handle.ptr.is_null() { + return RVec::new(); + } + + // SAFETY: We created this handle from Box + let state = unsafe { &mut *(handle.ptr as *mut MediaProviderState) }; + + state.refresh(); + state.items.clone().into() +} + +extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec { + if handle.ptr.is_null() { + return RVec::new(); + } + + let query_str = query.as_str(); + let state = unsafe { &*(handle.ptr as *const MediaProviderState) }; + + // Handle submenu request + if query_str == "?SUBMENU:controls" { + return state.generate_submenu_items().into(); + } + + RVec::new() +} + +extern "C" fn provider_drop(handle: ProviderHandle) { + if !handle.ptr.is_null() { + // SAFETY: We created this handle from Box + unsafe { + handle.drop_as::(); + } + } +} + +// Register the plugin vtable +owlry_plugin! { + info: plugin_info, + providers: plugin_providers, + init: provider_init, + refresh: provider_refresh, + query: provider_query, + drop: provider_drop, +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_string() { + let output = r#" + string "xesam:title" + variant string "My Song Title" + "#; + assert_eq!( + MediaProviderState::extract_string(output, "xesam:title"), + Some("My Song Title".to_string()) + ); + } + + #[test] + fn test_extract_array() { + let output = r#" + string "xesam:artist" + variant array [ + string "Artist One" + string "Artist Two" + ] + "#; + assert_eq!( + MediaProviderState::extract_array(output, "xesam:artist"), + Some("Artist One, Artist Two".to_string()) + ); + } + + #[test] + fn test_extract_string_not_found() { + let output = "some other output"; + assert_eq!( + MediaProviderState::extract_string(output, "xesam:title"), + None + ); + } + + #[test] + fn test_find_players_empty() { + // This will return empty on systems without D-Bus + let players = MediaProviderState::find_players(); + // Just verify it doesn't panic + let _ = players; + } +} diff --git a/crates/owlry-plugin-pomodoro/Cargo.toml b/crates/owlry-plugin-pomodoro/Cargo.toml new file mode 100644 index 0000000..25036ea --- /dev/null +++ b/crates/owlry-plugin-pomodoro/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "owlry-plugin-pomodoro" +version = "0.4.10" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Pomodoro timer widget plugin for owlry - work/break cycles with persistent state" +keywords = ["owlry", "plugin", "pomodoro", "timer", "widget"] +categories = ["gui"] + +[lib] +crate-type = ["cdylib"] # Compile as dynamic library (.so) + +[dependencies] +# Plugin API for owlry +owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" } + +# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) +abi_stable = "0.11" + +# JSON serialization for persistent state +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# TOML config parsing +toml = "0.8" + +# For finding data directory +dirs = "5.0" diff --git a/crates/owlry-plugin-pomodoro/src/lib.rs b/crates/owlry-plugin-pomodoro/src/lib.rs new file mode 100644 index 0000000..85f4af7 --- /dev/null +++ b/crates/owlry-plugin-pomodoro/src/lib.rs @@ -0,0 +1,478 @@ +//! Pomodoro Timer Widget Plugin for Owlry +//! +//! Shows timer with work/break cycles. Select to open controls submenu. +//! State persists across sessions via JSON file. +//! +//! ## Configuration +//! +//! Configure via `~/.config/owlry/config.toml`: +//! +//! ```toml +//! [plugins.pomodoro] +//! work_mins = 25 # Work session duration (default: 25) +//! break_mins = 5 # Break duration (default: 5) +//! ``` + +use abi_stable::std_types::{ROption, RStr, RString, RVec}; +use owlry_plugin_api::{ + notify_with_urgency, owlry_plugin, NotifyUrgency, PluginInfo, PluginItem, ProviderHandle, + ProviderInfo, ProviderKind, ProviderPosition, API_VERSION, +}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +// Plugin metadata +const PLUGIN_ID: &str = "pomodoro"; +const PLUGIN_NAME: &str = "Pomodoro Timer"; +const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); +const PLUGIN_DESCRIPTION: &str = "Pomodoro timer widget with work/break cycles"; + +// Provider metadata +const PROVIDER_ID: &str = "pomodoro"; +const PROVIDER_NAME: &str = "Pomodoro"; +const PROVIDER_ICON: &str = "alarm"; +const PROVIDER_TYPE_ID: &str = "pomodoro"; + +// Default timing (in minutes) +const DEFAULT_WORK_MINS: u32 = 25; +const DEFAULT_BREAK_MINS: u32 = 5; + +/// Pomodoro configuration +#[derive(Debug, Clone)] +struct PomodoroConfig { + work_mins: u32, + break_mins: u32, +} + +impl PomodoroConfig { + /// Load config from ~/.config/owlry/config.toml + /// + /// Reads from [plugins.pomodoro] section, with fallback to [providers] for compatibility. + fn load() -> Self { + let config_path = dirs::config_dir() + .map(|d| d.join("owlry").join("config.toml")); + + let config_content = config_path + .and_then(|p| fs::read_to_string(p).ok()); + + if let Some(content) = config_content + && let Ok(toml) = content.parse::() + { + // Try [plugins.pomodoro] first (new format) + if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table()) + && let Some(pomodoro) = plugins.get("pomodoro").and_then(|v| v.as_table()) + { + return Self::from_toml_table(pomodoro); + } + + // Fallback to [providers] section (old format) + if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) { + let work_mins = providers + .get("pomodoro_work_mins") + .and_then(|v| v.as_integer()) + .map(|v| v as u32) + .unwrap_or(DEFAULT_WORK_MINS); + + let break_mins = providers + .get("pomodoro_break_mins") + .and_then(|v| v.as_integer()) + .map(|v| v as u32) + .unwrap_or(DEFAULT_BREAK_MINS); + + return Self { work_mins, break_mins }; + } + } + + // Default config + Self { + work_mins: DEFAULT_WORK_MINS, + break_mins: DEFAULT_BREAK_MINS, + } + } + + /// Parse config from a TOML table + fn from_toml_table(table: &toml::Table) -> Self { + let work_mins = table + .get("work_mins") + .and_then(|v| v.as_integer()) + .map(|v| v as u32) + .unwrap_or(DEFAULT_WORK_MINS); + + let break_mins = table + .get("break_mins") + .and_then(|v| v.as_integer()) + .map(|v| v as u32) + .unwrap_or(DEFAULT_BREAK_MINS); + + Self { work_mins, break_mins } + } +} + +/// Timer phase +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)] +enum PomodoroPhase { + #[default] + Idle, + Working, + WorkPaused, + Break, + BreakPaused, +} + +/// Persistent state (saved to disk) +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct PomodoroState { + phase: PomodoroPhase, + remaining_secs: u32, + sessions: u32, + last_update: u64, +} + +/// Pomodoro provider state +struct PomodoroProviderState { + items: Vec, + state: PomodoroState, + work_mins: u32, + break_mins: u32, +} + +impl PomodoroProviderState { + fn new() -> Self { + let config = PomodoroConfig::load(); + + let state = Self::load_state().unwrap_or_else(|| PomodoroState { + phase: PomodoroPhase::Idle, + remaining_secs: config.work_mins * 60, + sessions: 0, + last_update: Self::now_secs(), + }); + + let mut provider = Self { + items: Vec::new(), + state, + work_mins: config.work_mins, + break_mins: config.break_mins, + }; + + provider.update_elapsed_time(); + provider.generate_items(); + provider + } + + fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + } + + fn data_dir() -> Option { + dirs::data_dir().map(|d| d.join("owlry")) + } + + fn load_state() -> Option { + let path = Self::data_dir()?.join("pomodoro.json"); + let content = fs::read_to_string(&path).ok()?; + serde_json::from_str(&content).ok() + } + + fn save_state(&self) { + if let Some(data_dir) = Self::data_dir() { + let path = data_dir.join("pomodoro.json"); + if fs::create_dir_all(&data_dir).is_err() { + return; + } + let mut state = self.state.clone(); + state.last_update = Self::now_secs(); + if let Ok(json) = serde_json::to_string_pretty(&state) { + let _ = fs::write(&path, json); + } + } + } + + fn update_elapsed_time(&mut self) { + let now = Self::now_secs(); + let elapsed = now.saturating_sub(self.state.last_update); + + match self.state.phase { + PomodoroPhase::Working | PomodoroPhase::Break => { + if elapsed >= self.state.remaining_secs as u64 { + self.complete_phase(); + } else { + self.state.remaining_secs -= elapsed as u32; + } + } + _ => {} + } + self.state.last_update = now; + } + + fn complete_phase(&mut self) { + match self.state.phase { + PomodoroPhase::Working => { + self.state.sessions += 1; + self.state.phase = PomodoroPhase::Break; + self.state.remaining_secs = self.break_mins * 60; + notify_with_urgency( + "Pomodoro Complete!", + &format!( + "Great work! Session {} complete. Time for a {}-minute break.", + self.state.sessions, self.break_mins + ), + "alarm", + NotifyUrgency::Normal, + ); + } + PomodoroPhase::Break => { + self.state.phase = PomodoroPhase::Idle; + self.state.remaining_secs = self.work_mins * 60; + notify_with_urgency( + "Break Complete", + "Break time's over! Ready for another work session?", + "alarm", + NotifyUrgency::Normal, + ); + } + _ => {} + } + self.save_state(); + } + + fn refresh(&mut self) { + self.update_elapsed_time(); + self.generate_items(); + } + + fn handle_action(&mut self, action: &str) { + match action { + "start" => { + self.state.phase = PomodoroPhase::Working; + self.state.remaining_secs = self.work_mins * 60; + self.state.last_update = Self::now_secs(); + } + "pause" => match self.state.phase { + PomodoroPhase::Working => self.state.phase = PomodoroPhase::WorkPaused, + PomodoroPhase::Break => self.state.phase = PomodoroPhase::BreakPaused, + _ => {} + }, + "resume" => { + self.state.last_update = Self::now_secs(); + match self.state.phase { + PomodoroPhase::WorkPaused => self.state.phase = PomodoroPhase::Working, + PomodoroPhase::BreakPaused => self.state.phase = PomodoroPhase::Break, + _ => {} + } + } + "skip" => self.complete_phase(), + "reset" => { + self.state.phase = PomodoroPhase::Idle; + self.state.remaining_secs = self.work_mins * 60; + self.state.sessions = 0; + } + _ => {} + } + self.save_state(); + self.generate_items(); + } + + fn format_time(secs: u32) -> String { + let mins = secs / 60; + let secs = secs % 60; + format!("{:02}:{:02}", mins, secs) + } + + /// Generate single main item with submenu for controls + fn generate_items(&mut self) { + self.items.clear(); + + let (phase_name, _is_running) = match self.state.phase { + PomodoroPhase::Idle => ("Ready", false), + PomodoroPhase::Working => ("Work", true), + PomodoroPhase::WorkPaused => ("Paused", false), + PomodoroPhase::Break => ("Break", true), + PomodoroPhase::BreakPaused => ("Paused", false), + }; + + let time_str = Self::format_time(self.state.remaining_secs); + let name = format!("{}: {}", phase_name, time_str); + + let description = if self.state.sessions > 0 { + format!( + "Sessions: {} | {}min work / {}min break", + self.state.sessions, self.work_mins, self.break_mins + ) + } else { + format!("{}min work / {}min break", self.work_mins, self.break_mins) + }; + + // Single item that opens submenu with controls + self.items.push( + PluginItem::new("pomo-timer", name, "SUBMENU:pomodoro:controls") + .with_description(description) + .with_icon("/org/owlry/launcher/icons/pomodoro/tomato.svg") + .with_keywords(vec![ + "pomodoro".to_string(), + "widget".to_string(), + "timer".to_string(), + ]), + ); + } + + /// Generate submenu items for controls + fn generate_submenu_items(&self) -> Vec { + let mut items = Vec::new(); + let is_running = matches!( + self.state.phase, + PomodoroPhase::Working | PomodoroPhase::Break + ); + + // Primary control: Start/Pause/Resume + if is_running { + items.push( + PluginItem::new("pomo-pause", "Pause", "POMODORO:pause") + .with_description("Pause the timer") + .with_icon("media-playback-pause"), + ); + } else { + match self.state.phase { + PomodoroPhase::Idle => { + items.push( + PluginItem::new("pomo-start", "Start Work", "POMODORO:start") + .with_description("Start a new work session") + .with_icon("media-playback-start"), + ); + } + _ => { + items.push( + PluginItem::new("pomo-resume", "Resume", "POMODORO:resume") + .with_description("Resume the timer") + .with_icon("media-playback-start"), + ); + } + } + } + + // Skip (only when not idle) + if self.state.phase != PomodoroPhase::Idle { + items.push( + PluginItem::new("pomo-skip", "Skip", "POMODORO:skip") + .with_description("Skip to next phase") + .with_icon("media-skip-forward"), + ); + } + + // Reset + items.push( + PluginItem::new("pomo-reset", "Reset", "POMODORO:reset") + .with_description("Reset timer and sessions") + .with_icon("view-refresh"), + ); + + items + } +} + +// ============================================================================ +// Plugin Interface Implementation +// ============================================================================ + +extern "C" fn plugin_info() -> PluginInfo { + PluginInfo { + id: RString::from(PLUGIN_ID), + name: RString::from(PLUGIN_NAME), + version: RString::from(PLUGIN_VERSION), + description: RString::from(PLUGIN_DESCRIPTION), + api_version: API_VERSION, + } +} + +extern "C" fn plugin_providers() -> RVec { + vec![ProviderInfo { + id: RString::from(PROVIDER_ID), + name: RString::from(PROVIDER_NAME), + prefix: ROption::RNone, + icon: RString::from(PROVIDER_ICON), + provider_type: ProviderKind::Static, + type_id: RString::from(PROVIDER_TYPE_ID), + position: ProviderPosition::Widget, + priority: 11500, // Widget: pomodoro timer + }] + .into() +} + +extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { + let state = Box::new(PomodoroProviderState::new()); + ProviderHandle::from_box(state) +} + +extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { + if handle.ptr.is_null() { + return RVec::new(); + } + + let state = unsafe { &mut *(handle.ptr as *mut PomodoroProviderState) }; + state.refresh(); + state.items.clone().into() +} + +extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec { + if handle.ptr.is_null() { + return RVec::new(); + } + + let query_str = query.as_str(); + let state = unsafe { &mut *(handle.ptr as *mut PomodoroProviderState) }; + + // Handle submenu request + if query_str == "?SUBMENU:controls" { + return state.generate_submenu_items().into(); + } + + // Handle action commands + if let Some(action) = query_str.strip_prefix("!POMODORO:") { + state.handle_action(action); + } + + RVec::new() +} + +extern "C" fn provider_drop(handle: ProviderHandle) { + if !handle.ptr.is_null() { + let state = unsafe { &*(handle.ptr as *const PomodoroProviderState) }; + state.save_state(); + unsafe { + handle.drop_as::(); + } + } +} + +owlry_plugin! { + info: plugin_info, + providers: plugin_providers, + init: provider_init, + refresh: provider_refresh, + query: provider_query, + drop: provider_drop, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_time() { + assert_eq!(PomodoroProviderState::format_time(0), "00:00"); + assert_eq!(PomodoroProviderState::format_time(60), "01:00"); + assert_eq!(PomodoroProviderState::format_time(90), "01:30"); + assert_eq!(PomodoroProviderState::format_time(1500), "25:00"); + assert_eq!(PomodoroProviderState::format_time(3599), "59:59"); + } + + #[test] + fn test_default_phase() { + let phase: PomodoroPhase = Default::default(); + assert_eq!(phase, PomodoroPhase::Idle); + } +} diff --git a/crates/owlry-plugin-scripts/Cargo.toml b/crates/owlry-plugin-scripts/Cargo.toml new file mode 100644 index 0000000..d20bf41 --- /dev/null +++ b/crates/owlry-plugin-scripts/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "owlry-plugin-scripts" +version = "0.4.10" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Scripts plugin for owlry - run user scripts from ~/.local/share/owlry/scripts/" +keywords = ["owlry", "plugin", "scripts"] +categories = ["os"] + +[lib] +crate-type = ["cdylib"] # Compile as dynamic library (.so) + +[dependencies] +# Plugin API for owlry +owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" } + +# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) +abi_stable = "0.11" + +# For finding ~/.local/share/owlry/scripts +dirs = "5.0" diff --git a/crates/owlry-plugin-scripts/src/lib.rs b/crates/owlry-plugin-scripts/src/lib.rs new file mode 100644 index 0000000..c49efa4 --- /dev/null +++ b/crates/owlry-plugin-scripts/src/lib.rs @@ -0,0 +1,290 @@ +//! Scripts Plugin for Owlry +//! +//! A static provider that scans `~/.local/share/owlry/scripts/` for executable +//! scripts and provides them as launch items. +//! +//! Scripts can include a description by adding a comment after the shebang: +//! ```bash +//! #!/bin/bash +//! # This is my script description +//! echo "Hello" +//! ``` + +use abi_stable::std_types::{ROption, RStr, RString, RVec}; +use owlry_plugin_api::{ + owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, + ProviderPosition, API_VERSION, +}; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; + +// Plugin metadata +const PLUGIN_ID: &str = "scripts"; +const PLUGIN_NAME: &str = "Scripts"; +const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); +const PLUGIN_DESCRIPTION: &str = "Run user scripts from ~/.local/share/owlry/scripts/"; + +// Provider metadata +const PROVIDER_ID: &str = "scripts"; +const PROVIDER_NAME: &str = "Scripts"; +const PROVIDER_PREFIX: &str = ":script"; +const PROVIDER_ICON: &str = "utilities-terminal"; +const PROVIDER_TYPE_ID: &str = "scripts"; + +/// Scripts provider state - holds cached items +struct ScriptsState { + items: Vec, +} + +impl ScriptsState { + fn new() -> Self { + Self { items: Vec::new() } + } + + fn scripts_dir() -> Option { + dirs::data_dir().map(|d| d.join("owlry").join("scripts")) + } + + fn load_scripts(&mut self) { + self.items.clear(); + + let scripts_dir = match Self::scripts_dir() { + Some(p) => p, + None => return, + }; + + if !scripts_dir.exists() { + // Create the directory for the user + let _ = fs::create_dir_all(&scripts_dir); + return; + } + + let entries = match fs::read_dir(&scripts_dir) { + Ok(e) => e, + Err(_) => return, + }; + + for entry in entries.flatten() { + let path = entry.path(); + + // Skip directories + if path.is_dir() { + continue; + } + + // Check if executable + let metadata = match path.metadata() { + Ok(m) => m, + Err(_) => continue, + }; + + let is_executable = metadata.permissions().mode() & 0o111 != 0; + if !is_executable { + continue; + } + + // Get script name without extension + let filename = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + let name = path + .file_stem() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or(filename.clone()); + + // Try to read description from first line comment + let description = Self::read_script_description(&path); + + // Determine icon based on extension or shebang + let icon = Self::determine_icon(&path); + + let mut item = PluginItem::new( + format!("script:{}", filename), + format!("Script: {}", name), + path.to_string_lossy().to_string(), + ) + .with_icon(icon) + .with_keywords(vec!["script".to_string()]); + + if let Some(desc) = description { + item = item.with_description(desc); + } + + self.items.push(item); + } + } + + fn read_script_description(path: &PathBuf) -> Option { + let content = fs::read_to_string(path).ok()?; + let mut lines = content.lines(); + + // Skip shebang if present + let first_line = lines.next()?; + let check_line = if first_line.starts_with("#!") { + lines.next()? + } else { + first_line + }; + + // Look for a comment description + if let Some(desc) = check_line.strip_prefix("# ") { + Some(desc.trim().to_string()) + } else { check_line.strip_prefix("// ").map(|desc| desc.trim().to_string()) } + } + + fn determine_icon(path: &PathBuf) -> String { + // Check extension first + if let Some(ext) = path.extension() { + match ext.to_string_lossy().as_ref() { + "sh" | "bash" | "zsh" => return "utilities-terminal".to_string(), + "py" | "python" => return "text-x-python".to_string(), + "js" | "ts" => return "text-x-javascript".to_string(), + "rb" => return "text-x-ruby".to_string(), + "pl" => return "text-x-perl".to_string(), + _ => {} + } + } + + // Check shebang + if let Ok(content) = fs::read_to_string(path) + && let Some(first_line) = content.lines().next() { + if first_line.contains("bash") || first_line.contains("sh") { + return "utilities-terminal".to_string(); + } else if first_line.contains("python") { + return "text-x-python".to_string(); + } else if first_line.contains("node") { + return "text-x-javascript".to_string(); + } else if first_line.contains("ruby") { + return "text-x-ruby".to_string(); + } + } + + "application-x-executable".to_string() + } +} + +// ============================================================================ +// Plugin Interface Implementation +// ============================================================================ + +extern "C" fn plugin_info() -> PluginInfo { + PluginInfo { + id: RString::from(PLUGIN_ID), + name: RString::from(PLUGIN_NAME), + version: RString::from(PLUGIN_VERSION), + description: RString::from(PLUGIN_DESCRIPTION), + api_version: API_VERSION, + } +} + +extern "C" fn plugin_providers() -> RVec { + vec![ProviderInfo { + id: RString::from(PROVIDER_ID), + name: RString::from(PROVIDER_NAME), + prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)), + icon: RString::from(PROVIDER_ICON), + provider_type: ProviderKind::Static, + type_id: RString::from(PROVIDER_TYPE_ID), + position: ProviderPosition::Normal, + priority: 0, // Static: use frecency ordering + }] + .into() +} + +extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { + let state = Box::new(ScriptsState::new()); + ProviderHandle::from_box(state) +} + +extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { + if handle.ptr.is_null() { + return RVec::new(); + } + + // SAFETY: We created this handle from Box + let state = unsafe { &mut *(handle.ptr as *mut ScriptsState) }; + + // Load scripts + state.load_scripts(); + + // Return items + state.items.to_vec().into() +} + +extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec { + // Static provider - query is handled by the core using cached items + RVec::new() +} + +extern "C" fn provider_drop(handle: ProviderHandle) { + if !handle.ptr.is_null() { + // SAFETY: We created this handle from Box + unsafe { + handle.drop_as::(); + } + } +} + +// Register the plugin vtable +owlry_plugin! { + info: plugin_info, + providers: plugin_providers, + init: provider_init, + refresh: provider_refresh, + query: provider_query, + drop: provider_drop, +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_scripts_state_new() { + let state = ScriptsState::new(); + assert!(state.items.is_empty()); + } + + #[test] + fn test_determine_icon_sh() { + let path = PathBuf::from("/test/script.sh"); + let icon = ScriptsState::determine_icon(&path); + assert_eq!(icon, "utilities-terminal"); + } + + #[test] + fn test_determine_icon_python() { + let path = PathBuf::from("/test/script.py"); + let icon = ScriptsState::determine_icon(&path); + assert_eq!(icon, "text-x-python"); + } + + #[test] + fn test_determine_icon_js() { + let path = PathBuf::from("/test/script.js"); + let icon = ScriptsState::determine_icon(&path); + assert_eq!(icon, "text-x-javascript"); + } + + #[test] + fn test_determine_icon_unknown() { + let path = PathBuf::from("/test/script.xyz"); + let icon = ScriptsState::determine_icon(&path); + assert_eq!(icon, "application-x-executable"); + } + + #[test] + fn test_scripts_dir() { + // Should return Some path + let dir = ScriptsState::scripts_dir(); + assert!(dir.is_some()); + assert!(dir.unwrap().ends_with("owlry/scripts")); + } +} diff --git a/crates/owlry-plugin-ssh/Cargo.toml b/crates/owlry-plugin-ssh/Cargo.toml new file mode 100644 index 0000000..37baf01 --- /dev/null +++ b/crates/owlry-plugin-ssh/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "owlry-plugin-ssh" +version = "0.4.10" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "SSH plugin for owlry - quick connect to SSH hosts from ~/.ssh/config" +keywords = ["owlry", "plugin", "ssh"] +categories = ["network-programming"] + +[lib] +crate-type = ["cdylib"] # Compile as dynamic library (.so) + +[dependencies] +# Plugin API for owlry +owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" } + +# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) +abi_stable = "0.11" + +# For finding ~/.ssh/config +dirs = "5.0" diff --git a/crates/owlry-plugin-ssh/src/lib.rs b/crates/owlry-plugin-ssh/src/lib.rs new file mode 100644 index 0000000..01e889b --- /dev/null +++ b/crates/owlry-plugin-ssh/src/lib.rs @@ -0,0 +1,328 @@ +//! SSH Plugin for Owlry +//! +//! A static provider that parses ~/.ssh/config and provides quick-connect +//! entries for SSH hosts. +//! +//! Examples: +//! - `SSH: myserver` → Connect to myserver +//! - `SSH: work-box` → Connect to work-box with configured user/port + +use abi_stable::std_types::{ROption, RStr, RString, RVec}; +use owlry_plugin_api::{ + owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, + ProviderPosition, API_VERSION, +}; +use std::fs; +use std::path::PathBuf; + +// Plugin metadata +const PLUGIN_ID: &str = "ssh"; +const PLUGIN_NAME: &str = "SSH"; +const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); +const PLUGIN_DESCRIPTION: &str = "Quick connect to SSH hosts from ~/.ssh/config"; + +// Provider metadata +const PROVIDER_ID: &str = "ssh"; +const PROVIDER_NAME: &str = "SSH"; +const PROVIDER_PREFIX: &str = ":ssh"; +const PROVIDER_ICON: &str = "utilities-terminal"; +const PROVIDER_TYPE_ID: &str = "ssh"; + +// Default terminal command (TODO: make configurable via plugin config) +const DEFAULT_TERMINAL: &str = "kitty"; + +/// SSH provider state - holds cached items +struct SshState { + items: Vec, + terminal_command: String, +} + +impl SshState { + fn new() -> Self { + // Try to detect terminal from environment, fall back to default + let terminal = std::env::var("TERMINAL") + .unwrap_or_else(|_| DEFAULT_TERMINAL.to_string()); + + Self { + items: Vec::new(), + terminal_command: terminal, + } + } + + fn ssh_config_path() -> Option { + dirs::home_dir().map(|h| h.join(".ssh").join("config")) + } + + fn parse_ssh_config(&mut self) { + self.items.clear(); + + let config_path = match Self::ssh_config_path() { + Some(p) => p, + None => return, + }; + + if !config_path.exists() { + return; + } + + let content = match fs::read_to_string(&config_path) { + Ok(c) => c, + Err(_) => return, + }; + + let mut current_host: Option = None; + let mut current_hostname: Option = None; + let mut current_user: Option = None; + let mut current_port: Option = None; + + for line in content.lines() { + let line = line.trim(); + + // Skip comments and empty lines + if line.is_empty() || line.starts_with('#') { + continue; + } + + // Split on whitespace or '=' + let parts: Vec<&str> = line + .splitn(2, |c: char| c.is_whitespace() || c == '=') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); + + if parts.len() < 2 { + continue; + } + + let key = parts[0].to_lowercase(); + let value = parts[1]; + + match key.as_str() { + "host" => { + // Save previous host if exists + if let Some(host) = current_host.take() { + self.add_host_item( + &host, + current_hostname.take(), + current_user.take(), + current_port.take(), + ); + } + + // Skip wildcards and patterns + if !value.contains('*') && !value.contains('?') && value != "*" { + current_host = Some(value.to_string()); + } + current_hostname = None; + current_user = None; + current_port = None; + } + "hostname" => { + current_hostname = Some(value.to_string()); + } + "user" => { + current_user = Some(value.to_string()); + } + "port" => { + current_port = Some(value.to_string()); + } + _ => {} + } + } + + // Don't forget the last host + if let Some(host) = current_host.take() { + self.add_host_item(&host, current_hostname, current_user, current_port); + } + } + + fn add_host_item( + &mut self, + host: &str, + hostname: Option, + user: Option, + port: Option, + ) { + // Build description + let mut desc_parts = Vec::new(); + if let Some(ref h) = hostname { + desc_parts.push(h.clone()); + } + if let Some(ref u) = user { + desc_parts.push(format!("user: {}", u)); + } + if let Some(ref p) = port { + desc_parts.push(format!("port: {}", p)); + } + + let description = if desc_parts.is_empty() { + None + } else { + Some(desc_parts.join(", ")) + }; + + // Build SSH command - just use the host alias, SSH will resolve the rest + let ssh_command = format!("ssh {}", host); + + // Wrap in terminal + let command = format!("{} -e {}", self.terminal_command, ssh_command); + + let mut item = PluginItem::new( + format!("ssh:{}", host), + format!("SSH: {}", host), + command, + ) + .with_icon(PROVIDER_ICON) + .with_keywords(vec!["ssh".to_string(), "remote".to_string()]); + + if let Some(desc) = description { + item = item.with_description(desc); + } + + self.items.push(item); + } +} + +// ============================================================================ +// Plugin Interface Implementation +// ============================================================================ + +extern "C" fn plugin_info() -> PluginInfo { + PluginInfo { + id: RString::from(PLUGIN_ID), + name: RString::from(PLUGIN_NAME), + version: RString::from(PLUGIN_VERSION), + description: RString::from(PLUGIN_DESCRIPTION), + api_version: API_VERSION, + } +} + +extern "C" fn plugin_providers() -> RVec { + vec![ProviderInfo { + id: RString::from(PROVIDER_ID), + name: RString::from(PROVIDER_NAME), + prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)), + icon: RString::from(PROVIDER_ICON), + provider_type: ProviderKind::Static, + type_id: RString::from(PROVIDER_TYPE_ID), + position: ProviderPosition::Normal, + priority: 0, // Static: use frecency ordering + }] + .into() +} + +extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { + let state = Box::new(SshState::new()); + ProviderHandle::from_box(state) +} + +extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { + if handle.ptr.is_null() { + return RVec::new(); + } + + // SAFETY: We created this handle from Box + let state = unsafe { &mut *(handle.ptr as *mut SshState) }; + + // Parse SSH config + state.parse_ssh_config(); + + // Return items + state.items.to_vec().into() +} + +extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec { + // Static provider - query is handled by the core using cached items + RVec::new() +} + +extern "C" fn provider_drop(handle: ProviderHandle) { + if !handle.ptr.is_null() { + // SAFETY: We created this handle from Box + unsafe { + handle.drop_as::(); + } + } +} + +// Register the plugin vtable +owlry_plugin! { + info: plugin_info, + providers: plugin_providers, + init: provider_init, + refresh: provider_refresh, + query: provider_query, + drop: provider_drop, +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ssh_state_new() { + let state = SshState::new(); + assert!(state.items.is_empty()); + } + + #[test] + fn test_parse_simple_config() { + let mut state = SshState::new(); + + // We can't easily test the full flow without mocking file paths, + // but we can test the add_host_item method + state.add_host_item( + "myserver", + Some("192.168.1.100".to_string()), + Some("admin".to_string()), + Some("2222".to_string()), + ); + + assert_eq!(state.items.len(), 1); + assert_eq!(state.items[0].name.as_str(), "SSH: myserver"); + assert!(state.items[0].command.as_str().contains("ssh myserver")); + } + + #[test] + fn test_add_host_without_details() { + let mut state = SshState::new(); + state.add_host_item("simple-host", None, None, None); + + assert_eq!(state.items.len(), 1); + assert_eq!(state.items[0].name.as_str(), "SSH: simple-host"); + assert!(state.items[0].description.is_none()); + } + + #[test] + fn test_add_host_with_partial_details() { + let mut state = SshState::new(); + state.add_host_item("partial", Some("example.com".to_string()), None, None); + + assert_eq!(state.items.len(), 1); + let desc = state.items[0].description.as_ref().unwrap(); + assert_eq!(desc.as_str(), "example.com"); + } + + #[test] + fn test_items_have_icons() { + let mut state = SshState::new(); + state.add_host_item("test", None, None, None); + + assert!(state.items[0].icon.is_some()); + assert_eq!(state.items[0].icon.as_ref().unwrap().as_str(), PROVIDER_ICON); + } + + #[test] + fn test_items_have_keywords() { + let mut state = SshState::new(); + state.add_host_item("test", None, None, None); + + assert!(!state.items[0].keywords.is_empty()); + let keywords: Vec<&str> = state.items[0].keywords.iter().map(|s| s.as_str()).collect(); + assert!(keywords.contains(&"ssh")); + } +} diff --git a/crates/owlry-plugin-system/Cargo.toml b/crates/owlry-plugin-system/Cargo.toml new file mode 100644 index 0000000..acad3bd --- /dev/null +++ b/crates/owlry-plugin-system/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "owlry-plugin-system" +version = "0.4.10" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "System plugin for owlry - power and session management commands" +keywords = ["owlry", "plugin", "system", "power"] +categories = ["os"] + +[lib] +crate-type = ["cdylib"] # Compile as dynamic library (.so) + +[dependencies] +# Plugin API for owlry +owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" } + +# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) +abi_stable = "0.11" diff --git a/crates/owlry-plugin-system/src/lib.rs b/crates/owlry-plugin-system/src/lib.rs new file mode 100644 index 0000000..f68e4f7 --- /dev/null +++ b/crates/owlry-plugin-system/src/lib.rs @@ -0,0 +1,254 @@ +//! System Plugin for Owlry +//! +//! A static provider that provides system power and session management commands. +//! +//! Commands: +//! - Shutdown - Power off the system +//! - Reboot - Restart the system +//! - Reboot into BIOS - Restart into UEFI/BIOS setup +//! - Suspend - Suspend to RAM +//! - Hibernate - Suspend to disk +//! - Lock Screen - Lock the session +//! - Log Out - End the current session + +use abi_stable::std_types::{ROption, RStr, RString, RVec}; +use owlry_plugin_api::{ + owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, + ProviderPosition, API_VERSION, +}; + +// Plugin metadata +const PLUGIN_ID: &str = "system"; +const PLUGIN_NAME: &str = "System"; +const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); +const PLUGIN_DESCRIPTION: &str = "Power and session management commands"; + +// Provider metadata +const PROVIDER_ID: &str = "system"; +const PROVIDER_NAME: &str = "System"; +const PROVIDER_PREFIX: &str = ":sys"; +const PROVIDER_ICON: &str = "system-shutdown"; +const PROVIDER_TYPE_ID: &str = "system"; + +/// System provider state - holds cached items +struct SystemState { + items: Vec, +} + +impl SystemState { + fn new() -> Self { + Self { items: Vec::new() } + } + + fn load_commands(&mut self) { + self.items.clear(); + + // Define system commands + // Format: (id, name, description, icon, command) + let commands: &[(&str, &str, &str, &str, &str)] = &[ + ( + "system:shutdown", + "Shutdown", + "Power off the system", + "system-shutdown", + "systemctl poweroff", + ), + ( + "system:reboot", + "Reboot", + "Restart the system", + "system-reboot", + "systemctl reboot", + ), + ( + "system:reboot-bios", + "Reboot into BIOS", + "Restart into UEFI/BIOS setup", + "system-reboot", + "systemctl reboot --firmware-setup", + ), + ( + "system:suspend", + "Suspend", + "Suspend to RAM", + "system-suspend", + "systemctl suspend", + ), + ( + "system:hibernate", + "Hibernate", + "Suspend to disk", + "system-suspend-hibernate", + "systemctl hibernate", + ), + ( + "system:lock", + "Lock Screen", + "Lock the session", + "system-lock-screen", + "loginctl lock-session", + ), + ( + "system:logout", + "Log Out", + "End the current session", + "system-log-out", + "loginctl terminate-session self", + ), + ]; + + for (id, name, description, icon, command) in commands { + self.items.push( + PluginItem::new(*id, *name, *command) + .with_description(*description) + .with_icon(*icon) + .with_keywords(vec!["power".to_string(), "system".to_string()]), + ); + } + } +} + +// ============================================================================ +// Plugin Interface Implementation +// ============================================================================ + +extern "C" fn plugin_info() -> PluginInfo { + PluginInfo { + id: RString::from(PLUGIN_ID), + name: RString::from(PLUGIN_NAME), + version: RString::from(PLUGIN_VERSION), + description: RString::from(PLUGIN_DESCRIPTION), + api_version: API_VERSION, + } +} + +extern "C" fn plugin_providers() -> RVec { + vec![ProviderInfo { + id: RString::from(PROVIDER_ID), + name: RString::from(PROVIDER_NAME), + prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)), + icon: RString::from(PROVIDER_ICON), + provider_type: ProviderKind::Static, + type_id: RString::from(PROVIDER_TYPE_ID), + position: ProviderPosition::Normal, + priority: 0, // Static: use frecency ordering + }] + .into() +} + +extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { + let state = Box::new(SystemState::new()); + ProviderHandle::from_box(state) +} + +extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { + if handle.ptr.is_null() { + return RVec::new(); + } + + // SAFETY: We created this handle from Box + let state = unsafe { &mut *(handle.ptr as *mut SystemState) }; + + // Load/reload commands + state.load_commands(); + + // Return items + state.items.to_vec().into() +} + +extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec { + // Static provider - query is handled by the core using cached items + RVec::new() +} + +extern "C" fn provider_drop(handle: ProviderHandle) { + if !handle.ptr.is_null() { + // SAFETY: We created this handle from Box + unsafe { + handle.drop_as::(); + } + } +} + +// Register the plugin vtable +owlry_plugin! { + info: plugin_info, + providers: plugin_providers, + init: provider_init, + refresh: provider_refresh, + query: provider_query, + drop: provider_drop, +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_system_state_new() { + let state = SystemState::new(); + assert!(state.items.is_empty()); + } + + #[test] + fn test_system_commands_loaded() { + let mut state = SystemState::new(); + state.load_commands(); + + assert!(state.items.len() >= 6); + + // Check for specific commands + let names: Vec<&str> = state.items.iter().map(|i| i.name.as_str()).collect(); + assert!(names.contains(&"Shutdown")); + assert!(names.contains(&"Reboot")); + assert!(names.contains(&"Suspend")); + assert!(names.contains(&"Lock Screen")); + assert!(names.contains(&"Log Out")); + } + + #[test] + fn test_reboot_bios_command() { + let mut state = SystemState::new(); + state.load_commands(); + + let bios_cmd = state + .items + .iter() + .find(|i| i.name.as_str() == "Reboot into BIOS") + .expect("Reboot into BIOS should exist"); + + assert_eq!(bios_cmd.command.as_str(), "systemctl reboot --firmware-setup"); + } + + #[test] + fn test_commands_have_icons() { + let mut state = SystemState::new(); + state.load_commands(); + + for item in &state.items { + assert!( + item.icon.is_some(), + "Item '{}' should have an icon", + item.name.as_str() + ); + } + } + + #[test] + fn test_commands_have_descriptions() { + let mut state = SystemState::new(); + state.load_commands(); + + for item in &state.items { + assert!( + item.description.is_some(), + "Item '{}' should have a description", + item.name.as_str() + ); + } + } +} diff --git a/crates/owlry-plugin-systemd/Cargo.toml b/crates/owlry-plugin-systemd/Cargo.toml new file mode 100644 index 0000000..b725f1f --- /dev/null +++ b/crates/owlry-plugin-systemd/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "owlry-plugin-systemd" +version = "0.4.10" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "systemd user services plugin for owlry - list and control user-level systemd services" +keywords = ["owlry", "plugin", "systemd", "services"] +categories = ["os"] + +[lib] +crate-type = ["cdylib"] # Compile as dynamic library (.so) + +[dependencies] +# Plugin API for owlry +owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" } + +# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) +abi_stable = "0.11" diff --git a/crates/owlry-plugin-systemd/src/lib.rs b/crates/owlry-plugin-systemd/src/lib.rs new file mode 100644 index 0000000..25b0afa --- /dev/null +++ b/crates/owlry-plugin-systemd/src/lib.rs @@ -0,0 +1,457 @@ +//! systemd User Services Plugin for Owlry +//! +//! Lists and controls systemd user-level services. +//! Uses `systemctl --user` commands to interact with services. +//! +//! Each service item opens a submenu with actions like: +//! - Start/Stop/Restart/Reload/Kill +//! - Enable/Disable on startup +//! - View status and journal logs + +use abi_stable::std_types::{ROption, RStr, RString, RVec}; +use owlry_plugin_api::{ + owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, + ProviderPosition, API_VERSION, +}; +use std::process::Command; + +// Plugin metadata +const PLUGIN_ID: &str = "systemd"; +const PLUGIN_NAME: &str = "systemd Services"; +const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); +const PLUGIN_DESCRIPTION: &str = "List and control systemd user services"; + +// Provider metadata +const PROVIDER_ID: &str = "systemd"; +const PROVIDER_NAME: &str = "User Units"; +const PROVIDER_PREFIX: &str = ":uuctl"; +const PROVIDER_ICON: &str = "system-run"; +const PROVIDER_TYPE_ID: &str = "uuctl"; + +/// systemd provider state +struct SystemdState { + items: Vec, +} + +impl SystemdState { + fn new() -> Self { + let mut state = Self { items: Vec::new() }; + state.refresh(); + state + } + + fn refresh(&mut self) { + self.items.clear(); + + if !Self::systemctl_available() { + return; + } + + // List all user services (both running and available) + let output = match Command::new("systemctl") + .args([ + "--user", + "list-units", + "--type=service", + "--all", + "--no-legend", + "--no-pager", + ]) + .output() + { + Ok(o) if o.status.success() => o, + _ => return, + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + self.items = Self::parse_systemctl_output(&stdout); + + // Sort by name + self.items.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str())); + } + + fn systemctl_available() -> bool { + Command::new("systemctl") + .args(["--user", "--version"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn parse_systemctl_output(output: &str) -> Vec { + let mut items = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + // Parse systemctl output - handle variable whitespace + // Format: UNIT LOAD ACTIVE SUB DESCRIPTION... + let mut parts = line.split_whitespace(); + + let unit_name = match parts.next() { + Some(u) => u, + None => continue, + }; + + // Skip if not a proper service name + if !unit_name.ends_with(".service") { + continue; + } + + let _load_state = parts.next().unwrap_or(""); + let active_state = parts.next().unwrap_or(""); + let sub_state = parts.next().unwrap_or(""); + let description: String = parts.collect::>().join(" "); + + // Create a clean display name + let display_name = unit_name + .trim_end_matches(".service") + .replace("app-", "") + .replace("@autostart", "") + .replace("\\x2d", "-"); + + let is_active = active_state == "active"; + let status_icon = if is_active { "●" } else { "○" }; + + let status_desc = if description.is_empty() { + format!("{} {} ({})", status_icon, sub_state, active_state) + } else { + format!("{} {} ({})", status_icon, description, sub_state) + }; + + // Store service info in the command field as encoded data + // Format: SUBMENU:type_id:data where data is "unit_name:is_active" + let submenu_data = format!("SUBMENU:uuctl:{}:{}", unit_name, is_active); + + let icon = if is_active { + "emblem-ok-symbolic" + } else { + "emblem-pause-symbolic" + }; + + items.push( + PluginItem::new( + format!("systemd:service:{}", unit_name), + display_name, + submenu_data, + ) + .with_description(status_desc) + .with_icon(icon) + .with_keywords(vec!["systemd".to_string(), "service".to_string()]), + ); + } + + items + } +} + +// ============================================================================ +// Submenu Action Generation (exported for core to use) +// ============================================================================ + +/// Generate submenu actions for a given service +/// This function is called by the core when a service is selected +pub fn actions_for_service(unit_name: &str, display_name: &str, is_active: bool) -> Vec { + let mut actions = Vec::new(); + + if is_active { + actions.push( + PluginItem::new( + format!("systemd:restart:{}", unit_name), + "↻ Restart", + format!("systemctl --user restart {}", unit_name), + ) + .with_description(format!("Restart {}", display_name)) + .with_icon("view-refresh") + .with_keywords(vec!["systemd".to_string(), "service".to_string()]), + ); + + actions.push( + PluginItem::new( + format!("systemd:stop:{}", unit_name), + "■ Stop", + format!("systemctl --user stop {}", unit_name), + ) + .with_description(format!("Stop {}", display_name)) + .with_icon("process-stop") + .with_keywords(vec!["systemd".to_string(), "service".to_string()]), + ); + + actions.push( + PluginItem::new( + format!("systemd:reload:{}", unit_name), + "⟳ Reload", + format!("systemctl --user reload {}", unit_name), + ) + .with_description(format!("Reload {} configuration", display_name)) + .with_icon("view-refresh") + .with_keywords(vec!["systemd".to_string(), "service".to_string()]), + ); + + actions.push( + PluginItem::new( + format!("systemd:kill:{}", unit_name), + "✗ Kill", + format!("systemctl --user kill {}", unit_name), + ) + .with_description(format!("Force kill {}", display_name)) + .with_icon("edit-delete") + .with_keywords(vec!["systemd".to_string(), "service".to_string()]), + ); + } else { + actions.push( + PluginItem::new( + format!("systemd:start:{}", unit_name), + "▶ Start", + format!("systemctl --user start {}", unit_name), + ) + .with_description(format!("Start {}", display_name)) + .with_icon("media-playback-start") + .with_keywords(vec!["systemd".to_string(), "service".to_string()]), + ); + } + + // Always available actions + actions.push( + PluginItem::new( + format!("systemd:status:{}", unit_name), + "ℹ Status", + format!("systemctl --user status {}", unit_name), + ) + .with_description(format!("Show {} status", display_name)) + .with_icon("dialog-information") + .with_keywords(vec!["systemd".to_string(), "service".to_string()]) + .with_terminal(true), + ); + + actions.push( + PluginItem::new( + format!("systemd:journal:{}", unit_name), + "📋 Journal", + format!("journalctl --user -u {} -f", unit_name), + ) + .with_description(format!("Show {} logs", display_name)) + .with_icon("utilities-system-monitor") + .with_keywords(vec!["systemd".to_string(), "service".to_string()]) + .with_terminal(true), + ); + + actions.push( + PluginItem::new( + format!("systemd:enable:{}", unit_name), + "⊕ Enable", + format!("systemctl --user enable {}", unit_name), + ) + .with_description(format!("Enable {} on startup", display_name)) + .with_icon("emblem-default") + .with_keywords(vec!["systemd".to_string(), "service".to_string()]), + ); + + actions.push( + PluginItem::new( + format!("systemd:disable:{}", unit_name), + "⊖ Disable", + format!("systemctl --user disable {}", unit_name), + ) + .with_description(format!("Disable {} on startup", display_name)) + .with_icon("emblem-unreadable") + .with_keywords(vec!["systemd".to_string(), "service".to_string()]), + ); + + actions +} + +// ============================================================================ +// Plugin Interface Implementation +// ============================================================================ + +extern "C" fn plugin_info() -> PluginInfo { + PluginInfo { + id: RString::from(PLUGIN_ID), + name: RString::from(PLUGIN_NAME), + version: RString::from(PLUGIN_VERSION), + description: RString::from(PLUGIN_DESCRIPTION), + api_version: API_VERSION, + } +} + +extern "C" fn plugin_providers() -> RVec { + vec![ProviderInfo { + id: RString::from(PROVIDER_ID), + name: RString::from(PROVIDER_NAME), + prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)), + icon: RString::from(PROVIDER_ICON), + provider_type: ProviderKind::Static, + type_id: RString::from(PROVIDER_TYPE_ID), + position: ProviderPosition::Normal, + priority: 0, // Static: use frecency ordering + }] + .into() +} + +extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { + let state = Box::new(SystemdState::new()); + ProviderHandle::from_box(state) +} + +extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { + if handle.ptr.is_null() { + return RVec::new(); + } + + // SAFETY: We created this handle from Box + let state = unsafe { &mut *(handle.ptr as *mut SystemdState) }; + + state.refresh(); + state.items.clone().into() +} + +extern "C" fn provider_query(_handle: ProviderHandle, query: RStr<'_>) -> RVec { + let query_str = query.as_str(); + + // Handle submenu action requests: ?SUBMENU:unit.service:is_active + if let Some(data) = query_str.strip_prefix("?SUBMENU:") { + // Parse data format: "unit_name:is_active" + let parts: Vec<&str> = data.splitn(2, ':').collect(); + if parts.len() >= 2 { + let unit_name = parts[0]; + let is_active = parts[1] == "true"; + let display_name = unit_name + .trim_end_matches(".service") + .replace("app-", "") + .replace("@autostart", "") + .replace("\\x2d", "-"); + + return actions_for_service(unit_name, &display_name, is_active).into(); + } else if !data.is_empty() { + // Fallback: just unit name, assume not active + let display_name = data + .trim_end_matches(".service") + .replace("app-", "") + .replace("@autostart", "") + .replace("\\x2d", "-"); + return actions_for_service(data, &display_name, false).into(); + } + } + + // Static provider - normal queries not used + RVec::new() +} + +extern "C" fn provider_drop(handle: ProviderHandle) { + if !handle.ptr.is_null() { + // SAFETY: We created this handle from Box + unsafe { + handle.drop_as::(); + } + } +} + +// Register the plugin vtable +owlry_plugin! { + info: plugin_info, + providers: plugin_providers, + init: provider_init, + refresh: provider_refresh, + query: provider_query, + drop: provider_drop, +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_systemctl_output() { + let output = r#" +foo.service loaded active running Foo Service +bar.service loaded inactive dead Bar Service +baz@autostart.service loaded active running Baz App +"#; + let items = SystemdState::parse_systemctl_output(output); + assert_eq!(items.len(), 3); + + // Check first item + assert_eq!(items[0].name.as_str(), "foo"); + assert!(items[0].command.as_str().contains("SUBMENU:uuctl:foo.service:true")); + + // Check second item (inactive) + assert_eq!(items[1].name.as_str(), "bar"); + assert!(items[1].command.as_str().contains("SUBMENU:uuctl:bar.service:false")); + + // Check third item (cleaned name) + assert_eq!(items[2].name.as_str(), "baz"); + } + + #[test] + fn test_actions_for_active_service() { + let actions = actions_for_service("test.service", "Test", true); + + // Active services should have restart, stop, reload, kill + common actions + let action_ids: Vec<_> = actions.iter().map(|a| a.id.as_str()).collect(); + assert!(action_ids.contains(&"systemd:restart:test.service")); + assert!(action_ids.contains(&"systemd:stop:test.service")); + assert!(action_ids.contains(&"systemd:status:test.service")); + assert!(!action_ids.contains(&"systemd:start:test.service")); // Not for active + } + + #[test] + fn test_actions_for_inactive_service() { + let actions = actions_for_service("test.service", "Test", false); + + // Inactive services should have start + common actions + let action_ids: Vec<_> = actions.iter().map(|a| a.id.as_str()).collect(); + assert!(action_ids.contains(&"systemd:start:test.service")); + assert!(action_ids.contains(&"systemd:status:test.service")); + assert!(!action_ids.contains(&"systemd:stop:test.service")); // Not for inactive + } + + #[test] + fn test_terminal_actions() { + let actions = actions_for_service("test.service", "Test", true); + + // Status and journal should have terminal=true + for action in &actions { + let id = action.id.as_str(); + if id.contains(":status:") || id.contains(":journal:") { + assert!(action.terminal, "Action {} should have terminal=true", id); + } + } + } + + #[test] + fn test_submenu_query() { + // Test that provider_query handles ?SUBMENU: queries correctly + let handle = ProviderHandle { ptr: std::ptr::null_mut() }; + + // Query for active service + let query = RStr::from_str("?SUBMENU:test.service:true"); + let actions = provider_query(handle, query); + assert!(!actions.is_empty(), "Should return actions for submenu query"); + + // Should have restart action for active service + let has_restart = actions.iter().any(|a| a.id.as_str().contains(":restart:")); + assert!(has_restart, "Active service should have restart action"); + + // Query for inactive service + let query = RStr::from_str("?SUBMENU:test.service:false"); + let actions = provider_query(handle, query); + assert!(!actions.is_empty(), "Should return actions for submenu query"); + + // Should have start action for inactive service + let has_start = actions.iter().any(|a| a.id.as_str().contains(":start:")); + assert!(has_start, "Inactive service should have start action"); + + // Normal query should return empty + let query = RStr::from_str("some search"); + let actions = provider_query(handle, query); + assert!(actions.is_empty(), "Normal query should return empty"); + } +} diff --git a/crates/owlry-plugin-weather/Cargo.toml b/crates/owlry-plugin-weather/Cargo.toml new file mode 100644 index 0000000..0a103f3 --- /dev/null +++ b/crates/owlry-plugin-weather/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "owlry-plugin-weather" +version = "0.4.10" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Weather widget plugin for owlry - shows current weather with multiple API support" +keywords = ["owlry", "plugin", "weather", "widget"] +categories = ["gui"] + +[lib] +crate-type = ["cdylib"] # Compile as dynamic library (.so) + +[dependencies] +# Plugin API for owlry +owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" } + +# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) +abi_stable = "0.11" + +# HTTP client for weather API requests +reqwest = { version = "0.13", features = ["blocking", "json"] } + +# JSON parsing for API responses +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# TOML config parsing +toml = "0.8" + +# XDG directories for cache persistence +dirs = "5.0" diff --git a/crates/owlry-plugin-weather/src/lib.rs b/crates/owlry-plugin-weather/src/lib.rs new file mode 100644 index 0000000..0f3edc7 --- /dev/null +++ b/crates/owlry-plugin-weather/src/lib.rs @@ -0,0 +1,754 @@ +//! Weather Widget Plugin for Owlry +//! +//! Shows current weather with support for multiple APIs: +//! - wttr.in (default, no API key required) +//! - OpenWeatherMap (requires API key) +//! - Open-Meteo (no API key required) +//! +//! Weather data is cached for 15 minutes. +//! +//! ## Configuration +//! +//! Configure via `~/.config/owlry/config.toml`: +//! +//! ```toml +//! [plugins.weather] +//! provider = "wttr.in" # or: openweathermap, open-meteo +//! location = "Berlin" # city name or "lat,lon" +//! # api_key = "..." # Required for OpenWeatherMap +//! ``` + +use abi_stable::std_types::{ROption, RStr, RString, RVec}; +use owlry_plugin_api::{ + owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, + ProviderPosition, API_VERSION, +}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +// Plugin metadata +const PLUGIN_ID: &str = "weather"; +const PLUGIN_NAME: &str = "Weather"; +const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); +const PLUGIN_DESCRIPTION: &str = "Weather widget with multiple API support"; + +// Provider metadata +const PROVIDER_ID: &str = "weather"; +const PROVIDER_NAME: &str = "Weather"; +const PROVIDER_ICON: &str = "weather-clear"; +const PROVIDER_TYPE_ID: &str = "weather"; + +// Timing constants +const CACHE_DURATION_SECS: u64 = 900; // 15 minutes +const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); +const USER_AGENT: &str = "owlry-launcher/0.3"; + +#[derive(Debug, Clone, PartialEq)] +enum WeatherProviderType { + WttrIn, + OpenWeatherMap, + OpenMeteo, +} + +impl std::str::FromStr for WeatherProviderType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "wttr.in" | "wttr" | "wttrin" => Ok(Self::WttrIn), + "openweathermap" | "owm" => Ok(Self::OpenWeatherMap), + "open-meteo" | "openmeteo" | "meteo" => Ok(Self::OpenMeteo), + _ => Err(format!("Unknown weather provider: {}", s)), + } + } +} + +#[derive(Debug, Clone)] +struct WeatherConfig { + provider: WeatherProviderType, + api_key: Option, + location: String, +} + +impl WeatherConfig { + /// Load config from ~/.config/owlry/config.toml + /// + /// Reads from [plugins.weather] section, with fallback to [providers] for compatibility. + fn load() -> Self { + let config_path = dirs::config_dir() + .map(|d| d.join("owlry").join("config.toml")); + + let config_content = config_path + .and_then(|p| fs::read_to_string(p).ok()); + + if let Some(content) = config_content + && let Ok(toml) = content.parse::() + { + // Try [plugins.weather] first (new format) + if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table()) + && let Some(weather) = plugins.get("weather").and_then(|v| v.as_table()) + { + return Self::from_toml_table(weather); + } + + // Fallback to [providers] section (old format) + if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) { + let provider_str = providers + .get("weather_provider") + .and_then(|v| v.as_str()) + .unwrap_or("wttr.in"); + + let provider = provider_str.parse().unwrap_or(WeatherProviderType::WttrIn); + + let api_key = providers + .get("weather_api_key") + .and_then(|v| v.as_str()) + .map(String::from); + + let location = providers + .get("weather_location") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + return Self { + provider, + api_key, + location, + }; + } + } + + // Default config + Self { + provider: WeatherProviderType::WttrIn, + api_key: None, + location: String::new(), + } + } + + /// Parse config from a TOML table + fn from_toml_table(table: &toml::Table) -> Self { + let provider_str = table + .get("provider") + .and_then(|v| v.as_str()) + .unwrap_or("wttr.in"); + + let provider = provider_str.parse().unwrap_or(WeatherProviderType::WttrIn); + + let api_key = table + .get("api_key") + .and_then(|v| v.as_str()) + .map(String::from); + + let location = table + .get("location") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + Self { + provider, + api_key, + location, + } + } +} + +/// Cached weather data (persisted to disk) +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WeatherData { + temperature: f32, + feels_like: Option, + condition: String, + humidity: Option, + wind_speed: Option, + icon: String, + location: String, +} + +/// Persistent cache structure (saved to ~/.local/share/owlry/weather_cache.json) +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WeatherCache { + last_fetch_epoch: u64, + data: WeatherData, +} + +/// Weather provider state +struct WeatherState { + items: Vec, + config: WeatherConfig, + last_fetch_epoch: u64, + cached_data: Option, +} + +impl WeatherState { + fn new() -> Self { + Self::with_config(WeatherConfig::load()) + } + + fn with_config(config: WeatherConfig) -> Self { + // Load cached weather from disk if available + // This prevents blocking HTTP requests on every app open + let (last_fetch_epoch, cached_data) = Self::load_cache() + .map(|c| (c.last_fetch_epoch, Some(c.data))) + .unwrap_or((0, None)); + + Self { + items: Vec::new(), + config, + last_fetch_epoch, + cached_data, + } + } + + fn data_dir() -> Option { + dirs::data_dir().map(|d| d.join("owlry")) + } + + fn cache_path() -> Option { + Self::data_dir().map(|d| d.join("weather_cache.json")) + } + + fn load_cache() -> Option { + let path = Self::cache_path()?; + let content = fs::read_to_string(&path).ok()?; + serde_json::from_str(&content).ok() + } + + fn save_cache(&self) { + if let (Some(data_dir), Some(cache_path), Some(data)) = + (Self::data_dir(), Self::cache_path(), &self.cached_data) + { + if fs::create_dir_all(&data_dir).is_err() { + return; + } + let cache = WeatherCache { + last_fetch_epoch: self.last_fetch_epoch, + data: data.clone(), + }; + if let Ok(json) = serde_json::to_string_pretty(&cache) { + let _ = fs::write(&cache_path, json); + } + } + } + + fn now_epoch() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + } + + fn is_cache_valid(&self) -> bool { + if self.last_fetch_epoch == 0 { + return false; + } + let now = Self::now_epoch(); + now.saturating_sub(self.last_fetch_epoch) < CACHE_DURATION_SECS + } + + fn refresh(&mut self) { + // Use cache if still valid (works across app restarts) + if self.is_cache_valid() + && let Some(data) = self.cached_data.clone() { + self.generate_items(&data); + return; + } + + // Fetch new data from API + if let Some(data) = self.fetch_weather() { + self.cached_data = Some(data.clone()); + self.last_fetch_epoch = Self::now_epoch(); + self.save_cache(); // Persist to disk for next app open + self.generate_items(&data); + } else { + // On fetch failure, try to use stale cache if available + if let Some(data) = self.cached_data.clone() { + self.generate_items(&data); + } else { + self.items.clear(); + } + } + } + + fn fetch_weather(&self) -> Option { + match self.config.provider { + WeatherProviderType::WttrIn => self.fetch_wttr_in(), + WeatherProviderType::OpenWeatherMap => self.fetch_openweathermap(), + WeatherProviderType::OpenMeteo => self.fetch_open_meteo(), + } + } + + fn fetch_wttr_in(&self) -> Option { + let location = if self.config.location.is_empty() { + String::new() + } else { + self.config.location.clone() + }; + + let url = format!("https://wttr.in/{}?format=j1", location); + + let client = reqwest::blocking::Client::builder() + .timeout(REQUEST_TIMEOUT) + .user_agent(USER_AGENT) + .build() + .ok()?; + + let response = client.get(&url).send().ok()?; + let json: WttrInResponse = response.json().ok()?; + + let current = json.current_condition.first()?; + let nearest = json.nearest_area.first()?; + + let location_name = nearest + .area_name + .first() + .map(|a| a.value.clone()) + .unwrap_or_else(|| "Unknown".to_string()); + + Some(WeatherData { + temperature: current.temp_c.parse().unwrap_or(0.0), + feels_like: current.feels_like_c.parse().ok(), + condition: current + .weather_desc + .first() + .map(|d| d.value.clone()) + .unwrap_or_else(|| "Unknown".to_string()), + humidity: current.humidity.parse().ok(), + wind_speed: current.windspeed_kmph.parse().ok(), + icon: Self::wttr_code_to_icon(¤t.weather_code), + location: location_name, + }) + } + + fn fetch_openweathermap(&self) -> Option { + let api_key = self.config.api_key.as_ref()?; + if self.config.location.is_empty() { + return None; // OWM requires a location + } + + let url = format!( + "https://api.openweathermap.org/data/2.5/weather?q={}&appid={}&units=metric", + self.config.location, api_key + ); + + let client = reqwest::blocking::Client::builder() + .timeout(REQUEST_TIMEOUT) + .build() + .ok()?; + + let response = client.get(&url).send().ok()?; + let json: OpenWeatherMapResponse = response.json().ok()?; + + let weather = json.weather.first()?; + + Some(WeatherData { + temperature: json.main.temp, + feels_like: Some(json.main.feels_like), + condition: weather.description.clone(), + humidity: Some(json.main.humidity), + wind_speed: Some(json.wind.speed * 3.6), // m/s to km/h + icon: Self::owm_icon_to_freedesktop(&weather.icon), + location: json.name, + }) + } + + fn fetch_open_meteo(&self) -> Option { + let (lat, lon, location_name) = self.get_coordinates()?; + + let url = format!( + "https://api.open-meteo.com/v1/forecast?latitude={}&longitude={}¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&timezone=auto", + lat, lon + ); + + let client = reqwest::blocking::Client::builder() + .timeout(REQUEST_TIMEOUT) + .build() + .ok()?; + + let response = client.get(&url).send().ok()?; + let json: OpenMeteoResponse = response.json().ok()?; + + let current = json.current; + + Some(WeatherData { + temperature: current.temperature_2m, + feels_like: None, + condition: Self::wmo_code_to_description(current.weather_code), + humidity: Some(current.relative_humidity_2m as u8), + wind_speed: Some(current.wind_speed_10m), + icon: Self::wmo_code_to_icon(current.weather_code), + location: location_name, + }) + } + + fn get_coordinates(&self) -> Option<(f64, f64, String)> { + let location = &self.config.location; + + // Check if location is already coordinates (lat,lon) + if location.contains(',') { + let parts: Vec<&str> = location.split(',').collect(); + if parts.len() == 2 + && let (Ok(lat), Ok(lon)) = ( + parts[0].trim().parse::(), + parts[1].trim().parse::(), + ) { + return Some((lat, lon, location.clone())); + } + } + + // Use Open-Meteo geocoding API + let url = format!( + "https://geocoding-api.open-meteo.com/v1/search?name={}&count=1", + location + ); + + let client = reqwest::blocking::Client::builder() + .timeout(REQUEST_TIMEOUT) + .build() + .ok()?; + + let response = client.get(&url).send().ok()?; + let json: GeocodingResponse = response.json().ok()?; + + let result = json.results?.into_iter().next()?; + Some((result.latitude, result.longitude, result.name)) + } + + fn wttr_code_to_icon(code: &str) -> String { + match code { + "113" => "weather-clear", + "116" => "weather-few-clouds", + "119" => "weather-overcast", + "122" => "weather-overcast", + "143" | "248" | "260" => "weather-fog", + "176" | "263" | "266" | "293" | "296" | "299" | "302" | "305" | "308" => { + "weather-showers" + } + "179" | "182" | "185" | "227" | "230" | "323" | "326" | "329" | "332" | "335" + | "338" | "350" | "368" | "371" | "374" | "377" => "weather-snow", + "200" | "386" | "389" | "392" | "395" => "weather-storm", + _ => "weather-clear", + } + .to_string() + } + + fn owm_icon_to_freedesktop(icon: &str) -> String { + match icon { + "01d" | "01n" => "weather-clear", + "02d" | "02n" => "weather-few-clouds", + "03d" | "03n" | "04d" | "04n" => "weather-overcast", + "09d" | "09n" | "10d" | "10n" => "weather-showers", + "11d" | "11n" => "weather-storm", + "13d" | "13n" => "weather-snow", + "50d" | "50n" => "weather-fog", + _ => "weather-clear", + } + .to_string() + } + + fn wmo_code_to_description(code: i32) -> String { + match code { + 0 => "Clear sky", + 1 => "Mainly clear", + 2 => "Partly cloudy", + 3 => "Overcast", + 45 | 48 => "Foggy", + 51 | 53 | 55 => "Drizzle", + 61 | 63 | 65 => "Rain", + 66 | 67 => "Freezing rain", + 71 | 73 | 75 | 77 => "Snow", + 80..=82 => "Rain showers", + 85 | 86 => "Snow showers", + 95 | 96 | 99 => "Thunderstorm", + _ => "Unknown", + } + .to_string() + } + + fn wmo_code_to_icon(code: i32) -> String { + match code { + 0 | 1 => "weather-clear", + 2 => "weather-few-clouds", + 3 => "weather-overcast", + 45 | 48 => "weather-fog", + 51 | 53 | 55 | 61 | 63 | 65 | 80 | 81 | 82 => "weather-showers", + 66 | 67 | 71 | 73 | 75 | 77 | 85 | 86 => "weather-snow", + 95 | 96 | 99 => "weather-storm", + _ => "weather-clear", + } + .to_string() + } + + fn icon_to_resource_path(icon: &str) -> String { + let weather_icon = if icon.contains("clear") { + "wi-day-sunny" + } else if icon.contains("few-clouds") { + "wi-day-cloudy" + } else if icon.contains("overcast") || icon.contains("clouds") { + "wi-cloudy" + } else if icon.contains("fog") { + "wi-fog" + } else if icon.contains("showers") || icon.contains("rain") { + "wi-rain" + } else if icon.contains("snow") { + "wi-snow" + } else if icon.contains("storm") { + "wi-thunderstorm" + } else { + "wi-thermometer" + }; + format!("/org/owlry/launcher/icons/weather/{}.svg", weather_icon) + } + + fn generate_items(&mut self, data: &WeatherData) { + self.items.clear(); + + let temp_str = format!("{}°C", data.temperature.round() as i32); + let name = format!("{} {}", temp_str, data.condition); + + let mut details = vec![data.location.clone()]; + if let Some(humidity) = data.humidity { + details.push(format!("Humidity {}%", humidity)); + } + if let Some(wind) = data.wind_speed { + details.push(format!("Wind {} km/h", wind.round() as i32)); + } + if let Some(feels) = data.feels_like + && (feels - data.temperature).abs() > 2.0 { + details.push(format!("Feels like {}°C", feels.round() as i32)); + } + + let encoded_location = data.location.replace(' ', "+"); + let command = format!("xdg-open 'https://wttr.in/{}'", encoded_location); + + self.items.push( + PluginItem::new("weather-current", name, command) + .with_description(details.join(" | ")) + .with_icon(Self::icon_to_resource_path(&data.icon)) + .with_keywords(vec!["weather".to_string(), "widget".to_string()]), + ); + } +} + +// ============================================================================ +// API Response Types +// ============================================================================ + +#[derive(Debug, Deserialize)] +struct WttrInResponse { + current_condition: Vec, + nearest_area: Vec, +} + +#[derive(Debug, Deserialize)] +struct WttrInCurrent { + #[serde(rename = "temp_C")] + temp_c: String, + #[serde(rename = "FeelsLikeC")] + feels_like_c: String, + humidity: String, + #[serde(rename = "weatherCode")] + weather_code: String, + #[serde(rename = "weatherDesc")] + weather_desc: Vec, + #[serde(rename = "windspeedKmph")] + windspeed_kmph: String, +} + +#[derive(Debug, Deserialize)] +struct WttrInValue { + value: String, +} + +#[derive(Debug, Deserialize)] +struct WttrInArea { + #[serde(rename = "areaName")] + area_name: Vec, +} + +#[derive(Debug, Deserialize)] +struct OpenWeatherMapResponse { + main: OwmMain, + weather: Vec, + wind: OwmWind, + name: String, +} + +#[derive(Debug, Deserialize)] +struct OwmMain { + temp: f32, + feels_like: f32, + humidity: u8, +} + +#[derive(Debug, Deserialize)] +struct OwmWeather { + description: String, + icon: String, +} + +#[derive(Debug, Deserialize)] +struct OwmWind { + speed: f32, +} + +#[derive(Debug, Deserialize)] +struct OpenMeteoResponse { + current: OpenMeteoCurrent, +} + +#[derive(Debug, Deserialize)] +struct OpenMeteoCurrent { + temperature_2m: f32, + relative_humidity_2m: f32, + weather_code: i32, + wind_speed_10m: f32, +} + +#[derive(Debug, Deserialize)] +struct GeocodingResponse { + results: Option>, +} + +#[derive(Debug, Deserialize)] +struct GeocodingResult { + name: String, + latitude: f64, + longitude: f64, +} + +// ============================================================================ +// Plugin Interface Implementation +// ============================================================================ + +extern "C" fn plugin_info() -> PluginInfo { + PluginInfo { + id: RString::from(PLUGIN_ID), + name: RString::from(PLUGIN_NAME), + version: RString::from(PLUGIN_VERSION), + description: RString::from(PLUGIN_DESCRIPTION), + api_version: API_VERSION, + } +} + +extern "C" fn plugin_providers() -> RVec { + vec![ProviderInfo { + id: RString::from(PROVIDER_ID), + name: RString::from(PROVIDER_NAME), + prefix: ROption::RNone, + icon: RString::from(PROVIDER_ICON), + provider_type: ProviderKind::Static, + type_id: RString::from(PROVIDER_TYPE_ID), + position: ProviderPosition::Widget, + priority: 12000, // Widget: highest priority + }] + .into() +} + +extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { + let state = Box::new(WeatherState::new()); + ProviderHandle::from_box(state) +} + +extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { + if handle.ptr.is_null() { + return RVec::new(); + } + + // SAFETY: We created this handle from Box + let state = unsafe { &mut *(handle.ptr as *mut WeatherState) }; + + state.refresh(); + state.items.clone().into() +} + +extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec { + // Static provider - query not used, return empty + RVec::new() +} + +extern "C" fn provider_drop(handle: ProviderHandle) { + if !handle.ptr.is_null() { + // SAFETY: We created this handle from Box + unsafe { + handle.drop_as::(); + } + } +} + +// Register the plugin vtable +owlry_plugin! { + info: plugin_info, + providers: plugin_providers, + init: provider_init, + refresh: provider_refresh, + query: provider_query, + drop: provider_drop, +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_weather_provider_type_from_str() { + assert_eq!( + "wttr.in".parse::().unwrap(), + WeatherProviderType::WttrIn + ); + assert_eq!( + "owm".parse::().unwrap(), + WeatherProviderType::OpenWeatherMap + ); + assert_eq!( + "open-meteo".parse::().unwrap(), + WeatherProviderType::OpenMeteo + ); + } + + #[test] + fn test_wttr_code_to_icon() { + assert_eq!(WeatherState::wttr_code_to_icon("113"), "weather-clear"); + assert_eq!(WeatherState::wttr_code_to_icon("116"), "weather-few-clouds"); + assert_eq!(WeatherState::wttr_code_to_icon("176"), "weather-showers"); + assert_eq!(WeatherState::wttr_code_to_icon("200"), "weather-storm"); + } + + #[test] + fn test_wmo_code_to_description() { + assert_eq!(WeatherState::wmo_code_to_description(0), "Clear sky"); + assert_eq!(WeatherState::wmo_code_to_description(3), "Overcast"); + assert_eq!(WeatherState::wmo_code_to_description(95), "Thunderstorm"); + } + + #[test] + fn test_icon_to_resource_path() { + assert_eq!( + WeatherState::icon_to_resource_path("weather-clear"), + "/org/owlry/launcher/icons/weather/wi-day-sunny.svg" + ); + } + + #[test] + fn test_cache_validity() { + let state = WeatherState { + items: Vec::new(), + config: WeatherConfig { + provider: WeatherProviderType::WttrIn, + api_key: None, + location: String::new(), + }, + last_fetch_epoch: 0, + cached_data: None, + }; + assert!(!state.is_cache_valid()); + } +} diff --git a/crates/owlry-plugin-websearch/Cargo.toml b/crates/owlry-plugin-websearch/Cargo.toml new file mode 100644 index 0000000..25b2136 --- /dev/null +++ b/crates/owlry-plugin-websearch/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "owlry-plugin-websearch" +version = "0.4.10" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Web search plugin for owlry - search the web with configurable search engines" +keywords = ["owlry", "plugin", "websearch", "search"] +categories = ["web-programming"] + +[lib] +crate-type = ["cdylib"] # Compile as dynamic library (.so) + +[dependencies] +# Plugin API for owlry +owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" } + +# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) +abi_stable = "0.11" diff --git a/crates/owlry-plugin-websearch/src/lib.rs b/crates/owlry-plugin-websearch/src/lib.rs new file mode 100644 index 0000000..66cf00f --- /dev/null +++ b/crates/owlry-plugin-websearch/src/lib.rs @@ -0,0 +1,299 @@ +//! Web Search Plugin for Owlry +//! +//! A dynamic provider that opens web searches in the browser. +//! Supports multiple search engines. +//! +//! Examples: +//! - `? rust programming` → Search DuckDuckGo for "rust programming" +//! - `web rust docs` → Search for "rust docs" +//! - `search how to rust` → Search for "how to rust" + +use abi_stable::std_types::{ROption, RStr, RString, RVec}; +use owlry_plugin_api::{ + owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, + ProviderPosition, API_VERSION, +}; + +// Plugin metadata +const PLUGIN_ID: &str = "websearch"; +const PLUGIN_NAME: &str = "Web Search"; +const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); +const PLUGIN_DESCRIPTION: &str = "Search the web with configurable search engines"; + +// Provider metadata +const PROVIDER_ID: &str = "websearch"; +const PROVIDER_NAME: &str = "Web Search"; +const PROVIDER_PREFIX: &str = "?"; +const PROVIDER_ICON: &str = "web-browser"; +const PROVIDER_TYPE_ID: &str = "websearch"; + +/// Common search engine URL templates +/// {query} is replaced with the URL-encoded search term +const SEARCH_ENGINES: &[(&str, &str)] = &[ + ("google", "https://www.google.com/search?q={query}"), + ("duckduckgo", "https://duckduckgo.com/?q={query}"), + ("bing", "https://www.bing.com/search?q={query}"), + ("startpage", "https://www.startpage.com/search?q={query}"), + ("searxng", "https://searx.be/search?q={query}"), + ("brave", "https://search.brave.com/search?q={query}"), + ("ecosia", "https://www.ecosia.org/search?q={query}"), +]; + +/// Default search engine if not configured +const DEFAULT_ENGINE: &str = "duckduckgo"; + +/// Web search provider state +struct WebSearchState { + /// URL template with {query} placeholder + url_template: String, +} + +impl WebSearchState { + fn new() -> Self { + Self::with_engine(DEFAULT_ENGINE) + } + + fn with_engine(engine_name: &str) -> Self { + let url_template = SEARCH_ENGINES + .iter() + .find(|(name, _)| *name == engine_name.to_lowercase()) + .map(|(_, url)| url.to_string()) + .unwrap_or_else(|| { + // If not a known engine, treat it as a custom URL template + if engine_name.contains("{query}") { + engine_name.to_string() + } else { + // Fall back to default + SEARCH_ENGINES + .iter() + .find(|(name, _)| *name == DEFAULT_ENGINE) + .map(|(_, url)| url.to_string()) + .unwrap() + } + }); + + Self { url_template } + } + + /// Extract the search term from the query + fn extract_search_term(query: &str) -> Option<&str> { + let trimmed = query.trim(); + + if let Some(rest) = trimmed.strip_prefix("? ") { + Some(rest.trim()) + } else if let Some(rest) = trimmed.strip_prefix("?") { + Some(rest.trim()) + } else if trimmed.to_lowercase().starts_with("web ") { + Some(trimmed[4..].trim()) + } else if trimmed.to_lowercase().starts_with("search ") { + Some(trimmed[7..].trim()) + } else { + // In filter mode, accept raw query + Some(trimmed) + } + } + + /// URL-encode a search query + fn url_encode(query: &str) -> String { + query + .chars() + .map(|c| match c { + ' ' => "+".to_string(), + '&' => "%26".to_string(), + '=' => "%3D".to_string(), + '?' => "%3F".to_string(), + '#' => "%23".to_string(), + '+' => "%2B".to_string(), + '%' => "%25".to_string(), + c if c.is_ascii_alphanumeric() || "-_.~".contains(c) => c.to_string(), + c => format!("%{:02X}", c as u32), + }) + .collect() + } + + /// Build the search URL from a query + fn build_search_url(&self, search_term: &str) -> String { + let encoded = Self::url_encode(search_term); + self.url_template.replace("{query}", &encoded) + } + + /// Evaluate a query and return a PluginItem if valid + fn evaluate(&self, query: &str) -> Option { + let search_term = Self::extract_search_term(query)?; + + if search_term.is_empty() { + return None; + } + + let url = self.build_search_url(search_term); + + // Use xdg-open to open the browser + let command = format!("xdg-open '{}'", url); + + Some( + PluginItem::new( + format!("websearch:{}", search_term), + format!("Search: {}", search_term), + command, + ) + .with_description("Open in browser") + .with_icon(PROVIDER_ICON) + .with_keywords(vec!["web".to_string(), "search".to_string()]), + ) + } +} + +// ============================================================================ +// Plugin Interface Implementation +// ============================================================================ + +extern "C" fn plugin_info() -> PluginInfo { + PluginInfo { + id: RString::from(PLUGIN_ID), + name: RString::from(PLUGIN_NAME), + version: RString::from(PLUGIN_VERSION), + description: RString::from(PLUGIN_DESCRIPTION), + api_version: API_VERSION, + } +} + +extern "C" fn plugin_providers() -> RVec { + vec![ProviderInfo { + id: RString::from(PROVIDER_ID), + name: RString::from(PROVIDER_NAME), + prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)), + icon: RString::from(PROVIDER_ICON), + provider_type: ProviderKind::Dynamic, + type_id: RString::from(PROVIDER_TYPE_ID), + position: ProviderPosition::Normal, + priority: 9000, // Dynamic: web search + }] + .into() +} + +extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { + // TODO: Read search engine from config when plugin config is available + let state = Box::new(WebSearchState::new()); + ProviderHandle::from_box(state) +} + +extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec { + // Dynamic provider - refresh does nothing + RVec::new() +} + +extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec { + if handle.ptr.is_null() { + return RVec::new(); + } + + // SAFETY: We created this handle from Box + let state = unsafe { &*(handle.ptr as *const WebSearchState) }; + + let query_str = query.as_str(); + + match state.evaluate(query_str) { + Some(item) => vec![item].into(), + None => RVec::new(), + } +} + +extern "C" fn provider_drop(handle: ProviderHandle) { + if !handle.ptr.is_null() { + // SAFETY: We created this handle from Box + unsafe { + handle.drop_as::(); + } + } +} + +// Register the plugin vtable +owlry_plugin! { + info: plugin_info, + providers: plugin_providers, + init: provider_init, + refresh: provider_refresh, + query: provider_query, + drop: provider_drop, +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_search_term() { + assert_eq!( + WebSearchState::extract_search_term("? rust programming"), + Some("rust programming") + ); + assert_eq!( + WebSearchState::extract_search_term("?rust"), + Some("rust") + ); + assert_eq!( + WebSearchState::extract_search_term("web rust docs"), + Some("rust docs") + ); + assert_eq!( + WebSearchState::extract_search_term("search how to rust"), + Some("how to rust") + ); + } + + #[test] + fn test_url_encode() { + assert_eq!(WebSearchState::url_encode("hello world"), "hello+world"); + assert_eq!(WebSearchState::url_encode("foo&bar"), "foo%26bar"); + assert_eq!(WebSearchState::url_encode("a=b"), "a%3Db"); + assert_eq!(WebSearchState::url_encode("test?query"), "test%3Fquery"); + } + + #[test] + fn test_build_search_url() { + let state = WebSearchState::with_engine("duckduckgo"); + let url = state.build_search_url("rust programming"); + assert_eq!(url, "https://duckduckgo.com/?q=rust+programming"); + } + + #[test] + fn test_build_search_url_google() { + let state = WebSearchState::with_engine("google"); + let url = state.build_search_url("rust"); + assert_eq!(url, "https://www.google.com/search?q=rust"); + } + + #[test] + fn test_evaluate() { + let state = WebSearchState::new(); + let item = state.evaluate("? rust docs").unwrap(); + assert_eq!(item.name.as_str(), "Search: rust docs"); + assert!(item.command.as_str().contains("xdg-open")); + assert!(item.command.as_str().contains("duckduckgo")); + } + + #[test] + fn test_evaluate_empty() { + let state = WebSearchState::new(); + assert!(state.evaluate("?").is_none()); + assert!(state.evaluate("? ").is_none()); + } + + #[test] + fn test_custom_url_template() { + let state = WebSearchState::with_engine("https://custom.search/q={query}"); + let url = state.build_search_url("test"); + assert_eq!(url, "https://custom.search/q=test"); + } + + #[test] + fn test_fallback_to_default() { + let state = WebSearchState::with_engine("nonexistent"); + let url = state.build_search_url("test"); + assert!(url.contains("duckduckgo")); // Falls back to default + } +} diff --git a/crates/owlry-rune/Cargo.toml b/crates/owlry-rune/Cargo.toml new file mode 100644 index 0000000..c86e5c0 --- /dev/null +++ b/crates/owlry-rune/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "owlry-rune" +version = "0.4.10" +edition = "2024" +rust-version = "1.90" +description = "Rune scripting runtime for owlry plugins" +license = "GPL-3.0-or-later" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +# Shared plugin API +owlry-plugin-api = { path = "/home/cnachtigall/ssd/git/archive/owlibou/owlry/crates/owlry-plugin-api" } + +# Rune scripting language +rune = "0.14" +rune-modules = { version = "0.14", features = ["full"] } + +# Logging +log = "0.4" +env_logger = "0.11" + +# HTTP client for network API +reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Configuration parsing +toml = "0.8" + +# Semantic versioning +semver = "1" + +# Date/time +chrono = "0.4" + +# Directory paths +dirs = "5" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/owlry-rune/src/api.rs b/crates/owlry-rune/src/api.rs new file mode 100644 index 0000000..5f28498 --- /dev/null +++ b/crates/owlry-rune/src/api.rs @@ -0,0 +1,130 @@ +//! Owlry API bindings for Rune plugins +//! +//! This module provides the `owlry` module that Rune plugins can use. + +use rune::{ContextError, Module}; +use std::sync::Mutex; + +use owlry_plugin_api::{PluginItem, RString}; + +/// Provider registration info +#[derive(Debug, Clone)] +pub struct ProviderRegistration { + pub name: String, + pub display_name: String, + pub type_id: String, + pub default_icon: String, + pub is_static: bool, + pub prefix: Option, +} + +/// An item returned by a provider +/// +/// Used for converting Rune plugin items to FFI format. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct Item { + pub id: String, + pub name: String, + pub description: Option, + pub icon: Option, + pub command: String, + pub terminal: bool, + pub keywords: Vec, +} + +impl Item { + /// Convert to PluginItem for FFI + #[allow(dead_code)] + pub fn to_plugin_item(&self) -> PluginItem { + let mut item = PluginItem::new( + RString::from(self.id.as_str()), + RString::from(self.name.as_str()), + RString::from(self.command.as_str()), + ); + + if let Some(ref desc) = self.description { + item = item.with_description(desc.clone()); + } + if let Some(ref icon) = self.icon { + item = item.with_icon(icon.clone()); + } + + item.with_terminal(self.terminal) + .with_keywords(self.keywords.clone()) + } +} + +/// Global state for provider registrations (thread-safe) +pub static REGISTRATIONS: Mutex> = Mutex::new(Vec::new()); + +/// Create the owlry module for Rune +pub fn module() -> Result { + let mut module = Module::with_crate("owlry")?; + + // Register logging functions using builder pattern + module.function("log_info", log_info).build()?; + module.function("log_debug", log_debug).build()?; + module.function("log_warn", log_warn).build()?; + module.function("log_error", log_error).build()?; + + Ok(module) +} + +// ============================================================================ +// Logging Functions +// ============================================================================ + +fn log_info(message: &str) { + log::info!("[Rune] {}", message); +} + +fn log_debug(message: &str) { + log::debug!("[Rune] {}", message); +} + +fn log_warn(message: &str) { + log::warn!("[Rune] {}", message); +} + +fn log_error(message: &str) { + log::error!("[Rune] {}", message); +} + +/// Get all provider registrations +pub fn get_registrations() -> Vec { + REGISTRATIONS.lock().unwrap().clone() +} + +/// Clear all registrations (for testing or reloading) +pub fn clear_registrations() { + REGISTRATIONS.lock().unwrap().clear(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_item_creation() { + let item = Item { + id: "test-1".to_string(), + name: "Test Item".to_string(), + description: Some("A test".to_string()), + icon: Some("test-icon".to_string()), + command: "echo test".to_string(), + terminal: false, + keywords: vec!["test".to_string()], + }; + + let plugin_item = item.to_plugin_item(); + assert_eq!(plugin_item.id.as_str(), "test-1"); + assert_eq!(plugin_item.name.as_str(), "Test Item"); + } + + #[test] + fn test_module_creation() { + let module = module(); + assert!(module.is_ok()); + } +} diff --git a/crates/owlry-rune/src/lib.rs b/crates/owlry-rune/src/lib.rs new file mode 100644 index 0000000..4476840 --- /dev/null +++ b/crates/owlry-rune/src/lib.rs @@ -0,0 +1,251 @@ +//! Owlry Rune Runtime +//! +//! This crate provides a Rune scripting runtime for owlry user plugins. +//! It is loaded dynamically by the core when installed. +//! +//! # Architecture +//! +//! The runtime exports a C-compatible vtable that the core uses to: +//! 1. Initialize the runtime with a plugins directory +//! 2. Get a list of providers from loaded plugins +//! 3. Refresh/query providers +//! 4. Clean up resources +//! +//! # Plugin Structure +//! +//! Rune plugins live in `~/.config/owlry/plugins//`: +//! ```text +//! my-plugin/ +//! plugin.toml # Manifest +//! init.rn # Entry point (Rune script) +//! ``` + +mod api; +mod loader; +mod manifest; +mod runtime; + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Mutex; + +use owlry_plugin_api::{PluginItem, ROption, RStr, RString, RVec}; + +pub use loader::LoadedPlugin; +pub use manifest::PluginManifest; + +// ============================================================================ +// Runtime VTable (C-compatible interface) +// ============================================================================ + +/// Information about this runtime +#[repr(C)] +pub struct RuntimeInfo { + pub name: RString, + pub version: RString, +} + +/// Information about a provider from a plugin +#[repr(C)] +#[derive(Clone)] +pub struct RuneProviderInfo { + pub name: RString, + pub display_name: RString, + pub type_id: RString, + pub default_icon: RString, + pub is_static: bool, + pub prefix: ROption, +} + +/// Opaque handle to runtime state +#[repr(transparent)] +#[derive(Clone, Copy)] +pub struct RuntimeHandle(pub *mut ()); + +/// Runtime state managed by the handle +struct RuntimeState { + plugins: HashMap, + providers: Vec, +} + +/// VTable for the Rune runtime +#[repr(C)] +pub struct RuneRuntimeVTable { + pub info: extern "C" fn() -> RuntimeInfo, + pub init: extern "C" fn(plugins_dir: RStr<'_>) -> RuntimeHandle, + pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec, + pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec, + pub query: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>, query: RStr<'_>) -> RVec, + pub drop: extern "C" fn(handle: RuntimeHandle), +} + +// ============================================================================ +// VTable Implementation +// ============================================================================ + +extern "C" fn runtime_info() -> RuntimeInfo { + RuntimeInfo { + name: RString::from("rune"), + version: RString::from(env!("CARGO_PKG_VERSION")), + } +} + +extern "C" fn runtime_init(plugins_dir: RStr<'_>) -> RuntimeHandle { + let _ = env_logger::try_init(); + + let plugins_dir = PathBuf::from(plugins_dir.as_str()); + log::info!("Initializing Rune runtime with plugins from: {}", plugins_dir.display()); + + let mut state = RuntimeState { + plugins: HashMap::new(), + providers: Vec::new(), + }; + + // Discover and load Rune plugins + match loader::discover_rune_plugins(&plugins_dir) { + Ok(plugins) => { + for (id, plugin) in plugins { + // Collect provider info before storing plugin + for reg in plugin.provider_registrations() { + state.providers.push(RuneProviderInfo { + name: RString::from(reg.name.as_str()), + display_name: RString::from(reg.display_name.as_str()), + type_id: RString::from(reg.type_id.as_str()), + default_icon: RString::from(reg.default_icon.as_str()), + is_static: reg.is_static, + prefix: reg.prefix.as_ref() + .map(|p| RString::from(p.as_str())) + .into(), + }); + } + state.plugins.insert(id, plugin); + } + log::info!("Loaded {} Rune plugin(s) with {} provider(s)", + state.plugins.len(), state.providers.len()); + } + Err(e) => { + log::error!("Failed to discover Rune plugins: {}", e); + } + } + + // Box and leak the state, returning an opaque handle + let boxed = Box::new(Mutex::new(state)); + RuntimeHandle(Box::into_raw(boxed) as *mut ()) +} + +extern "C" fn runtime_providers(handle: RuntimeHandle) -> RVec { + let state = unsafe { &*(handle.0 as *const Mutex) }; + let guard = state.lock().unwrap(); + guard.providers.clone().into_iter().collect() +} + +extern "C" fn runtime_refresh(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec { + let state = unsafe { &*(handle.0 as *const Mutex) }; + let mut guard = state.lock().unwrap(); + + let provider_name = provider_id.as_str(); + + // Find the plugin that provides this provider + for plugin in guard.plugins.values_mut() { + if plugin.provides_provider(provider_name) { + match plugin.refresh_provider(provider_name) { + Ok(items) => return items.into_iter().collect(), + Err(e) => { + log::error!("Failed to refresh provider '{}': {}", provider_name, e); + return RVec::new(); + } + } + } + } + + log::warn!("Provider '{}' not found", provider_name); + RVec::new() +} + +extern "C" fn runtime_query( + handle: RuntimeHandle, + provider_id: RStr<'_>, + query: RStr<'_>, +) -> RVec { + let state = unsafe { &*(handle.0 as *const Mutex) }; + let mut guard = state.lock().unwrap(); + + let provider_name = provider_id.as_str(); + let query_str = query.as_str(); + + // Find the plugin that provides this provider + for plugin in guard.plugins.values_mut() { + if plugin.provides_provider(provider_name) { + match plugin.query_provider(provider_name, query_str) { + Ok(items) => return items.into_iter().collect(), + Err(e) => { + log::error!("Failed to query provider '{}': {}", provider_name, e); + return RVec::new(); + } + } + } + } + + log::warn!("Provider '{}' not found", provider_name); + RVec::new() +} + +extern "C" fn runtime_drop(handle: RuntimeHandle) { + if !handle.0.is_null() { + // SAFETY: We created this box in runtime_init + unsafe { + let _ = Box::from_raw(handle.0 as *mut Mutex); + } + log::info!("Rune runtime cleaned up"); + } +} + +/// Static vtable instance +static RUNE_RUNTIME_VTABLE: RuneRuntimeVTable = RuneRuntimeVTable { + info: runtime_info, + init: runtime_init, + providers: runtime_providers, + refresh: runtime_refresh, + query: runtime_query, + drop: runtime_drop, +}; + +/// Entry point - returns the runtime vtable +#[unsafe(no_mangle)] +pub extern "C" fn owlry_rune_runtime_vtable() -> &'static RuneRuntimeVTable { + &RUNE_RUNTIME_VTABLE +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_runtime_info() { + let info = runtime_info(); + assert_eq!(info.name.as_str(), "rune"); + assert!(!info.version.as_str().is_empty()); + } + + #[test] + fn test_runtime_lifecycle() { + // Create a temp directory for plugins + let temp = tempfile::TempDir::new().unwrap(); + let plugins_dir = temp.path().to_string_lossy(); + + // Initialize runtime + let handle = runtime_init(RStr::from_str(&plugins_dir)); + assert!(!handle.0.is_null()); + + // Get providers (should be empty with no plugins) + let providers = runtime_providers(handle); + assert!(providers.is_empty()); + + // Clean up + runtime_drop(handle); + } +} diff --git a/crates/owlry-rune/src/loader.rs b/crates/owlry-rune/src/loader.rs new file mode 100644 index 0000000..9c0a869 --- /dev/null +++ b/crates/owlry-rune/src/loader.rs @@ -0,0 +1,175 @@ +//! Rune plugin discovery and loading + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use rune::{Context, Unit}; + +use crate::api::{self, ProviderRegistration}; +use crate::manifest::PluginManifest; +use crate::runtime::{compile_source, create_context, create_vm, SandboxConfig}; + +use owlry_plugin_api::PluginItem; + +/// A loaded Rune plugin +pub struct LoadedPlugin { + pub manifest: PluginManifest, + pub path: PathBuf, + /// Context for creating new VMs (reserved for refresh/query implementation) + #[allow(dead_code)] + context: Context, + /// Compiled unit (reserved for refresh/query implementation) + #[allow(dead_code)] + unit: Arc, + registrations: Vec, +} + +impl LoadedPlugin { + /// Create and initialize a new plugin + pub fn new(manifest: PluginManifest, path: PathBuf) -> Result { + let sandbox = SandboxConfig::from_permissions(&manifest.permissions); + let context = create_context(&sandbox) + .map_err(|e| format!("Failed to create context: {}", e))?; + + let entry_path = path.join(&manifest.plugin.entry); + if !entry_path.exists() { + return Err(format!("Entry point not found: {}", entry_path.display())); + } + + // Clear previous registrations before loading + api::clear_registrations(); + + // Compile the source + let unit = compile_source(&context, &entry_path) + .map_err(|e| format!("Failed to compile: {}", e))?; + + // Run the entry point to register providers + let mut vm = create_vm(&context, unit.clone()) + .map_err(|e| format!("Failed to create VM: {}", e))?; + + // Execute the main function if it exists + match vm.call(rune::Hash::type_hash(["main"]), ()) { + Ok(result) => { + // Try to complete the execution + let _: () = rune::from_value(result) + .unwrap_or(()); + } + Err(_) => { + // No main function is okay + } + } + + // Collect registrations + let registrations = api::get_registrations(); + + log::info!( + "Loaded Rune plugin '{}' with {} provider(s)", + manifest.plugin.id, + registrations.len() + ); + + Ok(Self { + manifest, + path, + context, + unit, + registrations, + }) + } + + /// Get plugin ID + pub fn id(&self) -> &str { + &self.manifest.plugin.id + } + + /// Get provider registrations + pub fn provider_registrations(&self) -> &[ProviderRegistration] { + &self.registrations + } + + /// Check if this plugin provides a specific provider + pub fn provides_provider(&self, name: &str) -> bool { + self.registrations.iter().any(|r| r.name == name) + } + + /// Refresh a static provider (stub for now) + pub fn refresh_provider(&mut self, _name: &str) -> Result, String> { + // TODO: Implement provider refresh by calling Rune function + Ok(Vec::new()) + } + + /// Query a dynamic provider (stub for now) + pub fn query_provider(&mut self, _name: &str, _query: &str) -> Result, String> { + // TODO: Implement provider query by calling Rune function + Ok(Vec::new()) + } +} + +/// Discover Rune plugins in a directory +pub fn discover_rune_plugins(plugins_dir: &Path) -> Result, String> { + let mut plugins = HashMap::new(); + + if !plugins_dir.exists() { + log::debug!("Plugins directory does not exist: {}", plugins_dir.display()); + return Ok(plugins); + } + + let entries = std::fs::read_dir(plugins_dir) + .map_err(|e| format!("Failed to read plugins directory: {}", e))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + let manifest_path = path.join("plugin.toml"); + if !manifest_path.exists() { + continue; + } + + // Load manifest + let manifest = match PluginManifest::load(&manifest_path) { + Ok(m) => m, + Err(e) => { + log::warn!("Failed to load manifest at {}: {}", manifest_path.display(), e); + continue; + } + }; + + // Check if this is a Rune plugin (entry ends with .rn) + if !manifest.plugin.entry.ends_with(".rn") { + log::debug!("Skipping non-Rune plugin: {}", manifest.plugin.id); + continue; + } + + // Load the plugin + match LoadedPlugin::new(manifest.clone(), path.clone()) { + Ok(plugin) => { + let id = manifest.plugin.id.clone(); + plugins.insert(id, plugin); + } + Err(e) => { + log::warn!("Failed to load plugin '{}': {}", manifest.plugin.id, e); + } + } + } + + Ok(plugins) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_discover_empty_dir() { + let temp = TempDir::new().unwrap(); + let plugins = discover_rune_plugins(temp.path()).unwrap(); + assert!(plugins.is_empty()); + } +} diff --git a/crates/owlry-rune/src/manifest.rs b/crates/owlry-rune/src/manifest.rs new file mode 100644 index 0000000..7c7946b --- /dev/null +++ b/crates/owlry-rune/src/manifest.rs @@ -0,0 +1,155 @@ +//! Plugin manifest parsing for Rune plugins + +use serde::Deserialize; +use std::path::Path; + +/// Plugin manifest from plugin.toml +#[derive(Debug, Clone, Deserialize)] +pub struct PluginManifest { + pub plugin: PluginInfo, + #[serde(default)] + pub provides: PluginProvides, + #[serde(default)] + pub permissions: PluginPermissions, +} + +/// Core plugin information +#[derive(Debug, Clone, Deserialize)] +pub struct PluginInfo { + pub id: String, + pub name: String, + pub version: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub author: String, + #[serde(default = "default_owlry_version")] + pub owlry_version: String, + #[serde(default = "default_entry")] + pub entry: String, +} + +fn default_owlry_version() -> String { + ">=0.1.0".to_string() +} + +fn default_entry() -> String { + "init.rn".to_string() +} + +/// What the plugin provides +#[derive(Debug, Clone, Default, Deserialize)] +pub struct PluginProvides { + #[serde(default)] + pub providers: Vec, + #[serde(default)] + pub actions: bool, + #[serde(default)] + pub themes: Vec, + #[serde(default)] + pub hooks: bool, +} + +/// Plugin permissions +#[derive(Debug, Clone, Default, Deserialize)] +pub struct PluginPermissions { + #[serde(default)] + pub network: bool, + #[serde(default)] + pub filesystem: Vec, + #[serde(default)] + pub run_commands: Vec, +} + +impl PluginManifest { + /// Load manifest from a plugin.toml file + pub fn load(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("Failed to read manifest: {}", e))?; + let manifest: PluginManifest = toml::from_str(&content) + .map_err(|e| format!("Failed to parse manifest: {}", e))?; + manifest.validate()?; + Ok(manifest) + } + + /// Validate the manifest + fn validate(&self) -> Result<(), String> { + if self.plugin.id.is_empty() { + return Err("Plugin ID cannot be empty".to_string()); + } + + if !self.plugin.id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { + return Err("Plugin ID must be lowercase alphanumeric with hyphens".to_string()); + } + + // Validate version format + if semver::Version::parse(&self.plugin.version).is_err() { + return Err(format!("Invalid version format: {}", self.plugin.version)); + } + + // Rune plugins must have .rn entry point + if !self.plugin.entry.ends_with(".rn") { + return Err("Entry point must be a .rn file for Rune plugins".to_string()); + } + + Ok(()) + } + + /// Check compatibility with owlry version + pub fn is_compatible_with(&self, owlry_version: &str) -> bool { + let req = match semver::VersionReq::parse(&self.plugin.owlry_version) { + Ok(r) => r, + Err(_) => return false, + }; + let version = match semver::Version::parse(owlry_version) { + Ok(v) => v, + Err(_) => return false, + }; + req.matches(&version) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_minimal_manifest() { + let toml_str = r#" +[plugin] +id = "test-plugin" +name = "Test Plugin" +version = "1.0.0" +"#; + let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); + assert_eq!(manifest.plugin.id, "test-plugin"); + assert_eq!(manifest.plugin.entry, "init.rn"); + } + + #[test] + fn test_validate_entry_point() { + let toml_str = r#" +[plugin] +id = "test" +name = "Test" +version = "1.0.0" +entry = "main.lua" +"#; + let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); + assert!(manifest.validate().is_err()); // .lua not allowed for Rune + } + + #[test] + fn test_version_compatibility() { + let toml_str = r#" +[plugin] +id = "test" +name = "Test" +version = "1.0.0" +owlry_version = ">=0.3.0" +"#; + let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); + assert!(manifest.is_compatible_with("0.3.5")); + assert!(!manifest.is_compatible_with("0.2.0")); + } +} diff --git a/crates/owlry-rune/src/runtime.rs b/crates/owlry-rune/src/runtime.rs new file mode 100644 index 0000000..7b60310 --- /dev/null +++ b/crates/owlry-rune/src/runtime.rs @@ -0,0 +1,160 @@ +//! Rune VM runtime creation and sandboxing + +use rune::{Context, Diagnostics, Source, Sources, Unit, Vm}; +use std::path::Path; +use std::sync::Arc; + +use crate::manifest::PluginPermissions; + +/// Configuration for the Rune sandbox +/// +/// Some fields are reserved for future sandbox enforcement. +#[derive(Debug, Clone)] +#[allow(dead_code)] +#[derive(Default)] +pub struct SandboxConfig { + /// Allow network/HTTP operations + pub network: bool, + /// Allow filesystem operations + pub filesystem: bool, + /// Allowed filesystem paths (reserved for future sandbox enforcement) + pub allowed_paths: Vec, + /// Allow running external commands (reserved for future sandbox enforcement) + pub run_commands: bool, + /// Allowed commands (reserved for future sandbox enforcement) + pub allowed_commands: Vec, +} + + +impl SandboxConfig { + /// Create sandbox config from plugin permissions + pub fn from_permissions(permissions: &PluginPermissions) -> Self { + Self { + network: permissions.network, + filesystem: !permissions.filesystem.is_empty(), + allowed_paths: permissions.filesystem.clone(), + run_commands: !permissions.run_commands.is_empty(), + allowed_commands: permissions.run_commands.clone(), + } + } +} + +/// Create a Rune context with owlry API modules +pub fn create_context(sandbox: &SandboxConfig) -> Result { + let mut context = Context::with_default_modules()?; + + // Add standard modules based on permissions + if sandbox.network { + log::debug!("Network access enabled for Rune plugin"); + } + + if sandbox.filesystem { + log::debug!("Filesystem access enabled for Rune plugin"); + } + + // Add owlry API module + context.install(crate::api::module()?)?; + + Ok(context) +} + +/// Compile Rune source code into a Unit +pub fn compile_source( + context: &Context, + source_path: &Path, +) -> Result, CompileError> { + let source_content = std::fs::read_to_string(source_path) + .map_err(|e| CompileError::Io(e.to_string()))?; + + let source_name = source_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("init.rn"); + + let mut sources = Sources::new(); + sources + .insert(Source::new(source_name, &source_content).map_err(|e| CompileError::Compile(e.to_string()))?) + .map_err(|e| CompileError::Compile(format!("Failed to insert source: {}", e)))?; + + let mut diagnostics = Diagnostics::new(); + + let result = rune::prepare(&mut sources) + .with_context(context) + .with_diagnostics(&mut diagnostics) + .build(); + + match result { + Ok(unit) => Ok(Arc::new(unit)), + Err(e) => { + // Collect error messages + let mut error_msg = format!("Compilation failed: {}", e); + for diagnostic in diagnostics.diagnostics() { + error_msg.push_str(&format!("\n {:?}", diagnostic)); + } + Err(CompileError::Compile(error_msg)) + } + } +} + +/// Create a new Rune VM from compiled unit +pub fn create_vm( + context: &Context, + unit: Arc, +) -> Result { + let runtime = Arc::new( + context.runtime() + .map_err(|e| CompileError::Compile(format!("Failed to get runtime: {}", e)))? + ); + Ok(Vm::new(runtime, unit)) +} + +/// Error type for compilation +#[derive(Debug)] +pub enum CompileError { + Io(String), + Compile(String), +} + +impl std::fmt::Display for CompileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CompileError::Io(e) => write!(f, "IO error: {}", e), + CompileError::Compile(e) => write!(f, "Compile error: {}", e), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sandbox_config_default() { + let config = SandboxConfig::default(); + assert!(!config.network); + assert!(!config.filesystem); + assert!(!config.run_commands); + } + + #[test] + fn test_sandbox_from_permissions() { + let permissions = PluginPermissions { + network: true, + filesystem: vec!["~/.config".to_string()], + run_commands: vec!["notify-send".to_string()], + }; + let config = SandboxConfig::from_permissions(&permissions); + assert!(config.network); + assert!(config.filesystem); + assert!(config.run_commands); + assert_eq!(config.allowed_paths, vec!["~/.config"]); + assert_eq!(config.allowed_commands, vec!["notify-send"]); + } + + #[test] + fn test_create_context() { + let config = SandboxConfig::default(); + let context = create_context(&config); + assert!(context.is_ok()); + } +}