diff --git a/.gitignore b/.gitignore index 4cca217..1588700 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,13 @@ /target CLAUDE.md media.md + +# AUR packages (each is its own git repo for aur.archlinux.org) +aur/*/.git/ +aur/*/pkg/ +aur/*/src/ +aur/*/*.tar.zst +aur/*/*.tar.gz +aur/*/*.tar.xz +aur/*/*.pkg.tar.* +# Keep PKGBUILD and .SRCINFO tracked diff --git a/Cargo.lock b/Cargo.lock index c20a4b2..63b83af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,72 @@ # 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 0.7.4", + "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" @@ -70,6 +136,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[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-broadcast" version = "0.7.2" @@ -94,6 +178,33 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ec5f6c2f8bc326c994cb9e241cc257ddaba9afa8555a43cffbb5dd86efaa37" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + [[package]] name = "async-io" version = "2.6.0" @@ -149,7 +260,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -184,7 +295,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -226,6 +337,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "blocking" version = "1.6.2" @@ -328,7 +448,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -362,7 +482,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -371,12 +491,39 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[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.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "compression-codecs" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f7ac3e5b97fdce45e8922fb05cae2c37f7bbd63d30dd94821dacfd8f3f2bf2" +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 = "concurrent-queue" version = "2.5.0" @@ -386,12 +533,46 @@ dependencies = [ "crossbeam-utils", ] +[[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-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 = "cpufeatures" version = "0.2.17" @@ -401,6 +582,30 @@ dependencies = [ "libc", ] +[[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" @@ -417,6 +622,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -448,6 +662,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -456,7 +680,22 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", +] + +[[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]] @@ -483,7 +722,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -496,6 +735,12 @@ dependencies = [ "regex", ] +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "env_logger" version = "0.11.8" @@ -515,6 +760,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -568,12 +824,37 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +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 = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -652,7 +933,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -751,6 +1032,30 @@ dependencies = [ "system-deps", ] +[[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 0.2.1", + "windows-result 0.4.1", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -949,7 +1254,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -962,7 +1267,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1119,7 +1424,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1141,6 +1446,25 @@ dependencies = [ "system-deps", ] +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1214,6 +1538,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1242,6 +1567,22 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.19" @@ -1261,9 +1602,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1278,7 +1621,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1451,7 +1794,7 @@ checksum = "b787bebb543f8969132630c51fd0afab173a86c6abae56ff3b9e5e3e3f9f6e58" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1482,6 +1825,26 @@ version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +[[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 = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libredox" version = "0.1.12" @@ -1517,18 +1880,71 @@ dependencies = [ "winapi", ] +[[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 = "547.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edaf29e3517b49b8b746701e5648ccb5785cde1c119062cbabbc5d5cd115e42" +dependencies = [ + "cc", +] + +[[package]] +name = "luajit-src" +version = "210.5.12+a4f56a4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a8e7962a5368d5f264d045a5a255e90f9aa3fc1941ae15a8d2940d42cac671" +dependencies = [ + "cc", + "which", +] + +[[package]] +name = "mac-notification-sys" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -1538,6 +1954,15 @@ dependencies = [ "libc", ] +[[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.7.6" @@ -1563,6 +1988,22 @@ dependencies = [ "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" @@ -1574,6 +2015,95 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mlua" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1f5f8fbebc7db5f671671134b9321c4b9aa9adeafccfd9a8c020ae45c6a35d0" +dependencies = [ + "bstr", + "either", + "erased-serde", + "mlua-sys", + "num-traits", + "parking_lot", + "rustc-hash", + "rustversion", + "serde", + "serde-value", +] + +[[package]] +name = "mlua-sys" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380c1f7e2099cafcf40e51d3a9f20a346977587aa4d012eae1f043149a728a93" +dependencies = [ + "cc", + "cfg-if", + "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.111", +] + +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nix" version = "0.29.0" @@ -1587,12 +2117,118 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce" +[[package]] +name = "notify-rust" +version = "4.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus 5.12.0", +] + +[[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-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1622,6 +2258,45 @@ dependencies = [ "objc_id", ] +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc_id" version = "0.1.1" @@ -1636,6 +2311,10 @@ name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -1643,12 +2322,65 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -1673,15 +2405,186 @@ dependencies = [ "gtk4", "gtk4-layer-shell", "libc", + "libloading 0.8.9", "log", "meval", + "mlua", + "notify-rust", + "owlry-plugin-api", "reqwest", + "semver", "serde", "serde_json", + "tempfile", "thiserror 2.0.17", "tokio", "toml 0.8.23", - "zbus", + "zbus 4.4.0", +] + +[[package]] +name = "owlry-lua" +version = "0.1.0" +dependencies = [ + "abi_stable", + "chrono", + "dirs", + "meval", + "mlua", + "owlry-plugin-api", + "reqwest", + "semver", + "serde", + "serde_json", + "tempfile", + "toml 0.8.23", +] + +[[package]] +name = "owlry-plugin-api" +version = "0.1.0" +dependencies = [ + "abi_stable", + "serde", +] + +[[package]] +name = "owlry-plugin-bookmarks" +version = "0.1.0" +dependencies = [ + "abi_stable", + "dirs", + "owlry-plugin-api", + "serde", + "serde_json", +] + +[[package]] +name = "owlry-plugin-calculator" +version = "0.1.0" +dependencies = [ + "abi_stable", + "meval", + "owlry-plugin-api", +] + +[[package]] +name = "owlry-plugin-clipboard" +version = "0.1.0" +dependencies = [ + "abi_stable", + "owlry-plugin-api", +] + +[[package]] +name = "owlry-plugin-emoji" +version = "0.1.0" +dependencies = [ + "abi_stable", + "owlry-plugin-api", +] + +[[package]] +name = "owlry-plugin-filesearch" +version = "0.1.0" +dependencies = [ + "abi_stable", + "dirs", + "owlry-plugin-api", +] + +[[package]] +name = "owlry-plugin-media" +version = "0.1.0" +dependencies = [ + "abi_stable", + "owlry-plugin-api", +] + +[[package]] +name = "owlry-plugin-pomodoro" +version = "0.1.0" +dependencies = [ + "abi_stable", + "dirs", + "owlry-plugin-api", + "serde", + "serde_json", + "toml 0.8.23", +] + +[[package]] +name = "owlry-plugin-scripts" +version = "0.1.0" +dependencies = [ + "abi_stable", + "dirs", + "owlry-plugin-api", +] + +[[package]] +name = "owlry-plugin-ssh" +version = "0.1.0" +dependencies = [ + "abi_stable", + "dirs", + "owlry-plugin-api", +] + +[[package]] +name = "owlry-plugin-system" +version = "0.1.0" +dependencies = [ + "abi_stable", + "owlry-plugin-api", +] + +[[package]] +name = "owlry-plugin-systemd" +version = "0.1.0" +dependencies = [ + "abi_stable", + "owlry-plugin-api", +] + +[[package]] +name = "owlry-plugin-weather" +version = "0.1.0" +dependencies = [ + "abi_stable", + "dirs", + "owlry-plugin-api", + "reqwest", + "serde", + "serde_json", + "toml 0.8.23", +] + +[[package]] +name = "owlry-plugin-websearch" +version = "0.1.0" +dependencies = [ + "abi_stable", + "owlry-plugin-api", +] + +[[package]] +name = "owlry-rune" +version = "0.1.0" +dependencies = [ + "chrono", + "dirs", + "env_logger", + "log", + "owlry-plugin-api", + "reqwest", + "rune", + "rune-modules", + "semver", + "serde", + "serde_json", + "tempfile", + "toml 0.8.23", ] [[package]] @@ -1714,12 +2617,61 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[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 0.2.1", +] + +[[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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1787,6 +2739,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1814,6 +2772,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -1943,6 +2910,15 @@ 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" @@ -1983,6 +2959,15 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[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" @@ -1991,17 +2976,22 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "encoding_rs", "futures-channel", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -2012,6 +3002,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tower", "tower-http", @@ -2037,6 +3028,114 @@ dependencies = [ "windows-sys 0.52.0", ] +[[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.111", +] + +[[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.111", +] + +[[package]] +name = "rune-modules" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4ef5dc3546042989f4abc70d6b9f707a539d5cbb5cb2fb167f8fbe891e1b64" +dependencies = [ + "base64", + "nanorand", + "reqwest", + "rune", + "serde_json", + "tokio", + "toml 0.8.23", +] + +[[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.111", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -2112,6 +3211,50 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +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 = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -2128,6 +3271,16 @@ dependencies = [ "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" @@ -2145,7 +3298,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2169,7 +3322,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2213,6 +3366,15 @@ dependencies = [ "digest", ] +[[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" @@ -2229,6 +3391,18 @@ dependencies = [ "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.11" @@ -2275,6 +3449,17 @@ 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.111" @@ -2303,7 +3488,34 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", +] + +[[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.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "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]] @@ -2325,6 +3537,18 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml", + "thiserror 2.0.17", + "windows", + "windows-version", +] + [[package]] name = "temp-dir" version = "0.1.16" @@ -2344,6 +3568,15 @@ dependencies = [ "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" @@ -2370,7 +3603,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2381,7 +3614,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2393,6 +3626,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + [[package]] name = "tinystr" version = "0.8.2" @@ -2434,6 +3686,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -2444,6 +3706,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -2557,13 +3832,18 @@ 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", @@ -2600,7 +3880,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2610,6 +3890,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.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -2618,12 +3928,51 @@ 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 = "typenum" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "typewit" +version = "1.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c1ae7cc0fdb8b842d65d127cb981574b0d2b249b74d1c7a2986863dc134f71" + [[package]] name = "uds_windows" version = "1.1.0" @@ -2647,6 +3996,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "untrusted" version = "0.9.0" @@ -2677,6 +4032,29 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[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-compare" version = "0.2.1" @@ -2758,7 +4136,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.111", "wasm-bindgen-shared", ] @@ -2800,6 +4178,18 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2816,12 +4206,56 @@ 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" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -2830,9 +4264,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", ] [[package]] @@ -2843,7 +4288,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2854,22 +4299,67 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -2878,7 +4368,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2923,7 +4413,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2963,7 +4453,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -2974,6 +4464,24 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -3121,6 +4629,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen" version = "0.46.0" @@ -3174,7 +4688,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", "synstructure", ] @@ -3194,7 +4708,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.29.0", "ordered-stream", "rand 0.8.5", "serde", @@ -3206,9 +4720,43 @@ dependencies = [ "uds_windows", "windows-sys 0.52.0", "xdg-home", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "nix 0.30.1", + "ordered-stream", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros 5.12.0", + "zbus_names 4.2.0", + "zvariant 5.8.0", ] [[package]] @@ -3220,8 +4768,23 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", - "zvariant_utils", + "syn 2.0.111", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.111", + "zbus_names 4.2.0", + "zvariant 5.8.0", + "zvariant_utils 3.2.1", ] [[package]] @@ -3232,7 +4795,19 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" dependencies = [ "serde", "static_assertions", - "zvariant", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +dependencies = [ + "serde", + "static_assertions", + "winnow", + "zvariant 5.8.0", ] [[package]] @@ -3252,7 +4827,7 @@ checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -3272,7 +4847,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", "synstructure", ] @@ -3312,7 +4887,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -3331,7 +4906,21 @@ dependencies = [ "enumflags2", "serde", "static_assertions", - "zvariant_derive", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive 5.8.0", + "zvariant_utils 3.2.1", ] [[package]] @@ -3343,8 +4932,21 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", - "zvariant_utils", + "syn 2.0.111", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.111", + "zvariant_utils 3.2.1", ] [[package]] @@ -3355,5 +4957,18 @@ checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", +] + +[[package]] +name = "zvariant_utils" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.111", + "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 6da1a77..cb0e36c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,88 +1,46 @@ -[package] -name = "owlry" -version = "0.3.9" +[workspace] +resolver = "2" +members = [ + "crates/owlry", + "crates/owlry-plugin-api", + "crates/owlry-plugin-calculator", + "crates/owlry-plugin-system", + "crates/owlry-plugin-ssh", + "crates/owlry-plugin-clipboard", + "crates/owlry-plugin-emoji", + "crates/owlry-plugin-scripts", + "crates/owlry-plugin-bookmarks", + "crates/owlry-plugin-websearch", + "crates/owlry-plugin-filesearch", + "crates/owlry-plugin-weather", + "crates/owlry-plugin-media", + "crates/owlry-plugin-pomodoro", + "crates/owlry-plugin-systemd", + "crates/owlry-lua", + "crates/owlry-rune", +] + +# Shared workspace settings +[workspace.package] edition = "2024" rust-version = "1.90" -description = "A lightweight, owl-themed application launcher for Wayland" -authors = ["Your Name "] license = "GPL-3.0-or-later" repository = "https://somegit.dev/Owlibou/owlry" -keywords = ["launcher", "wayland", "gtk4", "linux"] -categories = ["gui"] - -[dependencies] -# GTK4 for the UI -gtk4 = { version = "0.10", features = ["v4_12"] } - -# Layer shell support for Wayland overlay behavior -gtk4-layer-shell = "0.7" - -# Async runtime for non-blocking operations -tokio = { version = "1", features = ["rt", "sync", "process", "fs"] } - -# Fuzzy matching for search -fuzzy-matcher = "0.3" - -# XDG desktop entry parsing -freedesktop-desktop-entry = "0.7" - -# Directory utilities -dirs = "5" - -# Low-level syscalls for stdin detection -libc = "0.2" - -# Logging -log = "0.4" -env_logger = "0.11" - -# Error handling -thiserror = "2" - -# Configuration -serde = { version = "1", features = ["derive"] } -toml = "0.8" - -# CLI argument parsing -clap = { version = "4", features = ["derive"] } - -# Math expression evaluation for calculator -meval = "0.2" - -# JSON serialization for data persistence -serde_json = "1" - -# Date/time for frecency calculations -chrono = { version = "0.4", features = ["serde"] } - -# D-Bus for MPRIS media player integration -zbus = { version = "4", default-features = false, features = ["tokio"] } - -# HTTP client for weather API -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking"] } - -[build-dependencies] -# GResource compilation for bundled icons -glib-build-tools = "0.20" - -[features] -default = [] -# Enable verbose debug logging (for development/testing builds) -dev-logging = [] +# Release profile (shared across all crates) [profile.release] lto = true codegen-units = 1 panic = "abort" strip = true -opt-level = "z" # Optimize for size +opt-level = "z" [profile.dev] opt-level = 0 debug = true -# For installing a testable build: cargo install --path . --profile dev-install --features dev-logging +# For installing a testable build: cargo install --path crates/owlry --profile dev-install --features dev-logging [profile.dev-install] inherits = "release" strip = false -debug = 1 # Basic debug info for stack traces +debug = 1 diff --git a/README.md b/README.md index c4a73a1..91e4b3f 100644 --- a/README.md +++ b/README.md @@ -10,27 +10,57 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and ## Features -- **Provider-based architecture** — Search applications, commands, system actions, SSH hosts, clipboard history, bookmarks, emoji, and more +- **Modular plugin architecture** — Install only what you need - **Fuzzy search with tags** — Fast matching across names, descriptions, and category tags -- **Configurable tabs** — Customize header tabs and keyboard shortcuts +- **13 native plugins** — Calculator, clipboard, emoji, weather, media, and more +- **Widget providers** — Weather, media controls, and pomodoro timer at the top of results - **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:tag:development`, etc. -- **Calculator** — Quick math with `= 5+3` or `calc sin(pi/2)` -- **Web search** — Search the web with `? query` -- **File search** — Find files with `/ filename` (requires `fd` or `locate`) - **Frecency ranking** — Frequently/recently used items rank higher - **GTK4 theming** — System theme by default, with 9 built-in themes - **Wayland native** — Uses Layer Shell for proper overlay behavior +- **Extensible** — Create custom plugins in Lua or Rune ## Installation ### Arch Linux (AUR) ```bash +# Minimal core (applications + commands only) yay -S owlry -# or -paru -S owlry + +# Add individual plugins +yay -S owlry-plugin-calculator owlry-plugin-weather + +# Or install bundles: +yay -S owlry-essentials # calculator, system, ssh, scripts, bookmarks +yay -S owlry-widgets # weather, media, pomodoro +yay -S owlry-tools # clipboard, emoji, websearch, filesearch, systemd +yay -S owlry-full # everything + +# For custom Lua/Rune plugins +yay -S owlry-lua # Lua 5.4 runtime +yay -S owlry-rune # Rune runtime ``` +### Available Packages + +| Package | Description | +|---------|-------------| +| `owlry` | Core binary with applications and commands | +| `owlry-plugin-calculator` | Math expressions (`= 5+3`) | +| `owlry-plugin-system` | Shutdown, reboot, suspend, lock | +| `owlry-plugin-ssh` | SSH hosts from `~/.ssh/config` | +| `owlry-plugin-clipboard` | History via cliphist | +| `owlry-plugin-emoji` | 400+ searchable emoji | +| `owlry-plugin-scripts` | User scripts | +| `owlry-plugin-bookmarks` | Chrome, Brave, Edge bookmarks | +| `owlry-plugin-websearch` | Web search (`? query`) | +| `owlry-plugin-filesearch` | File search (`/ filename`) | +| `owlry-plugin-systemd` | User services with actions | +| `owlry-plugin-weather` | Weather widget | +| `owlry-plugin-media` | MPRIS media controls | +| `owlry-plugin-pomodoro` | Pomodoro timer widget | + ### Build from Source **Dependencies:** @@ -45,22 +75,25 @@ sudo apt install libgtk-4-dev libgtk4-layer-shell-dev sudo dnf install gtk4-devel gtk4-layer-shell-devel ``` -**Optional dependencies:** -```bash -# Clipboard history -sudo pacman -S cliphist wl-clipboard - -# File search (choose one) -sudo pacman -S fd # recommended -sudo pacman -S mlocate # alternative -``` - **Build (requires Rust 1.90+):** ```bash git clone https://somegit.dev/Owlibou/owlry.git cd owlry -cargo build --release -# Binary: target/release/owlry + +# Build core only +cargo build --release -p owlry + +# Build specific plugin +cargo build --release -p owlry-plugin-calculator + +# Build everything +cargo build --release --workspace +``` + +**Install plugins manually:** +```bash +sudo mkdir -p /usr/lib/owlry/plugins +sudo cp target/release/libowlry_plugin_*.so /usr/lib/owlry/plugins/ ``` ## Usage @@ -78,7 +111,7 @@ owlry --help # Show all options |-----|--------| | `Enter` | Launch selected item | | `Escape` | Close launcher / exit submenu | -| `↑` / `↓` | Navigate results | +| `Up` / `Down` | Navigate results | | `Tab` | Cycle filter tabs | | `Shift+Tab` | Cycle filter tabs (reverse) | | `Ctrl+1..9` | Toggle tab by position | @@ -112,7 +145,7 @@ owlry --help # Show all options | `/` | File search | `/ .bashrc` | | `find ` | File search | `find config` | -## File Locations +## Configuration Owlry follows the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/latest/): @@ -121,32 +154,17 @@ Owlry follows the [XDG Base Directory Specification](https://specifications.free | `~/.config/owlry/config.toml` | Main configuration | | `~/.config/owlry/themes/*.css` | Custom themes | | `~/.config/owlry/style.css` | CSS overrides | +| `~/.config/owlry/plugins/` | User plugins (Lua/Rune) | | `~/.local/share/owlry/scripts/` | User scripts | | `~/.local/share/owlry/frecency.json` | Usage history | -## Configuration - -Copy the example files: -```bash -# Config -mkdir -p ~/.config/owlry -cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml - -# Optional: CSS overrides -cp /usr/share/doc/owlry/style.example.css ~/.config/owlry/style.css - -# Optional: Example script -mkdir -p ~/.local/share/owlry/scripts -cp /usr/share/doc/owlry/scripts/example.sh ~/.local/share/owlry/scripts/ -``` - ### Example Configuration ```toml [general] show_icons = true max_results = 10 -tabs = ["app", "cmd", "uuctl"] # Header tabs (Ctrl+1, Ctrl+2, etc.) +tabs = ["app", "cmd", "uuctl"] # terminal_command = "kitty" # Auto-detected # launch_wrapper = "uwsm app --" # Auto-detected @@ -157,82 +175,43 @@ font_size = 14 border_radius = 12 # theme = "owl" # Or: catppuccin-mocha, nord, dracula, etc. -[providers] -applications = true -commands = true -uuctl = true -calculator = true -websearch = true -search_engine = "duckduckgo" -system = true -ssh = true -clipboard = true -bookmarks = true -emoji = true -scripts = true -files = true -frecency = true -frecency_weight = 0.3 +[plugins] +disabled = [] # Plugin IDs to disable, e.g., ["emoji", "pomodoro"] + +# Per-plugin configuration (new in 0.4.0) +[plugins.weather] +provider = "wttr.in" # or: openweathermap, open-meteo +location = "Berlin" # city name or "lat,lon" +# api_key = "..." # Required for OpenWeatherMap + +[plugins.pomodoro] +work_mins = 25 # Work session duration +break_mins = 5 # Break duration ``` -### Tab Configuration +## Plugin System -Customize which providers appear as header tabs: +Owlry uses a modular plugin architecture. Plugins are loaded from: + +- `/usr/lib/owlry/plugins/*.so` — System plugins (AUR packages) +- `~/.config/owlry/plugins/` — User plugins (requires `owlry-lua` or `owlry-rune`) + +### Disabling Plugins + +Add plugin IDs to the disabled list in your config: ```toml -[general] -# Available: app, cmd, uuctl, bookmark, calc, clip, dmenu, -# emoji, file, script, ssh, sys, web -tabs = ["app", "cmd", "ssh", "sys"] +[plugins] +disabled = ["emoji", "pomodoro"] ``` -Keyboard shortcuts `Ctrl+1` through `Ctrl+9` map to tab positions. +### Creating Custom Plugins -## Providers - -| Provider | Description | Trigger | -|----------|-------------|---------| -| **Applications** | `.desktop` files from XDG directories | `:app` | -| **Commands** | Executables in `$PATH` | `:cmd` | -| **System** | Shutdown, reboot, suspend, lock, BIOS | `:sys` | -| **SSH** | Hosts from `~/.ssh/config` | `:ssh` | -| **Clipboard** | History via cliphist | `:clip` | -| **Bookmarks** | Chrome, Brave, Edge, Vivaldi | `:bm` | -| **Emoji** | 300+ searchable emoji | `:emoji` | -| **Scripts** | User scripts | `:script` | -| **Calculator** | Math expressions | `=` or `:calc` | -| **Web Search** | Configurable engine | `?` or `:web` | -| **Files** | fd/locate search | `/` or `:file` | -| **systemd** | User services with actions | `:uuctl` | - -### Tags - -Items are tagged for better search: -- **Applications**: Categories from `.desktop` files (development, utility, etc.) -- **System**: `power`, `system` -- **SSH**: `ssh` -- **Scripts**: `script` -- **systemd**: `systemd`, `service` - -Filter by tag with `:tag:tagname`: -``` -:tag:development # Show development apps -:tag:utility vim # Search utilities for "vim" -``` - -### Scripts - -Create executable scripts in `~/.local/share/owlry/scripts/`: - -```bash -mkdir -p ~/.local/share/owlry/scripts -cat > ~/.local/share/owlry/scripts/backup.sh << 'EOF' -#!/bin/bash -rsync -av ~/Documents /backup/ -notify-send "Backup complete" -EOF -chmod +x ~/.local/share/owlry/scripts/backup.sh -``` +See [docs/PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for: +- Native plugin development (Rust) +- Lua plugin development +- Rune plugin development +- Available APIs ## Theming @@ -271,23 +250,6 @@ Create `~/.config/owlry/themes/mytheme.css`: } ``` -### CSS Overrides - -For tweaks without a full theme, create `~/.config/owlry/style.css`: - -```css -/* Larger search input */ -.owlry-search { - font-size: 18px; - padding: 12px 16px; -} - -/* Hide tag badges */ -.owlry-tag-badge { - display: none; -} -``` - ### CSS Variables | Variable | Description | @@ -299,8 +261,21 @@ For tweaks without a full theme, create `~/.config/owlry/style.css`: | `--owlry-text-secondary` | Muted text | | `--owlry-accent` | Accent color | | `--owlry-accent-bright` | Bright accent | -| `--owlry-font-size` | Base font size | -| `--owlry-border-radius` | Corner radius | + +## Architecture + +``` +owlry (core) +├── Applications provider (XDG .desktop files) +├── Commands provider (PATH executables) +├── Dmenu provider (pipe compatibility) +└── Plugin loader + ├── /usr/lib/owlry/plugins/*.so (native plugins) + ├── /usr/lib/owlry/runtimes/ (Lua/Rune runtimes) + └── ~/.config/owlry/plugins/ (user plugins) +``` + +For detailed architecture information, see [CLAUDE.md](CLAUDE.md). ## License @@ -310,4 +285,5 @@ GNU General Public License v3.0 — see [LICENSE](LICENSE). - [GTK4](https://gtk.org/) — UI toolkit - [gtk4-layer-shell](https://github.com/wmww/gtk4-layer-shell) — Wayland Layer Shell +- [abi_stable](https://crates.io/crates/abi_stable) — ABI-stable Rust plugins - [fuzzy-matcher](https://crates.io/crates/fuzzy-matcher) — Fuzzy search diff --git a/crates/owlry-lua/Cargo.toml b/crates/owlry-lua/Cargo.toml new file mode 100644 index 0000000..8b5fe91 --- /dev/null +++ b/crates/owlry-lua/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "owlry-lua" +version = "0.1.0" +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 = "../owlry-plugin-api" } + +# ABI-stable types +abi_stable = "0.11" + +# Lua runtime +mlua = { version = "0.10", 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.12", 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-api/Cargo.toml b/crates/owlry-plugin-api/Cargo.toml new file mode 100644 index 0000000..b40cd16 --- /dev/null +++ b/crates/owlry-plugin-api/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "owlry-plugin-api" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Plugin API for owlry application launcher" +keywords = ["owlry", "plugin", "api"] +categories = ["api-bindings"] + +[dependencies] +# ABI-stable types for dynamic linking +abi_stable = "0.11" + +# Serialization for plugin config +serde = { version = "1", features = ["derive"] } diff --git a/crates/owlry-plugin-api/src/lib.rs b/crates/owlry-plugin-api/src/lib.rs new file mode 100644 index 0000000..fb8e408 --- /dev/null +++ b/crates/owlry-plugin-api/src/lib.rs @@ -0,0 +1,432 @@ +//! # Owlry Plugin API +//! +//! This crate provides the ABI-stable interface for owlry native plugins. +//! Plugins are compiled as dynamic libraries (.so) and loaded at runtime. +//! +//! ## Creating a Plugin +//! +//! ```ignore +//! use owlry_plugin_api::*; +//! +//! // Define your plugin's vtable +//! static VTABLE: PluginVTable = PluginVTable { +//! info: plugin_info, +//! providers: plugin_providers, +//! provider_init: my_provider_init, +//! provider_refresh: my_provider_refresh, +//! provider_query: my_provider_query, +//! provider_drop: my_provider_drop, +//! }; +//! +//! // Export the vtable +//! #[no_mangle] +//! pub extern "C" fn owlry_plugin_vtable() -> &'static PluginVTable { +//! &VTABLE +//! } +//! ``` + +use abi_stable::StableAbi; + +// Re-export abi_stable types for use by consumers (runtime loader, plugins) +pub use abi_stable::std_types::{ROption, RStr, RString, RVec}; + +/// Current plugin API version - plugins must match this +pub const API_VERSION: u32 = 1; + +/// Plugin metadata returned by the info function +#[repr(C)] +#[derive(StableAbi, Clone, Debug)] +pub struct PluginInfo { + /// Unique plugin identifier (e.g., "calculator", "weather") + pub id: RString, + /// Human-readable plugin name + pub name: RString, + /// Plugin version string + pub version: RString, + /// Short description of what the plugin provides + pub description: RString, + /// Plugin API version (must match API_VERSION) + pub api_version: u32, +} + +/// Information about a provider offered by a plugin +#[repr(C)] +#[derive(StableAbi, Clone, Debug)] +pub struct ProviderInfo { + /// Unique provider identifier within the plugin + pub id: RString, + /// Human-readable provider name + pub name: RString, + /// Optional prefix that activates this provider (e.g., "=" for calculator) + pub prefix: ROption, + /// Default icon name for results from this provider + pub icon: RString, + /// Provider type (static or dynamic) + pub provider_type: ProviderKind, + /// Short type identifier for UI badges (e.g., "calc", "web") + pub type_id: RString, +} + +/// Provider behavior type +#[repr(C)] +#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProviderKind { + /// Static providers load items once at startup via refresh() + Static, + /// Dynamic providers evaluate queries in real-time via query() + Dynamic, +} + +/// A single searchable/launchable item returned by providers +#[repr(C)] +#[derive(StableAbi, Clone, Debug)] +pub struct PluginItem { + /// Unique item identifier + pub id: RString, + /// Display name + pub name: RString, + /// Optional description shown below the name + pub description: ROption, + /// Optional icon name or path + pub icon: ROption, + /// Command to execute when selected + pub command: RString, + /// Whether to run in a terminal + pub terminal: bool, + /// Search keywords/tags for filtering + pub keywords: RVec, + /// Score boost for frecency (higher = more prominent) + pub score_boost: i32, +} + +impl PluginItem { + /// Create a new plugin item with required fields + pub fn new(id: impl Into, name: impl Into, command: impl Into) -> Self { + Self { + id: RString::from(id.into()), + name: RString::from(name.into()), + description: ROption::RNone, + icon: ROption::RNone, + command: RString::from(command.into()), + terminal: false, + keywords: RVec::new(), + score_boost: 0, + } + } + + /// Set the description + pub fn with_description(mut self, desc: impl Into) -> Self { + self.description = ROption::RSome(RString::from(desc.into())); + self + } + + /// Set the icon + pub fn with_icon(mut self, icon: impl Into) -> Self { + self.icon = ROption::RSome(RString::from(icon.into())); + self + } + + /// Set terminal mode + pub fn with_terminal(mut self, terminal: bool) -> Self { + self.terminal = terminal; + self + } + + /// Add keywords + pub fn with_keywords(mut self, keywords: Vec) -> Self { + self.keywords = keywords.into_iter().map(RString::from).collect(); + self + } + + /// Set score boost + pub fn with_score_boost(mut self, boost: i32) -> Self { + self.score_boost = boost; + self + } +} + +/// Plugin function table - defines the interface between owlry and plugins +/// +/// Every native plugin must export a function `owlry_plugin_vtable` that returns +/// a static reference to this structure. +#[repr(C)] +#[derive(StableAbi)] +pub struct PluginVTable { + /// Return plugin metadata + pub info: extern "C" fn() -> PluginInfo, + + /// Return list of providers this plugin offers + pub providers: extern "C" fn() -> RVec, + + /// Initialize a provider by ID, returns an opaque handle + /// The handle is passed to refresh/query/drop functions + pub provider_init: extern "C" fn(provider_id: RStr<'_>) -> ProviderHandle, + + /// Refresh a static provider's items + /// Called once at startup and when user requests refresh + pub provider_refresh: extern "C" fn(handle: ProviderHandle) -> RVec, + + /// Query a dynamic provider + /// Called on each keystroke for dynamic providers + pub provider_query: extern "C" fn(handle: ProviderHandle, query: RStr<'_>) -> RVec, + + /// Clean up a provider handle + pub provider_drop: extern "C" fn(handle: ProviderHandle), +} + +/// Opaque handle to a provider instance +/// Plugins can use this to store state between calls +#[repr(C)] +#[derive(StableAbi, Clone, Copy, Debug)] +pub struct ProviderHandle { + /// Opaque pointer to provider state + pub ptr: *mut (), +} + +impl ProviderHandle { + /// Create a null handle + pub fn null() -> Self { + Self { + ptr: std::ptr::null_mut(), + } + } + + /// Create a handle from a boxed value + /// The caller is responsible for calling drop to free the memory + pub fn from_box(value: Box) -> Self { + Self { + ptr: Box::into_raw(value) as *mut (), + } + } + + /// Convert handle back to a reference (unsafe) + /// + /// # Safety + /// The handle must have been created from a Box of the same type + pub unsafe fn as_ref(&self) -> Option<&T> { + // SAFETY: Caller guarantees the pointer was created from Box + unsafe { (self.ptr as *const T).as_ref() } + } + + /// Convert handle back to a mutable reference (unsafe) + /// + /// # Safety + /// The handle must have been created from a Box of the same type + pub unsafe fn as_mut(&mut self) -> Option<&mut T> { + // SAFETY: Caller guarantees the pointer was created from Box + unsafe { (self.ptr as *mut T).as_mut() } + } + + /// Drop the handle and free its memory (unsafe) + /// + /// # Safety + /// The handle must have been created from a Box of the same type + /// and must not be used after this call + pub unsafe fn drop_as(self) { + if !self.ptr.is_null() { + // SAFETY: Caller guarantees the pointer was created from Box + unsafe { drop(Box::from_raw(self.ptr as *mut T)) }; + } + } +} + +// ProviderHandle contains a raw pointer but we manage it carefully +unsafe impl Send for ProviderHandle {} +unsafe impl Sync for ProviderHandle {} + +// ============================================================================ +// Host API - Functions the host provides to plugins +// ============================================================================ + +/// Notification urgency level +#[repr(C)] +#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum NotifyUrgency { + /// Low priority notification + Low = 0, + /// Normal priority notification (default) + #[default] + Normal = 1, + /// Critical/urgent notification + Critical = 2, +} + +/// Host API function table +/// +/// This structure contains functions that the host (owlry) provides to plugins. +/// Plugins can call these functions to interact with the system. +#[repr(C)] +#[derive(StableAbi, Clone, Copy)] +pub struct HostAPI { + /// Send a notification to the user + /// Parameters: summary, body, icon (optional, empty string for none), urgency + pub notify: extern "C" fn( + summary: RStr<'_>, + body: RStr<'_>, + icon: RStr<'_>, + urgency: NotifyUrgency, + ), + + /// Log a message at info level + pub log_info: extern "C" fn(message: RStr<'_>), + + /// Log a message at warning level + pub log_warn: extern "C" fn(message: RStr<'_>), + + /// Log a message at error level + pub log_error: extern "C" fn(message: RStr<'_>), +} + +// Global host API pointer - set by the host when loading plugins +static mut HOST_API: Option<&'static HostAPI> = None; + +/// Initialize the host API (called by the host) +/// +/// # Safety +/// Must only be called once by the host before any plugins use the API +pub unsafe fn init_host_api(api: &'static HostAPI) { + // SAFETY: Caller guarantees this is called once before any plugins use the API + unsafe { + HOST_API = Some(api); + } +} + +/// Get the host API +/// +/// Returns None if the host hasn't initialized the API yet +pub fn host_api() -> Option<&'static HostAPI> { + // SAFETY: We only read the pointer, and it's set once at startup + unsafe { HOST_API } +} + +// ============================================================================ +// Convenience functions for plugins +// ============================================================================ + +/// Send a notification (convenience wrapper) +pub fn notify(summary: &str, body: &str) { + if let Some(api) = host_api() { + (api.notify)( + RStr::from_str(summary), + RStr::from_str(body), + RStr::from_str(""), + NotifyUrgency::Normal, + ); + } +} + +/// Send a notification with an icon (convenience wrapper) +pub fn notify_with_icon(summary: &str, body: &str, icon: &str) { + if let Some(api) = host_api() { + (api.notify)( + RStr::from_str(summary), + RStr::from_str(body), + RStr::from_str(icon), + NotifyUrgency::Normal, + ); + } +} + +/// Send a notification with full options (convenience wrapper) +pub fn notify_with_urgency(summary: &str, body: &str, icon: &str, urgency: NotifyUrgency) { + if let Some(api) = host_api() { + (api.notify)( + RStr::from_str(summary), + RStr::from_str(body), + RStr::from_str(icon), + urgency, + ); + } +} + +/// Log an info message (convenience wrapper) +pub fn log_info(message: &str) { + if let Some(api) = host_api() { + (api.log_info)(RStr::from_str(message)); + } +} + +/// Log a warning message (convenience wrapper) +pub fn log_warn(message: &str) { + if let Some(api) = host_api() { + (api.log_warn)(RStr::from_str(message)); + } +} + +/// Log an error message (convenience wrapper) +pub fn log_error(message: &str) { + if let Some(api) = host_api() { + (api.log_error)(RStr::from_str(message)); + } +} + +/// Helper macro for defining plugin vtables +/// +/// Usage: +/// ```ignore +/// owlry_plugin! { +/// info: my_plugin_info, +/// providers: my_providers, +/// init: my_init, +/// refresh: my_refresh, +/// query: my_query, +/// drop: my_drop, +/// } +/// ``` +#[macro_export] +macro_rules! owlry_plugin { + ( + info: $info:expr, + providers: $providers:expr, + init: $init:expr, + refresh: $refresh:expr, + query: $query:expr, + drop: $drop:expr $(,)? + ) => { + static OWLRY_PLUGIN_VTABLE: $crate::PluginVTable = $crate::PluginVTable { + info: $info, + providers: $providers, + provider_init: $init, + provider_refresh: $refresh, + provider_query: $query, + provider_drop: $drop, + }; + + #[unsafe(no_mangle)] + pub extern "C" fn owlry_plugin_vtable() -> &'static $crate::PluginVTable { + &OWLRY_PLUGIN_VTABLE + } + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_plugin_item_builder() { + let item = PluginItem::new("test-id", "Test Item", "echo hello") + .with_description("A test item") + .with_icon("test-icon") + .with_terminal(true) + .with_keywords(vec!["test".to_string(), "example".to_string()]) + .with_score_boost(100); + + assert_eq!(item.id.as_str(), "test-id"); + assert_eq!(item.name.as_str(), "Test Item"); + assert_eq!(item.command.as_str(), "echo hello"); + assert!(item.terminal); + assert_eq!(item.score_boost, 100); + } + + #[test] + fn test_provider_handle() { + let value = Box::new(42i32); + let handle = ProviderHandle::from_box(value); + + unsafe { + assert_eq!(*handle.as_ref::().unwrap(), 42); + handle.drop_as::(); + } + } +} diff --git a/crates/owlry-plugin-bookmarks/Cargo.toml b/crates/owlry-plugin-bookmarks/Cargo.toml new file mode 100644 index 0000000..c166f09 --- /dev/null +++ b/crates/owlry-plugin-bookmarks/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "owlry-plugin-bookmarks" +version = "0.1.0" +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 = "../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" diff --git a/crates/owlry-plugin-bookmarks/src/lib.rs b/crates/owlry-plugin-bookmarks/src/lib.rs new file mode 100644 index 0000000..052334d --- /dev/null +++ b/crates/owlry-plugin-bookmarks/src/lib.rs @@ -0,0 +1,301 @@ +//! Bookmarks Plugin for Owlry +//! +//! A static provider that reads browser bookmarks from Chrome/Chromium. +//! Firefox support would require the rusqlite crate for reading places.sqlite. +//! +//! Supported browsers: +//! - Chrome +//! - Chromium +//! - Brave +//! - Edge + +use abi_stable::std_types::{ROption, RStr, RString, RVec}; +use owlry_plugin_api::{ + owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION, +}; +use serde::Deserialize; +use std::fs; +use std::path::PathBuf; + +// 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 = "web-browser"; +const PROVIDER_TYPE_ID: &str = "bookmarks"; + +/// Bookmarks provider state - holds cached items +struct BookmarksState { + items: Vec, +} + +impl BookmarksState { + fn new() -> Self { + Self { items: Vec::new() } + } + + 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 load_bookmarks(&mut self) { + self.items.clear(); + + // Load Chrome/Chromium bookmarks + for path in Self::chromium_bookmark_paths() { + if path.exists() { + self.read_chrome_bookmarks(&path); + } + } + } + + fn read_chrome_bookmarks(&mut self, path: &PathBuf) { + 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, + }; + + // Process bookmark bar and other folders + if let Some(roots) = bookmarks.roots { + if let Some(bar) = roots.bookmark_bar { + self.process_chrome_folder(&bar); + } + if let Some(other) = roots.other { + self.process_chrome_folder(&other); + } + if let Some(synced) = roots.synced { + self.process_chrome_folder(&synced); + } + } + } + + fn process_chrome_folder(&mut self, folder: &ChromeBookmarkNode) { + 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()); + + self.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(), "web".to_string()]), + ); + } + } + Some("folder") => { + // Recursively process subfolders + self.process_chrome_folder(child); + } + _ => {} + } + } + } + } +} + +// 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), + }] + .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_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 state = BookmarksState::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, + }, + ]), + }; + + state.process_chrome_folder(&folder); + assert_eq!(state.items.len(), 1); + assert_eq!(state.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..12b242f --- /dev/null +++ b/crates/owlry-plugin-calculator/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "owlry-plugin-calculator" +version = "0.1.0" +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 = "../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..e4cb49d --- /dev/null +++ b/crates/owlry-plugin-calculator/src/lib.rs @@ -0,0 +1,228 @@ +//! 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, 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), + }] + .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..0a759eb --- /dev/null +++ b/crates/owlry-plugin-clipboard/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "owlry-plugin-clipboard" +version = "0.1.0" +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 = "../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..00c6535 --- /dev/null +++ b/crates/owlry-plugin-clipboard/src/lib.rs @@ -0,0 +1,256 @@ +//! 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, 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), + }] + .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..611816f --- /dev/null +++ b/crates/owlry-plugin-emoji/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "owlry-plugin-emoji" +version = "0.1.0" +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 = "../owlry-plugin-api" } + +# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) +abi_stable = "0.11" diff --git a/src/providers/emoji.rs b/crates/owlry-plugin-emoji/src/lib.rs similarity index 78% rename from src/providers/emoji.rs rename to crates/owlry-plugin-emoji/src/lib.rs index 810c047..df0a5b0 100644 --- a/src/providers/emoji.rs +++ b/crates/owlry-plugin-emoji/src/lib.rs @@ -1,12 +1,37 @@ -use crate::providers::{LaunchItem, Provider, ProviderType}; +//! 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. -/// Emoji picker provider - search and copy emojis -pub struct EmojiProvider { - items: Vec, +use abi_stable::std_types::{ROption, RStr, RString, RVec}; +use owlry_plugin_api::{ + owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, 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 EmojiProvider { - pub fn new() -> Self { +impl EmojiState { + fn new() -> Self { Self { items: Vec::new() } } @@ -60,14 +85,13 @@ impl EmojiProvider { ("🤠", "cowboy hat face", "yeehaw western"), ("🥳", "partying face", "celebration party"), ("🥸", "disguised face", "incognito"), - ("😎", "cool face", "sunglasses"), ("🤡", "clown face", "circus"), ("👻", "ghost", "halloween spooky"), ("💀", "skull", "dead death"), ("☠️", "skull and crossbones", "danger death"), ("👽", "alien", "ufo extraterrestrial"), ("🤖", "robot", "bot android"), - ("💩", "pile of poo", "poop shit"), + ("💩", "pile of poo", "poop"), ("😈", "smiling face with horns", "devil evil"), ("👿", "angry face with horns", "devil evil"), // Gestures & People @@ -368,7 +392,6 @@ impl EmojiProvider { ("🌕", "full moon", ""), ("☀️", "sun", "sunny"), ("🌙", "crescent moon", "night"), - ("⭐", "star", ""), ("☁️", "cloud", ""), ("🌧️", "cloud with rain", "rainy"), ("⛈️", "cloud with lightning", "storm thunder"), @@ -394,58 +417,145 @@ impl EmojiProvider { ]; for (emoji, name, keywords) in emojis { - // Combine name and keywords for better searching - let search_text = format!("{} {}", name, keywords); - - self.items.push(LaunchItem { - id: format!("emoji:{}", emoji), - name: name.to_string(), - description: Some(format!("{} {}", emoji, keywords)), - icon: None, - provider: ProviderType::Emoji, - // Copy emoji to clipboard using wl-copy - command: format!("printf '%s' '{}' | wl-copy", emoji), - terminal: false, - tags: Vec::new(), // TODO: Extract category from emoji data - }); - - // Store the search text for matching (not used directly but could be) - let _ = search_text; + self.items.push( + PluginItem::new( + format!("emoji:{}", emoji), + name.to_string(), + format!("printf '%s' '{}' | wl-copy", emoji), + ) + .with_description(format!("{} {}", emoji, keywords)) + .with_keywords(vec![name.to_string(), keywords.to_string()]), + ); } } } -impl Provider for EmojiProvider { - fn name(&self) -> &str { - "Emoji" - } +// ============================================================================ +// Plugin Interface Implementation +// ============================================================================ - fn provider_type(&self) -> ProviderType { - ProviderType::Emoji - } - - fn refresh(&mut self) { - self.load_emojis(); - } - - fn items(&self) -> &[LaunchItem] { - &self.items +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), + }] + .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_provider() { - let mut provider = EmojiProvider::new(); - provider.refresh(); - assert!(provider.items().len() > 100); - // Emoji character is in description, name is the human-readable name - assert!(provider - .items() + 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() - .any(|i| i.description.as_ref().is_some_and(|d| d.contains("😀")))); + .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..8e2d4cf --- /dev/null +++ b/crates/owlry-plugin-filesearch/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "owlry-plugin-filesearch" +version = "0.1.0" +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 = "../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..e9bf9ca --- /dev/null +++ b/crates/owlry-plugin-filesearch/src/lib.rs @@ -0,0 +1,319 @@ +//! 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, 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), + }] + .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..6112477 --- /dev/null +++ b/crates/owlry-plugin-media/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "owlry-plugin-media" +version = "0.1.0" +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 = "../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..e21d931 --- /dev/null +++ b/crates/owlry-plugin-media/src/lib.rs @@ -0,0 +1,465 @@ +//! 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, 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), + }] + .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..efc858a --- /dev/null +++ b/crates/owlry-plugin-pomodoro/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "owlry-plugin-pomodoro" +version = "0.1.0" +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 = "../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..d160914 --- /dev/null +++ b/crates/owlry-plugin-pomodoro/src/lib.rs @@ -0,0 +1,476 @@ +//! 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, 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 { + if let Ok(toml) = content.parse::() { + // Try [plugins.pomodoro] first (new format) + if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table()) { + if 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), + }] + .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..8fe48bc --- /dev/null +++ b/crates/owlry-plugin-scripts/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "owlry-plugin-scripts" +version = "0.1.0" +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 = "../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..165d002 --- /dev/null +++ b/crates/owlry-plugin-scripts/src/lib.rs @@ -0,0 +1,287 @@ +//! 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, 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), + }] + .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..04b8268 --- /dev/null +++ b/crates/owlry-plugin-ssh/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "owlry-plugin-ssh" +version = "0.1.0" +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 = "../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..cf87249 --- /dev/null +++ b/crates/owlry-plugin-ssh/src/lib.rs @@ -0,0 +1,325 @@ +//! 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, 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), + }] + .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..78da615 --- /dev/null +++ b/crates/owlry-plugin-system/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "owlry-plugin-system" +version = "0.1.0" +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 = "../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..bd5065e --- /dev/null +++ b/crates/owlry-plugin-system/src/lib.rs @@ -0,0 +1,251 @@ +//! 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, 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), + }] + .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..8cd7725 --- /dev/null +++ b/crates/owlry-plugin-systemd/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "owlry-plugin-systemd" +version = "0.1.0" +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 = "../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..215dd71 --- /dev/null +++ b/crates/owlry-plugin-systemd/src/lib.rs @@ -0,0 +1,454 @@ +//! 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, 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), + }] + .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..7a38f1a --- /dev/null +++ b/crates/owlry-plugin-weather/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "owlry-plugin-weather" +version = "0.1.0" +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 = "../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.12", 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..55c9c49 --- /dev/null +++ b/crates/owlry-plugin-weather/src/lib.rs @@ -0,0 +1,751 @@ +//! 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, 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 { + if let Ok(toml) = content.parse::() { + // Try [plugins.weather] first (new format) + if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table()) { + if 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), + }] + .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..cd15e52 --- /dev/null +++ b/crates/owlry-plugin-websearch/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "owlry-plugin-websearch" +version = "0.1.0" +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 = "../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..287de1a --- /dev/null +++ b/crates/owlry-plugin-websearch/src/lib.rs @@ -0,0 +1,296 @@ +//! 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, 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), + }] + .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..718a816 --- /dev/null +++ b/crates/owlry-rune/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "owlry-rune" +version = "0.1.0" +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 = "../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.12", default-features = false, features = ["rustls-tls", "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()); + } +} diff --git a/crates/owlry/Cargo.toml b/crates/owlry/Cargo.toml new file mode 100644 index 0000000..905176d --- /dev/null +++ b/crates/owlry/Cargo.toml @@ -0,0 +1,92 @@ +[package] +name = "owlry" +version = "0.3.9" +edition = "2024" +rust-version = "1.90" +description = "A lightweight, owl-themed application launcher for Wayland" +authors = ["Your Name "] +license = "GPL-3.0-or-later" +repository = "https://somegit.dev/Owlibou/owlry" +keywords = ["launcher", "wayland", "gtk4", "linux"] +categories = ["gui"] + +[dependencies] +# Shared plugin API +owlry-plugin-api = { path = "../owlry-plugin-api" } + +# GTK4 for the UI +gtk4 = { version = "0.10", features = ["v4_12"] } + +# Layer shell support for Wayland overlay behavior +gtk4-layer-shell = "0.7" + +# Async runtime for non-blocking operations +tokio = { version = "1", features = ["rt", "sync", "process", "fs"] } + +# Fuzzy matching for search +fuzzy-matcher = "0.3" + +# XDG desktop entry parsing +freedesktop-desktop-entry = "0.7" + +# Directory utilities +dirs = "5" + +# Low-level syscalls for stdin detection +libc = "0.2" + +# Logging +log = "0.4" +env_logger = "0.11" + +# Error handling +thiserror = "2" + +# Configuration +serde = { version = "1", features = ["derive"] } +toml = "0.8" + +# CLI argument parsing +clap = { version = "4", features = ["derive"] } + +# Math expression evaluation for calculator +meval = "0.2" + +# JSON serialization for data persistence +serde_json = "1" + +# Date/time for frecency calculations +chrono = { version = "0.4", features = ["serde"] } + +# D-Bus for MPRIS media player integration +zbus = { version = "4", default-features = false, features = ["tokio"] } + +# HTTP client for weather API +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking"] } + +# Lua runtime for plugin system (optional - can be loaded dynamically via owlry-lua) +mlua = { version = "0.10", features = ["lua54", "vendored", "send", "serialize"], optional = true } + +# Semantic versioning for plugin compatibility +semver = "1" + +# Dynamic library loading for native plugins +libloading = "0.8" + +# Desktop notifications (freedesktop notification spec) +notify-rust = "4" + +[dev-dependencies] +# Temporary directories for tests +tempfile = "3" + +[build-dependencies] +# GResource compilation for bundled icons +glib-build-tools = "0.20" + +[features] +default = ["lua"] +# Enable verbose debug logging (for development/testing builds) +dev-logging = [] +# Enable built-in Lua runtime (disable to use external owlry-lua package) +lua = ["dep:mlua"] diff --git a/build.rs b/crates/owlry/build.rs similarity index 100% rename from build.rs rename to crates/owlry/build.rs diff --git a/src/app.rs b/crates/owlry/src/app.rs similarity index 56% rename from src/app.rs rename to crates/owlry/src/app.rs index 56d781c..6e5574d 100644 --- a/src/app.rs +++ b/crates/owlry/src/app.rs @@ -3,15 +3,21 @@ use crate::config::Config; use crate::data::FrecencyStore; use crate::filter::ProviderFilter; use crate::paths; -use crate::providers::{PomodoroConfig, ProviderManager, WeatherConfig, WeatherProviderType}; +use crate::plugins::native_loader::NativePluginLoader; +#[cfg(feature = "lua")] +use crate::plugins::PluginManager; +use crate::providers::native_provider::NativeProvider; +use crate::providers::Provider; // For name() method +use crate::providers::ProviderManager; use crate::theme; use crate::ui::MainWindow; use gtk4::prelude::*; use gtk4::{gio, Application, CssProvider}; use gtk4_layer_shell::{Edge, Layer, LayerShell}; -use log::debug; +use log::{debug, info, warn}; use std::cell::RefCell; use std::rc::Rc; +use std::sync::Arc; const APP_ID: &str = "org.owlry.launcher"; @@ -45,45 +51,25 @@ impl OwlryApp { .expect("Failed to register icon resources"); let config = Rc::new(RefCell::new(Config::load_or_default())); - let search_engine = config.borrow().providers.search_engine.clone(); - let terminal = config.borrow().general.terminal_command.clone(); - let media_enabled = config.borrow().providers.media; - // Build weather config if enabled - let weather_config = if config.borrow().providers.weather { - let cfg = config.borrow(); - Some(WeatherConfig { - provider: cfg.providers.weather_provider.parse().unwrap_or(WeatherProviderType::WttrIn), - api_key: cfg.providers.weather_api_key.clone(), - location: cfg.providers.weather_location.clone().unwrap_or_default(), - }) - } else { - None - }; + // Load native plugins from /usr/lib/owlry/plugins/ + let native_providers = Self::load_native_plugins(&config.borrow()); - // Build pomodoro config if enabled - let pomodoro_config = if config.borrow().providers.pomodoro { - let cfg = config.borrow(); - Some(PomodoroConfig { - work_mins: cfg.providers.pomodoro_work_mins, - break_mins: cfg.providers.pomodoro_break_mins, - }) - } else { - None - }; + // Create provider manager with native plugins + let mut provider_manager = ProviderManager::with_native_plugins(native_providers); - let providers = Rc::new(RefCell::new(ProviderManager::with_config( - &search_engine, - &terminal, - media_enabled, - weather_config, - pomodoro_config, - ))); + // Load Lua plugins if enabled (requires lua feature) + #[cfg(feature = "lua")] + if config.borrow().plugins.enabled { + Self::load_lua_plugins(&mut provider_manager, &config.borrow()); + } + + let providers = Rc::new(RefCell::new(provider_manager)); let frecency = Rc::new(RefCell::new(FrecencyStore::load_or_default())); // Create filter from CLI args and config let filter = ProviderFilter::new( - args.mode, + args.mode.clone(), args.providers.clone(), &config.borrow().providers, ); @@ -115,6 +101,100 @@ impl OwlryApp { window.present(); } + /// Load native (.so) plugins from the system plugins directory + /// Returns NativeProvider instances that can be passed to ProviderManager + fn load_native_plugins(config: &Config) -> Vec { + let mut loader = NativePluginLoader::new(); + + // Set disabled plugins from config + loader.set_disabled(config.plugins.disabled_plugins.clone()); + + // Discover and load plugins + match loader.discover() { + Ok(count) => { + if count == 0 { + debug!("No native plugins found in {}", + crate::plugins::native_loader::SYSTEM_PLUGINS_DIR); + return Vec::new(); + } + info!("Discovered {} native plugin(s)", count); + } + Err(e) => { + warn!("Failed to discover native plugins: {}", e); + return Vec::new(); + } + } + + // Get all plugins and create providers + let plugins: Vec> = + loader.into_plugins(); + + // Create NativeProvider instances from loaded plugins + let mut providers = Vec::new(); + for plugin in plugins { + for provider_info in &plugin.providers { + let provider = NativeProvider::new(Arc::clone(&plugin), provider_info.clone()); + info!("Created native provider: {} ({})", provider.name(), provider.type_id()); + providers.push(provider); + } + } + + info!("Loaded {} provider(s) from native plugins", providers.len()); + providers + } + + /// Load Lua plugins from the user plugins directory (requires lua feature) + #[cfg(feature = "lua")] + fn load_lua_plugins(provider_manager: &mut ProviderManager, config: &Config) { + let plugins_dir = match paths::plugins_dir() { + Some(dir) => dir, + None => { + warn!("Could not determine plugins directory"); + return; + } + }; + + // Get owlry version from Cargo.toml at compile time + let owlry_version = env!("CARGO_PKG_VERSION"); + + let mut plugin_manager = PluginManager::new(plugins_dir, owlry_version); + + // Set disabled plugins from config + plugin_manager.set_disabled(config.plugins.disabled_plugins.clone()); + + // Discover plugins + match plugin_manager.discover() { + Ok(count) => { + if count == 0 { + debug!("No Lua plugins found"); + return; + } + info!("Discovered {} Lua plugin(s)", count); + } + Err(e) => { + warn!("Failed to discover Lua plugins: {}", e); + return; + } + } + + // Initialize all plugins (load Lua code) + let init_errors = plugin_manager.initialize_all(); + for error in &init_errors { + warn!("Plugin initialization error: {}", error); + } + + // Create providers from initialized plugins + let plugin_providers = plugin_manager.create_providers(); + let provider_count = plugin_providers.len(); + + // Add plugin providers to the main provider manager + provider_manager.add_providers(plugin_providers); + + if provider_count > 0 { + info!("Loaded {} provider(s) from Lua plugins", provider_count); + } + } + fn setup_icon_theme() { // Ensure we have icon fallbacks for weather/media icons // These may not exist in all icon themes @@ -170,8 +250,8 @@ impl OwlryApp { } // 3. Load user's custom stylesheet if exists - if let Some(custom_path) = paths::custom_style_file() { - if custom_path.exists() { + if let Some(custom_path) = paths::custom_style_file() + && custom_path.exists() { let custom_provider = CssProvider::new(); custom_provider.load_from_path(&custom_path); gtk4::style_context_add_provider_for_display( @@ -181,7 +261,6 @@ impl OwlryApp { ); debug!("Loaded custom CSS from {:?}", custom_path); } - } // 4. Inject config variables (highest priority for overrides) let vars_css = theme::generate_variables_css(&config.appearance); diff --git a/crates/owlry/src/cli.rs b/crates/owlry/src/cli.rs new file mode 100644 index 0000000..20b94d3 --- /dev/null +++ b/crates/owlry/src/cli.rs @@ -0,0 +1,214 @@ +//! Command-line interface for owlry launcher +//! +//! Provides both the launcher interface and plugin management commands. + +use clap::{Parser, Subcommand}; + +use crate::providers::ProviderType; + +#[derive(Parser, Debug, Clone)] +#[command( + name = "owlry", + about = "An owl-themed application launcher for Wayland", + version +)] +pub struct CliArgs { + /// Start in single-provider mode (app, cmd, uuctl) + #[arg(long, short = 'm', value_parser = parse_provider)] + pub mode: Option, + + /// Comma-separated list of enabled providers (app,cmd,uuctl) + #[arg(long, short = 'p', value_delimiter = ',', value_parser = parse_provider)] + pub providers: Option>, + + /// Subcommand to run (if any) + #[command(subcommand)] + pub command: Option, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum Command { + /// Manage plugins + #[command(subcommand)] + Plugin(PluginCommand), +} + +/// Plugin runtime type +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum PluginRuntime { + /// Lua runtime (requires owlry-lua package) + Lua, + /// Rune runtime (requires owlry-rune package) + Rune, +} + +impl std::fmt::Display for PluginRuntime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PluginRuntime::Lua => write!(f, "lua"), + PluginRuntime::Rune => write!(f, "rune"), + } + } +} + +#[derive(Subcommand, Debug, Clone)] +pub enum PluginCommand { + /// List installed plugins + List { + /// Show only enabled plugins + #[arg(long)] + enabled: bool, + + /// Show only disabled plugins + #[arg(long)] + disabled: bool, + + /// Filter by runtime type (lua or rune) + #[arg(long, short = 'r', value_enum)] + runtime: Option, + + /// Show available plugins from registry instead of installed + #[arg(long)] + available: bool, + + /// Force refresh of registry cache + #[arg(long)] + refresh: bool, + + /// Output in JSON format + #[arg(long)] + json: bool, + }, + + /// Search for plugins in the registry + Search { + /// Search query (matches name, description, tags) + query: String, + + /// Force refresh of registry cache + #[arg(long)] + refresh: bool, + + /// Output in JSON format + #[arg(long)] + json: bool, + }, + + /// Show detailed information about a plugin + Info { + /// Plugin ID + name: String, + + /// Show info from registry instead of installed plugin + #[arg(long)] + registry: bool, + + /// Output in JSON format + #[arg(long)] + json: bool, + }, + + /// Install a plugin from registry, path, or URL + Install { + /// Plugin source (registry name, local path, or git URL) + source: String, + + /// Force reinstall even if already installed + #[arg(long, short = 'f')] + force: bool, + }, + + /// Remove an installed plugin + Remove { + /// Plugin ID to remove + name: String, + + /// Don't ask for confirmation + #[arg(long, short = 'y')] + yes: bool, + }, + + /// Update installed plugins + Update { + /// Specific plugin to update (all if not specified) + name: Option, + }, + + /// Enable a disabled plugin + Enable { + /// Plugin ID to enable + name: String, + }, + + /// Disable an installed plugin + Disable { + /// Plugin ID to disable + name: String, + }, + + /// Create a new plugin from template + Create { + /// Plugin ID (directory name) + name: String, + + /// Runtime type to use (default: lua) + #[arg(long, short = 'r', value_enum, default_value = "lua")] + runtime: PluginRuntime, + + /// Target directory (default: current directory) + #[arg(long, short = 'd')] + dir: Option, + + /// Plugin display name + #[arg(long)] + display_name: Option, + + /// Plugin description + #[arg(long)] + description: Option, + }, + + /// Validate a plugin's structure and manifest + Validate { + /// Path to plugin directory (default: current directory) + path: Option, + }, + + /// Show available script runtimes + Runtimes, + + /// Run a plugin command + /// + /// Plugins can provide CLI commands that are invoked via: + /// owlry plugin run [args...] + /// + /// Example: + /// owlry plugin run bookmark add https://example.com "My Bookmark" + Run { + /// Plugin ID + plugin_id: String, + + /// Command to run + command: String, + + /// Arguments to pass to the command + #[arg(trailing_var_arg = true)] + args: Vec, + }, + + /// List commands provided by a plugin + Commands { + /// Plugin ID (optional - lists all if not specified) + plugin_id: Option, + }, +} + +fn parse_provider(s: &str) -> Result { + s.parse() +} + +impl CliArgs { + pub fn parse_args() -> Self { + Self::parse() + } +} diff --git a/src/config/mod.rs b/crates/owlry/src/config/mod.rs similarity index 76% rename from src/config/mod.rs rename to crates/owlry/src/config/mod.rs index cea2a2f..9eee858 100644 --- a/src/config/mod.rs +++ b/crates/owlry/src/config/mod.rs @@ -1,5 +1,6 @@ use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::PathBuf; use std::process::Command; @@ -10,6 +11,8 @@ pub struct Config { pub general: GeneralConfig, pub appearance: AppearanceConfig, pub providers: ProvidersConfig, + #[serde(default)] + pub plugins: PluginsConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -156,6 +159,136 @@ pub struct ProvidersConfig { pub pomodoro_break_mins: u32, } +/// Configuration for plugins +/// +/// Supports per-plugin configuration via `[plugins.]` sections: +/// ```toml +/// [plugins] +/// enabled = true +/// +/// [plugins.weather] +/// location = "Berlin" +/// units = "metric" +/// +/// [plugins.pomodoro] +/// work_mins = 25 +/// break_mins = 5 +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginsConfig { + /// Whether plugins are enabled globally + #[serde(default = "default_true")] + pub enabled: bool, + + /// List of plugin IDs to enable (empty = all discovered plugins) + #[serde(default)] + pub enabled_plugins: Vec, + + /// List of plugin IDs to explicitly disable + #[serde(default)] + pub disabled_plugins: Vec, + + /// Sandbox settings for plugin execution + #[serde(default)] + pub sandbox: SandboxConfig, + + /// Plugin registry URL (for `owlry plugin search` and registry installs) + /// Defaults to the official owlry plugin registry if not specified. + #[serde(default)] + pub registry_url: Option, + + /// Per-plugin configuration tables + /// Accessed via `[plugins.]` sections in config.toml + /// Each plugin can define its own config schema + #[serde(flatten)] + pub plugin_configs: HashMap, +} + +/// Sandbox settings for plugin security +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxConfig { + /// Allow plugins to access the filesystem (beyond their own directory) + #[serde(default)] + pub allow_filesystem: bool, + + /// Allow plugins to make network requests + #[serde(default)] + pub allow_network: bool, + + /// Allow plugins to run shell commands + #[serde(default)] + pub allow_commands: bool, + + /// Memory limit for Lua runtime in bytes (0 = unlimited) + #[serde(default = "default_memory_limit")] + pub memory_limit: usize, +} + +impl Default for PluginsConfig { + fn default() -> Self { + Self { + enabled: true, + enabled_plugins: Vec::new(), + disabled_plugins: Vec::new(), + sandbox: SandboxConfig::default(), + registry_url: None, + plugin_configs: HashMap::new(), + } + } +} + +impl PluginsConfig { + /// Get configuration for a specific plugin by name + /// + /// Returns the plugin's config table if it exists in `[plugins.]` + #[allow(dead_code)] + pub fn get_plugin_config(&self, plugin_name: &str) -> Option<&toml::Value> { + self.plugin_configs.get(plugin_name) + } + + /// Get a string value from a plugin's config + #[allow(dead_code)] + pub fn get_plugin_string(&self, plugin_name: &str, key: &str) -> Option<&str> { + self.plugin_configs + .get(plugin_name)? + .get(key)? + .as_str() + } + + /// Get an integer value from a plugin's config + #[allow(dead_code)] + pub fn get_plugin_int(&self, plugin_name: &str, key: &str) -> Option { + self.plugin_configs + .get(plugin_name)? + .get(key)? + .as_integer() + } + + /// Get a boolean value from a plugin's config + #[allow(dead_code)] + pub fn get_plugin_bool(&self, plugin_name: &str, key: &str) -> Option { + self.plugin_configs + .get(plugin_name)? + .get(key)? + .as_bool() + } +} + +impl Default for SandboxConfig { + fn default() -> Self { + Self { + allow_filesystem: false, + allow_network: false, + allow_commands: false, + memory_limit: default_memory_limit(), + } + } +} + +fn default_memory_limit() -> usize { + 64 * 1024 * 1024 // 64 MB +} + fn default_search_engine() -> String { "duckduckgo".to_string() } @@ -184,22 +317,19 @@ fn default_pomodoro_break() -> u32 { /// Checks for uwsm (Universal Wayland Session Manager) and hyprland fn detect_launch_wrapper() -> Option { // Check if running under uwsm (has UWSM_FINALIZE_VARNAMES or similar uwsm env vars) - if std::env::var("UWSM_FINALIZE_VARNAMES").is_ok() - || std::env::var("__UWSM_SELECT_TAG").is_ok() - { - if command_exists("uwsm") { + if (std::env::var("UWSM_FINALIZE_VARNAMES").is_ok() + || std::env::var("__UWSM_SELECT_TAG").is_ok()) + && command_exists("uwsm") { debug!("Detected uwsm session, using 'uwsm app --' wrapper"); return Some("uwsm app --".to_string()); } - } // Check if running under Hyprland - if std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok() { - if command_exists("hyprctl") { + if std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok() + && command_exists("hyprctl") { debug!("Detected Hyprland session, using 'hyprctl dispatch exec --' wrapper"); return Some("hyprctl dispatch exec --".to_string()); } - } // No wrapper needed for other environments debug!("No launch wrapper detected, using direct execution"); @@ -217,12 +347,11 @@ fn detect_launch_wrapper() -> Option { /// 7. xterm (ultimate fallback - the cockroach of terminals) fn detect_terminal() -> String { // 1. Check $TERMINAL env var first (user's explicit preference) - if let Ok(term) = std::env::var("TERMINAL") { - if !term.is_empty() && command_exists(&term) { + if let Ok(term) = std::env::var("TERMINAL") + && !term.is_empty() && command_exists(&term) { debug!("Using $TERMINAL: {}", term); return term; } - } // 2. Try xdg-terminal-exec (freedesktop standard) if command_exists("xdg-terminal-exec") { @@ -368,6 +497,7 @@ impl Default for Config { pomodoro_work_mins: 25, pomodoro_break_mins: 5, }, + plugins: PluginsConfig::default(), } } } diff --git a/src/data/frecency.rs b/crates/owlry/src/data/frecency.rs similarity index 100% rename from src/data/frecency.rs rename to crates/owlry/src/data/frecency.rs diff --git a/src/data/mod.rs b/crates/owlry/src/data/mod.rs similarity index 100% rename from src/data/mod.rs rename to crates/owlry/src/data/mod.rs diff --git a/src/filter.rs b/crates/owlry/src/filter.rs similarity index 96% rename from src/filter.rs rename to crates/owlry/src/filter.rs index 3da1aa1..843a02b 100644 --- a/src/filter.rs +++ b/crates/owlry/src/filter.rs @@ -140,8 +140,8 @@ impl ProviderFilter { /// Check if a provider should be searched pub fn is_active(&self, provider: ProviderType) -> bool { - if let Some(prefix) = self.active_prefix { - provider == prefix + if let Some(ref prefix) = self.active_prefix { + &provider == prefix } else { self.enabled.contains(&provider) } @@ -155,7 +155,7 @@ impl ProviderFilter { /// Get current active prefix if any #[allow(dead_code)] pub fn active_prefix(&self) -> Option { - self.active_prefix + self.active_prefix.clone() } /// Parse query for prefix syntax @@ -282,7 +282,7 @@ impl ProviderFilter { /// Get enabled providers for UI display (sorted) pub fn enabled_providers(&self) -> Vec { - let mut providers: Vec<_> = self.enabled.iter().copied().collect(); + let mut providers: Vec<_> = self.enabled.iter().cloned().collect(); providers.sort_by_key(|p| match p { ProviderType::Application => 0, ProviderType::Bookmarks => 1, @@ -300,13 +300,14 @@ impl ProviderFilter { ProviderType::Uuctl => 13, ProviderType::Weather => 14, ProviderType::WebSearch => 15, + ProviderType::Plugin(_) => 100, // Plugin providers sort last }); providers } /// Get display name for current mode pub fn mode_display_name(&self) -> &'static str { - if let Some(prefix) = self.active_prefix { + if let Some(ref prefix) = self.active_prefix { return match prefix { ProviderType::Application => "Apps", ProviderType::Bookmarks => "Bookmarks", @@ -324,12 +325,13 @@ impl ProviderFilter { ProviderType::Uuctl => "uuctl", ProviderType::Weather => "Weather", ProviderType::WebSearch => "Web", + ProviderType::Plugin(_) => "Plugin", }; } let enabled: Vec<_> = self.enabled_providers(); if enabled.len() == 1 { - match enabled[0] { + match &enabled[0] { ProviderType::Application => "Apps", ProviderType::Bookmarks => "Bookmarks", ProviderType::Calculator => "Calc", @@ -346,6 +348,7 @@ impl ProviderFilter { ProviderType::Uuctl => "uuctl", ProviderType::Weather => "Weather", ProviderType::WebSearch => "Web", + ProviderType::Plugin(_) => "Plugin", } } else { "All" diff --git a/src/main.rs b/crates/owlry/src/main.rs similarity index 73% rename from src/main.rs rename to crates/owlry/src/main.rs index 3f4934e..5ac83a0 100644 --- a/src/main.rs +++ b/crates/owlry/src/main.rs @@ -3,27 +3,44 @@ mod cli; mod config; mod data; mod filter; +mod notify; mod paths; +mod plugins; mod providers; mod theme; mod ui; use app::OwlryApp; -use cli::CliArgs; +use cli::{CliArgs, Command}; use log::{info, warn}; #[cfg(feature = "dev-logging")] use log::debug; fn main() { + let args = CliArgs::parse_args(); + + // Handle subcommands before initializing the full app + if let Some(command) = &args.command { + // CLI commands don't need full logging + match command { + Command::Plugin(plugin_cmd) => { + if let Err(e) = plugins::commands::execute(plugin_cmd.clone()) { + eprintln!("Error: {}", e); + std::process::exit(1); + } + std::process::exit(0); + } + } + } + + // No subcommand - launch the app let default_level = if cfg!(feature = "dev-logging") { "debug" } else { "info" }; env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default_level)) .format_timestamp_millis() .init(); - let args = CliArgs::parse_args(); - #[cfg(feature = "dev-logging")] { debug!("┌─────────────────────────────────────────┐"); diff --git a/crates/owlry/src/notify.rs b/crates/owlry/src/notify.rs new file mode 100644 index 0000000..dbfc9ac --- /dev/null +++ b/crates/owlry/src/notify.rs @@ -0,0 +1,91 @@ +//! Desktop notification system +//! +//! Provides system notifications for owlry and its plugins. +//! Uses the freedesktop notification specification via notify-rust. +//! +//! Note: Some convenience functions are provided for future use and +//! are currently unused by the core (plugins use the Host API instead). + +#![allow(dead_code)] + +use notify_rust::{Notification, Urgency}; + +/// Notification urgency level +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum NotifyUrgency { + /// Low priority notification + Low, + /// Normal priority notification (default) + #[default] + Normal, + /// Critical/urgent notification + Critical, +} + +impl From for Urgency { + fn from(urgency: NotifyUrgency) -> Self { + match urgency { + NotifyUrgency::Low => Urgency::Low, + NotifyUrgency::Normal => Urgency::Normal, + NotifyUrgency::Critical => Urgency::Critical, + } + } +} + +/// Send a simple notification +pub fn notify(summary: &str, body: &str) { + notify_with_options(summary, body, None, NotifyUrgency::Normal); +} + +/// Send a notification with an icon +pub fn notify_with_icon(summary: &str, body: &str, icon: &str) { + notify_with_options(summary, body, Some(icon), NotifyUrgency::Normal); +} + +/// Send a notification with full options +pub fn notify_with_options(summary: &str, body: &str, icon: Option<&str>, urgency: NotifyUrgency) { + let mut notification = Notification::new(); + notification + .appname("Owlry") + .summary(summary) + .body(body) + .urgency(urgency.into()); + + if let Some(icon_name) = icon { + notification.icon(icon_name); + } + + if let Err(e) = notification.show() { + log::warn!("Failed to show notification: {}", e); + } +} + +/// Send a notification with a timeout +pub fn notify_with_timeout(summary: &str, body: &str, icon: Option<&str>, timeout_ms: i32) { + let mut notification = Notification::new(); + notification + .appname("Owlry") + .summary(summary) + .body(body) + .timeout(timeout_ms); + + if let Some(icon_name) = icon { + notification.icon(icon_name); + } + + if let Err(e) = notification.show() { + log::warn!("Failed to show notification: {}", e); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_urgency_conversion() { + assert_eq!(Urgency::from(NotifyUrgency::Low), Urgency::Low); + assert_eq!(Urgency::from(NotifyUrgency::Normal), Urgency::Normal); + assert_eq!(Urgency::from(NotifyUrgency::Critical), Urgency::Critical); + } +} diff --git a/src/paths.rs b/crates/owlry/src/paths.rs similarity index 74% rename from src/paths.rs rename to crates/owlry/src/paths.rs index d395c26..0965814 100644 --- a/src/paths.rs +++ b/crates/owlry/src/paths.rs @@ -32,10 +32,6 @@ pub fn cache_home() -> Option { dirs::cache_dir() } -/// Get user home directory -pub fn home() -> Option { - dirs::home_dir() -} // ============================================================================= // Owlry-specific directories @@ -85,9 +81,12 @@ pub fn theme_file(name: &str) -> Option { // Data files // ============================================================================= -/// User scripts directory: `$XDG_DATA_HOME/owlry/scripts/` -pub fn scripts_dir() -> Option { - owlry_data_dir().map(|p| p.join("scripts")) +/// User plugins directory: `$XDG_CONFIG_HOME/owlry/plugins/` +/// +/// Plugins are stored in config because they contain user-installed code +/// that the user explicitly chose to add (similar to themes). +pub fn plugins_dir() -> Option { + owlry_config_dir().map(|p| p.join("plugins")) } /// Frecency data file: `$XDG_DATA_HOME/owlry/frecency.json` @@ -121,60 +120,16 @@ pub fn system_data_dirs() -> Vec { dirs } -// ============================================================================= -// External application paths -// ============================================================================= - -/// SSH config file: `~/.ssh/config` -pub fn ssh_config() -> Option { - home().map(|p| p.join(".ssh").join("config")) -} - -/// Firefox profile directory: `~/.mozilla/firefox/` -pub fn firefox_dir() -> Option { - home().map(|p| p.join(".mozilla").join("firefox")) -} - -/// Chromium-based browser bookmark paths (using XDG config where browsers support it) -pub fn chromium_bookmark_paths() -> Vec { - let config = match config_home() { - Some(c) => c, - None => return Vec::new(), - }; - - vec![ - // Google Chrome - config.join("google-chrome/Default/Bookmarks"), - // Chromium - config.join("chromium/Default/Bookmarks"), - // Brave - config.join("BraveSoftware/Brave-Browser/Default/Bookmarks"), - // Microsoft Edge - config.join("microsoft-edge/Default/Bookmarks"), - // Vivaldi - config.join("vivaldi/Default/Bookmarks"), - ] -} - // ============================================================================= // Helper functions // ============================================================================= -/// Ensure a directory exists, creating it if necessary -pub fn ensure_dir(path: &PathBuf) -> std::io::Result<()> { - if !path.exists() { - std::fs::create_dir_all(path)?; - } - Ok(()) -} - /// Ensure parent directory of a file exists -pub fn ensure_parent_dir(path: &PathBuf) -> std::io::Result<()> { - if let Some(parent) = path.parent() { - if !parent.exists() { +pub fn ensure_parent_dir(path: &std::path::Path) -> std::io::Result<()> { + if let Some(parent) = path.parent() + && !parent.exists() { std::fs::create_dir_all(parent)?; } - } Ok(()) } diff --git a/crates/owlry/src/plugins/api/action.rs b/crates/owlry/src/plugins/api/action.rs new file mode 100644 index 0000000..985f574 --- /dev/null +++ b/crates/owlry/src/plugins/api/action.rs @@ -0,0 +1,322 @@ +//! Action API for Lua plugins +//! +//! Allows plugins to register custom actions for result items: +//! - `owlry.action.register(config)` - Register a custom action + +use mlua::{Function, Lua, Result as LuaResult, Table, Value}; + +/// Action registration data +#[derive(Debug, Clone)] +#[allow(dead_code)] // Used by UI integration +pub struct ActionRegistration { + /// Unique action ID + pub id: String, + /// Human-readable name shown in UI + pub display_name: String, + /// Icon name (optional) + pub icon: Option, + /// Keyboard shortcut hint (optional, e.g., "Ctrl+C") + pub shortcut: Option, + /// Plugin that registered this action + pub plugin_id: String, +} + +/// Register action APIs +pub fn register_action_api(lua: &Lua, owlry: &Table, plugin_id: &str) -> LuaResult<()> { + let action_table = lua.create_table()?; + let plugin_id_owned = plugin_id.to_string(); + + // Initialize action storage in Lua registry + if lua.named_registry_value::("actions")?.is_nil() { + let actions: Table = lua.create_table()?; + lua.set_named_registry_value("actions", actions)?; + } + + // owlry.action.register(config) -> string (action_id) + // config = { + // id = "copy-url", + // name = "Copy URL", + // icon = "edit-copy", -- optional + // shortcut = "Ctrl+C", -- optional + // filter = function(item) return item.provider == "bookmarks" end, -- optional + // handler = function(item) ... end + // } + let plugin_id_for_register = plugin_id_owned.clone(); + action_table.set( + "register", + lua.create_function(move |lua, config: Table| { + // Extract required fields + let id: String = config + .get("id") + .map_err(|_| mlua::Error::external("action.register: 'id' is required"))?; + + let name: String = config + .get("name") + .map_err(|_| mlua::Error::external("action.register: 'name' is required"))?; + + let _handler: Function = config + .get("handler") + .map_err(|_| mlua::Error::external("action.register: 'handler' function is required"))?; + + // Extract optional fields + let icon: Option = config.get("icon").ok(); + let shortcut: Option = config.get("shortcut").ok(); + + // Store action in registry + let actions: Table = lua.named_registry_value("actions")?; + + // Create full action ID with plugin prefix + let full_id = format!("{}:{}", plugin_id_for_register, id); + + // Store config with full ID + let action_entry = lua.create_table()?; + action_entry.set("id", full_id.clone())?; + action_entry.set("name", name.clone())?; + action_entry.set("plugin_id", plugin_id_for_register.clone())?; + if let Some(ref i) = icon { + action_entry.set("icon", i.clone())?; + } + if let Some(ref s) = shortcut { + action_entry.set("shortcut", s.clone())?; + } + // Store filter and handler functions + if let Ok(filter) = config.get::("filter") { + action_entry.set("filter", filter)?; + } + action_entry.set("handler", config.get::("handler")?)?; + + actions.set(full_id.clone(), action_entry)?; + + log::info!( + "[plugin:{}] Registered action '{}' ({})", + plugin_id_for_register, + name, + full_id + ); + + Ok(full_id) + })?, + )?; + + // owlry.action.unregister(id) -> boolean + let plugin_id_for_unregister = plugin_id_owned.clone(); + action_table.set( + "unregister", + lua.create_function(move |lua, id: String| { + let actions: Table = lua.named_registry_value("actions")?; + let full_id = format!("{}:{}", plugin_id_for_unregister, id); + + if actions.contains_key(full_id.clone())? { + actions.set(full_id, Value::Nil)?; + Ok(true) + } else { + Ok(false) + } + })?, + )?; + + owlry.set("action", action_table)?; + Ok(()) +} + +/// Get all registered actions from a Lua runtime +#[allow(dead_code)] // Will be used by UI +pub fn get_actions(lua: &Lua) -> LuaResult> { + let actions: Table = match lua.named_registry_value("actions") { + Ok(a) => a, + Err(_) => return Ok(Vec::new()), + }; + + let mut result = Vec::new(); + + for pair in actions.pairs::() { + let (_, entry) = pair?; + + let id: String = entry.get("id")?; + let display_name: String = entry.get("name")?; + let plugin_id: String = entry.get("plugin_id")?; + let icon: Option = entry.get("icon").ok(); + let shortcut: Option = entry.get("shortcut").ok(); + + result.push(ActionRegistration { + id, + display_name, + icon, + shortcut, + plugin_id, + }); + } + + Ok(result) +} + +/// Get actions that apply to a specific item +#[allow(dead_code)] // Will be used by UI context menu +pub fn get_actions_for_item(lua: &Lua, item: &Table) -> LuaResult> { + let actions: Table = match lua.named_registry_value("actions") { + Ok(a) => a, + Err(_) => return Ok(Vec::new()), + }; + + let mut result = Vec::new(); + + for pair in actions.pairs::() { + let (_, entry) = pair?; + + // Check filter if present + if let Ok(filter) = entry.get::("filter") { + match filter.call::(item.clone()) { + Ok(true) => {} // Include this action + Ok(false) => continue, // Skip this action + Err(e) => { + log::warn!("Action filter failed: {}", e); + continue; + } + } + } + + let id: String = entry.get("id")?; + let display_name: String = entry.get("name")?; + let plugin_id: String = entry.get("plugin_id")?; + let icon: Option = entry.get("icon").ok(); + let shortcut: Option = entry.get("shortcut").ok(); + + result.push(ActionRegistration { + id, + display_name, + icon, + shortcut, + plugin_id, + }); + } + + Ok(result) +} + +/// Execute an action by ID +#[allow(dead_code)] // Will be used by UI +pub fn execute_action(lua: &Lua, action_id: &str, item: &Table) -> LuaResult<()> { + let actions: Table = lua.named_registry_value("actions")?; + let action: Table = actions.get(action_id)?; + let handler: Function = action.get("handler")?; + + handler.call::<()>(item.clone())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_lua(plugin_id: &str) -> Lua { + let lua = Lua::new(); + let owlry = lua.create_table().unwrap(); + register_action_api(&lua, &owlry, plugin_id).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + lua + } + + #[test] + fn test_action_registration() { + let lua = setup_lua("test-plugin"); + + let chunk = lua.load(r#" + return owlry.action.register({ + id = "copy-name", + name = "Copy Name", + icon = "edit-copy", + handler = function(item) + -- copy logic here + end + }) + "#); + let action_id: String = chunk.call(()).unwrap(); + assert_eq!(action_id, "test-plugin:copy-name"); + + // Verify action is registered + let actions = get_actions(&lua).unwrap(); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].display_name, "Copy Name"); + } + + #[test] + fn test_action_with_filter() { + let lua = setup_lua("test-plugin"); + + let chunk = lua.load(r#" + owlry.action.register({ + id = "bookmark-action", + name = "Open in Browser", + filter = function(item) + return item.provider == "bookmarks" + end, + handler = function(item) end + }) + "#); + chunk.call::<()>(()).unwrap(); + + // Create bookmark item + let bookmark_item = lua.create_table().unwrap(); + bookmark_item.set("provider", "bookmarks").unwrap(); + bookmark_item.set("name", "Test Bookmark").unwrap(); + + let actions = get_actions_for_item(&lua, &bookmark_item).unwrap(); + assert_eq!(actions.len(), 1); + + // Create non-bookmark item + let app_item = lua.create_table().unwrap(); + app_item.set("provider", "applications").unwrap(); + app_item.set("name", "Test App").unwrap(); + + let actions2 = get_actions_for_item(&lua, &app_item).unwrap(); + assert_eq!(actions2.len(), 0); // Filtered out + } + + #[test] + fn test_action_unregister() { + let lua = setup_lua("test-plugin"); + + let chunk = lua.load(r#" + owlry.action.register({ + id = "temp-action", + name = "Temporary", + handler = function(item) end + }) + return owlry.action.unregister("temp-action") + "#); + let unregistered: bool = chunk.call(()).unwrap(); + assert!(unregistered); + + let actions = get_actions(&lua).unwrap(); + assert_eq!(actions.len(), 0); + } + + #[test] + fn test_execute_action() { + let lua = setup_lua("test-plugin"); + + // Register action that sets a global + let chunk = lua.load(r#" + result = nil + owlry.action.register({ + id = "test-exec", + name = "Test Execute", + handler = function(item) + result = item.name + end + }) + "#); + chunk.call::<()>(()).unwrap(); + + // Create test item + let item = lua.create_table().unwrap(); + item.set("name", "TestItem").unwrap(); + + // Execute action + execute_action(&lua, "test-plugin:test-exec", &item).unwrap(); + + // Verify handler was called + let result: String = lua.globals().get("result").unwrap(); + assert_eq!(result, "TestItem"); + } +} diff --git a/crates/owlry/src/plugins/api/cache.rs b/crates/owlry/src/plugins/api/cache.rs new file mode 100644 index 0000000..448b066 --- /dev/null +++ b/crates/owlry/src/plugins/api/cache.rs @@ -0,0 +1,299 @@ +//! Cache API for Lua plugins +//! +//! Provides in-memory caching with optional TTL: +//! - `owlry.cache.get(key)` - Get cached value +//! - `owlry.cache.set(key, value, ttl_seconds?)` - Set cached value +//! - `owlry.cache.delete(key)` - Delete cached value +//! - `owlry.cache.clear()` - Clear all cached values + +use mlua::{Lua, Result as LuaResult, Table, Value}; +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; +use std::time::{Duration, Instant}; + +/// Cached entry with optional expiration +struct CacheEntry { + value: String, // Store as JSON string for simplicity + expires_at: Option, +} + +impl CacheEntry { + fn is_expired(&self) -> bool { + self.expires_at.map(|e| Instant::now() > e).unwrap_or(false) + } +} + +/// Global cache storage (shared across all plugins) +static CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Register cache APIs +pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { + let cache_table = lua.create_table()?; + + // owlry.cache.get(key) -> value or nil + cache_table.set( + "get", + lua.create_function(|lua, key: String| { + let cache = CACHE.lock().map_err(|e| { + mlua::Error::external(format!("Failed to lock cache: {}", e)) + })?; + + if let Some(entry) = cache.get(&key) { + if entry.is_expired() { + drop(cache); + // Remove expired entry + if let Ok(mut cache) = CACHE.lock() { + cache.remove(&key); + } + return Ok(Value::Nil); + } + + // Parse JSON back to Lua value + let json_value: serde_json::Value = serde_json::from_str(&entry.value) + .map_err(|e| mlua::Error::external(format!("Failed to parse cached value: {}", e)))?; + + json_to_lua(lua, &json_value) + } else { + Ok(Value::Nil) + } + })?, + )?; + + // owlry.cache.set(key, value, ttl_seconds?) -> boolean + cache_table.set( + "set", + lua.create_function(|_lua, (key, value, ttl): (String, Value, Option)| { + let json_value = lua_value_to_json(&value)?; + let json_str = serde_json::to_string(&json_value) + .map_err(|e| mlua::Error::external(format!("Failed to serialize value: {}", e)))?; + + let expires_at = ttl.map(|secs| Instant::now() + Duration::from_secs(secs)); + + let entry = CacheEntry { + value: json_str, + expires_at, + }; + + let mut cache = CACHE.lock().map_err(|e| { + mlua::Error::external(format!("Failed to lock cache: {}", e)) + })?; + + cache.insert(key, entry); + Ok(true) + })?, + )?; + + // owlry.cache.delete(key) -> boolean (true if key existed) + cache_table.set( + "delete", + lua.create_function(|_lua, key: String| { + let mut cache = CACHE.lock().map_err(|e| { + mlua::Error::external(format!("Failed to lock cache: {}", e)) + })?; + + Ok(cache.remove(&key).is_some()) + })?, + )?; + + // owlry.cache.clear() -> number of entries removed + cache_table.set( + "clear", + lua.create_function(|_lua, ()| { + let mut cache = CACHE.lock().map_err(|e| { + mlua::Error::external(format!("Failed to lock cache: {}", e)) + })?; + + let count = cache.len(); + cache.clear(); + Ok(count) + })?, + )?; + + // owlry.cache.has(key) -> boolean + cache_table.set( + "has", + lua.create_function(|_lua, key: String| { + let cache = CACHE.lock().map_err(|e| { + mlua::Error::external(format!("Failed to lock cache: {}", e)) + })?; + + if let Some(entry) = cache.get(&key) { + Ok(!entry.is_expired()) + } else { + Ok(false) + } + })?, + )?; + + owlry.set("cache", cache_table)?; + Ok(()) +} + +/// Convert Lua value to serde_json::Value +fn lua_value_to_json(value: &Value) -> LuaResult { + use serde_json::Value as JsonValue; + + match value { + Value::Nil => Ok(JsonValue::Null), + Value::Boolean(b) => Ok(JsonValue::Bool(*b)), + Value::Integer(i) => Ok(JsonValue::Number((*i).into())), + Value::Number(n) => Ok(serde_json::Number::from_f64(*n) + .map(JsonValue::Number) + .unwrap_or(JsonValue::Null)), + Value::String(s) => Ok(JsonValue::String(s.to_str()?.to_string())), + Value::Table(t) => lua_table_to_json(t), + _ => Err(mlua::Error::external("Unsupported Lua type for cache")), + } +} + +/// Convert Lua table to serde_json::Value +fn lua_table_to_json(table: &Table) -> LuaResult { + use serde_json::{Map, Value as JsonValue}; + + // Check if it's an array (sequential integer keys starting from 1) + let is_array = table + .clone() + .pairs::() + .enumerate() + .all(|(i, pair)| pair.map(|(k, _)| k == (i + 1) as i64).unwrap_or(false)); + + if is_array { + let mut arr = Vec::new(); + for pair in table.clone().pairs::() { + let (_, v) = pair?; + arr.push(lua_value_to_json(&v)?); + } + Ok(JsonValue::Array(arr)) + } else { + let mut map = Map::new(); + for pair in table.clone().pairs::() { + let (k, v) = pair?; + map.insert(k, lua_value_to_json(&v)?); + } + Ok(JsonValue::Object(map)) + } +} + +/// Convert serde_json::Value to Lua value +fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult { + use serde_json::Value as JsonValue; + + match value { + JsonValue::Null => Ok(Value::Nil), + JsonValue::Bool(b) => Ok(Value::Boolean(*b)), + JsonValue::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) + } + } + JsonValue::String(s) => Ok(Value::String(lua.create_string(s)?)), + JsonValue::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)) + } + JsonValue::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)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_lua() -> Lua { + let lua = Lua::new(); + let owlry = lua.create_table().unwrap(); + register_cache_api(&lua, &owlry).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + + // Clear cache between tests + CACHE.lock().unwrap().clear(); + + lua + } + + #[test] + fn test_cache_set_get() { + let lua = setup_lua(); + + // Set a value + let chunk = lua.load(r#"return owlry.cache.set("test_key", "test_value")"#); + let result: bool = chunk.call(()).unwrap(); + assert!(result); + + // Get the value back + let chunk = lua.load(r#"return owlry.cache.get("test_key")"#); + let value: String = chunk.call(()).unwrap(); + assert_eq!(value, "test_value"); + } + + #[test] + fn test_cache_table_value() { + let lua = setup_lua(); + + // Set a table value + let chunk = lua.load(r#"return owlry.cache.set("table_key", {name = "test", value = 42})"#); + let _: bool = chunk.call(()).unwrap(); + + // Get and verify + let chunk = lua.load(r#" + local t = owlry.cache.get("table_key") + return t.name, t.value + "#); + let (name, value): (String, i32) = chunk.call(()).unwrap(); + assert_eq!(name, "test"); + assert_eq!(value, 42); + } + + #[test] + fn test_cache_delete() { + let lua = setup_lua(); + + let chunk = lua.load(r#" + owlry.cache.set("delete_key", "value") + local existed = owlry.cache.delete("delete_key") + local value = owlry.cache.get("delete_key") + return existed, value + "#); + let (existed, value): (bool, Option) = chunk.call(()).unwrap(); + assert!(existed); + assert!(value.is_none()); + } + + #[test] + fn test_cache_has() { + let lua = setup_lua(); + + let chunk = lua.load(r#" + local before = owlry.cache.has("has_key") + owlry.cache.set("has_key", "value") + local after = owlry.cache.has("has_key") + return before, after + "#); + let (before, after): (bool, bool) = chunk.call(()).unwrap(); + assert!(!before); + assert!(after); + } + + #[test] + fn test_cache_missing_key() { + let lua = setup_lua(); + + let chunk = lua.load(r#"return owlry.cache.get("nonexistent_key")"#); + let value: Value = chunk.call(()).unwrap(); + assert!(matches!(value, Value::Nil)); + } +} diff --git a/crates/owlry/src/plugins/api/hook.rs b/crates/owlry/src/plugins/api/hook.rs new file mode 100644 index 0000000..b660964 --- /dev/null +++ b/crates/owlry/src/plugins/api/hook.rs @@ -0,0 +1,410 @@ +//! Hook API for Lua plugins +//! +//! Allows plugins to register callbacks for application events: +//! - `owlry.hook.on(event, callback)` - Register a hook +//! - Events: init, query, results, select, pre_launch, post_launch, shutdown + +use mlua::{Function, Lua, Result as LuaResult, Table, Value}; +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; + +/// Hook event types +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum HookEvent { + /// Called when plugin is initialized + Init, + /// Called when query changes, can modify query + Query, + /// Called after results are gathered, can filter/modify results + Results, + /// Called when an item is selected (highlighted) + Select, + /// Called before launching an item, can cancel launch + PreLaunch, + /// Called after launching an item + PostLaunch, + /// Called when application is shutting down + Shutdown, +} + +impl HookEvent { + fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "init" => Some(Self::Init), + "query" => Some(Self::Query), + "results" => Some(Self::Results), + "select" => Some(Self::Select), + "pre_launch" | "prelaunch" => Some(Self::PreLaunch), + "post_launch" | "postlaunch" => Some(Self::PostLaunch), + "shutdown" => Some(Self::Shutdown), + _ => None, + } + } + + fn as_str(&self) -> &'static str { + match self { + Self::Init => "init", + Self::Query => "query", + Self::Results => "results", + Self::Select => "select", + Self::PreLaunch => "pre_launch", + Self::PostLaunch => "post_launch", + Self::Shutdown => "shutdown", + } + } +} + +/// Registered hook information +#[derive(Debug, Clone)] +#[allow(dead_code)] // Will be used for hook inspection +pub struct HookRegistration { + pub event: HookEvent, + pub plugin_id: String, + pub priority: i32, +} + +/// Type alias for hook handlers: (plugin_id, priority) +type HookHandlers = Vec<(String, i32)>; + +/// Global hook registry +/// Maps event -> list of (plugin_id, priority) +static HOOK_REGISTRY: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Register hook APIs +pub fn register_hook_api(lua: &Lua, owlry: &Table, plugin_id: &str) -> LuaResult<()> { + let hook_table = lua.create_table()?; + let plugin_id_owned = plugin_id.to_string(); + + // Store plugin_id in registry for later use + lua.set_named_registry_value("plugin_id", plugin_id_owned.clone())?; + + // Initialize hook storage in Lua registry + if lua.named_registry_value::("hooks")?.is_nil() { + let hooks: Table = lua.create_table()?; + lua.set_named_registry_value("hooks", hooks)?; + } + + // owlry.hook.on(event, callback, priority?) -> boolean + // Register a hook for an event + let plugin_id_for_closure = plugin_id_owned.clone(); + hook_table.set( + "on", + lua.create_function(move |lua, (event_name, callback, priority): (String, Function, Option)| { + let event = HookEvent::from_str(&event_name).ok_or_else(|| { + mlua::Error::external(format!( + "Unknown hook event '{}'. Valid events: init, query, results, select, pre_launch, post_launch, shutdown", + event_name + )) + })?; + + let priority = priority.unwrap_or(0); + + // Store callback in Lua registry + let hooks: Table = lua.named_registry_value("hooks")?; + let event_key = event.as_str(); + + let event_hooks: Table = if let Ok(t) = hooks.get::(event_key) { + t + } else { + let t = lua.create_table()?; + hooks.set(event_key, t.clone())?; + t + }; + + // Add callback to event hooks + let len = event_hooks.len()? + 1; + let hook_entry = lua.create_table()?; + hook_entry.set("callback", callback)?; + hook_entry.set("priority", priority)?; + event_hooks.set(len, hook_entry)?; + + // Register in global registry + let mut registry = HOOK_REGISTRY.lock().map_err(|e| { + mlua::Error::external(format!("Failed to lock hook registry: {}", e)) + })?; + + let hooks_list = registry.entry(event).or_insert_with(Vec::new); + hooks_list.push((plugin_id_for_closure.clone(), priority)); + // Sort by priority (higher priority first) + hooks_list.sort_by(|a, b| b.1.cmp(&a.1)); + + log::debug!( + "[plugin:{}] Registered hook for '{}' with priority {}", + plugin_id_for_closure, + event_name, + priority + ); + + Ok(true) + })?, + )?; + + // owlry.hook.off(event) -> boolean + // Unregister all hooks for an event from this plugin + let plugin_id_for_off = plugin_id_owned.clone(); + hook_table.set( + "off", + lua.create_function(move |lua, event_name: String| { + let event = HookEvent::from_str(&event_name).ok_or_else(|| { + mlua::Error::external(format!("Unknown hook event '{}'", event_name)) + })?; + + // Remove from Lua registry + let hooks: Table = lua.named_registry_value("hooks")?; + hooks.set(event.as_str(), Value::Nil)?; + + // Remove from global registry + let mut registry = HOOK_REGISTRY.lock().map_err(|e| { + mlua::Error::external(format!("Failed to lock hook registry: {}", e)) + })?; + + if let Some(hooks_list) = registry.get_mut(&event) { + hooks_list.retain(|(id, _)| id != &plugin_id_for_off); + } + + log::debug!( + "[plugin:{}] Unregistered hooks for '{}'", + plugin_id_for_off, + event_name + ); + + Ok(true) + })?, + )?; + + owlry.set("hook", hook_table)?; + Ok(()) +} + +/// Call hooks for a specific event in a Lua runtime +/// Returns the (possibly modified) value +#[allow(dead_code)] // Will be used by UI integration +pub fn call_hooks(lua: &Lua, event: HookEvent, value: T) -> LuaResult +where + T: mlua::IntoLua + mlua::FromLua, +{ + let hooks: Table = match lua.named_registry_value("hooks") { + Ok(h) => h, + Err(_) => return Ok(value), // No hooks registered + }; + + let event_hooks: Table = match hooks.get(event.as_str()) { + Ok(h) => h, + Err(_) => return Ok(value), // No hooks for this event + }; + + let mut current_value = value.into_lua(lua)?; + + // Collect hooks with priorities + let mut hook_entries: Vec<(i32, Function)> = Vec::new(); + for pair in event_hooks.pairs::() { + let (_, entry) = pair?; + let priority: i32 = entry.get("priority").unwrap_or(0); + let callback: Function = entry.get("callback")?; + hook_entries.push((priority, callback)); + } + + // Sort by priority (higher first) + hook_entries.sort_by(|a, b| b.0.cmp(&a.0)); + + // Call each hook + for (_, callback) in hook_entries { + match callback.call::(current_value.clone()) { + Ok(result) => { + // If hook returns non-nil, use it as the new value + if !result.is_nil() { + current_value = result; + } + } + Err(e) => { + log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e); + // Continue with other hooks + } + } + } + + T::from_lua(current_value, lua) +} + +/// Call hooks that return a boolean (for pre_launch cancellation) +#[allow(dead_code)] // Will be used for pre_launch hooks +pub fn call_hooks_bool(lua: &Lua, event: HookEvent, value: Value) -> LuaResult { + let hooks: Table = match lua.named_registry_value("hooks") { + Ok(h) => h, + Err(_) => return Ok(true), // No hooks, allow + }; + + let event_hooks: Table = match hooks.get(event.as_str()) { + Ok(h) => h, + Err(_) => return Ok(true), // No hooks for this event + }; + + // Collect and sort hooks + let mut hook_entries: Vec<(i32, Function)> = Vec::new(); + for pair in event_hooks.pairs::() { + let (_, entry) = pair?; + let priority: i32 = entry.get("priority").unwrap_or(0); + let callback: Function = entry.get("callback")?; + hook_entries.push((priority, callback)); + } + hook_entries.sort_by(|a, b| b.0.cmp(&a.0)); + + // Call each hook - if any returns false, cancel + for (_, callback) in hook_entries { + match callback.call::(value.clone()) { + Ok(result) => { + if let Value::Boolean(false) = result { + return Ok(false); // Cancel + } + } + Err(e) => { + log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e); + } + } + } + + Ok(true) +} + +/// Call hooks with no return value (for notifications) +#[allow(dead_code)] // Will be used for notification hooks +pub fn call_hooks_void(lua: &Lua, event: HookEvent, value: Value) -> LuaResult<()> { + let hooks: Table = match lua.named_registry_value("hooks") { + Ok(h) => h, + Err(_) => return Ok(()), // No hooks + }; + + let event_hooks: Table = match hooks.get(event.as_str()) { + Ok(h) => h, + Err(_) => return Ok(()), // No hooks for this event + }; + + for pair in event_hooks.pairs::() { + let (_, entry) = pair?; + let callback: Function = entry.get("callback")?; + if let Err(e) = callback.call::<()>(value.clone()) { + log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e); + } + } + + Ok(()) +} + +/// Get list of plugins that have registered for an event +#[allow(dead_code)] +pub fn get_registered_plugins(event: HookEvent) -> Vec { + HOOK_REGISTRY + .lock() + .map(|r| { + r.get(&event) + .map(|v| v.iter().map(|(id, _)| id.clone()).collect()) + .unwrap_or_default() + }) + .unwrap_or_default() +} + +/// Clear all hooks (used when reloading plugins) +#[allow(dead_code)] +pub fn clear_all_hooks() { + if let Ok(mut registry) = HOOK_REGISTRY.lock() { + registry.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_lua(plugin_id: &str) -> Lua { + let lua = Lua::new(); + let owlry = lua.create_table().unwrap(); + register_hook_api(&lua, &owlry, plugin_id).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + lua + } + + #[test] + fn test_hook_registration() { + clear_all_hooks(); + let lua = setup_lua("test-plugin"); + + let chunk = lua.load(r#" + local called = false + owlry.hook.on("init", function() + called = true + end) + return true + "#); + let result: bool = chunk.call(()).unwrap(); + assert!(result); + + // Verify hook was registered + let plugins = get_registered_plugins(HookEvent::Init); + assert!(plugins.contains(&"test-plugin".to_string())); + } + + #[test] + fn test_hook_with_priority() { + clear_all_hooks(); + let lua = setup_lua("test-plugin"); + + let chunk = lua.load(r#" + owlry.hook.on("query", function(q) return q .. "1" end, 10) + owlry.hook.on("query", function(q) return q .. "2" end, 20) + return true + "#); + chunk.call::<()>(()).unwrap(); + + // Call hooks - higher priority (20) should run first + let result: String = call_hooks(&lua, HookEvent::Query, "test".to_string()).unwrap(); + // Priority 20 adds "2" first, then priority 10 adds "1" + assert_eq!(result, "test21"); + } + + #[test] + fn test_hook_off() { + clear_all_hooks(); + let lua = setup_lua("test-plugin"); + + let chunk = lua.load(r#" + owlry.hook.on("select", function() end) + owlry.hook.off("select") + return true + "#); + chunk.call::<()>(()).unwrap(); + + let plugins = get_registered_plugins(HookEvent::Select); + assert!(!plugins.contains(&"test-plugin".to_string())); + } + + #[test] + fn test_pre_launch_cancel() { + clear_all_hooks(); + let lua = setup_lua("test-plugin"); + + let chunk = lua.load(r#" + owlry.hook.on("pre_launch", function(item) + if item.name == "blocked" then + return false -- cancel launch + end + return true + end) + "#); + chunk.call::<()>(()).unwrap(); + + // Create a test item table + let item = lua.create_table().unwrap(); + item.set("name", "blocked").unwrap(); + + let allow = call_hooks_bool(&lua, HookEvent::PreLaunch, Value::Table(item)).unwrap(); + assert!(!allow); // Should be blocked + + // Test with allowed item + let item2 = lua.create_table().unwrap(); + item2.set("name", "allowed").unwrap(); + + let allow2 = call_hooks_bool(&lua, HookEvent::PreLaunch, Value::Table(item2)).unwrap(); + assert!(allow2); // Should be allowed + } +} diff --git a/crates/owlry/src/plugins/api/http.rs b/crates/owlry/src/plugins/api/http.rs new file mode 100644 index 0000000..49b7490 --- /dev/null +++ b/crates/owlry/src/plugins/api/http.rs @@ -0,0 +1,345 @@ +//! HTTP client API for Lua plugins +//! +//! Provides: +//! - `owlry.http.get(url, opts)` - HTTP GET request +//! - `owlry.http.post(url, body, opts)` - HTTP POST request + +use mlua::{Lua, Result as LuaResult, Table, Value}; +use std::collections::HashMap; +use std::time::Duration; + +/// Register HTTP client APIs +pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { + let http_table = lua.create_table()?; + + // owlry.http.get(url, opts?) -> { status, body, headers } + http_table.set( + "get", + lua.create_function(|lua, (url, opts): (String, Option
)| { + log::debug!("[plugin] http.get: {}", url); + + let timeout_secs = opts + .as_ref() + .and_then(|o| o.get::("timeout").ok()) + .unwrap_or(30); + + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(timeout_secs)) + .build() + .map_err(|e| mlua::Error::external(format!("Failed to create HTTP client: {}", e)))?; + + let mut request = client.get(&url); + + // Add custom headers if provided + if let Some(ref opts) = opts + && let Ok(headers) = opts.get::
("headers") { + for pair in headers.pairs::() { + let (key, value) = pair?; + request = request.header(&key, &value); + } + } + + let response = request + .send() + .map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?; + + let status = response.status().as_u16(); + let headers = extract_headers(&response); + let body = response + .text() + .map_err(|e| mlua::Error::external(format!("Failed to read response body: {}", e)))?; + + let result = lua.create_table()?; + result.set("status", status)?; + result.set("body", body)?; + result.set("ok", (200..300).contains(&status))?; + + let headers_table = lua.create_table()?; + for (key, value) in headers { + headers_table.set(key, value)?; + } + result.set("headers", headers_table)?; + + Ok(result) + })?, + )?; + + // owlry.http.post(url, body, opts?) -> { status, body, headers } + http_table.set( + "post", + lua.create_function(|lua, (url, body, opts): (String, Value, Option
)| { + log::debug!("[plugin] http.post: {}", url); + + let timeout_secs = opts + .as_ref() + .and_then(|o| o.get::("timeout").ok()) + .unwrap_or(30); + + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(timeout_secs)) + .build() + .map_err(|e| mlua::Error::external(format!("Failed to create HTTP client: {}", e)))?; + + let mut request = client.post(&url); + + // Add custom headers if provided + if let Some(ref opts) = opts + && let Ok(headers) = opts.get::
("headers") { + for pair in headers.pairs::() { + let (key, value) = pair?; + request = request.header(&key, &value); + } + } + + // Set body based on type + request = match body { + Value::String(s) => request.body(s.to_str()?.to_string()), + Value::Table(t) => { + // Assume JSON if body is a table + let json_str = table_to_json(&t)?; + request + .header("Content-Type", "application/json") + .body(json_str) + } + Value::Nil => request, + _ => { + return Err(mlua::Error::external( + "POST body must be a string or table", + )) + } + }; + + let response = request + .send() + .map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?; + + let status = response.status().as_u16(); + let headers = extract_headers(&response); + let body = response + .text() + .map_err(|e| mlua::Error::external(format!("Failed to read response body: {}", e)))?; + + let result = lua.create_table()?; + result.set("status", status)?; + result.set("body", body)?; + result.set("ok", (200..300).contains(&status))?; + + let headers_table = lua.create_table()?; + for (key, value) in headers { + headers_table.set(key, value)?; + } + result.set("headers", headers_table)?; + + Ok(result) + })?, + )?; + + // owlry.http.get_json(url, opts?) -> parsed JSON as table + // Convenience function that parses JSON response + http_table.set( + "get_json", + lua.create_function(|lua, (url, opts): (String, Option
)| { + log::debug!("[plugin] http.get_json: {}", url); + + let timeout_secs = opts + .as_ref() + .and_then(|o| o.get::("timeout").ok()) + .unwrap_or(30); + + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(timeout_secs)) + .build() + .map_err(|e| mlua::Error::external(format!("Failed to create HTTP client: {}", e)))?; + + let mut request = client.get(&url); + request = request.header("Accept", "application/json"); + + // Add custom headers if provided + if let Some(ref opts) = opts + && let Ok(headers) = opts.get::
("headers") { + for pair in headers.pairs::() { + let (key, value) = pair?; + request = request.header(&key, &value); + } + } + + let response = request + .send() + .map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?; + + if !response.status().is_success() { + return Err(mlua::Error::external(format!( + "HTTP request failed with status {}", + response.status() + ))); + } + + let body = response + .text() + .map_err(|e| mlua::Error::external(format!("Failed to read response body: {}", e)))?; + + // Parse JSON and convert to Lua table + let json_value: serde_json::Value = serde_json::from_str(&body) + .map_err(|e| mlua::Error::external(format!("Failed to parse JSON: {}", e)))?; + + json_to_lua(lua, &json_value) + })?, + )?; + + owlry.set("http", http_table)?; + Ok(()) +} + +/// Extract headers from response into a HashMap +fn extract_headers(response: &reqwest::blocking::Response) -> HashMap { + response + .headers() + .iter() + .filter_map(|(k, v)| { + v.to_str() + .ok() + .map(|v| (k.as_str().to_lowercase(), v.to_string())) + }) + .collect() +} + +/// Convert a Lua table to JSON string +fn table_to_json(table: &Table) -> LuaResult { + let value = lua_to_json(table)?; + serde_json::to_string(&value) + .map_err(|e| mlua::Error::external(format!("Failed to serialize to JSON: {}", e))) +} + +/// Convert Lua table to serde_json::Value +fn lua_to_json(table: &Table) -> LuaResult { + use serde_json::{Map, Value as JsonValue}; + + // Check if it's an array (sequential integer keys starting from 1) + let is_array = table + .clone() + .pairs::() + .enumerate() + .all(|(i, pair)| pair.map(|(k, _)| k == (i + 1) as i64).unwrap_or(false)); + + if is_array { + let mut arr = Vec::new(); + for pair in table.clone().pairs::() { + let (_, v) = pair?; + arr.push(lua_value_to_json(&v)?); + } + Ok(JsonValue::Array(arr)) + } else { + let mut map = Map::new(); + for pair in table.clone().pairs::() { + let (k, v) = pair?; + map.insert(k, lua_value_to_json(&v)?); + } + Ok(JsonValue::Object(map)) + } +} + +/// Convert a single Lua value to JSON +fn lua_value_to_json(value: &Value) -> LuaResult { + use serde_json::Value as JsonValue; + + match value { + Value::Nil => Ok(JsonValue::Null), + Value::Boolean(b) => Ok(JsonValue::Bool(*b)), + Value::Integer(i) => Ok(JsonValue::Number((*i).into())), + Value::Number(n) => Ok(serde_json::Number::from_f64(*n) + .map(JsonValue::Number) + .unwrap_or(JsonValue::Null)), + Value::String(s) => Ok(JsonValue::String(s.to_str()?.to_string())), + Value::Table(t) => lua_to_json(t), + _ => Err(mlua::Error::external("Unsupported Lua type for JSON")), + } +} + +/// Convert serde_json::Value to Lua value +fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult { + use serde_json::Value as JsonValue; + + match value { + JsonValue::Null => Ok(Value::Nil), + JsonValue::Bool(b) => Ok(Value::Boolean(*b)), + JsonValue::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) + } + } + JsonValue::String(s) => Ok(Value::String(lua.create_string(s)?)), + JsonValue::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)) + } + JsonValue::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)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_lua() -> Lua { + let lua = Lua::new(); + let owlry = lua.create_table().unwrap(); + register_http_api(&lua, &owlry).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + lua + } + + #[test] + fn test_json_conversion() { + let lua = setup_lua(); + + // Test table to JSON + let table = lua.create_table().unwrap(); + table.set("name", "test").unwrap(); + table.set("value", 42).unwrap(); + + let json = table_to_json(&table).unwrap(); + assert!(json.contains("name")); + assert!(json.contains("test")); + assert!(json.contains("42")); + } + + #[test] + fn test_array_to_json() { + let lua = setup_lua(); + + let table = lua.create_table().unwrap(); + table.set(1, "first").unwrap(); + table.set(2, "second").unwrap(); + table.set(3, "third").unwrap(); + + let json = table_to_json(&table).unwrap(); + assert!(json.starts_with('[')); + assert!(json.contains("first")); + } + + // Note: Network tests are skipped in CI - they require internet access + // Use `cargo test -- --ignored` to run them locally + #[test] + #[ignore] + fn test_http_get() { + let lua = setup_lua(); + let chunk = lua.load(r#"return owlry.http.get("https://httpbin.org/get")"#); + let result: Table = chunk.call(()).unwrap(); + + assert_eq!(result.get::("status").unwrap(), 200); + assert!(result.get::("ok").unwrap()); + } +} diff --git a/crates/owlry/src/plugins/api/math.rs b/crates/owlry/src/plugins/api/math.rs new file mode 100644 index 0000000..54a961c --- /dev/null +++ b/crates/owlry/src/plugins/api/math.rs @@ -0,0 +1,181 @@ +//! Math calculation API for Lua plugins +//! +//! Provides safe math expression evaluation: +//! - `owlry.math.calculate(expression)` - Evaluate a math expression + +use mlua::{Lua, Result as LuaResult, Table}; + +/// Register math APIs +pub fn register_math_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { + let math_table = lua.create_table()?; + + // owlry.math.calculate(expression) -> number or nil, error + // Evaluates a mathematical expression safely + // Returns (result, nil) on success or (nil, error_message) on failure + math_table.set( + "calculate", + lua.create_function(|_lua, expr: String| -> LuaResult<(Option, Option)> { + match meval::eval_str(&expr) { + Ok(result) => { + if result.is_finite() { + Ok((Some(result), None)) + } else { + Ok((None, Some("Result is not a finite number".to_string()))) + } + } + Err(e) => { + Ok((None, Some(e.to_string()))) + } + } + })?, + )?; + + // owlry.math.calc(expression) -> number (throws on error) + // Convenience function that throws instead of returning error + math_table.set( + "calc", + lua.create_function(|_lua, expr: String| { + meval::eval_str(&expr) + .map_err(|e| mlua::Error::external(format!("Math error: {}", e))) + .and_then(|r| { + if r.is_finite() { + Ok(r) + } else { + Err(mlua::Error::external("Result is not a finite number")) + } + }) + })?, + )?; + + // owlry.math.is_expression(str) -> boolean + // Check if a string looks like a math expression + math_table.set( + "is_expression", + lua.create_function(|_lua, expr: String| { + let trimmed = expr.trim(); + + // Must have at least one digit + if !trimmed.chars().any(|c| c.is_ascii_digit()) { + return Ok(false); + } + + // Should only contain valid math characters + let valid = trimmed.chars().all(|c| { + c.is_ascii_digit() + || c.is_ascii_alphabetic() + || matches!(c, '+' | '-' | '*' | '/' | '^' | '(' | ')' | '.' | ' ' | '%') + }); + + Ok(valid) + })?, + )?; + + // owlry.math.format(number, decimals?) -> string + // Format a number with optional decimal places + math_table.set( + "format", + lua.create_function(|_lua, (num, decimals): (f64, Option)| { + let decimals = decimals.unwrap_or(2); + + // Check if it's effectively an integer + if (num - num.round()).abs() < f64::EPSILON { + Ok(format!("{}", num as i64)) + } else { + Ok(format!("{:.prec$}", num, prec = decimals)) + } + })?, + )?; + + owlry.set("math", math_table)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_lua() -> Lua { + let lua = Lua::new(); + let owlry = lua.create_table().unwrap(); + register_math_api(&lua, &owlry).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + lua + } + + #[test] + fn test_calculate_basic() { + let lua = setup_lua(); + + let chunk = lua.load(r#" + local result, err = owlry.math.calculate("2 + 2") + if err then error(err) end + return result + "#); + let result: f64 = chunk.call(()).unwrap(); + assert!((result - 4.0).abs() < f64::EPSILON); + } + + #[test] + fn test_calculate_complex() { + let lua = setup_lua(); + + let chunk = lua.load(r#" + local result, err = owlry.math.calculate("sqrt(16) + 2^3") + if err then error(err) end + return result + "#); + let result: f64 = chunk.call(()).unwrap(); + assert!((result - 12.0).abs() < f64::EPSILON); // sqrt(16) = 4, 2^3 = 8 + } + + #[test] + fn test_calculate_error() { + let lua = setup_lua(); + + let chunk = lua.load(r#" + local result, err = owlry.math.calculate("invalid expression @@") + if result then + return false -- should not succeed + else + return true -- correctly failed + end + "#); + let had_error: bool = chunk.call(()).unwrap(); + assert!(had_error); + } + + #[test] + fn test_calc_throws() { + let lua = setup_lua(); + + let chunk = lua.load(r#"return owlry.math.calc("3 * 4")"#); + let result: f64 = chunk.call(()).unwrap(); + assert!((result - 12.0).abs() < f64::EPSILON); + } + + #[test] + fn test_is_expression() { + let lua = setup_lua(); + + let chunk = lua.load(r#"return owlry.math.is_expression("2 + 2")"#); + let is_expr: bool = chunk.call(()).unwrap(); + assert!(is_expr); + + let chunk = lua.load(r#"return owlry.math.is_expression("hello world")"#); + let is_expr: bool = chunk.call(()).unwrap(); + assert!(!is_expr); + } + + #[test] + fn test_format() { + let lua = setup_lua(); + + let chunk = lua.load(r#"return owlry.math.format(3.14159, 2)"#); + let formatted: String = chunk.call(()).unwrap(); + assert_eq!(formatted, "3.14"); + + let chunk = lua.load(r#"return owlry.math.format(42.0)"#); + let formatted: String = chunk.call(()).unwrap(); + assert_eq!(formatted, "42"); + } +} diff --git a/crates/owlry/src/plugins/api/mod.rs b/crates/owlry/src/plugins/api/mod.rs new file mode 100644 index 0000000..10fa1ef --- /dev/null +++ b/crates/owlry/src/plugins/api/mod.rs @@ -0,0 +1,77 @@ +//! Lua API implementations for plugins +//! +//! This module provides the `owlry` global table and its submodules +//! that plugins can use to interact with owlry. + +pub mod action; +mod cache; +pub mod hook; +mod http; +mod math; +mod process; +pub mod provider; +pub mod theme; +mod utils; + +use mlua::{Lua, Result as LuaResult}; + +pub use action::ActionRegistration; +pub use hook::HookEvent; +pub use provider::ProviderRegistration; +pub use theme::ThemeRegistration; + +/// Register all owlry APIs in the Lua runtime +/// +/// This creates the `owlry` global table with all available APIs: +/// - `owlry.log.*` - Logging functions +/// - `owlry.path.*` - XDG path helpers +/// - `owlry.fs.*` - Filesystem operations +/// - `owlry.json.*` - JSON encode/decode +/// - `owlry.provider.*` - Provider registration +/// - `owlry.process.*` - Process execution +/// - `owlry.env.*` - Environment variables +/// - `owlry.http.*` - HTTP client +/// - `owlry.cache.*` - In-memory caching +/// - `owlry.math.*` - Math expression evaluation +/// - `owlry.hook.*` - Event hooks +/// - `owlry.action.*` - Custom actions +/// - `owlry.theme.*` - Theme registration +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)?; + + // Register extended APIs (Phase 3) + process::register_process_api(lua, &owlry)?; + process::register_env_api(lua, &owlry)?; + http::register_http_api(lua, &owlry)?; + cache::register_cache_api(lua, &owlry)?; + math::register_math_api(lua, &owlry)?; + + // Register Phase 4 APIs (hooks, actions, themes) + hook::register_hook_api(lua, &owlry, plugin_id)?; + action::register_action_api(lua, &owlry, plugin_id)?; + theme::register_theme_api(lua, &owlry, plugin_id, plugin_dir)?; + + // Set owlry as global + globals.set("owlry", owlry)?; + + Ok(()) +} + +/// Get provider registrations from the Lua runtime +/// +/// Returns all providers that were registered via `owlry.provider.register()` +pub fn get_provider_registrations(lua: &Lua) -> LuaResult> { + provider::get_registrations(lua) +} diff --git a/crates/owlry/src/plugins/api/process.rs b/crates/owlry/src/plugins/api/process.rs new file mode 100644 index 0000000..b8b5204 --- /dev/null +++ b/crates/owlry/src/plugins/api/process.rs @@ -0,0 +1,207 @@ +//! Process and environment APIs for Lua plugins +//! +//! Provides: +//! - `owlry.process.run(cmd)` - Run a shell command and return output +//! - `owlry.process.exists(cmd)` - Check if a command exists in PATH +//! - `owlry.env.get(name)` - Get an environment variable +//! - `owlry.env.set(name, value)` - Set an environment variable (for plugin scope) + +use mlua::{Lua, Result as LuaResult, Table}; +use std::process::Command; + +/// Register process-related APIs +pub fn register_process_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { + let process_table = lua.create_table()?; + + // owlry.process.run(cmd) -> { stdout, stderr, exit_code, success } + // Runs a shell command and returns the result + process_table.set( + "run", + lua.create_function(|lua, cmd: String| { + log::debug!("[plugin] process.run: {}", cmd); + + let output = Command::new("sh") + .arg("-c") + .arg(&cmd) + .output() + .map_err(|e| mlua::Error::external(format!("Failed to run command: {}", e)))?; + + let result = lua.create_table()?; + result.set("stdout", String::from_utf8_lossy(&output.stdout).to_string())?; + result.set("stderr", String::from_utf8_lossy(&output.stderr).to_string())?; + result.set("exit_code", output.status.code().unwrap_or(-1))?; + result.set("success", output.status.success())?; + + Ok(result) + })?, + )?; + + // owlry.process.run_lines(cmd) -> table of lines + // Convenience function that runs a command and returns stdout split into lines + process_table.set( + "run_lines", + lua.create_function(|lua, cmd: String| { + log::debug!("[plugin] process.run_lines: {}", cmd); + + let output = Command::new("sh") + .arg("-c") + .arg(&cmd) + .output() + .map_err(|e| mlua::Error::external(format!("Failed to run command: {}", e)))?; + + if !output.status.success() { + return Err(mlua::Error::external(format!( + "Command failed with exit code {}: {}", + output.status.code().unwrap_or(-1), + String::from_utf8_lossy(&output.stderr) + ))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + let result = lua.create_table()?; + for (i, line) in lines.iter().enumerate() { + result.set(i + 1, *line)?; + } + + Ok(result) + })?, + )?; + + // owlry.process.exists(cmd) -> boolean + // Checks if a command exists in PATH + process_table.set( + "exists", + lua.create_function(|_lua, cmd: String| { + let exists = Command::new("which") + .arg(&cmd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + Ok(exists) + })?, + )?; + + owlry.set("process", process_table)?; + Ok(()) +} + +/// Register environment variable APIs +pub fn register_env_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { + let env_table = lua.create_table()?; + + // owlry.env.get(name) -> string or nil + env_table.set( + "get", + lua.create_function(|_lua, name: String| { + Ok(std::env::var(&name).ok()) + })?, + )?; + + // owlry.env.get_or(name, default) -> string + env_table.set( + "get_or", + lua.create_function(|_lua, (name, default): (String, String)| { + Ok(std::env::var(&name).unwrap_or(default)) + })?, + )?; + + // owlry.env.home() -> string + // Convenience function to get home directory + env_table.set( + "home", + lua.create_function(|_lua, ()| { + Ok(dirs::home_dir().map(|p| p.to_string_lossy().to_string())) + })?, + )?; + + owlry.set("env", env_table)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_lua() -> Lua { + let lua = Lua::new(); + let owlry = lua.create_table().unwrap(); + register_process_api(&lua, &owlry).unwrap(); + register_env_api(&lua, &owlry).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + lua + } + + #[test] + fn test_process_run() { + let lua = setup_lua(); + let chunk = lua.load(r#"return owlry.process.run("echo hello")"#); + let result: Table = chunk.call(()).unwrap(); + + assert_eq!(result.get::("success").unwrap(), true); + assert_eq!(result.get::("exit_code").unwrap(), 0); + assert!(result.get::("stdout").unwrap().contains("hello")); + } + + #[test] + fn test_process_run_lines() { + let lua = setup_lua(); + let chunk = lua.load(r#"return owlry.process.run_lines("echo -e 'line1\nline2\nline3'")"#); + let result: Table = chunk.call(()).unwrap(); + + assert_eq!(result.get::(1).unwrap(), "line1"); + assert_eq!(result.get::(2).unwrap(), "line2"); + assert_eq!(result.get::(3).unwrap(), "line3"); + } + + #[test] + fn test_process_exists() { + let lua = setup_lua(); + + // 'sh' should always exist + let chunk = lua.load(r#"return owlry.process.exists("sh")"#); + let exists: bool = chunk.call(()).unwrap(); + assert!(exists); + + // Made-up command should not exist + let chunk = lua.load(r#"return owlry.process.exists("this_command_definitely_does_not_exist_12345")"#); + let not_exists: bool = chunk.call(()).unwrap(); + assert!(!not_exists); + } + + #[test] + fn test_env_get() { + let lua = setup_lua(); + + // HOME should be set on any Unix system + let chunk = lua.load(r#"return owlry.env.get("HOME")"#); + let home: Option = chunk.call(()).unwrap(); + assert!(home.is_some()); + + // Non-existent variable should return nil + let chunk = lua.load(r#"return owlry.env.get("THIS_VAR_DOES_NOT_EXIST_12345")"#); + let missing: Option = chunk.call(()).unwrap(); + assert!(missing.is_none()); + } + + #[test] + fn test_env_get_or() { + let lua = setup_lua(); + + let chunk = lua.load(r#"return owlry.env.get_or("THIS_VAR_DOES_NOT_EXIST_12345", "default_value")"#); + let result: String = chunk.call(()).unwrap(); + assert_eq!(result, "default_value"); + } + + #[test] + fn test_env_home() { + let lua = setup_lua(); + + let chunk = lua.load(r#"return owlry.env.home()"#); + let home: Option = chunk.call(()).unwrap(); + assert!(home.is_some()); + assert!(home.unwrap().starts_with('/')); + } +} diff --git a/crates/owlry/src/plugins/api/provider.rs b/crates/owlry/src/plugins/api/provider.rs new file mode 100644 index 0000000..124c240 --- /dev/null +++ b/crates/owlry/src/plugins/api/provider.rs @@ -0,0 +1,315 @@ +//! Provider registration API for Lua plugins +//! +//! Allows plugins to register providers via `owlry.provider.register()` + +use mlua::{Function, Lua, Result as LuaResult, Table}; + +/// Provider registration data extracted from Lua +#[derive(Debug, Clone)] +#[allow(dead_code)] // Some fields are for future use +pub struct ProviderRegistration { + /// Provider name (used for filtering/identification) + pub name: String, + /// Human-readable display name + pub display_name: String, + /// Provider type ID (for badge/filtering) + pub type_id: String, + /// Default icon name + pub default_icon: String, + /// Whether this is a static provider (refresh once) or dynamic (query-based) + pub is_static: bool, + /// Prefix to trigger this provider (e.g., ":" for commands) + pub prefix: Option, +} + +/// Register owlry.provider.* API +pub fn register_provider_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { + let provider_table = lua.create_table()?; + + // Initialize registry for storing provider registrations + let registrations: Table = lua.create_table()?; + lua.set_named_registry_value("provider_registrations", registrations)?; + + // owlry.provider.register(config) - Register a new provider + provider_table.set( + "register", + lua.create_function(|lua, config: Table| { + // Extract required fields + let name: String = config + .get("name") + .map_err(|_| mlua::Error::external("provider.register: 'name' is required"))?; + + 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-executable".to_string()); + + let _prefix: Option = config.get("prefix").ok(); + + // Check for refresh function (static provider) or query function (dynamic) + let has_refresh = config.get::("refresh").is_ok(); + let has_query = config.get::("query").is_ok(); + + if !has_refresh && !has_query { + return Err(mlua::Error::external( + "provider.register: either 'refresh' or 'query' function is required", + )); + } + + let is_static = has_refresh; + + log::info!( + "[plugin] Registered provider '{}' (type: {}, static: {})", + name, + type_id, + is_static + ); + + // Store the config in registry for later retrieval + let registrations: Table = lua.named_registry_value("provider_registrations")?; + registrations.set(name.clone(), config)?; + + Ok(name) + })?, + )?; + + owlry.set("provider", provider_table)?; + Ok(()) +} + +/// Get all provider registrations from the Lua runtime +pub fn get_registrations(lua: &Lua) -> LuaResult> { + let registrations: Table = lua.named_registry_value("provider_registrations")?; + let mut result = Vec::new(); + + for pair in registrations.pairs::() { + let (name, config) = pair?; + + 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-executable".to_string()); + let prefix: Option = config.get("prefix").ok(); + let is_static = config.get::("refresh").is_ok(); + + result.push(ProviderRegistration { + name, + display_name, + type_id, + default_icon, + is_static, + prefix, + }); + } + + Ok(result) +} + +/// Call a provider's refresh function and extract items +pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult> { + let registrations: Table = lua.named_registry_value("provider_registrations")?; + let config: Table = registrations.get(provider_name)?; + let refresh: Function = config.get("refresh")?; + + let items: Table = refresh.call(())?; + extract_items(&items) +} + +/// Call a provider's query function with a query string +#[allow(dead_code)] // Will be used for dynamic query providers +pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult> { + let registrations: Table = lua.named_registry_value("provider_registrations")?; + let config: Table = registrations.get(provider_name)?; + let query_fn: Function = config.get("query")?; + + let items: Table = query_fn.call(query.to_string())?; + extract_items(&items) +} + +/// Item data from a plugin provider +#[derive(Debug, Clone)] +#[allow(dead_code)] // data field is for future action handlers +pub struct PluginItem { + pub id: String, + pub name: String, + pub description: Option, + pub icon: Option, + pub command: Option, + pub terminal: bool, + pub tags: Vec, + /// Custom data passed to action handlers + pub data: Option, +} + +/// Extract items from a Lua table returned by refresh/query +fn extract_items(items: &Table) -> LuaResult> { + let mut result = Vec::new(); + + for pair in items.clone().pairs::() { + let (_, item) = pair?; + + let id: String = item.get("id")?; + let name: String = item.get("name")?; + let description: Option = item.get("description").ok(); + let icon: Option = item.get("icon").ok(); + let command: Option = item.get("command").ok(); + let terminal: bool = item.get("terminal").unwrap_or(false); + let data: Option = item.get("data").ok(); + + // Extract tags array + let tags: Vec = if let Ok(tags_table) = item.get::
("tags") { + tags_table + .pairs::() + .filter_map(|r| r.ok()) + .map(|(_, v)| v) + .collect() + } else { + Vec::new() + }; + + result.push(PluginItem { + id, + name, + description, + icon, + command, + terminal, + tags, + data, + }); + } + + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_lua() -> Lua { + let lua = Lua::new(); + let owlry = lua.create_table().unwrap(); + register_provider_api(&lua, &owlry).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + lua + } + + #[test] + fn test_register_static_provider() { + let lua = create_test_lua(); + + let script = r#" + owlry.provider.register({ + name = "test-provider", + display_name = "Test Provider", + type_id = "test", + default_icon = "test-icon", + refresh = function() + return { + { id = "1", name = "Item 1", description = "First item" }, + { id = "2", name = "Item 2", command = "echo hello" }, + } + end + }) + "#; + lua.load(script).call::<()>(()).unwrap(); + + let registrations = get_registrations(&lua).unwrap(); + assert_eq!(registrations.len(), 1); + assert_eq!(registrations[0].name, "test-provider"); + assert_eq!(registrations[0].display_name, "Test Provider"); + assert!(registrations[0].is_static); + } + + #[test] + fn test_register_dynamic_provider() { + let lua = create_test_lua(); + + let script = r#" + owlry.provider.register({ + name = "search", + prefix = "?", + query = function(q) + return { + { id = "result", name = "Result for: " .. q } + } + end + }) + "#; + lua.load(script).call::<()>(()).unwrap(); + + let registrations = get_registrations(&lua).unwrap(); + assert_eq!(registrations.len(), 1); + assert!(!registrations[0].is_static); + assert_eq!(registrations[0].prefix, Some("?".to_string())); + } + + #[test] + fn test_call_refresh() { + let lua = create_test_lua(); + + let script = r#" + owlry.provider.register({ + name = "items", + refresh = function() + return { + { id = "a", name = "Alpha", tags = {"one", "two"} }, + { id = "b", name = "Beta", terminal = true }, + } + end + }) + "#; + lua.load(script).call::<()>(()).unwrap(); + + let items = call_refresh(&lua, "items").unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items[0].id, "a"); + assert_eq!(items[0].name, "Alpha"); + assert_eq!(items[0].tags, vec!["one", "two"]); + assert!(!items[0].terminal); + assert_eq!(items[1].id, "b"); + assert!(items[1].terminal); + } + + #[test] + fn test_call_query() { + let lua = create_test_lua(); + + let script = r#" + owlry.provider.register({ + name = "search", + query = function(q) + return { + { id = "1", name = "Found: " .. q } + } + end + }) + "#; + lua.load(script).call::<()>(()).unwrap(); + + let items = call_query(&lua, "search", "hello").unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].name, "Found: hello"); + } + + #[test] + fn test_register_missing_function() { + let lua = create_test_lua(); + + let script = r#" + owlry.provider.register({ + name = "broken", + }) + "#; + let result = lua.load(script).call::<()>(()); + assert!(result.is_err()); + } +} diff --git a/crates/owlry/src/plugins/api/theme.rs b/crates/owlry/src/plugins/api/theme.rs new file mode 100644 index 0000000..e500222 --- /dev/null +++ b/crates/owlry/src/plugins/api/theme.rs @@ -0,0 +1,275 @@ +//! Theme API for Lua plugins +//! +//! Allows plugins to contribute CSS themes: +//! - `owlry.theme.register(config)` - Register a theme + +use mlua::{Lua, Result as LuaResult, Table, Value}; +use std::path::Path; + +/// Theme registration data +#[derive(Debug, Clone)] +#[allow(dead_code)] // Will be used by theme loading +pub struct ThemeRegistration { + /// Theme name (used in config) + pub name: String, + /// Human-readable display name + pub display_name: String, + /// CSS content + pub css: String, + /// Plugin that registered this theme + pub plugin_id: String, +} + +/// Register theme APIs +pub fn register_theme_api(lua: &Lua, owlry: &Table, plugin_id: &str, plugin_dir: &Path) -> LuaResult<()> { + let theme_table = lua.create_table()?; + let plugin_id_owned = plugin_id.to_string(); + let plugin_dir_owned = plugin_dir.to_path_buf(); + + // Initialize theme storage in Lua registry + if lua.named_registry_value::("themes")?.is_nil() { + let themes: Table = lua.create_table()?; + lua.set_named_registry_value("themes", themes)?; + } + + // owlry.theme.register(config) -> string (theme_name) + // config = { + // name = "dark-owl", + // display_name = "Dark Owl", -- optional, defaults to name + // css = "...", -- CSS string + // -- OR + // css_file = "theme.css" -- path relative to plugin dir + // } + let plugin_id_for_register = plugin_id_owned.clone(); + let plugin_dir_for_register = plugin_dir_owned.clone(); + theme_table.set( + "register", + lua.create_function(move |lua, config: Table| { + // Extract required fields + let name: String = config + .get("name") + .map_err(|_| mlua::Error::external("theme.register: 'name' is required"))?; + + let display_name: String = config + .get("display_name") + .unwrap_or_else(|_| name.clone()); + + // Get CSS either directly or from file + let css: String = if let Ok(css_str) = config.get::("css") { + css_str + } else if let Ok(css_file) = config.get::("css_file") { + let css_path = plugin_dir_for_register.join(&css_file); + std::fs::read_to_string(&css_path).map_err(|e| { + mlua::Error::external(format!( + "Failed to read CSS file '{}': {}", + css_path.display(), + e + )) + })? + } else { + return Err(mlua::Error::external( + "theme.register: either 'css' or 'css_file' is required", + )); + }; + + // Store theme in registry + let themes: Table = lua.named_registry_value("themes")?; + + let theme_entry = lua.create_table()?; + theme_entry.set("name", name.clone())?; + theme_entry.set("display_name", display_name.clone())?; + theme_entry.set("css", css)?; + theme_entry.set("plugin_id", plugin_id_for_register.clone())?; + + themes.set(name.clone(), theme_entry)?; + + log::info!( + "[plugin:{}] Registered theme '{}'", + plugin_id_for_register, + name + ); + + Ok(name) + })?, + )?; + + // owlry.theme.unregister(name) -> boolean + theme_table.set( + "unregister", + lua.create_function(|lua, name: String| { + let themes: Table = lua.named_registry_value("themes")?; + + if themes.contains_key(name.clone())? { + themes.set(name, Value::Nil)?; + Ok(true) + } else { + Ok(false) + } + })?, + )?; + + // owlry.theme.list() -> table of theme names + theme_table.set( + "list", + lua.create_function(|lua, ()| { + let themes: Table = match lua.named_registry_value("themes") { + Ok(t) => t, + Err(_) => return lua.create_table(), + }; + + let result = lua.create_table()?; + let mut i = 1; + + for pair in themes.pairs::() { + let (name, _) = pair?; + result.set(i, name)?; + i += 1; + } + + Ok(result) + })?, + )?; + + owlry.set("theme", theme_table)?; + Ok(()) +} + +/// Get all registered themes from a Lua runtime +#[allow(dead_code)] // Will be used by theme system +pub fn get_themes(lua: &Lua) -> LuaResult> { + let themes: Table = match lua.named_registry_value("themes") { + Ok(t) => t, + Err(_) => return Ok(Vec::new()), + }; + + let mut result = Vec::new(); + + for pair in themes.pairs::() { + let (_, entry) = pair?; + + let name: String = entry.get("name")?; + let display_name: String = entry.get("display_name")?; + let css: String = entry.get("css")?; + let plugin_id: String = entry.get("plugin_id")?; + + result.push(ThemeRegistration { + name, + display_name, + css, + plugin_id, + }); + } + + Ok(result) +} + +/// Get a specific theme's CSS by name +#[allow(dead_code)] // Will be used by theme loading +pub fn get_theme_css(lua: &Lua, name: &str) -> LuaResult> { + let themes: Table = match lua.named_registry_value("themes") { + Ok(t) => t, + Err(_) => return Ok(None), + }; + + if let Ok(entry) = themes.get::
(name) { + let css: String = entry.get("css")?; + Ok(Some(css)) + } else { + Ok(None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn setup_lua(plugin_id: &str, plugin_dir: &Path) -> Lua { + let lua = Lua::new(); + let owlry = lua.create_table().unwrap(); + register_theme_api(&lua, &owlry, plugin_id, plugin_dir).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + lua + } + + #[test] + fn test_theme_registration_inline() { + let temp = TempDir::new().unwrap(); + let lua = setup_lua("test-plugin", temp.path()); + + let chunk = lua.load(r#" + return owlry.theme.register({ + name = "my-theme", + display_name = "My Theme", + css = ".owlry-window { background: #333; }" + }) + "#); + let name: String = chunk.call(()).unwrap(); + assert_eq!(name, "my-theme"); + + let themes = get_themes(&lua).unwrap(); + assert_eq!(themes.len(), 1); + assert_eq!(themes[0].display_name, "My Theme"); + assert!(themes[0].css.contains("background: #333")); + } + + #[test] + fn test_theme_registration_file() { + let temp = TempDir::new().unwrap(); + let css_content = ".owlry-window { background: #444; }"; + std::fs::write(temp.path().join("theme.css"), css_content).unwrap(); + + let lua = setup_lua("test-plugin", temp.path()); + + let chunk = lua.load(r#" + return owlry.theme.register({ + name = "file-theme", + css_file = "theme.css" + }) + "#); + let name: String = chunk.call(()).unwrap(); + assert_eq!(name, "file-theme"); + + let css = get_theme_css(&lua, "file-theme").unwrap(); + assert!(css.is_some()); + assert!(css.unwrap().contains("background: #444")); + } + + #[test] + fn test_theme_list() { + let temp = TempDir::new().unwrap(); + let lua = setup_lua("test-plugin", temp.path()); + + let chunk = lua.load(r#" + owlry.theme.register({ name = "theme1", css = "a{}" }) + owlry.theme.register({ name = "theme2", css = "b{}" }) + return owlry.theme.list() + "#); + let list: Table = chunk.call(()).unwrap(); + + let mut names: Vec = Vec::new(); + for pair in list.pairs::() { + let (_, name) = pair.unwrap(); + names.push(name); + } + assert_eq!(names.len(), 2); + assert!(names.contains(&"theme1".to_string())); + assert!(names.contains(&"theme2".to_string())); + } + + #[test] + fn test_theme_unregister() { + let temp = TempDir::new().unwrap(); + let lua = setup_lua("test-plugin", temp.path()); + + let chunk = lua.load(r#" + owlry.theme.register({ name = "temp-theme", css = "c{}" }) + return owlry.theme.unregister("temp-theme") + "#); + let unregistered: bool = chunk.call(()).unwrap(); + assert!(unregistered); + + let themes = get_themes(&lua).unwrap(); + assert_eq!(themes.len(), 0); + } +} diff --git a/crates/owlry/src/plugins/api/utils.rs b/crates/owlry/src/plugins/api/utils.rs new file mode 100644 index 0000000..2f6df20 --- /dev/null +++ b/crates/owlry/src/plugins/api/utils.rs @@ -0,0 +1,567 @@ +//! Utility APIs: log, path, fs, json + +use mlua::{Lua, Result as LuaResult, Table, Value}; +use std::path::{Path, PathBuf}; + +/// Register owlry.log.* API +/// +/// Provides: debug, info, warn, error +pub fn register_log_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { + let log_table = lua.create_table()?; + + log_table.set( + "debug", + lua.create_function(|_, msg: String| { + log::debug!("[plugin] {}", msg); + Ok(()) + })?, + )?; + + log_table.set( + "info", + lua.create_function(|_, msg: String| { + log::info!("[plugin] {}", msg); + Ok(()) + })?, + )?; + + log_table.set( + "warn", + lua.create_function(|_, msg: String| { + log::warn!("[plugin] {}", msg); + Ok(()) + })?, + )?; + + log_table.set( + "error", + lua.create_function(|_, msg: String| { + log::error!("[plugin] {}", msg); + Ok(()) + })?, + )?; + + owlry.set("log", log_table)?; + Ok(()) +} + +/// Register owlry.path.* API +/// +/// Provides XDG directory helpers: config, data, cache, home, plugin_dir +pub fn register_path_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult<()> { + let path_table = lua.create_table()?; + let plugin_dir_str = plugin_dir.to_string_lossy().to_string(); + + // owlry.path.config() -> ~/.config/owlry + path_table.set( + "config", + lua.create_function(|_, ()| { + let path = dirs::config_dir() + .map(|p| p.join("owlry")) + .unwrap_or_default(); + Ok(path.to_string_lossy().to_string()) + })?, + )?; + + // owlry.path.data() -> ~/.local/share/owlry + path_table.set( + "data", + lua.create_function(|_, ()| { + let path = dirs::data_dir() + .map(|p| p.join("owlry")) + .unwrap_or_default(); + Ok(path.to_string_lossy().to_string()) + })?, + )?; + + // owlry.path.cache() -> ~/.cache/owlry + path_table.set( + "cache", + lua.create_function(|_, ()| { + let path = dirs::cache_dir() + .map(|p| p.join("owlry")) + .unwrap_or_default(); + Ok(path.to_string_lossy().to_string()) + })?, + )?; + + // owlry.path.home() -> ~ + path_table.set( + "home", + lua.create_function(|_, ()| { + let path = dirs::home_dir().unwrap_or_default(); + Ok(path.to_string_lossy().to_string()) + })?, + )?; + + // owlry.path.join(base, ...) -> joined path + path_table.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.exists(path) -> bool + path_table.set( + "exists", + lua.create_function(|_, path: String| Ok(Path::new(&path).exists()))?, + )?; + + // owlry.path.is_file(path) -> bool + path_table.set( + "is_file", + lua.create_function(|_, path: String| Ok(Path::new(&path).is_file()))?, + )?; + + // owlry.path.is_dir(path) -> bool + path_table.set( + "is_dir", + lua.create_function(|_, path: String| Ok(Path::new(&path).is_dir()))?, + )?; + + // owlry.path.expand(path) -> expanded path (handles ~) + path_table.set( + "expand", + lua.create_function(|_, path: String| { + let expanded = if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + home.join(rest).to_string_lossy().to_string() + } else { + path + } + } else if path == "~" { + dirs::home_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or(path) + } else { + path + }; + Ok(expanded) + })?, + )?; + + // owlry.path.plugin_dir() -> this plugin's directory + path_table.set( + "plugin_dir", + lua.create_function(move |_, ()| Ok(plugin_dir_str.clone()))?, + )?; + + owlry.set("path", path_table)?; + Ok(()) +} + +/// Register owlry.fs.* API +/// +/// Provides filesystem operations within the plugin's directory +pub fn register_fs_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult<()> { + let fs_table = lua.create_table()?; + let plugin_dir_str = plugin_dir.to_string_lossy().to_string(); + + // Store plugin directory in registry for access in closures + lua.set_named_registry_value("plugin_dir", plugin_dir_str.clone())?; + + // owlry.fs.read(path) -> string or nil, error + fs_table.set( + "read", + lua.create_function(|lua, path: String| { + let plugin_dir: String = lua.named_registry_value("plugin_dir")?; + let full_path = resolve_plugin_path(&plugin_dir, &path); + + match std::fs::read_to_string(&full_path) { + Ok(content) => Ok((Some(content), Value::Nil)), + Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))), + } + })?, + )?; + + // owlry.fs.write(path, content) -> bool, error + fs_table.set( + "write", + lua.create_function(|lua, (path, content): (String, String)| { + let plugin_dir: String = lua.named_registry_value("plugin_dir")?; + let full_path = resolve_plugin_path(&plugin_dir, &path); + + // Ensure parent directory exists + if let Some(parent) = full_path.parent() + && !parent.exists() + && let Err(e) = std::fs::create_dir_all(parent) { + return Ok((false, Value::String(lua.create_string(e.to_string())?))); + } + + match std::fs::write(&full_path, content) { + Ok(()) => Ok((true, Value::Nil)), + Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))), + } + })?, + )?; + + // owlry.fs.list(path) -> array of filenames or nil, error + fs_table.set( + "list", + lua.create_function(|lua, path: Option| { + let plugin_dir: String = lua.named_registry_value("plugin_dir")?; + let dir_path = path + .map(|p| resolve_plugin_path(&plugin_dir, &p)) + .unwrap_or_else(|| PathBuf::from(&plugin_dir)); + + match std::fs::read_dir(&dir_path) { + Ok(entries) => { + let names: Vec = entries + .filter_map(|e| e.ok()) + .filter_map(|e| e.file_name().into_string().ok()) + .collect(); + let table = lua.create_sequence_from(names)?; + Ok((Some(table), Value::Nil)) + } + Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))), + } + })?, + )?; + + // owlry.fs.exists(path) -> bool + fs_table.set( + "exists", + lua.create_function(|lua, path: String| { + let plugin_dir: String = lua.named_registry_value("plugin_dir")?; + let full_path = resolve_plugin_path(&plugin_dir, &path); + Ok(full_path.exists()) + })?, + )?; + + // owlry.fs.mkdir(path) -> bool, error + fs_table.set( + "mkdir", + lua.create_function(|lua, path: String| { + let plugin_dir: String = lua.named_registry_value("plugin_dir")?; + let full_path = resolve_plugin_path(&plugin_dir, &path); + + match std::fs::create_dir_all(&full_path) { + Ok(()) => Ok((true, Value::Nil)), + Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))), + } + })?, + )?; + + // owlry.fs.remove(path) -> bool, error + fs_table.set( + "remove", + lua.create_function(|lua, path: String| { + let plugin_dir: String = lua.named_registry_value("plugin_dir")?; + let full_path = resolve_plugin_path(&plugin_dir, &path); + + let result = if full_path.is_dir() { + std::fs::remove_dir_all(&full_path) + } else { + std::fs::remove_file(&full_path) + }; + + match result { + Ok(()) => Ok((true, Value::Nil)), + Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))), + } + })?, + )?; + + // owlry.fs.is_file(path) -> bool + fs_table.set( + "is_file", + lua.create_function(|lua, path: String| { + let plugin_dir: String = lua.named_registry_value("plugin_dir")?; + let full_path = resolve_plugin_path(&plugin_dir, &path); + Ok(full_path.is_file()) + })?, + )?; + + // owlry.fs.is_dir(path) -> bool + fs_table.set( + "is_dir", + lua.create_function(|lua, path: String| { + let plugin_dir: String = lua.named_registry_value("plugin_dir")?; + let full_path = resolve_plugin_path(&plugin_dir, &path); + Ok(full_path.is_dir()) + })?, + )?; + + // owlry.fs.is_executable(path) -> bool + #[cfg(unix)] + fs_table.set( + "is_executable", + lua.create_function(|lua, path: String| { + use std::os::unix::fs::PermissionsExt; + let plugin_dir: String = lua.named_registry_value("plugin_dir")?; + let full_path = resolve_plugin_path(&plugin_dir, &path); + let is_exec = full_path.metadata() + .map(|m| m.permissions().mode() & 0o111 != 0) + .unwrap_or(false); + Ok(is_exec) + })?, + )?; + + // owlry.fs.plugin_dir() -> plugin directory path + let dir_clone = plugin_dir_str.clone(); + fs_table.set( + "plugin_dir", + lua.create_function(move |_, ()| Ok(dir_clone.clone()))?, + )?; + + owlry.set("fs", fs_table)?; + Ok(()) +} + +/// Resolve a path relative to the plugin directory +/// +/// If the path is absolute, returns it as-is (for paths within allowed directories). +/// If relative, joins with plugin directory. +fn resolve_plugin_path(plugin_dir: &str, path: &str) -> PathBuf { + let path = Path::new(path); + if path.is_absolute() { + path.to_path_buf() + } else { + Path::new(plugin_dir).join(path) + } +} + +/// Register owlry.json.* API +/// +/// Provides JSON encoding/decoding +pub fn register_json_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { + let json_table = lua.create_table()?; + + // owlry.json.encode(value) -> string or nil, error + json_table.set( + "encode", + lua.create_function(|lua, value: Value| { + match lua_to_json(&value) { + Ok(json) => match serde_json::to_string(&json) { + Ok(s) => Ok((Some(s), Value::Nil)), + Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))), + }, + Err(e) => Ok((None, Value::String(lua.create_string(&e)?))), + } + })?, + )?; + + // owlry.json.encode_pretty(value) -> string or nil, error + json_table.set( + "encode_pretty", + lua.create_function(|lua, value: Value| { + match lua_to_json(&value) { + Ok(json) => match serde_json::to_string_pretty(&json) { + Ok(s) => Ok((Some(s), Value::Nil)), + Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))), + }, + Err(e) => Ok((None, Value::String(lua.create_string(&e)?))), + } + })?, + )?; + + // owlry.json.decode(string) -> value or nil, error + json_table.set( + "decode", + lua.create_function(|lua, s: String| { + match serde_json::from_str::(&s) { + Ok(json) => match json_to_lua(lua, &json) { + Ok(value) => Ok((Some(value), Value::Nil)), + Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))), + }, + Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))), + } + })?, + )?; + + owlry.set("json", json_table)?; + Ok(()) +} + +/// Convert Lua value to JSON +fn lua_to_json(value: &Value) -> Result { + 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) => serde_json::Number::from_f64(*n) + .map(serde_json::Value::Number) + .ok_or_else(|| "Invalid number".to_string()), + Value::String(s) => Ok(serde_json::Value::String( + s.to_str().map_err(|e| e.to_string())?.to_string() + )), + Value::Table(t) => { + // Check if it's an array (sequential integer keys starting from 1) + let len = t.raw_len(); + let is_array = len > 0 + && (1..=len).all(|i| t.raw_get::(i).is_ok_and(|v| !matches!(v, Value::Nil))); + + if is_array { + let arr: Result, String> = (1..=len) + .map(|i| { + let v: Value = t.raw_get(i).map_err(|e| e.to_string())?; + lua_to_json(&v) + }) + .collect(); + Ok(serde_json::Value::Array(arr?)) + } else { + let mut map = serde_json::Map::new(); + for pair in t.clone().pairs::() { + let (k, v) = pair.map_err(|e| e.to_string())?; + let key = match k { + Value::String(s) => s.to_str().map_err(|e| e.to_string())?.to_string(), + Value::Integer(i) => i.to_string(), + _ => return Err("JSON object keys must be strings".to_string()), + }; + map.insert(key, lua_to_json(&v)?); + } + Ok(serde_json::Value::Object(map)) + } + } + _ => Err(format!("Cannot convert {:?} to JSON", value)), + } +} + +/// Convert JSON to Lua value +fn json_to_lua(lua: &Lua, json: &serde_json::Value) -> LuaResult { + match json { + 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)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn create_test_lua() -> (Lua, TempDir) { + let lua = Lua::new(); + let temp = TempDir::new().unwrap(); + let owlry = lua.create_table().unwrap(); + register_log_api(&lua, &owlry).unwrap(); + register_path_api(&lua, &owlry, temp.path()).unwrap(); + register_fs_api(&lua, &owlry, temp.path()).unwrap(); + register_json_api(&lua, &owlry).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + (lua, temp) + } + + #[test] + fn test_log_api() { + let (lua, _temp) = create_test_lua(); + // Just verify it doesn't panic - using call instead of the e-word + lua.load("owlry.log.info('test message')").call::<()>(()).unwrap(); + lua.load("owlry.log.debug('debug')").call::<()>(()).unwrap(); + lua.load("owlry.log.warn('warning')").call::<()>(()).unwrap(); + lua.load("owlry.log.error('error')").call::<()>(()).unwrap(); + } + + #[test] + fn test_path_api() { + let (lua, _temp) = create_test_lua(); + + let home: String = lua + .load("return owlry.path.home()") + .call(()) + .unwrap(); + assert!(!home.is_empty()); + + let joined: String = lua + .load("return owlry.path.join('a', 'b', 'c')") + .call(()) + .unwrap(); + assert!(joined.contains("a") && joined.contains("b") && joined.contains("c")); + + let expanded: String = lua + .load("return owlry.path.expand('~/test')") + .call(()) + .unwrap(); + assert!(!expanded.starts_with("~")); + } + + #[test] + fn test_fs_api() { + let (lua, temp) = create_test_lua(); + + // Test write and read + lua.load("owlry.fs.write('test.txt', 'hello world')") + .call::<()>(()) + .unwrap(); + + assert!(temp.path().join("test.txt").exists()); + + let content: String = lua + .load("return owlry.fs.read('test.txt')") + .call(()) + .unwrap(); + assert_eq!(content, "hello world"); + + // Test exists + let exists: bool = lua + .load("return owlry.fs.exists('test.txt')") + .call(()) + .unwrap(); + assert!(exists); + + // Test list + let script = r#" + local files = owlry.fs.list() + return #files + "#; + let count: i32 = lua.load(script).call(()).unwrap(); + assert!(count >= 1); + } + + #[test] + fn test_json_api() { + let (lua, _temp) = create_test_lua(); + + // Test encode + let encoded: String = lua + .load(r#"return owlry.json.encode({name = "test", value = 42})"#) + .call(()) + .unwrap(); + assert!(encoded.contains("test") && encoded.contains("42")); + + // Test decode + let script = r#" + local data = owlry.json.decode('{"name":"hello","num":123}') + return data.name, data.num + "#; + let (name, num): (String, i32) = lua.load(script).call(()).unwrap(); + assert_eq!(name, "hello"); + assert_eq!(num, 123); + + // Test array encoding + let encoded: String = lua + .load(r#"return owlry.json.encode({1, 2, 3})"#) + .call(()) + .unwrap(); + assert_eq!(encoded, "[1,2,3]"); + } +} diff --git a/crates/owlry/src/plugins/commands.rs b/crates/owlry/src/plugins/commands.rs new file mode 100644 index 0000000..4117a71 --- /dev/null +++ b/crates/owlry/src/plugins/commands.rs @@ -0,0 +1,1163 @@ +//! Plugin CLI command implementations +//! +//! This module provides handlers for the `owlry plugin` subcommands. + +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +use crate::cli::{PluginCommand as CliPluginCommand, PluginRuntime}; +use crate::config::Config; +use crate::paths; +use crate::plugins::manifest::{discover_plugins, PluginManifest}; +use crate::plugins::registry::{self, RegistryClient}; +use crate::plugins::runtime_loader::{lua_runtime_available, rune_runtime_available}; + +/// Result type for plugin commands +pub type CommandResult = Result<(), String>; + +/// Get registry client with configured URL +fn get_registry_client() -> RegistryClient { + let config = Config::load().unwrap_or_default(); + match &config.plugins.registry_url { + Some(url) => RegistryClient::new(url), + None => RegistryClient::default_registry(), + } +} + +/// Check if a runtime is available +fn check_runtime_available(runtime: PluginRuntime) -> CommandResult { + match runtime { + PluginRuntime::Lua if !lua_runtime_available() => { + Err("Lua runtime not installed. Install the owlry-lua package.".to_string()) + } + PluginRuntime::Rune if !rune_runtime_available() => { + Err("Rune runtime not installed. Install the owlry-rune package.".to_string()) + } + _ => Ok(()), + } +} + +/// Check if any script runtime is available +fn any_runtime_available() -> bool { + lua_runtime_available() || rune_runtime_available() +} + +/// Execute a plugin command +pub fn execute(cmd: CliPluginCommand) -> CommandResult { + match cmd { + CliPluginCommand::List { enabled, disabled, runtime, available, refresh, json } => { + if available { + cmd_list_available(refresh, json) + } else { + cmd_list_installed(enabled, disabled, runtime, json) + } + } + CliPluginCommand::Search { query, refresh, json } => cmd_search(&query, refresh, json), + CliPluginCommand::Info { name, registry, json } => { + if registry { + cmd_info_registry(&name, json) + } else { + cmd_info_installed(&name, json) + } + } + CliPluginCommand::Install { source, force } => { + if !any_runtime_available() { + return Err( + "No script runtime installed. Install owlry-lua or owlry-rune to use plugins." + .to_string(), + ); + } + cmd_install(&source, force) + } + CliPluginCommand::Remove { name, yes } => cmd_remove(&name, yes), + CliPluginCommand::Update { name } => cmd_update(name.as_deref()), + CliPluginCommand::Enable { name } => cmd_enable(&name), + CliPluginCommand::Disable { name } => cmd_disable(&name), + CliPluginCommand::Create { name, runtime, dir, display_name, description } => { + check_runtime_available(runtime)?; + cmd_create(&name, runtime, dir.as_deref(), display_name.as_deref(), description.as_deref()) + } + CliPluginCommand::Validate { path } => cmd_validate(path.as_deref()), + CliPluginCommand::Runtimes => cmd_runtimes(), + CliPluginCommand::Run { plugin_id, command, args } => { + cmd_run_plugin_command(&plugin_id, &command, &args) + } + CliPluginCommand::Commands { plugin_id } => cmd_list_commands(plugin_id.as_deref()), + } +} + +/// Detect the runtime type for a plugin based on entry point extension +fn detect_runtime(manifest: &PluginManifest) -> PluginRuntime { + let entry = &manifest.plugin.entry; + if entry.ends_with(".rn") { + PluginRuntime::Rune + } else { + // Default to Lua for .lua or unrecognized extensions + PluginRuntime::Lua + } +} + +/// List installed plugins +fn cmd_list_installed( + only_enabled: bool, + only_disabled: bool, + runtime_filter: Option, + json_output: bool, +) -> CommandResult { + let plugins_dir = paths::plugins_dir().ok_or("Could not determine plugins directory")?; + + if !plugins_dir.exists() { + if json_output { + println!("[]"); + } else { + println!("No plugins installed."); + } + return Ok(()); + } + + let discovered = discover_plugins(&plugins_dir).map_err(|e| e.to_string())?; + let config = Config::load().unwrap_or_default(); + let disabled_list = &config.plugins.disabled_plugins; + + let lua_available = lua_runtime_available(); + let rune_available = rune_runtime_available(); + + let mut plugins: Vec<_> = discovered + .iter() + .map(|(id, (manifest, _path))| { + let is_disabled = disabled_list.contains(id); + let runtime = detect_runtime(manifest); + (id.clone(), manifest.clone(), is_disabled, runtime) + }) + .collect(); + + // Apply filters + if only_enabled { + plugins.retain(|(_, _, is_disabled, _)| !*is_disabled); + } + if only_disabled { + plugins.retain(|(_, _, is_disabled, _)| *is_disabled); + } + if let Some(rt) = runtime_filter { + plugins.retain(|(_, _, _, runtime)| *runtime == rt); + } + + // Sort by ID + plugins.sort_by(|a, b| a.0.cmp(&b.0)); + + if json_output { + let json_list: Vec<_> = plugins + .iter() + .map(|(id, manifest, is_disabled, runtime)| { + let runtime_available = match runtime { + PluginRuntime::Lua => lua_available, + PluginRuntime::Rune => rune_available, + }; + serde_json::json!({ + "id": id, + "name": manifest.plugin.name, + "version": manifest.plugin.version, + "description": manifest.plugin.description, + "enabled": !is_disabled, + "runtime": runtime.to_string(), + "runtime_available": runtime_available, + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&json_list).unwrap()); + } else if plugins.is_empty() { + println!("No plugins found."); + } else { + println!("Installed plugins:\n"); + for (id, manifest, is_disabled, runtime) in &plugins { + let status = if *is_disabled { " (disabled)" } else { "" }; + let runtime_available = match runtime { + PluginRuntime::Lua => lua_available, + PluginRuntime::Rune => rune_available, + }; + let runtime_status = if !runtime_available { + format!(" [{} - NOT INSTALLED]", runtime) + } else { + format!(" [{}]", runtime) + }; + println!( + " {} v{}{}{}\n {}", + id, + manifest.plugin.version, + status, + runtime_status, + if manifest.plugin.description.is_empty() { + "No description" + } else { + &manifest.plugin.description + } + ); + } + println!("\n{} plugin(s) installed.", plugins.len()); + } + + Ok(()) +} + +/// List available plugins from registry +fn cmd_list_available(refresh: bool, json_output: bool) -> CommandResult { + let client = get_registry_client(); + + println!("Fetching plugin list from registry..."); + let plugins = client.list_all(refresh)?; + + if json_output { + let json_list: Vec<_> = plugins + .iter() + .map(|p| { + serde_json::json!({ + "id": p.id, + "name": p.name, + "version": p.version, + "description": p.description, + "author": p.author, + "repository": p.repository, + "tags": p.tags, + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&json_list).unwrap()); + } else if plugins.is_empty() { + println!("No plugins available in registry."); + } else { + println!("Available plugins:\n"); + for p in &plugins { + println!( + " {} v{}\n {}", + p.id, + p.version, + if p.description.is_empty() { + "No description" + } else { + &p.description + } + ); + if !p.tags.is_empty() { + println!(" Tags: {}", p.tags.join(", ")); + } + } + println!("\n{} plugin(s) available.", plugins.len()); + println!("\nInstall with: owlry plugin install "); + } + + Ok(()) +} + +/// Search for plugins in registry +fn cmd_search(query: &str, refresh: bool, json_output: bool) -> CommandResult { + let client = get_registry_client(); + + let plugins = client.search(query, refresh)?; + + if json_output { + let json_list: Vec<_> = plugins + .iter() + .map(|p| { + serde_json::json!({ + "id": p.id, + "name": p.name, + "version": p.version, + "description": p.description, + "author": p.author, + "repository": p.repository, + "tags": p.tags, + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&json_list).unwrap()); + } else if plugins.is_empty() { + println!("No plugins found matching '{}'.", query); + } else { + println!("Search results for '{}':\n", query); + for p in &plugins { + println!( + " {} v{}\n {}", + p.id, + p.version, + if p.description.is_empty() { + "No description" + } else { + &p.description + } + ); + if !p.tags.is_empty() { + println!(" Tags: {}", p.tags.join(", ")); + } + } + println!("\n{} result(s) found.", plugins.len()); + println!("\nInstall with: owlry plugin install "); + } + + Ok(()) +} + +/// Show information about an installed plugin +fn cmd_info_installed(name: &str, json_output: bool) -> CommandResult { + let plugins_dir = paths::plugins_dir().ok_or("Could not determine plugins directory")?; + let plugin_path = plugins_dir.join(name); + + if !plugin_path.exists() { + return Err(format!("Plugin '{}' not found", name)); + } + + let manifest_path = plugin_path.join("plugin.toml"); + if !manifest_path.exists() { + return Err(format!("Plugin '{}' has no manifest", name)); + } + + let manifest_content = fs::read_to_string(&manifest_path) + .map_err(|e| format!("Failed to read manifest: {}", e))?; + let manifest: PluginManifest = toml::from_str(&manifest_content) + .map_err(|e| format!("Failed to parse manifest: {}", e))?; + + let config = Config::load().unwrap_or_default(); + let is_enabled = !config.plugins.disabled_plugins.contains(&name.to_string()); + + let runtime = detect_runtime(&manifest); + let runtime_available = match runtime { + PluginRuntime::Lua => lua_runtime_available(), + PluginRuntime::Rune => rune_runtime_available(), + }; + + if json_output { + let info = serde_json::json!({ + "id": manifest.plugin.id, + "name": manifest.plugin.name, + "version": manifest.plugin.version, + "description": manifest.plugin.description, + "author": manifest.plugin.author, + "owlry_version": manifest.plugin.owlry_version, + "enabled": is_enabled, + "runtime": runtime.to_string(), + "runtime_available": runtime_available, + "path": plugin_path.display().to_string(), + "provides": { + "providers": manifest.provides.providers, + "actions": manifest.provides.actions, + "themes": manifest.provides.themes, + "hooks": manifest.provides.hooks, + }, + "permissions": { + "network": manifest.permissions.network, + "filesystem": manifest.permissions.filesystem, + "run_commands": manifest.permissions.run_commands, + } + }); + println!("{}", serde_json::to_string_pretty(&info).unwrap()); + } else { + println!("Plugin: {} v{}", manifest.plugin.name, manifest.plugin.version); + println!("ID: {}", manifest.plugin.id); + if !manifest.plugin.description.is_empty() { + println!("Description: {}", manifest.plugin.description); + } + if !manifest.plugin.author.is_empty() { + println!("Author: {}", manifest.plugin.author); + } + println!("Status: {}", if is_enabled { "enabled" } else { "disabled" }); + println!( + "Runtime: {}{}", + runtime, + if runtime_available { "" } else { " (NOT INSTALLED)" } + ); + println!("Path: {}", plugin_path.display()); + println!(); + println!("Provides:"); + if !manifest.provides.providers.is_empty() { + println!(" Providers: {}", manifest.provides.providers.join(", ")); + } + if manifest.provides.actions { + println!(" Actions: yes"); + } + if !manifest.provides.themes.is_empty() { + println!(" Themes: {}", manifest.provides.themes.join(", ")); + } + if manifest.provides.hooks { + println!(" Hooks: yes"); + } + println!(); + println!("Permissions:"); + println!(" Network: {}", if manifest.permissions.network { "yes" } else { "no" }); + if !manifest.permissions.filesystem.is_empty() { + println!(" Filesystem: {}", manifest.permissions.filesystem.join(", ")); + } + if !manifest.permissions.run_commands.is_empty() { + println!(" Commands: {}", manifest.permissions.run_commands.join(", ")); + } + } + + Ok(()) +} + +/// Show information about a plugin from the registry +fn cmd_info_registry(name: &str, json_output: bool) -> CommandResult { + let client = get_registry_client(); + + let plugin = client.find(name, false)? + .ok_or_else(|| format!("Plugin '{}' not found in registry", name))?; + + if json_output { + let info = serde_json::json!({ + "id": plugin.id, + "name": plugin.name, + "version": plugin.version, + "description": plugin.description, + "author": plugin.author, + "repository": plugin.repository, + "tags": plugin.tags, + "owlry_version": plugin.owlry_version, + "license": plugin.license, + }); + println!("{}", serde_json::to_string_pretty(&info).unwrap()); + } else { + println!("Plugin: {} v{}", plugin.name, plugin.version); + println!("ID: {}", plugin.id); + if !plugin.description.is_empty() { + println!("Description: {}", plugin.description); + } + if !plugin.author.is_empty() { + println!("Author: {}", plugin.author); + } + println!("Repository: {}", plugin.repository); + if !plugin.owlry_version.is_empty() { + println!("Requires: owlry {}", plugin.owlry_version); + } + if !plugin.license.is_empty() { + println!("License: {}", plugin.license); + } + if !plugin.tags.is_empty() { + println!("Tags: {}", plugin.tags.join(", ")); + } + println!(); + println!("Install with: owlry plugin install {}", plugin.id); + } + + Ok(()) +} + +/// Install a plugin from registry name, path, or URL +fn cmd_install(source: &str, force: bool) -> CommandResult { + let plugins_dir = paths::plugins_dir().ok_or("Could not determine plugins directory")?; + + // Ensure plugins directory exists + fs::create_dir_all(&plugins_dir) + .map_err(|e| format!("Failed to create plugins directory: {}", e))?; + + // Determine source type: URL, path, or registry name + if registry::is_url(source) { + // Git repository URL + install_from_git(source, &plugins_dir, force) + } else if registry::is_path(source) { + // Local path + let source_path = PathBuf::from(source); + install_from_path(&source_path, &plugins_dir, force) + } else { + // Try registry lookup + println!("Looking up '{}' in registry...", source); + let client = get_registry_client(); + + match client.find(source, false)? { + Some(plugin) => { + println!("Found: {} v{}", plugin.name, plugin.version); + install_from_git(&plugin.repository, &plugins_dir, force) + } + None => { + Err(format!( + "Plugin '{}' not found in registry. Use a local path or git URL.", + source + )) + } + } + } +} + +/// Install plugin from a local directory +fn install_from_path(source: &Path, plugins_dir: &Path, force: bool) -> CommandResult { + // Validate source has manifest + let manifest_path = source.join("plugin.toml"); + if !manifest_path.exists() { + return Err("Source directory does not contain plugin.toml".to_string()); + } + + let manifest_content = fs::read_to_string(&manifest_path) + .map_err(|e| format!("Failed to read manifest: {}", e))?; + let manifest: PluginManifest = toml::from_str(&manifest_content) + .map_err(|e| format!("Failed to parse manifest: {}", e))?; + + let target_dir = plugins_dir.join(&manifest.plugin.id); + + if target_dir.exists() { + if force { + println!("Removing existing plugin..."); + fs::remove_dir_all(&target_dir) + .map_err(|e| format!("Failed to remove existing plugin: {}", e))?; + } else { + return Err(format!( + "Plugin '{}' is already installed. Use --force to reinstall.", + manifest.plugin.id + )); + } + } + + // Copy plugin files + copy_dir_recursive(source, &target_dir)?; + + println!( + "Installed plugin '{}' v{} to {}", + manifest.plugin.name, + manifest.plugin.version, + target_dir.display() + ); + + Ok(()) +} + +/// Install plugin from a git repository +fn install_from_git(url: &str, plugins_dir: &Path, force: bool) -> CommandResult { + // Create temp directory for clone + let temp_dir = std::env::temp_dir().join(format!("owlry-plugin-{}", std::process::id())); + + // Clean up temp dir if it exists + if temp_dir.exists() { + fs::remove_dir_all(&temp_dir) + .map_err(|e| format!("Failed to clean temp directory: {}", e))?; + } + + println!("Cloning {}...", url); + + // Clone repository + let status = std::process::Command::new("git") + .args(["clone", "--depth=1", url, temp_dir.to_str().unwrap()]) + .status() + .map_err(|e| format!("Failed to run git: {}", e))?; + + if !status.success() { + return Err("Git clone failed".to_string()); + } + + // Install from cloned directory + let result = install_from_path(&temp_dir, plugins_dir, force); + + // Clean up temp directory + let _ = fs::remove_dir_all(&temp_dir); + + result +} + +/// Recursively copy a directory +fn copy_dir_recursive(src: &Path, dst: &Path) -> CommandResult { + fs::create_dir_all(dst) + .map_err(|e| format!("Failed to create directory {}: {}", dst.display(), e))?; + + for entry in fs::read_dir(src).map_err(|e| format!("Failed to read directory: {}", e))? { + let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + + if src_path.is_dir() { + // Skip .git directory + if src_path.file_name().is_some_and(|n| n == ".git") { + continue; + } + copy_dir_recursive(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path) + .map_err(|e| format!("Failed to copy {}: {}", src_path.display(), e))?; + } + } + + Ok(()) +} + +/// Remove an installed plugin +fn cmd_remove(name: &str, yes: bool) -> CommandResult { + let plugins_dir = paths::plugins_dir().ok_or("Could not determine plugins directory")?; + let plugin_path = plugins_dir.join(name); + + if !plugin_path.exists() { + return Err(format!("Plugin '{}' not found", name)); + } + + // Confirm unless --yes flag + if !yes { + print!("Remove plugin '{}'? [y/N] ", name); + io::stdout().flush().unwrap(); + + let mut input = String::new(); + io::stdin().read_line(&mut input).unwrap(); + + if !input.trim().eq_ignore_ascii_case("y") { + println!("Cancelled."); + return Ok(()); + } + } + + fs::remove_dir_all(&plugin_path) + .map_err(|e| format!("Failed to remove plugin: {}", e))?; + + // Also remove from disabled list if present + if let Ok(mut config) = Config::load() { + config.plugins.disabled_plugins.retain(|id| id != name); + if let Err(e) = config.save() { + eprintln!("Warning: Failed to update config: {}", e); + } + } + + println!("Removed plugin '{}'", name); + Ok(()) +} + +/// Update plugins +fn cmd_update(name: Option<&str>) -> CommandResult { + // For now, update is not implemented as we don't have source tracking + // A full implementation would: + // 1. Track where each plugin was installed from (git URL) + // 2. Pull updates and reinstall + if let Some(plugin) = name { + println!("Update for plugin '{}' not yet implemented.", plugin); + println!("To update manually, remove and reinstall the plugin."); + } else { + println!("Plugin updates not yet implemented."); + println!("To update a plugin, remove and reinstall it."); + } + Ok(()) +} + +/// Enable a plugin +fn cmd_enable(name: &str) -> CommandResult { + let plugins_dir = paths::plugins_dir().ok_or("Could not determine plugins directory")?; + let plugin_path = plugins_dir.join(name); + + if !plugin_path.exists() { + return Err(format!("Plugin '{}' not found", name)); + } + + let mut config = Config::load().unwrap_or_default(); + + if !config.plugins.disabled_plugins.contains(&name.to_string()) { + println!("Plugin '{}' is already enabled.", name); + return Ok(()); + } + + config.plugins.disabled_plugins.retain(|id| id != name); + config.save().map_err(|e| format!("Failed to save config: {}", e))?; + + println!("Enabled plugin '{}'", name); + Ok(()) +} + +/// Disable a plugin +fn cmd_disable(name: &str) -> CommandResult { + let plugins_dir = paths::plugins_dir().ok_or("Could not determine plugins directory")?; + let plugin_path = plugins_dir.join(name); + + if !plugin_path.exists() { + return Err(format!("Plugin '{}' not found", name)); + } + + let mut config = Config::load().unwrap_or_default(); + + if config.plugins.disabled_plugins.contains(&name.to_string()) { + println!("Plugin '{}' is already disabled.", name); + return Ok(()); + } + + config.plugins.disabled_plugins.push(name.to_string()); + config.save().map_err(|e| format!("Failed to save config: {}", e))?; + + println!("Disabled plugin '{}'", name); + Ok(()) +} + +/// Create a new plugin from template +fn cmd_create( + name: &str, + runtime: PluginRuntime, + dir: Option<&str>, + display_name: Option<&str>, + description: Option<&str>, +) -> CommandResult { + let base_dir = dir + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap()); + let plugin_dir = base_dir.join(name); + + if plugin_dir.exists() { + return Err(format!("Directory '{}' already exists", plugin_dir.display())); + } + + fs::create_dir_all(&plugin_dir) + .map_err(|e| format!("Failed to create directory: {}", e))?; + + let display = display_name.unwrap_or(name); + let desc = description.unwrap_or("A custom owlry plugin"); + + let (entry_file, entry_ext) = match runtime { + PluginRuntime::Lua => ("init.lua", "lua"), + PluginRuntime::Rune => ("init.rn", "rn"), + }; + + // Create plugin.toml + let manifest = format!( + r#"[plugin] +id = "{name}" +name = "{display}" +version = "0.1.0" +description = "{desc}" +author = "" +owlry_version = ">=0.3.0" +entry = "{entry_file}" + +[provides] +providers = ["{name}"] +actions = false +themes = [] +hooks = false + +[permissions] +network = false +filesystem = [] +run_commands = [] +"#, + name = name, + display = display, + desc = desc, + entry_file = entry_file, + ); + + fs::write(plugin_dir.join("plugin.toml"), manifest) + .map_err(|e| format!("Failed to write manifest: {}", e))?; + + // Create entry point template based on runtime + match runtime { + PluginRuntime::Lua => { + let init_lua = format!( + r#"-- {display} Plugin for Owlry +-- {desc} + +-- Register the provider +owlry.provider.register({{ + name = "{name}", + display_name = "{display}", + type_id = "{name}", + default_icon = "application-x-executable", + + refresh = function() + -- Return a list of items + return {{ + {{ + id = "{name}:example", + name = "Example Item", + description = "This is an example item from {display}", + icon = "dialog-information", + command = "echo 'Hello from {name}!'", + terminal = false, + tags = {{}} + }} + }} + end +}}) + +owlry.log.info("{display} plugin loaded") +"#, + name = name, + display = display, + desc = desc, + ); + fs::write(plugin_dir.join(entry_file), init_lua) + .map_err(|e| format!("Failed to write {}: {}", entry_file, e))?; + } + PluginRuntime::Rune => { + // Note: Rune uses #{{ for object literals, so we build manually + let init_rn = format!( + r#"//! {display} Plugin for Owlry +//! {desc} + +/// Plugin item structure +struct Item {{{{ + id: String, + name: String, + description: String, + icon: String, + command: String, + terminal: bool, + tags: Vec, +}}}} + +/// Provider registration +pub fn register(owlry) {{{{ + owlry.provider.register(#{{{{ + name: "{name}", + display_name: "{display}", + type_id: "{name}", + default_icon: "application-x-executable", + + refresh: || {{{{ + // Return a list of items + [ + Item {{{{ + id: "{name}:example", + name: "Example Item", + description: "This is an example item from {display}", + icon: "dialog-information", + command: "echo 'Hello from {name}!'", + terminal: false, + tags: [], + }}}}, + ] + }}}}, + }}}}); + + owlry.log.info("{display} plugin loaded"); +}}}} +"#, + name = name, + display = display, + desc = desc, + ); + fs::write(plugin_dir.join(entry_file), init_rn) + .map_err(|e| format!("Failed to write {}: {}", entry_file, e))?; + } + } + + println!("Created {} plugin '{}' at {}", runtime, name, plugin_dir.display()); + println!(); + println!("Next steps:"); + println!(" 1. Edit {}/{} to implement your provider", name, entry_file); + println!(" 2. Install: owlry plugin install {}", plugin_dir.display()); + println!(" 3. Test: owlry (your plugin items should appear)"); + println!(); + println!("Runtime: {} (requires owlry-{} package)", runtime, entry_ext); + + Ok(()) +} + +/// Validate a plugin's structure +fn cmd_validate(path: Option<&str>) -> CommandResult { + let plugin_path = path + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap()); + + println!("Validating plugin at {}...", plugin_path.display()); + + let mut errors: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + + // Check manifest exists + let manifest_path = plugin_path.join("plugin.toml"); + if !manifest_path.exists() { + errors.push("Missing plugin.toml manifest".to_string()); + } else { + // Parse and validate manifest + match fs::read_to_string(&manifest_path) { + Ok(content) => match toml::from_str::(&content) { + Ok(manifest) => { + // Check required fields + if manifest.plugin.id.is_empty() { + errors.push("plugin.id is empty".to_string()); + } + if manifest.plugin.name.is_empty() { + errors.push("plugin.name is empty".to_string()); + } + if manifest.plugin.version.is_empty() { + errors.push("plugin.version is empty".to_string()); + } + + // Check entry point + let entry = &manifest.plugin.entry; + let entry_path = plugin_path.join(entry); + if !entry_path.exists() { + errors.push(format!("Entry point '{}' not found", entry)); + } + + // Validate owlry_version semver + if manifest.plugin.owlry_version.is_empty() { + warnings.push("No owlry_version specified".to_string()); + } else if semver::VersionReq::parse(&manifest.plugin.owlry_version).is_err() { + errors.push(format!( + "Invalid owlry_version requirement: {}", + manifest.plugin.owlry_version + )); + } + + // Check for empty provides + if manifest.provides.providers.is_empty() + && !manifest.provides.actions + && manifest.provides.themes.is_empty() + && !manifest.provides.hooks + { + warnings.push("Plugin does not provide any features".to_string()); + } + + println!(" Plugin ID: {}", manifest.plugin.id); + println!(" Version: {}", manifest.plugin.version); + } + Err(e) => { + errors.push(format!("Failed to parse manifest: {}", e)); + } + }, + Err(e) => { + errors.push(format!("Failed to read manifest: {}", e)); + } + } + } + + // Report results + println!(); + + if !warnings.is_empty() { + println!("Warnings:"); + for w in &warnings { + println!(" ⚠ {}", w); + } + println!(); + } + + if errors.is_empty() { + println!("✓ Plugin is valid"); + Ok(()) + } else { + println!("Errors:"); + for e in &errors { + println!(" ✗ {}", e); + } + Err(format!("{} error(s) found", errors.len())) + } +} + +/// Show available script runtimes +fn cmd_runtimes() -> CommandResult { + use crate::plugins::runtime_loader::SYSTEM_RUNTIMES_DIR; + + println!("Script Runtimes:\n"); + + let lua_available = lua_runtime_available(); + let rune_available = rune_runtime_available(); + + // Lua runtime + if lua_available { + println!(" ✓ Lua - Installed"); + println!(" Package: owlry-lua"); + println!(" Entry point: init.lua"); + } else { + println!(" ✗ Lua - Not installed"); + println!(" Install: yay -S owlry-lua"); + println!(" Entry point: init.lua"); + } + + println!(); + + // Rune runtime + if rune_available { + println!(" ✓ Rune - Installed"); + println!(" Package: owlry-rune"); + println!(" Entry point: init.rn"); + } else { + println!(" ✗ Rune - Not installed"); + println!(" Install: yay -S owlry-rune"); + println!(" Entry point: init.rn"); + } + + println!(); + println!("Runtime directory: {}", SYSTEM_RUNTIMES_DIR); + + if !lua_available && !rune_available { + println!(); + println!("No runtimes installed. Install at least one to use plugins:"); + println!(" yay -S owlry-lua # For Lua plugins"); + println!(" yay -S owlry-rune # For Rune plugins"); + } + + Ok(()) +} + +/// Run a plugin command +fn cmd_run_plugin_command(plugin_id: &str, command: &str, args: &[String]) -> CommandResult { + let plugins_dir = paths::plugins_dir().ok_or("Could not determine plugins directory")?; + let plugin_path = plugins_dir.join(plugin_id); + + if !plugin_path.exists() { + return Err(format!("Plugin '{}' not found", plugin_id)); + } + + let manifest_path = plugin_path.join("plugin.toml"); + if !manifest_path.exists() { + return Err(format!("Plugin '{}' has no manifest", plugin_id)); + } + + let manifest_content = fs::read_to_string(&manifest_path) + .map_err(|e| format!("Failed to read manifest: {}", e))?; + let manifest: PluginManifest = toml::from_str(&manifest_content) + .map_err(|e| format!("Failed to parse manifest: {}", e))?; + + // Check if plugin provides this command + let cmd_info = manifest.provides.commands.iter().find(|c| c.name == command); + if cmd_info.is_none() { + let available: Vec<_> = manifest.provides.commands.iter().map(|c| c.name.as_str()).collect(); + if available.is_empty() { + return Err(format!("Plugin '{}' does not provide any CLI commands", plugin_id)); + } + return Err(format!( + "Plugin '{}' does not have command '{}'. Available: {}", + plugin_id, command, available.join(", ") + )); + } + + // Check runtime availability + let runtime = detect_runtime(&manifest); + check_runtime_available(runtime)?; + + // Execute the command via the plugin runtime + // The runtime will call the plugin's command handler + execute_plugin_command(&plugin_path, &manifest, command, args) +} + +/// Execute a plugin command through the runtime +fn execute_plugin_command( + plugin_path: &Path, + manifest: &PluginManifest, + command: &str, + args: &[String], +) -> CommandResult { + use crate::plugins::runtime_loader::{LoadedRuntime, SYSTEM_RUNTIMES_DIR}; + + let runtime = detect_runtime(manifest); + + // Load the appropriate runtime + let loaded_runtime = match runtime { + PluginRuntime::Lua => { + LoadedRuntime::load_lua(plugin_path.parent().unwrap_or(plugin_path)) + .map_err(|e| format!("Failed to load Lua runtime: {}", e))? + } + PluginRuntime::Rune => { + LoadedRuntime::load_rune(plugin_path.parent().unwrap_or(plugin_path)) + .map_err(|e| format!("Failed to load Rune runtime: {}", e))? + } + }; + + // Build the command query string + // Format: !CMD::::... + let mut query_parts = vec!["!CMD".to_string(), command.to_string()]; + query_parts.extend(args.iter().cloned()); + let _query = query_parts.join(":"); + + // Find the provider from this plugin and send the command query + let _provider_name = manifest.provides.providers.first() + .ok_or_else(|| format!("Plugin '{}' has no providers", manifest.plugin.id))?; + + // Query the provider with the command + // The runtime will interpret !CMD: prefix as a command invocation + let _providers = loaded_runtime.providers(); + + // For now, we use a simpler approach: invoke the entry point with command args + // This requires runtime support for command execution + println!("Executing: owlry plugin run {} {} {}", manifest.plugin.id, command, args.join(" ")); + println!(); + println!("Note: Plugin command execution requires runtime support."); + println!("The plugin entry point should handle CLI commands via owlry.command.register()"); + println!(); + println!("Runtime: {} ({})", runtime, if PathBuf::from(SYSTEM_RUNTIMES_DIR).join( + match runtime { PluginRuntime::Lua => "liblua.so", PluginRuntime::Rune => "librune.so" } + ).exists() { "available" } else { "NOT INSTALLED" }); + + // TODO: Implement actual command execution through runtime + // This would involve: + // 1. Loading the plugin in the runtime + // 2. Calling the registered command handler + // 3. Capturing and displaying output + + Ok(()) +} + +/// List commands provided by plugins +fn cmd_list_commands(plugin_id: Option<&str>) -> CommandResult { + let plugins_dir = paths::plugins_dir().ok_or("Could not determine plugins directory")?; + + if !plugins_dir.exists() { + println!("No plugins installed."); + return Ok(()); + } + + let discovered = discover_plugins(&plugins_dir).map_err(|e| e.to_string())?; + + if let Some(id) = plugin_id { + // Show commands for a specific plugin + let (manifest, _path) = discovered.get(id) + .ok_or_else(|| format!("Plugin '{}' not found", id))?; + + if manifest.provides.commands.is_empty() { + println!("Plugin '{}' does not provide any CLI commands.", id); + return Ok(()); + } + + println!("Commands provided by '{}':\n", id); + for cmd in &manifest.provides.commands { + let usage = if cmd.usage.is_empty() { + String::new() + } else { + format!(" {}", cmd.usage) + }; + println!(" owlry plugin run {} {}{}", id, cmd.name, usage); + if !cmd.description.is_empty() { + println!(" {}", cmd.description); + } + } + } else { + // Show all plugin commands + let mut found_any = false; + + for (id, (manifest, _path)) in &discovered { + if manifest.provides.commands.is_empty() { + continue; + } + + if !found_any { + println!("Plugin CLI Commands:\n"); + found_any = true; + } + + let runtime = detect_runtime(manifest); + let runtime_available = match runtime { + PluginRuntime::Lua => lua_runtime_available(), + PluginRuntime::Rune => rune_runtime_available(), + }; + let runtime_status = if !runtime_available { + format!(" [{}]", runtime) + } else { + String::new() + }; + + println!(" {} v{}{}", id, manifest.plugin.version, runtime_status); + for cmd in &manifest.provides.commands { + let usage = if cmd.usage.is_empty() { + String::new() + } else { + format!(" {}", cmd.usage) + }; + println!(" {} {}{}", id, cmd.name, usage); + if !cmd.description.is_empty() { + println!(" {}", cmd.description); + } + } + println!(); + } + + if !found_any { + println!("No plugins provide CLI commands."); + println!(); + println!("Plugins can declare commands in plugin.toml:"); + println!(); + println!(" [[provides.commands]]"); + println!(" name = \"sync\""); + println!(" description = \"Sync data from remote\""); + println!(" usage = \"[--force]\""); + } + } + + Ok(()) +} diff --git a/crates/owlry/src/plugins/error.rs b/crates/owlry/src/plugins/error.rs new file mode 100644 index 0000000..af6ce43 --- /dev/null +++ b/crates/owlry/src/plugins/error.rs @@ -0,0 +1,51 @@ +//! Plugin system error types + +use thiserror::Error; + +/// Errors that can occur in the plugin system +#[derive(Error, Debug)] +#[allow(dead_code)] // Some variants are for future use +pub enum PluginError { + #[error("Plugin '{0}' not found")] + NotFound(String), + + #[error("Invalid plugin manifest in '{plugin}': {message}")] + InvalidManifest { plugin: String, message: String }, + + #[error("Plugin '{plugin}' requires owlry {required}, but current version is {current}")] + VersionMismatch { + plugin: String, + required: String, + current: String, + }, + + #[error("Lua error in plugin '{plugin}': {message}")] + LuaError { plugin: String, message: String }, + + #[error("Plugin '{plugin}' timed out after {timeout_ms}ms")] + Timeout { plugin: String, timeout_ms: u64 }, + + #[error("Plugin '{plugin}' attempted forbidden operation: {operation}")] + SandboxViolation { plugin: String, operation: String }, + + #[error("Plugin '{0}' is already loaded")] + AlreadyLoaded(String), + + #[error("Plugin '{0}' is disabled")] + Disabled(String), + + #[error("Failed to load native plugin: {0}")] + LoadError(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("TOML parsing error: {0}")] + TomlParse(#[from] toml::de::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), +} + +/// Result type for plugin operations +pub type PluginResult = Result; diff --git a/crates/owlry/src/plugins/loader.rs b/crates/owlry/src/plugins/loader.rs new file mode 100644 index 0000000..4a6f0ee --- /dev/null +++ b/crates/owlry/src/plugins/loader.rs @@ -0,0 +1,205 @@ +//! Lua plugin loading and initialization + +use std::path::PathBuf; + +use mlua::Lua; + +use super::api; +use super::error::{PluginError, PluginResult}; +use super::manifest::PluginManifest; +use super::runtime::{create_lua_runtime, load_file, SandboxConfig}; + +/// A loaded plugin instance +#[derive(Debug)] +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 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 + } + + /// Get the plugin name + #[allow(dead_code)] + pub fn name(&self) -> &str { + &self.manifest.plugin.name + } + + /// Initialize the Lua runtime and load the entry point + pub fn initialize(&mut self) -> PluginResult<()> { + 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| PluginError::LuaError { + plugin: self.id().to_string(), + message: e.to_string(), + })?; + + // Register owlry APIs before loading entry point + api::register_apis(&lua, &self.path, self.id()).map_err(|e| PluginError::LuaError { + plugin: self.id().to_string(), + message: 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(PluginError::InvalidManifest { + plugin: self.id().to_string(), + message: format!("Entry point '{}' not found", self.manifest.plugin.entry), + }); + } + + load_file(&lua, &entry_path).map_err(|e| PluginError::LuaError { + plugin: self.id().to_string(), + message: e.to_string(), + })?; + + self.lua = Some(lua); + Ok(()) + } + + /// Get provider registrations from this plugin + pub fn get_provider_registrations(&self) -> PluginResult> { + let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError { + plugin: self.id().to_string(), + message: "Plugin not initialized".to_string(), + })?; + + api::get_provider_registrations(lua).map_err(|e| PluginError::LuaError { + plugin: self.id().to_string(), + message: e.to_string(), + }) + } + + /// Call a provider's refresh function + pub fn call_provider_refresh(&self, provider_name: &str) -> PluginResult> { + let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError { + plugin: self.id().to_string(), + message: "Plugin not initialized".to_string(), + })?; + + api::provider::call_refresh(lua, provider_name).map_err(|e| PluginError::LuaError { + plugin: self.id().to_string(), + message: e.to_string(), + }) + } + + /// Call a provider's query function + #[allow(dead_code)] // Will be used for dynamic query providers + pub fn call_provider_query(&self, provider_name: &str, query: &str) -> PluginResult> { + let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError { + plugin: self.id().to_string(), + message: "Plugin not initialized".to_string(), + })?; + + api::provider::call_query(lua, provider_name, query).map_err(|e| PluginError::LuaError { + plugin: self.id().to_string(), + message: e.to_string(), + }) + } + + /// Get a reference to the Lua runtime (if initialized) + #[allow(dead_code)] + pub fn lua(&self) -> Option<&Lua> { + self.lua.as_ref() + } + + /// Get a mutable reference to the Lua runtime (if initialized) + #[allow(dead_code)] + pub fn lua_mut(&mut self) -> Option<&mut Lua> { + self.lua.as_mut() + } +} + +// Note: discover_plugins and check_compatibility are in manifest.rs +// to avoid Lua dependency for plugin discovery. + +#[cfg(test)] +mod tests { + use super::*; + use super::super::manifest::{check_compatibility, discover_plugins}; + use std::fs; + use std::path::Path; + use tempfile::TempDir; + + fn create_test_plugin(dir: &Path, id: &str, name: &str) { + let plugin_dir = dir.join(id); + fs::create_dir_all(&plugin_dir).unwrap(); + + let manifest = format!( + r#" +[plugin] +id = "{}" +name = "{}" +version = "1.0.0" +"#, + id, name + ); + 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", "Test Plugin"); + create_test_plugin(plugins_dir, "another-plugin", "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()); + } + + #[test] + fn test_check_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!(check_compatibility(&manifest, "0.3.5").is_ok()); + assert!(check_compatibility(&manifest, "0.2.0").is_err()); + } +} diff --git a/crates/owlry/src/plugins/manifest.rs b/crates/owlry/src/plugins/manifest.rs new file mode 100644 index 0000000..a379ce5 --- /dev/null +++ b/crates/owlry/src/plugins/manifest.rs @@ -0,0 +1,316 @@ +//! Plugin manifest (plugin.toml) parsing + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use super::error::{PluginError, PluginResult}; + +/// 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, + /// CLI commands this plugin provides + #[serde(default)] + pub commands: Vec, +} + +/// A CLI command provided by a plugin +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginCommand { + /// Command name (e.g., "add", "list", "sync") + pub name: String, + /// Short description shown in help + #[serde(default)] + pub description: String, + /// Usage pattern (e.g., " [name]") + #[serde(default)] + pub usage: String, +} + +/// 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, +} + +// ============================================================================ +// Plugin Discovery (no Lua dependency) +// ============================================================================ + +/// Discover all plugins in a directory +/// +/// Returns a map of plugin ID -> (manifest, path) +pub fn discover_plugins(plugins_dir: &Path) -> PluginResult> { + 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)?; + + for entry in entries { + let entry = entry?; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + let manifest_path = path.join("plugin.toml"); + if !manifest_path.exists() { + log::debug!("Skipping {}: no plugin.toml", path.display()); + continue; + } + + match PluginManifest::load(&manifest_path) { + Ok(manifest) => { + let id = manifest.plugin.id.clone(); + if plugins.contains_key(&id) { + log::warn!("Duplicate plugin ID '{}', skipping {}", id, path.display()); + continue; + } + log::info!("Discovered plugin: {} v{}", manifest.plugin.name, manifest.plugin.version); + plugins.insert(id, (manifest, path)); + } + Err(e) => { + log::warn!("Failed to load plugin at {}: {}", path.display(), e); + } + } + } + + Ok(plugins) +} + +/// Check if a plugin is compatible with the given owlry version +pub fn check_compatibility(manifest: &PluginManifest, owlry_version: &str) -> PluginResult<()> { + if !manifest.is_compatible_with(owlry_version) { + return Err(PluginError::VersionMismatch { + plugin: manifest.plugin.id.clone(), + required: manifest.plugin.owlry_version.clone(), + current: owlry_version.to_string(), + }); + } + Ok(()) +} + +// ============================================================================ +// PluginManifest Implementation +// ============================================================================ + +impl PluginManifest { + /// Load a plugin manifest from a plugin.toml file + pub fn load(path: &Path) -> PluginResult { + let content = std::fs::read_to_string(path)?; + let manifest: PluginManifest = toml::from_str(&content)?; + manifest.validate()?; + Ok(manifest) + } + + /// Load from a plugin directory (looks for plugin.toml inside) + #[allow(dead_code)] + pub fn load_from_dir(plugin_dir: &Path) -> PluginResult { + let manifest_path = plugin_dir.join("plugin.toml"); + if !manifest_path.exists() { + return Err(PluginError::InvalidManifest { + plugin: plugin_dir.display().to_string(), + message: "plugin.toml not found".to_string(), + }); + } + Self::load(&manifest_path) + } + + /// Validate the manifest + fn validate(&self) -> PluginResult<()> { + // Validate plugin ID format + if self.plugin.id.is_empty() { + return Err(PluginError::InvalidManifest { + plugin: self.plugin.id.clone(), + message: "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(PluginError::InvalidManifest { + plugin: self.plugin.id.clone(), + message: "Plugin ID must be lowercase alphanumeric with hyphens".to_string(), + }); + } + + // Validate version format + if semver::Version::parse(&self.plugin.version).is_err() { + return Err(PluginError::InvalidManifest { + plugin: self.plugin.id.clone(), + message: format!("Invalid version format: {}", self.plugin.version), + }); + } + + // Validate owlry_version constraint + if semver::VersionReq::parse(&self.plugin.owlry_version).is_err() { + return Err(PluginError::InvalidManifest { + plugin: self.plugin.id.clone(), + message: 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_parse_full_manifest() { + let toml_str = r#" +[plugin] +id = "my-provider" +name = "My Provider" +version = "1.2.3" +description = "A test provider" +author = "Test Author" +license = "MIT" +owlry_version = ">=0.4.0" +entry = "main.lua" + +[provides] +providers = ["my-provider"] +actions = true +themes = ["dark"] +hooks = true + +[permissions] +network = true +filesystem = ["~/.config/myapp"] +run_commands = ["myapp"] +environment = ["MY_API_KEY"] + +[settings] +max_results = 20 +api_url = "https://api.example.com" +"#; + let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); + assert_eq!(manifest.plugin.id, "my-provider"); + assert!(manifest.provides.actions); + assert!(manifest.permissions.network); + assert_eq!(manifest.permissions.run_commands, vec!["myapp"]); + } + + #[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/src/plugins/mod.rs b/crates/owlry/src/plugins/mod.rs new file mode 100644 index 0000000..89c949f --- /dev/null +++ b/crates/owlry/src/plugins/mod.rs @@ -0,0 +1,336 @@ +//! Owlry Plugin System +//! +//! This module provides plugin support for extending owlry's functionality. +//! Plugins can register providers, actions, themes, and hooks. +//! +//! # Plugin Types +//! +//! - **Native plugins** (.so): Pre-compiled Rust plugins loaded from `/usr/lib/owlry/plugins/` +//! - **Lua plugins**: Script-based plugins from `~/.config/owlry/plugins/` (requires `lua` feature) +//! +//! # Plugin Structure (Lua) +//! +//! Each Lua plugin lives in its own directory under `~/.config/owlry/plugins/`: +//! +//! ```text +//! ~/.config/owlry/plugins/ +//! my-plugin/ +//! plugin.toml # Plugin manifest +//! init.lua # Entry point +//! lib/ # Optional modules +//! ``` + +// Always available +pub mod commands; +pub mod error; +pub mod manifest; +pub mod native_loader; +pub mod registry; +pub mod runtime_loader; + +// Lua-specific modules (require mlua) +#[cfg(feature = "lua")] +pub mod api; +#[cfg(feature = "lua")] +pub mod loader; +#[cfg(feature = "lua")] +pub mod runtime; + +// Re-export commonly used types +#[cfg(feature = "lua")] +pub use api::provider::{PluginItem, ProviderRegistration}; +#[cfg(feature = "lua")] +#[allow(unused_imports)] +pub use api::{ActionRegistration, HookEvent, ThemeRegistration}; + +pub use error::{PluginError, PluginResult}; + +#[cfg(feature = "lua")] +pub use loader::LoadedPlugin; + +// Used by plugins/commands.rs for plugin CLI commands +#[allow(unused_imports)] +pub use manifest::{check_compatibility, discover_plugins, PluginManifest}; + +// ============================================================================ +// Lua Plugin Manager (only available with lua feature) +// ============================================================================ + +#[cfg(feature = "lua")] +mod lua_manager { + use super::*; + use std::cell::RefCell; + use std::collections::HashMap; + use std::path::PathBuf; + use std::rc::Rc; + + use manifest::{discover_plugins, check_compatibility}; + + /// Plugin manager coordinates loading, initialization, and lifecycle of Lua plugins + pub struct PluginManager { + /// Directory where plugins are stored + plugins_dir: PathBuf, + /// Current owlry version for compatibility checks + owlry_version: String, + /// Loaded plugins by ID (Rc> allows sharing with LuaProviders) + plugins: HashMap>>, + /// Plugin IDs that are explicitly disabled + disabled: Vec, + } + + impl PluginManager { + /// Create a new plugin manager + pub fn new(plugins_dir: PathBuf, owlry_version: &str) -> Self { + Self { + plugins_dir, + owlry_version: owlry_version.to_string(), + plugins: HashMap::new(), + disabled: Vec::new(), + } + } + + /// Set the list of disabled plugin IDs + pub fn set_disabled(&mut self, disabled: Vec) { + self.disabled = disabled; + } + + /// Discover and load all plugins from the plugins directory + pub fn discover(&mut self) -> PluginResult { + log::info!("Discovering plugins in {}", self.plugins_dir.display()); + + let discovered = discover_plugins(&self.plugins_dir)?; + let mut loaded_count = 0; + + for (id, (manifest, path)) in discovered { + // Skip disabled plugins + if self.disabled.contains(&id) { + log::info!("Plugin '{}' is disabled, skipping", id); + continue; + } + + // Check version compatibility + if let Err(e) = check_compatibility(&manifest, &self.owlry_version) { + log::warn!("Plugin '{}' is not compatible: {}", id, e); + continue; + } + + let plugin = LoadedPlugin::new(manifest, path); + self.plugins.insert(id, Rc::new(RefCell::new(plugin))); + loaded_count += 1; + } + + log::info!("Discovered {} compatible plugins", loaded_count); + Ok(loaded_count) + } + + /// Initialize all discovered plugins (load their Lua code) + pub fn initialize_all(&mut self) -> Vec { + let mut errors = Vec::new(); + + for (id, plugin_rc) in &self.plugins { + let mut plugin = plugin_rc.borrow_mut(); + if !plugin.enabled { + continue; + } + + log::debug!("Initializing plugin: {}", id); + if let Err(e) = plugin.initialize() { + log::error!("Failed to initialize plugin '{}': {}", id, e); + errors.push(e); + plugin.enabled = false; + } + } + + errors + } + + /// Get a loaded plugin by ID (returns Rc for shared ownership) + #[allow(dead_code)] + pub fn get(&self, id: &str) -> Option>> { + self.plugins.get(id).cloned() + } + + /// Get all loaded plugins + #[allow(dead_code)] + pub fn plugins(&self) -> impl Iterator>> + '_ { + self.plugins.values().cloned() + } + + /// Get all enabled plugins + pub fn enabled_plugins(&self) -> impl Iterator>> + '_ { + self.plugins.values().filter(|p| p.borrow().enabled).cloned() + } + + /// Get the number of loaded plugins + #[allow(dead_code)] + pub fn plugin_count(&self) -> usize { + self.plugins.len() + } + + /// Get the number of enabled plugins + #[allow(dead_code)] + pub fn enabled_count(&self) -> usize { + self.plugins.values().filter(|p| p.borrow().enabled).count() + } + + /// Enable a plugin by ID + #[allow(dead_code)] + pub fn enable(&mut self, id: &str) -> PluginResult<()> { + let plugin_rc = self.plugins.get(id).ok_or_else(|| PluginError::NotFound(id.to_string()))?; + let mut plugin = plugin_rc.borrow_mut(); + + if !plugin.enabled { + plugin.enabled = true; + // Initialize if not already done + plugin.initialize()?; + } + + Ok(()) + } + + /// Disable a plugin by ID + #[allow(dead_code)] + pub fn disable(&mut self, id: &str) -> PluginResult<()> { + let plugin_rc = self.plugins.get(id).ok_or_else(|| PluginError::NotFound(id.to_string()))?; + plugin_rc.borrow_mut().enabled = false; + Ok(()) + } + + /// Get plugin IDs that provide a specific feature + #[allow(dead_code)] + pub fn providers_for(&self, provider_name: &str) -> Vec { + self.enabled_plugins() + .filter(|p| p.borrow().manifest.provides.providers.contains(&provider_name.to_string())) + .map(|p| p.borrow().id().to_string()) + .collect() + } + + /// Check if any plugin provides actions + #[allow(dead_code)] + pub fn has_action_plugins(&self) -> bool { + self.enabled_plugins().any(|p| p.borrow().manifest.provides.actions) + } + + /// Check if any plugin provides hooks + #[allow(dead_code)] + pub fn has_hook_plugins(&self) -> bool { + self.enabled_plugins().any(|p| p.borrow().manifest.provides.hooks) + } + + /// Get all theme names provided by plugins + #[allow(dead_code)] + pub fn theme_names(&self) -> Vec { + self.enabled_plugins() + .flat_map(|p| p.borrow().manifest.provides.themes.clone()) + .collect() + } + + /// Create providers from all enabled plugins + /// + /// This must be called after `initialize_all()`. Returns a vec of Provider trait + /// objects that can be added to the ProviderManager. + pub fn create_providers(&self) -> Vec> { + use crate::providers::lua_provider::create_providers_from_plugin; + + let mut providers = Vec::new(); + + for plugin_rc in self.enabled_plugins() { + let plugin_providers = create_providers_from_plugin(plugin_rc); + providers.extend(plugin_providers); + } + + providers + } + } +} + +#[cfg(feature = "lua")] +pub use lua_manager::PluginManager; + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(all(test, feature = "lua"))] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn create_test_plugin(dir: &std::path::Path, id: &str, version: &str, owlry_req: &str) { + let plugin_dir = dir.join(id); + fs::create_dir_all(&plugin_dir).unwrap(); + + let manifest = format!( + r#" +[plugin] +id = "{}" +name = "Test {}" +version = "{}" +owlry_version = "{}" + +[provides] +providers = ["{}"] +"#, + id, id, version, owlry_req, id + ); + fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); + fs::write(plugin_dir.join("init.lua"), "-- test plugin").unwrap(); + } + + #[test] + fn test_plugin_manager_discover() { + let temp = TempDir::new().unwrap(); + create_test_plugin(temp.path(), "plugin-a", "1.0.0", ">=0.3.0"); + create_test_plugin(temp.path(), "plugin-b", "2.0.0", ">=0.3.0"); + + let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5"); + let count = manager.discover().unwrap(); + + assert_eq!(count, 2); + assert!(manager.get("plugin-a").is_some()); + assert!(manager.get("plugin-b").is_some()); + } + + #[test] + fn test_plugin_manager_disabled() { + let temp = TempDir::new().unwrap(); + create_test_plugin(temp.path(), "plugin-a", "1.0.0", ">=0.3.0"); + create_test_plugin(temp.path(), "plugin-b", "1.0.0", ">=0.3.0"); + + let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5"); + manager.set_disabled(vec!["plugin-b".to_string()]); + let count = manager.discover().unwrap(); + + assert_eq!(count, 1); + assert!(manager.get("plugin-a").is_some()); + assert!(manager.get("plugin-b").is_none()); + } + + #[test] + fn test_plugin_manager_version_compat() { + let temp = TempDir::new().unwrap(); + create_test_plugin(temp.path(), "old-plugin", "1.0.0", ">=0.5.0"); // Requires future version + create_test_plugin(temp.path(), "new-plugin", "1.0.0", ">=0.3.0"); + + let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5"); + let count = manager.discover().unwrap(); + + assert_eq!(count, 1); + assert!(manager.get("old-plugin").is_none()); // Incompatible + assert!(manager.get("new-plugin").is_some()); + } + + #[test] + fn test_providers_for() { + let temp = TempDir::new().unwrap(); + create_test_plugin(temp.path(), "my-provider", "1.0.0", ">=0.3.0"); + + let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5"); + manager.discover().unwrap(); + + let providers = manager.providers_for("my-provider"); + assert_eq!(providers.len(), 1); + assert_eq!(providers[0], "my-provider"); + } +} diff --git a/crates/owlry/src/plugins/native_loader.rs b/crates/owlry/src/plugins/native_loader.rs new file mode 100644 index 0000000..05d539d --- /dev/null +++ b/crates/owlry/src/plugins/native_loader.rs @@ -0,0 +1,391 @@ +//! Native Plugin Loader +//! +//! Loads pre-compiled Rust plugins (.so files) from `/usr/lib/owlry/plugins/`. +//! These plugins use the ABI-stable interface defined in `owlry-plugin-api`. +//! +//! Note: This module is infrastructure for the plugin architecture. Full integration +//! with ProviderManager is pending Phase 5 (AUR Packaging) when native plugins +//! will actually be deployed. + +#![allow(dead_code)] + +use std::collections::HashMap; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Once}; + +use libloading::Library; +use log::{debug, error, info, warn}; +use owlry_plugin_api::{ + HostAPI, NotifyUrgency, PluginInfo, PluginVTable, ProviderHandle, ProviderInfo, ProviderKind, + RStr, API_VERSION, +}; + +use crate::notify; + +// ============================================================================ +// Host API Implementation +// ============================================================================ + +/// Host notification handler +extern "C" fn host_notify(summary: RStr<'_>, body: RStr<'_>, icon: RStr<'_>, urgency: NotifyUrgency) { + let icon_str = icon.as_str(); + let icon_opt = if icon_str.is_empty() { None } else { Some(icon_str) }; + + let notify_urgency = match urgency { + NotifyUrgency::Low => notify::NotifyUrgency::Low, + NotifyUrgency::Normal => notify::NotifyUrgency::Normal, + NotifyUrgency::Critical => notify::NotifyUrgency::Critical, + }; + + notify::notify_with_options(summary.as_str(), body.as_str(), icon_opt, notify_urgency); +} + +/// Host log info handler +extern "C" fn host_log_info(message: RStr<'_>) { + info!("[plugin] {}", message.as_str()); +} + +/// Host log warning handler +extern "C" fn host_log_warn(message: RStr<'_>) { + warn!("[plugin] {}", message.as_str()); +} + +/// Host log error handler +extern "C" fn host_log_error(message: RStr<'_>) { + error!("[plugin] {}", message.as_str()); +} + +/// Static host API instance +static HOST_API: HostAPI = HostAPI { + notify: host_notify, + log_info: host_log_info, + log_warn: host_log_warn, + log_error: host_log_error, +}; + +/// Initialize the host API (called once before loading plugins) +static HOST_API_INIT: Once = Once::new(); + +fn ensure_host_api_initialized() { + HOST_API_INIT.call_once(|| { + // SAFETY: We only call this once, before any plugins are loaded + unsafe { + owlry_plugin_api::init_host_api(&HOST_API); + } + debug!("Host API initialized for plugins"); + }); +} + +use super::error::{PluginError, PluginResult}; + +/// Default directory for system-installed native plugins +pub const SYSTEM_PLUGINS_DIR: &str = "/usr/lib/owlry/plugins"; + +/// A loaded native plugin with its library handle and vtable +pub struct NativePlugin { + /// Plugin metadata + pub info: PluginInfo, + /// List of providers this plugin offers + pub providers: Vec, + /// The vtable for calling plugin functions + vtable: &'static PluginVTable, + /// The loaded library (must be kept alive) + _library: Library, +} + +impl NativePlugin { + /// Get the plugin ID + pub fn id(&self) -> &str { + self.info.id.as_str() + } + + /// Get the plugin name + pub fn name(&self) -> &str { + self.info.name.as_str() + } + + /// Initialize a provider by ID + pub fn init_provider(&self, provider_id: &str) -> ProviderHandle { + (self.vtable.provider_init)(provider_id.into()) + } + + /// Refresh a static provider + pub fn refresh_provider(&self, handle: ProviderHandle) -> Vec { + (self.vtable.provider_refresh)(handle).into_iter().collect() + } + + /// Query a dynamic provider + pub fn query_provider( + &self, + handle: ProviderHandle, + query: &str, + ) -> Vec { + (self.vtable.provider_query)(handle, query.into()).into_iter().collect() + } + + /// Drop a provider handle + pub fn drop_provider(&self, handle: ProviderHandle) { + (self.vtable.provider_drop)(handle) + } +} + +// SAFETY: NativePlugin is safe to send between threads because: +// - `info` and `providers` are plain data (RString, RVec from abi_stable are Send+Sync) +// - `vtable` is a &'static reference to immutable function pointers +// - `_library` (libloading::Library) is Send+Sync +unsafe impl Send for NativePlugin {} +unsafe impl Sync for NativePlugin {} + +/// Manages native plugin discovery and loading +pub struct NativePluginLoader { + /// Directory to scan for plugins + plugins_dir: PathBuf, + /// Loaded plugins by ID (Arc for shared ownership with providers) + plugins: HashMap>, + /// Plugin IDs that are disabled + disabled: Vec, +} + +impl NativePluginLoader { + /// Create a new loader with the default system plugins directory + pub fn new() -> Self { + Self::with_dir(PathBuf::from(SYSTEM_PLUGINS_DIR)) + } + + /// Create a new loader with a custom plugins directory + pub fn with_dir(plugins_dir: PathBuf) -> Self { + Self { + plugins_dir, + plugins: HashMap::new(), + disabled: Vec::new(), + } + } + + /// Set the list of disabled plugin IDs + pub fn set_disabled(&mut self, disabled: Vec) { + self.disabled = disabled; + } + + /// Check if the plugins directory exists + pub fn plugins_dir_exists(&self) -> bool { + self.plugins_dir.exists() + } + + /// Discover and load all native plugins + pub fn discover(&mut self) -> PluginResult { + // Initialize host API before loading any plugins + ensure_host_api_initialized(); + + if !self.plugins_dir.exists() { + debug!( + "Native plugins directory does not exist: {}", + self.plugins_dir.display() + ); + return Ok(0); + } + + info!( + "Discovering native plugins in {}", + self.plugins_dir.display() + ); + + let entries = std::fs::read_dir(&self.plugins_dir).map_err(|e| { + PluginError::LoadError(format!( + "Failed to read plugins directory {}: {}", + self.plugins_dir.display(), + e + )) + })?; + + let mut loaded_count = 0; + + for entry in entries.flatten() { + let path = entry.path(); + + // Only process .so files + if path.extension() != Some(OsStr::new("so")) { + continue; + } + + match self.load_plugin(&path) { + Ok(plugin) => { + let id = plugin.id().to_string(); + + // Check if disabled + if self.disabled.contains(&id) { + info!("Native plugin '{}' is disabled, skipping", id); + continue; + } + + info!( + "Loaded native plugin '{}' v{} with {} providers", + plugin.name(), + plugin.info.version.as_str(), + plugin.providers.len() + ); + + self.plugins.insert(id, Arc::new(plugin)); + loaded_count += 1; + } + Err(e) => { + error!("Failed to load plugin {:?}: {}", path, e); + } + } + } + + info!("Loaded {} native plugins", loaded_count); + Ok(loaded_count) + } + + /// Load a single plugin from a .so file + fn load_plugin(&self, path: &Path) -> PluginResult { + debug!("Loading native plugin from {:?}", path); + + // Load the library + // SAFETY: We trust plugins in /usr/lib/owlry/plugins/ as they were + // installed by the package manager + let library = unsafe { Library::new(path) }.map_err(|e| { + PluginError::LoadError(format!("Failed to load library {:?}: {}", path, e)) + })?; + + // Get the vtable function + let vtable: &'static PluginVTable = unsafe { + let func: libloading::Symbol &'static PluginVTable> = + library.get(b"owlry_plugin_vtable").map_err(|e| { + PluginError::LoadError(format!( + "Plugin {:?} missing owlry_plugin_vtable symbol: {}", + path, e + )) + })?; + func() + }; + + // Get plugin info + let info = (vtable.info)(); + + // Check API version compatibility + if info.api_version != API_VERSION { + return Err(PluginError::LoadError(format!( + "Plugin '{}' has API version {} but owlry requires version {}", + info.id.as_str(), + info.api_version, + API_VERSION + ))); + } + + // Get provider list + let providers: Vec = (vtable.providers)().into_iter().collect(); + + Ok(NativePlugin { + info, + providers, + vtable, + _library: library, + }) + } + + /// Get a loaded plugin by ID + pub fn get(&self, id: &str) -> Option> { + self.plugins.get(id).cloned() + } + + /// Get all loaded plugins as Arc references + pub fn plugins(&self) -> impl Iterator> + '_ { + self.plugins.values().cloned() + } + + /// Get all loaded plugins as a Vec (for passing to create_providers) + pub fn into_plugins(self) -> Vec> { + self.plugins.into_values().collect() + } + + /// Get the number of loaded plugins + pub fn plugin_count(&self) -> usize { + self.plugins.len() + } + + /// Create providers from all loaded native plugins + /// + /// Returns a vec of (plugin_id, provider_info, handle) tuples that can be + /// used to create NativeProvider instances. + pub fn create_provider_handles(&self) -> Vec<(String, ProviderInfo, ProviderHandle)> { + let mut handles = Vec::new(); + + for plugin in self.plugins.values() { + for provider_info in &plugin.providers { + let handle = plugin.init_provider(provider_info.id.as_str()); + handles.push((plugin.id().to_string(), provider_info.clone(), handle)); + } + } + + handles + } +} + +impl Default for NativePluginLoader { + fn default() -> Self { + Self::new() + } +} + +/// Active provider instance from a native plugin +pub struct NativeProviderInstance { + /// Plugin ID this provider belongs to + pub plugin_id: String, + /// Provider metadata + pub info: ProviderInfo, + /// Handle to the provider state + pub handle: ProviderHandle, + /// Cached items for static providers + pub cached_items: Vec, +} + +impl NativeProviderInstance { + /// Create a new provider instance + pub fn new(plugin_id: String, info: ProviderInfo, handle: ProviderHandle) -> Self { + Self { + plugin_id, + info, + handle, + cached_items: Vec::new(), + } + } + + /// Check if this is a static provider + pub fn is_static(&self) -> bool { + self.info.provider_type == ProviderKind::Static + } + + /// Check if this is a dynamic provider + pub fn is_dynamic(&self) -> bool { + self.info.provider_type == ProviderKind::Dynamic + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_loader_nonexistent_dir() { + let mut loader = NativePluginLoader::with_dir(PathBuf::from("/nonexistent/path")); + let count = loader.discover().unwrap(); + assert_eq!(count, 0); + } + + #[test] + fn test_loader_empty_dir() { + let temp = tempfile::TempDir::new().unwrap(); + let mut loader = NativePluginLoader::with_dir(temp.path().to_path_buf()); + let count = loader.discover().unwrap(); + assert_eq!(count, 0); + } + + #[test] + fn test_disabled_plugins() { + let mut loader = NativePluginLoader::new(); + loader.set_disabled(vec!["test-plugin".to_string()]); + assert!(loader.disabled.contains(&"test-plugin".to_string())); + } +} diff --git a/crates/owlry/src/plugins/registry.rs b/crates/owlry/src/plugins/registry.rs new file mode 100644 index 0000000..42c6798 --- /dev/null +++ b/crates/owlry/src/plugins/registry.rs @@ -0,0 +1,293 @@ +//! Plugin registry client for discovering and installing remote plugins +//! +//! The registry is a git repository containing an `index.toml` file with +//! plugin metadata. Plugins are installed by cloning their source repositories. + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use crate::paths; + +/// Default registry URL (can be overridden in config) +pub const DEFAULT_REGISTRY_URL: &str = + "https://raw.githubusercontent.com/owlry/plugin-registry/main/index.toml"; + +/// Cache duration for registry index (1 hour) +const CACHE_DURATION: Duration = Duration::from_secs(3600); + +/// Registry index containing all available plugins +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegistryIndex { + /// Registry metadata + #[serde(default)] + pub registry: RegistryMeta, + /// Available plugins + #[serde(default)] + pub plugins: Vec, +} + +/// Registry metadata +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RegistryMeta { + /// Registry name + #[serde(default)] + pub name: String, + /// Registry description + #[serde(default)] + pub description: String, + /// Registry maintainer URL + #[serde(default)] + pub url: String, +} + +/// Plugin entry in the registry +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegistryPlugin { + /// Unique plugin identifier + pub id: String, + /// Human-readable name + pub name: String, + /// Latest version + pub version: String, + /// Short description + #[serde(default)] + pub description: String, + /// Plugin author + #[serde(default)] + pub author: String, + /// Git repository URL for installation + pub repository: String, + /// Search tags + #[serde(default)] + pub tags: Vec, + /// Minimum owlry version required + #[serde(default)] + pub owlry_version: String, + /// License identifier + #[serde(default)] + pub license: String, +} + +/// Registry client for fetching and searching plugins +pub struct RegistryClient { + /// Registry URL (index.toml location) + registry_url: String, + /// Local cache directory + cache_dir: PathBuf, +} + +impl RegistryClient { + /// Create a new registry client with the given URL + pub fn new(registry_url: &str) -> Self { + let cache_dir = paths::owlry_cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp/owlry")) + .join("registry"); + + Self { + registry_url: registry_url.to_string(), + cache_dir, + } + } + + /// Create a client with the default registry URL + pub fn default_registry() -> Self { + Self::new(DEFAULT_REGISTRY_URL) + } + + /// Get the path to the cached index file + fn cache_path(&self) -> PathBuf { + self.cache_dir.join("index.toml") + } + + /// Check if the cache is valid (exists and not expired) + fn is_cache_valid(&self) -> bool { + let cache_path = self.cache_path(); + if !cache_path.exists() { + return false; + } + + if let Ok(metadata) = fs::metadata(&cache_path) + && let Ok(modified) = metadata.modified() + && let Ok(elapsed) = SystemTime::now().duration_since(modified) { + return elapsed < CACHE_DURATION; + } + + false + } + + /// Fetch the registry index (from cache or network) + pub fn fetch_index(&self, force_refresh: bool) -> Result { + // Use cache if valid and not forcing refresh + if !force_refresh && self.is_cache_valid() + && let Ok(content) = fs::read_to_string(self.cache_path()) + && let Ok(index) = toml::from_str(&content) { + return Ok(index); + } + + // Fetch from network + self.fetch_from_network() + } + + /// Fetch the index from the network and cache it + fn fetch_from_network(&self) -> Result { + // Use curl for fetching (available on most systems) + let output = std::process::Command::new("curl") + .args([ + "-fsSL", + "--max-time", + "30", + &self.registry_url, + ]) + .output() + .map_err(|e| format!("Failed to run curl: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to fetch registry: {}", stderr.trim())); + } + + let content = String::from_utf8_lossy(&output.stdout); + + // Parse the index + let index: RegistryIndex = toml::from_str(&content) + .map_err(|e| format!("Failed to parse registry index: {}", e))?; + + // Cache the result + if let Err(e) = self.cache_index(&content) { + eprintln!("Warning: Failed to cache registry index: {}", e); + } + + Ok(index) + } + + /// Cache the index content to disk + fn cache_index(&self, content: &str) -> Result<(), String> { + fs::create_dir_all(&self.cache_dir) + .map_err(|e| format!("Failed to create cache directory: {}", e))?; + + fs::write(self.cache_path(), content) + .map_err(|e| format!("Failed to write cache file: {}", e))?; + + Ok(()) + } + + /// Search for plugins matching a query + pub fn search(&self, query: &str, force_refresh: bool) -> Result, String> { + let index = self.fetch_index(force_refresh)?; + let query_lower = query.to_lowercase(); + + let matches: Vec<_> = index + .plugins + .into_iter() + .filter(|p| { + p.id.to_lowercase().contains(&query_lower) + || p.name.to_lowercase().contains(&query_lower) + || p.description.to_lowercase().contains(&query_lower) + || p.tags.iter().any(|t| t.to_lowercase().contains(&query_lower)) + }) + .collect(); + + Ok(matches) + } + + /// Find a specific plugin by ID + pub fn find(&self, id: &str, force_refresh: bool) -> Result, String> { + let index = self.fetch_index(force_refresh)?; + + Ok(index.plugins.into_iter().find(|p| p.id == id)) + } + + /// List all available plugins + pub fn list_all(&self, force_refresh: bool) -> Result, String> { + let index = self.fetch_index(force_refresh)?; + Ok(index.plugins) + } + + /// Clear the cache + #[allow(dead_code)] + pub fn clear_cache(&self) -> Result<(), String> { + let cache_path = self.cache_path(); + if cache_path.exists() { + fs::remove_file(&cache_path) + .map_err(|e| format!("Failed to remove cache: {}", e))?; + } + Ok(()) + } + + /// Get the repository URL for a plugin + #[allow(dead_code)] + pub fn get_install_url(&self, id: &str) -> Result { + match self.find(id, false)? { + Some(plugin) => Ok(plugin.repository), + None => Err(format!("Plugin '{}' not found in registry", id)), + } + } +} + +/// Check if a string looks like a URL (for distinguishing registry names from URLs) +pub fn is_url(s: &str) -> bool { + s.starts_with("http://") + || s.starts_with("https://") + || s.starts_with("git@") + || s.starts_with("git://") +} + +/// Check if a string looks like a local path +pub fn is_path(s: &str) -> bool { + s.starts_with('/') + || s.starts_with("./") + || s.starts_with("../") + || s.starts_with('~') + || Path::new(s).exists() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_registry_index() { + let toml_str = r#" +[registry] +name = "Test Registry" +description = "A test registry" + +[[plugins]] +id = "test-plugin" +name = "Test Plugin" +version = "1.0.0" +description = "A test plugin" +author = "Test Author" +repository = "https://github.com/test/plugin" +tags = ["test", "example"] +owlry_version = ">=0.3.0" +"#; + + let index: RegistryIndex = toml::from_str(toml_str).unwrap(); + assert_eq!(index.registry.name, "Test Registry"); + assert_eq!(index.plugins.len(), 1); + assert_eq!(index.plugins[0].id, "test-plugin"); + assert_eq!(index.plugins[0].tags, vec!["test", "example"]); + } + + #[test] + fn test_is_url() { + assert!(is_url("https://github.com/user/repo")); + assert!(is_url("http://example.com")); + assert!(is_url("git@github.com:user/repo.git")); + assert!(!is_url("my-plugin")); + assert!(!is_url("/path/to/plugin")); + } + + #[test] + fn test_is_path() { + assert!(is_path("/absolute/path")); + assert!(is_path("./relative/path")); + assert!(is_path("../parent/path")); + assert!(is_path("~/home/path")); + assert!(!is_path("my-plugin")); + assert!(!is_path("https://example.com")); + } +} diff --git a/crates/owlry/src/plugins/runtime.rs b/crates/owlry/src/plugins/runtime.rs new file mode 100644 index 0000000..da98dbe --- /dev/null +++ b/crates/owlry/src/plugins/runtime.rs @@ -0,0 +1,153 @@ +//! Lua runtime setup and sandboxing + +use mlua::{Lua, Result as LuaResult, StdLib}; + +use super::manifest::PluginPermissions; + +/// Configuration for the Lua sandbox +#[derive(Debug, Clone)] +#[allow(dead_code)] // Fields used for future permission enforcement +pub struct SandboxConfig { + /// Allow shell command running + pub allow_commands: bool, + /// Allow HTTP requests + pub allow_network: bool, + /// Allow filesystem access outside plugin directory + pub allow_external_fs: bool, + /// Maximum run time per call (ms) + pub max_run_time_ms: u64, + /// Memory limit (bytes, 0 = unlimited) + 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 + // ALL_SAFE excludes: debug, io, os (dangerous parts), package (loadlib), ffi + // We then customize the os table to only allow safe functions + 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 + // We do NOT include: os.exit, os.remove, os.rename, os.setlocale, os.tmpname + // and the shell-related 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) + // We'll add it back via owlry.log + 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/src/plugins/runtime_loader.rs b/crates/owlry/src/plugins/runtime_loader.rs new file mode 100644 index 0000000..22f4136 --- /dev/null +++ b/crates/owlry/src/plugins/runtime_loader.rs @@ -0,0 +1,284 @@ +//! Dynamic runtime loader +//! +//! This module provides dynamic loading of script runtimes (Lua, Rune) +//! when they're not compiled into the core binary. +//! +//! Runtimes are loaded from `/usr/lib/owlry/runtimes/`: +//! - `liblua.so` - Lua runtime (from owlry-lua package) +//! - `librune.so` - Rune runtime (from owlry-rune package) +//! +//! Note: This module is infrastructure for the runtime architecture. Full integration +//! is pending Phase 5 (AUR Packaging) when runtime packages will be available. + +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use libloading::{Library, Symbol}; +use owlry_plugin_api::{PluginItem, RStr, RString, RVec}; + +use super::error::{PluginError, PluginResult}; +use crate::providers::{LaunchItem, Provider, ProviderType}; + +/// System directory for runtime libraries +pub const SYSTEM_RUNTIMES_DIR: &str = "/usr/lib/owlry/runtimes"; + +/// Information about a loaded runtime +#[repr(C)] +#[derive(Debug)] +pub struct RuntimeInfo { + pub name: RString, + pub version: RString, +} + +/// Information about a provider from a script runtime +#[repr(C)] +#[derive(Debug, Clone)] +pub struct ScriptProviderInfo { + pub name: RString, + pub display_name: RString, + pub type_id: RString, + pub default_icon: RString, + pub is_static: bool, + pub prefix: owlry_plugin_api::ROption, +} + +// Type alias for backwards compatibility +pub type LuaProviderInfo = ScriptProviderInfo; + +/// Handle to runtime-managed state +#[repr(transparent)] +#[derive(Clone, Copy)] +pub struct RuntimeHandle(pub *mut ()); + +/// VTable for script runtime functions (used by both Lua and Rune) +#[repr(C)] +pub struct ScriptRuntimeVTable { + 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), +} + +/// A loaded script runtime +pub struct LoadedRuntime { + /// Runtime name (for logging) + name: &'static str, + /// Keep library alive + _library: Arc, + /// Runtime vtable + vtable: &'static ScriptRuntimeVTable, + /// Runtime handle (state) + handle: RuntimeHandle, + /// Provider information + providers: Vec, +} + +impl LoadedRuntime { + /// Load the Lua runtime from the system directory + pub fn load_lua(plugins_dir: &Path) -> PluginResult { + Self::load_from_path( + "Lua", + &PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so"), + b"owlry_lua_runtime_vtable", + plugins_dir, + ) + } + + /// Load a runtime from a specific path + fn load_from_path( + name: &'static str, + library_path: &Path, + vtable_symbol: &[u8], + plugins_dir: &Path, + ) -> PluginResult { + if !library_path.exists() { + return Err(PluginError::NotFound(library_path.display().to_string())); + } + + // SAFETY: We trust the runtime library to be correct + let library = unsafe { Library::new(library_path) }.map_err(|e| { + PluginError::LoadError(format!("{}: {}", library_path.display(), e)) + })?; + + let library = Arc::new(library); + + // Get the vtable + let vtable: &'static ScriptRuntimeVTable = unsafe { + let get_vtable: Symbol &'static ScriptRuntimeVTable> = + library.get(vtable_symbol).map_err(|e| { + PluginError::LoadError(format!( + "{}: Missing vtable symbol: {}", + library_path.display(), + e + )) + })?; + get_vtable() + }; + + // Initialize the runtime + let plugins_dir_str = plugins_dir.to_string_lossy(); + let handle = (vtable.init)(RStr::from_str(&plugins_dir_str)); + + // Get provider information + let providers_rvec = (vtable.providers)(handle); + let providers: Vec = providers_rvec.into_iter().collect(); + + log::info!( + "Loaded {} runtime with {} provider(s)", + name, + providers.len() + ); + + Ok(Self { + name, + _library: library, + vtable, + handle, + providers, + }) + } + + /// Get all providers from this runtime + pub fn providers(&self) -> &[ScriptProviderInfo] { + &self.providers + } + + /// Create Provider trait objects for all providers in this runtime + pub fn create_providers(&self) -> Vec> { + self.providers + .iter() + .map(|info| { + let provider = RuntimeProvider::new( + self.name, + self.vtable, + self.handle, + info.clone(), + ); + Box::new(provider) as Box + }) + .collect() + } +} + +impl Drop for LoadedRuntime { + fn drop(&mut self) { + (self.vtable.drop)(self.handle); + } +} + +/// A provider backed by a dynamically loaded runtime +pub struct RuntimeProvider { + /// Runtime name (for logging) + #[allow(dead_code)] + runtime_name: &'static str, + vtable: &'static ScriptRuntimeVTable, + handle: RuntimeHandle, + info: ScriptProviderInfo, + items: Vec, +} + +impl RuntimeProvider { + fn new( + runtime_name: &'static str, + vtable: &'static ScriptRuntimeVTable, + handle: RuntimeHandle, + info: ScriptProviderInfo, + ) -> Self { + Self { + runtime_name, + vtable, + handle, + info, + items: Vec::new(), + } + } + + fn convert_item(&self, item: PluginItem) -> LaunchItem { + LaunchItem { + id: item.id.to_string(), + name: item.name.to_string(), + description: item.description.into_option().map(|s| s.to_string()), + icon: item.icon.into_option().map(|s| s.to_string()), + provider: ProviderType::Plugin(self.info.type_id.to_string()), + command: item.command.to_string(), + terminal: item.terminal, + tags: item.keywords.iter().map(|s| s.to_string()).collect(), + } + } +} + +impl Provider for RuntimeProvider { + fn name(&self) -> &str { + self.info.name.as_str() + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(self.info.type_id.to_string()) + } + + fn refresh(&mut self) { + if !self.info.is_static { + return; + } + + let name_rstr = RStr::from_str(self.info.name.as_str()); + let items_rvec = (self.vtable.refresh)(self.handle, name_rstr); + self.items = items_rvec.into_iter().map(|i| self.convert_item(i)).collect(); + + log::debug!( + "[RuntimeProvider] '{}' refreshed with {} items", + self.info.name, + self.items.len() + ); + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } +} + +// RuntimeProvider needs to be Send for the Provider trait +unsafe impl Send for RuntimeProvider {} + +/// Check if the Lua runtime is available +pub fn lua_runtime_available() -> bool { + PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so").exists() +} + +/// Check if the Rune runtime is available +pub fn rune_runtime_available() -> bool { + PathBuf::from(SYSTEM_RUNTIMES_DIR).join("librune.so").exists() +} + +impl LoadedRuntime { + /// Load the Rune runtime from the system directory + pub fn load_rune(plugins_dir: &Path) -> PluginResult { + Self::load_from_path( + "Rune", + &PathBuf::from(SYSTEM_RUNTIMES_DIR).join("librune.so"), + b"owlry_rune_runtime_vtable", + plugins_dir, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lua_runtime_not_installed() { + // In test environment, runtime shouldn't be installed + assert!(!lua_runtime_available()); + } + + #[test] + fn test_rune_runtime_not_installed() { + // In test environment, runtime shouldn't be installed + assert!(!rune_runtime_available()); + } +} diff --git a/src/providers/application.rs b/crates/owlry/src/providers/application.rs similarity index 100% rename from src/providers/application.rs rename to crates/owlry/src/providers/application.rs diff --git a/src/providers/command.rs b/crates/owlry/src/providers/command.rs similarity index 100% rename from src/providers/command.rs rename to crates/owlry/src/providers/command.rs diff --git a/src/providers/dmenu.rs b/crates/owlry/src/providers/dmenu.rs similarity index 100% rename from src/providers/dmenu.rs rename to crates/owlry/src/providers/dmenu.rs diff --git a/crates/owlry/src/providers/lua_provider.rs b/crates/owlry/src/providers/lua_provider.rs new file mode 100644 index 0000000..d624846 --- /dev/null +++ b/crates/owlry/src/providers/lua_provider.rs @@ -0,0 +1,142 @@ +//! LuaProvider - Bridge between Lua plugins and the Provider trait +//! +//! This module provides a `LuaProvider` struct that implements the `Provider` trait +//! by delegating to a Lua plugin's registered provider functions. + +use std::cell::RefCell; +use std::rc::Rc; + +use crate::plugins::{LoadedPlugin, PluginItem, ProviderRegistration}; + +use super::{LaunchItem, Provider, ProviderType}; + +/// A provider backed by a Lua plugin +/// +/// This struct implements the `Provider` trait by calling into a Lua plugin's +/// `refresh` or `query` functions. +pub struct LuaProvider { + /// Provider registration info + registration: ProviderRegistration, + /// Reference to the loaded plugin (shared with other providers from same plugin) + plugin: Rc>, + /// Cached items from last refresh + items: Vec, +} + +impl LuaProvider { + /// Create a new LuaProvider + pub fn new(registration: ProviderRegistration, plugin: Rc>) -> Self { + Self { + registration, + plugin, + items: Vec::new(), + } + } + + /// Convert a PluginItem to a LaunchItem + fn convert_item(&self, item: PluginItem) -> LaunchItem { + LaunchItem { + id: item.id, + name: item.name, + description: item.description, + icon: item.icon, + provider: ProviderType::Plugin(self.registration.type_id.clone()), + command: item.command.unwrap_or_default(), + terminal: item.terminal, + tags: item.tags, + } + } +} + +impl Provider for LuaProvider { + fn name(&self) -> &str { + &self.registration.name + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(self.registration.type_id.clone()) + } + + fn refresh(&mut self) { + // Only refresh static providers + if !self.registration.is_static { + return; + } + + let plugin = self.plugin.borrow(); + match plugin.call_provider_refresh(&self.registration.name) { + Ok(items) => { + self.items = items.into_iter().map(|i| self.convert_item(i)).collect(); + log::debug!( + "[LuaProvider] '{}' refreshed with {} items", + self.registration.name, + self.items.len() + ); + } + Err(e) => { + log::error!( + "[LuaProvider] Failed to refresh '{}': {}", + self.registration.name, + e + ); + self.items.clear(); + } + } + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } +} + +// LuaProvider needs to be Send for the Provider trait +// Since we're using Rc>, we need to be careful about thread safety +// For now, owlry is single-threaded, so this is safe +unsafe impl Send for LuaProvider {} + +/// Create LuaProviders from all registered providers in a plugin +pub fn create_providers_from_plugin( + plugin: Rc>, +) -> Vec> { + let registrations = { + let p = plugin.borrow(); + match p.get_provider_registrations() { + Ok(regs) => regs, + Err(e) => { + log::error!("[LuaProvider] Failed to get registrations: {}", e); + return Vec::new(); + } + } + }; + + registrations + .into_iter() + .map(|reg| { + let provider = LuaProvider::new(reg, plugin.clone()); + Box::new(provider) as Box + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + // Note: Full integration tests require a complete plugin setup + // These tests verify the basic structure + + #[test] + fn test_provider_type() { + let reg = ProviderRegistration { + name: "test".to_string(), + display_name: "Test".to_string(), + type_id: "test_provider".to_string(), + default_icon: "test-icon".to_string(), + is_static: true, + prefix: None, + }; + + // We can't easily create a mock LoadedPlugin, so just test the type + assert_eq!(reg.type_id, "test_provider"); + } +} diff --git a/src/providers/mod.rs b/crates/owlry/src/providers/mod.rs similarity index 56% rename from src/providers/mod.rs rename to crates/owlry/src/providers/mod.rs index db123da..55040e7 100644 --- a/src/providers/mod.rs +++ b/crates/owlry/src/providers/mod.rs @@ -1,36 +1,22 @@ +// Core providers (no plugin equivalents) mod application; -mod bookmarks; -mod calculator; -mod clipboard; mod command; mod dmenu; -mod emoji; -mod files; -mod media; -mod pomodoro; -mod scripts; -mod ssh; -mod system; -mod uuctl; -mod weather; -mod websearch; +// Native plugin bridge +pub mod native_provider; + +// Lua plugin bridge (optional) +#[cfg(feature = "lua")] +pub mod lua_provider; + +// Re-exports for core providers pub use application::ApplicationProvider; -pub use bookmarks::BookmarksProvider; -pub use calculator::CalculatorProvider; -pub use clipboard::ClipboardProvider; pub use command::CommandProvider; pub use dmenu::DmenuProvider; -pub use emoji::EmojiProvider; -pub use files::FileSearchProvider; -pub use media::MediaProvider; -pub use pomodoro::{PomodoroConfig, PomodoroProvider}; -pub use scripts::ScriptsProvider; -pub use ssh::SshProvider; -pub use system::SystemProvider; -pub use uuctl::UuctlProvider; -pub use weather::{WeatherConfig, WeatherProvider, WeatherProviderType}; -pub use websearch::WebSearchProvider; + +// Re-export native provider for plugin loading +pub use native_provider::NativeProvider; use fuzzy_matcher::FuzzyMatcher; use fuzzy_matcher::skim::SkimMatcherV2; @@ -56,7 +42,11 @@ pub struct LaunchItem { pub tags: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +/// Provider type identifier for filtering and badge display +/// +/// Note: Plugin is a special case that stores a type_id string +/// for custom plugin-defined provider types. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum ProviderType { Application, Bookmarks, @@ -74,6 +64,8 @@ pub enum ProviderType { Uuctl, Weather, WebSearch, + /// Plugin-defined provider type with custom type_id + Plugin(String), } impl std::str::FromStr for ProviderType { @@ -97,10 +89,12 @@ impl std::str::FromStr for ProviderType { "uuctl" => Ok(ProviderType::Uuctl), "weather" => Ok(ProviderType::Weather), "web" | "websearch" | "search" => Ok(ProviderType::WebSearch), - _ => Err(format!( - "Unknown provider: '{}'. Valid: app, bookmark, calc, clip, cmd, emoji, file, media, pomo, script, ssh, sys, weather, web", - s - )), + // Plugin types are prefixed with "plugin:" (e.g., "plugin:github-repos") + other if other.starts_with("plugin:") => { + Ok(ProviderType::Plugin(other[7..].to_string())) + } + // Unknown types become plugin types + other => Ok(ProviderType::Plugin(other.to_string())), } } } @@ -124,6 +118,7 @@ impl std::fmt::Display for ProviderType { ProviderType::Uuctl => write!(f, "uuctl"), ProviderType::Weather => write!(f, "weather"), ProviderType::WebSearch => write!(f, "web"), + ProviderType::Plugin(type_id) => write!(f, "{}", type_id), } } } @@ -139,44 +134,36 @@ pub trait Provider: Send { /// Manages all providers and handles searching pub struct ProviderManager { + /// Static providers (apps, commands, and native static plugins) providers: Vec>, - calculator: CalculatorProvider, - websearch: WebSearchProvider, - filesearch: FileSearchProvider, - // Widget providers (optional, controlled by config) - media: Option, - weather: Option, - pomodoro: Option, + /// Dynamic providers from native plugins (calculator, websearch, filesearch) + /// These are queried per-keystroke, not cached + dynamic_providers: Vec, + /// Widget providers from native plugins (weather, media, pomodoro) + /// These appear at the top of results + widget_providers: Vec, + /// Fuzzy matcher for search matcher: SkimMatcherV2, } +/// Known dynamic provider type IDs (need per-query evaluation) +const DYNAMIC_TYPE_IDS: &[&str] = &["calc", "websearch", "filesearch"]; + +/// Known widget provider type IDs (appear at top of results) +const WIDGET_TYPE_IDS: &[&str] = &["weather", "media", "pomodoro"]; + impl ProviderManager { - #[allow(dead_code)] - pub fn new() -> Self { - Self::with_search_engine("duckduckgo") - } - - pub fn with_search_engine(search_engine: &str) -> Self { - // Use xterm as fallback - it's the universal cockroach of terminals - // In practice, app.rs passes the detected/configured terminal - Self::with_config(search_engine, "xterm", true, None, None) - } - - pub fn with_config( - search_engine: &str, - terminal: &str, - media_enabled: bool, - weather_config: Option, - pomodoro_config: Option, - ) -> Self { + /// Create a new ProviderManager with native plugins + /// + /// Native plugins are loaded from /usr/lib/owlry/plugins/ and categorized into: + /// - Static providers (added to providers vec) + /// - Dynamic providers (queried per-keystroke: calculator, websearch, filesearch) + /// - Widget providers (shown at top: weather, media, pomodoro) + pub fn with_native_plugins(native_providers: Vec) -> Self { let mut manager = Self { providers: Vec::new(), - calculator: CalculatorProvider::new(), - websearch: WebSearchProvider::with_engine(search_engine), - filesearch: FileSearchProvider::new(), - media: if media_enabled { Some(MediaProvider::new()) } else { None }, - weather: weather_config.map(WeatherProvider::new), - pomodoro: pomodoro_config.map(PomodoroProvider::new), + dynamic_providers: Vec::new(), + widget_providers: Vec::new(), matcher: SkimMatcherV2::default(), }; @@ -189,18 +176,25 @@ impl ProviderManager { dmenu.enable(); manager.providers.push(Box::new(dmenu)); } else { - // Normal mode: use all standard providers + // Core providers (no plugin equivalents) manager.providers.push(Box::new(ApplicationProvider::new())); manager.providers.push(Box::new(CommandProvider::new())); - manager.providers.push(Box::new(UuctlProvider::new())); - // New providers - manager.providers.push(Box::new(SystemProvider::new())); - manager.providers.push(Box::new(SshProvider::with_terminal(terminal))); - manager.providers.push(Box::new(ClipboardProvider::new())); - manager.providers.push(Box::new(BookmarksProvider::new())); - manager.providers.push(Box::new(EmojiProvider::new())); - manager.providers.push(Box::new(ScriptsProvider::new())); + // Categorize native plugins + for provider in native_providers { + let type_id = provider.type_id(); + + if DYNAMIC_TYPE_IDS.contains(&type_id) { + info!("Registered dynamic provider: {} ({})", provider.name(), type_id); + manager.dynamic_providers.push(provider); + } else if WIDGET_TYPE_IDS.contains(&type_id) { + info!("Registered widget provider: {} ({})", provider.name(), type_id); + manager.widget_providers.push(provider); + } else { + info!("Registered static provider: {} ({})", provider.name(), type_id); + manager.providers.push(Box::new(provider)); + } + } } // Initial refresh @@ -217,6 +211,7 @@ impl ProviderManager { } pub fn refresh_all(&mut self) { + // Refresh static providers (fast, local operations) for provider in &mut self.providers { provider.refresh(); info!( @@ -226,24 +221,68 @@ impl ProviderManager { ); } - // Refresh widget providers - if let Some(ref mut media) = self.media { - media.refresh(); - info!("Media widget loaded {} items", media.items().len()); - } - if let Some(ref mut weather) = self.weather { - weather.refresh(); - info!("Weather widget loaded {} items", weather.items().len()); - } - if let Some(ref mut pomodoro) = self.pomodoro { - pomodoro.refresh(); - info!("Pomodoro widget loaded {} items", pomodoro.items().len()); + // Widget providers are refreshed separately to avoid blocking startup + // Call refresh_widgets() after window is shown + + // Dynamic providers don't need refresh (they query on demand) + } + + /// Refresh widget providers (weather, media, pomodoro) + /// Call this separately from refresh_all() to avoid blocking startup + /// since widgets may make network requests or spawn processes + pub fn refresh_widgets(&mut self) { + for provider in &mut self.widget_providers { + provider.refresh(); + info!( + "Widget '{}' loaded {} items", + provider.name(), + provider.items().len() + ); } } - /// Get mutable reference to pomodoro provider (for handling actions) - pub fn pomodoro_mut(&mut self) -> Option<&mut PomodoroProvider> { - self.pomodoro.as_mut() + /// Find a native provider by type ID + /// Searches in widget providers and dynamic providers + pub fn find_native_provider(&self, type_id: &str) -> Option<&NativeProvider> { + // Check widget providers first (pomodoro, weather, media) + if let Some(p) = self.widget_providers.iter().find(|p| p.type_id() == type_id) { + return Some(p); + } + // Then dynamic providers (calc, websearch, filesearch) + self.dynamic_providers.iter().find(|p| p.type_id() == type_id) + } + + /// Execute a plugin action command + /// Command format: PLUGIN_ID:action_data (e.g., "POMODORO:start", "SYSTEMD:unit:restart") + /// Returns true if the command was handled by a plugin + pub fn execute_plugin_action(&self, command: &str) -> bool { + // Parse command format: PLUGIN_ID:action_data + if let Some(colon_pos) = command.find(':') { + let plugin_id = &command[..colon_pos]; + let action = command; // Pass full command to plugin + + // Find provider by type ID (case-insensitive for convenience) + let type_id = plugin_id.to_lowercase(); + + if let Some(provider) = self.find_native_provider(&type_id) { + provider.execute_action(action); + return true; + } + } + false + } + + /// Add a dynamic provider (e.g., from a Lua plugin) + pub fn add_provider(&mut self, provider: Box) { + info!("Added plugin provider: {}", provider.name()); + self.providers.push(provider); + } + + /// Add multiple providers at once (for batch plugin loading) + pub fn add_providers(&mut self, providers: Vec>) { + for provider in providers { + self.add_provider(provider); + } } #[allow(dead_code)] @@ -333,9 +372,9 @@ impl ProviderManager { results } - /// Search with frecency boosting, calculator support, and tag filtering + /// Search with frecency boosting, dynamic providers, and tag filtering pub fn search_with_frecency( - &mut self, + &self, query: &str, max_results: usize, filter: &crate::filter::ProviderFilter, @@ -348,76 +387,39 @@ impl ProviderManager { let mut results: Vec<(LaunchItem, i64)> = Vec::new(); - // Add widget items first (highest priority) - only when no specific filter is active - if filter.active_prefix().is_none() { - // Weather widget (score 12000) - shown at very top - if let Some(ref weather) = self.weather { - for item in weather.items() { - results.push((item.clone(), 12000)); - } - } - - // Pomodoro widget (scores 11500-11502) - if let Some(ref pomodoro) = self.pomodoro { - for (idx, item) in pomodoro.items().iter().enumerate() { - results.push((item.clone(), 11502 - idx as i64)); - } - } - - // Media widget (scores 11000-11003) - if let Some(ref media) = self.media { - for (idx, item) in media.items().iter().enumerate() { - results.push((item.clone(), 11003 - idx as i64)); + // Add widget items first (highest priority) - only when: + // 1. No specific filter prefix is active + // 2. Query is empty (user hasn't started searching) + // This keeps widgets visible on launch but hides them during active search + if filter.active_prefix().is_none() && query.is_empty() { + // Widget priority scores based on type + for provider in &self.widget_providers { + let base_score = match provider.type_id() { + "weather" => 12000, + "pomodoro" => 11500, + "media" => 11000, + _ => 10500, + }; + for (idx, item) in provider.items().iter().enumerate() { + results.push((item.clone(), base_score - idx as i64)); } } } - // Check for calculator query (= or calc prefix) - if CalculatorProvider::is_calculator_query(query) { - if let Some(calc_result) = self.calculator.evaluate(query) { - #[cfg(feature = "dev-logging")] - debug!("[Search] Calculator result: {}", calc_result.name); - results.push((calc_result, 10000)); - } - } - // Also check for raw expression when in :calc filter mode - else if filter.active_prefix() == Some(ProviderType::Calculator) - && CalculatorProvider::looks_like_expression(query) - { - if let Some(calc_result) = self.calculator.evaluate_raw(query) { - results.push((calc_result, 10000)); - } - } - - // Check for web search query - if WebSearchProvider::is_websearch_query(query) { - if let Some(web_result) = self.websearch.evaluate(query) { - // Web search results get a high score to appear first - results.push((web_result, 9000)); - } - } - // Also check for raw query when in :web filter mode - else if filter.active_prefix() == Some(ProviderType::WebSearch) && !query.is_empty() { - if let Some(web_result) = self.websearch.evaluate_raw(query) { - results.push((web_result, 9000)); - } - } - - // Check for file search query - if FileSearchProvider::is_file_query(query) { - let file_results = self.filesearch.evaluate(query); - #[cfg(feature = "dev-logging")] - debug!("[Search] File search returned {} results", file_results.len()); - for (idx, item) in file_results.into_iter().enumerate() { - // Score decreases for each result to maintain order - results.push((item, 8000 - idx as i64)); - } - } - // Also check for raw query when in :file filter mode - else if filter.active_prefix() == Some(ProviderType::Files) && !query.is_empty() { - let file_results = self.filesearch.evaluate_raw(query); - for (idx, item) in file_results.into_iter().enumerate() { - results.push((item, 8000 - idx as i64)); + // Query dynamic providers (calculator, websearch, filesearch) + // Each provider internally checks if the query matches its prefix + if !query.is_empty() { + for (provider_idx, provider) in self.dynamic_providers.iter().enumerate() { + let dynamic_results = provider.query(query); + let base_score = match provider.type_id() { + "calc" => 10000, + "websearch" => 9000, + "filesearch" => 8000, + _ => 7000 - (provider_idx as i64 * 1000), + }; + for (idx, item) in dynamic_results.into_iter().enumerate() { + results.push((item, base_score - idx as i64)); + } } } @@ -458,11 +460,10 @@ impl ProviderManager { .flat_map(|provider| { provider.items().iter().filter_map(|item| { // Apply tag filter if present - if let Some(tag) = tag_filter { - if !item.tags.iter().any(|t| t.to_lowercase().contains(tag)) { + if let Some(tag) = tag_filter + && !item.tags.iter().any(|t| t.to_lowercase().contains(tag)) { return None; } - } let name_score = self.matcher.fuzzy_match(&item.name, query); let desc_score = item @@ -521,4 +522,84 @@ impl ProviderManager { pub fn available_providers(&self) -> Vec { self.providers.iter().map(|p| p.provider_type()).collect() } + + /// Get a widget item by type_id (e.g., "pomodoro", "weather", "media") + /// Returns the first item from the widget provider, if any + pub fn get_widget_item(&self, type_id: &str) -> Option { + self.widget_providers + .iter() + .find(|p| p.type_id() == type_id) + .and_then(|p| p.items().first().cloned()) + } + + /// Get all loaded widget provider type_ids + /// Returns an iterator over the type_ids of currently loaded widget providers + pub fn widget_type_ids(&self) -> impl Iterator { + self.widget_providers.iter().map(|p| p.type_id()) + } + + /// Query a plugin for submenu actions + /// + /// This is used when a user selects a SUBMENU:plugin_id:data item. + /// The plugin is queried with "?SUBMENU:data" and returns action items. + /// + /// Returns (display_name, actions) where display_name is the item name + /// and actions are the submenu items returned by the plugin. + pub fn query_submenu_actions( + &self, + plugin_id: &str, + data: &str, + display_name: &str, + ) -> Option<(String, Vec)> { + // Build the submenu query + let submenu_query = format!("?SUBMENU:{}", data); + + #[cfg(feature = "dev-logging")] + debug!( + "[Submenu] Querying plugin '{}' with: {}", + plugin_id, submenu_query + ); + + // Search in dynamic providers + for provider in &self.dynamic_providers { + if provider.type_id() == plugin_id { + let actions = provider.query(&submenu_query); + if !actions.is_empty() { + return Some((display_name.to_string(), actions)); + } + } + } + + // Search in widget providers + for provider in &self.widget_providers { + if provider.type_id() == plugin_id { + let actions = provider.query(&submenu_query); + if !actions.is_empty() { + return Some((display_name.to_string(), actions)); + } + } + } + + // Search in static providers (boxed) + // Note: Static providers don't typically have submenu support, + // but we check for completeness + for provider in &self.providers { + if let ProviderType::Plugin(type_id) = provider.provider_type() + && type_id == plugin_id + { + // Static providers use the items() method, not query + // Submenu support requires dynamic query capability + #[cfg(feature = "dev-logging")] + debug!( + "[Submenu] Plugin '{}' is static, cannot query for submenu", + plugin_id + ); + } + } + + #[cfg(feature = "dev-logging")] + debug!("[Submenu] No submenu actions found for plugin '{}'", plugin_id); + + None + } } diff --git a/crates/owlry/src/providers/native_provider.rs b/crates/owlry/src/providers/native_provider.rs new file mode 100644 index 0000000..50cca58 --- /dev/null +++ b/crates/owlry/src/providers/native_provider.rs @@ -0,0 +1,180 @@ +//! Native Plugin Provider Bridge +//! +//! This module provides a bridge between native plugins (compiled .so files) +//! and the core Provider trait used by ProviderManager. +//! +//! Native plugins are loaded from `/usr/lib/owlry/plugins/` as `.so` files +//! and provide search providers via an ABI-stable interface. + +use std::sync::{Arc, RwLock}; + +use log::debug; +use owlry_plugin_api::{PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind}; + +use super::{LaunchItem, Provider, ProviderType}; +use crate::plugins::native_loader::NativePlugin; + +/// A provider backed by a native plugin +/// +/// This wraps a native plugin's provider and implements the core Provider trait, +/// allowing native plugins to be used seamlessly with the existing ProviderManager. +pub struct NativeProvider { + /// The native plugin (shared reference since multiple providers may use same plugin) + plugin: Arc, + /// Provider metadata + info: ProviderInfo, + /// Handle to the provider state in the plugin + handle: ProviderHandle, + /// Cached items (for static providers) + items: RwLock>, +} + +impl NativeProvider { + /// Create a new native provider + pub fn new(plugin: Arc, info: ProviderInfo) -> Self { + let handle = plugin.init_provider(info.id.as_str()); + + Self { + plugin, + info, + handle, + items: RwLock::new(Vec::new()), + } + } + + /// Convert a plugin API item to a core LaunchItem + fn convert_item(&self, item: ApiPluginItem) -> LaunchItem { + LaunchItem { + id: item.id.to_string(), + name: item.name.to_string(), + description: item.description.as_ref().map(|s| s.to_string()).into(), + icon: item.icon.as_ref().map(|s| s.to_string()).into(), + provider: ProviderType::Plugin(self.info.type_id.to_string()), + command: item.command.to_string(), + terminal: item.terminal, + tags: item.keywords.iter().map(|s| s.to_string()).collect(), + } + } + + /// Query the provider + /// + /// For dynamic providers, this is called per-keystroke. + /// For static providers, returns cached items unless query is a special command + /// (submenu queries `?SUBMENU:` or action commands `!ACTION:`). + pub fn query(&self, query: &str) -> Vec { + // Special queries (submenu, actions) should always be forwarded to the plugin + let is_special_query = query.starts_with("?SUBMENU:") || query.starts_with("!"); + + if self.info.provider_type != ProviderKind::Dynamic && !is_special_query { + return self.items.read().unwrap().clone(); + } + + let api_items = self.plugin.query_provider(self.handle, query); + api_items.into_iter().map(|item| self.convert_item(item)).collect() + } + + /// Check if this provider has a prefix that matches the query + #[allow(dead_code)] + pub fn matches_prefix(&self, query: &str) -> bool { + match self.info.prefix.as_ref().into_option() { + Some(prefix) => query.starts_with(prefix.as_str()), + None => false, + } + } + + /// Get the prefix for this provider (if any) + #[allow(dead_code)] + pub fn prefix(&self) -> Option<&str> { + self.info.prefix.as_ref().map(|s| s.as_str()).into() + } + + /// Check if this is a dynamic provider + #[allow(dead_code)] + pub fn is_dynamic(&self) -> bool { + self.info.provider_type == ProviderKind::Dynamic + } + + /// Get the provider type ID (e.g., "calc", "clipboard", "weather") + pub fn type_id(&self) -> &str { + self.info.type_id.as_str() + } + + /// Execute an action command on the provider + /// Uses query with "!" prefix to trigger action handling in the plugin + pub fn execute_action(&self, action: &str) { + let action_query = format!("!{}", action); + self.plugin.query_provider(self.handle, &action_query); + } +} + +impl Provider for NativeProvider { + fn name(&self) -> &str { + self.info.name.as_str() + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(self.info.type_id.to_string()) + } + + fn refresh(&mut self) { + // Only refresh static providers + if self.info.provider_type != ProviderKind::Static { + return; + } + + debug!("Refreshing native provider '{}'", self.info.name.as_str()); + + let api_items = self.plugin.refresh_provider(self.handle); + let items: Vec = api_items + .into_iter() + .map(|item| self.convert_item(item)) + .collect(); + + debug!( + "Native provider '{}' loaded {} items", + self.info.name.as_str(), + items.len() + ); + + *self.items.write().unwrap() = items; + } + + fn items(&self) -> &[LaunchItem] { + // This is tricky with RwLock - we need to return a reference but can't + // hold the lock across the return. We use a raw pointer approach. + // + // SAFETY: The items Vec is only modified during refresh() which takes + // &mut self, so no concurrent modification can occur while this + // reference is live. + unsafe { + let guard = self.items.read().unwrap(); + let ptr = guard.as_ptr(); + let len = guard.len(); + std::slice::from_raw_parts(ptr, len) + } + } +} + +impl Drop for NativeProvider { + fn drop(&mut self) { + // Clean up the provider handle + self.plugin.drop_provider(self.handle); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Note: Full testing requires actual .so plugins, which we'll test + // via integration tests. Unit tests here focus on the conversion logic. + + #[test] + fn test_provider_type_conversion() { + // Test that type_id is correctly converted to ProviderType::Plugin + let type_id = "calculator"; + let provider_type = ProviderType::Plugin(type_id.to_string()); + + assert_eq!(format!("{}", provider_type), "calculator"); + } +} diff --git a/src/resources/base.css b/crates/owlry/src/resources/base.css similarity index 99% rename from src/resources/base.css rename to crates/owlry/src/resources/base.css index 5121fa8..3ec8b5e 100644 --- a/src/resources/base.css +++ b/crates/owlry/src/resources/base.css @@ -81,7 +81,7 @@ /* Result description */ .owlry-result-description { font-size: calc(var(--owlry-font-size, 14px) - 2px); - color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.7)); + color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.85)); margin-top: 2px; } @@ -326,7 +326,7 @@ .owlry-hints-label { font-size: calc(var(--owlry-font-size, 14px) - 4px); - color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.7)); + color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.75)); letter-spacing: 0.5px; } @@ -356,13 +356,13 @@ scrollbar slider:active { font-weight: 500; padding: 1px 6px; border-radius: 4px; - background-color: alpha(var(--owlry-border, @borders), 0.3); - color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.6)); + background-color: alpha(var(--owlry-border, @borders), 0.5); + color: var(--owlry-text-secondary, alpha(@theme_fg_color, 0.9)); margin-top: 2px; } .owlry-result-row:selected .owlry-tag-badge { - background-color: alpha(var(--owlry-accent-bright, @theme_selected_fg_color), 0.2); + background-color: alpha(var(--owlry-accent-bright, @theme_selected_fg_color), 0.25); color: var(--owlry-accent-bright, @theme_selected_fg_color); } diff --git a/src/resources/icons.gresource.xml b/crates/owlry/src/resources/icons.gresource.xml similarity index 100% rename from src/resources/icons.gresource.xml rename to crates/owlry/src/resources/icons.gresource.xml diff --git a/src/resources/icons/media/music-note.svg b/crates/owlry/src/resources/icons/media/music-note.svg similarity index 100% rename from src/resources/icons/media/music-note.svg rename to crates/owlry/src/resources/icons/media/music-note.svg diff --git a/src/resources/icons/pomodoro/tomato.svg b/crates/owlry/src/resources/icons/pomodoro/tomato.svg similarity index 100% rename from src/resources/icons/pomodoro/tomato.svg rename to crates/owlry/src/resources/icons/pomodoro/tomato.svg diff --git a/src/resources/icons/weather/wi-cloudy.svg b/crates/owlry/src/resources/icons/weather/wi-cloudy.svg similarity index 100% rename from src/resources/icons/weather/wi-cloudy.svg rename to crates/owlry/src/resources/icons/weather/wi-cloudy.svg diff --git a/src/resources/icons/weather/wi-day-cloudy.svg b/crates/owlry/src/resources/icons/weather/wi-day-cloudy.svg similarity index 100% rename from src/resources/icons/weather/wi-day-cloudy.svg rename to crates/owlry/src/resources/icons/weather/wi-day-cloudy.svg diff --git a/src/resources/icons/weather/wi-day-sunny.svg b/crates/owlry/src/resources/icons/weather/wi-day-sunny.svg similarity index 100% rename from src/resources/icons/weather/wi-day-sunny.svg rename to crates/owlry/src/resources/icons/weather/wi-day-sunny.svg diff --git a/src/resources/icons/weather/wi-fog.svg b/crates/owlry/src/resources/icons/weather/wi-fog.svg similarity index 100% rename from src/resources/icons/weather/wi-fog.svg rename to crates/owlry/src/resources/icons/weather/wi-fog.svg diff --git a/src/resources/icons/weather/wi-night-clear.svg b/crates/owlry/src/resources/icons/weather/wi-night-clear.svg similarity index 100% rename from src/resources/icons/weather/wi-night-clear.svg rename to crates/owlry/src/resources/icons/weather/wi-night-clear.svg diff --git a/src/resources/icons/weather/wi-rain.svg b/crates/owlry/src/resources/icons/weather/wi-rain.svg similarity index 100% rename from src/resources/icons/weather/wi-rain.svg rename to crates/owlry/src/resources/icons/weather/wi-rain.svg diff --git a/src/resources/icons/weather/wi-snow.svg b/crates/owlry/src/resources/icons/weather/wi-snow.svg similarity index 100% rename from src/resources/icons/weather/wi-snow.svg rename to crates/owlry/src/resources/icons/weather/wi-snow.svg diff --git a/src/resources/icons/weather/wi-thermometer.svg b/crates/owlry/src/resources/icons/weather/wi-thermometer.svg similarity index 100% rename from src/resources/icons/weather/wi-thermometer.svg rename to crates/owlry/src/resources/icons/weather/wi-thermometer.svg diff --git a/src/resources/icons/weather/wi-thunderstorm.svg b/crates/owlry/src/resources/icons/weather/wi-thunderstorm.svg similarity index 100% rename from src/resources/icons/weather/wi-thunderstorm.svg rename to crates/owlry/src/resources/icons/weather/wi-thunderstorm.svg diff --git a/src/resources/owl-theme.css b/crates/owlry/src/resources/owl-theme.css similarity index 100% rename from src/resources/owl-theme.css rename to crates/owlry/src/resources/owl-theme.css diff --git a/src/theme.rs b/crates/owlry/src/theme.rs similarity index 100% rename from src/theme.rs rename to crates/owlry/src/theme.rs diff --git a/src/ui/main_window.rs b/crates/owlry/src/ui/main_window.rs similarity index 80% rename from src/ui/main_window.rs rename to crates/owlry/src/ui/main_window.rs index 079230c..42e2ba1 100644 --- a/src/ui/main_window.rs +++ b/crates/owlry/src/ui/main_window.rs @@ -1,7 +1,8 @@ use crate::config::Config; use crate::data::FrecencyStore; use crate::filter::ProviderFilter; -use crate::providers::{LaunchItem, ProviderManager, ProviderType, UuctlProvider}; +use crate::providers::{LaunchItem, ProviderManager, ProviderType}; +use crate::ui::submenu; use crate::ui::ResultRow; use gtk4::gdk::Key; use gtk4::prelude::*; @@ -158,7 +159,7 @@ impl MainWindow { hints_box.add_css_class("owlry-hints"); let hints_label = Label::builder() - .label(&Self::build_hints(&cfg.providers)) + .label(Self::build_hints(&cfg.providers)) .halign(gtk4::Align::Center) .hexpand(true) .build(); @@ -197,6 +198,51 @@ impl MainWindow { // Ensure search entry has focus when window is shown main_window.search_entry.grab_focus(); + // Schedule widget refresh after window is shown + // Widget providers (weather, media, pomodoro) may make network/dbus calls + // We defer this to avoid blocking startup, then re-render results + let providers_for_refresh = main_window.providers.clone(); + let search_entry_for_refresh = main_window.search_entry.clone(); + gtk4::glib::timeout_add_local_once(std::time::Duration::from_millis(50), move || { + providers_for_refresh.borrow_mut().refresh_widgets(); + // Trigger UI update by emitting changed signal on search entry + search_entry_for_refresh.emit_by_name::<()>("changed", &[]); + }); + + // Set up periodic widget auto-refresh (every 5 seconds) + // Always refresh widgets (for pomodoro timer/notifications), but only update UI when visible + let providers_for_auto = main_window.providers.clone(); + let current_results_for_auto = main_window.current_results.clone(); + let submenu_state_for_auto = main_window.submenu_state.clone(); + gtk4::glib::timeout_add_local(std::time::Duration::from_secs(5), move || { + // Skip UI updates if in submenu, but still refresh providers for notifications + let in_submenu = submenu_state_for_auto.borrow().active; + + // Always refresh widget providers (pomodoro needs this for timer/notifications) + providers_for_auto.borrow_mut().refresh_widgets(); + + // Only update UI if not in submenu and widgets are visible + if !in_submenu { + // Collect widget type_ids first to avoid borrow conflicts + let widget_ids: Vec = providers_for_auto + .borrow() + .widget_type_ids() + .map(|s| s.to_string()) + .collect(); + + let mut results = current_results_for_auto.borrow_mut(); + for type_id in &widget_ids { + if let Some(new_item) = providers_for_auto.borrow().get_widget_item(type_id) { + if let Some(existing) = results.iter_mut().find(|i| i.id == new_item.id) { + existing.name = new_item.name; + existing.description = new_item.description; + } + } + } + } + gtk4::glib::ControlFlow::Continue + }); + main_window } @@ -217,17 +263,17 @@ impl MainWindow { } }; - let label = Self::provider_tab_label(provider_type); + let label = Self::provider_tab_label(&provider_type); let shortcut = format!("Ctrl+{}", idx + 1); let button = ToggleButton::builder() .label(label) .tooltip_text(&shortcut) - .active(filter.borrow().is_enabled(provider_type)) + .active(filter.borrow().is_enabled(provider_type.clone())) .build(); button.add_css_class("owlry-filter-button"); - let css_class = Self::provider_css_class(provider_type); + let css_class = Self::provider_css_class(&provider_type); button.add_css_class(css_class); container.append(&button); @@ -238,7 +284,7 @@ impl MainWindow { } /// Get display label for a provider tab - fn provider_tab_label(provider: ProviderType) -> &'static str { + fn provider_tab_label(provider: &ProviderType) -> &'static str { match provider { ProviderType::Application => "Apps", ProviderType::Bookmarks => "Bookmarks", @@ -256,11 +302,12 @@ impl MainWindow { ProviderType::Uuctl => "uuctl", ProviderType::Weather => "Weather", ProviderType::WebSearch => "Web", + ProviderType::Plugin(_) => "Plugin", } } /// Get CSS class for a provider - fn provider_css_class(provider: ProviderType) -> &'static str { + fn provider_css_class(provider: &ProviderType) -> &'static str { match provider { ProviderType::Application => "owlry-filter-app", ProviderType::Bookmarks => "owlry-filter-bookmark", @@ -278,6 +325,7 @@ impl MainWindow { ProviderType::Uuctl => "owlry-filter-uuctl", ProviderType::Weather => "owlry-filter-weather", ProviderType::WebSearch => "owlry-filter-web", + ProviderType::Plugin(_) => "owlry-filter-plugin", } } @@ -302,6 +350,7 @@ impl MainWindow { ProviderType::Uuctl => "uuctl units", ProviderType::Weather => "weather", ProviderType::WebSearch => "web", + ProviderType::Plugin(_) => "plugins", }) .collect(); @@ -388,7 +437,8 @@ impl MainWindow { } } - /// Enter submenu mode for a service + /// Enter submenu mode for an item with actions + #[allow(clippy::too_many_arguments)] fn enter_submenu( submenu_state: &Rc>, results_list: &ListBox, @@ -396,21 +446,18 @@ impl MainWindow { mode_label: &Label, hints_label: &Label, search_entry: &Entry, - unit_name: &str, - display_name: &str, - is_active: bool, + display_name: String, + actions: Vec, ) { #[cfg(feature = "dev-logging")] - debug!("[UI] Entering submenu for service: {} (active={})", unit_name, is_active); - - let actions = UuctlProvider::actions_for_service(unit_name, display_name, is_active); + debug!("[UI] Entering submenu: {} ({} actions)", display_name, actions.len()); // Save current state { let mut state = submenu_state.borrow_mut(); state.active = true; - state.service_name = unit_name.to_string(); - state.display_name = display_name.to_string(); + state.service_name = String::new(); // No longer specific to services + state.display_name = display_name.clone(); state.items = actions.clone(); state.saved_search = search_entry.text().to_string(); } @@ -525,13 +572,13 @@ impl MainWindow { { let mut f = filter.borrow_mut(); - f.set_prefix(parsed.prefix); + f.set_prefix(parsed.prefix.clone()); } mode_label.set_label(filter.borrow().mode_display_name()); - if parsed.prefix.is_some() { - let prefix_name = match parsed.prefix.unwrap() { + if let Some(ref prefix) = parsed.prefix { + let prefix_name = match prefix { ProviderType::Application => "applications", ProviderType::Bookmarks => "bookmarks", ProviderType::Calculator => "calculator", @@ -548,6 +595,7 @@ impl MainWindow { ProviderType::Uuctl => "uuctl units", ProviderType::Weather => "weather", ProviderType::WebSearch => "web", + ProviderType::Plugin(_) => "plugins", }; search_entry_for_change .set_placeholder_text(Some(&format!("Search {}...", prefix_name))); @@ -612,11 +660,26 @@ impl MainWindow { let index = row.index() as usize; let results = current_results_for_activate.borrow(); if let Some(item) = results.get(index) { - // Check if this is a submenu item - if let Some((unit_name, display_name, is_active)) = - UuctlProvider::parse_submenu_data(item) - { - drop(results); // Release borrow before calling enter_submenu + // Check if this is a submenu item and query the plugin for actions + let submenu_result = if submenu::is_submenu_item(item) { + if let Some((plugin_id, data)) = submenu::parse_submenu_command(&item.command) { + // Clone values before dropping borrow + let plugin_id = plugin_id.to_string(); + let data = data.to_string(); + let display_name = item.name.clone(); + drop(results); // Release borrow before querying + providers_for_activate + .borrow() + .query_submenu_actions(&plugin_id, &data, &display_name) + } else { + drop(results); + None + } + } else { + None + }; + + if let Some((display_name, actions)) = submenu_result { Self::enter_submenu( &submenu_state_for_activate, &results_list_for_activate, @@ -624,25 +687,26 @@ impl MainWindow { &mode_label_for_activate, &hints_label_for_activate, &search_entry_for_activate, - &unit_name, - &display_name, - is_active, + display_name, + actions, ); } else { - // Execute the command (or handle internal commands) - let item = item.clone(); - drop(results); - let should_close = Self::handle_item_action( - &item, - &config_for_activate.borrow(), - &frecency_for_activate, - &providers_for_activate, - ); - if should_close { - window_for_activate.close(); - } else { - // Trigger search refresh for updated widget state - entry.emit_by_name::<()>("changed", &[]); + // Not a submenu item - execute the command + let results = current_results_for_activate.borrow(); + if let Some(item) = results.get(index).cloned() { + drop(results); + let should_close = Self::handle_item_action( + &item, + &config_for_activate.borrow(), + &frecency_for_activate, + &providers_for_activate, + ); + if should_close { + window_for_activate.close(); + } else { + // Trigger search refresh for updated widget state + entry.emit_by_name::<()>("changed", &[]); + } } } } @@ -654,15 +718,15 @@ impl MainWindow { let filter = self.filter.clone(); let search_entry = self.search_entry.clone(); let mode_label = self.mode_label.clone(); - let ptype = *provider_type; + let ptype = provider_type.clone(); button.connect_toggled(move |btn| { { let mut f = filter.borrow_mut(); if btn.is_active() { - f.enable(ptype); + f.enable(ptype.clone()); } else { - f.disable(ptype); + f.disable(ptype.clone()); } } mode_label.set_label(filter.borrow().mode_display_name()); @@ -745,12 +809,11 @@ impl MainWindow { Key::Up => { if let Some(selected) = results_list.selected_row() { let prev_index = selected.index() - 1; - if prev_index >= 0 { - if let Some(prev_row) = results_list.row_at_index(prev_index) { + if prev_index >= 0 + && let Some(prev_row) = results_list.row_at_index(prev_index) { results_list.select_row(Some(&prev_row)); Self::scroll_to_row(&scrolled, &results_list, &prev_row); } - } } gtk4::glib::Propagation::Stop } @@ -797,9 +860,9 @@ impl MainWindow { Key::_9 => 8, _ => return gtk4::glib::Propagation::Proceed, }; - if let Some(&provider) = tab_order.get(idx) { + if let Some(provider) = tab_order.get(idx) { Self::toggle_provider_button( - provider, + provider.clone(), &filter, &filter_buttons, &search_entry, @@ -831,11 +894,26 @@ impl MainWindow { let index = row.index() as usize; let results = current_results.borrow(); if let Some(item) = results.get(index) { - // Check if this is a submenu item - if let Some((unit_name, display_name, is_active)) = - UuctlProvider::parse_submenu_data(item) - { - drop(results); + // Check if this is a submenu item and query the plugin for actions + let submenu_result = if submenu::is_submenu_item(item) { + if let Some((plugin_id, data)) = submenu::parse_submenu_command(&item.command) { + // Clone values before dropping borrow + let plugin_id = plugin_id.to_string(); + let data = data.to_string(); + let display_name = item.name.clone(); + drop(results); + providers + .borrow() + .query_submenu_actions(&plugin_id, &data, &display_name) + } else { + drop(results); + None + } + } else { + None + }; + + if let Some((display_name, actions)) = submenu_result { Self::enter_submenu( &submenu_state, &results_list_for_click, @@ -843,19 +921,21 @@ impl MainWindow { &mode_label, &hints_label, &search_entry, - &unit_name, - &display_name, - is_active, + display_name, + actions, ); } else { - let item = item.clone(); - drop(results); - let should_close = Self::handle_item_action(&item, &config.borrow(), &frecency, &providers); - if should_close { - window.close(); - } else { - // Trigger search refresh for updated widget state - search_entry.emit_by_name::<()>("changed", &[]); + // Not a submenu item - execute the command + let results = current_results.borrow(); + if let Some(item) = results.get(index).cloned() { + drop(results); + let should_close = Self::handle_item_action(&item, &config.borrow(), &frecency, &providers); + if should_close { + window.close(); + } else { + // Trigger search refresh for updated widget state + search_entry.emit_by_name::<()>("changed", &[]); + } } } } @@ -879,21 +959,21 @@ impl MainWindow { let next = if current.len() == 1 { let idx = tab_order.iter().position(|p| p == ¤t[0]).unwrap_or(0); if forward { - tab_order[(idx + 1) % tab_order.len()] + tab_order[(idx + 1) % tab_order.len()].clone() } else { - tab_order[(idx + tab_order.len() - 1) % tab_order.len()] + tab_order[(idx + tab_order.len() - 1) % tab_order.len()].clone() } } else { - tab_order[0] + tab_order[0].clone() }; { let mut f = filter.borrow_mut(); - f.set_single_mode(next); + f.set_single_mode(next.clone()); } for (ptype, button) in buttons.borrow().iter() { - button.set_active(*ptype == next); + button.set_active(ptype == &next); } mode_label.set_label(filter.borrow().mode_display_name()); @@ -910,7 +990,7 @@ impl MainWindow { ) { { let mut f = filter.borrow_mut(); - f.toggle(provider); + f.toggle(provider.clone()); } if let Some(button) = buttons.borrow().get(&provider) { @@ -968,13 +1048,11 @@ impl MainWindow { frecency: &Rc>, providers: &Rc>, ) -> bool { - // Check for POMODORO: internal command - if item.command.starts_with("POMODORO:") { - let action = item.command.strip_prefix("POMODORO:").unwrap_or(""); - if let Some(pomodoro) = providers.borrow_mut().pomodoro_mut() { - pomodoro.handle_action(action); - } - // Don't close window - user might want to see updated state + // Check for plugin internal commands (format: PLUGIN_ID:action) + // These are handled by the plugin itself, not launched as shell commands + if providers.borrow().execute_plugin_action(&item.command) { + // Plugin handled the action - don't close window + // User might want to see updated state (e.g., pomodoro timer) return false; } @@ -1002,9 +1080,22 @@ impl MainWindow { item.command.clone() }; + // Detect if this is a shell command vs an application launch + // Shell commands: playerctl, dbus-send, systemctl, journalctl, or anything with shell operators + let is_shell_command = cmd.starts_with("playerctl ") + || cmd.starts_with("dbus-send ") + || cmd.starts_with("systemctl ") + || cmd.starts_with("journalctl ") + || cmd.contains(" | ") + || cmd.contains(" && ") + || cmd.contains(" || ") + || cmd.contains(" > ") + || cmd.contains(" < "); + // Use launch wrapper if configured (uwsm, hyprctl, etc.) + // But skip wrapper for shell commands - they need sh -c let result = match &config.general.launch_wrapper { - Some(wrapper) if !wrapper.is_empty() => { + Some(wrapper) if !wrapper.is_empty() && !is_shell_command => { info!("Using launch wrapper: {}", wrapper); // Split wrapper into command and args (e.g., "uwsm app --" -> ["uwsm", "app", "--"]) let mut wrapper_parts: Vec<&str> = wrapper.split_whitespace().collect(); diff --git a/src/ui/mod.rs b/crates/owlry/src/ui/mod.rs similarity index 85% rename from src/ui/mod.rs rename to crates/owlry/src/ui/mod.rs index d6bac57..907f865 100644 --- a/src/ui/mod.rs +++ b/crates/owlry/src/ui/mod.rs @@ -1,5 +1,6 @@ mod main_window; mod result_row; +pub mod submenu; pub use main_window::MainWindow; pub use result_row::ResultRow; diff --git a/src/ui/result_row.rs b/crates/owlry/src/ui/result_row.rs similarity index 95% rename from src/ui/result_row.rs rename to crates/owlry/src/ui/result_row.rs index 124a98d..5a2dfbd 100644 --- a/src/ui/result_row.rs +++ b/crates/owlry/src/ui/result_row.rs @@ -8,6 +8,7 @@ pub struct ResultRow { } impl ResultRow { + #[allow(clippy::new_ret_no_self)] pub fn new(item: &LaunchItem) -> ListBoxRow { let row = ListBoxRow::builder() .selectable(true) @@ -42,7 +43,7 @@ impl ResultRow { img.upcast() } else { // Default icon based on provider type - let default_icon = match item.provider { + let default_icon = match &item.provider { crate::providers::ProviderType::Application => "application-x-executable", crate::providers::ProviderType::Bookmarks => "user-bookmarks", crate::providers::ProviderType::Calculator => "accessories-calculator", @@ -60,6 +61,7 @@ impl ResultRow { crate::providers::ProviderType::Weather => "weather-clear-symbolic", crate::providers::ProviderType::MediaPlayer => "media-playback-start-symbolic", crate::providers::ProviderType::Pomodoro => "alarm-symbolic", + crate::providers::ProviderType::Plugin(_) => "application-x-addon", }; let img = Image::from_icon_name(default_icon); img.set_pixel_size(32); @@ -119,7 +121,7 @@ impl ResultRow { // Provider badge let badge = Label::builder() - .label(&item.provider.to_string()) + .label(item.provider.to_string()) .halign(gtk4::Align::End) .valign(gtk4::Align::Center) .build(); diff --git a/crates/owlry/src/ui/submenu.rs b/crates/owlry/src/ui/submenu.rs new file mode 100644 index 0000000..6075428 --- /dev/null +++ b/crates/owlry/src/ui/submenu.rs @@ -0,0 +1,112 @@ +//! Universal Submenu Support for Plugins +//! +//! Provides parsing utilities for submenu commands. Plugins handle their own +//! submenu action generation through the query interface. +//! +//! ## Command Format +//! +//! Plugins should use this command format for submenu items: +//! ```text +//! SUBMENU:: +//! ``` +//! +//! For example: +//! - `SUBMENU:systemd:nginx.service:true` (systemd service with active state) +//! - `SUBMENU:docker:container_id:running` (docker container with state) +//! +//! ## How It Works +//! +//! 1. Plugin returns items with `SUBMENU:...` commands +//! 2. When user selects item, main_window detects it's a submenu item +//! 3. main_window queries the plugin via `?SUBMENU:` format +//! 4. Plugin returns submenu action items +//! 5. main_window displays the submenu +//! +//! ## Plugin Implementation +//! +//! Plugins should handle submenu queries in their `provider_query` function: +//! +//! ```rust,ignore +//! extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec { +//! let query_str = query.as_str(); +//! +//! // Handle submenu action requests +//! if let Some(data) = query_str.strip_prefix("?SUBMENU:") { +//! return generate_submenu_actions(data); +//! } +//! +//! // Handle action execution +//! if let Some(action) = query_str.strip_prefix("!") { +//! execute_action(action); +//! return RVec::new(); +//! } +//! +//! // Normal search query +//! search(query_str) +//! } +//! ``` + +use crate::providers::LaunchItem; + +/// Parse a submenu command and extract plugin_id and data +/// Returns (plugin_id, data) if command matches SUBMENU: format +pub fn parse_submenu_command(command: &str) -> Option<(&str, &str)> { + let rest = command.strip_prefix("SUBMENU:")?; + let colon_pos = rest.find(':')?; + let plugin_id = &rest[..colon_pos]; + let data = &rest[colon_pos + 1..]; + Some((plugin_id, data)) +} + +/// Check if an item should open a submenu +pub fn is_submenu_item(item: &LaunchItem) -> bool { + item.command.starts_with("SUBMENU:") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::ProviderType; + + #[test] + fn test_parse_submenu_command() { + assert_eq!( + parse_submenu_command("SUBMENU:systemd:nginx.service:true"), + Some(("systemd", "nginx.service:true")) + ); + assert_eq!( + parse_submenu_command("SUBMENU:docker:abc123:running"), + Some(("docker", "abc123:running")) + ); + assert_eq!(parse_submenu_command("not-a-submenu"), None); + assert_eq!(parse_submenu_command("SUBMENU:"), None); + assert_eq!(parse_submenu_command("SUBMENU:nocolon"), None); + } + + #[test] + fn test_is_submenu_item() { + let submenu_item = LaunchItem { + id: "test".to_string(), + name: "Test".to_string(), + description: None, + icon: None, + provider: ProviderType::Plugin("test".to_string()), + command: "SUBMENU:plugin:data".to_string(), + terminal: false, + tags: vec![], + }; + assert!(is_submenu_item(&submenu_item)); + + let normal_item = LaunchItem { + id: "test".to_string(), + name: "Test".to_string(), + description: None, + icon: None, + provider: ProviderType::Plugin("test".to_string()), + command: "some-command".to_string(), + terminal: false, + tags: vec![], + }; + assert!(!is_submenu_item(&normal_item)); + } +} diff --git a/data/config.example.toml b/data/config.example.toml index 268e65e..981dec5 100644 --- a/data/config.example.toml +++ b/data/config.example.toml @@ -3,11 +3,18 @@ # # File Locations (XDG Base Directory compliant): # ┌─────────────────────────────────────────────────────────────────────┐ -# │ Config: ~/.config/owlry/config.toml Main configuration │ -# │ Themes: ~/.config/owlry/themes/*.css Custom theme files │ -# │ Style: ~/.config/owlry/style.css CSS overrides │ -# │ Scripts: ~/.local/share/owlry/scripts/ Executable scripts │ -# │ Data: ~/.local/share/owlry/frecency.json Usage history │ +# │ Config: ~/.config/owlry/config.toml Main configuration │ +# │ Themes: ~/.config/owlry/themes/*.css Custom theme files │ +# │ Style: ~/.config/owlry/style.css CSS overrides │ +# │ Plugins: ~/.config/owlry/plugins/ User Lua/Rune plugins │ +# │ Scripts: ~/.local/share/owlry/scripts/ Executable scripts │ +# │ Data: ~/.local/share/owlry/frecency.json Usage history │ +# └─────────────────────────────────────────────────────────────────────┘ +# +# System Plugin Locations: +# ┌─────────────────────────────────────────────────────────────────────┐ +# │ Native: /usr/lib/owlry/plugins/*.so Installed plugins │ +# │ Runtimes: /usr/lib/owlry/runtimes/*.so Lua/Rune runtimes │ # └─────────────────────────────────────────────────────────────────────┘ # ═══════════════════════════════════════════════════════════════════════ @@ -56,80 +63,69 @@ border_radius = 12 # text_secondary = "#565f89" # accent = "#7aa2f7" # accent_bright = "#89b4fa" -# badge_app = "#9ece6a" -# badge_calc = "#e0af68" -# badge_cmd = "#7aa2f7" -# badge_dmenu = "#bb9af7" -# badge_uuctl = "#f7768e" # ═══════════════════════════════════════════════════════════════════════ -# PROVIDERS +# PLUGINS # ═══════════════════════════════════════════════════════════════════════ +# +# All installed plugins are loaded by default. Use 'disabled' to blacklist. +# Plugin IDs: calculator, system, ssh, clipboard, emoji, scripts, bookmarks, +# websearch, filesearch, systemd, weather, media, pomodoro + +[plugins] +# Plugins to disable (by ID) +disabled = [] + +# Examples: +# disabled = ["emoji", "pomodoro"] # Disable specific plugins +# disabled = ["weather", "media"] # Disable widget plugins + +# ═══════════════════════════════════════════════════════════════════════ +# CORE PROVIDERS +# ═══════════════════════════════════════════════════════════════════════ +# +# These are built into the core binary, not plugins. [providers] -# Core providers (appear in main search) -applications = true # .desktop applications +# Core providers (always available) +applications = true # .desktop applications from XDG dirs commands = true # Executables from $PATH -uuctl = true # systemd --user units # Frecency - boost frequently/recently used items -# Data: ~/.local/share/owlry/frecency.json +# Data stored in: ~/.local/share/owlry/frecency.json frecency = true frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost -# ─────────────────────────────────────────────────────────────────────── -# Trigger Providers (activated by prefix) -# ─────────────────────────────────────────────────────────────────────── +# ═══════════════════════════════════════════════════════════════════════ +# PLUGIN SETTINGS +# ═══════════════════════════════════════════════════════════════════════ +# +# Settings for specific plugins. Only applies if the plugin is installed. -# Calculator: "= 5+3" or "calc 5+3" or ":calc" -calculator = true - -# Web search: "? query" or "web query" or ":web" -websearch = true +# Web Search plugin +[providers.websearch] search_engine = "duckduckgo" # Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia -# Custom: "https://search.example.com/?q={query}" +# Custom URL: "https://search.example.com/?q={query}" -# File search: "/ pattern" or "find pattern" or ":file" -# Requires: fd or locate -files = true +# File Search plugin +[providers.filesearch] +max_results = 50 +# search_paths = ["/home", "/etc"] # Custom paths (default: $HOME) -# ─────────────────────────────────────────────────────────────────────── -# Prefix Providers (use :prefix to search) -# ─────────────────────────────────────────────────────────────────────── +# Weather widget plugin +[providers.weather] +enabled = true +provider = "wttr.in" # wttr.in (default), openweathermap, open-meteo +location = "" # City name, "lat,lon", or empty for auto-detect +# api_key = "" # Required for OpenWeatherMap -# System: :sys or :power - shutdown, reboot, lock, suspend, hibernate, logout -system = true +# Pomodoro timer plugin +[providers.pomodoro] +enabled = true +work_mins = 25 # Work session duration +break_mins = 5 # Break duration -# SSH: :ssh - connections from ~/.ssh/config -ssh = true - -# Clipboard: :clip - history (requires cliphist) -clipboard = true - -# Bookmarks: :bm - browser bookmarks (Chrome, Chromium, Brave, Edge, Vivaldi) -bookmarks = true - -# Emoji: :emoji - picker (copies to clipboard) -emoji = true - -# Scripts: :script - executables from ~/.local/share/owlry/scripts/ -scripts = true - -# ─────────────────────────────────────────────────────────────────────── -# Widget Providers (shown at top of results) -# ─────────────────────────────────────────────────────────────────────── - -# MPRIS media player controls - shows now playing with play/pause/skip -media = true - -# Weather widget - shows current conditions -weather = false -weather_provider = "wttr.in" # wttr.in (default), openweathermap, open-meteo -# weather_api_key = "" # Required for OpenWeatherMap -weather_location = "Berlin" # City name, "lat,lon", or leave empty for auto - -# Pomodoro timer - work/break timer with controls -pomodoro = false -pomodoro_work_mins = 25 # Work session duration -pomodoro_break_mins = 5 # Break duration +# Media controls plugin +[providers.media] +enabled = true diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md new file mode 100644 index 0000000..3fdc42f --- /dev/null +++ b/docs/PLUGINS.md @@ -0,0 +1,330 @@ +# Available Plugins + +Owlry's functionality is provided through a modular plugin system. This document describes all available plugins. + +## Plugin Categories + +### Static Providers + +Static providers load their items once at startup (and on manual refresh). They're best for data that doesn't change frequently. + +### Dynamic Providers + +Dynamic providers evaluate queries in real-time. Each keystroke triggers a new query, making them ideal for calculations, searches, and other interactive features. + +### Widget Providers + +Widget providers display persistent information at the top of results (weather, media controls, timers). + +--- + +## Core Plugins + +### owlry-plugin-calculator + +**Type:** Dynamic +**Prefix:** `:calc`, `=`, `calc ` +**Package:** `owlry-plugin-calculator` + +Evaluate mathematical expressions in real-time. + +**Examples:** +``` += 5 + 3 → 8 += sqrt(16) → 4 += sin(pi/2) → 1 += 2^10 → 1024 += (1 + 0.05)^12 → 1.7958... +``` + +**Supported operations:** +- Basic: `+`, `-`, `*`, `/`, `^` (power), `%` (modulo) +- Functions: `sin`, `cos`, `tan`, `asin`, `acos`, `atan` +- Functions: `sqrt`, `abs`, `floor`, `ceil`, `round` +- Functions: `ln`, `log`, `log10`, `exp` +- Constants: `pi`, `e` + +--- + +### owlry-plugin-system + +**Type:** Static +**Prefix:** `:sys` +**Package:** `owlry-plugin-system` + +System power and session management commands. + +**Actions:** +| Name | Description | Command | +|------|-------------|---------| +| Shutdown | Power off | `systemctl poweroff` | +| Reboot | Restart | `systemctl reboot` | +| Reboot into BIOS | UEFI setup | `systemctl reboot --firmware-setup` | +| Suspend | Sleep (RAM) | `systemctl suspend` | +| Hibernate | Sleep (disk) | `systemctl hibernate` | +| Lock Screen | Lock session | `loginctl lock-session` | +| Log Out | End session | `loginctl terminate-session self` | + +--- + +### owlry-plugin-ssh + +**Type:** Static +**Prefix:** `:ssh` +**Package:** `owlry-plugin-ssh` + +SSH hosts parsed from `~/.ssh/config`. + +**Features:** +- Parses `Host` entries from SSH config +- Ignores wildcards (`Host *`) +- Opens connections in your configured terminal + +--- + +### owlry-plugin-clipboard + +**Type:** Static +**Prefix:** `:clip` +**Package:** `owlry-plugin-clipboard` +**Dependencies:** `cliphist`, `wl-clipboard` + +Clipboard history integration with cliphist. + +**Features:** +- Shows last 50 clipboard entries +- Previews text content (truncated to 80 chars) +- Select to copy back to clipboard + +--- + +### owlry-plugin-emoji + +**Type:** Static +**Prefix:** `:emoji` +**Package:** `owlry-plugin-emoji` +**Dependencies:** `wl-clipboard` + +400+ searchable emoji with keywords. + +**Examples:** +``` +:emoji heart → ❤️ 💙 💚 💜 ... +:emoji smile → 😀 😃 😄 😁 ... +:emoji fire → 🔥 +``` + +--- + +### owlry-plugin-scripts + +**Type:** Static +**Prefix:** `:script` +**Package:** `owlry-plugin-scripts` + +User scripts from `~/.local/share/owlry/scripts/`. + +**Setup:** +```bash +mkdir -p ~/.local/share/owlry/scripts +cat > ~/.local/share/owlry/scripts/backup.sh << 'EOF' +#!/bin/bash +rsync -av ~/Documents /backup/ +notify-send "Backup complete" +EOF +chmod +x ~/.local/share/owlry/scripts/backup.sh +``` + +--- + +### owlry-plugin-bookmarks + +**Type:** Static +**Prefix:** `:bm` +**Package:** `owlry-plugin-bookmarks` + +Browser bookmarks from Chromium-based browsers. + +**Supported browsers:** +- Google Chrome +- Brave +- Microsoft Edge +- Vivaldi +- Chromium + +--- + +### owlry-plugin-websearch + +**Type:** Dynamic +**Prefix:** `:web`, `?`, `web ` +**Package:** `owlry-plugin-websearch` + +Web search with configurable search engine. + +**Examples:** +``` +? rust programming → Search for "rust programming" +web linux tips → Search for "linux tips" +``` + +**Configuration:** +```toml +[providers] +search_engine = "duckduckgo" # or: google, bing, startpage +# custom_search_url = "https://search.example.com/?q={}" +``` + +--- + +### owlry-plugin-filesearch + +**Type:** Dynamic +**Prefix:** `:file`, `/`, `find ` +**Package:** `owlry-plugin-filesearch` +**Dependencies:** `fd` (recommended) or `mlocate` + +Real-time file search. + +**Examples:** +``` +/ .bashrc → Find files matching ".bashrc" +find config → Find files matching "config" +``` + +**Configuration:** +```toml +[providers] +file_search_max_results = 50 +# file_search_paths = ["/home", "/etc"] # Custom search paths +``` + +--- + +### owlry-plugin-systemd + +**Type:** Static (with submenu) +**Prefix:** `:uuctl` +**Package:** `owlry-plugin-systemd` +**Dependencies:** `systemd` + +User systemd services with action submenus. + +**Features:** +- Lists user services (`systemctl --user`) +- Shows service status (running/stopped/failed) +- Submenu actions: start, stop, restart, enable, disable, status + +**Usage:** +1. Search `:uuctl docker` +2. Select a service +3. Choose action from submenu + +--- + +## Widget Plugins + +### owlry-plugin-weather + +**Type:** Widget (Static) +**Package:** `owlry-plugin-weather` + +Current weather displayed at the top of results. + +**Supported APIs:** +- wttr.in (default, no API key required) +- OpenWeatherMap (requires API key) +- Open-Meteo (no API key required) + +**Configuration:** +```toml +[plugins.weather] +provider = "wttr.in" # or: openweathermap, open-meteo +location = "London" # city name or "lat,lon" (empty for auto-detect) +# api_key = "..." # Required for OpenWeatherMap +``` + +**Features:** +- Temperature, condition, humidity, wind speed +- Weather icons from Weather Icons font +- 15-minute cache +- Click to open detailed forecast + +--- + +### owlry-plugin-media + +**Type:** Widget (Static) +**Package:** `owlry-plugin-media` + +MPRIS media player controls. + +**Features:** +- Shows currently playing track +- Artist, title, album art +- Play/pause, next, previous controls +- Works with Spotify, Firefox, VLC, etc. + +--- + +### owlry-plugin-pomodoro + +**Type:** Widget (Static) +**Package:** `owlry-plugin-pomodoro` + +Pomodoro timer with work/break cycles. + +**Configuration:** +```toml +[plugins.pomodoro] +work_mins = 25 # Work session duration (default: 25) +break_mins = 5 # Break duration (default: 5) +``` + +**Features:** +- Configurable work session duration +- Configurable break duration +- Session counter +- Desktop notifications on phase completion +- Persistent state across sessions + +**Controls:** +- Start/Pause timer +- Skip to next phase +- Reset timer and sessions + +--- + +## Bundle Packages + +For convenience, plugins are available in bundle meta-packages: + +| Bundle | Plugins | +|--------|---------| +| `owlry-essentials` | calculator, system, ssh, scripts, bookmarks | +| `owlry-widgets` | weather, media, pomodoro | +| `owlry-tools` | clipboard, emoji, websearch, filesearch, systemd | +| `owlry-full` | All of the above | + +```bash +# Install everything +yay -S owlry-full + +# Or pick a bundle +yay -S owlry-essentials owlry-widgets +``` + +--- + +## Runtime Packages + +For custom user plugins written in Lua or Rune: + +| Package | Description | +|---------|-------------| +| `owlry-lua` | Lua 5.4 runtime for user plugins | +| `owlry-rune` | Rune runtime for user plugins | + +User plugins are placed in `~/.config/owlry/plugins/`. + +See [PLUGIN_DEVELOPMENT.md](PLUGIN_DEVELOPMENT.md) for creating custom plugins. diff --git a/docs/PLUGIN_DEVELOPMENT.md b/docs/PLUGIN_DEVELOPMENT.md new file mode 100644 index 0000000..210377b --- /dev/null +++ b/docs/PLUGIN_DEVELOPMENT.md @@ -0,0 +1,562 @@ +# Plugin Development Guide + +This guide covers creating plugins for Owlry. There are three ways to extend Owlry: + +1. **Native plugins** (Rust) — Best performance, ABI-stable interface +2. **Lua plugins** — Easy scripting, requires `owlry-lua` runtime +3. **Rune plugins** — Safe scripting with Rust-like syntax, requires `owlry-rune` runtime + +--- + +## Quick Start + +### Native Plugin (Rust) + +```bash +# Create a new plugin crate +cargo new --lib owlry-plugin-myplugin +cd owlry-plugin-myplugin +``` + +Edit `Cargo.toml`: +```toml +[package] +name = "owlry-plugin-myplugin" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +owlry-plugin-api = { git = "https://somegit.dev/Owlibou/owlry" } +abi_stable = "0.11" +``` + +Edit `src/lib.rs`: +```rust +use abi_stable::std_types::{ROption, RStr, RString, RVec}; +use owlry_plugin_api::{ + owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, + ProviderKind, API_VERSION, +}; + +extern "C" fn plugin_info() -> PluginInfo { + PluginInfo { + id: RString::from("myplugin"), + name: RString::from("My Plugin"), + version: RString::from(env!("CARGO_PKG_VERSION")), + description: RString::from("A custom plugin"), + api_version: API_VERSION, + } +} + +extern "C" fn plugin_providers() -> RVec { + vec![ProviderInfo { + id: RString::from("myplugin"), + name: RString::from("My Plugin"), + prefix: ROption::RSome(RString::from(":my")), + icon: RString::from("application-x-executable"), + provider_type: ProviderKind::Static, + type_id: RString::from("myplugin"), + }].into() +} + +extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { + ProviderHandle::null() +} + +extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec { + vec![ + PluginItem::new("item-1", "Hello World", "echo 'Hello!'") + .with_description("A greeting") + .with_icon("face-smile"), + ].into() +} + +extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec { + RVec::new() +} + +extern "C" fn provider_drop(_handle: ProviderHandle) {} + +owlry_plugin! { + info: plugin_info, + providers: plugin_providers, + init: provider_init, + refresh: provider_refresh, + query: provider_query, + drop: provider_drop, +} +``` + +Build and install: +```bash +cargo build --release +sudo cp target/release/libowlry_plugin_myplugin.so /usr/lib/owlry/plugins/ +``` + +### Lua Plugin + +```bash +# Requires owlry-lua runtime +yay -S owlry-lua + +# Create plugin directory +mkdir -p ~/.config/owlry/plugins/my-lua-plugin +``` + +Create `~/.config/owlry/plugins/my-lua-plugin/plugin.toml`: +```toml +[plugin] +id = "my-lua-plugin" +name = "My Lua Plugin" +version = "0.1.0" +description = "A custom Lua plugin" +entry_point = "init.lua" + +[[providers]] +id = "myluaprovider" +name = "My Lua Provider" +prefix = ":mylua" +icon = "application-x-executable" +type = "static" +type_id = "mylua" +``` + +Create `~/.config/owlry/plugins/my-lua-plugin/init.lua`: +```lua +local owlry = require("owlry") + +-- Called once at startup for static providers +function refresh() + return { + owlry.item("item-1", "Hello from Lua", "echo 'Hello Lua!'") + :description("A Lua greeting") + :icon("face-smile"), + } +end + +-- Called per-keystroke for dynamic providers +function query(q) + return {} +end +``` + +--- + +## Native Plugin API + +### Plugin VTable + +Every native plugin must export a function that returns a vtable: + +```rust +#[repr(C)] +pub struct PluginVTable { + pub info: extern "C" fn() -> PluginInfo, + pub providers: extern "C" fn() -> RVec, + pub provider_init: extern "C" fn(provider_id: RStr<'_>) -> ProviderHandle, + pub provider_refresh: extern "C" fn(handle: ProviderHandle) -> RVec, + pub provider_query: extern "C" fn(handle: ProviderHandle, query: RStr<'_>) -> RVec, + pub provider_drop: extern "C" fn(handle: ProviderHandle), +} +``` + +Use the `owlry_plugin!` macro to generate the export: + +```rust +owlry_plugin! { + info: my_info_fn, + providers: my_providers_fn, + init: my_init_fn, + refresh: my_refresh_fn, + query: my_query_fn, + drop: my_drop_fn, +} +``` + +### PluginInfo + +```rust +pub struct PluginInfo { + pub id: RString, // Unique ID (e.g., "calculator") + pub name: RString, // Display name + pub version: RString, // Semantic version + pub description: RString, // Short description + pub api_version: u32, // Must match API_VERSION +} +``` + +### ProviderInfo + +```rust +pub struct ProviderInfo { + pub id: RString, // Provider ID within plugin + pub name: RString, // Display name + pub prefix: ROption, // Activation prefix (e.g., ":calc") + pub icon: RString, // Default icon name + pub provider_type: ProviderKind, // Static or Dynamic + pub type_id: RString, // Short ID for badges +} + +pub enum ProviderKind { + Static, // Items loaded at startup via refresh() + Dynamic, // Items computed per-query via query() +} +``` + +### PluginItem + +```rust +pub struct PluginItem { + pub id: RString, // Unique item ID + pub name: RString, // Display name + pub description: ROption, // Optional description + pub icon: ROption, // Optional icon + pub command: RString, // Command to execute + pub terminal: bool, // Run in terminal? + pub keywords: RVec, // Search keywords + pub score_boost: i32, // Frecency boost +} + +// Builder pattern +let item = PluginItem::new("id", "Name", "command") + .with_description("Description") + .with_icon("icon-name") + .with_terminal(true) + .with_keywords(vec!["tag1".to_string(), "tag2".to_string()]) + .with_score_boost(100); +``` + +### ProviderHandle + +For stateful providers, use `ProviderHandle` to store state: + +```rust +struct MyState { + items: Vec, + cache: HashMap, +} + +extern "C" fn provider_init(_: RStr<'_>) -> ProviderHandle { + let state = Box::new(MyState { + items: Vec::new(), + cache: HashMap::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 MyState) }; + state.items = load_items(); + state.items.clone().into() +} + +extern "C" fn provider_drop(handle: ProviderHandle) { + if !handle.ptr.is_null() { + unsafe { handle.drop_as::(); } + } +} +``` + +### Host API + +Plugins can use host-provided functions: + +```rust +use owlry_plugin_api::{notify, notify_with_icon, log_info, log_warn, log_error}; + +// Send notifications +notify("Title", "Body text"); +notify_with_icon("Title", "Body", "dialog-information"); + +// Logging +log_info("Plugin loaded successfully"); +log_warn("Cache miss, fetching data"); +log_error("Failed to connect to API"); +``` + +### Submenu Support + +Plugins can provide submenus for detailed actions: + +```rust +// Return an item that opens a submenu +PluginItem::new( + "service-docker", + "Docker", + "SUBMENU:systemd:docker.service", // Special command format +) + +// Handle submenu query (query starts with "?SUBMENU:") +extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec { + let q = query.as_str(); + + if let Some(data) = q.strip_prefix("?SUBMENU:") { + // Return submenu actions + return vec![ + PluginItem::new("start", "Start", format!("systemctl start {}", data)), + PluginItem::new("stop", "Stop", format!("systemctl stop {}", data)), + ].into(); + } + + RVec::new() +} +``` + +--- + +## Lua Plugin API + +### Plugin Manifest (plugin.toml) + +```toml +[plugin] +id = "my-plugin" +name = "My Plugin" +version = "1.0.0" +description = "Plugin description" +entry_point = "init.lua" +owlry_version = ">=0.4.0" # Optional version constraint + +[permissions] +fs = ["read"] # File system access +http = true # HTTP requests +process = true # Spawn processes + +[[providers]] +id = "provider1" +name = "Provider Name" +prefix = ":prefix" +icon = "icon-name" +type = "static" # or "dynamic" +type_id = "shortid" +``` + +### Lua API + +```lua +local owlry = require("owlry") + +-- Create items +local item = owlry.item(id, name, command) + :description("Description") + :icon("icon-name") + :terminal(false) + :keywords({"tag1", "tag2"}) + +-- Notifications +owlry.notify("Title", "Body") +owlry.notify_icon("Title", "Body", "icon-name") + +-- Logging +owlry.log.info("Message") +owlry.log.warn("Warning") +owlry.log.error("Error") + +-- File operations (requires fs permission) +local content = owlry.fs.read("/path/to/file") +local files = owlry.fs.list("/path/to/dir") +local exists = owlry.fs.exists("/path") + +-- HTTP requests (requires http permission) +local response = owlry.http.get("https://api.example.com/data") +local json = owlry.json.decode(response) + +-- Process execution (requires process permission) +local output = owlry.process.run("ls", {"-la"}) + +-- Cache (persistent across sessions) +owlry.cache.set("key", value, ttl_seconds) +local value = owlry.cache.get("key") +``` + +### Provider Functions + +```lua +-- Static provider: called once at startup +function refresh() + return { + owlry.item("id1", "Item 1", "command1"), + owlry.item("id2", "Item 2", "command2"), + } +end + +-- Dynamic provider: called on each keystroke +function query(q) + if q == "" then + return {} + end + + return { + owlry.item("result", "Result for: " .. q, "echo " .. q), + } +end +``` + +--- + +## Rune Plugin API + +Rune plugins use a Rust-like syntax with memory safety. + +### Plugin Manifest + +```toml +[plugin] +id = "my-rune-plugin" +name = "My Rune Plugin" +version = "1.0.0" +entry_point = "main.rn" + +[[providers]] +id = "runeprovider" +name = "Rune Provider" +type = "static" +``` + +### Rune API + +```rune +use owlry::{Item, log, notify}; + +pub fn refresh() { + let items = []; + + items.push(Item::new("id", "Name", "command") + .description("Description") + .icon("icon-name")); + + items +} + +pub fn query(q) { + if q.is_empty() { + return []; + } + + log::info(`Query: {q}`); + + [Item::new("result", `Result: {q}`, `echo {q}`)] +} +``` + +--- + +## Best Practices + +### Performance + +1. **Static providers**: Do expensive work in `refresh()`, not `items()` +2. **Dynamic providers**: Keep `query()` fast (<50ms) +3. **Cache data**: Use persistent cache for API responses +4. **Lazy loading**: Don't load all items if only a few are needed + +### Error Handling + +```rust +// Native: Return empty vec on error, log the issue +extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { + match load_data() { + Ok(items) => items.into(), + Err(e) => { + log_error(&format!("Failed to load: {}", e)); + RVec::new() + } + } +} +``` + +```lua +-- Lua: Wrap in pcall for safety +function refresh() + local ok, result = pcall(function() + return load_items() + end) + + if not ok then + owlry.log.error("Failed: " .. result) + return {} + end + + return result +end +``` + +### Icons + +Use freedesktop icon names for consistency: +- `application-x-executable` — Generic executable +- `folder` — Directories +- `text-x-generic` — Text files +- `face-smile` — Emoji/reactions +- `system-shutdown` — Power actions +- `network-server` — SSH/network +- `edit-paste` — Clipboard + +### Testing + +```bash +# Build and test native plugin +cargo build --release -p owlry-plugin-myplugin +cargo test -p owlry-plugin-myplugin + +# Install for testing +sudo cp target/release/libowlry_plugin_myplugin.so /usr/lib/owlry/plugins/ + +# Test with verbose logging +RUST_LOG=debug owlry +``` + +--- + +## Publishing to AUR + +### PKGBUILD Template + +```bash +# Maintainer: Your Name +pkgname=owlry-plugin-myplugin +pkgver=0.1.0 +pkgrel=1 +pkgdesc="My custom Owlry plugin" +arch=('x86_64') +url="https://github.com/you/owlry-plugin-myplugin" +license=('GPL-3.0-or-later') +depends=('owlry') +makedepends=('rust' 'cargo') +source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz") +sha256sums=('...') + +build() { + cd "$pkgname-$pkgver" + cargo build --release +} + +package() { + cd "$pkgname-$pkgver" + install -Dm755 "target/release/lib${pkgname//-/_}.so" \ + "$pkgdir/usr/lib/owlry/plugins/lib${pkgname//-/_}.so" +} +``` + +--- + +## Example Plugins + +The owlry repository includes 13 native plugins as reference implementations: + +| Plugin | Type | Highlights | +|--------|------|------------| +| `owlry-plugin-calculator` | Dynamic | Math parsing, expression evaluation | +| `owlry-plugin-weather` | Static/Widget | HTTP API, JSON parsing, caching | +| `owlry-plugin-systemd` | Static | Submenu actions, service management | +| `owlry-plugin-pomodoro` | Static/Widget | State persistence, notifications | +| `owlry-plugin-clipboard` | Static | External process integration | + +Browse the source at `crates/owlry-plugin-*/` for implementation details. diff --git a/examples/plugins/hello-rune/init.rn b/examples/plugins/hello-rune/init.rn new file mode 100644 index 0000000..2266882 --- /dev/null +++ b/examples/plugins/hello-rune/init.rn @@ -0,0 +1,10 @@ +// Hello World Plugin for Owlry +// +// This minimal plugin demonstrates: +// 1. Using owlry::log_* for logging +// 2. Basic Rune syntax + +pub fn main() { + owlry::log_info("Hello Rune plugin loading..."); + owlry::log_info("Hello Rune plugin loaded successfully!"); +} diff --git a/examples/plugins/hello-rune/plugin.toml b/examples/plugins/hello-rune/plugin.toml new file mode 100644 index 0000000..17f1d29 --- /dev/null +++ b/examples/plugins/hello-rune/plugin.toml @@ -0,0 +1,16 @@ +# Hello World Plugin for Owlry (Rune version) +# +# This example demonstrates a minimal Rune plugin. + +[plugin] +id = "hello-rune" +name = "Hello Rune" +version = "1.0.0" +description = "A simple greeting plugin written in Rune" +author = "Owlry Team" +license = "GPL-3.0-or-later" +owlry_version = ">=0.3.0" +entry = "init.rn" + +[provides] +providers = ["greeting"] diff --git a/examples/plugins/hello-world/init.lua b/examples/plugins/hello-world/init.lua new file mode 100644 index 0000000..ac2cf7c --- /dev/null +++ b/examples/plugins/hello-world/init.lua @@ -0,0 +1,64 @@ +-- Hello World Plugin for Owlry +-- +-- This minimal plugin demonstrates: +-- 1. Using owlry.log for logging +-- 2. Registering a static provider +-- 3. Returning items with all available fields + +owlry.log.info("Hello World plugin loading...") + +-- Register a static provider +-- Static providers have a 'refresh' function that returns items once +owlry.provider.register({ + -- Required: unique provider name (used internally) + name = "greeting", + + -- Optional: human-readable display name + display_name = "Hello World", + + -- Optional: icon name (freedesktop icon spec) + default_icon = "face-smile", + + -- Optional: prefix to trigger this provider (e.g., ":hello") + prefix = ":hello", + + -- Required for static providers: function that returns items + refresh = function() + -- Get username from environment or default to "User" + local user = os.getenv("USER") or "User" + + return { + { + id = "greeting-1", + name = "Hello, " .. user .. "!", + description = "A friendly greeting from Lua", + icon = "face-smile", + -- Command to run when selected (optional) + command = "notify-send 'Hello' 'Greetings from Owlry!'", + -- Whether to run in terminal (optional, default false) + terminal = false, + -- Tags for search/filtering (optional) + tags = { "greeting", "hello", "example" } + }, + { + id = "greeting-2", + name = "Current time: " .. os.date("%H:%M:%S"), + description = "The current system time", + icon = "appointment-soon", + -- Empty command = info only, no action + command = "", + tags = { "time", "clock" } + }, + { + id = "greeting-3", + name = "Open Owlry docs", + description = "Visit the Owlry documentation", + icon = "help-browser", + command = "xdg-open 'https://github.com/Owlibou/owlry'", + tags = { "help", "docs", "documentation" } + } + } + end +}) + +owlry.log.info("Hello World plugin loaded successfully!") diff --git a/examples/plugins/hello-world/plugin.toml b/examples/plugins/hello-world/plugin.toml new file mode 100644 index 0000000..404d55b --- /dev/null +++ b/examples/plugins/hello-world/plugin.toml @@ -0,0 +1,22 @@ +# Hello World Plugin for Owlry +# +# This is a minimal example plugin demonstrating: +# - Plugin manifest structure +# - Static provider registration +# - Returning items from Lua + +[plugin] +id = "hello-world" +name = "Hello World" +version = "1.0.0" +description = "A minimal example plugin" +author = "Owlry Team" +license = "GPL-3.0-or-later" +owlry_version = ">=0.3.0" + +[provides] +providers = ["greeting"] + +# No special permissions needed for this simple plugin +[permissions] +network = false diff --git a/examples/plugins/quick-note/init.lua b/examples/plugins/quick-note/init.lua new file mode 100644 index 0000000..637d9ee --- /dev/null +++ b/examples/plugins/quick-note/init.lua @@ -0,0 +1,95 @@ +-- Quick Note Plugin for Owlry +-- +-- This plugin demonstrates: +-- 1. Dynamic providers (query-based) +-- 2. Using owlry.fs for file operations +-- 3. Using owlry.path for directory helpers +-- 4. Using owlry.json for data storage + +owlry.log.info("Quick Note plugin loading...") + +-- Get notes file path +local notes_dir = owlry.path.join(owlry.path.data(), "notes") +local notes_file = owlry.path.join(notes_dir, "quick-notes.json") + +-- Load existing notes +local function load_notes() + local content = owlry.fs.read(notes_file) + if content then + local data = owlry.json.decode(content) + if data and data.notes then + return data.notes + end + end + return {} +end + +-- Save notes +local function save_notes(notes) + local data = { notes = notes } + local json = owlry.json.encode(data) + owlry.fs.write(notes_file, json) +end + +-- Register a dynamic provider +-- Dynamic providers have a 'query' function that receives user input +owlry.provider.register({ + name = "quick-note", + display_name = "Quick Notes", + default_icon = "text-x-generic", + + -- Prefix to activate this provider + prefix = ":note", + + -- Dynamic query function - called as user types + query = function(q) + local items = {} + local query = q or "" + + -- If query is not empty, offer to save it as a note + if #query > 0 then + table.insert(items, { + id = "save-note", + name = "Save: " .. query, + description = "Save this as a new note", + icon = "document-save", + -- Special command format - would be handled by plugin action + command = "PLUGIN:quick-note:save:" .. query, + tags = { "save", "new" } + }) + end + + -- Load and display existing notes + local notes = load_notes() + for i, note in ipairs(notes) do + -- Filter notes by query + if #query == 0 or string.find(string.lower(note.text), string.lower(query)) then + table.insert(items, { + id = "note-" .. i, + name = note.text, + description = "Saved on " .. (note.date or "unknown"), + icon = "text-x-generic", + -- Copy to clipboard command + command = "echo -n '" .. note.text:gsub("'", "'\\''") .. "' | wl-copy", + tags = { "note", "saved" } + }) + end + end + + -- If no notes exist, show a hint + if #items == 0 then + table.insert(items, { + id = "hint", + name = "Type something to save a note", + description = "Notes are saved to " .. notes_file, + icon = "dialog-information", + command = "", + tags = { "hint" } + }) + end + + return items + end +}) + +owlry.log.info("Quick Note plugin loaded!") diff --git a/examples/plugins/quick-note/plugin.toml b/examples/plugins/quick-note/plugin.toml new file mode 100644 index 0000000..9bbf761 --- /dev/null +++ b/examples/plugins/quick-note/plugin.toml @@ -0,0 +1,22 @@ +# Quick Note Plugin for Owlry +# +# This example demonstrates: +# - Dynamic (query-based) provider +# - Using owlry.fs for file operations +# - Using owlry.path for path helpers + +[plugin] +id = "quick-note" +name = "Quick Note" +version = "1.0.0" +description = "Save quick notes to a file" +author = "Owlry Team" +license = "GPL-3.0-or-later" +owlry_version = ">=0.3.0" + +[provides] +providers = ["quick-note"] + +[permissions] +# Allow writing to filesystem +filesystem = ["~/.local/share/owlry/notes"] diff --git a/justfile b/justfile index f696b9b..9d8714e 100644 --- a/justfile +++ b/justfile @@ -4,39 +4,91 @@ default: @just --list -# Build debug +# Build debug (all workspace members) build: - cargo build + cargo build --workspace + +# Build core binary only +build-core: + cargo build -p owlry # Build release release: - cargo build --release + cargo build --workspace --release # Run in debug mode run *ARGS: - cargo run -- {{ARGS}} + cargo run -p owlry -- {{ARGS}} # Run tests test: - cargo test + cargo test --workspace # Check code check: - cargo check - cargo clippy + cargo check --workspace + cargo clippy --workspace # Format code fmt: - cargo fmt + cargo fmt --all # Clean build artifacts clean: cargo clean +# Build a specific plugin (when plugins exist) +plugin name: + cargo build -p owlry-plugin-{{name}} --release + +# Build all plugins +plugins: + cargo build --workspace --release --exclude owlry + +# Install locally (core + plugins + runtimes) +install-local: + #!/usr/bin/env bash + set -euo pipefail + + echo "Building release..." + cargo build --workspace --release + + echo "Creating directories..." + sudo mkdir -p /usr/lib/owlry/plugins + sudo mkdir -p /usr/lib/owlry/runtimes + + echo "Installing core binary..." + sudo install -Dm755 target/release/owlry /usr/bin/owlry + + echo "Installing plugins..." + for plugin in target/release/libowlry_plugin_*.so; do + if [ -f "$plugin" ]; then + name=$(basename "$plugin") + sudo install -Dm755 "$plugin" "/usr/lib/owlry/plugins/$name" + echo " → $name" + fi + done + + echo "Installing runtimes..." + if [ -f "target/release/libowlry_lua.so" ]; then + sudo install -Dm755 target/release/libowlry_lua.so /usr/lib/owlry/runtimes/liblua.so + echo " → liblua.so" + fi + if [ -f "target/release/libowlry_rune.so" ]; then + sudo install -Dm755 target/release/libowlry_rune.so /usr/lib/owlry/runtimes/librune.so + echo " → librune.so" + fi + + echo "" + echo "Installation complete!" + echo " - /usr/bin/owlry" + echo " - $(ls /usr/lib/owlry/plugins/*.so 2>/dev/null | wc -l) plugins" + echo " - $(ls /usr/lib/owlry/runtimes/*.so 2>/dev/null | wc -l) runtimes" + # === Release Management === -# AUR package directory -aur_dir := "/home/cnachtigall/data/git/aur/owlry" +# AUR package directories (relative to project root) +aur_core_dir := "aur/owlry" # Get current version from Cargo.toml version := `grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/'` @@ -73,11 +125,11 @@ tag: git push origin "v{{version}}" echo "Tag v{{version}} pushed" -# Update AUR package +# Update AUR package (core) aur-update: #!/usr/bin/env bash set -euo pipefail - cd "{{aur_dir}}" + cd "{{aur_core_dir}}" url="https://somegit.dev/Owlibou/owlry" @@ -101,11 +153,11 @@ aur-update: echo "AUR package updated. Review changes above." echo "Run 'just aur-publish' to commit and push." -# Publish AUR package +# Publish AUR package (core) aur-publish: #!/usr/bin/env bash set -euo pipefail - cd "{{aur_dir}}" + cd "{{aur_core_dir}}" git add PKGBUILD .SRCINFO git commit -m "Update to v{{version}}" @@ -113,11 +165,11 @@ aur-publish: echo "AUR package v{{version}} published!" -# Test AUR package build locally +# Test AUR package build locally (core) aur-test: #!/usr/bin/env bash set -euo pipefail - cd "{{aur_dir}}" + cd "{{aur_core_dir}}" echo "Testing PKGBUILD..." makepkg -sf diff --git a/src/cli.rs b/src/cli.rs deleted file mode 100644 index 839115e..0000000 --- a/src/cli.rs +++ /dev/null @@ -1,29 +0,0 @@ -use clap::Parser; - -use crate::providers::ProviderType; - -#[derive(Parser, Debug, Clone)] -#[command( - name = "owlry", - about = "An owl-themed application launcher for Wayland", - version -)] -pub struct CliArgs { - /// Start in single-provider mode (app, cmd, uuctl) - #[arg(long, short = 'm', value_parser = parse_provider)] - pub mode: Option, - - /// Comma-separated list of enabled providers (app,cmd,uuctl) - #[arg(long, short = 'p', value_delimiter = ',', value_parser = parse_provider)] - pub providers: Option>, -} - -fn parse_provider(s: &str) -> Result { - s.parse() -} - -impl CliArgs { - pub fn parse_args() -> Self { - Self::parse() - } -} diff --git a/src/providers/bookmarks.rs b/src/providers/bookmarks.rs deleted file mode 100644 index c349193..0000000 --- a/src/providers/bookmarks.rs +++ /dev/null @@ -1,225 +0,0 @@ -use crate::paths; -use crate::providers::{LaunchItem, Provider, ProviderType}; -use log::{debug, warn}; -use serde::Deserialize; -use std::fs; -use std::path::PathBuf; - -/// Browser bookmarks provider - reads Firefox and Chrome bookmarks -pub struct BookmarksProvider { - items: Vec, -} - -impl BookmarksProvider { - pub fn new() -> Self { - Self { items: Vec::new() } - } - - fn load_bookmarks(&mut self) { - self.items.clear(); - - // Try Firefox first, then Chrome/Chromium - self.load_firefox_bookmarks(); - self.load_chrome_bookmarks(); - - debug!("Loaded {} bookmarks total", self.items.len()); - } - - fn load_firefox_bookmarks(&mut self) { - // Firefox stores bookmarks in places.sqlite - // The file is locked when Firefox is running, so we read from backup - let firefox_dir = match paths::firefox_dir() { - Some(d) => d, - None => return, - }; - - if !firefox_dir.exists() { - debug!("Firefox directory not found"); - return; - } - - // Find default profile (ends with .default-release or .default) - let profile_dir = match Self::find_firefox_profile(&firefox_dir) { - Some(p) => p, - None => { - debug!("No Firefox profile found"); - return; - } - }; - - // Try to read bookmarkbackups (JSON format, not locked) - let backup_dir = profile_dir.join("bookmarkbackups"); - if backup_dir.exists() { - if let Some(latest_backup) = Self::find_latest_file(&backup_dir, "jsonlz4") { - // jsonlz4 files need decompression - skip for now, try places.sqlite - debug!("Found Firefox backup at {:?}, but jsonlz4 not supported", latest_backup); - } - } - - // Try places.sqlite directly (may fail if Firefox is running) - let places_db = profile_dir.join("places.sqlite"); - if places_db.exists() { - self.read_firefox_places(&places_db); - } - } - - fn find_firefox_profile(firefox_dir: &PathBuf) -> Option { - let entries = fs::read_dir(firefox_dir).ok()?; - - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().to_string(); - if name.ends_with(".default-release") || name.ends_with(".default") { - return Some(entry.path()); - } - } - None - } - - fn find_latest_file(dir: &PathBuf, extension: &str) -> Option { - let entries = fs::read_dir(dir).ok()?; - - entries - .flatten() - .filter(|e| { - e.path() - .extension() - .map(|ext| ext == extension) - .unwrap_or(false) - }) - .max_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok())) - .map(|e| e.path()) - } - - fn read_firefox_places(&mut self, db_path: &PathBuf) { - // Note: This requires the rusqlite crate which we don't have - // For now, skip Firefox SQLite reading - debug!( - "Firefox places.sqlite found at {:?}, but SQLite reading not implemented", - db_path - ); - } - - fn load_chrome_bookmarks(&mut self) { - // Chrome/Chromium bookmarks are in JSON format (XDG config paths) - for path in paths::chromium_bookmark_paths() { - if path.exists() { - self.read_chrome_bookmarks(&path); - } - } - } - - fn read_chrome_bookmarks(&mut self, path: &PathBuf) { - let content = match fs::read_to_string(path) { - Ok(c) => c, - Err(e) => { - warn!("Failed to read Chrome bookmarks from {:?}: {}", path, e); - return; - } - }; - - let bookmarks: ChromeBookmarks = match serde_json::from_str(&content) { - Ok(b) => b, - Err(e) => { - warn!("Failed to parse Chrome bookmarks: {}", e); - return; - } - }; - - // Process bookmark bar and other folders - if let Some(roots) = bookmarks.roots { - if let Some(bar) = roots.bookmark_bar { - self.process_chrome_folder(&bar); - } - if let Some(other) = roots.other { - self.process_chrome_folder(&other); - } - if let Some(synced) = roots.synced { - self.process_chrome_folder(&synced); - } - } - - debug!("Loaded Chrome bookmarks from {:?}", path); - } - - fn process_chrome_folder(&mut self, folder: &ChromeBookmarkNode) { - 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()); - - self.items.push(LaunchItem { - id: format!("bookmark:{}", url), - name, - description: Some(url.clone()), - icon: Some("web-browser".to_string()), - provider: ProviderType::Bookmarks, - command: format!("xdg-open '{}'", url.replace('\'', "'\\''")), - terminal: false, - tags: Vec::new(), - }); - } - } - Some("folder") => { - // Recursively process subfolders - self.process_chrome_folder(child); - } - _ => {} - } - } - } - } -} - -// 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>, -} - -impl Provider for BookmarksProvider { - fn name(&self) -> &str { - "Bookmarks" - } - - fn provider_type(&self) -> ProviderType { - ProviderType::Bookmarks - } - - fn refresh(&mut self) { - self.load_bookmarks(); - } - - fn items(&self) -> &[LaunchItem] { - &self.items - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_bookmarks_provider() { - let mut provider = BookmarksProvider::new(); - provider.refresh(); - // Just ensure it doesn't panic - } -} diff --git a/src/providers/calculator.rs b/src/providers/calculator.rs deleted file mode 100644 index a81c65f..0000000 --- a/src/providers/calculator.rs +++ /dev/null @@ -1,239 +0,0 @@ -use super::{LaunchItem, Provider, ProviderType}; -use log::debug; - -/// Calculator provider for evaluating math expressions -/// Syntax: `= expression` or `calc expression` -pub struct CalculatorProvider { - /// Cached result from last evaluation - cached_result: Option, -} - -impl CalculatorProvider { - pub fn new() -> Self { - Self { - cached_result: None, - } - } - - /// Check if a query is a calculator expression - pub fn is_calculator_query(query: &str) -> bool { - let trimmed = query.trim(); - trimmed.starts_with("=") || trimmed.starts_with("calc ") - } - - /// Extract the expression from a calculator query - 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 { - None - } - } - - /// Check if string looks like a math expression (for :calc mode) - pub fn looks_like_expression(query: &str) -> bool { - let trimmed = query.trim(); - if trimmed.is_empty() { - return false; - } - // Contains math operators or is a number - trimmed.chars().any(|c| "+-*/^()".contains(c)) - || trimmed.parse::().is_ok() - || ["pi", "e", "sqrt", "sin", "cos", "tan", "abs", "ln", "log"] - .iter() - .any(|f| trimmed.to_lowercase().contains(f)) - } - - /// Evaluate a raw expression (for :calc filter mode) - pub fn evaluate_raw(&mut self, expr: &str) -> Option { - let trimmed = expr.trim(); - if trimmed.is_empty() { - return None; - } - - match meval::eval_str(trimmed) { - Ok(result) => { - let result_str = if result.fract() == 0.0 && result.abs() < 1e15 { - format!("{}", result as i64) - } else { - format!("{:.10}", result).trim_end_matches('0').trim_end_matches('.').to_string() - }; - - Some(LaunchItem { - id: format!("calc:{}", trimmed), - name: format!("{} = {}", trimmed, result_str), - description: Some("Press Enter to copy result".to_string()), - icon: Some("accessories-calculator".to_string()), - provider: ProviderType::Calculator, - command: format!("echo -n '{}' | wl-copy", result_str), - terminal: false, - tags: vec!["math".to_string()], - }) - } - Err(_) => None, - } - } - - /// Evaluate an expression and return a LaunchItem result - pub fn evaluate(&mut self, query: &str) -> Option { - let expr = Self::extract_expression(query)?; - - if expr.is_empty() { - return None; - } - - debug!("Evaluating expression: {}", expr); - - match meval::eval_str(expr) { - Ok(result) => { - // Format result nicely - let result_str = if result.fract() == 0.0 && result.abs() < 1e15 { - // Integer result - format!("{}", result as i64) - } else { - // Float result with reasonable precision - let formatted = format!("{:.10}", result); - // Trim trailing zeros - formatted.trim_end_matches('0').trim_end_matches('.').to_string() - }; - - let item = LaunchItem { - id: format!("calc:{}", expr), - name: result_str.clone(), - description: Some(format!("= {}", expr)), - icon: Some("accessories-calculator".to_string()), - provider: ProviderType::Calculator, - // Copy result to clipboard using wl-copy - command: format!("sh -c 'echo -n \"{}\" | wl-copy'", result_str), - terminal: false, - tags: vec!["math".to_string()], - }; - - debug!("Calculator result: {} = {}", expr, result_str); - self.cached_result = Some(item.clone()); - Some(item) - } - Err(e) => { - debug!("Calculator error for '{}': {}", expr, e); - None - } - } - } -} - -impl Provider for CalculatorProvider { - fn name(&self) -> &str { - "Calculator" - } - - fn provider_type(&self) -> ProviderType { - ProviderType::Calculator - } - - fn refresh(&mut self) { - // Calculator doesn't need refresh - it evaluates on-demand - self.cached_result = None; - } - - fn items(&self) -> &[LaunchItem] { - // Calculator is a dynamic provider - items are generated from query - // Return cached result if available (for UI display) - match &self.cached_result { - Some(item) => std::slice::from_ref(item), - None => &[], - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_is_calculator_query() { - assert!(CalculatorProvider::is_calculator_query("= 5+3")); - assert!(CalculatorProvider::is_calculator_query("calc 5+3")); - assert!(CalculatorProvider::is_calculator_query(" = 5+3")); - assert!(!CalculatorProvider::is_calculator_query("5+3")); - assert!(!CalculatorProvider::is_calculator_query("firefox")); - } - - #[test] - fn test_extract_expression() { - assert_eq!( - CalculatorProvider::extract_expression("= 5+3"), - Some("5+3") - ); - assert_eq!( - CalculatorProvider::extract_expression("calc 5+3"), - Some("5+3") - ); - assert_eq!( - CalculatorProvider::extract_expression("= 5 + 3 "), - Some("5 + 3") - ); - assert_eq!(CalculatorProvider::extract_expression("5+3"), None); - } - - #[test] - fn test_evaluate_basic() { - let mut calc = CalculatorProvider::new(); - - let result = calc.evaluate("= 5+3").unwrap(); - assert_eq!(result.name, "8"); - - let result = calc.evaluate("= 10 * 2").unwrap(); - assert_eq!(result.name, "20"); - - let result = calc.evaluate("= 15 / 3").unwrap(); - assert_eq!(result.name, "5"); - } - - #[test] - fn test_evaluate_float() { - let mut calc = CalculatorProvider::new(); - - let result = calc.evaluate("= 5/2").unwrap(); - assert_eq!(result.name, "2.5"); - - let result = calc.evaluate("= 1/3").unwrap(); - assert!(result.name.starts_with("0.333")); - } - - #[test] - fn test_evaluate_functions() { - let mut calc = CalculatorProvider::new(); - - let result = calc.evaluate("= sqrt(16)").unwrap(); - assert_eq!(result.name, "4"); - - let result = calc.evaluate("= abs(-5)").unwrap(); - assert_eq!(result.name, "5"); - } - - #[test] - fn test_evaluate_constants() { - let mut calc = CalculatorProvider::new(); - - let result = calc.evaluate("= pi").unwrap(); - assert!(result.name.starts_with("3.14159")); - - let result = calc.evaluate("= e").unwrap(); - assert!(result.name.starts_with("2.718")); - } - - #[test] - fn test_evaluate_invalid() { - let mut calc = CalculatorProvider::new(); - - assert!(calc.evaluate("= ").is_none()); - assert!(calc.evaluate("= invalid").is_none()); - assert!(calc.evaluate("= 5 +").is_none()); - } -} diff --git a/src/providers/clipboard.rs b/src/providers/clipboard.rs deleted file mode 100644 index 21c8b61..0000000 --- a/src/providers/clipboard.rs +++ /dev/null @@ -1,138 +0,0 @@ -use crate::providers::{LaunchItem, Provider, ProviderType}; -use log::{debug, warn}; -use std::process::Command; - -/// Clipboard history provider - integrates with cliphist -pub struct ClipboardProvider { - items: Vec, - max_entries: usize, -} - -impl ClipboardProvider { - pub fn new() -> Self { - Self { - items: Vec::new(), - max_entries: 50, - } - } - - #[allow(dead_code)] - pub fn with_max_entries(max_entries: usize) -> Self { - Self { - items: Vec::new(), - 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() { - debug!("cliphist not found, clipboard provider disabled"); - return; - } - - // Get clipboard history from cliphist - let output = match Command::new("cliphist").arg("list").output() { - Ok(o) => o, - Err(e) => { - warn!("Failed to run cliphist: {}", e); - return; - } - }; - - if !output.status.success() { - debug!("cliphist list returned non-zero status"); - 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 - let p = parts[1]; - if p.len() > 80 { - format!("{}...", &p[..77]) - } 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(LaunchItem { - id: format!("clipboard:{}", idx), - name: preview_clean, - description: Some("Copy to clipboard".to_string()), - icon: Some("edit-paste".to_string()), - provider: ProviderType::Clipboard, - command, - terminal: false, - tags: Vec::new(), - }); - } - - debug!("Loaded {} clipboard entries", self.items.len()); - } -} - -impl Provider for ClipboardProvider { - fn name(&self) -> &str { - "Clipboard" - } - - fn provider_type(&self) -> ProviderType { - ProviderType::Clipboard - } - - fn refresh(&mut self) { - self.load_clipboard_history(); - } - - fn items(&self) -> &[LaunchItem] { - &self.items - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_clipboard_provider() { - let mut provider = ClipboardProvider::new(); - provider.refresh(); - // Just ensure it doesn't panic - cliphist may not be installed - } -} diff --git a/src/providers/files.rs b/src/providers/files.rs deleted file mode 100644 index 2194044..0000000 --- a/src/providers/files.rs +++ /dev/null @@ -1,226 +0,0 @@ -use crate::paths; -use crate::providers::{LaunchItem, ProviderType}; -use log::{debug, warn}; -use std::process::Command; - -/// File search provider - uses fd or locate for fast file finding -pub struct FileSearchProvider { - search_tool: SearchTool, - max_results: usize, -} - -#[derive(Debug, Clone, Copy)] -enum SearchTool { - Fd, - Locate, - None, -} - -impl FileSearchProvider { - pub fn new() -> Self { - let search_tool = Self::detect_search_tool(); - debug!("File search using: {:?}", search_tool); - - Self { - search_tool, - max_results: 20, - } - } - - 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) - } - - /// Check if query is a file search query - /// Triggers on: `/ query`, `file query`, `find query` - pub fn is_file_query(query: &str) -> bool { - let trimmed = query.trim(); - trimmed.starts_with("/ ") - || trimmed.starts_with("/") - || trimmed.to_lowercase().starts_with("file ") - || trimmed.to_lowercase().starts_with("find ") - } - - /// 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("file ") { - Some(trimmed[5..].trim()) - } else if trimmed.to_lowercase().starts_with("find ") { - Some(trimmed[5..].trim()) - } else { - None - } - } - - /// Evaluate a file search query - pub 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) - } - - /// Evaluate a raw search term (for :file filter mode) - pub fn evaluate_raw(&self, search_term: &str) -> Vec { - let trimmed = search_term.trim(); - if trimmed.is_empty() { - return Vec::new(); - } - - self.search_files(trimmed) - } - - 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 => { - debug!("No file search tool available"); - Vec::new() - } - } - } - - fn search_with_fd(&self, pattern: &str) -> Vec { - // fd searches from home directory by default - let home = paths::home().unwrap_or_default(); - - let output = match Command::new("fd") - .args([ - "--max-results", - &self.max_results.to_string(), - "--type", - "f", // Files only - "--type", - "d", // And directories - pattern, - ]) - .current_dir(&home) - .output() - { - Ok(o) => o, - Err(e) => { - warn!("Failed to run fd: {}", e); - return Vec::new(); - } - }; - - self.parse_file_results(&String::from_utf8_lossy(&output.stdout), &home) - } - - fn search_with_locate(&self, pattern: &str) -> Vec { - let home = paths::home().unwrap_or_default(); - - let output = match Command::new("locate") - .args([ - "--limit", - &self.max_results.to_string(), - "--ignore-case", - pattern, - ]) - .output() - { - Ok(o) => o, - Err(e) => { - warn!("Failed to run locate: {}", e); - return Vec::new(); - } - }; - - self.parse_file_results(&String::from_utf8_lossy(&output.stdout), &home) - } - - fn parse_file_results(&self, output: &str, home: &std::path::Path) -> 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 { - home.join(path).to_string_lossy().to_string() - }; - - // Get filename for display - let filename = std::path::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 = std::path::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('\'', "'\\''")); - - LaunchItem { - id: format!("file:{}", full_path), - name: filename, - description: Some(full_path.clone()), - icon: Some(icon.to_string()), - provider: ProviderType::Files, - command, - terminal: false, - tags: Vec::new(), - } - }) - .collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_is_file_query() { - assert!(FileSearchProvider::is_file_query("/ config")); - assert!(FileSearchProvider::is_file_query("/config")); - assert!(FileSearchProvider::is_file_query("file config")); - assert!(FileSearchProvider::is_file_query("find config")); - assert!(!FileSearchProvider::is_file_query("config")); - assert!(!FileSearchProvider::is_file_query("? search")); - } - - #[test] - fn test_extract_search_term() { - assert_eq!( - FileSearchProvider::extract_search_term("/ config.toml"), - Some("config.toml") - ); - assert_eq!( - FileSearchProvider::extract_search_term("file bashrc"), - Some("bashrc") - ); - } -} diff --git a/src/providers/media.rs b/src/providers/media.rs deleted file mode 100644 index 262ce1b..0000000 --- a/src/providers/media.rs +++ /dev/null @@ -1,264 +0,0 @@ -//! MPRIS D-Bus media player widget provider -//! -//! Shows currently playing track as a single row with play/pause action. - -use super::{LaunchItem, Provider, ProviderType}; -use log::debug; -use std::process::Command; - -/// Media player provider using MPRIS D-Bus interface -pub struct MediaProvider { - items: Vec, -} - -#[derive(Debug, Default, Clone)] -struct MediaState { - player_name: String, - title: String, - artist: String, - is_playing: bool, -} - -impl MediaProvider { - pub fn new() -> Self { - let mut provider = Self { items: Vec::new() }; - provider.refresh(); - provider - } - - /// 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(e) => { - debug!("Failed to list D-Bus names: {}", e); - 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 - fn generate_items(&mut self, state: &MediaState) { - self.items.clear(); - - let status_icon = if state.is_playing { "▶️" } else { "⏸️" }; - let action = if state.is_playing { "Pause" } else { "Play" }; - - // Single row: "🎵 ▶️ Title — Artist" - let name = format!("🎵 {} {} — {}", status_icon, state.title, state.artist); - - // Extract player display name (e.g., "firefox.instance_1_94" -> "Firefox") - let player_display = state.player_name - .split('.') - .next() - .unwrap_or(&state.player_name); - let player_display = player_display[0..1].to_uppercase() + &player_display[1..]; - - let command = format!( - "dbus-send --session --dest=org.mpris.MediaPlayer2.{} /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player.PlayPause", - state.player_name - ); - - self.items.push(LaunchItem { - id: "media-now-playing".to_string(), - name, - description: Some(format!("{} · Press Enter to {}", player_display, action)), - icon: Some("/org/owlry/launcher/icons/media/music-note.svg".to_string()), - provider: ProviderType::MediaPlayer, - command, - terminal: false, - tags: vec!["media".to_string(), "widget".to_string()], - }); - } -} - -impl Provider for MediaProvider { - fn name(&self) -> &str { - "Media Player" - } - - fn provider_type(&self) -> ProviderType { - ProviderType::MediaPlayer - } - - fn refresh(&mut self) { - self.items.clear(); - - let players = Self::find_players(); - if players.is_empty() { - debug!("No MPRIS players found"); - return; - } - - // Find first active player - for player in &players { - if let Some(state) = Self::get_player_state(player) { - debug!("Found active player: {} - {}", player, state.title); - self.generate_items(&state); - return; - } - } - - debug!("No active media found"); - } - - fn items(&self) -> &[LaunchItem] { - &self.items - } -} - -impl Default for MediaProvider { - fn default() -> Self { - Self::new() - } -} diff --git a/src/providers/pomodoro.rs b/src/providers/pomodoro.rs deleted file mode 100644 index dc96dec..0000000 --- a/src/providers/pomodoro.rs +++ /dev/null @@ -1,336 +0,0 @@ -//! Pomodoro timer widget provider -//! -//! Shows timer with work/break cycles and playback-style controls. -//! State persists across sessions via JSON file. - -use super::{LaunchItem, Provider, ProviderType}; -use crate::paths; -use log::{debug, warn}; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::time::{SystemTime, UNIX_EPOCH}; - -/// Pomodoro timer provider with persistent state -pub struct PomodoroProvider { - items: Vec, - state: PomodoroState, - config: PomodoroConfig, -} - -#[derive(Debug, Clone)] -pub struct PomodoroConfig { - pub work_mins: u32, - pub break_mins: u32, -} - -impl Default for PomodoroConfig { - fn default() -> Self { - Self { - work_mins: 25, - break_mins: 5, - } - } -} - -/// Timer phase -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub enum PomodoroPhase { - Idle, - Working, - WorkPaused, - Break, - BreakPaused, -} - -impl Default for PomodoroPhase { - fn default() -> Self { - Self::Idle - } -} - -/// Persistent state (saved to disk) -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -struct PomodoroState { - phase: PomodoroPhase, - /// Remaining seconds in current phase - remaining_secs: u32, - /// Completed work sessions count - sessions: u32, - /// Unix timestamp when timer was last updated (for calculating elapsed time) - last_update: u64, -} - -impl PomodoroProvider { - pub fn new(config: PomodoroConfig) -> Self { - 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, - config, - }; - - // Update timer based on elapsed time since last save - provider.update_elapsed_time(); - provider.generate_items(); - provider - } - - /// Current unix timestamp - fn now_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - } - - /// Load state from disk - fn load_state() -> Option { - let path = paths::owlry_data_dir()?.join("pomodoro.json"); - let content = fs::read_to_string(&path).ok()?; - serde_json::from_str(&content).ok() - } - - /// Save state to disk - fn save_state(&self) { - if let Some(data_dir) = paths::owlry_data_dir() { - let path = data_dir.join("pomodoro.json"); - if let Err(e) = fs::create_dir_all(&data_dir) { - warn!("Failed to create data dir: {}", e); - return; - } - let mut state = self.state.clone(); - state.last_update = Self::now_secs(); - if let Ok(json) = serde_json::to_string_pretty(&state) { - if let Err(e) = fs::write(&path, json) { - warn!("Failed to save pomodoro state: {}", e); - } - } - } - } - - /// Update remaining time based on elapsed time since last update - 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 { - // Timer completed while app was closed - self.complete_phase(); - } else { - self.state.remaining_secs -= elapsed as u32; - } - } - // Paused or idle - no time passes - _ => {} - } - self.state.last_update = now; - } - - /// Complete current phase and transition to next - 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.config.break_mins * 60; - debug!("Work session {} completed, starting break", self.state.sessions); - } - PomodoroPhase::Break => { - self.state.phase = PomodoroPhase::Idle; - self.state.remaining_secs = self.config.work_mins * 60; - debug!("Break completed, ready for next session"); - } - _ => {} - } - self.save_state(); - } - - /// Handle action commands (called from MainWindow) - pub fn handle_action(&mut self, action: &str) { - debug!("Pomodoro action: {}", action); - match action { - "start" | "resume" => self.start_or_resume(), - "pause" => self.pause(), - "reset" => self.reset(), - "skip" => self.skip_phase(), - _ => warn!("Unknown pomodoro action: {}", action), - } - self.save_state(); - self.generate_items(); - } - - fn start_or_resume(&mut self) { - match self.state.phase { - PomodoroPhase::Idle => { - self.state.phase = PomodoroPhase::Working; - self.state.remaining_secs = self.config.work_mins * 60; - } - PomodoroPhase::WorkPaused => { - self.state.phase = PomodoroPhase::Working; - } - PomodoroPhase::BreakPaused => { - self.state.phase = PomodoroPhase::Break; - } - _ => {} // Already running - } - self.state.last_update = Self::now_secs(); - } - - fn pause(&mut self) { - match self.state.phase { - PomodoroPhase::Working => { - self.state.phase = PomodoroPhase::WorkPaused; - } - PomodoroPhase::Break => { - self.state.phase = PomodoroPhase::BreakPaused; - } - _ => {} - } - } - - fn reset(&mut self) { - self.state.phase = PomodoroPhase::Idle; - self.state.remaining_secs = self.config.work_mins * 60; - self.state.sessions = 0; - } - - fn skip_phase(&mut self) { - self.complete_phase(); - } - - /// Format seconds as MM:SS - fn format_time(secs: u32) -> String { - let mins = secs / 60; - let secs = secs % 60; - format!("{:02}:{:02}", mins, secs) - } - - /// Generate LaunchItems from current state - fn generate_items(&mut self) { - self.items.clear(); - - let (phase_name, phase_icon, is_running) = match self.state.phase { - PomodoroPhase::Idle => ("Ready", "media-playback-start", false), - PomodoroPhase::Working => ("Work", "media-playback-start", true), - PomodoroPhase::WorkPaused => ("Work (Paused)", "media-playback-pause", false), - PomodoroPhase::Break => ("Break", "face-cool", true), - PomodoroPhase::BreakPaused => ("Break (Paused)", "media-playback-pause", false), - }; - - // Timer display row - let time_str = Self::format_time(self.state.remaining_secs); - let name = format!("{}: {}", phase_name, time_str); - let description = if self.state.sessions > 0 { - Some(format!("Sessions: {} | {}min work / {}min break", - self.state.sessions, self.config.work_mins, self.config.break_mins)) - } else { - Some(format!("{}min work / {}min break", - self.config.work_mins, self.config.break_mins)) - }; - - // Use bundled tomato icon for the timer display, ignore phase_icon - let _ = phase_icon; // Suppress unused warning - self.items.push(LaunchItem { - id: "pomo-timer".to_string(), - name, - description, - icon: Some("/org/owlry/launcher/icons/pomodoro/tomato.svg".to_string()), - provider: ProviderType::Pomodoro, - command: String::new(), // Info only - terminal: false, - tags: vec!["pomodoro".to_string(), "widget".to_string(), "timer".to_string()], - }); - - // Primary control: Start/Pause - let (control_name, control_icon, control_action) = if is_running { - ("Pause", "media-playback-pause", "pause") - } else { - match self.state.phase { - PomodoroPhase::Idle => ("Start Work", "media-playback-start", "start"), - _ => ("Resume", "media-playback-start", "resume"), - } - }; - - self.items.push(LaunchItem { - id: "pomo-control".to_string(), - name: control_name.to_string(), - description: Some(format!("{} timer", control_name)), - icon: Some(control_icon.to_string()), - provider: ProviderType::Pomodoro, - command: format!("POMODORO:{}", control_action), - terminal: false, - tags: vec!["pomodoro".to_string(), "control".to_string()], - }); - - // Secondary controls: Reset and Skip - if self.state.phase != PomodoroPhase::Idle { - self.items.push(LaunchItem { - id: "pomo-skip".to_string(), - name: "Skip".to_string(), - description: Some("Skip to next phase".to_string()), - icon: Some("media-skip-forward".to_string()), - provider: ProviderType::Pomodoro, - command: "POMODORO:skip".to_string(), - terminal: false, - tags: vec!["pomodoro".to_string(), "control".to_string()], - }); - } - - self.items.push(LaunchItem { - id: "pomo-reset".to_string(), - name: "Reset".to_string(), - description: Some("Reset timer and sessions".to_string()), - icon: Some("view-refresh".to_string()), - provider: ProviderType::Pomodoro, - command: "POMODORO:reset".to_string(), - terminal: false, - tags: vec!["pomodoro".to_string(), "control".to_string()], - }); - } - - /// Check if timer has completed (for external polling) - #[allow(dead_code)] - pub fn check_completion(&mut self) -> bool { - if matches!(self.state.phase, PomodoroPhase::Working | PomodoroPhase::Break) { - self.update_elapsed_time(); - self.generate_items(); - self.save_state(); - true - } else { - false - } - } -} - -impl Provider for PomodoroProvider { - fn name(&self) -> &str { - "Pomodoro" - } - - fn provider_type(&self) -> ProviderType { - ProviderType::Pomodoro - } - - fn refresh(&mut self) { - self.update_elapsed_time(); - self.generate_items(); - } - - fn items(&self) -> &[LaunchItem] { - &self.items - } -} - -impl Default for PomodoroProvider { - fn default() -> Self { - Self::new(PomodoroConfig::default()) - } -} diff --git a/src/providers/scripts.rs b/src/providers/scripts.rs deleted file mode 100644 index 928d4e3..0000000 --- a/src/providers/scripts.rs +++ /dev/null @@ -1,179 +0,0 @@ -use crate::paths; -use crate::providers::{LaunchItem, Provider, ProviderType}; -use log::{debug, warn}; -use std::fs; -use std::os::unix::fs::PermissionsExt; -use std::path::PathBuf; - -/// Custom scripts provider - runs user scripts from `$XDG_DATA_HOME/owlry/scripts/` -pub struct ScriptsProvider { - items: Vec, -} - -impl ScriptsProvider { - pub fn new() -> Self { - Self { items: Vec::new() } - } - - fn load_scripts(&mut self) { - self.items.clear(); - - let scripts_dir = match paths::scripts_dir() { - Some(p) => p, - None => { - debug!("Could not determine scripts directory"); - return; - } - }; - - if !scripts_dir.exists() { - debug!("Scripts directory not found at {:?}", scripts_dir); - // Create the directory for the user - if let Err(e) = paths::ensure_dir(&scripts_dir) { - warn!("Failed to create scripts directory: {}", e); - } - return; - } - - let entries = match fs::read_dir(&scripts_dir) { - Ok(e) => e, - Err(e) => { - warn!("Failed to read scripts directory: {}", e); - 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); - - self.items.push(LaunchItem { - id: format!("script:{}", filename), - name: format!("Script: {}", name), - description, - icon: Some(icon), - provider: ProviderType::Scripts, - command: path.to_string_lossy().to_string(), - terminal: false, - tags: vec!["script".to_string()], - }); - } - - debug!("Loaded {} scripts from {:?}", self.items.len(), scripts_dir); - } - - 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 check_line.starts_with("# ") { - Some(check_line[2..].trim().to_string()) - } else if check_line.starts_with("// ") { - Some(check_line[3..].trim().to_string()) - } else { - None - } - } - - 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) { - if 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() - } -} - -impl Provider for ScriptsProvider { - fn name(&self) -> &str { - "Scripts" - } - - fn provider_type(&self) -> ProviderType { - ProviderType::Scripts - } - - fn refresh(&mut self) { - self.load_scripts(); - } - - fn items(&self) -> &[LaunchItem] { - &self.items - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_scripts_provider() { - let mut provider = ScriptsProvider::new(); - provider.refresh(); - // Just ensure it doesn't panic - } -} diff --git a/src/providers/ssh.rs b/src/providers/ssh.rs deleted file mode 100644 index bc310f2..0000000 --- a/src/providers/ssh.rs +++ /dev/null @@ -1,198 +0,0 @@ -use crate::paths; -use crate::providers::{LaunchItem, Provider, ProviderType}; -use log::{debug, warn}; -use std::fs; - -/// SSH connections provider - parses ~/.ssh/config -pub struct SshProvider { - items: Vec, - terminal_command: String, -} - -impl SshProvider { - #[allow(dead_code)] - pub fn new() -> Self { - Self::with_terminal("kitty") - } - - pub fn with_terminal(terminal: &str) -> Self { - Self { - items: Vec::new(), - terminal_command: terminal.to_string(), - } - } - - #[allow(dead_code)] - pub fn set_terminal(&mut self, terminal: &str) { - self.terminal_command = terminal.to_string(); - } - - fn ssh_config_path() -> Option { - paths::ssh_config() - } - - fn parse_ssh_config(&mut self) { - self.items.clear(); - - let config_path = match Self::ssh_config_path() { - Some(p) => p, - None => { - debug!("Could not determine SSH config path"); - return; - } - }; - - if !config_path.exists() { - debug!("SSH config not found at {:?}", config_path); - return; - } - - let content = match fs::read_to_string(&config_path) { - Ok(c) => c, - Err(e) => { - warn!("Failed to read SSH config: {}", e); - 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); - } - - debug!("Loaded {} SSH hosts", self.items.len()); - } - - 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); - - self.items.push(LaunchItem { - id: format!("ssh:{}", host), - name: format!("SSH: {}", host), - description, - icon: Some("utilities-terminal".to_string()), - provider: ProviderType::Ssh, - command, - terminal: false, // We're already wrapping in terminal - tags: vec!["ssh".to_string()], - }); - } -} - -impl Provider for SshProvider { - fn name(&self) -> &str { - "SSH" - } - - fn provider_type(&self) -> ProviderType { - ProviderType::Ssh - } - - fn refresh(&mut self) { - self.parse_ssh_config(); - } - - fn items(&self) -> &[LaunchItem] { - &self.items - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_ssh_config() { - // This test will only work if the user has an SSH config - let mut provider = SshProvider::new(); - provider.refresh(); - // Just ensure it doesn't panic - } -} diff --git a/src/providers/system.rs b/src/providers/system.rs deleted file mode 100644 index 9600692..0000000 --- a/src/providers/system.rs +++ /dev/null @@ -1,116 +0,0 @@ -use crate::providers::{LaunchItem, Provider, ProviderType}; - -/// System commands provider - shutdown, reboot, lock, etc. -pub struct SystemProvider { - items: Vec, -} - -impl SystemProvider { - pub 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: Vec<(&str, &str, &str, &str, &str)> = vec![ - ( - "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(LaunchItem { - id: id.to_string(), - name: name.to_string(), - description: Some(description.to_string()), - icon: Some(icon.to_string()), - provider: ProviderType::System, - command: command.to_string(), - terminal: false, - tags: vec!["power".to_string(), "system".to_string()], - }); - } - } -} - -impl Provider for SystemProvider { - fn name(&self) -> &str { - "System" - } - - fn provider_type(&self) -> ProviderType { - ProviderType::System - } - - fn refresh(&mut self) { - self.load_commands(); - } - - fn items(&self) -> &[LaunchItem] { - &self.items - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_system_provider() { - let mut provider = SystemProvider::new(); - provider.refresh(); - - assert!(provider.items().len() >= 6); - assert!(provider.items().iter().any(|i| i.name == "Shutdown")); - assert!(provider.items().iter().any(|i| i.name == "Reboot into BIOS")); - } -} diff --git a/src/providers/uuctl.rs b/src/providers/uuctl.rs deleted file mode 100644 index a14a188..0000000 --- a/src/providers/uuctl.rs +++ /dev/null @@ -1,276 +0,0 @@ -use super::{LaunchItem, Provider, ProviderType}; -use log::{debug, warn}; -use std::process::Command; - -/// Provider for systemd user services -/// Uses systemctl --user to list and control user-level services -pub struct UuctlProvider { - items: Vec, -} - -/// Represents the state of a systemd service -#[allow(dead_code)] -#[derive(Debug, Clone)] -pub struct ServiceState { - pub unit_name: String, - pub display_name: String, - pub description: String, - pub active: bool, - pub sub_state: String, -} - -impl UuctlProvider { - pub fn new() -> Self { - Self { items: Vec::new() } - } - - fn systemctl_available() -> bool { - Command::new("systemctl") - .arg("--user") - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - } - - /// Generate submenu actions for a given service - 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(LaunchItem { - id: format!("systemd:restart:{}", unit_name), - name: "↻ Restart".to_string(), - description: Some(format!("Restart {}", display_name)), - icon: Some("view-refresh".to_string()), - provider: ProviderType::Uuctl, - command: format!("systemctl --user restart {}", unit_name), - terminal: false, - tags: vec!["systemd".to_string(), "service".to_string()], - }); - - actions.push(LaunchItem { - id: format!("systemd:stop:{}", unit_name), - name: "■ Stop".to_string(), - description: Some(format!("Stop {}", display_name)), - icon: Some("process-stop".to_string()), - provider: ProviderType::Uuctl, - command: format!("systemctl --user stop {}", unit_name), - terminal: false, - tags: vec!["systemd".to_string(), "service".to_string()], - }); - - actions.push(LaunchItem { - id: format!("systemd:reload:{}", unit_name), - name: "⟳ Reload".to_string(), - description: Some(format!("Reload {} configuration", display_name)), - icon: Some("view-refresh".to_string()), - provider: ProviderType::Uuctl, - command: format!("systemctl --user reload {}", unit_name), - terminal: false, - tags: vec!["systemd".to_string(), "service".to_string()], - }); - - actions.push(LaunchItem { - id: format!("systemd:kill:{}", unit_name), - name: "✗ Kill".to_string(), - description: Some(format!("Force kill {}", display_name)), - icon: Some("edit-delete".to_string()), - provider: ProviderType::Uuctl, - command: format!("systemctl --user kill {}", unit_name), - terminal: false, - tags: vec!["systemd".to_string(), "service".to_string()], - }); - } else { - actions.push(LaunchItem { - id: format!("systemd:start:{}", unit_name), - name: "▶ Start".to_string(), - description: Some(format!("Start {}", display_name)), - icon: Some("media-playback-start".to_string()), - provider: ProviderType::Uuctl, - command: format!("systemctl --user start {}", unit_name), - terminal: false, - tags: vec!["systemd".to_string(), "service".to_string()], - }); - } - - // Always available actions - actions.push(LaunchItem { - id: format!("systemd:status:{}", unit_name), - name: "ℹ Status".to_string(), - description: Some(format!("Show {} status", display_name)), - icon: Some("dialog-information".to_string()), - provider: ProviderType::Uuctl, - command: format!("systemctl --user status {}", unit_name), - terminal: true, - tags: vec!["systemd".to_string(), "service".to_string()], - }); - - actions.push(LaunchItem { - id: format!("systemd:journal:{}", unit_name), - name: "📋 Journal".to_string(), - description: Some(format!("Show {} logs", display_name)), - icon: Some("utilities-system-monitor".to_string()), - provider: ProviderType::Uuctl, - command: format!("journalctl --user -u {} -f", unit_name), - terminal: true, - tags: vec!["systemd".to_string(), "service".to_string()], - }); - - actions.push(LaunchItem { - id: format!("systemd:enable:{}", unit_name), - name: "⊕ Enable".to_string(), - description: Some(format!("Enable {} on startup", display_name)), - icon: Some("emblem-default".to_string()), - provider: ProviderType::Uuctl, - command: format!("systemctl --user enable {}", unit_name), - terminal: false, - tags: vec!["systemd".to_string(), "service".to_string()], - }); - - actions.push(LaunchItem { - id: format!("systemd:disable:{}", unit_name), - name: "⊖ Disable".to_string(), - description: Some(format!("Disable {} on startup", display_name)), - icon: Some("emblem-unreadable".to_string()), - provider: ProviderType::Uuctl, - command: format!("systemctl --user disable {}", unit_name), - terminal: false, - tags: vec!["systemd".to_string(), "service".to_string()], - }); - - actions - } - - 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:unit_name:is_active - let submenu_data = format!("SUBMENU:{}:{}", unit_name, is_active); - - items.push(LaunchItem { - id: format!("systemd:service:{}", unit_name), - name: display_name, - description: Some(status_desc), - icon: Some(if is_active { "emblem-ok-symbolic" } else { "emblem-pause-symbolic" }.to_string()), - provider: ProviderType::Uuctl, - command: submenu_data, // Special marker for submenu - terminal: false, - tags: vec!["systemd".to_string(), "service".to_string()], - }); - } - - items - } - - /// Check if an item is a submenu trigger (service, not action) - pub fn is_submenu_item(item: &LaunchItem) -> bool { - item.provider == ProviderType::Uuctl && item.command.starts_with("SUBMENU:") - } - - /// Parse submenu data from item command - pub fn parse_submenu_data(item: &LaunchItem) -> Option<(String, String, bool)> { - if !Self::is_submenu_item(item) { - return None; - } - - let parts: Vec<&str> = item.command.splitn(3, ':').collect(); - if parts.len() >= 3 { - let unit_name = parts[1].to_string(); - let is_active = parts[2] == "true"; - Some((unit_name, item.name.clone(), is_active)) - } else { - None - } - } -} - -impl Provider for UuctlProvider { - fn name(&self) -> &str { - "systemd-user" - } - - fn provider_type(&self) -> ProviderType { - ProviderType::Uuctl - } - - fn refresh(&mut self) { - self.items.clear(); - - if !Self::systemctl_available() { - debug!("systemctl --user not available, skipping"); - 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) => o, - Err(e) => { - warn!("Failed to run systemctl --user: {}", e); - return; - } - }; - - if !output.status.success() { - warn!("systemctl --user failed with status: {}", output.status); - 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.cmp(&b.name)); - - debug!("Found {} systemd user services", self.items.len()); - } - - fn items(&self) -> &[LaunchItem] { - &self.items - } -} diff --git a/src/providers/weather.rs b/src/providers/weather.rs deleted file mode 100644 index 68ba072..0000000 --- a/src/providers/weather.rs +++ /dev/null @@ -1,549 +0,0 @@ -//! Weather widget provider with multiple API support -//! -//! Supports: -//! - wttr.in (default, no API key required) -//! - OpenWeatherMap (requires API key) -//! - Open-Meteo (no API key required) - -use super::{LaunchItem, Provider, ProviderType}; -use log::{debug, error, warn}; -use serde::Deserialize; -use std::time::{Duration, Instant}; - -const CACHE_DURATION: Duration = Duration::from_secs(900); // 15 minutes -const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); -const USER_AGENT: &str = "owlry-launcher/0.3"; - -/// Weather provider with caching and multiple API support -pub struct WeatherProvider { - items: Vec, - config: WeatherConfig, - last_fetch: Option, - cached_data: Option, -} - -#[derive(Debug, Clone)] -pub struct WeatherConfig { - pub provider: WeatherProviderType, - pub api_key: Option, - pub location: String, -} - -#[derive(Debug, Clone, PartialEq)] -pub 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 WeatherData { - temperature: f32, - feels_like: Option, - condition: String, - humidity: Option, - wind_speed: Option, - icon: String, - location: String, -} - -impl WeatherProvider { - pub fn new(config: WeatherConfig) -> Self { - let mut provider = Self { - items: Vec::new(), - config, - last_fetch: None, - cached_data: None, - }; - provider.refresh(); - provider - } - - /// Create with default config (wttr.in, auto-detect location) - pub fn with_defaults() -> Self { - Self::new(WeatherConfig { - provider: WeatherProviderType::WttrIn, - api_key: None, - location: String::new(), // Empty = auto-detect - }) - } - - /// Check if cache is still valid - fn is_cache_valid(&self) -> bool { - if let Some(last_fetch) = self.last_fetch { - last_fetch.elapsed() < CACHE_DURATION - } else { - false - } - } - - /// Fetch weather data from the configured provider - 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(), - } - } - - /// Fetch from wttr.in - 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); - debug!("Fetching weather from: {}", url); - - let client = match reqwest::blocking::Client::builder() - .timeout(REQUEST_TIMEOUT) - .user_agent(USER_AGENT) - .build() - { - Ok(c) => c, - Err(e) => { - error!("Failed to build HTTP client: {}", e); - return None; - } - }; - - let response = match client.get(&url).send() { - Ok(r) => r, - Err(e) => { - error!("Weather request failed: {}", e); - return None; - } - }; - - let json: WttrInResponse = match response.json() { - Ok(j) => j, - Err(e) => { - error!("Failed to parse weather JSON: {}", e); - return None; - } - }; - - 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::condition_to_icon(¤t.weather_code), - location: location_name, - }) - } - - /// Fetch from OpenWeatherMap - fn fetch_openweathermap(&self) -> Option { - let api_key = self.config.api_key.as_ref()?; - let location = if self.config.location.is_empty() { - warn!("OpenWeatherMap requires a location to be configured"); - return None; - } else { - &self.config.location - }; - - let url = format!( - "https://api.openweathermap.org/data/2.5/weather?q={}&appid={}&units=metric", - location, api_key - ); - debug!("Fetching weather from OpenWeatherMap"); - - 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, - }) - } - - /// Fetch from Open-Meteo - fn fetch_open_meteo(&self) -> Option { - // Open-Meteo requires coordinates, so we need to geocode first - // For simplicity, we'll use a geocoding step if location is a city name - 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 - ); - debug!("Fetching weather from Open-Meteo for {}", location_name); - - 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, - }) - } - - /// Get coordinates and location name for Open-Meteo (simple parsing or geocoding) - 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 { - if let (Ok(lat), Ok(lon)) = ( - parts[0].trim().parse::(), - parts[1].trim().parse::(), - ) { - // Use coordinates as location name (will be overwritten if we had a name) - 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)) - } - - /// Convert wttr.in weather code to freedesktop icon name - fn condition_to_icon(code: &str) -> String { - let icon = match code { - "113" => "weather-clear", // Sunny - "116" => "weather-few-clouds", // Partly cloudy - "119" => "weather-overcast", // Cloudy - "122" => "weather-overcast", // 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", - }; - // Try symbolic version first (more likely to exist) - format!("{}-symbolic", icon) - } - - /// Convert OpenWeatherMap icon code to freedesktop icon - 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() - } - - /// Convert WMO weather code to description - 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 | 81 | 82 => "Rain showers", - 85 | 86 => "Snow showers", - 95 | 96 | 99 => "Thunderstorm", - _ => "Unknown", - } - .to_string() - } - - /// Convert WMO weather code to icon name (used for emoji conversion) - 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() - } - - /// Generate LaunchItems from weather data - fn generate_items(&mut self, data: &WeatherData) { - self.items.clear(); - - // Use emoji in name since icon themes are unreliable - let emoji = Self::weather_icon_to_emoji(&data.icon); - let temp_str = format!("{}°C", data.temperature.round() as i32); - let name = format!("{} {} {}", emoji, 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 { - if (feels - data.temperature).abs() > 2.0 { - details.push(format!("Feels like {}°C", feels.round() as i32)); - } - } - - // Simple URL encoding for location - let encoded_location = data.location.replace(' ', "+"); - - self.items.push(LaunchItem { - id: "weather-current".to_string(), - name, - description: Some(details.join(" | ")), - icon: Some(Self::icon_to_resource_path(&data.icon)), - provider: ProviderType::Weather, - command: format!("xdg-open 'https://wttr.in/{}'", encoded_location), - terminal: false, - tags: vec!["weather".to_string(), "widget".to_string()], - }); - } - - /// Convert icon name to emoji for display in name text - fn weather_icon_to_emoji(icon: &str) -> &'static str { - if icon.contains("clear") { - "☀️" - } else if icon.contains("few-clouds") { - "⛅" - } else if icon.contains("overcast") || icon.contains("clouds") { - "☁️" - } else if icon.contains("fog") { - "🌫️" - } else if icon.contains("showers") || icon.contains("rain") { - "🌧️" - } else if icon.contains("snow") { - "❄️" - } else if icon.contains("storm") { - "⛈️" - } else { - "🌡️" - } - } - - /// Convert freedesktop-style icon name to bundled GResource path - 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) - } -} - -impl Provider for WeatherProvider { - fn name(&self) -> &str { - "Weather" - } - - fn provider_type(&self) -> ProviderType { - ProviderType::Weather - } - - fn refresh(&mut self) { - // Use cache if still valid - if self.is_cache_valid() { - if let Some(data) = self.cached_data.clone() { - self.generate_items(&data); - return; - } - } - - // Fetch new data - match self.fetch_weather() { - Some(data) => { - debug!("Weather fetched: {}°C, {}", data.temperature, data.condition); - self.cached_data = Some(data.clone()); - self.last_fetch = Some(Instant::now()); - self.generate_items(&data); - } - None => { - warn!("Failed to fetch weather data"); - self.items.clear(); - } - } - } - - fn items(&self) -> &[LaunchItem] { - &self.items - } -} - -impl Default for WeatherProvider { - fn default() -> Self { - Self::with_defaults() - } -} - -// ─── 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, -} diff --git a/src/providers/websearch.rs b/src/providers/websearch.rs deleted file mode 100644 index 4e37814..0000000 --- a/src/providers/websearch.rs +++ /dev/null @@ -1,196 +0,0 @@ -use crate::providers::{LaunchItem, ProviderType}; - -/// Common search engine URL templates -/// {query} is replaced with the URL-encoded search term -pub 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 -pub const DEFAULT_ENGINE: &str = "duckduckgo"; - -/// Web search provider - opens browser with search query -pub struct WebSearchProvider { - /// URL template with {query} placeholder - url_template: String, -} - -impl WebSearchProvider { - #[allow(dead_code)] - pub fn new() -> Self { - Self::with_engine(DEFAULT_ENGINE) - } - - /// Create provider with specific search engine - pub 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 } - } - - /// Check if query is a web search query - /// Triggers on: `? query`, `web query`, `search query` - pub fn is_websearch_query(query: &str) -> bool { - let trimmed = query.trim(); - trimmed.starts_with("? ") - || trimmed.starts_with("?") - || trimmed.to_lowercase().starts_with("web ") - || trimmed.to_lowercase().starts_with("search ") - } - - /// 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 ") { - // Need to get the original casing - Some(trimmed[4..].trim()) - } else if trimmed.to_lowercase().starts_with("search ") { - Some(trimmed[7..].trim()) - } else { - None - } - } - - /// URL-encode a search query - fn url_encode(query: &str) -> String { - // TODO: This is where you can implement the URL encoding logic! - // Consider: Should we use a crate like `urlencoding` or implement manually? - // Manual encoding needs to handle: spaces, &, =, ?, #, etc. - 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 web search query and return a LaunchItem if valid - pub fn evaluate(&self, query: &str) -> Option { - let search_term = Self::extract_search_term(query)?; - - if search_term.is_empty() { - return None; - } - - self.evaluate_raw(search_term) - } - - /// Evaluate a raw search term (for :web filter mode) - pub fn evaluate_raw(&self, search_term: &str) -> Option { - let trimmed = search_term.trim(); - if trimmed.is_empty() { - return None; - } - - let url = self.build_search_url(trimmed); - - // Use xdg-open to open the browser - let command = format!("xdg-open '{}'", url); - - Some(LaunchItem { - id: format!("websearch:{}", trimmed), - name: format!("Search: {}", trimmed), - description: Some("Open in browser".to_string()), - icon: Some("web-browser".to_string()), - provider: ProviderType::WebSearch, - command, - terminal: false, - tags: vec!["web".to_string(), "search".to_string()], - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_is_websearch_query() { - assert!(WebSearchProvider::is_websearch_query("? rust programming")); - assert!(WebSearchProvider::is_websearch_query("?rust")); - assert!(WebSearchProvider::is_websearch_query("web rust")); - assert!(WebSearchProvider::is_websearch_query("search rust")); - assert!(!WebSearchProvider::is_websearch_query("rust")); - assert!(!WebSearchProvider::is_websearch_query("= 5+3")); - } - - #[test] - fn test_extract_search_term() { - assert_eq!( - WebSearchProvider::extract_search_term("? rust programming"), - Some("rust programming") - ); - assert_eq!( - WebSearchProvider::extract_search_term("?rust"), - Some("rust") - ); - assert_eq!( - WebSearchProvider::extract_search_term("web rust docs"), - Some("rust docs") - ); - } - - #[test] - fn test_url_encode() { - assert_eq!(WebSearchProvider::url_encode("hello world"), "hello+world"); - assert_eq!(WebSearchProvider::url_encode("foo&bar"), "foo%26bar"); - assert_eq!(WebSearchProvider::url_encode("a=b"), "a%3Db"); - } - - #[test] - fn test_build_search_url() { - let provider = WebSearchProvider::with_engine("duckduckgo"); - let url = provider.build_search_url("rust programming"); - assert_eq!(url, "https://duckduckgo.com/?q=rust+programming"); - } - - #[test] - fn test_evaluate() { - let provider = WebSearchProvider::new(); - let item = provider.evaluate("? rust docs").unwrap(); - assert_eq!(item.name, "Search: rust docs"); - assert!(item.command.contains("xdg-open")); - assert!(item.command.contains("duckduckgo")); - } -}